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) messages: Vec<Message>,
21 pub(crate) thinking: String,
22 pub(crate) streaming: String,
23 pub(crate) input: String,
24 pub(crate) model: String,
25 pub(crate) tokens_in: u64,
27 pub(crate) tokens_out: u64,
28 pub(crate) session_total_out: u64,
29 pub(crate) current_request_tokens: u64, 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) request_start: Option<Instant>,
40 pub(crate) frame: usize,
42 pub(crate) last_anim: Instant,
43 pub(crate) show_welcome: bool,
44 pub(crate) exit: bool,
45 pub(crate) cursor_pos: usize,
47 pub(crate) input_history: Vec<String>,
49 pub(crate) history_index: Option<usize>, pub(crate) history_draft: String, pub(crate) scroll_offset: u16,
53 pub(crate) auto_scroll: bool,
54 pub(crate) max_scroll: std::cell::Cell<u16>,
55 pub(crate) new_message_while_scrolled: std::cell::Cell<bool>, 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) ask_options: Vec<crate::types::AskOption>,
66 pub(crate) ask_selected_index: usize,
67 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) tx: tokio::sync::mpsc::Sender<String>,
75 pub(crate) rx: tokio::sync::mpsc::Receiver<AgentEvent>,
76 pub(crate) cancel: CancellationToken,
77 pub(crate) pending_messages: Vec<String>,
79 pub(crate) loop_task: Option<LoopTask>,
81 pub(crate) cron_tasks: Vec<CronTask>,
83 pub(crate) debug_mode: bool,
85}
86
87#[derive(Clone)]
89pub struct LoopTask {
90 pub message: String,
91 pub interval_secs: u64,
92 pub count: u64,
93 pub max_count: Option<u64>,
94 pub cancel_token: CancellationToken,
95}
96
97#[derive(Clone)]
99pub struct CronTask {
100 pub id: usize,
101 pub message: String,
102 pub minute_interval: u64, #[allow(dead_code)]
104 pub next_run: Instant, pub cancel_token: CancellationToken,
106}
107
108impl TuiApp {
109 pub fn new(
110 tx: tokio::sync::mpsc::Sender<String>,
111 rx: tokio::sync::mpsc::Receiver<AgentEvent>,
112 cancel: CancellationToken,
113 ) -> Self {
114 Self {
115 activity: Activity::Idle,
116 activity_detail: String::new(),
117 messages: Vec::new(),
118 thinking: String::new(),
119 streaming: String::new(),
120 input: String::new(),
121 model: "claude-sonnet-4".into(),
122 tokens_in: 0,
123 tokens_out: 0,
124 session_total_out: 0,
125 current_request_tokens: 0,
126 cache_read: 0,
127 cache_created: 0,
128 context_size: 200_000,
129 api_calls: 0,
130 compressions: 0,
131 memory_saves: 0,
132 tool_calls: 0,
133 request_start: None,
134 frame: 0,
135 last_anim: Instant::now(),
136 show_welcome: true,
137 exit: false,
138 cursor_pos: 0,
139 input_history: Vec::new(),
140 history_index: None,
141 history_draft: String::new(),
142 scroll_offset: 0,
143 auto_scroll: true,
144 max_scroll: std::cell::Cell::new(0),
145 new_message_while_scrolled: std::cell::Cell::new(false),
146 thinking_collapsed: true, approve_mode: ApproveMode::Ask,
148 shared_approve_mode: None,
149 ask_tx: None,
150 waiting_for_ask: false,
151 ask_options: Vec::new(),
152 ask_selected_index: 0,
153 ask_multi_select: false,
154 ask_submit_mode: SubmitMode::default(),
155 ask_other_input_active: false,
156 ask_questions: Vec::new(),
157 current_question_idx: 0,
158 tx,
159 rx,
160 cancel,
161 pending_messages: Vec::new(),
162 loop_task: None,
163 cron_tasks: Vec::new(),
164 debug_mode: false,
165 }
166 }
167
168 pub fn with_ask_channel(mut self, ask_tx: tokio::sync::mpsc::Sender<String>) -> Self {
169 self.ask_tx = Some(ask_tx);
170 self
171 }
172
173 pub fn with_shared_approve_mode(
175 mut self,
176 shared: std::sync::Arc<std::sync::atomic::AtomicU8>,
177 ) -> Self {
178 self.shared_approve_mode = Some(shared);
179 self
180 }
181
182 pub fn with_config(
183 mut self,
184 model: &str,
185 _think: bool,
186 _max_tokens: u32,
187 context_size: Option<u64>,
188 ) -> Self {
189 self.model = model.to_string();
190 self.context_size = context_size.unwrap_or_else(|| {
191 let m = model.to_ascii_lowercase();
192 if m.contains("1m") || m.contains("opus-4-7") {
193 1_000_000
194 } else if m.contains("claude-3")
195 || m.contains("claude-4")
196 || m.contains("claude-sonnet")
197 {
198 200_000
199 } else {
200 128_000
201 }
202 });
203 self
204 }
205
206 pub fn with_debug_mode(mut self, debug_mode: bool) -> Self {
208 self.debug_mode = debug_mode;
209 self
210 }
211
212 pub fn load_messages(&mut self, core_messages: Vec<matrixcode_core::Message>) {
213 let mut tool_names: HashMap<String, String> = HashMap::new();
215
216 for msg in &core_messages {
218 if let matrixcode_core::MessageContent::Blocks(blocks) = &msg.content {
219 for b in blocks {
220 if let matrixcode_core::ContentBlock::ToolUse { id, name, .. } = b {
221 tool_names.insert(id.clone(), name.clone());
222 }
223 }
224 }
225 }
226
227 for msg in core_messages {
229 match &msg.content {
231 matrixcode_core::MessageContent::Text(t) => {
232 if t.is_empty() {
233 continue;
234 }
235 let role = match msg.role {
236 matrixcode_core::Role::User => Role::User,
237 matrixcode_core::Role::Assistant => Role::Assistant,
238 matrixcode_core::Role::System => Role::System,
239 matrixcode_core::Role::Tool => Role::Tool {
240 name: "tool".into(),
241 detail: None,
242 is_error: false,
243 },
244 };
245 if role == Role::User
247 && !t.starts_with('/')
248 && self.input_history.last().map(|s| s.as_str()) != Some(t)
249 {
250 self.input_history.push(t.clone());
251 }
252 self.messages.push(Message {
253 role,
254 content: t.clone(),
255 });
256 }
257 matrixcode_core::MessageContent::Blocks(blocks) => {
258 for b in blocks {
260 match b {
261 matrixcode_core::ContentBlock::Text { text } => {
262 if text.is_empty() {
263 continue;
264 }
265 let role = match msg.role {
266 matrixcode_core::Role::User => Role::User,
267 matrixcode_core::Role::Assistant => Role::Assistant,
268 matrixcode_core::Role::System => Role::System,
269 matrixcode_core::Role::Tool => Role::Tool {
270 name: "tool".into(),
271 detail: None,
272 is_error: false,
273 },
274 };
275 if role == Role::User
277 && !text.starts_with('/')
278 && self.input_history.last().map(|s| s.as_str()) != Some(text)
279 {
280 self.input_history.push(text.clone());
281 }
282 self.messages.push(Message {
283 role,
284 content: text.clone(),
285 });
286 }
287 matrixcode_core::ContentBlock::Thinking { thinking, .. } => {
288 if thinking.is_empty() {
289 continue;
290 }
291 self.messages.push(Message {
293 role: Role::Thinking,
294 content: thinking.clone(),
295 });
296 }
297 matrixcode_core::ContentBlock::ToolUse { name: _, .. } => {
298 }
300 matrixcode_core::ContentBlock::ToolResult {
301 content,
302 tool_use_id,
303 ..
304 } => {
305 if content.is_empty() {
306 continue;
307 }
308 let is_error = content.contains("error")
310 || content.contains("failed")
311 || content.contains("Error");
312 let name =
314 tool_names.get(tool_use_id).cloned().unwrap_or_else(|| {
315 if tool_use_id.starts_with("bash") {
317 "bash".into()
318 } else if tool_use_id.starts_with("read") {
319 "read".into()
320 } else if tool_use_id.starts_with("write") {
321 "write".into()
322 } else if tool_use_id.starts_with("edit") {
323 "edit".into()
324 } else {
325 "tool".into()
326 }
327 });
328 self.messages.push(Message {
329 role: Role::Tool {
330 name,
331 detail: None,
332 is_error,
333 },
334 content: content.clone(),
335 });
336 }
337 _ => {}
338 }
339 }
340 }
341 }
342 }
343 if !self.messages.is_empty() {
344 self.show_welcome = false;
345 }
346 }
347
348 pub fn run(&mut self, term: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
349 loop {
350 if self.last_anim.elapsed().as_millis() >= ANIM_MS as u128 {
352 self.frame = (self.frame + 1) % 10;
353 self.last_anim = Instant::now();
354 }
355
356 term.draw(|f| self.draw(f))?;
357
358 if event::poll(Duration::from_millis(16))? {
360 match event::read()? {
361 Event::Key(k) => self.on_key(k),
362 Event::Mouse(m) => self.on_mouse(m),
363 Event::Paste(text) => self.on_paste(&text),
364 _ => {}
365 }
366 }
367
368 while let Ok(e) = self.rx.try_recv() {
370 self.on_event(e);
371 }
372
373 if self.exit {
374 break;
375 }
376 }
377 Ok(())
378 }
379 fn on_mouse(&mut self, m: MouseEvent) {
380 match m.kind {
381 MouseEventKind::ScrollUp => {
382 if self.auto_scroll {
383 self.auto_scroll = false;
384 self.scroll_offset = self.max_scroll.get().max(50);
385 }
386 self.scroll_offset = self.scroll_offset.saturating_sub(3);
387 }
388 MouseEventKind::ScrollDown => {
389 if !self.auto_scroll {
390 self.scroll_offset = self.scroll_offset.saturating_add(3);
391 let max = self.max_scroll.get();
392 if max > 0 && self.scroll_offset >= max {
393 self.auto_scroll = true;
394 self.scroll_offset = 0;
395 }
396 }
397 }
398 _ => {}
399 }
400 }
401}