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