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;
24const AUTOCHAT_MAX_AGENTS: usize = 8;
25const AUTOCHAT_DEFAULT_AGENTS: usize = 3;
26const AUTOCHAT_MAX_ROUNDS: usize = 3;
27const AUTOCHAT_RLM_THRESHOLD_CHARS: usize = 6_000;
28
29use crate::bus::relay::{ProtocolRelayRuntime, RelayAgentProfile};
30use crate::config::Config;
31use crate::provider::{ContentPart, Role};
32use crate::ralph::{RalphConfig, RalphLoop};
33use crate::rlm::RlmExecutor;
34use crate::session::{Session, SessionEvent, SessionSummary, list_sessions_with_opencode};
35use crate::swarm::{DecompositionStrategy, SwarmConfig, SwarmExecutor};
36use crate::tui::bus_log::{BusLogState, render_bus_log};
37use crate::tui::message_formatter::MessageFormatter;
38use crate::tui::ralph_view::{RalphEvent, RalphViewState, render_ralph_view};
39use crate::tui::swarm_view::{SwarmEvent, SwarmViewState, render_swarm_view};
40use crate::tui::theme::Theme;
41use crate::tui::token_display::TokenDisplay;
42use anyhow::Result;
43use base64::Engine;
44use crossterm::{
45 event::{
46 DisableBracketedPaste, EnableBracketedPaste, Event, EventStream, KeyCode, KeyEventKind,
47 KeyModifiers,
48 },
49 execute,
50 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
51};
52use futures::StreamExt;
53use ratatui::{
54 Frame, Terminal,
55 backend::CrosstermBackend,
56 layout::{Constraint, Direction, Layout, Rect},
57 style::{Color, Modifier, Style},
58 text::{Line, Span},
59 widgets::{
60 Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
61 },
62};
63use std::collections::{HashMap, HashSet};
64use std::io;
65use std::path::{Path, PathBuf};
66use std::process::Command;
67use std::time::{Duration, Instant};
68use tokio::sync::mpsc;
69
70pub async fn run(project: Option<PathBuf>) -> Result<()> {
72 if let Some(dir) = project {
74 std::env::set_current_dir(&dir)?;
75 }
76
77 enable_raw_mode()?;
79 let mut stdout = io::stdout();
80 execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
81 let backend = CrosstermBackend::new(stdout);
82 let mut terminal = Terminal::new(backend)?;
83
84 let result = run_app(&mut terminal).await;
86
87 disable_raw_mode()?;
89 execute!(
90 terminal.backend_mut(),
91 LeaveAlternateScreen,
92 DisableBracketedPaste
93 )?;
94 terminal.show_cursor()?;
95
96 result
97}
98
99#[derive(Debug, Clone)]
101enum MessageType {
102 Text(String),
103 Image {
104 url: String,
105 mime_type: Option<String>,
106 },
107 ToolCall {
108 name: String,
109 arguments_preview: String,
110 arguments_len: usize,
111 truncated: bool,
112 },
113 ToolResult {
114 name: String,
115 output_preview: String,
116 output_len: usize,
117 truncated: bool,
118 },
119 File {
120 path: String,
121 mime_type: Option<String>,
122 },
123 Thinking(String),
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128enum ViewMode {
129 Chat,
130 Swarm,
131 Ralph,
132 BusLog,
133 Protocol,
134 SessionPicker,
135 ModelPicker,
136 AgentPicker,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140enum ChatLayoutMode {
141 Classic,
142 Webview,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum WorkspaceEntryKind {
147 Directory,
148 File,
149}
150
151#[derive(Debug, Clone)]
152struct WorkspaceEntry {
153 name: String,
154 kind: WorkspaceEntryKind,
155}
156
157#[derive(Debug, Clone, Default)]
158struct WorkspaceSnapshot {
159 root_display: String,
160 git_branch: Option<String>,
161 git_dirty_files: usize,
162 entries: Vec<WorkspaceEntry>,
163 captured_at: String,
164}
165
166struct App {
168 input: String,
169 cursor_position: usize,
170 messages: Vec<ChatMessage>,
171 current_agent: String,
172 scroll: usize,
173 show_help: bool,
174 command_history: Vec<String>,
175 history_index: Option<usize>,
176 session: Option<Session>,
177 is_processing: bool,
178 processing_message: Option<String>,
179 current_tool: Option<String>,
180 processing_started_at: Option<Instant>,
182 streaming_text: Option<String>,
184 tool_call_count: usize,
186 response_rx: Option<mpsc::Receiver<SessionEvent>>,
187 provider_registry: Option<std::sync::Arc<crate::provider::ProviderRegistry>>,
189 workspace_dir: PathBuf,
191 view_mode: ViewMode,
193 chat_layout: ChatLayoutMode,
194 show_inspector: bool,
195 workspace: WorkspaceSnapshot,
196 swarm_state: SwarmViewState,
197 swarm_rx: Option<mpsc::Receiver<SwarmEvent>>,
198 ralph_state: RalphViewState,
200 ralph_rx: Option<mpsc::Receiver<RalphEvent>>,
201 bus_log_state: BusLogState,
203 bus_log_rx: Option<mpsc::Receiver<crate::bus::BusEnvelope>>,
204 bus: Option<std::sync::Arc<crate::bus::AgentBus>>,
205 session_picker_list: Vec<SessionSummary>,
207 session_picker_selected: usize,
208 session_picker_filter: String,
209 session_picker_confirm_delete: bool,
210 model_picker_list: Vec<(String, String, String)>, model_picker_selected: usize,
213 model_picker_filter: String,
214 agent_picker_selected: usize,
216 agent_picker_filter: String,
217 protocol_selected: usize,
219 protocol_scroll: usize,
220 active_model: Option<String>,
221 active_spawned_agent: Option<String>,
223 spawned_agents: HashMap<String, SpawnedAgent>,
224 agent_response_rxs: Vec<(String, mpsc::Receiver<SessionEvent>)>,
225 last_max_scroll: usize,
227}
228
229#[allow(dead_code)]
230struct ChatMessage {
231 role: String,
232 content: String,
233 timestamp: String,
234 message_type: MessageType,
235 usage_meta: Option<UsageMeta>,
237 agent_name: Option<String>,
239}
240
241#[allow(dead_code)]
243struct SpawnedAgent {
244 name: String,
246 instructions: String,
248 session: Session,
250 is_processing: bool,
252}
253
254#[derive(Debug, Clone)]
256struct UsageMeta {
257 prompt_tokens: usize,
258 completion_tokens: usize,
259 duration_ms: u64,
260 cost_usd: Option<f64>,
261}
262
263fn estimate_cost(model: &str, prompt_tokens: usize, completion_tokens: usize) -> Option<f64> {
266 let (input_rate, output_rate) = match model {
268 m if m.contains("claude-opus") => (15.0, 75.0),
270 m if m.contains("claude-sonnet") => (3.0, 15.0),
271 m if m.contains("claude-haiku") => (0.25, 1.25),
272 m if m.contains("gpt-4o-mini") => (0.15, 0.6),
274 m if m.contains("gpt-4o") => (2.5, 10.0),
275 m if m.contains("o3") => (10.0, 40.0),
276 m if m.contains("o4-mini") => (1.10, 4.40),
277 m if m.contains("gemini-2.5-pro") => (1.25, 10.0),
279 m if m.contains("gemini-2.5-flash") => (0.15, 0.6),
280 m if m.contains("gemini-2.0-flash") => (0.10, 0.40),
281 m if m.contains("kimi-k2") => (0.35, 1.40),
283 m if m.contains("deepseek") => (0.80, 2.0),
284 m if m.contains("llama") => (0.50, 1.50),
285 m if m.contains("nova-pro") => (0.80, 3.20),
287 m if m.contains("nova-lite") => (0.06, 0.24),
288 m if m.contains("nova-micro") => (0.035, 0.14),
289 m if m.contains("glm-5") => (2.0, 8.0),
291 m if m.contains("glm-4.7-flash") => (0.0, 0.0),
292 m if m.contains("glm-4.7") => (0.50, 2.0),
293 m if m.contains("glm-4") => (0.35, 1.40),
294 _ => return None,
295 };
296 let cost =
297 (prompt_tokens as f64 * input_rate + completion_tokens as f64 * output_rate) / 1_000_000.0;
298 Some(cost)
299}
300
301impl ChatMessage {
302 fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
303 let content = content.into();
304 Self {
305 role: role.into(),
306 timestamp: chrono::Local::now().format("%H:%M").to_string(),
307 message_type: MessageType::Text(content.clone()),
308 content,
309 usage_meta: None,
310 agent_name: None,
311 }
312 }
313
314 fn with_message_type(mut self, message_type: MessageType) -> Self {
315 self.message_type = message_type;
316 self
317 }
318
319 fn with_usage_meta(mut self, meta: UsageMeta) -> Self {
320 self.usage_meta = Some(meta);
321 self
322 }
323
324 fn with_agent_name(mut self, name: impl Into<String>) -> Self {
325 self.agent_name = Some(name.into());
326 self
327 }
328}
329
330impl WorkspaceSnapshot {
331 fn capture(root: &Path, max_entries: usize) -> Self {
332 let mut entries: Vec<WorkspaceEntry> = Vec::new();
333
334 if let Ok(read_dir) = std::fs::read_dir(root) {
335 for entry in read_dir.flatten() {
336 let file_name = entry.file_name().to_string_lossy().to_string();
337 if should_skip_workspace_entry(&file_name) {
338 continue;
339 }
340
341 let kind = match entry.file_type() {
342 Ok(ft) if ft.is_dir() => WorkspaceEntryKind::Directory,
343 _ => WorkspaceEntryKind::File,
344 };
345
346 entries.push(WorkspaceEntry {
347 name: file_name,
348 kind,
349 });
350 }
351 }
352
353 entries.sort_by(|a, b| match (a.kind, b.kind) {
354 (WorkspaceEntryKind::Directory, WorkspaceEntryKind::File) => std::cmp::Ordering::Less,
355 (WorkspaceEntryKind::File, WorkspaceEntryKind::Directory) => {
356 std::cmp::Ordering::Greater
357 }
358 _ => a
359 .name
360 .to_ascii_lowercase()
361 .cmp(&b.name.to_ascii_lowercase()),
362 });
363 entries.truncate(max_entries);
364
365 Self {
366 root_display: root.to_string_lossy().to_string(),
367 git_branch: detect_git_branch(root),
368 git_dirty_files: detect_git_dirty_files(root),
369 entries,
370 captured_at: chrono::Local::now().format("%H:%M:%S").to_string(),
371 }
372 }
373}
374
375fn should_skip_workspace_entry(name: &str) -> bool {
376 matches!(
377 name,
378 ".git" | "node_modules" | "target" | ".next" | "__pycache__" | ".venv"
379 )
380}
381
382fn detect_git_branch(root: &Path) -> Option<String> {
383 let output = Command::new("git")
384 .arg("-C")
385 .arg(root)
386 .args(["rev-parse", "--abbrev-ref", "HEAD"])
387 .output()
388 .ok()?;
389
390 if !output.status.success() {
391 return None;
392 }
393
394 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
395 if branch.is_empty() {
396 None
397 } else {
398 Some(branch)
399 }
400}
401
402fn detect_git_dirty_files(root: &Path) -> usize {
403 let output = match Command::new("git")
404 .arg("-C")
405 .arg(root)
406 .args(["status", "--porcelain"])
407 .output()
408 {
409 Ok(out) => out,
410 Err(_) => return 0,
411 };
412
413 if !output.status.success() {
414 return 0;
415 }
416
417 String::from_utf8_lossy(&output.stdout)
418 .lines()
419 .filter(|line| !line.trim().is_empty())
420 .count()
421}
422
423impl App {
424 fn new() -> Self {
425 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
426
427 Self {
428 input: String::new(),
429 cursor_position: 0,
430 messages: vec![
431 ChatMessage::new("system", "Welcome to CodeTether Agent! Press ? for help."),
432 ChatMessage::new(
433 "assistant",
434 "Quick start (easy mode):\n• Type a message to chat with the AI\n• /go <task> - run team relay on your task (easy /autochat)\n• /add <name> - create a helper teammate\n• /talk <name> <message> - message a teammate\n• /list - show teammates\n• /remove <name> - remove teammate\n• /home - return to main chat\n• /help - open help\n\nPower user mode still works: /autochat, /spawn, /agent, /swarm, /ralph, /protocol",
435 ),
436 ],
437 current_agent: "build".to_string(),
438 scroll: 0,
439 show_help: false,
440 command_history: Vec::new(),
441 history_index: None,
442 session: None,
443 is_processing: false,
444 processing_message: None,
445 current_tool: None,
446 processing_started_at: None,
447 streaming_text: None,
448 tool_call_count: 0,
449 response_rx: None,
450 provider_registry: None,
451 workspace_dir: workspace_root.clone(),
452 view_mode: ViewMode::Chat,
453 chat_layout: ChatLayoutMode::Webview,
454 show_inspector: true,
455 workspace: WorkspaceSnapshot::capture(&workspace_root, 18),
456 swarm_state: SwarmViewState::new(),
457 swarm_rx: None,
458 ralph_state: RalphViewState::new(),
459 ralph_rx: None,
460 bus_log_state: BusLogState::new(),
461 bus_log_rx: None,
462 bus: None,
463 session_picker_list: Vec::new(),
464 session_picker_selected: 0,
465 session_picker_filter: String::new(),
466 session_picker_confirm_delete: false,
467 model_picker_list: Vec::new(),
468 model_picker_selected: 0,
469 model_picker_filter: String::new(),
470 agent_picker_selected: 0,
471 agent_picker_filter: String::new(),
472 protocol_selected: 0,
473 protocol_scroll: 0,
474 active_model: None,
475 active_spawned_agent: None,
476 spawned_agents: HashMap::new(),
477 agent_response_rxs: Vec::new(),
478 last_max_scroll: 0,
479 }
480 }
481
482 fn refresh_workspace(&mut self) {
483 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
484 self.workspace = WorkspaceSnapshot::capture(&workspace_root, 18);
485 }
486
487 fn update_cached_sessions(&mut self, sessions: Vec<SessionSummary>) {
488 self.session_picker_list = sessions.into_iter().take(16).collect();
489 if self.session_picker_selected >= self.session_picker_list.len() {
490 self.session_picker_selected = self.session_picker_list.len().saturating_sub(1);
491 }
492 }
493
494 fn is_agent_protocol_registered(&self, agent_name: &str) -> bool {
495 self.bus
496 .as_ref()
497 .is_some_and(|bus| bus.registry.get(agent_name).is_some())
498 }
499
500 fn protocol_registered_count(&self) -> usize {
501 self.bus.as_ref().map_or(0, |bus| bus.registry.len())
502 }
503
504 fn protocol_cards(&self) -> Vec<crate::a2a::types::AgentCard> {
505 let Some(bus) = &self.bus else {
506 return Vec::new();
507 };
508
509 let mut ids = bus.registry.agent_ids();
510 ids.sort_by_key(|id| id.to_lowercase());
511
512 ids.into_iter()
513 .filter_map(|id| bus.registry.get(&id))
514 .collect()
515 }
516
517 fn open_protocol_view(&mut self) {
518 self.protocol_selected = 0;
519 self.protocol_scroll = 0;
520 self.view_mode = ViewMode::Protocol;
521 }
522
523 fn unique_spawned_name(&self, base: &str) -> String {
524 if !self.spawned_agents.contains_key(base) {
525 return base.to_string();
526 }
527
528 let mut suffix = 2usize;
529 loop {
530 let candidate = format!("{base}-{suffix}");
531 if !self.spawned_agents.contains_key(&candidate) {
532 return candidate;
533 }
534 suffix += 1;
535 }
536 }
537
538 fn cleanup_autochat_agents(
539 &mut self,
540 relay: &ProtocolRelayRuntime,
541 agent_names: &[String],
542 ) -> usize {
543 if agent_names.is_empty() {
544 return 0;
545 }
546
547 relay.shutdown_agents(agent_names);
548
549 let names: HashSet<&str> = agent_names.iter().map(String::as_str).collect();
550 self.agent_response_rxs
551 .retain(|(name, _)| !names.contains(name.as_str()));
552
553 if let Some(active) = self.active_spawned_agent.as_deref()
554 && names.contains(active)
555 {
556 self.active_spawned_agent = None;
557 }
558
559 let mut removed = 0usize;
560 for name in agent_names {
561 if self.spawned_agents.remove(name).is_some() {
562 removed += 1;
563 }
564 }
565
566 removed
567 }
568
569 fn build_autochat_profiles(&self, count: usize) -> Vec<(String, String, Vec<String>)> {
570 let templates = [
571 (
572 "planner",
573 "Decompose objectives into precise, sequenced steps.",
574 "planning",
575 ),
576 (
577 "researcher",
578 "Validate assumptions, surface edge cases, and gather critical evidence.",
579 "research",
580 ),
581 (
582 "coder",
583 "Propose concrete implementation details and practical code-level direction.",
584 "implementation",
585 ),
586 (
587 "reviewer",
588 "Challenge weak spots, enforce quality, and reduce regressions.",
589 "review",
590 ),
591 (
592 "tester",
593 "Design verification strategy, tests, and failure-oriented checks.",
594 "testing",
595 ),
596 (
597 "integrator",
598 "Synthesize contributions into a coherent delivery plan.",
599 "integration",
600 ),
601 (
602 "skeptic",
603 "Stress-test confidence and call out hidden risks early.",
604 "risk-analysis",
605 ),
606 (
607 "summarizer",
608 "Produce concise, actionable final guidance.",
609 "summarization",
610 ),
611 ];
612
613 let mut profiles = Vec::with_capacity(count);
614 for idx in 0..count {
615 let (slug, mission, specialty) = templates[idx % templates.len()];
616 let base = if idx < templates.len() {
617 format!("auto-{slug}")
618 } else {
619 format!("auto-{slug}-{}", idx + 1)
620 };
621 let name = self.unique_spawned_name(&base);
622 let instructions = format!(
623 "You are @{name}, a specialist in {specialty}. {mission}\n\n
624 This is a protocol-first relay conversation. Treat the incoming handoff as the authoritative context.\n\
625 Keep your response concise, concrete, and useful for the next specialist.\n\
626 Include one clear recommendation for what the next agent should do."
627 );
628 let capabilities = vec![
629 specialty.to_string(),
630 "relay".to_string(),
631 "context-handoff".to_string(),
632 "rlm-aware".to_string(),
633 "autochat".to_string(),
634 ];
635
636 profiles.push((name, instructions, capabilities));
637 }
638
639 profiles
640 }
641
642 fn resolve_provider_for_model(
643 &self,
644 registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
645 model_ref: &str,
646 ) -> Option<(std::sync::Arc<dyn crate::provider::Provider>, String)> {
647 let (provider_name, model_name) = crate::provider::parse_model_string(model_ref);
648 if let Some(provider_name) = provider_name
649 && let Some(provider) = registry.get(provider_name)
650 {
651 return Some((provider, model_name.to_string()));
652 }
653
654 let fallbacks = [
655 "zai",
656 "openai",
657 "github-copilot",
658 "anthropic",
659 "openrouter",
660 "novita",
661 "moonshotai",
662 "google",
663 ];
664
665 for provider_name in fallbacks {
666 if let Some(provider) = registry.get(provider_name) {
667 return Some((provider, model_ref.to_string()));
668 }
669 }
670
671 registry
672 .list()
673 .first()
674 .copied()
675 .and_then(|name| registry.get(name))
676 .map(|provider| (provider, model_ref.to_string()))
677 }
678
679 async fn prepare_autochat_handoff(
680 &self,
681 task: &str,
682 from_agent: &str,
683 output: &str,
684 model_ref: &str,
685 ) -> (String, bool) {
686 let mut used_rlm = false;
687 let mut relay_payload = output.to_string();
688
689 if output.len() > AUTOCHAT_RLM_THRESHOLD_CHARS {
690 relay_payload = truncate_with_ellipsis(output, 3_500);
691
692 if let Some(registry) = self.provider_registry.as_ref()
693 && let Some((provider, model_name)) =
694 self.resolve_provider_for_model(registry, model_ref)
695 {
696 let mut executor = RlmExecutor::new(output.to_string(), provider, model_name)
697 .with_max_iterations(2);
698
699 let query = "Summarize this agent output for the next specialist in a relay. Keep:\n\
700 1) key conclusions, 2) unresolved risks, 3) exact next action.\n\
701 Keep it concise and actionable.";
702 match executor.analyze(query).await {
703 Ok(result) => {
704 relay_payload = result.answer;
705 used_rlm = true;
706 }
707 Err(err) => {
708 tracing::warn!(error = %err, "RLM handoff summarization failed; using truncation fallback");
709 }
710 }
711 }
712 }
713
714 (
715 format!(
716 "Relay task:\n{task}\n\nIncoming handoff from @{from_agent}:\n{relay_payload}\n\n\
717 Continue the work from this handoff. Keep your response focused and provide one concrete next-step instruction for the next agent."
718 ),
719 used_rlm,
720 )
721 }
722
723 async fn prompt_spawned_agent_sync(
724 &mut self,
725 agent_name: &str,
726 message: &str,
727 ) -> Result<String> {
728 if self.provider_registry.is_none() {
729 let registry = crate::provider::ProviderRegistry::from_vault().await?;
730 self.provider_registry = Some(std::sync::Arc::new(registry));
731 }
732
733 let registry = self
734 .provider_registry
735 .clone()
736 .ok_or_else(|| anyhow::anyhow!("Provider registry unavailable"))?;
737
738 let mut session = self
739 .spawned_agents
740 .get(agent_name)
741 .map(|agent| agent.session.clone())
742 .ok_or_else(|| anyhow::anyhow!("Spawned agent '{}' not found", agent_name))?;
743
744 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
745 agent.is_processing = true;
746 }
747
748 let (tx, mut rx) = mpsc::channel(256);
749 let prompt_text = message.to_string();
750
751 let join = tokio::spawn(async move {
752 let result = session.prompt_with_events(&prompt_text, tx, registry).await;
753 (session, result)
754 });
755
756 while !join.is_finished() {
757 while let Ok(event) = rx.try_recv() {
758 self.handle_agent_response(agent_name, event);
759 }
760 tokio::time::sleep(Duration::from_millis(20)).await;
761 }
762
763 let (updated_session, result) = join
764 .await
765 .map_err(|err| anyhow::anyhow!("Autochat agent task join error: {err}"))?;
766
767 while let Ok(event) = rx.try_recv() {
768 self.handle_agent_response(agent_name, event);
769 }
770
771 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
772 agent.session = updated_session;
773 agent.is_processing = false;
774 }
775
776 Ok(result?.text)
777 }
778
779 async fn start_autochat_execution(
780 &mut self,
781 agent_count: usize,
782 task: String,
783 config: &Config,
784 ) {
785 if !(2..=AUTOCHAT_MAX_AGENTS).contains(&agent_count) {
786 self.messages.push(ChatMessage::new(
787 "system",
788 format!(
789 "Usage: /autochat <count> <task>\ncount must be between 2 and {AUTOCHAT_MAX_AGENTS}."
790 ),
791 ));
792 return;
793 }
794
795 let Some(bus) = self.bus.clone() else {
796 self.messages.push(ChatMessage::new(
797 "system",
798 "Protocol bus unavailable; cannot start /autochat relay.",
799 ));
800 return;
801 };
802
803 let model_ref = self
804 .active_model
805 .clone()
806 .or_else(|| config.default_model.clone())
807 .unwrap_or_else(|| "zai/glm-5".to_string());
808
809 if self.provider_registry.is_none() {
810 match crate::provider::ProviderRegistry::from_vault().await {
811 Ok(registry) => {
812 self.provider_registry = Some(std::sync::Arc::new(registry));
813 }
814 Err(err) => {
815 self.messages.push(ChatMessage::new(
816 "system",
817 format!("Failed to load providers for /autochat: {err}"),
818 ));
819 return;
820 }
821 }
822 }
823
824 let relay = ProtocolRelayRuntime::new(bus);
825 let profiles = self.build_autochat_profiles(agent_count);
826 if profiles.is_empty() {
827 self.messages.push(ChatMessage::new(
828 "system",
829 "No relay profiles could be created.",
830 ));
831 return;
832 }
833
834 let mut relay_profiles = Vec::with_capacity(profiles.len());
835 let mut ordered_agents = Vec::with_capacity(profiles.len());
836
837 for (name, instructions, capabilities) in profiles {
838 match Session::new().await {
839 Ok(mut session) => {
840 session.metadata.model = Some(model_ref.clone());
841 session.agent = name.clone();
842 session.add_message(crate::provider::Message {
843 role: Role::System,
844 content: vec![ContentPart::Text {
845 text: instructions.clone(),
846 }],
847 });
848
849 self.spawned_agents.insert(
850 name.clone(),
851 SpawnedAgent {
852 name: name.clone(),
853 instructions,
854 session,
855 is_processing: false,
856 },
857 );
858 relay_profiles.push(RelayAgentProfile {
859 name: name.clone(),
860 capabilities,
861 });
862 ordered_agents.push(name);
863 }
864 Err(err) => {
865 self.messages.push(ChatMessage::new(
866 "system",
867 format!("Failed creating relay agent session: {err}"),
868 ));
869 }
870 }
871 }
872
873 if ordered_agents.len() < 2 {
874 self.agent_response_rxs
875 .retain(|(name, _)| !ordered_agents.iter().any(|n| n == name));
876 for name in &ordered_agents {
877 self.spawned_agents.remove(name);
878 }
879
880 self.messages.push(ChatMessage::new(
881 "system",
882 "Autochat needs at least 2 agents to relay.",
883 ));
884 return;
885 }
886
887 relay.register_agents(&relay_profiles);
888 self.active_spawned_agent = None;
889
890 self.messages.push(ChatMessage::new(
891 "user",
892 format!("/autochat {agent_count} {task}"),
893 ));
894 self.messages.push(ChatMessage::new(
895 "system",
896 format!(
897 "Starting protocol-first relay {id} with agents: {agents}\nModel: {model_ref}",
898 id = relay.relay_id(),
899 agents = ordered_agents
900 .iter()
901 .map(|name| format!("@{name}"))
902 .collect::<Vec<_>>()
903 .join(", "),
904 ),
905 ));
906
907 let mut baton = format!(
908 "Task:\n{task}\n\nStart by proposing an execution strategy and one immediate next step."
909 );
910 let mut previous_normalized: Option<String> = None;
911 let mut convergence_hits = 0usize;
912 let mut turns = 0usize;
913 let mut status = "max_rounds_reached";
914 let mut failure_note: Option<String> = None;
915
916 'relay_loop: for round in 1..=AUTOCHAT_MAX_ROUNDS {
917 for idx in 0..ordered_agents.len() {
918 let to = ordered_agents[idx].clone();
919 let from = if idx == 0 {
920 if round == 1 {
921 "user".to_string()
922 } else {
923 ordered_agents[ordered_agents.len() - 1].clone()
924 }
925 } else {
926 ordered_agents[idx - 1].clone()
927 };
928
929 turns += 1;
930 relay.send_handoff(&from, &to, &baton);
931 self.messages.push(ChatMessage::new(
932 "system",
933 format!(
934 "[relay {} • round {}] {} → @{}",
935 relay.relay_id(),
936 round,
937 from,
938 to
939 ),
940 ));
941
942 let output = match self.prompt_spawned_agent_sync(&to, &baton).await {
943 Ok(text) => text,
944 Err(err) => {
945 status = "agent_error";
946 failure_note = Some(format!("Relay agent @{to} failed: {err}"));
947 self.messages.push(ChatMessage::new(
948 "system",
949 format!("Relay agent @{to} failed: {err}"),
950 ));
951 break 'relay_loop;
952 }
953 };
954
955 let normalized = normalize_for_convergence(&output);
956 if previous_normalized.as_deref() == Some(normalized.as_str()) {
957 convergence_hits += 1;
958 } else {
959 convergence_hits = 0;
960 }
961 previous_normalized = Some(normalized);
962
963 let (next_handoff, used_rlm) = self
964 .prepare_autochat_handoff(&task, &to, &output, &model_ref)
965 .await;
966 if used_rlm {
967 self.messages.push(ChatMessage::new(
968 "system",
969 format!("RLM compressed handoff from @{to} before the next relay step."),
970 ));
971 }
972
973 baton = next_handoff;
974
975 if convergence_hits >= 2 {
976 status = "converged";
977 self.messages.push(ChatMessage::new(
978 "system",
979 format!("Relay convergence reached after {turns} turns."),
980 ));
981 break 'relay_loop;
982 }
983 }
984 }
985
986 let removed_agents = self.cleanup_autochat_agents(&relay, &ordered_agents);
987
988 let mut summary = format!(
989 "Autochat complete ({status}) — relay {} with {} agents over {} turns.",
990 relay.relay_id(),
991 ordered_agents.len(),
992 turns,
993 );
994 if let Some(note) = failure_note {
995 summary.push_str(&format!("\n\nFailure detail: {note}"));
996 }
997 summary.push_str(&format!(
998 "\n\nFinal relay handoff:\n{}",
999 truncate_with_ellipsis(&baton, 4_000)
1000 ));
1001 summary.push_str(&format!(
1002 "\n\nCleanup: deregistered relay agents and removed {} autochat participant(s) from active roster.",
1003 removed_agents
1004 ));
1005
1006 self.messages.push(ChatMessage::new("assistant", summary));
1007 self.scroll = SCROLL_BOTTOM;
1008 }
1009
1010 async fn submit_message(&mut self, config: &Config) {
1011 if self.input.is_empty() {
1012 return;
1013 }
1014
1015 let mut message = std::mem::take(&mut self.input);
1016 self.cursor_position = 0;
1017
1018 if !message.trim().is_empty() {
1020 self.command_history.push(message.clone());
1021 self.history_index = None;
1022 }
1023
1024 message = normalize_easy_command(&message);
1026
1027 if message.trim() == "/help" {
1028 self.show_help = true;
1029 return;
1030 }
1031
1032 if message.trim().starts_with("/agent") {
1034 let rest = message.trim().strip_prefix("/agent").unwrap_or("").trim();
1035
1036 if rest.is_empty() {
1037 self.open_agent_picker();
1038 return;
1039 }
1040
1041 if rest == "pick" || rest == "picker" || rest == "select" {
1042 self.open_agent_picker();
1043 return;
1044 }
1045
1046 if rest == "main" || rest == "off" {
1047 if let Some(target) = self.active_spawned_agent.take() {
1048 self.messages.push(ChatMessage::new(
1049 "system",
1050 format!("Exited focused sub-agent chat (@{target})."),
1051 ));
1052 } else {
1053 self.messages
1054 .push(ChatMessage::new("system", "Already in main chat mode."));
1055 }
1056 return;
1057 }
1058
1059 if rest == "build" || rest == "plan" {
1060 self.current_agent = rest.to_string();
1061 self.active_spawned_agent = None;
1062 self.messages.push(ChatMessage::new(
1063 "system",
1064 format!("Switched main agent to '{rest}'. (Tab also works.)"),
1065 ));
1066 return;
1067 }
1068
1069 if rest == "list" || rest == "ls" {
1070 message = "/agents".to_string();
1071 } else if let Some(args) = rest
1072 .strip_prefix("spawn ")
1073 .map(str::trim)
1074 .filter(|s| !s.is_empty())
1075 {
1076 message = format!("/spawn {args}");
1077 } else if let Some(name) = rest
1078 .strip_prefix("kill ")
1079 .map(str::trim)
1080 .filter(|s| !s.is_empty())
1081 {
1082 message = format!("/kill {name}");
1083 } else if !rest.contains(' ') {
1084 let target = rest.trim_start_matches('@');
1085 if self.spawned_agents.contains_key(target) {
1086 self.active_spawned_agent = Some(target.to_string());
1087 self.messages.push(ChatMessage::new(
1088 "system",
1089 format!(
1090 "Focused chat on @{target}. Type messages directly; use /agent main to exit focus."
1091 ),
1092 ));
1093 } else {
1094 self.messages.push(ChatMessage::new(
1095 "system",
1096 format!(
1097 "No agent named @{target}. Use /agents to list, or /spawn <name> <instructions> to create one."
1098 ),
1099 ));
1100 }
1101 return;
1102 } else if let Some((name, content)) = rest.split_once(' ') {
1103 let target = name.trim().trim_start_matches('@');
1104 let content = content.trim();
1105 if target.is_empty() || content.is_empty() {
1106 self.messages
1107 .push(ChatMessage::new("system", "Usage: /agent <name> <message>"));
1108 return;
1109 }
1110 message = format!("@{target} {content}");
1111 } else {
1112 self.messages.push(ChatMessage::new(
1113 "system",
1114 "Unknown /agent usage. Try /agent, /agent <name>, /agent <name> <message>, or /agent list.",
1115 ));
1116 return;
1117 }
1118 }
1119
1120 if let Some(rest) = command_with_optional_args(&message, "/autochat") {
1122 let Some((count, task)) = parse_autochat_args(rest) else {
1123 self.messages.push(ChatMessage::new(
1124 "system",
1125 format!(
1126 "Usage: /autochat [count] <task>\nEasy mode: /go <task>\nExamples:\n /autochat implement protocol-first relay with tests\n /autochat 4 implement protocol-first relay with tests\ncount range: 2-{} (default: {})",
1127 AUTOCHAT_MAX_AGENTS,
1128 AUTOCHAT_DEFAULT_AGENTS,
1129 ),
1130 ));
1131 return;
1132 };
1133
1134 self.start_autochat_execution(count, task.to_string(), config)
1135 .await;
1136 return;
1137 }
1138
1139 if let Some(task) = command_with_optional_args(&message, "/swarm") {
1141 if task.is_empty() {
1142 self.messages.push(ChatMessage::new(
1143 "system",
1144 "Usage: /swarm <task description>",
1145 ));
1146 return;
1147 }
1148 self.start_swarm_execution(task.to_string(), config).await;
1149 return;
1150 }
1151
1152 if message.trim().starts_with("/ralph") {
1154 let prd_path = message
1155 .trim()
1156 .strip_prefix("/ralph")
1157 .map(|s| s.trim())
1158 .filter(|s| !s.is_empty())
1159 .unwrap_or("prd.json")
1160 .to_string();
1161 self.start_ralph_execution(prd_path, config).await;
1162 return;
1163 }
1164
1165 if message.trim() == "/webview" {
1166 self.chat_layout = ChatLayoutMode::Webview;
1167 self.messages.push(ChatMessage::new(
1168 "system",
1169 "Switched to webview layout. Use /classic to return to single-pane chat.",
1170 ));
1171 return;
1172 }
1173
1174 if message.trim() == "/classic" {
1175 self.chat_layout = ChatLayoutMode::Classic;
1176 self.messages.push(ChatMessage::new(
1177 "system",
1178 "Switched to classic layout. Use /webview for dashboard-style panes.",
1179 ));
1180 return;
1181 }
1182
1183 if message.trim() == "/inspector" {
1184 self.show_inspector = !self.show_inspector;
1185 let state = if self.show_inspector {
1186 "enabled"
1187 } else {
1188 "disabled"
1189 };
1190 self.messages.push(ChatMessage::new(
1191 "system",
1192 format!("Inspector pane {}. Press F3 to toggle quickly.", state),
1193 ));
1194 return;
1195 }
1196
1197 if message.trim() == "/refresh" {
1198 self.refresh_workspace();
1199 match list_sessions_with_opencode(&self.workspace_dir).await {
1200 Ok(sessions) => self.update_cached_sessions(sessions),
1201 Err(err) => self.messages.push(ChatMessage::new(
1202 "system",
1203 format!(
1204 "Workspace refreshed, but failed to refresh sessions: {}",
1205 err
1206 ),
1207 )),
1208 }
1209 self.messages.push(ChatMessage::new(
1210 "system",
1211 "Workspace and session cache refreshed.",
1212 ));
1213 return;
1214 }
1215
1216 if message.trim() == "/view" {
1218 self.view_mode = match self.view_mode {
1219 ViewMode::Chat
1220 | ViewMode::SessionPicker
1221 | ViewMode::ModelPicker
1222 | ViewMode::AgentPicker
1223 | ViewMode::BusLog
1224 | ViewMode::Protocol => ViewMode::Swarm,
1225 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
1226 };
1227 return;
1228 }
1229
1230 if message.trim() == "/buslog" || message.trim() == "/bus" {
1232 self.view_mode = ViewMode::BusLog;
1233 return;
1234 }
1235
1236 if message.trim() == "/protocol" || message.trim() == "/registry" {
1238 self.open_protocol_view();
1239 return;
1240 }
1241
1242 if let Some(rest) = command_with_optional_args(&message, "/spawn") {
1244 let default_instructions = |agent_name: &str| {
1245 format!(
1246 "You are @{agent_name}, a helpful teammate. Explain things in simple words, short steps, and a friendly tone."
1247 )
1248 };
1249
1250 let (name, instructions, used_default_instructions) = if rest.is_empty() {
1251 self.messages.push(ChatMessage::new(
1252 "system",
1253 "Usage: /spawn <name> [instructions]\nEasy mode: /add <name>\nExample: /spawn planner You are a planning agent. Break tasks into steps.",
1254 ));
1255 return;
1256 } else {
1257 let mut parts = rest.splitn(2, char::is_whitespace);
1258 let name = parts.next().unwrap_or("").trim();
1259 if name.is_empty() {
1260 self.messages.push(ChatMessage::new(
1261 "system",
1262 "Usage: /spawn <name> [instructions]\nEasy mode: /add <name>",
1263 ));
1264 return;
1265 }
1266
1267 let instructions = parts.next().map(str::trim).filter(|s| !s.is_empty());
1268 match instructions {
1269 Some(custom) => (name.to_string(), custom.to_string(), false),
1270 None => (name.to_string(), default_instructions(name), true),
1271 }
1272 };
1273
1274 if self.spawned_agents.contains_key(&name) {
1275 self.messages.push(ChatMessage::new(
1276 "system",
1277 format!("Agent @{name} already exists. Use /kill {name} first."),
1278 ));
1279 return;
1280 }
1281
1282 match Session::new().await {
1283 Ok(mut session) => {
1284 session.metadata.model = self
1286 .active_model
1287 .clone()
1288 .or_else(|| config.default_model.clone());
1289 session.agent = name.clone();
1290
1291 session.add_message(crate::provider::Message {
1293 role: Role::System,
1294 content: vec![ContentPart::Text {
1295 text: format!(
1296 "You are @{name}, a specialized sub-agent. {instructions}\n\n\
1297 When you receive a message from another agent (prefixed with their name), \
1298 respond helpfully. Keep responses concise and focused on your specialty."
1299 ),
1300 }],
1301 });
1302
1303 let mut protocol_registered = false;
1305 if let Some(ref bus) = self.bus {
1306 let handle = bus.handle(&name);
1307 handle.announce_ready(vec!["sub-agent".to_string(), name.clone()]);
1308 protocol_registered = bus.registry.get(&name).is_some();
1309 }
1310
1311 let agent = SpawnedAgent {
1312 name: name.clone(),
1313 instructions: instructions.clone(),
1314 session,
1315 is_processing: false,
1316 };
1317 self.spawned_agents.insert(name.clone(), agent);
1318 self.active_spawned_agent = Some(name.clone());
1319
1320 let protocol_line = if protocol_registered {
1321 format!("Protocol registration: ✅ bus://local/{name}")
1322 } else {
1323 "Protocol registration: ⚠ unavailable (bus not connected)".to_string()
1324 };
1325
1326 self.messages.push(ChatMessage::new(
1327 "system",
1328 format!(
1329 "Spawned agent @{name}: {instructions}\nFocused chat on @{name}. Type directly, or use @{name} <message>.\n{protocol_line}{}",
1330 if used_default_instructions {
1331 "\nTip: I used friendly default instructions. You can customize with /add <name> <instructions>."
1332 } else {
1333 ""
1334 }
1335 ),
1336 ));
1337 }
1338 Err(e) => {
1339 self.messages.push(ChatMessage::new(
1340 "system",
1341 format!("Failed to spawn agent: {e}"),
1342 ));
1343 }
1344 }
1345 return;
1346 }
1347
1348 if message.trim() == "/agents" {
1350 if self.spawned_agents.is_empty() {
1351 self.messages.push(ChatMessage::new(
1352 "system",
1353 "No agents spawned. Use /spawn <name> <instructions> to create one.",
1354 ));
1355 } else {
1356 let mut lines = vec![format!(
1357 "Active agents: {} (protocol registered: {})",
1358 self.spawned_agents.len(),
1359 self.protocol_registered_count()
1360 )];
1361
1362 let mut agents = self.spawned_agents.iter().collect::<Vec<_>>();
1363 agents.sort_by(|(a, _), (b, _)| a.to_lowercase().cmp(&b.to_lowercase()));
1364
1365 for (name, agent) in agents {
1366 let status = if agent.is_processing {
1367 "⚡ working"
1368 } else {
1369 "● idle"
1370 };
1371 let protocol_status = if self.is_agent_protocol_registered(name) {
1372 "🔗 protocol"
1373 } else {
1374 "⚠ protocol-pending"
1375 };
1376 let focused = if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
1377 " [focused]"
1378 } else {
1379 ""
1380 };
1381 lines.push(format!(
1382 " @{name} [{status}] {protocol_status}{focused} — {}",
1383 agent.instructions
1384 ));
1385 }
1386 self.messages
1387 .push(ChatMessage::new("system", lines.join("\n")));
1388 self.messages.push(ChatMessage::new(
1389 "system",
1390 "Tip: use /agent to open the picker, /agent <name> to focus, or Ctrl+A.",
1391 ));
1392 }
1393 return;
1394 }
1395
1396 if let Some(name) = command_with_optional_args(&message, "/kill") {
1398 if name.is_empty() {
1399 self.messages
1400 .push(ChatMessage::new("system", "Usage: /kill <name>"));
1401 return;
1402 }
1403
1404 let name = name.to_string();
1405 if self.spawned_agents.remove(&name).is_some() {
1406 self.agent_response_rxs.retain(|(n, _)| n != &name);
1408 if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
1409 self.active_spawned_agent = None;
1410 }
1411 if let Some(ref bus) = self.bus {
1413 let handle = bus.handle(&name);
1414 handle.announce_shutdown();
1415 }
1416 self.messages.push(ChatMessage::new(
1417 "system",
1418 format!("Agent @{name} removed."),
1419 ));
1420 } else {
1421 self.messages.push(ChatMessage::new(
1422 "system",
1423 format!("No agent named @{name}. Use /agents to list."),
1424 ));
1425 }
1426 return;
1427 }
1428
1429 if message.trim().starts_with('@') {
1431 let trimmed = message.trim();
1432 let (target, content) = match trimmed.split_once(' ') {
1433 Some((mention, rest)) => (
1434 mention.strip_prefix('@').unwrap_or(mention).to_string(),
1435 rest.to_string(),
1436 ),
1437 None => {
1438 self.messages.push(ChatMessage::new(
1439 "system",
1440 format!(
1441 "Usage: @agent_name your message\nAvailable: {}",
1442 if self.spawned_agents.is_empty() {
1443 "none (use /spawn first)".to_string()
1444 } else {
1445 self.spawned_agents
1446 .keys()
1447 .map(|n| format!("@{n}"))
1448 .collect::<Vec<_>>()
1449 .join(", ")
1450 }
1451 ),
1452 ));
1453 return;
1454 }
1455 };
1456
1457 if !self.spawned_agents.contains_key(&target) {
1458 self.messages.push(ChatMessage::new(
1459 "system",
1460 format!(
1461 "No agent named @{target}. Available: {}",
1462 if self.spawned_agents.is_empty() {
1463 "none (use /spawn first)".to_string()
1464 } else {
1465 self.spawned_agents
1466 .keys()
1467 .map(|n| format!("@{n}"))
1468 .collect::<Vec<_>>()
1469 .join(", ")
1470 }
1471 ),
1472 ));
1473 return;
1474 }
1475
1476 self.messages
1478 .push(ChatMessage::new("user", format!("@{target} {content}")));
1479 self.scroll = SCROLL_BOTTOM;
1480
1481 if let Some(ref bus) = self.bus {
1483 let handle = bus.handle("user");
1484 handle.send_to_agent(
1485 &target,
1486 vec![crate::a2a::types::Part::Text {
1487 text: content.clone(),
1488 }],
1489 );
1490 }
1491
1492 self.send_to_agent(&target, &content, config).await;
1494 return;
1495 }
1496
1497 if !message.trim().starts_with('/')
1499 && let Some(target) = self.active_spawned_agent.clone()
1500 {
1501 if !self.spawned_agents.contains_key(&target) {
1502 self.active_spawned_agent = None;
1503 self.messages.push(ChatMessage::new(
1504 "system",
1505 format!(
1506 "Focused agent @{target} is no longer available. Use /agents or /spawn to continue."
1507 ),
1508 ));
1509 return;
1510 }
1511
1512 let content = message.trim().to_string();
1513 if content.is_empty() {
1514 return;
1515 }
1516
1517 self.messages
1518 .push(ChatMessage::new("user", format!("@{target} {content}")));
1519 self.scroll = SCROLL_BOTTOM;
1520
1521 if let Some(ref bus) = self.bus {
1522 let handle = bus.handle("user");
1523 handle.send_to_agent(
1524 &target,
1525 vec![crate::a2a::types::Part::Text {
1526 text: content.clone(),
1527 }],
1528 );
1529 }
1530
1531 self.send_to_agent(&target, &content, config).await;
1532 return;
1533 }
1534
1535 if message.trim() == "/sessions" {
1537 match list_sessions_with_opencode(&self.workspace_dir).await {
1538 Ok(sessions) => {
1539 if sessions.is_empty() {
1540 self.messages
1541 .push(ChatMessage::new("system", "No saved sessions found."));
1542 } else {
1543 self.update_cached_sessions(sessions);
1544 self.session_picker_selected = 0;
1545 self.view_mode = ViewMode::SessionPicker;
1546 }
1547 }
1548 Err(e) => {
1549 self.messages.push(ChatMessage::new(
1550 "system",
1551 format!("Failed to list sessions: {}", e),
1552 ));
1553 }
1554 }
1555 return;
1556 }
1557
1558 if message.trim() == "/resume" || message.trim().starts_with("/resume ") {
1560 let session_id = message
1561 .trim()
1562 .strip_prefix("/resume")
1563 .map(|s| s.trim())
1564 .filter(|s| !s.is_empty());
1565 let loaded = if let Some(id) = session_id {
1566 if let Some(oc_id) = id.strip_prefix("opencode_") {
1567 if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
1568 Session::from_opencode(oc_id, &storage).await
1569 } else {
1570 Err(anyhow::anyhow!("OpenCode storage not available"))
1571 }
1572 } else {
1573 Session::load(id).await
1574 }
1575 } else {
1576 match Session::last_for_directory(Some(&self.workspace_dir)).await {
1577 Ok(s) => Ok(s),
1578 Err(_) => Session::last_opencode_for_directory(&self.workspace_dir).await,
1579 }
1580 };
1581
1582 match loaded {
1583 Ok(session) => {
1584 self.messages.clear();
1586 self.messages.push(ChatMessage::new(
1587 "system",
1588 format!(
1589 "Resumed session: {}\nCreated: {}\n{} messages loaded",
1590 session.title.as_deref().unwrap_or("(untitled)"),
1591 session.created_at.format("%Y-%m-%d %H:%M"),
1592 session.messages.len()
1593 ),
1594 ));
1595
1596 for msg in &session.messages {
1597 let role_str = match msg.role {
1598 Role::System => "system",
1599 Role::User => "user",
1600 Role::Assistant => "assistant",
1601 Role::Tool => "tool",
1602 };
1603
1604 for part in &msg.content {
1606 match part {
1607 ContentPart::Text { text } => {
1608 if !text.is_empty() {
1609 self.messages
1610 .push(ChatMessage::new(role_str, text.clone()));
1611 }
1612 }
1613 ContentPart::Image { url, mime_type } => {
1614 self.messages.push(
1615 ChatMessage::new(role_str, "").with_message_type(
1616 MessageType::Image {
1617 url: url.clone(),
1618 mime_type: mime_type.clone(),
1619 },
1620 ),
1621 );
1622 }
1623 ContentPart::ToolCall {
1624 name, arguments, ..
1625 } => {
1626 let (preview, truncated) = build_tool_arguments_preview(
1627 name,
1628 arguments,
1629 TOOL_ARGS_PREVIEW_MAX_LINES,
1630 TOOL_ARGS_PREVIEW_MAX_BYTES,
1631 );
1632 self.messages.push(
1633 ChatMessage::new(role_str, format!("🔧 {name}"))
1634 .with_message_type(MessageType::ToolCall {
1635 name: name.clone(),
1636 arguments_preview: preview,
1637 arguments_len: arguments.len(),
1638 truncated,
1639 }),
1640 );
1641 }
1642 ContentPart::ToolResult { content, .. } => {
1643 let truncated = truncate_with_ellipsis(content, 500);
1644 let (preview, preview_truncated) = build_text_preview(
1645 content,
1646 TOOL_OUTPUT_PREVIEW_MAX_LINES,
1647 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
1648 );
1649 self.messages.push(
1650 ChatMessage::new(
1651 role_str,
1652 format!("✅ Result\n{truncated}"),
1653 )
1654 .with_message_type(MessageType::ToolResult {
1655 name: "tool".to_string(),
1656 output_preview: preview,
1657 output_len: content.len(),
1658 truncated: preview_truncated,
1659 }),
1660 );
1661 }
1662 ContentPart::File { path, mime_type } => {
1663 self.messages.push(
1664 ChatMessage::new(role_str, format!("📎 {}", path))
1665 .with_message_type(MessageType::File {
1666 path: path.clone(),
1667 mime_type: mime_type.clone(),
1668 }),
1669 );
1670 }
1671 ContentPart::Thinking { text } => {
1672 if !text.is_empty() {
1673 self.messages.push(
1674 ChatMessage::new(role_str, text.clone())
1675 .with_message_type(MessageType::Thinking(
1676 text.clone(),
1677 )),
1678 );
1679 }
1680 }
1681 }
1682 }
1683 }
1684
1685 self.current_agent = session.agent.clone();
1686 self.session = Some(session);
1687 self.scroll = SCROLL_BOTTOM;
1688 }
1689 Err(e) => {
1690 self.messages.push(ChatMessage::new(
1691 "system",
1692 format!("Failed to load session: {}", e),
1693 ));
1694 }
1695 }
1696 return;
1697 }
1698
1699 if message.trim() == "/model" || message.trim().starts_with("/model ") {
1701 let direct_model = message
1702 .trim()
1703 .strip_prefix("/model")
1704 .map(|s| s.trim())
1705 .filter(|s| !s.is_empty());
1706
1707 if let Some(model_str) = direct_model {
1708 self.active_model = Some(model_str.to_string());
1710 if let Some(session) = self.session.as_mut() {
1711 session.metadata.model = Some(model_str.to_string());
1712 }
1713 self.messages.push(ChatMessage::new(
1714 "system",
1715 format!("Model set to: {}", model_str),
1716 ));
1717 } else {
1718 self.open_model_picker(config).await;
1720 }
1721 return;
1722 }
1723
1724 if message.trim() == "/undo" {
1726 let mut found_user = false;
1729 while let Some(msg) = self.messages.last() {
1730 if msg.role == "user" {
1731 if found_user {
1732 break; }
1734 found_user = true;
1735 }
1736 if msg.role == "system" && !found_user {
1738 break;
1739 }
1740 self.messages.pop();
1741 }
1742
1743 if !found_user {
1744 self.messages
1745 .push(ChatMessage::new("system", "Nothing to undo."));
1746 return;
1747 }
1748
1749 if let Some(session) = self.session.as_mut() {
1752 let mut found_session_user = false;
1753 while let Some(msg) = session.messages.last() {
1754 if msg.role == crate::provider::Role::User {
1755 if found_session_user {
1756 break;
1757 }
1758 found_session_user = true;
1759 }
1760 if msg.role == crate::provider::Role::System && !found_session_user {
1761 break;
1762 }
1763 session.messages.pop();
1764 }
1765 if let Err(e) = session.save().await {
1766 tracing::warn!(error = %e, "Failed to save session after undo");
1767 }
1768 }
1769
1770 self.messages.push(ChatMessage::new(
1771 "system",
1772 "Undid last message and response.",
1773 ));
1774 self.scroll = SCROLL_BOTTOM;
1775 return;
1776 }
1777
1778 if message.trim() == "/new" {
1780 self.session = None;
1781 self.messages.clear();
1782 self.messages.push(ChatMessage::new(
1783 "system",
1784 "Started a new session. Previous session was saved.",
1785 ));
1786 return;
1787 }
1788
1789 self.messages
1791 .push(ChatMessage::new("user", message.clone()));
1792
1793 self.scroll = SCROLL_BOTTOM;
1795
1796 let current_agent = self.current_agent.clone();
1797 let model = self
1798 .active_model
1799 .clone()
1800 .or_else(|| {
1801 config
1802 .agents
1803 .get(¤t_agent)
1804 .and_then(|agent| agent.model.clone())
1805 })
1806 .or_else(|| config.default_model.clone())
1807 .or_else(|| Some("zai/glm-5".to_string()));
1808
1809 if self.session.is_none() {
1811 match Session::new().await {
1812 Ok(session) => {
1813 self.session = Some(session);
1814 }
1815 Err(err) => {
1816 tracing::error!(error = %err, "Failed to create session");
1817 self.messages
1818 .push(ChatMessage::new("assistant", format!("Error: {err}")));
1819 return;
1820 }
1821 }
1822 }
1823
1824 let session = match self.session.as_mut() {
1825 Some(session) => session,
1826 None => {
1827 self.messages.push(ChatMessage::new(
1828 "assistant",
1829 "Error: session not initialized",
1830 ));
1831 return;
1832 }
1833 };
1834
1835 if let Some(model) = model {
1836 session.metadata.model = Some(model);
1837 }
1838
1839 session.agent = current_agent;
1840
1841 self.is_processing = true;
1843 self.processing_message = Some("Thinking...".to_string());
1844 self.current_tool = None;
1845 self.processing_started_at = Some(Instant::now());
1846 self.streaming_text = None;
1847
1848 if self.provider_registry.is_none() {
1850 match crate::provider::ProviderRegistry::from_vault().await {
1851 Ok(registry) => {
1852 self.provider_registry = Some(std::sync::Arc::new(registry));
1853 }
1854 Err(err) => {
1855 tracing::error!(error = %err, "Failed to load provider registry");
1856 self.messages.push(ChatMessage::new(
1857 "assistant",
1858 format!("Error loading providers: {err}"),
1859 ));
1860 self.is_processing = false;
1861 return;
1862 }
1863 }
1864 }
1865 let registry = self.provider_registry.clone().unwrap();
1866
1867 let (tx, rx) = mpsc::channel(100);
1869 self.response_rx = Some(rx);
1870
1871 let session_clone = session.clone();
1873 let message_clone = message.clone();
1874
1875 tokio::spawn(async move {
1877 let mut session = session_clone;
1878 if let Err(err) = session
1879 .prompt_with_events(&message_clone, tx.clone(), registry)
1880 .await
1881 {
1882 tracing::error!(error = %err, "Agent processing failed");
1883 let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
1884 let _ = tx.send(SessionEvent::Done).await;
1885 }
1886 });
1887 }
1888
1889 fn handle_response(&mut self, event: SessionEvent) {
1890 self.scroll = SCROLL_BOTTOM;
1892
1893 match event {
1894 SessionEvent::Thinking => {
1895 self.processing_message = Some("Thinking...".to_string());
1896 self.current_tool = None;
1897 if self.processing_started_at.is_none() {
1898 self.processing_started_at = Some(Instant::now());
1899 }
1900 }
1901 SessionEvent::ToolCallStart { name, arguments } => {
1902 if let Some(text) = self.streaming_text.take() {
1904 if !text.is_empty() {
1905 self.messages.push(ChatMessage::new("assistant", text));
1906 }
1907 }
1908 self.processing_message = Some(format!("Running {}...", name));
1909 self.current_tool = Some(name.clone());
1910 self.tool_call_count += 1;
1911
1912 let (preview, truncated) = build_tool_arguments_preview(
1913 &name,
1914 &arguments,
1915 TOOL_ARGS_PREVIEW_MAX_LINES,
1916 TOOL_ARGS_PREVIEW_MAX_BYTES,
1917 );
1918 self.messages.push(
1919 ChatMessage::new("tool", format!("🔧 {}", name)).with_message_type(
1920 MessageType::ToolCall {
1921 name,
1922 arguments_preview: preview,
1923 arguments_len: arguments.len(),
1924 truncated,
1925 },
1926 ),
1927 );
1928 }
1929 SessionEvent::ToolCallComplete {
1930 name,
1931 output,
1932 success,
1933 } => {
1934 let icon = if success { "✓" } else { "✗" };
1935
1936 let (preview, truncated) = build_text_preview(
1937 &output,
1938 TOOL_OUTPUT_PREVIEW_MAX_LINES,
1939 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
1940 );
1941 self.messages.push(
1942 ChatMessage::new("tool", format!("{} {}", icon, name)).with_message_type(
1943 MessageType::ToolResult {
1944 name,
1945 output_preview: preview,
1946 output_len: output.len(),
1947 truncated,
1948 },
1949 ),
1950 );
1951 self.current_tool = None;
1952 self.processing_message = Some("Thinking...".to_string());
1953 }
1954 SessionEvent::TextChunk(text) => {
1955 self.streaming_text = Some(text);
1957 }
1958 SessionEvent::ThinkingComplete(text) => {
1959 if !text.is_empty() {
1960 self.messages.push(
1961 ChatMessage::new("assistant", &text)
1962 .with_message_type(MessageType::Thinking(text)),
1963 );
1964 }
1965 }
1966 SessionEvent::TextComplete(text) => {
1967 self.streaming_text = None;
1969 if !text.is_empty() {
1970 self.messages.push(ChatMessage::new("assistant", text));
1971 }
1972 }
1973 SessionEvent::UsageReport {
1974 prompt_tokens,
1975 completion_tokens,
1976 duration_ms,
1977 model,
1978 } => {
1979 let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
1980 let meta = UsageMeta {
1981 prompt_tokens,
1982 completion_tokens,
1983 duration_ms,
1984 cost_usd,
1985 };
1986 if let Some(msg) = self
1988 .messages
1989 .iter_mut()
1990 .rev()
1991 .find(|m| m.role == "assistant")
1992 {
1993 msg.usage_meta = Some(meta);
1994 }
1995 }
1996 SessionEvent::SessionSync(session) => {
1997 self.session = Some(session);
2000 }
2001 SessionEvent::Error(err) => {
2002 self.messages
2003 .push(ChatMessage::new("assistant", format!("Error: {}", err)));
2004 }
2005 SessionEvent::Done => {
2006 self.is_processing = false;
2007 self.processing_message = None;
2008 self.current_tool = None;
2009 self.processing_started_at = None;
2010 self.streaming_text = None;
2011 self.response_rx = None;
2012 }
2013 }
2014 }
2015
2016 async fn send_to_agent(&mut self, agent_name: &str, message: &str, _config: &Config) {
2018 if self.provider_registry.is_none() {
2020 match crate::provider::ProviderRegistry::from_vault().await {
2021 Ok(registry) => {
2022 self.provider_registry = Some(std::sync::Arc::new(registry));
2023 }
2024 Err(err) => {
2025 self.messages.push(ChatMessage::new(
2026 "system",
2027 format!("Error loading providers: {err}"),
2028 ));
2029 return;
2030 }
2031 }
2032 }
2033 let registry = self.provider_registry.clone().unwrap();
2034
2035 let agent = match self.spawned_agents.get_mut(agent_name) {
2036 Some(a) => a,
2037 None => return,
2038 };
2039
2040 agent.is_processing = true;
2041 let session_clone = agent.session.clone();
2042 let msg_clone = message.to_string();
2043 let agent_name_owned = agent_name.to_string();
2044 let bus_arc = self.bus.clone();
2045
2046 let (tx, rx) = mpsc::channel(100);
2047 self.agent_response_rxs.push((agent_name.to_string(), rx));
2048
2049 tokio::spawn(async move {
2050 let mut session = session_clone;
2051 if let Err(err) = session
2052 .prompt_with_events(&msg_clone, tx.clone(), registry)
2053 .await
2054 {
2055 tracing::error!(agent = %agent_name_owned, error = %err, "Spawned agent failed");
2056 let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
2057 let _ = tx.send(SessionEvent::Done).await;
2058 }
2059
2060 if let Some(ref bus) = bus_arc {
2062 let handle = bus.handle(&agent_name_owned);
2063 handle.send(
2064 format!("agent.{agent_name_owned}.events"),
2065 crate::bus::BusMessage::AgentMessage {
2066 from: agent_name_owned.clone(),
2067 to: "user".to_string(),
2068 parts: vec![crate::a2a::types::Part::Text {
2069 text: "(response complete)".to_string(),
2070 }],
2071 },
2072 );
2073 }
2074 });
2075 }
2076
2077 fn handle_agent_response(&mut self, agent_name: &str, event: SessionEvent) {
2079 self.scroll = SCROLL_BOTTOM;
2080
2081 match event {
2082 SessionEvent::Thinking => {
2083 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
2085 agent.is_processing = true;
2086 }
2087 }
2088 SessionEvent::ToolCallStart { name, arguments } => {
2089 let (preview, truncated) = build_tool_arguments_preview(
2090 &name,
2091 &arguments,
2092 TOOL_ARGS_PREVIEW_MAX_LINES,
2093 TOOL_ARGS_PREVIEW_MAX_BYTES,
2094 );
2095 self.messages.push(
2096 ChatMessage::new("tool", format!("🔧 @{agent_name} → {name}"))
2097 .with_message_type(MessageType::ToolCall {
2098 name,
2099 arguments_preview: preview,
2100 arguments_len: arguments.len(),
2101 truncated,
2102 })
2103 .with_agent_name(agent_name),
2104 );
2105 }
2106 SessionEvent::ToolCallComplete {
2107 name,
2108 output,
2109 success,
2110 } => {
2111 let icon = if success { "✓" } else { "✗" };
2112 let (preview, truncated) = build_text_preview(
2113 &output,
2114 TOOL_OUTPUT_PREVIEW_MAX_LINES,
2115 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
2116 );
2117 self.messages.push(
2118 ChatMessage::new("tool", format!("{icon} @{agent_name} → {name}"))
2119 .with_message_type(MessageType::ToolResult {
2120 name,
2121 output_preview: preview,
2122 output_len: output.len(),
2123 truncated,
2124 })
2125 .with_agent_name(agent_name),
2126 );
2127 }
2128 SessionEvent::TextChunk(_text) => {
2129 }
2131 SessionEvent::ThinkingComplete(text) => {
2132 if !text.is_empty() {
2133 self.messages.push(
2134 ChatMessage::new("assistant", &text)
2135 .with_message_type(MessageType::Thinking(text))
2136 .with_agent_name(agent_name),
2137 );
2138 }
2139 }
2140 SessionEvent::TextComplete(text) => {
2141 if !text.is_empty() {
2142 self.messages
2143 .push(ChatMessage::new("assistant", &text).with_agent_name(agent_name));
2144 }
2145 }
2146 SessionEvent::UsageReport {
2147 prompt_tokens,
2148 completion_tokens,
2149 duration_ms,
2150 model,
2151 } => {
2152 let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
2153 let meta = UsageMeta {
2154 prompt_tokens,
2155 completion_tokens,
2156 duration_ms,
2157 cost_usd,
2158 };
2159 if let Some(msg) =
2160 self.messages.iter_mut().rev().find(|m| {
2161 m.role == "assistant" && m.agent_name.as_deref() == Some(agent_name)
2162 })
2163 {
2164 msg.usage_meta = Some(meta);
2165 }
2166 }
2167 SessionEvent::SessionSync(session) => {
2168 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
2169 agent.session = session;
2170 }
2171 }
2172 SessionEvent::Error(err) => {
2173 self.messages.push(
2174 ChatMessage::new("assistant", format!("Error: {err}"))
2175 .with_agent_name(agent_name),
2176 );
2177 }
2178 SessionEvent::Done => {
2179 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
2180 agent.is_processing = false;
2181 }
2182 }
2183 }
2184 }
2185
2186 fn handle_swarm_event(&mut self, event: SwarmEvent) {
2188 self.swarm_state.handle_event(event.clone());
2189
2190 if let SwarmEvent::Complete { success, ref stats } = event {
2192 self.view_mode = ViewMode::Chat;
2193 let summary = if success {
2194 format!(
2195 "Swarm completed successfully.\n\
2196 Subtasks: {} completed, {} failed\n\
2197 Total tool calls: {}\n\
2198 Time: {:.1}s (speedup: {:.1}x)",
2199 stats.subagents_completed,
2200 stats.subagents_failed,
2201 stats.total_tool_calls,
2202 stats.execution_time_ms as f64 / 1000.0,
2203 stats.speedup_factor
2204 )
2205 } else {
2206 format!(
2207 "Swarm completed with failures.\n\
2208 Subtasks: {} completed, {} failed\n\
2209 Check the subtask results for details.",
2210 stats.subagents_completed, stats.subagents_failed
2211 )
2212 };
2213 self.messages.push(ChatMessage::new("system", &summary));
2214 self.swarm_rx = None;
2215 }
2216
2217 if let SwarmEvent::Error(ref err) = event {
2218 self.messages
2219 .push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
2220 }
2221 }
2222
2223 fn handle_ralph_event(&mut self, event: RalphEvent) {
2225 self.ralph_state.handle_event(event.clone());
2226
2227 if let RalphEvent::Complete {
2229 ref status,
2230 passed,
2231 total,
2232 } = event
2233 {
2234 self.view_mode = ViewMode::Chat;
2235 let summary = format!(
2236 "Ralph loop finished: {}\n\
2237 Stories: {}/{} passed",
2238 status, passed, total
2239 );
2240 self.messages.push(ChatMessage::new("system", &summary));
2241 self.ralph_rx = None;
2242 }
2243
2244 if let RalphEvent::Error(ref err) = event {
2245 self.messages
2246 .push(ChatMessage::new("system", &format!("Ralph error: {}", err)));
2247 }
2248 }
2249
2250 async fn start_ralph_execution(&mut self, prd_path: String, config: &Config) {
2252 self.messages
2254 .push(ChatMessage::new("user", format!("/ralph {}", prd_path)));
2255
2256 let model = self
2258 .active_model
2259 .clone()
2260 .or_else(|| config.default_model.clone())
2261 .or_else(|| Some("zai/glm-5".to_string()));
2262
2263 let model = match model {
2264 Some(m) => m,
2265 None => {
2266 self.messages.push(ChatMessage::new(
2267 "system",
2268 "No model configured. Use /model to select one first.",
2269 ));
2270 return;
2271 }
2272 };
2273
2274 let prd_file = std::path::PathBuf::from(&prd_path);
2276 if !prd_file.exists() {
2277 self.messages.push(ChatMessage::new(
2278 "system",
2279 format!("PRD file not found: {}", prd_path),
2280 ));
2281 return;
2282 }
2283
2284 let (tx, rx) = mpsc::channel(200);
2286 self.ralph_rx = Some(rx);
2287
2288 self.view_mode = ViewMode::Ralph;
2290 self.ralph_state = RalphViewState::new();
2291
2292 let ralph_config = RalphConfig {
2294 prd_path: prd_path.clone(),
2295 max_iterations: 10,
2296 progress_path: "progress.txt".to_string(),
2297 quality_checks_enabled: true,
2298 auto_commit: true,
2299 model: Some(model.clone()),
2300 use_rlm: false,
2301 parallel_enabled: true,
2302 max_concurrent_stories: 3,
2303 worktree_enabled: true,
2304 story_timeout_secs: 300,
2305 conflict_timeout_secs: 120,
2306 };
2307
2308 let (provider_name, model_name) = if let Some(pos) = model.find('/') {
2310 (model[..pos].to_string(), model[pos + 1..].to_string())
2311 } else {
2312 (model.clone(), model.clone())
2313 };
2314
2315 let prd_path_clone = prd_path.clone();
2316 let tx_clone = tx.clone();
2317
2318 tokio::spawn(async move {
2320 let provider = match crate::provider::ProviderRegistry::from_vault().await {
2322 Ok(registry) => match registry.get(&provider_name) {
2323 Some(p) => p,
2324 None => {
2325 let _ = tx_clone
2326 .send(RalphEvent::Error(format!(
2327 "Provider '{}' not found",
2328 provider_name
2329 )))
2330 .await;
2331 return;
2332 }
2333 },
2334 Err(e) => {
2335 let _ = tx_clone
2336 .send(RalphEvent::Error(format!(
2337 "Failed to load providers: {}",
2338 e
2339 )))
2340 .await;
2341 return;
2342 }
2343 };
2344
2345 let prd_path_buf = std::path::PathBuf::from(&prd_path_clone);
2346 match RalphLoop::new(prd_path_buf, provider, model_name, ralph_config).await {
2347 Ok(ralph) => {
2348 let mut ralph = ralph.with_event_tx(tx_clone.clone());
2349 match ralph.run().await {
2350 Ok(_state) => {
2351 }
2353 Err(e) => {
2354 let _ = tx_clone.send(RalphEvent::Error(e.to_string())).await;
2355 }
2356 }
2357 }
2358 Err(e) => {
2359 let _ = tx_clone
2360 .send(RalphEvent::Error(format!(
2361 "Failed to initialize Ralph: {}",
2362 e
2363 )))
2364 .await;
2365 }
2366 }
2367 });
2368
2369 self.messages.push(ChatMessage::new(
2370 "system",
2371 format!("Starting Ralph loop with PRD: {}", prd_path),
2372 ));
2373 }
2374
2375 async fn start_swarm_execution(&mut self, task: String, config: &Config) {
2377 self.messages
2379 .push(ChatMessage::new("user", format!("/swarm {}", task)));
2380
2381 let model = config
2383 .default_model
2384 .clone()
2385 .or_else(|| Some("zai/glm-5".to_string()));
2386
2387 let swarm_config = SwarmConfig {
2389 model,
2390 max_subagents: 10,
2391 max_steps_per_subagent: 50,
2392 worktree_enabled: true,
2393 worktree_auto_merge: true,
2394 working_dir: Some(
2395 std::env::current_dir()
2396 .map(|p| p.to_string_lossy().to_string())
2397 .unwrap_or_else(|_| ".".to_string()),
2398 ),
2399 ..Default::default()
2400 };
2401
2402 let (tx, rx) = mpsc::channel(100);
2404 self.swarm_rx = Some(rx);
2405
2406 self.view_mode = ViewMode::Swarm;
2408 self.swarm_state = SwarmViewState::new();
2409
2410 let _ = tx
2412 .send(SwarmEvent::Started {
2413 task: task.clone(),
2414 total_subtasks: 0,
2415 })
2416 .await;
2417
2418 let task_clone = task;
2420 let bus_arc = self.bus.clone();
2421 tokio::spawn(async move {
2422 let mut executor = SwarmExecutor::new(swarm_config).with_event_tx(tx.clone());
2424 if let Some(bus) = bus_arc {
2425 executor = executor.with_bus(bus);
2426 }
2427 let result = executor
2428 .execute(&task_clone, DecompositionStrategy::Automatic)
2429 .await;
2430
2431 match result {
2432 Ok(swarm_result) => {
2433 let _ = tx
2434 .send(SwarmEvent::Complete {
2435 success: swarm_result.success,
2436 stats: swarm_result.stats,
2437 })
2438 .await;
2439 }
2440 Err(e) => {
2441 let _ = tx.send(SwarmEvent::Error(e.to_string())).await;
2442 }
2443 }
2444 });
2445 }
2446
2447 async fn open_model_picker(&mut self, config: &Config) {
2449 let mut models: Vec<(String, String, String)> = Vec::new();
2450
2451 match crate::provider::ProviderRegistry::from_vault().await {
2453 Ok(registry) => {
2454 for provider_name in registry.list() {
2455 if let Some(provider) = registry.get(provider_name) {
2456 match provider.list_models().await {
2457 Ok(model_list) => {
2458 for m in model_list {
2459 let label = format!("{}/{}", provider_name, m.id);
2460 let value = format!("{}/{}", provider_name, m.id);
2461 let name = m.name.clone();
2462 models.push((label, value, name));
2463 }
2464 }
2465 Err(e) => {
2466 tracing::warn!(
2467 "Failed to list models for {}: {}",
2468 provider_name,
2469 e
2470 );
2471 }
2472 }
2473 }
2474 }
2475 }
2476 Err(e) => {
2477 tracing::warn!("Failed to load provider registry: {}", e);
2478 }
2479 }
2480
2481 if models.is_empty() {
2483 if let Ok(registry) = crate::provider::ProviderRegistry::from_config(config).await {
2484 for provider_name in registry.list() {
2485 if let Some(provider) = registry.get(provider_name) {
2486 if let Ok(model_list) = provider.list_models().await {
2487 for m in model_list {
2488 let label = format!("{}/{}", provider_name, m.id);
2489 let value = format!("{}/{}", provider_name, m.id);
2490 let name = m.name.clone();
2491 models.push((label, value, name));
2492 }
2493 }
2494 }
2495 }
2496 }
2497 }
2498
2499 if models.is_empty() {
2500 self.messages.push(ChatMessage::new(
2501 "system",
2502 "No models found. Check provider configuration (Vault or config).",
2503 ));
2504 } else {
2505 models.sort_by(|a, b| a.0.cmp(&b.0));
2507 self.model_picker_list = models;
2508 self.model_picker_selected = 0;
2509 self.model_picker_filter.clear();
2510 self.view_mode = ViewMode::ModelPicker;
2511 }
2512 }
2513
2514 fn filtered_sessions(&self) -> Vec<(usize, &SessionSummary)> {
2516 if self.session_picker_filter.is_empty() {
2517 self.session_picker_list.iter().enumerate().collect()
2518 } else {
2519 let filter = self.session_picker_filter.to_lowercase();
2520 self.session_picker_list
2521 .iter()
2522 .enumerate()
2523 .filter(|(_, s)| {
2524 s.title
2525 .as_deref()
2526 .unwrap_or("")
2527 .to_lowercase()
2528 .contains(&filter)
2529 || s.agent.to_lowercase().contains(&filter)
2530 || s.id.to_lowercase().contains(&filter)
2531 })
2532 .collect()
2533 }
2534 }
2535
2536 fn filtered_models(&self) -> Vec<(usize, &(String, String, String))> {
2538 if self.model_picker_filter.is_empty() {
2539 self.model_picker_list.iter().enumerate().collect()
2540 } else {
2541 let filter = self.model_picker_filter.to_lowercase();
2542 self.model_picker_list
2543 .iter()
2544 .enumerate()
2545 .filter(|(_, (label, _, name))| {
2546 label.to_lowercase().contains(&filter) || name.to_lowercase().contains(&filter)
2547 })
2548 .collect()
2549 }
2550 }
2551
2552 fn filtered_spawned_agents(&self) -> Vec<(String, String, bool, bool)> {
2554 let mut agents: Vec<(String, String, bool, bool)> = self
2555 .spawned_agents
2556 .iter()
2557 .map(|(name, agent)| {
2558 let protocol_registered = self.is_agent_protocol_registered(name);
2559 (
2560 name.clone(),
2561 agent.instructions.clone(),
2562 agent.is_processing,
2563 protocol_registered,
2564 )
2565 })
2566 .collect();
2567
2568 agents.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
2569
2570 if self.agent_picker_filter.is_empty() {
2571 agents
2572 } else {
2573 let filter = self.agent_picker_filter.to_lowercase();
2574 agents
2575 .into_iter()
2576 .filter(|(name, instructions, _, _)| {
2577 name.to_lowercase().contains(&filter)
2578 || instructions.to_lowercase().contains(&filter)
2579 })
2580 .collect()
2581 }
2582 }
2583
2584 fn open_agent_picker(&mut self) {
2586 if self.spawned_agents.is_empty() {
2587 self.messages.push(ChatMessage::new(
2588 "system",
2589 "No agents spawned yet. Use /spawn <name> <instructions> first.",
2590 ));
2591 return;
2592 }
2593
2594 self.agent_picker_filter.clear();
2595 let filtered = self.filtered_spawned_agents();
2596 self.agent_picker_selected = if let Some(active) = &self.active_spawned_agent {
2597 filtered
2598 .iter()
2599 .position(|(name, _, _, _)| name == active)
2600 .unwrap_or(0)
2601 } else {
2602 0
2603 };
2604 self.view_mode = ViewMode::AgentPicker;
2605 }
2606
2607 fn navigate_history(&mut self, direction: isize) {
2608 if self.command_history.is_empty() {
2609 return;
2610 }
2611
2612 let history_len = self.command_history.len();
2613 let new_index = match self.history_index {
2614 Some(current) => {
2615 let new = current as isize + direction;
2616 if new < 0 {
2617 None
2618 } else if new >= history_len as isize {
2619 Some(history_len - 1)
2620 } else {
2621 Some(new as usize)
2622 }
2623 }
2624 None => {
2625 if direction > 0 {
2626 Some(0)
2627 } else {
2628 Some(history_len.saturating_sub(1))
2629 }
2630 }
2631 };
2632
2633 self.history_index = new_index;
2634 if let Some(index) = new_index {
2635 self.input = self.command_history[index].clone();
2636 self.cursor_position = self.input.len();
2637 } else {
2638 self.input.clear();
2639 self.cursor_position = 0;
2640 }
2641 }
2642
2643 fn search_history(&mut self) {
2644 if self.command_history.is_empty() {
2646 return;
2647 }
2648
2649 let search_term = self.input.trim().to_lowercase();
2650
2651 if search_term.is_empty() {
2652 if !self.command_history.is_empty() {
2654 self.input = self.command_history.last().unwrap().clone();
2655 self.cursor_position = self.input.len();
2656 self.history_index = Some(self.command_history.len() - 1);
2657 }
2658 return;
2659 }
2660
2661 for (index, cmd) in self.command_history.iter().enumerate().rev() {
2663 if cmd.to_lowercase().starts_with(&search_term) {
2664 self.input = cmd.clone();
2665 self.cursor_position = self.input.len();
2666 self.history_index = Some(index);
2667 return;
2668 }
2669 }
2670
2671 for (index, cmd) in self.command_history.iter().enumerate().rev() {
2673 if cmd.to_lowercase().contains(&search_term) {
2674 self.input = cmd.clone();
2675 self.cursor_position = self.input.len();
2676 self.history_index = Some(index);
2677 return;
2678 }
2679 }
2680 }
2681}
2682
2683async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
2684 let mut app = App::new();
2685 if let Ok(sessions) = list_sessions_with_opencode(&app.workspace_dir).await {
2686 app.update_cached_sessions(sessions);
2687 }
2688
2689 let bus = std::sync::Arc::new(crate::bus::AgentBus::new());
2691 let mut bus_handle = bus.handle("tui-observer");
2692 let (bus_tx, bus_rx) = mpsc::channel::<crate::bus::BusEnvelope>(512);
2693 app.bus_log_rx = Some(bus_rx);
2694 app.bus = Some(bus.clone());
2695
2696 tokio::spawn(async move {
2698 loop {
2699 match bus_handle.recv().await {
2700 Some(env) => {
2701 if bus_tx.send(env).await.is_err() {
2702 break; }
2704 }
2705 None => break, }
2707 }
2708 });
2709
2710 let mut config = Config::load().await?;
2712 let mut theme = crate::tui::theme_utils::validate_theme(&config.load_theme());
2713
2714 let _config_paths = vec![
2716 std::path::PathBuf::from("./codetether.toml"),
2717 std::path::PathBuf::from("./.codetether/config.toml"),
2718 ];
2719
2720 let _global_config_path = directories::ProjectDirs::from("com", "codetether", "codetether")
2721 .map(|dirs| dirs.config_dir().join("config.toml"));
2722
2723 let mut last_check = Instant::now();
2724 let mut event_stream = EventStream::new();
2725
2726 let (session_tx, mut session_rx) = mpsc::channel::<Vec<crate::session::SessionSummary>>(1);
2728 {
2729 let workspace_dir = app.workspace_dir.clone();
2730 tokio::spawn(async move {
2731 let mut interval = tokio::time::interval(Duration::from_secs(5));
2732 loop {
2733 interval.tick().await;
2734 if let Ok(sessions) = list_sessions_with_opencode(&workspace_dir).await {
2735 if session_tx.send(sessions).await.is_err() {
2736 break; }
2738 }
2739 }
2740 });
2741 }
2742
2743 loop {
2744 if let Ok(sessions) = session_rx.try_recv() {
2748 app.update_cached_sessions(sessions);
2749 }
2750
2751 if config.ui.hot_reload && last_check.elapsed() > Duration::from_secs(2) {
2753 if let Ok(new_config) = Config::load().await {
2754 if new_config.ui.theme != config.ui.theme
2755 || new_config.ui.custom_theme != config.ui.custom_theme
2756 {
2757 theme = crate::tui::theme_utils::validate_theme(&new_config.load_theme());
2758 config = new_config;
2759 }
2760 }
2761 last_check = Instant::now();
2762 }
2763
2764 terminal.draw(|f| ui(f, &mut app, &theme))?;
2765
2766 let terminal_height = terminal.size()?.height.saturating_sub(6) as usize;
2769 let estimated_lines = app.messages.len() * 4; app.last_max_scroll = estimated_lines.saturating_sub(terminal_height);
2771
2772 if let Some(mut rx) = app.response_rx.take() {
2774 while let Ok(response) = rx.try_recv() {
2775 app.handle_response(response);
2776 }
2777 app.response_rx = Some(rx);
2778 }
2779
2780 if let Some(mut rx) = app.swarm_rx.take() {
2782 while let Ok(event) = rx.try_recv() {
2783 app.handle_swarm_event(event);
2784 }
2785 app.swarm_rx = Some(rx);
2786 }
2787
2788 if let Some(mut rx) = app.ralph_rx.take() {
2790 while let Ok(event) = rx.try_recv() {
2791 app.handle_ralph_event(event);
2792 }
2793 app.ralph_rx = Some(rx);
2794 }
2795
2796 if let Some(mut rx) = app.bus_log_rx.take() {
2798 while let Ok(env) = rx.try_recv() {
2799 app.bus_log_state.ingest(&env);
2800 }
2801 app.bus_log_rx = Some(rx);
2802 }
2803
2804 {
2806 let mut i = 0;
2807 while i < app.agent_response_rxs.len() {
2808 let mut done = false;
2809 while let Ok(event) = app.agent_response_rxs[i].1.try_recv() {
2810 if matches!(event, SessionEvent::Done) {
2811 done = true;
2812 }
2813 let name = app.agent_response_rxs[i].0.clone();
2814 app.handle_agent_response(&name, event);
2815 }
2816 if done {
2817 app.agent_response_rxs.swap_remove(i);
2818 } else {
2819 i += 1;
2820 }
2821 }
2822 }
2823
2824 let ev = tokio::select! {
2826 maybe_event = event_stream.next() => {
2827 match maybe_event {
2828 Some(Ok(ev)) => ev,
2829 Some(Err(_)) => continue,
2830 None => return Ok(()), }
2832 }
2833 _ = tokio::time::sleep(Duration::from_millis(50)) => continue,
2835 };
2836
2837 if let Event::Paste(text) = &ev {
2839 let mut pos = app.cursor_position;
2841 while pos > 0 && !app.input.is_char_boundary(pos) {
2842 pos -= 1;
2843 }
2844 app.cursor_position = pos;
2845
2846 for c in text.chars() {
2847 if c == '\n' || c == '\r' {
2848 app.input.insert(app.cursor_position, ' ');
2850 } else {
2851 app.input.insert(app.cursor_position, c);
2852 }
2853 app.cursor_position += c.len_utf8();
2854 }
2855 continue;
2856 }
2857
2858 if let Event::Key(key) = ev {
2859 if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
2863 continue;
2864 }
2865
2866 if app.show_help {
2868 if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
2869 app.show_help = false;
2870 }
2871 continue;
2872 }
2873
2874 if app.view_mode == ViewMode::ModelPicker {
2876 match key.code {
2877 KeyCode::Esc => {
2878 app.view_mode = ViewMode::Chat;
2879 }
2880 KeyCode::Up | KeyCode::Char('k')
2881 if !key.modifiers.contains(KeyModifiers::ALT) =>
2882 {
2883 if app.model_picker_selected > 0 {
2884 app.model_picker_selected -= 1;
2885 }
2886 }
2887 KeyCode::Down | KeyCode::Char('j')
2888 if !key.modifiers.contains(KeyModifiers::ALT) =>
2889 {
2890 let filtered = app.filtered_models();
2891 if app.model_picker_selected < filtered.len().saturating_sub(1) {
2892 app.model_picker_selected += 1;
2893 }
2894 }
2895 KeyCode::Enter => {
2896 let filtered = app.filtered_models();
2897 if let Some((_, (label, value, _name))) =
2898 filtered.get(app.model_picker_selected)
2899 {
2900 let label = label.clone();
2901 let value = value.clone();
2902 app.active_model = Some(value.clone());
2903 if let Some(session) = app.session.as_mut() {
2904 session.metadata.model = Some(value.clone());
2905 }
2906 app.messages.push(ChatMessage::new(
2907 "system",
2908 format!("Model set to: {}", label),
2909 ));
2910 app.view_mode = ViewMode::Chat;
2911 }
2912 }
2913 KeyCode::Backspace => {
2914 app.model_picker_filter.pop();
2915 app.model_picker_selected = 0;
2916 }
2917 KeyCode::Char(c)
2918 if !key.modifiers.contains(KeyModifiers::CONTROL)
2919 && !key.modifiers.contains(KeyModifiers::ALT) =>
2920 {
2921 app.model_picker_filter.push(c);
2922 app.model_picker_selected = 0;
2923 }
2924 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2925 return Ok(());
2926 }
2927 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2928 return Ok(());
2929 }
2930 _ => {}
2931 }
2932 continue;
2933 }
2934
2935 if app.view_mode == ViewMode::SessionPicker {
2937 match key.code {
2938 KeyCode::Esc => {
2939 if app.session_picker_confirm_delete {
2940 app.session_picker_confirm_delete = false;
2941 } else {
2942 app.session_picker_filter.clear();
2943 app.view_mode = ViewMode::Chat;
2944 }
2945 }
2946 KeyCode::Up | KeyCode::Char('k') => {
2947 if app.session_picker_selected > 0 {
2948 app.session_picker_selected -= 1;
2949 }
2950 app.session_picker_confirm_delete = false;
2951 }
2952 KeyCode::Down | KeyCode::Char('j') => {
2953 let filtered_count = app.filtered_sessions().len();
2954 if app.session_picker_selected < filtered_count.saturating_sub(1) {
2955 app.session_picker_selected += 1;
2956 }
2957 app.session_picker_confirm_delete = false;
2958 }
2959 KeyCode::Char('d') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
2960 if app.session_picker_confirm_delete {
2961 let filtered = app.filtered_sessions();
2963 if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
2964 let session_id = app.session_picker_list[*orig_idx].id.clone();
2965 let is_active = app
2966 .session
2967 .as_ref()
2968 .map(|s| s.id == session_id)
2969 .unwrap_or(false);
2970 if !is_active {
2971 if let Err(e) = Session::delete(&session_id).await {
2972 app.messages.push(ChatMessage::new(
2973 "system",
2974 format!("Failed to delete session: {}", e),
2975 ));
2976 } else {
2977 app.session_picker_list.retain(|s| s.id != session_id);
2978 if app.session_picker_selected
2979 >= app.session_picker_list.len()
2980 {
2981 app.session_picker_selected =
2982 app.session_picker_list.len().saturating_sub(1);
2983 }
2984 }
2985 }
2986 }
2987 app.session_picker_confirm_delete = false;
2988 } else {
2989 let filtered = app.filtered_sessions();
2991 if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
2992 let is_active = app
2993 .session
2994 .as_ref()
2995 .map(|s| s.id == app.session_picker_list[*orig_idx].id)
2996 .unwrap_or(false);
2997 if !is_active {
2998 app.session_picker_confirm_delete = true;
2999 }
3000 }
3001 }
3002 }
3003 KeyCode::Backspace => {
3004 app.session_picker_filter.pop();
3005 app.session_picker_selected = 0;
3006 app.session_picker_confirm_delete = false;
3007 }
3008 KeyCode::Char('/') => {
3009 }
3011 KeyCode::Enter => {
3012 app.session_picker_confirm_delete = false;
3013 let filtered = app.filtered_sessions();
3014 let session_id = filtered
3015 .get(app.session_picker_selected)
3016 .map(|(orig_idx, _)| app.session_picker_list[*orig_idx].id.clone());
3017 if let Some(session_id) = session_id {
3018 let load_result =
3019 if let Some(oc_id) = session_id.strip_prefix("opencode_") {
3020 if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
3021 Session::from_opencode(oc_id, &storage).await
3022 } else {
3023 Err(anyhow::anyhow!("OpenCode storage not available"))
3024 }
3025 } else {
3026 Session::load(&session_id).await
3027 };
3028 match load_result {
3029 Ok(session) => {
3030 app.messages.clear();
3031 app.messages.push(ChatMessage::new(
3032 "system",
3033 format!(
3034 "Resumed session: {}\nCreated: {}\n{} messages loaded",
3035 session.title.as_deref().unwrap_or("(untitled)"),
3036 session.created_at.format("%Y-%m-%d %H:%M"),
3037 session.messages.len()
3038 ),
3039 ));
3040
3041 for msg in &session.messages {
3042 let role_str = match msg.role {
3043 Role::System => "system",
3044 Role::User => "user",
3045 Role::Assistant => "assistant",
3046 Role::Tool => "tool",
3047 };
3048
3049 for part in &msg.content {
3052 match part {
3053 ContentPart::Text { text } => {
3054 if !text.is_empty() {
3055 app.messages.push(ChatMessage::new(
3056 role_str,
3057 text.clone(),
3058 ));
3059 }
3060 }
3061 ContentPart::Image { url, mime_type } => {
3062 app.messages.push(
3063 ChatMessage::new(role_str, "")
3064 .with_message_type(
3065 MessageType::Image {
3066 url: url.clone(),
3067 mime_type: mime_type.clone(),
3068 },
3069 ),
3070 );
3071 }
3072 ContentPart::ToolCall {
3073 name, arguments, ..
3074 } => {
3075 let (preview, truncated) =
3076 build_tool_arguments_preview(
3077 name,
3078 arguments,
3079 TOOL_ARGS_PREVIEW_MAX_LINES,
3080 TOOL_ARGS_PREVIEW_MAX_BYTES,
3081 );
3082 app.messages.push(
3083 ChatMessage::new(
3084 role_str,
3085 format!("🔧 {name}"),
3086 )
3087 .with_message_type(MessageType::ToolCall {
3088 name: name.clone(),
3089 arguments_preview: preview,
3090 arguments_len: arguments.len(),
3091 truncated,
3092 }),
3093 );
3094 }
3095 ContentPart::ToolResult { content, .. } => {
3096 let truncated =
3097 truncate_with_ellipsis(content, 500);
3098 let (preview, preview_truncated) =
3099 build_text_preview(
3100 content,
3101 TOOL_OUTPUT_PREVIEW_MAX_LINES,
3102 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
3103 );
3104 app.messages.push(
3105 ChatMessage::new(
3106 role_str,
3107 format!("✅ Result\n{truncated}"),
3108 )
3109 .with_message_type(
3110 MessageType::ToolResult {
3111 name: "tool".to_string(),
3112 output_preview: preview,
3113 output_len: content.len(),
3114 truncated: preview_truncated,
3115 },
3116 ),
3117 );
3118 }
3119 ContentPart::File { path, mime_type } => {
3120 app.messages.push(
3121 ChatMessage::new(
3122 role_str,
3123 format!("📎 {path}"),
3124 )
3125 .with_message_type(MessageType::File {
3126 path: path.clone(),
3127 mime_type: mime_type.clone(),
3128 }),
3129 );
3130 }
3131 ContentPart::Thinking { text } => {
3132 if !text.is_empty() {
3133 app.messages.push(
3134 ChatMessage::new(
3135 role_str,
3136 text.clone(),
3137 )
3138 .with_message_type(
3139 MessageType::Thinking(text.clone()),
3140 ),
3141 );
3142 }
3143 }
3144 }
3145 }
3146 }
3147
3148 app.current_agent = session.agent.clone();
3149 app.session = Some(session);
3150 app.scroll = SCROLL_BOTTOM;
3151 app.view_mode = ViewMode::Chat;
3152 }
3153 Err(e) => {
3154 app.messages.push(ChatMessage::new(
3155 "system",
3156 format!("Failed to load session: {}", e),
3157 ));
3158 app.view_mode = ViewMode::Chat;
3159 }
3160 }
3161 }
3162 }
3163 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3164 return Ok(());
3165 }
3166 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3167 return Ok(());
3168 }
3169 KeyCode::Char(c)
3170 if !key.modifiers.contains(KeyModifiers::CONTROL)
3171 && !key.modifiers.contains(KeyModifiers::ALT)
3172 && c != 'j'
3173 && c != 'k' =>
3174 {
3175 app.session_picker_filter.push(c);
3176 app.session_picker_selected = 0;
3177 app.session_picker_confirm_delete = false;
3178 }
3179 _ => {}
3180 }
3181 continue;
3182 }
3183
3184 if app.view_mode == ViewMode::AgentPicker {
3186 match key.code {
3187 KeyCode::Esc => {
3188 app.agent_picker_filter.clear();
3189 app.view_mode = ViewMode::Chat;
3190 }
3191 KeyCode::Up | KeyCode::Char('k')
3192 if !key.modifiers.contains(KeyModifiers::ALT) =>
3193 {
3194 if app.agent_picker_selected > 0 {
3195 app.agent_picker_selected -= 1;
3196 }
3197 }
3198 KeyCode::Down | KeyCode::Char('j')
3199 if !key.modifiers.contains(KeyModifiers::ALT) =>
3200 {
3201 let filtered = app.filtered_spawned_agents();
3202 if app.agent_picker_selected < filtered.len().saturating_sub(1) {
3203 app.agent_picker_selected += 1;
3204 }
3205 }
3206 KeyCode::Enter => {
3207 let filtered = app.filtered_spawned_agents();
3208 if let Some((name, _, _, _)) = filtered.get(app.agent_picker_selected) {
3209 app.active_spawned_agent = Some(name.clone());
3210 app.messages.push(ChatMessage::new(
3211 "system",
3212 format!(
3213 "Focused chat on @{name}. Type messages directly; use /agent main to exit focus."
3214 ),
3215 ));
3216 app.view_mode = ViewMode::Chat;
3217 }
3218 }
3219 KeyCode::Backspace => {
3220 app.agent_picker_filter.pop();
3221 app.agent_picker_selected = 0;
3222 }
3223 KeyCode::Char('m') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
3224 app.active_spawned_agent = None;
3225 app.messages
3226 .push(ChatMessage::new("system", "Returned to main chat mode."));
3227 app.view_mode = ViewMode::Chat;
3228 }
3229 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3230 return Ok(());
3231 }
3232 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3233 return Ok(());
3234 }
3235 KeyCode::Char(c)
3236 if !key.modifiers.contains(KeyModifiers::CONTROL)
3237 && !key.modifiers.contains(KeyModifiers::ALT)
3238 && c != 'j'
3239 && c != 'k'
3240 && c != 'm' =>
3241 {
3242 app.agent_picker_filter.push(c);
3243 app.agent_picker_selected = 0;
3244 }
3245 _ => {}
3246 }
3247 continue;
3248 }
3249
3250 if app.view_mode == ViewMode::Swarm {
3252 match key.code {
3253 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3254 return Ok(());
3255 }
3256 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3257 return Ok(());
3258 }
3259 KeyCode::Esc => {
3260 if app.swarm_state.detail_mode {
3261 app.swarm_state.exit_detail();
3262 } else {
3263 app.view_mode = ViewMode::Chat;
3264 }
3265 }
3266 KeyCode::Up | KeyCode::Char('k') => {
3267 if app.swarm_state.detail_mode {
3268 app.swarm_state.exit_detail();
3270 app.swarm_state.select_prev();
3271 app.swarm_state.enter_detail();
3272 } else {
3273 app.swarm_state.select_prev();
3274 }
3275 }
3276 KeyCode::Down | KeyCode::Char('j') => {
3277 if app.swarm_state.detail_mode {
3278 app.swarm_state.exit_detail();
3279 app.swarm_state.select_next();
3280 app.swarm_state.enter_detail();
3281 } else {
3282 app.swarm_state.select_next();
3283 }
3284 }
3285 KeyCode::Enter => {
3286 if !app.swarm_state.detail_mode {
3287 app.swarm_state.enter_detail();
3288 }
3289 }
3290 KeyCode::PageDown => {
3291 app.swarm_state.detail_scroll_down(10);
3292 }
3293 KeyCode::PageUp => {
3294 app.swarm_state.detail_scroll_up(10);
3295 }
3296 KeyCode::Char('?') => {
3297 app.show_help = true;
3298 }
3299 KeyCode::F(2) => {
3300 app.view_mode = ViewMode::Chat;
3301 }
3302 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3303 app.view_mode = ViewMode::Chat;
3304 }
3305 _ => {}
3306 }
3307 continue;
3308 }
3309
3310 if app.view_mode == ViewMode::Ralph {
3312 match key.code {
3313 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3314 return Ok(());
3315 }
3316 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3317 return Ok(());
3318 }
3319 KeyCode::Esc => {
3320 if app.ralph_state.detail_mode {
3321 app.ralph_state.exit_detail();
3322 } else {
3323 app.view_mode = ViewMode::Chat;
3324 }
3325 }
3326 KeyCode::Up | KeyCode::Char('k') => {
3327 if app.ralph_state.detail_mode {
3328 app.ralph_state.exit_detail();
3329 app.ralph_state.select_prev();
3330 app.ralph_state.enter_detail();
3331 } else {
3332 app.ralph_state.select_prev();
3333 }
3334 }
3335 KeyCode::Down | KeyCode::Char('j') => {
3336 if app.ralph_state.detail_mode {
3337 app.ralph_state.exit_detail();
3338 app.ralph_state.select_next();
3339 app.ralph_state.enter_detail();
3340 } else {
3341 app.ralph_state.select_next();
3342 }
3343 }
3344 KeyCode::Enter => {
3345 if !app.ralph_state.detail_mode {
3346 app.ralph_state.enter_detail();
3347 }
3348 }
3349 KeyCode::PageDown => {
3350 app.ralph_state.detail_scroll_down(10);
3351 }
3352 KeyCode::PageUp => {
3353 app.ralph_state.detail_scroll_up(10);
3354 }
3355 KeyCode::Char('?') => {
3356 app.show_help = true;
3357 }
3358 KeyCode::F(2) | KeyCode::Char('s')
3359 if key.modifiers.contains(KeyModifiers::CONTROL) =>
3360 {
3361 app.view_mode = ViewMode::Chat;
3362 }
3363 _ => {}
3364 }
3365 continue;
3366 }
3367
3368 if app.view_mode == ViewMode::BusLog {
3370 match key.code {
3371 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3372 return Ok(());
3373 }
3374 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3375 return Ok(());
3376 }
3377 KeyCode::Esc => {
3378 if app.bus_log_state.detail_mode {
3379 app.bus_log_state.exit_detail();
3380 } else {
3381 app.view_mode = ViewMode::Chat;
3382 }
3383 }
3384 KeyCode::Up | KeyCode::Char('k') => {
3385 if app.bus_log_state.detail_mode {
3386 app.bus_log_state.exit_detail();
3387 app.bus_log_state.select_prev();
3388 app.bus_log_state.enter_detail();
3389 } else {
3390 app.bus_log_state.select_prev();
3391 }
3392 }
3393 KeyCode::Down | KeyCode::Char('j') => {
3394 if app.bus_log_state.detail_mode {
3395 app.bus_log_state.exit_detail();
3396 app.bus_log_state.select_next();
3397 app.bus_log_state.enter_detail();
3398 } else {
3399 app.bus_log_state.select_next();
3400 }
3401 }
3402 KeyCode::Enter => {
3403 if !app.bus_log_state.detail_mode {
3404 app.bus_log_state.enter_detail();
3405 }
3406 }
3407 KeyCode::PageDown => {
3408 app.bus_log_state.detail_scroll_down(10);
3409 }
3410 KeyCode::PageUp => {
3411 app.bus_log_state.detail_scroll_up(10);
3412 }
3413 KeyCode::Char('c') => {
3415 app.bus_log_state.entries.clear();
3416 app.bus_log_state.selected_index = 0;
3417 }
3418 KeyCode::Char('g') => {
3420 let len = app.bus_log_state.filtered_entries().len();
3421 if len > 0 {
3422 app.bus_log_state.selected_index = len - 1;
3423 app.bus_log_state.list_state.select(Some(len - 1));
3424 }
3425 app.bus_log_state.auto_scroll = true;
3426 }
3427 KeyCode::Char('?') => {
3428 app.show_help = true;
3429 }
3430 _ => {}
3431 }
3432 continue;
3433 }
3434
3435 if app.view_mode == ViewMode::Protocol {
3437 match key.code {
3438 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3439 return Ok(());
3440 }
3441 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3442 return Ok(());
3443 }
3444 KeyCode::Esc => {
3445 app.view_mode = ViewMode::Chat;
3446 }
3447 KeyCode::Up | KeyCode::Char('k') => {
3448 if app.protocol_selected > 0 {
3449 app.protocol_selected -= 1;
3450 }
3451 app.protocol_scroll = 0;
3452 }
3453 KeyCode::Down | KeyCode::Char('j') => {
3454 let len = app.protocol_cards().len();
3455 if app.protocol_selected < len.saturating_sub(1) {
3456 app.protocol_selected += 1;
3457 }
3458 app.protocol_scroll = 0;
3459 }
3460 KeyCode::PageDown => {
3461 app.protocol_scroll = app.protocol_scroll.saturating_add(10);
3462 }
3463 KeyCode::PageUp => {
3464 app.protocol_scroll = app.protocol_scroll.saturating_sub(10);
3465 }
3466 KeyCode::Char('g') => {
3467 app.protocol_scroll = 0;
3468 }
3469 KeyCode::Char('?') => {
3470 app.show_help = true;
3471 }
3472 _ => {}
3473 }
3474 continue;
3475 }
3476
3477 match key.code {
3478 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3480 return Ok(());
3481 }
3482 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3483 return Ok(());
3484 }
3485
3486 KeyCode::Char('?') => {
3488 app.show_help = true;
3489 }
3490
3491 KeyCode::F(2) => {
3493 app.view_mode = match app.view_mode {
3494 ViewMode::Chat
3495 | ViewMode::SessionPicker
3496 | ViewMode::ModelPicker
3497 | ViewMode::AgentPicker
3498 | ViewMode::Protocol
3499 | ViewMode::BusLog => ViewMode::Swarm,
3500 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
3501 };
3502 }
3503 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3504 app.view_mode = match app.view_mode {
3505 ViewMode::Chat
3506 | ViewMode::SessionPicker
3507 | ViewMode::ModelPicker
3508 | ViewMode::AgentPicker
3509 | ViewMode::Protocol
3510 | ViewMode::BusLog => ViewMode::Swarm,
3511 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
3512 };
3513 }
3514
3515 KeyCode::F(3) => {
3517 app.show_inspector = !app.show_inspector;
3518 }
3519
3520 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3522 let msg = app
3523 .messages
3524 .iter()
3525 .rev()
3526 .find(|m| m.role == "assistant" && !m.content.trim().is_empty())
3527 .or_else(|| {
3528 app.messages
3529 .iter()
3530 .rev()
3531 .find(|m| !m.content.trim().is_empty())
3532 });
3533
3534 let Some(msg) = msg else {
3535 app.messages
3536 .push(ChatMessage::new("system", "Nothing to copy yet."));
3537 app.scroll = SCROLL_BOTTOM;
3538 continue;
3539 };
3540
3541 let text = message_clipboard_text(msg);
3542 match copy_text_to_clipboard_best_effort(&text) {
3543 Ok(method) => {
3544 app.messages.push(ChatMessage::new(
3545 "system",
3546 format!("Copied latest reply ({method})."),
3547 ));
3548 app.scroll = SCROLL_BOTTOM;
3549 }
3550 Err(err) => {
3551 tracing::warn!(error = %err, "Copy to clipboard failed");
3552 app.messages.push(ChatMessage::new(
3553 "system",
3554 "Could not copy to clipboard in this environment.",
3555 ));
3556 app.scroll = SCROLL_BOTTOM;
3557 }
3558 }
3559 }
3560
3561 KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3563 app.chat_layout = match app.chat_layout {
3564 ChatLayoutMode::Classic => ChatLayoutMode::Webview,
3565 ChatLayoutMode::Webview => ChatLayoutMode::Classic,
3566 };
3567 }
3568
3569 KeyCode::Esc => {
3571 if app.view_mode == ViewMode::Swarm
3572 || app.view_mode == ViewMode::Ralph
3573 || app.view_mode == ViewMode::BusLog
3574 || app.view_mode == ViewMode::Protocol
3575 || app.view_mode == ViewMode::SessionPicker
3576 || app.view_mode == ViewMode::ModelPicker
3577 || app.view_mode == ViewMode::AgentPicker
3578 {
3579 app.view_mode = ViewMode::Chat;
3580 }
3581 }
3582
3583 KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3585 app.open_model_picker(&config).await;
3586 }
3587
3588 KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3590 app.open_agent_picker();
3591 }
3592
3593 KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3595 app.view_mode = ViewMode::BusLog;
3596 }
3597
3598 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3600 app.open_protocol_view();
3601 }
3602
3603 KeyCode::Tab => {
3605 app.current_agent = if app.current_agent == "build" {
3606 "plan".to_string()
3607 } else {
3608 "build".to_string()
3609 };
3610 }
3611
3612 KeyCode::Enter => {
3614 app.submit_message(&config).await;
3615 }
3616
3617 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
3619 if app.scroll < SCROLL_BOTTOM {
3620 app.scroll = app.scroll.saturating_add(1);
3621 }
3622 }
3623 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
3624 if app.scroll >= SCROLL_BOTTOM {
3625 app.scroll = app.last_max_scroll; }
3627 app.scroll = app.scroll.saturating_sub(1);
3628 }
3629
3630 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3632 app.search_history();
3633 }
3634 KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
3635 app.navigate_history(-1);
3636 }
3637 KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
3638 app.navigate_history(1);
3639 }
3640
3641 KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3643 app.scroll = 0; }
3645 KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3646 app.scroll = SCROLL_BOTTOM;
3648 }
3649
3650 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
3652 if app.scroll < SCROLL_BOTTOM {
3654 app.scroll = app.scroll.saturating_add(5);
3655 }
3656 }
3657 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
3658 if app.scroll >= SCROLL_BOTTOM {
3660 app.scroll = app.last_max_scroll;
3661 }
3662 app.scroll = app.scroll.saturating_sub(5);
3663 }
3664
3665 KeyCode::Char(c) => {
3667 while app.cursor_position > 0
3669 && !app.input.is_char_boundary(app.cursor_position)
3670 {
3671 app.cursor_position -= 1;
3672 }
3673 app.input.insert(app.cursor_position, c);
3674 app.cursor_position += c.len_utf8();
3675 }
3676 KeyCode::Backspace => {
3677 while app.cursor_position > 0
3679 && !app.input.is_char_boundary(app.cursor_position)
3680 {
3681 app.cursor_position -= 1;
3682 }
3683 if app.cursor_position > 0 {
3684 let prev = app.input[..app.cursor_position].char_indices().rev().next();
3686 if let Some((idx, ch)) = prev {
3687 app.input.replace_range(idx..idx + ch.len_utf8(), "");
3688 app.cursor_position = idx;
3689 }
3690 }
3691 }
3692 KeyCode::Delete => {
3693 while app.cursor_position > 0
3695 && !app.input.is_char_boundary(app.cursor_position)
3696 {
3697 app.cursor_position -= 1;
3698 }
3699 if app.cursor_position < app.input.len() {
3700 let ch = app.input[app.cursor_position..].chars().next();
3701 if let Some(ch) = ch {
3702 app.input.replace_range(
3703 app.cursor_position..app.cursor_position + ch.len_utf8(),
3704 "",
3705 );
3706 }
3707 }
3708 }
3709 KeyCode::Left => {
3710 let prev = app.input[..app.cursor_position].char_indices().rev().next();
3712 if let Some((idx, _)) = prev {
3713 app.cursor_position = idx;
3714 }
3715 }
3716 KeyCode::Right => {
3717 if app.cursor_position < app.input.len() {
3718 let ch = app.input[app.cursor_position..].chars().next();
3719 if let Some(ch) = ch {
3720 app.cursor_position += ch.len_utf8();
3721 }
3722 }
3723 }
3724 KeyCode::Home => {
3725 app.cursor_position = 0;
3726 }
3727 KeyCode::End => {
3728 app.cursor_position = app.input.len();
3729 }
3730
3731 KeyCode::Up => {
3733 if app.scroll >= SCROLL_BOTTOM {
3734 app.scroll = app.last_max_scroll; }
3736 app.scroll = app.scroll.saturating_sub(1);
3737 }
3738 KeyCode::Down => {
3739 if app.scroll < SCROLL_BOTTOM {
3740 app.scroll = app.scroll.saturating_add(1);
3741 }
3742 }
3743 KeyCode::PageUp => {
3744 if app.scroll >= SCROLL_BOTTOM {
3745 app.scroll = app.last_max_scroll;
3746 }
3747 app.scroll = app.scroll.saturating_sub(10);
3748 }
3749 KeyCode::PageDown => {
3750 if app.scroll < SCROLL_BOTTOM {
3751 app.scroll = app.scroll.saturating_add(10);
3752 }
3753 }
3754
3755 _ => {}
3756 }
3757 }
3758 }
3759}
3760
3761fn ui(f: &mut Frame, app: &mut App, theme: &Theme) {
3762 if app.view_mode == ViewMode::Swarm {
3764 let chunks = Layout::default()
3766 .direction(Direction::Vertical)
3767 .constraints([
3768 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
3772 .split(f.area());
3773
3774 render_swarm_view(f, &mut app.swarm_state, chunks[0]);
3776
3777 let input_block = Block::default()
3779 .borders(Borders::ALL)
3780 .title(" Press Esc, Ctrl+S, or /view to return to chat ")
3781 .border_style(Style::default().fg(Color::Cyan));
3782
3783 let input = Paragraph::new(app.input.as_str())
3784 .block(input_block)
3785 .wrap(Wrap { trim: false });
3786 f.render_widget(input, chunks[1]);
3787
3788 let status_line = if app.swarm_state.detail_mode {
3790 Line::from(vec![
3791 Span::styled(
3792 " AGENT DETAIL ",
3793 Style::default().fg(Color::Black).bg(Color::Cyan),
3794 ),
3795 Span::raw(" | "),
3796 Span::styled("Esc", Style::default().fg(Color::Yellow)),
3797 Span::raw(": Back to list | "),
3798 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3799 Span::raw(": Prev/Next agent | "),
3800 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
3801 Span::raw(": Scroll"),
3802 ])
3803 } else {
3804 Line::from(vec![
3805 Span::styled(
3806 " SWARM MODE ",
3807 Style::default().fg(Color::Black).bg(Color::Cyan),
3808 ),
3809 Span::raw(" | "),
3810 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3811 Span::raw(": Select | "),
3812 Span::styled("Enter", Style::default().fg(Color::Yellow)),
3813 Span::raw(": Detail | "),
3814 Span::styled("Esc", Style::default().fg(Color::Yellow)),
3815 Span::raw(": Back | "),
3816 Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
3817 Span::raw(": Toggle view"),
3818 ])
3819 };
3820 let status = Paragraph::new(status_line);
3821 f.render_widget(status, chunks[2]);
3822 return;
3823 }
3824
3825 if app.view_mode == ViewMode::Ralph {
3827 let chunks = Layout::default()
3828 .direction(Direction::Vertical)
3829 .constraints([
3830 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
3834 .split(f.area());
3835
3836 render_ralph_view(f, &mut app.ralph_state, chunks[0]);
3837
3838 let input_block = Block::default()
3839 .borders(Borders::ALL)
3840 .title(" Press Esc to return to chat ")
3841 .border_style(Style::default().fg(Color::Magenta));
3842
3843 let input = Paragraph::new(app.input.as_str())
3844 .block(input_block)
3845 .wrap(Wrap { trim: false });
3846 f.render_widget(input, chunks[1]);
3847
3848 let status_line = if app.ralph_state.detail_mode {
3849 Line::from(vec![
3850 Span::styled(
3851 " STORY DETAIL ",
3852 Style::default().fg(Color::Black).bg(Color::Magenta),
3853 ),
3854 Span::raw(" | "),
3855 Span::styled("Esc", Style::default().fg(Color::Yellow)),
3856 Span::raw(": Back to list | "),
3857 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3858 Span::raw(": Prev/Next story | "),
3859 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
3860 Span::raw(": Scroll"),
3861 ])
3862 } else {
3863 Line::from(vec![
3864 Span::styled(
3865 " RALPH MODE ",
3866 Style::default().fg(Color::Black).bg(Color::Magenta),
3867 ),
3868 Span::raw(" | "),
3869 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3870 Span::raw(": Select | "),
3871 Span::styled("Enter", Style::default().fg(Color::Yellow)),
3872 Span::raw(": Detail | "),
3873 Span::styled("Esc", Style::default().fg(Color::Yellow)),
3874 Span::raw(": Back"),
3875 ])
3876 };
3877 let status = Paragraph::new(status_line);
3878 f.render_widget(status, chunks[2]);
3879 return;
3880 }
3881
3882 if app.view_mode == ViewMode::BusLog {
3884 let chunks = Layout::default()
3885 .direction(Direction::Vertical)
3886 .constraints([
3887 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
3891 .split(f.area());
3892
3893 render_bus_log(f, &mut app.bus_log_state, chunks[0]);
3894
3895 let input_block = Block::default()
3896 .borders(Borders::ALL)
3897 .title(" Press Esc to return to chat ")
3898 .border_style(Style::default().fg(Color::Green));
3899
3900 let input = Paragraph::new(app.input.as_str())
3901 .block(input_block)
3902 .wrap(Wrap { trim: false });
3903 f.render_widget(input, chunks[1]);
3904
3905 let count_info = format!(
3906 " {}/{} ",
3907 app.bus_log_state.visible_count(),
3908 app.bus_log_state.total_count()
3909 );
3910 let status_line = Line::from(vec![
3911 Span::styled(
3912 " BUS LOG ",
3913 Style::default().fg(Color::Black).bg(Color::Green),
3914 ),
3915 Span::raw(&count_info),
3916 Span::raw("| "),
3917 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3918 Span::raw(": Select | "),
3919 Span::styled("Enter", Style::default().fg(Color::Yellow)),
3920 Span::raw(": Detail | "),
3921 Span::styled("c", Style::default().fg(Color::Yellow)),
3922 Span::raw(": Clear | "),
3923 Span::styled("Esc", Style::default().fg(Color::Yellow)),
3924 Span::raw(": Back"),
3925 ]);
3926 let status = Paragraph::new(status_line);
3927 f.render_widget(status, chunks[2]);
3928 return;
3929 }
3930
3931 if app.view_mode == ViewMode::Protocol {
3933 let chunks = Layout::default()
3934 .direction(Direction::Vertical)
3935 .constraints([
3936 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
3940 .split(f.area());
3941
3942 render_protocol_registry(f, app, theme, chunks[0]);
3943
3944 let input_block = Block::default()
3945 .borders(Borders::ALL)
3946 .title(" Press Esc to return to chat ")
3947 .border_style(Style::default().fg(Color::Blue));
3948
3949 let input = Paragraph::new(app.input.as_str())
3950 .block(input_block)
3951 .wrap(Wrap { trim: false });
3952 f.render_widget(input, chunks[1]);
3953
3954 let cards = app.protocol_cards();
3955 let status_line = Line::from(vec![
3956 Span::styled(
3957 " PROTOCOL REGISTRY ",
3958 Style::default().fg(Color::Black).bg(Color::Blue),
3959 ),
3960 Span::raw(format!(" {} cards | ", cards.len())),
3961 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3962 Span::raw(": Select | "),
3963 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
3964 Span::raw(": Scroll detail | "),
3965 Span::styled("Esc", Style::default().fg(Color::Yellow)),
3966 Span::raw(": Back"),
3967 ]);
3968 let status = Paragraph::new(status_line);
3969 f.render_widget(status, chunks[2]);
3970 return;
3971 }
3972
3973 if app.view_mode == ViewMode::ModelPicker {
3975 let area = centered_rect(70, 70, f.area());
3976 f.render_widget(Clear, area);
3977
3978 let filter_display = if app.model_picker_filter.is_empty() {
3979 "type to filter".to_string()
3980 } else {
3981 format!("filter: {}", app.model_picker_filter)
3982 };
3983
3984 let picker_block = Block::default()
3985 .borders(Borders::ALL)
3986 .title(format!(
3987 " Select Model (↑↓ navigate, Enter select, Esc cancel) [{}] ",
3988 filter_display
3989 ))
3990 .border_style(Style::default().fg(Color::Magenta));
3991
3992 let filtered = app.filtered_models();
3993 let mut list_lines: Vec<Line> = Vec::new();
3994 list_lines.push(Line::from(""));
3995
3996 if let Some(ref active) = app.active_model {
3997 list_lines.push(Line::styled(
3998 format!(" Current: {}", active),
3999 Style::default()
4000 .fg(Color::Green)
4001 .add_modifier(Modifier::DIM),
4002 ));
4003 list_lines.push(Line::from(""));
4004 }
4005
4006 if filtered.is_empty() {
4007 list_lines.push(Line::styled(
4008 " No models match filter",
4009 Style::default().fg(Color::DarkGray),
4010 ));
4011 } else {
4012 let mut current_provider = String::new();
4013 for (display_idx, (_, (label, _, human_name))) in filtered.iter().enumerate() {
4014 let provider = label.split('/').next().unwrap_or("");
4015 if provider != current_provider {
4016 if !current_provider.is_empty() {
4017 list_lines.push(Line::from(""));
4018 }
4019 list_lines.push(Line::styled(
4020 format!(" ─── {} ───", provider),
4021 Style::default()
4022 .fg(Color::Cyan)
4023 .add_modifier(Modifier::BOLD),
4024 ));
4025 current_provider = provider.to_string();
4026 }
4027
4028 let is_selected = display_idx == app.model_picker_selected;
4029 let is_active = app.active_model.as_deref() == Some(label.as_str());
4030 let marker = if is_selected { "▶" } else { " " };
4031 let active_marker = if is_active { " ✓" } else { "" };
4032 let model_id = label.split('/').skip(1).collect::<Vec<_>>().join("/");
4033 let display = if human_name != &model_id && !human_name.is_empty() {
4035 format!("{} ({})", human_name, model_id)
4036 } else {
4037 model_id
4038 };
4039
4040 let style = if is_selected {
4041 Style::default()
4042 .fg(Color::Magenta)
4043 .add_modifier(Modifier::BOLD)
4044 } else if is_active {
4045 Style::default().fg(Color::Green)
4046 } else {
4047 Style::default()
4048 };
4049
4050 list_lines.push(Line::styled(
4051 format!(" {} {}{}", marker, display, active_marker),
4052 style,
4053 ));
4054 }
4055 }
4056
4057 let list = Paragraph::new(list_lines)
4058 .block(picker_block)
4059 .wrap(Wrap { trim: false });
4060 f.render_widget(list, area);
4061 return;
4062 }
4063
4064 if app.view_mode == ViewMode::SessionPicker {
4066 let chunks = Layout::default()
4067 .direction(Direction::Vertical)
4068 .constraints([
4069 Constraint::Min(1), Constraint::Length(1), ])
4072 .split(f.area());
4073
4074 let filter_display = if app.session_picker_filter.is_empty() {
4076 String::new()
4077 } else {
4078 format!(" [filter: {}]", app.session_picker_filter)
4079 };
4080
4081 let list_block = Block::default()
4082 .borders(Borders::ALL)
4083 .title(format!(
4084 " Sessions (↑↓ navigate, Enter load, d delete, Esc cancel){} ",
4085 filter_display
4086 ))
4087 .border_style(Style::default().fg(Color::Cyan));
4088
4089 let mut list_lines: Vec<Line> = Vec::new();
4090 list_lines.push(Line::from(""));
4091
4092 let filtered = app.filtered_sessions();
4093 if filtered.is_empty() {
4094 if app.session_picker_filter.is_empty() {
4095 list_lines.push(Line::styled(
4096 " No sessions found.",
4097 Style::default().fg(Color::DarkGray),
4098 ));
4099 } else {
4100 list_lines.push(Line::styled(
4101 format!(" No sessions matching '{}'", app.session_picker_filter),
4102 Style::default().fg(Color::DarkGray),
4103 ));
4104 }
4105 }
4106
4107 for (display_idx, (_orig_idx, session)) in filtered.iter().enumerate() {
4108 let is_selected = display_idx == app.session_picker_selected;
4109 let is_active = app
4110 .session
4111 .as_ref()
4112 .map(|s| s.id == session.id)
4113 .unwrap_or(false);
4114 let title = session.title.as_deref().unwrap_or("(untitled)");
4115 let date = session.updated_at.format("%Y-%m-%d %H:%M");
4116 let active_marker = if is_active { " ●" } else { "" };
4117 let line_str = format!(
4118 " {} {}{} - {} ({} msgs)",
4119 if is_selected { "▶" } else { " " },
4120 title,
4121 active_marker,
4122 date,
4123 session.message_count
4124 );
4125
4126 let style = if is_selected && app.session_picker_confirm_delete {
4127 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
4128 } else if is_selected {
4129 Style::default()
4130 .fg(Color::Cyan)
4131 .add_modifier(Modifier::BOLD)
4132 } else if is_active {
4133 Style::default().fg(Color::Green)
4134 } else {
4135 Style::default()
4136 };
4137
4138 list_lines.push(Line::styled(line_str, style));
4139
4140 if is_selected {
4142 if app.session_picker_confirm_delete {
4143 list_lines.push(Line::styled(
4144 " ⚠ Press d again to confirm delete, Esc to cancel",
4145 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
4146 ));
4147 } else {
4148 list_lines.push(Line::styled(
4149 format!(" Agent: {} | ID: {}", session.agent, session.id),
4150 Style::default().fg(Color::DarkGray),
4151 ));
4152 }
4153 }
4154 }
4155
4156 let list = Paragraph::new(list_lines)
4157 .block(list_block)
4158 .wrap(Wrap { trim: false });
4159 f.render_widget(list, chunks[0]);
4160
4161 let mut status_spans = vec![
4163 Span::styled(
4164 " SESSION PICKER ",
4165 Style::default().fg(Color::Black).bg(Color::Cyan),
4166 ),
4167 Span::raw(" "),
4168 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
4169 Span::raw(": Nav "),
4170 Span::styled("Enter", Style::default().fg(Color::Yellow)),
4171 Span::raw(": Load "),
4172 Span::styled("d", Style::default().fg(Color::Yellow)),
4173 Span::raw(": Delete "),
4174 Span::styled("Esc", Style::default().fg(Color::Yellow)),
4175 Span::raw(": Cancel "),
4176 ];
4177 if !app.session_picker_filter.is_empty() || !app.session_picker_list.is_empty() {
4178 status_spans.push(Span::styled("Type", Style::default().fg(Color::Yellow)));
4179 status_spans.push(Span::raw(": Filter "));
4180 }
4181 let total = app.session_picker_list.len();
4182 let showing = filtered.len();
4183 if showing < total {
4184 status_spans.push(Span::styled(
4185 format!("{}/{}", showing, total),
4186 Style::default().fg(Color::DarkGray),
4187 ));
4188 }
4189
4190 let status = Paragraph::new(Line::from(status_spans));
4191 f.render_widget(status, chunks[1]);
4192 return;
4193 }
4194
4195 if app.view_mode == ViewMode::AgentPicker {
4197 let area = centered_rect(70, 70, f.area());
4198 f.render_widget(Clear, area);
4199
4200 let filter_display = if app.agent_picker_filter.is_empty() {
4201 "type to filter".to_string()
4202 } else {
4203 format!("filter: {}", app.agent_picker_filter)
4204 };
4205
4206 let picker_block = Block::default()
4207 .borders(Borders::ALL)
4208 .title(format!(
4209 " Select Agent (↑↓ navigate, Enter focus, m main chat, Esc cancel) [{}] ",
4210 filter_display
4211 ))
4212 .border_style(Style::default().fg(Color::Magenta));
4213
4214 let filtered = app.filtered_spawned_agents();
4215 let mut list_lines: Vec<Line> = Vec::new();
4216 list_lines.push(Line::from(""));
4217
4218 if let Some(ref active) = app.active_spawned_agent {
4219 list_lines.push(Line::styled(
4220 format!(" Current focus: @{}", active),
4221 Style::default()
4222 .fg(Color::Green)
4223 .add_modifier(Modifier::DIM),
4224 ));
4225 list_lines.push(Line::from(""));
4226 }
4227
4228 if filtered.is_empty() {
4229 list_lines.push(Line::styled(
4230 " No spawned agents match filter",
4231 Style::default().fg(Color::DarkGray),
4232 ));
4233 } else {
4234 for (display_idx, (name, instructions, is_processing, is_registered)) in
4235 filtered.iter().enumerate()
4236 {
4237 let is_selected = display_idx == app.agent_picker_selected;
4238 let is_focused = app.active_spawned_agent.as_deref() == Some(name.as_str());
4239 let marker = if is_selected { "▶" } else { " " };
4240 let focused_marker = if is_focused { " ✓" } else { "" };
4241 let status = if *is_processing { "⚡" } else { "●" };
4242 let protocol = if *is_registered { "🔗" } else { "⚠" };
4243
4244 let style = if is_selected {
4245 Style::default()
4246 .fg(Color::Magenta)
4247 .add_modifier(Modifier::BOLD)
4248 } else if is_focused {
4249 Style::default().fg(Color::Green)
4250 } else {
4251 Style::default()
4252 };
4253
4254 list_lines.push(Line::styled(
4255 format!(" {marker} {status} {protocol} @{name}{focused_marker}"),
4256 style,
4257 ));
4258
4259 if is_selected {
4260 list_lines.push(Line::styled(
4261 format!(" {}", instructions),
4262 Style::default().fg(Color::DarkGray),
4263 ));
4264 list_lines.push(Line::styled(
4265 format!(
4266 " protocol: {}",
4267 if *is_registered {
4268 "registered"
4269 } else {
4270 "not registered"
4271 }
4272 ),
4273 if *is_registered {
4274 Style::default().fg(Color::Green)
4275 } else {
4276 Style::default().fg(Color::Yellow)
4277 },
4278 ));
4279 }
4280 }
4281 }
4282
4283 let list = Paragraph::new(list_lines)
4284 .block(picker_block)
4285 .wrap(Wrap { trim: false });
4286 f.render_widget(list, area);
4287 return;
4288 }
4289
4290 if app.chat_layout == ChatLayoutMode::Webview {
4291 if render_webview_chat(f, app, theme) {
4292 render_help_overlay_if_needed(f, app, theme);
4293 return;
4294 }
4295 }
4296
4297 let chunks = Layout::default()
4299 .direction(Direction::Vertical)
4300 .constraints([
4301 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
4305 .split(f.area());
4306
4307 let messages_area = chunks[0];
4309 let model_label = app.active_model.as_deref().unwrap_or("auto");
4310 let target_label = app
4311 .active_spawned_agent
4312 .as_ref()
4313 .map(|name| format!(" @{}", name))
4314 .unwrap_or_default();
4315 let messages_block = Block::default()
4316 .borders(Borders::ALL)
4317 .title(format!(
4318 " CodeTether Agent [{}{}] model:{} ",
4319 app.current_agent, target_label, model_label
4320 ))
4321 .border_style(Style::default().fg(theme.border_color.to_color()));
4322
4323 let max_width = messages_area.width.saturating_sub(4) as usize;
4324 let message_lines = build_message_lines(app, theme, max_width);
4325
4326 let total_lines = message_lines.len();
4328 let visible_lines = messages_area.height.saturating_sub(2) as usize;
4329 let max_scroll = total_lines.saturating_sub(visible_lines);
4330 let scroll = if app.scroll >= SCROLL_BOTTOM {
4332 max_scroll
4333 } else {
4334 app.scroll.min(max_scroll)
4335 };
4336
4337 let messages_paragraph = Paragraph::new(
4339 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
4340 )
4341 .block(messages_block.clone())
4342 .wrap(Wrap { trim: false });
4343
4344 f.render_widget(messages_paragraph, messages_area);
4345
4346 if total_lines > visible_lines {
4348 let scrollbar = Scrollbar::default()
4349 .orientation(ScrollbarOrientation::VerticalRight)
4350 .symbols(ratatui::symbols::scrollbar::VERTICAL)
4351 .begin_symbol(Some("↑"))
4352 .end_symbol(Some("↓"));
4353
4354 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
4355
4356 let scrollbar_area = Rect::new(
4357 messages_area.right() - 1,
4358 messages_area.top() + 1,
4359 1,
4360 messages_area.height - 2,
4361 );
4362
4363 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
4364 }
4365
4366 let input_title = if app.is_processing {
4368 if let Some(started) = app.processing_started_at {
4369 let elapsed = started.elapsed();
4370 format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
4371 } else {
4372 " Message (Processing...) ".to_string()
4373 }
4374 } else if app.input.starts_with('/') {
4375 let hint = match_slash_command_hint(&app.input);
4376 format!(" {} ", hint)
4377 } else if let Some(target) = &app.active_spawned_agent {
4378 format!(" Message to @{target} (use /agent main to exit) ")
4379 } else {
4380 " Message (Enter to send, / for commands) ".to_string()
4381 };
4382 let input_block = Block::default()
4383 .borders(Borders::ALL)
4384 .title(input_title)
4385 .border_style(Style::default().fg(if app.is_processing {
4386 Color::Yellow
4387 } else if app.input.starts_with('/') {
4388 Color::Magenta
4389 } else {
4390 theme.input_border_color.to_color()
4391 }));
4392
4393 let input = Paragraph::new(app.input.as_str())
4394 .block(input_block)
4395 .wrap(Wrap { trim: false });
4396 f.render_widget(input, chunks[1]);
4397
4398 f.set_cursor_position((
4400 chunks[1].x + app.cursor_position as u16 + 1,
4401 chunks[1].y + 1,
4402 ));
4403
4404 let token_display = TokenDisplay::new();
4406 let mut status_line = token_display.create_status_bar(theme);
4407 let model_status = if let Some(ref active) = app.active_model {
4408 let (provider, model) = crate::provider::parse_model_string(active);
4409 format!(" {}:{} ", provider.unwrap_or("auto"), model)
4410 } else {
4411 " auto ".to_string()
4412 };
4413 status_line.spans.insert(
4414 0,
4415 Span::styled(
4416 "│ ",
4417 Style::default()
4418 .fg(theme.timestamp_color.to_color())
4419 .add_modifier(Modifier::DIM),
4420 ),
4421 );
4422 status_line.spans.insert(
4423 0,
4424 Span::styled(model_status, Style::default().fg(Color::Cyan)),
4425 );
4426 let status = Paragraph::new(status_line);
4427 f.render_widget(status, chunks[2]);
4428
4429 render_help_overlay_if_needed(f, app, theme);
4430}
4431
4432fn render_webview_chat(f: &mut Frame, app: &App, theme: &Theme) -> bool {
4433 let area = f.area();
4434 if area.width < 90 || area.height < 18 {
4435 return false;
4436 }
4437
4438 let main_chunks = Layout::default()
4439 .direction(Direction::Vertical)
4440 .constraints([
4441 Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
4446 .split(area);
4447
4448 render_webview_header(f, app, theme, main_chunks[0]);
4449
4450 let body_constraints = if app.show_inspector {
4451 vec![
4452 Constraint::Length(26),
4453 Constraint::Min(40),
4454 Constraint::Length(30),
4455 ]
4456 } else {
4457 vec![Constraint::Length(26), Constraint::Min(40)]
4458 };
4459
4460 let body_chunks = Layout::default()
4461 .direction(Direction::Horizontal)
4462 .constraints(body_constraints)
4463 .split(main_chunks[1]);
4464
4465 render_webview_sidebar(f, app, theme, body_chunks[0]);
4466 render_webview_chat_center(f, app, theme, body_chunks[1]);
4467 if app.show_inspector && body_chunks.len() > 2 {
4468 render_webview_inspector(f, app, theme, body_chunks[2]);
4469 }
4470
4471 render_webview_input(f, app, theme, main_chunks[2]);
4472
4473 let token_display = TokenDisplay::new();
4474 let mut status_line = token_display.create_status_bar(theme);
4475 let model_status = if let Some(ref active) = app.active_model {
4476 let (provider, model) = crate::provider::parse_model_string(active);
4477 format!(" {}:{} ", provider.unwrap_or("auto"), model)
4478 } else {
4479 " auto ".to_string()
4480 };
4481 status_line.spans.insert(
4482 0,
4483 Span::styled(
4484 "│ ",
4485 Style::default()
4486 .fg(theme.timestamp_color.to_color())
4487 .add_modifier(Modifier::DIM),
4488 ),
4489 );
4490 status_line.spans.insert(
4491 0,
4492 Span::styled(model_status, Style::default().fg(Color::Cyan)),
4493 );
4494 let status = Paragraph::new(status_line);
4495 f.render_widget(status, main_chunks[3]);
4496
4497 true
4498}
4499
4500fn render_protocol_registry(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
4501 let cards = app.protocol_cards();
4502 let selected = app.protocol_selected.min(cards.len().saturating_sub(1));
4503
4504 let chunks = Layout::default()
4505 .direction(Direction::Horizontal)
4506 .constraints([Constraint::Length(34), Constraint::Min(30)])
4507 .split(area);
4508
4509 let list_block = Block::default()
4510 .borders(Borders::ALL)
4511 .title(" Registered Agents ")
4512 .border_style(Style::default().fg(theme.border_color.to_color()));
4513
4514 let mut list_lines: Vec<Line> = Vec::new();
4515 if cards.is_empty() {
4516 list_lines.push(Line::styled(
4517 "No protocol-registered agents.",
4518 Style::default().fg(Color::DarkGray),
4519 ));
4520 list_lines.push(Line::styled(
4521 "Spawn an agent with /spawn.",
4522 Style::default().fg(Color::DarkGray),
4523 ));
4524 } else {
4525 for (idx, card) in cards.iter().enumerate() {
4526 let marker = if idx == selected { "▶" } else { " " };
4527 let style = if idx == selected {
4528 Style::default()
4529 .fg(Color::Blue)
4530 .add_modifier(Modifier::BOLD)
4531 } else {
4532 Style::default()
4533 };
4534 let transport = card.preferred_transport.as_deref().unwrap_or("JSONRPC");
4535 list_lines.push(Line::styled(format!(" {marker} {}", card.name), style));
4536 list_lines.push(Line::styled(
4537 format!(
4538 " {transport} • {}",
4539 truncate_with_ellipsis(&card.url, 22)
4540 ),
4541 Style::default().fg(Color::DarkGray),
4542 ));
4543 }
4544 }
4545
4546 let list = Paragraph::new(list_lines)
4547 .block(list_block)
4548 .wrap(Wrap { trim: false });
4549 f.render_widget(list, chunks[0]);
4550
4551 let detail_block = Block::default()
4552 .borders(Borders::ALL)
4553 .title(" Agent Card Detail ")
4554 .border_style(Style::default().fg(theme.border_color.to_color()));
4555
4556 let mut detail_lines: Vec<Line> = Vec::new();
4557 if let Some(card) = cards.get(selected) {
4558 let label_style = Style::default().fg(Color::DarkGray);
4559 detail_lines.push(Line::from(vec![
4560 Span::styled("Name: ", label_style),
4561 Span::styled(
4562 card.name.clone(),
4563 Style::default().add_modifier(Modifier::BOLD),
4564 ),
4565 ]));
4566 detail_lines.push(Line::from(vec![
4567 Span::styled("Description: ", label_style),
4568 Span::raw(card.description.clone()),
4569 ]));
4570 detail_lines.push(Line::from(vec![
4571 Span::styled("URL: ", label_style),
4572 Span::styled(card.url.clone(), Style::default().fg(Color::Cyan)),
4573 ]));
4574 detail_lines.push(Line::from(vec![
4575 Span::styled("Version: ", label_style),
4576 Span::raw(format!(
4577 "{} (protocol {})",
4578 card.version, card.protocol_version
4579 )),
4580 ]));
4581
4582 let preferred_transport = card.preferred_transport.as_deref().unwrap_or("JSONRPC");
4583 detail_lines.push(Line::from(vec![
4584 Span::styled("Transport: ", label_style),
4585 Span::raw(preferred_transport.to_string()),
4586 ]));
4587 if !card.additional_interfaces.is_empty() {
4588 detail_lines.push(Line::from(vec![
4589 Span::styled("Interfaces: ", label_style),
4590 Span::raw(format!("{} additional", card.additional_interfaces.len())),
4591 ]));
4592 for iface in &card.additional_interfaces {
4593 detail_lines.push(Line::styled(
4594 format!(" • {} -> {}", iface.transport, iface.url),
4595 Style::default().fg(Color::DarkGray),
4596 ));
4597 }
4598 }
4599
4600 detail_lines.push(Line::from(""));
4601 detail_lines.push(Line::styled(
4602 "Capabilities",
4603 Style::default().add_modifier(Modifier::BOLD),
4604 ));
4605 detail_lines.push(Line::styled(
4606 format!(
4607 " streaming={} push_notifications={} state_history={}",
4608 card.capabilities.streaming,
4609 card.capabilities.push_notifications,
4610 card.capabilities.state_transition_history
4611 ),
4612 Style::default().fg(Color::DarkGray),
4613 ));
4614 if !card.capabilities.extensions.is_empty() {
4615 detail_lines.push(Line::styled(
4616 format!(
4617 " extensions: {}",
4618 card.capabilities
4619 .extensions
4620 .iter()
4621 .map(|e| e.uri.as_str())
4622 .collect::<Vec<_>>()
4623 .join(", ")
4624 ),
4625 Style::default().fg(Color::DarkGray),
4626 ));
4627 }
4628
4629 detail_lines.push(Line::from(""));
4630 detail_lines.push(Line::styled(
4631 format!("Skills ({})", card.skills.len()),
4632 Style::default().add_modifier(Modifier::BOLD),
4633 ));
4634 if card.skills.is_empty() {
4635 detail_lines.push(Line::styled(" none", Style::default().fg(Color::DarkGray)));
4636 } else {
4637 for skill in &card.skills {
4638 let tags = if skill.tags.is_empty() {
4639 "".to_string()
4640 } else {
4641 format!(" [{}]", skill.tags.join(","))
4642 };
4643 detail_lines.push(Line::styled(
4644 format!(" • {}{}", skill.name, tags),
4645 Style::default().fg(Color::Green),
4646 ));
4647 if !skill.description.is_empty() {
4648 detail_lines.push(Line::styled(
4649 format!(" {}", skill.description),
4650 Style::default().fg(Color::DarkGray),
4651 ));
4652 }
4653 }
4654 }
4655
4656 detail_lines.push(Line::from(""));
4657 detail_lines.push(Line::styled(
4658 "Security",
4659 Style::default().add_modifier(Modifier::BOLD),
4660 ));
4661 if card.security_schemes.is_empty() {
4662 detail_lines.push(Line::styled(
4663 " schemes: none",
4664 Style::default().fg(Color::DarkGray),
4665 ));
4666 } else {
4667 let mut names = card.security_schemes.keys().cloned().collect::<Vec<_>>();
4668 names.sort();
4669 detail_lines.push(Line::styled(
4670 format!(" schemes: {}", names.join(", ")),
4671 Style::default().fg(Color::DarkGray),
4672 ));
4673 }
4674 detail_lines.push(Line::styled(
4675 format!(" requirements: {}", card.security.len()),
4676 Style::default().fg(Color::DarkGray),
4677 ));
4678 detail_lines.push(Line::styled(
4679 format!(
4680 " authenticated_extended_card: {}",
4681 card.supports_authenticated_extended_card
4682 ),
4683 Style::default().fg(Color::DarkGray),
4684 ));
4685 } else {
4686 detail_lines.push(Line::styled(
4687 "No card selected.",
4688 Style::default().fg(Color::DarkGray),
4689 ));
4690 }
4691
4692 let detail = Paragraph::new(detail_lines)
4693 .block(detail_block)
4694 .wrap(Wrap { trim: false })
4695 .scroll((app.protocol_scroll as u16, 0));
4696 f.render_widget(detail, chunks[1]);
4697}
4698
4699fn render_webview_header(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
4700 let session_title = app
4701 .session
4702 .as_ref()
4703 .and_then(|s| s.title.clone())
4704 .unwrap_or_else(|| "Workspace Chat".to_string());
4705 let session_id = app
4706 .session
4707 .as_ref()
4708 .map(|s| s.id.chars().take(8).collect::<String>())
4709 .unwrap_or_else(|| "new".to_string());
4710 let model_label = app
4711 .session
4712 .as_ref()
4713 .and_then(|s| s.metadata.model.clone())
4714 .unwrap_or_else(|| "auto".to_string());
4715 let workspace_label = app.workspace.root_display.clone();
4716 let branch_label = app
4717 .workspace
4718 .git_branch
4719 .clone()
4720 .unwrap_or_else(|| "no-git".to_string());
4721 let dirty_label = if app.workspace.git_dirty_files > 0 {
4722 format!("{} dirty", app.workspace.git_dirty_files)
4723 } else {
4724 "clean".to_string()
4725 };
4726
4727 let header_block = Block::default()
4728 .borders(Borders::ALL)
4729 .title(" CodeTether Webview ")
4730 .border_style(Style::default().fg(theme.border_color.to_color()));
4731
4732 let header_lines = vec![
4733 Line::from(vec![
4734 Span::styled(session_title, Style::default().add_modifier(Modifier::BOLD)),
4735 Span::raw(" "),
4736 Span::styled(
4737 format!("#{}", session_id),
4738 Style::default()
4739 .fg(theme.timestamp_color.to_color())
4740 .add_modifier(Modifier::DIM),
4741 ),
4742 ]),
4743 Line::from(vec![
4744 Span::styled(
4745 "Workspace ",
4746 Style::default().fg(theme.timestamp_color.to_color()),
4747 ),
4748 Span::styled(workspace_label, Style::default()),
4749 Span::raw(" "),
4750 Span::styled(
4751 "Branch ",
4752 Style::default().fg(theme.timestamp_color.to_color()),
4753 ),
4754 Span::styled(
4755 branch_label,
4756 Style::default()
4757 .fg(Color::Cyan)
4758 .add_modifier(Modifier::BOLD),
4759 ),
4760 Span::raw(" "),
4761 Span::styled(
4762 dirty_label,
4763 Style::default()
4764 .fg(Color::Yellow)
4765 .add_modifier(Modifier::BOLD),
4766 ),
4767 Span::raw(" "),
4768 Span::styled(
4769 "Model ",
4770 Style::default().fg(theme.timestamp_color.to_color()),
4771 ),
4772 Span::styled(model_label, Style::default().fg(Color::Green)),
4773 ]),
4774 ];
4775
4776 let header = Paragraph::new(header_lines)
4777 .block(header_block)
4778 .wrap(Wrap { trim: true });
4779 f.render_widget(header, area);
4780}
4781
4782fn render_webview_sidebar(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
4783 let sidebar_chunks = Layout::default()
4784 .direction(Direction::Vertical)
4785 .constraints([Constraint::Min(8), Constraint::Min(6)])
4786 .split(area);
4787
4788 let workspace_block = Block::default()
4789 .borders(Borders::ALL)
4790 .title(" Workspace ")
4791 .border_style(Style::default().fg(theme.border_color.to_color()));
4792
4793 let mut workspace_lines = Vec::new();
4794 workspace_lines.push(Line::from(vec![
4795 Span::styled(
4796 "Updated ",
4797 Style::default().fg(theme.timestamp_color.to_color()),
4798 ),
4799 Span::styled(
4800 app.workspace.captured_at.clone(),
4801 Style::default().fg(theme.timestamp_color.to_color()),
4802 ),
4803 ]));
4804 workspace_lines.push(Line::from(""));
4805
4806 if app.workspace.entries.is_empty() {
4807 workspace_lines.push(Line::styled(
4808 "No entries found",
4809 Style::default().fg(Color::DarkGray),
4810 ));
4811 } else {
4812 for entry in app.workspace.entries.iter().take(12) {
4813 let icon = match entry.kind {
4814 WorkspaceEntryKind::Directory => "📁",
4815 WorkspaceEntryKind::File => "📄",
4816 };
4817 workspace_lines.push(Line::from(vec![
4818 Span::styled(icon, Style::default().fg(Color::Cyan)),
4819 Span::raw(" "),
4820 Span::styled(entry.name.clone(), Style::default()),
4821 ]));
4822 }
4823 }
4824
4825 workspace_lines.push(Line::from(""));
4826 workspace_lines.push(Line::styled(
4827 "Use /refresh to rescan",
4828 Style::default()
4829 .fg(Color::DarkGray)
4830 .add_modifier(Modifier::DIM),
4831 ));
4832
4833 let workspace_panel = Paragraph::new(workspace_lines)
4834 .block(workspace_block)
4835 .wrap(Wrap { trim: true });
4836 f.render_widget(workspace_panel, sidebar_chunks[0]);
4837
4838 let sessions_block = Block::default()
4839 .borders(Borders::ALL)
4840 .title(" Recent Sessions ")
4841 .border_style(Style::default().fg(theme.border_color.to_color()));
4842
4843 let mut session_lines = Vec::new();
4844 if app.session_picker_list.is_empty() {
4845 session_lines.push(Line::styled(
4846 "No sessions yet",
4847 Style::default().fg(Color::DarkGray),
4848 ));
4849 } else {
4850 for session in app.session_picker_list.iter().take(6) {
4851 let is_active = app
4852 .session
4853 .as_ref()
4854 .map(|s| s.id == session.id)
4855 .unwrap_or(false);
4856 let title = session.title.as_deref().unwrap_or("(untitled)");
4857 let indicator = if is_active { "●" } else { "○" };
4858 let line_style = if is_active {
4859 Style::default()
4860 .fg(Color::Cyan)
4861 .add_modifier(Modifier::BOLD)
4862 } else {
4863 Style::default()
4864 };
4865 session_lines.push(Line::from(vec![
4866 Span::styled(indicator, line_style),
4867 Span::raw(" "),
4868 Span::styled(title, line_style),
4869 ]));
4870 session_lines.push(Line::styled(
4871 format!(
4872 " {} msgs • {}",
4873 session.message_count,
4874 session.updated_at.format("%m-%d %H:%M")
4875 ),
4876 Style::default().fg(Color::DarkGray),
4877 ));
4878 }
4879 }
4880
4881 let sessions_panel = Paragraph::new(session_lines)
4882 .block(sessions_block)
4883 .wrap(Wrap { trim: true });
4884 f.render_widget(sessions_panel, sidebar_chunks[1]);
4885}
4886
4887fn render_webview_chat_center(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
4888 let messages_area = area;
4889 let focused_suffix = app
4890 .active_spawned_agent
4891 .as_ref()
4892 .map(|name| format!(" → @{name}"))
4893 .unwrap_or_default();
4894 let messages_block = Block::default()
4895 .borders(Borders::ALL)
4896 .title(format!(" Chat [{}{}] ", app.current_agent, focused_suffix))
4897 .border_style(Style::default().fg(theme.border_color.to_color()));
4898
4899 let max_width = messages_area.width.saturating_sub(4) as usize;
4900 let message_lines = build_message_lines(app, theme, max_width);
4901
4902 let total_lines = message_lines.len();
4903 let visible_lines = messages_area.height.saturating_sub(2) as usize;
4904 let max_scroll = total_lines.saturating_sub(visible_lines);
4905 let scroll = if app.scroll >= SCROLL_BOTTOM {
4906 max_scroll
4907 } else {
4908 app.scroll.min(max_scroll)
4909 };
4910
4911 let messages_paragraph = Paragraph::new(
4912 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
4913 )
4914 .block(messages_block.clone())
4915 .wrap(Wrap { trim: false });
4916
4917 f.render_widget(messages_paragraph, messages_area);
4918
4919 if total_lines > visible_lines {
4920 let scrollbar = Scrollbar::default()
4921 .orientation(ScrollbarOrientation::VerticalRight)
4922 .symbols(ratatui::symbols::scrollbar::VERTICAL)
4923 .begin_symbol(Some("↑"))
4924 .end_symbol(Some("↓"));
4925
4926 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
4927
4928 let scrollbar_area = Rect::new(
4929 messages_area.right() - 1,
4930 messages_area.top() + 1,
4931 1,
4932 messages_area.height - 2,
4933 );
4934
4935 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
4936 }
4937}
4938
4939fn render_webview_inspector(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
4940 let block = Block::default()
4941 .borders(Borders::ALL)
4942 .title(" Inspector ")
4943 .border_style(Style::default().fg(theme.border_color.to_color()));
4944
4945 let status_label = if app.is_processing {
4946 "Processing"
4947 } else {
4948 "Idle"
4949 };
4950 let status_style = if app.is_processing {
4951 Style::default()
4952 .fg(Color::Yellow)
4953 .add_modifier(Modifier::BOLD)
4954 } else {
4955 Style::default().fg(Color::Green)
4956 };
4957 let tool_label = app
4958 .current_tool
4959 .clone()
4960 .unwrap_or_else(|| "none".to_string());
4961 let message_count = app.messages.len();
4962 let session_id = app
4963 .session
4964 .as_ref()
4965 .map(|s| s.id.chars().take(8).collect::<String>())
4966 .unwrap_or_else(|| "new".to_string());
4967 let model_label = app
4968 .active_model
4969 .as_deref()
4970 .or_else(|| {
4971 app.session
4972 .as_ref()
4973 .and_then(|s| s.metadata.model.as_deref())
4974 })
4975 .unwrap_or("auto");
4976 let conversation_depth = app.session.as_ref().map(|s| s.messages.len()).unwrap_or(0);
4977
4978 let label_style = Style::default().fg(theme.timestamp_color.to_color());
4979
4980 let mut lines = Vec::new();
4981 lines.push(Line::from(vec![
4982 Span::styled("Status: ", label_style),
4983 Span::styled(status_label, status_style),
4984 ]));
4985
4986 if let Some(started) = app.processing_started_at {
4988 let elapsed = started.elapsed();
4989 let elapsed_str = if elapsed.as_secs() >= 60 {
4990 format!("{}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
4991 } else {
4992 format!("{:.1}s", elapsed.as_secs_f64())
4993 };
4994 lines.push(Line::from(vec![
4995 Span::styled("Elapsed: ", label_style),
4996 Span::styled(
4997 elapsed_str,
4998 Style::default()
4999 .fg(Color::Yellow)
5000 .add_modifier(Modifier::BOLD),
5001 ),
5002 ]));
5003 }
5004
5005 lines.push(Line::from(vec![
5006 Span::styled("Tool: ", label_style),
5007 Span::styled(
5008 tool_label,
5009 if app.current_tool.is_some() {
5010 Style::default()
5011 .fg(Color::Cyan)
5012 .add_modifier(Modifier::BOLD)
5013 } else {
5014 Style::default().fg(Color::DarkGray)
5015 },
5016 ),
5017 ]));
5018 lines.push(Line::from(""));
5019 lines.push(Line::styled(
5020 "Session",
5021 Style::default().add_modifier(Modifier::BOLD),
5022 ));
5023 lines.push(Line::from(vec![
5024 Span::styled("ID: ", label_style),
5025 Span::styled(format!("#{}", session_id), Style::default().fg(Color::Cyan)),
5026 ]));
5027 lines.push(Line::from(vec![
5028 Span::styled("Model: ", label_style),
5029 Span::styled(model_label.to_string(), Style::default().fg(Color::Green)),
5030 ]));
5031 let agent_display = if let Some(target) = &app.active_spawned_agent {
5032 format!("{} → @{} (focused)", app.current_agent, target)
5033 } else {
5034 app.current_agent.clone()
5035 };
5036 lines.push(Line::from(vec![
5037 Span::styled("Agent: ", label_style),
5038 Span::styled(agent_display, Style::default()),
5039 ]));
5040 lines.push(Line::from(vec![
5041 Span::styled("Messages: ", label_style),
5042 Span::styled(message_count.to_string(), Style::default()),
5043 ]));
5044 lines.push(Line::from(vec![
5045 Span::styled("Context: ", label_style),
5046 Span::styled(format!("{} turns", conversation_depth), Style::default()),
5047 ]));
5048 lines.push(Line::from(vec![
5049 Span::styled("Tools used: ", label_style),
5050 Span::styled(app.tool_call_count.to_string(), Style::default()),
5051 ]));
5052 lines.push(Line::from(vec![
5053 Span::styled("Protocol: ", label_style),
5054 Span::styled(
5055 format!("{} registered", app.protocol_registered_count()),
5056 Style::default().fg(Color::Cyan),
5057 ),
5058 ]));
5059 lines.push(Line::from(""));
5060 lines.push(Line::styled(
5061 "Sub-agents",
5062 Style::default().add_modifier(Modifier::BOLD),
5063 ));
5064 if app.spawned_agents.is_empty() {
5065 lines.push(Line::styled(
5066 "None (use /spawn <name> <instructions>)",
5067 Style::default().fg(Color::DarkGray),
5068 ));
5069 } else {
5070 for (name, agent) in app.spawned_agents.iter().take(4) {
5071 let status = if agent.is_processing { "⚡" } else { "●" };
5072 let is_registered = app.is_agent_protocol_registered(name);
5073 let protocol = if is_registered { "🔗" } else { "⚠" };
5074 let focused = if app.active_spawned_agent.as_deref() == Some(name.as_str()) {
5075 " [focused]"
5076 } else {
5077 ""
5078 };
5079 lines.push(Line::styled(
5080 format!("{status} {protocol} @{name}{focused}"),
5081 if focused.is_empty() {
5082 Style::default().fg(Color::Magenta)
5083 } else {
5084 Style::default()
5085 .fg(Color::Magenta)
5086 .add_modifier(Modifier::BOLD)
5087 },
5088 ));
5089 lines.push(Line::styled(
5090 format!(" {}", agent.instructions),
5091 Style::default()
5092 .fg(Color::DarkGray)
5093 .add_modifier(Modifier::DIM),
5094 ));
5095 if is_registered {
5096 lines.push(Line::styled(
5097 format!(" bus://local/{name}"),
5098 Style::default()
5099 .fg(Color::Green)
5100 .add_modifier(Modifier::DIM),
5101 ));
5102 }
5103 }
5104 if app.spawned_agents.len() > 4 {
5105 lines.push(Line::styled(
5106 format!("… and {} more", app.spawned_agents.len() - 4),
5107 Style::default()
5108 .fg(Color::DarkGray)
5109 .add_modifier(Modifier::DIM),
5110 ));
5111 }
5112 }
5113 lines.push(Line::from(""));
5114 lines.push(Line::styled(
5115 "Shortcuts",
5116 Style::default().add_modifier(Modifier::BOLD),
5117 ));
5118 lines.push(Line::from(vec![
5119 Span::styled("F3 ", Style::default().fg(Color::Yellow)),
5120 Span::styled("Inspector", Style::default().fg(Color::DarkGray)),
5121 ]));
5122 lines.push(Line::from(vec![
5123 Span::styled("Ctrl+B ", Style::default().fg(Color::Yellow)),
5124 Span::styled("Layout", Style::default().fg(Color::DarkGray)),
5125 ]));
5126 lines.push(Line::from(vec![
5127 Span::styled("Ctrl+Y ", Style::default().fg(Color::Yellow)),
5128 Span::styled("Copy", Style::default().fg(Color::DarkGray)),
5129 ]));
5130 lines.push(Line::from(vec![
5131 Span::styled("Ctrl+M ", Style::default().fg(Color::Yellow)),
5132 Span::styled("Model", Style::default().fg(Color::DarkGray)),
5133 ]));
5134 lines.push(Line::from(vec![
5135 Span::styled("Ctrl+S ", Style::default().fg(Color::Yellow)),
5136 Span::styled("Swarm", Style::default().fg(Color::DarkGray)),
5137 ]));
5138 lines.push(Line::from(vec![
5139 Span::styled("? ", Style::default().fg(Color::Yellow)),
5140 Span::styled("Help", Style::default().fg(Color::DarkGray)),
5141 ]));
5142
5143 let panel = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
5144 f.render_widget(panel, area);
5145}
5146
5147fn render_webview_input(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
5148 let title = if app.is_processing {
5149 if let Some(started) = app.processing_started_at {
5150 let elapsed = started.elapsed();
5151 format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
5152 } else {
5153 " Message (Processing...) ".to_string()
5154 }
5155 } else if app.input.starts_with('/') {
5156 let hint = match_slash_command_hint(&app.input);
5158 format!(" {} ", hint)
5159 } else if let Some(target) = &app.active_spawned_agent {
5160 format!(" Message to @{target} (use /agent main to exit) ")
5161 } else {
5162 " Message (Enter to send, / for commands) ".to_string()
5163 };
5164
5165 let input_block = Block::default()
5166 .borders(Borders::ALL)
5167 .title(title)
5168 .border_style(Style::default().fg(if app.is_processing {
5169 Color::Yellow
5170 } else if app.input.starts_with('/') {
5171 Color::Magenta
5172 } else {
5173 theme.input_border_color.to_color()
5174 }));
5175
5176 let input = Paragraph::new(app.input.as_str())
5177 .block(input_block)
5178 .wrap(Wrap { trim: false });
5179 f.render_widget(input, area);
5180
5181 f.set_cursor_position((area.x + app.cursor_position as u16 + 1, area.y + 1));
5182}
5183
5184fn build_message_lines(app: &App, theme: &Theme, max_width: usize) -> Vec<Line<'static>> {
5185 let mut message_lines = Vec::new();
5186 let separator_width = max_width.min(60);
5187
5188 for (idx, message) in app.messages.iter().enumerate() {
5189 let role_style = theme.get_role_style(&message.role);
5190
5191 if idx > 0 {
5193 let sep_char = match message.role.as_str() {
5194 "tool" => "·",
5195 _ => "─",
5196 };
5197 message_lines.push(Line::from(Span::styled(
5198 sep_char.repeat(separator_width),
5199 Style::default()
5200 .fg(theme.timestamp_color.to_color())
5201 .add_modifier(Modifier::DIM),
5202 )));
5203 }
5204
5205 let role_icon = match message.role.as_str() {
5207 "user" => "▸ ",
5208 "assistant" => "◆ ",
5209 "system" => "⚙ ",
5210 "tool" => "⚡",
5211 _ => " ",
5212 };
5213
5214 let header_line = {
5215 let mut spans = vec![
5216 Span::styled(
5217 format!("[{}] ", message.timestamp),
5218 Style::default()
5219 .fg(theme.timestamp_color.to_color())
5220 .add_modifier(Modifier::DIM),
5221 ),
5222 Span::styled(role_icon, role_style),
5223 Span::styled(message.role.clone(), role_style),
5224 ];
5225 if let Some(ref agent) = message.agent_name {
5226 spans.push(Span::styled(
5227 format!(" @{agent}"),
5228 Style::default()
5229 .fg(Color::Magenta)
5230 .add_modifier(Modifier::BOLD),
5231 ));
5232 }
5233 Line::from(spans)
5234 };
5235 message_lines.push(header_line);
5236
5237 match &message.message_type {
5238 MessageType::ToolCall {
5239 name,
5240 arguments_preview,
5241 arguments_len,
5242 truncated,
5243 } => {
5244 let tool_header = Line::from(vec![
5245 Span::styled(" 🔧 ", Style::default().fg(Color::Yellow)),
5246 Span::styled(
5247 format!("Tool: {}", name),
5248 Style::default()
5249 .fg(Color::Yellow)
5250 .add_modifier(Modifier::BOLD),
5251 ),
5252 ]);
5253 message_lines.push(tool_header);
5254
5255 if arguments_preview.trim().is_empty() {
5256 message_lines.push(Line::from(vec![
5257 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
5258 Span::styled(
5259 "(no arguments)",
5260 Style::default()
5261 .fg(Color::DarkGray)
5262 .add_modifier(Modifier::DIM),
5263 ),
5264 ]));
5265 } else {
5266 for line in arguments_preview.lines() {
5267 let args_line = Line::from(vec![
5268 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
5269 Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
5270 ]);
5271 message_lines.push(args_line);
5272 }
5273 }
5274
5275 if *truncated {
5276 let args_line = Line::from(vec![
5277 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
5278 Span::styled(
5279 format!("... (truncated; {} bytes)", arguments_len),
5280 Style::default()
5281 .fg(Color::DarkGray)
5282 .add_modifier(Modifier::DIM),
5283 ),
5284 ]);
5285 message_lines.push(args_line);
5286 }
5287 }
5288 MessageType::ToolResult {
5289 name,
5290 output_preview,
5291 output_len,
5292 truncated,
5293 } => {
5294 let result_header = Line::from(vec![
5295 Span::styled(" ✅ ", Style::default().fg(Color::Green)),
5296 Span::styled(
5297 format!("Result from {}", name),
5298 Style::default()
5299 .fg(Color::Green)
5300 .add_modifier(Modifier::BOLD),
5301 ),
5302 ]);
5303 message_lines.push(result_header);
5304
5305 if output_preview.trim().is_empty() {
5306 message_lines.push(Line::from(vec![
5307 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
5308 Span::styled(
5309 "(empty output)",
5310 Style::default()
5311 .fg(Color::DarkGray)
5312 .add_modifier(Modifier::DIM),
5313 ),
5314 ]));
5315 } else {
5316 for line in output_preview.lines() {
5317 let output_line = Line::from(vec![
5318 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
5319 Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
5320 ]);
5321 message_lines.push(output_line);
5322 }
5323 }
5324
5325 if *truncated {
5326 message_lines.push(Line::from(vec![
5327 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
5328 Span::styled(
5329 format!("... (truncated; {} bytes)", output_len),
5330 Style::default()
5331 .fg(Color::DarkGray)
5332 .add_modifier(Modifier::DIM),
5333 ),
5334 ]));
5335 }
5336 }
5337 MessageType::Text(text) => {
5338 let formatter = MessageFormatter::new(max_width);
5339 let formatted_content = formatter.format_content(text, &message.role);
5340 message_lines.extend(formatted_content);
5341 }
5342 MessageType::Thinking(text) => {
5343 let thinking_style = Style::default()
5344 .fg(Color::DarkGray)
5345 .add_modifier(Modifier::DIM | Modifier::ITALIC);
5346 message_lines.push(Line::from(Span::styled(
5347 " 💭 Thinking...",
5348 Style::default()
5349 .fg(Color::Magenta)
5350 .add_modifier(Modifier::DIM),
5351 )));
5352 let max_thinking_lines = 8;
5354 let mut iter = text.lines();
5355 let mut shown = 0usize;
5356 while shown < max_thinking_lines {
5357 let Some(line) = iter.next() else { break };
5358 message_lines.push(Line::from(vec![
5359 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
5360 Span::styled(line.to_string(), thinking_style),
5361 ]));
5362 shown += 1;
5363 }
5364 if iter.next().is_some() {
5365 message_lines.push(Line::from(Span::styled(
5366 " │ ... (truncated)",
5367 thinking_style,
5368 )));
5369 }
5370 }
5371 MessageType::Image { url, mime_type } => {
5372 let formatter = MessageFormatter::new(max_width);
5373 let image_line = formatter.format_image(url, mime_type.as_deref());
5374 message_lines.push(image_line);
5375 }
5376 MessageType::File { path, mime_type } => {
5377 let mime_label = mime_type.as_deref().unwrap_or("unknown type");
5378 let file_header = Line::from(vec![
5379 Span::styled(" 📎 ", Style::default().fg(Color::Cyan)),
5380 Span::styled(
5381 format!("File: {}", path),
5382 Style::default()
5383 .fg(Color::Cyan)
5384 .add_modifier(Modifier::BOLD),
5385 ),
5386 Span::styled(
5387 format!(" ({})", mime_label),
5388 Style::default()
5389 .fg(Color::DarkGray)
5390 .add_modifier(Modifier::DIM),
5391 ),
5392 ]);
5393 message_lines.push(file_header);
5394 }
5395 }
5396
5397 if message.role == "assistant" {
5399 if let Some(ref meta) = message.usage_meta {
5400 let duration_str = if meta.duration_ms >= 60_000 {
5401 format!(
5402 "{}m{:02}.{}s",
5403 meta.duration_ms / 60_000,
5404 (meta.duration_ms % 60_000) / 1000,
5405 (meta.duration_ms % 1000) / 100
5406 )
5407 } else {
5408 format!(
5409 "{}.{}s",
5410 meta.duration_ms / 1000,
5411 (meta.duration_ms % 1000) / 100
5412 )
5413 };
5414 let tokens_str =
5415 format!("{}→{} tokens", meta.prompt_tokens, meta.completion_tokens);
5416 let cost_str = match meta.cost_usd {
5417 Some(c) if c < 0.01 => format!("${:.4}", c),
5418 Some(c) => format!("${:.2}", c),
5419 None => String::new(),
5420 };
5421 let dim_style = Style::default()
5422 .fg(theme.timestamp_color.to_color())
5423 .add_modifier(Modifier::DIM);
5424 let mut spans = vec![Span::styled(
5425 format!(" ⏱ {} │ 📊 {}", duration_str, tokens_str),
5426 dim_style,
5427 )];
5428 if !cost_str.is_empty() {
5429 spans.push(Span::styled(format!(" │ 💰 {}", cost_str), dim_style));
5430 }
5431 message_lines.push(Line::from(spans));
5432 }
5433 }
5434
5435 message_lines.push(Line::from(""));
5436 }
5437
5438 if let Some(ref streaming) = app.streaming_text {
5440 if !streaming.is_empty() {
5441 message_lines.push(Line::from(Span::styled(
5442 "─".repeat(separator_width),
5443 Style::default()
5444 .fg(theme.timestamp_color.to_color())
5445 .add_modifier(Modifier::DIM),
5446 )));
5447 message_lines.push(Line::from(vec![
5448 Span::styled(
5449 format!("[{}] ", chrono::Local::now().format("%H:%M")),
5450 Style::default()
5451 .fg(theme.timestamp_color.to_color())
5452 .add_modifier(Modifier::DIM),
5453 ),
5454 Span::styled("◆ ", theme.get_role_style("assistant")),
5455 Span::styled("assistant", theme.get_role_style("assistant")),
5456 Span::styled(
5457 " (streaming...)",
5458 Style::default()
5459 .fg(theme.timestamp_color.to_color())
5460 .add_modifier(Modifier::DIM),
5461 ),
5462 ]));
5463 let formatter = MessageFormatter::new(max_width);
5464 let formatted = formatter.format_content(streaming, "assistant");
5465 message_lines.extend(formatted);
5466 message_lines.push(Line::from(""));
5467 }
5468 }
5469
5470 if app.is_processing {
5471 let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5472 let spinner_idx = (std::time::SystemTime::now()
5473 .duration_since(std::time::UNIX_EPOCH)
5474 .unwrap_or_default()
5475 .as_millis()
5476 / 100) as usize
5477 % spinner.len();
5478
5479 let elapsed_str = if let Some(started) = app.processing_started_at {
5481 let elapsed = started.elapsed();
5482 if elapsed.as_secs() >= 60 {
5483 format!(" {}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
5484 } else {
5485 format!(" {:.1}s", elapsed.as_secs_f64())
5486 }
5487 } else {
5488 String::new()
5489 };
5490
5491 let processing_line = Line::from(vec![
5492 Span::styled(
5493 format!("[{}] ", chrono::Local::now().format("%H:%M")),
5494 Style::default()
5495 .fg(theme.timestamp_color.to_color())
5496 .add_modifier(Modifier::DIM),
5497 ),
5498 Span::styled("◆ ", theme.get_role_style("assistant")),
5499 Span::styled("assistant", theme.get_role_style("assistant")),
5500 Span::styled(
5501 elapsed_str,
5502 Style::default()
5503 .fg(theme.timestamp_color.to_color())
5504 .add_modifier(Modifier::DIM),
5505 ),
5506 ]);
5507 message_lines.push(processing_line);
5508
5509 let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
5510 (
5511 format!(" {} Running: {}", spinner[spinner_idx], tool),
5512 Color::Cyan,
5513 )
5514 } else {
5515 (
5516 format!(
5517 " {} {}",
5518 spinner[spinner_idx],
5519 app.processing_message.as_deref().unwrap_or("Thinking...")
5520 ),
5521 Color::Yellow,
5522 )
5523 };
5524
5525 let indicator_line = Line::from(vec![Span::styled(
5526 status_text,
5527 Style::default()
5528 .fg(status_color)
5529 .add_modifier(Modifier::BOLD),
5530 )]);
5531 message_lines.push(indicator_line);
5532 message_lines.push(Line::from(""));
5533 }
5534
5535 message_lines
5536}
5537
5538fn match_slash_command_hint(input: &str) -> String {
5539 let commands = [
5540 ("/go ", "Easy mode: run relay chat with default team size"),
5541 ("/add ", "Easy mode: create a teammate"),
5542 ("/talk ", "Easy mode: message or focus a teammate"),
5543 ("/list", "Easy mode: list teammates"),
5544 ("/remove ", "Easy mode: remove a teammate"),
5545 ("/home", "Easy mode: return to main chat"),
5546 ("/help", "Open help"),
5547 ("/spawn ", "Create a named sub-agent"),
5548 ("/autochat ", "Run protocol-first multi-agent relay chat"),
5549 ("/agents", "List spawned sub-agents"),
5550 ("/kill ", "Remove a spawned sub-agent"),
5551 ("/agent ", "Focus or message a spawned sub-agent"),
5552 ("/swarm ", "Run task in parallel swarm mode"),
5553 ("/ralph", "Start autonomous PRD loop"),
5554 ("/undo", "Undo last message and response"),
5555 ("/sessions", "Open session picker"),
5556 ("/resume", "Resume a session"),
5557 ("/new", "Start a new session"),
5558 ("/model", "Select or set model"),
5559 ("/webview", "Switch to webview layout"),
5560 ("/classic", "Switch to classic layout"),
5561 ("/inspector", "Toggle inspector pane"),
5562 ("/refresh", "Refresh workspace"),
5563 ("/view", "Toggle swarm view"),
5564 ("/buslog", "Show protocol bus log"),
5565 ("/protocol", "Show protocol registry"),
5566 ];
5567
5568 let input_lower = input.to_lowercase();
5569 let matches: Vec<_> = commands
5570 .iter()
5571 .filter(|(cmd, _)| cmd.starts_with(&input_lower))
5572 .collect();
5573
5574 if matches.len() == 1 {
5575 format!("{} — {}", matches[0].0.trim(), matches[0].1)
5576 } else if matches.is_empty() {
5577 "Unknown command".to_string()
5578 } else {
5579 let cmds: Vec<_> = matches.iter().map(|(cmd, _)| cmd.trim()).collect();
5580 cmds.join(" | ")
5581 }
5582}
5583
5584fn command_with_optional_args<'a>(input: &'a str, command: &str) -> Option<&'a str> {
5585 let trimmed = input.trim();
5586 let rest = trimmed.strip_prefix(command)?;
5587
5588 if rest.is_empty() {
5589 return Some("");
5590 }
5591
5592 let first = rest.chars().next()?;
5593 if first.is_whitespace() {
5594 Some(rest.trim())
5595 } else {
5596 None
5597 }
5598}
5599
5600fn normalize_easy_command(input: &str) -> String {
5601 let trimmed = input.trim();
5602 if trimmed.is_empty() {
5603 return String::new();
5604 }
5605
5606 if !trimmed.starts_with('/') {
5607 return input.to_string();
5608 }
5609
5610 let mut parts = trimmed.splitn(2, char::is_whitespace);
5611 let command = parts.next().unwrap_or("");
5612 let args = parts.next().unwrap_or("").trim();
5613
5614 match command.to_ascii_lowercase().as_str() {
5615 "/go" | "/team" => {
5616 if args.is_empty() {
5617 "/autochat".to_string()
5618 } else {
5619 format!("/autochat {AUTOCHAT_DEFAULT_AGENTS} {args}")
5620 }
5621 }
5622 "/add" => {
5623 if args.is_empty() {
5624 "/spawn".to_string()
5625 } else {
5626 format!("/spawn {args}")
5627 }
5628 }
5629 "/list" | "/ls" => "/agents".to_string(),
5630 "/remove" | "/rm" => {
5631 if args.is_empty() {
5632 "/kill".to_string()
5633 } else {
5634 format!("/kill {args}")
5635 }
5636 }
5637 "/talk" | "/say" => {
5638 if args.is_empty() {
5639 "/agent".to_string()
5640 } else {
5641 format!("/agent {args}")
5642 }
5643 }
5644 "/focus" => {
5645 if args.is_empty() {
5646 "/agent".to_string()
5647 } else {
5648 format!("/agent {}", args.trim_start_matches('@'))
5649 }
5650 }
5651 "/home" | "/main" => "/agent main".to_string(),
5652 "/h" | "/?" => "/help".to_string(),
5653 _ => trimmed.to_string(),
5654 }
5655}
5656
5657fn parse_autochat_args(rest: &str) -> Option<(usize, &str)> {
5658 let rest = rest.trim();
5659 if rest.is_empty() {
5660 return None;
5661 }
5662
5663 let mut parts = rest.splitn(2, char::is_whitespace);
5664 let first = parts.next().unwrap_or("").trim();
5665 if first.is_empty() {
5666 return None;
5667 }
5668
5669 if let Ok(count) = first.parse::<usize>() {
5670 let task = parts.next().unwrap_or("").trim();
5671 if task.is_empty() {
5672 None
5673 } else {
5674 Some((count, task))
5675 }
5676 } else {
5677 Some((AUTOCHAT_DEFAULT_AGENTS, rest))
5678 }
5679}
5680
5681fn normalize_for_convergence(text: &str) -> String {
5682 let mut normalized = String::with_capacity(text.len().min(512));
5683 let mut last_was_space = false;
5684
5685 for ch in text.chars() {
5686 if ch.is_ascii_alphanumeric() {
5687 normalized.push(ch.to_ascii_lowercase());
5688 last_was_space = false;
5689 } else if ch.is_whitespace() && !last_was_space {
5690 normalized.push(' ');
5691 last_was_space = true;
5692 }
5693
5694 if normalized.len() >= 280 {
5695 break;
5696 }
5697 }
5698
5699 normalized.trim().to_string()
5700}
5701
5702fn format_tool_call_arguments(name: &str, arguments: &str) -> String {
5703 if arguments.len() > TOOL_ARGS_PRETTY_JSON_MAX_BYTES {
5707 return arguments.to_string();
5708 }
5709
5710 let parsed = match serde_json::from_str::<serde_json::Value>(arguments) {
5711 Ok(value) => value,
5712 Err(_) => return arguments.to_string(),
5713 };
5714
5715 if name == "question"
5716 && let Some(question) = parsed.get("question").and_then(serde_json::Value::as_str)
5717 {
5718 return question.to_string();
5719 }
5720
5721 serde_json::to_string_pretty(&parsed).unwrap_or_else(|_| arguments.to_string())
5722}
5723
5724fn build_tool_arguments_preview(
5725 tool_name: &str,
5726 arguments: &str,
5727 max_lines: usize,
5728 max_bytes: usize,
5729) -> (String, bool) {
5730 let formatted = format_tool_call_arguments(tool_name, arguments);
5732 build_text_preview(&formatted, max_lines, max_bytes)
5733}
5734
5735fn build_text_preview(text: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
5739 if max_lines == 0 || max_bytes == 0 || text.is_empty() {
5740 return (String::new(), !text.is_empty());
5741 }
5742
5743 let mut out = String::new();
5744 let mut truncated = false;
5745 let mut remaining = max_bytes;
5746
5747 let mut iter = text.lines();
5748 for i in 0..max_lines {
5749 let Some(line) = iter.next() else { break };
5750
5751 if i > 0 {
5753 if remaining == 0 {
5754 truncated = true;
5755 break;
5756 }
5757 out.push('\n');
5758 remaining = remaining.saturating_sub(1);
5759 }
5760
5761 if remaining == 0 {
5762 truncated = true;
5763 break;
5764 }
5765
5766 if line.len() <= remaining {
5767 out.push_str(line);
5768 remaining = remaining.saturating_sub(line.len());
5769 } else {
5770 let mut end = remaining;
5772 while end > 0 && !line.is_char_boundary(end) {
5773 end -= 1;
5774 }
5775 out.push_str(&line[..end]);
5776 truncated = true;
5777 break;
5778 }
5779 }
5780
5781 if !truncated && iter.next().is_some() {
5783 truncated = true;
5784 }
5785
5786 (out, truncated)
5787}
5788
5789fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
5790 if max_chars == 0 {
5791 return String::new();
5792 }
5793
5794 let mut chars = value.chars();
5795 let mut output = String::new();
5796 for _ in 0..max_chars {
5797 if let Some(ch) = chars.next() {
5798 output.push(ch);
5799 } else {
5800 return value.to_string();
5801 }
5802 }
5803
5804 if chars.next().is_some() {
5805 format!("{output}...")
5806 } else {
5807 output
5808 }
5809}
5810
5811fn message_clipboard_text(message: &ChatMessage) -> String {
5812 let mut prefix = String::new();
5813 if let Some(agent) = &message.agent_name {
5814 prefix = format!("@{agent}\n");
5815 }
5816
5817 match &message.message_type {
5818 MessageType::Text(text) => format!("{prefix}{text}"),
5819 MessageType::Thinking(text) => format!("{prefix}{text}"),
5820 MessageType::Image { url, .. } => format!("{prefix}{url}"),
5821 MessageType::File { path, .. } => format!("{prefix}{path}"),
5822 MessageType::ToolCall {
5823 name,
5824 arguments_preview,
5825 ..
5826 } => format!("{prefix}Tool call: {name}\n{arguments_preview}"),
5827 MessageType::ToolResult {
5828 name,
5829 output_preview,
5830 ..
5831 } => format!("{prefix}Tool result: {name}\n{output_preview}"),
5832 }
5833}
5834
5835fn copy_text_to_clipboard_best_effort(text: &str) -> Result<&'static str, String> {
5836 if text.trim().is_empty() {
5837 return Err("empty text".to_string());
5838 }
5839
5840 match arboard::Clipboard::new().and_then(|mut clipboard| clipboard.set_text(text.to_string())) {
5842 Ok(()) => return Ok("system clipboard"),
5843 Err(e) => {
5844 tracing::debug!(error = %e, "System clipboard unavailable; falling back to OSC52");
5845 }
5846 }
5847
5848 osc52_copy(text).map_err(|e| format!("osc52 copy failed: {e}"))?;
5850 Ok("OSC52")
5851}
5852
5853fn osc52_copy(text: &str) -> std::io::Result<()> {
5854 let payload = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
5857 let seq = format!("\u{1b}]52;c;{payload}\u{07}");
5858
5859 let mut stdout = std::io::stdout();
5860 crossterm::execute!(stdout, crossterm::style::Print(seq))?;
5861 use std::io::Write;
5862 stdout.flush()?;
5863 Ok(())
5864}
5865
5866fn render_help_overlay_if_needed(f: &mut Frame, app: &App, theme: &Theme) {
5867 if !app.show_help {
5868 return;
5869 }
5870
5871 let area = centered_rect(60, 60, f.area());
5872 f.render_widget(Clear, area);
5873
5874 let token_display = TokenDisplay::new();
5875 let token_info = token_display.create_detailed_display();
5876
5877 let model_section: Vec<String> = if let Some(ref active) = app.active_model {
5879 let (provider, model) = crate::provider::parse_model_string(active);
5880 let provider_label = provider.unwrap_or("auto");
5881 vec![
5882 "".to_string(),
5883 " ACTIVE MODEL".to_string(),
5884 " ==============".to_string(),
5885 format!(" Provider: {}", provider_label),
5886 format!(" Model: {}", model),
5887 format!(" Agent: {}", app.current_agent),
5888 ]
5889 } else {
5890 vec![
5891 "".to_string(),
5892 " ACTIVE MODEL".to_string(),
5893 " ==============".to_string(),
5894 format!(" Provider: auto"),
5895 format!(" Model: (default)"),
5896 format!(" Agent: {}", app.current_agent),
5897 ]
5898 };
5899
5900 let help_text: Vec<String> = vec![
5901 "".to_string(),
5902 " KEYBOARD SHORTCUTS".to_string(),
5903 " ==================".to_string(),
5904 "".to_string(),
5905 " Enter Send message".to_string(),
5906 " Tab Switch between build/plan agents".to_string(),
5907 " Ctrl+A Open spawned-agent picker".to_string(),
5908 " Ctrl+M Open model picker".to_string(),
5909 " Ctrl+L Protocol bus log".to_string(),
5910 " Ctrl+P Protocol registry".to_string(),
5911 " Ctrl+S Toggle swarm view".to_string(),
5912 " Ctrl+B Toggle webview layout".to_string(),
5913 " Ctrl+Y Copy latest assistant reply".to_string(),
5914 " F3 Toggle inspector pane".to_string(),
5915 " Ctrl+C Quit".to_string(),
5916 " ? Toggle this help".to_string(),
5917 "".to_string(),
5918 " SLASH COMMANDS (auto-complete hints shown while typing)".to_string(),
5919 " EASY MODE".to_string(),
5920 " /go <task> Start relay chat with default team size".to_string(),
5921 " /add <name> Create a helper teammate".to_string(),
5922 " /talk <name> <message> Message teammate".to_string(),
5923 " /list List teammates".to_string(),
5924 " /remove <name> Remove teammate".to_string(),
5925 " /home Return to main chat".to_string(),
5926 " /help Open this help".to_string(),
5927 "".to_string(),
5928 " ADVANCED MODE".to_string(),
5929 " /spawn <name> <instructions> Create a named sub-agent".to_string(),
5930 " /autochat [count] <task> Protocol-first auto multi-agent relay".to_string(),
5931 " /agents List spawned sub-agents".to_string(),
5932 " /kill <name> Remove a spawned sub-agent".to_string(),
5933 " /agent <name> Focus chat on a spawned sub-agent".to_string(),
5934 " /agent <name> <message> Send one message to a spawned sub-agent".to_string(),
5935 " /agent Open spawned-agent picker".to_string(),
5936 " /agent main|off Exit focused sub-agent chat".to_string(),
5937 " /swarm <task> Run task in parallel swarm mode".to_string(),
5938 " /ralph [path] Start Ralph PRD loop (default: prd.json)".to_string(),
5939 " /undo Undo last message and response".to_string(),
5940 " /sessions Open session picker (filter, delete, load)".to_string(),
5941 " /resume Resume most recent session".to_string(),
5942 " /resume <id> Resume specific session by ID".to_string(),
5943 " /new Start a fresh session".to_string(),
5944 " /model Open model picker (or /model <name>)".to_string(),
5945 " /view Toggle swarm view".to_string(),
5946 " /buslog Show protocol bus log".to_string(),
5947 " /protocol Show protocol registry and AgentCards".to_string(),
5948 " /webview Web dashboard layout".to_string(),
5949 " /classic Single-pane layout".to_string(),
5950 " /inspector Toggle inspector pane".to_string(),
5951 " /refresh Refresh workspace and sessions".to_string(),
5952 "".to_string(),
5953 " SESSION PICKER".to_string(),
5954 " ↑/↓/j/k Navigate sessions".to_string(),
5955 " Enter Load selected session".to_string(),
5956 " d Delete session (press twice to confirm)".to_string(),
5957 " Type Filter sessions by name/agent/ID".to_string(),
5958 " Backspace Clear filter character".to_string(),
5959 " Esc Close picker".to_string(),
5960 "".to_string(),
5961 " VIM-STYLE NAVIGATION".to_string(),
5962 " Alt+j Scroll down".to_string(),
5963 " Alt+k Scroll up".to_string(),
5964 " Ctrl+g Go to top".to_string(),
5965 " Ctrl+G Go to bottom".to_string(),
5966 "".to_string(),
5967 " SCROLLING".to_string(),
5968 " Up/Down Scroll messages".to_string(),
5969 " PageUp/Dn Scroll one page".to_string(),
5970 " Alt+u/d Scroll half page".to_string(),
5971 "".to_string(),
5972 " COMMAND HISTORY".to_string(),
5973 " Ctrl+R Search history".to_string(),
5974 " Ctrl+Up/Dn Navigate history".to_string(),
5975 "".to_string(),
5976 " Press ? or Esc to close".to_string(),
5977 "".to_string(),
5978 ];
5979
5980 let mut combined_text = token_info;
5981 combined_text.extend(model_section);
5982 combined_text.extend(help_text);
5983
5984 let help = Paragraph::new(combined_text.join("\n"))
5985 .block(
5986 Block::default()
5987 .borders(Borders::ALL)
5988 .title(" Help ")
5989 .border_style(Style::default().fg(theme.help_border_color.to_color())),
5990 )
5991 .wrap(Wrap { trim: false });
5992
5993 f.render_widget(help, area);
5994}
5995
5996fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
5998 let popup_layout = Layout::default()
5999 .direction(Direction::Vertical)
6000 .constraints([
6001 Constraint::Percentage((100 - percent_y) / 2),
6002 Constraint::Percentage(percent_y),
6003 Constraint::Percentage((100 - percent_y) / 2),
6004 ])
6005 .split(r);
6006
6007 Layout::default()
6008 .direction(Direction::Horizontal)
6009 .constraints([
6010 Constraint::Percentage((100 - percent_x) / 2),
6011 Constraint::Percentage(percent_x),
6012 Constraint::Percentage((100 - percent_x) / 2),
6013 ])
6014 .split(popup_layout[1])[1]
6015}
6016
6017#[cfg(test)]
6018mod tests {
6019 use super::{
6020 command_with_optional_args, match_slash_command_hint, normalize_easy_command,
6021 normalize_for_convergence, parse_autochat_args,
6022 };
6023
6024 #[test]
6025 fn command_with_optional_args_handles_bare_command() {
6026 assert_eq!(command_with_optional_args("/spawn", "/spawn"), Some(""));
6027 }
6028
6029 #[test]
6030 fn command_with_optional_args_handles_arguments() {
6031 assert_eq!(
6032 command_with_optional_args("/spawn planner you plan", "/spawn"),
6033 Some("planner you plan")
6034 );
6035 }
6036
6037 #[test]
6038 fn command_with_optional_args_ignores_prefix_collisions() {
6039 assert_eq!(command_with_optional_args("/spawned", "/spawn"), None);
6040 }
6041
6042 #[test]
6043 fn command_with_optional_args_ignores_autochat_prefix_collisions() {
6044 assert_eq!(command_with_optional_args("/autochatty", "/autochat"), None);
6045 }
6046
6047 #[test]
6048 fn command_with_optional_args_trims_leading_whitespace_in_args() {
6049 assert_eq!(
6050 command_with_optional_args("/kill local-agent-1", "/kill"),
6051 Some("local-agent-1")
6052 );
6053 }
6054
6055 #[test]
6056 fn slash_hint_includes_protocol_command() {
6057 let hint = match_slash_command_hint("/protocol");
6058 assert!(hint.contains("/protocol"));
6059 }
6060
6061 #[test]
6062 fn slash_hint_includes_autochat_command() {
6063 let hint = match_slash_command_hint("/autochat");
6064 assert!(hint.contains("/autochat"));
6065 }
6066
6067 #[test]
6068 fn normalize_easy_command_maps_go_to_autochat() {
6069 assert_eq!(
6070 normalize_easy_command("/go build a calculator"),
6071 "/autochat 3 build a calculator"
6072 );
6073 }
6074
6075 #[test]
6076 fn parse_autochat_args_supports_default_count() {
6077 assert_eq!(
6078 parse_autochat_args("build a calculator"),
6079 Some((3, "build a calculator"))
6080 );
6081 }
6082
6083 #[test]
6084 fn parse_autochat_args_supports_explicit_count() {
6085 assert_eq!(
6086 parse_autochat_args("4 build a calculator"),
6087 Some((4, "build a calculator"))
6088 );
6089 }
6090
6091 #[test]
6092 fn normalize_for_convergence_ignores_case_and_punctuation() {
6093 let a = normalize_for_convergence("Done! Next Step: Add tests.");
6094 let b = normalize_for_convergence("done next step add tests");
6095 assert_eq!(a, b);
6096 }
6097}