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) input_collapsed: bool,
64 pub(crate) dirty: std::cell::Cell<bool>,
66 pub(crate) approve_mode: ApproveMode,
68 pub(crate) shared_approve_mode: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
70 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, 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>,
83 pub(crate) tx: tokio::sync::mpsc::Sender<String>,
85 pub(crate) rx: tokio::sync::mpsc::Receiver<AgentEvent>,
86 pub(crate) cancel: CancellationToken,
87 pub(crate) proxy_response_tx: Option<tokio::sync::mpsc::Sender<ProxyToolResponse>>,
89 pub(crate) pending_messages: Vec<String>,
91 pub(crate) pending_input_tx: Option<tokio::sync::mpsc::Sender<String>>,
93 pub(crate) loop_task: Option<LoopTask>,
95 pub(crate) cron_tasks: Vec<CronTask>,
97 pub(crate) debug_mode: bool,
99 pub(crate) show_debug_panel: bool,
101 pub(crate) debug_logs: Vec<String>,
102 pub(crate) debug_scroll_offset: u16,
103 pub(crate) multiline_confirm_send: bool,
105 pub(crate) workflow_state: crate::workflow::WorkflowViewState,
107 pub(crate) last_workflow_refresh: Instant,
109 pub(crate) mcp_servers: Vec<matrixcode_core::event::McpServerInfo>,
111 pub(crate) lsp_servers: Vec<matrixcode_core::LspServerInfo>,
113 pub(crate) session_list: Vec<SessionInfo>,
115 pub(crate) session_selected_index: usize,
116 pub(crate) waiting_for_session: bool, }
118
119#[derive(Clone, Debug)]
121pub struct SessionInfo {
122 pub short_id: String,
123 pub title: String, pub message_count: usize,
125 pub created_at: String,
126}
127
128#[derive(Clone)]
130#[allow(dead_code)] pub struct TodoItem {
132 pub content: String,
133 pub status: String, }
135
136#[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#[derive(Clone)]
148pub struct CronTask {
149 pub id: usize,
150 pub message: String,
151 pub minute_interval: u64, #[allow(dead_code)]
153 pub next_run: Instant, 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, input_collapsed: true, dirty: std::cell::Cell::new(true), 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 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 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 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 pub fn with_debug_mode(mut self, debug_mode: bool) -> Self {
290 self.debug_mode = debug_mode;
291 self
292 }
293
294 pub fn toggle_debug_panel(&mut self) {
296 self.show_debug_panel = !self.show_debug_panel;
297 self.dirty.set(true);
298 }
299
300 pub fn add_debug_log(&mut self, log: String) {
302 if self.debug_logs.len() >= 100 {
304 self.debug_logs.remove(0);
305 }
306 self.debug_logs.push(log);
307 self.debug_scroll_offset = self.debug_logs.len().saturating_sub(1) as u16;
309 self.dirty.set(true);
310 }
311
312 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 let mut tool_names: HashMap<String, String> = HashMap::new();
322
323 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 for msg in core_messages {
336 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 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 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 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 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 }
412 matrixcode_core::ContentBlock::ToolResult {
413 content,
414 tool_use_id,
415 ..
416 } => {
417 if content.is_empty() {
418 continue;
419 }
420 let is_error = content.contains("error")
422 || content.contains("failed")
423 || content.contains("Error");
424 let name =
426 tool_names.get(tool_use_id).cloned().unwrap_or_else(|| {
427 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 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 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 self.workflow_state.advance_spinner();
483 }
484
485 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 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 let mut pasted = text;
509 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 break;
516 }
517 }
518 self.on_paste(&pasted);
519 self.dirty.set(true);
520 }
521 _ => {}
522 }
523 }
524
525 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 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 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 fn refresh_workflow_state(&mut self) {
579 if !self.workflow_state.visible {
580 return;
581 }
582
583 let project_dir = std::env::current_dir().ok();
585
586 if self.workflow_state.context.is_some() {
588 let instances =
590 crate::workflow::WorkflowViewState::load_recent_instances(project_dir.as_ref());
591 if let Some(ctx) = instances.first() {
592 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 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 self.workflow_state.load_most_recent(project_dir.as_ref());
620 }
621 }
622}