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