1use std::io::Stdout;
2use std::time::{Duration, Instant};
3
4use anyhow::Result;
5use ratatui::{
6 backend::CrosstermBackend,
7 crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind},
8 Terminal,
9};
10
11use matrixcode_core::{AgentEvent, EventData, EventType, cancel::CancellationToken};
12use ratatui::crossterm::event::MouseButton;
13
14use crate::types::{Activity, ApproveMode, Role, Message};
15use crate::utils::{truncate, extract_tool_detail, fmt_tokens};
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) cache_read: u64,
31 pub(crate) cache_created: u64,
32 pub(crate) context_size: u64,
33 pub(crate) api_calls: u64,
35 pub(crate) compressions: u64,
36 pub(crate) memory_saves: u64,
37 pub(crate) tool_calls: u64,
38 pub(crate) frame: usize,
40 pub(crate) last_anim: Instant,
41 pub(crate) show_welcome: bool,
42 pub(crate) exit: bool,
43 pub(crate) cursor_pos: usize,
45 pub(crate) scroll_offset: u16,
47 pub(crate) auto_scroll: bool,
48 pub(crate) max_scroll: std::cell::Cell<u16>,
49 pub(crate) thinking_collapsed: bool,
51 pub(crate) approve_mode: ApproveMode,
53 pub(crate) ask_tx: Option<tokio::sync::mpsc::Sender<String>>,
55 pub(crate) waiting_for_ask: bool,
56 pub(crate) tx: tokio::sync::mpsc::Sender<String>,
58 pub(crate) rx: tokio::sync::mpsc::Receiver<AgentEvent>,
59 pub(crate) cancel: CancellationToken,
60 pub(crate) pending_messages: Vec<String>,
62 pub(crate) loop_task: Option<LoopTask>,
64 pub(crate) cron_tasks: Vec<CronTask>,
66 pub(crate) selection: Option<Selection>,
68 pub(crate) selecting: bool, pub(crate) msg_area_top: std::cell::Cell<u16>, pub(crate) debug_mode: bool,
72}
73
74#[derive(Clone, Copy, Debug)]
76pub struct Selection {
77 pub start_line: usize,
78 pub start_col: usize,
79 pub end_line: usize,
80 pub end_col: usize,
81}
82
83impl Selection {
84 pub fn new(start_line: usize, start_col: usize) -> Self {
85 Self {
86 start_line,
87 start_col,
88 end_line: start_line,
89 end_col: start_col,
90 }
91 }
92
93 pub fn extend_to(&mut self, line: usize, col: usize) {
94 self.end_line = line;
95 self.end_col = col;
96 }
97
98 #[allow(dead_code)]
99 pub fn is_empty(&self) -> bool {
100 self.start_line == self.end_line && self.start_col == self.end_col
101 }
102
103 pub fn normalized(&self) -> Self {
104 if self.start_line > self.end_line ||
106 (self.start_line == self.end_line && self.start_col > self.end_col) {
107 Self {
108 start_line: self.end_line,
109 start_col: self.end_col,
110 end_line: self.start_line,
111 end_col: self.start_col,
112 }
113 } else {
114 *self
115 }
116 }
117
118 #[allow(dead_code)]
119 pub fn contains(&self, line: usize, col: usize) -> bool {
120 let norm = self.normalized();
121 if line < norm.start_line || line > norm.end_line {
122 return false;
123 }
124 if line == norm.start_line && line == norm.end_line {
125 return col >= norm.start_col && col <= norm.end_col;
126 }
127 if line == norm.start_line {
128 return col >= norm.start_col;
129 }
130 if line == norm.end_line {
131 return col <= norm.end_col;
132 }
133 true }
135}
136
137#[derive(Clone)]
139pub struct LoopTask {
140 pub message: String,
141 pub interval_secs: u64,
142 pub count: u64,
143 pub max_count: Option<u64>,
144 pub cancel_token: CancellationToken,
145}
146
147#[derive(Clone)]
149pub struct CronTask {
150 pub id: usize,
151 pub message: String,
152 pub minute_interval: u64, #[allow(dead_code)]
154 pub next_run: Instant, pub cancel_token: CancellationToken,
156}
157
158impl TuiApp {
159 pub fn new(
160 tx: tokio::sync::mpsc::Sender<String>,
161 rx: tokio::sync::mpsc::Receiver<AgentEvent>,
162 cancel: CancellationToken,
163 ) -> Self {
164 Self {
165 activity: Activity::Idle,
166 activity_detail: String::new(),
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 cache_read: 0,
176 cache_created: 0,
177 context_size: 200_000,
178 api_calls: 0,
179 compressions: 0,
180 memory_saves: 0,
181 tool_calls: 0,
182 frame: 0,
183 last_anim: Instant::now(),
184 show_welcome: true,
185 exit: false,
186 cursor_pos: 0,
187 scroll_offset: 0,
188 auto_scroll: true,
189 max_scroll: std::cell::Cell::new(0),
190 thinking_collapsed: false, approve_mode: ApproveMode::Ask,
192 ask_tx: None,
193 waiting_for_ask: false,
194 tx, rx, cancel,
195 pending_messages: Vec::new(),
196 loop_task: None,
197 cron_tasks: Vec::new(),
198 selection: None,
199 selecting: false,
200 msg_area_top: std::cell::Cell::new(0),
201 debug_mode: false,
202 }
203 }
204
205 pub fn with_ask_channel(mut self, ask_tx: tokio::sync::mpsc::Sender<String>) -> Self {
206 self.ask_tx = Some(ask_tx);
207 self
208 }
209
210 pub fn with_config(mut self, model: &str, _think: bool, _max_tokens: u32, context_size: Option<u64>) -> Self {
211 self.model = model.to_string();
212 self.context_size = context_size.unwrap_or_else(|| {
213 let m = model.to_ascii_lowercase();
214 if m.contains("1m") || m.contains("opus-4-7") {
215 1_000_000
216 } else if m.contains("claude-3") || m.contains("claude-4") || m.contains("claude-sonnet") {
217 200_000
218 } else {
219 128_000
220 }
221 });
222 self
223 }
224
225 pub fn load_messages(&mut self, core_messages: Vec<matrixcode_core::Message>) {
226 for msg in core_messages {
227 let content = match &msg.content {
228 matrixcode_core::MessageContent::Text(t) => t.clone(),
229 matrixcode_core::MessageContent::Blocks(blocks) => {
230 blocks.iter().filter_map(|b| match b {
231 matrixcode_core::ContentBlock::Text { text } => Some(text.clone()),
232 _ => None,
233 }).collect::<Vec<_>>().join("\n")
234 }
235 };
236 if content.is_empty() { continue; }
237 let role = match msg.role {
238 matrixcode_core::Role::User => Role::User,
239 matrixcode_core::Role::Assistant => Role::Assistant,
240 matrixcode_core::Role::System => Role::System,
241 matrixcode_core::Role::Tool => Role::Tool { name: "tool".into(), is_error: false },
242 };
243 self.messages.push(Message { role, content });
244 }
245 if !self.messages.is_empty() {
246 self.show_welcome = false;
247 }
248 }
249
250 fn get_selected_text(&self, selection: Selection) -> String {
252 let norm = selection.normalized();
253
254 let mut result = String::new();
259
260 let mut all_text: Vec<String> = Vec::new();
262 for msg in &self.messages {
263 let icon = msg.role.icon();
264 let label = msg.role.label();
265 all_text.push(format!("{} {}", icon, label));
266 for line in msg.content.lines() {
267 all_text.push(format!(" {}", line));
268 }
269 all_text.push(String::new()); }
271
272 for i in norm.start_line..=norm.end_line {
274 if let Some(line) = all_text.get(i) {
275 if i == norm.start_line && i == norm.end_line {
276 if norm.start_col < line.len() && norm.end_col <= line.len() {
278 result.push_str(&line[norm.start_col..norm.end_col]);
279 }
280 } else if i == norm.start_line {
281 if norm.start_col < line.len() {
283 result.push_str(&line[norm.start_col..]);
284 }
285 result.push('\n');
286 } else if i == norm.end_line {
287 if norm.end_col <= line.len() {
289 result.push_str(&line[..norm.end_col]);
290 }
291 } else {
292 result.push_str(line);
294 result.push('\n');
295 }
296 }
297 }
298
299 result
300 }
301
302 pub fn run(&mut self, term: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
303 loop {
304 if self.last_anim.elapsed().as_millis() >= ANIM_MS as u128 {
306 self.frame = (self.frame + 1) % 10;
307 self.last_anim = Instant::now();
308 }
309
310 term.draw(|f| self.draw(f))?;
311
312 if event::poll(Duration::from_millis(16))? {
314 match event::read()? {
315 Event::Key(k) => self.on_key(k),
316 Event::Mouse(m) => self.on_mouse(m, self.msg_area_top.get()),
317 Event::Paste(text) => self.on_paste(&text),
318 _ => {}
319 }
320 }
321
322 while let Ok(e) = self.rx.try_recv() {
324 self.on_event(e);
325 }
326
327 if self.exit { break; }
328 }
329 Ok(())
330 }
331
332 fn on_key(&mut self, k: KeyEvent) {
333 if k.kind != KeyEventKind::Press { return; }
334
335 match k.code {
336 KeyCode::Enter => {
338 if k.modifiers.contains(KeyModifiers::SHIFT) {
339 self.ensure_char_boundary();
341 self.input.insert(self.cursor_pos, '\n');
342 self.cursor_pos += 1; } else if !self.input.trim().is_empty() {
344 self.send_input();
345 }
346 }
347
348 KeyCode::Esc => {
350 if self.activity == Activity::Asking {
351 self.waiting_for_ask = false;
353 self.activity = Activity::Idle;
354 self.messages.push(Message { role: Role::System, content: "⚠️ Approval aborted".into() });
355 if let Some(ask_tx) = &self.ask_tx {
356 ask_tx.try_send("abort".to_string()).ok();
357 }
358 } else if self.activity != Activity::Idle {
359 self.cancel.cancel();
360 self.cancel.reset();
361 self.activity = Activity::Idle;
362 self.messages.push(Message { role: Role::System, content: "⚠️ Interrupted".into() });
363 } else {
364 self.input.clear();
365 self.cursor_pos = 0;
366 }
367 }
368
369 KeyCode::Char('c') if k.modifiers.contains(KeyModifiers::CONTROL) => {
371 if let Some(sel) = self.selection {
372 let selected_text = self.get_selected_text(sel);
374 if !selected_text.is_empty() {
375 let clipboard_result = arboard::Clipboard::new()
377 .and_then(|mut cb| cb.set_text(&selected_text));
378
379 match clipboard_result {
380 Ok(_) => {
381 self.messages.push(Message {
382 role: Role::System,
383 content: format!("📋 Copied {} chars to clipboard", selected_text.len())
384 });
385 }
386 Err(_) => {
387 self.messages.push(Message {
388 role: Role::System,
389 content: format!("📋 Copied {} chars (clipboard unavailable)", selected_text.len())
390 });
391 }
392 }
393 self.selection = None;
394 self.selecting = false;
395 }
396 } else if self.activity != Activity::Idle {
397 self.cancel.cancel();
398 self.cancel.reset();
399 self.activity = Activity::Idle;
400 self.messages.push(Message { role: Role::System, content: "⚠️ Interrupted".into() });
401 }
402 }
403
404 KeyCode::Char('d') if k.modifiers.contains(KeyModifiers::CONTROL) => {
406 self.exit = true;
407 }
408
409 KeyCode::Char('v') if k.modifiers.contains(KeyModifiers::CONTROL) => {
411 if let Ok(mut clipboard) = arboard::Clipboard::new() {
413 if let Ok(text) = clipboard.get_text() {
414 self.on_paste(&text);
415 }
416 }
417 }
418
419 KeyCode::Backspace => {
421 if self.cursor_pos > 0 {
422 let prev_pos = self.prev_char_boundary();
423 self.input.drain(prev_pos..self.cursor_pos);
424 self.cursor_pos = prev_pos;
425 }
426 }
427
428 KeyCode::Delete => {
430 if self.cursor_pos < self.input.len() {
431 let next_pos = self.next_char_boundary();
432 self.input.drain(self.cursor_pos..next_pos);
433 }
434 }
435
436 KeyCode::Left => {
438 if self.cursor_pos > 0 {
439 self.cursor_pos = self.prev_char_boundary();
440 }
441 }
442
443 KeyCode::Right => {
445 if self.cursor_pos < self.input.len() {
446 self.cursor_pos = self.next_char_boundary();
447 }
448 }
449
450 KeyCode::Up if !k.modifiers.contains(KeyModifiers::ALT) => {
452 if self.input.contains('\n') {
453 let (current_line_num, col_chars, _) = self.get_line_info();
454 if current_line_num > 1 {
455 let char_pos = self.byte_pos_to_char_pos();
456 let input_chars: Vec<char> = self.input.chars().collect();
457 let before_cursor_str: String = input_chars[..char_pos.min(input_chars.len())].iter().collect();
458
459 let prev_lines_str = &before_cursor_str[..before_cursor_str.rfind('\n').unwrap_or(0)];
461 let prev_line_start_char = prev_lines_str.chars().count();
462
463 let prev_line_end_char = char_pos.saturating_sub(col_chars).saturating_sub(1); let prev_line_len_chars = prev_line_end_char.saturating_sub(prev_line_start_char);
466
467 let target_char_pos = prev_line_start_char + col_chars.min(prev_line_len_chars);
469 self.cursor_pos = self.char_pos_to_byte_pos(target_char_pos);
470 }
471 }
472 }
473
474 KeyCode::Down if !k.modifiers.contains(KeyModifiers::ALT) => {
476 if self.input.contains('\n') {
477 let (current_line_num, col_chars, total_lines) = self.get_line_info();
478 if current_line_num < total_lines {
479 let char_pos = self.byte_pos_to_char_pos();
480 let input_chars: Vec<char> = self.input.chars().collect();
481
482 let safe_char_pos = char_pos.min(input_chars.len());
484
485 let remaining_chars = &input_chars[safe_char_pos..];
487 let next_line_start_char = remaining_chars.iter().position(|c| *c == '\n')
488 .map(|i| safe_char_pos + i + 1)
489 .unwrap_or_else(|| input_chars.len());
490
491 let next_line_chars = &input_chars[next_line_start_char..];
493 let next_line_end_char = next_line_chars.iter().position(|c| *c == '\n')
494 .map(|i| next_line_start_char + i)
495 .unwrap_or_else(|| input_chars.len());
496
497 let next_line_len_chars = next_line_end_char.saturating_sub(next_line_start_char);
498
499 let target_char_pos = next_line_start_char + col_chars.min(next_line_len_chars);
501 self.cursor_pos = self.char_pos_to_byte_pos(target_char_pos);
502 }
503 }
504 }
505
506 KeyCode::Char(c) if !k.modifiers.contains(KeyModifiers::ALT) && !k.modifiers.contains(KeyModifiers::CONTROL) => {
508 self.ensure_char_boundary();
509 self.input.insert(self.cursor_pos, c);
510 self.cursor_pos += c.len_utf8();
511 }
512
513 KeyCode::Char('m') if k.modifiers.contains(KeyModifiers::ALT) => {
515 self.approve_mode = self.approve_mode.next();
516 self.tx.try_send(format!("/mode:{}", self.approve_mode.label())).ok();
517 }
518
519 KeyCode::Char('t') if k.modifiers.contains(KeyModifiers::ALT) => {
521 self.thinking_collapsed = !self.thinking_collapsed;
522 }
523
524 KeyCode::Tab if k.modifiers.contains(KeyModifiers::SHIFT) => {
526 self.approve_mode = self.approve_mode.next();
527 self.tx.try_send(format!("/mode:{}", self.approve_mode.label())).ok();
528 }
529 KeyCode::BackTab => {
530 self.approve_mode = self.approve_mode.next();
531 self.tx.try_send(format!("/mode:{}", self.approve_mode.label())).ok();
532 }
533
534 KeyCode::PageUp => {
536 if self.auto_scroll {
537 self.scroll_offset = self.max_scroll.get();
538 self.auto_scroll = false;
539 }
540 self.scroll_offset = self.scroll_offset.saturating_sub(10);
541 }
542
543 KeyCode::PageDown => {
545 if !self.auto_scroll {
546 self.scroll_offset = self.scroll_offset.saturating_add(10);
547 if self.scroll_offset >= self.max_scroll.get() {
548 self.auto_scroll = true;
549 self.scroll_offset = 0;
550 }
551 }
552 }
553
554 KeyCode::Up if k.modifiers.contains(KeyModifiers::ALT) => {
556 if self.auto_scroll {
557 self.scroll_offset = self.max_scroll.get();
558 self.auto_scroll = false;
559 }
560 self.scroll_offset = self.scroll_offset.saturating_sub(1);
561 }
562
563 KeyCode::Down if k.modifiers.contains(KeyModifiers::ALT) => {
565 if !self.auto_scroll {
566 self.scroll_offset = self.scroll_offset.saturating_add(1);
567 if self.scroll_offset >= self.max_scroll.get() {
568 self.auto_scroll = true;
569 self.scroll_offset = 0;
570 }
571 }
572 }
573
574 KeyCode::Home => {
576 if !self.input.is_empty() {
577 self.cursor_pos = 0;
578 } else {
579 self.auto_scroll = false;
580 self.scroll_offset = 0;
581 }
582 }
583
584 KeyCode::End => {
586 if !self.input.is_empty() {
587 self.cursor_pos = self.input.len();
588 } else {
589 self.auto_scroll = true;
590 self.scroll_offset = 0;
591 }
592 }
593
594 _ => {}
595 }
596 }
597
598 fn ensure_char_boundary(&mut self) {
605 if !self.input.is_char_boundary(self.cursor_pos) {
606 self.cursor_pos = self.input.char_indices()
607 .rfind(|(i, _)| *i <= self.cursor_pos)
608 .map(|(i, _)| i)
609 .unwrap_or(0);
610 }
611 }
612
613 fn prev_char_boundary(&self) -> usize {
616 self.input.char_indices()
617 .rfind(|(i, _)| *i < self.cursor_pos)
618 .map(|(i, _)| i)
619 .unwrap_or(0)
620 }
621
622 fn next_char_boundary(&self) -> usize {
625 self.input.char_indices()
626 .find(|(i, _)| *i > self.cursor_pos)
627 .map(|(i, _)| i)
628 .unwrap_or_else(|| self.input.len())
629 }
630
631 fn byte_pos_to_char_pos(&self) -> usize {
633 self.input.char_indices().take(self.cursor_pos).count()
634 }
635
636 fn char_pos_to_byte_pos(&self, char_pos: usize) -> usize {
638 self.input.char_indices()
639 .nth(char_pos)
640 .map(|(i, _)| i)
641 .unwrap_or_else(|| self.input.len())
642 }
643
644 fn get_line_info(&self) -> (usize, usize, usize) {
646 let char_pos = self.byte_pos_to_char_pos();
647 let before_cursor_str: String = self.input.chars().take(char_pos).collect();
648 let current_line_num = before_cursor_str.lines().count();
649 let total_lines = self.input.lines().count();
650 let current_line_start_char = before_cursor_str.rfind('\n')
651 .map(|i| before_cursor_str[i+1..].chars().count())
652 .unwrap_or(0);
653 let col_chars = char_pos - current_line_start_char;
654 (current_line_num, col_chars, total_lines)
655 }
656
657 fn send_input(&mut self) {
658 self.show_welcome = false;
659 let input = self.input.trim().to_string();
660 self.input.clear();
661 self.cursor_pos = 0;
662
663 if self.waiting_for_ask {
664 self.waiting_for_ask = false;
666 self.messages.push(Message { role: Role::User, content: input.clone() });
667 if let Some(ask_tx) = &self.ask_tx {
668 ask_tx.try_send(input).ok();
669 }
670 self.activity = Activity::Thinking;
671 self.auto_scroll = true;
672 } else if input.starts_with('/') {
673 self.handle_command(&input);
675 } else if self.activity == Activity::Idle {
676 self.messages.push(Message { role: Role::User, content: input.clone() });
678 self.tx.try_send(input).ok();
679 self.activity = Activity::Thinking;
680 self.auto_scroll = true;
681 } else {
682 self.pending_messages.push(input.clone());
684 }
685 }
686
687 fn on_mouse(&mut self, m: MouseEvent, msg_area_y: u16) {
688 match m.kind {
689 MouseEventKind::ScrollUp => {
690 if self.auto_scroll {
692 self.scroll_offset = self.max_scroll.get();
693 self.auto_scroll = false;
694 }
695 self.scroll_offset = self.scroll_offset.saturating_sub(3);
696 self.selection = None; }
698 MouseEventKind::ScrollDown => {
699 if !self.auto_scroll {
701 self.scroll_offset = self.scroll_offset.saturating_add(3);
702 if self.scroll_offset >= self.max_scroll.get() {
703 self.auto_scroll = true;
704 self.scroll_offset = 0;
705 }
706 }
707 self.selection = None; }
709 MouseEventKind::Down(MouseButton::Left) => {
710 if m.row >= msg_area_y {
712 if self.auto_scroll {
714 self.scroll_offset = self.max_scroll.get();
715 }
716 let line = self.scroll_offset as usize + (m.row - msg_area_y) as usize;
717 let col = m.column as usize;
718 self.selection = Some(Selection::new(line, col));
719 self.selecting = true;
720 self.auto_scroll = false; }
722 }
723 MouseEventKind::Drag(MouseButton::Left) => {
724 if self.selecting && m.row >= msg_area_y {
726 if self.auto_scroll {
728 self.scroll_offset = self.max_scroll.get();
729 self.auto_scroll = false;
730 }
731 let line = self.scroll_offset as usize + (m.row - msg_area_y) as usize;
732 let col = m.column as usize;
733 if let Some(ref mut sel) = self.selection {
734 sel.extend_to(line, col);
735 }
736 }
737 }
738 MouseEventKind::Up(MouseButton::Left) => {
739 self.selecting = false;
740 }
741 _ => {}
742 }
743 }
744
745 fn on_paste(&mut self, text: &str) {
746 self.ensure_char_boundary();
747 self.input.insert_str(self.cursor_pos, text);
748 self.cursor_pos += text.len(); }
750
751 fn handle_command(&mut self, cmd: &str) {
752 let parts: Vec<&str> = cmd.split_whitespace().collect();
753 let command = parts.first().copied().unwrap_or("");
754 let args = &parts[1..];
755
756 match command {
757 "/exit" | "/quit" | "/q" => {
758 self.exit = true;
759 }
760 "/clear" => {
761 if self.activity == Activity::Idle {
762 self.messages.clear();
763 self.pending_messages.clear();
764 self.messages.push(Message { role: Role::System, content: "✓ Messages cleared".into() });
765 } else {
766 self.messages.push(Message { role: Role::System, content: "⚠️ Cannot clear while AI is processing".into() });
767 }
768 self.auto_scroll = true;
769 }
770 "/history" => {
771 let user_count = self.messages.iter().filter(|m| m.role == Role::User).count();
772 let assistant_count = self.messages.iter().filter(|m| m.role == Role::Assistant).count();
773 let tool_count = self.messages.iter().filter(|m| matches!(m.role, Role::Tool { .. })).count();
774 let queue_count = self.pending_messages.len();
775 self.messages.push(Message {
776 role: Role::System,
777 content: format!(
778 "📊 Session: {} user, {} assistant, {} tools, {} queued, {} output tokens",
779 user_count, assistant_count, tool_count, queue_count, fmt_tokens(self.session_total_out)
780 )
781 });
782 self.auto_scroll = true;
783 }
784 "/mode" => {
785 if args.is_empty() {
786 self.messages.push(Message {
787 role: Role::System,
788 content: format!("Current mode: {} (use /mode ask|auto|strict)", self.approve_mode.label())
789 });
790 } else {
791 match args[0] {
792 "ask" => self.approve_mode = ApproveMode::Ask,
793 "auto" => self.approve_mode = ApproveMode::Auto,
794 "strict" => self.approve_mode = ApproveMode::Strict,
795 _ => {
796 self.messages.push(Message {
797 role: Role::System,
798 content: "Invalid mode. Use: ask, auto, strict".into()
799 });
800 return;
801 }
802 }
803 self.tx.try_send(format!("/mode:{}", self.approve_mode.label())).ok();
804 self.messages.push(Message {
805 role: Role::System,
806 content: format!("✓ Mode: {}", self.approve_mode.label())
807 });
808 }
809 self.auto_scroll = true;
810 }
811 "/model" => {
812 if args.is_empty() {
813 self.messages.push(Message {
814 role: Role::System,
815 content: format!("Model: {} (context: {})", self.model, fmt_tokens(self.context_size))
816 });
817 } else if self.activity == Activity::Idle {
818 let new_model = args.join(" ");
819 self.model = new_model.clone();
820 self.messages.push(Message {
821 role: Role::System,
822 content: format!("✓ Model: {}", new_model)
823 });
824 } else {
825 self.messages.push(Message {
826 role: Role::System,
827 content: "⚠️ Cannot change model while AI is processing".into()
828 });
829 }
830 self.auto_scroll = true;
831 }
832 "/compact" | "/compress" => {
833 self.tx.try_send("/compact".to_string()).ok();
834 self.auto_scroll = true;
835 }
836 "/init" => {
837 if args.is_empty() {
839 self.tx.try_send("/init".to_string()).ok();
841 self.messages.push(Message {
842 role: Role::System,
843 content: "🔄 Generating project overview...".into()
844 });
845 } else if args[0] == "status" {
846 self.tx.try_send("/init status".to_string()).ok();
848 self.messages.push(Message {
849 role: Role::System,
850 content: "⏳ Checking project status...".into()
851 });
852 } else if args[0] == "reset" || args[0] == "clear" {
853 self.tx.try_send("/init reset".to_string()).ok();
855 self.messages.push(Message {
856 role: Role::System,
857 content: "⏳ Resetting project configuration...".into()
858 });
859 } else {
860 self.messages.push(Message {
861 role: Role::System,
862 content: "Unknown init command. Use: /init, /init status, /init reset".into()
863 });
864 }
865 self.auto_scroll = true;
866 }
867 "/debug" => {
868 self.debug_mode = !self.debug_mode;
870 self.messages.push(Message {
871 role: Role::System,
872 content: format!("🔧 Debug mode: {} (api/tools counts {})",
873 if self.debug_mode { "ON" } else { "OFF" },
874 if self.debug_mode { "visible" } else { "hidden" }
875 )
876 });
877 self.auto_scroll = true;
878 }
879 "/retry" => {
880 if !self.pending_messages.is_empty() && self.activity == Activity::Idle {
882 let next_msg = self.pending_messages.remove(0);
883 self.messages.push(Message { role: Role::User, content: next_msg.clone() });
884 self.tx.try_send(next_msg).ok();
885 self.activity = Activity::Thinking;
886 self.auto_scroll = true;
887 self.messages.push(Message {
888 role: Role::System,
889 content: if self.pending_messages.is_empty() {
890 "✓ Retry: processing last queued message".into()
891 } else {
892 format!("⏳ Retry: {} messages remaining", self.pending_messages.len())
893 }
894 });
895 } else if self.pending_messages.is_empty() {
896 self.messages.push(Message { role: Role::System, content: "No pending messages to retry".into() });
897 } else {
898 self.messages.push(Message { role: Role::System, content: "AI is busy, please wait".into() });
899 }
900 self.auto_scroll = true;
901 }
902 "/new" => {
903 if self.activity == Activity::Idle {
904 self.messages.clear();
905 self.pending_messages.clear();
906 self.tokens_in = 0;
907 self.tokens_out = 0;
908 self.session_total_out = 0;
909 self.tx.try_send("/new".to_string()).ok();
910 self.messages.push(Message { role: Role::System, content: "✓ New session".into() });
911 } else {
912 self.messages.push(Message { role: Role::System, content: "⚠️ Cannot start new session while AI is processing".into() });
913 }
914 self.auto_scroll = true;
915 }
916 "/help" => {
917 self.messages.push(Message {
918 role: Role::System,
919 content: concat!(
920 "📖 Commands:\n",
921 " /help - Show this help\n",
922 " /exit - Exit MatrixCode\n",
923 " /clear - Clear messages\n",
924 " /history - Show session history\n",
925 " /mode - Change approve mode (ask/auto/strict)\n",
926 " /model - Show/change model\n",
927 " /compact - Compress context\n",
928 " /retry - Retry last queued message\n",
929 " /new - Start new session\n",
930 " /init - Initialize/reset project\n",
931 " /skills - List loaded skills\n",
932 " /memory - View/manage memories\n",
933 " /overview - View project overview\n",
934 " /save - Save current session\n",
935 " /sessions - List saved sessions\n",
936 " /load <id>- Load a session\n",
937 " /debug - Toggle debug mode\n",
938 " /loop - Start/stop loop task\n",
939 " /cron - Manage scheduled tasks\n",
940 "\n",
941 "⌨️ Shortcuts:\n",
942 " Enter=send │ Shift+Enter=newline │ PgUp/PgDn=scroll\n",
943 " Home/End=top/bot │ Alt+M=mode │ Alt+T=thinking\n",
944 " Esc=interrupt │ Ctrl+D=exit"
945 ).into()
946 });
947 self.auto_scroll = true;
948 }
949 "/skills" => {
950 self.tx.try_send("/skills".to_string()).ok();
952 self.auto_scroll = true;
953 }
954 "/memory" => {
955 self.tx.try_send("/memory".to_string()).ok();
957 self.auto_scroll = true;
958 }
959 "/overview" => {
960 self.tx.try_send("/overview".to_string()).ok();
962 self.auto_scroll = true;
963 }
964 "/save" => {
965 self.tx.try_send("/save".to_string()).ok();
967 self.auto_scroll = true;
968 }
969 "/sessions" | "/resume" => {
970 self.tx.try_send("/sessions".to_string()).ok();
972 self.auto_scroll = true;
973 }
974 "/loop" => {
975 if args.is_empty() {
976 self.messages.push(Message {
977 role: Role::System,
978 content: "/loop <message> [interval] [count] - Start loop\n/loop stop - Stop loop\n/loop status - Show status".into()
979 });
980 } else if args[0] == "stop" {
981 let task = self.loop_task.take();
983 if let Some(ref task) = task {
984 task.cancel_token.cancel();
985 self.messages.push(Message {
986 role: Role::System,
987 content: format!("✓ Loop stopped (executed {} times)", task.count)
988 });
989 self.loop_task = None; } else {
991 self.messages.push(Message { role: Role::System, content: "No active loop".into() });
992 }
993 } else if args[0] == "status" {
994 if let Some(ref task) = self.loop_task {
995 self.messages.push(Message {
996 role: Role::System,
997 content: format!(
998 "🔄 Loop active: '{}' every {}s, count {}{}",
999 truncate(&task.message, 30),
1000 task.interval_secs,
1001 task.count,
1002 task.max_count.map(|m| format!(" (max {})", m)).unwrap_or_default()
1003 )
1004 });
1005 } else {
1006 self.messages.push(Message { role: Role::System, content: "No active loop".into() });
1007 }
1008 } else {
1009 if self.loop_task.is_some() {
1011 self.messages.push(Message { role: Role::System, content: "⚠️ Loop already active. Use /loop stop first".into() });
1012 } else {
1013 let message = args[0].to_string();
1014 let interval_secs: u64 = args.get(1)
1015 .and_then(|s| s.parse().ok())
1016 .unwrap_or(60);
1017 let max_count: Option<u64> = args.get(2)
1018 .and_then(|s| s.parse().ok());
1019
1020 let cancel_token = CancellationToken::new();
1021 self.loop_task = Some(LoopTask {
1022 message: message.clone(),
1023 interval_secs,
1024 count: 0,
1025 max_count,
1026 cancel_token: cancel_token.clone(),
1027 });
1028
1029 let tx = self.tx.clone();
1031 let msg = message.clone();
1032 tokio::spawn(async move {
1033 loop {
1034 if cancel_token.is_cancelled() {
1035 break;
1036 }
1037 tx.try_send(msg.clone()).ok();
1039 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
1041 }
1042 });
1043
1044 self.messages.push(Message {
1045 role: Role::System,
1046 content: format!(
1047 "🔄 Loop started: '{}' every {}s{}",
1048 truncate(&message, 30),
1049 interval_secs,
1050 max_count.map(|m| format!(" (max {})", m)).unwrap_or_default()
1051 )
1052 });
1053 }
1054 }
1055 self.auto_scroll = true;
1056 }
1057 "/cron" => {
1058 if args.is_empty() {
1059 self.messages.push(Message {
1060 role: Role::System,
1061 content: "/cron add <message> <minutes> - Add cron task\n/cron list - List tasks\n/cron remove <id> - Remove task\n/cron clear - Clear all".into()
1062 });
1063 } else if args[0] == "list" {
1064 if self.cron_tasks.is_empty() {
1065 self.messages.push(Message { role: Role::System, content: "No cron tasks".into() });
1066 } else {
1067 let list: Vec<String> = self.cron_tasks.iter()
1068 .map(|t| format!("#{}: '{}' every {}min", t.id, truncate(&t.message, 20), t.minute_interval))
1069 .collect();
1070 self.messages.push(Message {
1071 role: Role::System,
1072 content: format!("📋 Cron tasks:\n{}", list.join("\n"))
1073 });
1074 }
1075 } else if args[0] == "remove" || args[0] == "rm" {
1076 let id: usize = args.get(1)
1077 .and_then(|s| s.parse().ok())
1078 .unwrap_or(0);
1079 if let Some(pos) = self.cron_tasks.iter().position(|t| t.id == id) {
1080 let task = &self.cron_tasks[pos];
1081 task.cancel_token.cancel();
1082 self.cron_tasks.remove(pos);
1083 self.messages.push(Message {
1084 role: Role::System,
1085 content: format!("✓ Cron task #{} removed", id)
1086 });
1087 } else {
1088 self.messages.push(Message { role: Role::System, content: format!("Cron task #{} not found", id) });
1089 }
1090 } else if args[0] == "clear" {
1091 for task in &self.cron_tasks {
1092 task.cancel_token.cancel();
1093 }
1094 let count = self.cron_tasks.len();
1095 self.cron_tasks.clear();
1096 self.messages.push(Message {
1097 role: Role::System,
1098 content: format!("✓ {} cron tasks cleared", count)
1099 });
1100 } else if args[0] == "add" {
1101 if args.len() < 3 {
1103 self.messages.push(Message {
1104 role: Role::System,
1105 content: "Usage: /cron add <message> <minutes>".into()
1106 });
1107 } else {
1108 let message = args[1].to_string();
1109 let minute_interval: u64 = args.get(2)
1110 .and_then(|s| s.parse().ok())
1111 .unwrap_or(5);
1112
1113 let id = self.cron_tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
1114 let cancel_token = CancellationToken::new();
1115
1116 let task = CronTask {
1117 id,
1118 message: message.clone(),
1119 minute_interval,
1120 next_run: Instant::now() + Duration::from_secs(minute_interval * 60),
1121 cancel_token: cancel_token.clone(),
1122 };
1123
1124 self.cron_tasks.push(task.clone());
1125
1126 let tx = self.tx.clone();
1128 let msg = message.clone();
1129 let interval_secs = minute_interval * 60;
1130 tokio::spawn(async move {
1131 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
1133 loop {
1134 if cancel_token.is_cancelled() {
1135 break;
1136 }
1137 tx.try_send(msg.clone()).ok();
1139 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
1141 }
1142 });
1143
1144 self.messages.push(Message {
1145 role: Role::System,
1146 content: format!("✓ Cron #{} added: '{}' every {}min", id, truncate(&message, 30), minute_interval)
1147 });
1148 }
1149 } else {
1150 self.messages.push(Message {
1151 role: Role::System,
1152 content: "Unknown cron command. Use: add, list, remove, clear".into()
1153 });
1154 }
1155 self.auto_scroll = true;
1156 }
1157 _ => {
1158 self.messages.push(Message {
1159 role: Role::System,
1160 content: format!("Unknown: {}. Type /help", command)
1161 });
1162 self.auto_scroll = true;
1163 }
1164 }
1165 }
1166
1167 fn on_event(&mut self, e: AgentEvent) {
1168 match e.event_type {
1169 EventType::ThinkingStart => {
1170 self.activity = Activity::Thinking;
1171 self.thinking.clear();
1172 }
1173 EventType::ThinkingDelta => {
1174 if let Some(EventData::Thinking { delta, .. }) = e.data {
1175 self.thinking.push_str(&delta);
1176 self.activity = Activity::Thinking;
1177 }
1178 }
1179 EventType::ThinkingEnd => {
1180 if !self.thinking.is_empty() {
1181 self.messages.push(Message { role: Role::Thinking, content: self.thinking.clone() });
1182 self.thinking.clear();
1183 }
1184 }
1185 EventType::TextStart => {
1186 self.streaming.clear();
1187 self.activity = Activity::Thinking;
1188 }
1189 EventType::TextDelta => {
1190 if let Some(EventData::Text { delta }) = e.data {
1191 self.streaming.push_str(&delta);
1192 self.activity = Activity::Thinking;
1193 }
1194 }
1195 EventType::TextEnd => {
1196 if !self.streaming.is_empty() {
1197 self.messages.push(Message { role: Role::Assistant, content: self.streaming.clone() });
1198 self.streaming.clear();
1199 }
1200 }
1201 EventType::ToolUseStart => {
1202 if let Some(EventData::ToolUse { name, input, .. }) = e.data {
1203 self.activity = Activity::from_tool(&name);
1204 self.activity_detail = extract_tool_detail(&name, input.as_ref());
1205 }
1206 }
1207 EventType::ToolResult => {
1208 if let Some(EventData::ToolResult { content, is_error, .. }) = e.data {
1209 let tool_name = self.activity.label();
1210 self.messages.push(Message {
1211 role: Role::Tool { name: tool_name, is_error },
1212 content: content });
1214 self.tool_calls += 1;
1215 self.activity = Activity::Thinking;
1216 self.activity_detail.clear();
1217 }
1218 }
1219 EventType::SessionEnded => {
1220 if !self.streaming.is_empty() {
1222 self.messages.push(Message { role: Role::Assistant, content: self.streaming.clone() });
1223 self.streaming.clear();
1224 }
1225 if !self.thinking.is_empty() {
1226 self.messages.push(Message { role: Role::Thinking, content: self.thinking.clone() });
1227 self.thinking.clear();
1228 }
1229
1230 if !self.pending_messages.is_empty() {
1232 let next_msg = self.pending_messages.remove(0);
1233 self.messages.push(Message { role: Role::User, content: next_msg.clone() });
1234 self.tx.try_send(next_msg).ok();
1235 self.activity = Activity::Thinking;
1236 self.auto_scroll = true;
1237 self.messages.push(Message {
1239 role: Role::System,
1240 content: if self.pending_messages.is_empty() {
1241 "✓ Queue completed".into()
1242 } else {
1243 format!("⏳ Processing queue ({} left)", self.pending_messages.len())
1244 }
1245 });
1246 } else {
1247 self.activity = Activity::Idle;
1248 }
1249 self.activity_detail.clear();
1250 }
1251 EventType::Error => {
1252 if let Some(EventData::Error { message, .. }) = e.data {
1253 self.messages.push(Message { role: Role::System, content: format!("❌ Error: {}", message) });
1254 self.streaming.clear();
1255 self.thinking.clear();
1256 }
1257 self.activity = Activity::Idle;
1258 self.activity_detail.clear();
1259
1260 if !self.pending_messages.is_empty() {
1262 self.messages.push(Message {
1263 role: Role::System,
1264 content: format!("⚠️ Queue paused ({} messages). Send '/retry' to process.", self.pending_messages.len())
1265 });
1266 }
1267 }
1268 EventType::Usage => {
1269 if let Some(EventData::Usage { input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens }) = e.data {
1270 self.tokens_in = input_tokens;
1271 self.tokens_out = output_tokens;
1272 self.session_total_out += output_tokens;
1273
1274 let cache_read = cache_read_input_tokens.unwrap_or(0);
1277 let cache_created = cache_creation_input_tokens.unwrap_or(0);
1278
1279 self.cache_read += cache_read;
1280 self.cache_created += cache_created;
1281 self.api_calls += 1;
1282 }
1283 }
1284 EventType::CompressionCompleted => {
1285 if let Some(EventData::Compression { original_tokens, compressed_tokens, ratio }) = e.data {
1286 self.compressions += 1;
1287 self.tokens_in = compressed_tokens;
1289 self.messages.push(Message {
1290 role: Role::System,
1291 content: format!("📦 Compressed: {} → {} tokens ({:.0}% saved)\n Context: {} tokens remaining",
1292 fmt_tokens(original_tokens), fmt_tokens(compressed_tokens), (1.0 - ratio) * 100.0,
1293 fmt_tokens(compressed_tokens))
1294 });
1295 self.auto_scroll = true;
1296 }
1297 }
1298 EventType::CompressionTriggered => {
1299 if let Some(EventData::Progress { message, .. }) = e.data {
1300 self.messages.push(Message {
1301 role: Role::System,
1302 content: format!("⏳ {}", message)
1303 });
1304 self.auto_scroll = true;
1305 }
1306 }
1307 EventType::Progress => {
1308 if let Some(EventData::Progress { message, .. }) = e.data {
1309 self.messages.push(Message {
1310 role: Role::System,
1311 content: message
1312 });
1313 self.auto_scroll = true;
1314 }
1315 }
1316 EventType::MemoryLoaded => {
1317 if let Some(EventData::Memory { entries_count, .. }) = e.data
1318 && entries_count > 0 {
1319 self.memory_saves += 1;
1320 self.messages.push(Message {
1321 role: Role::System,
1322 content: format!("🧠 Memory: {} entries", entries_count)
1323 });
1324 self.auto_scroll = true;
1325 }
1326 }
1327 EventType::MemoryDetected => {
1328 if let Some(EventData::Memory { summary, entries_count }) = e.data {
1329 self.memory_saves += 1;
1330 self.messages.push(Message {
1331 role: Role::System,
1332 content: format!("🧠 Detected {} memories: {}", entries_count, summary)
1333 });
1334 self.auto_scroll = true;
1335 }
1336 }
1337 EventType::KeywordsExtracted => {
1338 if self.debug_mode {
1341 if let Some(EventData::Keywords { keywords, source }) = e.data {
1342 let preview = truncate(&source, 50);
1343 self.messages.push(Message {
1344 role: Role::System,
1345 content: format!("🔍 Keywords: {} from '{}'", keywords.join(", "), preview)
1346 });
1347 }
1348 }
1349 }
1350 EventType::AskQuestion => {
1351 if let Some(EventData::AskQuestion { question, options }) = e.data {
1352 let is_approval = question.contains("requires approval") || question.contains("Allow?");
1354 let has_options = options.is_some();
1355
1356 let mut content = if is_approval {
1358 let lines: Vec<&str> = question.lines().collect();
1360 let header = lines.first().copied().unwrap_or("");
1361 let detail = lines.get(1).copied().unwrap_or("");
1362 format!("{}\n{}", header, detail)
1363 } else if has_options {
1364 format!("❓ {}", question)
1365 } else {
1366 question.clone()
1367 };
1368
1369 if let Some(ref opts) = options && let Some(arr) = opts.as_array() {
1371 content.push_str("\n\nOptions:");
1372 for opt in arr {
1373 let id = opt["id"].as_str().unwrap_or("?");
1374 let label = opt["label"].as_str().unwrap_or("");
1375 let desc = opt["description"].as_str().unwrap_or("");
1376 let desc_text = if desc.is_empty() { String::new() } else { format!("({})", desc) };
1377 content.push_str(&format!("\n [{}] {} {}", id, label, desc_text));
1378 }
1379 }
1380
1381 if is_approval {
1383 content.push_str("\n\n[y] Approve [n] Reject [a] Abort");
1384 } else if has_options {
1385 content.push_str("\n\nEnter option ID (e.g., 'A' or 'B')");
1386 }
1387
1388 self.messages.push(Message { role: Role::System, content });
1389 self.waiting_for_ask = true;
1390 self.activity = Activity::Asking;
1391 self.auto_scroll = true;
1392 }
1393 }
1394 _ => {}
1395 }
1396 }
1397}