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    /// Simplified: returns raw message content for the selected range
317    /// Maps rendered line numbers to original message content
318    pub(crate) fn get_selected_text(&self, selection: Selection) -> String {
319        let norm = selection.normalized();
320
321        // Build a mapping from rendered line index to message content
322        // This matches the rendering logic in draw_messages
323        let mut line_to_content: Vec<(usize, String)> = Vec::new();  // (message_idx, line_content)
324        let mut rendered_lines: Vec<String> = Vec::new();
325
326        // Welcome message lines (7 MATRIX lines + 1 subtitle + 1 empty)
327        if self.show_welcome && self.messages.is_empty() {
328            // MATRIX ASCII art lines (user wants to copy these)
329            rendered_lines.push("  █     █    █    ███████ ██████  ███ █     █ ".into());
330            rendered_lines.push("  ██   ██   █ █      █    █     █  █   █   █  ".into());
331            rendered_lines.push("  █ █ █ █  █   █     █    █     █  █    █ █   ".into());
332            rendered_lines.push("  █  █  █ █     █    █    ██████   █     █    ".into());
333            rendered_lines.push("  █     █ ███████    █    █   █    █    █ █   ".into());
334            rendered_lines.push("  █     █ █     █    █    █    █   █   █   █  ".into());
335            rendered_lines.push("  █     █ █     █    █    █     █ ███ █     █ ".into());
336            rendered_lines.push("    AI coding assistant | /help for commands".into());
337            rendered_lines.push(String::new());
338        }
339
340        // Process messages - simplified format matching actual rendering
341        for (msg_idx, msg) in self.messages.iter().enumerate() {
342            match &msg.role {
343                Role::User => {
344                    // User: │ prefix for each content line
345                    for line in msg.content.lines() {
346                        rendered_lines.push(format!("│ {}", line));
347                        line_to_content.push((msg_idx, line.to_string()));
348                    }
349                    rendered_lines.push(String::new());
350                }
351                Role::Assistant => {
352                    // Assistant: ── separator + content lines
353                    rendered_lines.push("  ──".into());
354                    for line in msg.content.lines() {
355                        rendered_lines.push(format!("  {}", line));
356                        line_to_content.push((msg_idx, line.to_string()));
357                    }
358                    rendered_lines.push(String::new());
359                }
360                Role::Thinking => {
361                    // Thinking: 💭 prefix
362                    rendered_lines.push("  💭 ▼ Thinking".into());
363                    for line in msg.content.lines() {
364                        rendered_lines.push(format!("    {}", line));
365                        line_to_content.push((msg_idx, line.to_string()));
366                    }
367                }
368                Role::Tool { name, .. } => {
369                    // Tool: simplified header + content
370                    rendered_lines.push(format!("  {} →", name));
371                    for line in msg.content.lines() {
372                        rendered_lines.push(format!("    {}", line));
373                        line_to_content.push((msg_idx, line.to_string()));
374                    }
375                    rendered_lines.push(String::new());
376                }
377                Role::System => {
378                    // System: ⚡ prefix or just content
379                    if msg.content.contains("APPROVAL") {
380                        for line in msg.content.lines() {
381                            rendered_lines.push(format!("  ⚡ {}", line));
382                        }
383                    } else {
384                        for line in msg.content.lines() {
385                            rendered_lines.push(format!("  {}", line));
386                        }
387                    }
388                    rendered_lines.push(String::new());
389                }
390                Role::Ask => {
391                    // Ask: full content with borders
392                    for line in msg.content.lines() {
393                        rendered_lines.push(line.to_string());
394                        line_to_content.push((msg_idx, line.to_string()));
395                    }
396                    rendered_lines.push(String::new());
397                }
398            }
399        }
400
401        // Extract selected range
402        let mut result = String::new();
403        for i in norm.start_line..=norm.end_line {
404            if let Some(line) = rendered_lines.get(i) {
405                let (start_col, end_col) = if i == norm.start_line && i == norm.end_line {
406                    (norm.start_col, norm.end_col)
407                } else if i == norm.start_line {
408                    (norm.start_col, usize::MAX)
409                } else if i == norm.end_line {
410                    (0, norm.end_col)
411                } else {
412                    (0, usize::MAX)
413                };
414
415                // Extract substring from visual column position
416                let extracted = extract_by_visual_col(line, start_col, end_col);
417                result.push_str(&extracted);
418                if i != norm.end_line {
419                    result.push('\n');
420                }
421            }
422        }
423
424        result
425    }
426
427    pub fn run(&mut self, term: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
428        loop {
429            // Animation frame - cycle through 10 frames for spinner
430            if self.last_anim.elapsed().as_millis() >= ANIM_MS as u128 {
431                self.frame = (self.frame + 1) % 10;
432                self.last_anim = Instant::now();
433            }
434
435            term.draw(|f| self.draw(f))?;
436
437            // Handle events
438            if event::poll(Duration::from_millis(16))? {
439                match event::read()? {
440                    Event::Key(k) => self.on_key(k),
441                    Event::Mouse(m) => self.on_mouse(m, self.msg_area_top.get()),
442                    Event::Paste(text) => self.on_paste(&text),
443                    _ => {}
444                }
445            }
446
447            // Process agent events
448            while let Ok(e) = self.rx.try_recv() {
449                self.on_event(e);
450            }
451
452            if self.exit { break; }
453        }
454        Ok(())
455    }
456    fn on_mouse(&mut self, m: MouseEvent, msg_area_y: u16) {
457        match m.kind {
458            MouseEventKind::ScrollUp => {
459                // Scroll up = view earlier content = decrease offset
460                // ratatui scroll(offset) skips first N lines, so:
461                // - scroll_offset=0 shows top, scroll_offset=max shows bottom
462                // - scroll up (earlier) = decrease offset
463                if self.auto_scroll {
464                    self.auto_scroll = false;
465                    // We need to start from bottom, then scroll up
466                    // Use max_scroll (will be updated in draw) or a large value
467                    self.scroll_offset = self.max_scroll.get().max(50);
468                }
469                self.scroll_offset = self.scroll_offset.saturating_sub(3);
470                self.selection = None;  // Clear selection on scroll
471            }
472            MouseEventKind::ScrollDown => {
473                // Scroll down = view newer content = increase offset
474                if !self.auto_scroll {
475                    self.scroll_offset = self.scroll_offset.saturating_add(3);
476                    // Check if we've scrolled to the bottom
477                    // Use max_scroll if available, otherwise just keep scrolling
478                    let max = self.max_scroll.get();
479                    if max > 0 && self.scroll_offset >= max {
480                        self.auto_scroll = true;
481                        self.scroll_offset = 0;
482                    }
483                }
484                self.selection = None;  // Clear selection on scroll
485            }
486            MouseEventKind::Down(MouseButton::Left) => {
487                // Start selection in messages area
488                if m.row >= msg_area_y {
489                    // If auto_scroll is on, sync scroll_offset first before disabling it
490                    if self.auto_scroll {
491                        self.scroll_offset = self.max_scroll.get().max(50);
492                    }
493                    let line = self.scroll_offset as usize + (m.row - msg_area_y) as usize;
494                    let col = m.column as usize;
495                    self.selection = Some(Selection::new(line, col));
496                    self.selecting = true;
497                    self.auto_scroll = false;  // Stop auto scroll when selecting
498                }
499            }
500            MouseEventKind::Drag(MouseButton::Left) => {
501                // Extend selection
502                if self.selecting && m.row >= msg_area_y {
503                    // Sync scroll_offset if auto_scroll was on
504                    if self.auto_scroll {
505                        self.scroll_offset = self.max_scroll.get().max(50);
506                        self.auto_scroll = false;
507                    }
508                    let line = self.scroll_offset as usize + (m.row - msg_area_y) as usize;
509                    let col = m.column as usize;
510                    if let Some(ref mut sel) = self.selection {
511                        sel.extend_to(line, col);
512                    }
513                }
514            }
515            MouseEventKind::Up(MouseButton::Left) => {
516                self.selecting = false;
517                // Auto-copy to clipboard on mouse release (like terminal behavior)
518                if let Some(sel) = self.selection {
519                    let text = self.get_selected_text(sel);
520                    if !text.is_empty() {
521                        // Try clipboard and show result in debug mode
522                        let result = arboard::Clipboard::new()
523                            .and_then(|mut cb| cb.set_text(&text));
524                        if self.debug_mode {
525                            match result {
526                                Ok(_) => self.messages.push(Message {
527                                    role: Role::System,
528                                    content: format!("✓ Copied {} chars", text.len())
529                                }),
530                                Err(e) => self.messages.push(Message {
531                                    role: Role::System,
532                                    content: format!("❌ Copy failed: {}", e)
533                                }),
534                            }
535                            self.auto_scroll = true;
536                        }
537                    }
538                }
539            }
540            _ => {}
541        }
542    }
543
544}