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::provider::{ContentPart, Role};
13use crate::session::{list_sessions, Session, SessionEvent};
14use crate::swarm::{DecompositionStrategy, Orchestrator, SwarmConfig, SwarmExecutor, SwarmStats};
15use crate::tui::message_formatter::MessageFormatter;
16use crate::tui::swarm_view::{render_swarm_view, SubTaskInfo, SwarmEvent, SwarmViewState};
17use crate::tui::theme::Theme;
18use crate::tui::token_display::TokenDisplay;
19use anyhow::Result;
20use crossterm::{
21 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
22 execute,
23 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
24};
25use ratatui::{
26 Frame, Terminal,
27 backend::CrosstermBackend,
28 layout::{Constraint, Direction, Layout, Rect},
29 style::{Color, Modifier, Style},
30 text::{Line, Span},
31 widgets::{
32 Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
33 },
34};
35use std::io;
36use std::path::PathBuf;
37use tokio::sync::mpsc;
38
39pub async fn run(project: Option<PathBuf>) -> Result<()> {
41 if let Some(dir) = project {
43 std::env::set_current_dir(&dir)?;
44 }
45
46 enable_raw_mode()?;
48 let mut stdout = io::stdout();
49 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
50 let backend = CrosstermBackend::new(stdout);
51 let mut terminal = Terminal::new(backend)?;
52
53 let result = run_app(&mut terminal).await;
55
56 disable_raw_mode()?;
58 execute!(
59 terminal.backend_mut(),
60 LeaveAlternateScreen,
61 DisableMouseCapture
62 )?;
63 terminal.show_cursor()?;
64
65 result
66}
67
68#[derive(Debug, Clone)]
70enum MessageType {
71 Text(String),
72 ToolCall { name: String, arguments: String },
73 ToolResult { name: String, output: String },
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum ViewMode {
79 Chat,
80 Swarm,
81}
82
83struct App {
85 input: String,
86 cursor_position: usize,
87 messages: Vec<ChatMessage>,
88 current_agent: String,
89 scroll: usize,
90 show_help: bool,
91 command_history: Vec<String>,
92 history_index: Option<usize>,
93 session: Option<Session>,
94 is_processing: bool,
95 processing_message: Option<String>,
96 current_tool: Option<String>,
97 response_rx: Option<mpsc::Receiver<SessionEvent>>,
98 view_mode: ViewMode,
100 swarm_state: SwarmViewState,
101 swarm_rx: Option<mpsc::Receiver<SwarmEvent>>,
102}
103
104struct ChatMessage {
105 role: String,
106 content: String,
107 timestamp: String,
108 message_type: MessageType,
109}
110
111impl ChatMessage {
112 fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
113 Self {
114 role: role.into(),
115 content: content.into(),
116 timestamp: chrono::Local::now().format("%H:%M").to_string(),
117 message_type: MessageType::Text(String::new()),
118 }
119 }
120
121 fn with_message_type(mut self, message_type: MessageType) -> Self {
122 self.message_type = message_type;
123 self
124 }
125}
126
127impl App {
128 fn new() -> Self {
129 Self {
130 input: String::new(),
131 cursor_position: 0,
132 messages: vec![
133 ChatMessage::new("system", "Welcome to CodeTether Agent! Press ? for help."),
134 ChatMessage::new("assistant", "Quick start:\n• Type a message to chat with the AI\n• /swarm <task> - parallel execution\n• /resume - continue last session\n• /sessions - list saved sessions\n• Tab - switch agents | ? - help"),
135 ],
136 current_agent: "build".to_string(),
137 scroll: 0,
138 show_help: false,
139 command_history: Vec::new(),
140 history_index: None,
141 session: None,
142 is_processing: false,
143 processing_message: None,
144 current_tool: None,
145 response_rx: None,
146 view_mode: ViewMode::Chat,
147 swarm_state: SwarmViewState::new(),
148 swarm_rx: None,
149 }
150 }
151
152 async fn submit_message(&mut self, config: &Config) {
153 if self.input.is_empty() {
154 return;
155 }
156
157 let message = std::mem::take(&mut self.input);
158 self.cursor_position = 0;
159
160 if !message.trim().is_empty() {
162 self.command_history.push(message.clone());
163 self.history_index = None;
164 }
165
166 if message.trim().starts_with("/swarm ") {
168 let task = message.trim().strip_prefix("/swarm ").unwrap_or("").to_string();
169 if task.is_empty() {
170 self.messages.push(ChatMessage::new("system", "Usage: /swarm <task description>"));
171 return;
172 }
173 self.start_swarm_execution(task, config).await;
174 return;
175 }
176
177 if message.trim() == "/view" || message.trim() == "/swarm" {
179 self.view_mode = match self.view_mode {
180 ViewMode::Chat => ViewMode::Swarm,
181 ViewMode::Swarm => ViewMode::Chat,
182 };
183 return;
184 }
185
186 if message.trim() == "/sessions" {
188 match list_sessions().await {
189 Ok(sessions) => {
190 if sessions.is_empty() {
191 self.messages.push(ChatMessage::new("system", "No saved sessions found."));
192 } else {
193 let mut output = String::from("Recent sessions:\n\n");
194 for (i, s) in sessions.iter().take(10).enumerate() {
195 let title = s.title.as_deref().unwrap_or("(untitled)");
196 let date = s.updated_at.format("%Y-%m-%d %H:%M");
197 output.push_str(&format!(
198 "{}. {} - {} ({} msgs)\n ID: {}\n\n",
199 i + 1, title, date, s.message_count, s.id
200 ));
201 }
202 output.push_str("Use /resume to load the last session, or /resume <id> to load a specific one.");
203 self.messages.push(ChatMessage::new("system", output));
204 }
205 }
206 Err(e) => {
207 self.messages.push(ChatMessage::new("system", format!("Failed to list sessions: {}", e)));
208 }
209 }
210 return;
211 }
212
213 if message.trim() == "/resume" || message.trim().starts_with("/resume ") {
215 let session_id = message.trim().strip_prefix("/resume").map(|s| s.trim()).filter(|s| !s.is_empty());
216
217 let loaded = if let Some(id) = session_id {
218 Session::load(id).await
219 } else {
220 Session::last().await
221 };
222
223 match loaded {
224 Ok(session) => {
225 self.messages.clear();
227 self.messages.push(ChatMessage::new("system", format!(
228 "Resumed session: {}\nCreated: {}\n{} messages loaded",
229 session.title.as_deref().unwrap_or("(untitled)"),
230 session.created_at.format("%Y-%m-%d %H:%M"),
231 session.messages.len()
232 )));
233
234 for msg in &session.messages {
235 let role_str = match msg.role {
236 Role::System => "system",
237 Role::User => "user",
238 Role::Assistant => "assistant",
239 Role::Tool => "tool",
240 };
241
242 let content: String = msg.content.iter()
244 .filter_map(|part| match part {
245 ContentPart::Text { text } => Some(text.clone()),
246 ContentPart::ToolCall { name, arguments, .. } => {
247 Some(format!("[Tool: {}]\n{}", name, arguments))
248 }
249 ContentPart::ToolResult { content, .. } => {
250 let truncated = if content.len() > 500 {
251 format!("{}...", &content[..497])
252 } else {
253 content.clone()
254 };
255 Some(format!("[Result]\n{}", truncated))
256 }
257 _ => None,
258 })
259 .collect::<Vec<_>>()
260 .join("\n");
261
262 if !content.is_empty() {
263 self.messages.push(ChatMessage::new(role_str, content));
264 }
265 }
266
267 self.current_agent = session.agent.clone();
268 self.session = Some(session);
269 self.scroll = usize::MAX;
270 }
271 Err(e) => {
272 self.messages.push(ChatMessage::new("system", format!("Failed to load session: {}", e)));
273 }
274 }
275 return;
276 }
277
278 if message.trim() == "/new" {
280 self.session = None;
281 self.messages.clear();
282 self.messages.push(ChatMessage::new("system", "Started a new session. Previous session was saved."));
283 return;
284 }
285
286 self.messages.push(ChatMessage::new("user", message.clone()));
288
289 self.scroll = usize::MAX;
291
292 let current_agent = self.current_agent.clone();
293 let model = config
294 .agents
295 .get(¤t_agent)
296 .and_then(|agent| agent.model.clone())
297 .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok())
298 .or_else(|| config.default_model.clone())
299 .or_else(|| Some("zhipuai/glm-4.7".to_string()));
300
301 if self.session.is_none() {
303 match Session::new().await {
304 Ok(session) => {
305 self.session = Some(session);
306 }
307 Err(err) => {
308 tracing::error!(error = %err, "Failed to create session");
309 self.messages.push(ChatMessage::new("assistant", format!("Error: {err}")));
310 return;
311 }
312 }
313 }
314
315 let session = match self.session.as_mut() {
316 Some(session) => session,
317 None => {
318 self.messages.push(ChatMessage::new("assistant", "Error: session not initialized"));
319 return;
320 }
321 };
322
323 if let Some(model) = model {
324 session.metadata.model = Some(model);
325 }
326
327 session.agent = current_agent;
328
329 self.is_processing = true;
331 self.processing_message = Some("Thinking...".to_string());
332 self.current_tool = None;
333
334 let (tx, rx) = mpsc::channel(100);
336 self.response_rx = Some(rx);
337
338 let session_clone = session.clone();
340 let message_clone = message.clone();
341
342 tokio::spawn(async move {
344 let mut session = session_clone;
345 if let Err(err) = session.prompt_with_events(&message_clone, tx.clone()).await {
346 tracing::error!(error = %err, "Agent processing failed");
347 let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
348 let _ = tx.send(SessionEvent::Done).await;
349 }
350 });
351 }
352
353 fn handle_response(&mut self, event: SessionEvent) {
354 self.scroll = usize::MAX;
356
357 match event {
358 SessionEvent::Thinking => {
359 self.processing_message = Some("Thinking...".to_string());
360 self.current_tool = None;
361 }
362 SessionEvent::ToolCallStart { name, arguments } => {
363 self.processing_message = Some(format!("Running {}...", name));
364 self.current_tool = Some(name.clone());
365 self.messages.push(
366 ChatMessage::new("tool", format!("🔧 {}", name))
367 .with_message_type(MessageType::ToolCall { name, arguments }),
368 );
369 }
370 SessionEvent::ToolCallComplete { name, output, success } => {
371 let icon = if success { "✓" } else { "✗" };
372 self.messages.push(
373 ChatMessage::new("tool", format!("{} {}", icon, name))
374 .with_message_type(MessageType::ToolResult { name, output }),
375 );
376 self.current_tool = None;
377 self.processing_message = Some("Thinking...".to_string());
378 }
379 SessionEvent::TextChunk(_text) => {
380 }
382 SessionEvent::TextComplete(text) => {
383 if !text.is_empty() {
384 self.messages.push(ChatMessage::new("assistant", text));
385 }
386 }
387 SessionEvent::Error(err) => {
388 self.messages.push(ChatMessage::new("assistant", format!("Error: {}", err)));
389 }
390 SessionEvent::Done => {
391 self.is_processing = false;
392 self.processing_message = None;
393 self.current_tool = None;
394 self.response_rx = None;
395 }
396 }
397 }
398
399 fn handle_swarm_event(&mut self, event: SwarmEvent) {
401 self.swarm_state.handle_event(event.clone());
402
403 if let SwarmEvent::Complete { success, ref stats } = event {
405 self.view_mode = ViewMode::Chat;
406 let summary = if success {
407 format!(
408 "Swarm completed successfully.\n\
409 Subtasks: {} completed, {} failed\n\
410 Total tool calls: {}\n\
411 Time: {:.1}s (speedup: {:.1}x)",
412 stats.subagents_completed,
413 stats.subagents_failed,
414 stats.total_tool_calls,
415 stats.execution_time_ms as f64 / 1000.0,
416 stats.speedup_factor
417 )
418 } else {
419 format!(
420 "Swarm completed with failures.\n\
421 Subtasks: {} completed, {} failed\n\
422 Check the subtask results for details.",
423 stats.subagents_completed, stats.subagents_failed
424 )
425 };
426 self.messages.push(ChatMessage::new("system", &summary));
427 self.swarm_rx = None;
428 }
429
430 if let SwarmEvent::Error(ref err) = event {
431 self.messages.push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
432 }
433 }
434
435 async fn start_swarm_execution(&mut self, task: String, config: &Config) {
437 self.messages.push(ChatMessage::new("user", format!("/swarm {}", task)));
439
440 let model = config
442 .default_model
443 .clone()
444 .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok());
445
446 let swarm_config = SwarmConfig {
448 model,
449 max_subagents: 10,
450 max_steps_per_subagent: 50,
451 worktree_enabled: true,
452 worktree_auto_merge: true,
453 working_dir: Some(std::env::current_dir()
454 .map(|p| p.to_string_lossy().to_string())
455 .unwrap_or_else(|_| ".".to_string())),
456 ..Default::default()
457 };
458
459 let (tx, rx) = mpsc::channel(100);
461 self.swarm_rx = Some(rx);
462
463 self.view_mode = ViewMode::Swarm;
465 self.swarm_state = SwarmViewState::new();
466
467 let task_clone = task.clone();
469
470 let _ = tx.send(SwarmEvent::Started {
472 task: task.clone(),
473 total_subtasks: 0,
474 }).await;
475
476 tokio::spawn(async move {
478 let orchestrator_result = Orchestrator::new(swarm_config.clone()).await;
480
481 let mut orchestrator = match orchestrator_result {
482 Ok(o) => o,
483 Err(e) => {
484 let _ = tx.send(SwarmEvent::Error(format!("Failed to create orchestrator: {}", e))).await;
485 return;
486 }
487 };
488
489 let subtasks = match orchestrator.decompose(&task_clone, DecompositionStrategy::Automatic).await {
491 Ok(subtasks) => subtasks,
492 Err(e) => {
493 let _ = tx.send(SwarmEvent::Error(format!("Decomposition failed: {}", e))).await;
494 return;
495 }
496 };
497
498 let subtask_infos: Vec<SubTaskInfo> = subtasks
500 .iter()
501 .map(|s| SubTaskInfo {
502 id: s.id.clone(),
503 name: s.name.clone(),
504 status: crate::swarm::SubTaskStatus::Pending,
505 stage: s.stage,
506 dependencies: s.dependencies.clone(),
507 agent_name: s.specialty.clone(),
508 current_tool: None,
509 steps: 0,
510 max_steps: 50,
511 })
512 .collect();
513
514 let _ = tx.send(SwarmEvent::Decomposed {
515 subtasks: subtask_infos,
516 }).await;
517
518 let executor = SwarmExecutor::new(swarm_config);
520 let result = executor.execute(&task_clone, DecompositionStrategy::Automatic).await;
521
522 match result {
523 Ok(swarm_result) => {
524 let _ = tx.send(SwarmEvent::Complete {
526 success: swarm_result.success,
527 stats: swarm_result.stats,
528 }).await;
529 }
530 Err(e) => {
531 let _ = tx.send(SwarmEvent::Error(e.to_string())).await;
532 }
533 }
534 });
535 }
536
537 fn navigate_history(&mut self, direction: isize) {
538 if self.command_history.is_empty() {
539 return;
540 }
541
542 let history_len = self.command_history.len();
543 let new_index = match self.history_index {
544 Some(current) => {
545 let new = current as isize + direction;
546 if new < 0 {
547 None
548 } else if new >= history_len as isize {
549 Some(history_len - 1)
550 } else {
551 Some(new as usize)
552 }
553 }
554 None => {
555 if direction > 0 {
556 Some(0)
557 } else {
558 Some(history_len.saturating_sub(1))
559 }
560 }
561 };
562
563 self.history_index = new_index;
564 if let Some(index) = new_index {
565 self.input = self.command_history[index].clone();
566 self.cursor_position = self.input.len();
567 } else {
568 self.input.clear();
569 self.cursor_position = 0;
570 }
571 }
572
573 fn search_history(&mut self) {
574 if self.command_history.is_empty() {
576 return;
577 }
578
579 let search_term = self.input.trim().to_lowercase();
580
581 if search_term.is_empty() {
582 if !self.command_history.is_empty() {
584 self.input = self.command_history.last().unwrap().clone();
585 self.cursor_position = self.input.len();
586 self.history_index = Some(self.command_history.len() - 1);
587 }
588 return;
589 }
590
591 for (index, cmd) in self.command_history.iter().enumerate().rev() {
593 if cmd.to_lowercase().starts_with(&search_term) {
594 self.input = cmd.clone();
595 self.cursor_position = self.input.len();
596 self.history_index = Some(index);
597 return;
598 }
599 }
600
601 for (index, cmd) in self.command_history.iter().enumerate().rev() {
603 if cmd.to_lowercase().contains(&search_term) {
604 self.input = cmd.clone();
605 self.cursor_position = self.input.len();
606 self.history_index = Some(index);
607 return;
608 }
609 }
610 }
611}
612
613async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
614 let mut app = App::new();
615
616 let mut config = Config::load().await?;
618 let mut theme = crate::tui::theme_utils::validate_theme(&config.load_theme());
619
620 let _config_paths = vec![
622 std::path::PathBuf::from("./codetether.toml"),
623 std::path::PathBuf::from("./.codetether/config.toml"),
624 ];
625
626 let _global_config_path = directories::ProjectDirs::from("com", "codetether", "codetether")
627 .map(|dirs| dirs.config_dir().join("config.toml"));
628
629 let mut last_check = std::time::Instant::now();
630
631 loop {
632 if config.ui.hot_reload && last_check.elapsed() > std::time::Duration::from_secs(2) {
634 if let Ok(new_config) = Config::load().await {
635 if new_config.ui.theme != config.ui.theme
636 || new_config.ui.custom_theme != config.ui.custom_theme
637 {
638 theme = crate::tui::theme_utils::validate_theme(&new_config.load_theme());
639 config = new_config;
640 }
641 }
642 last_check = std::time::Instant::now();
643 }
644
645 terminal.draw(|f| ui(f, &app, &theme))?;
646
647 if let Some(ref mut rx) = app.response_rx {
649 if let Ok(response) = rx.try_recv() {
650 app.handle_response(response);
651 }
652 }
653
654 if let Some(ref mut rx) = app.swarm_rx {
656 if let Ok(event) = rx.try_recv() {
657 app.handle_swarm_event(event);
658 }
659 }
660
661 if event::poll(std::time::Duration::from_millis(100))? {
662 if let Event::Key(key) = event::read()? {
663 if app.show_help {
665 if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
666 app.show_help = false;
667 }
668 continue;
669 }
670
671 match key.code {
672 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
674 return Ok(());
675 }
676 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
677 return Ok(());
678 }
679
680 KeyCode::Char('?') => {
682 app.show_help = true;
683 }
684
685 KeyCode::F(2) => {
687 app.view_mode = match app.view_mode {
688 ViewMode::Chat => ViewMode::Swarm,
689 ViewMode::Swarm => ViewMode::Chat,
690 };
691 }
692 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
693 app.view_mode = match app.view_mode {
694 ViewMode::Chat => ViewMode::Swarm,
695 ViewMode::Swarm => ViewMode::Chat,
696 };
697 }
698
699 KeyCode::Esc => {
701 if app.view_mode == ViewMode::Swarm {
702 app.view_mode = ViewMode::Chat;
703 }
704 }
705
706 KeyCode::Tab => {
708 app.current_agent = if app.current_agent == "build" {
709 "plan".to_string()
710 } else {
711 "build".to_string()
712 };
713 }
714
715 KeyCode::Enter => {
717 app.submit_message(&config).await;
718 }
719
720 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
722 app.scroll = app.scroll.saturating_add(1);
723 }
724 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
725 app.scroll = app.scroll.saturating_sub(1);
726 }
727
728 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
730 app.search_history();
731 }
732 KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
733 app.navigate_history(-1);
734 }
735 KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
736 app.navigate_history(1);
737 }
738
739 KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
741 app.scroll = 0; }
743 KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
744 app.scroll = usize::MAX;
746 }
747
748 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
750 app.scroll = app.scroll.saturating_add(5);
752 }
753 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
754 app.scroll = app.scroll.saturating_sub(5);
756 }
757
758 KeyCode::Char(c) => {
760 app.input.insert(app.cursor_position, c);
761 app.cursor_position += 1;
762 }
763 KeyCode::Backspace => {
764 if app.cursor_position > 0 {
765 app.cursor_position -= 1;
766 app.input.remove(app.cursor_position);
767 }
768 }
769 KeyCode::Delete => {
770 if app.cursor_position < app.input.len() {
771 app.input.remove(app.cursor_position);
772 }
773 }
774 KeyCode::Left => {
775 app.cursor_position = app.cursor_position.saturating_sub(1);
776 }
777 KeyCode::Right => {
778 if app.cursor_position < app.input.len() {
779 app.cursor_position += 1;
780 }
781 }
782 KeyCode::Home => {
783 app.cursor_position = 0;
784 }
785 KeyCode::End => {
786 app.cursor_position = app.input.len();
787 }
788
789 KeyCode::Up => {
791 app.scroll = app.scroll.saturating_sub(1);
792 }
793 KeyCode::Down => {
794 app.scroll = app.scroll.saturating_add(1);
795 }
796 KeyCode::PageUp => {
797 app.scroll = app.scroll.saturating_sub(10);
798 }
799 KeyCode::PageDown => {
800 app.scroll = app.scroll.saturating_add(10);
801 }
802
803 _ => {}
804 }
805 }
806 }
807 }
808}
809
810fn ui(f: &mut Frame, app: &App, theme: &Theme) {
811 if app.view_mode == ViewMode::Swarm {
813 let chunks = Layout::default()
815 .direction(Direction::Vertical)
816 .constraints([
817 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
821 .split(f.area());
822
823 render_swarm_view(f, &app.swarm_state, chunks[0]);
825
826 let input_block = Block::default()
828 .borders(Borders::ALL)
829 .title(" Press Esc, Ctrl+S, or /view to return to chat ")
830 .border_style(Style::default().fg(Color::Cyan));
831
832 let input = Paragraph::new(app.input.as_str())
833 .block(input_block)
834 .wrap(Wrap { trim: false });
835 f.render_widget(input, chunks[1]);
836
837 let status = Paragraph::new(Line::from(vec![
839 Span::styled(" SWARM MODE ", Style::default().fg(Color::Black).bg(Color::Cyan)),
840 Span::raw(" | "),
841 Span::styled("Esc", Style::default().fg(Color::Yellow)),
842 Span::raw(": Back | "),
843 Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
844 Span::raw(": Toggle view"),
845 ]));
846 f.render_widget(status, chunks[2]);
847 return;
848 }
849
850 let chunks = Layout::default()
852 .direction(Direction::Vertical)
853 .constraints([
854 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
858 .split(f.area());
859
860 let messages_area = chunks[0];
862 let messages_block = Block::default()
863 .borders(Borders::ALL)
864 .title(format!(" CodeTether Agent [{}] ", app.current_agent))
865 .border_style(Style::default().fg(theme.border_color.to_color()));
866
867 let mut message_lines = Vec::new();
869 let max_width = messages_area.width.saturating_sub(4) as usize;
870
871 for message in &app.messages {
872 let role_style = theme.get_role_style(&message.role);
874
875 let header_line = Line::from(vec![
876 Span::styled(
877 format!("[{}] ", message.timestamp),
878 Style::default()
879 .fg(theme.timestamp_color.to_color())
880 .add_modifier(Modifier::DIM),
881 ),
882 Span::styled(&message.role, role_style),
883 ]);
884 message_lines.push(header_line);
885
886 match &message.message_type {
888 MessageType::ToolCall { name, arguments } => {
889 let tool_header = Line::from(vec![
891 Span::styled(" 🔧 ", Style::default().fg(Color::Yellow)),
892 Span::styled(format!("Tool: {}", name), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
893 ]);
894 message_lines.push(tool_header);
895
896 let args_str = if arguments.len() > 200 {
898 format!("{}...", &arguments[..197])
899 } else {
900 arguments.clone()
901 };
902 let args_line = Line::from(vec![
903 Span::styled(" ", Style::default()),
904 Span::styled(args_str, Style::default().fg(Color::DarkGray)),
905 ]);
906 message_lines.push(args_line);
907 }
908 MessageType::ToolResult { name, output } => {
909 let result_header = Line::from(vec![
911 Span::styled(" ✅ ", Style::default().fg(Color::Green)),
912 Span::styled(format!("Result from {}", name), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
913 ]);
914 message_lines.push(result_header);
915
916 let output_str = if output.len() > 300 {
918 format!("{}... (truncated)", &output[..297])
919 } else {
920 output.clone()
921 };
922 let output_lines: Vec<&str> = output_str.lines().collect();
923 for line in output_lines.iter().take(5) {
924 let output_line = Line::from(vec![
925 Span::styled(" ", Style::default()),
926 Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
927 ]);
928 message_lines.push(output_line);
929 }
930 if output_lines.len() > 5 {
931 message_lines.push(Line::from(vec![
932 Span::styled(" ", Style::default()),
933 Span::styled(format!("... and {} more lines", output_lines.len() - 5), Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM)),
934 ]));
935 }
936 }
937 _ => {
938 let formatter = MessageFormatter::new(max_width);
940 let formatted_content = formatter.format_content(&message.content, &message.role);
941 message_lines.extend(formatted_content);
942 }
943 }
944
945 message_lines.push(Line::from(""));
947 }
948
949 if app.is_processing {
951 let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
952 let spinner_idx = (std::time::SystemTime::now()
953 .duration_since(std::time::UNIX_EPOCH)
954 .unwrap_or_default()
955 .as_millis() / 100) as usize % spinner.len();
956
957 let processing_line = Line::from(vec![
958 Span::styled(
959 format!("[{}] ", chrono::Local::now().format("%H:%M")),
960 Style::default()
961 .fg(theme.timestamp_color.to_color())
962 .add_modifier(Modifier::DIM),
963 ),
964 Span::styled("assistant", theme.get_role_style("assistant")),
965 ]);
966 message_lines.push(processing_line);
967
968 let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
970 (format!(" {} Running: {}", spinner[spinner_idx], tool), Color::Cyan)
971 } else {
972 (format!(" {} {}", spinner[spinner_idx], app.processing_message.as_deref().unwrap_or("Thinking...")), Color::Yellow)
973 };
974
975 let indicator_line = Line::from(vec![
976 Span::styled(status_text, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
977 ]);
978 message_lines.push(indicator_line);
979 message_lines.push(Line::from(""));
980 }
981
982 let total_lines = message_lines.len();
984 let visible_lines = messages_area.height.saturating_sub(2) as usize;
985 let max_scroll = total_lines.saturating_sub(visible_lines);
986 let scroll = app.scroll.min(max_scroll);
987
988 let messages_paragraph = Paragraph::new(
990 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
991 )
992 .block(messages_block.clone())
993 .wrap(Wrap { trim: false });
994
995 f.render_widget(messages_paragraph, messages_area);
996
997 if total_lines > visible_lines {
999 let scrollbar = Scrollbar::default()
1000 .orientation(ScrollbarOrientation::VerticalRight)
1001 .symbols(ratatui::symbols::scrollbar::VERTICAL)
1002 .begin_symbol(Some("↑"))
1003 .end_symbol(Some("↓"));
1004
1005 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
1006
1007 let scrollbar_area = Rect::new(
1008 messages_area.right() - 1,
1009 messages_area.top() + 1,
1010 1,
1011 messages_area.height - 2,
1012 );
1013
1014 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
1015 }
1016
1017 let input_block = Block::default()
1019 .borders(Borders::ALL)
1020 .title(if app.is_processing {
1021 " Message (Processing...) "
1022 } else {
1023 " Message (Enter to send) "
1024 })
1025 .border_style(Style::default().fg(if app.is_processing {
1026 Color::Yellow
1027 } else {
1028 theme.input_border_color.to_color()
1029 }));
1030
1031 let input = Paragraph::new(app.input.as_str())
1032 .block(input_block)
1033 .wrap(Wrap { trim: false });
1034 f.render_widget(input, chunks[1]);
1035
1036 f.set_cursor_position((
1038 chunks[1].x + app.cursor_position as u16 + 1,
1039 chunks[1].y + 1,
1040 ));
1041
1042 let token_display = TokenDisplay::new();
1044 let status = Paragraph::new(token_display.create_status_bar(theme));
1045 f.render_widget(status, chunks[2]);
1046
1047 if app.show_help {
1049 let area = centered_rect(60, 60, f.area());
1050 f.render_widget(Clear, area);
1051
1052 let token_display = TokenDisplay::new();
1054 let token_info = token_display.create_detailed_display();
1055
1056 let help_text: Vec<String> = vec![
1057 "".to_string(),
1058 " KEYBOARD SHORTCUTS".to_string(),
1059 " ==================".to_string(),
1060 "".to_string(),
1061 " Enter Send message".to_string(),
1062 " Tab Switch between build/plan agents".to_string(),
1063 " Ctrl+S Toggle swarm view".to_string(),
1064 " Ctrl+C Quit".to_string(),
1065 " ? Toggle this help".to_string(),
1066 "".to_string(),
1067 " SLASH COMMANDS".to_string(),
1068 " /swarm <task> Run task in parallel swarm mode".to_string(),
1069 " /sessions List saved sessions".to_string(),
1070 " /resume Resume most recent session".to_string(),
1071 " /resume <id> Resume specific session by ID".to_string(),
1072 " /new Start a fresh session".to_string(),
1073 " /view Toggle swarm view".to_string(),
1074 "".to_string(),
1075 " VIM-STYLE NAVIGATION".to_string(),
1076 " Alt+j Scroll down".to_string(),
1077 " Alt+k Scroll up".to_string(),
1078 " Ctrl+g Go to top".to_string(),
1079 " Ctrl+G Go to bottom".to_string(),
1080 "".to_string(),
1081 " SCROLLING".to_string(),
1082 " Up/Down Scroll messages".to_string(),
1083 " PageUp/Dn Scroll one page".to_string(),
1084 " Alt+u/d Scroll half page".to_string(),
1085 "".to_string(),
1086 " COMMAND HISTORY".to_string(),
1087 " Ctrl+R Search history".to_string(),
1088 " Ctrl+Up/Dn Navigate history".to_string(),
1089 "".to_string(),
1090 " Press ? or Esc to close".to_string(),
1091 "".to_string(),
1092 ];
1093
1094 let mut combined_text = token_info;
1095 combined_text.extend(help_text);
1096
1097 let help = Paragraph::new(combined_text.join("\n"))
1098 .block(
1099 Block::default()
1100 .borders(Borders::ALL)
1101 .title(" Help ")
1102 .border_style(Style::default().fg(theme.help_border_color.to_color())),
1103 )
1104 .wrap(Wrap { trim: false });
1105
1106 f.render_widget(help, area);
1107 }
1108}
1109
1110fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1112 let popup_layout = Layout::default()
1113 .direction(Direction::Vertical)
1114 .constraints([
1115 Constraint::Percentage((100 - percent_y) / 2),
1116 Constraint::Percentage(percent_y),
1117 Constraint::Percentage((100 - percent_y) / 2),
1118 ])
1119 .split(r);
1120
1121 Layout::default()
1122 .direction(Direction::Horizontal)
1123 .constraints([
1124 Constraint::Percentage((100 - percent_x) / 2),
1125 Constraint::Percentage(percent_x),
1126 Constraint::Percentage((100 - percent_x) / 2),
1127 ])
1128 .split(popup_layout[1])[1]
1129}