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 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(
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 pub fn with_debug_mode(mut self, debug_mode: bool) -> Self {
271 self.debug_mode = debug_mode;
272 self
273 }
274
275 pub fn toggle_debug_panel(&mut self) {
277 self.show_debug_panel = !self.show_debug_panel;
278 self.dirty.set(true);
279 }
280
281 pub fn add_debug_log(&mut self, log: String) {
283 if self.debug_logs.len() >= 100 {
285 self.debug_logs.remove(0);
286 }
287 self.debug_logs.push(log);
288 self.debug_scroll_offset = self.debug_logs.len().saturating_sub(1) as u16;
290 self.dirty.set(true);
291 }
292
293 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 let mut tool_names: HashMap<String, String> = HashMap::new();
303
304 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 for msg in core_messages {
317 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 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 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 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 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 }
393 matrixcode_core::ContentBlock::ToolResult {
394 content,
395 tool_use_id,
396 ..
397 } => {
398 if content.is_empty() {
399 continue;
400 }
401 let is_error = content.contains("error")
403 || content.contains("failed")
404 || content.contains("Error");
405 let name =
407 tool_names.get(tool_use_id).cloned().unwrap_or_else(|| {
408 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 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 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 self.workflow_state.advance_spinner();
464 }
465
466 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 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 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 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 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 fn refresh_workflow_state(&mut self) {
549 if !self.workflow_state.visible {
550 return;
551 }
552
553 let project_dir = std::env::current_dir().ok();
555
556 if self.workflow_state.context.is_some() {
558 let instances =
560 crate::workflow::WorkflowViewState::load_recent_instances(project_dir.as_ref());
561 if let Some(ctx) = instances.first() {
562 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 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 self.workflow_state.load_most_recent(project_dir.as_ref());
590 }
591 }
592}