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, MouseEvent, MouseEventKind},
8    Terminal,
9};
10
11use matrixcode_core::{AgentEvent, cancel::CancellationToken};
12
13use crate::types::{Activity, ApproveMode, Role, Message};
14use crate::ANIM_MS;
15
16pub struct TuiApp {
17    pub(crate) activity: Activity,
18    pub(crate) activity_detail: String,
19    pub(crate) messages: Vec<Message>,
20    pub(crate) thinking: String,
21    pub(crate) streaming: String,
22    pub(crate) input: String,
23    pub(crate) model: String,
24    // Token stats
25    pub(crate) tokens_in: u64,
26    pub(crate) tokens_out: u64,
27    pub(crate) session_total_out: u64,
28    pub(crate) current_request_tokens: u64,  // Tokens for current request (real-time)
29    pub(crate) cache_read: u64,
30    pub(crate) cache_created: u64,
31    pub(crate) context_size: u64,
32    // Debug stats
33    pub(crate) api_calls: u64,
34    pub(crate) compressions: u64,
35    pub(crate) memory_saves: u64,
36    pub(crate) tool_calls: u64,
37    // Timing
38    pub(crate) request_start: Option<Instant>,
39    // UI state
40    pub(crate) frame: usize,
41    pub(crate) last_anim: Instant,
42    pub(crate) show_welcome: bool,
43    pub(crate) exit: bool,
44    // Input cursor position (character index in input string)
45    pub(crate) cursor_pos: usize,
46    // Input history (Up/Down arrow navigation)
47    pub(crate) input_history: Vec<String>,
48    pub(crate) history_index: Option<usize>,  // None = not browsing history
49    pub(crate) history_draft: String,  // Saves current input when entering history mode
50    // Scroll state
51    pub(crate) scroll_offset: u16,
52    pub(crate) auto_scroll: bool,
53    pub(crate) max_scroll: std::cell::Cell<u16>,
54    // Thinking display state
55    pub(crate) thinking_collapsed: bool,
56    // Approval mode
57    pub(crate) approve_mode: ApproveMode,
58    // Shared approve mode atomic - directly updates agent's mode in real-time
59    pub(crate) shared_approve_mode: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
60    // Ask tool channel
61    pub(crate) ask_tx: Option<tokio::sync::mpsc::Sender<String>>,
62    pub(crate) waiting_for_ask: bool,
63    pub(crate) ask_options: Vec<crate::types::AskOption>,
64    pub(crate) ask_selected_index: usize,
65    // Channels
66    pub(crate) tx: tokio::sync::mpsc::Sender<String>,
67    pub(crate) rx: tokio::sync::mpsc::Receiver<AgentEvent>,
68    pub(crate) cancel: CancellationToken,
69    // Message queue for pending inputs while AI is processing
70    pub(crate) pending_messages: Vec<String>,
71    // Loop task state
72    pub(crate) loop_task: Option<LoopTask>,
73    // Cron tasks state
74    pub(crate) cron_tasks: Vec<CronTask>,
75    // Debug mode
76    pub(crate) debug_mode: bool,
77}
78
79/// Loop task - repeatedly send message
80#[derive(Clone)]
81pub struct LoopTask {
82    pub message: String,
83    pub interval_secs: u64,
84    pub count: u64,
85    pub max_count: Option<u64>,
86    pub cancel_token: CancellationToken,
87}
88
89/// Cron task - scheduled message sending
90#[derive(Clone)]
91pub struct CronTask {
92    pub id: usize,
93    pub message: String,
94    pub minute_interval: u64,  // Simplified: run every N minutes
95    #[allow(dead_code)]
96    pub next_run: Instant,  // For future use: precise scheduling
97    pub cancel_token: CancellationToken,
98}
99
100impl TuiApp {
101    pub fn new(
102        tx: tokio::sync::mpsc::Sender<String>,
103        rx: tokio::sync::mpsc::Receiver<AgentEvent>,
104        cancel: CancellationToken,
105    ) -> Self {
106        Self {
107            activity: Activity::Idle,
108            activity_detail: String::new(),
109            messages: Vec::new(),
110            thinking: String::new(),
111            streaming: String::new(),
112            input: String::new(),
113            model: "claude-sonnet-4".into(),
114            tokens_in: 0,
115            tokens_out: 0,
116            session_total_out: 0,
117            current_request_tokens: 0,
118            cache_read: 0,
119            cache_created: 0,
120            context_size: 200_000,
121            api_calls: 0,
122            compressions: 0,
123            memory_saves: 0,
124            tool_calls: 0,
125            request_start: None,
126            frame: 0,
127            last_anim: Instant::now(),
128            show_welcome: true,
129            exit: false,
130            cursor_pos: 0,
131            input_history: Vec::new(),
132            history_index: None,
133            history_draft: String::new(),
134            scroll_offset: 0,
135            auto_scroll: true,
136            max_scroll: std::cell::Cell::new(0),
137            thinking_collapsed: false,  // Default: expanded
138            approve_mode: ApproveMode::Ask,
139            shared_approve_mode: None,
140            ask_tx: None,
141            waiting_for_ask: false,
142            ask_options: Vec::new(),
143            ask_selected_index: 0,
144            tx, rx, cancel,
145            pending_messages: Vec::new(),
146            loop_task: None,
147            cron_tasks: Vec::new(),
148            debug_mode: false,
149        }
150    }
151
152    pub fn with_ask_channel(mut self, ask_tx: tokio::sync::mpsc::Sender<String>) -> Self {
153        self.ask_tx = Some(ask_tx);
154        self
155    }
156
157    /// Set shared approve mode atomic for real-time mode switching during agent execution.
158    pub fn with_shared_approve_mode(mut self, shared: std::sync::Arc<std::sync::atomic::AtomicU8>) -> Self {
159        self.shared_approve_mode = Some(shared);
160        self
161    }
162
163    pub fn with_config(mut self, model: &str, _think: bool, _max_tokens: u32, context_size: Option<u64>) -> Self {
164        self.model = model.to_string();
165        self.context_size = context_size.unwrap_or_else(|| {
166            let m = model.to_ascii_lowercase();
167            if m.contains("1m") || m.contains("opus-4-7") {
168                1_000_000
169            } else if m.contains("claude-3") || m.contains("claude-4") || m.contains("claude-sonnet") {
170                200_000
171            } else {
172                128_000
173            }
174        });
175        self
176    }
177
178    pub fn load_messages(&mut self, core_messages: Vec<matrixcode_core::Message>) {
179        for msg in core_messages {
180            // Handle different content block types separately
181            match &msg.content {
182                matrixcode_core::MessageContent::Text(t) => {
183                    if t.is_empty() { continue; }
184                    let role = match msg.role {
185                        matrixcode_core::Role::User => Role::User,
186                        matrixcode_core::Role::Assistant => Role::Assistant,
187                        matrixcode_core::Role::System => Role::System,
188                        matrixcode_core::Role::Tool => Role::Tool { name: "tool".into(), detail: None, is_error: false },
189                    };
190                    // Restore input history from user messages
191                    if role == Role::User && !t.starts_with('/')
192                        && self.input_history.last().map(|s| s.as_str()) != Some(t) {
193                        self.input_history.push(t.clone());
194                    }
195                    self.messages.push(Message { role, content: t.clone() });
196                }
197                matrixcode_core::MessageContent::Blocks(blocks) => {
198                    // Process each block separately to maintain proper message types
199                    for b in blocks {
200                        match b {
201                            matrixcode_core::ContentBlock::Text { text } => {
202                                if text.is_empty() { continue; }
203                                let role = match msg.role {
204                                    matrixcode_core::Role::User => Role::User,
205                                    matrixcode_core::Role::Assistant => Role::Assistant,
206                                    matrixcode_core::Role::System => Role::System,
207                                    matrixcode_core::Role::Tool => Role::Tool { name: "tool".into(), detail: None, is_error: false },
208                                };
209                                // Restore input history from user messages
210                                if role == Role::User && !text.starts_with('/')
211                                    && self.input_history.last().map(|s| s.as_str()) != Some(text) {
212                                    self.input_history.push(text.clone());
213                                }
214                                self.messages.push(Message { role, content: text.clone() });
215                            }
216                            matrixcode_core::ContentBlock::Thinking { thinking, .. } => {
217                                if thinking.is_empty() { continue; }
218                                // Create separate Thinking message for proper rendering
219                                self.messages.push(Message { role: Role::Thinking, content: thinking.clone() });
220                            }
221                            matrixcode_core::ContentBlock::ToolUse { name: _, .. } => {
222                                // Skip tool_use blocks - metadata only
223                            }
224                            matrixcode_core::ContentBlock::ToolResult { content, tool_use_id, .. } => {
225                                if content.is_empty() { continue; }
226                                // Try to determine if this is an error from content
227                                let is_error = content.contains("error") || content.contains("failed") || content.contains("Error");
228                                self.messages.push(Message { 
229                                    role: Role::Tool { 
230                                        name: if tool_use_id.starts_with("bash") { "bash".into() } else { tool_use_id.clone() },
231                                        detail: None,
232                                        is_error 
233                                    }, 
234                                    content: content.clone() 
235                                });
236                            }
237                            _ => {}
238                        }
239                    }
240                }
241            }
242        }
243        if !self.messages.is_empty() {
244            self.show_welcome = false;
245        }
246    }
247
248    pub fn run(&mut self, term: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
249        loop {
250            // Animation frame - cycle through 10 frames for spinner
251            if self.last_anim.elapsed().as_millis() >= ANIM_MS as u128 {
252                self.frame = (self.frame + 1) % 10;
253                self.last_anim = Instant::now();
254            }
255
256            term.draw(|f| self.draw(f))?;
257
258            // Handle events
259            if event::poll(Duration::from_millis(16))? {
260                match event::read()? {
261                    Event::Key(k) => self.on_key(k),
262                    Event::Mouse(m) => self.on_mouse(m),
263                    Event::Paste(text) => self.on_paste(&text),
264                    _ => {}
265                }
266            }
267
268            // Process agent events
269            while let Ok(e) = self.rx.try_recv() {
270                self.on_event(e);
271            }
272
273            if self.exit { break; }
274        }
275        Ok(())
276    }
277    fn on_mouse(&mut self, m: MouseEvent) {
278        match m.kind {
279            MouseEventKind::ScrollUp => {
280                if self.auto_scroll {
281                    self.auto_scroll = false;
282                    self.scroll_offset = self.max_scroll.get().max(50);
283                }
284                self.scroll_offset = self.scroll_offset.saturating_sub(3);
285            }
286            MouseEventKind::ScrollDown => {
287                if !self.auto_scroll {
288                    self.scroll_offset = self.scroll_offset.saturating_add(3);
289                    let max = self.max_scroll.get();
290                    if max > 0 && self.scroll_offset >= max {
291                        self.auto_scroll = true;
292                        self.scroll_offset = 0;
293                    }
294                }
295            }
296            _ => {}
297        }
298    }
299
300}