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