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}
93
94#[derive(Clone)]
96#[allow(dead_code)] pub struct TodoItem {
98 pub content: String,
99 pub status: String, }
101
102#[derive(Clone)]
104pub struct LoopTask {
105 pub message: String,
106 pub interval_secs: u64,
107 pub count: u64,
108 pub max_count: Option<u64>,
109 pub cancel_token: CancellationToken,
110}
111
112#[derive(Clone)]
114pub struct CronTask {
115 pub id: usize,
116 pub message: String,
117 pub minute_interval: u64, #[allow(dead_code)]
119 pub next_run: Instant, pub cancel_token: CancellationToken,
121}
122
123impl TuiApp {
124 pub fn new(
125 tx: tokio::sync::mpsc::Sender<String>,
126 rx: tokio::sync::mpsc::Receiver<AgentEvent>,
127 cancel: CancellationToken,
128 ) -> Self {
129 Self {
130 activity: Activity::Idle,
131 activity_detail: String::new(),
132 activity_input: None,
133 messages: Vec::new(),
134 thinking: String::new(),
135 streaming: String::new(),
136 input: String::new(),
137 model: "claude-sonnet-4".into(),
138 tokens_in: 0,
139 tokens_out: 0,
140 session_total_out: 0,
141 current_request_tokens: 0,
142 cache_read: 0,
143 cache_created: 0,
144 context_size: 200_000,
145 api_calls: 0,
146 compressions: 0,
147 memory_saves: 0,
148 tool_calls: 0,
149 request_start: None,
150 tool_start: None,
151 frame: 0,
152 last_anim: Instant::now(),
153 show_welcome: true,
154 exit: false,
155 cursor_pos: 0,
156 input_history: Vec::new(),
157 history_index: None,
158 history_draft: String::new(),
159 scroll_offset: 0,
160 auto_scroll: true,
161 max_scroll: std::cell::Cell::new(0),
162 new_message_while_scrolled: std::cell::Cell::new(false),
163 thinking_collapsed: false, dirty: std::cell::Cell::new(true), approve_mode: ApproveMode::Ask,
166 shared_approve_mode: None,
167 ask_tx: None,
168 waiting_for_ask: false,
169 ask_options: Vec::new(),
170 ask_selected_index: 0,
171 ask_multi_select: false,
172 ask_submit_mode: SubmitMode::default(),
173 ask_other_input_active: false,
174 ask_questions: Vec::new(),
175 current_question_idx: 0,
176 todo_items: Vec::new(),
177 tx,
178 rx,
179 cancel,
180 pending_messages: Vec::new(),
181 loop_task: None,
182 cron_tasks: Vec::new(),
183 debug_mode: false,
184 }
185 }
186
187 pub fn with_ask_channel(mut self, ask_tx: tokio::sync::mpsc::Sender<String>) -> Self {
188 self.ask_tx = Some(ask_tx);
189 self
190 }
191
192 pub fn with_shared_approve_mode(
194 mut self,
195 shared: std::sync::Arc<std::sync::atomic::AtomicU8>,
196 ) -> Self {
197 self.shared_approve_mode = Some(shared);
198 self
199 }
200
201 pub fn with_config(
202 mut self,
203 model: &str,
204 _think: bool,
205 _max_tokens: u32,
206 context_size: Option<u64>,
207 ) -> Self {
208 self.model = model.to_string();
209 self.context_size = context_size.unwrap_or_else(|| {
210 let m = model.to_ascii_lowercase();
211 if m.contains("1m") || m.contains("opus-4-7") {
212 1_000_000
213 } else if m.contains("claude-3")
214 || m.contains("claude-4")
215 || m.contains("claude-sonnet")
216 {
217 200_000
218 } else {
219 128_000
220 }
221 });
222 self
223 }
224
225 pub fn with_debug_mode(mut self, debug_mode: bool) -> Self {
227 self.debug_mode = debug_mode;
228 self
229 }
230
231 pub fn load_messages(&mut self, core_messages: Vec<matrixcode_core::Message>) {
232 let mut tool_names: HashMap<String, String> = HashMap::new();
234
235 for msg in &core_messages {
237 if let matrixcode_core::MessageContent::Blocks(blocks) = &msg.content {
238 for b in blocks {
239 if let matrixcode_core::ContentBlock::ToolUse { id, name, .. } = b {
240 tool_names.insert(id.clone(), name.clone());
241 }
242 }
243 }
244 }
245
246 for msg in core_messages {
248 match &msg.content {
250 matrixcode_core::MessageContent::Text(t) => {
251 if t.is_empty() {
252 continue;
253 }
254 let role = match msg.role {
255 matrixcode_core::Role::User => Role::User,
256 matrixcode_core::Role::Assistant => Role::Assistant,
257 matrixcode_core::Role::System => Role::System,
258 matrixcode_core::Role::Tool => Role::Tool {
259 name: "tool".into(),
260 detail: None,
261 is_error: false,
262 },
263 };
264 if role == Role::User
266 && !t.starts_with('/')
267 && self.input_history.last().map(|s| s.as_str()) != Some(t)
268 {
269 self.input_history.push(t.clone());
270 }
271 self.messages.push(Message {
272 role,
273 content: t.clone(),
274 });
275 }
276 matrixcode_core::MessageContent::Blocks(blocks) => {
277 for b in blocks {
279 match b {
280 matrixcode_core::ContentBlock::Text { text } => {
281 if text.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 && !text.starts_with('/')
297 && self.input_history.last().map(|s| s.as_str()) != Some(text)
298 {
299 self.input_history.push(text.clone());
300 }
301 self.messages.push(Message {
302 role,
303 content: text.clone(),
304 });
305 }
306 matrixcode_core::ContentBlock::Thinking { thinking, .. } => {
307 if thinking.is_empty() {
308 continue;
309 }
310 self.messages.push(Message {
312 role: Role::Thinking,
313 content: thinking.clone(),
314 });
315 }
316 matrixcode_core::ContentBlock::ToolUse { name: _, .. } => {
317 }
319 matrixcode_core::ContentBlock::ToolResult {
320 content,
321 tool_use_id,
322 ..
323 } => {
324 if content.is_empty() {
325 continue;
326 }
327 let is_error = content.contains("error")
329 || content.contains("failed")
330 || content.contains("Error");
331 let name =
333 tool_names.get(tool_use_id).cloned().unwrap_or_else(|| {
334 if tool_use_id.starts_with("bash") {
336 "bash".into()
337 } else if tool_use_id.starts_with("read") {
338 "read".into()
339 } else if tool_use_id.starts_with("write") {
340 "write".into()
341 } else if tool_use_id.starts_with("edit") {
342 "edit".into()
343 } else {
344 "tool".into()
345 }
346 });
347 self.messages.push(Message {
348 role: Role::Tool {
349 name,
350 detail: None,
351 is_error,
352 },
353 content: content.clone(),
354 });
355 }
356 _ => {}
357 }
358 }
359 }
360 }
361 }
362 if !self.messages.is_empty() {
363 self.show_welcome = false;
364 }
365 }
366
367 pub fn set_token_stats(&mut self, input_tokens: u64, total_output_tokens: u64, _message_count: usize) {
369 self.tokens_in = input_tokens;
370 self.session_total_out = total_output_tokens;
371 }
372
373 pub fn run(&mut self, term: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
374 loop {
375 let anim_update = self.last_anim.elapsed().as_millis() >= ANIM_MS as u128;
378 if anim_update {
379 self.frame = (self.frame + 1) % 10;
380 self.last_anim = Instant::now();
381 self.dirty.set(true);
382 }
383
384 if event::poll(Duration::from_millis(ANIM_MS as u64))? {
386 match event::read()? {
387 Event::Key(k) => {
388 self.on_key(k);
389 self.dirty.set(true);
390 }
391 Event::Mouse(m) => {
392 self.on_mouse(m);
393 self.dirty.set(true);
394 }
395 Event::Paste(text) => {
396 self.on_paste(&text);
397 self.dirty.set(true);
398 }
399 _ => {}
400 }
401 }
402
403 let mut had_event = false;
405 while let Ok(e) = self.rx.try_recv() {
406 log::debug!("TUI received event: type={:?}", e.event_type);
407 self.on_event(e);
408 had_event = true;
409 }
410 if had_event {
411 log::debug!("TUI: had events, marking dirty");
412 self.dirty.set(true);
413 }
414
415 if self.dirty.get() {
417 term.draw(|f| self.draw(f))?;
418 self.dirty.set(false);
419 }
420
421 if self.exit {
422 break;
423 }
424 }
425 Ok(())
426 }
427 fn on_mouse(&mut self, m: MouseEvent) {
428 if m.modifiers.contains(event::KeyModifiers::SHIFT) {
430 return;
431 }
432
433 match m.kind {
434 MouseEventKind::ScrollUp => {
435 if self.auto_scroll {
436 self.auto_scroll = false;
437 self.scroll_offset = self.max_scroll.get().max(50);
438 }
439 self.scroll_offset = self.scroll_offset.saturating_sub(3);
440 }
441 MouseEventKind::ScrollDown => {
442 if !self.auto_scroll {
443 self.scroll_offset = self.scroll_offset.saturating_add(3);
444 let max = self.max_scroll.get();
445 if max > 0 && self.scroll_offset >= max {
446 self.auto_scroll = true;
447 self.scroll_offset = 0;
448 }
449 }
450 }
451 _ => {}
452 }
453 }
454}