1pub mod bus_log;
6pub mod message_formatter;
7pub mod ralph_view;
8pub mod swarm_view;
9pub mod theme;
10pub mod theme_utils;
11pub mod token_display;
12
13const SCROLL_BOTTOM: usize = 1_000_000;
15
16const TOOL_ARGS_PRETTY_JSON_MAX_BYTES: usize = 16_000;
20const TOOL_ARGS_PREVIEW_MAX_LINES: usize = 10;
21const TOOL_ARGS_PREVIEW_MAX_BYTES: usize = 6_000;
22const TOOL_OUTPUT_PREVIEW_MAX_LINES: usize = 5;
23const TOOL_OUTPUT_PREVIEW_MAX_BYTES: usize = 4_000;
24
25use crate::config::Config;
26use crate::provider::{ContentPart, Role};
27use crate::ralph::{RalphConfig, RalphLoop};
28use crate::session::{Session, SessionEvent, SessionSummary, list_sessions_with_opencode};
29use crate::swarm::{DecompositionStrategy, SwarmConfig, SwarmExecutor};
30use crate::tui::bus_log::{BusLogState, render_bus_log};
31use crate::tui::message_formatter::MessageFormatter;
32use crate::tui::ralph_view::{RalphEvent, RalphViewState, render_ralph_view};
33use crate::tui::swarm_view::{SwarmEvent, SwarmViewState, render_swarm_view};
34use crate::tui::theme::Theme;
35use crate::tui::token_display::TokenDisplay;
36use anyhow::Result;
37use base64::Engine;
38use crossterm::{
39 event::{
40 DisableBracketedPaste, EnableBracketedPaste, Event, EventStream, KeyCode, KeyModifiers,
41 },
42 execute,
43 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
44};
45use futures::StreamExt;
46use ratatui::{
47 Frame, Terminal,
48 backend::CrosstermBackend,
49 layout::{Constraint, Direction, Layout, Rect},
50 style::{Color, Modifier, Style},
51 text::{Line, Span},
52 widgets::{
53 Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
54 },
55};
56use std::collections::HashMap;
57use std::io;
58use std::path::{Path, PathBuf};
59use std::process::Command;
60use std::time::{Duration, Instant};
61use tokio::sync::mpsc;
62
63pub async fn run(project: Option<PathBuf>) -> Result<()> {
65 if let Some(dir) = project {
67 std::env::set_current_dir(&dir)?;
68 }
69
70 enable_raw_mode()?;
72 let mut stdout = io::stdout();
73 execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
74 let backend = CrosstermBackend::new(stdout);
75 let mut terminal = Terminal::new(backend)?;
76
77 let result = run_app(&mut terminal).await;
79
80 disable_raw_mode()?;
82 execute!(
83 terminal.backend_mut(),
84 LeaveAlternateScreen,
85 DisableBracketedPaste
86 )?;
87 terminal.show_cursor()?;
88
89 result
90}
91
92#[derive(Debug, Clone)]
94enum MessageType {
95 Text(String),
96 Image {
97 url: String,
98 mime_type: Option<String>,
99 },
100 ToolCall {
101 name: String,
102 arguments_preview: String,
103 arguments_len: usize,
104 truncated: bool,
105 },
106 ToolResult {
107 name: String,
108 output_preview: String,
109 output_len: usize,
110 truncated: bool,
111 },
112 File {
113 path: String,
114 mime_type: Option<String>,
115 },
116 Thinking(String),
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121enum ViewMode {
122 Chat,
123 Swarm,
124 Ralph,
125 BusLog,
126 SessionPicker,
127 ModelPicker,
128 AgentPicker,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132enum ChatLayoutMode {
133 Classic,
134 Webview,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138enum WorkspaceEntryKind {
139 Directory,
140 File,
141}
142
143#[derive(Debug, Clone)]
144struct WorkspaceEntry {
145 name: String,
146 kind: WorkspaceEntryKind,
147}
148
149#[derive(Debug, Clone, Default)]
150struct WorkspaceSnapshot {
151 root_display: String,
152 git_branch: Option<String>,
153 git_dirty_files: usize,
154 entries: Vec<WorkspaceEntry>,
155 captured_at: String,
156}
157
158struct App {
160 input: String,
161 cursor_position: usize,
162 messages: Vec<ChatMessage>,
163 current_agent: String,
164 scroll: usize,
165 show_help: bool,
166 command_history: Vec<String>,
167 history_index: Option<usize>,
168 session: Option<Session>,
169 is_processing: bool,
170 processing_message: Option<String>,
171 current_tool: Option<String>,
172 processing_started_at: Option<Instant>,
174 streaming_text: Option<String>,
176 tool_call_count: usize,
178 response_rx: Option<mpsc::Receiver<SessionEvent>>,
179 provider_registry: Option<std::sync::Arc<crate::provider::ProviderRegistry>>,
181 workspace_dir: PathBuf,
183 view_mode: ViewMode,
185 chat_layout: ChatLayoutMode,
186 show_inspector: bool,
187 workspace: WorkspaceSnapshot,
188 swarm_state: SwarmViewState,
189 swarm_rx: Option<mpsc::Receiver<SwarmEvent>>,
190 ralph_state: RalphViewState,
192 ralph_rx: Option<mpsc::Receiver<RalphEvent>>,
193 bus_log_state: BusLogState,
195 bus_log_rx: Option<mpsc::Receiver<crate::bus::BusEnvelope>>,
196 bus: Option<std::sync::Arc<crate::bus::AgentBus>>,
197 session_picker_list: Vec<SessionSummary>,
199 session_picker_selected: usize,
200 session_picker_filter: String,
201 session_picker_confirm_delete: bool,
202 model_picker_list: Vec<(String, String, String)>, model_picker_selected: usize,
205 model_picker_filter: String,
206 agent_picker_selected: usize,
208 agent_picker_filter: String,
209 active_model: Option<String>,
210 active_spawned_agent: Option<String>,
212 spawned_agents: HashMap<String, SpawnedAgent>,
213 agent_response_rxs: Vec<(String, mpsc::Receiver<SessionEvent>)>,
214 last_max_scroll: usize,
216}
217
218#[allow(dead_code)]
219struct ChatMessage {
220 role: String,
221 content: String,
222 timestamp: String,
223 message_type: MessageType,
224 usage_meta: Option<UsageMeta>,
226 agent_name: Option<String>,
228}
229
230#[allow(dead_code)]
232struct SpawnedAgent {
233 name: String,
235 instructions: String,
237 session: Session,
239 is_processing: bool,
241}
242
243#[derive(Debug, Clone)]
245struct UsageMeta {
246 prompt_tokens: usize,
247 completion_tokens: usize,
248 duration_ms: u64,
249 cost_usd: Option<f64>,
250}
251
252fn estimate_cost(model: &str, prompt_tokens: usize, completion_tokens: usize) -> Option<f64> {
255 let (input_rate, output_rate) = match model {
257 m if m.contains("claude-opus") => (15.0, 75.0),
259 m if m.contains("claude-sonnet") => (3.0, 15.0),
260 m if m.contains("claude-haiku") => (0.25, 1.25),
261 m if m.contains("gpt-4o-mini") => (0.15, 0.6),
263 m if m.contains("gpt-4o") => (2.5, 10.0),
264 m if m.contains("o3") => (10.0, 40.0),
265 m if m.contains("o4-mini") => (1.10, 4.40),
266 m if m.contains("gemini-2.5-pro") => (1.25, 10.0),
268 m if m.contains("gemini-2.5-flash") => (0.15, 0.6),
269 m if m.contains("gemini-2.0-flash") => (0.10, 0.40),
270 m if m.contains("kimi-k2") => (0.35, 1.40),
272 m if m.contains("deepseek") => (0.80, 2.0),
273 m if m.contains("llama") => (0.50, 1.50),
274 m if m.contains("nova-pro") => (0.80, 3.20),
276 m if m.contains("nova-lite") => (0.06, 0.24),
277 m if m.contains("nova-micro") => (0.035, 0.14),
278 m if m.contains("glm-5") => (2.0, 8.0),
280 m if m.contains("glm-4.7-flash") => (0.0, 0.0),
281 m if m.contains("glm-4.7") => (0.50, 2.0),
282 m if m.contains("glm-4") => (0.35, 1.40),
283 _ => return None,
284 };
285 let cost =
286 (prompt_tokens as f64 * input_rate + completion_tokens as f64 * output_rate) / 1_000_000.0;
287 Some(cost)
288}
289
290impl ChatMessage {
291 fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
292 let content = content.into();
293 Self {
294 role: role.into(),
295 timestamp: chrono::Local::now().format("%H:%M").to_string(),
296 message_type: MessageType::Text(content.clone()),
297 content,
298 usage_meta: None,
299 agent_name: None,
300 }
301 }
302
303 fn with_message_type(mut self, message_type: MessageType) -> Self {
304 self.message_type = message_type;
305 self
306 }
307
308 fn with_usage_meta(mut self, meta: UsageMeta) -> Self {
309 self.usage_meta = Some(meta);
310 self
311 }
312
313 fn with_agent_name(mut self, name: impl Into<String>) -> Self {
314 self.agent_name = Some(name.into());
315 self
316 }
317}
318
319impl WorkspaceSnapshot {
320 fn capture(root: &Path, max_entries: usize) -> Self {
321 let mut entries: Vec<WorkspaceEntry> = Vec::new();
322
323 if let Ok(read_dir) = std::fs::read_dir(root) {
324 for entry in read_dir.flatten() {
325 let file_name = entry.file_name().to_string_lossy().to_string();
326 if should_skip_workspace_entry(&file_name) {
327 continue;
328 }
329
330 let kind = match entry.file_type() {
331 Ok(ft) if ft.is_dir() => WorkspaceEntryKind::Directory,
332 _ => WorkspaceEntryKind::File,
333 };
334
335 entries.push(WorkspaceEntry {
336 name: file_name,
337 kind,
338 });
339 }
340 }
341
342 entries.sort_by(|a, b| match (a.kind, b.kind) {
343 (WorkspaceEntryKind::Directory, WorkspaceEntryKind::File) => std::cmp::Ordering::Less,
344 (WorkspaceEntryKind::File, WorkspaceEntryKind::Directory) => {
345 std::cmp::Ordering::Greater
346 }
347 _ => a
348 .name
349 .to_ascii_lowercase()
350 .cmp(&b.name.to_ascii_lowercase()),
351 });
352 entries.truncate(max_entries);
353
354 Self {
355 root_display: root.to_string_lossy().to_string(),
356 git_branch: detect_git_branch(root),
357 git_dirty_files: detect_git_dirty_files(root),
358 entries,
359 captured_at: chrono::Local::now().format("%H:%M:%S").to_string(),
360 }
361 }
362}
363
364fn should_skip_workspace_entry(name: &str) -> bool {
365 matches!(
366 name,
367 ".git" | "node_modules" | "target" | ".next" | "__pycache__" | ".venv"
368 )
369}
370
371fn detect_git_branch(root: &Path) -> Option<String> {
372 let output = Command::new("git")
373 .arg("-C")
374 .arg(root)
375 .args(["rev-parse", "--abbrev-ref", "HEAD"])
376 .output()
377 .ok()?;
378
379 if !output.status.success() {
380 return None;
381 }
382
383 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
384 if branch.is_empty() {
385 None
386 } else {
387 Some(branch)
388 }
389}
390
391fn detect_git_dirty_files(root: &Path) -> usize {
392 let output = match Command::new("git")
393 .arg("-C")
394 .arg(root)
395 .args(["status", "--porcelain"])
396 .output()
397 {
398 Ok(out) => out,
399 Err(_) => return 0,
400 };
401
402 if !output.status.success() {
403 return 0;
404 }
405
406 String::from_utf8_lossy(&output.stdout)
407 .lines()
408 .filter(|line| !line.trim().is_empty())
409 .count()
410}
411
412impl App {
413 fn new() -> Self {
414 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
415
416 Self {
417 input: String::new(),
418 cursor_position: 0,
419 messages: vec![
420 ChatMessage::new("system", "Welcome to CodeTether Agent! Press ? for help."),
421 ChatMessage::new(
422 "assistant",
423 "Quick start:\n• Type a message to chat with the AI\n• Ctrl+Y - copy latest assistant reply\n• /model - pick a model (or Ctrl+M)\n• /spawn <name> <instructions> - create a sub-agent\n• /agent <name> - focus chat on a spawned sub-agent\n• /agent <name> <message> - send one message to a spawned sub-agent\n• /swarm <task> - parallel execution\n• /ralph [prd.json] - autonomous PRD loop\n• /buslog - protocol bus log (or Ctrl+L)\n• /sessions - pick a session to resume\n• /resume - continue last session\n• Tab - switch agents | ? - help",
424 ),
425 ],
426 current_agent: "build".to_string(),
427 scroll: 0,
428 show_help: false,
429 command_history: Vec::new(),
430 history_index: None,
431 session: None,
432 is_processing: false,
433 processing_message: None,
434 current_tool: None,
435 processing_started_at: None,
436 streaming_text: None,
437 tool_call_count: 0,
438 response_rx: None,
439 provider_registry: None,
440 workspace_dir: workspace_root.clone(),
441 view_mode: ViewMode::Chat,
442 chat_layout: ChatLayoutMode::Webview,
443 show_inspector: true,
444 workspace: WorkspaceSnapshot::capture(&workspace_root, 18),
445 swarm_state: SwarmViewState::new(),
446 swarm_rx: None,
447 ralph_state: RalphViewState::new(),
448 ralph_rx: None,
449 bus_log_state: BusLogState::new(),
450 bus_log_rx: None,
451 bus: None,
452 session_picker_list: Vec::new(),
453 session_picker_selected: 0,
454 session_picker_filter: String::new(),
455 session_picker_confirm_delete: false,
456 model_picker_list: Vec::new(),
457 model_picker_selected: 0,
458 model_picker_filter: String::new(),
459 agent_picker_selected: 0,
460 agent_picker_filter: String::new(),
461 active_model: None,
462 active_spawned_agent: None,
463 spawned_agents: HashMap::new(),
464 agent_response_rxs: Vec::new(),
465 last_max_scroll: 0,
466 }
467 }
468
469 fn refresh_workspace(&mut self) {
470 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
471 self.workspace = WorkspaceSnapshot::capture(&workspace_root, 18);
472 }
473
474 fn update_cached_sessions(&mut self, sessions: Vec<SessionSummary>) {
475 self.session_picker_list = sessions.into_iter().take(16).collect();
476 if self.session_picker_selected >= self.session_picker_list.len() {
477 self.session_picker_selected = self.session_picker_list.len().saturating_sub(1);
478 }
479 }
480
481 async fn submit_message(&mut self, config: &Config) {
482 if self.input.is_empty() {
483 return;
484 }
485
486 let mut message = std::mem::take(&mut self.input);
487 self.cursor_position = 0;
488
489 if !message.trim().is_empty() {
491 self.command_history.push(message.clone());
492 self.history_index = None;
493 }
494
495 if message.trim().starts_with("/agent") {
497 let rest = message
498 .trim()
499 .strip_prefix("/agent")
500 .unwrap_or("")
501 .trim();
502
503 if rest.is_empty() {
504 self.open_agent_picker();
505 return;
506 }
507
508 if rest == "pick" || rest == "picker" || rest == "select" {
509 self.open_agent_picker();
510 return;
511 }
512
513 if rest == "main" || rest == "off" {
514 if let Some(target) = self.active_spawned_agent.take() {
515 self.messages.push(ChatMessage::new(
516 "system",
517 format!("Exited focused sub-agent chat (@{target})."),
518 ));
519 } else {
520 self.messages.push(ChatMessage::new(
521 "system",
522 "Already in main chat mode.",
523 ));
524 }
525 return;
526 }
527
528 if rest == "build" || rest == "plan" {
529 self.current_agent = rest.to_string();
530 self.active_spawned_agent = None;
531 self.messages.push(ChatMessage::new(
532 "system",
533 format!("Switched main agent to '{rest}'. (Tab also works.)"),
534 ));
535 return;
536 }
537
538 if rest == "list" || rest == "ls" {
539 message = "/agents".to_string();
540 } else if let Some(args) = rest
541 .strip_prefix("spawn ")
542 .map(str::trim)
543 .filter(|s| !s.is_empty())
544 {
545 message = format!("/spawn {args}");
546 } else if let Some(name) = rest
547 .strip_prefix("kill ")
548 .map(str::trim)
549 .filter(|s| !s.is_empty())
550 {
551 message = format!("/kill {name}");
552 } else if !rest.contains(' ') {
553 let target = rest.trim_start_matches('@');
554 if self.spawned_agents.contains_key(target) {
555 self.active_spawned_agent = Some(target.to_string());
556 self.messages.push(ChatMessage::new(
557 "system",
558 format!(
559 "Focused chat on @{target}. Type messages directly; use /agent main to exit focus."
560 ),
561 ));
562 } else {
563 self.messages.push(ChatMessage::new(
564 "system",
565 format!(
566 "No agent named @{target}. Use /agents to list, or /spawn <name> <instructions> to create one."
567 ),
568 ));
569 }
570 return;
571 } else if let Some((name, content)) = rest.split_once(' ') {
572 let target = name.trim().trim_start_matches('@');
573 let content = content.trim();
574 if target.is_empty() || content.is_empty() {
575 self.messages.push(ChatMessage::new(
576 "system",
577 "Usage: /agent <name> <message>",
578 ));
579 return;
580 }
581 message = format!("@{target} {content}");
582 } else {
583 self.messages.push(ChatMessage::new(
584 "system",
585 "Unknown /agent usage. Try /agent, /agent <name>, /agent <name> <message>, or /agent list.",
586 ));
587 return;
588 }
589 }
590
591 if message.trim().starts_with("/swarm ") {
593 let task = message
594 .trim()
595 .strip_prefix("/swarm ")
596 .unwrap_or("")
597 .to_string();
598 if task.is_empty() {
599 self.messages.push(ChatMessage::new(
600 "system",
601 "Usage: /swarm <task description>",
602 ));
603 return;
604 }
605 self.start_swarm_execution(task, config).await;
606 return;
607 }
608
609 if message.trim().starts_with("/ralph") {
611 let prd_path = message
612 .trim()
613 .strip_prefix("/ralph")
614 .map(|s| s.trim())
615 .filter(|s| !s.is_empty())
616 .unwrap_or("prd.json")
617 .to_string();
618 self.start_ralph_execution(prd_path, config).await;
619 return;
620 }
621
622 if message.trim() == "/webview" {
623 self.chat_layout = ChatLayoutMode::Webview;
624 self.messages.push(ChatMessage::new(
625 "system",
626 "Switched to webview layout. Use /classic to return to single-pane chat.",
627 ));
628 return;
629 }
630
631 if message.trim() == "/classic" {
632 self.chat_layout = ChatLayoutMode::Classic;
633 self.messages.push(ChatMessage::new(
634 "system",
635 "Switched to classic layout. Use /webview for dashboard-style panes.",
636 ));
637 return;
638 }
639
640 if message.trim() == "/inspector" {
641 self.show_inspector = !self.show_inspector;
642 let state = if self.show_inspector {
643 "enabled"
644 } else {
645 "disabled"
646 };
647 self.messages.push(ChatMessage::new(
648 "system",
649 format!("Inspector pane {}. Press F3 to toggle quickly.", state),
650 ));
651 return;
652 }
653
654 if message.trim() == "/refresh" {
655 self.refresh_workspace();
656 match list_sessions_with_opencode(&self.workspace_dir).await {
657 Ok(sessions) => self.update_cached_sessions(sessions),
658 Err(err) => self.messages.push(ChatMessage::new(
659 "system",
660 format!(
661 "Workspace refreshed, but failed to refresh sessions: {}",
662 err
663 ),
664 )),
665 }
666 self.messages.push(ChatMessage::new(
667 "system",
668 "Workspace and session cache refreshed.",
669 ));
670 return;
671 }
672
673 if message.trim() == "/view" || message.trim() == "/swarm" {
675 self.view_mode = match self.view_mode {
676 ViewMode::Chat
677 | ViewMode::SessionPicker
678 | ViewMode::ModelPicker
679 | ViewMode::AgentPicker
680 | ViewMode::BusLog => ViewMode::Swarm,
681 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
682 };
683 return;
684 }
685
686 if message.trim() == "/buslog" || message.trim() == "/bus" {
688 self.view_mode = ViewMode::BusLog;
689 return;
690 }
691
692 if message.trim().starts_with("/spawn ") {
694 let rest = message.trim().strip_prefix("/spawn ").unwrap_or("").trim();
695 let (name, instructions) = match rest.split_once(' ') {
696 Some((n, i)) => (n.to_string(), i.to_string()),
697 None => {
698 self.messages.push(ChatMessage::new(
699 "system",
700 "Usage: /spawn <name> <instructions>\nExample: /spawn planner You are a planning agent. Break tasks into steps.",
701 ));
702 return;
703 }
704 };
705
706 if self.spawned_agents.contains_key(&name) {
707 self.messages.push(ChatMessage::new(
708 "system",
709 format!("Agent @{name} already exists. Use /kill {name} first."),
710 ));
711 return;
712 }
713
714 match Session::new().await {
715 Ok(mut session) => {
716 session.metadata.model = self
718 .active_model
719 .clone()
720 .or_else(|| config.default_model.clone());
721 session.agent = name.clone();
722
723 session.add_message(crate::provider::Message {
725 role: Role::System,
726 content: vec![ContentPart::Text {
727 text: format!(
728 "You are @{name}, a specialized sub-agent. {instructions}\n\n\
729 When you receive a message from another agent (prefixed with their name), \
730 respond helpfully. Keep responses concise and focused on your specialty."
731 ),
732 }],
733 });
734
735 if let Some(ref bus) = self.bus {
737 let handle = bus.handle(&name);
738 handle.announce_ready(vec![name.clone()]);
739 }
740
741 let agent = SpawnedAgent {
742 name: name.clone(),
743 instructions: instructions.clone(),
744 session,
745 is_processing: false,
746 };
747 self.spawned_agents.insert(name.clone(), agent);
748 self.active_spawned_agent = Some(name.clone());
749 self.messages.push(ChatMessage::new(
750 "system",
751 format!("Spawned agent @{name}: {instructions}\nFocused chat on @{name}. Type directly, or use @{name} <message>."),
752 ));
753 }
754 Err(e) => {
755 self.messages.push(ChatMessage::new(
756 "system",
757 format!("Failed to spawn agent: {e}"),
758 ));
759 }
760 }
761 return;
762 }
763
764 if message.trim() == "/agents" {
766 if self.spawned_agents.is_empty() {
767 self.messages.push(ChatMessage::new(
768 "system",
769 "No agents spawned. Use /spawn <name> <instructions> to create one.",
770 ));
771 } else {
772 let mut lines = vec!["Active agents:".to_string()];
773 for (name, agent) in &self.spawned_agents {
774 let status = if agent.is_processing {
775 "⚡ working"
776 } else {
777 "● idle"
778 };
779 let focused = if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
780 " [focused]"
781 } else {
782 ""
783 };
784 lines.push(format!(
785 " @{name} [{status}]{focused} — {}",
786 agent.instructions
787 ));
788 }
789 self.messages
790 .push(ChatMessage::new("system", lines.join("\n")));
791 self.messages.push(ChatMessage::new(
792 "system",
793 "Tip: use /agent to open the picker, /agent <name> to focus, or Ctrl+A.",
794 ));
795 }
796 return;
797 }
798
799 if message.trim().starts_with("/kill ") {
801 let name = message
802 .trim()
803 .strip_prefix("/kill ")
804 .unwrap_or("")
805 .trim()
806 .to_string();
807 if self.spawned_agents.remove(&name).is_some() {
808 self.agent_response_rxs.retain(|(n, _)| n != &name);
810 if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
811 self.active_spawned_agent = None;
812 }
813 if let Some(ref bus) = self.bus {
815 let handle = bus.handle(&name);
816 handle.send(
817 "broadcast",
818 crate::bus::BusMessage::AgentShutdown {
819 agent_id: name.clone(),
820 },
821 );
822 }
823 self.messages.push(ChatMessage::new(
824 "system",
825 format!("Agent @{name} removed."),
826 ));
827 } else {
828 self.messages.push(ChatMessage::new(
829 "system",
830 format!("No agent named @{name}. Use /agents to list."),
831 ));
832 }
833 return;
834 }
835
836 if message.trim().starts_with('@') {
838 let trimmed = message.trim();
839 let (target, content) = match trimmed.split_once(' ') {
840 Some((mention, rest)) => (
841 mention.strip_prefix('@').unwrap_or(mention).to_string(),
842 rest.to_string(),
843 ),
844 None => {
845 self.messages.push(ChatMessage::new(
846 "system",
847 format!(
848 "Usage: @agent_name your message\nAvailable: {}",
849 if self.spawned_agents.is_empty() {
850 "none (use /spawn first)".to_string()
851 } else {
852 self.spawned_agents
853 .keys()
854 .map(|n| format!("@{n}"))
855 .collect::<Vec<_>>()
856 .join(", ")
857 }
858 ),
859 ));
860 return;
861 }
862 };
863
864 if !self.spawned_agents.contains_key(&target) {
865 self.messages.push(ChatMessage::new(
866 "system",
867 format!(
868 "No agent named @{target}. Available: {}",
869 if self.spawned_agents.is_empty() {
870 "none (use /spawn first)".to_string()
871 } else {
872 self.spawned_agents
873 .keys()
874 .map(|n| format!("@{n}"))
875 .collect::<Vec<_>>()
876 .join(", ")
877 }
878 ),
879 ));
880 return;
881 }
882
883 self.messages
885 .push(ChatMessage::new("user", format!("@{target} {content}")));
886 self.scroll = SCROLL_BOTTOM;
887
888 if let Some(ref bus) = self.bus {
890 let handle = bus.handle("user");
891 handle.send_to_agent(
892 &target,
893 vec![crate::a2a::types::Part::Text {
894 text: content.clone(),
895 }],
896 );
897 }
898
899 self.send_to_agent(&target, &content, config).await;
901 return;
902 }
903
904 if !message.trim().starts_with('/')
906 && let Some(target) = self.active_spawned_agent.clone()
907 {
908 if !self.spawned_agents.contains_key(&target) {
909 self.active_spawned_agent = None;
910 self.messages.push(ChatMessage::new(
911 "system",
912 format!(
913 "Focused agent @{target} is no longer available. Use /agents or /spawn to continue."
914 ),
915 ));
916 return;
917 }
918
919 let content = message.trim().to_string();
920 if content.is_empty() {
921 return;
922 }
923
924 self.messages
925 .push(ChatMessage::new("user", format!("@{target} {content}")));
926 self.scroll = SCROLL_BOTTOM;
927
928 if let Some(ref bus) = self.bus {
929 let handle = bus.handle("user");
930 handle.send_to_agent(
931 &target,
932 vec![crate::a2a::types::Part::Text {
933 text: content.clone(),
934 }],
935 );
936 }
937
938 self.send_to_agent(&target, &content, config).await;
939 return;
940 }
941
942 if message.trim() == "/sessions" {
944 match list_sessions_with_opencode(&self.workspace_dir).await {
945 Ok(sessions) => {
946 if sessions.is_empty() {
947 self.messages
948 .push(ChatMessage::new("system", "No saved sessions found."));
949 } else {
950 self.update_cached_sessions(sessions);
951 self.session_picker_selected = 0;
952 self.view_mode = ViewMode::SessionPicker;
953 }
954 }
955 Err(e) => {
956 self.messages.push(ChatMessage::new(
957 "system",
958 format!("Failed to list sessions: {}", e),
959 ));
960 }
961 }
962 return;
963 }
964
965 if message.trim() == "/resume" || message.trim().starts_with("/resume ") {
967 let session_id = message
968 .trim()
969 .strip_prefix("/resume")
970 .map(|s| s.trim())
971 .filter(|s| !s.is_empty());
972 let loaded = if let Some(id) = session_id {
973 if let Some(oc_id) = id.strip_prefix("opencode_") {
974 if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
975 Session::from_opencode(oc_id, &storage).await
976 } else {
977 Err(anyhow::anyhow!("OpenCode storage not available"))
978 }
979 } else {
980 Session::load(id).await
981 }
982 } else {
983 match Session::last_for_directory(Some(&self.workspace_dir)).await {
984 Ok(s) => Ok(s),
985 Err(_) => Session::last_opencode_for_directory(&self.workspace_dir).await,
986 }
987 };
988
989 match loaded {
990 Ok(session) => {
991 self.messages.clear();
993 self.messages.push(ChatMessage::new(
994 "system",
995 format!(
996 "Resumed session: {}\nCreated: {}\n{} messages loaded",
997 session.title.as_deref().unwrap_or("(untitled)"),
998 session.created_at.format("%Y-%m-%d %H:%M"),
999 session.messages.len()
1000 ),
1001 ));
1002
1003 for msg in &session.messages {
1004 let role_str = match msg.role {
1005 Role::System => "system",
1006 Role::User => "user",
1007 Role::Assistant => "assistant",
1008 Role::Tool => "tool",
1009 };
1010
1011 for part in &msg.content {
1013 match part {
1014 ContentPart::Text { text } => {
1015 if !text.is_empty() {
1016 self.messages
1017 .push(ChatMessage::new(role_str, text.clone()));
1018 }
1019 }
1020 ContentPart::Image { url, mime_type } => {
1021 self.messages.push(
1022 ChatMessage::new(role_str, "").with_message_type(
1023 MessageType::Image {
1024 url: url.clone(),
1025 mime_type: mime_type.clone(),
1026 },
1027 ),
1028 );
1029 }
1030 ContentPart::ToolCall {
1031 name, arguments, ..
1032 } => {
1033 let (preview, truncated) = build_tool_arguments_preview(
1034 name,
1035 arguments,
1036 TOOL_ARGS_PREVIEW_MAX_LINES,
1037 TOOL_ARGS_PREVIEW_MAX_BYTES,
1038 );
1039 self.messages.push(
1040 ChatMessage::new(role_str, format!("🔧 {name}"))
1041 .with_message_type(MessageType::ToolCall {
1042 name: name.clone(),
1043 arguments_preview: preview,
1044 arguments_len: arguments.len(),
1045 truncated,
1046 }),
1047 );
1048 }
1049 ContentPart::ToolResult { content, .. } => {
1050 let truncated = truncate_with_ellipsis(content, 500);
1051 let (preview, preview_truncated) = build_text_preview(
1052 content,
1053 TOOL_OUTPUT_PREVIEW_MAX_LINES,
1054 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
1055 );
1056 self.messages.push(
1057 ChatMessage::new(
1058 role_str,
1059 format!("✅ Result\n{truncated}"),
1060 )
1061 .with_message_type(MessageType::ToolResult {
1062 name: "tool".to_string(),
1063 output_preview: preview,
1064 output_len: content.len(),
1065 truncated: preview_truncated,
1066 }),
1067 );
1068 }
1069 ContentPart::File { path, mime_type } => {
1070 self.messages.push(
1071 ChatMessage::new(role_str, format!("📎 {}", path))
1072 .with_message_type(MessageType::File {
1073 path: path.clone(),
1074 mime_type: mime_type.clone(),
1075 }),
1076 );
1077 }
1078 ContentPart::Thinking { text } => {
1079 if !text.is_empty() {
1080 self.messages.push(
1081 ChatMessage::new(role_str, text.clone())
1082 .with_message_type(MessageType::Thinking(
1083 text.clone(),
1084 )),
1085 );
1086 }
1087 }
1088 }
1089 }
1090 }
1091
1092 self.current_agent = session.agent.clone();
1093 self.session = Some(session);
1094 self.scroll = SCROLL_BOTTOM;
1095 }
1096 Err(e) => {
1097 self.messages.push(ChatMessage::new(
1098 "system",
1099 format!("Failed to load session: {}", e),
1100 ));
1101 }
1102 }
1103 return;
1104 }
1105
1106 if message.trim() == "/model" || message.trim().starts_with("/model ") {
1108 let direct_model = message
1109 .trim()
1110 .strip_prefix("/model")
1111 .map(|s| s.trim())
1112 .filter(|s| !s.is_empty());
1113
1114 if let Some(model_str) = direct_model {
1115 self.active_model = Some(model_str.to_string());
1117 if let Some(session) = self.session.as_mut() {
1118 session.metadata.model = Some(model_str.to_string());
1119 }
1120 self.messages.push(ChatMessage::new(
1121 "system",
1122 format!("Model set to: {}", model_str),
1123 ));
1124 } else {
1125 self.open_model_picker(config).await;
1127 }
1128 return;
1129 }
1130
1131 if message.trim() == "/undo" {
1133 let mut found_user = false;
1136 while let Some(msg) = self.messages.last() {
1137 if msg.role == "user" {
1138 if found_user {
1139 break; }
1141 found_user = true;
1142 }
1143 if msg.role == "system" && !found_user {
1145 break;
1146 }
1147 self.messages.pop();
1148 }
1149
1150 if !found_user {
1151 self.messages
1152 .push(ChatMessage::new("system", "Nothing to undo."));
1153 return;
1154 }
1155
1156 if let Some(session) = self.session.as_mut() {
1159 let mut found_session_user = false;
1160 while let Some(msg) = session.messages.last() {
1161 if msg.role == crate::provider::Role::User {
1162 if found_session_user {
1163 break;
1164 }
1165 found_session_user = true;
1166 }
1167 if msg.role == crate::provider::Role::System && !found_session_user {
1168 break;
1169 }
1170 session.messages.pop();
1171 }
1172 if let Err(e) = session.save().await {
1173 tracing::warn!(error = %e, "Failed to save session after undo");
1174 }
1175 }
1176
1177 self.messages.push(ChatMessage::new(
1178 "system",
1179 "Undid last message and response.",
1180 ));
1181 self.scroll = SCROLL_BOTTOM;
1182 return;
1183 }
1184
1185 if message.trim() == "/new" {
1187 self.session = None;
1188 self.messages.clear();
1189 self.messages.push(ChatMessage::new(
1190 "system",
1191 "Started a new session. Previous session was saved.",
1192 ));
1193 return;
1194 }
1195
1196 self.messages
1198 .push(ChatMessage::new("user", message.clone()));
1199
1200 self.scroll = SCROLL_BOTTOM;
1202
1203 let current_agent = self.current_agent.clone();
1204 let model = self
1205 .active_model
1206 .clone()
1207 .or_else(|| {
1208 config
1209 .agents
1210 .get(¤t_agent)
1211 .and_then(|agent| agent.model.clone())
1212 })
1213 .or_else(|| config.default_model.clone())
1214 .or_else(|| Some("zai/glm-5".to_string()));
1215
1216 if self.session.is_none() {
1218 match Session::new().await {
1219 Ok(session) => {
1220 self.session = Some(session);
1221 }
1222 Err(err) => {
1223 tracing::error!(error = %err, "Failed to create session");
1224 self.messages
1225 .push(ChatMessage::new("assistant", format!("Error: {err}")));
1226 return;
1227 }
1228 }
1229 }
1230
1231 let session = match self.session.as_mut() {
1232 Some(session) => session,
1233 None => {
1234 self.messages.push(ChatMessage::new(
1235 "assistant",
1236 "Error: session not initialized",
1237 ));
1238 return;
1239 }
1240 };
1241
1242 if let Some(model) = model {
1243 session.metadata.model = Some(model);
1244 }
1245
1246 session.agent = current_agent;
1247
1248 self.is_processing = true;
1250 self.processing_message = Some("Thinking...".to_string());
1251 self.current_tool = None;
1252 self.processing_started_at = Some(Instant::now());
1253 self.streaming_text = None;
1254
1255 if self.provider_registry.is_none() {
1257 match crate::provider::ProviderRegistry::from_vault().await {
1258 Ok(registry) => {
1259 self.provider_registry = Some(std::sync::Arc::new(registry));
1260 }
1261 Err(err) => {
1262 tracing::error!(error = %err, "Failed to load provider registry");
1263 self.messages.push(ChatMessage::new(
1264 "assistant",
1265 format!("Error loading providers: {err}"),
1266 ));
1267 self.is_processing = false;
1268 return;
1269 }
1270 }
1271 }
1272 let registry = self.provider_registry.clone().unwrap();
1273
1274 let (tx, rx) = mpsc::channel(100);
1276 self.response_rx = Some(rx);
1277
1278 let session_clone = session.clone();
1280 let message_clone = message.clone();
1281
1282 tokio::spawn(async move {
1284 let mut session = session_clone;
1285 if let Err(err) = session
1286 .prompt_with_events(&message_clone, tx.clone(), registry)
1287 .await
1288 {
1289 tracing::error!(error = %err, "Agent processing failed");
1290 let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
1291 let _ = tx.send(SessionEvent::Done).await;
1292 }
1293 });
1294 }
1295
1296 fn handle_response(&mut self, event: SessionEvent) {
1297 self.scroll = SCROLL_BOTTOM;
1299
1300 match event {
1301 SessionEvent::Thinking => {
1302 self.processing_message = Some("Thinking...".to_string());
1303 self.current_tool = None;
1304 if self.processing_started_at.is_none() {
1305 self.processing_started_at = Some(Instant::now());
1306 }
1307 }
1308 SessionEvent::ToolCallStart { name, arguments } => {
1309 if let Some(text) = self.streaming_text.take() {
1311 if !text.is_empty() {
1312 self.messages.push(ChatMessage::new("assistant", text));
1313 }
1314 }
1315 self.processing_message = Some(format!("Running {}...", name));
1316 self.current_tool = Some(name.clone());
1317 self.tool_call_count += 1;
1318
1319 let (preview, truncated) = build_tool_arguments_preview(
1320 &name,
1321 &arguments,
1322 TOOL_ARGS_PREVIEW_MAX_LINES,
1323 TOOL_ARGS_PREVIEW_MAX_BYTES,
1324 );
1325 self.messages.push(
1326 ChatMessage::new("tool", format!("🔧 {}", name)).with_message_type(
1327 MessageType::ToolCall {
1328 name,
1329 arguments_preview: preview,
1330 arguments_len: arguments.len(),
1331 truncated,
1332 },
1333 ),
1334 );
1335 }
1336 SessionEvent::ToolCallComplete {
1337 name,
1338 output,
1339 success,
1340 } => {
1341 let icon = if success { "✓" } else { "✗" };
1342
1343 let (preview, truncated) = build_text_preview(
1344 &output,
1345 TOOL_OUTPUT_PREVIEW_MAX_LINES,
1346 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
1347 );
1348 self.messages.push(
1349 ChatMessage::new("tool", format!("{} {}", icon, name)).with_message_type(
1350 MessageType::ToolResult {
1351 name,
1352 output_preview: preview,
1353 output_len: output.len(),
1354 truncated,
1355 },
1356 ),
1357 );
1358 self.current_tool = None;
1359 self.processing_message = Some("Thinking...".to_string());
1360 }
1361 SessionEvent::TextChunk(text) => {
1362 self.streaming_text = Some(text);
1364 }
1365 SessionEvent::ThinkingComplete(text) => {
1366 if !text.is_empty() {
1367 self.messages.push(
1368 ChatMessage::new("assistant", &text)
1369 .with_message_type(MessageType::Thinking(text)),
1370 );
1371 }
1372 }
1373 SessionEvent::TextComplete(text) => {
1374 self.streaming_text = None;
1376 if !text.is_empty() {
1377 self.messages.push(ChatMessage::new("assistant", text));
1378 }
1379 }
1380 SessionEvent::UsageReport {
1381 prompt_tokens,
1382 completion_tokens,
1383 duration_ms,
1384 model,
1385 } => {
1386 let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
1387 let meta = UsageMeta {
1388 prompt_tokens,
1389 completion_tokens,
1390 duration_ms,
1391 cost_usd,
1392 };
1393 if let Some(msg) = self
1395 .messages
1396 .iter_mut()
1397 .rev()
1398 .find(|m| m.role == "assistant")
1399 {
1400 msg.usage_meta = Some(meta);
1401 }
1402 }
1403 SessionEvent::SessionSync(session) => {
1404 self.session = Some(session);
1407 }
1408 SessionEvent::Error(err) => {
1409 self.messages
1410 .push(ChatMessage::new("assistant", format!("Error: {}", err)));
1411 }
1412 SessionEvent::Done => {
1413 self.is_processing = false;
1414 self.processing_message = None;
1415 self.current_tool = None;
1416 self.processing_started_at = None;
1417 self.streaming_text = None;
1418 self.response_rx = None;
1419 }
1420 }
1421 }
1422
1423 async fn send_to_agent(&mut self, agent_name: &str, message: &str, _config: &Config) {
1425 if self.provider_registry.is_none() {
1427 match crate::provider::ProviderRegistry::from_vault().await {
1428 Ok(registry) => {
1429 self.provider_registry = Some(std::sync::Arc::new(registry));
1430 }
1431 Err(err) => {
1432 self.messages.push(ChatMessage::new(
1433 "system",
1434 format!("Error loading providers: {err}"),
1435 ));
1436 return;
1437 }
1438 }
1439 }
1440 let registry = self.provider_registry.clone().unwrap();
1441
1442 let agent = match self.spawned_agents.get_mut(agent_name) {
1443 Some(a) => a,
1444 None => return,
1445 };
1446
1447 agent.is_processing = true;
1448 let session_clone = agent.session.clone();
1449 let msg_clone = message.to_string();
1450 let agent_name_owned = agent_name.to_string();
1451 let bus_arc = self.bus.clone();
1452
1453 let (tx, rx) = mpsc::channel(100);
1454 self.agent_response_rxs.push((agent_name.to_string(), rx));
1455
1456 tokio::spawn(async move {
1457 let mut session = session_clone;
1458 if let Err(err) = session
1459 .prompt_with_events(&msg_clone, tx.clone(), registry)
1460 .await
1461 {
1462 tracing::error!(agent = %agent_name_owned, error = %err, "Spawned agent failed");
1463 let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
1464 let _ = tx.send(SessionEvent::Done).await;
1465 }
1466
1467 if let Some(ref bus) = bus_arc {
1469 let handle = bus.handle(&agent_name_owned);
1470 handle.send(
1471 format!("agent.{agent_name_owned}.events"),
1472 crate::bus::BusMessage::AgentMessage {
1473 from: agent_name_owned.clone(),
1474 to: "user".to_string(),
1475 parts: vec![crate::a2a::types::Part::Text {
1476 text: "(response complete)".to_string(),
1477 }],
1478 },
1479 );
1480 }
1481 });
1482 }
1483
1484 fn handle_agent_response(&mut self, agent_name: &str, event: SessionEvent) {
1486 self.scroll = SCROLL_BOTTOM;
1487
1488 match event {
1489 SessionEvent::Thinking => {
1490 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
1492 agent.is_processing = true;
1493 }
1494 }
1495 SessionEvent::ToolCallStart { name, arguments } => {
1496 let (preview, truncated) = build_tool_arguments_preview(
1497 &name,
1498 &arguments,
1499 TOOL_ARGS_PREVIEW_MAX_LINES,
1500 TOOL_ARGS_PREVIEW_MAX_BYTES,
1501 );
1502 self.messages.push(
1503 ChatMessage::new("tool", format!("🔧 @{agent_name} → {name}"))
1504 .with_message_type(MessageType::ToolCall {
1505 name,
1506 arguments_preview: preview,
1507 arguments_len: arguments.len(),
1508 truncated,
1509 })
1510 .with_agent_name(agent_name),
1511 );
1512 }
1513 SessionEvent::ToolCallComplete {
1514 name,
1515 output,
1516 success,
1517 } => {
1518 let icon = if success { "✓" } else { "✗" };
1519 let (preview, truncated) = build_text_preview(
1520 &output,
1521 TOOL_OUTPUT_PREVIEW_MAX_LINES,
1522 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
1523 );
1524 self.messages.push(
1525 ChatMessage::new("tool", format!("{icon} @{agent_name} → {name}"))
1526 .with_message_type(MessageType::ToolResult {
1527 name,
1528 output_preview: preview,
1529 output_len: output.len(),
1530 truncated,
1531 })
1532 .with_agent_name(agent_name),
1533 );
1534 }
1535 SessionEvent::TextChunk(_text) => {
1536 }
1538 SessionEvent::ThinkingComplete(text) => {
1539 if !text.is_empty() {
1540 self.messages.push(
1541 ChatMessage::new("assistant", &text)
1542 .with_message_type(MessageType::Thinking(text))
1543 .with_agent_name(agent_name),
1544 );
1545 }
1546 }
1547 SessionEvent::TextComplete(text) => {
1548 if !text.is_empty() {
1549 self.messages
1550 .push(ChatMessage::new("assistant", &text).with_agent_name(agent_name));
1551 }
1552 }
1553 SessionEvent::UsageReport {
1554 prompt_tokens,
1555 completion_tokens,
1556 duration_ms,
1557 model,
1558 } => {
1559 let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
1560 let meta = UsageMeta {
1561 prompt_tokens,
1562 completion_tokens,
1563 duration_ms,
1564 cost_usd,
1565 };
1566 if let Some(msg) =
1567 self.messages.iter_mut().rev().find(|m| {
1568 m.role == "assistant" && m.agent_name.as_deref() == Some(agent_name)
1569 })
1570 {
1571 msg.usage_meta = Some(meta);
1572 }
1573 }
1574 SessionEvent::SessionSync(session) => {
1575 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
1576 agent.session = session;
1577 }
1578 }
1579 SessionEvent::Error(err) => {
1580 self.messages.push(
1581 ChatMessage::new("assistant", format!("Error: {err}"))
1582 .with_agent_name(agent_name),
1583 );
1584 }
1585 SessionEvent::Done => {
1586 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
1587 agent.is_processing = false;
1588 }
1589 }
1590 }
1591 }
1592
1593 fn handle_swarm_event(&mut self, event: SwarmEvent) {
1595 self.swarm_state.handle_event(event.clone());
1596
1597 if let SwarmEvent::Complete { success, ref stats } = event {
1599 self.view_mode = ViewMode::Chat;
1600 let summary = if success {
1601 format!(
1602 "Swarm completed successfully.\n\
1603 Subtasks: {} completed, {} failed\n\
1604 Total tool calls: {}\n\
1605 Time: {:.1}s (speedup: {:.1}x)",
1606 stats.subagents_completed,
1607 stats.subagents_failed,
1608 stats.total_tool_calls,
1609 stats.execution_time_ms as f64 / 1000.0,
1610 stats.speedup_factor
1611 )
1612 } else {
1613 format!(
1614 "Swarm completed with failures.\n\
1615 Subtasks: {} completed, {} failed\n\
1616 Check the subtask results for details.",
1617 stats.subagents_completed, stats.subagents_failed
1618 )
1619 };
1620 self.messages.push(ChatMessage::new("system", &summary));
1621 self.swarm_rx = None;
1622 }
1623
1624 if let SwarmEvent::Error(ref err) = event {
1625 self.messages
1626 .push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
1627 }
1628 }
1629
1630 fn handle_ralph_event(&mut self, event: RalphEvent) {
1632 self.ralph_state.handle_event(event.clone());
1633
1634 if let RalphEvent::Complete {
1636 ref status,
1637 passed,
1638 total,
1639 } = event
1640 {
1641 self.view_mode = ViewMode::Chat;
1642 let summary = format!(
1643 "Ralph loop finished: {}\n\
1644 Stories: {}/{} passed",
1645 status, passed, total
1646 );
1647 self.messages.push(ChatMessage::new("system", &summary));
1648 self.ralph_rx = None;
1649 }
1650
1651 if let RalphEvent::Error(ref err) = event {
1652 self.messages
1653 .push(ChatMessage::new("system", &format!("Ralph error: {}", err)));
1654 }
1655 }
1656
1657 async fn start_ralph_execution(&mut self, prd_path: String, config: &Config) {
1659 self.messages
1661 .push(ChatMessage::new("user", format!("/ralph {}", prd_path)));
1662
1663 let model = self
1665 .active_model
1666 .clone()
1667 .or_else(|| config.default_model.clone())
1668 .or_else(|| Some("zai/glm-5".to_string()));
1669
1670 let model = match model {
1671 Some(m) => m,
1672 None => {
1673 self.messages.push(ChatMessage::new(
1674 "system",
1675 "No model configured. Use /model to select one first.",
1676 ));
1677 return;
1678 }
1679 };
1680
1681 let prd_file = std::path::PathBuf::from(&prd_path);
1683 if !prd_file.exists() {
1684 self.messages.push(ChatMessage::new(
1685 "system",
1686 format!("PRD file not found: {}", prd_path),
1687 ));
1688 return;
1689 }
1690
1691 let (tx, rx) = mpsc::channel(200);
1693 self.ralph_rx = Some(rx);
1694
1695 self.view_mode = ViewMode::Ralph;
1697 self.ralph_state = RalphViewState::new();
1698
1699 let ralph_config = RalphConfig {
1701 prd_path: prd_path.clone(),
1702 max_iterations: 10,
1703 progress_path: "progress.txt".to_string(),
1704 quality_checks_enabled: true,
1705 auto_commit: true,
1706 model: Some(model.clone()),
1707 use_rlm: false,
1708 parallel_enabled: true,
1709 max_concurrent_stories: 3,
1710 worktree_enabled: true,
1711 story_timeout_secs: 300,
1712 conflict_timeout_secs: 120,
1713 };
1714
1715 let (provider_name, model_name) = if let Some(pos) = model.find('/') {
1717 (model[..pos].to_string(), model[pos + 1..].to_string())
1718 } else {
1719 (model.clone(), model.clone())
1720 };
1721
1722 let prd_path_clone = prd_path.clone();
1723 let tx_clone = tx.clone();
1724
1725 tokio::spawn(async move {
1727 let provider = match crate::provider::ProviderRegistry::from_vault().await {
1729 Ok(registry) => match registry.get(&provider_name) {
1730 Some(p) => p,
1731 None => {
1732 let _ = tx_clone
1733 .send(RalphEvent::Error(format!(
1734 "Provider '{}' not found",
1735 provider_name
1736 )))
1737 .await;
1738 return;
1739 }
1740 },
1741 Err(e) => {
1742 let _ = tx_clone
1743 .send(RalphEvent::Error(format!(
1744 "Failed to load providers: {}",
1745 e
1746 )))
1747 .await;
1748 return;
1749 }
1750 };
1751
1752 let prd_path_buf = std::path::PathBuf::from(&prd_path_clone);
1753 match RalphLoop::new(prd_path_buf, provider, model_name, ralph_config).await {
1754 Ok(ralph) => {
1755 let mut ralph = ralph.with_event_tx(tx_clone.clone());
1756 match ralph.run().await {
1757 Ok(_state) => {
1758 }
1760 Err(e) => {
1761 let _ = tx_clone.send(RalphEvent::Error(e.to_string())).await;
1762 }
1763 }
1764 }
1765 Err(e) => {
1766 let _ = tx_clone
1767 .send(RalphEvent::Error(format!(
1768 "Failed to initialize Ralph: {}",
1769 e
1770 )))
1771 .await;
1772 }
1773 }
1774 });
1775
1776 self.messages.push(ChatMessage::new(
1777 "system",
1778 format!("Starting Ralph loop with PRD: {}", prd_path),
1779 ));
1780 }
1781
1782 async fn start_swarm_execution(&mut self, task: String, config: &Config) {
1784 self.messages
1786 .push(ChatMessage::new("user", format!("/swarm {}", task)));
1787
1788 let model = config
1790 .default_model
1791 .clone()
1792 .or_else(|| Some("zai/glm-5".to_string()));
1793
1794 let swarm_config = SwarmConfig {
1796 model,
1797 max_subagents: 10,
1798 max_steps_per_subagent: 50,
1799 worktree_enabled: true,
1800 worktree_auto_merge: true,
1801 working_dir: Some(
1802 std::env::current_dir()
1803 .map(|p| p.to_string_lossy().to_string())
1804 .unwrap_or_else(|_| ".".to_string()),
1805 ),
1806 ..Default::default()
1807 };
1808
1809 let (tx, rx) = mpsc::channel(100);
1811 self.swarm_rx = Some(rx);
1812
1813 self.view_mode = ViewMode::Swarm;
1815 self.swarm_state = SwarmViewState::new();
1816
1817 let _ = tx
1819 .send(SwarmEvent::Started {
1820 task: task.clone(),
1821 total_subtasks: 0,
1822 })
1823 .await;
1824
1825 let task_clone = task;
1827 let bus_arc = self.bus.clone();
1828 tokio::spawn(async move {
1829 let mut executor = SwarmExecutor::new(swarm_config).with_event_tx(tx.clone());
1831 if let Some(bus) = bus_arc {
1832 executor = executor.with_bus(bus);
1833 }
1834 let result = executor
1835 .execute(&task_clone, DecompositionStrategy::Automatic)
1836 .await;
1837
1838 match result {
1839 Ok(swarm_result) => {
1840 let _ = tx
1841 .send(SwarmEvent::Complete {
1842 success: swarm_result.success,
1843 stats: swarm_result.stats,
1844 })
1845 .await;
1846 }
1847 Err(e) => {
1848 let _ = tx.send(SwarmEvent::Error(e.to_string())).await;
1849 }
1850 }
1851 });
1852 }
1853
1854 async fn open_model_picker(&mut self, config: &Config) {
1856 let mut models: Vec<(String, String, String)> = Vec::new();
1857
1858 match crate::provider::ProviderRegistry::from_vault().await {
1860 Ok(registry) => {
1861 for provider_name in registry.list() {
1862 if let Some(provider) = registry.get(provider_name) {
1863 match provider.list_models().await {
1864 Ok(model_list) => {
1865 for m in model_list {
1866 let label = format!("{}/{}", provider_name, m.id);
1867 let value = format!("{}/{}", provider_name, m.id);
1868 let name = m.name.clone();
1869 models.push((label, value, name));
1870 }
1871 }
1872 Err(e) => {
1873 tracing::warn!(
1874 "Failed to list models for {}: {}",
1875 provider_name,
1876 e
1877 );
1878 }
1879 }
1880 }
1881 }
1882 }
1883 Err(e) => {
1884 tracing::warn!("Failed to load provider registry: {}", e);
1885 }
1886 }
1887
1888 if models.is_empty() {
1890 if let Ok(registry) = crate::provider::ProviderRegistry::from_config(config).await {
1891 for provider_name in registry.list() {
1892 if let Some(provider) = registry.get(provider_name) {
1893 if let Ok(model_list) = provider.list_models().await {
1894 for m in model_list {
1895 let label = format!("{}/{}", provider_name, m.id);
1896 let value = format!("{}/{}", provider_name, m.id);
1897 let name = m.name.clone();
1898 models.push((label, value, name));
1899 }
1900 }
1901 }
1902 }
1903 }
1904 }
1905
1906 if models.is_empty() {
1907 self.messages.push(ChatMessage::new(
1908 "system",
1909 "No models found. Check provider configuration (Vault or config).",
1910 ));
1911 } else {
1912 models.sort_by(|a, b| a.0.cmp(&b.0));
1914 self.model_picker_list = models;
1915 self.model_picker_selected = 0;
1916 self.model_picker_filter.clear();
1917 self.view_mode = ViewMode::ModelPicker;
1918 }
1919 }
1920
1921 fn filtered_sessions(&self) -> Vec<(usize, &SessionSummary)> {
1923 if self.session_picker_filter.is_empty() {
1924 self.session_picker_list.iter().enumerate().collect()
1925 } else {
1926 let filter = self.session_picker_filter.to_lowercase();
1927 self.session_picker_list
1928 .iter()
1929 .enumerate()
1930 .filter(|(_, s)| {
1931 s.title
1932 .as_deref()
1933 .unwrap_or("")
1934 .to_lowercase()
1935 .contains(&filter)
1936 || s.agent.to_lowercase().contains(&filter)
1937 || s.id.to_lowercase().contains(&filter)
1938 })
1939 .collect()
1940 }
1941 }
1942
1943 fn filtered_models(&self) -> Vec<(usize, &(String, String, String))> {
1945 if self.model_picker_filter.is_empty() {
1946 self.model_picker_list.iter().enumerate().collect()
1947 } else {
1948 let filter = self.model_picker_filter.to_lowercase();
1949 self.model_picker_list
1950 .iter()
1951 .enumerate()
1952 .filter(|(_, (label, _, name))| {
1953 label.to_lowercase().contains(&filter) || name.to_lowercase().contains(&filter)
1954 })
1955 .collect()
1956 }
1957 }
1958
1959 fn filtered_spawned_agents(&self) -> Vec<(String, String, bool)> {
1961 let mut agents: Vec<(String, String, bool)> = self
1962 .spawned_agents
1963 .iter()
1964 .map(|(name, agent)| {
1965 (
1966 name.clone(),
1967 agent.instructions.clone(),
1968 agent.is_processing,
1969 )
1970 })
1971 .collect();
1972
1973 agents.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
1974
1975 if self.agent_picker_filter.is_empty() {
1976 agents
1977 } else {
1978 let filter = self.agent_picker_filter.to_lowercase();
1979 agents
1980 .into_iter()
1981 .filter(|(name, instructions, _)| {
1982 name.to_lowercase().contains(&filter)
1983 || instructions.to_lowercase().contains(&filter)
1984 })
1985 .collect()
1986 }
1987 }
1988
1989 fn open_agent_picker(&mut self) {
1991 if self.spawned_agents.is_empty() {
1992 self.messages.push(ChatMessage::new(
1993 "system",
1994 "No agents spawned yet. Use /spawn <name> <instructions> first.",
1995 ));
1996 return;
1997 }
1998
1999 self.agent_picker_filter.clear();
2000 let filtered = self.filtered_spawned_agents();
2001 self.agent_picker_selected = if let Some(active) = &self.active_spawned_agent {
2002 filtered
2003 .iter()
2004 .position(|(name, _, _)| name == active)
2005 .unwrap_or(0)
2006 } else {
2007 0
2008 };
2009 self.view_mode = ViewMode::AgentPicker;
2010 }
2011
2012 fn navigate_history(&mut self, direction: isize) {
2013 if self.command_history.is_empty() {
2014 return;
2015 }
2016
2017 let history_len = self.command_history.len();
2018 let new_index = match self.history_index {
2019 Some(current) => {
2020 let new = current as isize + direction;
2021 if new < 0 {
2022 None
2023 } else if new >= history_len as isize {
2024 Some(history_len - 1)
2025 } else {
2026 Some(new as usize)
2027 }
2028 }
2029 None => {
2030 if direction > 0 {
2031 Some(0)
2032 } else {
2033 Some(history_len.saturating_sub(1))
2034 }
2035 }
2036 };
2037
2038 self.history_index = new_index;
2039 if let Some(index) = new_index {
2040 self.input = self.command_history[index].clone();
2041 self.cursor_position = self.input.len();
2042 } else {
2043 self.input.clear();
2044 self.cursor_position = 0;
2045 }
2046 }
2047
2048 fn search_history(&mut self) {
2049 if self.command_history.is_empty() {
2051 return;
2052 }
2053
2054 let search_term = self.input.trim().to_lowercase();
2055
2056 if search_term.is_empty() {
2057 if !self.command_history.is_empty() {
2059 self.input = self.command_history.last().unwrap().clone();
2060 self.cursor_position = self.input.len();
2061 self.history_index = Some(self.command_history.len() - 1);
2062 }
2063 return;
2064 }
2065
2066 for (index, cmd) in self.command_history.iter().enumerate().rev() {
2068 if cmd.to_lowercase().starts_with(&search_term) {
2069 self.input = cmd.clone();
2070 self.cursor_position = self.input.len();
2071 self.history_index = Some(index);
2072 return;
2073 }
2074 }
2075
2076 for (index, cmd) in self.command_history.iter().enumerate().rev() {
2078 if cmd.to_lowercase().contains(&search_term) {
2079 self.input = cmd.clone();
2080 self.cursor_position = self.input.len();
2081 self.history_index = Some(index);
2082 return;
2083 }
2084 }
2085 }
2086}
2087
2088async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
2089 let mut app = App::new();
2090 if let Ok(sessions) = list_sessions_with_opencode(&app.workspace_dir).await {
2091 app.update_cached_sessions(sessions);
2092 }
2093
2094 let bus = std::sync::Arc::new(crate::bus::AgentBus::new());
2096 let mut bus_handle = bus.handle("tui-observer");
2097 let (bus_tx, bus_rx) = mpsc::channel::<crate::bus::BusEnvelope>(512);
2098 app.bus_log_rx = Some(bus_rx);
2099 app.bus = Some(bus.clone());
2100
2101 tokio::spawn(async move {
2103 loop {
2104 match bus_handle.recv().await {
2105 Some(env) => {
2106 if bus_tx.send(env).await.is_err() {
2107 break; }
2109 }
2110 None => break, }
2112 }
2113 });
2114
2115 let mut config = Config::load().await?;
2117 let mut theme = crate::tui::theme_utils::validate_theme(&config.load_theme());
2118
2119 let _config_paths = vec![
2121 std::path::PathBuf::from("./codetether.toml"),
2122 std::path::PathBuf::from("./.codetether/config.toml"),
2123 ];
2124
2125 let _global_config_path = directories::ProjectDirs::from("com", "codetether", "codetether")
2126 .map(|dirs| dirs.config_dir().join("config.toml"));
2127
2128 let mut last_check = Instant::now();
2129 let mut event_stream = EventStream::new();
2130
2131 let (session_tx, mut session_rx) = mpsc::channel::<Vec<crate::session::SessionSummary>>(1);
2133 {
2134 let workspace_dir = app.workspace_dir.clone();
2135 tokio::spawn(async move {
2136 let mut interval = tokio::time::interval(Duration::from_secs(5));
2137 loop {
2138 interval.tick().await;
2139 if let Ok(sessions) = list_sessions_with_opencode(&workspace_dir).await {
2140 if session_tx.send(sessions).await.is_err() {
2141 break; }
2143 }
2144 }
2145 });
2146 }
2147
2148 loop {
2149 if let Ok(sessions) = session_rx.try_recv() {
2153 app.update_cached_sessions(sessions);
2154 }
2155
2156 if config.ui.hot_reload && last_check.elapsed() > Duration::from_secs(2) {
2158 if let Ok(new_config) = Config::load().await {
2159 if new_config.ui.theme != config.ui.theme
2160 || new_config.ui.custom_theme != config.ui.custom_theme
2161 {
2162 theme = crate::tui::theme_utils::validate_theme(&new_config.load_theme());
2163 config = new_config;
2164 }
2165 }
2166 last_check = Instant::now();
2167 }
2168
2169 terminal.draw(|f| ui(f, &mut app, &theme))?;
2170
2171 let terminal_height = terminal.size()?.height.saturating_sub(6) as usize;
2174 let estimated_lines = app.messages.len() * 4; app.last_max_scroll = estimated_lines.saturating_sub(terminal_height);
2176
2177 if let Some(mut rx) = app.response_rx.take() {
2179 while let Ok(response) = rx.try_recv() {
2180 app.handle_response(response);
2181 }
2182 app.response_rx = Some(rx);
2183 }
2184
2185 if let Some(mut rx) = app.swarm_rx.take() {
2187 while let Ok(event) = rx.try_recv() {
2188 app.handle_swarm_event(event);
2189 }
2190 app.swarm_rx = Some(rx);
2191 }
2192
2193 if let Some(mut rx) = app.ralph_rx.take() {
2195 while let Ok(event) = rx.try_recv() {
2196 app.handle_ralph_event(event);
2197 }
2198 app.ralph_rx = Some(rx);
2199 }
2200
2201 if let Some(mut rx) = app.bus_log_rx.take() {
2203 while let Ok(env) = rx.try_recv() {
2204 app.bus_log_state.ingest(&env);
2205 }
2206 app.bus_log_rx = Some(rx);
2207 }
2208
2209 {
2211 let mut i = 0;
2212 while i < app.agent_response_rxs.len() {
2213 let mut done = false;
2214 while let Ok(event) = app.agent_response_rxs[i].1.try_recv() {
2215 if matches!(event, SessionEvent::Done) {
2216 done = true;
2217 }
2218 let name = app.agent_response_rxs[i].0.clone();
2219 app.handle_agent_response(&name, event);
2220 }
2221 if done {
2222 app.agent_response_rxs.swap_remove(i);
2223 } else {
2224 i += 1;
2225 }
2226 }
2227 }
2228
2229 let ev = tokio::select! {
2231 maybe_event = event_stream.next() => {
2232 match maybe_event {
2233 Some(Ok(ev)) => ev,
2234 Some(Err(_)) => continue,
2235 None => return Ok(()), }
2237 }
2238 _ = tokio::time::sleep(Duration::from_millis(50)) => continue,
2240 };
2241
2242 if let Event::Paste(text) = &ev {
2244 let mut pos = app.cursor_position;
2246 while pos > 0 && !app.input.is_char_boundary(pos) {
2247 pos -= 1;
2248 }
2249 app.cursor_position = pos;
2250
2251 for c in text.chars() {
2252 if c == '\n' || c == '\r' {
2253 app.input.insert(app.cursor_position, ' ');
2255 } else {
2256 app.input.insert(app.cursor_position, c);
2257 }
2258 app.cursor_position += c.len_utf8();
2259 }
2260 continue;
2261 }
2262
2263 if let Event::Key(key) = ev {
2264 if app.show_help {
2266 if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
2267 app.show_help = false;
2268 }
2269 continue;
2270 }
2271
2272 if app.view_mode == ViewMode::ModelPicker {
2274 match key.code {
2275 KeyCode::Esc => {
2276 app.view_mode = ViewMode::Chat;
2277 }
2278 KeyCode::Up | KeyCode::Char('k')
2279 if !key.modifiers.contains(KeyModifiers::ALT) =>
2280 {
2281 if app.model_picker_selected > 0 {
2282 app.model_picker_selected -= 1;
2283 }
2284 }
2285 KeyCode::Down | KeyCode::Char('j')
2286 if !key.modifiers.contains(KeyModifiers::ALT) =>
2287 {
2288 let filtered = app.filtered_models();
2289 if app.model_picker_selected < filtered.len().saturating_sub(1) {
2290 app.model_picker_selected += 1;
2291 }
2292 }
2293 KeyCode::Enter => {
2294 let filtered = app.filtered_models();
2295 if let Some((_, (label, value, _name))) =
2296 filtered.get(app.model_picker_selected)
2297 {
2298 let label = label.clone();
2299 let value = value.clone();
2300 app.active_model = Some(value.clone());
2301 if let Some(session) = app.session.as_mut() {
2302 session.metadata.model = Some(value.clone());
2303 }
2304 app.messages.push(ChatMessage::new(
2305 "system",
2306 format!("Model set to: {}", label),
2307 ));
2308 app.view_mode = ViewMode::Chat;
2309 }
2310 }
2311 KeyCode::Backspace => {
2312 app.model_picker_filter.pop();
2313 app.model_picker_selected = 0;
2314 }
2315 KeyCode::Char(c)
2316 if !key.modifiers.contains(KeyModifiers::CONTROL)
2317 && !key.modifiers.contains(KeyModifiers::ALT) =>
2318 {
2319 app.model_picker_filter.push(c);
2320 app.model_picker_selected = 0;
2321 }
2322 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2323 return Ok(());
2324 }
2325 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2326 return Ok(());
2327 }
2328 _ => {}
2329 }
2330 continue;
2331 }
2332
2333 if app.view_mode == ViewMode::SessionPicker {
2335 match key.code {
2336 KeyCode::Esc => {
2337 if app.session_picker_confirm_delete {
2338 app.session_picker_confirm_delete = false;
2339 } else {
2340 app.session_picker_filter.clear();
2341 app.view_mode = ViewMode::Chat;
2342 }
2343 }
2344 KeyCode::Up | KeyCode::Char('k') => {
2345 if app.session_picker_selected > 0 {
2346 app.session_picker_selected -= 1;
2347 }
2348 app.session_picker_confirm_delete = false;
2349 }
2350 KeyCode::Down | KeyCode::Char('j') => {
2351 let filtered_count = app.filtered_sessions().len();
2352 if app.session_picker_selected < filtered_count.saturating_sub(1) {
2353 app.session_picker_selected += 1;
2354 }
2355 app.session_picker_confirm_delete = false;
2356 }
2357 KeyCode::Char('d') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
2358 if app.session_picker_confirm_delete {
2359 let filtered = app.filtered_sessions();
2361 if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
2362 let session_id = app.session_picker_list[*orig_idx].id.clone();
2363 let is_active = app
2364 .session
2365 .as_ref()
2366 .map(|s| s.id == session_id)
2367 .unwrap_or(false);
2368 if !is_active {
2369 if let Err(e) = Session::delete(&session_id).await {
2370 app.messages.push(ChatMessage::new(
2371 "system",
2372 format!("Failed to delete session: {}", e),
2373 ));
2374 } else {
2375 app.session_picker_list.retain(|s| s.id != session_id);
2376 if app.session_picker_selected
2377 >= app.session_picker_list.len()
2378 {
2379 app.session_picker_selected =
2380 app.session_picker_list.len().saturating_sub(1);
2381 }
2382 }
2383 }
2384 }
2385 app.session_picker_confirm_delete = false;
2386 } else {
2387 let filtered = app.filtered_sessions();
2389 if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
2390 let is_active = app
2391 .session
2392 .as_ref()
2393 .map(|s| s.id == app.session_picker_list[*orig_idx].id)
2394 .unwrap_or(false);
2395 if !is_active {
2396 app.session_picker_confirm_delete = true;
2397 }
2398 }
2399 }
2400 }
2401 KeyCode::Backspace => {
2402 app.session_picker_filter.pop();
2403 app.session_picker_selected = 0;
2404 app.session_picker_confirm_delete = false;
2405 }
2406 KeyCode::Char('/') => {
2407 }
2409 KeyCode::Enter => {
2410 app.session_picker_confirm_delete = false;
2411 let filtered = app.filtered_sessions();
2412 let session_id = filtered
2413 .get(app.session_picker_selected)
2414 .map(|(orig_idx, _)| app.session_picker_list[*orig_idx].id.clone());
2415 if let Some(session_id) = session_id {
2416 let load_result =
2417 if let Some(oc_id) = session_id.strip_prefix("opencode_") {
2418 if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
2419 Session::from_opencode(oc_id, &storage).await
2420 } else {
2421 Err(anyhow::anyhow!("OpenCode storage not available"))
2422 }
2423 } else {
2424 Session::load(&session_id).await
2425 };
2426 match load_result {
2427 Ok(session) => {
2428 app.messages.clear();
2429 app.messages.push(ChatMessage::new(
2430 "system",
2431 format!(
2432 "Resumed session: {}\nCreated: {}\n{} messages loaded",
2433 session.title.as_deref().unwrap_or("(untitled)"),
2434 session.created_at.format("%Y-%m-%d %H:%M"),
2435 session.messages.len()
2436 ),
2437 ));
2438
2439 for msg in &session.messages {
2440 let role_str = match msg.role {
2441 Role::System => "system",
2442 Role::User => "user",
2443 Role::Assistant => "assistant",
2444 Role::Tool => "tool",
2445 };
2446
2447 for part in &msg.content {
2450 match part {
2451 ContentPart::Text { text } => {
2452 if !text.is_empty() {
2453 app.messages.push(ChatMessage::new(
2454 role_str,
2455 text.clone(),
2456 ));
2457 }
2458 }
2459 ContentPart::Image { url, mime_type } => {
2460 app.messages.push(
2461 ChatMessage::new(role_str, "")
2462 .with_message_type(
2463 MessageType::Image {
2464 url: url.clone(),
2465 mime_type: mime_type.clone(),
2466 },
2467 ),
2468 );
2469 }
2470 ContentPart::ToolCall {
2471 name, arguments, ..
2472 } => {
2473 let (preview, truncated) =
2474 build_tool_arguments_preview(
2475 name,
2476 arguments,
2477 TOOL_ARGS_PREVIEW_MAX_LINES,
2478 TOOL_ARGS_PREVIEW_MAX_BYTES,
2479 );
2480 app.messages.push(
2481 ChatMessage::new(
2482 role_str,
2483 format!("🔧 {name}"),
2484 )
2485 .with_message_type(MessageType::ToolCall {
2486 name: name.clone(),
2487 arguments_preview: preview,
2488 arguments_len: arguments.len(),
2489 truncated,
2490 }),
2491 );
2492 }
2493 ContentPart::ToolResult { content, .. } => {
2494 let truncated =
2495 truncate_with_ellipsis(content, 500);
2496 let (preview, preview_truncated) =
2497 build_text_preview(
2498 content,
2499 TOOL_OUTPUT_PREVIEW_MAX_LINES,
2500 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
2501 );
2502 app.messages.push(
2503 ChatMessage::new(
2504 role_str,
2505 format!("✅ Result\n{truncated}"),
2506 )
2507 .with_message_type(
2508 MessageType::ToolResult {
2509 name: "tool".to_string(),
2510 output_preview: preview,
2511 output_len: content.len(),
2512 truncated: preview_truncated,
2513 },
2514 ),
2515 );
2516 }
2517 ContentPart::File { path, mime_type } => {
2518 app.messages.push(
2519 ChatMessage::new(
2520 role_str,
2521 format!("📎 {path}"),
2522 )
2523 .with_message_type(MessageType::File {
2524 path: path.clone(),
2525 mime_type: mime_type.clone(),
2526 }),
2527 );
2528 }
2529 ContentPart::Thinking { text } => {
2530 if !text.is_empty() {
2531 app.messages.push(
2532 ChatMessage::new(
2533 role_str,
2534 text.clone(),
2535 )
2536 .with_message_type(
2537 MessageType::Thinking(text.clone()),
2538 ),
2539 );
2540 }
2541 }
2542 }
2543 }
2544 }
2545
2546 app.current_agent = session.agent.clone();
2547 app.session = Some(session);
2548 app.scroll = SCROLL_BOTTOM;
2549 app.view_mode = ViewMode::Chat;
2550 }
2551 Err(e) => {
2552 app.messages.push(ChatMessage::new(
2553 "system",
2554 format!("Failed to load session: {}", e),
2555 ));
2556 app.view_mode = ViewMode::Chat;
2557 }
2558 }
2559 }
2560 }
2561 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2562 return Ok(());
2563 }
2564 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2565 return Ok(());
2566 }
2567 KeyCode::Char(c)
2568 if !key.modifiers.contains(KeyModifiers::CONTROL)
2569 && !key.modifiers.contains(KeyModifiers::ALT)
2570 && c != 'j'
2571 && c != 'k' =>
2572 {
2573 app.session_picker_filter.push(c);
2574 app.session_picker_selected = 0;
2575 app.session_picker_confirm_delete = false;
2576 }
2577 _ => {}
2578 }
2579 continue;
2580 }
2581
2582 if app.view_mode == ViewMode::AgentPicker {
2584 match key.code {
2585 KeyCode::Esc => {
2586 app.agent_picker_filter.clear();
2587 app.view_mode = ViewMode::Chat;
2588 }
2589 KeyCode::Up | KeyCode::Char('k')
2590 if !key.modifiers.contains(KeyModifiers::ALT) =>
2591 {
2592 if app.agent_picker_selected > 0 {
2593 app.agent_picker_selected -= 1;
2594 }
2595 }
2596 KeyCode::Down | KeyCode::Char('j')
2597 if !key.modifiers.contains(KeyModifiers::ALT) =>
2598 {
2599 let filtered = app.filtered_spawned_agents();
2600 if app.agent_picker_selected < filtered.len().saturating_sub(1) {
2601 app.agent_picker_selected += 1;
2602 }
2603 }
2604 KeyCode::Enter => {
2605 let filtered = app.filtered_spawned_agents();
2606 if let Some((name, _, _)) = filtered.get(app.agent_picker_selected) {
2607 app.active_spawned_agent = Some(name.clone());
2608 app.messages.push(ChatMessage::new(
2609 "system",
2610 format!(
2611 "Focused chat on @{name}. Type messages directly; use /agent main to exit focus."
2612 ),
2613 ));
2614 app.view_mode = ViewMode::Chat;
2615 }
2616 }
2617 KeyCode::Backspace => {
2618 app.agent_picker_filter.pop();
2619 app.agent_picker_selected = 0;
2620 }
2621 KeyCode::Char('m') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
2622 app.active_spawned_agent = None;
2623 app.messages
2624 .push(ChatMessage::new("system", "Returned to main chat mode."));
2625 app.view_mode = ViewMode::Chat;
2626 }
2627 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2628 return Ok(());
2629 }
2630 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2631 return Ok(());
2632 }
2633 KeyCode::Char(c)
2634 if !key.modifiers.contains(KeyModifiers::CONTROL)
2635 && !key.modifiers.contains(KeyModifiers::ALT)
2636 && c != 'j'
2637 && c != 'k'
2638 && c != 'm' =>
2639 {
2640 app.agent_picker_filter.push(c);
2641 app.agent_picker_selected = 0;
2642 }
2643 _ => {}
2644 }
2645 continue;
2646 }
2647
2648 if app.view_mode == ViewMode::Swarm {
2650 match key.code {
2651 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2652 return Ok(());
2653 }
2654 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2655 return Ok(());
2656 }
2657 KeyCode::Esc => {
2658 if app.swarm_state.detail_mode {
2659 app.swarm_state.exit_detail();
2660 } else {
2661 app.view_mode = ViewMode::Chat;
2662 }
2663 }
2664 KeyCode::Up | KeyCode::Char('k') => {
2665 if app.swarm_state.detail_mode {
2666 app.swarm_state.exit_detail();
2668 app.swarm_state.select_prev();
2669 app.swarm_state.enter_detail();
2670 } else {
2671 app.swarm_state.select_prev();
2672 }
2673 }
2674 KeyCode::Down | KeyCode::Char('j') => {
2675 if app.swarm_state.detail_mode {
2676 app.swarm_state.exit_detail();
2677 app.swarm_state.select_next();
2678 app.swarm_state.enter_detail();
2679 } else {
2680 app.swarm_state.select_next();
2681 }
2682 }
2683 KeyCode::Enter => {
2684 if !app.swarm_state.detail_mode {
2685 app.swarm_state.enter_detail();
2686 }
2687 }
2688 KeyCode::PageDown => {
2689 app.swarm_state.detail_scroll_down(10);
2690 }
2691 KeyCode::PageUp => {
2692 app.swarm_state.detail_scroll_up(10);
2693 }
2694 KeyCode::Char('?') => {
2695 app.show_help = true;
2696 }
2697 KeyCode::F(2) => {
2698 app.view_mode = ViewMode::Chat;
2699 }
2700 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2701 app.view_mode = ViewMode::Chat;
2702 }
2703 _ => {}
2704 }
2705 continue;
2706 }
2707
2708 if app.view_mode == ViewMode::Ralph {
2710 match key.code {
2711 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2712 return Ok(());
2713 }
2714 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2715 return Ok(());
2716 }
2717 KeyCode::Esc => {
2718 if app.ralph_state.detail_mode {
2719 app.ralph_state.exit_detail();
2720 } else {
2721 app.view_mode = ViewMode::Chat;
2722 }
2723 }
2724 KeyCode::Up | KeyCode::Char('k') => {
2725 if app.ralph_state.detail_mode {
2726 app.ralph_state.exit_detail();
2727 app.ralph_state.select_prev();
2728 app.ralph_state.enter_detail();
2729 } else {
2730 app.ralph_state.select_prev();
2731 }
2732 }
2733 KeyCode::Down | KeyCode::Char('j') => {
2734 if app.ralph_state.detail_mode {
2735 app.ralph_state.exit_detail();
2736 app.ralph_state.select_next();
2737 app.ralph_state.enter_detail();
2738 } else {
2739 app.ralph_state.select_next();
2740 }
2741 }
2742 KeyCode::Enter => {
2743 if !app.ralph_state.detail_mode {
2744 app.ralph_state.enter_detail();
2745 }
2746 }
2747 KeyCode::PageDown => {
2748 app.ralph_state.detail_scroll_down(10);
2749 }
2750 KeyCode::PageUp => {
2751 app.ralph_state.detail_scroll_up(10);
2752 }
2753 KeyCode::Char('?') => {
2754 app.show_help = true;
2755 }
2756 KeyCode::F(2) | KeyCode::Char('s')
2757 if key.modifiers.contains(KeyModifiers::CONTROL) =>
2758 {
2759 app.view_mode = ViewMode::Chat;
2760 }
2761 _ => {}
2762 }
2763 continue;
2764 }
2765
2766 if app.view_mode == ViewMode::BusLog {
2768 match key.code {
2769 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2770 return Ok(());
2771 }
2772 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2773 return Ok(());
2774 }
2775 KeyCode::Esc => {
2776 if app.bus_log_state.detail_mode {
2777 app.bus_log_state.exit_detail();
2778 } else {
2779 app.view_mode = ViewMode::Chat;
2780 }
2781 }
2782 KeyCode::Up | KeyCode::Char('k') => {
2783 if app.bus_log_state.detail_mode {
2784 app.bus_log_state.exit_detail();
2785 app.bus_log_state.select_prev();
2786 app.bus_log_state.enter_detail();
2787 } else {
2788 app.bus_log_state.select_prev();
2789 }
2790 }
2791 KeyCode::Down | KeyCode::Char('j') => {
2792 if app.bus_log_state.detail_mode {
2793 app.bus_log_state.exit_detail();
2794 app.bus_log_state.select_next();
2795 app.bus_log_state.enter_detail();
2796 } else {
2797 app.bus_log_state.select_next();
2798 }
2799 }
2800 KeyCode::Enter => {
2801 if !app.bus_log_state.detail_mode {
2802 app.bus_log_state.enter_detail();
2803 }
2804 }
2805 KeyCode::PageDown => {
2806 app.bus_log_state.detail_scroll_down(10);
2807 }
2808 KeyCode::PageUp => {
2809 app.bus_log_state.detail_scroll_up(10);
2810 }
2811 KeyCode::Char('c') => {
2813 app.bus_log_state.entries.clear();
2814 app.bus_log_state.selected_index = 0;
2815 }
2816 KeyCode::Char('g') => {
2818 let len = app.bus_log_state.filtered_entries().len();
2819 if len > 0 {
2820 app.bus_log_state.selected_index = len - 1;
2821 app.bus_log_state.list_state.select(Some(len - 1));
2822 }
2823 app.bus_log_state.auto_scroll = true;
2824 }
2825 KeyCode::Char('?') => {
2826 app.show_help = true;
2827 }
2828 _ => {}
2829 }
2830 continue;
2831 }
2832
2833 match key.code {
2834 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2836 return Ok(());
2837 }
2838 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2839 return Ok(());
2840 }
2841
2842 KeyCode::Char('?') => {
2844 app.show_help = true;
2845 }
2846
2847 KeyCode::F(2) => {
2849 app.view_mode = match app.view_mode {
2850 ViewMode::Chat
2851 | ViewMode::SessionPicker
2852 | ViewMode::ModelPicker
2853 | ViewMode::AgentPicker
2854 | ViewMode::BusLog => ViewMode::Swarm,
2855 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
2856 };
2857 }
2858 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2859 app.view_mode = match app.view_mode {
2860 ViewMode::Chat
2861 | ViewMode::SessionPicker
2862 | ViewMode::ModelPicker
2863 | ViewMode::AgentPicker
2864 | ViewMode::BusLog => ViewMode::Swarm,
2865 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
2866 };
2867 }
2868
2869 KeyCode::F(3) => {
2871 app.show_inspector = !app.show_inspector;
2872 }
2873
2874 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2876 let msg = app
2877 .messages
2878 .iter()
2879 .rev()
2880 .find(|m| m.role == "assistant" && !m.content.trim().is_empty())
2881 .or_else(|| app.messages.iter().rev().find(|m| !m.content.trim().is_empty()));
2882
2883 let Some(msg) = msg else {
2884 app.messages.push(ChatMessage::new("system", "Nothing to copy yet."));
2885 app.scroll = SCROLL_BOTTOM;
2886 continue;
2887 };
2888
2889 let text = message_clipboard_text(msg);
2890 match copy_text_to_clipboard_best_effort(&text) {
2891 Ok(method) => {
2892 app.messages.push(ChatMessage::new(
2893 "system",
2894 format!("Copied latest reply ({method})."),
2895 ));
2896 app.scroll = SCROLL_BOTTOM;
2897 }
2898 Err(err) => {
2899 tracing::warn!(error = %err, "Copy to clipboard failed");
2900 app.messages.push(ChatMessage::new(
2901 "system",
2902 "Could not copy to clipboard in this environment.",
2903 ));
2904 app.scroll = SCROLL_BOTTOM;
2905 }
2906 }
2907 }
2908
2909 KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2911 app.chat_layout = match app.chat_layout {
2912 ChatLayoutMode::Classic => ChatLayoutMode::Webview,
2913 ChatLayoutMode::Webview => ChatLayoutMode::Classic,
2914 };
2915 }
2916
2917 KeyCode::Esc => {
2919 if app.view_mode == ViewMode::Swarm
2920 || app.view_mode == ViewMode::Ralph
2921 || app.view_mode == ViewMode::BusLog
2922 || app.view_mode == ViewMode::SessionPicker
2923 || app.view_mode == ViewMode::ModelPicker
2924 || app.view_mode == ViewMode::AgentPicker
2925 {
2926 app.view_mode = ViewMode::Chat;
2927 }
2928 }
2929
2930 KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2932 app.open_model_picker(&config).await;
2933 }
2934
2935 KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2937 app.open_agent_picker();
2938 }
2939
2940 KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2942 app.view_mode = ViewMode::BusLog;
2943 }
2944
2945 KeyCode::Tab => {
2947 app.current_agent = if app.current_agent == "build" {
2948 "plan".to_string()
2949 } else {
2950 "build".to_string()
2951 };
2952 }
2953
2954 KeyCode::Enter => {
2956 app.submit_message(&config).await;
2957 }
2958
2959 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
2961 if app.scroll < SCROLL_BOTTOM {
2962 app.scroll = app.scroll.saturating_add(1);
2963 }
2964 }
2965 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
2966 if app.scroll >= SCROLL_BOTTOM {
2967 app.scroll = app.last_max_scroll; }
2969 app.scroll = app.scroll.saturating_sub(1);
2970 }
2971
2972 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2974 app.search_history();
2975 }
2976 KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
2977 app.navigate_history(-1);
2978 }
2979 KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
2980 app.navigate_history(1);
2981 }
2982
2983 KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2985 app.scroll = 0; }
2987 KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2988 app.scroll = SCROLL_BOTTOM;
2990 }
2991
2992 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
2994 if app.scroll < SCROLL_BOTTOM {
2996 app.scroll = app.scroll.saturating_add(5);
2997 }
2998 }
2999 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
3000 if app.scroll >= SCROLL_BOTTOM {
3002 app.scroll = app.last_max_scroll;
3003 }
3004 app.scroll = app.scroll.saturating_sub(5);
3005 }
3006
3007 KeyCode::Char(c) => {
3009 while app.cursor_position > 0
3011 && !app.input.is_char_boundary(app.cursor_position)
3012 {
3013 app.cursor_position -= 1;
3014 }
3015 app.input.insert(app.cursor_position, c);
3016 app.cursor_position += c.len_utf8();
3017 }
3018 KeyCode::Backspace => {
3019 while app.cursor_position > 0
3021 && !app.input.is_char_boundary(app.cursor_position)
3022 {
3023 app.cursor_position -= 1;
3024 }
3025 if app.cursor_position > 0 {
3026 let prev = app.input[..app.cursor_position].char_indices().rev().next();
3028 if let Some((idx, ch)) = prev {
3029 app.input.replace_range(idx..idx + ch.len_utf8(), "");
3030 app.cursor_position = idx;
3031 }
3032 }
3033 }
3034 KeyCode::Delete => {
3035 while app.cursor_position > 0
3037 && !app.input.is_char_boundary(app.cursor_position)
3038 {
3039 app.cursor_position -= 1;
3040 }
3041 if app.cursor_position < app.input.len() {
3042 let ch = app.input[app.cursor_position..].chars().next();
3043 if let Some(ch) = ch {
3044 app.input.replace_range(
3045 app.cursor_position..app.cursor_position + ch.len_utf8(),
3046 "",
3047 );
3048 }
3049 }
3050 }
3051 KeyCode::Left => {
3052 let prev = app.input[..app.cursor_position].char_indices().rev().next();
3054 if let Some((idx, _)) = prev {
3055 app.cursor_position = idx;
3056 }
3057 }
3058 KeyCode::Right => {
3059 if app.cursor_position < app.input.len() {
3060 let ch = app.input[app.cursor_position..].chars().next();
3061 if let Some(ch) = ch {
3062 app.cursor_position += ch.len_utf8();
3063 }
3064 }
3065 }
3066 KeyCode::Home => {
3067 app.cursor_position = 0;
3068 }
3069 KeyCode::End => {
3070 app.cursor_position = app.input.len();
3071 }
3072
3073 KeyCode::Up => {
3075 if app.scroll >= SCROLL_BOTTOM {
3076 app.scroll = app.last_max_scroll; }
3078 app.scroll = app.scroll.saturating_sub(1);
3079 }
3080 KeyCode::Down => {
3081 if app.scroll < SCROLL_BOTTOM {
3082 app.scroll = app.scroll.saturating_add(1);
3083 }
3084 }
3085 KeyCode::PageUp => {
3086 if app.scroll >= SCROLL_BOTTOM {
3087 app.scroll = app.last_max_scroll;
3088 }
3089 app.scroll = app.scroll.saturating_sub(10);
3090 }
3091 KeyCode::PageDown => {
3092 if app.scroll < SCROLL_BOTTOM {
3093 app.scroll = app.scroll.saturating_add(10);
3094 }
3095 }
3096
3097 _ => {}
3098 }
3099 }
3100 }
3101}
3102
3103fn ui(f: &mut Frame, app: &mut App, theme: &Theme) {
3104 if app.view_mode == ViewMode::Swarm {
3106 let chunks = Layout::default()
3108 .direction(Direction::Vertical)
3109 .constraints([
3110 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
3114 .split(f.area());
3115
3116 render_swarm_view(f, &mut app.swarm_state, chunks[0]);
3118
3119 let input_block = Block::default()
3121 .borders(Borders::ALL)
3122 .title(" Press Esc, Ctrl+S, or /view to return to chat ")
3123 .border_style(Style::default().fg(Color::Cyan));
3124
3125 let input = Paragraph::new(app.input.as_str())
3126 .block(input_block)
3127 .wrap(Wrap { trim: false });
3128 f.render_widget(input, chunks[1]);
3129
3130 let status_line = if app.swarm_state.detail_mode {
3132 Line::from(vec![
3133 Span::styled(
3134 " AGENT DETAIL ",
3135 Style::default().fg(Color::Black).bg(Color::Cyan),
3136 ),
3137 Span::raw(" | "),
3138 Span::styled("Esc", Style::default().fg(Color::Yellow)),
3139 Span::raw(": Back to list | "),
3140 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3141 Span::raw(": Prev/Next agent | "),
3142 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
3143 Span::raw(": Scroll"),
3144 ])
3145 } else {
3146 Line::from(vec![
3147 Span::styled(
3148 " SWARM MODE ",
3149 Style::default().fg(Color::Black).bg(Color::Cyan),
3150 ),
3151 Span::raw(" | "),
3152 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3153 Span::raw(": Select | "),
3154 Span::styled("Enter", Style::default().fg(Color::Yellow)),
3155 Span::raw(": Detail | "),
3156 Span::styled("Esc", Style::default().fg(Color::Yellow)),
3157 Span::raw(": Back | "),
3158 Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
3159 Span::raw(": Toggle view"),
3160 ])
3161 };
3162 let status = Paragraph::new(status_line);
3163 f.render_widget(status, chunks[2]);
3164 return;
3165 }
3166
3167 if app.view_mode == ViewMode::Ralph {
3169 let chunks = Layout::default()
3170 .direction(Direction::Vertical)
3171 .constraints([
3172 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
3176 .split(f.area());
3177
3178 render_ralph_view(f, &mut app.ralph_state, chunks[0]);
3179
3180 let input_block = Block::default()
3181 .borders(Borders::ALL)
3182 .title(" Press Esc to return to chat ")
3183 .border_style(Style::default().fg(Color::Magenta));
3184
3185 let input = Paragraph::new(app.input.as_str())
3186 .block(input_block)
3187 .wrap(Wrap { trim: false });
3188 f.render_widget(input, chunks[1]);
3189
3190 let status_line = if app.ralph_state.detail_mode {
3191 Line::from(vec![
3192 Span::styled(
3193 " STORY DETAIL ",
3194 Style::default().fg(Color::Black).bg(Color::Magenta),
3195 ),
3196 Span::raw(" | "),
3197 Span::styled("Esc", Style::default().fg(Color::Yellow)),
3198 Span::raw(": Back to list | "),
3199 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3200 Span::raw(": Prev/Next story | "),
3201 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
3202 Span::raw(": Scroll"),
3203 ])
3204 } else {
3205 Line::from(vec![
3206 Span::styled(
3207 " RALPH MODE ",
3208 Style::default().fg(Color::Black).bg(Color::Magenta),
3209 ),
3210 Span::raw(" | "),
3211 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3212 Span::raw(": Select | "),
3213 Span::styled("Enter", Style::default().fg(Color::Yellow)),
3214 Span::raw(": Detail | "),
3215 Span::styled("Esc", Style::default().fg(Color::Yellow)),
3216 Span::raw(": Back"),
3217 ])
3218 };
3219 let status = Paragraph::new(status_line);
3220 f.render_widget(status, chunks[2]);
3221 return;
3222 }
3223
3224 if app.view_mode == ViewMode::BusLog {
3226 let chunks = Layout::default()
3227 .direction(Direction::Vertical)
3228 .constraints([
3229 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
3233 .split(f.area());
3234
3235 render_bus_log(f, &mut app.bus_log_state, chunks[0]);
3236
3237 let input_block = Block::default()
3238 .borders(Borders::ALL)
3239 .title(" Press Esc to return to chat ")
3240 .border_style(Style::default().fg(Color::Green));
3241
3242 let input = Paragraph::new(app.input.as_str())
3243 .block(input_block)
3244 .wrap(Wrap { trim: false });
3245 f.render_widget(input, chunks[1]);
3246
3247 let count_info = format!(
3248 " {}/{} ",
3249 app.bus_log_state.visible_count(),
3250 app.bus_log_state.total_count()
3251 );
3252 let status_line = Line::from(vec![
3253 Span::styled(
3254 " BUS LOG ",
3255 Style::default().fg(Color::Black).bg(Color::Green),
3256 ),
3257 Span::raw(&count_info),
3258 Span::raw("| "),
3259 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3260 Span::raw(": Select | "),
3261 Span::styled("Enter", Style::default().fg(Color::Yellow)),
3262 Span::raw(": Detail | "),
3263 Span::styled("c", Style::default().fg(Color::Yellow)),
3264 Span::raw(": Clear | "),
3265 Span::styled("Esc", Style::default().fg(Color::Yellow)),
3266 Span::raw(": Back"),
3267 ]);
3268 let status = Paragraph::new(status_line);
3269 f.render_widget(status, chunks[2]);
3270 return;
3271 }
3272
3273 if app.view_mode == ViewMode::ModelPicker {
3275 let area = centered_rect(70, 70, f.area());
3276 f.render_widget(Clear, area);
3277
3278 let filter_display = if app.model_picker_filter.is_empty() {
3279 "type to filter".to_string()
3280 } else {
3281 format!("filter: {}", app.model_picker_filter)
3282 };
3283
3284 let picker_block = Block::default()
3285 .borders(Borders::ALL)
3286 .title(format!(
3287 " Select Model (↑↓ navigate, Enter select, Esc cancel) [{}] ",
3288 filter_display
3289 ))
3290 .border_style(Style::default().fg(Color::Magenta));
3291
3292 let filtered = app.filtered_models();
3293 let mut list_lines: Vec<Line> = Vec::new();
3294 list_lines.push(Line::from(""));
3295
3296 if let Some(ref active) = app.active_model {
3297 list_lines.push(Line::styled(
3298 format!(" Current: {}", active),
3299 Style::default()
3300 .fg(Color::Green)
3301 .add_modifier(Modifier::DIM),
3302 ));
3303 list_lines.push(Line::from(""));
3304 }
3305
3306 if filtered.is_empty() {
3307 list_lines.push(Line::styled(
3308 " No models match filter",
3309 Style::default().fg(Color::DarkGray),
3310 ));
3311 } else {
3312 let mut current_provider = String::new();
3313 for (display_idx, (_, (label, _, human_name))) in filtered.iter().enumerate() {
3314 let provider = label.split('/').next().unwrap_or("");
3315 if provider != current_provider {
3316 if !current_provider.is_empty() {
3317 list_lines.push(Line::from(""));
3318 }
3319 list_lines.push(Line::styled(
3320 format!(" ─── {} ───", provider),
3321 Style::default()
3322 .fg(Color::Cyan)
3323 .add_modifier(Modifier::BOLD),
3324 ));
3325 current_provider = provider.to_string();
3326 }
3327
3328 let is_selected = display_idx == app.model_picker_selected;
3329 let is_active = app.active_model.as_deref() == Some(label.as_str());
3330 let marker = if is_selected { "▶" } else { " " };
3331 let active_marker = if is_active { " ✓" } else { "" };
3332 let model_id = label.split('/').skip(1).collect::<Vec<_>>().join("/");
3333 let display = if human_name != &model_id && !human_name.is_empty() {
3335 format!("{} ({})", human_name, model_id)
3336 } else {
3337 model_id
3338 };
3339
3340 let style = if is_selected {
3341 Style::default()
3342 .fg(Color::Magenta)
3343 .add_modifier(Modifier::BOLD)
3344 } else if is_active {
3345 Style::default().fg(Color::Green)
3346 } else {
3347 Style::default()
3348 };
3349
3350 list_lines.push(Line::styled(
3351 format!(" {} {}{}", marker, display, active_marker),
3352 style,
3353 ));
3354 }
3355 }
3356
3357 let list = Paragraph::new(list_lines)
3358 .block(picker_block)
3359 .wrap(Wrap { trim: false });
3360 f.render_widget(list, area);
3361 return;
3362 }
3363
3364 if app.view_mode == ViewMode::SessionPicker {
3366 let chunks = Layout::default()
3367 .direction(Direction::Vertical)
3368 .constraints([
3369 Constraint::Min(1), Constraint::Length(1), ])
3372 .split(f.area());
3373
3374 let filter_display = if app.session_picker_filter.is_empty() {
3376 String::new()
3377 } else {
3378 format!(" [filter: {}]", app.session_picker_filter)
3379 };
3380
3381 let list_block = Block::default()
3382 .borders(Borders::ALL)
3383 .title(format!(
3384 " Sessions (↑↓ navigate, Enter load, d delete, Esc cancel){} ",
3385 filter_display
3386 ))
3387 .border_style(Style::default().fg(Color::Cyan));
3388
3389 let mut list_lines: Vec<Line> = Vec::new();
3390 list_lines.push(Line::from(""));
3391
3392 let filtered = app.filtered_sessions();
3393 if filtered.is_empty() {
3394 if app.session_picker_filter.is_empty() {
3395 list_lines.push(Line::styled(
3396 " No sessions found.",
3397 Style::default().fg(Color::DarkGray),
3398 ));
3399 } else {
3400 list_lines.push(Line::styled(
3401 format!(" No sessions matching '{}'", app.session_picker_filter),
3402 Style::default().fg(Color::DarkGray),
3403 ));
3404 }
3405 }
3406
3407 for (display_idx, (_orig_idx, session)) in filtered.iter().enumerate() {
3408 let is_selected = display_idx == app.session_picker_selected;
3409 let is_active = app
3410 .session
3411 .as_ref()
3412 .map(|s| s.id == session.id)
3413 .unwrap_or(false);
3414 let title = session.title.as_deref().unwrap_or("(untitled)");
3415 let date = session.updated_at.format("%Y-%m-%d %H:%M");
3416 let active_marker = if is_active { " ●" } else { "" };
3417 let line_str = format!(
3418 " {} {}{} - {} ({} msgs)",
3419 if is_selected { "▶" } else { " " },
3420 title,
3421 active_marker,
3422 date,
3423 session.message_count
3424 );
3425
3426 let style = if is_selected && app.session_picker_confirm_delete {
3427 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
3428 } else if is_selected {
3429 Style::default()
3430 .fg(Color::Cyan)
3431 .add_modifier(Modifier::BOLD)
3432 } else if is_active {
3433 Style::default().fg(Color::Green)
3434 } else {
3435 Style::default()
3436 };
3437
3438 list_lines.push(Line::styled(line_str, style));
3439
3440 if is_selected {
3442 if app.session_picker_confirm_delete {
3443 list_lines.push(Line::styled(
3444 " ⚠ Press d again to confirm delete, Esc to cancel",
3445 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
3446 ));
3447 } else {
3448 list_lines.push(Line::styled(
3449 format!(" Agent: {} | ID: {}", session.agent, session.id),
3450 Style::default().fg(Color::DarkGray),
3451 ));
3452 }
3453 }
3454 }
3455
3456 let list = Paragraph::new(list_lines)
3457 .block(list_block)
3458 .wrap(Wrap { trim: false });
3459 f.render_widget(list, chunks[0]);
3460
3461 let mut status_spans = vec![
3463 Span::styled(
3464 " SESSION PICKER ",
3465 Style::default().fg(Color::Black).bg(Color::Cyan),
3466 ),
3467 Span::raw(" "),
3468 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3469 Span::raw(": Nav "),
3470 Span::styled("Enter", Style::default().fg(Color::Yellow)),
3471 Span::raw(": Load "),
3472 Span::styled("d", Style::default().fg(Color::Yellow)),
3473 Span::raw(": Delete "),
3474 Span::styled("Esc", Style::default().fg(Color::Yellow)),
3475 Span::raw(": Cancel "),
3476 ];
3477 if !app.session_picker_filter.is_empty() || !app.session_picker_list.is_empty() {
3478 status_spans.push(Span::styled("Type", Style::default().fg(Color::Yellow)));
3479 status_spans.push(Span::raw(": Filter "));
3480 }
3481 let total = app.session_picker_list.len();
3482 let showing = filtered.len();
3483 if showing < total {
3484 status_spans.push(Span::styled(
3485 format!("{}/{}", showing, total),
3486 Style::default().fg(Color::DarkGray),
3487 ));
3488 }
3489
3490 let status = Paragraph::new(Line::from(status_spans));
3491 f.render_widget(status, chunks[1]);
3492 return;
3493 }
3494
3495 if app.view_mode == ViewMode::AgentPicker {
3497 let area = centered_rect(70, 70, f.area());
3498 f.render_widget(Clear, area);
3499
3500 let filter_display = if app.agent_picker_filter.is_empty() {
3501 "type to filter".to_string()
3502 } else {
3503 format!("filter: {}", app.agent_picker_filter)
3504 };
3505
3506 let picker_block = Block::default()
3507 .borders(Borders::ALL)
3508 .title(format!(
3509 " Select Agent (↑↓ navigate, Enter focus, m main chat, Esc cancel) [{}] ",
3510 filter_display
3511 ))
3512 .border_style(Style::default().fg(Color::Magenta));
3513
3514 let filtered = app.filtered_spawned_agents();
3515 let mut list_lines: Vec<Line> = Vec::new();
3516 list_lines.push(Line::from(""));
3517
3518 if let Some(ref active) = app.active_spawned_agent {
3519 list_lines.push(Line::styled(
3520 format!(" Current focus: @{}", active),
3521 Style::default()
3522 .fg(Color::Green)
3523 .add_modifier(Modifier::DIM),
3524 ));
3525 list_lines.push(Line::from(""));
3526 }
3527
3528 if filtered.is_empty() {
3529 list_lines.push(Line::styled(
3530 " No spawned agents match filter",
3531 Style::default().fg(Color::DarkGray),
3532 ));
3533 } else {
3534 for (display_idx, (name, instructions, is_processing)) in filtered.iter().enumerate() {
3535 let is_selected = display_idx == app.agent_picker_selected;
3536 let is_focused = app.active_spawned_agent.as_deref() == Some(name.as_str());
3537 let marker = if is_selected { "▶" } else { " " };
3538 let focused_marker = if is_focused { " ✓" } else { "" };
3539 let status = if *is_processing { "⚡" } else { "●" };
3540
3541 let style = if is_selected {
3542 Style::default()
3543 .fg(Color::Magenta)
3544 .add_modifier(Modifier::BOLD)
3545 } else if is_focused {
3546 Style::default().fg(Color::Green)
3547 } else {
3548 Style::default()
3549 };
3550
3551 list_lines.push(Line::styled(
3552 format!(" {marker} {status} @{name}{focused_marker}"),
3553 style,
3554 ));
3555
3556 if is_selected {
3557 list_lines.push(Line::styled(
3558 format!(" {}", instructions),
3559 Style::default().fg(Color::DarkGray),
3560 ));
3561 }
3562 }
3563 }
3564
3565 let list = Paragraph::new(list_lines)
3566 .block(picker_block)
3567 .wrap(Wrap { trim: false });
3568 f.render_widget(list, area);
3569 return;
3570 }
3571
3572 if app.chat_layout == ChatLayoutMode::Webview {
3573 if render_webview_chat(f, app, theme) {
3574 render_help_overlay_if_needed(f, app, theme);
3575 return;
3576 }
3577 }
3578
3579 let chunks = Layout::default()
3581 .direction(Direction::Vertical)
3582 .constraints([
3583 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
3587 .split(f.area());
3588
3589 let messages_area = chunks[0];
3591 let model_label = app.active_model.as_deref().unwrap_or("auto");
3592 let target_label = app
3593 .active_spawned_agent
3594 .as_ref()
3595 .map(|name| format!(" @{}", name))
3596 .unwrap_or_default();
3597 let messages_block = Block::default()
3598 .borders(Borders::ALL)
3599 .title(format!(
3600 " CodeTether Agent [{}{}] model:{} ",
3601 app.current_agent, target_label, model_label
3602 ))
3603 .border_style(Style::default().fg(theme.border_color.to_color()));
3604
3605 let max_width = messages_area.width.saturating_sub(4) as usize;
3606 let message_lines = build_message_lines(app, theme, max_width);
3607
3608 let total_lines = message_lines.len();
3610 let visible_lines = messages_area.height.saturating_sub(2) as usize;
3611 let max_scroll = total_lines.saturating_sub(visible_lines);
3612 let scroll = if app.scroll >= SCROLL_BOTTOM {
3614 max_scroll
3615 } else {
3616 app.scroll.min(max_scroll)
3617 };
3618
3619 let messages_paragraph = Paragraph::new(
3621 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
3622 )
3623 .block(messages_block.clone())
3624 .wrap(Wrap { trim: false });
3625
3626 f.render_widget(messages_paragraph, messages_area);
3627
3628 if total_lines > visible_lines {
3630 let scrollbar = Scrollbar::default()
3631 .orientation(ScrollbarOrientation::VerticalRight)
3632 .symbols(ratatui::symbols::scrollbar::VERTICAL)
3633 .begin_symbol(Some("↑"))
3634 .end_symbol(Some("↓"));
3635
3636 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
3637
3638 let scrollbar_area = Rect::new(
3639 messages_area.right() - 1,
3640 messages_area.top() + 1,
3641 1,
3642 messages_area.height - 2,
3643 );
3644
3645 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
3646 }
3647
3648 let input_title = if app.is_processing {
3650 if let Some(started) = app.processing_started_at {
3651 let elapsed = started.elapsed();
3652 format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
3653 } else {
3654 " Message (Processing...) ".to_string()
3655 }
3656 } else if app.input.starts_with('/') {
3657 let hint = match_slash_command_hint(&app.input);
3658 format!(" {} ", hint)
3659 } else if let Some(target) = &app.active_spawned_agent {
3660 format!(" Message to @{target} (use /agent main to exit) ")
3661 } else {
3662 " Message (Enter to send, / for commands) ".to_string()
3663 };
3664 let input_block = Block::default()
3665 .borders(Borders::ALL)
3666 .title(input_title)
3667 .border_style(Style::default().fg(if app.is_processing {
3668 Color::Yellow
3669 } else if app.input.starts_with('/') {
3670 Color::Magenta
3671 } else {
3672 theme.input_border_color.to_color()
3673 }));
3674
3675 let input = Paragraph::new(app.input.as_str())
3676 .block(input_block)
3677 .wrap(Wrap { trim: false });
3678 f.render_widget(input, chunks[1]);
3679
3680 f.set_cursor_position((
3682 chunks[1].x + app.cursor_position as u16 + 1,
3683 chunks[1].y + 1,
3684 ));
3685
3686 let token_display = TokenDisplay::new();
3688 let mut status_line = token_display.create_status_bar(theme);
3689 let model_status = if let Some(ref active) = app.active_model {
3690 let (provider, model) = crate::provider::parse_model_string(active);
3691 format!(" {}:{} ", provider.unwrap_or("auto"), model)
3692 } else {
3693 " auto ".to_string()
3694 };
3695 status_line.spans.insert(
3696 0,
3697 Span::styled(
3698 "│ ",
3699 Style::default()
3700 .fg(theme.timestamp_color.to_color())
3701 .add_modifier(Modifier::DIM),
3702 ),
3703 );
3704 status_line.spans.insert(
3705 0,
3706 Span::styled(model_status, Style::default().fg(Color::Cyan)),
3707 );
3708 let status = Paragraph::new(status_line);
3709 f.render_widget(status, chunks[2]);
3710
3711 render_help_overlay_if_needed(f, app, theme);
3712}
3713
3714fn render_webview_chat(f: &mut Frame, app: &App, theme: &Theme) -> bool {
3715 let area = f.area();
3716 if area.width < 90 || area.height < 18 {
3717 return false;
3718 }
3719
3720 let main_chunks = Layout::default()
3721 .direction(Direction::Vertical)
3722 .constraints([
3723 Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
3728 .split(area);
3729
3730 render_webview_header(f, app, theme, main_chunks[0]);
3731
3732 let body_constraints = if app.show_inspector {
3733 vec![
3734 Constraint::Length(26),
3735 Constraint::Min(40),
3736 Constraint::Length(30),
3737 ]
3738 } else {
3739 vec![Constraint::Length(26), Constraint::Min(40)]
3740 };
3741
3742 let body_chunks = Layout::default()
3743 .direction(Direction::Horizontal)
3744 .constraints(body_constraints)
3745 .split(main_chunks[1]);
3746
3747 render_webview_sidebar(f, app, theme, body_chunks[0]);
3748 render_webview_chat_center(f, app, theme, body_chunks[1]);
3749 if app.show_inspector && body_chunks.len() > 2 {
3750 render_webview_inspector(f, app, theme, body_chunks[2]);
3751 }
3752
3753 render_webview_input(f, app, theme, main_chunks[2]);
3754
3755 let token_display = TokenDisplay::new();
3756 let mut status_line = token_display.create_status_bar(theme);
3757 let model_status = if let Some(ref active) = app.active_model {
3758 let (provider, model) = crate::provider::parse_model_string(active);
3759 format!(" {}:{} ", provider.unwrap_or("auto"), model)
3760 } else {
3761 " auto ".to_string()
3762 };
3763 status_line.spans.insert(
3764 0,
3765 Span::styled(
3766 "│ ",
3767 Style::default()
3768 .fg(theme.timestamp_color.to_color())
3769 .add_modifier(Modifier::DIM),
3770 ),
3771 );
3772 status_line.spans.insert(
3773 0,
3774 Span::styled(model_status, Style::default().fg(Color::Cyan)),
3775 );
3776 let status = Paragraph::new(status_line);
3777 f.render_widget(status, main_chunks[3]);
3778
3779 true
3780}
3781
3782fn render_webview_header(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
3783 let session_title = app
3784 .session
3785 .as_ref()
3786 .and_then(|s| s.title.clone())
3787 .unwrap_or_else(|| "Workspace Chat".to_string());
3788 let session_id = app
3789 .session
3790 .as_ref()
3791 .map(|s| s.id.chars().take(8).collect::<String>())
3792 .unwrap_or_else(|| "new".to_string());
3793 let model_label = app
3794 .session
3795 .as_ref()
3796 .and_then(|s| s.metadata.model.clone())
3797 .unwrap_or_else(|| "auto".to_string());
3798 let workspace_label = app.workspace.root_display.clone();
3799 let branch_label = app
3800 .workspace
3801 .git_branch
3802 .clone()
3803 .unwrap_or_else(|| "no-git".to_string());
3804 let dirty_label = if app.workspace.git_dirty_files > 0 {
3805 format!("{} dirty", app.workspace.git_dirty_files)
3806 } else {
3807 "clean".to_string()
3808 };
3809
3810 let header_block = Block::default()
3811 .borders(Borders::ALL)
3812 .title(" CodeTether Webview ")
3813 .border_style(Style::default().fg(theme.border_color.to_color()));
3814
3815 let header_lines = vec![
3816 Line::from(vec![
3817 Span::styled(session_title, Style::default().add_modifier(Modifier::BOLD)),
3818 Span::raw(" "),
3819 Span::styled(
3820 format!("#{}", session_id),
3821 Style::default()
3822 .fg(theme.timestamp_color.to_color())
3823 .add_modifier(Modifier::DIM),
3824 ),
3825 ]),
3826 Line::from(vec![
3827 Span::styled(
3828 "Workspace ",
3829 Style::default().fg(theme.timestamp_color.to_color()),
3830 ),
3831 Span::styled(workspace_label, Style::default()),
3832 Span::raw(" "),
3833 Span::styled(
3834 "Branch ",
3835 Style::default().fg(theme.timestamp_color.to_color()),
3836 ),
3837 Span::styled(
3838 branch_label,
3839 Style::default()
3840 .fg(Color::Cyan)
3841 .add_modifier(Modifier::BOLD),
3842 ),
3843 Span::raw(" "),
3844 Span::styled(
3845 dirty_label,
3846 Style::default()
3847 .fg(Color::Yellow)
3848 .add_modifier(Modifier::BOLD),
3849 ),
3850 Span::raw(" "),
3851 Span::styled(
3852 "Model ",
3853 Style::default().fg(theme.timestamp_color.to_color()),
3854 ),
3855 Span::styled(model_label, Style::default().fg(Color::Green)),
3856 ]),
3857 ];
3858
3859 let header = Paragraph::new(header_lines)
3860 .block(header_block)
3861 .wrap(Wrap { trim: true });
3862 f.render_widget(header, area);
3863}
3864
3865fn render_webview_sidebar(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
3866 let sidebar_chunks = Layout::default()
3867 .direction(Direction::Vertical)
3868 .constraints([Constraint::Min(8), Constraint::Min(6)])
3869 .split(area);
3870
3871 let workspace_block = Block::default()
3872 .borders(Borders::ALL)
3873 .title(" Workspace ")
3874 .border_style(Style::default().fg(theme.border_color.to_color()));
3875
3876 let mut workspace_lines = Vec::new();
3877 workspace_lines.push(Line::from(vec![
3878 Span::styled(
3879 "Updated ",
3880 Style::default().fg(theme.timestamp_color.to_color()),
3881 ),
3882 Span::styled(
3883 app.workspace.captured_at.clone(),
3884 Style::default().fg(theme.timestamp_color.to_color()),
3885 ),
3886 ]));
3887 workspace_lines.push(Line::from(""));
3888
3889 if app.workspace.entries.is_empty() {
3890 workspace_lines.push(Line::styled(
3891 "No entries found",
3892 Style::default().fg(Color::DarkGray),
3893 ));
3894 } else {
3895 for entry in app.workspace.entries.iter().take(12) {
3896 let icon = match entry.kind {
3897 WorkspaceEntryKind::Directory => "📁",
3898 WorkspaceEntryKind::File => "📄",
3899 };
3900 workspace_lines.push(Line::from(vec![
3901 Span::styled(icon, Style::default().fg(Color::Cyan)),
3902 Span::raw(" "),
3903 Span::styled(entry.name.clone(), Style::default()),
3904 ]));
3905 }
3906 }
3907
3908 workspace_lines.push(Line::from(""));
3909 workspace_lines.push(Line::styled(
3910 "Use /refresh to rescan",
3911 Style::default()
3912 .fg(Color::DarkGray)
3913 .add_modifier(Modifier::DIM),
3914 ));
3915
3916 let workspace_panel = Paragraph::new(workspace_lines)
3917 .block(workspace_block)
3918 .wrap(Wrap { trim: true });
3919 f.render_widget(workspace_panel, sidebar_chunks[0]);
3920
3921 let sessions_block = Block::default()
3922 .borders(Borders::ALL)
3923 .title(" Recent Sessions ")
3924 .border_style(Style::default().fg(theme.border_color.to_color()));
3925
3926 let mut session_lines = Vec::new();
3927 if app.session_picker_list.is_empty() {
3928 session_lines.push(Line::styled(
3929 "No sessions yet",
3930 Style::default().fg(Color::DarkGray),
3931 ));
3932 } else {
3933 for session in app.session_picker_list.iter().take(6) {
3934 let is_active = app
3935 .session
3936 .as_ref()
3937 .map(|s| s.id == session.id)
3938 .unwrap_or(false);
3939 let title = session.title.as_deref().unwrap_or("(untitled)");
3940 let indicator = if is_active { "●" } else { "○" };
3941 let line_style = if is_active {
3942 Style::default()
3943 .fg(Color::Cyan)
3944 .add_modifier(Modifier::BOLD)
3945 } else {
3946 Style::default()
3947 };
3948 session_lines.push(Line::from(vec![
3949 Span::styled(indicator, line_style),
3950 Span::raw(" "),
3951 Span::styled(title, line_style),
3952 ]));
3953 session_lines.push(Line::styled(
3954 format!(
3955 " {} msgs • {}",
3956 session.message_count,
3957 session.updated_at.format("%m-%d %H:%M")
3958 ),
3959 Style::default().fg(Color::DarkGray),
3960 ));
3961 }
3962 }
3963
3964 let sessions_panel = Paragraph::new(session_lines)
3965 .block(sessions_block)
3966 .wrap(Wrap { trim: true });
3967 f.render_widget(sessions_panel, sidebar_chunks[1]);
3968}
3969
3970fn render_webview_chat_center(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
3971 let messages_area = area;
3972 let focused_suffix = app
3973 .active_spawned_agent
3974 .as_ref()
3975 .map(|name| format!(" → @{name}"))
3976 .unwrap_or_default();
3977 let messages_block = Block::default()
3978 .borders(Borders::ALL)
3979 .title(format!(" Chat [{}{}] ", app.current_agent, focused_suffix))
3980 .border_style(Style::default().fg(theme.border_color.to_color()));
3981
3982 let max_width = messages_area.width.saturating_sub(4) as usize;
3983 let message_lines = build_message_lines(app, theme, max_width);
3984
3985 let total_lines = message_lines.len();
3986 let visible_lines = messages_area.height.saturating_sub(2) as usize;
3987 let max_scroll = total_lines.saturating_sub(visible_lines);
3988 let scroll = if app.scroll >= SCROLL_BOTTOM {
3989 max_scroll
3990 } else {
3991 app.scroll.min(max_scroll)
3992 };
3993
3994 let messages_paragraph = Paragraph::new(
3995 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
3996 )
3997 .block(messages_block.clone())
3998 .wrap(Wrap { trim: false });
3999
4000 f.render_widget(messages_paragraph, messages_area);
4001
4002 if total_lines > visible_lines {
4003 let scrollbar = Scrollbar::default()
4004 .orientation(ScrollbarOrientation::VerticalRight)
4005 .symbols(ratatui::symbols::scrollbar::VERTICAL)
4006 .begin_symbol(Some("↑"))
4007 .end_symbol(Some("↓"));
4008
4009 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
4010
4011 let scrollbar_area = Rect::new(
4012 messages_area.right() - 1,
4013 messages_area.top() + 1,
4014 1,
4015 messages_area.height - 2,
4016 );
4017
4018 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
4019 }
4020}
4021
4022fn render_webview_inspector(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
4023 let block = Block::default()
4024 .borders(Borders::ALL)
4025 .title(" Inspector ")
4026 .border_style(Style::default().fg(theme.border_color.to_color()));
4027
4028 let status_label = if app.is_processing {
4029 "Processing"
4030 } else {
4031 "Idle"
4032 };
4033 let status_style = if app.is_processing {
4034 Style::default()
4035 .fg(Color::Yellow)
4036 .add_modifier(Modifier::BOLD)
4037 } else {
4038 Style::default().fg(Color::Green)
4039 };
4040 let tool_label = app
4041 .current_tool
4042 .clone()
4043 .unwrap_or_else(|| "none".to_string());
4044 let message_count = app.messages.len();
4045 let session_id = app
4046 .session
4047 .as_ref()
4048 .map(|s| s.id.chars().take(8).collect::<String>())
4049 .unwrap_or_else(|| "new".to_string());
4050 let model_label = app
4051 .active_model
4052 .as_deref()
4053 .or_else(|| {
4054 app.session
4055 .as_ref()
4056 .and_then(|s| s.metadata.model.as_deref())
4057 })
4058 .unwrap_or("auto");
4059 let conversation_depth = app.session.as_ref().map(|s| s.messages.len()).unwrap_or(0);
4060
4061 let label_style = Style::default().fg(theme.timestamp_color.to_color());
4062
4063 let mut lines = Vec::new();
4064 lines.push(Line::from(vec![
4065 Span::styled("Status: ", label_style),
4066 Span::styled(status_label, status_style),
4067 ]));
4068
4069 if let Some(started) = app.processing_started_at {
4071 let elapsed = started.elapsed();
4072 let elapsed_str = if elapsed.as_secs() >= 60 {
4073 format!("{}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
4074 } else {
4075 format!("{:.1}s", elapsed.as_secs_f64())
4076 };
4077 lines.push(Line::from(vec![
4078 Span::styled("Elapsed: ", label_style),
4079 Span::styled(
4080 elapsed_str,
4081 Style::default()
4082 .fg(Color::Yellow)
4083 .add_modifier(Modifier::BOLD),
4084 ),
4085 ]));
4086 }
4087
4088 lines.push(Line::from(vec![
4089 Span::styled("Tool: ", label_style),
4090 Span::styled(
4091 tool_label,
4092 if app.current_tool.is_some() {
4093 Style::default()
4094 .fg(Color::Cyan)
4095 .add_modifier(Modifier::BOLD)
4096 } else {
4097 Style::default().fg(Color::DarkGray)
4098 },
4099 ),
4100 ]));
4101 lines.push(Line::from(""));
4102 lines.push(Line::styled(
4103 "Session",
4104 Style::default().add_modifier(Modifier::BOLD),
4105 ));
4106 lines.push(Line::from(vec![
4107 Span::styled("ID: ", label_style),
4108 Span::styled(format!("#{}", session_id), Style::default().fg(Color::Cyan)),
4109 ]));
4110 lines.push(Line::from(vec![
4111 Span::styled("Model: ", label_style),
4112 Span::styled(model_label.to_string(), Style::default().fg(Color::Green)),
4113 ]));
4114 let agent_display = if let Some(target) = &app.active_spawned_agent {
4115 format!("{} → @{} (focused)", app.current_agent, target)
4116 } else {
4117 app.current_agent.clone()
4118 };
4119 lines.push(Line::from(vec![
4120 Span::styled("Agent: ", label_style),
4121 Span::styled(agent_display, Style::default()),
4122 ]));
4123 lines.push(Line::from(vec![
4124 Span::styled("Messages: ", label_style),
4125 Span::styled(message_count.to_string(), Style::default()),
4126 ]));
4127 lines.push(Line::from(vec![
4128 Span::styled("Context: ", label_style),
4129 Span::styled(format!("{} turns", conversation_depth), Style::default()),
4130 ]));
4131 lines.push(Line::from(vec![
4132 Span::styled("Tools used: ", label_style),
4133 Span::styled(app.tool_call_count.to_string(), Style::default()),
4134 ]));
4135 lines.push(Line::from(""));
4136 lines.push(Line::styled(
4137 "Sub-agents",
4138 Style::default().add_modifier(Modifier::BOLD),
4139 ));
4140 if app.spawned_agents.is_empty() {
4141 lines.push(Line::styled(
4142 "None (use /spawn <name> <instructions>)",
4143 Style::default().fg(Color::DarkGray),
4144 ));
4145 } else {
4146 for (name, agent) in app.spawned_agents.iter().take(4) {
4147 let status = if agent.is_processing { "⚡" } else { "●" };
4148 let focused = if app.active_spawned_agent.as_deref() == Some(name.as_str()) {
4149 " [focused]"
4150 } else {
4151 ""
4152 };
4153 lines.push(Line::styled(
4154 format!("{status} @{name}{focused}"),
4155 if focused.is_empty() {
4156 Style::default().fg(Color::Magenta)
4157 } else {
4158 Style::default()
4159 .fg(Color::Magenta)
4160 .add_modifier(Modifier::BOLD)
4161 },
4162 ));
4163 lines.push(Line::styled(
4164 format!(" {}", agent.instructions),
4165 Style::default()
4166 .fg(Color::DarkGray)
4167 .add_modifier(Modifier::DIM),
4168 ));
4169 }
4170 if app.spawned_agents.len() > 4 {
4171 lines.push(Line::styled(
4172 format!("… and {} more", app.spawned_agents.len() - 4),
4173 Style::default()
4174 .fg(Color::DarkGray)
4175 .add_modifier(Modifier::DIM),
4176 ));
4177 }
4178 }
4179 lines.push(Line::from(""));
4180 lines.push(Line::styled(
4181 "Shortcuts",
4182 Style::default().add_modifier(Modifier::BOLD),
4183 ));
4184 lines.push(Line::from(vec![
4185 Span::styled("F3 ", Style::default().fg(Color::Yellow)),
4186 Span::styled("Inspector", Style::default().fg(Color::DarkGray)),
4187 ]));
4188 lines.push(Line::from(vec![
4189 Span::styled("Ctrl+B ", Style::default().fg(Color::Yellow)),
4190 Span::styled("Layout", Style::default().fg(Color::DarkGray)),
4191 ]));
4192 lines.push(Line::from(vec![
4193 Span::styled("Ctrl+Y ", Style::default().fg(Color::Yellow)),
4194 Span::styled("Copy", Style::default().fg(Color::DarkGray)),
4195 ]));
4196 lines.push(Line::from(vec![
4197 Span::styled("Ctrl+M ", Style::default().fg(Color::Yellow)),
4198 Span::styled("Model", Style::default().fg(Color::DarkGray)),
4199 ]));
4200 lines.push(Line::from(vec![
4201 Span::styled("Ctrl+S ", Style::default().fg(Color::Yellow)),
4202 Span::styled("Swarm", Style::default().fg(Color::DarkGray)),
4203 ]));
4204 lines.push(Line::from(vec![
4205 Span::styled("? ", Style::default().fg(Color::Yellow)),
4206 Span::styled("Help", Style::default().fg(Color::DarkGray)),
4207 ]));
4208
4209 let panel = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
4210 f.render_widget(panel, area);
4211}
4212
4213fn render_webview_input(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
4214 let title = if app.is_processing {
4215 if let Some(started) = app.processing_started_at {
4216 let elapsed = started.elapsed();
4217 format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
4218 } else {
4219 " Message (Processing...) ".to_string()
4220 }
4221 } else if app.input.starts_with('/') {
4222 let hint = match_slash_command_hint(&app.input);
4224 format!(" {} ", hint)
4225 } else if let Some(target) = &app.active_spawned_agent {
4226 format!(" Message to @{target} (use /agent main to exit) ")
4227 } else {
4228 " Message (Enter to send, / for commands) ".to_string()
4229 };
4230
4231 let input_block = Block::default()
4232 .borders(Borders::ALL)
4233 .title(title)
4234 .border_style(Style::default().fg(if app.is_processing {
4235 Color::Yellow
4236 } else if app.input.starts_with('/') {
4237 Color::Magenta
4238 } else {
4239 theme.input_border_color.to_color()
4240 }));
4241
4242 let input = Paragraph::new(app.input.as_str())
4243 .block(input_block)
4244 .wrap(Wrap { trim: false });
4245 f.render_widget(input, area);
4246
4247 f.set_cursor_position((area.x + app.cursor_position as u16 + 1, area.y + 1));
4248}
4249
4250fn build_message_lines(app: &App, theme: &Theme, max_width: usize) -> Vec<Line<'static>> {
4251 let mut message_lines = Vec::new();
4252 let separator_width = max_width.min(60);
4253
4254 for (idx, message) in app.messages.iter().enumerate() {
4255 let role_style = theme.get_role_style(&message.role);
4256
4257 if idx > 0 {
4259 let sep_char = match message.role.as_str() {
4260 "tool" => "·",
4261 _ => "─",
4262 };
4263 message_lines.push(Line::from(Span::styled(
4264 sep_char.repeat(separator_width),
4265 Style::default()
4266 .fg(theme.timestamp_color.to_color())
4267 .add_modifier(Modifier::DIM),
4268 )));
4269 }
4270
4271 let role_icon = match message.role.as_str() {
4273 "user" => "▸ ",
4274 "assistant" => "◆ ",
4275 "system" => "⚙ ",
4276 "tool" => "⚡",
4277 _ => " ",
4278 };
4279
4280 let header_line = {
4281 let mut spans = vec![
4282 Span::styled(
4283 format!("[{}] ", message.timestamp),
4284 Style::default()
4285 .fg(theme.timestamp_color.to_color())
4286 .add_modifier(Modifier::DIM),
4287 ),
4288 Span::styled(role_icon, role_style),
4289 Span::styled(message.role.clone(), role_style),
4290 ];
4291 if let Some(ref agent) = message.agent_name {
4292 spans.push(Span::styled(
4293 format!(" @{agent}"),
4294 Style::default()
4295 .fg(Color::Magenta)
4296 .add_modifier(Modifier::BOLD),
4297 ));
4298 }
4299 Line::from(spans)
4300 };
4301 message_lines.push(header_line);
4302
4303 match &message.message_type {
4304 MessageType::ToolCall {
4305 name,
4306 arguments_preview,
4307 arguments_len,
4308 truncated,
4309 } => {
4310 let tool_header = Line::from(vec![
4311 Span::styled(" 🔧 ", Style::default().fg(Color::Yellow)),
4312 Span::styled(
4313 format!("Tool: {}", name),
4314 Style::default()
4315 .fg(Color::Yellow)
4316 .add_modifier(Modifier::BOLD),
4317 ),
4318 ]);
4319 message_lines.push(tool_header);
4320
4321 if arguments_preview.trim().is_empty() {
4322 message_lines.push(Line::from(vec![
4323 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
4324 Span::styled(
4325 "(no arguments)",
4326 Style::default()
4327 .fg(Color::DarkGray)
4328 .add_modifier(Modifier::DIM),
4329 ),
4330 ]));
4331 } else {
4332 for line in arguments_preview.lines() {
4333 let args_line = Line::from(vec![
4334 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
4335 Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
4336 ]);
4337 message_lines.push(args_line);
4338 }
4339 }
4340
4341 if *truncated {
4342 let args_line = Line::from(vec![
4343 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
4344 Span::styled(
4345 format!("... (truncated; {} bytes)", arguments_len),
4346 Style::default()
4347 .fg(Color::DarkGray)
4348 .add_modifier(Modifier::DIM),
4349 ),
4350 ]);
4351 message_lines.push(args_line);
4352 }
4353 }
4354 MessageType::ToolResult {
4355 name,
4356 output_preview,
4357 output_len,
4358 truncated,
4359 } => {
4360 let result_header = Line::from(vec![
4361 Span::styled(" ✅ ", Style::default().fg(Color::Green)),
4362 Span::styled(
4363 format!("Result from {}", name),
4364 Style::default()
4365 .fg(Color::Green)
4366 .add_modifier(Modifier::BOLD),
4367 ),
4368 ]);
4369 message_lines.push(result_header);
4370
4371 if output_preview.trim().is_empty() {
4372 message_lines.push(Line::from(vec![
4373 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
4374 Span::styled(
4375 "(empty output)",
4376 Style::default()
4377 .fg(Color::DarkGray)
4378 .add_modifier(Modifier::DIM),
4379 ),
4380 ]));
4381 } else {
4382 for line in output_preview.lines() {
4383 let output_line = Line::from(vec![
4384 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
4385 Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
4386 ]);
4387 message_lines.push(output_line);
4388 }
4389 }
4390
4391 if *truncated {
4392 message_lines.push(Line::from(vec![
4393 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
4394 Span::styled(
4395 format!("... (truncated; {} bytes)", output_len),
4396 Style::default()
4397 .fg(Color::DarkGray)
4398 .add_modifier(Modifier::DIM),
4399 ),
4400 ]));
4401 }
4402 }
4403 MessageType::Text(text) => {
4404 let formatter = MessageFormatter::new(max_width);
4405 let formatted_content = formatter.format_content(text, &message.role);
4406 message_lines.extend(formatted_content);
4407 }
4408 MessageType::Thinking(text) => {
4409 let thinking_style = Style::default()
4410 .fg(Color::DarkGray)
4411 .add_modifier(Modifier::DIM | Modifier::ITALIC);
4412 message_lines.push(Line::from(Span::styled(
4413 " 💭 Thinking...",
4414 Style::default()
4415 .fg(Color::Magenta)
4416 .add_modifier(Modifier::DIM),
4417 )));
4418 let max_thinking_lines = 8;
4420 let mut iter = text.lines();
4421 let mut shown = 0usize;
4422 while shown < max_thinking_lines {
4423 let Some(line) = iter.next() else { break };
4424 message_lines.push(Line::from(vec![
4425 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
4426 Span::styled(line.to_string(), thinking_style),
4427 ]));
4428 shown += 1;
4429 }
4430 if iter.next().is_some() {
4431 message_lines.push(Line::from(Span::styled(
4432 " │ ... (truncated)",
4433 thinking_style,
4434 )));
4435 }
4436 }
4437 MessageType::Image { url, mime_type } => {
4438 let formatter = MessageFormatter::new(max_width);
4439 let image_line = formatter.format_image(url, mime_type.as_deref());
4440 message_lines.push(image_line);
4441 }
4442 MessageType::File { path, mime_type } => {
4443 let mime_label = mime_type.as_deref().unwrap_or("unknown type");
4444 let file_header = Line::from(vec![
4445 Span::styled(" 📎 ", Style::default().fg(Color::Cyan)),
4446 Span::styled(
4447 format!("File: {}", path),
4448 Style::default()
4449 .fg(Color::Cyan)
4450 .add_modifier(Modifier::BOLD),
4451 ),
4452 Span::styled(
4453 format!(" ({})", mime_label),
4454 Style::default()
4455 .fg(Color::DarkGray)
4456 .add_modifier(Modifier::DIM),
4457 ),
4458 ]);
4459 message_lines.push(file_header);
4460 }
4461 }
4462
4463 if message.role == "assistant" {
4465 if let Some(ref meta) = message.usage_meta {
4466 let duration_str = if meta.duration_ms >= 60_000 {
4467 format!(
4468 "{}m{:02}.{}s",
4469 meta.duration_ms / 60_000,
4470 (meta.duration_ms % 60_000) / 1000,
4471 (meta.duration_ms % 1000) / 100
4472 )
4473 } else {
4474 format!(
4475 "{}.{}s",
4476 meta.duration_ms / 1000,
4477 (meta.duration_ms % 1000) / 100
4478 )
4479 };
4480 let tokens_str =
4481 format!("{}→{} tokens", meta.prompt_tokens, meta.completion_tokens);
4482 let cost_str = match meta.cost_usd {
4483 Some(c) if c < 0.01 => format!("${:.4}", c),
4484 Some(c) => format!("${:.2}", c),
4485 None => String::new(),
4486 };
4487 let dim_style = Style::default()
4488 .fg(theme.timestamp_color.to_color())
4489 .add_modifier(Modifier::DIM);
4490 let mut spans = vec![Span::styled(
4491 format!(" ⏱ {} │ 📊 {}", duration_str, tokens_str),
4492 dim_style,
4493 )];
4494 if !cost_str.is_empty() {
4495 spans.push(Span::styled(format!(" │ 💰 {}", cost_str), dim_style));
4496 }
4497 message_lines.push(Line::from(spans));
4498 }
4499 }
4500
4501 message_lines.push(Line::from(""));
4502 }
4503
4504 if let Some(ref streaming) = app.streaming_text {
4506 if !streaming.is_empty() {
4507 message_lines.push(Line::from(Span::styled(
4508 "─".repeat(separator_width),
4509 Style::default()
4510 .fg(theme.timestamp_color.to_color())
4511 .add_modifier(Modifier::DIM),
4512 )));
4513 message_lines.push(Line::from(vec![
4514 Span::styled(
4515 format!("[{}] ", chrono::Local::now().format("%H:%M")),
4516 Style::default()
4517 .fg(theme.timestamp_color.to_color())
4518 .add_modifier(Modifier::DIM),
4519 ),
4520 Span::styled("◆ ", theme.get_role_style("assistant")),
4521 Span::styled("assistant", theme.get_role_style("assistant")),
4522 Span::styled(
4523 " (streaming...)",
4524 Style::default()
4525 .fg(theme.timestamp_color.to_color())
4526 .add_modifier(Modifier::DIM),
4527 ),
4528 ]));
4529 let formatter = MessageFormatter::new(max_width);
4530 let formatted = formatter.format_content(streaming, "assistant");
4531 message_lines.extend(formatted);
4532 message_lines.push(Line::from(""));
4533 }
4534 }
4535
4536 if app.is_processing {
4537 let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4538 let spinner_idx = (std::time::SystemTime::now()
4539 .duration_since(std::time::UNIX_EPOCH)
4540 .unwrap_or_default()
4541 .as_millis()
4542 / 100) as usize
4543 % spinner.len();
4544
4545 let elapsed_str = if let Some(started) = app.processing_started_at {
4547 let elapsed = started.elapsed();
4548 if elapsed.as_secs() >= 60 {
4549 format!(" {}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
4550 } else {
4551 format!(" {:.1}s", elapsed.as_secs_f64())
4552 }
4553 } else {
4554 String::new()
4555 };
4556
4557 let processing_line = Line::from(vec![
4558 Span::styled(
4559 format!("[{}] ", chrono::Local::now().format("%H:%M")),
4560 Style::default()
4561 .fg(theme.timestamp_color.to_color())
4562 .add_modifier(Modifier::DIM),
4563 ),
4564 Span::styled("◆ ", theme.get_role_style("assistant")),
4565 Span::styled("assistant", theme.get_role_style("assistant")),
4566 Span::styled(
4567 elapsed_str,
4568 Style::default()
4569 .fg(theme.timestamp_color.to_color())
4570 .add_modifier(Modifier::DIM),
4571 ),
4572 ]);
4573 message_lines.push(processing_line);
4574
4575 let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
4576 (
4577 format!(" {} Running: {}", spinner[spinner_idx], tool),
4578 Color::Cyan,
4579 )
4580 } else {
4581 (
4582 format!(
4583 " {} {}",
4584 spinner[spinner_idx],
4585 app.processing_message.as_deref().unwrap_or("Thinking...")
4586 ),
4587 Color::Yellow,
4588 )
4589 };
4590
4591 let indicator_line = Line::from(vec![Span::styled(
4592 status_text,
4593 Style::default()
4594 .fg(status_color)
4595 .add_modifier(Modifier::BOLD),
4596 )]);
4597 message_lines.push(indicator_line);
4598 message_lines.push(Line::from(""));
4599 }
4600
4601 message_lines
4602}
4603
4604fn match_slash_command_hint(input: &str) -> String {
4605 let commands = [
4606 ("/spawn ", "Create a named sub-agent"),
4607 ("/agents", "List spawned sub-agents"),
4608 ("/kill ", "Remove a spawned sub-agent"),
4609 ("/agent ", "Focus or message a spawned sub-agent"),
4610 ("/swarm ", "Run task in parallel swarm mode"),
4611 ("/ralph", "Start autonomous PRD loop"),
4612 ("/undo", "Undo last message and response"),
4613 ("/sessions", "Open session picker"),
4614 ("/resume", "Resume a session"),
4615 ("/new", "Start a new session"),
4616 ("/model", "Select or set model"),
4617 ("/webview", "Switch to webview layout"),
4618 ("/classic", "Switch to classic layout"),
4619 ("/inspector", "Toggle inspector pane"),
4620 ("/refresh", "Refresh workspace"),
4621 ("/view", "Toggle swarm view"),
4622 ("/buslog", "Show protocol bus log"),
4623 ];
4624
4625 let input_lower = input.to_lowercase();
4626 let matches: Vec<_> = commands
4627 .iter()
4628 .filter(|(cmd, _)| cmd.starts_with(&input_lower))
4629 .collect();
4630
4631 if matches.len() == 1 {
4632 format!("{} — {}", matches[0].0.trim(), matches[0].1)
4633 } else if matches.is_empty() {
4634 "Unknown command".to_string()
4635 } else {
4636 let cmds: Vec<_> = matches.iter().map(|(cmd, _)| cmd.trim()).collect();
4637 cmds.join(" | ")
4638 }
4639}
4640
4641fn format_tool_call_arguments(name: &str, arguments: &str) -> String {
4642 if arguments.len() > TOOL_ARGS_PRETTY_JSON_MAX_BYTES {
4646 return arguments.to_string();
4647 }
4648
4649 let parsed = match serde_json::from_str::<serde_json::Value>(arguments) {
4650 Ok(value) => value,
4651 Err(_) => return arguments.to_string(),
4652 };
4653
4654 if name == "question"
4655 && let Some(question) = parsed.get("question").and_then(serde_json::Value::as_str)
4656 {
4657 return question.to_string();
4658 }
4659
4660 serde_json::to_string_pretty(&parsed).unwrap_or_else(|_| arguments.to_string())
4661}
4662
4663fn build_tool_arguments_preview(
4664 tool_name: &str,
4665 arguments: &str,
4666 max_lines: usize,
4667 max_bytes: usize,
4668) -> (String, bool) {
4669 let formatted = format_tool_call_arguments(tool_name, arguments);
4671 build_text_preview(&formatted, max_lines, max_bytes)
4672}
4673
4674fn build_text_preview(text: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
4678 if max_lines == 0 || max_bytes == 0 || text.is_empty() {
4679 return (String::new(), !text.is_empty());
4680 }
4681
4682 let mut out = String::new();
4683 let mut truncated = false;
4684 let mut remaining = max_bytes;
4685
4686 let mut iter = text.lines();
4687 for i in 0..max_lines {
4688 let Some(line) = iter.next() else { break };
4689
4690 if i > 0 {
4692 if remaining == 0 {
4693 truncated = true;
4694 break;
4695 }
4696 out.push('\n');
4697 remaining = remaining.saturating_sub(1);
4698 }
4699
4700 if remaining == 0 {
4701 truncated = true;
4702 break;
4703 }
4704
4705 if line.len() <= remaining {
4706 out.push_str(line);
4707 remaining = remaining.saturating_sub(line.len());
4708 } else {
4709 let mut end = remaining;
4711 while end > 0 && !line.is_char_boundary(end) {
4712 end -= 1;
4713 }
4714 out.push_str(&line[..end]);
4715 truncated = true;
4716 break;
4717 }
4718 }
4719
4720 if !truncated && iter.next().is_some() {
4722 truncated = true;
4723 }
4724
4725 (out, truncated)
4726}
4727
4728fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
4729 if max_chars == 0 {
4730 return String::new();
4731 }
4732
4733 let mut chars = value.chars();
4734 let mut output = String::new();
4735 for _ in 0..max_chars {
4736 if let Some(ch) = chars.next() {
4737 output.push(ch);
4738 } else {
4739 return value.to_string();
4740 }
4741 }
4742
4743 if chars.next().is_some() {
4744 format!("{output}...")
4745 } else {
4746 output
4747 }
4748}
4749
4750fn message_clipboard_text(message: &ChatMessage) -> String {
4751 let mut prefix = String::new();
4752 if let Some(agent) = &message.agent_name {
4753 prefix = format!("@{agent}\n");
4754 }
4755
4756 match &message.message_type {
4757 MessageType::Text(text) => format!("{prefix}{text}"),
4758 MessageType::Thinking(text) => format!("{prefix}{text}"),
4759 MessageType::Image { url, .. } => format!("{prefix}{url}"),
4760 MessageType::File { path, .. } => format!("{prefix}{path}"),
4761 MessageType::ToolCall {
4762 name,
4763 arguments_preview,
4764 ..
4765 } => format!("{prefix}Tool call: {name}\n{arguments_preview}"),
4766 MessageType::ToolResult {
4767 name,
4768 output_preview,
4769 ..
4770 } => format!("{prefix}Tool result: {name}\n{output_preview}"),
4771 }
4772}
4773
4774fn copy_text_to_clipboard_best_effort(text: &str) -> Result<&'static str, String> {
4775 if text.trim().is_empty() {
4776 return Err("empty text".to_string());
4777 }
4778
4779 match arboard::Clipboard::new()
4781 .and_then(|mut clipboard| clipboard.set_text(text.to_string()))
4782 {
4783 Ok(()) => return Ok("system clipboard"),
4784 Err(e) => {
4785 tracing::debug!(error = %e, "System clipboard unavailable; falling back to OSC52");
4786 }
4787 }
4788
4789 osc52_copy(text).map_err(|e| format!("osc52 copy failed: {e}"))?;
4791 Ok("OSC52")
4792}
4793
4794fn osc52_copy(text: &str) -> std::io::Result<()> {
4795 let payload = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
4798 let seq = format!("\u{1b}]52;c;{payload}\u{07}");
4799
4800 let mut stdout = std::io::stdout();
4801 crossterm::execute!(stdout, crossterm::style::Print(seq))?;
4802 use std::io::Write;
4803 stdout.flush()?;
4804 Ok(())
4805}
4806
4807fn render_help_overlay_if_needed(f: &mut Frame, app: &App, theme: &Theme) {
4808 if !app.show_help {
4809 return;
4810 }
4811
4812 let area = centered_rect(60, 60, f.area());
4813 f.render_widget(Clear, area);
4814
4815 let token_display = TokenDisplay::new();
4816 let token_info = token_display.create_detailed_display();
4817
4818 let model_section: Vec<String> = if let Some(ref active) = app.active_model {
4820 let (provider, model) = crate::provider::parse_model_string(active);
4821 let provider_label = provider.unwrap_or("auto");
4822 vec![
4823 "".to_string(),
4824 " ACTIVE MODEL".to_string(),
4825 " ==============".to_string(),
4826 format!(" Provider: {}", provider_label),
4827 format!(" Model: {}", model),
4828 format!(" Agent: {}", app.current_agent),
4829 ]
4830 } else {
4831 vec![
4832 "".to_string(),
4833 " ACTIVE MODEL".to_string(),
4834 " ==============".to_string(),
4835 format!(" Provider: auto"),
4836 format!(" Model: (default)"),
4837 format!(" Agent: {}", app.current_agent),
4838 ]
4839 };
4840
4841 let help_text: Vec<String> = vec![
4842 "".to_string(),
4843 " KEYBOARD SHORTCUTS".to_string(),
4844 " ==================".to_string(),
4845 "".to_string(),
4846 " Enter Send message".to_string(),
4847 " Tab Switch between build/plan agents".to_string(),
4848 " Ctrl+A Open spawned-agent picker".to_string(),
4849 " Ctrl+M Open model picker".to_string(),
4850 " Ctrl+L Protocol bus log".to_string(),
4851 " Ctrl+S Toggle swarm view".to_string(),
4852 " Ctrl+B Toggle webview layout".to_string(),
4853 " Ctrl+Y Copy latest assistant reply".to_string(),
4854 " F3 Toggle inspector pane".to_string(),
4855 " Ctrl+C Quit".to_string(),
4856 " ? Toggle this help".to_string(),
4857 "".to_string(),
4858 " SLASH COMMANDS (auto-complete hints shown while typing)".to_string(),
4859 " /spawn <name> <instructions> Create a named sub-agent".to_string(),
4860 " /agents List spawned sub-agents".to_string(),
4861 " /kill <name> Remove a spawned sub-agent".to_string(),
4862 " /agent <name> Focus chat on a spawned sub-agent".to_string(),
4863 " /agent <name> <message> Send one message to a spawned sub-agent"
4864 .to_string(),
4865 " /agent Open spawned-agent picker"
4866 .to_string(),
4867 " /agent main|off Exit focused sub-agent chat"
4868 .to_string(),
4869 " /swarm <task> Run task in parallel swarm mode".to_string(),
4870 " /ralph [path] Start Ralph PRD loop (default: prd.json)".to_string(),
4871 " /undo Undo last message and response".to_string(),
4872 " /sessions Open session picker (filter, delete, load)".to_string(),
4873 " /resume Resume most recent session".to_string(),
4874 " /resume <id> Resume specific session by ID".to_string(),
4875 " /new Start a fresh session".to_string(),
4876 " /model Open model picker (or /model <name>)".to_string(),
4877 " /view Toggle swarm view".to_string(),
4878 " /buslog Show protocol bus log".to_string(),
4879 " /webview Web dashboard layout".to_string(),
4880 " /classic Single-pane layout".to_string(),
4881 " /inspector Toggle inspector pane".to_string(),
4882 " /refresh Refresh workspace and sessions".to_string(),
4883 "".to_string(),
4884 " SESSION PICKER".to_string(),
4885 " ↑/↓/j/k Navigate sessions".to_string(),
4886 " Enter Load selected session".to_string(),
4887 " d Delete session (press twice to confirm)".to_string(),
4888 " Type Filter sessions by name/agent/ID".to_string(),
4889 " Backspace Clear filter character".to_string(),
4890 " Esc Close picker".to_string(),
4891 "".to_string(),
4892 " VIM-STYLE NAVIGATION".to_string(),
4893 " Alt+j Scroll down".to_string(),
4894 " Alt+k Scroll up".to_string(),
4895 " Ctrl+g Go to top".to_string(),
4896 " Ctrl+G Go to bottom".to_string(),
4897 "".to_string(),
4898 " SCROLLING".to_string(),
4899 " Up/Down Scroll messages".to_string(),
4900 " PageUp/Dn Scroll one page".to_string(),
4901 " Alt+u/d Scroll half page".to_string(),
4902 "".to_string(),
4903 " COMMAND HISTORY".to_string(),
4904 " Ctrl+R Search history".to_string(),
4905 " Ctrl+Up/Dn Navigate history".to_string(),
4906 "".to_string(),
4907 " Press ? or Esc to close".to_string(),
4908 "".to_string(),
4909 ];
4910
4911 let mut combined_text = token_info;
4912 combined_text.extend(model_section);
4913 combined_text.extend(help_text);
4914
4915 let help = Paragraph::new(combined_text.join("\n"))
4916 .block(
4917 Block::default()
4918 .borders(Borders::ALL)
4919 .title(" Help ")
4920 .border_style(Style::default().fg(theme.help_border_color.to_color())),
4921 )
4922 .wrap(Wrap { trim: false });
4923
4924 f.render_widget(help, area);
4925}
4926
4927fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
4929 let popup_layout = Layout::default()
4930 .direction(Direction::Vertical)
4931 .constraints([
4932 Constraint::Percentage((100 - percent_y) / 2),
4933 Constraint::Percentage(percent_y),
4934 Constraint::Percentage((100 - percent_y) / 2),
4935 ])
4936 .split(r);
4937
4938 Layout::default()
4939 .direction(Direction::Horizontal)
4940 .constraints([
4941 Constraint::Percentage((100 - percent_x) / 2),
4942 Constraint::Percentage(percent_x),
4943 Constraint::Percentage((100 - percent_x) / 2),
4944 ])
4945 .split(popup_layout[1])[1]
4946}