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, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind},
8    Terminal,
9};
10
11use matrixcode_core::{AgentEvent, EventData, EventType, cancel::CancellationToken};
12use ratatui::crossterm::event::MouseButton;
13
14use crate::types::{Activity, ApproveMode, Role, Message};
15use crate::utils::{truncate, extract_tool_detail, fmt_tokens};
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) cache_read: u64,
31    pub(crate) cache_created: u64,
32    pub(crate) context_size: u64,
33    // Debug stats
34    pub(crate) api_calls: u64,
35    pub(crate) compressions: u64,
36    pub(crate) memory_saves: u64,
37    pub(crate) tool_calls: u64,
38    // UI state
39    pub(crate) frame: usize,
40    pub(crate) last_anim: Instant,
41    pub(crate) show_welcome: bool,
42    pub(crate) exit: bool,
43    // Input cursor position (character index in input string)
44    pub(crate) cursor_pos: usize,
45    // Scroll state
46    pub(crate) scroll_offset: u16,
47    pub(crate) auto_scroll: bool,
48    pub(crate) max_scroll: std::cell::Cell<u16>,
49    // Thinking display state
50    pub(crate) thinking_collapsed: bool,
51    // Approval mode
52    pub(crate) approve_mode: ApproveMode,
53    // Ask tool channel
54    pub(crate) ask_tx: Option<tokio::sync::mpsc::Sender<String>>,
55    pub(crate) waiting_for_ask: bool,
56    // Channels
57    pub(crate) tx: tokio::sync::mpsc::Sender<String>,
58    pub(crate) rx: tokio::sync::mpsc::Receiver<AgentEvent>,
59    pub(crate) cancel: CancellationToken,
60    // Message queue for pending inputs while AI is processing
61    pub(crate) pending_messages: Vec<String>,
62    // Loop task state
63    pub(crate) loop_task: Option<LoopTask>,
64    // Cron tasks state
65    pub(crate) cron_tasks: Vec<CronTask>,
66    // Selection state
67    pub(crate) selection: Option<Selection>,
68    pub(crate) selecting: bool,  // True while mouse dragging
69    pub(crate) msg_area_top: std::cell::Cell<u16>,  // Messages area top Y (computed in draw)
70    // Debug mode
71    pub(crate) debug_mode: bool,
72}
73
74/// Text selection in messages area
75#[derive(Clone, Copy, Debug)]
76pub struct Selection {
77    pub start_line: usize,
78    pub start_col: usize,
79    pub end_line: usize,
80    pub end_col: usize,
81}
82
83impl Selection {
84    pub fn new(start_line: usize, start_col: usize) -> Self {
85        Self {
86            start_line,
87            start_col,
88            end_line: start_line,
89            end_col: start_col,
90        }
91    }
92    
93    pub fn extend_to(&mut self, line: usize, col: usize) {
94        self.end_line = line;
95        self.end_col = col;
96    }
97    
98    #[allow(dead_code)]
99    pub fn is_empty(&self) -> bool {
100        self.start_line == self.end_line && self.start_col == self.end_col
101    }
102    
103    pub fn normalized(&self) -> Self {
104        // Normalize so start <= end
105        if self.start_line > self.end_line || 
106           (self.start_line == self.end_line && self.start_col > self.end_col) {
107            Self {
108                start_line: self.end_line,
109                start_col: self.end_col,
110                end_line: self.start_line,
111                end_col: self.start_col,
112            }
113        } else {
114            *self
115        }
116    }
117    
118    #[allow(dead_code)]
119    pub fn contains(&self, line: usize, col: usize) -> bool {
120        let norm = self.normalized();
121        if line < norm.start_line || line > norm.end_line {
122            return false;
123        }
124        if line == norm.start_line && line == norm.end_line {
125            return col >= norm.start_col && col <= norm.end_col;
126        }
127        if line == norm.start_line {
128            return col >= norm.start_col;
129        }
130        if line == norm.end_line {
131            return col <= norm.end_col;
132        }
133        true  // Middle line
134    }
135}
136
137/// Loop task - repeatedly send message
138#[derive(Clone)]
139pub struct LoopTask {
140    pub message: String,
141    pub interval_secs: u64,
142    pub count: u64,
143    pub max_count: Option<u64>,
144    pub cancel_token: CancellationToken,
145}
146
147/// Cron task - scheduled message sending
148#[derive(Clone)]
149pub struct CronTask {
150    pub id: usize,
151    pub message: String,
152    pub minute_interval: u64,  // Simplified: run every N minutes
153    #[allow(dead_code)]
154    pub next_run: Instant,  // For future use: precise scheduling
155    pub cancel_token: CancellationToken,
156}
157
158impl TuiApp {
159    pub fn new(
160        tx: tokio::sync::mpsc::Sender<String>,
161        rx: tokio::sync::mpsc::Receiver<AgentEvent>,
162        cancel: CancellationToken,
163    ) -> Self {
164        Self {
165            activity: Activity::Idle,
166            activity_detail: String::new(),
167            messages: Vec::new(),
168            thinking: String::new(),
169            streaming: String::new(),
170            input: String::new(),
171            model: "claude-sonnet-4".into(),
172            tokens_in: 0,
173            tokens_out: 0,
174            session_total_out: 0,
175            cache_read: 0,
176            cache_created: 0,
177            context_size: 200_000,
178            api_calls: 0,
179            compressions: 0,
180            memory_saves: 0,
181            tool_calls: 0,
182            frame: 0,
183            last_anim: Instant::now(),
184            show_welcome: true,
185            exit: false,
186            cursor_pos: 0,
187            scroll_offset: 0,
188            auto_scroll: true,
189            max_scroll: std::cell::Cell::new(0),
190            thinking_collapsed: false,  // Default: expanded
191            approve_mode: ApproveMode::Ask,
192            ask_tx: None,
193            waiting_for_ask: false,
194            tx, rx, cancel,
195            pending_messages: Vec::new(),
196            loop_task: None,
197            cron_tasks: Vec::new(),
198            selection: None,
199            selecting: false,
200            msg_area_top: std::cell::Cell::new(0),
201            debug_mode: false,
202        }
203    }
204
205    pub fn with_ask_channel(mut self, ask_tx: tokio::sync::mpsc::Sender<String>) -> Self {
206        self.ask_tx = Some(ask_tx);
207        self
208    }
209
210    pub fn with_config(mut self, model: &str, _think: bool, _max_tokens: u32, context_size: Option<u64>) -> Self {
211        self.model = model.to_string();
212        self.context_size = context_size.unwrap_or_else(|| {
213            let m = model.to_ascii_lowercase();
214            if m.contains("1m") || m.contains("opus-4-7") {
215                1_000_000
216            } else if m.contains("claude-3") || m.contains("claude-4") || m.contains("claude-sonnet") {
217                200_000
218            } else {
219                128_000
220            }
221        });
222        self
223    }
224
225    pub fn load_messages(&mut self, core_messages: Vec<matrixcode_core::Message>) {
226        for msg in core_messages {
227            let content = match &msg.content {
228                matrixcode_core::MessageContent::Text(t) => t.clone(),
229                matrixcode_core::MessageContent::Blocks(blocks) => {
230                    blocks.iter().filter_map(|b| match b {
231                        matrixcode_core::ContentBlock::Text { text } => Some(text.clone()),
232                        _ => None,
233                    }).collect::<Vec<_>>().join("\n")
234                }
235            };
236            if content.is_empty() { continue; }
237            let role = match msg.role {
238                matrixcode_core::Role::User => Role::User,
239                matrixcode_core::Role::Assistant => Role::Assistant,
240                matrixcode_core::Role::System => Role::System,
241                matrixcode_core::Role::Tool => Role::Tool { name: "tool".into(), is_error: false },
242            };
243            self.messages.push(Message { role, content });
244        }
245        if !self.messages.is_empty() {
246            self.show_welcome = false;
247        }
248    }
249
250    /// Get selected text from messages
251    fn get_selected_text(&self, selection: Selection) -> String {
252        let norm = selection.normalized();
253        
254        // We need to reconstruct the text from rendered lines
255        // This is tricky because we don't store the rendered lines
256        // For simplicity, we'll extract from message content based on approximate line mapping
257        
258        let mut result = String::new();
259        
260        // Build all text lines from messages (approximate - doesn't account for wrapping)
261        let mut all_text: Vec<String> = Vec::new();
262        for msg in &self.messages {
263            let icon = msg.role.icon();
264            let label = msg.role.label();
265            all_text.push(format!("{} {}", icon, label));
266            for line in msg.content.lines() {
267                all_text.push(format!("  {}", line));
268            }
269            all_text.push(String::new());  // Empty line between messages
270        }
271        
272        // Extract selected range
273        for i in norm.start_line..=norm.end_line {
274            if let Some(line) = all_text.get(i) {
275                if i == norm.start_line && i == norm.end_line {
276                    // Single line selection
277                    if norm.start_col < line.len() && norm.end_col <= line.len() {
278                        result.push_str(&line[norm.start_col..norm.end_col]);
279                    }
280                } else if i == norm.start_line {
281                    // First line of multi-line selection
282                    if norm.start_col < line.len() {
283                        result.push_str(&line[norm.start_col..]);
284                    }
285                    result.push('\n');
286                } else if i == norm.end_line {
287                    // Last line of multi-line selection
288                    if norm.end_col <= line.len() {
289                        result.push_str(&line[..norm.end_col]);
290                    }
291                } else {
292                    // Middle line
293                    result.push_str(line);
294                    result.push('\n');
295                }
296            }
297        }
298        
299        result
300    }
301
302    pub fn run(&mut self, term: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
303        loop {
304            // Animation frame
305            if self.last_anim.elapsed().as_millis() >= ANIM_MS as u128 {
306                self.frame = (self.frame + 1) % 10;
307                self.last_anim = Instant::now();
308            }
309
310            term.draw(|f| self.draw(f))?;
311
312            // Handle events
313            if event::poll(Duration::from_millis(16))? {
314                match event::read()? {
315                    Event::Key(k) => self.on_key(k),
316                    Event::Mouse(m) => self.on_mouse(m, self.msg_area_top.get()),
317                    Event::Paste(text) => self.on_paste(&text),
318                    _ => {}
319                }
320            }
321
322            // Process agent events
323            while let Ok(e) = self.rx.try_recv() {
324                self.on_event(e);
325            }
326
327            if self.exit { break; }
328        }
329        Ok(())
330    }
331
332    fn on_key(&mut self, k: KeyEvent) {
333        if k.kind != KeyEventKind::Press { return; }
334
335        match k.code {
336            // Enter: send or newline
337            KeyCode::Enter => {
338                if k.modifiers.contains(KeyModifiers::SHIFT) {
339                    // Shift+Enter: insert newline at cursor position
340                    self.ensure_char_boundary();
341                    self.input.insert(self.cursor_pos, '\n');
342                    self.cursor_pos += 1;  // '\n' is 1 byte
343                } else if !self.input.trim().is_empty() {
344                    self.send_input();
345                }
346            }
347
348            // Escape: interrupt or clear input
349            KeyCode::Esc => {
350                if self.activity == Activity::Asking {
351                    // Abort approval request
352                    self.waiting_for_ask = false;
353                    self.activity = Activity::Idle;
354                    self.messages.push(Message { role: Role::System, content: "⚠️ Approval aborted".into() });
355                    if let Some(ask_tx) = &self.ask_tx {
356                        ask_tx.try_send("abort".to_string()).ok();
357                    }
358                } else if self.activity != Activity::Idle {
359                    self.cancel.cancel();
360                    self.cancel.reset();
361                    self.activity = Activity::Idle;
362                    self.messages.push(Message { role: Role::System, content: "⚠️ Interrupted".into() });
363                } else {
364                    self.input.clear();
365                    self.cursor_pos = 0;
366                }
367            }
368
369            // Ctrl+C: copy selection or interrupt
370            KeyCode::Char('c') if k.modifiers.contains(KeyModifiers::CONTROL) => {
371                if let Some(sel) = self.selection {
372                    // Copy selected text
373                    let selected_text = self.get_selected_text(sel);
374                    if !selected_text.is_empty() {
375                        // Try to copy to clipboard
376                        let clipboard_result = arboard::Clipboard::new()
377                            .and_then(|mut cb| cb.set_text(&selected_text));
378                        
379                        match clipboard_result {
380                            Ok(_) => {
381                                self.messages.push(Message {
382                                    role: Role::System,
383                                    content: format!("📋 Copied {} chars to clipboard", selected_text.len())
384                                });
385                            }
386                            Err(_) => {
387                                self.messages.push(Message {
388                                    role: Role::System,
389                                    content: format!("📋 Copied {} chars (clipboard unavailable)", selected_text.len())
390                                });
391                            }
392                        }
393                        self.selection = None;
394                        self.selecting = false;
395                    }
396                } else if self.activity != Activity::Idle {
397                    self.cancel.cancel();
398                    self.cancel.reset();
399                    self.activity = Activity::Idle;
400                    self.messages.push(Message { role: Role::System, content: "⚠️ Interrupted".into() });
401                }
402            }
403
404            // Ctrl+D: exit
405            KeyCode::Char('d') if k.modifiers.contains(KeyModifiers::CONTROL) => {
406                self.exit = true;
407            }
408
409            // Ctrl+V: paste from clipboard
410            KeyCode::Char('v') if k.modifiers.contains(KeyModifiers::CONTROL) => {
411                // Try to get text from clipboard
412                if let Ok(mut clipboard) = arboard::Clipboard::new() {
413                    if let Ok(text) = clipboard.get_text() {
414                        self.on_paste(&text);
415                    }
416                }
417            }
418
419            // Backspace: delete char before cursor
420            KeyCode::Backspace => {
421                if self.cursor_pos > 0 {
422                    let prev_pos = self.prev_char_boundary();
423                    self.input.drain(prev_pos..self.cursor_pos);
424                    self.cursor_pos = prev_pos;
425                }
426            }
427
428            // Delete: delete char at cursor
429            KeyCode::Delete => {
430                if self.cursor_pos < self.input.len() {
431                    let next_pos = self.next_char_boundary();
432                    self.input.drain(self.cursor_pos..next_pos);
433                }
434            }
435
436            // Left arrow: move cursor left (one character)
437            KeyCode::Left => {
438                if self.cursor_pos > 0 {
439                    self.cursor_pos = self.prev_char_boundary();
440                }
441            }
442
443            // Right arrow: move cursor right (one character)
444            KeyCode::Right => {
445                if self.cursor_pos < self.input.len() {
446                    self.cursor_pos = self.next_char_boundary();
447                }
448            }
449
450            // Up arrow: move cursor to previous line (in multiline input)
451            KeyCode::Up if !k.modifiers.contains(KeyModifiers::ALT) => {
452                if self.input.contains('\n') {
453                    let (current_line_num, col_chars, _) = self.get_line_info();
454                    if current_line_num > 1 {
455                        let char_pos = self.byte_pos_to_char_pos();
456                        let input_chars: Vec<char> = self.input.chars().collect();
457                        let before_cursor_str: String = input_chars[..char_pos.min(input_chars.len())].iter().collect();
458                        
459                        // Previous line is before the last '\n' in before_cursor_str
460                        let prev_lines_str = &before_cursor_str[..before_cursor_str.rfind('\n').unwrap_or(0)];
461                        let prev_line_start_char = prev_lines_str.chars().count();
462                        
463                        // Find previous line length
464                        let prev_line_end_char = char_pos.saturating_sub(col_chars).saturating_sub(1); // -1 for the newline
465                        let prev_line_len_chars = prev_line_end_char.saturating_sub(prev_line_start_char);
466                        
467                        // Move to same column (or end if shorter)
468                        let target_char_pos = prev_line_start_char + col_chars.min(prev_line_len_chars);
469                        self.cursor_pos = self.char_pos_to_byte_pos(target_char_pos);
470                    }
471                }
472            }
473
474            // Down arrow: move cursor to next line (in multiline input)
475            KeyCode::Down if !k.modifiers.contains(KeyModifiers::ALT) => {
476                if self.input.contains('\n') {
477                    let (current_line_num, col_chars, total_lines) = self.get_line_info();
478                    if current_line_num < total_lines {
479                        let char_pos = self.byte_pos_to_char_pos();
480                        let input_chars: Vec<char> = self.input.chars().collect();
481                        
482                        // Boundary check: char_pos must not exceed input_chars.len()
483                        let safe_char_pos = char_pos.min(input_chars.len());
484                        
485                        // Find next line start
486                        let remaining_chars = &input_chars[safe_char_pos..];
487                        let next_line_start_char = remaining_chars.iter().position(|c| *c == '\n')
488                            .map(|i| safe_char_pos + i + 1)
489                            .unwrap_or_else(|| input_chars.len());
490                        
491                        // Find next line end
492                        let next_line_chars = &input_chars[next_line_start_char..];
493                        let next_line_end_char = next_line_chars.iter().position(|c| *c == '\n')
494                            .map(|i| next_line_start_char + i)
495                            .unwrap_or_else(|| input_chars.len());
496                        
497                        let next_line_len_chars = next_line_end_char.saturating_sub(next_line_start_char);
498                        
499                        // Move to same column (or end if shorter)
500                        let target_char_pos = next_line_start_char + col_chars.min(next_line_len_chars);
501                        self.cursor_pos = self.char_pos_to_byte_pos(target_char_pos);
502                    }
503                }
504            }
505
506            // Regular character input (except when Alt/Ctrl is held)
507            KeyCode::Char(c) if !k.modifiers.contains(KeyModifiers::ALT) && !k.modifiers.contains(KeyModifiers::CONTROL) => {
508                self.ensure_char_boundary();
509                self.input.insert(self.cursor_pos, c);
510                self.cursor_pos += c.len_utf8();
511            }
512
513            // Alt+M: toggle approve mode
514            KeyCode::Char('m') if k.modifiers.contains(KeyModifiers::ALT) => {
515                self.approve_mode = self.approve_mode.next();
516                self.tx.try_send(format!("/mode:{}", self.approve_mode.label())).ok();
517            }
518
519            // Alt+T: toggle thinking collapse
520            KeyCode::Char('t') if k.modifiers.contains(KeyModifiers::ALT) => {
521                self.thinking_collapsed = !self.thinking_collapsed;
522            }
523
524            // Shift+Tab / BackTab: toggle approve mode
525            KeyCode::Tab if k.modifiers.contains(KeyModifiers::SHIFT) => {
526                self.approve_mode = self.approve_mode.next();
527                self.tx.try_send(format!("/mode:{}", self.approve_mode.label())).ok();
528            }
529            KeyCode::BackTab => {
530                self.approve_mode = self.approve_mode.next();
531                self.tx.try_send(format!("/mode:{}", self.approve_mode.label())).ok();
532            }
533
534            // Scroll: PageUp
535            KeyCode::PageUp => {
536                if self.auto_scroll {
537                    self.scroll_offset = self.max_scroll.get();
538                    self.auto_scroll = false;
539                }
540                self.scroll_offset = self.scroll_offset.saturating_sub(10);
541            }
542
543            // Scroll: PageDown
544            KeyCode::PageDown => {
545                if !self.auto_scroll {
546                    self.scroll_offset = self.scroll_offset.saturating_add(10);
547                    if self.scroll_offset >= self.max_scroll.get() {
548                        self.auto_scroll = true;
549                        self.scroll_offset = 0;
550                    }
551                }
552            }
553
554            // Scroll: Alt+Up (or Up when not idle)
555            KeyCode::Up if k.modifiers.contains(KeyModifiers::ALT) => {
556                if self.auto_scroll {
557                    self.scroll_offset = self.max_scroll.get();
558                    self.auto_scroll = false;
559                }
560                self.scroll_offset = self.scroll_offset.saturating_sub(1);
561            }
562
563            // Scroll: Alt+Down (or Down when not idle)
564            KeyCode::Down if k.modifiers.contains(KeyModifiers::ALT) => {
565                if !self.auto_scroll {
566                    self.scroll_offset = self.scroll_offset.saturating_add(1);
567                    if self.scroll_offset >= self.max_scroll.get() {
568                        self.auto_scroll = true;
569                        self.scroll_offset = 0;
570                    }
571                }
572            }
573
574            // Home: move cursor to start (if input has content) or scroll to top
575            KeyCode::Home => {
576                if !self.input.is_empty() {
577                    self.cursor_pos = 0;
578                } else {
579                    self.auto_scroll = false;
580                    self.scroll_offset = 0;
581                }
582            }
583
584            // End: move cursor to end (if input has content) or scroll to bottom
585            KeyCode::End => {
586                if !self.input.is_empty() {
587                    self.cursor_pos = self.input.len();
588                } else {
589                    self.auto_scroll = true;
590                    self.scroll_offset = 0;
591                }
592            }
593
594            _ => {}
595        }
596    }
597
598    // ============================================================================
599    // Unicode-safe cursor position helpers
600    // ============================================================================
601    
602    /// Ensure cursor_pos is at a valid UTF-8 character boundary.
603    /// If not, move to the nearest valid boundary.
604    fn ensure_char_boundary(&mut self) {
605        if !self.input.is_char_boundary(self.cursor_pos) {
606            self.cursor_pos = self.input.char_indices()
607                .rfind(|(i, _)| *i <= self.cursor_pos)
608                .map(|(i, _)| i)
609                .unwrap_or(0);
610        }
611    }
612    
613    /// Find the byte position of the previous character boundary.
614    /// Returns 0 if cursor is at the start.
615    fn prev_char_boundary(&self) -> usize {
616        self.input.char_indices()
617            .rfind(|(i, _)| *i < self.cursor_pos)
618            .map(|(i, _)| i)
619            .unwrap_or(0)
620    }
621    
622    /// Find the byte position of the next character boundary.
623    /// Returns input.len() if cursor is at the end.
624    fn next_char_boundary(&self) -> usize {
625        self.input.char_indices()
626            .find(|(i, _)| *i > self.cursor_pos)
627            .map(|(i, _)| i)
628            .unwrap_or_else(|| self.input.len())
629    }
630    
631    /// Convert byte position to character position (count of chars before cursor).
632    fn byte_pos_to_char_pos(&self) -> usize {
633        self.input.char_indices().take(self.cursor_pos).count()
634    }
635    
636    /// Convert character position to byte position.
637    fn char_pos_to_byte_pos(&self, char_pos: usize) -> usize {
638        self.input.char_indices()
639            .nth(char_pos)
640            .map(|(i, _)| i)
641            .unwrap_or_else(|| self.input.len())
642    }
643    
644    /// Get current line info: (current_line_number, column_in_chars, total_lines)
645    fn get_line_info(&self) -> (usize, usize, usize) {
646        let char_pos = self.byte_pos_to_char_pos();
647        let before_cursor_str: String = self.input.chars().take(char_pos).collect();
648        let current_line_num = before_cursor_str.lines().count();
649        let total_lines = self.input.lines().count();
650        let current_line_start_char = before_cursor_str.rfind('\n')
651            .map(|i| before_cursor_str[i+1..].chars().count())
652            .unwrap_or(0);
653        let col_chars = char_pos - current_line_start_char;
654        (current_line_num, col_chars, total_lines)
655    }
656
657    fn send_input(&mut self) {
658        self.show_welcome = false;
659        let input = self.input.trim().to_string();
660        self.input.clear();
661        self.cursor_pos = 0;
662
663        if self.waiting_for_ask {
664            // Respond to approval/ask question
665            self.waiting_for_ask = false;
666            self.messages.push(Message { role: Role::User, content: input.clone() });
667            if let Some(ask_tx) = &self.ask_tx {
668                ask_tx.try_send(input).ok();
669            }
670            self.activity = Activity::Thinking;
671            self.auto_scroll = true;
672        } else if input.starts_with('/') {
673            // Command
674            self.handle_command(&input);
675        } else if self.activity == Activity::Idle {
676            // Send immediately
677            self.messages.push(Message { role: Role::User, content: input.clone() });
678            self.tx.try_send(input).ok();
679            self.activity = Activity::Thinking;
680            self.auto_scroll = true;
681        } else {
682            // Queue message (AI is processing)
683            self.pending_messages.push(input.clone());
684        }
685    }
686
687    fn on_mouse(&mut self, m: MouseEvent, msg_area_y: u16) {
688        match m.kind {
689            MouseEventKind::ScrollUp => {
690                // Scroll up = view earlier content = decrease offset
691                if self.auto_scroll {
692                    self.scroll_offset = self.max_scroll.get();
693                    self.auto_scroll = false;
694                }
695                self.scroll_offset = self.scroll_offset.saturating_sub(3);
696                self.selection = None;  // Clear selection on scroll
697            }
698            MouseEventKind::ScrollDown => {
699                // Scroll down = view newer content = increase offset
700                if !self.auto_scroll {
701                    self.scroll_offset = self.scroll_offset.saturating_add(3);
702                    if self.scroll_offset >= self.max_scroll.get() {
703                        self.auto_scroll = true;
704                        self.scroll_offset = 0;
705                    }
706                }
707                self.selection = None;  // Clear selection on scroll
708            }
709            MouseEventKind::Down(MouseButton::Left) => {
710                // Start selection in messages area
711                if m.row >= msg_area_y {
712                    // If auto_scroll is on, sync scroll_offset first before disabling it
713                    if self.auto_scroll {
714                        self.scroll_offset = self.max_scroll.get();
715                    }
716                    let line = self.scroll_offset as usize + (m.row - msg_area_y) as usize;
717                    let col = m.column as usize;
718                    self.selection = Some(Selection::new(line, col));
719                    self.selecting = true;
720                    self.auto_scroll = false;  // Stop auto scroll when selecting
721                }
722            }
723            MouseEventKind::Drag(MouseButton::Left) => {
724                // Extend selection
725                if self.selecting && m.row >= msg_area_y {
726                    // Sync scroll_offset if auto_scroll was on
727                    if self.auto_scroll {
728                        self.scroll_offset = self.max_scroll.get();
729                        self.auto_scroll = false;
730                    }
731                    let line = self.scroll_offset as usize + (m.row - msg_area_y) as usize;
732                    let col = m.column as usize;
733                    if let Some(ref mut sel) = self.selection {
734                        sel.extend_to(line, col);
735                    }
736                }
737            }
738            MouseEventKind::Up(MouseButton::Left) => {
739                self.selecting = false;
740            }
741            _ => {}
742        }
743    }
744
745    fn on_paste(&mut self, text: &str) {
746        self.ensure_char_boundary();
747        self.input.insert_str(self.cursor_pos, text);
748        self.cursor_pos += text.len();  // cursor_pos is byte position
749    }
750
751    fn handle_command(&mut self, cmd: &str) {
752        let parts: Vec<&str> = cmd.split_whitespace().collect();
753        let command = parts.first().copied().unwrap_or("");
754        let args = &parts[1..];
755
756        match command {
757            "/exit" | "/quit" | "/q" => {
758                self.exit = true;
759            }
760            "/clear" => {
761                if self.activity == Activity::Idle {
762                    self.messages.clear();
763                    self.pending_messages.clear();
764                    self.messages.push(Message { role: Role::System, content: "✓ Messages cleared".into() });
765                } else {
766                    self.messages.push(Message { role: Role::System, content: "⚠️ Cannot clear while AI is processing".into() });
767                }
768                self.auto_scroll = true;
769            }
770            "/history" => {
771                let user_count = self.messages.iter().filter(|m| m.role == Role::User).count();
772                let assistant_count = self.messages.iter().filter(|m| m.role == Role::Assistant).count();
773                let tool_count = self.messages.iter().filter(|m| matches!(m.role, Role::Tool { .. })).count();
774                let queue_count = self.pending_messages.len();
775                self.messages.push(Message {
776                    role: Role::System,
777                    content: format!(
778                        "📊 Session: {} user, {} assistant, {} tools, {} queued, {} output tokens",
779                        user_count, assistant_count, tool_count, queue_count, fmt_tokens(self.session_total_out)
780                    )
781                });
782                self.auto_scroll = true;
783            }
784            "/mode" => {
785                if args.is_empty() {
786                    self.messages.push(Message {
787                        role: Role::System,
788                        content: format!("Current mode: {} (use /mode ask|auto|strict)", self.approve_mode.label())
789                    });
790                } else {
791                    match args[0] {
792                        "ask" => self.approve_mode = ApproveMode::Ask,
793                        "auto" => self.approve_mode = ApproveMode::Auto,
794                        "strict" => self.approve_mode = ApproveMode::Strict,
795                        _ => {
796                            self.messages.push(Message {
797                                role: Role::System,
798                                content: "Invalid mode. Use: ask, auto, strict".into()
799                            });
800                            return;
801                        }
802                    }
803                    self.tx.try_send(format!("/mode:{}", self.approve_mode.label())).ok();
804                    self.messages.push(Message {
805                        role: Role::System,
806                        content: format!("✓ Mode: {}", self.approve_mode.label())
807                    });
808                }
809                self.auto_scroll = true;
810            }
811            "/model" => {
812                if args.is_empty() {
813                    self.messages.push(Message {
814                        role: Role::System,
815                        content: format!("Model: {} (context: {})", self.model, fmt_tokens(self.context_size))
816                    });
817                } else if self.activity == Activity::Idle {
818                    let new_model = args.join(" ");
819                    self.model = new_model.clone();
820                    self.messages.push(Message {
821                        role: Role::System,
822                        content: format!("✓ Model: {}", new_model)
823                    });
824                } else {
825                    self.messages.push(Message {
826                        role: Role::System,
827                        content: "⚠️ Cannot change model while AI is processing".into()
828                    });
829                }
830                self.auto_scroll = true;
831            }
832            "/compact" | "/compress" => {
833                self.tx.try_send("/compact".to_string()).ok();
834                self.auto_scroll = true;
835            }
836            "/init" => {
837                // Initialize project configuration
838                if args.is_empty() {
839                    // Generate project overview
840                    self.tx.try_send("/init".to_string()).ok();
841                    self.messages.push(Message {
842                        role: Role::System,
843                        content: "🔄 Generating project overview...".into()
844                    });
845                } else if args[0] == "status" {
846                    // Show project status
847                    self.tx.try_send("/init status".to_string()).ok();
848                    self.messages.push(Message {
849                        role: Role::System,
850                        content: "⏳ Checking project status...".into()
851                    });
852                } else if args[0] == "reset" || args[0] == "clear" {
853                    // Reset configuration
854                    self.tx.try_send("/init reset".to_string()).ok();
855                    self.messages.push(Message {
856                        role: Role::System,
857                        content: "⏳ Resetting project configuration...".into()
858                    });
859                } else {
860                    self.messages.push(Message {
861                        role: Role::System,
862                        content: "Unknown init command. Use: /init, /init status, /init reset".into()
863                    });
864                }
865                self.auto_scroll = true;
866            }
867            "/debug" => {
868                // Toggle debug mode
869                self.debug_mode = !self.debug_mode;
870                self.messages.push(Message {
871                    role: Role::System,
872                    content: format!("🔧 Debug mode: {} (api/tools counts {})",
873                        if self.debug_mode { "ON" } else { "OFF" },
874                        if self.debug_mode { "visible" } else { "hidden" }
875                    )
876                });
877                self.auto_scroll = true;
878            }
879            "/retry" => {
880                // Process pending queue
881                if !self.pending_messages.is_empty() && self.activity == Activity::Idle {
882                    let next_msg = self.pending_messages.remove(0);
883                    self.messages.push(Message { role: Role::User, content: next_msg.clone() });
884                    self.tx.try_send(next_msg).ok();
885                    self.activity = Activity::Thinking;
886                    self.auto_scroll = true;
887                    self.messages.push(Message {
888                        role: Role::System,
889                        content: if self.pending_messages.is_empty() {
890                            "✓ Retry: processing last queued message".into()
891                        } else {
892                            format!("⏳ Retry: {} messages remaining", self.pending_messages.len())
893                        }
894                    });
895                } else if self.pending_messages.is_empty() {
896                    self.messages.push(Message { role: Role::System, content: "No pending messages to retry".into() });
897                } else {
898                    self.messages.push(Message { role: Role::System, content: "AI is busy, please wait".into() });
899                }
900                self.auto_scroll = true;
901            }
902            "/new" => {
903                if self.activity == Activity::Idle {
904                    self.messages.clear();
905                    self.pending_messages.clear();
906                    self.tokens_in = 0;
907                    self.tokens_out = 0;
908                    self.session_total_out = 0;
909                    self.tx.try_send("/new".to_string()).ok();
910                    self.messages.push(Message { role: Role::System, content: "✓ New session".into() });
911                } else {
912                    self.messages.push(Message { role: Role::System, content: "⚠️ Cannot start new session while AI is processing".into() });
913                }
914                self.auto_scroll = true;
915            }
916            "/help" => {
917                self.messages.push(Message {
918                    role: Role::System,
919                    content: concat!(
920                        "📖 Commands:\n",
921                        "  /help     - Show this help\n",
922                        "  /exit     - Exit MatrixCode\n",
923                        "  /clear    - Clear messages\n",
924                        "  /history  - Show session history\n",
925                        "  /mode     - Change approve mode (ask/auto/strict)\n",
926                        "  /model    - Show/change model\n",
927                        "  /compact  - Compress context\n",
928                        "  /retry    - Retry last queued message\n",
929                        "  /new      - Start new session\n",
930                        "  /init     - Initialize/reset project\n",
931                        "  /skills   - List loaded skills\n",
932                        "  /memory   - View/manage memories\n",
933                        "  /overview - View project overview\n",
934                        "  /save     - Save current session\n",
935                        "  /sessions - List saved sessions\n",
936                        "  /load <id>- Load a session\n",
937                        "  /debug    - Toggle debug mode\n",
938                        "  /loop     - Start/stop loop task\n",
939                        "  /cron     - Manage scheduled tasks\n",
940                        "\n",
941                        "⌨️ Shortcuts:\n",
942                        "  Enter=send │ Shift+Enter=newline │ PgUp/PgDn=scroll\n",
943                        "  Home/End=top/bot │ Alt+M=mode │ Alt+T=thinking\n",
944                        "  Esc=interrupt │ Ctrl+D=exit"
945                    ).into()
946                });
947                self.auto_scroll = true;
948            }
949            "/skills" => {
950                // Send to backend for processing (shows loaded skills, not tools)
951                self.tx.try_send("/skills".to_string()).ok();
952                self.auto_scroll = true;
953            }
954            "/memory" => {
955                // Send to backend for processing
956                self.tx.try_send("/memory".to_string()).ok();
957                self.auto_scroll = true;
958            }
959            "/overview" => {
960                // Send to backend for processing
961                self.tx.try_send("/overview".to_string()).ok();
962                self.auto_scroll = true;
963            }
964            "/save" => {
965                // Send to backend for processing
966                self.tx.try_send("/save".to_string()).ok();
967                self.auto_scroll = true;
968            }
969            "/sessions" | "/resume" => {
970                // Send to backend for processing
971                self.tx.try_send("/sessions".to_string()).ok();
972                self.auto_scroll = true;
973            }
974            "/loop" => {
975                if args.is_empty() {
976                    self.messages.push(Message {
977                        role: Role::System,
978                        content: "/loop <message> [interval] [count] - Start loop\n/loop stop - Stop loop\n/loop status - Show status".into()
979                    });
980                } else if args[0] == "stop" {
981                    // Take ownership to avoid borrow conflict
982                    let task = self.loop_task.take();
983                    if let Some(ref task) = task {
984                        task.cancel_token.cancel();
985                        self.messages.push(Message {
986                            role: Role::System,
987                            content: format!("✓ Loop stopped (executed {} times)", task.count)
988                        });
989                        self.loop_task = None;  // Already taken
990                    } else {
991                        self.messages.push(Message { role: Role::System, content: "No active loop".into() });
992                    }
993                } else if args[0] == "status" {
994                    if let Some(ref task) = self.loop_task {
995                        self.messages.push(Message {
996                            role: Role::System,
997                            content: format!(
998                                "🔄 Loop active: '{}' every {}s, count {}{}",
999                                truncate(&task.message, 30),
1000                                task.interval_secs,
1001                                task.count,
1002                                task.max_count.map(|m| format!(" (max {})", m)).unwrap_or_default()
1003                            )
1004                        });
1005                    } else {
1006                        self.messages.push(Message { role: Role::System, content: "No active loop".into() });
1007                    }
1008                } else {
1009                    // Start new loop: /loop "message" [interval] [max_count]
1010                    if self.loop_task.is_some() {
1011                        self.messages.push(Message { role: Role::System, content: "⚠️ Loop already active. Use /loop stop first".into() });
1012                    } else {
1013                        let message = args[0].to_string();
1014                        let interval_secs: u64 = args.get(1)
1015                            .and_then(|s| s.parse().ok())
1016                            .unwrap_or(60);
1017                        let max_count: Option<u64> = args.get(2)
1018                            .and_then(|s| s.parse().ok());
1019                        
1020                        let cancel_token = CancellationToken::new();
1021                        self.loop_task = Some(LoopTask {
1022                            message: message.clone(),
1023                            interval_secs,
1024                            count: 0,
1025                            max_count,
1026                            cancel_token: cancel_token.clone(),
1027                        });
1028                        
1029                        // Spawn background task
1030                        let tx = self.tx.clone();
1031                        let msg = message.clone();
1032                        tokio::spawn(async move {
1033                            loop {
1034                                if cancel_token.is_cancelled() {
1035                                    break;
1036                                }
1037                                // Send message
1038                                tx.try_send(msg.clone()).ok();
1039                                // Wait interval
1040                                tokio::time::sleep(Duration::from_secs(interval_secs)).await;
1041                            }
1042                        });
1043                        
1044                        self.messages.push(Message {
1045                            role: Role::System,
1046                            content: format!(
1047                                "🔄 Loop started: '{}' every {}s{}",
1048                                truncate(&message, 30),
1049                                interval_secs,
1050                                max_count.map(|m| format!(" (max {})", m)).unwrap_or_default()
1051                            )
1052                        });
1053                    }
1054                }
1055                self.auto_scroll = true;
1056            }
1057            "/cron" => {
1058                if args.is_empty() {
1059                    self.messages.push(Message {
1060                        role: Role::System,
1061                        content: "/cron add <message> <minutes> - Add cron task\n/cron list - List tasks\n/cron remove <id> - Remove task\n/cron clear - Clear all".into()
1062                    });
1063                } else if args[0] == "list" {
1064                    if self.cron_tasks.is_empty() {
1065                        self.messages.push(Message { role: Role::System, content: "No cron tasks".into() });
1066                    } else {
1067                        let list: Vec<String> = self.cron_tasks.iter()
1068                            .map(|t| format!("#{}: '{}' every {}min", t.id, truncate(&t.message, 20), t.minute_interval))
1069                            .collect();
1070                        self.messages.push(Message {
1071                            role: Role::System,
1072                            content: format!("📋 Cron tasks:\n{}", list.join("\n"))
1073                        });
1074                    }
1075                } else if args[0] == "remove" || args[0] == "rm" {
1076                    let id: usize = args.get(1)
1077                        .and_then(|s| s.parse().ok())
1078                        .unwrap_or(0);
1079                    if let Some(pos) = self.cron_tasks.iter().position(|t| t.id == id) {
1080                        let task = &self.cron_tasks[pos];
1081                        task.cancel_token.cancel();
1082                        self.cron_tasks.remove(pos);
1083                        self.messages.push(Message {
1084                            role: Role::System,
1085                            content: format!("✓ Cron task #{} removed", id)
1086                        });
1087                    } else {
1088                        self.messages.push(Message { role: Role::System, content: format!("Cron task #{} not found", id) });
1089                    }
1090                } else if args[0] == "clear" {
1091                    for task in &self.cron_tasks {
1092                        task.cancel_token.cancel();
1093                    }
1094                    let count = self.cron_tasks.len();
1095                    self.cron_tasks.clear();
1096                    self.messages.push(Message {
1097                        role: Role::System,
1098                        content: format!("✓ {} cron tasks cleared", count)
1099                    });
1100                } else if args[0] == "add" {
1101                    // /cron add "message" 5
1102                    if args.len() < 3 {
1103                        self.messages.push(Message {
1104                            role: Role::System,
1105                            content: "Usage: /cron add <message> <minutes>".into()
1106                        });
1107                    } else {
1108                        let message = args[1].to_string();
1109                        let minute_interval: u64 = args.get(2)
1110                            .and_then(|s| s.parse().ok())
1111                            .unwrap_or(5);
1112                        
1113                        let id = self.cron_tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
1114                        let cancel_token = CancellationToken::new();
1115                        
1116                        let task = CronTask {
1117                            id,
1118                            message: message.clone(),
1119                            minute_interval,
1120                            next_run: Instant::now() + Duration::from_secs(minute_interval * 60),
1121                            cancel_token: cancel_token.clone(),
1122                        };
1123                        
1124                        self.cron_tasks.push(task.clone());
1125                        
1126                        // Spawn background task
1127                        let tx = self.tx.clone();
1128                        let msg = message.clone();
1129                        let interval_secs = minute_interval * 60;
1130                        tokio::spawn(async move {
1131                            // Initial delay to next_run
1132                            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
1133                            loop {
1134                                if cancel_token.is_cancelled() {
1135                                    break;
1136                                }
1137                                // Send message
1138                                tx.try_send(msg.clone()).ok();
1139                                // Wait interval
1140                                tokio::time::sleep(Duration::from_secs(interval_secs)).await;
1141                            }
1142                        });
1143                        
1144                        self.messages.push(Message {
1145                            role: Role::System,
1146                            content: format!("✓ Cron #{} added: '{}' every {}min", id, truncate(&message, 30), minute_interval)
1147                        });
1148                    }
1149                } else {
1150                    self.messages.push(Message {
1151                        role: Role::System,
1152                        content: "Unknown cron command. Use: add, list, remove, clear".into()
1153                    });
1154                }
1155                self.auto_scroll = true;
1156            }
1157            _ => {
1158                self.messages.push(Message {
1159                    role: Role::System,
1160                    content: format!("Unknown: {}. Type /help", command)
1161                });
1162                self.auto_scroll = true;
1163            }
1164        }
1165    }
1166
1167    fn on_event(&mut self, e: AgentEvent) {
1168        match e.event_type {
1169            EventType::ThinkingStart => {
1170                self.activity = Activity::Thinking;
1171                self.thinking.clear();
1172            }
1173            EventType::ThinkingDelta => {
1174                if let Some(EventData::Thinking { delta, .. }) = e.data {
1175                    self.thinking.push_str(&delta);
1176                    self.activity = Activity::Thinking;
1177                }
1178            }
1179            EventType::ThinkingEnd => {
1180                if !self.thinking.is_empty() {
1181                    self.messages.push(Message { role: Role::Thinking, content: self.thinking.clone() });
1182                    self.thinking.clear();
1183                }
1184            }
1185            EventType::TextStart => {
1186                self.streaming.clear();
1187                self.activity = Activity::Thinking;
1188            }
1189            EventType::TextDelta => {
1190                if let Some(EventData::Text { delta }) = e.data {
1191                    self.streaming.push_str(&delta);
1192                    self.activity = Activity::Thinking;
1193                }
1194            }
1195            EventType::TextEnd => {
1196                if !self.streaming.is_empty() {
1197                    self.messages.push(Message { role: Role::Assistant, content: self.streaming.clone() });
1198                    self.streaming.clear();
1199                }
1200            }
1201            EventType::ToolUseStart => {
1202                if let Some(EventData::ToolUse { name, input, .. }) = e.data {
1203                    self.activity = Activity::from_tool(&name);
1204                    self.activity_detail = extract_tool_detail(&name, input.as_ref());
1205                }
1206            }
1207            EventType::ToolResult => {
1208                if let Some(EventData::ToolResult { content, is_error, .. }) = e.data {
1209                    let tool_name = self.activity.label();
1210                    self.messages.push(Message {
1211                        role: Role::Tool { name: tool_name, is_error },
1212                        content: content  // Keep full content, draw.rs will summarize
1213                    });
1214                    self.tool_calls += 1;
1215                    self.activity = Activity::Thinking;
1216                    self.activity_detail.clear();
1217                }
1218            }
1219            EventType::SessionEnded => {
1220                // Flush remaining content
1221                if !self.streaming.is_empty() {
1222                    self.messages.push(Message { role: Role::Assistant, content: self.streaming.clone() });
1223                    self.streaming.clear();
1224                }
1225                if !self.thinking.is_empty() {
1226                    self.messages.push(Message { role: Role::Thinking, content: self.thinking.clone() });
1227                    self.thinking.clear();
1228                }
1229
1230                // Process queue or go idle
1231                if !self.pending_messages.is_empty() {
1232                    let next_msg = self.pending_messages.remove(0);
1233                    self.messages.push(Message { role: Role::User, content: next_msg.clone() });
1234                    self.tx.try_send(next_msg).ok();
1235                    self.activity = Activity::Thinking;
1236                    self.auto_scroll = true;
1237                    // Show queue status
1238                    self.messages.push(Message {
1239                        role: Role::System,
1240                        content: if self.pending_messages.is_empty() {
1241                            "✓ Queue completed".into()
1242                        } else {
1243                            format!("⏳ Processing queue ({} left)", self.pending_messages.len())
1244                        }
1245                    });
1246                } else {
1247                    self.activity = Activity::Idle;
1248                }
1249                self.activity_detail.clear();
1250            }
1251            EventType::Error => {
1252                if let Some(EventData::Error { message, .. }) = e.data {
1253                    self.messages.push(Message { role: Role::System, content: format!("❌ Error: {}", message) });
1254                    self.streaming.clear();
1255                    self.thinking.clear();
1256                }
1257                self.activity = Activity::Idle;
1258                self.activity_detail.clear();
1259                
1260                // Check queue after error - user may want to retry
1261                if !self.pending_messages.is_empty() {
1262                    self.messages.push(Message {
1263                        role: Role::System,
1264                        content: format!("⚠️ Queue paused ({} messages). Send '/retry' to process.", self.pending_messages.len())
1265                    });
1266                }
1267            }
1268            EventType::Usage => {
1269                if let Some(EventData::Usage { input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens }) = e.data {
1270                    self.tokens_in = input_tokens;
1271                    self.tokens_out = output_tokens;
1272                    self.session_total_out += output_tokens;
1273                    
1274                    // Update cache stats (only when actually reported by API)
1275                    // Note: DashScope doesn't support prompt caching, so these may always be 0
1276                    let cache_read = cache_read_input_tokens.unwrap_or(0);
1277                    let cache_created = cache_creation_input_tokens.unwrap_or(0);
1278                    
1279                    self.cache_read += cache_read;
1280                    self.cache_created += cache_created;
1281                    self.api_calls += 1;
1282                }
1283            }
1284            EventType::CompressionCompleted => {
1285                if let Some(EventData::Compression { original_tokens, compressed_tokens, ratio }) = e.data {
1286                    self.compressions += 1;
1287                    // Update token display to reflect compressed state
1288                    self.tokens_in = compressed_tokens;
1289                    self.messages.push(Message {
1290                        role: Role::System,
1291                        content: format!("📦 Compressed: {} → {} tokens ({:.0}% saved)\n  Context: {} tokens remaining",
1292                            fmt_tokens(original_tokens), fmt_tokens(compressed_tokens), (1.0 - ratio) * 100.0,
1293                            fmt_tokens(compressed_tokens))
1294                    });
1295                    self.auto_scroll = true;
1296                }
1297            }
1298            EventType::CompressionTriggered => {
1299                if let Some(EventData::Progress { message, .. }) = e.data {
1300                    self.messages.push(Message {
1301                        role: Role::System,
1302                        content: format!("⏳ {}", message)
1303                    });
1304                    self.auto_scroll = true;
1305                }
1306            }
1307            EventType::Progress => {
1308                if let Some(EventData::Progress { message, .. }) = e.data {
1309                    self.messages.push(Message {
1310                        role: Role::System,
1311                        content: message
1312                    });
1313                    self.auto_scroll = true;
1314                }
1315            }
1316            EventType::MemoryLoaded => {
1317                if let Some(EventData::Memory { entries_count, .. }) = e.data
1318                    && entries_count > 0 {
1319                    self.memory_saves += 1;
1320                    self.messages.push(Message {
1321                        role: Role::System,
1322                        content: format!("🧠 Memory: {} entries", entries_count)
1323                    });
1324                    self.auto_scroll = true;
1325                }
1326            }
1327            EventType::MemoryDetected => {
1328                if let Some(EventData::Memory { summary, entries_count }) = e.data {
1329                    self.memory_saves += 1;
1330                    self.messages.push(Message {
1331                        role: Role::System,
1332                        content: format!("🧠 Detected {} memories: {}", entries_count, summary)
1333                    });
1334                    self.auto_scroll = true;
1335                }
1336            }
1337            EventType::KeywordsExtracted => {
1338                // Keywords extraction is an internal operation, don't show to user
1339                // Only update internal state if needed (for debug mode, could show)
1340                if self.debug_mode {
1341                    if let Some(EventData::Keywords { keywords, source }) = e.data {
1342                        let preview = truncate(&source, 50);
1343                        self.messages.push(Message {
1344                            role: Role::System,
1345                            content: format!("🔍 Keywords: {} from '{}'", keywords.join(", "), preview)
1346                        });
1347                    }
1348                }
1349            }
1350            EventType::AskQuestion => {
1351                if let Some(EventData::AskQuestion { question, options }) = e.data {
1352                    // Detect if this is an approval request
1353                    let is_approval = question.contains("requires approval") || question.contains("Allow?");
1354                    let has_options = options.is_some();
1355                    
1356                    // Format the display
1357                    let mut content = if is_approval {
1358                        // Extract tool name and risk level
1359                        let lines: Vec<&str> = question.lines().collect();
1360                        let header = lines.first().copied().unwrap_or("");
1361                        let detail = lines.get(1).copied().unwrap_or("");
1362                        format!("{}\n{}", header, detail)
1363                    } else if has_options {
1364                        format!("❓ {}", question)
1365                    } else {
1366                        question.clone()
1367                    };
1368                    
1369                    // Show options if available
1370                    if let Some(ref opts) = options && let Some(arr) = opts.as_array() {
1371                        content.push_str("\n\nOptions:");
1372                        for opt in arr {
1373                            let id = opt["id"].as_str().unwrap_or("?");
1374                            let label = opt["label"].as_str().unwrap_or("");
1375                            let desc = opt["description"].as_str().unwrap_or("");
1376                            let desc_text = if desc.is_empty() { String::new() } else { format!("({})", desc) };
1377                            content.push_str(&format!("\n  [{}] {} {}", id, label, desc_text));
1378                        }
1379                    }
1380                    
1381                    // Add hint for approval
1382                    if is_approval {
1383                        content.push_str("\n\n[y] Approve  [n] Reject  [a] Abort");
1384                    } else if has_options {
1385                        content.push_str("\n\nEnter option ID (e.g., 'A' or 'B')");
1386                    }
1387                    
1388                    self.messages.push(Message { role: Role::System, content });
1389                    self.waiting_for_ask = true;
1390                    self.activity = Activity::Asking;
1391                    self.auto_scroll = true;
1392                }
1393            }
1394            _ => {}
1395        }
1396    }
1397}