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