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::{
28 self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste,
29 EnableMouseCapture, Event, KeyCode, KeyModifiers,
30 },
31 execute,
32 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
33};
34use ratatui::{
35 Frame, Terminal,
36 backend::CrosstermBackend,
37 layout::{Constraint, Direction, Layout, Rect},
38 style::{Color, Modifier, Style},
39 text::{Line, Span},
40 widgets::{
41 Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
42 },
43};
44use std::io;
45use std::path::{Path, PathBuf};
46use std::process::Command;
47use std::time::{Duration, Instant};
48use tokio::sync::mpsc;
49
50pub async fn run(project: Option<PathBuf>) -> Result<()> {
52 if let Some(dir) = project {
54 std::env::set_current_dir(&dir)?;
55 }
56
57 enable_raw_mode()?;
59 let mut stdout = io::stdout();
60 execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?;
61 let backend = CrosstermBackend::new(stdout);
62 let mut terminal = Terminal::new(backend)?;
63
64 let result = run_app(&mut terminal).await;
66
67 disable_raw_mode()?;
69 execute!(
70 terminal.backend_mut(),
71 LeaveAlternateScreen,
72 DisableMouseCapture,
73 DisableBracketedPaste
74 )?;
75 terminal.show_cursor()?;
76
77 result
78}
79
80#[derive(Debug, Clone)]
82enum MessageType {
83 Text(String),
84 Image {
85 url: String,
86 mime_type: Option<String>,
87 },
88 ToolCall {
89 name: String,
90 arguments: String,
91 },
92 ToolResult {
93 name: String,
94 output: String,
95 },
96 File {
97 path: String,
98 mime_type: Option<String>,
99 },
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104enum ViewMode {
105 Chat,
106 Swarm,
107 Ralph,
108 SessionPicker,
109 ModelPicker,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113enum ChatLayoutMode {
114 Classic,
115 Webview,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119enum WorkspaceEntryKind {
120 Directory,
121 File,
122}
123
124#[derive(Debug, Clone)]
125struct WorkspaceEntry {
126 name: String,
127 kind: WorkspaceEntryKind,
128}
129
130#[derive(Debug, Clone, Default)]
131struct WorkspaceSnapshot {
132 root_display: String,
133 git_branch: Option<String>,
134 git_dirty_files: usize,
135 entries: Vec<WorkspaceEntry>,
136 captured_at: String,
137}
138
139struct App {
141 input: String,
142 cursor_position: usize,
143 messages: Vec<ChatMessage>,
144 current_agent: String,
145 scroll: usize,
146 show_help: bool,
147 command_history: Vec<String>,
148 history_index: Option<usize>,
149 session: Option<Session>,
150 is_processing: bool,
151 processing_message: Option<String>,
152 current_tool: Option<String>,
153 processing_started_at: Option<Instant>,
155 streaming_text: Option<String>,
157 tool_call_count: usize,
159 response_rx: Option<mpsc::Receiver<SessionEvent>>,
160 provider_registry: Option<std::sync::Arc<crate::provider::ProviderRegistry>>,
162 workspace_dir: PathBuf,
164 view_mode: ViewMode,
166 chat_layout: ChatLayoutMode,
167 show_inspector: bool,
168 workspace: WorkspaceSnapshot,
169 swarm_state: SwarmViewState,
170 swarm_rx: Option<mpsc::Receiver<SwarmEvent>>,
171 ralph_state: RalphViewState,
173 ralph_rx: Option<mpsc::Receiver<RalphEvent>>,
174 session_picker_list: Vec<SessionSummary>,
176 session_picker_selected: usize,
177 session_picker_filter: String,
178 session_picker_confirm_delete: bool,
179 model_picker_list: Vec<(String, String, String)>, model_picker_selected: usize,
182 model_picker_filter: String,
183 active_model: Option<String>,
184 last_max_scroll: usize,
186}
187
188#[allow(dead_code)]
189struct ChatMessage {
190 role: String,
191 content: String,
192 timestamp: String,
193 message_type: MessageType,
194}
195
196impl ChatMessage {
197 fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
198 let content = content.into();
199 Self {
200 role: role.into(),
201 timestamp: chrono::Local::now().format("%H:%M").to_string(),
202 message_type: MessageType::Text(content.clone()),
203 content,
204 }
205 }
206
207 fn with_message_type(mut self, message_type: MessageType) -> Self {
208 self.message_type = message_type;
209 self
210 }
211}
212
213impl WorkspaceSnapshot {
214 fn capture(root: &Path, max_entries: usize) -> Self {
215 let mut entries: Vec<WorkspaceEntry> = Vec::new();
216
217 if let Ok(read_dir) = std::fs::read_dir(root) {
218 for entry in read_dir.flatten() {
219 let file_name = entry.file_name().to_string_lossy().to_string();
220 if should_skip_workspace_entry(&file_name) {
221 continue;
222 }
223
224 let kind = match entry.file_type() {
225 Ok(ft) if ft.is_dir() => WorkspaceEntryKind::Directory,
226 _ => WorkspaceEntryKind::File,
227 };
228
229 entries.push(WorkspaceEntry {
230 name: file_name,
231 kind,
232 });
233 }
234 }
235
236 entries.sort_by(|a, b| match (a.kind, b.kind) {
237 (WorkspaceEntryKind::Directory, WorkspaceEntryKind::File) => std::cmp::Ordering::Less,
238 (WorkspaceEntryKind::File, WorkspaceEntryKind::Directory) => {
239 std::cmp::Ordering::Greater
240 }
241 _ => a
242 .name
243 .to_ascii_lowercase()
244 .cmp(&b.name.to_ascii_lowercase()),
245 });
246 entries.truncate(max_entries);
247
248 Self {
249 root_display: root.to_string_lossy().to_string(),
250 git_branch: detect_git_branch(root),
251 git_dirty_files: detect_git_dirty_files(root),
252 entries,
253 captured_at: chrono::Local::now().format("%H:%M:%S").to_string(),
254 }
255 }
256}
257
258fn should_skip_workspace_entry(name: &str) -> bool {
259 matches!(
260 name,
261 ".git" | "node_modules" | "target" | ".next" | "__pycache__" | ".venv"
262 )
263}
264
265fn detect_git_branch(root: &Path) -> Option<String> {
266 let output = Command::new("git")
267 .arg("-C")
268 .arg(root)
269 .args(["rev-parse", "--abbrev-ref", "HEAD"])
270 .output()
271 .ok()?;
272
273 if !output.status.success() {
274 return None;
275 }
276
277 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
278 if branch.is_empty() {
279 None
280 } else {
281 Some(branch)
282 }
283}
284
285fn detect_git_dirty_files(root: &Path) -> usize {
286 let output = match Command::new("git")
287 .arg("-C")
288 .arg(root)
289 .args(["status", "--porcelain"])
290 .output()
291 {
292 Ok(out) => out,
293 Err(_) => return 0,
294 };
295
296 if !output.status.success() {
297 return 0;
298 }
299
300 String::from_utf8_lossy(&output.stdout)
301 .lines()
302 .filter(|line| !line.trim().is_empty())
303 .count()
304}
305
306impl App {
307 fn new() -> Self {
308 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
309
310 Self {
311 input: String::new(),
312 cursor_position: 0,
313 messages: vec![
314 ChatMessage::new("system", "Welcome to CodeTether Agent! Press ? for help."),
315 ChatMessage::new(
316 "assistant",
317 "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",
318 ),
319 ],
320 current_agent: "build".to_string(),
321 scroll: 0,
322 show_help: false,
323 command_history: Vec::new(),
324 history_index: None,
325 session: None,
326 is_processing: false,
327 processing_message: None,
328 current_tool: None,
329 processing_started_at: None,
330 streaming_text: None,
331 tool_call_count: 0,
332 response_rx: None,
333 provider_registry: None,
334 workspace_dir: workspace_root.clone(),
335 view_mode: ViewMode::Chat,
336 chat_layout: ChatLayoutMode::Webview,
337 show_inspector: true,
338 workspace: WorkspaceSnapshot::capture(&workspace_root, 18),
339 swarm_state: SwarmViewState::new(),
340 swarm_rx: None,
341 ralph_state: RalphViewState::new(),
342 ralph_rx: None,
343 session_picker_list: Vec::new(),
344 session_picker_selected: 0,
345 session_picker_filter: String::new(),
346 session_picker_confirm_delete: false,
347 model_picker_list: Vec::new(),
348 model_picker_selected: 0,
349 model_picker_filter: String::new(),
350 active_model: None,
351 last_max_scroll: 0,
352 }
353 }
354
355 fn refresh_workspace(&mut self) {
356 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
357 self.workspace = WorkspaceSnapshot::capture(&workspace_root, 18);
358 }
359
360 fn update_cached_sessions(&mut self, sessions: Vec<SessionSummary>) {
361 self.session_picker_list = sessions.into_iter().take(16).collect();
362 if self.session_picker_selected >= self.session_picker_list.len() {
363 self.session_picker_selected = self.session_picker_list.len().saturating_sub(1);
364 }
365 }
366
367 async fn submit_message(&mut self, config: &Config) {
368 if self.input.is_empty() {
369 return;
370 }
371
372 let message = std::mem::take(&mut self.input);
373 self.cursor_position = 0;
374
375 if !message.trim().is_empty() {
377 self.command_history.push(message.clone());
378 self.history_index = None;
379 }
380
381 if message.trim().starts_with("/swarm ") {
383 let task = message
384 .trim()
385 .strip_prefix("/swarm ")
386 .unwrap_or("")
387 .to_string();
388 if task.is_empty() {
389 self.messages.push(ChatMessage::new(
390 "system",
391 "Usage: /swarm <task description>",
392 ));
393 return;
394 }
395 self.start_swarm_execution(task, config).await;
396 return;
397 }
398
399 if message.trim().starts_with("/ralph") {
401 let prd_path = message
402 .trim()
403 .strip_prefix("/ralph")
404 .map(|s| s.trim())
405 .filter(|s| !s.is_empty())
406 .unwrap_or("prd.json")
407 .to_string();
408 self.start_ralph_execution(prd_path, config).await;
409 return;
410 }
411
412 if message.trim() == "/webview" {
413 self.chat_layout = ChatLayoutMode::Webview;
414 self.messages.push(ChatMessage::new(
415 "system",
416 "Switched to webview layout. Use /classic to return to single-pane chat.",
417 ));
418 return;
419 }
420
421 if message.trim() == "/classic" {
422 self.chat_layout = ChatLayoutMode::Classic;
423 self.messages.push(ChatMessage::new(
424 "system",
425 "Switched to classic layout. Use /webview for dashboard-style panes.",
426 ));
427 return;
428 }
429
430 if message.trim() == "/inspector" {
431 self.show_inspector = !self.show_inspector;
432 let state = if self.show_inspector {
433 "enabled"
434 } else {
435 "disabled"
436 };
437 self.messages.push(ChatMessage::new(
438 "system",
439 format!("Inspector pane {}. Press F3 to toggle quickly.", state),
440 ));
441 return;
442 }
443
444 if message.trim() == "/refresh" {
445 self.refresh_workspace();
446 match list_sessions_for_directory(&self.workspace_dir).await {
447 Ok(sessions) => self.update_cached_sessions(sessions),
448 Err(err) => self.messages.push(ChatMessage::new(
449 "system",
450 format!(
451 "Workspace refreshed, but failed to refresh sessions: {}",
452 err
453 ),
454 )),
455 }
456 self.messages.push(ChatMessage::new(
457 "system",
458 "Workspace and session cache refreshed.",
459 ));
460 return;
461 }
462
463 if message.trim() == "/view" || message.trim() == "/swarm" {
465 self.view_mode = match self.view_mode {
466 ViewMode::Chat | ViewMode::SessionPicker | ViewMode::ModelPicker => ViewMode::Swarm,
467 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
468 };
469 return;
470 }
471
472 if message.trim() == "/sessions" {
474 match list_sessions_for_directory(&self.workspace_dir).await {
475 Ok(sessions) => {
476 if sessions.is_empty() {
477 self.messages
478 .push(ChatMessage::new("system", "No saved sessions found."));
479 } else {
480 self.update_cached_sessions(sessions);
481 self.session_picker_selected = 0;
482 self.view_mode = ViewMode::SessionPicker;
483 }
484 }
485 Err(e) => {
486 self.messages.push(ChatMessage::new(
487 "system",
488 format!("Failed to list sessions: {}", e),
489 ));
490 }
491 }
492 return;
493 }
494
495 if message.trim() == "/resume" || message.trim().starts_with("/resume ") {
497 let session_id = message
498 .trim()
499 .strip_prefix("/resume")
500 .map(|s| s.trim())
501 .filter(|s| !s.is_empty());
502 let loaded = if let Some(id) = session_id {
503 Session::load(id).await
504 } else {
505 Session::last_for_directory(Some(&self.workspace_dir)).await
506 };
507
508 match loaded {
509 Ok(session) => {
510 self.messages.clear();
512 self.messages.push(ChatMessage::new(
513 "system",
514 format!(
515 "Resumed session: {}\nCreated: {}\n{} messages loaded",
516 session.title.as_deref().unwrap_or("(untitled)"),
517 session.created_at.format("%Y-%m-%d %H:%M"),
518 session.messages.len()
519 ),
520 ));
521
522 for msg in &session.messages {
523 let role_str = match msg.role {
524 Role::System => "system",
525 Role::User => "user",
526 Role::Assistant => "assistant",
527 Role::Tool => "tool",
528 };
529
530 for part in &msg.content {
532 match part {
533 ContentPart::Text { text } => {
534 if !text.is_empty() {
535 self.messages
536 .push(ChatMessage::new(role_str, text.clone()));
537 }
538 }
539 ContentPart::Image { url, mime_type } => {
540 self.messages.push(
541 ChatMessage::new(role_str, "").with_message_type(
542 MessageType::Image {
543 url: url.clone(),
544 mime_type: mime_type.clone(),
545 },
546 ),
547 );
548 }
549 ContentPart::ToolCall {
550 name, arguments, ..
551 } => {
552 self.messages.push(
553 ChatMessage::new(role_str, format!("🔧 {name}"))
554 .with_message_type(MessageType::ToolCall {
555 name: name.clone(),
556 arguments: arguments.clone(),
557 }),
558 );
559 }
560 ContentPart::ToolResult { content, .. } => {
561 let truncated = truncate_with_ellipsis(content, 500);
562 self.messages.push(
563 ChatMessage::new(
564 role_str,
565 format!("✅ Result\n{truncated}"),
566 )
567 .with_message_type(MessageType::ToolResult {
568 name: "tool".to_string(),
569 output: content.clone(),
570 }),
571 );
572 }
573 ContentPart::File { path, mime_type } => {
574 self.messages.push(
575 ChatMessage::new(
576 role_str,
577 format!("📎 {}", path),
578 )
579 .with_message_type(MessageType::File {
580 path: path.clone(),
581 mime_type: mime_type.clone(),
582 }),
583 );
584 }
585 }
586 }
587 }
588
589 self.current_agent = session.agent.clone();
590 self.session = Some(session);
591 self.scroll = SCROLL_BOTTOM;
592 }
593 Err(e) => {
594 self.messages.push(ChatMessage::new(
595 "system",
596 format!("Failed to load session: {}", e),
597 ));
598 }
599 }
600 return;
601 }
602
603 if message.trim() == "/model" || message.trim().starts_with("/model ") {
605 let direct_model = message
606 .trim()
607 .strip_prefix("/model")
608 .map(|s| s.trim())
609 .filter(|s| !s.is_empty());
610
611 if let Some(model_str) = direct_model {
612 self.active_model = Some(model_str.to_string());
614 if let Some(session) = self.session.as_mut() {
615 session.metadata.model = Some(model_str.to_string());
616 }
617 self.messages.push(ChatMessage::new(
618 "system",
619 format!("Model set to: {}", model_str),
620 ));
621 } else {
622 self.open_model_picker(config).await;
624 }
625 return;
626 }
627
628 if message.trim() == "/new" {
630 self.session = None;
631 self.messages.clear();
632 self.messages.push(ChatMessage::new(
633 "system",
634 "Started a new session. Previous session was saved.",
635 ));
636 return;
637 }
638
639 self.messages
641 .push(ChatMessage::new("user", message.clone()));
642
643 self.scroll = SCROLL_BOTTOM;
645
646 let current_agent = self.current_agent.clone();
647 let model = self
648 .active_model
649 .clone()
650 .or_else(|| {
651 config
652 .agents
653 .get(¤t_agent)
654 .and_then(|agent| agent.model.clone())
655 })
656 .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok())
657 .or_else(|| config.default_model.clone());
658
659 if self.session.is_none() {
661 match Session::new().await {
662 Ok(session) => {
663 self.session = Some(session);
664 }
665 Err(err) => {
666 tracing::error!(error = %err, "Failed to create session");
667 self.messages
668 .push(ChatMessage::new("assistant", format!("Error: {err}")));
669 return;
670 }
671 }
672 }
673
674 let session = match self.session.as_mut() {
675 Some(session) => session,
676 None => {
677 self.messages.push(ChatMessage::new(
678 "assistant",
679 "Error: session not initialized",
680 ));
681 return;
682 }
683 };
684
685 if let Some(model) = model {
686 session.metadata.model = Some(model);
687 }
688
689 session.agent = current_agent;
690
691 self.is_processing = true;
693 self.processing_message = Some("Thinking...".to_string());
694 self.current_tool = None;
695 self.processing_started_at = Some(Instant::now());
696 self.streaming_text = None;
697
698 if self.provider_registry.is_none() {
700 match crate::provider::ProviderRegistry::from_vault().await {
701 Ok(registry) => {
702 self.provider_registry = Some(std::sync::Arc::new(registry));
703 }
704 Err(err) => {
705 tracing::error!(error = %err, "Failed to load provider registry");
706 self.messages.push(ChatMessage::new(
707 "assistant",
708 format!("Error loading providers: {err}"),
709 ));
710 self.is_processing = false;
711 return;
712 }
713 }
714 }
715 let registry = self.provider_registry.clone().unwrap();
716
717 let (tx, rx) = mpsc::channel(100);
719 self.response_rx = Some(rx);
720
721 let session_clone = session.clone();
723 let message_clone = message.clone();
724
725 tokio::spawn(async move {
727 let mut session = session_clone;
728 if let Err(err) = session
729 .prompt_with_events(&message_clone, tx.clone(), registry)
730 .await
731 {
732 tracing::error!(error = %err, "Agent processing failed");
733 let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
734 let _ = tx.send(SessionEvent::Done).await;
735 }
736 });
737 }
738
739 fn handle_response(&mut self, event: SessionEvent) {
740 self.scroll = SCROLL_BOTTOM;
742
743 match event {
744 SessionEvent::Thinking => {
745 self.processing_message = Some("Thinking...".to_string());
746 self.current_tool = None;
747 if self.processing_started_at.is_none() {
748 self.processing_started_at = Some(Instant::now());
749 }
750 }
751 SessionEvent::ToolCallStart { name, arguments } => {
752 if let Some(text) = self.streaming_text.take() {
754 if !text.is_empty() {
755 self.messages.push(ChatMessage::new("assistant", text));
756 }
757 }
758 self.processing_message = Some(format!("Running {}...", name));
759 self.current_tool = Some(name.clone());
760 self.tool_call_count += 1;
761 self.messages.push(
762 ChatMessage::new("tool", format!("🔧 {}", name))
763 .with_message_type(MessageType::ToolCall { name, arguments }),
764 );
765 }
766 SessionEvent::ToolCallComplete {
767 name,
768 output,
769 success,
770 } => {
771 let icon = if success { "✓" } else { "✗" };
772 self.messages.push(
773 ChatMessage::new("tool", format!("{} {}", icon, name))
774 .with_message_type(MessageType::ToolResult { name, output }),
775 );
776 self.current_tool = None;
777 self.processing_message = Some("Thinking...".to_string());
778 }
779 SessionEvent::TextChunk(text) => {
780 self.streaming_text = Some(text);
782 }
783 SessionEvent::TextComplete(text) => {
784 self.streaming_text = None;
786 if !text.is_empty() {
787 self.messages.push(ChatMessage::new("assistant", text));
788 }
789 }
790 SessionEvent::SessionSync(session) => {
791 self.session = Some(session);
794 }
795 SessionEvent::Error(err) => {
796 self.messages
797 .push(ChatMessage::new("assistant", format!("Error: {}", err)));
798 }
799 SessionEvent::Done => {
800 self.is_processing = false;
801 self.processing_message = None;
802 self.current_tool = None;
803 self.processing_started_at = None;
804 self.streaming_text = None;
805 self.response_rx = None;
806 }
807 }
808 }
809
810 fn handle_swarm_event(&mut self, event: SwarmEvent) {
812 self.swarm_state.handle_event(event.clone());
813
814 if let SwarmEvent::Complete { success, ref stats } = event {
816 self.view_mode = ViewMode::Chat;
817 let summary = if success {
818 format!(
819 "Swarm completed successfully.\n\
820 Subtasks: {} completed, {} failed\n\
821 Total tool calls: {}\n\
822 Time: {:.1}s (speedup: {:.1}x)",
823 stats.subagents_completed,
824 stats.subagents_failed,
825 stats.total_tool_calls,
826 stats.execution_time_ms as f64 / 1000.0,
827 stats.speedup_factor
828 )
829 } else {
830 format!(
831 "Swarm completed with failures.\n\
832 Subtasks: {} completed, {} failed\n\
833 Check the subtask results for details.",
834 stats.subagents_completed, stats.subagents_failed
835 )
836 };
837 self.messages.push(ChatMessage::new("system", &summary));
838 self.swarm_rx = None;
839 }
840
841 if let SwarmEvent::Error(ref err) = event {
842 self.messages
843 .push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
844 }
845 }
846
847 fn handle_ralph_event(&mut self, event: RalphEvent) {
849 self.ralph_state.handle_event(event.clone());
850
851 if let RalphEvent::Complete {
853 ref status,
854 passed,
855 total,
856 } = event
857 {
858 self.view_mode = ViewMode::Chat;
859 let summary = format!(
860 "Ralph loop finished: {}\n\
861 Stories: {}/{} passed",
862 status, passed, total
863 );
864 self.messages.push(ChatMessage::new("system", &summary));
865 self.ralph_rx = None;
866 }
867
868 if let RalphEvent::Error(ref err) = event {
869 self.messages
870 .push(ChatMessage::new("system", &format!("Ralph error: {}", err)));
871 }
872 }
873
874 async fn start_ralph_execution(&mut self, prd_path: String, config: &Config) {
876 self.messages
878 .push(ChatMessage::new("user", format!("/ralph {}", prd_path)));
879
880 let model = self
882 .active_model
883 .clone()
884 .or_else(|| config.default_model.clone())
885 .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok());
886
887 let model = match model {
888 Some(m) => m,
889 None => {
890 self.messages.push(ChatMessage::new(
891 "system",
892 "No model configured. Use /model to select one first.",
893 ));
894 return;
895 }
896 };
897
898 let prd_file = std::path::PathBuf::from(&prd_path);
900 if !prd_file.exists() {
901 self.messages.push(ChatMessage::new(
902 "system",
903 format!("PRD file not found: {}", prd_path),
904 ));
905 return;
906 }
907
908 let (tx, rx) = mpsc::channel(200);
910 self.ralph_rx = Some(rx);
911
912 self.view_mode = ViewMode::Ralph;
914 self.ralph_state = RalphViewState::new();
915
916 let ralph_config = RalphConfig {
918 prd_path: prd_path.clone(),
919 max_iterations: 10,
920 progress_path: "progress.txt".to_string(),
921 quality_checks_enabled: true,
922 auto_commit: true,
923 model: Some(model.clone()),
924 use_rlm: false,
925 parallel_enabled: true,
926 max_concurrent_stories: 3,
927 worktree_enabled: true,
928 story_timeout_secs: 300,
929 conflict_timeout_secs: 120,
930 };
931
932 let (provider_name, model_name) = if let Some(pos) = model.find('/') {
934 (model[..pos].to_string(), model[pos + 1..].to_string())
935 } else {
936 (model.clone(), model.clone())
937 };
938
939 let prd_path_clone = prd_path.clone();
940 let tx_clone = tx.clone();
941
942 tokio::spawn(async move {
944 let provider = match crate::provider::ProviderRegistry::from_vault().await {
946 Ok(registry) => match registry.get(&provider_name) {
947 Some(p) => p,
948 None => {
949 let _ = tx_clone
950 .send(RalphEvent::Error(format!(
951 "Provider '{}' not found",
952 provider_name
953 )))
954 .await;
955 return;
956 }
957 },
958 Err(e) => {
959 let _ = tx_clone
960 .send(RalphEvent::Error(format!(
961 "Failed to load providers: {}",
962 e
963 )))
964 .await;
965 return;
966 }
967 };
968
969 let prd_path_buf = std::path::PathBuf::from(&prd_path_clone);
970 match RalphLoop::new(prd_path_buf, provider, model_name, ralph_config).await {
971 Ok(ralph) => {
972 let mut ralph = ralph.with_event_tx(tx_clone.clone());
973 match ralph.run().await {
974 Ok(_state) => {
975 }
977 Err(e) => {
978 let _ = tx_clone.send(RalphEvent::Error(e.to_string())).await;
979 }
980 }
981 }
982 Err(e) => {
983 let _ = tx_clone
984 .send(RalphEvent::Error(format!(
985 "Failed to initialize Ralph: {}",
986 e
987 )))
988 .await;
989 }
990 }
991 });
992
993 self.messages.push(ChatMessage::new(
994 "system",
995 format!("Starting Ralph loop with PRD: {}", prd_path),
996 ));
997 }
998
999 async fn start_swarm_execution(&mut self, task: String, config: &Config) {
1001 self.messages
1003 .push(ChatMessage::new("user", format!("/swarm {}", task)));
1004
1005 let model = config
1007 .default_model
1008 .clone()
1009 .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok());
1010
1011 let swarm_config = SwarmConfig {
1013 model,
1014 max_subagents: 10,
1015 max_steps_per_subagent: 50,
1016 worktree_enabled: true,
1017 worktree_auto_merge: true,
1018 working_dir: Some(
1019 std::env::current_dir()
1020 .map(|p| p.to_string_lossy().to_string())
1021 .unwrap_or_else(|_| ".".to_string()),
1022 ),
1023 ..Default::default()
1024 };
1025
1026 let (tx, rx) = mpsc::channel(100);
1028 self.swarm_rx = Some(rx);
1029
1030 self.view_mode = ViewMode::Swarm;
1032 self.swarm_state = SwarmViewState::new();
1033
1034 let _ = tx
1036 .send(SwarmEvent::Started {
1037 task: task.clone(),
1038 total_subtasks: 0,
1039 })
1040 .await;
1041
1042 let task_clone = task;
1044 tokio::spawn(async move {
1045 let executor = SwarmExecutor::new(swarm_config).with_event_tx(tx.clone());
1047 let result = executor
1048 .execute(&task_clone, DecompositionStrategy::Automatic)
1049 .await;
1050
1051 match result {
1052 Ok(swarm_result) => {
1053 let _ = tx
1054 .send(SwarmEvent::Complete {
1055 success: swarm_result.success,
1056 stats: swarm_result.stats,
1057 })
1058 .await;
1059 }
1060 Err(e) => {
1061 let _ = tx.send(SwarmEvent::Error(e.to_string())).await;
1062 }
1063 }
1064 });
1065 }
1066
1067 async fn open_model_picker(&mut self, config: &Config) {
1069 let mut models: Vec<(String, String, String)> = Vec::new();
1070
1071 match crate::provider::ProviderRegistry::from_vault().await {
1073 Ok(registry) => {
1074 for provider_name in registry.list() {
1075 if let Some(provider) = registry.get(provider_name) {
1076 match provider.list_models().await {
1077 Ok(model_list) => {
1078 for m in model_list {
1079 let label = format!("{}/{}", provider_name, m.id);
1080 let value = format!("{}/{}", provider_name, m.id);
1081 let name = m.name.clone();
1082 models.push((label, value, name));
1083 }
1084 }
1085 Err(e) => {
1086 tracing::warn!(
1087 "Failed to list models for {}: {}",
1088 provider_name,
1089 e
1090 );
1091 }
1092 }
1093 }
1094 }
1095 }
1096 Err(e) => {
1097 tracing::warn!("Failed to load provider registry: {}", e);
1098 }
1099 }
1100
1101 if models.is_empty() {
1103 if let Ok(registry) = crate::provider::ProviderRegistry::from_config(config).await {
1104 for provider_name in registry.list() {
1105 if let Some(provider) = registry.get(provider_name) {
1106 if let Ok(model_list) = provider.list_models().await {
1107 for m in model_list {
1108 let label = format!("{}/{}", provider_name, m.id);
1109 let value = format!("{}/{}", provider_name, m.id);
1110 let name = m.name.clone();
1111 models.push((label, value, name));
1112 }
1113 }
1114 }
1115 }
1116 }
1117 }
1118
1119 if models.is_empty() {
1120 self.messages.push(ChatMessage::new(
1121 "system",
1122 "No models found. Check provider configuration (Vault or config).",
1123 ));
1124 } else {
1125 models.sort_by(|a, b| a.0.cmp(&b.0));
1127 self.model_picker_list = models;
1128 self.model_picker_selected = 0;
1129 self.model_picker_filter.clear();
1130 self.view_mode = ViewMode::ModelPicker;
1131 }
1132 }
1133
1134 fn filtered_sessions(&self) -> Vec<(usize, &SessionSummary)> {
1136 if self.session_picker_filter.is_empty() {
1137 self.session_picker_list.iter().enumerate().collect()
1138 } else {
1139 let filter = self.session_picker_filter.to_lowercase();
1140 self.session_picker_list
1141 .iter()
1142 .enumerate()
1143 .filter(|(_, s)| {
1144 s.title
1145 .as_deref()
1146 .unwrap_or("")
1147 .to_lowercase()
1148 .contains(&filter)
1149 || s.agent.to_lowercase().contains(&filter)
1150 || s.id.to_lowercase().contains(&filter)
1151 })
1152 .collect()
1153 }
1154 }
1155
1156 fn filtered_models(&self) -> Vec<(usize, &(String, String, String))> {
1158 if self.model_picker_filter.is_empty() {
1159 self.model_picker_list.iter().enumerate().collect()
1160 } else {
1161 let filter = self.model_picker_filter.to_lowercase();
1162 self.model_picker_list
1163 .iter()
1164 .enumerate()
1165 .filter(|(_, (label, _, name))| {
1166 label.to_lowercase().contains(&filter) || name.to_lowercase().contains(&filter)
1167 })
1168 .collect()
1169 }
1170 }
1171
1172 fn navigate_history(&mut self, direction: isize) {
1173 if self.command_history.is_empty() {
1174 return;
1175 }
1176
1177 let history_len = self.command_history.len();
1178 let new_index = match self.history_index {
1179 Some(current) => {
1180 let new = current as isize + direction;
1181 if new < 0 {
1182 None
1183 } else if new >= history_len as isize {
1184 Some(history_len - 1)
1185 } else {
1186 Some(new as usize)
1187 }
1188 }
1189 None => {
1190 if direction > 0 {
1191 Some(0)
1192 } else {
1193 Some(history_len.saturating_sub(1))
1194 }
1195 }
1196 };
1197
1198 self.history_index = new_index;
1199 if let Some(index) = new_index {
1200 self.input = self.command_history[index].clone();
1201 self.cursor_position = self.input.len();
1202 } else {
1203 self.input.clear();
1204 self.cursor_position = 0;
1205 }
1206 }
1207
1208 fn search_history(&mut self) {
1209 if self.command_history.is_empty() {
1211 return;
1212 }
1213
1214 let search_term = self.input.trim().to_lowercase();
1215
1216 if search_term.is_empty() {
1217 if !self.command_history.is_empty() {
1219 self.input = self.command_history.last().unwrap().clone();
1220 self.cursor_position = self.input.len();
1221 self.history_index = Some(self.command_history.len() - 1);
1222 }
1223 return;
1224 }
1225
1226 for (index, cmd) in self.command_history.iter().enumerate().rev() {
1228 if cmd.to_lowercase().starts_with(&search_term) {
1229 self.input = cmd.clone();
1230 self.cursor_position = self.input.len();
1231 self.history_index = Some(index);
1232 return;
1233 }
1234 }
1235
1236 for (index, cmd) in self.command_history.iter().enumerate().rev() {
1238 if cmd.to_lowercase().contains(&search_term) {
1239 self.input = cmd.clone();
1240 self.cursor_position = self.input.len();
1241 self.history_index = Some(index);
1242 return;
1243 }
1244 }
1245 }
1246}
1247
1248async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
1249 let mut app = App::new();
1250 if let Ok(sessions) = list_sessions_for_directory(&app.workspace_dir).await {
1251 app.update_cached_sessions(sessions);
1252 }
1253
1254 let mut config = Config::load().await?;
1256 let mut theme = crate::tui::theme_utils::validate_theme(&config.load_theme());
1257
1258 let _config_paths = vec![
1260 std::path::PathBuf::from("./codetether.toml"),
1261 std::path::PathBuf::from("./.codetether/config.toml"),
1262 ];
1263
1264 let _global_config_path = directories::ProjectDirs::from("com", "codetether", "codetether")
1265 .map(|dirs| dirs.config_dir().join("config.toml"));
1266
1267 let mut last_check = Instant::now();
1268 let mut last_session_refresh = Instant::now();
1269
1270 loop {
1271 if config.ui.hot_reload && last_check.elapsed() > Duration::from_secs(2) {
1273 if let Ok(new_config) = Config::load().await {
1274 if new_config.ui.theme != config.ui.theme
1275 || new_config.ui.custom_theme != config.ui.custom_theme
1276 {
1277 theme = crate::tui::theme_utils::validate_theme(&new_config.load_theme());
1278 config = new_config;
1279 }
1280 }
1281 last_check = Instant::now();
1282 }
1283
1284 if last_session_refresh.elapsed() > Duration::from_secs(5) {
1285 if let Ok(sessions) = list_sessions_for_directory(&app.workspace_dir).await {
1286 app.update_cached_sessions(sessions);
1287 }
1288 last_session_refresh = Instant::now();
1289 }
1290
1291 terminal.draw(|f| ui(f, &mut app, &theme))?;
1292
1293 let terminal_height = terminal.size()?.height.saturating_sub(6) as usize;
1296 let estimated_lines = app.messages.len() * 4; app.last_max_scroll = estimated_lines.saturating_sub(terminal_height);
1298
1299 if let Some(mut rx) = app.response_rx.take() {
1301 while let Ok(response) = rx.try_recv() {
1302 app.handle_response(response);
1303 }
1304 app.response_rx = Some(rx);
1305 }
1306
1307 if let Some(mut rx) = app.swarm_rx.take() {
1309 while let Ok(event) = rx.try_recv() {
1310 app.handle_swarm_event(event);
1311 }
1312 app.swarm_rx = Some(rx);
1313 }
1314
1315 if let Some(mut rx) = app.ralph_rx.take() {
1317 while let Ok(event) = rx.try_recv() {
1318 app.handle_ralph_event(event);
1319 }
1320 app.ralph_rx = Some(rx);
1321 }
1322
1323 if event::poll(std::time::Duration::from_millis(100))? {
1324 let ev = event::read()?;
1325
1326 if let Event::Paste(text) = &ev {
1328 for c in text.chars() {
1329 if c == '\n' || c == '\r' {
1330 app.input.insert(app.cursor_position, ' ');
1332 } else {
1333 app.input.insert(app.cursor_position, c);
1334 }
1335 app.cursor_position += 1;
1336 }
1337 continue;
1338 }
1339
1340 if let Event::Key(key) = ev {
1341 if app.show_help {
1343 if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
1344 app.show_help = false;
1345 }
1346 continue;
1347 }
1348
1349 if app.view_mode == ViewMode::ModelPicker {
1351 match key.code {
1352 KeyCode::Esc => {
1353 app.view_mode = ViewMode::Chat;
1354 }
1355 KeyCode::Up | KeyCode::Char('k')
1356 if !key.modifiers.contains(KeyModifiers::ALT) =>
1357 {
1358 if app.model_picker_selected > 0 {
1359 app.model_picker_selected -= 1;
1360 }
1361 }
1362 KeyCode::Down | KeyCode::Char('j')
1363 if !key.modifiers.contains(KeyModifiers::ALT) =>
1364 {
1365 let filtered = app.filtered_models();
1366 if app.model_picker_selected < filtered.len().saturating_sub(1) {
1367 app.model_picker_selected += 1;
1368 }
1369 }
1370 KeyCode::Enter => {
1371 let filtered = app.filtered_models();
1372 if let Some((_, (label, value, _name))) =
1373 filtered.get(app.model_picker_selected)
1374 {
1375 let label = label.clone();
1376 let value = value.clone();
1377 app.active_model = Some(value.clone());
1378 if let Some(session) = app.session.as_mut() {
1379 session.metadata.model = Some(value.clone());
1380 }
1381 app.messages.push(ChatMessage::new(
1382 "system",
1383 format!("Model set to: {}", label),
1384 ));
1385 app.view_mode = ViewMode::Chat;
1386 }
1387 }
1388 KeyCode::Backspace => {
1389 app.model_picker_filter.pop();
1390 app.model_picker_selected = 0;
1391 }
1392 KeyCode::Char(c)
1393 if !key.modifiers.contains(KeyModifiers::CONTROL)
1394 && !key.modifiers.contains(KeyModifiers::ALT) =>
1395 {
1396 app.model_picker_filter.push(c);
1397 app.model_picker_selected = 0;
1398 }
1399 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1400 return Ok(());
1401 }
1402 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1403 return Ok(());
1404 }
1405 _ => {}
1406 }
1407 continue;
1408 }
1409
1410 if app.view_mode == ViewMode::SessionPicker {
1412 match key.code {
1413 KeyCode::Esc => {
1414 if app.session_picker_confirm_delete {
1415 app.session_picker_confirm_delete = false;
1416 } else {
1417 app.session_picker_filter.clear();
1418 app.view_mode = ViewMode::Chat;
1419 }
1420 }
1421 KeyCode::Up | KeyCode::Char('k') => {
1422 if app.session_picker_selected > 0 {
1423 app.session_picker_selected -= 1;
1424 }
1425 app.session_picker_confirm_delete = false;
1426 }
1427 KeyCode::Down | KeyCode::Char('j') => {
1428 let filtered_count = app.filtered_sessions().len();
1429 if app.session_picker_selected < filtered_count.saturating_sub(1) {
1430 app.session_picker_selected += 1;
1431 }
1432 app.session_picker_confirm_delete = false;
1433 }
1434 KeyCode::Char('d') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
1435 if app.session_picker_confirm_delete {
1436 let filtered = app.filtered_sessions();
1438 if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
1439 let session_id = app.session_picker_list[*orig_idx].id.clone();
1440 let is_active = app.session.as_ref().map(|s| s.id == session_id).unwrap_or(false);
1441 if !is_active {
1442 if let Err(e) = Session::delete(&session_id).await {
1443 app.messages.push(ChatMessage::new(
1444 "system",
1445 format!("Failed to delete session: {}", e),
1446 ));
1447 } else {
1448 app.session_picker_list.retain(|s| s.id != session_id);
1449 if app.session_picker_selected >= app.session_picker_list.len() {
1450 app.session_picker_selected = app.session_picker_list.len().saturating_sub(1);
1451 }
1452 }
1453 }
1454 }
1455 app.session_picker_confirm_delete = false;
1456 } else {
1457 let filtered = app.filtered_sessions();
1459 if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
1460 let is_active = app.session.as_ref().map(|s| s.id == app.session_picker_list[*orig_idx].id).unwrap_or(false);
1461 if !is_active {
1462 app.session_picker_confirm_delete = true;
1463 }
1464 }
1465 }
1466 }
1467 KeyCode::Backspace => {
1468 app.session_picker_filter.pop();
1469 app.session_picker_selected = 0;
1470 app.session_picker_confirm_delete = false;
1471 }
1472 KeyCode::Char('/') => {
1473 }
1475 KeyCode::Enter => {
1476 app.session_picker_confirm_delete = false;
1477 let filtered = app.filtered_sessions();
1478 let session_id = filtered.get(app.session_picker_selected)
1479 .map(|(orig_idx, _)| app.session_picker_list[*orig_idx].id.clone());
1480 if let Some(session_id) = session_id {
1481 match Session::load(&session_id).await {
1482 Ok(session) => {
1483 app.messages.clear();
1484 app.messages.push(ChatMessage::new("system", format!(
1485 "Resumed session: {}\nCreated: {}\n{} messages loaded",
1486 session.title.as_deref().unwrap_or("(untitled)"),
1487 session.created_at.format("%Y-%m-%d %H:%M"),
1488 session.messages.len()
1489 )));
1490
1491 for msg in &session.messages {
1492 let role_str = match msg.role {
1493 Role::System => "system",
1494 Role::User => "user",
1495 Role::Assistant => "assistant",
1496 Role::Tool => "tool",
1497 };
1498
1499 for part in &msg.content {
1502 match part {
1503 ContentPart::Text { text } => {
1504 if !text.is_empty() {
1505 app.messages.push(ChatMessage::new(role_str, text.clone()));
1506 }
1507 }
1508 ContentPart::Image { url, mime_type } => {
1509 app.messages.push(
1510 ChatMessage::new(role_str, "").with_message_type(
1511 MessageType::Image {
1512 url: url.clone(),
1513 mime_type: mime_type.clone(),
1514 },
1515 ),
1516 );
1517 }
1518 ContentPart::ToolCall { name, arguments, .. } => {
1519 app.messages.push(
1520 ChatMessage::new(role_str, format!("🔧 {name}"))
1521 .with_message_type(MessageType::ToolCall {
1522 name: name.clone(),
1523 arguments: arguments.clone(),
1524 }),
1525 );
1526 }
1527 ContentPart::ToolResult { content, .. } => {
1528 let truncated = truncate_with_ellipsis(content, 500);
1529 app.messages.push(
1530 ChatMessage::new(role_str, format!("✅ Result\n{truncated}"))
1531 .with_message_type(MessageType::ToolResult {
1532 name: "tool".to_string(),
1533 output: content.clone(),
1534 }),
1535 );
1536 }
1537 ContentPart::File { path, mime_type } => {
1538 app.messages.push(
1539 ChatMessage::new(role_str, format!("📎 {path}"))
1540 .with_message_type(MessageType::File {
1541 path: path.clone(),
1542 mime_type: mime_type.clone(),
1543 }),
1544 );
1545 }
1546 }
1547 }
1548 }
1549
1550 app.current_agent = session.agent.clone();
1551 app.session = Some(session);
1552 app.scroll = SCROLL_BOTTOM;
1553 app.view_mode = ViewMode::Chat;
1554 }
1555 Err(e) => {
1556 app.messages.push(ChatMessage::new(
1557 "system",
1558 format!("Failed to load session: {}", e),
1559 ));
1560 app.view_mode = ViewMode::Chat;
1561 }
1562 }
1563 }
1564 }
1565 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1566 return Ok(());
1567 }
1568 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1569 return Ok(());
1570 }
1571 KeyCode::Char(c)
1572 if !key.modifiers.contains(KeyModifiers::CONTROL)
1573 && !key.modifiers.contains(KeyModifiers::ALT)
1574 && c != 'j' && c != 'k' =>
1575 {
1576 app.session_picker_filter.push(c);
1577 app.session_picker_selected = 0;
1578 app.session_picker_confirm_delete = false;
1579 }
1580 _ => {}
1581 }
1582 continue;
1583 }
1584
1585 if app.view_mode == ViewMode::Swarm {
1587 match key.code {
1588 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1589 return Ok(());
1590 }
1591 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1592 return Ok(());
1593 }
1594 KeyCode::Esc => {
1595 if app.swarm_state.detail_mode {
1596 app.swarm_state.exit_detail();
1597 } else {
1598 app.view_mode = ViewMode::Chat;
1599 }
1600 }
1601 KeyCode::Up | KeyCode::Char('k') => {
1602 if app.swarm_state.detail_mode {
1603 app.swarm_state.exit_detail();
1605 app.swarm_state.select_prev();
1606 app.swarm_state.enter_detail();
1607 } else {
1608 app.swarm_state.select_prev();
1609 }
1610 }
1611 KeyCode::Down | KeyCode::Char('j') => {
1612 if app.swarm_state.detail_mode {
1613 app.swarm_state.exit_detail();
1614 app.swarm_state.select_next();
1615 app.swarm_state.enter_detail();
1616 } else {
1617 app.swarm_state.select_next();
1618 }
1619 }
1620 KeyCode::Enter => {
1621 if !app.swarm_state.detail_mode {
1622 app.swarm_state.enter_detail();
1623 }
1624 }
1625 KeyCode::PageDown => {
1626 app.swarm_state.detail_scroll_down(10);
1627 }
1628 KeyCode::PageUp => {
1629 app.swarm_state.detail_scroll_up(10);
1630 }
1631 KeyCode::Char('?') => {
1632 app.show_help = true;
1633 }
1634 KeyCode::F(2) => {
1635 app.view_mode = ViewMode::Chat;
1636 }
1637 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1638 app.view_mode = ViewMode::Chat;
1639 }
1640 _ => {}
1641 }
1642 continue;
1643 }
1644
1645 if app.view_mode == ViewMode::Ralph {
1647 match key.code {
1648 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1649 return Ok(());
1650 }
1651 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1652 return Ok(());
1653 }
1654 KeyCode::Esc => {
1655 if app.ralph_state.detail_mode {
1656 app.ralph_state.exit_detail();
1657 } else {
1658 app.view_mode = ViewMode::Chat;
1659 }
1660 }
1661 KeyCode::Up | KeyCode::Char('k') => {
1662 if app.ralph_state.detail_mode {
1663 app.ralph_state.exit_detail();
1664 app.ralph_state.select_prev();
1665 app.ralph_state.enter_detail();
1666 } else {
1667 app.ralph_state.select_prev();
1668 }
1669 }
1670 KeyCode::Down | KeyCode::Char('j') => {
1671 if app.ralph_state.detail_mode {
1672 app.ralph_state.exit_detail();
1673 app.ralph_state.select_next();
1674 app.ralph_state.enter_detail();
1675 } else {
1676 app.ralph_state.select_next();
1677 }
1678 }
1679 KeyCode::Enter => {
1680 if !app.ralph_state.detail_mode {
1681 app.ralph_state.enter_detail();
1682 }
1683 }
1684 KeyCode::PageDown => {
1685 app.ralph_state.detail_scroll_down(10);
1686 }
1687 KeyCode::PageUp => {
1688 app.ralph_state.detail_scroll_up(10);
1689 }
1690 KeyCode::Char('?') => {
1691 app.show_help = true;
1692 }
1693 KeyCode::F(2) | KeyCode::Char('s')
1694 if key.modifiers.contains(KeyModifiers::CONTROL) =>
1695 {
1696 app.view_mode = ViewMode::Chat;
1697 }
1698 _ => {}
1699 }
1700 continue;
1701 }
1702
1703 match key.code {
1704 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1706 return Ok(());
1707 }
1708 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1709 return Ok(());
1710 }
1711
1712 KeyCode::Char('?') => {
1714 app.show_help = true;
1715 }
1716
1717 KeyCode::F(2) => {
1719 app.view_mode = match app.view_mode {
1720 ViewMode::Chat | ViewMode::SessionPicker | ViewMode::ModelPicker => {
1721 ViewMode::Swarm
1722 }
1723 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
1724 };
1725 }
1726 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1727 app.view_mode = match app.view_mode {
1728 ViewMode::Chat | ViewMode::SessionPicker | ViewMode::ModelPicker => {
1729 ViewMode::Swarm
1730 }
1731 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
1732 };
1733 }
1734
1735 KeyCode::F(3) => {
1737 app.show_inspector = !app.show_inspector;
1738 }
1739
1740 KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1742 app.chat_layout = match app.chat_layout {
1743 ChatLayoutMode::Classic => ChatLayoutMode::Webview,
1744 ChatLayoutMode::Webview => ChatLayoutMode::Classic,
1745 };
1746 }
1747
1748 KeyCode::Esc => {
1750 if app.view_mode == ViewMode::Swarm
1751 || app.view_mode == ViewMode::Ralph
1752 || app.view_mode == ViewMode::SessionPicker
1753 || app.view_mode == ViewMode::ModelPicker
1754 {
1755 app.view_mode = ViewMode::Chat;
1756 }
1757 }
1758
1759 KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1761 app.open_model_picker(&config).await;
1762 }
1763
1764 KeyCode::Tab => {
1766 app.current_agent = if app.current_agent == "build" {
1767 "plan".to_string()
1768 } else {
1769 "build".to_string()
1770 };
1771 }
1772
1773 KeyCode::Enter => {
1775 app.submit_message(&config).await;
1776 }
1777
1778 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
1780 if app.scroll < SCROLL_BOTTOM {
1781 app.scroll = app.scroll.saturating_add(1);
1782 }
1783 }
1784 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
1785 if app.scroll >= SCROLL_BOTTOM {
1786 app.scroll = app.last_max_scroll; }
1788 app.scroll = app.scroll.saturating_sub(1);
1789 }
1790
1791 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1793 app.search_history();
1794 }
1795 KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
1796 app.navigate_history(-1);
1797 }
1798 KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
1799 app.navigate_history(1);
1800 }
1801
1802 KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1804 app.scroll = 0; }
1806 KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1807 app.scroll = SCROLL_BOTTOM;
1809 }
1810
1811 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
1813 if app.scroll < SCROLL_BOTTOM {
1815 app.scroll = app.scroll.saturating_add(5);
1816 }
1817 }
1818 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
1819 if app.scroll >= SCROLL_BOTTOM {
1821 app.scroll = app.last_max_scroll;
1822 }
1823 app.scroll = app.scroll.saturating_sub(5);
1824 }
1825
1826 KeyCode::Char(c) => {
1828 app.input.insert(app.cursor_position, c);
1829 app.cursor_position += 1;
1830 }
1831 KeyCode::Backspace => {
1832 if app.cursor_position > 0 {
1833 app.cursor_position -= 1;
1834 app.input.remove(app.cursor_position);
1835 }
1836 }
1837 KeyCode::Delete => {
1838 if app.cursor_position < app.input.len() {
1839 app.input.remove(app.cursor_position);
1840 }
1841 }
1842 KeyCode::Left => {
1843 app.cursor_position = app.cursor_position.saturating_sub(1);
1844 }
1845 KeyCode::Right => {
1846 if app.cursor_position < app.input.len() {
1847 app.cursor_position += 1;
1848 }
1849 }
1850 KeyCode::Home => {
1851 app.cursor_position = 0;
1852 }
1853 KeyCode::End => {
1854 app.cursor_position = app.input.len();
1855 }
1856
1857 KeyCode::Up => {
1859 if app.scroll >= SCROLL_BOTTOM {
1860 app.scroll = app.last_max_scroll; }
1862 app.scroll = app.scroll.saturating_sub(1);
1863 }
1864 KeyCode::Down => {
1865 if app.scroll < SCROLL_BOTTOM {
1866 app.scroll = app.scroll.saturating_add(1);
1867 }
1868 }
1869 KeyCode::PageUp => {
1870 if app.scroll >= SCROLL_BOTTOM {
1871 app.scroll = app.last_max_scroll;
1872 }
1873 app.scroll = app.scroll.saturating_sub(10);
1874 }
1875 KeyCode::PageDown => {
1876 if app.scroll < SCROLL_BOTTOM {
1877 app.scroll = app.scroll.saturating_add(10);
1878 }
1879 }
1880
1881 _ => {}
1882 }
1883 }
1884 }
1885 }
1886}
1887
1888fn ui(f: &mut Frame, app: &mut App, theme: &Theme) {
1889 if app.view_mode == ViewMode::Swarm {
1891 let chunks = Layout::default()
1893 .direction(Direction::Vertical)
1894 .constraints([
1895 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
1899 .split(f.area());
1900
1901 render_swarm_view(f, &mut app.swarm_state, chunks[0]);
1903
1904 let input_block = Block::default()
1906 .borders(Borders::ALL)
1907 .title(" Press Esc, Ctrl+S, or /view to return to chat ")
1908 .border_style(Style::default().fg(Color::Cyan));
1909
1910 let input = Paragraph::new(app.input.as_str())
1911 .block(input_block)
1912 .wrap(Wrap { trim: false });
1913 f.render_widget(input, chunks[1]);
1914
1915 let status_line = if app.swarm_state.detail_mode {
1917 Line::from(vec![
1918 Span::styled(
1919 " AGENT DETAIL ",
1920 Style::default().fg(Color::Black).bg(Color::Cyan),
1921 ),
1922 Span::raw(" | "),
1923 Span::styled("Esc", Style::default().fg(Color::Yellow)),
1924 Span::raw(": Back to list | "),
1925 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
1926 Span::raw(": Prev/Next agent | "),
1927 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
1928 Span::raw(": Scroll"),
1929 ])
1930 } else {
1931 Line::from(vec![
1932 Span::styled(
1933 " SWARM MODE ",
1934 Style::default().fg(Color::Black).bg(Color::Cyan),
1935 ),
1936 Span::raw(" | "),
1937 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
1938 Span::raw(": Select | "),
1939 Span::styled("Enter", Style::default().fg(Color::Yellow)),
1940 Span::raw(": Detail | "),
1941 Span::styled("Esc", Style::default().fg(Color::Yellow)),
1942 Span::raw(": Back | "),
1943 Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
1944 Span::raw(": Toggle view"),
1945 ])
1946 };
1947 let status = Paragraph::new(status_line);
1948 f.render_widget(status, chunks[2]);
1949 return;
1950 }
1951
1952 if app.view_mode == ViewMode::Ralph {
1954 let chunks = Layout::default()
1955 .direction(Direction::Vertical)
1956 .constraints([
1957 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
1961 .split(f.area());
1962
1963 render_ralph_view(f, &mut app.ralph_state, chunks[0]);
1964
1965 let input_block = Block::default()
1966 .borders(Borders::ALL)
1967 .title(" Press Esc to return to chat ")
1968 .border_style(Style::default().fg(Color::Magenta));
1969
1970 let input = Paragraph::new(app.input.as_str())
1971 .block(input_block)
1972 .wrap(Wrap { trim: false });
1973 f.render_widget(input, chunks[1]);
1974
1975 let status_line = if app.ralph_state.detail_mode {
1976 Line::from(vec![
1977 Span::styled(
1978 " STORY DETAIL ",
1979 Style::default().fg(Color::Black).bg(Color::Magenta),
1980 ),
1981 Span::raw(" | "),
1982 Span::styled("Esc", Style::default().fg(Color::Yellow)),
1983 Span::raw(": Back to list | "),
1984 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
1985 Span::raw(": Prev/Next story | "),
1986 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
1987 Span::raw(": Scroll"),
1988 ])
1989 } else {
1990 Line::from(vec![
1991 Span::styled(
1992 " RALPH MODE ",
1993 Style::default().fg(Color::Black).bg(Color::Magenta),
1994 ),
1995 Span::raw(" | "),
1996 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
1997 Span::raw(": Select | "),
1998 Span::styled("Enter", Style::default().fg(Color::Yellow)),
1999 Span::raw(": Detail | "),
2000 Span::styled("Esc", Style::default().fg(Color::Yellow)),
2001 Span::raw(": Back"),
2002 ])
2003 };
2004 let status = Paragraph::new(status_line);
2005 f.render_widget(status, chunks[2]);
2006 return;
2007 }
2008
2009 if app.view_mode == ViewMode::ModelPicker {
2011 let area = centered_rect(70, 70, f.area());
2012 f.render_widget(Clear, area);
2013
2014 let filter_display = if app.model_picker_filter.is_empty() {
2015 "type to filter".to_string()
2016 } else {
2017 format!("filter: {}", app.model_picker_filter)
2018 };
2019
2020 let picker_block = Block::default()
2021 .borders(Borders::ALL)
2022 .title(format!(
2023 " Select Model (↑↓ navigate, Enter select, Esc cancel) [{}] ",
2024 filter_display
2025 ))
2026 .border_style(Style::default().fg(Color::Magenta));
2027
2028 let filtered = app.filtered_models();
2029 let mut list_lines: Vec<Line> = Vec::new();
2030 list_lines.push(Line::from(""));
2031
2032 if let Some(ref active) = app.active_model {
2033 list_lines.push(Line::styled(
2034 format!(" Current: {}", active),
2035 Style::default()
2036 .fg(Color::Green)
2037 .add_modifier(Modifier::DIM),
2038 ));
2039 list_lines.push(Line::from(""));
2040 }
2041
2042 if filtered.is_empty() {
2043 list_lines.push(Line::styled(
2044 " No models match filter",
2045 Style::default().fg(Color::DarkGray),
2046 ));
2047 } else {
2048 let mut current_provider = String::new();
2049 for (display_idx, (_, (label, _, human_name))) in filtered.iter().enumerate() {
2050 let provider = label.split('/').next().unwrap_or("");
2051 if provider != current_provider {
2052 if !current_provider.is_empty() {
2053 list_lines.push(Line::from(""));
2054 }
2055 list_lines.push(Line::styled(
2056 format!(" ─── {} ───", provider),
2057 Style::default()
2058 .fg(Color::Cyan)
2059 .add_modifier(Modifier::BOLD),
2060 ));
2061 current_provider = provider.to_string();
2062 }
2063
2064 let is_selected = display_idx == app.model_picker_selected;
2065 let is_active = app.active_model.as_deref() == Some(label.as_str());
2066 let marker = if is_selected { "▶" } else { " " };
2067 let active_marker = if is_active { " ✓" } else { "" };
2068 let model_id = label.split('/').skip(1).collect::<Vec<_>>().join("/");
2069 let display = if human_name != &model_id && !human_name.is_empty() {
2071 format!("{} ({})", human_name, model_id)
2072 } else {
2073 model_id
2074 };
2075
2076 let style = if is_selected {
2077 Style::default()
2078 .fg(Color::Magenta)
2079 .add_modifier(Modifier::BOLD)
2080 } else if is_active {
2081 Style::default().fg(Color::Green)
2082 } else {
2083 Style::default()
2084 };
2085
2086 list_lines.push(Line::styled(
2087 format!(" {} {}{}", marker, display, active_marker),
2088 style,
2089 ));
2090 }
2091 }
2092
2093 let list = Paragraph::new(list_lines)
2094 .block(picker_block)
2095 .wrap(Wrap { trim: false });
2096 f.render_widget(list, area);
2097 return;
2098 }
2099
2100 if app.view_mode == ViewMode::SessionPicker {
2102 let chunks = Layout::default()
2103 .direction(Direction::Vertical)
2104 .constraints([
2105 Constraint::Min(1), Constraint::Length(1), ])
2108 .split(f.area());
2109
2110 let filter_display = if app.session_picker_filter.is_empty() {
2112 String::new()
2113 } else {
2114 format!(" [filter: {}]", app.session_picker_filter)
2115 };
2116
2117 let list_block = Block::default()
2118 .borders(Borders::ALL)
2119 .title(format!(
2120 " Sessions (↑↓ navigate, Enter load, d delete, Esc cancel){} ",
2121 filter_display
2122 ))
2123 .border_style(Style::default().fg(Color::Cyan));
2124
2125 let mut list_lines: Vec<Line> = Vec::new();
2126 list_lines.push(Line::from(""));
2127
2128 let filtered = app.filtered_sessions();
2129 if filtered.is_empty() {
2130 if app.session_picker_filter.is_empty() {
2131 list_lines.push(Line::styled(
2132 " No sessions found.",
2133 Style::default().fg(Color::DarkGray),
2134 ));
2135 } else {
2136 list_lines.push(Line::styled(
2137 format!(" No sessions matching '{}'", app.session_picker_filter),
2138 Style::default().fg(Color::DarkGray),
2139 ));
2140 }
2141 }
2142
2143 for (display_idx, (_orig_idx, session)) in filtered.iter().enumerate() {
2144 let is_selected = display_idx == app.session_picker_selected;
2145 let is_active = app
2146 .session
2147 .as_ref()
2148 .map(|s| s.id == session.id)
2149 .unwrap_or(false);
2150 let title = session.title.as_deref().unwrap_or("(untitled)");
2151 let date = session.updated_at.format("%Y-%m-%d %H:%M");
2152 let active_marker = if is_active { " ●" } else { "" };
2153 let line_str = format!(
2154 " {} {}{} - {} ({} msgs)",
2155 if is_selected { "▶" } else { " " },
2156 title,
2157 active_marker,
2158 date,
2159 session.message_count
2160 );
2161
2162 let style = if is_selected && app.session_picker_confirm_delete {
2163 Style::default()
2164 .fg(Color::Red)
2165 .add_modifier(Modifier::BOLD)
2166 } else if is_selected {
2167 Style::default()
2168 .fg(Color::Cyan)
2169 .add_modifier(Modifier::BOLD)
2170 } else if is_active {
2171 Style::default().fg(Color::Green)
2172 } else {
2173 Style::default()
2174 };
2175
2176 list_lines.push(Line::styled(line_str, style));
2177
2178 if is_selected {
2180 if app.session_picker_confirm_delete {
2181 list_lines.push(Line::styled(
2182 " ⚠ Press d again to confirm delete, Esc to cancel",
2183 Style::default()
2184 .fg(Color::Red)
2185 .add_modifier(Modifier::BOLD),
2186 ));
2187 } else {
2188 list_lines.push(Line::styled(
2189 format!(" Agent: {} | ID: {}", session.agent, session.id),
2190 Style::default().fg(Color::DarkGray),
2191 ));
2192 }
2193 }
2194 }
2195
2196 let list = Paragraph::new(list_lines)
2197 .block(list_block)
2198 .wrap(Wrap { trim: false });
2199 f.render_widget(list, chunks[0]);
2200
2201 let mut status_spans = vec![
2203 Span::styled(
2204 " SESSION PICKER ",
2205 Style::default().fg(Color::Black).bg(Color::Cyan),
2206 ),
2207 Span::raw(" "),
2208 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
2209 Span::raw(": Nav "),
2210 Span::styled("Enter", Style::default().fg(Color::Yellow)),
2211 Span::raw(": Load "),
2212 Span::styled("d", Style::default().fg(Color::Yellow)),
2213 Span::raw(": Delete "),
2214 Span::styled("Esc", Style::default().fg(Color::Yellow)),
2215 Span::raw(": Cancel "),
2216 ];
2217 if !app.session_picker_filter.is_empty() || !app.session_picker_list.is_empty() {
2218 status_spans.push(Span::styled("Type", Style::default().fg(Color::Yellow)));
2219 status_spans.push(Span::raw(": Filter "));
2220 }
2221 let total = app.session_picker_list.len();
2222 let showing = filtered.len();
2223 if showing < total {
2224 status_spans.push(Span::styled(
2225 format!("{}/{}", showing, total),
2226 Style::default().fg(Color::DarkGray),
2227 ));
2228 }
2229
2230 let status = Paragraph::new(Line::from(status_spans));
2231 f.render_widget(status, chunks[1]);
2232 return;
2233 }
2234
2235 if app.chat_layout == ChatLayoutMode::Webview {
2236 if render_webview_chat(f, app, theme) {
2237 render_help_overlay_if_needed(f, app, theme);
2238 return;
2239 }
2240 }
2241
2242 let chunks = Layout::default()
2244 .direction(Direction::Vertical)
2245 .constraints([
2246 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
2250 .split(f.area());
2251
2252 let messages_area = chunks[0];
2254 let model_label = app.active_model.as_deref().unwrap_or("auto");
2255 let messages_block = Block::default()
2256 .borders(Borders::ALL)
2257 .title(format!(
2258 " CodeTether Agent [{}] model:{} ",
2259 app.current_agent, model_label
2260 ))
2261 .border_style(Style::default().fg(theme.border_color.to_color()));
2262
2263 let max_width = messages_area.width.saturating_sub(4) as usize;
2264 let message_lines = build_message_lines(app, theme, max_width);
2265
2266 let total_lines = message_lines.len();
2268 let visible_lines = messages_area.height.saturating_sub(2) as usize;
2269 let max_scroll = total_lines.saturating_sub(visible_lines);
2270 let scroll = if app.scroll >= SCROLL_BOTTOM {
2272 max_scroll
2273 } else {
2274 app.scroll.min(max_scroll)
2275 };
2276
2277 let messages_paragraph = Paragraph::new(
2279 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
2280 )
2281 .block(messages_block.clone())
2282 .wrap(Wrap { trim: false });
2283
2284 f.render_widget(messages_paragraph, messages_area);
2285
2286 if total_lines > visible_lines {
2288 let scrollbar = Scrollbar::default()
2289 .orientation(ScrollbarOrientation::VerticalRight)
2290 .symbols(ratatui::symbols::scrollbar::VERTICAL)
2291 .begin_symbol(Some("↑"))
2292 .end_symbol(Some("↓"));
2293
2294 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
2295
2296 let scrollbar_area = Rect::new(
2297 messages_area.right() - 1,
2298 messages_area.top() + 1,
2299 1,
2300 messages_area.height - 2,
2301 );
2302
2303 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
2304 }
2305
2306 let input_title = if app.is_processing {
2308 if let Some(started) = app.processing_started_at {
2309 let elapsed = started.elapsed();
2310 format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
2311 } else {
2312 " Message (Processing...) ".to_string()
2313 }
2314 } else if app.input.starts_with('/') {
2315 let hint = match_slash_command_hint(&app.input);
2316 format!(" {} ", hint)
2317 } else {
2318 " Message (Enter to send, / for commands) ".to_string()
2319 };
2320 let input_block = Block::default()
2321 .borders(Borders::ALL)
2322 .title(input_title)
2323 .border_style(Style::default().fg(if app.is_processing {
2324 Color::Yellow
2325 } else if app.input.starts_with('/') {
2326 Color::Magenta
2327 } else {
2328 theme.input_border_color.to_color()
2329 }));
2330
2331 let input = Paragraph::new(app.input.as_str())
2332 .block(input_block)
2333 .wrap(Wrap { trim: false });
2334 f.render_widget(input, chunks[1]);
2335
2336 f.set_cursor_position((
2338 chunks[1].x + app.cursor_position as u16 + 1,
2339 chunks[1].y + 1,
2340 ));
2341
2342 let token_display = TokenDisplay::new();
2344 let status = Paragraph::new(token_display.create_status_bar(theme));
2345 f.render_widget(status, chunks[2]);
2346
2347 render_help_overlay_if_needed(f, app, theme);
2348}
2349
2350fn render_webview_chat(f: &mut Frame, app: &App, theme: &Theme) -> bool {
2351 let area = f.area();
2352 if area.width < 90 || area.height < 18 {
2353 return false;
2354 }
2355
2356 let main_chunks = Layout::default()
2357 .direction(Direction::Vertical)
2358 .constraints([
2359 Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
2364 .split(area);
2365
2366 render_webview_header(f, app, theme, main_chunks[0]);
2367
2368 let body_constraints = if app.show_inspector {
2369 vec![
2370 Constraint::Length(26),
2371 Constraint::Min(40),
2372 Constraint::Length(30),
2373 ]
2374 } else {
2375 vec![Constraint::Length(26), Constraint::Min(40)]
2376 };
2377
2378 let body_chunks = Layout::default()
2379 .direction(Direction::Horizontal)
2380 .constraints(body_constraints)
2381 .split(main_chunks[1]);
2382
2383 render_webview_sidebar(f, app, theme, body_chunks[0]);
2384 render_webview_chat_center(f, app, theme, body_chunks[1]);
2385 if app.show_inspector && body_chunks.len() > 2 {
2386 render_webview_inspector(f, app, theme, body_chunks[2]);
2387 }
2388
2389 render_webview_input(f, app, theme, main_chunks[2]);
2390
2391 let token_display = TokenDisplay::new();
2392 let status = Paragraph::new(token_display.create_status_bar(theme));
2393 f.render_widget(status, main_chunks[3]);
2394
2395 true
2396}
2397
2398fn render_webview_header(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2399 let session_title = app
2400 .session
2401 .as_ref()
2402 .and_then(|s| s.title.clone())
2403 .unwrap_or_else(|| "Workspace Chat".to_string());
2404 let session_id = app
2405 .session
2406 .as_ref()
2407 .map(|s| s.id.chars().take(8).collect::<String>())
2408 .unwrap_or_else(|| "new".to_string());
2409 let model_label = app
2410 .session
2411 .as_ref()
2412 .and_then(|s| s.metadata.model.clone())
2413 .unwrap_or_else(|| "auto".to_string());
2414 let workspace_label = app.workspace.root_display.clone();
2415 let branch_label = app
2416 .workspace
2417 .git_branch
2418 .clone()
2419 .unwrap_or_else(|| "no-git".to_string());
2420 let dirty_label = if app.workspace.git_dirty_files > 0 {
2421 format!("{} dirty", app.workspace.git_dirty_files)
2422 } else {
2423 "clean".to_string()
2424 };
2425
2426 let header_block = Block::default()
2427 .borders(Borders::ALL)
2428 .title(" CodeTether Webview ")
2429 .border_style(Style::default().fg(theme.border_color.to_color()));
2430
2431 let header_lines = vec![
2432 Line::from(vec![
2433 Span::styled(session_title, Style::default().add_modifier(Modifier::BOLD)),
2434 Span::raw(" "),
2435 Span::styled(
2436 format!("#{}", session_id),
2437 Style::default()
2438 .fg(theme.timestamp_color.to_color())
2439 .add_modifier(Modifier::DIM),
2440 ),
2441 ]),
2442 Line::from(vec![
2443 Span::styled(
2444 "Workspace ",
2445 Style::default().fg(theme.timestamp_color.to_color()),
2446 ),
2447 Span::styled(workspace_label, Style::default()),
2448 Span::raw(" "),
2449 Span::styled(
2450 "Branch ",
2451 Style::default().fg(theme.timestamp_color.to_color()),
2452 ),
2453 Span::styled(
2454 branch_label,
2455 Style::default()
2456 .fg(Color::Cyan)
2457 .add_modifier(Modifier::BOLD),
2458 ),
2459 Span::raw(" "),
2460 Span::styled(
2461 dirty_label,
2462 Style::default()
2463 .fg(Color::Yellow)
2464 .add_modifier(Modifier::BOLD),
2465 ),
2466 Span::raw(" "),
2467 Span::styled(
2468 "Model ",
2469 Style::default().fg(theme.timestamp_color.to_color()),
2470 ),
2471 Span::styled(model_label, Style::default().fg(Color::Green)),
2472 ]),
2473 ];
2474
2475 let header = Paragraph::new(header_lines)
2476 .block(header_block)
2477 .wrap(Wrap { trim: true });
2478 f.render_widget(header, area);
2479}
2480
2481fn render_webview_sidebar(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2482 let sidebar_chunks = Layout::default()
2483 .direction(Direction::Vertical)
2484 .constraints([Constraint::Min(8), Constraint::Min(6)])
2485 .split(area);
2486
2487 let workspace_block = Block::default()
2488 .borders(Borders::ALL)
2489 .title(" Workspace ")
2490 .border_style(Style::default().fg(theme.border_color.to_color()));
2491
2492 let mut workspace_lines = Vec::new();
2493 workspace_lines.push(Line::from(vec![
2494 Span::styled(
2495 "Updated ",
2496 Style::default().fg(theme.timestamp_color.to_color()),
2497 ),
2498 Span::styled(
2499 app.workspace.captured_at.clone(),
2500 Style::default().fg(theme.timestamp_color.to_color()),
2501 ),
2502 ]));
2503 workspace_lines.push(Line::from(""));
2504
2505 if app.workspace.entries.is_empty() {
2506 workspace_lines.push(Line::styled(
2507 "No entries found",
2508 Style::default().fg(Color::DarkGray),
2509 ));
2510 } else {
2511 for entry in app.workspace.entries.iter().take(12) {
2512 let icon = match entry.kind {
2513 WorkspaceEntryKind::Directory => "📁",
2514 WorkspaceEntryKind::File => "📄",
2515 };
2516 workspace_lines.push(Line::from(vec![
2517 Span::styled(icon, Style::default().fg(Color::Cyan)),
2518 Span::raw(" "),
2519 Span::styled(entry.name.clone(), Style::default()),
2520 ]));
2521 }
2522 }
2523
2524 workspace_lines.push(Line::from(""));
2525 workspace_lines.push(Line::styled(
2526 "Use /refresh to rescan",
2527 Style::default()
2528 .fg(Color::DarkGray)
2529 .add_modifier(Modifier::DIM),
2530 ));
2531
2532 let workspace_panel = Paragraph::new(workspace_lines)
2533 .block(workspace_block)
2534 .wrap(Wrap { trim: true });
2535 f.render_widget(workspace_panel, sidebar_chunks[0]);
2536
2537 let sessions_block = Block::default()
2538 .borders(Borders::ALL)
2539 .title(" Recent Sessions ")
2540 .border_style(Style::default().fg(theme.border_color.to_color()));
2541
2542 let mut session_lines = Vec::new();
2543 if app.session_picker_list.is_empty() {
2544 session_lines.push(Line::styled(
2545 "No sessions yet",
2546 Style::default().fg(Color::DarkGray),
2547 ));
2548 } else {
2549 for session in app.session_picker_list.iter().take(6) {
2550 let is_active = app
2551 .session
2552 .as_ref()
2553 .map(|s| s.id == session.id)
2554 .unwrap_or(false);
2555 let title = session.title.as_deref().unwrap_or("(untitled)");
2556 let indicator = if is_active { "●" } else { "○" };
2557 let line_style = if is_active {
2558 Style::default()
2559 .fg(Color::Cyan)
2560 .add_modifier(Modifier::BOLD)
2561 } else {
2562 Style::default()
2563 };
2564 session_lines.push(Line::from(vec![
2565 Span::styled(indicator, line_style),
2566 Span::raw(" "),
2567 Span::styled(title, line_style),
2568 ]));
2569 session_lines.push(Line::styled(
2570 format!(
2571 " {} msgs • {}",
2572 session.message_count,
2573 session.updated_at.format("%m-%d %H:%M")
2574 ),
2575 Style::default().fg(Color::DarkGray),
2576 ));
2577 }
2578 }
2579
2580 let sessions_panel = Paragraph::new(session_lines)
2581 .block(sessions_block)
2582 .wrap(Wrap { trim: true });
2583 f.render_widget(sessions_panel, sidebar_chunks[1]);
2584}
2585
2586fn render_webview_chat_center(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2587 let messages_area = area;
2588 let messages_block = Block::default()
2589 .borders(Borders::ALL)
2590 .title(format!(" Chat [{}] ", app.current_agent))
2591 .border_style(Style::default().fg(theme.border_color.to_color()));
2592
2593 let max_width = messages_area.width.saturating_sub(4) as usize;
2594 let message_lines = build_message_lines(app, theme, max_width);
2595
2596 let total_lines = message_lines.len();
2597 let visible_lines = messages_area.height.saturating_sub(2) as usize;
2598 let max_scroll = total_lines.saturating_sub(visible_lines);
2599 let scroll = if app.scroll >= SCROLL_BOTTOM {
2600 max_scroll
2601 } else {
2602 app.scroll.min(max_scroll)
2603 };
2604
2605 let messages_paragraph = Paragraph::new(
2606 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
2607 )
2608 .block(messages_block.clone())
2609 .wrap(Wrap { trim: false });
2610
2611 f.render_widget(messages_paragraph, messages_area);
2612
2613 if total_lines > visible_lines {
2614 let scrollbar = Scrollbar::default()
2615 .orientation(ScrollbarOrientation::VerticalRight)
2616 .symbols(ratatui::symbols::scrollbar::VERTICAL)
2617 .begin_symbol(Some("↑"))
2618 .end_symbol(Some("↓"));
2619
2620 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
2621
2622 let scrollbar_area = Rect::new(
2623 messages_area.right() - 1,
2624 messages_area.top() + 1,
2625 1,
2626 messages_area.height - 2,
2627 );
2628
2629 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
2630 }
2631}
2632
2633fn render_webview_inspector(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2634 let block = Block::default()
2635 .borders(Borders::ALL)
2636 .title(" Inspector ")
2637 .border_style(Style::default().fg(theme.border_color.to_color()));
2638
2639 let status_label = if app.is_processing {
2640 "Processing"
2641 } else {
2642 "Idle"
2643 };
2644 let status_style = if app.is_processing {
2645 Style::default()
2646 .fg(Color::Yellow)
2647 .add_modifier(Modifier::BOLD)
2648 } else {
2649 Style::default().fg(Color::Green)
2650 };
2651 let tool_label = app
2652 .current_tool
2653 .clone()
2654 .unwrap_or_else(|| "none".to_string());
2655 let message_count = app.messages.len();
2656 let session_id = app
2657 .session
2658 .as_ref()
2659 .map(|s| s.id.chars().take(8).collect::<String>())
2660 .unwrap_or_else(|| "new".to_string());
2661 let model_label = app
2662 .active_model
2663 .as_deref()
2664 .or_else(|| {
2665 app.session
2666 .as_ref()
2667 .and_then(|s| s.metadata.model.as_deref())
2668 })
2669 .unwrap_or("auto");
2670 let conversation_depth = app
2671 .session
2672 .as_ref()
2673 .map(|s| s.messages.len())
2674 .unwrap_or(0);
2675
2676 let label_style = Style::default().fg(theme.timestamp_color.to_color());
2677
2678 let mut lines = Vec::new();
2679 lines.push(Line::from(vec![
2680 Span::styled("Status: ", label_style),
2681 Span::styled(status_label, status_style),
2682 ]));
2683
2684 if let Some(started) = app.processing_started_at {
2686 let elapsed = started.elapsed();
2687 let elapsed_str = if elapsed.as_secs() >= 60 {
2688 format!("{}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
2689 } else {
2690 format!("{:.1}s", elapsed.as_secs_f64())
2691 };
2692 lines.push(Line::from(vec![
2693 Span::styled("Elapsed: ", label_style),
2694 Span::styled(
2695 elapsed_str,
2696 Style::default()
2697 .fg(Color::Yellow)
2698 .add_modifier(Modifier::BOLD),
2699 ),
2700 ]));
2701 }
2702
2703 lines.push(Line::from(vec![
2704 Span::styled("Tool: ", label_style),
2705 Span::styled(
2706 tool_label,
2707 if app.current_tool.is_some() {
2708 Style::default()
2709 .fg(Color::Cyan)
2710 .add_modifier(Modifier::BOLD)
2711 } else {
2712 Style::default().fg(Color::DarkGray)
2713 },
2714 ),
2715 ]));
2716 lines.push(Line::from(""));
2717 lines.push(Line::styled(
2718 "Session",
2719 Style::default().add_modifier(Modifier::BOLD),
2720 ));
2721 lines.push(Line::from(vec![
2722 Span::styled("ID: ", label_style),
2723 Span::styled(format!("#{}", session_id), Style::default().fg(Color::Cyan)),
2724 ]));
2725 lines.push(Line::from(vec![
2726 Span::styled("Model: ", label_style),
2727 Span::styled(model_label.to_string(), Style::default().fg(Color::Green)),
2728 ]));
2729 lines.push(Line::from(vec![
2730 Span::styled("Agent: ", label_style),
2731 Span::styled(app.current_agent.clone(), Style::default()),
2732 ]));
2733 lines.push(Line::from(vec![
2734 Span::styled("Messages: ", label_style),
2735 Span::styled(message_count.to_string(), Style::default()),
2736 ]));
2737 lines.push(Line::from(vec![
2738 Span::styled("Context: ", label_style),
2739 Span::styled(
2740 format!("{} turns", conversation_depth),
2741 Style::default(),
2742 ),
2743 ]));
2744 lines.push(Line::from(vec![
2745 Span::styled("Tools used: ", label_style),
2746 Span::styled(app.tool_call_count.to_string(), Style::default()),
2747 ]));
2748 lines.push(Line::from(""));
2749 lines.push(Line::styled(
2750 "Shortcuts",
2751 Style::default().add_modifier(Modifier::BOLD),
2752 ));
2753 lines.push(Line::from(vec![
2754 Span::styled("F3 ", Style::default().fg(Color::Yellow)),
2755 Span::styled("Inspector", Style::default().fg(Color::DarkGray)),
2756 ]));
2757 lines.push(Line::from(vec![
2758 Span::styled("Ctrl+B ", Style::default().fg(Color::Yellow)),
2759 Span::styled("Layout", Style::default().fg(Color::DarkGray)),
2760 ]));
2761 lines.push(Line::from(vec![
2762 Span::styled("Ctrl+M ", Style::default().fg(Color::Yellow)),
2763 Span::styled("Model", Style::default().fg(Color::DarkGray)),
2764 ]));
2765 lines.push(Line::from(vec![
2766 Span::styled("Ctrl+S ", Style::default().fg(Color::Yellow)),
2767 Span::styled("Swarm", Style::default().fg(Color::DarkGray)),
2768 ]));
2769 lines.push(Line::from(vec![
2770 Span::styled("? ", Style::default().fg(Color::Yellow)),
2771 Span::styled("Help", Style::default().fg(Color::DarkGray)),
2772 ]));
2773
2774 let panel = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
2775 f.render_widget(panel, area);
2776}
2777
2778fn render_webview_input(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2779 let title = if app.is_processing {
2780 if let Some(started) = app.processing_started_at {
2781 let elapsed = started.elapsed();
2782 format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
2783 } else {
2784 " Message (Processing...) ".to_string()
2785 }
2786 } else if app.input.starts_with('/') {
2787 let hint = match_slash_command_hint(&app.input);
2789 format!(" {} ", hint)
2790 } else {
2791 " Message (Enter to send, / for commands) ".to_string()
2792 };
2793
2794 let input_block = Block::default()
2795 .borders(Borders::ALL)
2796 .title(title)
2797 .border_style(Style::default().fg(if app.is_processing {
2798 Color::Yellow
2799 } else if app.input.starts_with('/') {
2800 Color::Magenta
2801 } else {
2802 theme.input_border_color.to_color()
2803 }));
2804
2805 let input = Paragraph::new(app.input.as_str())
2806 .block(input_block)
2807 .wrap(Wrap { trim: false });
2808 f.render_widget(input, area);
2809
2810 f.set_cursor_position((area.x + app.cursor_position as u16 + 1, area.y + 1));
2811}
2812
2813fn build_message_lines(app: &App, theme: &Theme, max_width: usize) -> Vec<Line<'static>> {
2814 let mut message_lines = Vec::new();
2815 let separator_width = max_width.min(60);
2816
2817 for (idx, message) in app.messages.iter().enumerate() {
2818 let role_style = theme.get_role_style(&message.role);
2819
2820 if idx > 0 {
2822 let sep_char = match message.role.as_str() {
2823 "tool" => "·",
2824 _ => "─",
2825 };
2826 message_lines.push(Line::from(Span::styled(
2827 sep_char.repeat(separator_width),
2828 Style::default()
2829 .fg(theme.timestamp_color.to_color())
2830 .add_modifier(Modifier::DIM),
2831 )));
2832 }
2833
2834 let role_icon = match message.role.as_str() {
2836 "user" => "▸ ",
2837 "assistant" => "◆ ",
2838 "system" => "⚙ ",
2839 "tool" => "⚡",
2840 _ => " ",
2841 };
2842
2843 let header_line = Line::from(vec![
2844 Span::styled(
2845 format!("[{}] ", message.timestamp),
2846 Style::default()
2847 .fg(theme.timestamp_color.to_color())
2848 .add_modifier(Modifier::DIM),
2849 ),
2850 Span::styled(role_icon, role_style),
2851 Span::styled(message.role.clone(), role_style),
2852 ]);
2853 message_lines.push(header_line);
2854
2855 match &message.message_type {
2856 MessageType::ToolCall { name, arguments } => {
2857 let tool_header = Line::from(vec![
2858 Span::styled(" 🔧 ", Style::default().fg(Color::Yellow)),
2859 Span::styled(
2860 format!("Tool: {}", name),
2861 Style::default()
2862 .fg(Color::Yellow)
2863 .add_modifier(Modifier::BOLD),
2864 ),
2865 ]);
2866 message_lines.push(tool_header);
2867
2868 let mut formatted_args = format_tool_call_arguments(name, arguments);
2869 let mut truncated = false;
2870 if formatted_args.chars().count() > 900 {
2871 formatted_args = format!(
2872 "{}...",
2873 formatted_args.chars().take(897).collect::<String>()
2874 );
2875 truncated = true;
2876 }
2877
2878 let arg_lines: Vec<&str> = formatted_args.lines().collect();
2879 for line in arg_lines.iter().take(10) {
2880 let args_line = Line::from(vec![
2881 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
2882 Span::styled((*line).to_string(), Style::default().fg(Color::DarkGray)),
2883 ]);
2884 message_lines.push(args_line);
2885 }
2886 if arg_lines.len() > 10 || truncated {
2887 message_lines.push(Line::from(vec![
2888 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
2889 Span::styled(
2890 "... (truncated)",
2891 Style::default()
2892 .fg(Color::DarkGray)
2893 .add_modifier(Modifier::DIM),
2894 ),
2895 ]));
2896 }
2897 }
2898 MessageType::ToolResult { name, output } => {
2899 let result_header = Line::from(vec![
2900 Span::styled(" ✅ ", Style::default().fg(Color::Green)),
2901 Span::styled(
2902 format!("Result from {}", name),
2903 Style::default()
2904 .fg(Color::Green)
2905 .add_modifier(Modifier::BOLD),
2906 ),
2907 ]);
2908 message_lines.push(result_header);
2909
2910 let output_str = truncate_with_ellipsis(output, 300);
2911 let output_lines: Vec<&str> = output_str.lines().collect();
2912 for line in output_lines.iter().take(5) {
2913 let output_line = Line::from(vec![
2914 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
2915 Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
2916 ]);
2917 message_lines.push(output_line);
2918 }
2919 if output_lines.len() > 5 {
2920 message_lines.push(Line::from(vec![
2921 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
2922 Span::styled(
2923 format!("... and {} more lines", output_lines.len() - 5),
2924 Style::default()
2925 .fg(Color::DarkGray)
2926 .add_modifier(Modifier::DIM),
2927 ),
2928 ]));
2929 }
2930 }
2931 MessageType::Text(text) => {
2932 let formatter = MessageFormatter::new(max_width);
2933 let formatted_content = formatter.format_content(text, &message.role);
2934 message_lines.extend(formatted_content);
2935 }
2936 MessageType::Image { url, mime_type } => {
2937 let formatter = MessageFormatter::new(max_width);
2938 let image_line = formatter.format_image(url, mime_type.as_deref());
2939 message_lines.push(image_line);
2940 }
2941 MessageType::File { path, mime_type } => {
2942 let mime_label = mime_type
2943 .as_deref()
2944 .unwrap_or("unknown type");
2945 let file_header = Line::from(vec![
2946 Span::styled(" 📎 ", Style::default().fg(Color::Cyan)),
2947 Span::styled(
2948 format!("File: {}", path),
2949 Style::default()
2950 .fg(Color::Cyan)
2951 .add_modifier(Modifier::BOLD),
2952 ),
2953 Span::styled(
2954 format!(" ({})", mime_label),
2955 Style::default()
2956 .fg(Color::DarkGray)
2957 .add_modifier(Modifier::DIM),
2958 ),
2959 ]);
2960 message_lines.push(file_header);
2961 }
2962 }
2963
2964 message_lines.push(Line::from(""));
2965 }
2966
2967 if let Some(ref streaming) = app.streaming_text {
2969 if !streaming.is_empty() {
2970 message_lines.push(Line::from(Span::styled(
2971 "─".repeat(separator_width),
2972 Style::default()
2973 .fg(theme.timestamp_color.to_color())
2974 .add_modifier(Modifier::DIM),
2975 )));
2976 message_lines.push(Line::from(vec![
2977 Span::styled(
2978 format!("[{}] ", chrono::Local::now().format("%H:%M")),
2979 Style::default()
2980 .fg(theme.timestamp_color.to_color())
2981 .add_modifier(Modifier::DIM),
2982 ),
2983 Span::styled("◆ ", theme.get_role_style("assistant")),
2984 Span::styled("assistant", theme.get_role_style("assistant")),
2985 Span::styled(
2986 " (streaming...)",
2987 Style::default()
2988 .fg(theme.timestamp_color.to_color())
2989 .add_modifier(Modifier::DIM),
2990 ),
2991 ]));
2992 let formatter = MessageFormatter::new(max_width);
2993 let formatted = formatter.format_content(streaming, "assistant");
2994 message_lines.extend(formatted);
2995 message_lines.push(Line::from(""));
2996 }
2997 }
2998
2999 if app.is_processing {
3000 let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3001 let spinner_idx = (std::time::SystemTime::now()
3002 .duration_since(std::time::UNIX_EPOCH)
3003 .unwrap_or_default()
3004 .as_millis()
3005 / 100) as usize
3006 % spinner.len();
3007
3008 let elapsed_str = if let Some(started) = app.processing_started_at {
3010 let elapsed = started.elapsed();
3011 if elapsed.as_secs() >= 60 {
3012 format!(" {}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
3013 } else {
3014 format!(" {:.1}s", elapsed.as_secs_f64())
3015 }
3016 } else {
3017 String::new()
3018 };
3019
3020 let processing_line = Line::from(vec![
3021 Span::styled(
3022 format!("[{}] ", chrono::Local::now().format("%H:%M")),
3023 Style::default()
3024 .fg(theme.timestamp_color.to_color())
3025 .add_modifier(Modifier::DIM),
3026 ),
3027 Span::styled("◆ ", theme.get_role_style("assistant")),
3028 Span::styled("assistant", theme.get_role_style("assistant")),
3029 Span::styled(
3030 elapsed_str,
3031 Style::default()
3032 .fg(theme.timestamp_color.to_color())
3033 .add_modifier(Modifier::DIM),
3034 ),
3035 ]);
3036 message_lines.push(processing_line);
3037
3038 let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
3039 (
3040 format!(" {} Running: {}", spinner[spinner_idx], tool),
3041 Color::Cyan,
3042 )
3043 } else {
3044 (
3045 format!(
3046 " {} {}",
3047 spinner[spinner_idx],
3048 app.processing_message.as_deref().unwrap_or("Thinking...")
3049 ),
3050 Color::Yellow,
3051 )
3052 };
3053
3054 let indicator_line = Line::from(vec![Span::styled(
3055 status_text,
3056 Style::default()
3057 .fg(status_color)
3058 .add_modifier(Modifier::BOLD),
3059 )]);
3060 message_lines.push(indicator_line);
3061 message_lines.push(Line::from(""));
3062 }
3063
3064 message_lines
3065}
3066
3067fn match_slash_command_hint(input: &str) -> String {
3068 let commands = [
3069 ("/swarm ", "Run task in parallel swarm mode"),
3070 ("/ralph", "Start autonomous PRD loop"),
3071 ("/sessions", "Open session picker"),
3072 ("/resume", "Resume a session"),
3073 ("/new", "Start a new session"),
3074 ("/model", "Select or set model"),
3075 ("/webview", "Switch to webview layout"),
3076 ("/classic", "Switch to classic layout"),
3077 ("/inspector", "Toggle inspector pane"),
3078 ("/refresh", "Refresh workspace"),
3079 ("/view", "Toggle swarm view"),
3080 ];
3081
3082 let input_lower = input.to_lowercase();
3083 let matches: Vec<_> = commands
3084 .iter()
3085 .filter(|(cmd, _)| cmd.starts_with(&input_lower))
3086 .collect();
3087
3088 if matches.len() == 1 {
3089 format!("{} — {}", matches[0].0.trim(), matches[0].1)
3090 } else if matches.is_empty() {
3091 "Unknown command".to_string()
3092 } else {
3093 let cmds: Vec<_> = matches.iter().map(|(cmd, _)| cmd.trim()).collect();
3094 cmds.join(" | ")
3095 }
3096}
3097
3098fn format_tool_call_arguments(name: &str, arguments: &str) -> String {
3099 let parsed = match serde_json::from_str::<serde_json::Value>(arguments) {
3100 Ok(value) => value,
3101 Err(_) => return arguments.to_string(),
3102 };
3103
3104 if name == "question"
3105 && let Some(question) = parsed.get("question").and_then(serde_json::Value::as_str)
3106 {
3107 return question.to_string();
3108 }
3109
3110 serde_json::to_string_pretty(&parsed).unwrap_or_else(|_| arguments.to_string())
3111}
3112
3113fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
3114 if max_chars == 0 {
3115 return String::new();
3116 }
3117
3118 let mut chars = value.chars();
3119 let mut output = String::new();
3120 for _ in 0..max_chars {
3121 if let Some(ch) = chars.next() {
3122 output.push(ch);
3123 } else {
3124 return value.to_string();
3125 }
3126 }
3127
3128 if chars.next().is_some() {
3129 format!("{output}...")
3130 } else {
3131 output
3132 }
3133}
3134
3135fn render_help_overlay_if_needed(f: &mut Frame, app: &App, theme: &Theme) {
3136 if !app.show_help {
3137 return;
3138 }
3139
3140 let area = centered_rect(60, 60, f.area());
3141 f.render_widget(Clear, area);
3142
3143 let token_display = TokenDisplay::new();
3144 let token_info = token_display.create_detailed_display();
3145
3146 let help_text: Vec<String> = vec![
3147 "".to_string(),
3148 " KEYBOARD SHORTCUTS".to_string(),
3149 " ==================".to_string(),
3150 "".to_string(),
3151 " Enter Send message".to_string(),
3152 " Tab Switch between build/plan agents".to_string(),
3153 " Ctrl+M Open model picker".to_string(),
3154 " Ctrl+S Toggle swarm view".to_string(),
3155 " Ctrl+B Toggle webview layout".to_string(),
3156 " F3 Toggle inspector pane".to_string(),
3157 " Ctrl+C Quit".to_string(),
3158 " ? Toggle this help".to_string(),
3159 "".to_string(),
3160 " SLASH COMMANDS (auto-complete hints shown while typing)".to_string(),
3161 " /swarm <task> Run task in parallel swarm mode".to_string(),
3162 " /ralph [path] Start Ralph PRD loop (default: prd.json)".to_string(),
3163 " /sessions Open session picker (filter, delete, load)".to_string(),
3164 " /resume Resume most recent session".to_string(),
3165 " /resume <id> Resume specific session by ID".to_string(),
3166 " /new Start a fresh session".to_string(),
3167 " /model Open model picker (or /model <name>)".to_string(),
3168 " /view Toggle swarm view".to_string(),
3169 " /webview Web dashboard layout".to_string(),
3170 " /classic Single-pane layout".to_string(),
3171 " /inspector Toggle inspector pane".to_string(),
3172 " /refresh Refresh workspace and sessions".to_string(),
3173 "".to_string(),
3174 " SESSION PICKER".to_string(),
3175 " ↑/↓/j/k Navigate sessions".to_string(),
3176 " Enter Load selected session".to_string(),
3177 " d Delete session (press twice to confirm)".to_string(),
3178 " Type Filter sessions by name/agent/ID".to_string(),
3179 " Backspace Clear filter character".to_string(),
3180 " Esc Close picker".to_string(),
3181 "".to_string(),
3182 " VIM-STYLE NAVIGATION".to_string(),
3183 " Alt+j Scroll down".to_string(),
3184 " Alt+k Scroll up".to_string(),
3185 " Ctrl+g Go to top".to_string(),
3186 " Ctrl+G Go to bottom".to_string(),
3187 "".to_string(),
3188 " SCROLLING".to_string(),
3189 " Up/Down Scroll messages".to_string(),
3190 " PageUp/Dn Scroll one page".to_string(),
3191 " Alt+u/d Scroll half page".to_string(),
3192 "".to_string(),
3193 " COMMAND HISTORY".to_string(),
3194 " Ctrl+R Search history".to_string(),
3195 " Ctrl+Up/Dn Navigate history".to_string(),
3196 "".to_string(),
3197 " Press ? or Esc to close".to_string(),
3198 "".to_string(),
3199 ];
3200
3201 let mut combined_text = token_info;
3202 combined_text.extend(help_text);
3203
3204 let help = Paragraph::new(combined_text.join("\n"))
3205 .block(
3206 Block::default()
3207 .borders(Borders::ALL)
3208 .title(" Help ")
3209 .border_style(Style::default().fg(theme.help_border_color.to_color())),
3210 )
3211 .wrap(Wrap { trim: false });
3212
3213 f.render_widget(help, area);
3214}
3215
3216fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
3218 let popup_layout = Layout::default()
3219 .direction(Direction::Vertical)
3220 .constraints([
3221 Constraint::Percentage((100 - percent_y) / 2),
3222 Constraint::Percentage(percent_y),
3223 Constraint::Percentage((100 - percent_y) / 2),
3224 ])
3225 .split(r);
3226
3227 Layout::default()
3228 .direction(Direction::Horizontal)
3229 .constraints([
3230 Constraint::Percentage((100 - percent_x) / 2),
3231 Constraint::Percentage(percent_x),
3232 Constraint::Percentage((100 - percent_x) / 2),
3233 ])
3234 .split(popup_layout[1])[1]
3235}