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