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::tools::ProxyToolResponse;
13use matrixcode_core::{AgentEvent, cancel::CancellationToken};
14
15use crate::ANIM_MS;
16use crate::types::{Activity, ApproveMode, AskQuestion, Message, Role, SubmitMode};
17
18pub struct TuiApp {
19    pub(crate) activity: Activity,
20    pub(crate) activity_detail: String,
21    /// Full tool input for display (not truncated)
22    pub(crate) activity_input: Option<serde_json::Value>,
23    pub(crate) messages: Vec<Message>,
24    pub(crate) thinking: String,
25    pub(crate) streaming: String,
26    pub(crate) input: String,
27    pub(crate) model: String,
28    // Token stats
29    pub(crate) tokens_in: u64,
30    pub(crate) tokens_out: u64,
31    pub(crate) session_total_out: u64,
32    pub(crate) current_request_tokens: u64, // Tokens for current request (real-time)
33    pub(crate) cache_read: u64,
34    pub(crate) cache_created: u64,
35    pub(crate) context_size: u64,
36    // Debug stats
37    pub(crate) api_calls: u64,
38    pub(crate) compressions: u64,
39    pub(crate) memory_saves: u64,
40    pub(crate) tool_calls: u64,
41    // Timing
42    pub(crate) request_start: Option<Instant>,
43    pub(crate) tool_start: Option<Instant>, // When current tool execution started
44    // UI state
45    pub(crate) frame: usize,
46    pub(crate) last_anim: Instant,
47    pub(crate) show_welcome: bool,
48    pub(crate) exit: bool,
49    // Input cursor position (character index in input string)
50    pub(crate) cursor_pos: usize,
51    // Input history (Up/Down arrow navigation)
52    pub(crate) input_history: Vec<String>,
53    pub(crate) history_index: Option<usize>, // None = not browsing history
54    pub(crate) history_draft: String,        // Saves current input when entering history mode
55    // Scroll state
56    pub(crate) scroll_offset: u16,
57    pub(crate) auto_scroll: bool,
58    pub(crate) max_scroll: std::cell::Cell<u16>,
59    pub(crate) new_message_while_scrolled: std::cell::Cell<bool>, // Flag for notification when scrolled up
60    // Thinking display state
61    pub(crate) thinking_collapsed: bool,
62    // Dirty flag for rendering optimization - only redraw when something changed
63    pub(crate) dirty: std::cell::Cell<bool>,
64    // Approval mode
65    pub(crate) approve_mode: ApproveMode,
66    // Shared approve mode atomic - directly updates agent's mode in real-time
67    pub(crate) shared_approve_mode: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
68    // Ask tool channel
69    pub(crate) ask_tx: Option<tokio::sync::mpsc::Sender<String>>,
70    pub(crate) waiting_for_ask: bool,
71    pub(crate) ask_options: Vec<crate::types::AskOption>,
72    pub(crate) ask_selected_index: usize,
73    pub(crate) ask_multi_select: bool, // Whether this is a multi-select question
74    pub(crate) ask_submit_mode: SubmitMode, // How to submit selection
75    pub(crate) ask_other_input_active: bool, // Whether user is typing custom input for "Other" option
76    // Multi-question support
77    pub(crate) ask_questions: Vec<AskQuestion>, // Queue of questions
78    pub(crate) current_question_idx: usize,     // Current question index
79    // Todo tracking for progress display
80    pub(crate) todo_items: Vec<TodoItem>,
81    // Channels
82    pub(crate) tx: tokio::sync::mpsc::Sender<String>,
83    pub(crate) rx: tokio::sync::mpsc::Receiver<AgentEvent>,
84    pub(crate) cancel: CancellationToken,
85    // Proxy tool response channel
86    pub(crate) proxy_response_tx: Option<tokio::sync::mpsc::Sender<ProxyToolResponse>>,
87    // Message queue for pending inputs while AI is processing
88    pub(crate) pending_messages: Vec<String>,
89    // Real-time pending input sender (pushes to Agent during processing)
90    pub(crate) pending_input_tx: Option<tokio::sync::mpsc::Sender<String>>,
91    // Loop task state
92    pub(crate) loop_task: Option<LoopTask>,
93    // Cron tasks state
94    pub(crate) cron_tasks: Vec<CronTask>,
95    // Debug mode
96    pub(crate) debug_mode: bool,
97    // Debug panel state
98    pub(crate) show_debug_panel: bool,
99    pub(crate) debug_logs: Vec<String>,
100    pub(crate) debug_scroll_offset: u16,
101    // Multiline input confirmation state
102    pub(crate) multiline_confirm_send: bool,
103    // Workflow visualization state
104    pub(crate) workflow_state: crate::workflow::WorkflowViewState,
105    // Workflow refresh timing
106    pub(crate) last_workflow_refresh: Instant,
107    // MCP server status
108    pub(crate) mcp_servers: Vec<matrixcode_core::event::McpServerInfo>,
109    // LSP server status
110    pub(crate) lsp_servers: Vec<matrixcode_core::LspServerInfo>,
111}
112
113/// Todo item for progress tracking
114#[derive(Clone)]
115#[allow(dead_code)] // Fields used in serialization, not directly read
116pub struct TodoItem {
117    pub content: String,
118    pub status: String, // "pending", "in_progress", "completed"
119}
120
121/// Loop task - repeatedly send message
122#[derive(Clone)]
123pub struct LoopTask {
124    pub message: String,
125    pub interval_secs: u64,
126    pub count: u64,
127    pub max_count: Option<u64>,
128    pub cancel_token: CancellationToken,
129}
130
131/// Cron task - scheduled message sending
132#[derive(Clone)]
133pub struct CronTask {
134    pub id: usize,
135    pub message: String,
136    pub minute_interval: u64, // Simplified: run every N minutes
137    #[allow(dead_code)]
138    pub next_run: Instant, // For future use: precise scheduling
139    pub cancel_token: CancellationToken,
140}
141
142impl TuiApp {
143    pub fn new(
144        tx: tokio::sync::mpsc::Sender<String>,
145        rx: tokio::sync::mpsc::Receiver<AgentEvent>,
146        cancel: CancellationToken,
147    ) -> Self {
148        Self {
149            activity: Activity::Idle,
150            activity_detail: String::new(),
151            activity_input: None,
152            messages: Vec::new(),
153            thinking: String::new(),
154            streaming: String::new(),
155            input: String::new(),
156            model: "claude-sonnet-4".into(),
157            tokens_in: 0,
158            tokens_out: 0,
159            session_total_out: 0,
160            current_request_tokens: 0,
161            cache_read: 0,
162            cache_created: 0,
163            context_size: 200_000,
164            api_calls: 0,
165            compressions: 0,
166            memory_saves: 0,
167            tool_calls: 0,
168            request_start: None,
169            tool_start: None,
170            frame: 0,
171            last_anim: Instant::now(),
172            show_welcome: true,
173            exit: false,
174            cursor_pos: 0,
175            input_history: Vec::new(),
176            history_index: None,
177            history_draft: String::new(),
178            scroll_offset: 0,
179            auto_scroll: true,
180            max_scroll: std::cell::Cell::new(0),
181            new_message_while_scrolled: std::cell::Cell::new(false),
182            thinking_collapsed: false, // Default: expanded to show thinking content
183            dirty: std::cell::Cell::new(true), // Initial render needed
184            approve_mode: ApproveMode::Ask,
185            shared_approve_mode: None,
186            ask_tx: None,
187            waiting_for_ask: false,
188            ask_options: Vec::new(),
189            ask_selected_index: 0,
190            ask_multi_select: false,
191            ask_submit_mode: SubmitMode::default(),
192            ask_other_input_active: false,
193            ask_questions: Vec::new(),
194            current_question_idx: 0,
195            todo_items: Vec::new(),
196            tx,
197            rx,
198            cancel,
199            proxy_response_tx: None,
200            pending_messages: Vec::new(),
201            pending_input_tx: None,
202            loop_task: None,
203            cron_tasks: Vec::new(),
204            debug_mode: false,
205            show_debug_panel: false,
206            debug_logs: Vec::new(),
207            debug_scroll_offset: 0,
208            multiline_confirm_send: false,
209            workflow_state: crate::workflow::WorkflowViewState::default(),
210            last_workflow_refresh: Instant::now(),
211            mcp_servers: Vec::new(),
212            lsp_servers: Vec::new(),
213        }
214    }
215
216    pub fn with_ask_channel(mut self, ask_tx: tokio::sync::mpsc::Sender<String>) -> Self {
217        self.ask_tx = Some(ask_tx);
218        self
219    }
220
221    /// Set pending input sender for real-time message appending during processing.
222    pub fn with_pending_input_tx(mut self, tx: tokio::sync::mpsc::Sender<String>) -> Self {
223        self.pending_input_tx = Some(tx);
224        self
225    }
226
227    /// Set shared approve mode atomic for real-time mode switching during agent execution.
228    pub fn with_shared_approve_mode(
229        mut self,
230        shared: std::sync::Arc<std::sync::atomic::AtomicU8>,
231    ) -> Self {
232        self.shared_approve_mode = Some(shared);
233        self
234    }
235
236    /// Set proxy tool response channel
237    pub fn with_proxy_response_tx(
238        mut self,
239        tx: tokio::sync::mpsc::Sender<ProxyToolResponse>,
240    ) -> Self {
241        self.proxy_response_tx = Some(tx);
242        self
243    }
244
245    pub fn with_config(
246        mut self,
247        model: &str,
248        _think: bool,
249        _max_tokens: u32,
250        context_size: Option<u64>,
251    ) -> Self {
252        self.model = model.to_string();
253        self.context_size = context_size.unwrap_or_else(|| {
254            let m = model.to_ascii_lowercase();
255            if m.contains("1m") || m.contains("opus-4-7") {
256                1_000_000
257            } else if m.contains("claude-3")
258                || m.contains("claude-4")
259                || m.contains("claude-sonnet")
260            {
261                200_000
262            } else {
263                128_000
264            }
265        });
266        self
267    }
268
269    /// Set debug mode from environment or config
270    pub fn with_debug_mode(mut self, debug_mode: bool) -> Self {
271        self.debug_mode = debug_mode;
272        self
273    }
274
275    /// Toggle debug panel visibility
276    pub fn toggle_debug_panel(&mut self) {
277        self.show_debug_panel = !self.show_debug_panel;
278        self.dirty.set(true);
279    }
280
281    /// Add a debug log entry
282    pub fn add_debug_log(&mut self, log: String) {
283        // Keep only last 100 logs to avoid memory issues
284        if self.debug_logs.len() >= 100 {
285            self.debug_logs.remove(0);
286        }
287        self.debug_logs.push(log);
288        // Auto-scroll to bottom when new log added
289        self.debug_scroll_offset = self.debug_logs.len().saturating_sub(1) as u16;
290        self.dirty.set(true);
291    }
292
293    /// Clear debug logs
294    pub fn clear_debug_logs(&mut self) {
295        self.debug_logs.clear();
296        self.debug_scroll_offset = 0;
297        self.dirty.set(true);
298    }
299
300    pub fn load_messages(&mut self, core_messages: Vec<matrixcode_core::Message>) {
301        // Build mapping from tool_use_id to tool name
302        let mut tool_names: HashMap<String, String> = HashMap::new();
303
304        // First pass: collect tool names from ToolUse blocks
305        for msg in &core_messages {
306            if let matrixcode_core::MessageContent::Blocks(blocks) = &msg.content {
307                for b in blocks {
308                    if let matrixcode_core::ContentBlock::ToolUse { id, name, .. } = b {
309                        tool_names.insert(id.clone(), name.clone());
310                    }
311                }
312            }
313        }
314
315        // Second pass: process messages
316        for msg in core_messages {
317            // Handle different content block types separately
318            match &msg.content {
319                matrixcode_core::MessageContent::Text(t) => {
320                    if t.is_empty() {
321                        continue;
322                    }
323                    let role = match msg.role {
324                        matrixcode_core::Role::User => Role::User,
325                        matrixcode_core::Role::Assistant => Role::Assistant,
326                        matrixcode_core::Role::System => Role::System,
327                        matrixcode_core::Role::Tool => Role::Tool {
328                            name: "tool".into(),
329                            detail: None,
330                            is_error: false,
331                            is_pending: false,
332                        },
333                    };
334                    // Restore input history from user messages
335                    if role == Role::User
336                        && !t.starts_with('/')
337                        && self.input_history.last().map(|s| s.as_str()) != Some(t)
338                    {
339                        self.input_history.push(t.clone());
340                    }
341                    self.messages.push(Message {
342                        role,
343                        content: t.clone(),
344                        is_pending: false,
345                    });
346                }
347                matrixcode_core::MessageContent::Blocks(blocks) => {
348                    // Process each block separately to maintain proper message types
349                    for b in blocks {
350                        match b {
351                            matrixcode_core::ContentBlock::Text { text } => {
352                                if text.is_empty() {
353                                    continue;
354                                }
355                                let role = match msg.role {
356                                    matrixcode_core::Role::User => Role::User,
357                                    matrixcode_core::Role::Assistant => Role::Assistant,
358                                    matrixcode_core::Role::System => Role::System,
359                                    matrixcode_core::Role::Tool => Role::Tool {
360                                        name: "tool".into(),
361                                        detail: None,
362                                        is_error: false,
363                                        is_pending: false,
364                                    },
365                                };
366                                // Restore input history from user messages
367                                if role == Role::User
368                                    && !text.starts_with('/')
369                                    && self.input_history.last().map(|s| s.as_str()) != Some(text)
370                                {
371                                    self.input_history.push(text.clone());
372                                }
373                                self.messages.push(Message {
374                                    role,
375                                    content: text.clone(),
376                                    is_pending: false,
377                                });
378                            }
379                            matrixcode_core::ContentBlock::Thinking { thinking, .. } => {
380                                if thinking.is_empty() {
381                                    continue;
382                                }
383                                // Create separate Thinking message for proper rendering
384                                self.messages.push(Message {
385                                    role: Role::Thinking,
386                                    content: thinking.clone(),
387                                    is_pending: false,
388                                });
389                            }
390                            matrixcode_core::ContentBlock::ToolUse { name: _, .. } => {
391                                // Skip tool_use blocks - metadata only (already collected in first pass)
392                            }
393                            matrixcode_core::ContentBlock::ToolResult {
394                                content,
395                                tool_use_id,
396                                ..
397                            } => {
398                                if content.is_empty() {
399                                    continue;
400                                }
401                                // Try to determine if this is an error from content
402                                let is_error = content.contains("error")
403                                    || content.contains("failed")
404                                    || content.contains("Error");
405                                // Use tool name from mapping, or fallback to tool_use_id
406                                let name =
407                                    tool_names.get(tool_use_id).cloned().unwrap_or_else(|| {
408                                        // Fallback: try to guess from tool_use_id prefix
409                                        if tool_use_id.starts_with("bash") {
410                                            "bash".into()
411                                        } else if tool_use_id.starts_with("read") {
412                                            "read".into()
413                                        } else if tool_use_id.starts_with("write") {
414                                            "write".into()
415                                        } else if tool_use_id.starts_with("edit") {
416                                            "edit".into()
417                                        } else {
418                                            "tool".into()
419                                        }
420                                    });
421                                self.messages.push(Message {
422                                    role: Role::Tool {
423                                        name,
424                                        detail: None,
425                                        is_error,
426                                        is_pending: false,
427                                    },
428                                    content: content.clone(),
429                                    is_pending: false,
430                                });
431                            }
432                            _ => {}
433                        }
434                    }
435                }
436            }
437        }
438        if !self.messages.is_empty() {
439            self.show_welcome = false;
440        }
441    }
442
443    /// Set token stats from restored session metadata.
444    pub fn set_token_stats(
445        &mut self,
446        input_tokens: u64,
447        total_output_tokens: u64,
448        _message_count: usize,
449    ) {
450        self.tokens_in = input_tokens;
451        self.session_total_out = total_output_tokens;
452    }
453
454    pub fn run(&mut self, term: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
455        loop {
456            // Animation frame - only animate when NOT idle (avoid CPU waste when idle)
457            let anim_update = self.last_anim.elapsed().as_millis() >= ANIM_MS as u128;
458            if anim_update && self.activity != Activity::Idle {
459                self.frame = (self.frame + 1) % 10;
460                self.last_anim = Instant::now();
461                self.dirty.set(true);
462                // Advance workflow spinner frame
463                self.workflow_state.advance_spinner();
464            }
465
466            // Workflow state refresh - every 500ms when panel is visible
467            const WORKFLOW_REFRESH_MS: u64 = 500;
468            if self.workflow_state.visible
469                && self.last_workflow_refresh.elapsed().as_millis() >= WORKFLOW_REFRESH_MS as u128
470            {
471                self.refresh_workflow_state();
472                self.last_workflow_refresh = Instant::now();
473                self.dirty.set(true);
474            }
475
476            // Handle events - mark dirty on any user input
477            if event::poll(Duration::from_millis(ANIM_MS))? {
478                match event::read()? {
479                    Event::Key(k) => {
480                        self.on_key(k);
481                        self.dirty.set(true);
482                    }
483                    Event::Mouse(m) => {
484                        self.on_mouse(m);
485                        self.dirty.set(true);
486                    }
487                    Event::Paste(text) => {
488                        self.on_paste(&text);
489                        self.dirty.set(true);
490                    }
491                    _ => {}
492                }
493            }
494
495            // Process agent events - mark dirty on any event
496            let mut had_event = false;
497            while let Ok(e) = self.rx.try_recv() {
498                log::debug!("TUI received event: type={:?}", e.event_type);
499                self.on_event(e);
500                had_event = true;
501            }
502            if had_event {
503                log::debug!("TUI: had events, marking dirty");
504                self.dirty.set(true);
505            }
506
507            // Only render if dirty (something changed)
508            if self.dirty.get() {
509                term.draw(|f| self.draw(f))?;
510                self.dirty.set(false);
511            }
512
513            if self.exit {
514                break;
515            }
516        }
517        Ok(())
518    }
519    fn on_mouse(&mut self, m: MouseEvent) {
520        // If Shift is held, let terminal handle mouse for text selection
521        if m.modifiers.contains(event::KeyModifiers::SHIFT) {
522            return;
523        }
524
525        match m.kind {
526            MouseEventKind::ScrollUp => {
527                if self.auto_scroll {
528                    self.auto_scroll = false;
529                    self.scroll_offset = self.max_scroll.get().max(50);
530                }
531                self.scroll_offset = self.scroll_offset.saturating_sub(3);
532            }
533            MouseEventKind::ScrollDown => {
534                if !self.auto_scroll {
535                    self.scroll_offset = self.scroll_offset.saturating_add(3);
536                    let max = self.max_scroll.get();
537                    if max > 0 && self.scroll_offset >= max {
538                        self.auto_scroll = true;
539                        self.scroll_offset = 0;
540                    }
541                }
542            }
543            _ => {}
544        }
545    }
546
547    /// Refresh workflow state from persistence files
548    fn refresh_workflow_state(&mut self) {
549        if !self.workflow_state.visible {
550            return;
551        }
552
553        // Get current directory as project path
554        let project_dir = std::env::current_dir().ok();
555
556        // Reload workflow context from persistence
557        if self.workflow_state.context.is_some() {
558            // Reload existing workflow instance
559            let instances =
560                crate::workflow::WorkflowViewState::load_recent_instances(project_dir.as_ref());
561            if let Some(ctx) = instances.first() {
562                // Only update if status changed or execution_path grew
563                let old_ctx = self.workflow_state.context.as_ref();
564                let should_update = old_ctx
565                    .map(|old| {
566                        old.status != ctx.status
567                            || old.execution_path.len() != ctx.execution_path.len()
568                            || old.updated_at != ctx.updated_at
569                    })
570                    .unwrap_or(true);
571
572                if should_update {
573                    self.workflow_state.update_context(ctx.clone());
574                    // Also reload workflow def if workflow_id changed
575                    if (self.workflow_state.workflow_def.is_none()
576                        || self.workflow_state.workflow_def.as_ref().map(|d| &d.id)
577                            != Some(&ctx.workflow_id))
578                        && let Some(def) = crate::workflow::WorkflowViewState::load_workflow_def(
579                            project_dir.as_ref(),
580                            &ctx.workflow_id,
581                        )
582                    {
583                        self.workflow_state.set_workflow(def);
584                    }
585                }
586            }
587        } else if self.workflow_state.workflow_def.is_none() {
588            // No workflow loaded yet - try to load most recent
589            self.workflow_state.load_most_recent(project_dir.as_ref());
590        }
591    }
592}