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};
13use matrixcode_core::tools::ProxyToolResponse;
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 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 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, pub(crate) cache_read: u64,
34 pub(crate) cache_created: u64,
35 pub(crate) context_size: u64,
36 pub(crate) api_calls: u64,
38 pub(crate) compressions: u64,
39 pub(crate) memory_saves: u64,
40 pub(crate) tool_calls: u64,
41 pub(crate) request_start: Option<Instant>,
43 pub(crate) tool_start: Option<Instant>, pub(crate) frame: usize,
46 pub(crate) last_anim: Instant,
47 pub(crate) show_welcome: bool,
48 pub(crate) exit: bool,
49 pub(crate) cursor_pos: usize,
51 pub(crate) input_history: Vec<String>,
53 pub(crate) history_index: Option<usize>, pub(crate) history_draft: String, 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>, pub(crate) thinking_collapsed: bool,
62 pub(crate) dirty: std::cell::Cell<bool>,
64 pub(crate) approve_mode: ApproveMode,
66 pub(crate) shared_approve_mode: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
68 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, pub(crate) ask_submit_mode: SubmitMode, pub(crate) ask_other_input_active: bool, pub(crate) ask_questions: Vec<AskQuestion>, pub(crate) current_question_idx: usize, pub(crate) todo_items: Vec<TodoItem>,
81 pub(crate) tx: tokio::sync::mpsc::Sender<String>,
83 pub(crate) rx: tokio::sync::mpsc::Receiver<AgentEvent>,
84 pub(crate) cancel: CancellationToken,
85 pub(crate) proxy_response_tx: Option<tokio::sync::mpsc::Sender<ProxyToolResponse>>,
87 pub(crate) pending_messages: Vec<String>,
89 pub(crate) pending_input_tx: Option<tokio::sync::mpsc::Sender<String>>,
91 pub(crate) loop_task: Option<LoopTask>,
93 pub(crate) cron_tasks: Vec<CronTask>,
95 pub(crate) debug_mode: bool,
97 pub(crate) show_debug_panel: bool,
99 pub(crate) debug_logs: Vec<String>,
100 pub(crate) debug_scroll_offset: u16,
101 pub(crate) multiline_confirm_send: bool,
103 pub(crate) workflow_state: crate::workflow::WorkflowViewState,
105 pub(crate) last_workflow_refresh: Instant,
107 pub(crate) mcp_servers: Vec<matrixcode_core::event::McpServerInfo>,
109 pub(crate) lsp_servers: Vec<matrixcode_core::LspServerInfo>,
111}
112
113#[derive(Clone)]
115#[allow(dead_code)] pub struct TodoItem {
117 pub content: String,
118 pub status: String, }
120
121#[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#[derive(Clone)]
133pub struct CronTask {
134 pub id: usize,
135 pub message: String,
136 pub minute_interval: u64, #[allow(dead_code)]
138 pub next_run: Instant, 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, dirty: std::cell::Cell::new(true), 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 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 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 pub fn with_proxy_response_tx(mut self, tx: tokio::sync::mpsc::Sender<ProxyToolResponse>) -> Self {
238 self.proxy_response_tx = Some(tx);
239 self
240 }
241
242 pub fn with_config(
243 mut self,
244 model: &str,
245 _think: bool,
246 _max_tokens: u32,
247 context_size: Option<u64>,
248 ) -> Self {
249 self.model = model.to_string();
250 self.context_size = context_size.unwrap_or_else(|| {
251 let m = model.to_ascii_lowercase();
252 if m.contains("1m") || m.contains("opus-4-7") {
253 1_000_000
254 } else if m.contains("claude-3")
255 || m.contains("claude-4")
256 || m.contains("claude-sonnet")
257 {
258 200_000
259 } else {
260 128_000
261 }
262 });
263 self
264 }
265
266 pub fn with_debug_mode(mut self, debug_mode: bool) -> Self {
268 self.debug_mode = debug_mode;
269 self
270 }
271
272 pub fn toggle_debug_panel(&mut self) {
274 self.show_debug_panel = !self.show_debug_panel;
275 self.dirty.set(true);
276 }
277
278 pub fn add_debug_log(&mut self, log: String) {
280 if self.debug_logs.len() >= 100 {
282 self.debug_logs.remove(0);
283 }
284 self.debug_logs.push(log);
285 self.debug_scroll_offset = self.debug_logs.len().saturating_sub(1) as u16;
287 self.dirty.set(true);
288 }
289
290 pub fn clear_debug_logs(&mut self) {
292 self.debug_logs.clear();
293 self.debug_scroll_offset = 0;
294 self.dirty.set(true);
295 }
296
297 pub fn load_messages(&mut self, core_messages: Vec<matrixcode_core::Message>) {
298 let mut tool_names: HashMap<String, String> = HashMap::new();
300
301 for msg in &core_messages {
303 if let matrixcode_core::MessageContent::Blocks(blocks) = &msg.content {
304 for b in blocks {
305 if let matrixcode_core::ContentBlock::ToolUse { id, name, .. } = b {
306 tool_names.insert(id.clone(), name.clone());
307 }
308 }
309 }
310 }
311
312 for msg in core_messages {
314 match &msg.content {
316 matrixcode_core::MessageContent::Text(t) => {
317 if t.is_empty() {
318 continue;
319 }
320 let role = match msg.role {
321 matrixcode_core::Role::User => Role::User,
322 matrixcode_core::Role::Assistant => Role::Assistant,
323 matrixcode_core::Role::System => Role::System,
324 matrixcode_core::Role::Tool => Role::Tool {
325 name: "tool".into(),
326 detail: None,
327 is_error: false,
328 is_pending: false,
329 },
330 };
331 if role == Role::User
333 && !t.starts_with('/')
334 && self.input_history.last().map(|s| s.as_str()) != Some(t)
335 {
336 self.input_history.push(t.clone());
337 }
338 self.messages.push(Message {
339 role,
340 content: t.clone(),
341 is_pending: false,
342 });
343 }
344 matrixcode_core::MessageContent::Blocks(blocks) => {
345 for b in blocks {
347 match b {
348 matrixcode_core::ContentBlock::Text { text } => {
349 if text.is_empty() {
350 continue;
351 }
352 let role = match msg.role {
353 matrixcode_core::Role::User => Role::User,
354 matrixcode_core::Role::Assistant => Role::Assistant,
355 matrixcode_core::Role::System => Role::System,
356 matrixcode_core::Role::Tool => Role::Tool {
357 name: "tool".into(),
358 detail: None,
359 is_error: false,
360 is_pending: false,
361 },
362 };
363 if role == Role::User
365 && !text.starts_with('/')
366 && self.input_history.last().map(|s| s.as_str()) != Some(text)
367 {
368 self.input_history.push(text.clone());
369 }
370 self.messages.push(Message {
371 role,
372 content: text.clone(),
373 is_pending: false,
374 });
375 }
376 matrixcode_core::ContentBlock::Thinking { thinking, .. } => {
377 if thinking.is_empty() {
378 continue;
379 }
380 self.messages.push(Message {
382 role: Role::Thinking,
383 content: thinking.clone(),
384 is_pending: false,
385 });
386 }
387 matrixcode_core::ContentBlock::ToolUse { name: _, .. } => {
388 }
390 matrixcode_core::ContentBlock::ToolResult {
391 content,
392 tool_use_id,
393 ..
394 } => {
395 if content.is_empty() {
396 continue;
397 }
398 let is_error = content.contains("error")
400 || content.contains("failed")
401 || content.contains("Error");
402 let name =
404 tool_names.get(tool_use_id).cloned().unwrap_or_else(|| {
405 if tool_use_id.starts_with("bash") {
407 "bash".into()
408 } else if tool_use_id.starts_with("read") {
409 "read".into()
410 } else if tool_use_id.starts_with("write") {
411 "write".into()
412 } else if tool_use_id.starts_with("edit") {
413 "edit".into()
414 } else {
415 "tool".into()
416 }
417 });
418 self.messages.push(Message {
419 role: Role::Tool {
420 name,
421 detail: None,
422 is_error,
423 is_pending: false,
424 },
425 content: content.clone(),
426 is_pending: false,
427 });
428 }
429 _ => {}
430 }
431 }
432 }
433 }
434 }
435 if !self.messages.is_empty() {
436 self.show_welcome = false;
437 }
438 }
439
440 pub fn set_token_stats(&mut self, input_tokens: u64, total_output_tokens: u64, _message_count: usize) {
442 self.tokens_in = input_tokens;
443 self.session_total_out = total_output_tokens;
444 }
445
446 pub fn run(&mut self, term: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
447 loop {
448 let anim_update = self.last_anim.elapsed().as_millis() >= ANIM_MS as u128;
450 if anim_update && self.activity != Activity::Idle {
451 self.frame = (self.frame + 1) % 10;
452 self.last_anim = Instant::now();
453 self.dirty.set(true);
454 self.workflow_state.advance_spinner();
456 }
457
458 const WORKFLOW_REFRESH_MS: u64 = 500;
460 if self.workflow_state.visible
461 && self.last_workflow_refresh.elapsed().as_millis() >= WORKFLOW_REFRESH_MS as u128
462 {
463 self.refresh_workflow_state();
464 self.last_workflow_refresh = Instant::now();
465 self.dirty.set(true);
466 }
467
468 if event::poll(Duration::from_millis(ANIM_MS))? {
470 match event::read()? {
471 Event::Key(k) => {
472 self.on_key(k);
473 self.dirty.set(true);
474 }
475 Event::Mouse(m) => {
476 self.on_mouse(m);
477 self.dirty.set(true);
478 }
479 Event::Paste(text) => {
480 self.on_paste(&text);
481 self.dirty.set(true);
482 }
483 _ => {}
484 }
485 }
486
487 let mut had_event = false;
489 while let Ok(e) = self.rx.try_recv() {
490 log::debug!("TUI received event: type={:?}", e.event_type);
491 self.on_event(e);
492 had_event = true;
493 }
494 if had_event {
495 log::debug!("TUI: had events, marking dirty");
496 self.dirty.set(true);
497 }
498
499 if self.dirty.get() {
501 term.draw(|f| self.draw(f))?;
502 self.dirty.set(false);
503 }
504
505 if self.exit {
506 break;
507 }
508 }
509 Ok(())
510 }
511 fn on_mouse(&mut self, m: MouseEvent) {
512 if m.modifiers.contains(event::KeyModifiers::SHIFT) {
514 return;
515 }
516
517 match m.kind {
518 MouseEventKind::ScrollUp => {
519 if self.auto_scroll {
520 self.auto_scroll = false;
521 self.scroll_offset = self.max_scroll.get().max(50);
522 }
523 self.scroll_offset = self.scroll_offset.saturating_sub(3);
524 }
525 MouseEventKind::ScrollDown => {
526 if !self.auto_scroll {
527 self.scroll_offset = self.scroll_offset.saturating_add(3);
528 let max = self.max_scroll.get();
529 if max > 0 && self.scroll_offset >= max {
530 self.auto_scroll = true;
531 self.scroll_offset = 0;
532 }
533 }
534 }
535 _ => {}
536 }
537 }
538
539 fn refresh_workflow_state(&mut self) {
541 if !self.workflow_state.visible {
542 return;
543 }
544
545 let project_dir = std::env::current_dir().ok();
547
548 if self.workflow_state.context.is_some() {
550 let instances = crate::workflow::WorkflowViewState::load_recent_instances(project_dir.as_ref());
552 if let Some(ctx) = instances.first() {
553 let old_ctx = self.workflow_state.context.as_ref();
555 let should_update = old_ctx.map(|old| {
556 old.status != ctx.status ||
557 old.execution_path.len() != ctx.execution_path.len() ||
558 old.updated_at != ctx.updated_at
559 }).unwrap_or(true);
560
561 if should_update {
562 self.workflow_state.update_context(ctx.clone());
563 if (self.workflow_state.workflow_def.is_none() ||
565 self.workflow_state.workflow_def.as_ref().map(|d| &d.id) !=
566 Some(&ctx.workflow_id))
567 && let Some(def) = crate::workflow::WorkflowViewState::load_workflow_def(
568 project_dir.as_ref(),
569 &ctx.workflow_id
570 ) {
571 self.workflow_state.set_workflow(def);
572 }
573 }
574 }
575 } else if self.workflow_state.workflow_def.is_none() {
576 self.workflow_state.load_most_recent(project_dir.as_ref());
578 }
579 }
580}