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