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