1pub mod message_formatter;
6pub mod swarm_view;
7pub mod theme;
8pub mod theme_utils;
9pub mod token_display;
10
11use crate::config::Config;
12use crate::session::{Session, SessionEvent};
13use crate::swarm::{DecompositionStrategy, SwarmConfig, SwarmExecutor};
14use crate::tui::message_formatter::MessageFormatter;
15use crate::tui::swarm_view::{render_swarm_view, SubTaskInfo, SwarmEvent, SwarmViewState};
16use crate::tui::theme::Theme;
17use crate::tui::token_display::TokenDisplay;
18use anyhow::Result;
19use crossterm::{
20 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
21 execute,
22 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
23};
24use ratatui::{
25 Frame, Terminal,
26 backend::CrosstermBackend,
27 layout::{Constraint, Direction, Layout, Rect},
28 style::{Color, Modifier, Style},
29 text::{Line, Span},
30 widgets::{
31 Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
32 },
33};
34use std::io;
35use std::path::PathBuf;
36use tokio::sync::mpsc;
37
38pub async fn run(project: Option<PathBuf>) -> Result<()> {
40 if let Some(dir) = project {
42 std::env::set_current_dir(&dir)?;
43 }
44
45 enable_raw_mode()?;
47 let mut stdout = io::stdout();
48 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
49 let backend = CrosstermBackend::new(stdout);
50 let mut terminal = Terminal::new(backend)?;
51
52 let result = run_app(&mut terminal).await;
54
55 disable_raw_mode()?;
57 execute!(
58 terminal.backend_mut(),
59 LeaveAlternateScreen,
60 DisableMouseCapture
61 )?;
62 terminal.show_cursor()?;
63
64 result
65}
66
67#[derive(Debug, Clone)]
69enum MessageType {
70 Text(String),
71 ToolCall { name: String, arguments: String },
72 ToolResult { name: String, output: String },
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum ViewMode {
78 Chat,
79 Swarm,
80}
81
82struct App {
84 input: String,
85 cursor_position: usize,
86 messages: Vec<ChatMessage>,
87 current_agent: String,
88 scroll: usize,
89 show_help: bool,
90 command_history: Vec<String>,
91 history_index: Option<usize>,
92 session: Option<Session>,
93 is_processing: bool,
94 processing_message: Option<String>,
95 current_tool: Option<String>,
96 response_rx: Option<mpsc::Receiver<SessionEvent>>,
97 view_mode: ViewMode,
99 swarm_state: SwarmViewState,
100 swarm_rx: Option<mpsc::Receiver<SwarmEvent>>,
101}
102
103struct ChatMessage {
104 role: String,
105 content: String,
106 timestamp: String,
107 message_type: MessageType,
108}
109
110impl ChatMessage {
111 fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
112 Self {
113 role: role.into(),
114 content: content.into(),
115 timestamp: chrono::Local::now().format("%H:%M").to_string(),
116 message_type: MessageType::Text(String::new()),
117 }
118 }
119
120 fn with_message_type(mut self, message_type: MessageType) -> Self {
121 self.message_type = message_type;
122 self
123 }
124}
125
126impl App {
127 fn new() -> Self {
128 Self {
129 input: String::new(),
130 cursor_position: 0,
131 messages: vec![
132 ChatMessage::new("system", "Welcome to CodeTether Agent! Type a message to get started, or press ? for help.\n\nTip: Prefix with /swarm to run in parallel swarm mode!"),
133 ChatMessage::new("assistant", "Features:\n• Real-time tool call streaming\n• Swarm mode for parallel execution (/swarm <task>)\n• Press Tab to switch agents, ? for help\n\nExample code block:\n```rust\nfn main() {\n println!(\"Hello, World!\");\n}\n```"),
134 ],
135 current_agent: "build".to_string(),
136 scroll: 0,
137 show_help: false,
138 command_history: Vec::new(),
139 history_index: None,
140 session: None,
141 is_processing: false,
142 processing_message: None,
143 current_tool: None,
144 response_rx: None,
145 view_mode: ViewMode::Chat,
146 swarm_state: SwarmViewState::new(),
147 swarm_rx: None,
148 }
149 }
150
151 async fn submit_message(&mut self, config: &Config) {
152 if self.input.is_empty() {
153 return;
154 }
155
156 let message = std::mem::take(&mut self.input);
157 self.cursor_position = 0;
158
159 if !message.trim().is_empty() {
161 self.command_history.push(message.clone());
162 self.history_index = None;
163 }
164
165 if message.trim().starts_with("/swarm ") {
167 let task = message.trim().strip_prefix("/swarm ").unwrap_or("").to_string();
168 if task.is_empty() {
169 self.messages.push(ChatMessage::new("system", "Usage: /swarm <task description>"));
170 return;
171 }
172 self.start_swarm_execution(task, config).await;
173 return;
174 }
175
176 if message.trim() == "/view" || message.trim() == "/swarm" {
178 self.view_mode = match self.view_mode {
179 ViewMode::Chat => ViewMode::Swarm,
180 ViewMode::Swarm => ViewMode::Chat,
181 };
182 return;
183 }
184
185 self.messages.push(ChatMessage::new("user", message.clone()));
187
188 self.scroll = usize::MAX;
190
191 let current_agent = self.current_agent.clone();
192 let model = config
193 .agents
194 .get(¤t_agent)
195 .and_then(|agent| agent.model.clone())
196 .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok())
197 .or_else(|| config.default_model.clone())
198 .or_else(|| Some("zhipuai/glm-4.7".to_string()));
199
200 if self.session.is_none() {
202 match Session::new().await {
203 Ok(session) => {
204 self.session = Some(session);
205 }
206 Err(err) => {
207 tracing::error!(error = %err, "Failed to create session");
208 self.messages.push(ChatMessage::new("assistant", format!("Error: {err}")));
209 return;
210 }
211 }
212 }
213
214 let session = match self.session.as_mut() {
215 Some(session) => session,
216 None => {
217 self.messages.push(ChatMessage::new("assistant", "Error: session not initialized"));
218 return;
219 }
220 };
221
222 if let Some(model) = model {
223 session.metadata.model = Some(model);
224 }
225
226 session.agent = current_agent;
227
228 self.is_processing = true;
230 self.processing_message = Some("Thinking...".to_string());
231 self.current_tool = None;
232
233 let (tx, rx) = mpsc::channel(100);
235 self.response_rx = Some(rx);
236
237 let session_clone = session.clone();
239 let message_clone = message.clone();
240
241 tokio::spawn(async move {
243 let mut session = session_clone;
244 if let Err(err) = session.prompt_with_events(&message_clone, tx.clone()).await {
245 tracing::error!(error = %err, "Agent processing failed");
246 let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
247 let _ = tx.send(SessionEvent::Done).await;
248 }
249 });
250 }
251
252 fn handle_response(&mut self, event: SessionEvent) {
253 self.scroll = usize::MAX;
255
256 match event {
257 SessionEvent::Thinking => {
258 self.processing_message = Some("Thinking...".to_string());
259 self.current_tool = None;
260 }
261 SessionEvent::ToolCallStart { name, arguments } => {
262 self.processing_message = Some(format!("Running {}...", name));
263 self.current_tool = Some(name.clone());
264 self.messages.push(
265 ChatMessage::new("tool", format!("🔧 {}", name))
266 .with_message_type(MessageType::ToolCall { name, arguments }),
267 );
268 }
269 SessionEvent::ToolCallComplete { name, output, success } => {
270 let icon = if success { "✓" } else { "✗" };
271 self.messages.push(
272 ChatMessage::new("tool", format!("{} {}", icon, name))
273 .with_message_type(MessageType::ToolResult { name, output }),
274 );
275 self.current_tool = None;
276 self.processing_message = Some("Thinking...".to_string());
277 }
278 SessionEvent::TextChunk(_text) => {
279 }
281 SessionEvent::TextComplete(text) => {
282 if !text.is_empty() {
283 self.messages.push(ChatMessage::new("assistant", text));
284 }
285 }
286 SessionEvent::Error(err) => {
287 self.messages.push(ChatMessage::new("assistant", format!("Error: {}", err)));
288 }
289 SessionEvent::Done => {
290 self.is_processing = false;
291 self.processing_message = None;
292 self.current_tool = None;
293 self.response_rx = None;
294 }
295 }
296 }
297
298 fn handle_swarm_event(&mut self, event: SwarmEvent) {
300 self.swarm_state.handle_event(event.clone());
301
302 if let SwarmEvent::Complete { success, ref stats } = event {
304 self.view_mode = ViewMode::Chat;
305 let summary = if success {
306 format!(
307 "Swarm completed successfully.\n\
308 Subtasks: {} completed, {} failed\n\
309 Total tool calls: {}\n\
310 Time: {:.1}s (speedup: {:.1}x)",
311 stats.subagents_completed,
312 stats.subagents_failed,
313 stats.total_tool_calls,
314 stats.execution_time_ms as f64 / 1000.0,
315 stats.speedup_factor
316 )
317 } else {
318 format!(
319 "Swarm completed with failures.\n\
320 Subtasks: {} completed, {} failed\n\
321 Check the subtask results for details.",
322 stats.subagents_completed, stats.subagents_failed
323 )
324 };
325 self.messages.push(ChatMessage::new("system", &summary));
326 self.swarm_rx = None;
327 }
328
329 if let SwarmEvent::Error(ref err) = event {
330 self.messages.push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
331 }
332 }
333
334 async fn start_swarm_execution(&mut self, task: String, config: &Config) {
336 self.messages.push(ChatMessage::new("user", format!("/swarm {}", task)));
338
339 let model = config
341 .default_model
342 .clone()
343 .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok());
344
345 let swarm_config = SwarmConfig {
347 model,
348 max_subagents: 10,
349 max_steps_per_subagent: 50,
350 worktree_enabled: true,
351 worktree_auto_merge: true,
352 working_dir: Some(std::env::current_dir()
353 .map(|p| p.to_string_lossy().to_string())
354 .unwrap_or_else(|_| ".".to_string())),
355 ..Default::default()
356 };
357
358 let executor = SwarmExecutor::new(swarm_config);
359
360 let (tx, rx) = mpsc::channel(100);
362 self.swarm_rx = Some(rx);
363
364 self.view_mode = ViewMode::Swarm;
366 self.swarm_state = SwarmViewState::new();
367
368 let task_clone = task.clone();
370
371 let _ = tx.send(SwarmEvent::Started {
373 task: task.clone(),
374 total_subtasks: 0,
375 }).await;
376
377 tokio::spawn(async move {
379 let result = executor.execute(&task_clone, DecompositionStrategy::Automatic).await;
380
381 match result {
382 Ok(swarm_result) => {
383 let subtask_infos: Vec<SubTaskInfo> = swarm_result
385 .subtask_results
386 .iter()
387 .enumerate()
388 .map(|(i, r)| SubTaskInfo {
389 id: r.subtask_id.clone(),
390 name: format!("Subtask {}", i + 1),
391 status: if r.success {
392 crate::swarm::SubTaskStatus::Completed
393 } else {
394 crate::swarm::SubTaskStatus::Failed
395 },
396 stage: 0,
397 dependencies: vec![],
398 agent_name: Some(r.subagent_id.clone()),
399 current_tool: None,
400 steps: r.steps,
401 max_steps: 50,
402 })
403 .collect();
404
405 let _ = tx.send(SwarmEvent::Decomposed {
406 subtasks: subtask_infos,
407 }).await;
408
409 let _ = tx.send(SwarmEvent::Complete {
411 success: swarm_result.success,
412 stats: swarm_result.stats,
413 }).await;
414 }
415 Err(e) => {
416 let _ = tx.send(SwarmEvent::Error(e.to_string())).await;
417 }
418 }
419 });
420 }
421
422 fn navigate_history(&mut self, direction: isize) {
423 if self.command_history.is_empty() {
424 return;
425 }
426
427 let history_len = self.command_history.len();
428 let new_index = match self.history_index {
429 Some(current) => {
430 let new = current as isize + direction;
431 if new < 0 {
432 None
433 } else if new >= history_len as isize {
434 Some(history_len - 1)
435 } else {
436 Some(new as usize)
437 }
438 }
439 None => {
440 if direction > 0 {
441 Some(0)
442 } else {
443 Some(history_len.saturating_sub(1))
444 }
445 }
446 };
447
448 self.history_index = new_index;
449 if let Some(index) = new_index {
450 self.input = self.command_history[index].clone();
451 self.cursor_position = self.input.len();
452 } else {
453 self.input.clear();
454 self.cursor_position = 0;
455 }
456 }
457
458 fn search_history(&mut self) {
459 if self.command_history.is_empty() {
461 return;
462 }
463
464 let search_term = self.input.trim().to_lowercase();
465
466 if search_term.is_empty() {
467 if !self.command_history.is_empty() {
469 self.input = self.command_history.last().unwrap().clone();
470 self.cursor_position = self.input.len();
471 self.history_index = Some(self.command_history.len() - 1);
472 }
473 return;
474 }
475
476 for (index, cmd) in self.command_history.iter().enumerate().rev() {
478 if cmd.to_lowercase().starts_with(&search_term) {
479 self.input = cmd.clone();
480 self.cursor_position = self.input.len();
481 self.history_index = Some(index);
482 return;
483 }
484 }
485
486 for (index, cmd) in self.command_history.iter().enumerate().rev() {
488 if cmd.to_lowercase().contains(&search_term) {
489 self.input = cmd.clone();
490 self.cursor_position = self.input.len();
491 self.history_index = Some(index);
492 return;
493 }
494 }
495 }
496}
497
498async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
499 let mut app = App::new();
500
501 let mut config = Config::load().await?;
503 let mut theme = crate::tui::theme_utils::validate_theme(&config.load_theme());
504
505 let _config_paths = vec![
507 std::path::PathBuf::from("./codetether.toml"),
508 std::path::PathBuf::from("./.codetether/config.toml"),
509 ];
510
511 let _global_config_path = directories::ProjectDirs::from("com", "codetether", "codetether")
512 .map(|dirs| dirs.config_dir().join("config.toml"));
513
514 let mut last_check = std::time::Instant::now();
515
516 loop {
517 if config.ui.hot_reload && last_check.elapsed() > std::time::Duration::from_secs(2) {
519 if let Ok(new_config) = Config::load().await {
520 if new_config.ui.theme != config.ui.theme
521 || new_config.ui.custom_theme != config.ui.custom_theme
522 {
523 theme = crate::tui::theme_utils::validate_theme(&new_config.load_theme());
524 config = new_config;
525 }
526 }
527 last_check = std::time::Instant::now();
528 }
529
530 terminal.draw(|f| ui(f, &app, &theme))?;
531
532 if let Some(ref mut rx) = app.response_rx {
534 if let Ok(response) = rx.try_recv() {
535 app.handle_response(response);
536 }
537 }
538
539 if let Some(ref mut rx) = app.swarm_rx {
541 if let Ok(event) = rx.try_recv() {
542 app.handle_swarm_event(event);
543 }
544 }
545
546 if event::poll(std::time::Duration::from_millis(100))? {
547 if let Event::Key(key) = event::read()? {
548 if app.show_help {
550 if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
551 app.show_help = false;
552 }
553 continue;
554 }
555
556 match key.code {
557 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
559 return Ok(());
560 }
561 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
562 return Ok(());
563 }
564
565 KeyCode::Char('?') => {
567 app.show_help = true;
568 }
569
570 KeyCode::F(2) => {
572 app.view_mode = match app.view_mode {
573 ViewMode::Chat => ViewMode::Swarm,
574 ViewMode::Swarm => ViewMode::Chat,
575 };
576 }
577
578 KeyCode::Esc => {
580 if app.view_mode == ViewMode::Swarm {
581 app.view_mode = ViewMode::Chat;
582 }
583 }
584
585 KeyCode::Tab => {
587 app.current_agent = if app.current_agent == "build" {
588 "plan".to_string()
589 } else {
590 "build".to_string()
591 };
592 }
593
594 KeyCode::Enter => {
596 app.submit_message(&config).await;
597 }
598
599 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
601 app.scroll = app.scroll.saturating_add(1);
602 }
603 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
604 app.scroll = app.scroll.saturating_sub(1);
605 }
606
607 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
609 app.search_history();
610 }
611 KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
612 app.navigate_history(-1);
613 }
614 KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
615 app.navigate_history(1);
616 }
617
618 KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
620 app.scroll = 0; }
622 KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
623 app.scroll = usize::MAX;
625 }
626
627 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
629 app.scroll = app.scroll.saturating_add(5);
631 }
632 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
633 app.scroll = app.scroll.saturating_sub(5);
635 }
636
637 KeyCode::Char(c) => {
639 app.input.insert(app.cursor_position, c);
640 app.cursor_position += 1;
641 }
642 KeyCode::Backspace => {
643 if app.cursor_position > 0 {
644 app.cursor_position -= 1;
645 app.input.remove(app.cursor_position);
646 }
647 }
648 KeyCode::Delete => {
649 if app.cursor_position < app.input.len() {
650 app.input.remove(app.cursor_position);
651 }
652 }
653 KeyCode::Left => {
654 app.cursor_position = app.cursor_position.saturating_sub(1);
655 }
656 KeyCode::Right => {
657 if app.cursor_position < app.input.len() {
658 app.cursor_position += 1;
659 }
660 }
661 KeyCode::Home => {
662 app.cursor_position = 0;
663 }
664 KeyCode::End => {
665 app.cursor_position = app.input.len();
666 }
667
668 KeyCode::Up => {
670 app.scroll = app.scroll.saturating_sub(1);
671 }
672 KeyCode::Down => {
673 app.scroll = app.scroll.saturating_add(1);
674 }
675 KeyCode::PageUp => {
676 app.scroll = app.scroll.saturating_sub(10);
677 }
678 KeyCode::PageDown => {
679 app.scroll = app.scroll.saturating_add(10);
680 }
681
682 _ => {}
683 }
684 }
685 }
686 }
687}
688
689fn ui(f: &mut Frame, app: &App, theme: &Theme) {
690 if app.view_mode == ViewMode::Swarm {
692 let chunks = Layout::default()
694 .direction(Direction::Vertical)
695 .constraints([
696 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
700 .split(f.area());
701
702 render_swarm_view(f, &app.swarm_state, chunks[0]);
704
705 let input_block = Block::default()
707 .borders(Borders::ALL)
708 .title(" Press Esc or /view to return to chat ")
709 .border_style(Style::default().fg(Color::Cyan));
710
711 let input = Paragraph::new(app.input.as_str())
712 .block(input_block)
713 .wrap(Wrap { trim: false });
714 f.render_widget(input, chunks[1]);
715
716 let status = Paragraph::new(Line::from(vec![
718 Span::styled(" SWARM MODE ", Style::default().fg(Color::Black).bg(Color::Cyan)),
719 Span::raw(" | "),
720 Span::styled("Esc", Style::default().fg(Color::Yellow)),
721 Span::raw(": Back to chat | "),
722 Span::styled("F2", Style::default().fg(Color::Yellow)),
723 Span::raw(": Toggle view"),
724 ]));
725 f.render_widget(status, chunks[2]);
726 return;
727 }
728
729 let chunks = Layout::default()
731 .direction(Direction::Vertical)
732 .constraints([
733 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
737 .split(f.area());
738
739 let messages_area = chunks[0];
741 let messages_block = Block::default()
742 .borders(Borders::ALL)
743 .title(format!(" CodeTether Agent [{}] ", app.current_agent))
744 .border_style(Style::default().fg(theme.border_color.to_color()));
745
746 let mut message_lines = Vec::new();
748 let max_width = messages_area.width.saturating_sub(4) as usize;
749
750 for message in &app.messages {
751 let role_style = theme.get_role_style(&message.role);
753
754 let header_line = Line::from(vec![
755 Span::styled(
756 format!("[{}] ", message.timestamp),
757 Style::default()
758 .fg(theme.timestamp_color.to_color())
759 .add_modifier(Modifier::DIM),
760 ),
761 Span::styled(&message.role, role_style),
762 ]);
763 message_lines.push(header_line);
764
765 match &message.message_type {
767 MessageType::ToolCall { name, arguments } => {
768 let tool_header = Line::from(vec![
770 Span::styled(" 🔧 ", Style::default().fg(Color::Yellow)),
771 Span::styled(format!("Tool: {}", name), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
772 ]);
773 message_lines.push(tool_header);
774
775 let args_str = if arguments.len() > 200 {
777 format!("{}...", &arguments[..197])
778 } else {
779 arguments.clone()
780 };
781 let args_line = Line::from(vec![
782 Span::styled(" ", Style::default()),
783 Span::styled(args_str, Style::default().fg(Color::DarkGray)),
784 ]);
785 message_lines.push(args_line);
786 }
787 MessageType::ToolResult { name, output } => {
788 let result_header = Line::from(vec![
790 Span::styled(" ✅ ", Style::default().fg(Color::Green)),
791 Span::styled(format!("Result from {}", name), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
792 ]);
793 message_lines.push(result_header);
794
795 let output_str = if output.len() > 300 {
797 format!("{}... (truncated)", &output[..297])
798 } else {
799 output.clone()
800 };
801 let output_lines: Vec<&str> = output_str.lines().collect();
802 for line in output_lines.iter().take(5) {
803 let output_line = Line::from(vec![
804 Span::styled(" ", Style::default()),
805 Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
806 ]);
807 message_lines.push(output_line);
808 }
809 if output_lines.len() > 5 {
810 message_lines.push(Line::from(vec![
811 Span::styled(" ", Style::default()),
812 Span::styled(format!("... and {} more lines", output_lines.len() - 5), Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM)),
813 ]));
814 }
815 }
816 _ => {
817 let formatter = MessageFormatter::new(max_width);
819 let formatted_content = formatter.format_content(&message.content, &message.role);
820 message_lines.extend(formatted_content);
821 }
822 }
823
824 message_lines.push(Line::from(""));
826 }
827
828 if app.is_processing {
830 let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
831 let spinner_idx = (std::time::SystemTime::now()
832 .duration_since(std::time::UNIX_EPOCH)
833 .unwrap_or_default()
834 .as_millis() / 100) as usize % spinner.len();
835
836 let processing_line = Line::from(vec![
837 Span::styled(
838 format!("[{}] ", chrono::Local::now().format("%H:%M")),
839 Style::default()
840 .fg(theme.timestamp_color.to_color())
841 .add_modifier(Modifier::DIM),
842 ),
843 Span::styled("assistant", theme.get_role_style("assistant")),
844 ]);
845 message_lines.push(processing_line);
846
847 let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
849 (format!(" {} Running: {}", spinner[spinner_idx], tool), Color::Cyan)
850 } else {
851 (format!(" {} {}", spinner[spinner_idx], app.processing_message.as_deref().unwrap_or("Thinking...")), Color::Yellow)
852 };
853
854 let indicator_line = Line::from(vec![
855 Span::styled(status_text, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
856 ]);
857 message_lines.push(indicator_line);
858 message_lines.push(Line::from(""));
859 }
860
861 let total_lines = message_lines.len();
863 let visible_lines = messages_area.height.saturating_sub(2) as usize;
864 let max_scroll = total_lines.saturating_sub(visible_lines);
865 let scroll = app.scroll.min(max_scroll);
866
867 let messages_paragraph = Paragraph::new(
869 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
870 )
871 .block(messages_block.clone())
872 .wrap(Wrap { trim: false });
873
874 f.render_widget(messages_paragraph, messages_area);
875
876 if total_lines > visible_lines {
878 let scrollbar = Scrollbar::default()
879 .orientation(ScrollbarOrientation::VerticalRight)
880 .symbols(ratatui::symbols::scrollbar::VERTICAL)
881 .begin_symbol(Some("↑"))
882 .end_symbol(Some("↓"));
883
884 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
885
886 let scrollbar_area = Rect::new(
887 messages_area.right() - 1,
888 messages_area.top() + 1,
889 1,
890 messages_area.height - 2,
891 );
892
893 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
894 }
895
896 let input_block = Block::default()
898 .borders(Borders::ALL)
899 .title(if app.is_processing {
900 " Message (Processing...) "
901 } else {
902 " Message (Enter to send) "
903 })
904 .border_style(Style::default().fg(if app.is_processing {
905 Color::Yellow
906 } else {
907 theme.input_border_color.to_color()
908 }));
909
910 let input = Paragraph::new(app.input.as_str())
911 .block(input_block)
912 .wrap(Wrap { trim: false });
913 f.render_widget(input, chunks[1]);
914
915 f.set_cursor_position((
917 chunks[1].x + app.cursor_position as u16 + 1,
918 chunks[1].y + 1,
919 ));
920
921 let token_display = TokenDisplay::new();
923 let status = Paragraph::new(token_display.create_status_bar(theme));
924 f.render_widget(status, chunks[2]);
925
926 if app.show_help {
928 let area = centered_rect(60, 60, f.area());
929 f.render_widget(Clear, area);
930
931 let token_display = TokenDisplay::new();
933 let token_info = token_display.create_detailed_display();
934
935 let help_text: Vec<String> = vec![
936 "".to_string(),
937 " KEYBOARD SHORTCUTS".to_string(),
938 " ==================".to_string(),
939 "".to_string(),
940 " Enter Send message".to_string(),
941 " Tab Switch between build/plan agents".to_string(),
942 " Ctrl+C Quit".to_string(),
943 " ? Toggle this help".to_string(),
944 "".to_string(),
945 " VIM-STYLE NAVIGATION".to_string(),
946 " Alt+j Scroll down".to_string(),
947 " Alt+k Scroll up".to_string(),
948 " Ctrl+g Go to top".to_string(),
949 " Ctrl+G Go to bottom".to_string(),
950 "".to_string(),
951 " SCROLLING".to_string(),
952 " Up/Down Scroll messages".to_string(),
953 " PageUp Scroll up one page".to_string(),
954 " PageDown Scroll down one page".to_string(),
955 " Alt+u Scroll up half page".to_string(),
956 " Alt+d Scroll down half page".to_string(),
957 "".to_string(),
958 " COMMAND HISTORY".to_string(),
959 " Ctrl+R Search history (matches current input)".to_string(),
960 " Ctrl+Up Previous command".to_string(),
961 " Ctrl+Down Next command".to_string(),
962 "".to_string(),
963 " TEXT EDITING".to_string(),
964 " Left/Right Move cursor".to_string(),
965 " Home/End Jump to start/end".to_string(),
966 " Backspace Delete backwards".to_string(),
967 " Delete Delete forwards".to_string(),
968 "".to_string(),
969 " AGENTS".to_string(),
970 " ======".to_string(),
971 "".to_string(),
972 " build Full access for development work".to_string(),
973 " plan Read-only for analysis & exploration".to_string(),
974 "".to_string(),
975 " Press ? or Esc to close".to_string(),
976 "".to_string(),
977 ];
978
979 let mut combined_text = token_info;
980 combined_text.extend(help_text);
981
982 let help = Paragraph::new(combined_text.join("\n"))
983 .block(
984 Block::default()
985 .borders(Borders::ALL)
986 .title(" Help ")
987 .border_style(Style::default().fg(theme.help_border_color.to_color())),
988 )
989 .wrap(Wrap { trim: false });
990
991 f.render_widget(help, area);
992 }
993}
994
995fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
997 let popup_layout = Layout::default()
998 .direction(Direction::Vertical)
999 .constraints([
1000 Constraint::Percentage((100 - percent_y) / 2),
1001 Constraint::Percentage(percent_y),
1002 Constraint::Percentage((100 - percent_y) / 2),
1003 ])
1004 .split(r);
1005
1006 Layout::default()
1007 .direction(Direction::Horizontal)
1008 .constraints([
1009 Constraint::Percentage((100 - percent_x) / 2),
1010 Constraint::Percentage(percent_x),
1011 Constraint::Percentage((100 - percent_x) / 2),
1012 ])
1013 .split(popup_layout[1])[1]
1014}