Skip to main content

matrixcode_tui/
app.rs

1use std::io::Stdout;
2use std::time::{Duration, Instant};
3
4use anyhow::Result;
5use ratatui::{
6    backend::CrosstermBackend,
7    crossterm::event::{self, Event, MouseEvent, MouseEventKind},
8    Terminal,
9};
10
11use matrixcode_core::{AgentEvent, cancel::CancellationToken};
12use ratatui::crossterm::event::MouseButton;
13
14use crate::types::{Activity, ApproveMode, Role, Message};
15use crate::utils::extract_by_visual_col;
16use crate::ANIM_MS;
17
18pub struct TuiApp {
19    pub(crate) activity: Activity,
20    pub(crate) activity_detail: String,
21    pub(crate) messages: Vec<Message>,
22    pub(crate) thinking: String,
23    pub(crate) streaming: String,
24    pub(crate) input: String,
25    pub(crate) model: String,
26    // Token stats
27    pub(crate) tokens_in: u64,
28    pub(crate) tokens_out: u64,
29    pub(crate) session_total_out: u64,
30    pub(crate) current_request_tokens: u64,  // Tokens for current request (real-time)
31    pub(crate) cache_read: u64,
32    pub(crate) cache_created: u64,
33    pub(crate) context_size: u64,
34    // Debug stats
35    pub(crate) api_calls: u64,
36    pub(crate) compressions: u64,
37    pub(crate) memory_saves: u64,
38    pub(crate) tool_calls: u64,
39    // Timing
40    pub(crate) request_start: Option<Instant>,
41    // UI state
42    pub(crate) frame: usize,
43    pub(crate) last_anim: Instant,
44    pub(crate) show_welcome: bool,
45    pub(crate) exit: bool,
46    // Input cursor position (character index in input string)
47    pub(crate) cursor_pos: usize,
48    // Input history (Up/Down arrow navigation)
49    pub(crate) input_history: Vec<String>,
50    pub(crate) history_index: Option<usize>,  // None = not browsing history
51    pub(crate) history_draft: String,  // Saves current input when entering history mode
52    // Scroll state
53    pub(crate) scroll_offset: u16,
54    pub(crate) auto_scroll: bool,
55    pub(crate) max_scroll: std::cell::Cell<u16>,
56    // Thinking display state
57    pub(crate) thinking_collapsed: bool,
58    // Approval mode
59    pub(crate) approve_mode: ApproveMode,
60    // Shared approve mode atomic - directly updates agent's mode in real-time
61    pub(crate) shared_approve_mode: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
62    // Ask tool channel
63    pub(crate) ask_tx: Option<tokio::sync::mpsc::Sender<String>>,
64    pub(crate) waiting_for_ask: bool,
65    // Channels
66    pub(crate) tx: tokio::sync::mpsc::Sender<String>,
67    pub(crate) rx: tokio::sync::mpsc::Receiver<AgentEvent>,
68    pub(crate) cancel: CancellationToken,
69    // Message queue for pending inputs while AI is processing
70    pub(crate) pending_messages: Vec<String>,
71    // Loop task state
72    pub(crate) loop_task: Option<LoopTask>,
73    // Cron tasks state
74    pub(crate) cron_tasks: Vec<CronTask>,
75    // Selection state
76    pub(crate) selection: Option<Selection>,
77    pub(crate) selecting: bool,  // True while mouse dragging
78    pub(crate) msg_area_top: std::cell::Cell<u16>,  // Messages area top Y (computed in draw)
79    // Debug mode
80    pub(crate) debug_mode: bool,
81}
82
83/// Text selection in messages area
84#[derive(Clone, Copy, Debug)]
85pub struct Selection {
86    pub start_line: usize,
87    pub start_col: usize,
88    pub end_line: usize,
89    pub end_col: usize,
90}
91
92impl Selection {
93    pub fn new(start_line: usize, start_col: usize) -> Self {
94        Self {
95            start_line,
96            start_col,
97            end_line: start_line,
98            end_col: start_col,
99        }
100    }
101    
102    pub fn extend_to(&mut self, line: usize, col: usize) {
103        self.end_line = line;
104        self.end_col = col;
105    }
106    
107    #[allow(dead_code)]
108    pub fn is_empty(&self) -> bool {
109        self.start_line == self.end_line && self.start_col == self.end_col
110    }
111    
112    pub fn normalized(&self) -> Self {
113        // Normalize so start <= end
114        if self.start_line > self.end_line || 
115           (self.start_line == self.end_line && self.start_col > self.end_col) {
116            Self {
117                start_line: self.end_line,
118                start_col: self.end_col,
119                end_line: self.start_line,
120                end_col: self.start_col,
121            }
122        } else {
123            *self
124        }
125    }
126    
127    #[allow(dead_code)]
128    pub fn contains(&self, line: usize, col: usize) -> bool {
129        let norm = self.normalized();
130        if line < norm.start_line || line > norm.end_line {
131            return false;
132        }
133        if line == norm.start_line && line == norm.end_line {
134            return col >= norm.start_col && col <= norm.end_col;
135        }
136        if line == norm.start_line {
137            return col >= norm.start_col;
138        }
139        if line == norm.end_line {
140            return col <= norm.end_col;
141        }
142        true  // Middle line
143    }
144}
145
146/// Loop task - repeatedly send message
147#[derive(Clone)]
148pub struct LoopTask {
149    pub message: String,
150    pub interval_secs: u64,
151    pub count: u64,
152    pub max_count: Option<u64>,
153    pub cancel_token: CancellationToken,
154}
155
156/// Cron task - scheduled message sending
157#[derive(Clone)]
158pub struct CronTask {
159    pub id: usize,
160    pub message: String,
161    pub minute_interval: u64,  // Simplified: run every N minutes
162    #[allow(dead_code)]
163    pub next_run: Instant,  // For future use: precise scheduling
164    pub cancel_token: CancellationToken,
165}
166
167impl TuiApp {
168    pub fn new(
169        tx: tokio::sync::mpsc::Sender<String>,
170        rx: tokio::sync::mpsc::Receiver<AgentEvent>,
171        cancel: CancellationToken,
172    ) -> Self {
173        Self {
174            activity: Activity::Idle,
175            activity_detail: String::new(),
176            messages: Vec::new(),
177            thinking: String::new(),
178            streaming: String::new(),
179            input: String::new(),
180            model: "claude-sonnet-4".into(),
181            tokens_in: 0,
182            tokens_out: 0,
183            session_total_out: 0,
184            current_request_tokens: 0,
185            cache_read: 0,
186            cache_created: 0,
187            context_size: 200_000,
188            api_calls: 0,
189            compressions: 0,
190            memory_saves: 0,
191            tool_calls: 0,
192            request_start: None,
193            frame: 0,
194            last_anim: Instant::now(),
195            show_welcome: true,
196            exit: false,
197            cursor_pos: 0,
198            input_history: Vec::new(),
199            history_index: None,
200            history_draft: String::new(),
201            scroll_offset: 0,
202            auto_scroll: true,
203            max_scroll: std::cell::Cell::new(0),
204            thinking_collapsed: false,  // Default: expanded
205            approve_mode: ApproveMode::Ask,
206            shared_approve_mode: None,
207            ask_tx: None,
208            waiting_for_ask: false,
209            tx, rx, cancel,
210            pending_messages: Vec::new(),
211            loop_task: None,
212            cron_tasks: Vec::new(),
213            selection: None,
214            selecting: false,
215            msg_area_top: std::cell::Cell::new(0),
216            debug_mode: false,
217        }
218    }
219
220    pub fn with_ask_channel(mut self, ask_tx: tokio::sync::mpsc::Sender<String>) -> Self {
221        self.ask_tx = Some(ask_tx);
222        self
223    }
224
225    /// Set shared approve mode atomic for real-time mode switching during agent execution.
226    pub fn with_shared_approve_mode(mut self, shared: std::sync::Arc<std::sync::atomic::AtomicU8>) -> Self {
227        self.shared_approve_mode = Some(shared);
228        self
229    }
230
231    pub fn with_config(mut self, model: &str, _think: bool, _max_tokens: u32, context_size: Option<u64>) -> Self {
232        self.model = model.to_string();
233        self.context_size = context_size.unwrap_or_else(|| {
234            let m = model.to_ascii_lowercase();
235            if m.contains("1m") || m.contains("opus-4-7") {
236                1_000_000
237            } else if m.contains("claude-3") || m.contains("claude-4") || m.contains("claude-sonnet") {
238                200_000
239            } else {
240                128_000
241            }
242        });
243        self
244    }
245
246    pub fn load_messages(&mut self, core_messages: Vec<matrixcode_core::Message>) {
247        for msg in core_messages {
248            // Handle different content block types separately
249            match &msg.content {
250                matrixcode_core::MessageContent::Text(t) => {
251                    if t.is_empty() { continue; }
252                    let role = match msg.role {
253                        matrixcode_core::Role::User => Role::User,
254                        matrixcode_core::Role::Assistant => Role::Assistant,
255                        matrixcode_core::Role::System => Role::System,
256                        matrixcode_core::Role::Tool => Role::Tool { name: "tool".into(), is_error: false },
257                    };
258                    // Restore input history from user messages
259                    if role == Role::User && !t.starts_with('/')
260                        && self.input_history.last().map(|s| s.as_str()) != Some(t) {
261                        self.input_history.push(t.clone());
262                    }
263                    self.messages.push(Message { role, content: t.clone() });
264                }
265                matrixcode_core::MessageContent::Blocks(blocks) => {
266                    // Process each block separately to maintain proper message types
267                    for b in blocks {
268                        match b {
269                            matrixcode_core::ContentBlock::Text { text } => {
270                                if text.is_empty() { continue; }
271                                let role = match msg.role {
272                                    matrixcode_core::Role::User => Role::User,
273                                    matrixcode_core::Role::Assistant => Role::Assistant,
274                                    matrixcode_core::Role::System => Role::System,
275                                    matrixcode_core::Role::Tool => Role::Tool { name: "tool".into(), is_error: false },
276                                };
277                                // Restore input history from user messages
278                                if role == Role::User && !text.starts_with('/')
279                                    && self.input_history.last().map(|s| s.as_str()) != Some(text) {
280                                    self.input_history.push(text.clone());
281                                }
282                                self.messages.push(Message { role, content: text.clone() });
283                            }
284                            matrixcode_core::ContentBlock::Thinking { thinking, .. } => {
285                                if thinking.is_empty() { continue; }
286                                // Create separate Thinking message for proper rendering
287                                self.messages.push(Message { role: Role::Thinking, content: thinking.clone() });
288                            }
289                            matrixcode_core::ContentBlock::ToolUse { name: _, .. } => {
290                                // Skip tool_use blocks - metadata only
291                            }
292                            matrixcode_core::ContentBlock::ToolResult { content, tool_use_id, .. } => {
293                                if content.is_empty() { continue; }
294                                // Try to determine if this is an error from content
295                                let is_error = content.contains("error") || content.contains("failed") || content.contains("Error");
296                                self.messages.push(Message { 
297                                    role: Role::Tool { 
298                                        name: if tool_use_id.starts_with("bash") { "bash".into() } else { tool_use_id.clone() },
299                                        is_error 
300                                    }, 
301                                    content: content.clone() 
302                                });
303                            }
304                            _ => {}
305                        }
306                    }
307                }
308            }
309        }
310        if !self.messages.is_empty() {
311            self.show_welcome = false;
312        }
313    }
314
315    /// Get selected text from messages
316    pub(crate) fn get_selected_text(&self, selection: Selection) -> String {
317        let norm = selection.normalized();
318        
319        // We need to reconstruct the text from rendered lines
320        // Build all text lines from messages (approximate - matches draw_messages logic)
321        let mut all_text: Vec<String> = Vec::new();
322        
323        // Account for welcome message lines
324        if self.show_welcome && self.messages.is_empty() {
325            // 7 welcome lines + 1 empty
326            for _ in 0..8 {
327                all_text.push(String::new());
328            }
329        }
330        
331        for msg in &self.messages {
332            let icon = msg.role.icon();
333            let label = msg.role.label();
334            all_text.push(format!("{} {}", icon, label));
335            for line in msg.content.lines() {
336                all_text.push(format!("  {}", line));
337            }
338            all_text.push(String::new());  // Empty line between messages
339        }
340        
341        // Extract selected range using visual column positions
342        let mut result = String::new();
343        for i in norm.start_line..=norm.end_line {
344            if let Some(line) = all_text.get(i) {
345                let (start_col, end_col) = if i == norm.start_line && i == norm.end_line {
346                    (norm.start_col, norm.end_col)
347                } else if i == norm.start_line {
348                    (norm.start_col, usize::MAX)
349                } else if i == norm.end_line {
350                    (0, norm.end_col)
351                } else {
352                    (0, usize::MAX)
353                };
354                
355                // Convert visual column to char boundary
356                let extracted = extract_by_visual_col(line, start_col, end_col);
357                result.push_str(&extracted);
358                if i != norm.end_line {
359                    result.push('\n');
360                }
361            }
362        }
363        
364        result
365    }
366
367    pub fn run(&mut self, term: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
368        loop {
369            // Animation frame - cycle through 10 frames for spinner
370            if self.last_anim.elapsed().as_millis() >= ANIM_MS as u128 {
371                self.frame = (self.frame + 1) % 10;
372                self.last_anim = Instant::now();
373            }
374
375            term.draw(|f| self.draw(f))?;
376
377            // Handle events
378            if event::poll(Duration::from_millis(16))? {
379                match event::read()? {
380                    Event::Key(k) => self.on_key(k),
381                    Event::Mouse(m) => self.on_mouse(m, self.msg_area_top.get()),
382                    Event::Paste(text) => self.on_paste(&text),
383                    _ => {}
384                }
385            }
386
387            // Process agent events
388            while let Ok(e) = self.rx.try_recv() {
389                self.on_event(e);
390            }
391
392            if self.exit { break; }
393        }
394        Ok(())
395    }
396    fn on_mouse(&mut self, m: MouseEvent, msg_area_y: u16) {
397        match m.kind {
398            MouseEventKind::ScrollUp => {
399                // Scroll up = view earlier content = decrease offset
400                // ratatui scroll(offset) skips first N lines, so:
401                // - scroll_offset=0 shows top, scroll_offset=max shows bottom
402                // - scroll up (earlier) = decrease offset
403                if self.auto_scroll {
404                    self.auto_scroll = false;
405                    // We need to start from bottom, then scroll up
406                    // Use max_scroll (will be updated in draw) or a large value
407                    self.scroll_offset = self.max_scroll.get().max(50);
408                }
409                self.scroll_offset = self.scroll_offset.saturating_sub(3);
410                self.selection = None;  // Clear selection on scroll
411            }
412            MouseEventKind::ScrollDown => {
413                // Scroll down = view newer content = increase offset
414                if !self.auto_scroll {
415                    self.scroll_offset = self.scroll_offset.saturating_add(3);
416                    // Check if we've scrolled to the bottom
417                    // Use max_scroll if available, otherwise just keep scrolling
418                    let max = self.max_scroll.get();
419                    if max > 0 && self.scroll_offset >= max {
420                        self.auto_scroll = true;
421                        self.scroll_offset = 0;
422                    }
423                }
424                self.selection = None;  // Clear selection on scroll
425            }
426            MouseEventKind::Down(MouseButton::Left) => {
427                // Start selection in messages area
428                if m.row >= msg_area_y {
429                    // If auto_scroll is on, sync scroll_offset first before disabling it
430                    if self.auto_scroll {
431                        self.scroll_offset = self.max_scroll.get().max(50);
432                    }
433                    let line = self.scroll_offset as usize + (m.row - msg_area_y) as usize;
434                    let col = m.column as usize;
435                    self.selection = Some(Selection::new(line, col));
436                    self.selecting = true;
437                    self.auto_scroll = false;  // Stop auto scroll when selecting
438                }
439            }
440            MouseEventKind::Drag(MouseButton::Left) => {
441                // Extend selection
442                if self.selecting && m.row >= msg_area_y {
443                    // Sync scroll_offset if auto_scroll was on
444                    if self.auto_scroll {
445                        self.scroll_offset = self.max_scroll.get().max(50);
446                        self.auto_scroll = false;
447                    }
448                    let line = self.scroll_offset as usize + (m.row - msg_area_y) as usize;
449                    let col = m.column as usize;
450                    if let Some(ref mut sel) = self.selection {
451                        sel.extend_to(line, col);
452                    }
453                }
454            }
455            MouseEventKind::Up(MouseButton::Left) => {
456                self.selecting = false;
457                // Auto-copy to clipboard on mouse release (like terminal behavior)
458                if let Some(sel) = self.selection {
459                    let text = self.get_selected_text(sel);
460                    if !text.is_empty() {
461                        let _ = arboard::Clipboard::new()
462                            .and_then(|mut cb| cb.set_text(&text));
463                    }
464                }
465            }
466            _ => {}
467        }
468    }
469
470}