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