1pub mod message_formatter;
6pub mod ralph_view;
7pub mod swarm_view;
8pub mod theme;
9pub mod theme_utils;
10pub mod token_display;
11
12const SCROLL_BOTTOM: usize = 1_000_000;
14
15use crate::config::Config;
16use crate::provider::{ContentPart, Role};
17use crate::ralph::{RalphConfig, RalphLoop};
18use crate::session::{Session, SessionEvent, SessionSummary, list_sessions};
19use crate::swarm::{DecompositionStrategy, SwarmConfig, SwarmExecutor};
20use crate::tui::message_formatter::MessageFormatter;
21use crate::tui::ralph_view::{RalphEvent, RalphViewState, render_ralph_view};
22use crate::tui::swarm_view::{SwarmEvent, SwarmViewState, render_swarm_view};
23use crate::tui::theme::Theme;
24use crate::tui::token_display::TokenDisplay;
25use anyhow::Result;
26use crossterm::{
27 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
28 execute,
29 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
30};
31use ratatui::{
32 Frame, Terminal,
33 backend::CrosstermBackend,
34 layout::{Constraint, Direction, Layout, Rect},
35 style::{Color, Modifier, Style},
36 text::{Line, Span},
37 widgets::{
38 Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
39 },
40};
41use std::io;
42use std::path::{Path, PathBuf};
43use std::process::Command;
44use std::time::{Duration, Instant};
45use tokio::sync::mpsc;
46
47pub async fn run(project: Option<PathBuf>) -> Result<()> {
49 if let Some(dir) = project {
51 std::env::set_current_dir(&dir)?;
52 }
53
54 enable_raw_mode()?;
56 let mut stdout = io::stdout();
57 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
58 let backend = CrosstermBackend::new(stdout);
59 let mut terminal = Terminal::new(backend)?;
60
61 let result = run_app(&mut terminal).await;
63
64 disable_raw_mode()?;
66 execute!(
67 terminal.backend_mut(),
68 LeaveAlternateScreen,
69 DisableMouseCapture
70 )?;
71 terminal.show_cursor()?;
72
73 result
74}
75
76#[derive(Debug, Clone)]
78enum MessageType {
79 Text(String),
80 ToolCall { name: String, arguments: String },
81 ToolResult { name: String, output: String },
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86enum ViewMode {
87 Chat,
88 Swarm,
89 Ralph,
90 SessionPicker,
91 ModelPicker,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95enum ChatLayoutMode {
96 Classic,
97 Webview,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101enum WorkspaceEntryKind {
102 Directory,
103 File,
104}
105
106#[derive(Debug, Clone)]
107struct WorkspaceEntry {
108 name: String,
109 kind: WorkspaceEntryKind,
110}
111
112#[derive(Debug, Clone, Default)]
113struct WorkspaceSnapshot {
114 root_display: String,
115 git_branch: Option<String>,
116 git_dirty_files: usize,
117 entries: Vec<WorkspaceEntry>,
118 captured_at: String,
119}
120
121struct App {
123 input: String,
124 cursor_position: usize,
125 messages: Vec<ChatMessage>,
126 current_agent: String,
127 scroll: usize,
128 show_help: bool,
129 command_history: Vec<String>,
130 history_index: Option<usize>,
131 session: Option<Session>,
132 is_processing: bool,
133 processing_message: Option<String>,
134 current_tool: Option<String>,
135 response_rx: Option<mpsc::Receiver<SessionEvent>>,
136 view_mode: ViewMode,
138 chat_layout: ChatLayoutMode,
139 show_inspector: bool,
140 workspace: WorkspaceSnapshot,
141 swarm_state: SwarmViewState,
142 swarm_rx: Option<mpsc::Receiver<SwarmEvent>>,
143 ralph_state: RalphViewState,
145 ralph_rx: Option<mpsc::Receiver<RalphEvent>>,
146 session_picker_list: Vec<SessionSummary>,
148 session_picker_selected: usize,
149 model_picker_list: Vec<(String, String, String)>, model_picker_selected: usize,
152 model_picker_filter: String,
153 active_model: Option<String>,
154 last_max_scroll: usize,
156}
157
158#[allow(dead_code)]
159struct ChatMessage {
160 role: String,
161 content: String,
162 timestamp: String,
163 message_type: MessageType,
164}
165
166impl ChatMessage {
167 fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
168 let content = content.into();
169 Self {
170 role: role.into(),
171 timestamp: chrono::Local::now().format("%H:%M").to_string(),
172 message_type: MessageType::Text(content.clone()),
173 content,
174 }
175 }
176
177 fn with_message_type(mut self, message_type: MessageType) -> Self {
178 self.message_type = message_type;
179 self
180 }
181}
182
183impl WorkspaceSnapshot {
184 fn capture(root: &Path, max_entries: usize) -> Self {
185 let mut entries: Vec<WorkspaceEntry> = Vec::new();
186
187 if let Ok(read_dir) = std::fs::read_dir(root) {
188 for entry in read_dir.flatten() {
189 let file_name = entry.file_name().to_string_lossy().to_string();
190 if should_skip_workspace_entry(&file_name) {
191 continue;
192 }
193
194 let kind = match entry.file_type() {
195 Ok(ft) if ft.is_dir() => WorkspaceEntryKind::Directory,
196 _ => WorkspaceEntryKind::File,
197 };
198
199 entries.push(WorkspaceEntry {
200 name: file_name,
201 kind,
202 });
203 }
204 }
205
206 entries.sort_by(|a, b| match (a.kind, b.kind) {
207 (WorkspaceEntryKind::Directory, WorkspaceEntryKind::File) => std::cmp::Ordering::Less,
208 (WorkspaceEntryKind::File, WorkspaceEntryKind::Directory) => {
209 std::cmp::Ordering::Greater
210 }
211 _ => a
212 .name
213 .to_ascii_lowercase()
214 .cmp(&b.name.to_ascii_lowercase()),
215 });
216 entries.truncate(max_entries);
217
218 Self {
219 root_display: root.to_string_lossy().to_string(),
220 git_branch: detect_git_branch(root),
221 git_dirty_files: detect_git_dirty_files(root),
222 entries,
223 captured_at: chrono::Local::now().format("%H:%M:%S").to_string(),
224 }
225 }
226}
227
228fn should_skip_workspace_entry(name: &str) -> bool {
229 matches!(
230 name,
231 ".git" | "node_modules" | "target" | ".next" | "__pycache__" | ".venv"
232 )
233}
234
235fn detect_git_branch(root: &Path) -> Option<String> {
236 let output = Command::new("git")
237 .arg("-C")
238 .arg(root)
239 .args(["rev-parse", "--abbrev-ref", "HEAD"])
240 .output()
241 .ok()?;
242
243 if !output.status.success() {
244 return None;
245 }
246
247 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
248 if branch.is_empty() {
249 None
250 } else {
251 Some(branch)
252 }
253}
254
255fn detect_git_dirty_files(root: &Path) -> usize {
256 let output = match Command::new("git")
257 .arg("-C")
258 .arg(root)
259 .args(["status", "--porcelain"])
260 .output()
261 {
262 Ok(out) => out,
263 Err(_) => return 0,
264 };
265
266 if !output.status.success() {
267 return 0;
268 }
269
270 String::from_utf8_lossy(&output.stdout)
271 .lines()
272 .filter(|line| !line.trim().is_empty())
273 .count()
274}
275
276impl App {
277 fn new() -> Self {
278 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
279
280 Self {
281 input: String::new(),
282 cursor_position: 0,
283 messages: vec![
284 ChatMessage::new("system", "Welcome to CodeTether Agent! Press ? for help."),
285 ChatMessage::new(
286 "assistant",
287 "Quick start:\n• Type a message to chat with the AI\n• /model - pick a model (or Ctrl+M)\n• /swarm <task> - parallel execution\n• /ralph [prd.json] - autonomous PRD loop\n• /sessions - pick a session to resume\n• /resume - continue last session\n• Tab - switch agents | ? - help",
288 ),
289 ],
290 current_agent: "build".to_string(),
291 scroll: 0,
292 show_help: false,
293 command_history: Vec::new(),
294 history_index: None,
295 session: None,
296 is_processing: false,
297 processing_message: None,
298 current_tool: None,
299 response_rx: None,
300 view_mode: ViewMode::Chat,
301 chat_layout: ChatLayoutMode::Webview,
302 show_inspector: true,
303 workspace: WorkspaceSnapshot::capture(&workspace_root, 18),
304 swarm_state: SwarmViewState::new(),
305 swarm_rx: None,
306 ralph_state: RalphViewState::new(),
307 ralph_rx: None,
308 session_picker_list: Vec::new(),
309 session_picker_selected: 0,
310 model_picker_list: Vec::new(),
311 model_picker_selected: 0,
312 model_picker_filter: String::new(),
313 active_model: None,
314 last_max_scroll: 0,
315 }
316 }
317
318 fn refresh_workspace(&mut self) {
319 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
320 self.workspace = WorkspaceSnapshot::capture(&workspace_root, 18);
321 }
322
323 fn update_cached_sessions(&mut self, sessions: Vec<SessionSummary>) {
324 self.session_picker_list = sessions.into_iter().take(16).collect();
325 if self.session_picker_selected >= self.session_picker_list.len() {
326 self.session_picker_selected = self.session_picker_list.len().saturating_sub(1);
327 }
328 }
329
330 async fn submit_message(&mut self, config: &Config) {
331 if self.input.is_empty() {
332 return;
333 }
334
335 let message = std::mem::take(&mut self.input);
336 self.cursor_position = 0;
337
338 if !message.trim().is_empty() {
340 self.command_history.push(message.clone());
341 self.history_index = None;
342 }
343
344 if message.trim().starts_with("/swarm ") {
346 let task = message
347 .trim()
348 .strip_prefix("/swarm ")
349 .unwrap_or("")
350 .to_string();
351 if task.is_empty() {
352 self.messages.push(ChatMessage::new(
353 "system",
354 "Usage: /swarm <task description>",
355 ));
356 return;
357 }
358 self.start_swarm_execution(task, config).await;
359 return;
360 }
361
362 if message.trim().starts_with("/ralph") {
364 let prd_path = message
365 .trim()
366 .strip_prefix("/ralph")
367 .map(|s| s.trim())
368 .filter(|s| !s.is_empty())
369 .unwrap_or("prd.json")
370 .to_string();
371 self.start_ralph_execution(prd_path, config).await;
372 return;
373 }
374
375 if message.trim() == "/webview" {
376 self.chat_layout = ChatLayoutMode::Webview;
377 self.messages.push(ChatMessage::new(
378 "system",
379 "Switched to webview layout. Use /classic to return to single-pane chat.",
380 ));
381 return;
382 }
383
384 if message.trim() == "/classic" {
385 self.chat_layout = ChatLayoutMode::Classic;
386 self.messages.push(ChatMessage::new(
387 "system",
388 "Switched to classic layout. Use /webview for dashboard-style panes.",
389 ));
390 return;
391 }
392
393 if message.trim() == "/inspector" {
394 self.show_inspector = !self.show_inspector;
395 let state = if self.show_inspector { "enabled" } else { "disabled" };
396 self.messages.push(ChatMessage::new(
397 "system",
398 format!("Inspector pane {}. Press F3 to toggle quickly.", state),
399 ));
400 return;
401 }
402
403 if message.trim() == "/refresh" {
404 self.refresh_workspace();
405 match list_sessions().await {
406 Ok(sessions) => self.update_cached_sessions(sessions),
407 Err(err) => self.messages.push(ChatMessage::new(
408 "system",
409 format!("Workspace refreshed, but failed to refresh sessions: {}", err),
410 )),
411 }
412 self.messages.push(ChatMessage::new(
413 "system",
414 "Workspace and session cache refreshed.",
415 ));
416 return;
417 }
418
419 if message.trim() == "/view" || message.trim() == "/swarm" {
421 self.view_mode = match self.view_mode {
422 ViewMode::Chat | ViewMode::SessionPicker | ViewMode::ModelPicker => ViewMode::Swarm,
423 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
424 };
425 return;
426 }
427
428 if message.trim() == "/sessions" {
430 match list_sessions().await {
431 Ok(sessions) => {
432 if sessions.is_empty() {
433 self.messages
434 .push(ChatMessage::new("system", "No saved sessions found."));
435 } else {
436 self.update_cached_sessions(sessions);
437 self.session_picker_selected = 0;
438 self.view_mode = ViewMode::SessionPicker;
439 }
440 }
441 Err(e) => {
442 self.messages.push(ChatMessage::new(
443 "system",
444 format!("Failed to list sessions: {}", e),
445 ));
446 }
447 }
448 return;
449 }
450
451 if message.trim() == "/resume" || message.trim().starts_with("/resume ") {
453 let session_id = message
454 .trim()
455 .strip_prefix("/resume")
456 .map(|s| s.trim())
457 .filter(|s| !s.is_empty());
458 let loaded = if let Some(id) = session_id {
459 Session::load(id).await
460 } else {
461 Session::last().await
462 };
463
464 match loaded {
465 Ok(session) => {
466 self.messages.clear();
468 self.messages.push(ChatMessage::new(
469 "system",
470 format!(
471 "Resumed session: {}\nCreated: {}\n{} messages loaded",
472 session.title.as_deref().unwrap_or("(untitled)"),
473 session.created_at.format("%Y-%m-%d %H:%M"),
474 session.messages.len()
475 ),
476 ));
477
478 for msg in &session.messages {
479 let role_str = match msg.role {
480 Role::System => "system",
481 Role::User => "user",
482 Role::Assistant => "assistant",
483 Role::Tool => "tool",
484 };
485
486 let content: String = msg
488 .content
489 .iter()
490 .filter_map(|part| match part {
491 ContentPart::Text { text } => Some(text.clone()),
492 ContentPart::ToolCall {
493 name, arguments, ..
494 } => Some(format!("[Tool: {}]\n{}", name, arguments)),
495 ContentPart::ToolResult { content, .. } => {
496 let truncated = if content.len() > 500 {
497 format!("{}...", &content[..497])
498 } else {
499 content.clone()
500 };
501 Some(format!("[Result]\n{}", truncated))
502 }
503 _ => None,
504 })
505 .collect::<Vec<_>>()
506 .join("\n");
507
508 if !content.is_empty() {
509 self.messages.push(ChatMessage::new(role_str, content));
510 }
511 }
512
513 self.current_agent = session.agent.clone();
514 self.session = Some(session);
515 self.scroll = SCROLL_BOTTOM;
516 }
517 Err(e) => {
518 self.messages.push(ChatMessage::new(
519 "system",
520 format!("Failed to load session: {}", e),
521 ));
522 }
523 }
524 return;
525 }
526
527 if message.trim() == "/model" || message.trim().starts_with("/model ") {
529 let direct_model = message
530 .trim()
531 .strip_prefix("/model")
532 .map(|s| s.trim())
533 .filter(|s| !s.is_empty());
534
535 if let Some(model_str) = direct_model {
536 self.active_model = Some(model_str.to_string());
538 if let Some(session) = self.session.as_mut() {
539 session.metadata.model = Some(model_str.to_string());
540 }
541 self.messages.push(ChatMessage::new(
542 "system",
543 format!("Model set to: {}", model_str),
544 ));
545 } else {
546 self.open_model_picker(config).await;
548 }
549 return;
550 }
551
552 if message.trim() == "/new" {
554 self.session = None;
555 self.messages.clear();
556 self.messages.push(ChatMessage::new(
557 "system",
558 "Started a new session. Previous session was saved.",
559 ));
560 return;
561 }
562
563 self.messages
565 .push(ChatMessage::new("user", message.clone()));
566
567 self.scroll = SCROLL_BOTTOM;
569
570 let current_agent = self.current_agent.clone();
571 let model = self
572 .active_model
573 .clone()
574 .or_else(|| {
575 config
576 .agents
577 .get(¤t_agent)
578 .and_then(|agent| agent.model.clone())
579 })
580 .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok())
581 .or_else(|| config.default_model.clone());
582
583 if self.session.is_none() {
585 match Session::new().await {
586 Ok(session) => {
587 self.session = Some(session);
588 }
589 Err(err) => {
590 tracing::error!(error = %err, "Failed to create session");
591 self.messages
592 .push(ChatMessage::new("assistant", format!("Error: {err}")));
593 return;
594 }
595 }
596 }
597
598 let session = match self.session.as_mut() {
599 Some(session) => session,
600 None => {
601 self.messages.push(ChatMessage::new(
602 "assistant",
603 "Error: session not initialized",
604 ));
605 return;
606 }
607 };
608
609 if let Some(model) = model {
610 session.metadata.model = Some(model);
611 }
612
613 session.agent = current_agent;
614
615 self.is_processing = true;
617 self.processing_message = Some("Thinking...".to_string());
618 self.current_tool = None;
619
620 let (tx, rx) = mpsc::channel(100);
622 self.response_rx = Some(rx);
623
624 let session_clone = session.clone();
626 let message_clone = message.clone();
627
628 tokio::spawn(async move {
630 let mut session = session_clone;
631 if let Err(err) = session.prompt_with_events(&message_clone, tx.clone()).await {
632 tracing::error!(error = %err, "Agent processing failed");
633 let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
634 let _ = tx.send(SessionEvent::Done).await;
635 }
636 });
637 }
638
639 fn handle_response(&mut self, event: SessionEvent) {
640 self.scroll = SCROLL_BOTTOM;
642
643 match event {
644 SessionEvent::Thinking => {
645 self.processing_message = Some("Thinking...".to_string());
646 self.current_tool = None;
647 }
648 SessionEvent::ToolCallStart { name, arguments } => {
649 self.processing_message = Some(format!("Running {}...", name));
650 self.current_tool = Some(name.clone());
651 self.messages.push(
652 ChatMessage::new("tool", format!("🔧 {}", name))
653 .with_message_type(MessageType::ToolCall { name, arguments }),
654 );
655 }
656 SessionEvent::ToolCallComplete {
657 name,
658 output,
659 success,
660 } => {
661 let icon = if success { "✓" } else { "✗" };
662 self.messages.push(
663 ChatMessage::new("tool", format!("{} {}", icon, name))
664 .with_message_type(MessageType::ToolResult { name, output }),
665 );
666 self.current_tool = None;
667 self.processing_message = Some("Thinking...".to_string());
668 }
669 SessionEvent::TextChunk(_text) => {
670 }
672 SessionEvent::TextComplete(text) => {
673 if !text.is_empty() {
674 self.messages.push(ChatMessage::new("assistant", text));
675 }
676 }
677 SessionEvent::Error(err) => {
678 self.messages
679 .push(ChatMessage::new("assistant", format!("Error: {}", err)));
680 }
681 SessionEvent::Done => {
682 self.is_processing = false;
683 self.processing_message = None;
684 self.current_tool = None;
685 self.response_rx = None;
686 }
687 }
688 }
689
690 fn handle_swarm_event(&mut self, event: SwarmEvent) {
692 self.swarm_state.handle_event(event.clone());
693
694 if let SwarmEvent::Complete { success, ref stats } = event {
696 self.view_mode = ViewMode::Chat;
697 let summary = if success {
698 format!(
699 "Swarm completed successfully.\n\
700 Subtasks: {} completed, {} failed\n\
701 Total tool calls: {}\n\
702 Time: {:.1}s (speedup: {:.1}x)",
703 stats.subagents_completed,
704 stats.subagents_failed,
705 stats.total_tool_calls,
706 stats.execution_time_ms as f64 / 1000.0,
707 stats.speedup_factor
708 )
709 } else {
710 format!(
711 "Swarm completed with failures.\n\
712 Subtasks: {} completed, {} failed\n\
713 Check the subtask results for details.",
714 stats.subagents_completed, stats.subagents_failed
715 )
716 };
717 self.messages.push(ChatMessage::new("system", &summary));
718 self.swarm_rx = None;
719 }
720
721 if let SwarmEvent::Error(ref err) = event {
722 self.messages
723 .push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
724 }
725 }
726
727 fn handle_ralph_event(&mut self, event: RalphEvent) {
729 self.ralph_state.handle_event(event.clone());
730
731 if let RalphEvent::Complete {
733 ref status,
734 passed,
735 total,
736 } = event
737 {
738 self.view_mode = ViewMode::Chat;
739 let summary = format!(
740 "Ralph loop finished: {}\n\
741 Stories: {}/{} passed",
742 status, passed, total
743 );
744 self.messages.push(ChatMessage::new("system", &summary));
745 self.ralph_rx = None;
746 }
747
748 if let RalphEvent::Error(ref err) = event {
749 self.messages
750 .push(ChatMessage::new("system", &format!("Ralph error: {}", err)));
751 }
752 }
753
754 async fn start_ralph_execution(&mut self, prd_path: String, config: &Config) {
756 self.messages
758 .push(ChatMessage::new("user", format!("/ralph {}", prd_path)));
759
760 let model = self
762 .active_model
763 .clone()
764 .or_else(|| config.default_model.clone())
765 .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok());
766
767 let model = match model {
768 Some(m) => m,
769 None => {
770 self.messages.push(ChatMessage::new(
771 "system",
772 "No model configured. Use /model to select one first.",
773 ));
774 return;
775 }
776 };
777
778 let prd_file = std::path::PathBuf::from(&prd_path);
780 if !prd_file.exists() {
781 self.messages.push(ChatMessage::new(
782 "system",
783 format!("PRD file not found: {}", prd_path),
784 ));
785 return;
786 }
787
788 let (tx, rx) = mpsc::channel(200);
790 self.ralph_rx = Some(rx);
791
792 self.view_mode = ViewMode::Ralph;
794 self.ralph_state = RalphViewState::new();
795
796 let ralph_config = RalphConfig {
798 prd_path: prd_path.clone(),
799 max_iterations: 10,
800 progress_path: "progress.txt".to_string(),
801 quality_checks_enabled: true,
802 auto_commit: true,
803 model: Some(model.clone()),
804 use_rlm: false,
805 parallel_enabled: true,
806 max_concurrent_stories: 3,
807 worktree_enabled: true,
808 story_timeout_secs: 300,
809 conflict_timeout_secs: 120,
810 };
811
812 let (provider_name, model_name) = if let Some(pos) = model.find('/') {
814 (model[..pos].to_string(), model[pos + 1..].to_string())
815 } else {
816 (model.clone(), model.clone())
817 };
818
819 let prd_path_clone = prd_path.clone();
820 let tx_clone = tx.clone();
821
822 tokio::spawn(async move {
824 let provider = match crate::provider::ProviderRegistry::from_vault().await {
826 Ok(registry) => match registry.get(&provider_name) {
827 Some(p) => p,
828 None => {
829 let _ = tx_clone.send(RalphEvent::Error(
830 format!("Provider '{}' not found", provider_name),
831 )).await;
832 return;
833 }
834 },
835 Err(e) => {
836 let _ = tx_clone.send(RalphEvent::Error(
837 format!("Failed to load providers: {}", e),
838 )).await;
839 return;
840 }
841 };
842
843 let prd_path_buf = std::path::PathBuf::from(&prd_path_clone);
844 match RalphLoop::new(prd_path_buf, provider, model_name, ralph_config).await {
845 Ok(ralph) => {
846 let mut ralph = ralph.with_event_tx(tx_clone.clone());
847 match ralph.run().await {
848 Ok(_state) => {
849 }
851 Err(e) => {
852 let _ = tx_clone.send(RalphEvent::Error(e.to_string())).await;
853 }
854 }
855 }
856 Err(e) => {
857 let _ = tx_clone.send(RalphEvent::Error(
858 format!("Failed to initialize Ralph: {}", e),
859 )).await;
860 }
861 }
862 });
863
864 self.messages.push(ChatMessage::new(
865 "system",
866 format!("Starting Ralph loop with PRD: {}", prd_path),
867 ));
868 }
869
870 async fn start_swarm_execution(&mut self, task: String, config: &Config) {
872 self.messages
874 .push(ChatMessage::new("user", format!("/swarm {}", task)));
875
876 let model = config
878 .default_model
879 .clone()
880 .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok());
881
882 let swarm_config = SwarmConfig {
884 model,
885 max_subagents: 10,
886 max_steps_per_subagent: 50,
887 worktree_enabled: true,
888 worktree_auto_merge: true,
889 working_dir: Some(
890 std::env::current_dir()
891 .map(|p| p.to_string_lossy().to_string())
892 .unwrap_or_else(|_| ".".to_string()),
893 ),
894 ..Default::default()
895 };
896
897 let (tx, rx) = mpsc::channel(100);
899 self.swarm_rx = Some(rx);
900
901 self.view_mode = ViewMode::Swarm;
903 self.swarm_state = SwarmViewState::new();
904
905 let _ = tx
907 .send(SwarmEvent::Started {
908 task: task.clone(),
909 total_subtasks: 0,
910 })
911 .await;
912
913 let task_clone = task;
915 tokio::spawn(async move {
916 let executor = SwarmExecutor::new(swarm_config).with_event_tx(tx.clone());
918 let result = executor
919 .execute(&task_clone, DecompositionStrategy::Automatic)
920 .await;
921
922 match result {
923 Ok(swarm_result) => {
924 let _ = tx
925 .send(SwarmEvent::Complete {
926 success: swarm_result.success,
927 stats: swarm_result.stats,
928 })
929 .await;
930 }
931 Err(e) => {
932 let _ = tx.send(SwarmEvent::Error(e.to_string())).await;
933 }
934 }
935 });
936 }
937
938 async fn open_model_picker(&mut self, config: &Config) {
940 let mut models: Vec<(String, String, String)> = Vec::new();
941
942 match crate::provider::ProviderRegistry::from_vault().await {
944 Ok(registry) => {
945 for provider_name in registry.list() {
946 if let Some(provider) = registry.get(provider_name) {
947 match provider.list_models().await {
948 Ok(model_list) => {
949 for m in model_list {
950 let label = format!("{}/{}", provider_name, m.id);
951 let value = format!("{}/{}", provider_name, m.id);
952 let name = m.name.clone();
953 models.push((label, value, name));
954 }
955 }
956 Err(e) => {
957 tracing::warn!("Failed to list models for {}: {}", provider_name, e);
958 }
959 }
960 }
961 }
962 }
963 Err(e) => {
964 tracing::warn!("Failed to load provider registry: {}", e);
965 }
966 }
967
968 if models.is_empty() {
970 if let Ok(registry) = crate::provider::ProviderRegistry::from_config(config).await {
971 for provider_name in registry.list() {
972 if let Some(provider) = registry.get(provider_name) {
973 if let Ok(model_list) = provider.list_models().await {
974 for m in model_list {
975 let label = format!("{}/{}", provider_name, m.id);
976 let value = format!("{}/{}", provider_name, m.id);
977 let name = m.name.clone();
978 models.push((label, value, name));
979 }
980 }
981 }
982 }
983 }
984 }
985
986 if models.is_empty() {
987 self.messages.push(ChatMessage::new(
988 "system",
989 "No models found. Check provider configuration (Vault or config).",
990 ));
991 } else {
992 models.sort_by(|a, b| a.0.cmp(&b.0));
994 self.model_picker_list = models;
995 self.model_picker_selected = 0;
996 self.model_picker_filter.clear();
997 self.view_mode = ViewMode::ModelPicker;
998 }
999 }
1000
1001 fn filtered_models(&self) -> Vec<(usize, &(String, String, String))> {
1003 if self.model_picker_filter.is_empty() {
1004 self.model_picker_list.iter().enumerate().collect()
1005 } else {
1006 let filter = self.model_picker_filter.to_lowercase();
1007 self.model_picker_list
1008 .iter()
1009 .enumerate()
1010 .filter(|(_, (label, _, name))| {
1011 label.to_lowercase().contains(&filter)
1012 || name.to_lowercase().contains(&filter)
1013 })
1014 .collect()
1015 }
1016 }
1017
1018 fn navigate_history(&mut self, direction: isize) {
1019 if self.command_history.is_empty() {
1020 return;
1021 }
1022
1023 let history_len = self.command_history.len();
1024 let new_index = match self.history_index {
1025 Some(current) => {
1026 let new = current as isize + direction;
1027 if new < 0 {
1028 None
1029 } else if new >= history_len as isize {
1030 Some(history_len - 1)
1031 } else {
1032 Some(new as usize)
1033 }
1034 }
1035 None => {
1036 if direction > 0 {
1037 Some(0)
1038 } else {
1039 Some(history_len.saturating_sub(1))
1040 }
1041 }
1042 };
1043
1044 self.history_index = new_index;
1045 if let Some(index) = new_index {
1046 self.input = self.command_history[index].clone();
1047 self.cursor_position = self.input.len();
1048 } else {
1049 self.input.clear();
1050 self.cursor_position = 0;
1051 }
1052 }
1053
1054 fn search_history(&mut self) {
1055 if self.command_history.is_empty() {
1057 return;
1058 }
1059
1060 let search_term = self.input.trim().to_lowercase();
1061
1062 if search_term.is_empty() {
1063 if !self.command_history.is_empty() {
1065 self.input = self.command_history.last().unwrap().clone();
1066 self.cursor_position = self.input.len();
1067 self.history_index = Some(self.command_history.len() - 1);
1068 }
1069 return;
1070 }
1071
1072 for (index, cmd) in self.command_history.iter().enumerate().rev() {
1074 if cmd.to_lowercase().starts_with(&search_term) {
1075 self.input = cmd.clone();
1076 self.cursor_position = self.input.len();
1077 self.history_index = Some(index);
1078 return;
1079 }
1080 }
1081
1082 for (index, cmd) in self.command_history.iter().enumerate().rev() {
1084 if cmd.to_lowercase().contains(&search_term) {
1085 self.input = cmd.clone();
1086 self.cursor_position = self.input.len();
1087 self.history_index = Some(index);
1088 return;
1089 }
1090 }
1091 }
1092}
1093
1094async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
1095 let mut app = App::new();
1096 if let Ok(sessions) = list_sessions().await {
1097 app.update_cached_sessions(sessions);
1098 }
1099
1100 let mut config = Config::load().await?;
1102 let mut theme = crate::tui::theme_utils::validate_theme(&config.load_theme());
1103
1104 let _config_paths = vec![
1106 std::path::PathBuf::from("./codetether.toml"),
1107 std::path::PathBuf::from("./.codetether/config.toml"),
1108 ];
1109
1110 let _global_config_path = directories::ProjectDirs::from("com", "codetether", "codetether")
1111 .map(|dirs| dirs.config_dir().join("config.toml"));
1112
1113 let mut last_check = Instant::now();
1114 let mut last_session_refresh = Instant::now();
1115
1116 loop {
1117 if config.ui.hot_reload && last_check.elapsed() > Duration::from_secs(2) {
1119 if let Ok(new_config) = Config::load().await {
1120 if new_config.ui.theme != config.ui.theme
1121 || new_config.ui.custom_theme != config.ui.custom_theme
1122 {
1123 theme = crate::tui::theme_utils::validate_theme(&new_config.load_theme());
1124 config = new_config;
1125 }
1126 }
1127 last_check = Instant::now();
1128 }
1129
1130 if last_session_refresh.elapsed() > Duration::from_secs(5) {
1131 if let Ok(sessions) = list_sessions().await {
1132 app.update_cached_sessions(sessions);
1133 }
1134 last_session_refresh = Instant::now();
1135 }
1136
1137 terminal.draw(|f| ui(f, &mut app, &theme))?;
1138
1139 let terminal_height = terminal.size()?.height.saturating_sub(6) as usize;
1142 let estimated_lines = app.messages.len() * 4; app.last_max_scroll = estimated_lines.saturating_sub(terminal_height);
1144
1145 if let Some(mut rx) = app.response_rx.take() {
1147 while let Ok(response) = rx.try_recv() {
1148 app.handle_response(response);
1149 }
1150 app.response_rx = Some(rx);
1151 }
1152
1153 if let Some(mut rx) = app.swarm_rx.take() {
1155 while let Ok(event) = rx.try_recv() {
1156 app.handle_swarm_event(event);
1157 }
1158 app.swarm_rx = Some(rx);
1159 }
1160
1161 if let Some(mut rx) = app.ralph_rx.take() {
1163 while let Ok(event) = rx.try_recv() {
1164 app.handle_ralph_event(event);
1165 }
1166 app.ralph_rx = Some(rx);
1167 }
1168
1169 if event::poll(std::time::Duration::from_millis(100))? {
1170 if let Event::Key(key) = event::read()? {
1171 if app.show_help {
1173 if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
1174 app.show_help = false;
1175 }
1176 continue;
1177 }
1178
1179 if app.view_mode == ViewMode::ModelPicker {
1181 match key.code {
1182 KeyCode::Esc => {
1183 app.view_mode = ViewMode::Chat;
1184 }
1185 KeyCode::Up | KeyCode::Char('k') if !key.modifiers.contains(KeyModifiers::ALT) => {
1186 if app.model_picker_selected > 0 {
1187 app.model_picker_selected -= 1;
1188 }
1189 }
1190 KeyCode::Down | KeyCode::Char('j') if !key.modifiers.contains(KeyModifiers::ALT) => {
1191 let filtered = app.filtered_models();
1192 if app.model_picker_selected < filtered.len().saturating_sub(1) {
1193 app.model_picker_selected += 1;
1194 }
1195 }
1196 KeyCode::Enter => {
1197 let filtered = app.filtered_models();
1198 if let Some((_, (label, value, _name))) = filtered.get(app.model_picker_selected) {
1199 let label = label.clone();
1200 let value = value.clone();
1201 app.active_model = Some(value.clone());
1202 if let Some(session) = app.session.as_mut() {
1203 session.metadata.model = Some(value.clone());
1204 }
1205 app.messages.push(ChatMessage::new(
1206 "system",
1207 format!("Model set to: {}", label),
1208 ));
1209 app.view_mode = ViewMode::Chat;
1210 }
1211 }
1212 KeyCode::Backspace => {
1213 app.model_picker_filter.pop();
1214 app.model_picker_selected = 0;
1215 }
1216 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) => {
1217 app.model_picker_filter.push(c);
1218 app.model_picker_selected = 0;
1219 }
1220 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1221 return Ok(());
1222 }
1223 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1224 return Ok(());
1225 }
1226 _ => {}
1227 }
1228 continue;
1229 }
1230
1231 if app.view_mode == ViewMode::SessionPicker {
1233 match key.code {
1234 KeyCode::Esc => {
1235 app.view_mode = ViewMode::Chat;
1236 }
1237 KeyCode::Up | KeyCode::Char('k') => {
1238 if app.session_picker_selected > 0 {
1239 app.session_picker_selected -= 1;
1240 }
1241 }
1242 KeyCode::Down | KeyCode::Char('j') => {
1243 if app.session_picker_selected
1244 < app.session_picker_list.len().saturating_sub(1)
1245 {
1246 app.session_picker_selected += 1;
1247 }
1248 }
1249 KeyCode::Enter => {
1250 if let Some(session_summary) =
1251 app.session_picker_list.get(app.session_picker_selected)
1252 {
1253 let session_id = session_summary.id.clone();
1254 match Session::load(&session_id).await {
1255 Ok(session) => {
1256 app.messages.clear();
1257 app.messages.push(ChatMessage::new("system", format!(
1258 "Resumed session: {}\nCreated: {}\n{} messages loaded",
1259 session.title.as_deref().unwrap_or("(untitled)"),
1260 session.created_at.format("%Y-%m-%d %H:%M"),
1261 session.messages.len()
1262 )));
1263
1264 for msg in &session.messages {
1265 let role_str = match msg.role {
1266 Role::System => "system",
1267 Role::User => "user",
1268 Role::Assistant => "assistant",
1269 Role::Tool => "tool",
1270 };
1271
1272 let text = msg
1273 .content
1274 .iter()
1275 .filter_map(|part| {
1276 if let ContentPart::Text { text } = part {
1277 Some(text.as_str())
1278 } else {
1279 None
1280 }
1281 })
1282 .collect::<Vec<_>>()
1283 .join("\n");
1284
1285 if !text.is_empty() {
1286 app.messages.push(ChatMessage::new(role_str, text));
1287 }
1288 }
1289
1290 app.current_agent = session.agent.clone();
1291 app.session = Some(session);
1292 app.scroll = SCROLL_BOTTOM;
1293 app.view_mode = ViewMode::Chat;
1294 }
1295 Err(e) => {
1296 app.messages.push(ChatMessage::new(
1297 "system",
1298 format!("Failed to load session: {}", e),
1299 ));
1300 app.view_mode = ViewMode::Chat;
1301 }
1302 }
1303 }
1304 }
1305 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1306 return Ok(());
1307 }
1308 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1309 return Ok(());
1310 }
1311 _ => {}
1312 }
1313 continue;
1314 }
1315
1316 if app.view_mode == ViewMode::Swarm {
1318 match key.code {
1319 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1320 return Ok(());
1321 }
1322 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1323 return Ok(());
1324 }
1325 KeyCode::Esc => {
1326 if app.swarm_state.detail_mode {
1327 app.swarm_state.exit_detail();
1328 } else {
1329 app.view_mode = ViewMode::Chat;
1330 }
1331 }
1332 KeyCode::Up | KeyCode::Char('k') => {
1333 if app.swarm_state.detail_mode {
1334 app.swarm_state.exit_detail();
1336 app.swarm_state.select_prev();
1337 app.swarm_state.enter_detail();
1338 } else {
1339 app.swarm_state.select_prev();
1340 }
1341 }
1342 KeyCode::Down | KeyCode::Char('j') => {
1343 if app.swarm_state.detail_mode {
1344 app.swarm_state.exit_detail();
1345 app.swarm_state.select_next();
1346 app.swarm_state.enter_detail();
1347 } else {
1348 app.swarm_state.select_next();
1349 }
1350 }
1351 KeyCode::Enter => {
1352 if !app.swarm_state.detail_mode {
1353 app.swarm_state.enter_detail();
1354 }
1355 }
1356 KeyCode::PageDown => {
1357 app.swarm_state.detail_scroll_down(10);
1358 }
1359 KeyCode::PageUp => {
1360 app.swarm_state.detail_scroll_up(10);
1361 }
1362 KeyCode::Char('?') => {
1363 app.show_help = true;
1364 }
1365 KeyCode::F(2) => {
1366 app.view_mode = ViewMode::Chat;
1367 }
1368 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1369 app.view_mode = ViewMode::Chat;
1370 }
1371 _ => {}
1372 }
1373 continue;
1374 }
1375
1376 if app.view_mode == ViewMode::Ralph {
1378 match key.code {
1379 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1380 return Ok(());
1381 }
1382 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1383 return Ok(());
1384 }
1385 KeyCode::Esc => {
1386 if app.ralph_state.detail_mode {
1387 app.ralph_state.exit_detail();
1388 } else {
1389 app.view_mode = ViewMode::Chat;
1390 }
1391 }
1392 KeyCode::Up | KeyCode::Char('k') => {
1393 if app.ralph_state.detail_mode {
1394 app.ralph_state.exit_detail();
1395 app.ralph_state.select_prev();
1396 app.ralph_state.enter_detail();
1397 } else {
1398 app.ralph_state.select_prev();
1399 }
1400 }
1401 KeyCode::Down | KeyCode::Char('j') => {
1402 if app.ralph_state.detail_mode {
1403 app.ralph_state.exit_detail();
1404 app.ralph_state.select_next();
1405 app.ralph_state.enter_detail();
1406 } else {
1407 app.ralph_state.select_next();
1408 }
1409 }
1410 KeyCode::Enter => {
1411 if !app.ralph_state.detail_mode {
1412 app.ralph_state.enter_detail();
1413 }
1414 }
1415 KeyCode::PageDown => {
1416 app.ralph_state.detail_scroll_down(10);
1417 }
1418 KeyCode::PageUp => {
1419 app.ralph_state.detail_scroll_up(10);
1420 }
1421 KeyCode::Char('?') => {
1422 app.show_help = true;
1423 }
1424 KeyCode::F(2) | KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1425 app.view_mode = ViewMode::Chat;
1426 }
1427 _ => {}
1428 }
1429 continue;
1430 }
1431
1432 match key.code {
1433 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1435 return Ok(());
1436 }
1437 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1438 return Ok(());
1439 }
1440
1441 KeyCode::Char('?') => {
1443 app.show_help = true;
1444 }
1445
1446 KeyCode::F(2) => {
1448 app.view_mode = match app.view_mode {
1449 ViewMode::Chat | ViewMode::SessionPicker | ViewMode::ModelPicker => ViewMode::Swarm,
1450 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
1451 };
1452 }
1453 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1454 app.view_mode = match app.view_mode {
1455 ViewMode::Chat | ViewMode::SessionPicker | ViewMode::ModelPicker => ViewMode::Swarm,
1456 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
1457 };
1458 }
1459
1460 KeyCode::F(3) => {
1462 app.show_inspector = !app.show_inspector;
1463 }
1464
1465 KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1467 app.chat_layout = match app.chat_layout {
1468 ChatLayoutMode::Classic => ChatLayoutMode::Webview,
1469 ChatLayoutMode::Webview => ChatLayoutMode::Classic,
1470 };
1471 }
1472
1473 KeyCode::Esc => {
1475 if app.view_mode == ViewMode::Swarm
1476 || app.view_mode == ViewMode::Ralph
1477 || app.view_mode == ViewMode::SessionPicker
1478 || app.view_mode == ViewMode::ModelPicker
1479 {
1480 app.view_mode = ViewMode::Chat;
1481 }
1482 }
1483
1484 KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1486 app.open_model_picker(&config).await;
1487 }
1488
1489 KeyCode::Tab => {
1491 app.current_agent = if app.current_agent == "build" {
1492 "plan".to_string()
1493 } else {
1494 "build".to_string()
1495 };
1496 }
1497
1498 KeyCode::Enter => {
1500 app.submit_message(&config).await;
1501 }
1502
1503 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
1505 if app.scroll < SCROLL_BOTTOM {
1506 app.scroll = app.scroll.saturating_add(1);
1507 }
1508 }
1509 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
1510 if app.scroll >= SCROLL_BOTTOM {
1511 app.scroll = app.last_max_scroll; }
1513 app.scroll = app.scroll.saturating_sub(1);
1514 }
1515
1516 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1518 app.search_history();
1519 }
1520 KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
1521 app.navigate_history(-1);
1522 }
1523 KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
1524 app.navigate_history(1);
1525 }
1526
1527 KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1529 app.scroll = 0; }
1531 KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1532 app.scroll = SCROLL_BOTTOM;
1534 }
1535
1536 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
1538 if app.scroll < SCROLL_BOTTOM {
1540 app.scroll = app.scroll.saturating_add(5);
1541 }
1542 }
1543 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
1544 if app.scroll >= SCROLL_BOTTOM {
1546 app.scroll = app.last_max_scroll;
1547 }
1548 app.scroll = app.scroll.saturating_sub(5);
1549 }
1550
1551 KeyCode::Char(c) => {
1553 app.input.insert(app.cursor_position, c);
1554 app.cursor_position += 1;
1555 }
1556 KeyCode::Backspace => {
1557 if app.cursor_position > 0 {
1558 app.cursor_position -= 1;
1559 app.input.remove(app.cursor_position);
1560 }
1561 }
1562 KeyCode::Delete => {
1563 if app.cursor_position < app.input.len() {
1564 app.input.remove(app.cursor_position);
1565 }
1566 }
1567 KeyCode::Left => {
1568 app.cursor_position = app.cursor_position.saturating_sub(1);
1569 }
1570 KeyCode::Right => {
1571 if app.cursor_position < app.input.len() {
1572 app.cursor_position += 1;
1573 }
1574 }
1575 KeyCode::Home => {
1576 app.cursor_position = 0;
1577 }
1578 KeyCode::End => {
1579 app.cursor_position = app.input.len();
1580 }
1581
1582 KeyCode::Up => {
1584 if app.scroll >= SCROLL_BOTTOM {
1585 app.scroll = app.last_max_scroll; }
1587 app.scroll = app.scroll.saturating_sub(1);
1588 }
1589 KeyCode::Down => {
1590 if app.scroll < SCROLL_BOTTOM {
1591 app.scroll = app.scroll.saturating_add(1);
1592 }
1593 }
1594 KeyCode::PageUp => {
1595 if app.scroll >= SCROLL_BOTTOM {
1596 app.scroll = app.last_max_scroll;
1597 }
1598 app.scroll = app.scroll.saturating_sub(10);
1599 }
1600 KeyCode::PageDown => {
1601 if app.scroll < SCROLL_BOTTOM {
1602 app.scroll = app.scroll.saturating_add(10);
1603 }
1604 }
1605
1606 _ => {}
1607 }
1608 }
1609 }
1610 }
1611}
1612
1613fn ui(f: &mut Frame, app: &mut App, theme: &Theme) {
1614 if app.view_mode == ViewMode::Swarm {
1616 let chunks = Layout::default()
1618 .direction(Direction::Vertical)
1619 .constraints([
1620 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
1624 .split(f.area());
1625
1626 render_swarm_view(f, &mut app.swarm_state, chunks[0]);
1628
1629 let input_block = Block::default()
1631 .borders(Borders::ALL)
1632 .title(" Press Esc, Ctrl+S, or /view to return to chat ")
1633 .border_style(Style::default().fg(Color::Cyan));
1634
1635 let input = Paragraph::new(app.input.as_str())
1636 .block(input_block)
1637 .wrap(Wrap { trim: false });
1638 f.render_widget(input, chunks[1]);
1639
1640 let status_line = if app.swarm_state.detail_mode {
1642 Line::from(vec![
1643 Span::styled(
1644 " AGENT DETAIL ",
1645 Style::default().fg(Color::Black).bg(Color::Cyan),
1646 ),
1647 Span::raw(" | "),
1648 Span::styled("Esc", Style::default().fg(Color::Yellow)),
1649 Span::raw(": Back to list | "),
1650 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
1651 Span::raw(": Prev/Next agent | "),
1652 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
1653 Span::raw(": Scroll"),
1654 ])
1655 } else {
1656 Line::from(vec![
1657 Span::styled(
1658 " SWARM MODE ",
1659 Style::default().fg(Color::Black).bg(Color::Cyan),
1660 ),
1661 Span::raw(" | "),
1662 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
1663 Span::raw(": Select | "),
1664 Span::styled("Enter", Style::default().fg(Color::Yellow)),
1665 Span::raw(": Detail | "),
1666 Span::styled("Esc", Style::default().fg(Color::Yellow)),
1667 Span::raw(": Back | "),
1668 Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
1669 Span::raw(": Toggle view"),
1670 ])
1671 };
1672 let status = Paragraph::new(status_line);
1673 f.render_widget(status, chunks[2]);
1674 return;
1675 }
1676
1677 if app.view_mode == ViewMode::Ralph {
1679 let chunks = Layout::default()
1680 .direction(Direction::Vertical)
1681 .constraints([
1682 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
1686 .split(f.area());
1687
1688 render_ralph_view(f, &mut app.ralph_state, chunks[0]);
1689
1690 let input_block = Block::default()
1691 .borders(Borders::ALL)
1692 .title(" Press Esc to return to chat ")
1693 .border_style(Style::default().fg(Color::Magenta));
1694
1695 let input = Paragraph::new(app.input.as_str())
1696 .block(input_block)
1697 .wrap(Wrap { trim: false });
1698 f.render_widget(input, chunks[1]);
1699
1700 let status_line = if app.ralph_state.detail_mode {
1701 Line::from(vec![
1702 Span::styled(
1703 " STORY DETAIL ",
1704 Style::default().fg(Color::Black).bg(Color::Magenta),
1705 ),
1706 Span::raw(" | "),
1707 Span::styled("Esc", Style::default().fg(Color::Yellow)),
1708 Span::raw(": Back to list | "),
1709 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
1710 Span::raw(": Prev/Next story | "),
1711 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
1712 Span::raw(": Scroll"),
1713 ])
1714 } else {
1715 Line::from(vec![
1716 Span::styled(
1717 " RALPH MODE ",
1718 Style::default().fg(Color::Black).bg(Color::Magenta),
1719 ),
1720 Span::raw(" | "),
1721 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
1722 Span::raw(": Select | "),
1723 Span::styled("Enter", Style::default().fg(Color::Yellow)),
1724 Span::raw(": Detail | "),
1725 Span::styled("Esc", Style::default().fg(Color::Yellow)),
1726 Span::raw(": Back"),
1727 ])
1728 };
1729 let status = Paragraph::new(status_line);
1730 f.render_widget(status, chunks[2]);
1731 return;
1732 }
1733
1734 if app.view_mode == ViewMode::ModelPicker {
1736 let area = centered_rect(70, 70, f.area());
1737 f.render_widget(Clear, area);
1738
1739 let filter_display = if app.model_picker_filter.is_empty() {
1740 "type to filter".to_string()
1741 } else {
1742 format!("filter: {}", app.model_picker_filter)
1743 };
1744
1745 let picker_block = Block::default()
1746 .borders(Borders::ALL)
1747 .title(format!(" Select Model (↑↓ navigate, Enter select, Esc cancel) [{}] ", filter_display))
1748 .border_style(Style::default().fg(Color::Magenta));
1749
1750 let filtered = app.filtered_models();
1751 let mut list_lines: Vec<Line> = Vec::new();
1752 list_lines.push(Line::from(""));
1753
1754 if let Some(ref active) = app.active_model {
1755 list_lines.push(Line::styled(
1756 format!(" Current: {}", active),
1757 Style::default().fg(Color::Green).add_modifier(Modifier::DIM),
1758 ));
1759 list_lines.push(Line::from(""));
1760 }
1761
1762 if filtered.is_empty() {
1763 list_lines.push(Line::styled(
1764 " No models match filter",
1765 Style::default().fg(Color::DarkGray),
1766 ));
1767 } else {
1768 let mut current_provider = String::new();
1769 for (display_idx, (_, (label, _, human_name))) in filtered.iter().enumerate() {
1770 let provider = label.split('/').next().unwrap_or("");
1771 if provider != current_provider {
1772 if !current_provider.is_empty() {
1773 list_lines.push(Line::from(""));
1774 }
1775 list_lines.push(Line::styled(
1776 format!(" ─── {} ───", provider),
1777 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
1778 ));
1779 current_provider = provider.to_string();
1780 }
1781
1782 let is_selected = display_idx == app.model_picker_selected;
1783 let is_active = app.active_model.as_deref() == Some(label.as_str());
1784 let marker = if is_selected { "▶" } else { " " };
1785 let active_marker = if is_active { " ✓" } else { "" };
1786 let model_id = label.split('/').skip(1).collect::<Vec<_>>().join("/");
1787 let display = if human_name != &model_id && !human_name.is_empty() {
1789 format!("{} ({})", human_name, model_id)
1790 } else {
1791 model_id
1792 };
1793
1794 let style = if is_selected {
1795 Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
1796 } else if is_active {
1797 Style::default().fg(Color::Green)
1798 } else {
1799 Style::default()
1800 };
1801
1802 list_lines.push(Line::styled(
1803 format!(" {} {}{}", marker, display, active_marker),
1804 style,
1805 ));
1806 }
1807 }
1808
1809 let list = Paragraph::new(list_lines)
1810 .block(picker_block)
1811 .wrap(Wrap { trim: false });
1812 f.render_widget(list, area);
1813 return;
1814 }
1815
1816 if app.view_mode == ViewMode::SessionPicker {
1818 let chunks = Layout::default()
1819 .direction(Direction::Vertical)
1820 .constraints([
1821 Constraint::Min(1), Constraint::Length(1), ])
1824 .split(f.area());
1825
1826 let list_block = Block::default()
1828 .borders(Borders::ALL)
1829 .title(" Select Session (↑↓ to navigate, Enter to load, Esc to cancel) ")
1830 .border_style(Style::default().fg(Color::Cyan));
1831
1832 let mut list_lines: Vec<Line> = Vec::new();
1833 list_lines.push(Line::from(""));
1834
1835 for (i, session) in app.session_picker_list.iter().enumerate() {
1836 let is_selected = i == app.session_picker_selected;
1837 let title = session.title.as_deref().unwrap_or("(untitled)");
1838 let date = session.updated_at.format("%Y-%m-%d %H:%M");
1839 let line_str = format!(
1840 " {} {} - {} ({} msgs)",
1841 if is_selected { "▶" } else { " " },
1842 title,
1843 date,
1844 session.message_count
1845 );
1846
1847 let style = if is_selected {
1848 Style::default()
1849 .fg(Color::Cyan)
1850 .add_modifier(Modifier::BOLD)
1851 } else {
1852 Style::default()
1853 };
1854
1855 list_lines.push(Line::styled(line_str, style));
1856
1857 if is_selected {
1859 list_lines.push(Line::styled(
1860 format!(" Agent: {} | ID: {}", session.agent, session.id),
1861 Style::default().fg(Color::DarkGray),
1862 ));
1863 }
1864 }
1865
1866 let list = Paragraph::new(list_lines)
1867 .block(list_block)
1868 .wrap(Wrap { trim: false });
1869 f.render_widget(list, chunks[0]);
1870
1871 let status = Paragraph::new(Line::from(vec![
1873 Span::styled(
1874 " SESSION PICKER ",
1875 Style::default().fg(Color::Black).bg(Color::Cyan),
1876 ),
1877 Span::raw(" | "),
1878 Span::styled("↑↓/jk", Style::default().fg(Color::Yellow)),
1879 Span::raw(": Navigate | "),
1880 Span::styled("Enter", Style::default().fg(Color::Yellow)),
1881 Span::raw(": Load | "),
1882 Span::styled("Esc", Style::default().fg(Color::Yellow)),
1883 Span::raw(": Cancel"),
1884 ]));
1885 f.render_widget(status, chunks[1]);
1886 return;
1887 }
1888
1889 if app.chat_layout == ChatLayoutMode::Webview {
1890 if render_webview_chat(f, app, theme) {
1891 render_help_overlay_if_needed(f, app, theme);
1892 return;
1893 }
1894 }
1895
1896 let chunks = Layout::default()
1898 .direction(Direction::Vertical)
1899 .constraints([
1900 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
1904 .split(f.area());
1905
1906 let messages_area = chunks[0];
1908 let model_label = app.active_model.as_deref().unwrap_or("auto");
1909 let messages_block = Block::default()
1910 .borders(Borders::ALL)
1911 .title(format!(" CodeTether Agent [{}] model:{} ", app.current_agent, model_label))
1912 .border_style(Style::default().fg(theme.border_color.to_color()));
1913
1914 let max_width = messages_area.width.saturating_sub(4) as usize;
1915 let message_lines = build_message_lines(app, theme, max_width);
1916
1917 let total_lines = message_lines.len();
1919 let visible_lines = messages_area.height.saturating_sub(2) as usize;
1920 let max_scroll = total_lines.saturating_sub(visible_lines);
1921 let scroll = if app.scroll >= SCROLL_BOTTOM {
1923 max_scroll
1924 } else {
1925 app.scroll.min(max_scroll)
1926 };
1927
1928 let messages_paragraph = Paragraph::new(
1930 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
1931 )
1932 .block(messages_block.clone())
1933 .wrap(Wrap { trim: false });
1934
1935 f.render_widget(messages_paragraph, messages_area);
1936
1937 if total_lines > visible_lines {
1939 let scrollbar = Scrollbar::default()
1940 .orientation(ScrollbarOrientation::VerticalRight)
1941 .symbols(ratatui::symbols::scrollbar::VERTICAL)
1942 .begin_symbol(Some("↑"))
1943 .end_symbol(Some("↓"));
1944
1945 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
1946
1947 let scrollbar_area = Rect::new(
1948 messages_area.right() - 1,
1949 messages_area.top() + 1,
1950 1,
1951 messages_area.height - 2,
1952 );
1953
1954 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
1955 }
1956
1957 let input_block = Block::default()
1959 .borders(Borders::ALL)
1960 .title(if app.is_processing {
1961 " Message (Processing...) "
1962 } else {
1963 " Message (Enter to send) "
1964 })
1965 .border_style(Style::default().fg(if app.is_processing {
1966 Color::Yellow
1967 } else {
1968 theme.input_border_color.to_color()
1969 }));
1970
1971 let input = Paragraph::new(app.input.as_str())
1972 .block(input_block)
1973 .wrap(Wrap { trim: false });
1974 f.render_widget(input, chunks[1]);
1975
1976 f.set_cursor_position((
1978 chunks[1].x + app.cursor_position as u16 + 1,
1979 chunks[1].y + 1,
1980 ));
1981
1982 let token_display = TokenDisplay::new();
1984 let status = Paragraph::new(token_display.create_status_bar(theme));
1985 f.render_widget(status, chunks[2]);
1986
1987 render_help_overlay_if_needed(f, app, theme);
1988}
1989
1990fn render_webview_chat(f: &mut Frame, app: &App, theme: &Theme) -> bool {
1991 let area = f.area();
1992 if area.width < 90 || area.height < 18 {
1993 return false;
1994 }
1995
1996 let main_chunks = Layout::default()
1997 .direction(Direction::Vertical)
1998 .constraints([
1999 Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
2004 .split(area);
2005
2006 render_webview_header(f, app, theme, main_chunks[0]);
2007
2008 let body_constraints = if app.show_inspector {
2009 vec![
2010 Constraint::Length(26),
2011 Constraint::Min(40),
2012 Constraint::Length(30),
2013 ]
2014 } else {
2015 vec![Constraint::Length(26), Constraint::Min(40)]
2016 };
2017
2018 let body_chunks = Layout::default()
2019 .direction(Direction::Horizontal)
2020 .constraints(body_constraints)
2021 .split(main_chunks[1]);
2022
2023 render_webview_sidebar(f, app, theme, body_chunks[0]);
2024 render_webview_chat_center(f, app, theme, body_chunks[1]);
2025 if app.show_inspector && body_chunks.len() > 2 {
2026 render_webview_inspector(f, app, theme, body_chunks[2]);
2027 }
2028
2029 render_webview_input(f, app, theme, main_chunks[2]);
2030
2031 let token_display = TokenDisplay::new();
2032 let status = Paragraph::new(token_display.create_status_bar(theme));
2033 f.render_widget(status, main_chunks[3]);
2034
2035 true
2036}
2037
2038fn render_webview_header(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2039 let session_title = app
2040 .session
2041 .as_ref()
2042 .and_then(|s| s.title.clone())
2043 .unwrap_or_else(|| "Workspace Chat".to_string());
2044 let session_id = app
2045 .session
2046 .as_ref()
2047 .map(|s| s.id.chars().take(8).collect::<String>())
2048 .unwrap_or_else(|| "new".to_string());
2049 let model_label = app
2050 .session
2051 .as_ref()
2052 .and_then(|s| s.metadata.model.clone())
2053 .unwrap_or_else(|| "auto".to_string());
2054 let workspace_label = app.workspace.root_display.clone();
2055 let branch_label = app
2056 .workspace
2057 .git_branch
2058 .clone()
2059 .unwrap_or_else(|| "no-git".to_string());
2060 let dirty_label = if app.workspace.git_dirty_files > 0 {
2061 format!("{} dirty", app.workspace.git_dirty_files)
2062 } else {
2063 "clean".to_string()
2064 };
2065
2066 let header_block = Block::default()
2067 .borders(Borders::ALL)
2068 .title(" CodeTether Webview ")
2069 .border_style(Style::default().fg(theme.border_color.to_color()));
2070
2071 let header_lines = vec![
2072 Line::from(vec![
2073 Span::styled(session_title, Style::default().add_modifier(Modifier::BOLD)),
2074 Span::raw(" "),
2075 Span::styled(
2076 format!("#{}", session_id),
2077 Style::default()
2078 .fg(theme.timestamp_color.to_color())
2079 .add_modifier(Modifier::DIM),
2080 ),
2081 ]),
2082 Line::from(vec![
2083 Span::styled("Workspace ", Style::default().fg(theme.timestamp_color.to_color())),
2084 Span::styled(workspace_label, Style::default()),
2085 Span::raw(" "),
2086 Span::styled("Branch ", Style::default().fg(theme.timestamp_color.to_color())),
2087 Span::styled(
2088 branch_label,
2089 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
2090 ),
2091 Span::raw(" "),
2092 Span::styled(
2093 dirty_label,
2094 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
2095 ),
2096 Span::raw(" "),
2097 Span::styled("Model ", Style::default().fg(theme.timestamp_color.to_color())),
2098 Span::styled(model_label, Style::default().fg(Color::Green)),
2099 ]),
2100 ];
2101
2102 let header = Paragraph::new(header_lines)
2103 .block(header_block)
2104 .wrap(Wrap { trim: true });
2105 f.render_widget(header, area);
2106}
2107
2108fn render_webview_sidebar(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2109 let sidebar_chunks = Layout::default()
2110 .direction(Direction::Vertical)
2111 .constraints([Constraint::Min(8), Constraint::Min(6)])
2112 .split(area);
2113
2114 let workspace_block = Block::default()
2115 .borders(Borders::ALL)
2116 .title(" Workspace ")
2117 .border_style(Style::default().fg(theme.border_color.to_color()));
2118
2119 let mut workspace_lines = Vec::new();
2120 workspace_lines.push(Line::from(vec![
2121 Span::styled("Updated ", Style::default().fg(theme.timestamp_color.to_color())),
2122 Span::styled(
2123 app.workspace.captured_at.clone(),
2124 Style::default().fg(theme.timestamp_color.to_color()),
2125 ),
2126 ]));
2127 workspace_lines.push(Line::from(""));
2128
2129 if app.workspace.entries.is_empty() {
2130 workspace_lines.push(Line::styled(
2131 "No entries found",
2132 Style::default().fg(Color::DarkGray),
2133 ));
2134 } else {
2135 for entry in app.workspace.entries.iter().take(12) {
2136 let icon = match entry.kind {
2137 WorkspaceEntryKind::Directory => "📁",
2138 WorkspaceEntryKind::File => "📄",
2139 };
2140 workspace_lines.push(Line::from(vec![
2141 Span::styled(icon, Style::default().fg(Color::Cyan)),
2142 Span::raw(" "),
2143 Span::styled(entry.name.clone(), Style::default()),
2144 ]));
2145 }
2146 }
2147
2148 workspace_lines.push(Line::from(""));
2149 workspace_lines.push(Line::styled(
2150 "Use /refresh to rescan",
2151 Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM),
2152 ));
2153
2154 let workspace_panel = Paragraph::new(workspace_lines)
2155 .block(workspace_block)
2156 .wrap(Wrap { trim: true });
2157 f.render_widget(workspace_panel, sidebar_chunks[0]);
2158
2159 let sessions_block = Block::default()
2160 .borders(Borders::ALL)
2161 .title(" Recent Sessions ")
2162 .border_style(Style::default().fg(theme.border_color.to_color()));
2163
2164 let mut session_lines = Vec::new();
2165 if app.session_picker_list.is_empty() {
2166 session_lines.push(Line::styled(
2167 "No sessions yet",
2168 Style::default().fg(Color::DarkGray),
2169 ));
2170 } else {
2171 for session in app.session_picker_list.iter().take(6) {
2172 let is_active = app
2173 .session
2174 .as_ref()
2175 .map(|s| s.id == session.id)
2176 .unwrap_or(false);
2177 let title = session.title.as_deref().unwrap_or("(untitled)");
2178 let indicator = if is_active { "●" } else { "○" };
2179 let line_style = if is_active {
2180 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
2181 } else {
2182 Style::default()
2183 };
2184 session_lines.push(Line::from(vec![
2185 Span::styled(indicator, line_style),
2186 Span::raw(" "),
2187 Span::styled(title, line_style),
2188 ]));
2189 session_lines.push(Line::styled(
2190 format!(
2191 " {} msgs • {}",
2192 session.message_count,
2193 session.updated_at.format("%m-%d %H:%M")
2194 ),
2195 Style::default().fg(Color::DarkGray),
2196 ));
2197 }
2198 }
2199
2200 let sessions_panel = Paragraph::new(session_lines)
2201 .block(sessions_block)
2202 .wrap(Wrap { trim: true });
2203 f.render_widget(sessions_panel, sidebar_chunks[1]);
2204}
2205
2206fn render_webview_chat_center(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2207 let messages_area = area;
2208 let messages_block = Block::default()
2209 .borders(Borders::ALL)
2210 .title(format!(" Chat [{}] ", app.current_agent))
2211 .border_style(Style::default().fg(theme.border_color.to_color()));
2212
2213 let max_width = messages_area.width.saturating_sub(4) as usize;
2214 let message_lines = build_message_lines(app, theme, max_width);
2215
2216 let total_lines = message_lines.len();
2217 let visible_lines = messages_area.height.saturating_sub(2) as usize;
2218 let max_scroll = total_lines.saturating_sub(visible_lines);
2219 let scroll = if app.scroll >= SCROLL_BOTTOM {
2220 max_scroll
2221 } else {
2222 app.scroll.min(max_scroll)
2223 };
2224
2225 let messages_paragraph = Paragraph::new(
2226 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
2227 )
2228 .block(messages_block.clone())
2229 .wrap(Wrap { trim: false });
2230
2231 f.render_widget(messages_paragraph, messages_area);
2232
2233 if total_lines > visible_lines {
2234 let scrollbar = Scrollbar::default()
2235 .orientation(ScrollbarOrientation::VerticalRight)
2236 .symbols(ratatui::symbols::scrollbar::VERTICAL)
2237 .begin_symbol(Some("↑"))
2238 .end_symbol(Some("↓"));
2239
2240 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
2241
2242 let scrollbar_area = Rect::new(
2243 messages_area.right() - 1,
2244 messages_area.top() + 1,
2245 1,
2246 messages_area.height - 2,
2247 );
2248
2249 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
2250 }
2251}
2252
2253fn render_webview_inspector(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2254 let block = Block::default()
2255 .borders(Borders::ALL)
2256 .title(" Inspector ")
2257 .border_style(Style::default().fg(theme.border_color.to_color()));
2258
2259 let status_label = if app.is_processing {
2260 "Processing"
2261 } else {
2262 "Idle"
2263 };
2264 let status_style = if app.is_processing {
2265 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
2266 } else {
2267 Style::default().fg(Color::Green)
2268 };
2269 let tool_label = app
2270 .current_tool
2271 .clone()
2272 .unwrap_or_else(|| "none".to_string());
2273 let message_count = app.messages.len();
2274 let session_id = app
2275 .session
2276 .as_ref()
2277 .map(|s| s.id.chars().take(8).collect::<String>())
2278 .unwrap_or_else(|| "new".to_string());
2279
2280 let mut lines = Vec::new();
2281 lines.push(Line::from(vec![
2282 Span::styled("Status: ", Style::default().fg(theme.timestamp_color.to_color())),
2283 Span::styled(status_label, status_style),
2284 ]));
2285 lines.push(Line::from(vec![
2286 Span::styled("Tool: ", Style::default().fg(theme.timestamp_color.to_color())),
2287 Span::styled(tool_label, Style::default()),
2288 ]));
2289 lines.push(Line::from(vec![
2290 Span::styled("Session: ", Style::default().fg(theme.timestamp_color.to_color())),
2291 Span::styled(format!("#{}", session_id), Style::default().fg(Color::Cyan)),
2292 ]));
2293 lines.push(Line::from(vec![
2294 Span::styled("Messages: ", Style::default().fg(theme.timestamp_color.to_color())),
2295 Span::styled(message_count.to_string(), Style::default()),
2296 ]));
2297 lines.push(Line::from(vec![
2298 Span::styled("Agent: ", Style::default().fg(theme.timestamp_color.to_color())),
2299 Span::styled(app.current_agent.clone(), Style::default()),
2300 ]));
2301 lines.push(Line::from(""));
2302 lines.push(Line::styled(
2303 "Shortcuts:",
2304 Style::default().add_modifier(Modifier::BOLD),
2305 ));
2306 lines.push(Line::styled("F3 Toggle inspector", Style::default().fg(Color::DarkGray)));
2307 lines.push(Line::styled("Ctrl+B Toggle layout", Style::default().fg(Color::DarkGray)));
2308 lines.push(Line::styled("Ctrl+S Swarm view", Style::default().fg(Color::DarkGray)));
2309
2310 let panel = Paragraph::new(lines)
2311 .block(block)
2312 .wrap(Wrap { trim: true });
2313 f.render_widget(panel, area);
2314}
2315
2316fn render_webview_input(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2317 let input_block = Block::default()
2318 .borders(Borders::ALL)
2319 .title(if app.is_processing {
2320 " Message (Processing...) "
2321 } else {
2322 " Message (Enter to send) "
2323 })
2324 .border_style(Style::default().fg(if app.is_processing {
2325 Color::Yellow
2326 } else {
2327 theme.input_border_color.to_color()
2328 }));
2329
2330 let input = Paragraph::new(app.input.as_str())
2331 .block(input_block)
2332 .wrap(Wrap { trim: false });
2333 f.render_widget(input, area);
2334
2335 f.set_cursor_position((area.x + app.cursor_position as u16 + 1, area.y + 1));
2336}
2337
2338fn build_message_lines(app: &App, theme: &Theme, max_width: usize) -> Vec<Line<'static>> {
2339 let mut message_lines = Vec::new();
2340
2341 for message in &app.messages {
2342 let role_style = theme.get_role_style(&message.role);
2343
2344 let header_line = Line::from(vec![
2345 Span::styled(
2346 format!("[{}] ", message.timestamp),
2347 Style::default()
2348 .fg(theme.timestamp_color.to_color())
2349 .add_modifier(Modifier::DIM),
2350 ),
2351 Span::styled(message.role.clone(), role_style),
2352 ]);
2353 message_lines.push(header_line);
2354
2355 match &message.message_type {
2356 MessageType::ToolCall { name, arguments } => {
2357 let tool_header = Line::from(vec![
2358 Span::styled(" 🔧 ", Style::default().fg(Color::Yellow)),
2359 Span::styled(
2360 format!("Tool: {}", name),
2361 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
2362 ),
2363 ]);
2364 message_lines.push(tool_header);
2365
2366 let args_str = if arguments.len() > 200 {
2367 format!("{}...", &arguments[..197])
2368 } else {
2369 arguments.clone()
2370 };
2371 let args_line = Line::from(vec![
2372 Span::styled(" ", Style::default()),
2373 Span::styled(args_str, Style::default().fg(Color::DarkGray)),
2374 ]);
2375 message_lines.push(args_line);
2376 }
2377 MessageType::ToolResult { name, output } => {
2378 let result_header = Line::from(vec![
2379 Span::styled(" ✅ ", Style::default().fg(Color::Green)),
2380 Span::styled(
2381 format!("Result from {}", name),
2382 Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
2383 ),
2384 ]);
2385 message_lines.push(result_header);
2386
2387 let output_str = if output.len() > 300 {
2388 format!("{}... (truncated)", &output[..297])
2389 } else {
2390 output.clone()
2391 };
2392 let output_lines: Vec<&str> = output_str.lines().collect();
2393 for line in output_lines.iter().take(5) {
2394 let output_line = Line::from(vec![
2395 Span::styled(" ", Style::default()),
2396 Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
2397 ]);
2398 message_lines.push(output_line);
2399 }
2400 if output_lines.len() > 5 {
2401 message_lines.push(Line::from(vec![
2402 Span::styled(" ", Style::default()),
2403 Span::styled(
2404 format!("... and {} more lines", output_lines.len() - 5),
2405 Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM),
2406 ),
2407 ]));
2408 }
2409 }
2410 MessageType::Text(text) => {
2411 let formatter = MessageFormatter::new(max_width);
2412 let formatted_content = formatter.format_content(text, &message.role);
2413 message_lines.extend(formatted_content);
2414 }
2415 }
2416
2417 message_lines.push(Line::from(""));
2418 }
2419
2420 if app.is_processing {
2421 let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2422 let spinner_idx = (std::time::SystemTime::now()
2423 .duration_since(std::time::UNIX_EPOCH)
2424 .unwrap_or_default()
2425 .as_millis()
2426 / 100) as usize
2427 % spinner.len();
2428
2429 let processing_line = Line::from(vec![
2430 Span::styled(
2431 format!("[{}] ", chrono::Local::now().format("%H:%M")),
2432 Style::default()
2433 .fg(theme.timestamp_color.to_color())
2434 .add_modifier(Modifier::DIM),
2435 ),
2436 Span::styled("assistant", theme.get_role_style("assistant")),
2437 ]);
2438 message_lines.push(processing_line);
2439
2440 let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
2441 (format!(" {} Running: {}", spinner[spinner_idx], tool), Color::Cyan)
2442 } else {
2443 (
2444 format!(
2445 " {} {}",
2446 spinner[spinner_idx],
2447 app.processing_message
2448 .as_deref()
2449 .unwrap_or("Thinking...")
2450 ),
2451 Color::Yellow,
2452 )
2453 };
2454
2455 let indicator_line = Line::from(vec![
2456 Span::styled(status_text, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
2457 ]);
2458 message_lines.push(indicator_line);
2459 message_lines.push(Line::from(""));
2460 }
2461
2462 message_lines
2463}
2464
2465fn render_help_overlay_if_needed(f: &mut Frame, app: &App, theme: &Theme) {
2466 if !app.show_help {
2467 return;
2468 }
2469
2470 let area = centered_rect(60, 60, f.area());
2471 f.render_widget(Clear, area);
2472
2473 let token_display = TokenDisplay::new();
2474 let token_info = token_display.create_detailed_display();
2475
2476 let help_text: Vec<String> = vec![
2477 "".to_string(),
2478 " KEYBOARD SHORTCUTS".to_string(),
2479 " ==================".to_string(),
2480 "".to_string(),
2481 " Enter Send message".to_string(),
2482 " Tab Switch between build/plan agents".to_string(),
2483 " Ctrl+S Toggle swarm view".to_string(),
2484 " Ctrl+B Toggle webview layout".to_string(),
2485 " F3 Toggle inspector pane".to_string(),
2486 " Ctrl+C Quit".to_string(),
2487 " ? Toggle this help".to_string(),
2488 "".to_string(),
2489 " SLASH COMMANDS".to_string(),
2490 " /swarm <task> Run task in parallel swarm mode".to_string(),
2491 " /ralph [path] Start Ralph PRD loop (default: prd.json)".to_string(),
2492 " /sessions Open session picker to resume".to_string(),
2493 " /resume Resume most recent session".to_string(),
2494 " /resume <id> Resume specific session by ID".to_string(),
2495 " /new Start a fresh session".to_string(),
2496 " /model Open model picker (or /model <name>)".to_string(),
2497 " /view Toggle swarm view".to_string(),
2498 " /webview Web dashboard layout".to_string(),
2499 " /classic Single-pane layout".to_string(),
2500 " /inspector Toggle inspector pane".to_string(),
2501 " /refresh Refresh workspace and sessions".to_string(),
2502 "".to_string(),
2503 " VIM-STYLE NAVIGATION".to_string(),
2504 " Alt+j Scroll down".to_string(),
2505 " Alt+k Scroll up".to_string(),
2506 " Ctrl+g Go to top".to_string(),
2507 " Ctrl+G Go to bottom".to_string(),
2508 "".to_string(),
2509 " SCROLLING".to_string(),
2510 " Up/Down Scroll messages".to_string(),
2511 " PageUp/Dn Scroll one page".to_string(),
2512 " Alt+u/d Scroll half page".to_string(),
2513 "".to_string(),
2514 " COMMAND HISTORY".to_string(),
2515 " Ctrl+R Search history".to_string(),
2516 " Ctrl+Up/Dn Navigate history".to_string(),
2517 "".to_string(),
2518 " Press ? or Esc to close".to_string(),
2519 "".to_string(),
2520 ];
2521
2522 let mut combined_text = token_info;
2523 combined_text.extend(help_text);
2524
2525 let help = Paragraph::new(combined_text.join("\n"))
2526 .block(
2527 Block::default()
2528 .borders(Borders::ALL)
2529 .title(" Help ")
2530 .border_style(Style::default().fg(theme.help_border_color.to_color())),
2531 )
2532 .wrap(Wrap { trim: false });
2533
2534 f.render_widget(help, area);
2535}
2536
2537fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
2539 let popup_layout = Layout::default()
2540 .direction(Direction::Vertical)
2541 .constraints([
2542 Constraint::Percentage((100 - percent_y) / 2),
2543 Constraint::Percentage(percent_y),
2544 Constraint::Percentage((100 - percent_y) / 2),
2545 ])
2546 .split(r);
2547
2548 Layout::default()
2549 .direction(Direction::Horizontal)
2550 .constraints([
2551 Constraint::Percentage((100 - percent_x) / 2),
2552 Constraint::Percentage(percent_x),
2553 Constraint::Percentage((100 - percent_x) / 2),
2554 ])
2555 .split(popup_layout[1])[1]
2556}