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};
13
14use crate::ANIM_MS;
15use crate::types::{Activity, ApproveMode, AskQuestion, Message, Role, SubmitMode};
16
17pub struct TuiApp {
18 pub(crate) activity: Activity,
19 pub(crate) activity_detail: String,
20 pub(crate) activity_input: Option<serde_json::Value>,
22 pub(crate) messages: Vec<Message>,
23 pub(crate) thinking: String,
24 pub(crate) streaming: String,
25 pub(crate) input: String,
26 pub(crate) model: String,
27 pub(crate) tokens_in: u64,
29 pub(crate) tokens_out: u64,
30 pub(crate) session_total_out: u64,
31 pub(crate) current_request_tokens: u64, pub(crate) cache_read: u64,
33 pub(crate) cache_created: u64,
34 pub(crate) context_size: u64,
35 pub(crate) api_calls: u64,
37 pub(crate) compressions: u64,
38 pub(crate) memory_saves: u64,
39 pub(crate) tool_calls: u64,
40 pub(crate) request_start: Option<Instant>,
42 pub(crate) tool_start: Option<Instant>, pub(crate) frame: usize,
45 pub(crate) last_anim: Instant,
46 pub(crate) show_welcome: bool,
47 pub(crate) exit: bool,
48 pub(crate) cursor_pos: usize,
50 pub(crate) input_history: Vec<String>,
52 pub(crate) history_index: Option<usize>, pub(crate) history_draft: String, pub(crate) scroll_offset: u16,
56 pub(crate) auto_scroll: bool,
57 pub(crate) max_scroll: std::cell::Cell<u16>,
58 pub(crate) new_message_while_scrolled: std::cell::Cell<bool>, pub(crate) thinking_collapsed: bool,
61 pub(crate) dirty: std::cell::Cell<bool>,
63 pub(crate) approve_mode: ApproveMode,
65 pub(crate) shared_approve_mode: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
67 pub(crate) ask_tx: Option<tokio::sync::mpsc::Sender<String>>,
69 pub(crate) waiting_for_ask: bool,
70 pub(crate) ask_options: Vec<crate::types::AskOption>,
71 pub(crate) ask_selected_index: usize,
72 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>,
80 pub(crate) tx: tokio::sync::mpsc::Sender<String>,
82 pub(crate) rx: tokio::sync::mpsc::Receiver<AgentEvent>,
83 pub(crate) cancel: CancellationToken,
84 pub(crate) pending_messages: Vec<String>,
86 pub(crate) loop_task: Option<LoopTask>,
88 pub(crate) cron_tasks: Vec<CronTask>,
90 pub(crate) debug_mode: bool,
92 pub(crate) show_debug_panel: bool,
94 pub(crate) debug_logs: Vec<String>,
95 pub(crate) debug_scroll_offset: u16,
96}
97
98#[derive(Clone)]
100#[allow(dead_code)] pub struct TodoItem {
102 pub content: String,
103 pub status: String, }
105
106#[derive(Clone)]
108pub struct LoopTask {
109 pub message: String,
110 pub interval_secs: u64,
111 pub count: u64,
112 pub max_count: Option<u64>,
113 pub cancel_token: CancellationToken,
114}
115
116#[derive(Clone)]
118pub struct CronTask {
119 pub id: usize,
120 pub message: String,
121 pub minute_interval: u64, #[allow(dead_code)]
123 pub next_run: Instant, pub cancel_token: CancellationToken,
125}
126
127impl TuiApp {
128 pub fn new(
129 tx: tokio::sync::mpsc::Sender<String>,
130 rx: tokio::sync::mpsc::Receiver<AgentEvent>,
131 cancel: CancellationToken,
132 ) -> Self {
133 Self {
134 activity: Activity::Idle,
135 activity_detail: String::new(),
136 activity_input: None,
137 messages: Vec::new(),
138 thinking: String::new(),
139 streaming: String::new(),
140 input: String::new(),
141 model: "claude-sonnet-4".into(),
142 tokens_in: 0,
143 tokens_out: 0,
144 session_total_out: 0,
145 current_request_tokens: 0,
146 cache_read: 0,
147 cache_created: 0,
148 context_size: 200_000,
149 api_calls: 0,
150 compressions: 0,
151 memory_saves: 0,
152 tool_calls: 0,
153 request_start: None,
154 tool_start: None,
155 frame: 0,
156 last_anim: Instant::now(),
157 show_welcome: true,
158 exit: false,
159 cursor_pos: 0,
160 input_history: Vec::new(),
161 history_index: None,
162 history_draft: String::new(),
163 scroll_offset: 0,
164 auto_scroll: true,
165 max_scroll: std::cell::Cell::new(0),
166 new_message_while_scrolled: std::cell::Cell::new(false),
167 thinking_collapsed: false, dirty: std::cell::Cell::new(true), approve_mode: ApproveMode::Ask,
170 shared_approve_mode: None,
171 ask_tx: None,
172 waiting_for_ask: false,
173 ask_options: Vec::new(),
174 ask_selected_index: 0,
175 ask_multi_select: false,
176 ask_submit_mode: SubmitMode::default(),
177 ask_other_input_active: false,
178 ask_questions: Vec::new(),
179 current_question_idx: 0,
180 todo_items: Vec::new(),
181 tx,
182 rx,
183 cancel,
184 pending_messages: Vec::new(),
185 loop_task: None,
186 cron_tasks: Vec::new(),
187 debug_mode: false,
188 show_debug_panel: false,
189 debug_logs: Vec::new(),
190 debug_scroll_offset: 0,
191 }
192 }
193
194 pub fn with_ask_channel(mut self, ask_tx: tokio::sync::mpsc::Sender<String>) -> Self {
195 self.ask_tx = Some(ask_tx);
196 self
197 }
198
199 pub fn with_shared_approve_mode(
201 mut self,
202 shared: std::sync::Arc<std::sync::atomic::AtomicU8>,
203 ) -> Self {
204 self.shared_approve_mode = Some(shared);
205 self
206 }
207
208 pub fn with_config(
209 mut self,
210 model: &str,
211 _think: bool,
212 _max_tokens: u32,
213 context_size: Option<u64>,
214 ) -> Self {
215 self.model = model.to_string();
216 self.context_size = context_size.unwrap_or_else(|| {
217 let m = model.to_ascii_lowercase();
218 if m.contains("1m") || m.contains("opus-4-7") {
219 1_000_000
220 } else if m.contains("claude-3")
221 || m.contains("claude-4")
222 || m.contains("claude-sonnet")
223 {
224 200_000
225 } else {
226 128_000
227 }
228 });
229 self
230 }
231
232 pub fn with_debug_mode(mut self, debug_mode: bool) -> Self {
234 self.debug_mode = debug_mode;
235 self
236 }
237
238 pub fn toggle_debug_panel(&mut self) {
240 self.show_debug_panel = !self.show_debug_panel;
241 self.dirty.set(true);
242 }
243
244 pub fn add_debug_log(&mut self, log: String) {
246 if self.debug_logs.len() >= 100 {
248 self.debug_logs.remove(0);
249 }
250 self.debug_logs.push(log);
251 self.dirty.set(true);
252 }
253
254 pub fn clear_debug_logs(&mut self) {
256 self.debug_logs.clear();
257 self.debug_scroll_offset = 0;
258 self.dirty.set(true);
259 }
260
261 pub fn load_messages(&mut self, core_messages: Vec<matrixcode_core::Message>) {
262 let mut tool_names: HashMap<String, String> = HashMap::new();
264
265 for msg in &core_messages {
267 if let matrixcode_core::MessageContent::Blocks(blocks) = &msg.content {
268 for b in blocks {
269 if let matrixcode_core::ContentBlock::ToolUse { id, name, .. } = b {
270 tool_names.insert(id.clone(), name.clone());
271 }
272 }
273 }
274 }
275
276 for msg in core_messages {
278 match &msg.content {
280 matrixcode_core::MessageContent::Text(t) => {
281 if t.is_empty() {
282 continue;
283 }
284 let role = match msg.role {
285 matrixcode_core::Role::User => Role::User,
286 matrixcode_core::Role::Assistant => Role::Assistant,
287 matrixcode_core::Role::System => Role::System,
288 matrixcode_core::Role::Tool => Role::Tool {
289 name: "tool".into(),
290 detail: None,
291 is_error: false,
292 },
293 };
294 if role == Role::User
296 && !t.starts_with('/')
297 && self.input_history.last().map(|s| s.as_str()) != Some(t)
298 {
299 self.input_history.push(t.clone());
300 }
301 self.messages.push(Message {
302 role,
303 content: t.clone(),
304 });
305 }
306 matrixcode_core::MessageContent::Blocks(blocks) => {
307 for b in blocks {
309 match b {
310 matrixcode_core::ContentBlock::Text { text } => {
311 if text.is_empty() {
312 continue;
313 }
314 let role = match msg.role {
315 matrixcode_core::Role::User => Role::User,
316 matrixcode_core::Role::Assistant => Role::Assistant,
317 matrixcode_core::Role::System => Role::System,
318 matrixcode_core::Role::Tool => Role::Tool {
319 name: "tool".into(),
320 detail: None,
321 is_error: false,
322 },
323 };
324 if role == Role::User
326 && !text.starts_with('/')
327 && self.input_history.last().map(|s| s.as_str()) != Some(text)
328 {
329 self.input_history.push(text.clone());
330 }
331 self.messages.push(Message {
332 role,
333 content: text.clone(),
334 });
335 }
336 matrixcode_core::ContentBlock::Thinking { thinking, .. } => {
337 if thinking.is_empty() {
338 continue;
339 }
340 self.messages.push(Message {
342 role: Role::Thinking,
343 content: thinking.clone(),
344 });
345 }
346 matrixcode_core::ContentBlock::ToolUse { name: _, .. } => {
347 }
349 matrixcode_core::ContentBlock::ToolResult {
350 content,
351 tool_use_id,
352 ..
353 } => {
354 if content.is_empty() {
355 continue;
356 }
357 let is_error = content.contains("error")
359 || content.contains("failed")
360 || content.contains("Error");
361 let name =
363 tool_names.get(tool_use_id).cloned().unwrap_or_else(|| {
364 if tool_use_id.starts_with("bash") {
366 "bash".into()
367 } else if tool_use_id.starts_with("read") {
368 "read".into()
369 } else if tool_use_id.starts_with("write") {
370 "write".into()
371 } else if tool_use_id.starts_with("edit") {
372 "edit".into()
373 } else {
374 "tool".into()
375 }
376 });
377 self.messages.push(Message {
378 role: Role::Tool {
379 name,
380 detail: None,
381 is_error,
382 },
383 content: content.clone(),
384 });
385 }
386 _ => {}
387 }
388 }
389 }
390 }
391 }
392 if !self.messages.is_empty() {
393 self.show_welcome = false;
394 }
395 }
396
397 pub fn set_token_stats(&mut self, input_tokens: u64, total_output_tokens: u64, _message_count: usize) {
399 self.tokens_in = input_tokens;
400 self.session_total_out = total_output_tokens;
401 }
402
403 pub fn run(&mut self, term: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
404 loop {
405 let anim_update = self.last_anim.elapsed().as_millis() >= ANIM_MS as u128;
408 if anim_update {
409 self.frame = (self.frame + 1) % 10;
410 self.last_anim = Instant::now();
411 self.dirty.set(true);
412 }
413
414 if event::poll(Duration::from_millis(ANIM_MS as u64))? {
416 match event::read()? {
417 Event::Key(k) => {
418 self.on_key(k);
419 self.dirty.set(true);
420 }
421 Event::Mouse(m) => {
422 self.on_mouse(m);
423 self.dirty.set(true);
424 }
425 Event::Paste(text) => {
426 self.on_paste(&text);
427 self.dirty.set(true);
428 }
429 _ => {}
430 }
431 }
432
433 let mut had_event = false;
435 while let Ok(e) = self.rx.try_recv() {
436 log::debug!("TUI received event: type={:?}", e.event_type);
437 self.on_event(e);
438 had_event = true;
439 }
440 if had_event {
441 log::debug!("TUI: had events, marking dirty");
442 self.dirty.set(true);
443 }
444
445 if self.dirty.get() {
447 term.draw(|f| self.draw(f))?;
448 self.dirty.set(false);
449 }
450
451 if self.exit {
452 break;
453 }
454 }
455 Ok(())
456 }
457 fn on_mouse(&mut self, m: MouseEvent) {
458 if m.modifiers.contains(event::KeyModifiers::SHIFT) {
460 return;
461 }
462
463 match m.kind {
464 MouseEventKind::ScrollUp => {
465 if self.auto_scroll {
466 self.auto_scroll = false;
467 self.scroll_offset = self.max_scroll.get().max(50);
468 }
469 self.scroll_offset = self.scroll_offset.saturating_sub(3);
470 }
471 MouseEventKind::ScrollDown => {
472 if !self.auto_scroll {
473 self.scroll_offset = self.scroll_offset.saturating_add(3);
474 let max = self.max_scroll.get();
475 if max > 0 && self.scroll_offset >= max {
476 self.auto_scroll = true;
477 self.scroll_offset = 0;
478 }
479 }
480 }
481 _ => {}
482 }
483 }
484}