1pub mod message_formatter;
6pub mod swarm_view;
7pub mod theme;
8pub mod theme_utils;
9pub mod token_display;
10
11const SCROLL_BOTTOM: usize = 1_000_000;
13
14use crate::config::Config;
15use crate::provider::{ContentPart, Role};
16use crate::session::{list_sessions, Session, SessionEvent, SessionSummary};
17use crate::swarm::{DecompositionStrategy, Orchestrator, SwarmConfig, SwarmExecutor, SwarmStats};
18use crate::tui::message_formatter::MessageFormatter;
19use crate::tui::swarm_view::{render_swarm_view, SubTaskInfo, SwarmEvent, SwarmViewState};
20use crate::tui::theme::Theme;
21use crate::tui::token_display::TokenDisplay;
22use anyhow::Result;
23use crossterm::{
24 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
25 execute,
26 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
27};
28use ratatui::{
29 Frame, Terminal,
30 backend::CrosstermBackend,
31 layout::{Constraint, Direction, Layout, Rect},
32 style::{Color, Modifier, Style},
33 text::{Line, Span},
34 widgets::{
35 Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
36 },
37};
38use std::io;
39use std::path::PathBuf;
40use tokio::sync::mpsc;
41
42pub async fn run(project: Option<PathBuf>) -> Result<()> {
44 if let Some(dir) = project {
46 std::env::set_current_dir(&dir)?;
47 }
48
49 enable_raw_mode()?;
51 let mut stdout = io::stdout();
52 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
53 let backend = CrosstermBackend::new(stdout);
54 let mut terminal = Terminal::new(backend)?;
55
56 let result = run_app(&mut terminal).await;
58
59 disable_raw_mode()?;
61 execute!(
62 terminal.backend_mut(),
63 LeaveAlternateScreen,
64 DisableMouseCapture
65 )?;
66 terminal.show_cursor()?;
67
68 result
69}
70
71#[derive(Debug, Clone)]
73enum MessageType {
74 Text(String),
75 ToolCall { name: String, arguments: String },
76 ToolResult { name: String, output: String },
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum ViewMode {
82 Chat,
83 Swarm,
84 SessionPicker,
85}
86
87struct App {
89 input: String,
90 cursor_position: usize,
91 messages: Vec<ChatMessage>,
92 current_agent: String,
93 scroll: usize,
94 show_help: bool,
95 command_history: Vec<String>,
96 history_index: Option<usize>,
97 session: Option<Session>,
98 is_processing: bool,
99 processing_message: Option<String>,
100 current_tool: Option<String>,
101 response_rx: Option<mpsc::Receiver<SessionEvent>>,
102 view_mode: ViewMode,
104 swarm_state: SwarmViewState,
105 swarm_rx: Option<mpsc::Receiver<SwarmEvent>>,
106 session_picker_list: Vec<SessionSummary>,
108 session_picker_selected: usize,
109}
110
111struct ChatMessage {
112 role: String,
113 content: String,
114 timestamp: String,
115 message_type: MessageType,
116}
117
118impl ChatMessage {
119 fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
120 let content = content.into();
121 Self {
122 role: role.into(),
123 timestamp: chrono::Local::now().format("%H:%M").to_string(),
124 message_type: MessageType::Text(content.clone()),
125 content,
126 }
127 }
128
129 fn with_message_type(mut self, message_type: MessageType) -> Self {
130 self.message_type = message_type;
131 self
132 }
133}
134
135impl App {
136 fn new() -> Self {
137 Self {
138 input: String::new(),
139 cursor_position: 0,
140 messages: vec![
141 ChatMessage::new("system", "Welcome to CodeTether Agent! Press ? for help."),
142 ChatMessage::new("assistant", "Quick start:\n• Type a message to chat with the AI\n• /swarm <task> - parallel execution\n• /sessions - pick a session to resume\n• /resume - continue last session\n• Tab - switch agents | ? - help"),
143 ],
144 current_agent: "build".to_string(),
145 scroll: 0,
146 show_help: false,
147 command_history: Vec::new(),
148 history_index: None,
149 session: None,
150 is_processing: false,
151 processing_message: None,
152 current_tool: None,
153 response_rx: None,
154 view_mode: ViewMode::Chat,
155 swarm_state: SwarmViewState::new(),
156 swarm_rx: None,
157 session_picker_list: Vec::new(),
158 session_picker_selected: 0,
159 }
160 }
161
162 async fn submit_message(&mut self, config: &Config) {
163 if self.input.is_empty() {
164 return;
165 }
166
167 let message = std::mem::take(&mut self.input);
168 self.cursor_position = 0;
169
170 if !message.trim().is_empty() {
172 self.command_history.push(message.clone());
173 self.history_index = None;
174 }
175
176 if message.trim().starts_with("/swarm ") {
178 let task = message.trim().strip_prefix("/swarm ").unwrap_or("").to_string();
179 if task.is_empty() {
180 self.messages.push(ChatMessage::new("system", "Usage: /swarm <task description>"));
181 return;
182 }
183 self.start_swarm_execution(task, config).await;
184 return;
185 }
186
187 if message.trim() == "/view" || message.trim() == "/swarm" {
189 self.view_mode = match self.view_mode {
190 ViewMode::Chat | ViewMode::SessionPicker => ViewMode::Swarm,
191 ViewMode::Swarm => ViewMode::Chat,
192 };
193 return;
194 }
195
196 if message.trim() == "/sessions" {
198 match list_sessions().await {
199 Ok(sessions) => {
200 if sessions.is_empty() {
201 self.messages.push(ChatMessage::new("system", "No saved sessions found."));
202 } else {
203 self.session_picker_list = sessions.into_iter().take(10).collect();
204 self.session_picker_selected = 0;
205 self.view_mode = ViewMode::SessionPicker;
206 }
207 }
208 Err(e) => {
209 self.messages.push(ChatMessage::new("system", format!("Failed to list sessions: {}", e)));
210 }
211 }
212 return;
213 }
214
215 if message.trim() == "/resume" || message.trim().starts_with("/resume ") {
217 let session_id = message.trim().strip_prefix("/resume").map(|s| s.trim()).filter(|s| !s.is_empty());
218 let loaded = if let Some(id) = session_id {
219 Session::load(id).await
220 } else {
221 Session::last().await
222 };
223
224 match loaded {
225 Ok(session) => {
226 self.messages.clear();
228 self.messages.push(ChatMessage::new("system", format!(
229 "Resumed session: {}\nCreated: {}\n{} messages loaded",
230 session.title.as_deref().unwrap_or("(untitled)"),
231 session.created_at.format("%Y-%m-%d %H:%M"),
232 session.messages.len()
233 )));
234
235 for msg in &session.messages {
236 let role_str = match msg.role {
237 Role::System => "system",
238 Role::User => "user",
239 Role::Assistant => "assistant",
240 Role::Tool => "tool",
241 };
242
243 let content: String = msg.content.iter()
245 .filter_map(|part| match part {
246 ContentPart::Text { text } => Some(text.clone()),
247 ContentPart::ToolCall { name, arguments, .. } => {
248 Some(format!("[Tool: {}]\n{}", name, arguments))
249 }
250 ContentPart::ToolResult { content, .. } => {
251 let truncated = if content.len() > 500 {
252 format!("{}...", &content[..497])
253 } else {
254 content.clone()
255 };
256 Some(format!("[Result]\n{}", truncated))
257 }
258 _ => None,
259 })
260 .collect::<Vec<_>>()
261 .join("\n");
262
263 if !content.is_empty() {
264 self.messages.push(ChatMessage::new(role_str, content));
265 }
266 }
267
268 self.current_agent = session.agent.clone();
269 self.session = Some(session);
270 self.scroll = SCROLL_BOTTOM;
271 }
272 Err(e) => {
273 self.messages.push(ChatMessage::new("system", format!("Failed to load session: {}", e)));
274 }
275 }
276 return;
277 }
278
279 if message.trim() == "/new" {
281 self.session = None;
282 self.messages.clear();
283 self.messages.push(ChatMessage::new("system", "Started a new session. Previous session was saved."));
284 return;
285 }
286
287 self.messages.push(ChatMessage::new("user", message.clone()));
289
290 self.scroll = SCROLL_BOTTOM;
292
293 let current_agent = self.current_agent.clone();
294 let model = config
295 .agents
296 .get(¤t_agent)
297 .and_then(|agent| agent.model.clone())
298 .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok())
299 .or_else(|| config.default_model.clone())
300 .or_else(|| Some("zhipuai/glm-4.7".to_string()));
301
302 if self.session.is_none() {
304 match Session::new().await {
305 Ok(session) => {
306 self.session = Some(session);
307 }
308 Err(err) => {
309 tracing::error!(error = %err, "Failed to create session");
310 self.messages.push(ChatMessage::new("assistant", format!("Error: {err}")));
311 return;
312 }
313 }
314 }
315
316 let session = match self.session.as_mut() {
317 Some(session) => session,
318 None => {
319 self.messages.push(ChatMessage::new("assistant", "Error: session not initialized"));
320 return;
321 }
322 };
323
324 if let Some(model) = model {
325 session.metadata.model = Some(model);
326 }
327
328 session.agent = current_agent;
329
330 self.is_processing = true;
332 self.processing_message = Some("Thinking...".to_string());
333 self.current_tool = None;
334
335 let (tx, rx) = mpsc::channel(100);
337 self.response_rx = Some(rx);
338
339 let session_clone = session.clone();
341 let message_clone = message.clone();
342
343 tokio::spawn(async move {
345 let mut session = session_clone;
346 if let Err(err) = session.prompt_with_events(&message_clone, tx.clone()).await {
347 tracing::error!(error = %err, "Agent processing failed");
348 let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
349 let _ = tx.send(SessionEvent::Done).await;
350 }
351 });
352 }
353
354 fn handle_response(&mut self, event: SessionEvent) {
355 self.scroll = SCROLL_BOTTOM;
357
358 match event {
359 SessionEvent::Thinking => {
360 self.processing_message = Some("Thinking...".to_string());
361 self.current_tool = None;
362 }
363 SessionEvent::ToolCallStart { name, arguments } => {
364 self.processing_message = Some(format!("Running {}...", name));
365 self.current_tool = Some(name.clone());
366 self.messages.push(
367 ChatMessage::new("tool", format!("🔧 {}", name))
368 .with_message_type(MessageType::ToolCall { name, arguments }),
369 );
370 }
371 SessionEvent::ToolCallComplete { name, output, success } => {
372 let icon = if success { "✓" } else { "✗" };
373 self.messages.push(
374 ChatMessage::new("tool", format!("{} {}", icon, name))
375 .with_message_type(MessageType::ToolResult { name, output }),
376 );
377 self.current_tool = None;
378 self.processing_message = Some("Thinking...".to_string());
379 }
380 SessionEvent::TextChunk(_text) => {
381 }
383 SessionEvent::TextComplete(text) => {
384 if !text.is_empty() {
385 self.messages.push(ChatMessage::new("assistant", text));
386 }
387 }
388 SessionEvent::Error(err) => {
389 self.messages.push(ChatMessage::new("assistant", format!("Error: {}", err)));
390 }
391 SessionEvent::Done => {
392 self.is_processing = false;
393 self.processing_message = None;
394 self.current_tool = None;
395 self.response_rx = None;
396 }
397 }
398 }
399
400 fn handle_swarm_event(&mut self, event: SwarmEvent) {
402 self.swarm_state.handle_event(event.clone());
403
404 if let SwarmEvent::Complete { success, ref stats } = event {
406 self.view_mode = ViewMode::Chat;
407 let summary = if success {
408 format!(
409 "Swarm completed successfully.\n\
410 Subtasks: {} completed, {} failed\n\
411 Total tool calls: {}\n\
412 Time: {:.1}s (speedup: {:.1}x)",
413 stats.subagents_completed,
414 stats.subagents_failed,
415 stats.total_tool_calls,
416 stats.execution_time_ms as f64 / 1000.0,
417 stats.speedup_factor
418 )
419 } else {
420 format!(
421 "Swarm completed with failures.\n\
422 Subtasks: {} completed, {} failed\n\
423 Check the subtask results for details.",
424 stats.subagents_completed, stats.subagents_failed
425 )
426 };
427 self.messages.push(ChatMessage::new("system", &summary));
428 self.swarm_rx = None;
429 }
430
431 if let SwarmEvent::Error(ref err) = event {
432 self.messages.push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
433 }
434 }
435
436 async fn start_swarm_execution(&mut self, task: String, config: &Config) {
438 self.messages.push(ChatMessage::new("user", format!("/swarm {}", task)));
440
441 let model = config
443 .default_model
444 .clone()
445 .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok());
446
447 let swarm_config = SwarmConfig {
449 model,
450 max_subagents: 10,
451 max_steps_per_subagent: 50,
452 worktree_enabled: true,
453 worktree_auto_merge: true,
454 working_dir: Some(std::env::current_dir()
455 .map(|p| p.to_string_lossy().to_string())
456 .unwrap_or_else(|_| ".".to_string())),
457 ..Default::default()
458 };
459
460 let (tx, rx) = mpsc::channel(100);
462 self.swarm_rx = Some(rx);
463
464 self.view_mode = ViewMode::Swarm;
466 self.swarm_state = SwarmViewState::new();
467
468 let task_clone = task.clone();
470
471 let _ = tx.send(SwarmEvent::Started {
473 task: task.clone(),
474 total_subtasks: 0,
475 }).await;
476
477 tokio::spawn(async move {
479 let orchestrator_result = Orchestrator::new(swarm_config.clone()).await;
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 if app.view_mode == ViewMode::SessionPicker {
673 match key.code {
674 KeyCode::Esc => {
675 app.view_mode = ViewMode::Chat;
676 }
677 KeyCode::Up | KeyCode::Char('k') => {
678 if app.session_picker_selected > 0 {
679 app.session_picker_selected -= 1;
680 }
681 }
682 KeyCode::Down | KeyCode::Char('j') => {
683 if app.session_picker_selected < app.session_picker_list.len().saturating_sub(1) {
684 app.session_picker_selected += 1;
685 }
686 }
687 KeyCode::Enter => {
688 if let Some(session_summary) = app.session_picker_list.get(app.session_picker_selected) {
689 let session_id = session_summary.id.clone();
690 match Session::load(&session_id).await {
691 Ok(session) => {
692 app.messages.clear();
693 app.messages.push(ChatMessage::new("system", format!(
694 "Resumed session: {}\nCreated: {}\n{} messages loaded",
695 session.title.as_deref().unwrap_or("(untitled)"),
696 session.created_at.format("%Y-%m-%d %H:%M"),
697 session.messages.len()
698 )));
699
700 for msg in &session.messages {
701 let role_str = match msg.role {
702 Role::System => "system",
703 Role::User => "user",
704 Role::Assistant => "assistant",
705 Role::Tool => "tool",
706 };
707
708 let text = msg.content.iter()
709 .filter_map(|part| {
710 if let ContentPart::Text { text } = part {
711 Some(text.as_str())
712 } else {
713 None
714 }
715 })
716 .collect::<Vec<_>>()
717 .join("\n");
718
719 if !text.is_empty() {
720 app.messages.push(ChatMessage::new(role_str, text));
721 }
722 }
723
724 app.current_agent = session.agent.clone();
725 app.session = Some(session);
726 app.scroll = SCROLL_BOTTOM;
727 app.view_mode = ViewMode::Chat;
728 }
729 Err(e) => {
730 app.messages.push(ChatMessage::new("system", format!("Failed to load session: {}", e)));
731 app.view_mode = ViewMode::Chat;
732 }
733 }
734 }
735 }
736 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
737 return Ok(());
738 }
739 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
740 return Ok(());
741 }
742 _ => {}
743 }
744 continue;
745 }
746
747 match key.code {
748 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
750 return Ok(());
751 }
752 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
753 return Ok(());
754 }
755
756 KeyCode::Char('?') => {
758 app.show_help = true;
759 }
760
761 KeyCode::F(2) => {
763 app.view_mode = match app.view_mode {
764 ViewMode::Chat | ViewMode::SessionPicker => ViewMode::Swarm,
765 ViewMode::Swarm => ViewMode::Chat,
766 };
767 }
768 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
769 app.view_mode = match app.view_mode {
770 ViewMode::Chat | ViewMode::SessionPicker => ViewMode::Swarm,
771 ViewMode::Swarm => ViewMode::Chat,
772 };
773 }
774
775 KeyCode::Esc => {
777 if app.view_mode == ViewMode::Swarm || app.view_mode == ViewMode::SessionPicker {
778 app.view_mode = ViewMode::Chat;
779 }
780 }
781
782 KeyCode::Tab => {
784 app.current_agent = if app.current_agent == "build" {
785 "plan".to_string()
786 } else {
787 "build".to_string()
788 };
789 }
790
791 KeyCode::Enter => {
793 app.submit_message(&config).await;
794 }
795
796 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
798 if app.scroll < SCROLL_BOTTOM {
799 app.scroll = app.scroll.saturating_add(1);
800 }
801 }
802 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
803 if app.scroll >= SCROLL_BOTTOM {
804 app.scroll = SCROLL_BOTTOM - 1; }
806 app.scroll = app.scroll.saturating_sub(1);
807 }
808
809 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
811 app.search_history();
812 }
813 KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
814 app.navigate_history(-1);
815 }
816 KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
817 app.navigate_history(1);
818 }
819
820 KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
822 app.scroll = 0; }
824 KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
825 app.scroll = SCROLL_BOTTOM;
827 }
828
829 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
831 if app.scroll < SCROLL_BOTTOM {
833 app.scroll = app.scroll.saturating_add(5);
834 }
835 }
836 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
837 if app.scroll >= SCROLL_BOTTOM {
839 app.scroll = SCROLL_BOTTOM - 1;
840 }
841 app.scroll = app.scroll.saturating_sub(5);
842 }
843
844 KeyCode::Char(c) => {
846 app.input.insert(app.cursor_position, c);
847 app.cursor_position += 1;
848 }
849 KeyCode::Backspace => {
850 if app.cursor_position > 0 {
851 app.cursor_position -= 1;
852 app.input.remove(app.cursor_position);
853 }
854 }
855 KeyCode::Delete => {
856 if app.cursor_position < app.input.len() {
857 app.input.remove(app.cursor_position);
858 }
859 }
860 KeyCode::Left => {
861 app.cursor_position = app.cursor_position.saturating_sub(1);
862 }
863 KeyCode::Right => {
864 if app.cursor_position < app.input.len() {
865 app.cursor_position += 1;
866 }
867 }
868 KeyCode::Home => {
869 app.cursor_position = 0;
870 }
871 KeyCode::End => {
872 app.cursor_position = app.input.len();
873 }
874
875 KeyCode::Up => {
877 if app.scroll >= SCROLL_BOTTOM {
878 app.scroll = SCROLL_BOTTOM - 1; }
880 app.scroll = app.scroll.saturating_sub(1);
881 }
882 KeyCode::Down => {
883 if app.scroll < SCROLL_BOTTOM {
884 app.scroll = app.scroll.saturating_add(1);
885 }
886 }
887 KeyCode::PageUp => {
888 if app.scroll >= SCROLL_BOTTOM {
889 app.scroll = SCROLL_BOTTOM - 1;
890 }
891 app.scroll = app.scroll.saturating_sub(10);
892 }
893 KeyCode::PageDown => {
894 if app.scroll < SCROLL_BOTTOM {
895 app.scroll = app.scroll.saturating_add(10);
896 }
897 }
898
899 _ => {}
900 }
901 }
902 }
903 }
904}
905
906fn ui(f: &mut Frame, app: &App, theme: &Theme) {
907 if app.view_mode == ViewMode::Swarm {
909 let chunks = Layout::default()
911 .direction(Direction::Vertical)
912 .constraints([
913 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
917 .split(f.area());
918
919 render_swarm_view(f, &app.swarm_state, chunks[0]);
921
922 let input_block = Block::default()
924 .borders(Borders::ALL)
925 .title(" Press Esc, Ctrl+S, or /view to return to chat ")
926 .border_style(Style::default().fg(Color::Cyan));
927
928 let input = Paragraph::new(app.input.as_str())
929 .block(input_block)
930 .wrap(Wrap { trim: false });
931 f.render_widget(input, chunks[1]);
932
933 let status = Paragraph::new(Line::from(vec![
935 Span::styled(" SWARM MODE ", Style::default().fg(Color::Black).bg(Color::Cyan)),
936 Span::raw(" | "),
937 Span::styled("Esc", Style::default().fg(Color::Yellow)),
938 Span::raw(": Back | "),
939 Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
940 Span::raw(": Toggle view"),
941 ]));
942 f.render_widget(status, chunks[2]);
943 return;
944 }
945
946 if app.view_mode == ViewMode::SessionPicker {
948 let chunks = Layout::default()
949 .direction(Direction::Vertical)
950 .constraints([
951 Constraint::Min(1), Constraint::Length(1), ])
954 .split(f.area());
955
956 let list_block = Block::default()
958 .borders(Borders::ALL)
959 .title(" Select Session (↑↓ to navigate, Enter to load, Esc to cancel) ")
960 .border_style(Style::default().fg(Color::Cyan));
961
962 let mut list_lines: Vec<Line> = Vec::new();
963 list_lines.push(Line::from(""));
964
965 for (i, session) in app.session_picker_list.iter().enumerate() {
966 let is_selected = i == app.session_picker_selected;
967 let title = session.title.as_deref().unwrap_or("(untitled)");
968 let date = session.updated_at.format("%Y-%m-%d %H:%M");
969 let line_str = format!(
970 " {} {} - {} ({} msgs)",
971 if is_selected { "▶" } else { " " },
972 title,
973 date,
974 session.message_count
975 );
976
977 let style = if is_selected {
978 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
979 } else {
980 Style::default()
981 };
982
983 list_lines.push(Line::styled(line_str, style));
984
985 if is_selected {
987 list_lines.push(Line::styled(
988 format!(" Agent: {} | ID: {}", session.agent, session.id),
989 Style::default().fg(Color::DarkGray),
990 ));
991 }
992 }
993
994 let list = Paragraph::new(list_lines)
995 .block(list_block)
996 .wrap(Wrap { trim: false });
997 f.render_widget(list, chunks[0]);
998
999 let status = Paragraph::new(Line::from(vec![
1001 Span::styled(" SESSION PICKER ", Style::default().fg(Color::Black).bg(Color::Cyan)),
1002 Span::raw(" | "),
1003 Span::styled("↑↓/jk", Style::default().fg(Color::Yellow)),
1004 Span::raw(": Navigate | "),
1005 Span::styled("Enter", Style::default().fg(Color::Yellow)),
1006 Span::raw(": Load | "),
1007 Span::styled("Esc", Style::default().fg(Color::Yellow)),
1008 Span::raw(": Cancel"),
1009 ]));
1010 f.render_widget(status, chunks[1]);
1011 return;
1012 }
1013
1014 let chunks = Layout::default()
1016 .direction(Direction::Vertical)
1017 .constraints([
1018 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
1022 .split(f.area());
1023
1024 let messages_area = chunks[0];
1026 let messages_block = Block::default()
1027 .borders(Borders::ALL)
1028 .title(format!(" CodeTether Agent [{}] ", app.current_agent))
1029 .border_style(Style::default().fg(theme.border_color.to_color()));
1030
1031 let mut message_lines = Vec::new();
1033 let max_width = messages_area.width.saturating_sub(4) as usize;
1034
1035 for message in &app.messages {
1036 let role_style = theme.get_role_style(&message.role);
1038
1039 let header_line = Line::from(vec![
1040 Span::styled(
1041 format!("[{}] ", message.timestamp),
1042 Style::default()
1043 .fg(theme.timestamp_color.to_color())
1044 .add_modifier(Modifier::DIM),
1045 ),
1046 Span::styled(&message.role, role_style),
1047 ]);
1048 message_lines.push(header_line);
1049
1050 match &message.message_type {
1052 MessageType::ToolCall { name, arguments } => {
1053 let tool_header = Line::from(vec![
1055 Span::styled(" 🔧 ", Style::default().fg(Color::Yellow)),
1056 Span::styled(format!("Tool: {}", name), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
1057 ]);
1058 message_lines.push(tool_header);
1059
1060 let args_str = if arguments.len() > 200 {
1062 format!("{}...", &arguments[..197])
1063 } else {
1064 arguments.clone()
1065 };
1066 let args_line = Line::from(vec![
1067 Span::styled(" ", Style::default()),
1068 Span::styled(args_str, Style::default().fg(Color::DarkGray)),
1069 ]);
1070 message_lines.push(args_line);
1071 }
1072 MessageType::ToolResult { name, output } => {
1073 let result_header = Line::from(vec![
1075 Span::styled(" ✅ ", Style::default().fg(Color::Green)),
1076 Span::styled(format!("Result from {}", name), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
1077 ]);
1078 message_lines.push(result_header);
1079
1080 let output_str = if output.len() > 300 {
1082 format!("{}... (truncated)", &output[..297])
1083 } else {
1084 output.clone()
1085 };
1086 let output_lines: Vec<&str> = output_str.lines().collect();
1087 for line in output_lines.iter().take(5) {
1088 let output_line = Line::from(vec![
1089 Span::styled(" ", Style::default()),
1090 Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
1091 ]);
1092 message_lines.push(output_line);
1093 }
1094 if output_lines.len() > 5 {
1095 message_lines.push(Line::from(vec![
1096 Span::styled(" ", Style::default()),
1097 Span::styled(format!("... and {} more lines", output_lines.len() - 5), Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM)),
1098 ]));
1099 }
1100 }
1101 MessageType::Text(text) => {
1102 let formatter = MessageFormatter::new(max_width);
1104 let formatted_content = formatter.format_content(text, &message.role);
1105 message_lines.extend(formatted_content);
1106 }
1107 }
1108
1109 message_lines.push(Line::from(""));
1111 }
1112
1113 if app.is_processing {
1115 let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1116 let spinner_idx = (std::time::SystemTime::now()
1117 .duration_since(std::time::UNIX_EPOCH)
1118 .unwrap_or_default()
1119 .as_millis() / 100) as usize % spinner.len();
1120
1121 let processing_line = Line::from(vec![
1122 Span::styled(
1123 format!("[{}] ", chrono::Local::now().format("%H:%M")),
1124 Style::default()
1125 .fg(theme.timestamp_color.to_color())
1126 .add_modifier(Modifier::DIM),
1127 ),
1128 Span::styled("assistant", theme.get_role_style("assistant")),
1129 ]);
1130 message_lines.push(processing_line);
1131
1132 let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
1134 (format!(" {} Running: {}", spinner[spinner_idx], tool), Color::Cyan)
1135 } else {
1136 (format!(" {} {}", spinner[spinner_idx], app.processing_message.as_deref().unwrap_or("Thinking...")), Color::Yellow)
1137 };
1138
1139 let indicator_line = Line::from(vec![
1140 Span::styled(status_text, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
1141 ]);
1142 message_lines.push(indicator_line);
1143 message_lines.push(Line::from(""));
1144 }
1145
1146 let total_lines = message_lines.len();
1148 let visible_lines = messages_area.height.saturating_sub(2) as usize;
1149 let max_scroll = total_lines.saturating_sub(visible_lines);
1150 let scroll = if app.scroll >= SCROLL_BOTTOM {
1152 max_scroll
1153 } else {
1154 app.scroll.min(max_scroll)
1155 };
1156
1157 let messages_paragraph = Paragraph::new(
1159 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
1160 )
1161 .block(messages_block.clone())
1162 .wrap(Wrap { trim: false });
1163
1164 f.render_widget(messages_paragraph, messages_area);
1165
1166 if total_lines > visible_lines {
1168 let scrollbar = Scrollbar::default()
1169 .orientation(ScrollbarOrientation::VerticalRight)
1170 .symbols(ratatui::symbols::scrollbar::VERTICAL)
1171 .begin_symbol(Some("↑"))
1172 .end_symbol(Some("↓"));
1173
1174 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
1175
1176 let scrollbar_area = Rect::new(
1177 messages_area.right() - 1,
1178 messages_area.top() + 1,
1179 1,
1180 messages_area.height - 2,
1181 );
1182
1183 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
1184 }
1185
1186 let input_block = Block::default()
1188 .borders(Borders::ALL)
1189 .title(if app.is_processing {
1190 " Message (Processing...) "
1191 } else {
1192 " Message (Enter to send) "
1193 })
1194 .border_style(Style::default().fg(if app.is_processing {
1195 Color::Yellow
1196 } else {
1197 theme.input_border_color.to_color()
1198 }));
1199
1200 let input = Paragraph::new(app.input.as_str())
1201 .block(input_block)
1202 .wrap(Wrap { trim: false });
1203 f.render_widget(input, chunks[1]);
1204
1205 f.set_cursor_position((
1207 chunks[1].x + app.cursor_position as u16 + 1,
1208 chunks[1].y + 1,
1209 ));
1210
1211 let token_display = TokenDisplay::new();
1213 let status = Paragraph::new(token_display.create_status_bar(theme));
1214 f.render_widget(status, chunks[2]);
1215
1216 if app.show_help {
1218 let area = centered_rect(60, 60, f.area());
1219 f.render_widget(Clear, area);
1220
1221 let token_display = TokenDisplay::new();
1223 let token_info = token_display.create_detailed_display();
1224
1225 let help_text: Vec<String> = vec![
1226 "".to_string(),
1227 " KEYBOARD SHORTCUTS".to_string(),
1228 " ==================".to_string(),
1229 "".to_string(),
1230 " Enter Send message".to_string(),
1231 " Tab Switch between build/plan agents".to_string(),
1232 " Ctrl+S Toggle swarm view".to_string(),
1233 " Ctrl+C Quit".to_string(),
1234 " ? Toggle this help".to_string(),
1235 "".to_string(),
1236 " SLASH COMMANDS".to_string(),
1237 " /swarm <task> Run task in parallel swarm mode".to_string(),
1238 " /sessions Open session picker to resume".to_string(),
1239 " /resume Resume most recent session".to_string(),
1240 " /resume <id> Resume specific session by ID".to_string(),
1241 " /new Start a fresh session".to_string(),
1242 " /view Toggle swarm view".to_string(),
1243 "".to_string(),
1244 " VIM-STYLE NAVIGATION".to_string(),
1245 " Alt+j Scroll down".to_string(),
1246 " Alt+k Scroll up".to_string(),
1247 " Ctrl+g Go to top".to_string(),
1248 " Ctrl+G Go to bottom".to_string(),
1249 "".to_string(),
1250 " SCROLLING".to_string(),
1251 " Up/Down Scroll messages".to_string(),
1252 " PageUp/Dn Scroll one page".to_string(),
1253 " Alt+u/d Scroll half page".to_string(),
1254 "".to_string(),
1255 " COMMAND HISTORY".to_string(),
1256 " Ctrl+R Search history".to_string(),
1257 " Ctrl+Up/Dn Navigate history".to_string(),
1258 "".to_string(),
1259 " Press ? or Esc to close".to_string(),
1260 "".to_string(),
1261 ];
1262
1263 let mut combined_text = token_info;
1264 combined_text.extend(help_text);
1265
1266 let help = Paragraph::new(combined_text.join("\n"))
1267 .block(
1268 Block::default()
1269 .borders(Borders::ALL)
1270 .title(" Help ")
1271 .border_style(Style::default().fg(theme.help_border_color.to_color())),
1272 )
1273 .wrap(Wrap { trim: false });
1274
1275 f.render_widget(help, area);
1276 }
1277}
1278
1279fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1281 let popup_layout = Layout::default()
1282 .direction(Direction::Vertical)
1283 .constraints([
1284 Constraint::Percentage((100 - percent_y) / 2),
1285 Constraint::Percentage(percent_y),
1286 Constraint::Percentage((100 - percent_y) / 2),
1287 ])
1288 .split(r);
1289
1290 Layout::default()
1291 .direction(Direction::Horizontal)
1292 .constraints([
1293 Constraint::Percentage((100 - percent_x) / 2),
1294 Constraint::Percentage(percent_x),
1295 Constraint::Percentage((100 - percent_x) / 2),
1296 ])
1297 .split(popup_layout[1])[1]
1298}