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};
12
13use crate::types::{Activity, ApproveMode, Role, Message};
14use crate::ANIM_MS;
15
16pub struct TuiApp {
17 pub(crate) activity: Activity,
18 pub(crate) activity_detail: String,
19 pub(crate) messages: Vec<Message>,
20 pub(crate) thinking: String,
21 pub(crate) streaming: String,
22 pub(crate) input: String,
23 pub(crate) model: String,
24 pub(crate) tokens_in: u64,
26 pub(crate) tokens_out: u64,
27 pub(crate) session_total_out: u64,
28 pub(crate) current_request_tokens: u64, pub(crate) cache_read: u64,
30 pub(crate) cache_created: u64,
31 pub(crate) context_size: u64,
32 pub(crate) api_calls: u64,
34 pub(crate) compressions: u64,
35 pub(crate) memory_saves: u64,
36 pub(crate) tool_calls: u64,
37 pub(crate) request_start: Option<Instant>,
39 pub(crate) frame: usize,
41 pub(crate) last_anim: Instant,
42 pub(crate) show_welcome: bool,
43 pub(crate) exit: bool,
44 pub(crate) cursor_pos: usize,
46 pub(crate) input_history: Vec<String>,
48 pub(crate) history_index: Option<usize>, pub(crate) history_draft: String, pub(crate) scroll_offset: u16,
52 pub(crate) auto_scroll: bool,
53 pub(crate) max_scroll: std::cell::Cell<u16>,
54 pub(crate) thinking_collapsed: bool,
56 pub(crate) approve_mode: ApproveMode,
58 pub(crate) shared_approve_mode: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
60 pub(crate) ask_tx: Option<tokio::sync::mpsc::Sender<String>>,
62 pub(crate) waiting_for_ask: bool,
63 pub(crate) ask_options: Vec<crate::types::AskOption>,
64 pub(crate) ask_selected_index: usize,
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) debug_mode: bool,
77}
78
79#[derive(Clone)]
81pub struct LoopTask {
82 pub message: String,
83 pub interval_secs: u64,
84 pub count: u64,
85 pub max_count: Option<u64>,
86 pub cancel_token: CancellationToken,
87}
88
89#[derive(Clone)]
91pub struct CronTask {
92 pub id: usize,
93 pub message: String,
94 pub minute_interval: u64, #[allow(dead_code)]
96 pub next_run: Instant, pub cancel_token: CancellationToken,
98}
99
100impl TuiApp {
101 pub fn new(
102 tx: tokio::sync::mpsc::Sender<String>,
103 rx: tokio::sync::mpsc::Receiver<AgentEvent>,
104 cancel: CancellationToken,
105 ) -> Self {
106 Self {
107 activity: Activity::Idle,
108 activity_detail: String::new(),
109 messages: Vec::new(),
110 thinking: String::new(),
111 streaming: String::new(),
112 input: String::new(),
113 model: "claude-sonnet-4".into(),
114 tokens_in: 0,
115 tokens_out: 0,
116 session_total_out: 0,
117 current_request_tokens: 0,
118 cache_read: 0,
119 cache_created: 0,
120 context_size: 200_000,
121 api_calls: 0,
122 compressions: 0,
123 memory_saves: 0,
124 tool_calls: 0,
125 request_start: None,
126 frame: 0,
127 last_anim: Instant::now(),
128 show_welcome: true,
129 exit: false,
130 cursor_pos: 0,
131 input_history: Vec::new(),
132 history_index: None,
133 history_draft: String::new(),
134 scroll_offset: 0,
135 auto_scroll: true,
136 max_scroll: std::cell::Cell::new(0),
137 thinking_collapsed: false, approve_mode: ApproveMode::Ask,
139 shared_approve_mode: None,
140 ask_tx: None,
141 waiting_for_ask: false,
142 ask_options: Vec::new(),
143 ask_selected_index: 0,
144 tx, rx, cancel,
145 pending_messages: Vec::new(),
146 loop_task: None,
147 cron_tasks: Vec::new(),
148 debug_mode: false,
149 }
150 }
151
152 pub fn with_ask_channel(mut self, ask_tx: tokio::sync::mpsc::Sender<String>) -> Self {
153 self.ask_tx = Some(ask_tx);
154 self
155 }
156
157 pub fn with_shared_approve_mode(mut self, shared: std::sync::Arc<std::sync::atomic::AtomicU8>) -> Self {
159 self.shared_approve_mode = Some(shared);
160 self
161 }
162
163 pub fn with_config(mut self, model: &str, _think: bool, _max_tokens: u32, context_size: Option<u64>) -> Self {
164 self.model = model.to_string();
165 self.context_size = context_size.unwrap_or_else(|| {
166 let m = model.to_ascii_lowercase();
167 if m.contains("1m") || m.contains("opus-4-7") {
168 1_000_000
169 } else if m.contains("claude-3") || m.contains("claude-4") || m.contains("claude-sonnet") {
170 200_000
171 } else {
172 128_000
173 }
174 });
175 self
176 }
177
178 pub fn load_messages(&mut self, core_messages: Vec<matrixcode_core::Message>) {
179 for msg in core_messages {
180 match &msg.content {
182 matrixcode_core::MessageContent::Text(t) => {
183 if t.is_empty() { continue; }
184 let role = match msg.role {
185 matrixcode_core::Role::User => Role::User,
186 matrixcode_core::Role::Assistant => Role::Assistant,
187 matrixcode_core::Role::System => Role::System,
188 matrixcode_core::Role::Tool => Role::Tool { name: "tool".into(), detail: None, is_error: false },
189 };
190 if role == Role::User && !t.starts_with('/')
192 && self.input_history.last().map(|s| s.as_str()) != Some(t) {
193 self.input_history.push(t.clone());
194 }
195 self.messages.push(Message { role, content: t.clone() });
196 }
197 matrixcode_core::MessageContent::Blocks(blocks) => {
198 for b in blocks {
200 match b {
201 matrixcode_core::ContentBlock::Text { text } => {
202 if text.is_empty() { continue; }
203 let role = match msg.role {
204 matrixcode_core::Role::User => Role::User,
205 matrixcode_core::Role::Assistant => Role::Assistant,
206 matrixcode_core::Role::System => Role::System,
207 matrixcode_core::Role::Tool => Role::Tool { name: "tool".into(), detail: None, is_error: false },
208 };
209 if role == Role::User && !text.starts_with('/')
211 && self.input_history.last().map(|s| s.as_str()) != Some(text) {
212 self.input_history.push(text.clone());
213 }
214 self.messages.push(Message { role, content: text.clone() });
215 }
216 matrixcode_core::ContentBlock::Thinking { thinking, .. } => {
217 if thinking.is_empty() { continue; }
218 self.messages.push(Message { role: Role::Thinking, content: thinking.clone() });
220 }
221 matrixcode_core::ContentBlock::ToolUse { name: _, .. } => {
222 }
224 matrixcode_core::ContentBlock::ToolResult { content, tool_use_id, .. } => {
225 if content.is_empty() { continue; }
226 let is_error = content.contains("error") || content.contains("failed") || content.contains("Error");
228 self.messages.push(Message {
229 role: Role::Tool {
230 name: if tool_use_id.starts_with("bash") { "bash".into() } else { tool_use_id.clone() },
231 detail: None,
232 is_error
233 },
234 content: content.clone()
235 });
236 }
237 _ => {}
238 }
239 }
240 }
241 }
242 }
243 if !self.messages.is_empty() {
244 self.show_welcome = false;
245 }
246 }
247
248 pub fn run(&mut self, term: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
249 loop {
250 if self.last_anim.elapsed().as_millis() >= ANIM_MS as u128 {
252 self.frame = (self.frame + 1) % 10;
253 self.last_anim = Instant::now();
254 }
255
256 term.draw(|f| self.draw(f))?;
257
258 if event::poll(Duration::from_millis(16))? {
260 match event::read()? {
261 Event::Key(k) => self.on_key(k),
262 Event::Mouse(m) => self.on_mouse(m),
263 Event::Paste(text) => self.on_paste(&text),
264 _ => {}
265 }
266 }
267
268 while let Ok(e) = self.rx.try_recv() {
270 self.on_event(e);
271 }
272
273 if self.exit { break; }
274 }
275 Ok(())
276 }
277 fn on_mouse(&mut self, m: MouseEvent) {
278 match m.kind {
279 MouseEventKind::ScrollUp => {
280 if self.auto_scroll {
281 self.auto_scroll = false;
282 self.scroll_offset = self.max_scroll.get().max(50);
283 }
284 self.scroll_offset = self.scroll_offset.saturating_sub(3);
285 }
286 MouseEventKind::ScrollDown => {
287 if !self.auto_scroll {
288 self.scroll_offset = self.scroll_offset.saturating_add(3);
289 let max = self.max_scroll.get();
290 if max > 0 && self.scroll_offset >= max {
291 self.auto_scroll = true;
292 self.scroll_offset = 0;
293 }
294 }
295 }
296 _ => {}
297 }
298 }
299
300}