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