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