Skip to main content

matrixcode_tui/
app.rs

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