1use std::io::Stdout;
2use std::time::{Duration, Instant};
3
4use anyhow::Result;
5use ratatui::{
6 backend::CrosstermBackend,
7 crossterm::event::{self, Event, MouseEvent, MouseEventKind},
8 Terminal,
9};
10
11use matrixcode_core::{AgentEvent, cancel::CancellationToken};
12use ratatui::crossterm::event::MouseButton;
13
14use crate::types::{Activity, ApproveMode, Role, Message};
15use crate::utils::extract_by_visual_col;
16use crate::ANIM_MS;
17
18pub struct TuiApp {
19 pub(crate) activity: Activity,
20 pub(crate) activity_detail: String,
21 pub(crate) messages: Vec<Message>,
22 pub(crate) thinking: String,
23 pub(crate) streaming: String,
24 pub(crate) input: String,
25 pub(crate) model: String,
26 pub(crate) tokens_in: u64,
28 pub(crate) tokens_out: u64,
29 pub(crate) session_total_out: u64,
30 pub(crate) current_request_tokens: u64, pub(crate) cache_read: u64,
32 pub(crate) cache_created: u64,
33 pub(crate) context_size: u64,
34 pub(crate) api_calls: u64,
36 pub(crate) compressions: u64,
37 pub(crate) memory_saves: u64,
38 pub(crate) tool_calls: u64,
39 pub(crate) request_start: Option<Instant>,
41 pub(crate) frame: usize,
43 pub(crate) last_anim: Instant,
44 pub(crate) show_welcome: bool,
45 pub(crate) exit: bool,
46 pub(crate) cursor_pos: usize,
48 pub(crate) input_history: Vec<String>,
50 pub(crate) history_index: Option<usize>, pub(crate) history_draft: String, pub(crate) scroll_offset: u16,
54 pub(crate) auto_scroll: bool,
55 pub(crate) max_scroll: std::cell::Cell<u16>,
56 pub(crate) thinking_collapsed: bool,
58 pub(crate) approve_mode: ApproveMode,
60 pub(crate) shared_approve_mode: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
62 pub(crate) ask_tx: Option<tokio::sync::mpsc::Sender<String>>,
64 pub(crate) waiting_for_ask: bool,
65 pub(crate) tx: tokio::sync::mpsc::Sender<String>,
67 pub(crate) rx: tokio::sync::mpsc::Receiver<AgentEvent>,
68 pub(crate) cancel: CancellationToken,
69 pub(crate) pending_messages: Vec<String>,
71 pub(crate) loop_task: Option<LoopTask>,
73 pub(crate) cron_tasks: Vec<CronTask>,
75 pub(crate) selection: Option<Selection>,
77 pub(crate) selecting: bool, pub(crate) msg_area_top: std::cell::Cell<u16>, pub(crate) debug_mode: bool,
81}
82
83#[derive(Clone, Copy, Debug)]
85pub struct Selection {
86 pub start_line: usize,
87 pub start_col: usize,
88 pub end_line: usize,
89 pub end_col: usize,
90}
91
92impl Selection {
93 pub fn new(start_line: usize, start_col: usize) -> Self {
94 Self {
95 start_line,
96 start_col,
97 end_line: start_line,
98 end_col: start_col,
99 }
100 }
101
102 pub fn extend_to(&mut self, line: usize, col: usize) {
103 self.end_line = line;
104 self.end_col = col;
105 }
106
107 #[allow(dead_code)]
108 pub fn is_empty(&self) -> bool {
109 self.start_line == self.end_line && self.start_col == self.end_col
110 }
111
112 pub fn normalized(&self) -> Self {
113 if self.start_line > self.end_line ||
115 (self.start_line == self.end_line && self.start_col > self.end_col) {
116 Self {
117 start_line: self.end_line,
118 start_col: self.end_col,
119 end_line: self.start_line,
120 end_col: self.start_col,
121 }
122 } else {
123 *self
124 }
125 }
126
127 #[allow(dead_code)]
128 pub fn contains(&self, line: usize, col: usize) -> bool {
129 let norm = self.normalized();
130 if line < norm.start_line || line > norm.end_line {
131 return false;
132 }
133 if line == norm.start_line && line == norm.end_line {
134 return col >= norm.start_col && col <= norm.end_col;
135 }
136 if line == norm.start_line {
137 return col >= norm.start_col;
138 }
139 if line == norm.end_line {
140 return col <= norm.end_col;
141 }
142 true }
144}
145
146#[derive(Clone)]
148pub struct LoopTask {
149 pub message: String,
150 pub interval_secs: u64,
151 pub count: u64,
152 pub max_count: Option<u64>,
153 pub cancel_token: CancellationToken,
154}
155
156#[derive(Clone)]
158pub struct CronTask {
159 pub id: usize,
160 pub message: String,
161 pub minute_interval: u64, #[allow(dead_code)]
163 pub next_run: Instant, pub cancel_token: CancellationToken,
165}
166
167impl TuiApp {
168 pub fn new(
169 tx: tokio::sync::mpsc::Sender<String>,
170 rx: tokio::sync::mpsc::Receiver<AgentEvent>,
171 cancel: CancellationToken,
172 ) -> Self {
173 Self {
174 activity: Activity::Idle,
175 activity_detail: String::new(),
176 messages: Vec::new(),
177 thinking: String::new(),
178 streaming: String::new(),
179 input: String::new(),
180 model: "claude-sonnet-4".into(),
181 tokens_in: 0,
182 tokens_out: 0,
183 session_total_out: 0,
184 current_request_tokens: 0,
185 cache_read: 0,
186 cache_created: 0,
187 context_size: 200_000,
188 api_calls: 0,
189 compressions: 0,
190 memory_saves: 0,
191 tool_calls: 0,
192 request_start: None,
193 frame: 0,
194 last_anim: Instant::now(),
195 show_welcome: true,
196 exit: false,
197 cursor_pos: 0,
198 input_history: Vec::new(),
199 history_index: None,
200 history_draft: String::new(),
201 scroll_offset: 0,
202 auto_scroll: true,
203 max_scroll: std::cell::Cell::new(0),
204 thinking_collapsed: false, approve_mode: ApproveMode::Ask,
206 shared_approve_mode: None,
207 ask_tx: None,
208 waiting_for_ask: false,
209 tx, rx, cancel,
210 pending_messages: Vec::new(),
211 loop_task: None,
212 cron_tasks: Vec::new(),
213 selection: None,
214 selecting: false,
215 msg_area_top: std::cell::Cell::new(0),
216 debug_mode: false,
217 }
218 }
219
220 pub fn with_ask_channel(mut self, ask_tx: tokio::sync::mpsc::Sender<String>) -> Self {
221 self.ask_tx = Some(ask_tx);
222 self
223 }
224
225 pub fn with_shared_approve_mode(mut self, shared: std::sync::Arc<std::sync::atomic::AtomicU8>) -> Self {
227 self.shared_approve_mode = Some(shared);
228 self
229 }
230
231 pub fn with_config(mut self, model: &str, _think: bool, _max_tokens: u32, context_size: Option<u64>) -> Self {
232 self.model = model.to_string();
233 self.context_size = context_size.unwrap_or_else(|| {
234 let m = model.to_ascii_lowercase();
235 if m.contains("1m") || m.contains("opus-4-7") {
236 1_000_000
237 } else if m.contains("claude-3") || m.contains("claude-4") || m.contains("claude-sonnet") {
238 200_000
239 } else {
240 128_000
241 }
242 });
243 self
244 }
245
246 pub fn load_messages(&mut self, core_messages: Vec<matrixcode_core::Message>) {
247 for msg in core_messages {
248 match &msg.content {
250 matrixcode_core::MessageContent::Text(t) => {
251 if t.is_empty() { continue; }
252 let role = match msg.role {
253 matrixcode_core::Role::User => Role::User,
254 matrixcode_core::Role::Assistant => Role::Assistant,
255 matrixcode_core::Role::System => Role::System,
256 matrixcode_core::Role::Tool => Role::Tool { name: "tool".into(), is_error: false },
257 };
258 if role == Role::User && !t.starts_with('/')
260 && self.input_history.last().map(|s| s.as_str()) != Some(t) {
261 self.input_history.push(t.clone());
262 }
263 self.messages.push(Message { role, content: t.clone() });
264 }
265 matrixcode_core::MessageContent::Blocks(blocks) => {
266 for b in blocks {
268 match b {
269 matrixcode_core::ContentBlock::Text { text } => {
270 if text.is_empty() { continue; }
271 let role = match msg.role {
272 matrixcode_core::Role::User => Role::User,
273 matrixcode_core::Role::Assistant => Role::Assistant,
274 matrixcode_core::Role::System => Role::System,
275 matrixcode_core::Role::Tool => Role::Tool { name: "tool".into(), is_error: false },
276 };
277 if role == Role::User && !text.starts_with('/')
279 && self.input_history.last().map(|s| s.as_str()) != Some(text) {
280 self.input_history.push(text.clone());
281 }
282 self.messages.push(Message { role, content: text.clone() });
283 }
284 matrixcode_core::ContentBlock::Thinking { thinking, .. } => {
285 if thinking.is_empty() { continue; }
286 self.messages.push(Message { role: Role::Thinking, content: thinking.clone() });
288 }
289 matrixcode_core::ContentBlock::ToolUse { name: _, .. } => {
290 }
292 matrixcode_core::ContentBlock::ToolResult { content, tool_use_id, .. } => {
293 if content.is_empty() { continue; }
294 let is_error = content.contains("error") || content.contains("failed") || content.contains("Error");
296 self.messages.push(Message {
297 role: Role::Tool {
298 name: if tool_use_id.starts_with("bash") { "bash".into() } else { tool_use_id.clone() },
299 is_error
300 },
301 content: content.clone()
302 });
303 }
304 _ => {}
305 }
306 }
307 }
308 }
309 }
310 if !self.messages.is_empty() {
311 self.show_welcome = false;
312 }
313 }
314
315 pub(crate) fn get_selected_text(&self, selection: Selection) -> String {
319 let norm = selection.normalized();
320
321 let mut line_to_content: Vec<(usize, String)> = Vec::new(); let mut rendered_lines: Vec<String> = Vec::new();
325
326 if self.show_welcome && self.messages.is_empty() {
328 rendered_lines.push(" █ █ █ ███████ ██████ ███ █ █ ".into());
330 rendered_lines.push(" ██ ██ █ █ █ █ █ █ █ █ ".into());
331 rendered_lines.push(" █ █ █ █ █ █ █ █ █ █ █ █ ".into());
332 rendered_lines.push(" █ █ █ █ █ █ ██████ █ █ ".into());
333 rendered_lines.push(" █ █ ███████ █ █ █ █ █ █ ".into());
334 rendered_lines.push(" █ █ █ █ █ █ █ █ █ █ ".into());
335 rendered_lines.push(" █ █ █ █ █ █ █ ███ █ █ ".into());
336 rendered_lines.push(" AI coding assistant | /help for commands".into());
337 rendered_lines.push(String::new());
338 }
339
340 for (msg_idx, msg) in self.messages.iter().enumerate() {
342 match &msg.role {
343 Role::User => {
344 for line in msg.content.lines() {
346 rendered_lines.push(format!("│ {}", line));
347 line_to_content.push((msg_idx, line.to_string()));
348 }
349 rendered_lines.push(String::new());
350 }
351 Role::Assistant => {
352 rendered_lines.push(" ──".into());
354 for line in msg.content.lines() {
355 rendered_lines.push(format!(" {}", line));
356 line_to_content.push((msg_idx, line.to_string()));
357 }
358 rendered_lines.push(String::new());
359 }
360 Role::Thinking => {
361 rendered_lines.push(" 💭 ▼ Thinking".into());
363 for line in msg.content.lines() {
364 rendered_lines.push(format!(" {}", line));
365 line_to_content.push((msg_idx, line.to_string()));
366 }
367 }
368 Role::Tool { name, .. } => {
369 rendered_lines.push(format!(" {} →", name));
371 for line in msg.content.lines() {
372 rendered_lines.push(format!(" {}", line));
373 line_to_content.push((msg_idx, line.to_string()));
374 }
375 rendered_lines.push(String::new());
376 }
377 Role::System => {
378 if msg.content.contains("APPROVAL") {
380 for line in msg.content.lines() {
381 rendered_lines.push(format!(" ⚡ {}", line));
382 }
383 } else {
384 for line in msg.content.lines() {
385 rendered_lines.push(format!(" {}", line));
386 }
387 }
388 rendered_lines.push(String::new());
389 }
390 Role::Ask => {
391 for line in msg.content.lines() {
393 rendered_lines.push(line.to_string());
394 line_to_content.push((msg_idx, line.to_string()));
395 }
396 rendered_lines.push(String::new());
397 }
398 }
399 }
400
401 let mut result = String::new();
403 for i in norm.start_line..=norm.end_line {
404 if let Some(line) = rendered_lines.get(i) {
405 let (start_col, end_col) = if i == norm.start_line && i == norm.end_line {
406 (norm.start_col, norm.end_col)
407 } else if i == norm.start_line {
408 (norm.start_col, usize::MAX)
409 } else if i == norm.end_line {
410 (0, norm.end_col)
411 } else {
412 (0, usize::MAX)
413 };
414
415 let extracted = extract_by_visual_col(line, start_col, end_col);
417 result.push_str(&extracted);
418 if i != norm.end_line {
419 result.push('\n');
420 }
421 }
422 }
423
424 result
425 }
426
427 pub fn run(&mut self, term: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
428 loop {
429 if self.last_anim.elapsed().as_millis() >= ANIM_MS as u128 {
431 self.frame = (self.frame + 1) % 10;
432 self.last_anim = Instant::now();
433 }
434
435 term.draw(|f| self.draw(f))?;
436
437 if event::poll(Duration::from_millis(16))? {
439 match event::read()? {
440 Event::Key(k) => self.on_key(k),
441 Event::Mouse(m) => self.on_mouse(m, self.msg_area_top.get()),
442 Event::Paste(text) => self.on_paste(&text),
443 _ => {}
444 }
445 }
446
447 while let Ok(e) = self.rx.try_recv() {
449 self.on_event(e);
450 }
451
452 if self.exit { break; }
453 }
454 Ok(())
455 }
456 fn on_mouse(&mut self, m: MouseEvent, msg_area_y: u16) {
457 match m.kind {
458 MouseEventKind::ScrollUp => {
459 if self.auto_scroll {
464 self.auto_scroll = false;
465 self.scroll_offset = self.max_scroll.get().max(50);
468 }
469 self.scroll_offset = self.scroll_offset.saturating_sub(3);
470 self.selection = None; }
472 MouseEventKind::ScrollDown => {
473 if !self.auto_scroll {
475 self.scroll_offset = self.scroll_offset.saturating_add(3);
476 let max = self.max_scroll.get();
479 if max > 0 && self.scroll_offset >= max {
480 self.auto_scroll = true;
481 self.scroll_offset = 0;
482 }
483 }
484 self.selection = None; }
486 MouseEventKind::Down(MouseButton::Left) => {
487 if m.row >= msg_area_y {
489 if self.auto_scroll {
491 self.scroll_offset = self.max_scroll.get().max(50);
492 }
493 let line = self.scroll_offset as usize + (m.row - msg_area_y) as usize;
494 let col = m.column as usize;
495 self.selection = Some(Selection::new(line, col));
496 self.selecting = true;
497 self.auto_scroll = false; }
499 }
500 MouseEventKind::Drag(MouseButton::Left) => {
501 if self.selecting && m.row >= msg_area_y {
503 if self.auto_scroll {
505 self.scroll_offset = self.max_scroll.get().max(50);
506 self.auto_scroll = false;
507 }
508 let line = self.scroll_offset as usize + (m.row - msg_area_y) as usize;
509 let col = m.column as usize;
510 if let Some(ref mut sel) = self.selection {
511 sel.extend_to(line, col);
512 }
513 }
514 }
515 MouseEventKind::Up(MouseButton::Left) => {
516 self.selecting = false;
517 if let Some(sel) = self.selection {
519 let text = self.get_selected_text(sel);
520 if !text.is_empty() {
521 let result = arboard::Clipboard::new()
523 .and_then(|mut cb| cb.set_text(&text));
524 if self.debug_mode {
525 match result {
526 Ok(_) => self.messages.push(Message {
527 role: Role::System,
528 content: format!("✓ Copied {} chars", text.len())
529 }),
530 Err(e) => self.messages.push(Message {
531 role: Role::System,
532 content: format!("❌ Copy failed: {}", e)
533 }),
534 }
535 self.auto_scroll = true;
536 }
537 }
538 }
539 }
540 _ => {}
541 }
542 }
543
544}