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