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_MAX_DYNAMIC_SPAWNS: usize = 3;
28const AUTOCHAT_SPAWN_CHECK_MIN_CHARS: usize = 800;
29const AUTOCHAT_RLM_THRESHOLD_CHARS: usize = 6_000;
30const AUTOCHAT_QUICK_DEMO_TASK: &str = "Self-organize into the right specialties for this task, then relay one concrete implementation plan with clear next handoffs.";
31const GO_SWAP_MODEL_GLM: &str = "zai/glm-5";
32const GO_SWAP_MODEL_MINIMAX: &str = "minimax/MiniMax-M2.5";
33const CHAT_SYNC_DEFAULT_INTERVAL_SECS: u64 = 15;
34const CHAT_SYNC_MAX_INTERVAL_SECS: u64 = 300;
35const CHAT_SYNC_MAX_BATCH_BYTES: usize = 512 * 1024;
36const CHAT_SYNC_DEFAULT_BUCKET: &str = "codetether-chat-archive";
37const CHAT_SYNC_DEFAULT_PREFIX: &str = "sessions";
38const AGENT_AVATARS: [&str; 12] = [
39 "[o_o]", "[^_^]", "[>_<]", "[._.]", "[+_+]", "[~_~]", "[x_x]", "[0_0]", "[*_*]", "[=_=]",
40 "[T_T]", "[u_u]",
41];
42
43use crate::bus::relay::{ProtocolRelayRuntime, RelayAgentProfile};
44use crate::config::Config;
45use crate::provider::{ContentPart, Role};
46use crate::ralph::{RalphConfig, RalphLoop};
47use crate::rlm::RlmExecutor;
48use crate::session::{Session, SessionEvent, SessionSummary, list_sessions_with_opencode};
49use crate::swarm::{DecompositionStrategy, SwarmConfig, SwarmExecutor};
50use crate::tui::bus_log::{BusLogState, render_bus_log};
51use crate::tui::message_formatter::MessageFormatter;
52use crate::tui::ralph_view::{RalphEvent, RalphViewState, render_ralph_view};
53use crate::tui::swarm_view::{SwarmEvent, SwarmViewState, render_swarm_view};
54use crate::tui::theme::Theme;
55use crate::tui::token_display::TokenDisplay;
56use anyhow::Result;
57use base64::Engine;
58use crossterm::{
59 event::{
60 DisableBracketedPaste, EnableBracketedPaste, Event, EventStream, KeyCode, KeyEventKind,
61 KeyModifiers,
62 },
63 execute,
64 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
65};
66use futures::StreamExt;
67use minio::s3::builders::ObjectContent;
68use minio::s3::creds::StaticProvider;
69use minio::s3::http::BaseUrl;
70use minio::s3::types::S3Api;
71use minio::s3::{Client as MinioClient, ClientBuilder as MinioClientBuilder};
72use ratatui::{
73 Frame, Terminal,
74 backend::CrosstermBackend,
75 layout::{Constraint, Direction, Layout, Rect},
76 style::{Color, Modifier, Style},
77 text::{Line, Span},
78 widgets::{
79 Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
80 },
81};
82use serde::{Deserialize, Serialize, de::DeserializeOwned};
83use std::collections::HashMap;
84use std::io::{self, Read, Seek, SeekFrom, Write};
85use std::path::{Path, PathBuf};
86use std::process::Command;
87use std::time::{Duration, Instant};
88use tokio::sync::mpsc;
89
90pub async fn run(project: Option<PathBuf>) -> Result<()> {
92 if let Some(dir) = project {
94 std::env::set_current_dir(&dir)?;
95 }
96
97 enable_raw_mode()?;
99 let mut stdout = io::stdout();
100 execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
101 let backend = CrosstermBackend::new(stdout);
102 let mut terminal = Terminal::new(backend)?;
103
104 let result = run_app(&mut terminal).await;
106
107 disable_raw_mode()?;
109 execute!(
110 terminal.backend_mut(),
111 LeaveAlternateScreen,
112 DisableBracketedPaste
113 )?;
114 terminal.show_cursor()?;
115
116 result
117}
118
119#[derive(Debug, Clone)]
121enum MessageType {
122 Text(String),
123 Image {
124 url: String,
125 mime_type: Option<String>,
126 },
127 ToolCall {
128 name: String,
129 arguments_preview: String,
130 arguments_len: usize,
131 truncated: bool,
132 },
133 ToolResult {
134 name: String,
135 output_preview: String,
136 output_len: usize,
137 truncated: bool,
138 success: bool,
139 duration_ms: Option<u64>,
140 },
141 File {
142 path: String,
143 mime_type: Option<String>,
144 },
145 Thinking(String),
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150enum ViewMode {
151 Chat,
152 Swarm,
153 Ralph,
154 BusLog,
155 Protocol,
156 SessionPicker,
157 ModelPicker,
158 AgentPicker,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162enum ChatLayoutMode {
163 Classic,
164 Webview,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168enum WorkspaceEntryKind {
169 Directory,
170 File,
171}
172
173#[derive(Debug, Clone)]
174struct WorkspaceEntry {
175 name: String,
176 kind: WorkspaceEntryKind,
177}
178
179#[derive(Debug, Clone, Default)]
180struct WorkspaceSnapshot {
181 root_display: String,
182 git_branch: Option<String>,
183 git_dirty_files: usize,
184 entries: Vec<WorkspaceEntry>,
185 captured_at: String,
186}
187
188struct App {
190 input: String,
191 cursor_position: usize,
192 messages: Vec<ChatMessage>,
193 current_agent: String,
194 scroll: usize,
195 show_help: bool,
196 command_history: Vec<String>,
197 history_index: Option<usize>,
198 session: Option<Session>,
199 is_processing: bool,
200 processing_message: Option<String>,
201 current_tool: Option<String>,
202 current_tool_started_at: Option<Instant>,
203 processing_started_at: Option<Instant>,
205 streaming_text: Option<String>,
207 streaming_agent_texts: HashMap<String, String>,
209 tool_call_count: usize,
211 response_rx: Option<mpsc::Receiver<SessionEvent>>,
212 provider_registry: Option<std::sync::Arc<crate::provider::ProviderRegistry>>,
214 workspace_dir: PathBuf,
216 view_mode: ViewMode,
218 chat_layout: ChatLayoutMode,
219 show_inspector: bool,
220 workspace: WorkspaceSnapshot,
221 swarm_state: SwarmViewState,
222 swarm_rx: Option<mpsc::Receiver<SwarmEvent>>,
223 ralph_state: RalphViewState,
225 ralph_rx: Option<mpsc::Receiver<RalphEvent>>,
226 bus_log_state: BusLogState,
228 bus_log_rx: Option<mpsc::Receiver<crate::bus::BusEnvelope>>,
229 bus: Option<std::sync::Arc<crate::bus::AgentBus>>,
230 session_picker_list: Vec<SessionSummary>,
232 session_picker_selected: usize,
233 session_picker_filter: String,
234 session_picker_confirm_delete: bool,
235 model_picker_list: Vec<(String, String, String)>, model_picker_selected: usize,
238 model_picker_filter: String,
239 agent_picker_selected: usize,
241 agent_picker_filter: String,
242 protocol_selected: usize,
244 protocol_scroll: usize,
245 active_model: Option<String>,
246 active_spawned_agent: Option<String>,
248 spawned_agents: HashMap<String, SpawnedAgent>,
249 agent_response_rxs: Vec<(String, mpsc::Receiver<SessionEvent>)>,
250 agent_tool_started_at: HashMap<String, Instant>,
251 autochat_rx: Option<mpsc::Receiver<AutochatUiEvent>>,
252 autochat_running: bool,
253 autochat_started_at: Option<Instant>,
254 autochat_status: Option<String>,
255 chat_archive_path: Option<PathBuf>,
256 archived_message_count: usize,
257 chat_sync_rx: Option<mpsc::Receiver<ChatSyncUiEvent>>,
258 chat_sync_status: Option<String>,
259 chat_sync_last_success: Option<String>,
260 chat_sync_last_error: Option<String>,
261 chat_sync_uploaded_bytes: u64,
262 chat_sync_uploaded_batches: usize,
263 secure_environment: bool,
264 last_max_scroll: usize,
266}
267
268#[allow(dead_code)]
269struct ChatMessage {
270 role: String,
271 content: String,
272 timestamp: String,
273 message_type: MessageType,
274 usage_meta: Option<UsageMeta>,
276 agent_name: Option<String>,
278}
279
280#[allow(dead_code)]
282struct SpawnedAgent {
283 name: String,
285 instructions: String,
287 session: Session,
289 is_processing: bool,
291}
292
293#[derive(Debug, Clone, Copy)]
294struct AgentProfile {
295 codename: &'static str,
296 profile: &'static str,
297 personality: &'static str,
298 collaboration_style: &'static str,
299 signature_move: &'static str,
300}
301
302#[derive(Debug, Clone)]
304struct UsageMeta {
305 prompt_tokens: usize,
306 completion_tokens: usize,
307 duration_ms: u64,
308 cost_usd: Option<f64>,
309}
310
311enum AutochatUiEvent {
312 Progress(String),
313 SystemMessage(String),
314 AgentEvent {
315 agent_name: String,
316 event: SessionEvent,
317 },
318 Completed {
319 summary: String,
320 },
321}
322
323#[derive(Debug, Clone)]
324struct ChatSyncConfig {
325 endpoint: String,
326 fallback_endpoint: Option<String>,
327 access_key: String,
328 secret_key: String,
329 bucket: String,
330 prefix: String,
331 interval_secs: u64,
332 ignore_cert_check: bool,
333}
334
335enum ChatSyncUiEvent {
336 Status(String),
337 BatchUploaded {
338 bytes: u64,
339 records: usize,
340 object_key: String,
341 },
342 Error(String),
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
350struct RelayCheckpoint {
351 task: String,
353 model_ref: String,
355 ordered_agents: Vec<String>,
357 agent_session_ids: HashMap<String, String>,
359 agent_profiles: Vec<(String, String, Vec<String>)>,
361 round: usize,
363 idx: usize,
365 baton: String,
367 turns: usize,
369 convergence_hits: usize,
371 dynamic_spawn_count: usize,
373 rlm_handoff_count: usize,
375 workspace_dir: PathBuf,
377 started_at: String,
379}
380
381impl RelayCheckpoint {
382 fn checkpoint_path() -> Option<PathBuf> {
383 crate::config::Config::data_dir().map(|d| d.join("relay_checkpoint.json"))
384 }
385
386 async fn save(&self) -> Result<()> {
387 if let Some(path) = Self::checkpoint_path() {
388 if let Some(parent) = path.parent() {
389 tokio::fs::create_dir_all(parent).await?;
390 }
391 let content = serde_json::to_string_pretty(self)?;
392 tokio::fs::write(&path, content).await?;
393 tracing::debug!("Relay checkpoint saved");
394 }
395 Ok(())
396 }
397
398 async fn load() -> Option<Self> {
399 let path = Self::checkpoint_path()?;
400 let content = tokio::fs::read_to_string(&path).await.ok()?;
401 serde_json::from_str(&content).ok()
402 }
403
404 async fn delete() {
405 if let Some(path) = Self::checkpoint_path() {
406 let _ = tokio::fs::remove_file(&path).await;
407 tracing::debug!("Relay checkpoint deleted");
408 }
409 }
410}
411
412fn estimate_cost(model: &str, prompt_tokens: usize, completion_tokens: usize) -> Option<f64> {
415 let normalized_model = model.to_ascii_lowercase();
416
417 let (input_rate, output_rate) = match normalized_model.as_str() {
419 m if m.contains("claude-opus") => (15.0, 75.0),
421 m if m.contains("claude-sonnet") => (3.0, 15.0),
422 m if m.contains("claude-haiku") => (0.25, 1.25),
423 m if m.contains("gpt-4o-mini") => (0.15, 0.6),
425 m if m.contains("gpt-4o") => (2.5, 10.0),
426 m if m.contains("o3") => (10.0, 40.0),
427 m if m.contains("o4-mini") => (1.10, 4.40),
428 m if m.contains("gemini-2.5-pro") => (1.25, 10.0),
430 m if m.contains("gemini-2.5-flash") => (0.15, 0.6),
431 m if m.contains("gemini-2.0-flash") => (0.10, 0.40),
432 m if m.contains("kimi-k2") => (0.35, 1.40),
434 m if m.contains("deepseek") => (0.80, 2.0),
435 m if m.contains("llama") => (0.50, 1.50),
436 m if m.contains("minimax") && m.contains("m2.5-lightning") => (0.30, 2.40),
441 m if m.contains("minimax") && m.contains("m2.5") => (0.15, 1.20),
442 m if m.contains("nova-pro") => (0.80, 3.20),
444 m if m.contains("nova-lite") => (0.06, 0.24),
445 m if m.contains("nova-micro") => (0.035, 0.14),
446 m if m.contains("glm-5") => (2.0, 8.0),
448 m if m.contains("glm-4.7-flash") => (0.0, 0.0),
449 m if m.contains("glm-4.7") => (0.50, 2.0),
450 m if m.contains("glm-4") => (0.35, 1.40),
451 _ => return None,
452 };
453 let cost =
454 (prompt_tokens as f64 * input_rate + completion_tokens as f64 * output_rate) / 1_000_000.0;
455 Some(cost)
456}
457
458fn current_spinner_frame() -> &'static str {
459 const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
460 let idx = (std::time::SystemTime::now()
461 .duration_since(std::time::UNIX_EPOCH)
462 .unwrap_or_default()
463 .as_millis()
464 / 100) as usize
465 % SPINNER.len();
466 SPINNER[idx]
467}
468
469fn format_duration_ms(duration_ms: u64) -> String {
470 if duration_ms >= 60_000 {
471 format!(
472 "{}m{:02}s",
473 duration_ms / 60_000,
474 (duration_ms % 60_000) / 1000
475 )
476 } else if duration_ms >= 1000 {
477 format!("{:.1}s", duration_ms as f64 / 1000.0)
478 } else {
479 format!("{duration_ms}ms")
480 }
481}
482
483fn format_bytes(bytes: u64) -> String {
484 const KB: f64 = 1024.0;
485 const MB: f64 = KB * 1024.0;
486 const GB: f64 = MB * 1024.0;
487
488 if bytes < 1024 {
489 format!("{bytes}B")
490 } else if (bytes as f64) < MB {
491 format!("{:.1}KB", bytes as f64 / KB)
492 } else if (bytes as f64) < GB {
493 format!("{:.1}MB", bytes as f64 / MB)
494 } else {
495 format!("{:.2}GB", bytes as f64 / GB)
496 }
497}
498
499fn env_non_empty(name: &str) -> Option<String> {
500 std::env::var(name)
501 .ok()
502 .map(|value| value.trim().to_string())
503 .filter(|value| !value.is_empty())
504}
505
506fn env_bool(name: &str) -> Option<bool> {
507 let value = env_non_empty(name)?;
508 let value = value.to_ascii_lowercase();
509 match value.as_str() {
510 "1" | "true" | "yes" | "on" => Some(true),
511 "0" | "false" | "no" | "off" => Some(false),
512 _ => None,
513 }
514}
515
516fn parse_bool_str(value: &str) -> Option<bool> {
517 match value.trim().to_ascii_lowercase().as_str() {
518 "1" | "true" | "yes" | "on" => Some(true),
519 "0" | "false" | "no" | "off" => Some(false),
520 _ => None,
521 }
522}
523
524fn is_placeholder_secret(value: &str) -> bool {
525 matches!(
526 value.trim().to_ascii_lowercase().as_str(),
527 "replace-me" | "changeme" | "change-me" | "your-token" | "your-key"
528 )
529}
530
531fn env_non_placeholder(name: &str) -> Option<String> {
532 env_non_empty(name).filter(|value| !is_placeholder_secret(value))
533}
534
535fn vault_extra_string(secrets: &crate::secrets::ProviderSecrets, keys: &[&str]) -> Option<String> {
536 keys.iter().find_map(|key| {
537 secrets
538 .extra
539 .get(*key)
540 .and_then(|value| value.as_str())
541 .map(|value| value.trim().to_string())
542 .filter(|value| !value.is_empty())
543 })
544}
545
546fn vault_extra_bool(secrets: &crate::secrets::ProviderSecrets, keys: &[&str]) -> Option<bool> {
547 keys.iter().find_map(|key| {
548 secrets.extra.get(*key).and_then(|value| match value {
549 serde_json::Value::Bool(flag) => Some(*flag),
550 serde_json::Value::String(text) => parse_bool_str(text),
551 _ => None,
552 })
553 })
554}
555
556fn is_secure_environment_from_values(
557 secure_environment: Option<bool>,
558 secure_env: Option<bool>,
559 environment_name: Option<&str>,
560) -> bool {
561 if let Some(value) = secure_environment {
562 return value;
563 }
564
565 if let Some(value) = secure_env {
566 return value;
567 }
568
569 environment_name.is_some_and(|value| {
570 matches!(
571 value.trim().to_ascii_lowercase().as_str(),
572 "secure" | "production" | "prod"
573 )
574 })
575}
576
577fn is_secure_environment() -> bool {
578 is_secure_environment_from_values(
579 env_bool("CODETETHER_SECURE_ENVIRONMENT"),
580 env_bool("CODETETHER_SECURE_ENV"),
581 env_non_empty("CODETETHER_ENV").as_deref(),
582 )
583}
584
585fn normalize_minio_endpoint(endpoint: &str) -> String {
586 let mut normalized = endpoint.trim().trim_end_matches('/').to_string();
587 if let Some(stripped) = normalized.strip_suffix("/login") {
588 normalized = stripped.trim_end_matches('/').to_string();
589 }
590 if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
591 normalized = format!("http://{normalized}");
592 }
593 normalized
594}
595
596fn minio_fallback_endpoint(endpoint: &str) -> Option<String> {
597 if endpoint.contains(":9001") {
598 Some(endpoint.replacen(":9001", ":9000", 1))
599 } else {
600 None
601 }
602}
603
604fn sanitize_s3_key_segment(value: &str) -> String {
605 let mut out = String::with_capacity(value.len());
606 for ch in value.chars() {
607 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
608 out.push(ch);
609 } else {
610 out.push('-');
611 }
612 }
613 let cleaned = out.trim_matches('-').to_string();
614 if cleaned.is_empty() {
615 "unknown".to_string()
616 } else {
617 cleaned
618 }
619}
620
621async fn parse_chat_sync_config(
622 require_chat_sync: bool,
623) -> std::result::Result<Option<ChatSyncConfig>, String> {
624 let enabled = env_bool("CODETETHER_CHAT_SYNC_ENABLED").unwrap_or(false);
625 if !enabled {
626 tracing::info!(
627 secure_required = require_chat_sync,
628 "Remote chat sync disabled (CODETETHER_CHAT_SYNC_ENABLED is false or unset)"
629 );
630 if require_chat_sync {
631 return Err(
632 "CODETETHER_CHAT_SYNC_ENABLED must be true in secure environment".to_string(),
633 );
634 }
635 return Ok(None);
636 }
637
638 let vault_secrets = crate::secrets::get_provider_secrets("chat-sync-minio").await;
639
640 let endpoint_raw = env_non_empty("CODETETHER_CHAT_SYNC_MINIO_ENDPOINT")
641 .or_else(|| {
642 vault_secrets.as_ref().and_then(|secrets| {
643 secrets
644 .base_url
645 .clone()
646 .filter(|value| !value.trim().is_empty())
647 .or_else(|| vault_extra_string(secrets, &["endpoint", "minio_endpoint"]))
648 })
649 })
650 .ok_or_else(|| {
651 "CODETETHER_CHAT_SYNC_MINIO_ENDPOINT is required when chat sync is enabled (env or Vault: codetether/providers/chat-sync-minio.base_url)".to_string()
652 })?;
653 let endpoint = normalize_minio_endpoint(&endpoint_raw);
654 let fallback_endpoint = minio_fallback_endpoint(&endpoint);
655
656 let access_key = env_non_placeholder("CODETETHER_CHAT_SYNC_MINIO_ACCESS_KEY")
657 .or_else(|| {
658 vault_secrets.as_ref().and_then(|secrets| {
659 vault_extra_string(
660 secrets,
661 &[
662 "access_key",
663 "minio_access_key",
664 "username",
665 "key",
666 "api_key",
667 ],
668 )
669 .or_else(|| {
670 secrets
671 .api_key
672 .as_ref()
673 .map(|value| value.trim().to_string())
674 .filter(|value| !value.is_empty())
675 })
676 })
677 })
678 .ok_or_else(|| {
679 "CODETETHER_CHAT_SYNC_MINIO_ACCESS_KEY is required when chat sync is enabled (env or Vault: codetether/providers/chat-sync-minio.access_key/api_key)".to_string()
680 })?;
681 let secret_key = env_non_placeholder("CODETETHER_CHAT_SYNC_MINIO_SECRET_KEY")
682 .or_else(|| {
683 vault_secrets.as_ref().and_then(|secrets| {
684 vault_extra_string(
685 secrets,
686 &[
687 "secret_key",
688 "minio_secret_key",
689 "password",
690 "secret",
691 "api_secret",
692 ],
693 )
694 })
695 })
696 .ok_or_else(|| {
697 "CODETETHER_CHAT_SYNC_MINIO_SECRET_KEY is required when chat sync is enabled (env or Vault: codetether/providers/chat-sync-minio.secret_key)".to_string()
698 })?;
699
700 let bucket = env_non_empty("CODETETHER_CHAT_SYNC_MINIO_BUCKET")
701 .or_else(|| {
702 vault_secrets
703 .as_ref()
704 .and_then(|secrets| vault_extra_string(secrets, &["bucket", "minio_bucket"]))
705 })
706 .unwrap_or_else(|| CHAT_SYNC_DEFAULT_BUCKET.to_string());
707 let prefix = env_non_empty("CODETETHER_CHAT_SYNC_MINIO_PREFIX")
708 .or_else(|| {
709 vault_secrets
710 .as_ref()
711 .and_then(|secrets| vault_extra_string(secrets, &["prefix", "minio_prefix"]))
712 })
713 .unwrap_or_else(|| CHAT_SYNC_DEFAULT_PREFIX.to_string())
714 .trim_matches('/')
715 .to_string();
716
717 let interval_secs = env_non_empty("CODETETHER_CHAT_SYNC_INTERVAL_SECS")
718 .and_then(|value| value.parse::<u64>().ok())
719 .unwrap_or(CHAT_SYNC_DEFAULT_INTERVAL_SECS)
720 .clamp(1, CHAT_SYNC_MAX_INTERVAL_SECS);
721
722 let ignore_cert_check = env_bool("CODETETHER_CHAT_SYNC_MINIO_INSECURE_SKIP_TLS_VERIFY")
723 .or_else(|| {
724 vault_secrets.as_ref().and_then(|secrets| {
725 vault_extra_bool(
726 secrets,
727 &[
728 "insecure_skip_tls_verify",
729 "ignore_cert_check",
730 "skip_tls_verify",
731 ],
732 )
733 })
734 })
735 .unwrap_or(false);
736
737 Ok(Some(ChatSyncConfig {
738 endpoint,
739 fallback_endpoint,
740 access_key,
741 secret_key,
742 bucket,
743 prefix,
744 interval_secs,
745 ignore_cert_check,
746 }))
747}
748
749fn chat_sync_checkpoint_path(archive_path: &Path, config: &ChatSyncConfig) -> PathBuf {
750 let endpoint_tag = sanitize_s3_key_segment(&config.endpoint.replace("://", "-"));
751 let bucket_tag = sanitize_s3_key_segment(&config.bucket);
752 archive_path.with_file_name(format!(
753 "chat_events.minio-sync.{endpoint_tag}.{bucket_tag}.offset"
754 ))
755}
756
757fn load_chat_sync_offset(checkpoint_path: &Path) -> u64 {
758 std::fs::read_to_string(checkpoint_path)
759 .ok()
760 .and_then(|value| value.trim().parse::<u64>().ok())
761 .unwrap_or(0)
762}
763
764fn store_chat_sync_offset(checkpoint_path: &Path, offset: u64) {
765 if let Some(parent) = checkpoint_path.parent()
766 && let Err(err) = std::fs::create_dir_all(parent)
767 {
768 tracing::warn!(error = %err, path = %parent.display(), "Failed to create chat sync checkpoint directory");
769 return;
770 }
771
772 if let Err(err) = std::fs::write(checkpoint_path, offset.to_string()) {
773 tracing::warn!(error = %err, path = %checkpoint_path.display(), "Failed to persist chat sync checkpoint");
774 }
775}
776
777fn build_minio_client(endpoint: &str, config: &ChatSyncConfig) -> Result<MinioClient> {
778 let base_url: BaseUrl = endpoint.parse()?;
779 let provider = StaticProvider::new(&config.access_key, &config.secret_key, None);
780 let client = MinioClientBuilder::new(base_url)
781 .provider(Some(Box::new(provider)))
782 .ignore_cert_check(Some(config.ignore_cert_check))
783 .build()?;
784 Ok(client)
785}
786
787async fn ensure_minio_bucket(client: &MinioClient, bucket: &str) -> Result<()> {
788 let exists = client.bucket_exists(bucket).send().await?;
789 if !exists.exists {
790 match client.create_bucket(bucket).send().await {
791 Ok(_) => {}
792 Err(err) => {
793 let error_text = err.to_string();
794 if !error_text.contains("BucketAlreadyOwnedByYou")
795 && !error_text.contains("BucketAlreadyExists")
796 {
797 return Err(anyhow::anyhow!(error_text));
798 }
799 }
800 }
801 }
802 Ok(())
803}
804
805fn read_chat_archive_batch(archive_path: &Path, offset: u64) -> Result<(Vec<u8>, u64, usize)> {
806 let metadata = std::fs::metadata(archive_path)?;
807 let file_len = metadata.len();
808 if offset >= file_len {
809 return Ok((Vec::new(), offset, 0));
810 }
811
812 let mut file = std::fs::File::open(archive_path)?;
813 file.seek(SeekFrom::Start(offset))?;
814
815 let target_bytes = (file_len - offset).min(CHAT_SYNC_MAX_BATCH_BYTES as u64) as usize;
816 let mut buffer = vec![0_u8; target_bytes];
817 let read = file.read(&mut buffer)?;
818 buffer.truncate(read);
819
820 if read == 0 {
821 return Ok((Vec::new(), offset, 0));
822 }
823
824 if offset + (read as u64) < file_len {
826 if let Some(last_newline) = buffer.iter().rposition(|byte| *byte == b'\n') {
827 buffer.truncate(last_newline + 1);
828 }
829
830 if buffer.is_empty() {
831 let mut rolling = Vec::new();
832 let mut temp = [0_u8; 4096];
833 loop {
834 let n = file.read(&mut temp)?;
835 if n == 0 {
836 break;
837 }
838 rolling.extend_from_slice(&temp[..n]);
839 if let Some(pos) = rolling.iter().position(|byte| *byte == b'\n') {
840 rolling.truncate(pos + 1);
841 break;
842 }
843 if rolling.len() >= CHAT_SYNC_MAX_BATCH_BYTES {
844 break;
845 }
846 }
847 buffer = rolling;
848 }
849 }
850
851 let next_offset = offset + buffer.len() as u64;
852 let records = buffer.iter().filter(|byte| **byte == b'\n').count();
853 Ok((buffer, next_offset, records))
854}
855
856#[derive(Debug)]
857struct ChatSyncBatch {
858 bytes: u64,
859 records: usize,
860 object_key: String,
861 next_offset: u64,
862}
863
864async fn sync_chat_archive_batch(
865 client: &MinioClient,
866 archive_path: &Path,
867 config: &ChatSyncConfig,
868 host_tag: &str,
869 offset: u64,
870) -> Result<Option<ChatSyncBatch>> {
871 if !archive_path.exists() {
872 return Ok(None);
873 }
874
875 ensure_minio_bucket(client, &config.bucket).await?;
876
877 let (payload, next_offset, records) = read_chat_archive_batch(archive_path, offset)?;
878 if payload.is_empty() {
879 return Ok(None);
880 }
881
882 let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
883 let key_prefix = config.prefix.trim_matches('/');
884 let object_key = if key_prefix.is_empty() {
885 format!("{host_tag}/{timestamp}-chat-events-{offset:020}-{next_offset:020}.jsonl")
886 } else {
887 format!(
888 "{key_prefix}/{host_tag}/{timestamp}-chat-events-{offset:020}-{next_offset:020}.jsonl"
889 )
890 };
891
892 let bytes = payload.len() as u64;
893 let content = ObjectContent::from(payload);
894 client
895 .put_object_content(&config.bucket, &object_key, content)
896 .send()
897 .await?;
898
899 Ok(Some(ChatSyncBatch {
900 bytes,
901 records,
902 object_key,
903 next_offset,
904 }))
905}
906
907async fn run_chat_sync_worker(
908 tx: mpsc::Sender<ChatSyncUiEvent>,
909 archive_path: PathBuf,
910 config: ChatSyncConfig,
911) {
912 let checkpoint_path = chat_sync_checkpoint_path(&archive_path, &config);
913 let mut offset = load_chat_sync_offset(&checkpoint_path);
914 let host_tag = sanitize_s3_key_segment(
915 &std::env::var("HOSTNAME").unwrap_or_else(|_| "localhost".to_string()),
916 );
917
918 let fallback_label = config
919 .fallback_endpoint
920 .as_ref()
921 .map(|fallback| format!(" (fallback: {fallback})"))
922 .unwrap_or_default();
923
924 let _ = tx
925 .send(ChatSyncUiEvent::Status(format!(
926 "Archive sync enabled → {} / {} every {}s{}",
927 config.endpoint, config.bucket, config.interval_secs, fallback_label
928 )))
929 .await;
930
931 tracing::info!(
932 endpoint = %config.endpoint,
933 bucket = %config.bucket,
934 prefix = %config.prefix,
935 interval_secs = config.interval_secs,
936 checkpoint = %checkpoint_path.display(),
937 archive = %archive_path.display(),
938 "Chat sync worker started"
939 );
940
941 let mut interval = tokio::time::interval(Duration::from_secs(config.interval_secs));
942 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
943
944 loop {
945 interval.tick().await;
946
947 let primary_client = match build_minio_client(&config.endpoint, &config) {
948 Ok(client) => client,
949 Err(err) => {
950 let _ = tx
951 .send(ChatSyncUiEvent::Error(format!(
952 "Chat sync client init failed for {}: {err}",
953 config.endpoint
954 )))
955 .await;
956 continue;
957 }
958 };
959
960 let outcome = match sync_chat_archive_batch(
961 &primary_client,
962 &archive_path,
963 &config,
964 &host_tag,
965 offset,
966 )
967 .await
968 {
969 Ok(result) => Ok(result),
970 Err(primary_err) => {
971 if let Some(fallback_endpoint) = &config.fallback_endpoint {
972 let fallback_client = build_minio_client(fallback_endpoint, &config);
973 match fallback_client {
974 Ok(client) => {
975 let _ = tx
976 .send(ChatSyncUiEvent::Status(format!(
977 "Primary endpoint failed; retrying with fallback {}",
978 fallback_endpoint
979 )))
980 .await;
981 sync_chat_archive_batch(
982 &client,
983 &archive_path,
984 &config,
985 &host_tag,
986 offset,
987 )
988 .await
989 }
990 Err(err) => Err(anyhow::anyhow!(
991 "Primary sync error: {primary_err}; fallback init failed: {err}"
992 )),
993 }
994 } else {
995 Err(primary_err)
996 }
997 }
998 };
999
1000 match outcome {
1001 Ok(Some(batch)) => {
1002 offset = batch.next_offset;
1003 store_chat_sync_offset(&checkpoint_path, offset);
1004 tracing::info!(
1005 bytes = batch.bytes,
1006 records = batch.records,
1007 object_key = %batch.object_key,
1008 next_offset = batch.next_offset,
1009 "Chat sync uploaded batch"
1010 );
1011 let _ = tx
1012 .send(ChatSyncUiEvent::BatchUploaded {
1013 bytes: batch.bytes,
1014 records: batch.records,
1015 object_key: batch.object_key,
1016 })
1017 .await;
1018 }
1019 Ok(None) => {}
1020 Err(err) => {
1021 tracing::warn!(error = %err, "Chat sync batch upload failed");
1022 let _ = tx
1023 .send(ChatSyncUiEvent::Error(format!("Chat sync failed: {err}")))
1024 .await;
1025 }
1026 }
1027 }
1028}
1029
1030#[derive(Debug, Serialize)]
1031struct ChatArchiveRecord {
1032 recorded_at: String,
1033 workspace: String,
1034 session_id: Option<String>,
1035 role: String,
1036 agent_name: Option<String>,
1037 message_type: String,
1038 content: String,
1039 tool_name: Option<String>,
1040 tool_success: Option<bool>,
1041 tool_duration_ms: Option<u64>,
1042}
1043
1044impl ChatMessage {
1045 fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
1046 let content = content.into();
1047 Self {
1048 role: role.into(),
1049 timestamp: chrono::Local::now().format("%H:%M").to_string(),
1050 message_type: MessageType::Text(content.clone()),
1051 content,
1052 usage_meta: None,
1053 agent_name: None,
1054 }
1055 }
1056
1057 fn with_message_type(mut self, message_type: MessageType) -> Self {
1058 self.message_type = message_type;
1059 self
1060 }
1061
1062 fn with_usage_meta(mut self, meta: UsageMeta) -> Self {
1063 self.usage_meta = Some(meta);
1064 self
1065 }
1066
1067 fn with_agent_name(mut self, name: impl Into<String>) -> Self {
1068 self.agent_name = Some(name.into());
1069 self
1070 }
1071}
1072
1073impl WorkspaceSnapshot {
1074 fn capture(root: &Path, max_entries: usize) -> Self {
1075 let mut entries: Vec<WorkspaceEntry> = Vec::new();
1076
1077 if let Ok(read_dir) = std::fs::read_dir(root) {
1078 for entry in read_dir.flatten() {
1079 let file_name = entry.file_name().to_string_lossy().to_string();
1080 if should_skip_workspace_entry(&file_name) {
1081 continue;
1082 }
1083
1084 let kind = match entry.file_type() {
1085 Ok(ft) if ft.is_dir() => WorkspaceEntryKind::Directory,
1086 _ => WorkspaceEntryKind::File,
1087 };
1088
1089 entries.push(WorkspaceEntry {
1090 name: file_name,
1091 kind,
1092 });
1093 }
1094 }
1095
1096 entries.sort_by(|a, b| match (a.kind, b.kind) {
1097 (WorkspaceEntryKind::Directory, WorkspaceEntryKind::File) => std::cmp::Ordering::Less,
1098 (WorkspaceEntryKind::File, WorkspaceEntryKind::Directory) => {
1099 std::cmp::Ordering::Greater
1100 }
1101 _ => a
1102 .name
1103 .to_ascii_lowercase()
1104 .cmp(&b.name.to_ascii_lowercase()),
1105 });
1106 entries.truncate(max_entries);
1107
1108 Self {
1109 root_display: root.to_string_lossy().to_string(),
1110 git_branch: detect_git_branch(root),
1111 git_dirty_files: detect_git_dirty_files(root),
1112 entries,
1113 captured_at: chrono::Local::now().format("%H:%M:%S").to_string(),
1114 }
1115 }
1116}
1117
1118fn should_skip_workspace_entry(name: &str) -> bool {
1119 matches!(
1120 name,
1121 ".git" | "node_modules" | "target" | ".next" | "__pycache__" | ".venv"
1122 )
1123}
1124
1125fn detect_git_branch(root: &Path) -> Option<String> {
1126 let output = Command::new("git")
1127 .arg("-C")
1128 .arg(root)
1129 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1130 .output()
1131 .ok()?;
1132
1133 if !output.status.success() {
1134 return None;
1135 }
1136
1137 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
1138 if branch.is_empty() {
1139 None
1140 } else {
1141 Some(branch)
1142 }
1143}
1144
1145fn detect_git_dirty_files(root: &Path) -> usize {
1146 let output = match Command::new("git")
1147 .arg("-C")
1148 .arg(root)
1149 .args(["status", "--porcelain"])
1150 .output()
1151 {
1152 Ok(out) => out,
1153 Err(_) => return 0,
1154 };
1155
1156 if !output.status.success() {
1157 return 0;
1158 }
1159
1160 String::from_utf8_lossy(&output.stdout)
1161 .lines()
1162 .filter(|line| !line.trim().is_empty())
1163 .count()
1164}
1165
1166fn resolve_provider_for_model_autochat(
1167 registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
1168 model_ref: &str,
1169) -> Option<(std::sync::Arc<dyn crate::provider::Provider>, String)> {
1170 let (provider_name, model_name) = crate::provider::parse_model_string(model_ref);
1171 if let Some(provider_name) = provider_name
1172 && let Some(provider) = registry.get(provider_name)
1173 {
1174 return Some((provider, model_name.to_string()));
1175 }
1176
1177 let fallbacks = [
1178 "zai",
1179 "openai",
1180 "github-copilot",
1181 "anthropic",
1182 "openrouter",
1183 "novita",
1184 "moonshotai",
1185 "google",
1186 ];
1187
1188 for provider_name in fallbacks {
1189 if let Some(provider) = registry.get(provider_name) {
1190 return Some((provider, model_ref.to_string()));
1191 }
1192 }
1193
1194 registry
1195 .list()
1196 .first()
1197 .copied()
1198 .and_then(|name| registry.get(name))
1199 .map(|provider| (provider, model_ref.to_string()))
1200}
1201
1202#[derive(Debug, Clone, Deserialize)]
1203struct PlannedRelayProfile {
1204 #[serde(default)]
1205 name: String,
1206 #[serde(default)]
1207 specialty: String,
1208 #[serde(default)]
1209 mission: String,
1210 #[serde(default)]
1211 capabilities: Vec<String>,
1212}
1213
1214#[derive(Debug, Clone, Deserialize)]
1215struct PlannedRelayResponse {
1216 #[serde(default)]
1217 profiles: Vec<PlannedRelayProfile>,
1218}
1219
1220#[derive(Debug, Clone, Deserialize)]
1221struct RelaySpawnDecision {
1222 #[serde(default)]
1223 spawn: bool,
1224 #[serde(default)]
1225 reason: String,
1226 #[serde(default)]
1227 profile: Option<PlannedRelayProfile>,
1228}
1229
1230fn slugify_label(value: &str) -> String {
1231 let mut out = String::with_capacity(value.len());
1232 let mut last_dash = false;
1233
1234 for ch in value.chars() {
1235 let ch = ch.to_ascii_lowercase();
1236 if ch.is_ascii_alphanumeric() {
1237 out.push(ch);
1238 last_dash = false;
1239 } else if !last_dash {
1240 out.push('-');
1241 last_dash = true;
1242 }
1243 }
1244
1245 out.trim_matches('-').to_string()
1246}
1247
1248fn sanitize_relay_agent_name(value: &str) -> String {
1249 let raw = slugify_label(value);
1250 let base = if raw.is_empty() {
1251 "auto-specialist".to_string()
1252 } else if raw.starts_with("auto-") {
1253 raw
1254 } else {
1255 format!("auto-{raw}")
1256 };
1257
1258 truncate_with_ellipsis(&base, 48)
1259 .trim_end_matches("...")
1260 .to_string()
1261}
1262
1263fn unique_relay_agent_name(base: &str, existing: &[String]) -> String {
1264 if !existing.iter().any(|name| name == base) {
1265 return base.to_string();
1266 }
1267
1268 let mut suffix = 2usize;
1269 loop {
1270 let candidate = format!("{base}-{suffix}");
1271 if !existing.iter().any(|name| name == &candidate) {
1272 return candidate;
1273 }
1274 suffix += 1;
1275 }
1276}
1277
1278fn relay_instruction_from_plan(name: &str, specialty: &str, mission: &str) -> String {
1279 format!(
1280 "You are @{name}.\n\
1281 Specialty: {specialty}.\n\
1282 Mission: {mission}\n\n\
1283 This is a protocol-first relay conversation. Treat incoming handoffs as authoritative context.\n\
1284 Keep responses concise, concrete, and useful for the next specialist.\n\
1285 Include one clear recommendation for what the next agent should do.\n\
1286 If the task is too large for the current team, explicitly call out missing specialties and handoff boundaries.",
1287 )
1288}
1289
1290fn build_runtime_profile_from_plan(
1291 profile: PlannedRelayProfile,
1292 existing: &[String],
1293) -> Option<(String, String, Vec<String>)> {
1294 let specialty = if profile.specialty.trim().is_empty() {
1295 "generalist".to_string()
1296 } else {
1297 profile.specialty.trim().to_string()
1298 };
1299
1300 let mission = if profile.mission.trim().is_empty() {
1301 "Advance the relay with concrete next actions and clear handoffs.".to_string()
1302 } else {
1303 profile.mission.trim().to_string()
1304 };
1305
1306 let base_name = if profile.name.trim().is_empty() {
1307 format!("auto-{}", slugify_label(&specialty))
1308 } else {
1309 profile.name.trim().to_string()
1310 };
1311
1312 let sanitized = sanitize_relay_agent_name(&base_name);
1313 let name = unique_relay_agent_name(&sanitized, existing);
1314 if name.trim().is_empty() {
1315 return None;
1316 }
1317
1318 let mut capabilities: Vec<String> = Vec::new();
1319 let specialty_cap = slugify_label(&specialty);
1320 if !specialty_cap.is_empty() {
1321 capabilities.push(specialty_cap);
1322 }
1323
1324 for capability in profile.capabilities {
1325 let normalized = slugify_label(&capability);
1326 if !normalized.is_empty() && !capabilities.contains(&normalized) {
1327 capabilities.push(normalized);
1328 }
1329 }
1330
1331 for required in ["relay", "context-handoff", "rlm-aware", "autochat"] {
1332 if !capabilities.iter().any(|capability| capability == required) {
1333 capabilities.push(required.to_string());
1334 }
1335 }
1336
1337 let instructions = relay_instruction_from_plan(&name, &specialty, &mission);
1338 Some((name, instructions, capabilities))
1339}
1340
1341fn extract_json_payload<T: DeserializeOwned>(text: &str) -> Option<T> {
1342 let trimmed = text.trim();
1343 if let Ok(value) = serde_json::from_str::<T>(trimmed) {
1344 return Some(value);
1345 }
1346
1347 if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}'))
1348 && start < end
1349 && let Ok(value) = serde_json::from_str::<T>(&trimmed[start..=end])
1350 {
1351 return Some(value);
1352 }
1353
1354 if let (Some(start), Some(end)) = (trimmed.find('['), trimmed.rfind(']'))
1355 && start < end
1356 && let Ok(value) = serde_json::from_str::<T>(&trimmed[start..=end])
1357 {
1358 return Some(value);
1359 }
1360
1361 None
1362}
1363
1364async fn plan_relay_profiles_with_registry(
1365 task: &str,
1366 model_ref: &str,
1367 requested_agents: usize,
1368 registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
1369) -> Option<Vec<(String, String, Vec<String>)>> {
1370 let (provider, model_name) = resolve_provider_for_model_autochat(registry, model_ref)?;
1371 let requested_agents = requested_agents.clamp(2, AUTOCHAT_MAX_AGENTS);
1372
1373 let request = crate::provider::CompletionRequest {
1374 model: model_name,
1375 messages: vec![
1376 crate::provider::Message {
1377 role: crate::provider::Role::System,
1378 content: vec![crate::provider::ContentPart::Text {
1379 text: "You are a relay-team architect. Return ONLY valid JSON.".to_string(),
1380 }],
1381 },
1382 crate::provider::Message {
1383 role: crate::provider::Role::User,
1384 content: vec![crate::provider::ContentPart::Text {
1385 text: format!(
1386 "Task:\n{task}\n\nDesign a task-specific relay team.\n\
1387 Respond with JSON object only:\n\
1388 {{\n \"profiles\": [\n {{\"name\":\"auto-...\",\"specialty\":\"...\",\"mission\":\"...\",\"capabilities\":[\"...\"]}}\n ]\n}}\n\
1389 Requirements:\n\
1390 - Return {} profiles\n\
1391 - Names must be short kebab-case\n\
1392 - Capabilities must be concise skill tags\n\
1393 - Missions should be concrete and handoff-friendly",
1394 requested_agents
1395 ),
1396 }],
1397 },
1398 ],
1399 tools: Vec::new(),
1400 temperature: Some(1.0),
1401 top_p: Some(0.9),
1402 max_tokens: Some(1200),
1403 stop: Vec::new(),
1404 };
1405
1406 let response = provider.complete(request).await.ok()?;
1407 let text = response
1408 .message
1409 .content
1410 .iter()
1411 .filter_map(|part| match part {
1412 crate::provider::ContentPart::Text { text }
1413 | crate::provider::ContentPart::Thinking { text } => Some(text.as_str()),
1414 _ => None,
1415 })
1416 .collect::<Vec<_>>()
1417 .join("\n");
1418
1419 let planned = extract_json_payload::<PlannedRelayResponse>(&text)?;
1420 let mut existing = Vec::<String>::new();
1421 let mut runtime = Vec::<(String, String, Vec<String>)>::new();
1422
1423 for profile in planned.profiles.into_iter().take(AUTOCHAT_MAX_AGENTS) {
1424 if let Some((name, instructions, capabilities)) =
1425 build_runtime_profile_from_plan(profile, &existing)
1426 {
1427 existing.push(name.clone());
1428 runtime.push((name, instructions, capabilities));
1429 }
1430 }
1431
1432 if runtime.len() >= 2 {
1433 Some(runtime)
1434 } else {
1435 None
1436 }
1437}
1438
1439async fn decide_dynamic_spawn_with_registry(
1440 task: &str,
1441 model_ref: &str,
1442 latest_output: &str,
1443 round: usize,
1444 ordered_agents: &[String],
1445 registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
1446) -> Option<(String, String, Vec<String>, String)> {
1447 let (provider, model_name) = resolve_provider_for_model_autochat(registry, model_ref)?;
1448 let team = ordered_agents
1449 .iter()
1450 .map(|name| format!("@{name}"))
1451 .collect::<Vec<_>>()
1452 .join(", ");
1453 let output_excerpt = truncate_with_ellipsis(latest_output, 2200);
1454
1455 let request = crate::provider::CompletionRequest {
1456 model: model_name,
1457 messages: vec![
1458 crate::provider::Message {
1459 role: crate::provider::Role::System,
1460 content: vec![crate::provider::ContentPart::Text {
1461 text: "You are a relay scaling controller. Return ONLY valid JSON.".to_string(),
1462 }],
1463 },
1464 crate::provider::Message {
1465 role: crate::provider::Role::User,
1466 content: vec![crate::provider::ContentPart::Text {
1467 text: format!(
1468 "Task:\n{task}\n\nRound: {round}\nCurrent team: {team}\n\
1469 Latest handoff excerpt:\n{output_excerpt}\n\n\
1470 Decide whether the team needs one additional specialist right now.\n\
1471 Respond with JSON object only:\n\
1472 {{\n \"spawn\": true|false,\n \"reason\": \"...\",\n \"profile\": {{\"name\":\"auto-...\",\"specialty\":\"...\",\"mission\":\"...\",\"capabilities\":[\"...\"]}}\n}}\n\
1473 If spawn=false, profile may be null or omitted."
1474 ),
1475 }],
1476 },
1477 ],
1478 tools: Vec::new(),
1479 temperature: Some(1.0),
1480 top_p: Some(0.9),
1481 max_tokens: Some(420),
1482 stop: Vec::new(),
1483 };
1484
1485 let response = provider.complete(request).await.ok()?;
1486 let text = response
1487 .message
1488 .content
1489 .iter()
1490 .filter_map(|part| match part {
1491 crate::provider::ContentPart::Text { text }
1492 | crate::provider::ContentPart::Thinking { text } => Some(text.as_str()),
1493 _ => None,
1494 })
1495 .collect::<Vec<_>>()
1496 .join("\n");
1497
1498 let decision = extract_json_payload::<RelaySpawnDecision>(&text)?;
1499 if !decision.spawn {
1500 return None;
1501 }
1502
1503 let profile = decision.profile?;
1504 let (name, instructions, capabilities) =
1505 build_runtime_profile_from_plan(profile, ordered_agents)?;
1506 let reason = if decision.reason.trim().is_empty() {
1507 "Model requested additional specialist for task scope.".to_string()
1508 } else {
1509 decision.reason.trim().to_string()
1510 };
1511
1512 Some((name, instructions, capabilities, reason))
1513}
1514
1515async fn prepare_autochat_handoff_with_registry(
1516 task: &str,
1517 from_agent: &str,
1518 output: &str,
1519 model_ref: &str,
1520 registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
1521) -> (String, bool) {
1522 let mut used_rlm = false;
1523 let mut relay_payload = output.to_string();
1524
1525 if output.len() > AUTOCHAT_RLM_THRESHOLD_CHARS {
1526 relay_payload = truncate_with_ellipsis(output, 3_500);
1527
1528 if let Some((provider, model_name)) =
1529 resolve_provider_for_model_autochat(registry, model_ref)
1530 {
1531 let mut executor =
1532 RlmExecutor::new(output.to_string(), provider, model_name).with_max_iterations(2);
1533
1534 let query = "Summarize this agent output for the next specialist in a relay. Keep:\n\
1535 1) key conclusions, 2) unresolved risks, 3) exact next action.\n\
1536 Keep it concise and actionable.";
1537 match executor.analyze(query).await {
1538 Ok(result) => {
1539 relay_payload = result.answer;
1540 used_rlm = true;
1541 }
1542 Err(err) => {
1543 tracing::warn!(error = %err, "RLM handoff summarization failed; using truncation fallback");
1544 }
1545 }
1546 }
1547 }
1548
1549 (
1550 format!(
1551 "Relay task:\n{task}\n\nIncoming handoff from @{from_agent}:\n{relay_payload}\n\n\
1552 Continue the work from this handoff. Keep your response focused and provide one concrete next-step instruction for the next agent."
1553 ),
1554 used_rlm,
1555 )
1556}
1557
1558async fn run_autochat_worker(
1559 tx: mpsc::Sender<AutochatUiEvent>,
1560 bus: std::sync::Arc<crate::bus::AgentBus>,
1561 fallback_profiles: Vec<(String, String, Vec<String>)>,
1562 task: String,
1563 model_ref: String,
1564) {
1565 let _ = tx
1566 .send(AutochatUiEvent::Progress(
1567 "Loading providers from Vault…".to_string(),
1568 ))
1569 .await;
1570
1571 let registry = match crate::provider::ProviderRegistry::from_vault().await {
1572 Ok(registry) => std::sync::Arc::new(registry),
1573 Err(err) => {
1574 let _ = tx
1575 .send(AutochatUiEvent::SystemMessage(format!(
1576 "Failed to load providers for /autochat: {err}"
1577 )))
1578 .await;
1579 let _ = tx
1580 .send(AutochatUiEvent::Completed {
1581 summary: "Autochat aborted: provider registry unavailable.".to_string(),
1582 })
1583 .await;
1584 return;
1585 }
1586 };
1587
1588 let relay = ProtocolRelayRuntime::new(bus);
1589 let requested_agents = fallback_profiles.len().clamp(2, AUTOCHAT_MAX_AGENTS);
1590
1591 let planned_profiles = match plan_relay_profiles_with_registry(
1592 &task,
1593 &model_ref,
1594 requested_agents,
1595 ®istry,
1596 )
1597 .await
1598 {
1599 Some(planned) => {
1600 let _ = tx
1601 .send(AutochatUiEvent::Progress(format!(
1602 "Model self-organized relay team ({} agents)…",
1603 planned.len()
1604 )))
1605 .await;
1606 planned
1607 }
1608 None => {
1609 let _ = tx
1610 .send(AutochatUiEvent::SystemMessage(
1611 "Dynamic team planning unavailable; using fallback self-organizing relay profiles."
1612 .to_string(),
1613 ))
1614 .await;
1615 fallback_profiles
1616 }
1617 };
1618
1619 let mut relay_profiles = Vec::with_capacity(planned_profiles.len());
1620 let mut ordered_agents = Vec::with_capacity(planned_profiles.len());
1621 let mut sessions: HashMap<String, Session> = HashMap::new();
1622 let mut setup_errors: Vec<String> = Vec::new();
1623 let mut checkpoint_profiles: Vec<(String, String, Vec<String>)> = Vec::new();
1624
1625 let _ = tx
1626 .send(AutochatUiEvent::Progress(
1627 "Initializing relay agent sessions…".to_string(),
1628 ))
1629 .await;
1630
1631 for (name, instructions, capabilities) in planned_profiles {
1632 match Session::new().await {
1633 Ok(mut session) => {
1634 session.metadata.model = Some(model_ref.clone());
1635 session.agent = name.clone();
1636 session.add_message(crate::provider::Message {
1637 role: Role::System,
1638 content: vec![ContentPart::Text {
1639 text: instructions.clone(),
1640 }],
1641 });
1642
1643 relay_profiles.push(RelayAgentProfile {
1644 name: name.clone(),
1645 capabilities: capabilities.clone(),
1646 });
1647 checkpoint_profiles.push((name.clone(), instructions, capabilities));
1648 ordered_agents.push(name.clone());
1649 sessions.insert(name, session);
1650 }
1651 Err(err) => {
1652 setup_errors.push(format!(
1653 "Failed creating relay agent session @{name}: {err}"
1654 ));
1655 }
1656 }
1657 }
1658
1659 if !setup_errors.is_empty() {
1660 let _ = tx
1661 .send(AutochatUiEvent::SystemMessage(format!(
1662 "Relay setup warnings:\n{}",
1663 setup_errors.join("\n")
1664 )))
1665 .await;
1666 }
1667
1668 if ordered_agents.len() < 2 {
1669 let _ = tx
1670 .send(AutochatUiEvent::SystemMessage(
1671 "Autochat needs at least 2 agents to relay.".to_string(),
1672 ))
1673 .await;
1674 let _ = tx
1675 .send(AutochatUiEvent::Completed {
1676 summary: "Autochat aborted: insufficient relay participants.".to_string(),
1677 })
1678 .await;
1679 return;
1680 }
1681
1682 relay.register_agents(&relay_profiles);
1683
1684 let _ = tx
1685 .send(AutochatUiEvent::Progress(format!(
1686 "Relay {} registered {} agents. Starting handoffs…",
1687 relay.relay_id(),
1688 ordered_agents.len()
1689 )))
1690 .await;
1691
1692 let roster_profiles = relay_profiles
1693 .iter()
1694 .map(|profile| {
1695 let capability_summary = if profile.capabilities.is_empty() {
1696 "skills: dynamic-specialist".to_string()
1697 } else {
1698 format!("skills: {}", profile.capabilities.join(", "))
1699 };
1700
1701 format!(
1702 "• {} — {}",
1703 format_agent_identity(&profile.name),
1704 capability_summary
1705 )
1706 })
1707 .collect::<Vec<_>>()
1708 .join("\n");
1709 let _ = tx
1710 .send(AutochatUiEvent::SystemMessage(format!(
1711 "Relay {id} started • model: {model_ref}\n\nTeam personalities:\n{roster_profiles}",
1712 id = relay.relay_id()
1713 )))
1714 .await;
1715
1716 let mut baton = format!(
1717 "Task:\n{task}\n\nStart by proposing an execution strategy and one immediate next step."
1718 );
1719 let mut previous_normalized: Option<String> = None;
1720 let mut convergence_hits = 0usize;
1721 let mut turns = 0usize;
1722 let mut rlm_handoff_count = 0usize;
1723 let mut dynamic_spawn_count = 0usize;
1724 let mut status = "max_rounds_reached";
1725 let mut failure_note: Option<String> = None;
1726
1727 'relay_loop: for round in 1..=AUTOCHAT_MAX_ROUNDS {
1728 let mut idx = 0usize;
1729 while idx < ordered_agents.len() {
1730 let to = ordered_agents[idx].clone();
1731 let from = if idx == 0 {
1732 if round == 1 {
1733 "user".to_string()
1734 } else {
1735 ordered_agents[ordered_agents.len() - 1].clone()
1736 }
1737 } else {
1738 ordered_agents[idx - 1].clone()
1739 };
1740
1741 turns += 1;
1742 relay.send_handoff(&from, &to, &baton);
1743 let _ = tx
1744 .send(AutochatUiEvent::Progress(format!(
1745 "Round {round}/{AUTOCHAT_MAX_ROUNDS} • @{from} → @{to}"
1746 )))
1747 .await;
1748
1749 let Some(mut session) = sessions.remove(&to) else {
1750 status = "agent_error";
1751 failure_note = Some(format!("Relay agent @{to} session was unavailable."));
1752 break 'relay_loop;
1753 };
1754
1755 let (event_tx, mut event_rx) = mpsc::channel(256);
1756 let registry_for_prompt = registry.clone();
1757 let baton_for_prompt = baton.clone();
1758
1759 let join = tokio::spawn(async move {
1760 let result = session
1761 .prompt_with_events(&baton_for_prompt, event_tx, registry_for_prompt)
1762 .await;
1763 (session, result)
1764 });
1765
1766 while !join.is_finished() {
1767 while let Ok(event) = event_rx.try_recv() {
1768 if !matches!(event, SessionEvent::SessionSync(_)) {
1769 let _ = tx
1770 .send(AutochatUiEvent::AgentEvent {
1771 agent_name: to.clone(),
1772 event,
1773 })
1774 .await;
1775 }
1776 }
1777 tokio::time::sleep(Duration::from_millis(20)).await;
1778 }
1779
1780 let (updated_session, result) = match join.await {
1781 Ok(value) => value,
1782 Err(err) => {
1783 status = "agent_error";
1784 failure_note = Some(format!("Relay agent @{to} task join error: {err}"));
1785 break 'relay_loop;
1786 }
1787 };
1788
1789 while let Ok(event) = event_rx.try_recv() {
1790 if !matches!(event, SessionEvent::SessionSync(_)) {
1791 let _ = tx
1792 .send(AutochatUiEvent::AgentEvent {
1793 agent_name: to.clone(),
1794 event,
1795 })
1796 .await;
1797 }
1798 }
1799
1800 sessions.insert(to.clone(), updated_session);
1801
1802 let output = match result {
1803 Ok(response) => response.text,
1804 Err(err) => {
1805 status = "agent_error";
1806 failure_note = Some(format!("Relay agent @{to} failed: {err}"));
1807 let _ = tx
1808 .send(AutochatUiEvent::SystemMessage(format!(
1809 "Relay agent @{to} failed: {err}"
1810 )))
1811 .await;
1812 break 'relay_loop;
1813 }
1814 };
1815
1816 let normalized = normalize_for_convergence(&output);
1817 if previous_normalized.as_deref() == Some(normalized.as_str()) {
1818 convergence_hits += 1;
1819 } else {
1820 convergence_hits = 0;
1821 }
1822 previous_normalized = Some(normalized);
1823
1824 let (next_handoff, used_rlm) =
1825 prepare_autochat_handoff_with_registry(&task, &to, &output, &model_ref, ®istry)
1826 .await;
1827 if used_rlm {
1828 rlm_handoff_count += 1;
1829 }
1830
1831 baton = next_handoff;
1832
1833 let can_attempt_spawn = dynamic_spawn_count < AUTOCHAT_MAX_DYNAMIC_SPAWNS
1834 && ordered_agents.len() < AUTOCHAT_MAX_AGENTS
1835 && output.len() >= AUTOCHAT_SPAWN_CHECK_MIN_CHARS;
1836
1837 if can_attempt_spawn
1838 && let Some((name, instructions, capabilities, reason)) =
1839 decide_dynamic_spawn_with_registry(
1840 &task,
1841 &model_ref,
1842 &output,
1843 round,
1844 &ordered_agents,
1845 ®istry,
1846 )
1847 .await
1848 {
1849 match Session::new().await {
1850 Ok(mut spawned_session) => {
1851 spawned_session.metadata.model = Some(model_ref.clone());
1852 spawned_session.agent = name.clone();
1853 spawned_session.add_message(crate::provider::Message {
1854 role: Role::System,
1855 content: vec![ContentPart::Text {
1856 text: instructions.clone(),
1857 }],
1858 });
1859
1860 relay.register_agents(&[RelayAgentProfile {
1861 name: name.clone(),
1862 capabilities: capabilities.clone(),
1863 }]);
1864
1865 ordered_agents.insert(idx + 1, name.clone());
1866 checkpoint_profiles.push((name.clone(), instructions, capabilities));
1867 sessions.insert(name.clone(), spawned_session);
1868 dynamic_spawn_count += 1;
1869
1870 let _ = tx
1871 .send(AutochatUiEvent::SystemMessage(format!(
1872 "Dynamic spawn: {} joined relay after @{to}.\nReason: {reason}",
1873 format_agent_identity(&name)
1874 )))
1875 .await;
1876 }
1877 Err(err) => {
1878 let _ = tx
1879 .send(AutochatUiEvent::SystemMessage(format!(
1880 "Dynamic spawn requested but failed to create @{name}: {err}"
1881 )))
1882 .await;
1883 }
1884 }
1885 }
1886
1887 if convergence_hits >= 2 {
1888 status = "converged";
1889 break 'relay_loop;
1890 }
1891
1892 {
1894 let agent_session_ids: HashMap<String, String> = sessions
1895 .iter()
1896 .map(|(name, s)| (name.clone(), s.id.clone()))
1897 .collect();
1898 let next_idx = idx + 1;
1899 let (ck_round, ck_idx) = if next_idx >= ordered_agents.len() {
1900 (round + 1, 0)
1901 } else {
1902 (round, next_idx)
1903 };
1904 let checkpoint = RelayCheckpoint {
1905 task: task.clone(),
1906 model_ref: model_ref.clone(),
1907 ordered_agents: ordered_agents.clone(),
1908 agent_session_ids,
1909 agent_profiles: checkpoint_profiles.clone(),
1910 round: ck_round,
1911 idx: ck_idx,
1912 baton: baton.clone(),
1913 turns,
1914 convergence_hits,
1915 dynamic_spawn_count,
1916 rlm_handoff_count,
1917 workspace_dir: std::env::current_dir().unwrap_or_default(),
1918 started_at: chrono::Utc::now().to_rfc3339(),
1919 };
1920 if let Err(err) = checkpoint.save().await {
1921 tracing::warn!("Failed to save relay checkpoint: {err}");
1922 }
1923 }
1924
1925 idx += 1;
1926 }
1927 }
1928
1929 relay.shutdown_agents(&ordered_agents);
1930
1931 RelayCheckpoint::delete().await;
1933
1934 let _ = tx
1935 .send(AutochatUiEvent::Progress(
1936 "Finalizing relay summary…".to_string(),
1937 ))
1938 .await;
1939
1940 let mut summary = format!(
1941 "Autochat complete ({status}) — relay {} with {} agents over {} turns.",
1942 relay.relay_id(),
1943 ordered_agents.len(),
1944 turns,
1945 );
1946 if let Some(note) = failure_note {
1947 summary.push_str(&format!("\n\nFailure detail: {note}"));
1948 }
1949 if rlm_handoff_count > 0 {
1950 summary.push_str(&format!("\n\nRLM compressed handoffs: {rlm_handoff_count}"));
1951 }
1952 if dynamic_spawn_count > 0 {
1953 summary.push_str(&format!("\nDynamic relay spawns: {dynamic_spawn_count}"));
1954 }
1955 summary.push_str(&format!(
1956 "\n\nFinal relay handoff:\n{}",
1957 truncate_with_ellipsis(&baton, 4_000)
1958 ));
1959 summary.push_str(&format!(
1960 "\n\nCleanup: deregistered relay agents and disposed {} autochat worker session(s).",
1961 sessions.len()
1962 ));
1963
1964 let _ = tx.send(AutochatUiEvent::Completed { summary }).await;
1965}
1966
1967async fn resume_autochat_worker(
1972 tx: mpsc::Sender<AutochatUiEvent>,
1973 bus: std::sync::Arc<crate::bus::AgentBus>,
1974 checkpoint: RelayCheckpoint,
1975) {
1976 let _ = tx
1977 .send(AutochatUiEvent::Progress(
1978 "Resuming relay — loading providers…".to_string(),
1979 ))
1980 .await;
1981
1982 let registry = match crate::provider::ProviderRegistry::from_vault().await {
1983 Ok(registry) => std::sync::Arc::new(registry),
1984 Err(err) => {
1985 let _ = tx
1986 .send(AutochatUiEvent::SystemMessage(format!(
1987 "Failed to load providers for relay resume: {err}"
1988 )))
1989 .await;
1990 let _ = tx
1991 .send(AutochatUiEvent::Completed {
1992 summary: "Relay resume aborted: provider registry unavailable.".to_string(),
1993 })
1994 .await;
1995 return;
1996 }
1997 };
1998
1999 let relay = ProtocolRelayRuntime::new(bus);
2000 let task = checkpoint.task;
2001 let model_ref = checkpoint.model_ref;
2002 let mut ordered_agents = checkpoint.ordered_agents;
2003 let mut checkpoint_profiles = checkpoint.agent_profiles;
2004 let mut baton = checkpoint.baton;
2005 let mut turns = checkpoint.turns;
2006 let mut convergence_hits = checkpoint.convergence_hits;
2007 let mut rlm_handoff_count = checkpoint.rlm_handoff_count;
2008 let mut dynamic_spawn_count = checkpoint.dynamic_spawn_count;
2009 let start_round = checkpoint.round;
2010 let start_idx = checkpoint.idx;
2011
2012 let mut sessions: HashMap<String, Session> = HashMap::new();
2014 let mut load_errors: Vec<String> = Vec::new();
2015
2016 let _ = tx
2017 .send(AutochatUiEvent::Progress(
2018 "Reloading agent sessions from disk…".to_string(),
2019 ))
2020 .await;
2021
2022 for (agent_name, session_id) in &checkpoint.agent_session_ids {
2023 match Session::load(session_id).await {
2024 Ok(session) => {
2025 sessions.insert(agent_name.clone(), session);
2026 }
2027 Err(err) => {
2028 load_errors.push(format!(
2029 "Failed to reload @{agent_name} ({session_id}): {err}"
2030 ));
2031 }
2032 }
2033 }
2034
2035 if !load_errors.is_empty() {
2036 let _ = tx
2037 .send(AutochatUiEvent::SystemMessage(format!(
2038 "Session reload warnings:\n{}",
2039 load_errors.join("\n")
2040 )))
2041 .await;
2042 }
2043
2044 let relay_profiles: Vec<RelayAgentProfile> = checkpoint_profiles
2046 .iter()
2047 .map(|(name, _, capabilities)| RelayAgentProfile {
2048 name: name.clone(),
2049 capabilities: capabilities.clone(),
2050 })
2051 .collect();
2052 relay.register_agents(&relay_profiles);
2053
2054 let _ = tx
2055 .send(AutochatUiEvent::SystemMessage(format!(
2056 "Resuming relay from round {start_round}, agent index {start_idx}\n\
2057 Task: {}\n\
2058 Agents: {}\n\
2059 Turns completed so far: {turns}",
2060 truncate_with_ellipsis(&task, 120),
2061 ordered_agents.join(", ")
2062 )))
2063 .await;
2064
2065 let mut previous_normalized: Option<String> = None;
2066 let mut status = "max_rounds_reached";
2067 let mut failure_note: Option<String> = None;
2068
2069 'relay_loop: for round in start_round..=AUTOCHAT_MAX_ROUNDS {
2070 let first_idx = if round == start_round { start_idx } else { 0 };
2071 let mut idx = first_idx;
2072 while idx < ordered_agents.len() {
2073 let to = ordered_agents[idx].clone();
2074 let from = if idx == 0 {
2075 if round == 1 {
2076 "user".to_string()
2077 } else {
2078 ordered_agents[ordered_agents.len() - 1].clone()
2079 }
2080 } else {
2081 ordered_agents[idx - 1].clone()
2082 };
2083
2084 turns += 1;
2085 relay.send_handoff(&from, &to, &baton);
2086 let _ = tx
2087 .send(AutochatUiEvent::Progress(format!(
2088 "Round {round}/{AUTOCHAT_MAX_ROUNDS} • @{from} → @{to} (resumed)"
2089 )))
2090 .await;
2091
2092 let Some(mut session) = sessions.remove(&to) else {
2093 status = "agent_error";
2094 failure_note = Some(format!("Relay agent @{to} session was unavailable."));
2095 break 'relay_loop;
2096 };
2097
2098 let (event_tx, mut event_rx) = mpsc::channel(256);
2099 let registry_for_prompt = registry.clone();
2100 let baton_for_prompt = baton.clone();
2101
2102 let join = tokio::spawn(async move {
2103 let result = session
2104 .prompt_with_events(&baton_for_prompt, event_tx, registry_for_prompt)
2105 .await;
2106 (session, result)
2107 });
2108
2109 while !join.is_finished() {
2110 while let Ok(event) = event_rx.try_recv() {
2111 if !matches!(event, SessionEvent::SessionSync(_)) {
2112 let _ = tx
2113 .send(AutochatUiEvent::AgentEvent {
2114 agent_name: to.clone(),
2115 event,
2116 })
2117 .await;
2118 }
2119 }
2120 tokio::time::sleep(Duration::from_millis(20)).await;
2121 }
2122
2123 let (updated_session, result) = match join.await {
2124 Ok(value) => value,
2125 Err(err) => {
2126 status = "agent_error";
2127 failure_note = Some(format!("Relay agent @{to} task join error: {err}"));
2128 break 'relay_loop;
2129 }
2130 };
2131
2132 while let Ok(event) = event_rx.try_recv() {
2133 if !matches!(event, SessionEvent::SessionSync(_)) {
2134 let _ = tx
2135 .send(AutochatUiEvent::AgentEvent {
2136 agent_name: to.clone(),
2137 event,
2138 })
2139 .await;
2140 }
2141 }
2142
2143 sessions.insert(to.clone(), updated_session);
2144
2145 let output = match result {
2146 Ok(response) => response.text,
2147 Err(err) => {
2148 status = "agent_error";
2149 failure_note = Some(format!("Relay agent @{to} failed: {err}"));
2150 let _ = tx
2151 .send(AutochatUiEvent::SystemMessage(format!(
2152 "Relay agent @{to} failed: {err}"
2153 )))
2154 .await;
2155 break 'relay_loop;
2156 }
2157 };
2158
2159 let normalized = normalize_for_convergence(&output);
2160 if previous_normalized.as_deref() == Some(normalized.as_str()) {
2161 convergence_hits += 1;
2162 } else {
2163 convergence_hits = 0;
2164 }
2165 previous_normalized = Some(normalized);
2166
2167 let (next_handoff, used_rlm) =
2168 prepare_autochat_handoff_with_registry(&task, &to, &output, &model_ref, ®istry)
2169 .await;
2170 if used_rlm {
2171 rlm_handoff_count += 1;
2172 }
2173
2174 baton = next_handoff;
2175
2176 let can_attempt_spawn = dynamic_spawn_count < AUTOCHAT_MAX_DYNAMIC_SPAWNS
2177 && ordered_agents.len() < AUTOCHAT_MAX_AGENTS
2178 && output.len() >= AUTOCHAT_SPAWN_CHECK_MIN_CHARS;
2179
2180 if can_attempt_spawn
2181 && let Some((name, instructions, capabilities, reason)) =
2182 decide_dynamic_spawn_with_registry(
2183 &task,
2184 &model_ref,
2185 &output,
2186 round,
2187 &ordered_agents,
2188 ®istry,
2189 )
2190 .await
2191 {
2192 match Session::new().await {
2193 Ok(mut spawned_session) => {
2194 spawned_session.metadata.model = Some(model_ref.clone());
2195 spawned_session.agent = name.clone();
2196 spawned_session.add_message(crate::provider::Message {
2197 role: Role::System,
2198 content: vec![ContentPart::Text {
2199 text: instructions.clone(),
2200 }],
2201 });
2202
2203 relay.register_agents(&[RelayAgentProfile {
2204 name: name.clone(),
2205 capabilities: capabilities.clone(),
2206 }]);
2207
2208 ordered_agents.insert(idx + 1, name.clone());
2209 checkpoint_profiles.push((name.clone(), instructions, capabilities));
2210 sessions.insert(name.clone(), spawned_session);
2211 dynamic_spawn_count += 1;
2212
2213 let _ = tx
2214 .send(AutochatUiEvent::SystemMessage(format!(
2215 "Dynamic spawn: {} joined relay after @{to}.\nReason: {reason}",
2216 format_agent_identity(&name)
2217 )))
2218 .await;
2219 }
2220 Err(err) => {
2221 let _ = tx
2222 .send(AutochatUiEvent::SystemMessage(format!(
2223 "Dynamic spawn requested but failed to create @{name}: {err}"
2224 )))
2225 .await;
2226 }
2227 }
2228 }
2229
2230 if convergence_hits >= 2 {
2231 status = "converged";
2232 break 'relay_loop;
2233 }
2234
2235 {
2237 let agent_session_ids: HashMap<String, String> = sessions
2238 .iter()
2239 .map(|(name, s)| (name.clone(), s.id.clone()))
2240 .collect();
2241 let next_idx = idx + 1;
2242 let (ck_round, ck_idx) = if next_idx >= ordered_agents.len() {
2243 (round + 1, 0)
2244 } else {
2245 (round, next_idx)
2246 };
2247 let ck = RelayCheckpoint {
2248 task: task.clone(),
2249 model_ref: model_ref.clone(),
2250 ordered_agents: ordered_agents.clone(),
2251 agent_session_ids,
2252 agent_profiles: checkpoint_profiles.clone(),
2253 round: ck_round,
2254 idx: ck_idx,
2255 baton: baton.clone(),
2256 turns,
2257 convergence_hits,
2258 dynamic_spawn_count,
2259 rlm_handoff_count,
2260 workspace_dir: std::env::current_dir().unwrap_or_default(),
2261 started_at: chrono::Utc::now().to_rfc3339(),
2262 };
2263 if let Err(err) = ck.save().await {
2264 tracing::warn!("Failed to save relay checkpoint: {err}");
2265 }
2266 }
2267
2268 idx += 1;
2269 }
2270 }
2271
2272 relay.shutdown_agents(&ordered_agents);
2273
2274 RelayCheckpoint::delete().await;
2276
2277 let _ = tx
2278 .send(AutochatUiEvent::Progress(
2279 "Finalizing resumed relay summary…".to_string(),
2280 ))
2281 .await;
2282
2283 let mut summary = format!(
2284 "Resumed relay complete ({status}) — {} agents over {} turns.",
2285 ordered_agents.len(),
2286 turns,
2287 );
2288 if let Some(note) = failure_note {
2289 summary.push_str(&format!("\n\nFailure detail: {note}"));
2290 }
2291 if rlm_handoff_count > 0 {
2292 summary.push_str(&format!("\n\nRLM compressed handoffs: {rlm_handoff_count}"));
2293 }
2294 if dynamic_spawn_count > 0 {
2295 summary.push_str(&format!("\nDynamic relay spawns: {dynamic_spawn_count}"));
2296 }
2297 summary.push_str(&format!(
2298 "\n\nFinal relay handoff:\n{}",
2299 truncate_with_ellipsis(&baton, 4_000)
2300 ));
2301
2302 let _ = tx.send(AutochatUiEvent::Completed { summary }).await;
2303}
2304
2305impl App {
2306 fn new() -> Self {
2307 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2308 let chat_archive_path = directories::ProjectDirs::from("com", "codetether", "codetether")
2309 .map(|dirs| dirs.data_local_dir().join("chat_events.jsonl"));
2310
2311 Self {
2312 input: String::new(),
2313 cursor_position: 0,
2314 messages: vec![
2315 ChatMessage::new("system", "Welcome to CodeTether Agent! Press ? for help."),
2316 ChatMessage::new(
2317 "assistant",
2318 "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",
2319 ),
2320 ],
2321 current_agent: "build".to_string(),
2322 scroll: 0,
2323 show_help: false,
2324 command_history: Vec::new(),
2325 history_index: None,
2326 session: None,
2327 is_processing: false,
2328 processing_message: None,
2329 current_tool: None,
2330 current_tool_started_at: None,
2331 processing_started_at: None,
2332 streaming_text: None,
2333 streaming_agent_texts: HashMap::new(),
2334 tool_call_count: 0,
2335 response_rx: None,
2336 provider_registry: None,
2337 workspace_dir: workspace_root.clone(),
2338 view_mode: ViewMode::Chat,
2339 chat_layout: ChatLayoutMode::Webview,
2340 show_inspector: true,
2341 workspace: WorkspaceSnapshot::capture(&workspace_root, 18),
2342 swarm_state: SwarmViewState::new(),
2343 swarm_rx: None,
2344 ralph_state: RalphViewState::new(),
2345 ralph_rx: None,
2346 bus_log_state: BusLogState::new(),
2347 bus_log_rx: None,
2348 bus: None,
2349 session_picker_list: Vec::new(),
2350 session_picker_selected: 0,
2351 session_picker_filter: String::new(),
2352 session_picker_confirm_delete: false,
2353 model_picker_list: Vec::new(),
2354 model_picker_selected: 0,
2355 model_picker_filter: String::new(),
2356 agent_picker_selected: 0,
2357 agent_picker_filter: String::new(),
2358 protocol_selected: 0,
2359 protocol_scroll: 0,
2360 active_model: None,
2361 active_spawned_agent: None,
2362 spawned_agents: HashMap::new(),
2363 agent_response_rxs: Vec::new(),
2364 agent_tool_started_at: HashMap::new(),
2365 autochat_rx: None,
2366 autochat_running: false,
2367 autochat_started_at: None,
2368 autochat_status: None,
2369 chat_archive_path,
2370 archived_message_count: 0,
2371 chat_sync_rx: None,
2372 chat_sync_status: None,
2373 chat_sync_last_success: None,
2374 chat_sync_last_error: None,
2375 chat_sync_uploaded_bytes: 0,
2376 chat_sync_uploaded_batches: 0,
2377 secure_environment: false,
2378 last_max_scroll: 0,
2379 }
2380 }
2381
2382 fn refresh_workspace(&mut self) {
2383 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2384 self.workspace = WorkspaceSnapshot::capture(&workspace_root, 18);
2385 }
2386
2387 fn update_cached_sessions(&mut self, sessions: Vec<SessionSummary>) {
2388 self.session_picker_list = sessions.into_iter().take(16).collect();
2389 if self.session_picker_selected >= self.session_picker_list.len() {
2390 self.session_picker_selected = self.session_picker_list.len().saturating_sub(1);
2391 }
2392 }
2393
2394 fn is_agent_protocol_registered(&self, agent_name: &str) -> bool {
2395 self.bus
2396 .as_ref()
2397 .is_some_and(|bus| bus.registry.get(agent_name).is_some())
2398 }
2399
2400 fn protocol_registered_count(&self) -> usize {
2401 self.bus.as_ref().map_or(0, |bus| bus.registry.len())
2402 }
2403
2404 fn protocol_cards(&self) -> Vec<crate::a2a::types::AgentCard> {
2405 let Some(bus) = &self.bus else {
2406 return Vec::new();
2407 };
2408
2409 let mut ids = bus.registry.agent_ids();
2410 ids.sort_by_key(|id| id.to_lowercase());
2411
2412 ids.into_iter()
2413 .filter_map(|id| bus.registry.get(&id))
2414 .collect()
2415 }
2416
2417 fn open_protocol_view(&mut self) {
2418 self.protocol_selected = 0;
2419 self.protocol_scroll = 0;
2420 self.view_mode = ViewMode::Protocol;
2421 }
2422
2423 fn unique_spawned_name(&self, base: &str) -> String {
2424 if !self.spawned_agents.contains_key(base) {
2425 return base.to_string();
2426 }
2427
2428 let mut suffix = 2usize;
2429 loop {
2430 let candidate = format!("{base}-{suffix}");
2431 if !self.spawned_agents.contains_key(&candidate) {
2432 return candidate;
2433 }
2434 suffix += 1;
2435 }
2436 }
2437
2438 fn build_autochat_profiles(&self, count: usize) -> Vec<(String, String, Vec<String>)> {
2439 let mut profiles = Vec::with_capacity(count);
2440 for idx in 0..count {
2441 let base = format!("auto-agent-{}", idx + 1);
2442 let name = self.unique_spawned_name(&base);
2443 let instructions = format!(
2444 "You are @{name}.\n\
2445 Role policy: self-organize from task context and current handoff instead of assuming a fixed persona.\n\
2446 Mission: advance the relay with concrete, high-signal next actions and clear ownership boundaries.\n\n\
2447 This is a protocol-first relay conversation. Treat the incoming handoff as the authoritative context.\n\
2448 Keep your response concise, concrete, and useful for the next specialist.\n\
2449 Include one clear recommendation for what the next agent should do.\n\
2450 If the task scope is too large, explicitly call out missing specialties and handoff boundaries.",
2451 );
2452 let capabilities = vec![
2453 "generalist".to_string(),
2454 "self-organizing".to_string(),
2455 "relay".to_string(),
2456 "context-handoff".to_string(),
2457 "rlm-aware".to_string(),
2458 "autochat".to_string(),
2459 ];
2460
2461 profiles.push((name, instructions, capabilities));
2462 }
2463
2464 profiles
2465 }
2466 async fn start_autochat_execution(
2467 &mut self,
2468 agent_count: usize,
2469 task: String,
2470 config: &Config,
2471 ) {
2472 if !(2..=AUTOCHAT_MAX_AGENTS).contains(&agent_count) {
2473 self.messages.push(ChatMessage::new(
2474 "system",
2475 format!(
2476 "Usage: /autochat <count> <task>\ncount must be between 2 and {AUTOCHAT_MAX_AGENTS}."
2477 ),
2478 ));
2479 return;
2480 }
2481
2482 if self.autochat_running {
2483 self.messages.push(ChatMessage::new(
2484 "system",
2485 "Autochat relay already running. Wait for it to finish before starting another.",
2486 ));
2487 return;
2488 }
2489
2490 self.messages.push(ChatMessage::new(
2491 "user",
2492 format!("/autochat {agent_count} {task}"),
2493 ));
2494 self.messages.push(ChatMessage::new(
2495 "system",
2496 format!(
2497 "Preparing relay with {agent_count} agents…\nTask: {}\n(Compact mode: live agent streaming here, detailed relay envelopes in /buslog)",
2498 truncate_with_ellipsis(&task, 180)
2499 ),
2500 ));
2501 self.scroll = SCROLL_BOTTOM;
2502
2503 let Some(bus) = self.bus.clone() else {
2504 self.messages.push(ChatMessage::new(
2505 "system",
2506 "Protocol bus unavailable; cannot start /autochat relay.",
2507 ));
2508 return;
2509 };
2510
2511 let model_ref = self
2512 .active_model
2513 .clone()
2514 .or_else(|| config.default_model.clone())
2515 .unwrap_or_else(|| "zai/glm-5".to_string());
2516
2517 let profiles = self.build_autochat_profiles(agent_count);
2518 if profiles.is_empty() {
2519 self.messages.push(ChatMessage::new(
2520 "system",
2521 "No relay profiles could be created.",
2522 ));
2523 return;
2524 }
2525
2526 let (tx, rx) = mpsc::channel(512);
2527 self.autochat_rx = Some(rx);
2528 self.autochat_running = true;
2529 self.autochat_started_at = Some(Instant::now());
2530 self.autochat_status = Some("Preparing relay…".to_string());
2531 self.active_spawned_agent = None;
2532
2533 tokio::spawn(async move {
2534 run_autochat_worker(tx, bus, profiles, task, model_ref).await;
2535 });
2536 }
2537
2538 async fn resume_autochat_relay(&mut self, checkpoint: RelayCheckpoint) {
2540 if self.autochat_running {
2541 self.messages.push(ChatMessage::new(
2542 "system",
2543 "Autochat relay already running. Wait for it to finish before resuming.",
2544 ));
2545 return;
2546 }
2547
2548 let Some(bus) = self.bus.clone() else {
2549 self.messages.push(ChatMessage::new(
2550 "system",
2551 "Protocol bus unavailable; cannot resume relay.",
2552 ));
2553 return;
2554 };
2555
2556 self.messages.push(ChatMessage::new(
2557 "system",
2558 format!(
2559 "Resuming interrupted relay…\nTask: {}\nAgents: {}\nResuming from round {}, agent index {}\nTurns completed: {}",
2560 truncate_with_ellipsis(&checkpoint.task, 120),
2561 checkpoint.ordered_agents.join(" → "),
2562 checkpoint.round,
2563 checkpoint.idx,
2564 checkpoint.turns,
2565 ),
2566 ));
2567 self.scroll = SCROLL_BOTTOM;
2568
2569 let (tx, rx) = mpsc::channel(512);
2570 self.autochat_rx = Some(rx);
2571 self.autochat_running = true;
2572 self.autochat_started_at = Some(Instant::now());
2573 self.autochat_status = Some("Resuming relay…".to_string());
2574 self.active_spawned_agent = None;
2575
2576 tokio::spawn(async move {
2577 resume_autochat_worker(tx, bus, checkpoint).await;
2578 });
2579 }
2580
2581 async fn submit_message(&mut self, config: &Config) {
2582 if self.input.is_empty() {
2583 return;
2584 }
2585
2586 let mut message = std::mem::take(&mut self.input);
2587 let easy_go_requested = is_easy_go_command(&message);
2588 self.cursor_position = 0;
2589
2590 if !message.trim().is_empty() {
2592 self.command_history.push(message.clone());
2593 self.history_index = None;
2594 }
2595
2596 message = normalize_easy_command(&message);
2598
2599 if message.trim() == "/help" {
2600 self.show_help = true;
2601 return;
2602 }
2603
2604 if message.trim().starts_with("/agent") {
2606 let rest = message.trim().strip_prefix("/agent").unwrap_or("").trim();
2607
2608 if rest.is_empty() {
2609 self.open_agent_picker();
2610 return;
2611 }
2612
2613 if rest == "pick" || rest == "picker" || rest == "select" {
2614 self.open_agent_picker();
2615 return;
2616 }
2617
2618 if rest == "main" || rest == "off" {
2619 if let Some(target) = self.active_spawned_agent.take() {
2620 self.messages.push(ChatMessage::new(
2621 "system",
2622 format!("Exited focused sub-agent chat (@{target})."),
2623 ));
2624 } else {
2625 self.messages
2626 .push(ChatMessage::new("system", "Already in main chat mode."));
2627 }
2628 return;
2629 }
2630
2631 if rest == "build" || rest == "plan" {
2632 self.current_agent = rest.to_string();
2633 self.active_spawned_agent = None;
2634 self.messages.push(ChatMessage::new(
2635 "system",
2636 format!("Switched main agent to '{rest}'. (Tab also works.)"),
2637 ));
2638 return;
2639 }
2640
2641 if rest == "list" || rest == "ls" {
2642 message = "/agents".to_string();
2643 } else if let Some(args) = rest
2644 .strip_prefix("spawn ")
2645 .map(str::trim)
2646 .filter(|s| !s.is_empty())
2647 {
2648 message = format!("/spawn {args}");
2649 } else if let Some(name) = rest
2650 .strip_prefix("kill ")
2651 .map(str::trim)
2652 .filter(|s| !s.is_empty())
2653 {
2654 message = format!("/kill {name}");
2655 } else if !rest.contains(' ') {
2656 let target = rest.trim_start_matches('@');
2657 if self.spawned_agents.contains_key(target) {
2658 self.active_spawned_agent = Some(target.to_string());
2659 self.messages.push(ChatMessage::new(
2660 "system",
2661 format!(
2662 "Focused chat on @{target}. Type messages directly; use /agent main to exit focus."
2663 ),
2664 ));
2665 } else {
2666 self.messages.push(ChatMessage::new(
2667 "system",
2668 format!(
2669 "No agent named @{target}. Use /agents to list, or /spawn <name> <instructions> to create one."
2670 ),
2671 ));
2672 }
2673 return;
2674 } else if let Some((name, content)) = rest.split_once(' ') {
2675 let target = name.trim().trim_start_matches('@');
2676 let content = content.trim();
2677 if target.is_empty() || content.is_empty() {
2678 self.messages
2679 .push(ChatMessage::new("system", "Usage: /agent <name> <message>"));
2680 return;
2681 }
2682 message = format!("@{target} {content}");
2683 } else {
2684 self.messages.push(ChatMessage::new(
2685 "system",
2686 "Unknown /agent usage. Try /agent, /agent <name>, /agent <name> <message>, or /agent list.",
2687 ));
2688 return;
2689 }
2690 }
2691
2692 if let Some(rest) = command_with_optional_args(&message, "/autochat") {
2694 let Some((count, task)) = parse_autochat_args(rest) else {
2695 self.messages.push(ChatMessage::new(
2696 "system",
2697 format!(
2698 "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: {})",
2699 AUTOCHAT_MAX_AGENTS,
2700 AUTOCHAT_DEFAULT_AGENTS,
2701 ),
2702 ));
2703 return;
2704 };
2705
2706 if easy_go_requested {
2707 let current_model = self
2708 .active_model
2709 .as_deref()
2710 .or(config.default_model.as_deref());
2711 let next_model = next_go_model(current_model);
2712 self.active_model = Some(next_model.clone());
2713 if let Some(session) = self.session.as_mut() {
2714 session.metadata.model = Some(next_model.clone());
2715 }
2716 self.messages.push(ChatMessage::new(
2717 "system",
2718 format!("/go model swap → {next_model}"),
2719 ));
2720 }
2721
2722 self.start_autochat_execution(count, task.to_string(), config)
2723 .await;
2724 return;
2725 }
2726
2727 if let Some(task) = command_with_optional_args(&message, "/swarm") {
2729 if task.is_empty() {
2730 self.messages.push(ChatMessage::new(
2731 "system",
2732 "Usage: /swarm <task description>",
2733 ));
2734 return;
2735 }
2736 self.start_swarm_execution(task.to_string(), config).await;
2737 return;
2738 }
2739
2740 if message.trim().starts_with("/ralph") {
2742 let prd_path = message
2743 .trim()
2744 .strip_prefix("/ralph")
2745 .map(|s| s.trim())
2746 .filter(|s| !s.is_empty())
2747 .unwrap_or("prd.json")
2748 .to_string();
2749 self.start_ralph_execution(prd_path, config).await;
2750 return;
2751 }
2752
2753 if message.trim() == "/webview" {
2754 self.chat_layout = ChatLayoutMode::Webview;
2755 self.messages.push(ChatMessage::new(
2756 "system",
2757 "Switched to webview layout. Use /classic to return to single-pane chat.",
2758 ));
2759 return;
2760 }
2761
2762 if message.trim() == "/classic" {
2763 self.chat_layout = ChatLayoutMode::Classic;
2764 self.messages.push(ChatMessage::new(
2765 "system",
2766 "Switched to classic layout. Use /webview for dashboard-style panes.",
2767 ));
2768 return;
2769 }
2770
2771 if message.trim() == "/inspector" {
2772 self.show_inspector = !self.show_inspector;
2773 let state = if self.show_inspector {
2774 "enabled"
2775 } else {
2776 "disabled"
2777 };
2778 self.messages.push(ChatMessage::new(
2779 "system",
2780 format!("Inspector pane {}. Press F3 to toggle quickly.", state),
2781 ));
2782 return;
2783 }
2784
2785 if message.trim() == "/refresh" {
2786 self.refresh_workspace();
2787 match list_sessions_with_opencode(&self.workspace_dir).await {
2788 Ok(sessions) => self.update_cached_sessions(sessions),
2789 Err(err) => self.messages.push(ChatMessage::new(
2790 "system",
2791 format!(
2792 "Workspace refreshed, but failed to refresh sessions: {}",
2793 err
2794 ),
2795 )),
2796 }
2797 self.messages.push(ChatMessage::new(
2798 "system",
2799 "Workspace and session cache refreshed.",
2800 ));
2801 return;
2802 }
2803
2804 if message.trim() == "/archive" {
2805 let details = if let Some(path) = &self.chat_archive_path {
2806 format!(
2807 "Chat archive: {}\nCaptured records in this run: {}\n{}",
2808 path.display(),
2809 self.archived_message_count,
2810 self.chat_sync_summary(),
2811 )
2812 } else {
2813 format!(
2814 "Chat archive path unavailable in this environment.\n{}",
2815 self.chat_sync_summary()
2816 )
2817 };
2818 self.messages.push(ChatMessage::new("system", details));
2819 self.scroll = SCROLL_BOTTOM;
2820 return;
2821 }
2822
2823 if message.trim() == "/view" {
2825 self.view_mode = match self.view_mode {
2826 ViewMode::Chat
2827 | ViewMode::SessionPicker
2828 | ViewMode::ModelPicker
2829 | ViewMode::AgentPicker
2830 | ViewMode::BusLog
2831 | ViewMode::Protocol => ViewMode::Swarm,
2832 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
2833 };
2834 return;
2835 }
2836
2837 if message.trim() == "/buslog" || message.trim() == "/bus" {
2839 self.view_mode = ViewMode::BusLog;
2840 return;
2841 }
2842
2843 if message.trim() == "/protocol" || message.trim() == "/registry" {
2845 self.open_protocol_view();
2846 return;
2847 }
2848
2849 if let Some(rest) = command_with_optional_args(&message, "/spawn") {
2851 let default_instructions = |agent_name: &str| {
2852 let profile = agent_profile(agent_name);
2853 format!(
2854 "You are @{agent_name}, codename {codename}.\n\
2855 Profile: {profile_line}.\n\
2856 Personality: {personality}.\n\
2857 Collaboration style: {style}.\n\
2858 Signature move: {signature}.\n\
2859 Be a helpful teammate: explain in simple words, short steps, and a friendly tone.",
2860 codename = profile.codename,
2861 profile_line = profile.profile,
2862 personality = profile.personality,
2863 style = profile.collaboration_style,
2864 signature = profile.signature_move,
2865 )
2866 };
2867
2868 let (name, instructions, used_default_instructions) = if rest.is_empty() {
2869 self.messages.push(ChatMessage::new(
2870 "system",
2871 "Usage: /spawn <name> [instructions]\nEasy mode: /add <name>\nExample: /spawn planner You are a planning agent. Break tasks into steps.",
2872 ));
2873 return;
2874 } else {
2875 let mut parts = rest.splitn(2, char::is_whitespace);
2876 let name = parts.next().unwrap_or("").trim();
2877 if name.is_empty() {
2878 self.messages.push(ChatMessage::new(
2879 "system",
2880 "Usage: /spawn <name> [instructions]\nEasy mode: /add <name>",
2881 ));
2882 return;
2883 }
2884
2885 let instructions = parts.next().map(str::trim).filter(|s| !s.is_empty());
2886 match instructions {
2887 Some(custom) => (name.to_string(), custom.to_string(), false),
2888 None => (name.to_string(), default_instructions(name), true),
2889 }
2890 };
2891
2892 if self.spawned_agents.contains_key(&name) {
2893 self.messages.push(ChatMessage::new(
2894 "system",
2895 format!("Agent @{name} already exists. Use /kill {name} first."),
2896 ));
2897 return;
2898 }
2899
2900 match Session::new().await {
2901 Ok(mut session) => {
2902 session.metadata.model = self
2904 .active_model
2905 .clone()
2906 .or_else(|| config.default_model.clone());
2907 session.agent = name.clone();
2908
2909 session.add_message(crate::provider::Message {
2911 role: Role::System,
2912 content: vec![ContentPart::Text {
2913 text: format!(
2914 "You are @{name}, a specialized sub-agent. {instructions}\n\n\
2915 When you receive a message from another agent (prefixed with their name), \
2916 respond helpfully. Keep responses concise and focused on your specialty."
2917 ),
2918 }],
2919 });
2920
2921 let mut protocol_registered = false;
2923 if let Some(ref bus) = self.bus {
2924 let handle = bus.handle(&name);
2925 handle.announce_ready(vec!["sub-agent".to_string(), name.clone()]);
2926 protocol_registered = bus.registry.get(&name).is_some();
2927 }
2928
2929 let agent = SpawnedAgent {
2930 name: name.clone(),
2931 instructions: instructions.clone(),
2932 session,
2933 is_processing: false,
2934 };
2935 self.spawned_agents.insert(name.clone(), agent);
2936 self.active_spawned_agent = Some(name.clone());
2937
2938 let protocol_line = if protocol_registered {
2939 format!("Protocol registration: ✅ bus://local/{name}")
2940 } else {
2941 "Protocol registration: ⚠ unavailable (bus not connected)".to_string()
2942 };
2943 let profile_summary = format_agent_profile_summary(&name);
2944
2945 self.messages.push(ChatMessage::new(
2946 "system",
2947 format!(
2948 "Spawned agent {}\nProfile: {}\nInstructions: {instructions}\nFocused chat on @{name}. Type directly, or use @{name} <message>.\n{protocol_line}{}",
2949 format_agent_identity(&name),
2950 profile_summary,
2951 if used_default_instructions {
2952 "\nTip: I used friendly default instructions. You can customize with /add <name> <instructions>."
2953 } else {
2954 ""
2955 }
2956 ),
2957 ));
2958 }
2959 Err(e) => {
2960 self.messages.push(ChatMessage::new(
2961 "system",
2962 format!("Failed to spawn agent: {e}"),
2963 ));
2964 }
2965 }
2966 return;
2967 }
2968
2969 if message.trim() == "/agents" {
2971 if self.spawned_agents.is_empty() {
2972 self.messages.push(ChatMessage::new(
2973 "system",
2974 "No agents spawned. Use /spawn <name> <instructions> to create one.",
2975 ));
2976 } else {
2977 let mut lines = vec![format!(
2978 "Active agents: {} (protocol registered: {})",
2979 self.spawned_agents.len(),
2980 self.protocol_registered_count()
2981 )];
2982
2983 let mut agents = self.spawned_agents.iter().collect::<Vec<_>>();
2984 agents.sort_by(|(a, _), (b, _)| a.to_lowercase().cmp(&b.to_lowercase()));
2985
2986 for (name, agent) in agents {
2987 let status = if agent.is_processing {
2988 "⚡ working"
2989 } else {
2990 "● idle"
2991 };
2992 let protocol_status = if self.is_agent_protocol_registered(name) {
2993 "🔗 protocol"
2994 } else {
2995 "⚠ protocol-pending"
2996 };
2997 let focused = if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
2998 " [focused]"
2999 } else {
3000 ""
3001 };
3002 let profile_summary = format_agent_profile_summary(name);
3003 lines.push(format!(
3004 " {} @{name} [{status}] {protocol_status}{focused} — {} | {}",
3005 agent_avatar(name),
3006 profile_summary,
3007 agent.instructions
3008 ));
3009 }
3010 self.messages
3011 .push(ChatMessage::new("system", lines.join("\n")));
3012 self.messages.push(ChatMessage::new(
3013 "system",
3014 "Tip: use /agent to open the picker, /agent <name> to focus, or Ctrl+A.",
3015 ));
3016 }
3017 return;
3018 }
3019
3020 if let Some(name) = command_with_optional_args(&message, "/kill") {
3022 if name.is_empty() {
3023 self.messages
3024 .push(ChatMessage::new("system", "Usage: /kill <name>"));
3025 return;
3026 }
3027
3028 let name = name.to_string();
3029 if self.spawned_agents.remove(&name).is_some() {
3030 self.agent_response_rxs.retain(|(n, _)| n != &name);
3032 self.streaming_agent_texts.remove(&name);
3033 if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
3034 self.active_spawned_agent = None;
3035 }
3036 if let Some(ref bus) = self.bus {
3038 let handle = bus.handle(&name);
3039 handle.announce_shutdown();
3040 }
3041 self.messages.push(ChatMessage::new(
3042 "system",
3043 format!("Agent @{name} removed."),
3044 ));
3045 } else {
3046 self.messages.push(ChatMessage::new(
3047 "system",
3048 format!("No agent named @{name}. Use /agents to list."),
3049 ));
3050 }
3051 return;
3052 }
3053
3054 if message.trim().starts_with('@') {
3056 let trimmed = message.trim();
3057 let (target, content) = match trimmed.split_once(' ') {
3058 Some((mention, rest)) => (
3059 mention.strip_prefix('@').unwrap_or(mention).to_string(),
3060 rest.to_string(),
3061 ),
3062 None => {
3063 self.messages.push(ChatMessage::new(
3064 "system",
3065 format!(
3066 "Usage: @agent_name your message\nAvailable: {}",
3067 if self.spawned_agents.is_empty() {
3068 "none (use /spawn first)".to_string()
3069 } else {
3070 self.spawned_agents
3071 .keys()
3072 .map(|n| format!("@{n}"))
3073 .collect::<Vec<_>>()
3074 .join(", ")
3075 }
3076 ),
3077 ));
3078 return;
3079 }
3080 };
3081
3082 if !self.spawned_agents.contains_key(&target) {
3083 self.messages.push(ChatMessage::new(
3084 "system",
3085 format!(
3086 "No agent named @{target}. Available: {}",
3087 if self.spawned_agents.is_empty() {
3088 "none (use /spawn first)".to_string()
3089 } else {
3090 self.spawned_agents
3091 .keys()
3092 .map(|n| format!("@{n}"))
3093 .collect::<Vec<_>>()
3094 .join(", ")
3095 }
3096 ),
3097 ));
3098 return;
3099 }
3100
3101 self.messages
3103 .push(ChatMessage::new("user", format!("@{target} {content}")));
3104 self.scroll = SCROLL_BOTTOM;
3105
3106 if let Some(ref bus) = self.bus {
3108 let handle = bus.handle("user");
3109 handle.send_to_agent(
3110 &target,
3111 vec![crate::a2a::types::Part::Text {
3112 text: content.clone(),
3113 }],
3114 );
3115 }
3116
3117 self.send_to_agent(&target, &content, config).await;
3119 return;
3120 }
3121
3122 if !message.trim().starts_with('/')
3124 && let Some(target) = self.active_spawned_agent.clone()
3125 {
3126 if !self.spawned_agents.contains_key(&target) {
3127 self.active_spawned_agent = None;
3128 self.messages.push(ChatMessage::new(
3129 "system",
3130 format!(
3131 "Focused agent @{target} is no longer available. Use /agents or /spawn to continue."
3132 ),
3133 ));
3134 return;
3135 }
3136
3137 let content = message.trim().to_string();
3138 if content.is_empty() {
3139 return;
3140 }
3141
3142 self.messages
3143 .push(ChatMessage::new("user", format!("@{target} {content}")));
3144 self.scroll = SCROLL_BOTTOM;
3145
3146 if let Some(ref bus) = self.bus {
3147 let handle = bus.handle("user");
3148 handle.send_to_agent(
3149 &target,
3150 vec![crate::a2a::types::Part::Text {
3151 text: content.clone(),
3152 }],
3153 );
3154 }
3155
3156 self.send_to_agent(&target, &content, config).await;
3157 return;
3158 }
3159
3160 if message.trim() == "/sessions" {
3162 match list_sessions_with_opencode(&self.workspace_dir).await {
3163 Ok(sessions) => {
3164 if sessions.is_empty() {
3165 self.messages
3166 .push(ChatMessage::new("system", "No saved sessions found."));
3167 } else {
3168 self.update_cached_sessions(sessions);
3169 self.session_picker_selected = 0;
3170 self.view_mode = ViewMode::SessionPicker;
3171 }
3172 }
3173 Err(e) => {
3174 self.messages.push(ChatMessage::new(
3175 "system",
3176 format!("Failed to list sessions: {}", e),
3177 ));
3178 }
3179 }
3180 return;
3181 }
3182
3183 if message.trim() == "/resume" || message.trim().starts_with("/resume ") {
3185 let session_id = message
3186 .trim()
3187 .strip_prefix("/resume")
3188 .map(|s| s.trim())
3189 .filter(|s| !s.is_empty());
3190
3191 if session_id.is_none() {
3193 if let Some(checkpoint) = RelayCheckpoint::load().await {
3194 self.messages.push(ChatMessage::new("user", "/resume"));
3195 self.resume_autochat_relay(checkpoint).await;
3196 return;
3197 }
3198 }
3199
3200 let loaded = if let Some(id) = session_id {
3201 if let Some(oc_id) = id.strip_prefix("opencode_") {
3202 if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
3203 Session::from_opencode(oc_id, &storage).await
3204 } else {
3205 Err(anyhow::anyhow!("OpenCode storage not available"))
3206 }
3207 } else {
3208 Session::load(id).await
3209 }
3210 } else {
3211 match Session::last_for_directory(Some(&self.workspace_dir)).await {
3212 Ok(s) => Ok(s),
3213 Err(_) => Session::last_opencode_for_directory(&self.workspace_dir).await,
3214 }
3215 };
3216
3217 match loaded {
3218 Ok(session) => {
3219 self.messages.clear();
3221 self.messages.push(ChatMessage::new(
3222 "system",
3223 format!(
3224 "Resumed session: {}\nCreated: {}\n{} messages loaded",
3225 session.title.as_deref().unwrap_or("(untitled)"),
3226 session.created_at.format("%Y-%m-%d %H:%M"),
3227 session.messages.len()
3228 ),
3229 ));
3230
3231 for msg in &session.messages {
3232 let role_str = match msg.role {
3233 Role::System => "system",
3234 Role::User => "user",
3235 Role::Assistant => "assistant",
3236 Role::Tool => "tool",
3237 };
3238
3239 for part in &msg.content {
3241 match part {
3242 ContentPart::Text { text } => {
3243 if !text.is_empty() {
3244 self.messages
3245 .push(ChatMessage::new(role_str, text.clone()));
3246 }
3247 }
3248 ContentPart::Image { url, mime_type } => {
3249 self.messages.push(
3250 ChatMessage::new(role_str, "").with_message_type(
3251 MessageType::Image {
3252 url: url.clone(),
3253 mime_type: mime_type.clone(),
3254 },
3255 ),
3256 );
3257 }
3258 ContentPart::ToolCall {
3259 name, arguments, ..
3260 } => {
3261 let (preview, truncated) = build_tool_arguments_preview(
3262 name,
3263 arguments,
3264 TOOL_ARGS_PREVIEW_MAX_LINES,
3265 TOOL_ARGS_PREVIEW_MAX_BYTES,
3266 );
3267 self.messages.push(
3268 ChatMessage::new(role_str, format!("🔧 {name}"))
3269 .with_message_type(MessageType::ToolCall {
3270 name: name.clone(),
3271 arguments_preview: preview,
3272 arguments_len: arguments.len(),
3273 truncated,
3274 }),
3275 );
3276 }
3277 ContentPart::ToolResult { content, .. } => {
3278 let truncated = truncate_with_ellipsis(content, 500);
3279 let (preview, preview_truncated) = build_text_preview(
3280 content,
3281 TOOL_OUTPUT_PREVIEW_MAX_LINES,
3282 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
3283 );
3284 self.messages.push(
3285 ChatMessage::new(
3286 role_str,
3287 format!("✅ Result\n{truncated}"),
3288 )
3289 .with_message_type(MessageType::ToolResult {
3290 name: "tool".to_string(),
3291 output_preview: preview,
3292 output_len: content.len(),
3293 truncated: preview_truncated,
3294 success: true,
3295 duration_ms: None,
3296 }),
3297 );
3298 }
3299 ContentPart::File { path, mime_type } => {
3300 self.messages.push(
3301 ChatMessage::new(role_str, format!("📎 {}", path))
3302 .with_message_type(MessageType::File {
3303 path: path.clone(),
3304 mime_type: mime_type.clone(),
3305 }),
3306 );
3307 }
3308 ContentPart::Thinking { text } => {
3309 if !text.is_empty() {
3310 self.messages.push(
3311 ChatMessage::new(role_str, text.clone())
3312 .with_message_type(MessageType::Thinking(
3313 text.clone(),
3314 )),
3315 );
3316 }
3317 }
3318 }
3319 }
3320 }
3321
3322 self.current_agent = session.agent.clone();
3323 self.session = Some(session);
3324 self.scroll = SCROLL_BOTTOM;
3325 }
3326 Err(e) => {
3327 self.messages.push(ChatMessage::new(
3328 "system",
3329 format!("Failed to load session: {}", e),
3330 ));
3331 }
3332 }
3333 return;
3334 }
3335
3336 if message.trim() == "/model" || message.trim().starts_with("/model ") {
3338 let direct_model = message
3339 .trim()
3340 .strip_prefix("/model")
3341 .map(|s| s.trim())
3342 .filter(|s| !s.is_empty());
3343
3344 if let Some(model_str) = direct_model {
3345 self.active_model = Some(model_str.to_string());
3347 if let Some(session) = self.session.as_mut() {
3348 session.metadata.model = Some(model_str.to_string());
3349 }
3350 self.messages.push(ChatMessage::new(
3351 "system",
3352 format!("Model set to: {}", model_str),
3353 ));
3354 } else {
3355 self.open_model_picker(config).await;
3357 }
3358 return;
3359 }
3360
3361 if message.trim() == "/undo" {
3363 let mut found_user = false;
3366 while let Some(msg) = self.messages.last() {
3367 if msg.role == "user" {
3368 if found_user {
3369 break; }
3371 found_user = true;
3372 }
3373 if msg.role == "system" && !found_user {
3375 break;
3376 }
3377 self.messages.pop();
3378 }
3379
3380 if !found_user {
3381 self.messages
3382 .push(ChatMessage::new("system", "Nothing to undo."));
3383 return;
3384 }
3385
3386 if let Some(session) = self.session.as_mut() {
3389 let mut found_session_user = false;
3390 while let Some(msg) = session.messages.last() {
3391 if msg.role == crate::provider::Role::User {
3392 if found_session_user {
3393 break;
3394 }
3395 found_session_user = true;
3396 }
3397 if msg.role == crate::provider::Role::System && !found_session_user {
3398 break;
3399 }
3400 session.messages.pop();
3401 }
3402 if let Err(e) = session.save().await {
3403 tracing::warn!(error = %e, "Failed to save session after undo");
3404 }
3405 }
3406
3407 self.messages.push(ChatMessage::new(
3408 "system",
3409 "Undid last message and response.",
3410 ));
3411 self.scroll = SCROLL_BOTTOM;
3412 return;
3413 }
3414
3415 if message.trim() == "/new" {
3417 self.session = None;
3418 self.messages.clear();
3419 self.messages.push(ChatMessage::new(
3420 "system",
3421 "Started a new session. Previous session was saved.",
3422 ));
3423 return;
3424 }
3425
3426 self.messages
3428 .push(ChatMessage::new("user", message.clone()));
3429
3430 self.scroll = SCROLL_BOTTOM;
3432
3433 let current_agent = self.current_agent.clone();
3434 let model = self
3435 .active_model
3436 .clone()
3437 .or_else(|| {
3438 config
3439 .agents
3440 .get(¤t_agent)
3441 .and_then(|agent| agent.model.clone())
3442 })
3443 .or_else(|| config.default_model.clone())
3444 .or_else(|| Some("zai/glm-5".to_string()));
3445
3446 if self.session.is_none() {
3448 match Session::new().await {
3449 Ok(session) => {
3450 self.session = Some(session);
3451 }
3452 Err(err) => {
3453 tracing::error!(error = %err, "Failed to create session");
3454 self.messages
3455 .push(ChatMessage::new("assistant", format!("Error: {err}")));
3456 return;
3457 }
3458 }
3459 }
3460
3461 let session = match self.session.as_mut() {
3462 Some(session) => session,
3463 None => {
3464 self.messages.push(ChatMessage::new(
3465 "assistant",
3466 "Error: session not initialized",
3467 ));
3468 return;
3469 }
3470 };
3471
3472 if let Some(model) = model {
3473 session.metadata.model = Some(model);
3474 }
3475
3476 session.agent = current_agent;
3477
3478 self.is_processing = true;
3480 self.processing_message = Some("Thinking...".to_string());
3481 self.current_tool = None;
3482 self.current_tool_started_at = None;
3483 self.processing_started_at = Some(Instant::now());
3484 self.streaming_text = None;
3485
3486 if self.provider_registry.is_none() {
3488 match crate::provider::ProviderRegistry::from_vault().await {
3489 Ok(registry) => {
3490 self.provider_registry = Some(std::sync::Arc::new(registry));
3491 }
3492 Err(err) => {
3493 tracing::error!(error = %err, "Failed to load provider registry");
3494 self.messages.push(ChatMessage::new(
3495 "assistant",
3496 format!("Error loading providers: {err}"),
3497 ));
3498 self.is_processing = false;
3499 return;
3500 }
3501 }
3502 }
3503 let registry = self.provider_registry.clone().unwrap();
3504
3505 let (tx, rx) = mpsc::channel(100);
3507 self.response_rx = Some(rx);
3508
3509 let session_clone = session.clone();
3511 let message_clone = message.clone();
3512
3513 tokio::spawn(async move {
3515 let mut session = session_clone;
3516 if let Err(err) = session
3517 .prompt_with_events(&message_clone, tx.clone(), registry)
3518 .await
3519 {
3520 tracing::error!(error = %err, "Agent processing failed");
3521 let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
3522 let _ = tx.send(SessionEvent::Done).await;
3523 }
3524 });
3525 }
3526
3527 fn handle_response(&mut self, event: SessionEvent) {
3528 self.scroll = SCROLL_BOTTOM;
3530
3531 match event {
3532 SessionEvent::Thinking => {
3533 self.processing_message = Some("Thinking...".to_string());
3534 self.current_tool = None;
3535 self.current_tool_started_at = None;
3536 if self.processing_started_at.is_none() {
3537 self.processing_started_at = Some(Instant::now());
3538 }
3539 }
3540 SessionEvent::ToolCallStart { name, arguments } => {
3541 if let Some(text) = self.streaming_text.take() {
3543 if !text.is_empty() {
3544 self.messages.push(ChatMessage::new("assistant", text));
3545 }
3546 }
3547 self.processing_message = Some(format!("Running {}...", name));
3548 self.current_tool = Some(name.clone());
3549 self.current_tool_started_at = Some(Instant::now());
3550 self.tool_call_count += 1;
3551
3552 let (preview, truncated) = build_tool_arguments_preview(
3553 &name,
3554 &arguments,
3555 TOOL_ARGS_PREVIEW_MAX_LINES,
3556 TOOL_ARGS_PREVIEW_MAX_BYTES,
3557 );
3558 self.messages.push(
3559 ChatMessage::new("tool", format!("🔧 {}", name)).with_message_type(
3560 MessageType::ToolCall {
3561 name,
3562 arguments_preview: preview,
3563 arguments_len: arguments.len(),
3564 truncated,
3565 },
3566 ),
3567 );
3568 }
3569 SessionEvent::ToolCallComplete {
3570 name,
3571 output,
3572 success,
3573 } => {
3574 let icon = if success { "✓" } else { "✗" };
3575 let duration_ms = self
3576 .current_tool_started_at
3577 .take()
3578 .map(|started| started.elapsed().as_millis() as u64);
3579
3580 let (preview, truncated) = build_text_preview(
3581 &output,
3582 TOOL_OUTPUT_PREVIEW_MAX_LINES,
3583 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
3584 );
3585 self.messages.push(
3586 ChatMessage::new("tool", format!("{} {}", icon, name)).with_message_type(
3587 MessageType::ToolResult {
3588 name,
3589 output_preview: preview,
3590 output_len: output.len(),
3591 truncated,
3592 success,
3593 duration_ms,
3594 },
3595 ),
3596 );
3597 self.current_tool = None;
3598 self.processing_message = Some("Thinking...".to_string());
3599 }
3600 SessionEvent::TextChunk(text) => {
3601 self.streaming_text = Some(text);
3603 }
3604 SessionEvent::ThinkingComplete(text) => {
3605 if !text.is_empty() {
3606 self.messages.push(
3607 ChatMessage::new("assistant", &text)
3608 .with_message_type(MessageType::Thinking(text)),
3609 );
3610 }
3611 }
3612 SessionEvent::TextComplete(text) => {
3613 self.streaming_text = None;
3615 if !text.is_empty() {
3616 self.messages.push(ChatMessage::new("assistant", text));
3617 }
3618 }
3619 SessionEvent::UsageReport {
3620 prompt_tokens,
3621 completion_tokens,
3622 duration_ms,
3623 model,
3624 } => {
3625 let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
3626 let meta = UsageMeta {
3627 prompt_tokens,
3628 completion_tokens,
3629 duration_ms,
3630 cost_usd,
3631 };
3632 if let Some(msg) = self
3634 .messages
3635 .iter_mut()
3636 .rev()
3637 .find(|m| m.role == "assistant")
3638 {
3639 msg.usage_meta = Some(meta);
3640 }
3641 }
3642 SessionEvent::SessionSync(session) => {
3643 self.session = Some(session);
3646 }
3647 SessionEvent::Error(err) => {
3648 self.current_tool_started_at = None;
3649 self.messages
3650 .push(ChatMessage::new("assistant", format!("Error: {}", err)));
3651 }
3652 SessionEvent::Done => {
3653 self.is_processing = false;
3654 self.processing_message = None;
3655 self.current_tool = None;
3656 self.current_tool_started_at = None;
3657 self.processing_started_at = None;
3658 self.streaming_text = None;
3659 self.response_rx = None;
3660 }
3661 }
3662 }
3663
3664 async fn send_to_agent(&mut self, agent_name: &str, message: &str, _config: &Config) {
3666 if self.provider_registry.is_none() {
3668 match crate::provider::ProviderRegistry::from_vault().await {
3669 Ok(registry) => {
3670 self.provider_registry = Some(std::sync::Arc::new(registry));
3671 }
3672 Err(err) => {
3673 self.messages.push(ChatMessage::new(
3674 "system",
3675 format!("Error loading providers: {err}"),
3676 ));
3677 return;
3678 }
3679 }
3680 }
3681 let registry = self.provider_registry.clone().unwrap();
3682
3683 let agent = match self.spawned_agents.get_mut(agent_name) {
3684 Some(a) => a,
3685 None => return,
3686 };
3687
3688 agent.is_processing = true;
3689 self.streaming_agent_texts.remove(agent_name);
3690 let session_clone = agent.session.clone();
3691 let msg_clone = message.to_string();
3692 let agent_name_owned = agent_name.to_string();
3693 let bus_arc = self.bus.clone();
3694
3695 let (tx, rx) = mpsc::channel(100);
3696 self.agent_response_rxs.push((agent_name.to_string(), rx));
3697
3698 tokio::spawn(async move {
3699 let mut session = session_clone;
3700 if let Err(err) = session
3701 .prompt_with_events(&msg_clone, tx.clone(), registry)
3702 .await
3703 {
3704 tracing::error!(agent = %agent_name_owned, error = %err, "Spawned agent failed");
3705 let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
3706 let _ = tx.send(SessionEvent::Done).await;
3707 }
3708
3709 if let Some(ref bus) = bus_arc {
3711 let handle = bus.handle(&agent_name_owned);
3712 handle.send(
3713 format!("agent.{agent_name_owned}.events"),
3714 crate::bus::BusMessage::AgentMessage {
3715 from: agent_name_owned.clone(),
3716 to: "user".to_string(),
3717 parts: vec![crate::a2a::types::Part::Text {
3718 text: "(response complete)".to_string(),
3719 }],
3720 },
3721 );
3722 }
3723 });
3724 }
3725
3726 fn handle_agent_response(&mut self, agent_name: &str, event: SessionEvent) {
3728 self.scroll = SCROLL_BOTTOM;
3729
3730 match event {
3731 SessionEvent::Thinking => {
3732 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
3734 agent.is_processing = true;
3735 }
3736 }
3737 SessionEvent::ToolCallStart { name, arguments } => {
3738 self.streaming_agent_texts.remove(agent_name);
3739 self.agent_tool_started_at
3740 .insert(agent_name.to_string(), Instant::now());
3741 let (preview, truncated) = build_tool_arguments_preview(
3742 &name,
3743 &arguments,
3744 TOOL_ARGS_PREVIEW_MAX_LINES,
3745 TOOL_ARGS_PREVIEW_MAX_BYTES,
3746 );
3747 self.messages.push(
3748 ChatMessage::new(
3749 "tool",
3750 format!("🔧 {} → {name}", format_agent_identity(agent_name)),
3751 )
3752 .with_message_type(MessageType::ToolCall {
3753 name,
3754 arguments_preview: preview,
3755 arguments_len: arguments.len(),
3756 truncated,
3757 })
3758 .with_agent_name(agent_name),
3759 );
3760 }
3761 SessionEvent::ToolCallComplete {
3762 name,
3763 output,
3764 success,
3765 } => {
3766 self.streaming_agent_texts.remove(agent_name);
3767 let icon = if success { "✓" } else { "✗" };
3768 let duration_ms = self
3769 .agent_tool_started_at
3770 .remove(agent_name)
3771 .map(|started| started.elapsed().as_millis() as u64);
3772 let (preview, truncated) = build_text_preview(
3773 &output,
3774 TOOL_OUTPUT_PREVIEW_MAX_LINES,
3775 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
3776 );
3777 self.messages.push(
3778 ChatMessage::new(
3779 "tool",
3780 format!("{icon} {} → {name}", format_agent_identity(agent_name)),
3781 )
3782 .with_message_type(MessageType::ToolResult {
3783 name,
3784 output_preview: preview,
3785 output_len: output.len(),
3786 truncated,
3787 success,
3788 duration_ms,
3789 })
3790 .with_agent_name(agent_name),
3791 );
3792 }
3793 SessionEvent::TextChunk(text) => {
3794 if text.is_empty() {
3795 self.streaming_agent_texts.remove(agent_name);
3796 } else {
3797 self.streaming_agent_texts
3798 .insert(agent_name.to_string(), text);
3799 }
3800 }
3801 SessionEvent::ThinkingComplete(text) => {
3802 self.streaming_agent_texts.remove(agent_name);
3803 if !text.is_empty() {
3804 self.messages.push(
3805 ChatMessage::new("assistant", &text)
3806 .with_message_type(MessageType::Thinking(text))
3807 .with_agent_name(agent_name),
3808 );
3809 }
3810 }
3811 SessionEvent::TextComplete(text) => {
3812 self.streaming_agent_texts.remove(agent_name);
3813 if !text.is_empty() {
3814 self.messages
3815 .push(ChatMessage::new("assistant", &text).with_agent_name(agent_name));
3816 }
3817 }
3818 SessionEvent::UsageReport {
3819 prompt_tokens,
3820 completion_tokens,
3821 duration_ms,
3822 model,
3823 } => {
3824 let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
3825 let meta = UsageMeta {
3826 prompt_tokens,
3827 completion_tokens,
3828 duration_ms,
3829 cost_usd,
3830 };
3831 if let Some(msg) =
3832 self.messages.iter_mut().rev().find(|m| {
3833 m.role == "assistant" && m.agent_name.as_deref() == Some(agent_name)
3834 })
3835 {
3836 msg.usage_meta = Some(meta);
3837 }
3838 }
3839 SessionEvent::SessionSync(session) => {
3840 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
3841 agent.session = session;
3842 }
3843 }
3844 SessionEvent::Error(err) => {
3845 self.streaming_agent_texts.remove(agent_name);
3846 self.agent_tool_started_at.remove(agent_name);
3847 self.messages.push(
3848 ChatMessage::new("assistant", format!("Error: {err}"))
3849 .with_agent_name(agent_name),
3850 );
3851 }
3852 SessionEvent::Done => {
3853 self.streaming_agent_texts.remove(agent_name);
3854 self.agent_tool_started_at.remove(agent_name);
3855 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
3856 agent.is_processing = false;
3857 }
3858 }
3859 }
3860 }
3861
3862 fn handle_autochat_event(&mut self, event: AutochatUiEvent) -> bool {
3863 match event {
3864 AutochatUiEvent::Progress(status) => {
3865 self.autochat_status = Some(status);
3866 false
3867 }
3868 AutochatUiEvent::SystemMessage(text) => {
3869 self.autochat_status = Some(
3870 text.lines()
3871 .next()
3872 .unwrap_or("Relay update")
3873 .trim()
3874 .to_string(),
3875 );
3876 self.messages.push(ChatMessage::new("system", text));
3877 self.scroll = SCROLL_BOTTOM;
3878 false
3879 }
3880 AutochatUiEvent::AgentEvent { agent_name, event } => {
3881 self.autochat_status = Some(format!("Streaming from @{agent_name}…"));
3882 self.handle_agent_response(&agent_name, event);
3883 false
3884 }
3885 AutochatUiEvent::Completed { summary } => {
3886 self.autochat_status = Some("Completed".to_string());
3887 self.messages.push(ChatMessage::new("assistant", summary));
3888 self.scroll = SCROLL_BOTTOM;
3889 true
3890 }
3891 }
3892 }
3893
3894 fn handle_swarm_event(&mut self, event: SwarmEvent) {
3896 self.swarm_state.handle_event(event.clone());
3897
3898 if let SwarmEvent::Complete { success, ref stats } = event {
3900 self.view_mode = ViewMode::Chat;
3901 let summary = if success {
3902 format!(
3903 "Swarm completed successfully.\n\
3904 Subtasks: {} completed, {} failed\n\
3905 Total tool calls: {}\n\
3906 Time: {:.1}s (speedup: {:.1}x)",
3907 stats.subagents_completed,
3908 stats.subagents_failed,
3909 stats.total_tool_calls,
3910 stats.execution_time_ms as f64 / 1000.0,
3911 stats.speedup_factor
3912 )
3913 } else {
3914 format!(
3915 "Swarm completed with failures.\n\
3916 Subtasks: {} completed, {} failed\n\
3917 Check the subtask results for details.",
3918 stats.subagents_completed, stats.subagents_failed
3919 )
3920 };
3921 self.messages.push(ChatMessage::new("system", &summary));
3922 self.swarm_rx = None;
3923 }
3924
3925 if let SwarmEvent::Error(ref err) = event {
3926 self.messages
3927 .push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
3928 }
3929 }
3930
3931 fn handle_ralph_event(&mut self, event: RalphEvent) {
3933 self.ralph_state.handle_event(event.clone());
3934
3935 if let RalphEvent::Complete {
3937 ref status,
3938 passed,
3939 total,
3940 } = event
3941 {
3942 self.view_mode = ViewMode::Chat;
3943 let summary = format!(
3944 "Ralph loop finished: {}\n\
3945 Stories: {}/{} passed",
3946 status, passed, total
3947 );
3948 self.messages.push(ChatMessage::new("system", &summary));
3949 self.ralph_rx = None;
3950 }
3951
3952 if let RalphEvent::Error(ref err) = event {
3953 self.messages
3954 .push(ChatMessage::new("system", &format!("Ralph error: {}", err)));
3955 }
3956 }
3957
3958 async fn start_ralph_execution(&mut self, prd_path: String, config: &Config) {
3960 self.messages
3962 .push(ChatMessage::new("user", format!("/ralph {}", prd_path)));
3963
3964 let model = self
3966 .active_model
3967 .clone()
3968 .or_else(|| config.default_model.clone())
3969 .or_else(|| Some("zai/glm-5".to_string()));
3970
3971 let model = match model {
3972 Some(m) => m,
3973 None => {
3974 self.messages.push(ChatMessage::new(
3975 "system",
3976 "No model configured. Use /model to select one first.",
3977 ));
3978 return;
3979 }
3980 };
3981
3982 let prd_file = std::path::PathBuf::from(&prd_path);
3984 if !prd_file.exists() {
3985 self.messages.push(ChatMessage::new(
3986 "system",
3987 format!("PRD file not found: {}", prd_path),
3988 ));
3989 return;
3990 }
3991
3992 let (tx, rx) = mpsc::channel(200);
3994 self.ralph_rx = Some(rx);
3995
3996 self.view_mode = ViewMode::Ralph;
3998 self.ralph_state = RalphViewState::new();
3999
4000 let ralph_config = RalphConfig {
4002 prd_path: prd_path.clone(),
4003 max_iterations: 10,
4004 progress_path: "progress.txt".to_string(),
4005 quality_checks_enabled: true,
4006 auto_commit: true,
4007 model: Some(model.clone()),
4008 use_rlm: false,
4009 parallel_enabled: true,
4010 max_concurrent_stories: 3,
4011 worktree_enabled: true,
4012 story_timeout_secs: 300,
4013 conflict_timeout_secs: 120,
4014 };
4015
4016 let (provider_name, model_name) = if let Some(pos) = model.find('/') {
4018 (model[..pos].to_string(), model[pos + 1..].to_string())
4019 } else {
4020 (model.clone(), model.clone())
4021 };
4022
4023 let prd_path_clone = prd_path.clone();
4024 let tx_clone = tx.clone();
4025
4026 tokio::spawn(async move {
4028 let provider = match crate::provider::ProviderRegistry::from_vault().await {
4030 Ok(registry) => match registry.get(&provider_name) {
4031 Some(p) => p,
4032 None => {
4033 let _ = tx_clone
4034 .send(RalphEvent::Error(format!(
4035 "Provider '{}' not found",
4036 provider_name
4037 )))
4038 .await;
4039 return;
4040 }
4041 },
4042 Err(e) => {
4043 let _ = tx_clone
4044 .send(RalphEvent::Error(format!(
4045 "Failed to load providers: {}",
4046 e
4047 )))
4048 .await;
4049 return;
4050 }
4051 };
4052
4053 let prd_path_buf = std::path::PathBuf::from(&prd_path_clone);
4054 match RalphLoop::new(prd_path_buf, provider, model_name, ralph_config).await {
4055 Ok(ralph) => {
4056 let mut ralph = ralph.with_event_tx(tx_clone.clone());
4057 match ralph.run().await {
4058 Ok(_state) => {
4059 }
4061 Err(e) => {
4062 let _ = tx_clone.send(RalphEvent::Error(e.to_string())).await;
4063 }
4064 }
4065 }
4066 Err(e) => {
4067 let _ = tx_clone
4068 .send(RalphEvent::Error(format!(
4069 "Failed to initialize Ralph: {}",
4070 e
4071 )))
4072 .await;
4073 }
4074 }
4075 });
4076
4077 self.messages.push(ChatMessage::new(
4078 "system",
4079 format!("Starting Ralph loop with PRD: {}", prd_path),
4080 ));
4081 }
4082
4083 async fn start_swarm_execution(&mut self, task: String, config: &Config) {
4085 self.messages
4087 .push(ChatMessage::new("user", format!("/swarm {}", task)));
4088
4089 let model = config
4091 .default_model
4092 .clone()
4093 .or_else(|| Some("zai/glm-5".to_string()));
4094
4095 let swarm_config = SwarmConfig {
4097 model,
4098 max_subagents: 10,
4099 max_steps_per_subagent: 50,
4100 worktree_enabled: true,
4101 worktree_auto_merge: true,
4102 working_dir: Some(
4103 std::env::current_dir()
4104 .map(|p| p.to_string_lossy().to_string())
4105 .unwrap_or_else(|_| ".".to_string()),
4106 ),
4107 ..Default::default()
4108 };
4109
4110 let (tx, rx) = mpsc::channel(100);
4112 self.swarm_rx = Some(rx);
4113
4114 self.view_mode = ViewMode::Swarm;
4116 self.swarm_state = SwarmViewState::new();
4117
4118 let _ = tx
4120 .send(SwarmEvent::Started {
4121 task: task.clone(),
4122 total_subtasks: 0,
4123 })
4124 .await;
4125
4126 let task_clone = task;
4128 let bus_arc = self.bus.clone();
4129 tokio::spawn(async move {
4130 let mut executor = SwarmExecutor::new(swarm_config).with_event_tx(tx.clone());
4132 if let Some(bus) = bus_arc {
4133 executor = executor.with_bus(bus);
4134 }
4135 let result = executor
4136 .execute(&task_clone, DecompositionStrategy::Automatic)
4137 .await;
4138
4139 match result {
4140 Ok(swarm_result) => {
4141 let _ = tx
4142 .send(SwarmEvent::Complete {
4143 success: swarm_result.success,
4144 stats: swarm_result.stats,
4145 })
4146 .await;
4147 }
4148 Err(e) => {
4149 let _ = tx.send(SwarmEvent::Error(e.to_string())).await;
4150 }
4151 }
4152 });
4153 }
4154
4155 async fn open_model_picker(&mut self, config: &Config) {
4157 let mut models: Vec<(String, String, String)> = Vec::new();
4158
4159 match crate::provider::ProviderRegistry::from_vault().await {
4161 Ok(registry) => {
4162 for provider_name in registry.list() {
4163 if let Some(provider) = registry.get(provider_name) {
4164 match provider.list_models().await {
4165 Ok(model_list) => {
4166 for m in model_list {
4167 let label = format!("{}/{}", provider_name, m.id);
4168 let value = format!("{}/{}", provider_name, m.id);
4169 let name = m.name.clone();
4170 models.push((label, value, name));
4171 }
4172 }
4173 Err(e) => {
4174 tracing::warn!(
4175 "Failed to list models for {}: {}",
4176 provider_name,
4177 e
4178 );
4179 }
4180 }
4181 }
4182 }
4183 }
4184 Err(e) => {
4185 tracing::warn!("Failed to load provider registry: {}", e);
4186 }
4187 }
4188
4189 if models.is_empty() {
4191 if let Ok(registry) = crate::provider::ProviderRegistry::from_config(config).await {
4192 for provider_name in registry.list() {
4193 if let Some(provider) = registry.get(provider_name) {
4194 if let Ok(model_list) = provider.list_models().await {
4195 for m in model_list {
4196 let label = format!("{}/{}", provider_name, m.id);
4197 let value = format!("{}/{}", provider_name, m.id);
4198 let name = m.name.clone();
4199 models.push((label, value, name));
4200 }
4201 }
4202 }
4203 }
4204 }
4205 }
4206
4207 if models.is_empty() {
4208 self.messages.push(ChatMessage::new(
4209 "system",
4210 "No models found. Check provider configuration (Vault or config).",
4211 ));
4212 } else {
4213 models.sort_by(|a, b| a.0.cmp(&b.0));
4215 self.model_picker_list = models;
4216 self.model_picker_selected = 0;
4217 self.model_picker_filter.clear();
4218 self.view_mode = ViewMode::ModelPicker;
4219 }
4220 }
4221
4222 fn filtered_sessions(&self) -> Vec<(usize, &SessionSummary)> {
4224 if self.session_picker_filter.is_empty() {
4225 self.session_picker_list.iter().enumerate().collect()
4226 } else {
4227 let filter = self.session_picker_filter.to_lowercase();
4228 self.session_picker_list
4229 .iter()
4230 .enumerate()
4231 .filter(|(_, s)| {
4232 s.title
4233 .as_deref()
4234 .unwrap_or("")
4235 .to_lowercase()
4236 .contains(&filter)
4237 || s.agent.to_lowercase().contains(&filter)
4238 || s.id.to_lowercase().contains(&filter)
4239 })
4240 .collect()
4241 }
4242 }
4243
4244 fn filtered_models(&self) -> Vec<(usize, &(String, String, String))> {
4246 if self.model_picker_filter.is_empty() {
4247 self.model_picker_list.iter().enumerate().collect()
4248 } else {
4249 let filter = self.model_picker_filter.to_lowercase();
4250 self.model_picker_list
4251 .iter()
4252 .enumerate()
4253 .filter(|(_, (label, _, name))| {
4254 label.to_lowercase().contains(&filter) || name.to_lowercase().contains(&filter)
4255 })
4256 .collect()
4257 }
4258 }
4259
4260 fn filtered_spawned_agents(&self) -> Vec<(String, String, bool, bool)> {
4262 let mut agents: Vec<(String, String, bool, bool)> = self
4263 .spawned_agents
4264 .iter()
4265 .map(|(name, agent)| {
4266 let protocol_registered = self.is_agent_protocol_registered(name);
4267 (
4268 name.clone(),
4269 agent.instructions.clone(),
4270 agent.is_processing,
4271 protocol_registered,
4272 )
4273 })
4274 .collect();
4275
4276 agents.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
4277
4278 if self.agent_picker_filter.is_empty() {
4279 agents
4280 } else {
4281 let filter = self.agent_picker_filter.to_lowercase();
4282 agents
4283 .into_iter()
4284 .filter(|(name, instructions, _, _)| {
4285 name.to_lowercase().contains(&filter)
4286 || instructions.to_lowercase().contains(&filter)
4287 })
4288 .collect()
4289 }
4290 }
4291
4292 fn open_agent_picker(&mut self) {
4294 if self.spawned_agents.is_empty() {
4295 self.messages.push(ChatMessage::new(
4296 "system",
4297 "No agents spawned yet. Use /spawn <name> <instructions> first.",
4298 ));
4299 return;
4300 }
4301
4302 self.agent_picker_filter.clear();
4303 let filtered = self.filtered_spawned_agents();
4304 self.agent_picker_selected = if let Some(active) = &self.active_spawned_agent {
4305 filtered
4306 .iter()
4307 .position(|(name, _, _, _)| name == active)
4308 .unwrap_or(0)
4309 } else {
4310 0
4311 };
4312 self.view_mode = ViewMode::AgentPicker;
4313 }
4314
4315 fn navigate_history(&mut self, direction: isize) {
4316 if self.command_history.is_empty() {
4317 return;
4318 }
4319
4320 let history_len = self.command_history.len();
4321 let new_index = match self.history_index {
4322 Some(current) => {
4323 let new = current as isize + direction;
4324 if new < 0 {
4325 None
4326 } else if new >= history_len as isize {
4327 Some(history_len - 1)
4328 } else {
4329 Some(new as usize)
4330 }
4331 }
4332 None => {
4333 if direction > 0 {
4334 Some(0)
4335 } else {
4336 Some(history_len.saturating_sub(1))
4337 }
4338 }
4339 };
4340
4341 self.history_index = new_index;
4342 if let Some(index) = new_index {
4343 self.input = self.command_history[index].clone();
4344 self.cursor_position = self.input.len();
4345 } else {
4346 self.input.clear();
4347 self.cursor_position = 0;
4348 }
4349 }
4350
4351 fn search_history(&mut self) {
4352 if self.command_history.is_empty() {
4354 return;
4355 }
4356
4357 let search_term = self.input.trim().to_lowercase();
4358
4359 if search_term.is_empty() {
4360 if !self.command_history.is_empty() {
4362 self.input = self.command_history.last().unwrap().clone();
4363 self.cursor_position = self.input.len();
4364 self.history_index = Some(self.command_history.len() - 1);
4365 }
4366 return;
4367 }
4368
4369 for (index, cmd) in self.command_history.iter().enumerate().rev() {
4371 if cmd.to_lowercase().starts_with(&search_term) {
4372 self.input = cmd.clone();
4373 self.cursor_position = self.input.len();
4374 self.history_index = Some(index);
4375 return;
4376 }
4377 }
4378
4379 for (index, cmd) in self.command_history.iter().enumerate().rev() {
4381 if cmd.to_lowercase().contains(&search_term) {
4382 self.input = cmd.clone();
4383 self.cursor_position = self.input.len();
4384 self.history_index = Some(index);
4385 return;
4386 }
4387 }
4388 }
4389
4390 fn autochat_status_label(&self) -> Option<String> {
4391 if !self.autochat_running {
4392 return None;
4393 }
4394
4395 let elapsed = self
4396 .autochat_started_at
4397 .map(|started| {
4398 let elapsed = started.elapsed();
4399 if elapsed.as_secs() >= 60 {
4400 format!("{}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
4401 } else {
4402 format!("{:.1}s", elapsed.as_secs_f64())
4403 }
4404 })
4405 .unwrap_or_else(|| "0.0s".to_string());
4406
4407 let phase = self
4408 .autochat_status
4409 .as_deref()
4410 .unwrap_or("Relay is running…")
4411 .to_string();
4412
4413 Some(format!(
4414 "{} Autochat {elapsed} • {phase}",
4415 current_spinner_frame()
4416 ))
4417 }
4418
4419 fn chat_sync_summary(&self) -> String {
4420 if self.chat_sync_rx.is_none() && self.chat_sync_status.is_none() {
4421 if self.secure_environment {
4422 return "Remote sync: REQUIRED in secure environment (not running)".to_string();
4423 }
4424 return "Remote sync: disabled (set CODETETHER_CHAT_SYNC_ENABLED=true)".to_string();
4425 }
4426
4427 let status = self
4428 .chat_sync_status
4429 .as_deref()
4430 .unwrap_or("Remote sync active")
4431 .to_string();
4432 let last_success = self
4433 .chat_sync_last_success
4434 .as_deref()
4435 .unwrap_or("never")
4436 .to_string();
4437 let last_error = self
4438 .chat_sync_last_error
4439 .as_deref()
4440 .unwrap_or("none")
4441 .to_string();
4442
4443 format!(
4444 "Remote sync: {status}\nUploaded batches: {} ({})\nLast success: {last_success}\nLast error: {last_error}",
4445 self.chat_sync_uploaded_batches,
4446 format_bytes(self.chat_sync_uploaded_bytes)
4447 )
4448 }
4449
4450 fn handle_chat_sync_event(&mut self, event: ChatSyncUiEvent) {
4451 match event {
4452 ChatSyncUiEvent::Status(status) => {
4453 self.chat_sync_status = Some(status);
4454 }
4455 ChatSyncUiEvent::BatchUploaded {
4456 bytes,
4457 records,
4458 object_key,
4459 } => {
4460 self.chat_sync_uploaded_bytes = self.chat_sync_uploaded_bytes.saturating_add(bytes);
4461 self.chat_sync_uploaded_batches = self.chat_sync_uploaded_batches.saturating_add(1);
4462 let when = chrono::Local::now().format("%H:%M:%S").to_string();
4463 self.chat_sync_last_success = Some(format!(
4464 "{} • {} records • {} • {}",
4465 when,
4466 records,
4467 format_bytes(bytes),
4468 object_key
4469 ));
4470 self.chat_sync_last_error = None;
4471 self.chat_sync_status =
4472 Some(format!("Synced {} ({})", records, format_bytes(bytes)));
4473 }
4474 ChatSyncUiEvent::Error(error) => {
4475 self.chat_sync_last_error = Some(error.clone());
4476 self.chat_sync_status = Some("Sync error (will retry)".to_string());
4477 }
4478 }
4479 }
4480
4481 fn to_archive_record(
4482 message: &ChatMessage,
4483 workspace: &str,
4484 session_id: Option<String>,
4485 ) -> ChatArchiveRecord {
4486 let (message_type, tool_name, tool_success, tool_duration_ms) = match &message.message_type
4487 {
4488 MessageType::Text(_) => ("text".to_string(), None, None, None),
4489 MessageType::Image { .. } => ("image".to_string(), None, None, None),
4490 MessageType::ToolCall { name, .. } => {
4491 ("tool_call".to_string(), Some(name.clone()), None, None)
4492 }
4493 MessageType::ToolResult {
4494 name,
4495 success,
4496 duration_ms,
4497 ..
4498 } => (
4499 "tool_result".to_string(),
4500 Some(name.clone()),
4501 Some(*success),
4502 *duration_ms,
4503 ),
4504 MessageType::File { .. } => ("file".to_string(), None, None, None),
4505 MessageType::Thinking(_) => ("thinking".to_string(), None, None, None),
4506 };
4507
4508 ChatArchiveRecord {
4509 recorded_at: chrono::Utc::now().to_rfc3339(),
4510 workspace: workspace.to_string(),
4511 session_id,
4512 role: message.role.clone(),
4513 agent_name: message.agent_name.clone(),
4514 message_type,
4515 content: message.content.clone(),
4516 tool_name,
4517 tool_success,
4518 tool_duration_ms,
4519 }
4520 }
4521
4522 fn flush_chat_archive(&mut self) {
4523 let Some(path) = self.chat_archive_path.clone() else {
4524 self.archived_message_count = self.messages.len();
4525 return;
4526 };
4527
4528 if self.archived_message_count >= self.messages.len() {
4529 return;
4530 }
4531
4532 let workspace = self.workspace_dir.to_string_lossy().to_string();
4533 let session_id = self.session.as_ref().map(|session| session.id.clone());
4534 let records: Vec<ChatArchiveRecord> = self.messages[self.archived_message_count..]
4535 .iter()
4536 .map(|message| Self::to_archive_record(message, &workspace, session_id.clone()))
4537 .collect();
4538
4539 if let Some(parent) = path.parent()
4540 && let Err(err) = std::fs::create_dir_all(parent)
4541 {
4542 tracing::warn!(error = %err, path = %parent.display(), "Failed to create chat archive directory");
4543 return;
4544 }
4545
4546 let mut file = match std::fs::OpenOptions::new()
4547 .create(true)
4548 .append(true)
4549 .open(&path)
4550 {
4551 Ok(file) => file,
4552 Err(err) => {
4553 tracing::warn!(error = %err, path = %path.display(), "Failed to open chat archive file");
4554 return;
4555 }
4556 };
4557
4558 for record in records {
4559 if let Err(err) = serde_json::to_writer(&mut file, &record) {
4560 tracing::warn!(error = %err, path = %path.display(), "Failed to serialize chat archive record");
4561 return;
4562 }
4563 if let Err(err) = writeln!(&mut file) {
4564 tracing::warn!(error = %err, path = %path.display(), "Failed to write chat archive newline");
4565 return;
4566 }
4567 }
4568
4569 self.archived_message_count = self.messages.len();
4570 }
4571}
4572
4573async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
4574 let mut app = App::new();
4575 if let Ok(sessions) = list_sessions_with_opencode(&app.workspace_dir).await {
4576 app.update_cached_sessions(sessions);
4577 }
4578
4579 let bus = std::sync::Arc::new(crate::bus::AgentBus::new());
4581 let mut bus_handle = bus.handle("tui-observer");
4582 let (bus_tx, bus_rx) = mpsc::channel::<crate::bus::BusEnvelope>(512);
4583 app.bus_log_rx = Some(bus_rx);
4584 app.bus = Some(bus.clone());
4585
4586 tokio::spawn(async move {
4588 loop {
4589 match bus_handle.recv().await {
4590 Some(env) => {
4591 if bus_tx.send(env).await.is_err() {
4592 break; }
4594 }
4595 None => break, }
4597 }
4598 });
4599
4600 let mut config = Config::load().await?;
4602 let mut theme = crate::tui::theme_utils::validate_theme(&config.load_theme());
4603
4604 let secure_environment = is_secure_environment();
4605 app.secure_environment = secure_environment;
4606
4607 match parse_chat_sync_config(secure_environment).await {
4608 Ok(Some(sync_config)) => {
4609 if let Some(archive_path) = app.chat_archive_path.clone() {
4610 let (chat_sync_tx, chat_sync_rx) = mpsc::channel::<ChatSyncUiEvent>(64);
4611 app.chat_sync_rx = Some(chat_sync_rx);
4612 app.chat_sync_status = Some("Starting remote archive sync worker…".to_string());
4613 tokio::spawn(async move {
4614 run_chat_sync_worker(chat_sync_tx, archive_path, sync_config).await;
4615 });
4616 } else {
4617 let message = "Remote chat sync is enabled, but local archive path is unavailable.";
4618 if secure_environment {
4619 return Err(anyhow::anyhow!(
4620 "{message} Secure environment requires remote chat sync."
4621 ));
4622 }
4623 app.messages.push(ChatMessage::new("system", message));
4624 }
4625 }
4626 Ok(None) => {}
4627 Err(err) => {
4628 if secure_environment {
4629 return Err(anyhow::anyhow!(
4630 "Secure environment requires remote chat sync: {err}"
4631 ));
4632 }
4633 app.messages.push(ChatMessage::new(
4634 "system",
4635 format!("Remote chat sync disabled due to configuration error: {err}"),
4636 ));
4637 }
4638 }
4639
4640 let _config_paths = vec![
4642 std::path::PathBuf::from("./codetether.toml"),
4643 std::path::PathBuf::from("./.codetether/config.toml"),
4644 ];
4645
4646 let _global_config_path = directories::ProjectDirs::from("com", "codetether", "codetether")
4647 .map(|dirs| dirs.config_dir().join("config.toml"));
4648
4649 let mut last_check = Instant::now();
4650 let mut event_stream = EventStream::new();
4651
4652 let (session_tx, mut session_rx) = mpsc::channel::<Vec<crate::session::SessionSummary>>(1);
4654 {
4655 let workspace_dir = app.workspace_dir.clone();
4656 tokio::spawn(async move {
4657 let mut interval = tokio::time::interval(Duration::from_secs(5));
4658 loop {
4659 interval.tick().await;
4660 if let Ok(sessions) = list_sessions_with_opencode(&workspace_dir).await {
4661 if session_tx.send(sessions).await.is_err() {
4662 break; }
4664 }
4665 }
4666 });
4667 }
4668
4669 if let Some(checkpoint) = RelayCheckpoint::load().await {
4671 app.messages.push(ChatMessage::new(
4672 "system",
4673 format!(
4674 "Interrupted relay detected!\nTask: {}\nAgents: {}\nCompleted {} turns, was at round {}, index {}\n\nType /resume to continue the relay from where it left off.",
4675 truncate_with_ellipsis(&checkpoint.task, 120),
4676 checkpoint.ordered_agents.join(" → "),
4677 checkpoint.turns,
4678 checkpoint.round,
4679 checkpoint.idx,
4680 ),
4681 ));
4682 }
4683
4684 loop {
4685 if let Ok(sessions) = session_rx.try_recv() {
4689 app.update_cached_sessions(sessions);
4690 }
4691
4692 if config.ui.hot_reload && last_check.elapsed() > Duration::from_secs(2) {
4694 if let Ok(new_config) = Config::load().await {
4695 if new_config.ui.theme != config.ui.theme
4696 || new_config.ui.custom_theme != config.ui.custom_theme
4697 {
4698 theme = crate::tui::theme_utils::validate_theme(&new_config.load_theme());
4699 config = new_config;
4700 }
4701 }
4702 last_check = Instant::now();
4703 }
4704
4705 terminal.draw(|f| ui(f, &mut app, &theme))?;
4706
4707 let terminal_height = terminal.size()?.height.saturating_sub(6) as usize;
4710 let estimated_lines = app.messages.len() * 4; app.last_max_scroll = estimated_lines.saturating_sub(terminal_height);
4712
4713 if let Some(mut rx) = app.response_rx.take() {
4715 while let Ok(response) = rx.try_recv() {
4716 app.handle_response(response);
4717 }
4718 app.response_rx = Some(rx);
4719 }
4720
4721 if let Some(mut rx) = app.swarm_rx.take() {
4723 while let Ok(event) = rx.try_recv() {
4724 app.handle_swarm_event(event);
4725 }
4726 app.swarm_rx = Some(rx);
4727 }
4728
4729 if let Some(mut rx) = app.ralph_rx.take() {
4731 while let Ok(event) = rx.try_recv() {
4732 app.handle_ralph_event(event);
4733 }
4734 app.ralph_rx = Some(rx);
4735 }
4736
4737 if let Some(mut rx) = app.bus_log_rx.take() {
4739 while let Ok(env) = rx.try_recv() {
4740 app.bus_log_state.ingest(&env);
4741 }
4742 app.bus_log_rx = Some(rx);
4743 }
4744
4745 {
4747 let mut i = 0;
4748 while i < app.agent_response_rxs.len() {
4749 let mut done = false;
4750 while let Ok(event) = app.agent_response_rxs[i].1.try_recv() {
4751 if matches!(event, SessionEvent::Done) {
4752 done = true;
4753 }
4754 let name = app.agent_response_rxs[i].0.clone();
4755 app.handle_agent_response(&name, event);
4756 }
4757 if done {
4758 app.agent_response_rxs.swap_remove(i);
4759 } else {
4760 i += 1;
4761 }
4762 }
4763 }
4764
4765 if let Some(mut rx) = app.autochat_rx.take() {
4767 let mut completed = false;
4768 while let Ok(event) = rx.try_recv() {
4769 if app.handle_autochat_event(event) {
4770 completed = true;
4771 }
4772 }
4773
4774 if completed || rx.is_closed() {
4775 if !completed && app.autochat_running {
4776 app.messages.push(ChatMessage::new(
4777 "system",
4778 "Autochat relay worker stopped unexpectedly.",
4779 ));
4780 app.scroll = SCROLL_BOTTOM;
4781 }
4782 app.autochat_running = false;
4783 app.autochat_started_at = None;
4784 app.autochat_status = None;
4785 app.autochat_rx = None;
4786 } else {
4787 app.autochat_rx = Some(rx);
4788 }
4789 }
4790
4791 if let Some(mut rx) = app.chat_sync_rx.take() {
4793 while let Ok(event) = rx.try_recv() {
4794 app.handle_chat_sync_event(event);
4795 }
4796
4797 if rx.is_closed() {
4798 app.chat_sync_status = Some("Remote archive sync worker stopped.".to_string());
4799 app.chat_sync_rx = None;
4800 if app.secure_environment {
4801 return Err(anyhow::anyhow!(
4802 "Remote archive sync worker stopped in secure environment"
4803 ));
4804 }
4805 } else {
4806 app.chat_sync_rx = Some(rx);
4807 }
4808 }
4809
4810 app.flush_chat_archive();
4812
4813 let ev = tokio::select! {
4815 maybe_event = event_stream.next() => {
4816 match maybe_event {
4817 Some(Ok(ev)) => ev,
4818 Some(Err(_)) => continue,
4819 None => return Ok(()), }
4821 }
4822 _ = tokio::time::sleep(Duration::from_millis(50)) => continue,
4824 };
4825
4826 if let Event::Paste(text) = &ev {
4828 let mut pos = app.cursor_position;
4830 while pos > 0 && !app.input.is_char_boundary(pos) {
4831 pos -= 1;
4832 }
4833 app.cursor_position = pos;
4834
4835 for c in text.chars() {
4836 if c == '\n' || c == '\r' {
4837 app.input.insert(app.cursor_position, ' ');
4839 } else {
4840 app.input.insert(app.cursor_position, c);
4841 }
4842 app.cursor_position += c.len_utf8();
4843 }
4844 continue;
4845 }
4846
4847 if let Event::Key(key) = ev {
4848 if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
4852 continue;
4853 }
4854
4855 if app.show_help {
4857 if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
4858 app.show_help = false;
4859 }
4860 continue;
4861 }
4862
4863 if app.view_mode == ViewMode::ModelPicker {
4865 match key.code {
4866 KeyCode::Esc => {
4867 app.view_mode = ViewMode::Chat;
4868 }
4869 KeyCode::Up | KeyCode::Char('k')
4870 if !key.modifiers.contains(KeyModifiers::ALT) =>
4871 {
4872 if app.model_picker_selected > 0 {
4873 app.model_picker_selected -= 1;
4874 }
4875 }
4876 KeyCode::Down | KeyCode::Char('j')
4877 if !key.modifiers.contains(KeyModifiers::ALT) =>
4878 {
4879 let filtered = app.filtered_models();
4880 if app.model_picker_selected < filtered.len().saturating_sub(1) {
4881 app.model_picker_selected += 1;
4882 }
4883 }
4884 KeyCode::Enter => {
4885 let filtered = app.filtered_models();
4886 if let Some((_, (label, value, _name))) =
4887 filtered.get(app.model_picker_selected)
4888 {
4889 let label = label.clone();
4890 let value = value.clone();
4891 app.active_model = Some(value.clone());
4892 if let Some(session) = app.session.as_mut() {
4893 session.metadata.model = Some(value.clone());
4894 }
4895 app.messages.push(ChatMessage::new(
4896 "system",
4897 format!("Model set to: {}", label),
4898 ));
4899 app.view_mode = ViewMode::Chat;
4900 }
4901 }
4902 KeyCode::Backspace => {
4903 app.model_picker_filter.pop();
4904 app.model_picker_selected = 0;
4905 }
4906 KeyCode::Char(c)
4907 if !key.modifiers.contains(KeyModifiers::CONTROL)
4908 && !key.modifiers.contains(KeyModifiers::ALT) =>
4909 {
4910 app.model_picker_filter.push(c);
4911 app.model_picker_selected = 0;
4912 }
4913 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4914 return Ok(());
4915 }
4916 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4917 return Ok(());
4918 }
4919 _ => {}
4920 }
4921 continue;
4922 }
4923
4924 if app.view_mode == ViewMode::SessionPicker {
4926 match key.code {
4927 KeyCode::Esc => {
4928 if app.session_picker_confirm_delete {
4929 app.session_picker_confirm_delete = false;
4930 } else {
4931 app.session_picker_filter.clear();
4932 app.view_mode = ViewMode::Chat;
4933 }
4934 }
4935 KeyCode::Up | KeyCode::Char('k') => {
4936 if app.session_picker_selected > 0 {
4937 app.session_picker_selected -= 1;
4938 }
4939 app.session_picker_confirm_delete = false;
4940 }
4941 KeyCode::Down | KeyCode::Char('j') => {
4942 let filtered_count = app.filtered_sessions().len();
4943 if app.session_picker_selected < filtered_count.saturating_sub(1) {
4944 app.session_picker_selected += 1;
4945 }
4946 app.session_picker_confirm_delete = false;
4947 }
4948 KeyCode::Char('d') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
4949 if app.session_picker_confirm_delete {
4950 let filtered = app.filtered_sessions();
4952 if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
4953 let session_id = app.session_picker_list[*orig_idx].id.clone();
4954 let is_active = app
4955 .session
4956 .as_ref()
4957 .map(|s| s.id == session_id)
4958 .unwrap_or(false);
4959 if !is_active {
4960 if let Err(e) = Session::delete(&session_id).await {
4961 app.messages.push(ChatMessage::new(
4962 "system",
4963 format!("Failed to delete session: {}", e),
4964 ));
4965 } else {
4966 app.session_picker_list.retain(|s| s.id != session_id);
4967 if app.session_picker_selected
4968 >= app.session_picker_list.len()
4969 {
4970 app.session_picker_selected =
4971 app.session_picker_list.len().saturating_sub(1);
4972 }
4973 }
4974 }
4975 }
4976 app.session_picker_confirm_delete = false;
4977 } else {
4978 let filtered = app.filtered_sessions();
4980 if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
4981 let is_active = app
4982 .session
4983 .as_ref()
4984 .map(|s| s.id == app.session_picker_list[*orig_idx].id)
4985 .unwrap_or(false);
4986 if !is_active {
4987 app.session_picker_confirm_delete = true;
4988 }
4989 }
4990 }
4991 }
4992 KeyCode::Backspace => {
4993 app.session_picker_filter.pop();
4994 app.session_picker_selected = 0;
4995 app.session_picker_confirm_delete = false;
4996 }
4997 KeyCode::Char('/') => {
4998 }
5000 KeyCode::Enter => {
5001 app.session_picker_confirm_delete = false;
5002 let filtered = app.filtered_sessions();
5003 let session_id = filtered
5004 .get(app.session_picker_selected)
5005 .map(|(orig_idx, _)| app.session_picker_list[*orig_idx].id.clone());
5006 if let Some(session_id) = session_id {
5007 let load_result =
5008 if let Some(oc_id) = session_id.strip_prefix("opencode_") {
5009 if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
5010 Session::from_opencode(oc_id, &storage).await
5011 } else {
5012 Err(anyhow::anyhow!("OpenCode storage not available"))
5013 }
5014 } else {
5015 Session::load(&session_id).await
5016 };
5017 match load_result {
5018 Ok(session) => {
5019 app.messages.clear();
5020 app.messages.push(ChatMessage::new(
5021 "system",
5022 format!(
5023 "Resumed session: {}\nCreated: {}\n{} messages loaded",
5024 session.title.as_deref().unwrap_or("(untitled)"),
5025 session.created_at.format("%Y-%m-%d %H:%M"),
5026 session.messages.len()
5027 ),
5028 ));
5029
5030 for msg in &session.messages {
5031 let role_str = match msg.role {
5032 Role::System => "system",
5033 Role::User => "user",
5034 Role::Assistant => "assistant",
5035 Role::Tool => "tool",
5036 };
5037
5038 for part in &msg.content {
5041 match part {
5042 ContentPart::Text { text } => {
5043 if !text.is_empty() {
5044 app.messages.push(ChatMessage::new(
5045 role_str,
5046 text.clone(),
5047 ));
5048 }
5049 }
5050 ContentPart::Image { url, mime_type } => {
5051 app.messages.push(
5052 ChatMessage::new(role_str, "")
5053 .with_message_type(
5054 MessageType::Image {
5055 url: url.clone(),
5056 mime_type: mime_type.clone(),
5057 },
5058 ),
5059 );
5060 }
5061 ContentPart::ToolCall {
5062 name, arguments, ..
5063 } => {
5064 let (preview, truncated) =
5065 build_tool_arguments_preview(
5066 name,
5067 arguments,
5068 TOOL_ARGS_PREVIEW_MAX_LINES,
5069 TOOL_ARGS_PREVIEW_MAX_BYTES,
5070 );
5071 app.messages.push(
5072 ChatMessage::new(
5073 role_str,
5074 format!("🔧 {name}"),
5075 )
5076 .with_message_type(MessageType::ToolCall {
5077 name: name.clone(),
5078 arguments_preview: preview,
5079 arguments_len: arguments.len(),
5080 truncated,
5081 }),
5082 );
5083 }
5084 ContentPart::ToolResult { content, .. } => {
5085 let truncated =
5086 truncate_with_ellipsis(content, 500);
5087 let (preview, preview_truncated) =
5088 build_text_preview(
5089 content,
5090 TOOL_OUTPUT_PREVIEW_MAX_LINES,
5091 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
5092 );
5093 app.messages.push(
5094 ChatMessage::new(
5095 role_str,
5096 format!("✅ Result\n{truncated}"),
5097 )
5098 .with_message_type(
5099 MessageType::ToolResult {
5100 name: "tool".to_string(),
5101 output_preview: preview,
5102 output_len: content.len(),
5103 truncated: preview_truncated,
5104 success: true,
5105 duration_ms: None,
5106 },
5107 ),
5108 );
5109 }
5110 ContentPart::File { path, mime_type } => {
5111 app.messages.push(
5112 ChatMessage::new(
5113 role_str,
5114 format!("📎 {path}"),
5115 )
5116 .with_message_type(MessageType::File {
5117 path: path.clone(),
5118 mime_type: mime_type.clone(),
5119 }),
5120 );
5121 }
5122 ContentPart::Thinking { text } => {
5123 if !text.is_empty() {
5124 app.messages.push(
5125 ChatMessage::new(
5126 role_str,
5127 text.clone(),
5128 )
5129 .with_message_type(
5130 MessageType::Thinking(text.clone()),
5131 ),
5132 );
5133 }
5134 }
5135 }
5136 }
5137 }
5138
5139 app.current_agent = session.agent.clone();
5140 app.session = Some(session);
5141 app.scroll = SCROLL_BOTTOM;
5142 app.view_mode = ViewMode::Chat;
5143 }
5144 Err(e) => {
5145 app.messages.push(ChatMessage::new(
5146 "system",
5147 format!("Failed to load session: {}", e),
5148 ));
5149 app.view_mode = ViewMode::Chat;
5150 }
5151 }
5152 }
5153 }
5154 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5155 return Ok(());
5156 }
5157 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5158 return Ok(());
5159 }
5160 KeyCode::Char(c)
5161 if !key.modifiers.contains(KeyModifiers::CONTROL)
5162 && !key.modifiers.contains(KeyModifiers::ALT)
5163 && c != 'j'
5164 && c != 'k' =>
5165 {
5166 app.session_picker_filter.push(c);
5167 app.session_picker_selected = 0;
5168 app.session_picker_confirm_delete = false;
5169 }
5170 _ => {}
5171 }
5172 continue;
5173 }
5174
5175 if app.view_mode == ViewMode::AgentPicker {
5177 match key.code {
5178 KeyCode::Esc => {
5179 app.agent_picker_filter.clear();
5180 app.view_mode = ViewMode::Chat;
5181 }
5182 KeyCode::Up | KeyCode::Char('k')
5183 if !key.modifiers.contains(KeyModifiers::ALT) =>
5184 {
5185 if app.agent_picker_selected > 0 {
5186 app.agent_picker_selected -= 1;
5187 }
5188 }
5189 KeyCode::Down | KeyCode::Char('j')
5190 if !key.modifiers.contains(KeyModifiers::ALT) =>
5191 {
5192 let filtered = app.filtered_spawned_agents();
5193 if app.agent_picker_selected < filtered.len().saturating_sub(1) {
5194 app.agent_picker_selected += 1;
5195 }
5196 }
5197 KeyCode::Enter => {
5198 let filtered = app.filtered_spawned_agents();
5199 if let Some((name, _, _, _)) = filtered.get(app.agent_picker_selected) {
5200 app.active_spawned_agent = Some(name.clone());
5201 app.messages.push(ChatMessage::new(
5202 "system",
5203 format!(
5204 "Focused chat on @{name}. Type messages directly; use /agent main to exit focus."
5205 ),
5206 ));
5207 app.view_mode = ViewMode::Chat;
5208 }
5209 }
5210 KeyCode::Backspace => {
5211 app.agent_picker_filter.pop();
5212 app.agent_picker_selected = 0;
5213 }
5214 KeyCode::Char('m') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
5215 app.active_spawned_agent = None;
5216 app.messages
5217 .push(ChatMessage::new("system", "Returned to main chat mode."));
5218 app.view_mode = ViewMode::Chat;
5219 }
5220 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5221 return Ok(());
5222 }
5223 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5224 return Ok(());
5225 }
5226 KeyCode::Char(c)
5227 if !key.modifiers.contains(KeyModifiers::CONTROL)
5228 && !key.modifiers.contains(KeyModifiers::ALT)
5229 && c != 'j'
5230 && c != 'k'
5231 && c != 'm' =>
5232 {
5233 app.agent_picker_filter.push(c);
5234 app.agent_picker_selected = 0;
5235 }
5236 _ => {}
5237 }
5238 continue;
5239 }
5240
5241 if app.view_mode == ViewMode::Swarm {
5243 match key.code {
5244 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5245 return Ok(());
5246 }
5247 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5248 return Ok(());
5249 }
5250 KeyCode::Esc => {
5251 if app.swarm_state.detail_mode {
5252 app.swarm_state.exit_detail();
5253 } else {
5254 app.view_mode = ViewMode::Chat;
5255 }
5256 }
5257 KeyCode::Up | KeyCode::Char('k') => {
5258 if app.swarm_state.detail_mode {
5259 app.swarm_state.exit_detail();
5261 app.swarm_state.select_prev();
5262 app.swarm_state.enter_detail();
5263 } else {
5264 app.swarm_state.select_prev();
5265 }
5266 }
5267 KeyCode::Down | KeyCode::Char('j') => {
5268 if app.swarm_state.detail_mode {
5269 app.swarm_state.exit_detail();
5270 app.swarm_state.select_next();
5271 app.swarm_state.enter_detail();
5272 } else {
5273 app.swarm_state.select_next();
5274 }
5275 }
5276 KeyCode::Enter => {
5277 if !app.swarm_state.detail_mode {
5278 app.swarm_state.enter_detail();
5279 }
5280 }
5281 KeyCode::PageDown => {
5282 app.swarm_state.detail_scroll_down(10);
5283 }
5284 KeyCode::PageUp => {
5285 app.swarm_state.detail_scroll_up(10);
5286 }
5287 KeyCode::Char('?') => {
5288 app.show_help = true;
5289 }
5290 KeyCode::F(2) => {
5291 app.view_mode = ViewMode::Chat;
5292 }
5293 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5294 app.view_mode = ViewMode::Chat;
5295 }
5296 _ => {}
5297 }
5298 continue;
5299 }
5300
5301 if app.view_mode == ViewMode::Ralph {
5303 match key.code {
5304 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5305 return Ok(());
5306 }
5307 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5308 return Ok(());
5309 }
5310 KeyCode::Esc => {
5311 if app.ralph_state.detail_mode {
5312 app.ralph_state.exit_detail();
5313 } else {
5314 app.view_mode = ViewMode::Chat;
5315 }
5316 }
5317 KeyCode::Up | KeyCode::Char('k') => {
5318 if app.ralph_state.detail_mode {
5319 app.ralph_state.exit_detail();
5320 app.ralph_state.select_prev();
5321 app.ralph_state.enter_detail();
5322 } else {
5323 app.ralph_state.select_prev();
5324 }
5325 }
5326 KeyCode::Down | KeyCode::Char('j') => {
5327 if app.ralph_state.detail_mode {
5328 app.ralph_state.exit_detail();
5329 app.ralph_state.select_next();
5330 app.ralph_state.enter_detail();
5331 } else {
5332 app.ralph_state.select_next();
5333 }
5334 }
5335 KeyCode::Enter => {
5336 if !app.ralph_state.detail_mode {
5337 app.ralph_state.enter_detail();
5338 }
5339 }
5340 KeyCode::PageDown => {
5341 app.ralph_state.detail_scroll_down(10);
5342 }
5343 KeyCode::PageUp => {
5344 app.ralph_state.detail_scroll_up(10);
5345 }
5346 KeyCode::Char('?') => {
5347 app.show_help = true;
5348 }
5349 KeyCode::F(2) | KeyCode::Char('s')
5350 if key.modifiers.contains(KeyModifiers::CONTROL) =>
5351 {
5352 app.view_mode = ViewMode::Chat;
5353 }
5354 _ => {}
5355 }
5356 continue;
5357 }
5358
5359 if app.view_mode == ViewMode::BusLog {
5361 match key.code {
5362 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5363 return Ok(());
5364 }
5365 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5366 return Ok(());
5367 }
5368 KeyCode::Esc => {
5369 if app.bus_log_state.detail_mode {
5370 app.bus_log_state.exit_detail();
5371 } else {
5372 app.view_mode = ViewMode::Chat;
5373 }
5374 }
5375 KeyCode::Up | KeyCode::Char('k') => {
5376 if app.bus_log_state.detail_mode {
5377 app.bus_log_state.exit_detail();
5378 app.bus_log_state.select_prev();
5379 app.bus_log_state.enter_detail();
5380 } else {
5381 app.bus_log_state.select_prev();
5382 }
5383 }
5384 KeyCode::Down | KeyCode::Char('j') => {
5385 if app.bus_log_state.detail_mode {
5386 app.bus_log_state.exit_detail();
5387 app.bus_log_state.select_next();
5388 app.bus_log_state.enter_detail();
5389 } else {
5390 app.bus_log_state.select_next();
5391 }
5392 }
5393 KeyCode::Enter => {
5394 if !app.bus_log_state.detail_mode {
5395 app.bus_log_state.enter_detail();
5396 }
5397 }
5398 KeyCode::PageDown => {
5399 app.bus_log_state.detail_scroll_down(10);
5400 }
5401 KeyCode::PageUp => {
5402 app.bus_log_state.detail_scroll_up(10);
5403 }
5404 KeyCode::Char('c') => {
5406 app.bus_log_state.entries.clear();
5407 app.bus_log_state.selected_index = 0;
5408 }
5409 KeyCode::Char('g') => {
5411 let len = app.bus_log_state.filtered_entries().len();
5412 if len > 0 {
5413 app.bus_log_state.selected_index = len - 1;
5414 app.bus_log_state.list_state.select(Some(len - 1));
5415 }
5416 app.bus_log_state.auto_scroll = true;
5417 }
5418 KeyCode::Char('?') => {
5419 app.show_help = true;
5420 }
5421 _ => {}
5422 }
5423 continue;
5424 }
5425
5426 if app.view_mode == ViewMode::Protocol {
5428 match key.code {
5429 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5430 return Ok(());
5431 }
5432 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5433 return Ok(());
5434 }
5435 KeyCode::Esc => {
5436 app.view_mode = ViewMode::Chat;
5437 }
5438 KeyCode::Up | KeyCode::Char('k') => {
5439 if app.protocol_selected > 0 {
5440 app.protocol_selected -= 1;
5441 }
5442 app.protocol_scroll = 0;
5443 }
5444 KeyCode::Down | KeyCode::Char('j') => {
5445 let len = app.protocol_cards().len();
5446 if app.protocol_selected < len.saturating_sub(1) {
5447 app.protocol_selected += 1;
5448 }
5449 app.protocol_scroll = 0;
5450 }
5451 KeyCode::PageDown => {
5452 app.protocol_scroll = app.protocol_scroll.saturating_add(10);
5453 }
5454 KeyCode::PageUp => {
5455 app.protocol_scroll = app.protocol_scroll.saturating_sub(10);
5456 }
5457 KeyCode::Char('g') => {
5458 app.protocol_scroll = 0;
5459 }
5460 KeyCode::Char('?') => {
5461 app.show_help = true;
5462 }
5463 _ => {}
5464 }
5465 continue;
5466 }
5467
5468 match key.code {
5469 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5471 return Ok(());
5472 }
5473 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5474 return Ok(());
5475 }
5476
5477 KeyCode::Char('?') => {
5479 app.show_help = true;
5480 }
5481
5482 KeyCode::F(2) => {
5484 app.view_mode = match app.view_mode {
5485 ViewMode::Chat
5486 | ViewMode::SessionPicker
5487 | ViewMode::ModelPicker
5488 | ViewMode::AgentPicker
5489 | ViewMode::Protocol
5490 | ViewMode::BusLog => ViewMode::Swarm,
5491 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
5492 };
5493 }
5494 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5495 app.view_mode = match app.view_mode {
5496 ViewMode::Chat
5497 | ViewMode::SessionPicker
5498 | ViewMode::ModelPicker
5499 | ViewMode::AgentPicker
5500 | ViewMode::Protocol
5501 | ViewMode::BusLog => ViewMode::Swarm,
5502 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
5503 };
5504 }
5505
5506 KeyCode::F(3) => {
5508 app.show_inspector = !app.show_inspector;
5509 }
5510
5511 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5513 let msg = app
5514 .messages
5515 .iter()
5516 .rev()
5517 .find(|m| m.role == "assistant" && !m.content.trim().is_empty())
5518 .or_else(|| {
5519 app.messages
5520 .iter()
5521 .rev()
5522 .find(|m| !m.content.trim().is_empty())
5523 });
5524
5525 let Some(msg) = msg else {
5526 app.messages
5527 .push(ChatMessage::new("system", "Nothing to copy yet."));
5528 app.scroll = SCROLL_BOTTOM;
5529 continue;
5530 };
5531
5532 let text = message_clipboard_text(msg);
5533 match copy_text_to_clipboard_best_effort(&text) {
5534 Ok(method) => {
5535 app.messages.push(ChatMessage::new(
5536 "system",
5537 format!("Copied latest reply ({method})."),
5538 ));
5539 app.scroll = SCROLL_BOTTOM;
5540 }
5541 Err(err) => {
5542 tracing::warn!(error = %err, "Copy to clipboard failed");
5543 app.messages.push(ChatMessage::new(
5544 "system",
5545 "Could not copy to clipboard in this environment.",
5546 ));
5547 app.scroll = SCROLL_BOTTOM;
5548 }
5549 }
5550 }
5551
5552 KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5554 app.chat_layout = match app.chat_layout {
5555 ChatLayoutMode::Classic => ChatLayoutMode::Webview,
5556 ChatLayoutMode::Webview => ChatLayoutMode::Classic,
5557 };
5558 }
5559
5560 KeyCode::Esc => {
5562 if app.view_mode == ViewMode::Swarm
5563 || app.view_mode == ViewMode::Ralph
5564 || app.view_mode == ViewMode::BusLog
5565 || app.view_mode == ViewMode::Protocol
5566 || app.view_mode == ViewMode::SessionPicker
5567 || app.view_mode == ViewMode::ModelPicker
5568 || app.view_mode == ViewMode::AgentPicker
5569 {
5570 app.view_mode = ViewMode::Chat;
5571 }
5572 }
5573
5574 KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5576 app.open_model_picker(&config).await;
5577 }
5578
5579 KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5581 app.open_agent_picker();
5582 }
5583
5584 KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5586 app.view_mode = ViewMode::BusLog;
5587 }
5588
5589 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5591 app.open_protocol_view();
5592 }
5593
5594 KeyCode::Tab => {
5596 app.current_agent = if app.current_agent == "build" {
5597 "plan".to_string()
5598 } else {
5599 "build".to_string()
5600 };
5601 }
5602
5603 KeyCode::Enter => {
5605 app.submit_message(&config).await;
5606 }
5607
5608 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
5610 if app.scroll < SCROLL_BOTTOM {
5611 app.scroll = app.scroll.saturating_add(1);
5612 }
5613 }
5614 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
5615 if app.scroll >= SCROLL_BOTTOM {
5616 app.scroll = app.last_max_scroll; }
5618 app.scroll = app.scroll.saturating_sub(1);
5619 }
5620
5621 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5623 app.search_history();
5624 }
5625 KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
5626 app.navigate_history(-1);
5627 }
5628 KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
5629 app.navigate_history(1);
5630 }
5631
5632 KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5634 app.scroll = 0; }
5636 KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5637 app.scroll = SCROLL_BOTTOM;
5639 }
5640
5641 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
5643 if app.scroll < SCROLL_BOTTOM {
5645 app.scroll = app.scroll.saturating_add(5);
5646 }
5647 }
5648 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
5649 if app.scroll >= SCROLL_BOTTOM {
5651 app.scroll = app.last_max_scroll;
5652 }
5653 app.scroll = app.scroll.saturating_sub(5);
5654 }
5655
5656 KeyCode::Char(c) => {
5658 while app.cursor_position > 0
5660 && !app.input.is_char_boundary(app.cursor_position)
5661 {
5662 app.cursor_position -= 1;
5663 }
5664 app.input.insert(app.cursor_position, c);
5665 app.cursor_position += c.len_utf8();
5666 }
5667 KeyCode::Backspace => {
5668 while app.cursor_position > 0
5670 && !app.input.is_char_boundary(app.cursor_position)
5671 {
5672 app.cursor_position -= 1;
5673 }
5674 if app.cursor_position > 0 {
5675 let prev = app.input[..app.cursor_position].char_indices().rev().next();
5677 if let Some((idx, ch)) = prev {
5678 app.input.replace_range(idx..idx + ch.len_utf8(), "");
5679 app.cursor_position = idx;
5680 }
5681 }
5682 }
5683 KeyCode::Delete => {
5684 while app.cursor_position > 0
5686 && !app.input.is_char_boundary(app.cursor_position)
5687 {
5688 app.cursor_position -= 1;
5689 }
5690 if app.cursor_position < app.input.len() {
5691 let ch = app.input[app.cursor_position..].chars().next();
5692 if let Some(ch) = ch {
5693 app.input.replace_range(
5694 app.cursor_position..app.cursor_position + ch.len_utf8(),
5695 "",
5696 );
5697 }
5698 }
5699 }
5700 KeyCode::Left => {
5701 let prev = app.input[..app.cursor_position].char_indices().rev().next();
5703 if let Some((idx, _)) = prev {
5704 app.cursor_position = idx;
5705 }
5706 }
5707 KeyCode::Right => {
5708 if app.cursor_position < app.input.len() {
5709 let ch = app.input[app.cursor_position..].chars().next();
5710 if let Some(ch) = ch {
5711 app.cursor_position += ch.len_utf8();
5712 }
5713 }
5714 }
5715 KeyCode::Home => {
5716 app.cursor_position = 0;
5717 }
5718 KeyCode::End => {
5719 app.cursor_position = app.input.len();
5720 }
5721
5722 KeyCode::Up => {
5724 if app.scroll >= SCROLL_BOTTOM {
5725 app.scroll = app.last_max_scroll; }
5727 app.scroll = app.scroll.saturating_sub(1);
5728 }
5729 KeyCode::Down => {
5730 if app.scroll < SCROLL_BOTTOM {
5731 app.scroll = app.scroll.saturating_add(1);
5732 }
5733 }
5734 KeyCode::PageUp => {
5735 if app.scroll >= SCROLL_BOTTOM {
5736 app.scroll = app.last_max_scroll;
5737 }
5738 app.scroll = app.scroll.saturating_sub(10);
5739 }
5740 KeyCode::PageDown => {
5741 if app.scroll < SCROLL_BOTTOM {
5742 app.scroll = app.scroll.saturating_add(10);
5743 }
5744 }
5745
5746 _ => {}
5747 }
5748 }
5749 }
5750}
5751
5752fn ui(f: &mut Frame, app: &mut App, theme: &Theme) {
5753 if app.view_mode == ViewMode::Swarm {
5755 let chunks = Layout::default()
5757 .direction(Direction::Vertical)
5758 .constraints([
5759 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
5763 .split(f.area());
5764
5765 render_swarm_view(f, &mut app.swarm_state, chunks[0]);
5767
5768 let input_block = Block::default()
5770 .borders(Borders::ALL)
5771 .title(" Press Esc, Ctrl+S, or /view to return to chat ")
5772 .border_style(Style::default().fg(Color::Cyan));
5773
5774 let input = Paragraph::new(app.input.as_str())
5775 .block(input_block)
5776 .wrap(Wrap { trim: false });
5777 f.render_widget(input, chunks[1]);
5778
5779 let status_line = if app.swarm_state.detail_mode {
5781 Line::from(vec![
5782 Span::styled(
5783 " AGENT DETAIL ",
5784 Style::default().fg(Color::Black).bg(Color::Cyan),
5785 ),
5786 Span::raw(" | "),
5787 Span::styled("Esc", Style::default().fg(Color::Yellow)),
5788 Span::raw(": Back to list | "),
5789 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
5790 Span::raw(": Prev/Next agent | "),
5791 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
5792 Span::raw(": Scroll"),
5793 ])
5794 } else {
5795 Line::from(vec![
5796 Span::styled(
5797 " SWARM MODE ",
5798 Style::default().fg(Color::Black).bg(Color::Cyan),
5799 ),
5800 Span::raw(" | "),
5801 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
5802 Span::raw(": Select | "),
5803 Span::styled("Enter", Style::default().fg(Color::Yellow)),
5804 Span::raw(": Detail | "),
5805 Span::styled("Esc", Style::default().fg(Color::Yellow)),
5806 Span::raw(": Back | "),
5807 Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
5808 Span::raw(": Toggle view"),
5809 ])
5810 };
5811 let status = Paragraph::new(status_line);
5812 f.render_widget(status, chunks[2]);
5813 return;
5814 }
5815
5816 if app.view_mode == ViewMode::Ralph {
5818 let chunks = Layout::default()
5819 .direction(Direction::Vertical)
5820 .constraints([
5821 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
5825 .split(f.area());
5826
5827 render_ralph_view(f, &mut app.ralph_state, chunks[0]);
5828
5829 let input_block = Block::default()
5830 .borders(Borders::ALL)
5831 .title(" Press Esc to return to chat ")
5832 .border_style(Style::default().fg(Color::Magenta));
5833
5834 let input = Paragraph::new(app.input.as_str())
5835 .block(input_block)
5836 .wrap(Wrap { trim: false });
5837 f.render_widget(input, chunks[1]);
5838
5839 let status_line = if app.ralph_state.detail_mode {
5840 Line::from(vec![
5841 Span::styled(
5842 " STORY DETAIL ",
5843 Style::default().fg(Color::Black).bg(Color::Magenta),
5844 ),
5845 Span::raw(" | "),
5846 Span::styled("Esc", Style::default().fg(Color::Yellow)),
5847 Span::raw(": Back to list | "),
5848 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
5849 Span::raw(": Prev/Next story | "),
5850 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
5851 Span::raw(": Scroll"),
5852 ])
5853 } else {
5854 Line::from(vec![
5855 Span::styled(
5856 " RALPH MODE ",
5857 Style::default().fg(Color::Black).bg(Color::Magenta),
5858 ),
5859 Span::raw(" | "),
5860 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
5861 Span::raw(": Select | "),
5862 Span::styled("Enter", Style::default().fg(Color::Yellow)),
5863 Span::raw(": Detail | "),
5864 Span::styled("Esc", Style::default().fg(Color::Yellow)),
5865 Span::raw(": Back"),
5866 ])
5867 };
5868 let status = Paragraph::new(status_line);
5869 f.render_widget(status, chunks[2]);
5870 return;
5871 }
5872
5873 if app.view_mode == ViewMode::BusLog {
5875 let chunks = Layout::default()
5876 .direction(Direction::Vertical)
5877 .constraints([
5878 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
5882 .split(f.area());
5883
5884 render_bus_log(f, &mut app.bus_log_state, chunks[0]);
5885
5886 let input_block = Block::default()
5887 .borders(Borders::ALL)
5888 .title(" Press Esc to return to chat ")
5889 .border_style(Style::default().fg(Color::Green));
5890
5891 let input = Paragraph::new(app.input.as_str())
5892 .block(input_block)
5893 .wrap(Wrap { trim: false });
5894 f.render_widget(input, chunks[1]);
5895
5896 let count_info = format!(
5897 " {}/{} ",
5898 app.bus_log_state.visible_count(),
5899 app.bus_log_state.total_count()
5900 );
5901 let status_line = Line::from(vec![
5902 Span::styled(
5903 " BUS LOG ",
5904 Style::default().fg(Color::Black).bg(Color::Green),
5905 ),
5906 Span::raw(&count_info),
5907 Span::raw("| "),
5908 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
5909 Span::raw(": Select | "),
5910 Span::styled("Enter", Style::default().fg(Color::Yellow)),
5911 Span::raw(": Detail | "),
5912 Span::styled("c", Style::default().fg(Color::Yellow)),
5913 Span::raw(": Clear | "),
5914 Span::styled("Esc", Style::default().fg(Color::Yellow)),
5915 Span::raw(": Back"),
5916 ]);
5917 let status = Paragraph::new(status_line);
5918 f.render_widget(status, chunks[2]);
5919 return;
5920 }
5921
5922 if app.view_mode == ViewMode::Protocol {
5924 let chunks = Layout::default()
5925 .direction(Direction::Vertical)
5926 .constraints([
5927 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
5931 .split(f.area());
5932
5933 render_protocol_registry(f, app, theme, chunks[0]);
5934
5935 let input_block = Block::default()
5936 .borders(Borders::ALL)
5937 .title(" Press Esc to return to chat ")
5938 .border_style(Style::default().fg(Color::Blue));
5939
5940 let input = Paragraph::new(app.input.as_str())
5941 .block(input_block)
5942 .wrap(Wrap { trim: false });
5943 f.render_widget(input, chunks[1]);
5944
5945 let cards = app.protocol_cards();
5946 let status_line = Line::from(vec![
5947 Span::styled(
5948 " PROTOCOL REGISTRY ",
5949 Style::default().fg(Color::Black).bg(Color::Blue),
5950 ),
5951 Span::raw(format!(" {} cards | ", cards.len())),
5952 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
5953 Span::raw(": Select | "),
5954 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
5955 Span::raw(": Scroll detail | "),
5956 Span::styled("Esc", Style::default().fg(Color::Yellow)),
5957 Span::raw(": Back"),
5958 ]);
5959 let status = Paragraph::new(status_line);
5960 f.render_widget(status, chunks[2]);
5961 return;
5962 }
5963
5964 if app.view_mode == ViewMode::ModelPicker {
5966 let area = centered_rect(70, 70, f.area());
5967 f.render_widget(Clear, area);
5968
5969 let filter_display = if app.model_picker_filter.is_empty() {
5970 "type to filter".to_string()
5971 } else {
5972 format!("filter: {}", app.model_picker_filter)
5973 };
5974
5975 let picker_block = Block::default()
5976 .borders(Borders::ALL)
5977 .title(format!(
5978 " Select Model (↑↓ navigate, Enter select, Esc cancel) [{}] ",
5979 filter_display
5980 ))
5981 .border_style(Style::default().fg(Color::Magenta));
5982
5983 let filtered = app.filtered_models();
5984 let mut list_lines: Vec<Line> = Vec::new();
5985 list_lines.push(Line::from(""));
5986
5987 if let Some(ref active) = app.active_model {
5988 list_lines.push(Line::styled(
5989 format!(" Current: {}", active),
5990 Style::default()
5991 .fg(Color::Green)
5992 .add_modifier(Modifier::DIM),
5993 ));
5994 list_lines.push(Line::from(""));
5995 }
5996
5997 if filtered.is_empty() {
5998 list_lines.push(Line::styled(
5999 " No models match filter",
6000 Style::default().fg(Color::DarkGray),
6001 ));
6002 } else {
6003 let mut current_provider = String::new();
6004 for (display_idx, (_, (label, _, human_name))) in filtered.iter().enumerate() {
6005 let provider = label.split('/').next().unwrap_or("");
6006 if provider != current_provider {
6007 if !current_provider.is_empty() {
6008 list_lines.push(Line::from(""));
6009 }
6010 list_lines.push(Line::styled(
6011 format!(" ─── {} ───", provider),
6012 Style::default()
6013 .fg(Color::Cyan)
6014 .add_modifier(Modifier::BOLD),
6015 ));
6016 current_provider = provider.to_string();
6017 }
6018
6019 let is_selected = display_idx == app.model_picker_selected;
6020 let is_active = app.active_model.as_deref() == Some(label.as_str());
6021 let marker = if is_selected { "▶" } else { " " };
6022 let active_marker = if is_active { " ✓" } else { "" };
6023 let model_id = label.split('/').skip(1).collect::<Vec<_>>().join("/");
6024 let display = if human_name != &model_id && !human_name.is_empty() {
6026 format!("{} ({})", human_name, model_id)
6027 } else {
6028 model_id
6029 };
6030
6031 let style = if is_selected {
6032 Style::default()
6033 .fg(Color::Magenta)
6034 .add_modifier(Modifier::BOLD)
6035 } else if is_active {
6036 Style::default().fg(Color::Green)
6037 } else {
6038 Style::default()
6039 };
6040
6041 list_lines.push(Line::styled(
6042 format!(" {} {}{}", marker, display, active_marker),
6043 style,
6044 ));
6045 }
6046 }
6047
6048 let list = Paragraph::new(list_lines)
6049 .block(picker_block)
6050 .wrap(Wrap { trim: false });
6051 f.render_widget(list, area);
6052 return;
6053 }
6054
6055 if app.view_mode == ViewMode::SessionPicker {
6057 let chunks = Layout::default()
6058 .direction(Direction::Vertical)
6059 .constraints([
6060 Constraint::Min(1), Constraint::Length(1), ])
6063 .split(f.area());
6064
6065 let filter_display = if app.session_picker_filter.is_empty() {
6067 String::new()
6068 } else {
6069 format!(" [filter: {}]", app.session_picker_filter)
6070 };
6071
6072 let list_block = Block::default()
6073 .borders(Borders::ALL)
6074 .title(format!(
6075 " Sessions (↑↓ navigate, Enter load, d delete, Esc cancel){} ",
6076 filter_display
6077 ))
6078 .border_style(Style::default().fg(Color::Cyan));
6079
6080 let mut list_lines: Vec<Line> = Vec::new();
6081 list_lines.push(Line::from(""));
6082
6083 let filtered = app.filtered_sessions();
6084 if filtered.is_empty() {
6085 if app.session_picker_filter.is_empty() {
6086 list_lines.push(Line::styled(
6087 " No sessions found.",
6088 Style::default().fg(Color::DarkGray),
6089 ));
6090 } else {
6091 list_lines.push(Line::styled(
6092 format!(" No sessions matching '{}'", app.session_picker_filter),
6093 Style::default().fg(Color::DarkGray),
6094 ));
6095 }
6096 }
6097
6098 for (display_idx, (_orig_idx, session)) in filtered.iter().enumerate() {
6099 let is_selected = display_idx == app.session_picker_selected;
6100 let is_active = app
6101 .session
6102 .as_ref()
6103 .map(|s| s.id == session.id)
6104 .unwrap_or(false);
6105 let title = session.title.as_deref().unwrap_or("(untitled)");
6106 let date = session.updated_at.format("%Y-%m-%d %H:%M");
6107 let active_marker = if is_active { " ●" } else { "" };
6108 let line_str = format!(
6109 " {} {}{} - {} ({} msgs)",
6110 if is_selected { "▶" } else { " " },
6111 title,
6112 active_marker,
6113 date,
6114 session.message_count
6115 );
6116
6117 let style = if is_selected && app.session_picker_confirm_delete {
6118 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
6119 } else if is_selected {
6120 Style::default()
6121 .fg(Color::Cyan)
6122 .add_modifier(Modifier::BOLD)
6123 } else if is_active {
6124 Style::default().fg(Color::Green)
6125 } else {
6126 Style::default()
6127 };
6128
6129 list_lines.push(Line::styled(line_str, style));
6130
6131 if is_selected {
6133 if app.session_picker_confirm_delete {
6134 list_lines.push(Line::styled(
6135 " ⚠ Press d again to confirm delete, Esc to cancel",
6136 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
6137 ));
6138 } else {
6139 list_lines.push(Line::styled(
6140 format!(" Agent: {} | ID: {}", session.agent, session.id),
6141 Style::default().fg(Color::DarkGray),
6142 ));
6143 }
6144 }
6145 }
6146
6147 let list = Paragraph::new(list_lines)
6148 .block(list_block)
6149 .wrap(Wrap { trim: false });
6150 f.render_widget(list, chunks[0]);
6151
6152 let mut status_spans = vec![
6154 Span::styled(
6155 " SESSION PICKER ",
6156 Style::default().fg(Color::Black).bg(Color::Cyan),
6157 ),
6158 Span::raw(" "),
6159 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6160 Span::raw(": Nav "),
6161 Span::styled("Enter", Style::default().fg(Color::Yellow)),
6162 Span::raw(": Load "),
6163 Span::styled("d", Style::default().fg(Color::Yellow)),
6164 Span::raw(": Delete "),
6165 Span::styled("Esc", Style::default().fg(Color::Yellow)),
6166 Span::raw(": Cancel "),
6167 ];
6168 if !app.session_picker_filter.is_empty() || !app.session_picker_list.is_empty() {
6169 status_spans.push(Span::styled("Type", Style::default().fg(Color::Yellow)));
6170 status_spans.push(Span::raw(": Filter "));
6171 }
6172 let total = app.session_picker_list.len();
6173 let showing = filtered.len();
6174 if showing < total {
6175 status_spans.push(Span::styled(
6176 format!("{}/{}", showing, total),
6177 Style::default().fg(Color::DarkGray),
6178 ));
6179 }
6180
6181 let status = Paragraph::new(Line::from(status_spans));
6182 f.render_widget(status, chunks[1]);
6183 return;
6184 }
6185
6186 if app.view_mode == ViewMode::AgentPicker {
6188 let area = centered_rect(70, 70, f.area());
6189 f.render_widget(Clear, area);
6190
6191 let filter_display = if app.agent_picker_filter.is_empty() {
6192 "type to filter".to_string()
6193 } else {
6194 format!("filter: {}", app.agent_picker_filter)
6195 };
6196
6197 let picker_block = Block::default()
6198 .borders(Borders::ALL)
6199 .title(format!(
6200 " Select Agent (↑↓ navigate, Enter focus, m main chat, Esc cancel) [{}] ",
6201 filter_display
6202 ))
6203 .border_style(Style::default().fg(Color::Magenta));
6204
6205 let filtered = app.filtered_spawned_agents();
6206 let mut list_lines: Vec<Line> = Vec::new();
6207 list_lines.push(Line::from(""));
6208
6209 if let Some(ref active) = app.active_spawned_agent {
6210 list_lines.push(Line::styled(
6211 format!(" Current focus: @{}", active),
6212 Style::default()
6213 .fg(Color::Green)
6214 .add_modifier(Modifier::DIM),
6215 ));
6216 list_lines.push(Line::from(""));
6217 }
6218
6219 if filtered.is_empty() {
6220 list_lines.push(Line::styled(
6221 " No spawned agents match filter",
6222 Style::default().fg(Color::DarkGray),
6223 ));
6224 } else {
6225 for (display_idx, (name, instructions, is_processing, is_registered)) in
6226 filtered.iter().enumerate()
6227 {
6228 let is_selected = display_idx == app.agent_picker_selected;
6229 let is_focused = app.active_spawned_agent.as_deref() == Some(name.as_str());
6230 let marker = if is_selected { "▶" } else { " " };
6231 let focused_marker = if is_focused { " ✓" } else { "" };
6232 let status = if *is_processing { "⚡" } else { "●" };
6233 let protocol = if *is_registered { "🔗" } else { "⚠" };
6234 let avatar = agent_avatar(name);
6235
6236 let style = if is_selected {
6237 Style::default()
6238 .fg(Color::Magenta)
6239 .add_modifier(Modifier::BOLD)
6240 } else if is_focused {
6241 Style::default().fg(Color::Green)
6242 } else {
6243 Style::default()
6244 };
6245
6246 list_lines.push(Line::styled(
6247 format!(" {marker} {status} {protocol} {avatar} @{name}{focused_marker}"),
6248 style,
6249 ));
6250
6251 if is_selected {
6252 let profile = agent_profile(name);
6253 list_lines.push(Line::styled(
6254 format!(" profile: {} — {}", profile.codename, profile.profile),
6255 Style::default().fg(Color::Cyan),
6256 ));
6257 list_lines.push(Line::styled(
6258 format!(" {}", instructions),
6259 Style::default().fg(Color::DarkGray),
6260 ));
6261 list_lines.push(Line::styled(
6262 format!(
6263 " protocol: {}",
6264 if *is_registered {
6265 "registered"
6266 } else {
6267 "not registered"
6268 }
6269 ),
6270 if *is_registered {
6271 Style::default().fg(Color::Green)
6272 } else {
6273 Style::default().fg(Color::Yellow)
6274 },
6275 ));
6276 }
6277 }
6278 }
6279
6280 let list = Paragraph::new(list_lines)
6281 .block(picker_block)
6282 .wrap(Wrap { trim: false });
6283 f.render_widget(list, area);
6284 return;
6285 }
6286
6287 if app.chat_layout == ChatLayoutMode::Webview {
6288 if render_webview_chat(f, app, theme) {
6289 render_help_overlay_if_needed(f, app, theme);
6290 return;
6291 }
6292 }
6293
6294 let chunks = Layout::default()
6296 .direction(Direction::Vertical)
6297 .constraints([
6298 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
6302 .split(f.area());
6303
6304 let messages_area = chunks[0];
6306 let model_label = app.active_model.as_deref().unwrap_or("auto");
6307 let target_label = app
6308 .active_spawned_agent
6309 .as_ref()
6310 .map(|name| format!(" @{}", name))
6311 .unwrap_or_default();
6312 let messages_block = Block::default()
6313 .borders(Borders::ALL)
6314 .title(format!(
6315 " CodeTether Agent [{}{}] model:{} ",
6316 app.current_agent, target_label, model_label
6317 ))
6318 .border_style(Style::default().fg(theme.border_color.to_color()));
6319
6320 let max_width = messages_area.width.saturating_sub(4) as usize;
6321 let message_lines = build_message_lines(app, theme, max_width);
6322
6323 let total_lines = message_lines.len();
6325 let visible_lines = messages_area.height.saturating_sub(2) as usize;
6326 let max_scroll = total_lines.saturating_sub(visible_lines);
6327 let scroll = if app.scroll >= SCROLL_BOTTOM {
6329 max_scroll
6330 } else {
6331 app.scroll.min(max_scroll)
6332 };
6333
6334 let messages_paragraph = Paragraph::new(
6336 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
6337 )
6338 .block(messages_block.clone())
6339 .wrap(Wrap { trim: false });
6340
6341 f.render_widget(messages_paragraph, messages_area);
6342
6343 if total_lines > visible_lines {
6345 let scrollbar = Scrollbar::default()
6346 .orientation(ScrollbarOrientation::VerticalRight)
6347 .symbols(ratatui::symbols::scrollbar::VERTICAL)
6348 .begin_symbol(Some("↑"))
6349 .end_symbol(Some("↓"));
6350
6351 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
6352
6353 let scrollbar_area = Rect::new(
6354 messages_area.right() - 1,
6355 messages_area.top() + 1,
6356 1,
6357 messages_area.height - 2,
6358 );
6359
6360 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
6361 }
6362
6363 let input_title = if app.is_processing {
6365 if let Some(started) = app.processing_started_at {
6366 let elapsed = started.elapsed();
6367 format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
6368 } else {
6369 " Message (Processing...) ".to_string()
6370 }
6371 } else if app.autochat_running {
6372 format!(
6373 " {} ",
6374 app.autochat_status_label()
6375 .unwrap_or_else(|| "Autochat running…".to_string())
6376 )
6377 } else if app.input.starts_with('/') {
6378 let hint = match_slash_command_hint(&app.input);
6379 format!(" {} ", hint)
6380 } else if let Some(target) = &app.active_spawned_agent {
6381 format!(" Message to @{target} (use /agent main to exit) ")
6382 } else {
6383 " Message (Enter to send, / for commands) ".to_string()
6384 };
6385 let input_block = Block::default()
6386 .borders(Borders::ALL)
6387 .title(input_title)
6388 .border_style(Style::default().fg(if app.is_processing {
6389 Color::Yellow
6390 } else if app.autochat_running {
6391 Color::Cyan
6392 } else if app.input.starts_with('/') {
6393 Color::Magenta
6394 } else {
6395 theme.input_border_color.to_color()
6396 }));
6397
6398 let input = Paragraph::new(app.input.as_str())
6399 .block(input_block)
6400 .wrap(Wrap { trim: false });
6401 f.render_widget(input, chunks[1]);
6402
6403 f.set_cursor_position((
6405 chunks[1].x + app.cursor_position as u16 + 1,
6406 chunks[1].y + 1,
6407 ));
6408
6409 let token_display = TokenDisplay::new();
6411 let mut status_line = token_display.create_status_bar(theme);
6412 let model_status = if let Some(ref active) = app.active_model {
6413 let (provider, model) = crate::provider::parse_model_string(active);
6414 format!(" {}:{} ", provider.unwrap_or("auto"), model)
6415 } else {
6416 " auto ".to_string()
6417 };
6418 status_line.spans.insert(
6419 0,
6420 Span::styled(
6421 "│ ",
6422 Style::default()
6423 .fg(theme.timestamp_color.to_color())
6424 .add_modifier(Modifier::DIM),
6425 ),
6426 );
6427 status_line.spans.insert(
6428 0,
6429 Span::styled(model_status, Style::default().fg(Color::Cyan)),
6430 );
6431 if let Some(autochat_status) = app.autochat_status_label() {
6432 status_line.spans.insert(
6433 0,
6434 Span::styled(
6435 format!(" {autochat_status} "),
6436 Style::default()
6437 .fg(Color::Yellow)
6438 .add_modifier(Modifier::BOLD),
6439 ),
6440 );
6441 }
6442 let status = Paragraph::new(status_line);
6443 f.render_widget(status, chunks[2]);
6444
6445 render_help_overlay_if_needed(f, app, theme);
6446}
6447
6448fn render_webview_chat(f: &mut Frame, app: &App, theme: &Theme) -> bool {
6449 let area = f.area();
6450 if area.width < 90 || area.height < 18 {
6451 return false;
6452 }
6453
6454 let main_chunks = Layout::default()
6455 .direction(Direction::Vertical)
6456 .constraints([
6457 Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
6462 .split(area);
6463
6464 render_webview_header(f, app, theme, main_chunks[0]);
6465
6466 let body_constraints = if app.show_inspector {
6467 vec![
6468 Constraint::Length(26),
6469 Constraint::Min(40),
6470 Constraint::Length(30),
6471 ]
6472 } else {
6473 vec![Constraint::Length(26), Constraint::Min(40)]
6474 };
6475
6476 let body_chunks = Layout::default()
6477 .direction(Direction::Horizontal)
6478 .constraints(body_constraints)
6479 .split(main_chunks[1]);
6480
6481 render_webview_sidebar(f, app, theme, body_chunks[0]);
6482 render_webview_chat_center(f, app, theme, body_chunks[1]);
6483 if app.show_inspector && body_chunks.len() > 2 {
6484 render_webview_inspector(f, app, theme, body_chunks[2]);
6485 }
6486
6487 render_webview_input(f, app, theme, main_chunks[2]);
6488
6489 let token_display = TokenDisplay::new();
6490 let mut status_line = token_display.create_status_bar(theme);
6491 let model_status = if let Some(ref active) = app.active_model {
6492 let (provider, model) = crate::provider::parse_model_string(active);
6493 format!(" {}:{} ", provider.unwrap_or("auto"), model)
6494 } else {
6495 " auto ".to_string()
6496 };
6497 status_line.spans.insert(
6498 0,
6499 Span::styled(
6500 "│ ",
6501 Style::default()
6502 .fg(theme.timestamp_color.to_color())
6503 .add_modifier(Modifier::DIM),
6504 ),
6505 );
6506 status_line.spans.insert(
6507 0,
6508 Span::styled(model_status, Style::default().fg(Color::Cyan)),
6509 );
6510 if let Some(autochat_status) = app.autochat_status_label() {
6511 status_line.spans.insert(
6512 0,
6513 Span::styled(
6514 format!(" {autochat_status} "),
6515 Style::default()
6516 .fg(Color::Yellow)
6517 .add_modifier(Modifier::BOLD),
6518 ),
6519 );
6520 }
6521 let status = Paragraph::new(status_line);
6522 f.render_widget(status, main_chunks[3]);
6523
6524 true
6525}
6526
6527fn render_protocol_registry(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
6528 let cards = app.protocol_cards();
6529 let selected = app.protocol_selected.min(cards.len().saturating_sub(1));
6530
6531 let chunks = Layout::default()
6532 .direction(Direction::Horizontal)
6533 .constraints([Constraint::Length(34), Constraint::Min(30)])
6534 .split(area);
6535
6536 let list_block = Block::default()
6537 .borders(Borders::ALL)
6538 .title(" Registered Agents ")
6539 .border_style(Style::default().fg(theme.border_color.to_color()));
6540
6541 let mut list_lines: Vec<Line> = Vec::new();
6542 if cards.is_empty() {
6543 list_lines.push(Line::styled(
6544 "No protocol-registered agents.",
6545 Style::default().fg(Color::DarkGray),
6546 ));
6547 list_lines.push(Line::styled(
6548 "Spawn an agent with /spawn.",
6549 Style::default().fg(Color::DarkGray),
6550 ));
6551 } else {
6552 for (idx, card) in cards.iter().enumerate() {
6553 let marker = if idx == selected { "▶" } else { " " };
6554 let style = if idx == selected {
6555 Style::default()
6556 .fg(Color::Blue)
6557 .add_modifier(Modifier::BOLD)
6558 } else {
6559 Style::default()
6560 };
6561 let transport = card.preferred_transport.as_deref().unwrap_or("JSONRPC");
6562 list_lines.push(Line::styled(format!(" {marker} {}", card.name), style));
6563 list_lines.push(Line::styled(
6564 format!(
6565 " {transport} • {}",
6566 truncate_with_ellipsis(&card.url, 22)
6567 ),
6568 Style::default().fg(Color::DarkGray),
6569 ));
6570 }
6571 }
6572
6573 let list = Paragraph::new(list_lines)
6574 .block(list_block)
6575 .wrap(Wrap { trim: false });
6576 f.render_widget(list, chunks[0]);
6577
6578 let detail_block = Block::default()
6579 .borders(Borders::ALL)
6580 .title(" Agent Card Detail ")
6581 .border_style(Style::default().fg(theme.border_color.to_color()));
6582
6583 let mut detail_lines: Vec<Line> = Vec::new();
6584 if let Some(card) = cards.get(selected) {
6585 let label_style = Style::default().fg(Color::DarkGray);
6586 detail_lines.push(Line::from(vec![
6587 Span::styled("Name: ", label_style),
6588 Span::styled(
6589 card.name.clone(),
6590 Style::default().add_modifier(Modifier::BOLD),
6591 ),
6592 ]));
6593 detail_lines.push(Line::from(vec![
6594 Span::styled("Description: ", label_style),
6595 Span::raw(card.description.clone()),
6596 ]));
6597 detail_lines.push(Line::from(vec![
6598 Span::styled("URL: ", label_style),
6599 Span::styled(card.url.clone(), Style::default().fg(Color::Cyan)),
6600 ]));
6601 detail_lines.push(Line::from(vec![
6602 Span::styled("Version: ", label_style),
6603 Span::raw(format!(
6604 "{} (protocol {})",
6605 card.version, card.protocol_version
6606 )),
6607 ]));
6608
6609 let preferred_transport = card.preferred_transport.as_deref().unwrap_or("JSONRPC");
6610 detail_lines.push(Line::from(vec![
6611 Span::styled("Transport: ", label_style),
6612 Span::raw(preferred_transport.to_string()),
6613 ]));
6614 if !card.additional_interfaces.is_empty() {
6615 detail_lines.push(Line::from(vec![
6616 Span::styled("Interfaces: ", label_style),
6617 Span::raw(format!("{} additional", card.additional_interfaces.len())),
6618 ]));
6619 for iface in &card.additional_interfaces {
6620 detail_lines.push(Line::styled(
6621 format!(" • {} -> {}", iface.transport, iface.url),
6622 Style::default().fg(Color::DarkGray),
6623 ));
6624 }
6625 }
6626
6627 detail_lines.push(Line::from(""));
6628 detail_lines.push(Line::styled(
6629 "Capabilities",
6630 Style::default().add_modifier(Modifier::BOLD),
6631 ));
6632 detail_lines.push(Line::styled(
6633 format!(
6634 " streaming={} push_notifications={} state_history={}",
6635 card.capabilities.streaming,
6636 card.capabilities.push_notifications,
6637 card.capabilities.state_transition_history
6638 ),
6639 Style::default().fg(Color::DarkGray),
6640 ));
6641 if !card.capabilities.extensions.is_empty() {
6642 detail_lines.push(Line::styled(
6643 format!(
6644 " extensions: {}",
6645 card.capabilities
6646 .extensions
6647 .iter()
6648 .map(|e| e.uri.as_str())
6649 .collect::<Vec<_>>()
6650 .join(", ")
6651 ),
6652 Style::default().fg(Color::DarkGray),
6653 ));
6654 }
6655
6656 detail_lines.push(Line::from(""));
6657 detail_lines.push(Line::styled(
6658 format!("Skills ({})", card.skills.len()),
6659 Style::default().add_modifier(Modifier::BOLD),
6660 ));
6661 if card.skills.is_empty() {
6662 detail_lines.push(Line::styled(" none", Style::default().fg(Color::DarkGray)));
6663 } else {
6664 for skill in &card.skills {
6665 let tags = if skill.tags.is_empty() {
6666 "".to_string()
6667 } else {
6668 format!(" [{}]", skill.tags.join(","))
6669 };
6670 detail_lines.push(Line::styled(
6671 format!(" • {}{}", skill.name, tags),
6672 Style::default().fg(Color::Green),
6673 ));
6674 if !skill.description.is_empty() {
6675 detail_lines.push(Line::styled(
6676 format!(" {}", skill.description),
6677 Style::default().fg(Color::DarkGray),
6678 ));
6679 }
6680 }
6681 }
6682
6683 detail_lines.push(Line::from(""));
6684 detail_lines.push(Line::styled(
6685 "Security",
6686 Style::default().add_modifier(Modifier::BOLD),
6687 ));
6688 if card.security_schemes.is_empty() {
6689 detail_lines.push(Line::styled(
6690 " schemes: none",
6691 Style::default().fg(Color::DarkGray),
6692 ));
6693 } else {
6694 let mut names = card.security_schemes.keys().cloned().collect::<Vec<_>>();
6695 names.sort();
6696 detail_lines.push(Line::styled(
6697 format!(" schemes: {}", names.join(", ")),
6698 Style::default().fg(Color::DarkGray),
6699 ));
6700 }
6701 detail_lines.push(Line::styled(
6702 format!(" requirements: {}", card.security.len()),
6703 Style::default().fg(Color::DarkGray),
6704 ));
6705 detail_lines.push(Line::styled(
6706 format!(
6707 " authenticated_extended_card: {}",
6708 card.supports_authenticated_extended_card
6709 ),
6710 Style::default().fg(Color::DarkGray),
6711 ));
6712 } else {
6713 detail_lines.push(Line::styled(
6714 "No card selected.",
6715 Style::default().fg(Color::DarkGray),
6716 ));
6717 }
6718
6719 let detail = Paragraph::new(detail_lines)
6720 .block(detail_block)
6721 .wrap(Wrap { trim: false })
6722 .scroll((app.protocol_scroll as u16, 0));
6723 f.render_widget(detail, chunks[1]);
6724}
6725
6726fn render_webview_header(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
6727 let session_title = app
6728 .session
6729 .as_ref()
6730 .and_then(|s| s.title.clone())
6731 .unwrap_or_else(|| "Workspace Chat".to_string());
6732 let session_id = app
6733 .session
6734 .as_ref()
6735 .map(|s| s.id.chars().take(8).collect::<String>())
6736 .unwrap_or_else(|| "new".to_string());
6737 let model_label = app
6738 .session
6739 .as_ref()
6740 .and_then(|s| s.metadata.model.clone())
6741 .unwrap_or_else(|| "auto".to_string());
6742 let workspace_label = app.workspace.root_display.clone();
6743 let branch_label = app
6744 .workspace
6745 .git_branch
6746 .clone()
6747 .unwrap_or_else(|| "no-git".to_string());
6748 let dirty_label = if app.workspace.git_dirty_files > 0 {
6749 format!("{} dirty", app.workspace.git_dirty_files)
6750 } else {
6751 "clean".to_string()
6752 };
6753
6754 let header_block = Block::default()
6755 .borders(Borders::ALL)
6756 .title(" CodeTether Webview ")
6757 .border_style(Style::default().fg(theme.border_color.to_color()));
6758
6759 let header_lines = vec![
6760 Line::from(vec![
6761 Span::styled(session_title, Style::default().add_modifier(Modifier::BOLD)),
6762 Span::raw(" "),
6763 Span::styled(
6764 format!("#{}", session_id),
6765 Style::default()
6766 .fg(theme.timestamp_color.to_color())
6767 .add_modifier(Modifier::DIM),
6768 ),
6769 ]),
6770 Line::from(vec![
6771 Span::styled(
6772 "Workspace ",
6773 Style::default().fg(theme.timestamp_color.to_color()),
6774 ),
6775 Span::styled(workspace_label, Style::default()),
6776 Span::raw(" "),
6777 Span::styled(
6778 "Branch ",
6779 Style::default().fg(theme.timestamp_color.to_color()),
6780 ),
6781 Span::styled(
6782 branch_label,
6783 Style::default()
6784 .fg(Color::Cyan)
6785 .add_modifier(Modifier::BOLD),
6786 ),
6787 Span::raw(" "),
6788 Span::styled(
6789 dirty_label,
6790 Style::default()
6791 .fg(Color::Yellow)
6792 .add_modifier(Modifier::BOLD),
6793 ),
6794 Span::raw(" "),
6795 Span::styled(
6796 "Model ",
6797 Style::default().fg(theme.timestamp_color.to_color()),
6798 ),
6799 Span::styled(model_label, Style::default().fg(Color::Green)),
6800 ]),
6801 ];
6802
6803 let header = Paragraph::new(header_lines)
6804 .block(header_block)
6805 .wrap(Wrap { trim: true });
6806 f.render_widget(header, area);
6807}
6808
6809fn render_webview_sidebar(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
6810 let sidebar_chunks = Layout::default()
6811 .direction(Direction::Vertical)
6812 .constraints([Constraint::Min(8), Constraint::Min(6)])
6813 .split(area);
6814
6815 let workspace_block = Block::default()
6816 .borders(Borders::ALL)
6817 .title(" Workspace ")
6818 .border_style(Style::default().fg(theme.border_color.to_color()));
6819
6820 let mut workspace_lines = Vec::new();
6821 workspace_lines.push(Line::from(vec![
6822 Span::styled(
6823 "Updated ",
6824 Style::default().fg(theme.timestamp_color.to_color()),
6825 ),
6826 Span::styled(
6827 app.workspace.captured_at.clone(),
6828 Style::default().fg(theme.timestamp_color.to_color()),
6829 ),
6830 ]));
6831 workspace_lines.push(Line::from(""));
6832
6833 if app.workspace.entries.is_empty() {
6834 workspace_lines.push(Line::styled(
6835 "No entries found",
6836 Style::default().fg(Color::DarkGray),
6837 ));
6838 } else {
6839 for entry in app.workspace.entries.iter().take(12) {
6840 let icon = match entry.kind {
6841 WorkspaceEntryKind::Directory => "📁",
6842 WorkspaceEntryKind::File => "📄",
6843 };
6844 workspace_lines.push(Line::from(vec![
6845 Span::styled(icon, Style::default().fg(Color::Cyan)),
6846 Span::raw(" "),
6847 Span::styled(entry.name.clone(), Style::default()),
6848 ]));
6849 }
6850 }
6851
6852 workspace_lines.push(Line::from(""));
6853 workspace_lines.push(Line::styled(
6854 "Use /refresh to rescan",
6855 Style::default()
6856 .fg(Color::DarkGray)
6857 .add_modifier(Modifier::DIM),
6858 ));
6859
6860 let workspace_panel = Paragraph::new(workspace_lines)
6861 .block(workspace_block)
6862 .wrap(Wrap { trim: true });
6863 f.render_widget(workspace_panel, sidebar_chunks[0]);
6864
6865 let sessions_block = Block::default()
6866 .borders(Borders::ALL)
6867 .title(" Recent Sessions ")
6868 .border_style(Style::default().fg(theme.border_color.to_color()));
6869
6870 let mut session_lines = Vec::new();
6871 if app.session_picker_list.is_empty() {
6872 session_lines.push(Line::styled(
6873 "No sessions yet",
6874 Style::default().fg(Color::DarkGray),
6875 ));
6876 } else {
6877 for session in app.session_picker_list.iter().take(6) {
6878 let is_active = app
6879 .session
6880 .as_ref()
6881 .map(|s| s.id == session.id)
6882 .unwrap_or(false);
6883 let title = session.title.as_deref().unwrap_or("(untitled)");
6884 let indicator = if is_active { "●" } else { "○" };
6885 let line_style = if is_active {
6886 Style::default()
6887 .fg(Color::Cyan)
6888 .add_modifier(Modifier::BOLD)
6889 } else {
6890 Style::default()
6891 };
6892 session_lines.push(Line::from(vec![
6893 Span::styled(indicator, line_style),
6894 Span::raw(" "),
6895 Span::styled(title, line_style),
6896 ]));
6897 session_lines.push(Line::styled(
6898 format!(
6899 " {} msgs • {}",
6900 session.message_count,
6901 session.updated_at.format("%m-%d %H:%M")
6902 ),
6903 Style::default().fg(Color::DarkGray),
6904 ));
6905 }
6906 }
6907
6908 let sessions_panel = Paragraph::new(session_lines)
6909 .block(sessions_block)
6910 .wrap(Wrap { trim: true });
6911 f.render_widget(sessions_panel, sidebar_chunks[1]);
6912}
6913
6914fn render_webview_chat_center(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
6915 let messages_area = area;
6916 let focused_suffix = app
6917 .active_spawned_agent
6918 .as_ref()
6919 .map(|name| format!(" → @{name}"))
6920 .unwrap_or_default();
6921 let messages_block = Block::default()
6922 .borders(Borders::ALL)
6923 .title(format!(" Chat [{}{}] ", app.current_agent, focused_suffix))
6924 .border_style(Style::default().fg(theme.border_color.to_color()));
6925
6926 let max_width = messages_area.width.saturating_sub(4) as usize;
6927 let message_lines = build_message_lines(app, theme, max_width);
6928
6929 let total_lines = message_lines.len();
6930 let visible_lines = messages_area.height.saturating_sub(2) as usize;
6931 let max_scroll = total_lines.saturating_sub(visible_lines);
6932 let scroll = if app.scroll >= SCROLL_BOTTOM {
6933 max_scroll
6934 } else {
6935 app.scroll.min(max_scroll)
6936 };
6937
6938 let messages_paragraph = Paragraph::new(
6939 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
6940 )
6941 .block(messages_block.clone())
6942 .wrap(Wrap { trim: false });
6943
6944 f.render_widget(messages_paragraph, messages_area);
6945
6946 if total_lines > visible_lines {
6947 let scrollbar = Scrollbar::default()
6948 .orientation(ScrollbarOrientation::VerticalRight)
6949 .symbols(ratatui::symbols::scrollbar::VERTICAL)
6950 .begin_symbol(Some("↑"))
6951 .end_symbol(Some("↓"));
6952
6953 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
6954
6955 let scrollbar_area = Rect::new(
6956 messages_area.right() - 1,
6957 messages_area.top() + 1,
6958 1,
6959 messages_area.height - 2,
6960 );
6961
6962 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
6963 }
6964}
6965
6966fn render_webview_inspector(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
6967 let block = Block::default()
6968 .borders(Borders::ALL)
6969 .title(" Inspector ")
6970 .border_style(Style::default().fg(theme.border_color.to_color()));
6971
6972 let status_label = if app.is_processing {
6973 "Processing"
6974 } else if app.autochat_running {
6975 "Autochat"
6976 } else {
6977 "Idle"
6978 };
6979 let status_style = if app.is_processing {
6980 Style::default()
6981 .fg(Color::Yellow)
6982 .add_modifier(Modifier::BOLD)
6983 } else if app.autochat_running {
6984 Style::default()
6985 .fg(Color::Cyan)
6986 .add_modifier(Modifier::BOLD)
6987 } else {
6988 Style::default().fg(Color::Green)
6989 };
6990 let tool_label = app
6991 .current_tool
6992 .clone()
6993 .unwrap_or_else(|| "none".to_string());
6994 let message_count = app.messages.len();
6995 let session_id = app
6996 .session
6997 .as_ref()
6998 .map(|s| s.id.chars().take(8).collect::<String>())
6999 .unwrap_or_else(|| "new".to_string());
7000 let model_label = app
7001 .active_model
7002 .as_deref()
7003 .or_else(|| {
7004 app.session
7005 .as_ref()
7006 .and_then(|s| s.metadata.model.as_deref())
7007 })
7008 .unwrap_or("auto");
7009 let conversation_depth = app.session.as_ref().map(|s| s.messages.len()).unwrap_or(0);
7010
7011 let label_style = Style::default().fg(theme.timestamp_color.to_color());
7012
7013 let mut lines = Vec::new();
7014 lines.push(Line::from(vec![
7015 Span::styled("Status: ", label_style),
7016 Span::styled(status_label, status_style),
7017 ]));
7018
7019 if let Some(started) = app.processing_started_at {
7021 let elapsed = started.elapsed();
7022 let elapsed_str = if elapsed.as_secs() >= 60 {
7023 format!("{}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
7024 } else {
7025 format!("{:.1}s", elapsed.as_secs_f64())
7026 };
7027 lines.push(Line::from(vec![
7028 Span::styled("Elapsed: ", label_style),
7029 Span::styled(
7030 elapsed_str,
7031 Style::default()
7032 .fg(Color::Yellow)
7033 .add_modifier(Modifier::BOLD),
7034 ),
7035 ]));
7036 }
7037
7038 if app.autochat_running {
7039 if let Some(status) = app.autochat_status_label() {
7040 lines.push(Line::from(vec![
7041 Span::styled("Relay: ", label_style),
7042 Span::styled(status, Style::default().fg(Color::Cyan)),
7043 ]));
7044 }
7045 }
7046
7047 lines.push(Line::from(vec![
7048 Span::styled("Tool: ", label_style),
7049 Span::styled(
7050 tool_label,
7051 if app.current_tool.is_some() {
7052 Style::default()
7053 .fg(Color::Cyan)
7054 .add_modifier(Modifier::BOLD)
7055 } else {
7056 Style::default().fg(Color::DarkGray)
7057 },
7058 ),
7059 ]));
7060 lines.push(Line::from(""));
7061 lines.push(Line::styled(
7062 "Session",
7063 Style::default().add_modifier(Modifier::BOLD),
7064 ));
7065 lines.push(Line::from(vec![
7066 Span::styled("ID: ", label_style),
7067 Span::styled(format!("#{}", session_id), Style::default().fg(Color::Cyan)),
7068 ]));
7069 lines.push(Line::from(vec![
7070 Span::styled("Model: ", label_style),
7071 Span::styled(model_label.to_string(), Style::default().fg(Color::Green)),
7072 ]));
7073 let agent_display = if let Some(target) = &app.active_spawned_agent {
7074 format!("{} → @{} (focused)", app.current_agent, target)
7075 } else {
7076 app.current_agent.clone()
7077 };
7078 lines.push(Line::from(vec![
7079 Span::styled("Agent: ", label_style),
7080 Span::styled(agent_display, Style::default()),
7081 ]));
7082 lines.push(Line::from(vec![
7083 Span::styled("Messages: ", label_style),
7084 Span::styled(message_count.to_string(), Style::default()),
7085 ]));
7086 lines.push(Line::from(vec![
7087 Span::styled("Context: ", label_style),
7088 Span::styled(format!("{} turns", conversation_depth), Style::default()),
7089 ]));
7090 lines.push(Line::from(vec![
7091 Span::styled("Tools used: ", label_style),
7092 Span::styled(app.tool_call_count.to_string(), Style::default()),
7093 ]));
7094 lines.push(Line::from(vec![
7095 Span::styled("Protocol: ", label_style),
7096 Span::styled(
7097 format!("{} registered", app.protocol_registered_count()),
7098 Style::default().fg(Color::Cyan),
7099 ),
7100 ]));
7101 lines.push(Line::from(vec![
7102 Span::styled("Archive: ", label_style),
7103 Span::styled(
7104 format!("{} records", app.archived_message_count),
7105 Style::default(),
7106 ),
7107 ]));
7108 let sync_style = if app.chat_sync_last_error.is_some() {
7109 Style::default().fg(Color::Red)
7110 } else if app.chat_sync_rx.is_some() {
7111 Style::default().fg(Color::Green)
7112 } else {
7113 Style::default().fg(Color::DarkGray)
7114 };
7115 lines.push(Line::from(vec![
7116 Span::styled("Remote sync: ", label_style),
7117 Span::styled(
7118 app.chat_sync_status
7119 .as_deref()
7120 .unwrap_or("disabled")
7121 .to_string(),
7122 sync_style,
7123 ),
7124 ]));
7125 lines.push(Line::from(""));
7126 lines.push(Line::styled(
7127 "Sub-agents",
7128 Style::default().add_modifier(Modifier::BOLD),
7129 ));
7130 if app.spawned_agents.is_empty() {
7131 lines.push(Line::styled(
7132 "None (use /spawn <name> <instructions>)",
7133 Style::default().fg(Color::DarkGray),
7134 ));
7135 } else {
7136 for (name, agent) in app.spawned_agents.iter().take(4) {
7137 let status = if agent.is_processing { "⚡" } else { "●" };
7138 let is_registered = app.is_agent_protocol_registered(name);
7139 let protocol = if is_registered { "🔗" } else { "⚠" };
7140 let focused = if app.active_spawned_agent.as_deref() == Some(name.as_str()) {
7141 " [focused]"
7142 } else {
7143 ""
7144 };
7145 lines.push(Line::styled(
7146 format!(
7147 "{status} {protocol} {} @{name}{focused}",
7148 agent_avatar(name)
7149 ),
7150 if focused.is_empty() {
7151 Style::default().fg(Color::Magenta)
7152 } else {
7153 Style::default()
7154 .fg(Color::Magenta)
7155 .add_modifier(Modifier::BOLD)
7156 },
7157 ));
7158 let profile = agent_profile(name);
7159 lines.push(Line::styled(
7160 format!(" {} — {}", profile.codename, profile.profile),
7161 Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
7162 ));
7163 lines.push(Line::styled(
7164 format!(" {}", agent.instructions),
7165 Style::default()
7166 .fg(Color::DarkGray)
7167 .add_modifier(Modifier::DIM),
7168 ));
7169 if is_registered {
7170 lines.push(Line::styled(
7171 format!(" bus://local/{name}"),
7172 Style::default()
7173 .fg(Color::Green)
7174 .add_modifier(Modifier::DIM),
7175 ));
7176 }
7177 }
7178 if app.spawned_agents.len() > 4 {
7179 lines.push(Line::styled(
7180 format!("… and {} more", app.spawned_agents.len() - 4),
7181 Style::default()
7182 .fg(Color::DarkGray)
7183 .add_modifier(Modifier::DIM),
7184 ));
7185 }
7186 }
7187 lines.push(Line::from(""));
7188 lines.push(Line::styled(
7189 "Shortcuts",
7190 Style::default().add_modifier(Modifier::BOLD),
7191 ));
7192 lines.push(Line::from(vec![
7193 Span::styled("F3 ", Style::default().fg(Color::Yellow)),
7194 Span::styled("Inspector", Style::default().fg(Color::DarkGray)),
7195 ]));
7196 lines.push(Line::from(vec![
7197 Span::styled("Ctrl+B ", Style::default().fg(Color::Yellow)),
7198 Span::styled("Layout", Style::default().fg(Color::DarkGray)),
7199 ]));
7200 lines.push(Line::from(vec![
7201 Span::styled("Ctrl+Y ", Style::default().fg(Color::Yellow)),
7202 Span::styled("Copy", Style::default().fg(Color::DarkGray)),
7203 ]));
7204 lines.push(Line::from(vec![
7205 Span::styled("Ctrl+M ", Style::default().fg(Color::Yellow)),
7206 Span::styled("Model", Style::default().fg(Color::DarkGray)),
7207 ]));
7208 lines.push(Line::from(vec![
7209 Span::styled("Ctrl+S ", Style::default().fg(Color::Yellow)),
7210 Span::styled("Swarm", Style::default().fg(Color::DarkGray)),
7211 ]));
7212 lines.push(Line::from(vec![
7213 Span::styled("? ", Style::default().fg(Color::Yellow)),
7214 Span::styled("Help", Style::default().fg(Color::DarkGray)),
7215 ]));
7216
7217 let panel = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
7218 f.render_widget(panel, area);
7219}
7220
7221fn render_webview_input(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
7222 let title = if app.is_processing {
7223 if let Some(started) = app.processing_started_at {
7224 let elapsed = started.elapsed();
7225 format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
7226 } else {
7227 " Message (Processing...) ".to_string()
7228 }
7229 } else if app.autochat_running {
7230 format!(
7231 " {} ",
7232 app.autochat_status_label()
7233 .unwrap_or_else(|| "Autochat running…".to_string())
7234 )
7235 } else if app.input.starts_with('/') {
7236 let hint = match_slash_command_hint(&app.input);
7238 format!(" {} ", hint)
7239 } else if let Some(target) = &app.active_spawned_agent {
7240 format!(" Message to @{target} (use /agent main to exit) ")
7241 } else {
7242 " Message (Enter to send, / for commands) ".to_string()
7243 };
7244
7245 let input_block = Block::default()
7246 .borders(Borders::ALL)
7247 .title(title)
7248 .border_style(Style::default().fg(if app.is_processing {
7249 Color::Yellow
7250 } else if app.autochat_running {
7251 Color::Cyan
7252 } else if app.input.starts_with('/') {
7253 Color::Magenta
7254 } else {
7255 theme.input_border_color.to_color()
7256 }));
7257
7258 let input = Paragraph::new(app.input.as_str())
7259 .block(input_block)
7260 .wrap(Wrap { trim: false });
7261 f.render_widget(input, area);
7262
7263 f.set_cursor_position((area.x + app.cursor_position as u16 + 1, area.y + 1));
7264}
7265
7266fn build_message_lines(app: &App, theme: &Theme, max_width: usize) -> Vec<Line<'static>> {
7267 let mut message_lines = Vec::new();
7268 let separator_width = max_width.min(60);
7269
7270 for (idx, message) in app.messages.iter().enumerate() {
7271 let role_style = theme.get_role_style(&message.role);
7272
7273 if idx > 0 {
7275 let sep_char = match message.role.as_str() {
7276 "tool" => "·",
7277 _ => "─",
7278 };
7279 message_lines.push(Line::from(Span::styled(
7280 sep_char.repeat(separator_width),
7281 Style::default()
7282 .fg(theme.timestamp_color.to_color())
7283 .add_modifier(Modifier::DIM),
7284 )));
7285 }
7286
7287 let role_icon = match message.role.as_str() {
7289 "user" => "▸ ",
7290 "assistant" => "◆ ",
7291 "system" => "⚙ ",
7292 "tool" => "⚡",
7293 _ => " ",
7294 };
7295
7296 let header_line = {
7297 let mut spans = vec![
7298 Span::styled(
7299 format!("[{}] ", message.timestamp),
7300 Style::default()
7301 .fg(theme.timestamp_color.to_color())
7302 .add_modifier(Modifier::DIM),
7303 ),
7304 Span::styled(role_icon, role_style),
7305 Span::styled(message.role.clone(), role_style),
7306 ];
7307 if let Some(ref agent) = message.agent_name {
7308 let profile = agent_profile(agent);
7309 spans.push(Span::styled(
7310 format!(" {} @{agent} ‹{}›", agent_avatar(agent), profile.codename),
7311 Style::default()
7312 .fg(Color::Magenta)
7313 .add_modifier(Modifier::BOLD),
7314 ));
7315 }
7316 Line::from(spans)
7317 };
7318 message_lines.push(header_line);
7319
7320 match &message.message_type {
7321 MessageType::ToolCall {
7322 name,
7323 arguments_preview,
7324 arguments_len,
7325 truncated,
7326 } => {
7327 let tool_header = Line::from(vec![
7328 Span::styled(" 🔧 ", Style::default().fg(Color::Yellow)),
7329 Span::styled(
7330 format!("Tool: {}", name),
7331 Style::default()
7332 .fg(Color::Yellow)
7333 .add_modifier(Modifier::BOLD),
7334 ),
7335 ]);
7336 message_lines.push(tool_header);
7337
7338 if arguments_preview.trim().is_empty() {
7339 message_lines.push(Line::from(vec![
7340 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
7341 Span::styled(
7342 "(no arguments)",
7343 Style::default()
7344 .fg(Color::DarkGray)
7345 .add_modifier(Modifier::DIM),
7346 ),
7347 ]));
7348 } else {
7349 for line in arguments_preview.lines() {
7350 let args_line = Line::from(vec![
7351 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
7352 Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
7353 ]);
7354 message_lines.push(args_line);
7355 }
7356 }
7357
7358 if *truncated {
7359 let args_line = Line::from(vec![
7360 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
7361 Span::styled(
7362 format!("... (truncated; {} bytes)", arguments_len),
7363 Style::default()
7364 .fg(Color::DarkGray)
7365 .add_modifier(Modifier::DIM),
7366 ),
7367 ]);
7368 message_lines.push(args_line);
7369 }
7370 }
7371 MessageType::ToolResult {
7372 name,
7373 output_preview,
7374 output_len,
7375 truncated,
7376 success,
7377 duration_ms,
7378 } => {
7379 let icon = if *success { "✅" } else { "❌" };
7380 let result_header = Line::from(vec![
7381 Span::styled(
7382 format!(" {icon} "),
7383 Style::default().fg(if *success { Color::Green } else { Color::Red }),
7384 ),
7385 Span::styled(
7386 format!("Result from {}", name),
7387 Style::default()
7388 .fg(if *success { Color::Green } else { Color::Red })
7389 .add_modifier(Modifier::BOLD),
7390 ),
7391 ]);
7392 message_lines.push(result_header);
7393
7394 let status_line = format!(
7395 " │ status: {}{}",
7396 if *success { "success" } else { "failure" },
7397 duration_ms
7398 .map(|ms| format!(" • {}", format_duration_ms(ms)))
7399 .unwrap_or_default()
7400 );
7401 message_lines.push(Line::from(vec![
7402 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
7403 Span::styled(
7404 status_line.trim_start_matches(" │ ").to_string(),
7405 Style::default().fg(Color::DarkGray),
7406 ),
7407 ]));
7408
7409 if output_preview.trim().is_empty() {
7410 message_lines.push(Line::from(vec![
7411 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
7412 Span::styled(
7413 "(empty output)",
7414 Style::default()
7415 .fg(Color::DarkGray)
7416 .add_modifier(Modifier::DIM),
7417 ),
7418 ]));
7419 } else {
7420 for line in output_preview.lines() {
7421 let output_line = Line::from(vec![
7422 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
7423 Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
7424 ]);
7425 message_lines.push(output_line);
7426 }
7427 }
7428
7429 if *truncated {
7430 message_lines.push(Line::from(vec![
7431 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
7432 Span::styled(
7433 format!("... (truncated; {} bytes)", output_len),
7434 Style::default()
7435 .fg(Color::DarkGray)
7436 .add_modifier(Modifier::DIM),
7437 ),
7438 ]));
7439 }
7440 }
7441 MessageType::Text(text) => {
7442 let formatter = MessageFormatter::new(max_width);
7443 let formatted_content = formatter.format_content(text, &message.role);
7444 message_lines.extend(formatted_content);
7445 }
7446 MessageType::Thinking(text) => {
7447 let thinking_style = Style::default()
7448 .fg(Color::DarkGray)
7449 .add_modifier(Modifier::DIM | Modifier::ITALIC);
7450 message_lines.push(Line::from(Span::styled(
7451 " 💭 Thinking...",
7452 Style::default()
7453 .fg(Color::Magenta)
7454 .add_modifier(Modifier::DIM),
7455 )));
7456 let max_thinking_lines = 8;
7458 let mut iter = text.lines();
7459 let mut shown = 0usize;
7460 while shown < max_thinking_lines {
7461 let Some(line) = iter.next() else { break };
7462 message_lines.push(Line::from(vec![
7463 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
7464 Span::styled(line.to_string(), thinking_style),
7465 ]));
7466 shown += 1;
7467 }
7468 if iter.next().is_some() {
7469 message_lines.push(Line::from(Span::styled(
7470 " │ ... (truncated)",
7471 thinking_style,
7472 )));
7473 }
7474 }
7475 MessageType::Image { url, mime_type } => {
7476 let formatter = MessageFormatter::new(max_width);
7477 let image_line = formatter.format_image(url, mime_type.as_deref());
7478 message_lines.push(image_line);
7479 }
7480 MessageType::File { path, mime_type } => {
7481 let mime_label = mime_type.as_deref().unwrap_or("unknown type");
7482 let file_header = Line::from(vec![
7483 Span::styled(" 📎 ", Style::default().fg(Color::Cyan)),
7484 Span::styled(
7485 format!("File: {}", path),
7486 Style::default()
7487 .fg(Color::Cyan)
7488 .add_modifier(Modifier::BOLD),
7489 ),
7490 Span::styled(
7491 format!(" ({})", mime_label),
7492 Style::default()
7493 .fg(Color::DarkGray)
7494 .add_modifier(Modifier::DIM),
7495 ),
7496 ]);
7497 message_lines.push(file_header);
7498 }
7499 }
7500
7501 if message.role == "assistant" {
7503 if let Some(ref meta) = message.usage_meta {
7504 let duration_str = if meta.duration_ms >= 60_000 {
7505 format!(
7506 "{}m{:02}.{}s",
7507 meta.duration_ms / 60_000,
7508 (meta.duration_ms % 60_000) / 1000,
7509 (meta.duration_ms % 1000) / 100
7510 )
7511 } else {
7512 format!(
7513 "{}.{}s",
7514 meta.duration_ms / 1000,
7515 (meta.duration_ms % 1000) / 100
7516 )
7517 };
7518 let tokens_str =
7519 format!("{}→{} tokens", meta.prompt_tokens, meta.completion_tokens);
7520 let cost_str = match meta.cost_usd {
7521 Some(c) if c < 0.01 => format!("${:.4}", c),
7522 Some(c) => format!("${:.2}", c),
7523 None => String::new(),
7524 };
7525 let dim_style = Style::default()
7526 .fg(theme.timestamp_color.to_color())
7527 .add_modifier(Modifier::DIM);
7528 let mut spans = vec![Span::styled(
7529 format!(" ⏱ {} │ 📊 {}", duration_str, tokens_str),
7530 dim_style,
7531 )];
7532 if !cost_str.is_empty() {
7533 spans.push(Span::styled(format!(" │ 💰 {}", cost_str), dim_style));
7534 }
7535 message_lines.push(Line::from(spans));
7536 }
7537 }
7538
7539 message_lines.push(Line::from(""));
7540 }
7541
7542 if let Some(ref streaming) = app.streaming_text {
7544 if !streaming.is_empty() {
7545 message_lines.push(Line::from(Span::styled(
7546 "─".repeat(separator_width),
7547 Style::default()
7548 .fg(theme.timestamp_color.to_color())
7549 .add_modifier(Modifier::DIM),
7550 )));
7551 message_lines.push(Line::from(vec![
7552 Span::styled(
7553 format!("[{}] ", chrono::Local::now().format("%H:%M")),
7554 Style::default()
7555 .fg(theme.timestamp_color.to_color())
7556 .add_modifier(Modifier::DIM),
7557 ),
7558 Span::styled("◆ ", theme.get_role_style("assistant")),
7559 Span::styled("assistant", theme.get_role_style("assistant")),
7560 Span::styled(
7561 " (streaming...)",
7562 Style::default()
7563 .fg(theme.timestamp_color.to_color())
7564 .add_modifier(Modifier::DIM),
7565 ),
7566 ]));
7567 let formatter = MessageFormatter::new(max_width);
7568 let formatted = formatter.format_content(streaming, "assistant");
7569 message_lines.extend(formatted);
7570 message_lines.push(Line::from(""));
7571 }
7572 }
7573
7574 let mut agent_streams = app.streaming_agent_texts.iter().collect::<Vec<_>>();
7575 agent_streams.sort_by(|(a, _), (b, _)| a.to_lowercase().cmp(&b.to_lowercase()));
7576 for (agent, streaming) in agent_streams {
7577 if streaming.is_empty() {
7578 continue;
7579 }
7580
7581 let profile = agent_profile(agent);
7582
7583 message_lines.push(Line::from(Span::styled(
7584 "─".repeat(separator_width),
7585 Style::default()
7586 .fg(theme.timestamp_color.to_color())
7587 .add_modifier(Modifier::DIM),
7588 )));
7589 message_lines.push(Line::from(vec![
7590 Span::styled(
7591 format!("[{}] ", chrono::Local::now().format("%H:%M")),
7592 Style::default()
7593 .fg(theme.timestamp_color.to_color())
7594 .add_modifier(Modifier::DIM),
7595 ),
7596 Span::styled("◆ ", theme.get_role_style("assistant")),
7597 Span::styled("assistant", theme.get_role_style("assistant")),
7598 Span::styled(
7599 format!(" {} @{} ‹{}›", agent_avatar(agent), agent, profile.codename),
7600 Style::default()
7601 .fg(Color::Magenta)
7602 .add_modifier(Modifier::BOLD),
7603 ),
7604 Span::styled(
7605 " (streaming...)",
7606 Style::default()
7607 .fg(theme.timestamp_color.to_color())
7608 .add_modifier(Modifier::DIM),
7609 ),
7610 ]));
7611
7612 let formatter = MessageFormatter::new(max_width);
7613 let formatted = formatter.format_content(streaming, "assistant");
7614 message_lines.extend(formatted);
7615 message_lines.push(Line::from(""));
7616 }
7617
7618 if app.is_processing {
7619 let spinner = current_spinner_frame();
7620
7621 let elapsed_str = if let Some(started) = app.processing_started_at {
7623 let elapsed = started.elapsed();
7624 if elapsed.as_secs() >= 60 {
7625 format!(" {}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
7626 } else {
7627 format!(" {:.1}s", elapsed.as_secs_f64())
7628 }
7629 } else {
7630 String::new()
7631 };
7632
7633 let processing_line = Line::from(vec![
7634 Span::styled(
7635 format!("[{}] ", chrono::Local::now().format("%H:%M")),
7636 Style::default()
7637 .fg(theme.timestamp_color.to_color())
7638 .add_modifier(Modifier::DIM),
7639 ),
7640 Span::styled("◆ ", theme.get_role_style("assistant")),
7641 Span::styled("assistant", theme.get_role_style("assistant")),
7642 Span::styled(
7643 elapsed_str,
7644 Style::default()
7645 .fg(theme.timestamp_color.to_color())
7646 .add_modifier(Modifier::DIM),
7647 ),
7648 ]);
7649 message_lines.push(processing_line);
7650
7651 let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
7652 (format!(" {spinner} Running: {}", tool), Color::Cyan)
7653 } else {
7654 (
7655 format!(
7656 " {} {}",
7657 spinner,
7658 app.processing_message.as_deref().unwrap_or("Thinking...")
7659 ),
7660 Color::Yellow,
7661 )
7662 };
7663
7664 let indicator_line = Line::from(vec![Span::styled(
7665 status_text,
7666 Style::default()
7667 .fg(status_color)
7668 .add_modifier(Modifier::BOLD),
7669 )]);
7670 message_lines.push(indicator_line);
7671 message_lines.push(Line::from(""));
7672 }
7673
7674 if app.autochat_running {
7675 let status_text = app
7676 .autochat_status_label()
7677 .unwrap_or_else(|| "Autochat running…".to_string());
7678 message_lines.push(Line::from(Span::styled(
7679 "─".repeat(separator_width),
7680 Style::default()
7681 .fg(theme.timestamp_color.to_color())
7682 .add_modifier(Modifier::DIM),
7683 )));
7684 message_lines.push(Line::from(vec![
7685 Span::styled(
7686 format!("[{}] ", chrono::Local::now().format("%H:%M")),
7687 Style::default()
7688 .fg(theme.timestamp_color.to_color())
7689 .add_modifier(Modifier::DIM),
7690 ),
7691 Span::styled("⚙ ", theme.get_role_style("system")),
7692 Span::styled(
7693 status_text,
7694 Style::default()
7695 .fg(Color::Cyan)
7696 .add_modifier(Modifier::BOLD),
7697 ),
7698 ]));
7699 message_lines.push(Line::from(""));
7700 }
7701
7702 message_lines
7703}
7704
7705fn match_slash_command_hint(input: &str) -> String {
7706 let commands = [
7707 ("/go ", "Easy mode: run relay chat with default team size"),
7708 ("/add ", "Easy mode: create a teammate"),
7709 ("/talk ", "Easy mode: message or focus a teammate"),
7710 ("/list", "Easy mode: list teammates"),
7711 ("/remove ", "Easy mode: remove a teammate"),
7712 ("/home", "Easy mode: return to main chat"),
7713 ("/help", "Open help"),
7714 ("/spawn ", "Create a named sub-agent"),
7715 ("/autochat ", "Run protocol-first multi-agent relay chat"),
7716 ("/agents", "List spawned sub-agents"),
7717 ("/kill ", "Remove a spawned sub-agent"),
7718 ("/agent ", "Focus or message a spawned sub-agent"),
7719 ("/swarm ", "Run task in parallel swarm mode"),
7720 ("/ralph", "Start autonomous PRD loop"),
7721 ("/undo", "Undo last message and response"),
7722 ("/sessions", "Open session picker"),
7723 ("/resume", "Resume session or interrupted relay"),
7724 ("/new", "Start a new session"),
7725 ("/model", "Select or set model"),
7726 ("/webview", "Switch to webview layout"),
7727 ("/classic", "Switch to classic layout"),
7728 ("/inspector", "Toggle inspector pane"),
7729 ("/refresh", "Refresh workspace"),
7730 ("/archive", "Show persistent chat archive path"),
7731 ("/view", "Toggle swarm view"),
7732 ("/buslog", "Show protocol bus log"),
7733 ("/protocol", "Show protocol registry"),
7734 ];
7735
7736 let trimmed = input.trim_start();
7737 let input_lower = trimmed.to_lowercase();
7738
7739 if let Some((cmd, desc)) = commands.iter().find(|(cmd, _)| {
7741 let key = cmd.trim_end().to_ascii_lowercase();
7742 input_lower == key || input_lower.starts_with(&(key + " "))
7743 }) {
7744 return format!("{} — {}", cmd.trim(), desc);
7745 }
7746
7747 let matches: Vec<_> = commands
7749 .iter()
7750 .filter(|(cmd, _)| cmd.starts_with(&input_lower))
7751 .collect();
7752
7753 if matches.len() == 1 {
7754 format!("{} — {}", matches[0].0.trim(), matches[0].1)
7755 } else if matches.is_empty() {
7756 "Unknown command".to_string()
7757 } else {
7758 let cmds: Vec<_> = matches.iter().map(|(cmd, _)| cmd.trim()).collect();
7759 cmds.join(" | ")
7760 }
7761}
7762
7763fn command_with_optional_args<'a>(input: &'a str, command: &str) -> Option<&'a str> {
7764 let trimmed = input.trim();
7765 let rest = trimmed.strip_prefix(command)?;
7766
7767 if rest.is_empty() {
7768 return Some("");
7769 }
7770
7771 let first = rest.chars().next()?;
7772 if first.is_whitespace() {
7773 Some(rest.trim())
7774 } else {
7775 None
7776 }
7777}
7778
7779fn normalize_easy_command(input: &str) -> String {
7780 let trimmed = input.trim();
7781 if trimmed.is_empty() {
7782 return String::new();
7783 }
7784
7785 if !trimmed.starts_with('/') {
7786 return input.to_string();
7787 }
7788
7789 let mut parts = trimmed.splitn(2, char::is_whitespace);
7790 let command = parts.next().unwrap_or("");
7791 let args = parts.next().unwrap_or("").trim();
7792
7793 match command.to_ascii_lowercase().as_str() {
7794 "/go" | "/team" => {
7795 if args.is_empty() {
7796 "/autochat".to_string()
7797 } else {
7798 let mut parts = args.splitn(2, char::is_whitespace);
7799 let first = parts.next().unwrap_or("").trim();
7800 if let Ok(count) = first.parse::<usize>() {
7801 let rest = parts.next().unwrap_or("").trim();
7802 if rest.is_empty() {
7803 format!("/autochat {count} {AUTOCHAT_QUICK_DEMO_TASK}")
7804 } else {
7805 format!("/autochat {count} {rest}")
7806 }
7807 } else {
7808 format!("/autochat {AUTOCHAT_DEFAULT_AGENTS} {args}")
7809 }
7810 }
7811 }
7812 "/add" => {
7813 if args.is_empty() {
7814 "/spawn".to_string()
7815 } else {
7816 format!("/spawn {args}")
7817 }
7818 }
7819 "/list" | "/ls" => "/agents".to_string(),
7820 "/remove" | "/rm" => {
7821 if args.is_empty() {
7822 "/kill".to_string()
7823 } else {
7824 format!("/kill {args}")
7825 }
7826 }
7827 "/talk" | "/say" => {
7828 if args.is_empty() {
7829 "/agent".to_string()
7830 } else {
7831 format!("/agent {args}")
7832 }
7833 }
7834 "/focus" => {
7835 if args.is_empty() {
7836 "/agent".to_string()
7837 } else {
7838 format!("/agent {}", args.trim_start_matches('@'))
7839 }
7840 }
7841 "/home" | "/main" => "/agent main".to_string(),
7842 "/h" | "/?" => "/help".to_string(),
7843 _ => trimmed.to_string(),
7844 }
7845}
7846
7847fn is_easy_go_command(input: &str) -> bool {
7848 let command = input
7849 .trim_start()
7850 .split_whitespace()
7851 .next()
7852 .unwrap_or("")
7853 .to_ascii_lowercase();
7854
7855 matches!(command.as_str(), "/go" | "/team")
7856}
7857
7858fn is_glm5_model(model: &str) -> bool {
7859 let normalized = model.trim().to_ascii_lowercase();
7860 matches!(
7861 normalized.as_str(),
7862 "zai/glm-5" | "z-ai/glm-5" | "openrouter/z-ai/glm-5"
7863 )
7864}
7865
7866fn is_minimax_m25_model(model: &str) -> bool {
7867 let normalized = model.trim().to_ascii_lowercase();
7868 matches!(normalized.as_str(), "minimax/minimax-m2.5" | "minimax-m2.5")
7869}
7870
7871fn next_go_model(current_model: Option<&str>) -> String {
7872 match current_model {
7873 Some(model) if is_glm5_model(model) => GO_SWAP_MODEL_MINIMAX.to_string(),
7874 Some(model) if is_minimax_m25_model(model) => GO_SWAP_MODEL_GLM.to_string(),
7875 _ => GO_SWAP_MODEL_MINIMAX.to_string(),
7876 }
7877}
7878
7879fn parse_autochat_args(rest: &str) -> Option<(usize, &str)> {
7880 let rest = rest.trim();
7881 if rest.is_empty() {
7882 return None;
7883 }
7884
7885 let mut parts = rest.splitn(2, char::is_whitespace);
7886 let first = parts.next().unwrap_or("").trim();
7887 if first.is_empty() {
7888 return None;
7889 }
7890
7891 if let Ok(count) = first.parse::<usize>() {
7892 let task = parts.next().unwrap_or("").trim();
7893 if task.is_empty() {
7894 Some((count, AUTOCHAT_QUICK_DEMO_TASK))
7895 } else {
7896 Some((count, task))
7897 }
7898 } else {
7899 Some((AUTOCHAT_DEFAULT_AGENTS, rest))
7900 }
7901}
7902
7903fn normalize_for_convergence(text: &str) -> String {
7904 let mut normalized = String::with_capacity(text.len().min(512));
7905 let mut last_was_space = false;
7906
7907 for ch in text.chars() {
7908 if ch.is_ascii_alphanumeric() {
7909 normalized.push(ch.to_ascii_lowercase());
7910 last_was_space = false;
7911 } else if ch.is_whitespace() && !last_was_space {
7912 normalized.push(' ');
7913 last_was_space = true;
7914 }
7915
7916 if normalized.len() >= 280 {
7917 break;
7918 }
7919 }
7920
7921 normalized.trim().to_string()
7922}
7923
7924fn agent_profile(agent_name: &str) -> AgentProfile {
7925 let normalized = agent_name.to_ascii_lowercase();
7926
7927 if normalized.contains("planner") {
7928 return AgentProfile {
7929 codename: "Strategist",
7930 profile: "Goal decomposition specialist",
7931 personality: "calm, methodical, and dependency-aware",
7932 collaboration_style: "opens with numbered plans and explicit priorities",
7933 signature_move: "turns vague goals into concrete execution ladders",
7934 };
7935 }
7936
7937 if normalized.contains("research") {
7938 return AgentProfile {
7939 codename: "Archivist",
7940 profile: "Evidence and assumptions analyst",
7941 personality: "curious, skeptical, and detail-focused",
7942 collaboration_style: "validates claims and cites edge-case evidence",
7943 signature_move: "surfaces blind spots before implementation starts",
7944 };
7945 }
7946
7947 if normalized.contains("coder") || normalized.contains("implement") {
7948 return AgentProfile {
7949 codename: "Forge",
7950 profile: "Implementation architect",
7951 personality: "pragmatic, direct, and execution-heavy",
7952 collaboration_style: "proposes concrete code-level actions quickly",
7953 signature_move: "translates plans into shippable implementation steps",
7954 };
7955 }
7956
7957 if normalized.contains("review") {
7958 return AgentProfile {
7959 codename: "Sentinel",
7960 profile: "Quality and regression guardian",
7961 personality: "disciplined, assertive, and standards-driven",
7962 collaboration_style: "challenges weak reasoning and hardens quality",
7963 signature_move: "detects brittle assumptions and failure modes",
7964 };
7965 }
7966
7967 if normalized.contains("tester") || normalized.contains("test") {
7968 return AgentProfile {
7969 codename: "Probe",
7970 profile: "Verification strategist",
7971 personality: "adversarial in a good way, systematic, and precise",
7972 collaboration_style: "designs checks around failure-first thinking",
7973 signature_move: "builds test matrices that catch hidden breakage",
7974 };
7975 }
7976
7977 if normalized.contains("integrat") {
7978 return AgentProfile {
7979 codename: "Conductor",
7980 profile: "Cross-stream synthesis lead",
7981 personality: "balanced, diplomatic, and outcome-oriented",
7982 collaboration_style: "reconciles competing inputs into one plan",
7983 signature_move: "merges parallel work into coherent delivery",
7984 };
7985 }
7986
7987 if normalized.contains("skeptic") || normalized.contains("risk") {
7988 return AgentProfile {
7989 codename: "Radar",
7990 profile: "Risk and threat analyst",
7991 personality: "blunt, anticipatory, and protective",
7992 collaboration_style: "flags downside scenarios and mitigation paths",
7993 signature_move: "turns uncertainty into explicit risk registers",
7994 };
7995 }
7996
7997 if normalized.contains("summary") || normalized.contains("summarizer") {
7998 return AgentProfile {
7999 codename: "Beacon",
8000 profile: "Decision synthesis specialist",
8001 personality: "concise, clear, and action-first",
8002 collaboration_style: "compresses complexity into executable next steps",
8003 signature_move: "creates crisp briefings that unblock teams quickly",
8004 };
8005 }
8006
8007 let fallback_profiles = [
8008 AgentProfile {
8009 codename: "Navigator",
8010 profile: "Generalist coordinator",
8011 personality: "adaptable and context-aware",
8012 collaboration_style: "balances speed with clarity",
8013 signature_move: "keeps team momentum aligned",
8014 },
8015 AgentProfile {
8016 codename: "Vector",
8017 profile: "Execution operator",
8018 personality: "focused and deadline-driven",
8019 collaboration_style: "prefers direct action and feedback loops",
8020 signature_move: "drives ambiguous tasks toward decisions",
8021 },
8022 AgentProfile {
8023 codename: "Signal",
8024 profile: "Communication specialist",
8025 personality: "clear, friendly, and structured",
8026 collaboration_style: "frames updates for quick handoffs",
8027 signature_move: "turns noisy context into clean status",
8028 },
8029 AgentProfile {
8030 codename: "Kernel",
8031 profile: "Core-systems thinker",
8032 personality: "analytical and stable",
8033 collaboration_style: "organizes work around constraints and invariants",
8034 signature_move: "locks down the critical path early",
8035 },
8036 ];
8037
8038 let mut hash: u64 = 2_166_136_261;
8039 for byte in normalized.bytes() {
8040 hash = (hash ^ u64::from(byte)).wrapping_mul(16_777_619);
8041 }
8042 fallback_profiles[hash as usize % fallback_profiles.len()]
8043}
8044
8045fn format_agent_profile_summary(agent_name: &str) -> String {
8046 let profile = agent_profile(agent_name);
8047 format!(
8048 "{} — {} ({})",
8049 profile.codename, profile.profile, profile.personality
8050 )
8051}
8052
8053fn agent_avatar(agent_name: &str) -> &'static str {
8054 let mut hash: u64 = 2_166_136_261;
8055 for byte in agent_name.bytes() {
8056 hash = (hash ^ u64::from(byte.to_ascii_lowercase())).wrapping_mul(16_777_619);
8057 }
8058 AGENT_AVATARS[hash as usize % AGENT_AVATARS.len()]
8059}
8060
8061fn format_agent_identity(agent_name: &str) -> String {
8062 let profile = agent_profile(agent_name);
8063 format!(
8064 "{} @{} ‹{}›",
8065 agent_avatar(agent_name),
8066 agent_name,
8067 profile.codename
8068 )
8069}
8070
8071fn format_relay_participant(participant: &str) -> String {
8072 if participant.eq_ignore_ascii_case("user") {
8073 "[you]".to_string()
8074 } else {
8075 format_agent_identity(participant)
8076 }
8077}
8078
8079fn format_relay_handoff_line(relay_id: &str, round: usize, from: &str, to: &str) -> String {
8080 format!(
8081 "[relay {relay_id} • round {round}] {} → {}",
8082 format_relay_participant(from),
8083 format_relay_participant(to)
8084 )
8085}
8086
8087fn format_tool_call_arguments(name: &str, arguments: &str) -> String {
8088 if arguments.len() > TOOL_ARGS_PRETTY_JSON_MAX_BYTES {
8092 return arguments.to_string();
8093 }
8094
8095 let parsed = match serde_json::from_str::<serde_json::Value>(arguments) {
8096 Ok(value) => value,
8097 Err(_) => return arguments.to_string(),
8098 };
8099
8100 if name == "question"
8101 && let Some(question) = parsed.get("question").and_then(serde_json::Value::as_str)
8102 {
8103 return question.to_string();
8104 }
8105
8106 serde_json::to_string_pretty(&parsed).unwrap_or_else(|_| arguments.to_string())
8107}
8108
8109fn build_tool_arguments_preview(
8110 tool_name: &str,
8111 arguments: &str,
8112 max_lines: usize,
8113 max_bytes: usize,
8114) -> (String, bool) {
8115 let formatted = format_tool_call_arguments(tool_name, arguments);
8117 build_text_preview(&formatted, max_lines, max_bytes)
8118}
8119
8120fn build_text_preview(text: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
8124 if max_lines == 0 || max_bytes == 0 || text.is_empty() {
8125 return (String::new(), !text.is_empty());
8126 }
8127
8128 let mut out = String::new();
8129 let mut truncated = false;
8130 let mut remaining = max_bytes;
8131
8132 let mut iter = text.lines();
8133 for i in 0..max_lines {
8134 let Some(line) = iter.next() else { break };
8135
8136 if i > 0 {
8138 if remaining == 0 {
8139 truncated = true;
8140 break;
8141 }
8142 out.push('\n');
8143 remaining = remaining.saturating_sub(1);
8144 }
8145
8146 if remaining == 0 {
8147 truncated = true;
8148 break;
8149 }
8150
8151 if line.len() <= remaining {
8152 out.push_str(line);
8153 remaining = remaining.saturating_sub(line.len());
8154 } else {
8155 let mut end = remaining;
8157 while end > 0 && !line.is_char_boundary(end) {
8158 end -= 1;
8159 }
8160 out.push_str(&line[..end]);
8161 truncated = true;
8162 break;
8163 }
8164 }
8165
8166 if !truncated && iter.next().is_some() {
8168 truncated = true;
8169 }
8170
8171 (out, truncated)
8172}
8173
8174fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
8175 if max_chars == 0 {
8176 return String::new();
8177 }
8178
8179 let mut chars = value.chars();
8180 let mut output = String::new();
8181 for _ in 0..max_chars {
8182 if let Some(ch) = chars.next() {
8183 output.push(ch);
8184 } else {
8185 return value.to_string();
8186 }
8187 }
8188
8189 if chars.next().is_some() {
8190 format!("{output}...")
8191 } else {
8192 output
8193 }
8194}
8195
8196fn message_clipboard_text(message: &ChatMessage) -> String {
8197 let mut prefix = String::new();
8198 if let Some(agent) = &message.agent_name {
8199 prefix = format!("@{agent}\n");
8200 }
8201
8202 match &message.message_type {
8203 MessageType::Text(text) => format!("{prefix}{text}"),
8204 MessageType::Thinking(text) => format!("{prefix}{text}"),
8205 MessageType::Image { url, .. } => format!("{prefix}{url}"),
8206 MessageType::File { path, .. } => format!("{prefix}{path}"),
8207 MessageType::ToolCall {
8208 name,
8209 arguments_preview,
8210 ..
8211 } => format!("{prefix}Tool call: {name}\n{arguments_preview}"),
8212 MessageType::ToolResult {
8213 name,
8214 output_preview,
8215 ..
8216 } => format!("{prefix}Tool result: {name}\n{output_preview}"),
8217 }
8218}
8219
8220fn copy_text_to_clipboard_best_effort(text: &str) -> Result<&'static str, String> {
8221 if text.trim().is_empty() {
8222 return Err("empty text".to_string());
8223 }
8224
8225 match arboard::Clipboard::new().and_then(|mut clipboard| clipboard.set_text(text.to_string())) {
8227 Ok(()) => return Ok("system clipboard"),
8228 Err(e) => {
8229 tracing::debug!(error = %e, "System clipboard unavailable; falling back to OSC52");
8230 }
8231 }
8232
8233 osc52_copy(text).map_err(|e| format!("osc52 copy failed: {e}"))?;
8235 Ok("OSC52")
8236}
8237
8238fn osc52_copy(text: &str) -> std::io::Result<()> {
8239 let payload = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
8242 let seq = format!("\u{1b}]52;c;{payload}\u{07}");
8243
8244 let mut stdout = std::io::stdout();
8245 crossterm::execute!(stdout, crossterm::style::Print(seq))?;
8246 use std::io::Write;
8247 stdout.flush()?;
8248 Ok(())
8249}
8250
8251fn render_help_overlay_if_needed(f: &mut Frame, app: &App, theme: &Theme) {
8252 if !app.show_help {
8253 return;
8254 }
8255
8256 let area = centered_rect(60, 60, f.area());
8257 f.render_widget(Clear, area);
8258
8259 let token_display = TokenDisplay::new();
8260 let token_info = token_display.create_detailed_display();
8261
8262 let model_section: Vec<String> = if let Some(ref active) = app.active_model {
8264 let (provider, model) = crate::provider::parse_model_string(active);
8265 let provider_label = provider.unwrap_or("auto");
8266 vec![
8267 "".to_string(),
8268 " ACTIVE MODEL".to_string(),
8269 " ==============".to_string(),
8270 format!(" Provider: {}", provider_label),
8271 format!(" Model: {}", model),
8272 format!(" Agent: {}", app.current_agent),
8273 ]
8274 } else {
8275 vec![
8276 "".to_string(),
8277 " ACTIVE MODEL".to_string(),
8278 " ==============".to_string(),
8279 format!(" Provider: auto"),
8280 format!(" Model: (default)"),
8281 format!(" Agent: {}", app.current_agent),
8282 ]
8283 };
8284
8285 let help_text: Vec<String> = vec![
8286 "".to_string(),
8287 " KEYBOARD SHORTCUTS".to_string(),
8288 " ==================".to_string(),
8289 "".to_string(),
8290 " Enter Send message".to_string(),
8291 " Tab Switch between build/plan agents".to_string(),
8292 " Ctrl+A Open spawned-agent picker".to_string(),
8293 " Ctrl+M Open model picker".to_string(),
8294 " Ctrl+L Protocol bus log".to_string(),
8295 " Ctrl+P Protocol registry".to_string(),
8296 " Ctrl+S Toggle swarm view".to_string(),
8297 " Ctrl+B Toggle webview layout".to_string(),
8298 " Ctrl+Y Copy latest assistant reply".to_string(),
8299 " F3 Toggle inspector pane".to_string(),
8300 " Ctrl+C Quit".to_string(),
8301 " ? Toggle this help".to_string(),
8302 "".to_string(),
8303 " SLASH COMMANDS (auto-complete hints shown while typing)".to_string(),
8304 " EASY MODE".to_string(),
8305 " /go <task> Start relay chat with default team size".to_string(),
8306 " /add <name> Create a helper teammate".to_string(),
8307 " /talk <name> <message> Message teammate".to_string(),
8308 " /list List teammates".to_string(),
8309 " /remove <name> Remove teammate".to_string(),
8310 " /home Return to main chat".to_string(),
8311 " /help Open this help".to_string(),
8312 "".to_string(),
8313 " ADVANCED MODE".to_string(),
8314 " /spawn <name> <instructions> Create a named sub-agent".to_string(),
8315 " /autochat [count] <task> Protocol-first auto multi-agent relay".to_string(),
8316 " /agents List spawned sub-agents".to_string(),
8317 " /kill <name> Remove a spawned sub-agent".to_string(),
8318 " /agent <name> Focus chat on a spawned sub-agent".to_string(),
8319 " /agent <name> <message> Send one message to a spawned sub-agent".to_string(),
8320 " /agent Open spawned-agent picker".to_string(),
8321 " /agent main|off Exit focused sub-agent chat".to_string(),
8322 " /swarm <task> Run task in parallel swarm mode".to_string(),
8323 " /ralph [path] Start Ralph PRD loop (default: prd.json)".to_string(),
8324 " /undo Undo last message and response".to_string(),
8325 " /sessions Open session picker (filter, delete, load)".to_string(),
8326 " /resume Resume interrupted relay or most recent session".to_string(),
8327 " /resume <id> Resume specific session by ID".to_string(),
8328 " /new Start a fresh session".to_string(),
8329 " /model Open model picker (or /model <name>)".to_string(),
8330 " /view Toggle swarm view".to_string(),
8331 " /buslog Show protocol bus log".to_string(),
8332 " /protocol Show protocol registry and AgentCards".to_string(),
8333 " /webview Web dashboard layout".to_string(),
8334 " /classic Single-pane layout".to_string(),
8335 " /inspector Toggle inspector pane".to_string(),
8336 " /refresh Refresh workspace and sessions".to_string(),
8337 " /archive Show persistent chat archive path".to_string(),
8338 "".to_string(),
8339 " SESSION PICKER".to_string(),
8340 " ↑/↓/j/k Navigate sessions".to_string(),
8341 " Enter Load selected session".to_string(),
8342 " d Delete session (press twice to confirm)".to_string(),
8343 " Type Filter sessions by name/agent/ID".to_string(),
8344 " Backspace Clear filter character".to_string(),
8345 " Esc Close picker".to_string(),
8346 "".to_string(),
8347 " VIM-STYLE NAVIGATION".to_string(),
8348 " Alt+j Scroll down".to_string(),
8349 " Alt+k Scroll up".to_string(),
8350 " Ctrl+g Go to top".to_string(),
8351 " Ctrl+G Go to bottom".to_string(),
8352 "".to_string(),
8353 " SCROLLING".to_string(),
8354 " Up/Down Scroll messages".to_string(),
8355 " PageUp/Dn Scroll one page".to_string(),
8356 " Alt+u/d Scroll half page".to_string(),
8357 "".to_string(),
8358 " COMMAND HISTORY".to_string(),
8359 " Ctrl+R Search history".to_string(),
8360 " Ctrl+Up/Dn Navigate history".to_string(),
8361 "".to_string(),
8362 " Press ? or Esc to close".to_string(),
8363 "".to_string(),
8364 ];
8365
8366 let mut combined_text = token_info;
8367 combined_text.extend(model_section);
8368 combined_text.extend(help_text);
8369
8370 let help = Paragraph::new(combined_text.join("\n"))
8371 .block(
8372 Block::default()
8373 .borders(Borders::ALL)
8374 .title(" Help ")
8375 .border_style(Style::default().fg(theme.help_border_color.to_color())),
8376 )
8377 .wrap(Wrap { trim: false });
8378
8379 f.render_widget(help, area);
8380}
8381
8382fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
8384 let popup_layout = Layout::default()
8385 .direction(Direction::Vertical)
8386 .constraints([
8387 Constraint::Percentage((100 - percent_y) / 2),
8388 Constraint::Percentage(percent_y),
8389 Constraint::Percentage((100 - percent_y) / 2),
8390 ])
8391 .split(r);
8392
8393 Layout::default()
8394 .direction(Direction::Horizontal)
8395 .constraints([
8396 Constraint::Percentage((100 - percent_x) / 2),
8397 Constraint::Percentage(percent_x),
8398 Constraint::Percentage((100 - percent_x) / 2),
8399 ])
8400 .split(popup_layout[1])[1]
8401}
8402
8403#[cfg(test)]
8404mod tests {
8405 use super::{
8406 AUTOCHAT_QUICK_DEMO_TASK, agent_avatar, agent_profile, command_with_optional_args,
8407 estimate_cost, format_agent_identity, format_relay_handoff_line, is_easy_go_command,
8408 is_secure_environment_from_values, match_slash_command_hint, minio_fallback_endpoint,
8409 next_go_model, normalize_easy_command, normalize_for_convergence, normalize_minio_endpoint,
8410 parse_autochat_args,
8411 };
8412
8413 #[test]
8414 fn command_with_optional_args_handles_bare_command() {
8415 assert_eq!(command_with_optional_args("/spawn", "/spawn"), Some(""));
8416 }
8417
8418 #[test]
8419 fn command_with_optional_args_handles_arguments() {
8420 assert_eq!(
8421 command_with_optional_args("/spawn planner you plan", "/spawn"),
8422 Some("planner you plan")
8423 );
8424 }
8425
8426 #[test]
8427 fn command_with_optional_args_ignores_prefix_collisions() {
8428 assert_eq!(command_with_optional_args("/spawned", "/spawn"), None);
8429 }
8430
8431 #[test]
8432 fn command_with_optional_args_ignores_autochat_prefix_collisions() {
8433 assert_eq!(command_with_optional_args("/autochatty", "/autochat"), None);
8434 }
8435
8436 #[test]
8437 fn command_with_optional_args_trims_leading_whitespace_in_args() {
8438 assert_eq!(
8439 command_with_optional_args("/kill local-agent-1", "/kill"),
8440 Some("local-agent-1")
8441 );
8442 }
8443
8444 #[test]
8445 fn slash_hint_includes_protocol_command() {
8446 let hint = match_slash_command_hint("/protocol");
8447 assert!(hint.contains("/protocol"));
8448 }
8449
8450 #[test]
8451 fn slash_hint_includes_autochat_command() {
8452 let hint = match_slash_command_hint("/autochat");
8453 assert!(hint.contains("/autochat"));
8454 }
8455
8456 #[test]
8457 fn normalize_easy_command_maps_go_to_autochat() {
8458 assert_eq!(
8459 normalize_easy_command("/go build a calculator"),
8460 "/autochat 3 build a calculator"
8461 );
8462 }
8463
8464 #[test]
8465 fn normalize_easy_command_maps_go_count_and_task() {
8466 assert_eq!(
8467 normalize_easy_command("/go 4 build a calculator"),
8468 "/autochat 4 build a calculator"
8469 );
8470 }
8471
8472 #[test]
8473 fn normalize_easy_command_maps_go_count_only_to_demo_task() {
8474 assert_eq!(
8475 normalize_easy_command("/go 4"),
8476 format!("/autochat 4 {AUTOCHAT_QUICK_DEMO_TASK}")
8477 );
8478 }
8479
8480 #[test]
8481 fn slash_hint_handles_command_with_args() {
8482 let hint = match_slash_command_hint("/go 4");
8483 assert!(hint.contains("/go"));
8484 }
8485
8486 #[test]
8487 fn parse_autochat_args_supports_default_count() {
8488 assert_eq!(
8489 parse_autochat_args("build a calculator"),
8490 Some((3, "build a calculator"))
8491 );
8492 }
8493
8494 #[test]
8495 fn parse_autochat_args_supports_explicit_count() {
8496 assert_eq!(
8497 parse_autochat_args("4 build a calculator"),
8498 Some((4, "build a calculator"))
8499 );
8500 }
8501
8502 #[test]
8503 fn parse_autochat_args_count_only_uses_quick_demo_task() {
8504 assert_eq!(
8505 parse_autochat_args("4"),
8506 Some((4, AUTOCHAT_QUICK_DEMO_TASK))
8507 );
8508 }
8509
8510 #[test]
8511 fn normalize_for_convergence_ignores_case_and_punctuation() {
8512 let a = normalize_for_convergence("Done! Next Step: Add tests.");
8513 let b = normalize_for_convergence("done next step add tests");
8514 assert_eq!(a, b);
8515 }
8516
8517 #[test]
8518 fn agent_avatar_is_stable_and_ascii() {
8519 let avatar = agent_avatar("planner");
8520 assert_eq!(avatar, agent_avatar("planner"));
8521 assert!(avatar.is_ascii());
8522 assert!(avatar.starts_with('[') && avatar.ends_with(']'));
8523 }
8524
8525 #[test]
8526 fn relay_handoff_line_shows_avatar_labels() {
8527 let line = format_relay_handoff_line("relay-1", 2, "planner", "coder");
8528 assert!(line.contains("relay relay-1"));
8529 assert!(line.contains("@planner"));
8530 assert!(line.contains("@coder"));
8531 assert!(line.contains('['));
8532 }
8533
8534 #[test]
8535 fn relay_handoff_line_formats_user_sender() {
8536 let line = format_relay_handoff_line("relay-2", 1, "user", "planner");
8537 assert!(line.contains("[you]"));
8538 assert!(line.contains("@planner"));
8539 }
8540
8541 #[test]
8542 fn planner_profile_has_expected_personality() {
8543 let profile = agent_profile("auto-planner");
8544 assert_eq!(profile.codename, "Strategist");
8545 assert!(profile.profile.contains("decomposition"));
8546 }
8547
8548 #[test]
8549 fn formatted_identity_includes_codename() {
8550 let identity = format_agent_identity("auto-coder");
8551 assert!(identity.contains("@auto-coder"));
8552 assert!(identity.contains("‹Forge›"));
8553 }
8554
8555 #[test]
8556 fn normalize_minio_endpoint_strips_login_path() {
8557 assert_eq!(
8558 normalize_minio_endpoint("http://192.168.50.223:9001/login"),
8559 "http://192.168.50.223:9001"
8560 );
8561 }
8562
8563 #[test]
8564 fn normalize_minio_endpoint_adds_default_scheme() {
8565 assert_eq!(
8566 normalize_minio_endpoint("192.168.50.223:9000"),
8567 "http://192.168.50.223:9000"
8568 );
8569 }
8570
8571 #[test]
8572 fn fallback_endpoint_maps_console_port_to_s3_port() {
8573 assert_eq!(
8574 minio_fallback_endpoint("http://192.168.50.223:9001"),
8575 Some("http://192.168.50.223:9000".to_string())
8576 );
8577 assert_eq!(minio_fallback_endpoint("http://192.168.50.223:9000"), None);
8578 }
8579
8580 #[test]
8581 fn secure_environment_detection_respects_explicit_flags() {
8582 assert!(is_secure_environment_from_values(Some(true), None, None));
8583 assert!(!is_secure_environment_from_values(
8584 Some(false),
8585 Some(true),
8586 Some("secure")
8587 ));
8588 }
8589
8590 #[test]
8591 fn secure_environment_detection_uses_environment_name_fallback() {
8592 assert!(is_secure_environment_from_values(None, None, Some("PROD")));
8593 assert!(is_secure_environment_from_values(
8594 None,
8595 None,
8596 Some("production")
8597 ));
8598 assert!(!is_secure_environment_from_values(None, None, Some("dev")));
8599 }
8600
8601 #[test]
8602 fn minimax_m25_pricing_estimate_matches_announcement_rates() {
8603 let cost = estimate_cost("minimax/MiniMax-M2.5", 1_000_000, 1_000_000)
8604 .expect("MiniMax M2.5 cost should be available");
8605 assert!((cost - 1.35).abs() < 1e-9);
8606 }
8607
8608 #[test]
8609 fn minimax_m25_lightning_pricing_is_case_insensitive() {
8610 let cost = estimate_cost("MiniMax-M2.5-Lightning", 1_000_000, 1_000_000)
8611 .expect("MiniMax M2.5 Lightning cost should be available");
8612 assert!((cost - 2.7).abs() < 1e-9);
8613 }
8614
8615 #[test]
8616 fn easy_go_command_detects_go_and_team_aliases() {
8617 assert!(is_easy_go_command("/go build indexing"));
8618 assert!(is_easy_go_command("/team 4 implement auth"));
8619 assert!(!is_easy_go_command("/autochat build indexing"));
8620 }
8621
8622 #[test]
8623 fn next_go_model_toggles_between_glm_and_minimax() {
8624 assert_eq!(next_go_model(Some("zai/glm-5")), "minimax/MiniMax-M2.5");
8625 assert_eq!(next_go_model(Some("z-ai/glm-5")), "minimax/MiniMax-M2.5");
8626 assert_eq!(next_go_model(Some("minimax/MiniMax-M2.5")), "zai/glm-5");
8627 assert_eq!(next_go_model(Some("unknown/model")), "minimax/MiniMax-M2.5");
8628 }
8629}