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