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) = OkrRepository::from_config().await {
3251 self.okr_repository = Some(std::sync::Arc::new(repo));
3252 }
3253 }
3254
3255 let pending = PendingOkrApproval::new(task.to_string(), count, next_model.clone());
3257
3258 self.messages
3259 .push(ChatMessage::new("system", pending.approval_prompt()));
3260 self.scroll = SCROLL_BOTTOM;
3261
3262 self.pending_okr_approval = Some(pending);
3264 return;
3265 }
3266
3267 self.start_autochat_execution(count, task.to_string(), config, None, None)
3268 .await;
3269 return;
3270 }
3271
3272 if let Some(task) = command_with_optional_args(&message, "/swarm") {
3274 if task.is_empty() {
3275 self.messages.push(ChatMessage::new(
3276 "system",
3277 "Usage: /swarm <task description>",
3278 ));
3279 return;
3280 }
3281 self.start_swarm_execution(task.to_string(), config).await;
3282 return;
3283 }
3284
3285 if message.trim().starts_with("/ralph") {
3287 let prd_path = message
3288 .trim()
3289 .strip_prefix("/ralph")
3290 .map(|s| s.trim())
3291 .filter(|s| !s.is_empty())
3292 .unwrap_or("prd.json")
3293 .to_string();
3294 self.start_ralph_execution(prd_path, config).await;
3295 return;
3296 }
3297
3298 if message.trim() == "/webview" {
3299 self.chat_layout = ChatLayoutMode::Webview;
3300 self.messages.push(ChatMessage::new(
3301 "system",
3302 "Switched to webview layout. Use /classic to return to single-pane chat.",
3303 ));
3304 return;
3305 }
3306
3307 if message.trim() == "/classic" {
3308 self.chat_layout = ChatLayoutMode::Classic;
3309 self.messages.push(ChatMessage::new(
3310 "system",
3311 "Switched to classic layout. Use /webview for dashboard-style panes.",
3312 ));
3313 return;
3314 }
3315
3316 if message.trim() == "/inspector" {
3317 self.show_inspector = !self.show_inspector;
3318 let state = if self.show_inspector {
3319 "enabled"
3320 } else {
3321 "disabled"
3322 };
3323 self.messages.push(ChatMessage::new(
3324 "system",
3325 format!("Inspector pane {}. Press F3 to toggle quickly.", state),
3326 ));
3327 return;
3328 }
3329
3330 if message.trim() == "/refresh" {
3331 self.refresh_workspace();
3332 let limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
3333 .ok()
3334 .and_then(|v| v.parse().ok())
3335 .unwrap_or(100);
3336 self.session_picker_offset = 0;
3338 match list_sessions_with_opencode_paged(&self.workspace_dir, limit, 0).await {
3339 Ok(sessions) => self.update_cached_sessions(sessions),
3340 Err(err) => self.messages.push(ChatMessage::new(
3341 "system",
3342 format!(
3343 "Workspace refreshed, but failed to refresh sessions: {}",
3344 err
3345 ),
3346 )),
3347 }
3348 self.messages.push(ChatMessage::new(
3349 "system",
3350 "Workspace and session cache refreshed.",
3351 ));
3352 return;
3353 }
3354
3355 if message.trim() == "/archive" {
3356 let details = if let Some(path) = &self.chat_archive_path {
3357 format!(
3358 "Chat archive: {}\nCaptured records in this run: {}\n{}",
3359 path.display(),
3360 self.archived_message_count,
3361 self.chat_sync_summary(),
3362 )
3363 } else {
3364 format!(
3365 "Chat archive path unavailable in this environment.\n{}",
3366 self.chat_sync_summary()
3367 )
3368 };
3369 self.messages.push(ChatMessage::new("system", details));
3370 self.scroll = SCROLL_BOTTOM;
3371 return;
3372 }
3373
3374 if message.trim() == "/view" {
3376 self.view_mode = match self.view_mode {
3377 ViewMode::Chat
3378 | ViewMode::SessionPicker
3379 | ViewMode::ModelPicker
3380 | ViewMode::AgentPicker
3381 | ViewMode::BusLog
3382 | ViewMode::Protocol => ViewMode::Swarm,
3383 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
3384 };
3385 return;
3386 }
3387
3388 if message.trim() == "/buslog" || message.trim() == "/bus" {
3390 self.view_mode = ViewMode::BusLog;
3391 return;
3392 }
3393
3394 if message.trim() == "/protocol" || message.trim() == "/registry" {
3396 self.open_protocol_view();
3397 return;
3398 }
3399
3400 if let Some(rest) = command_with_optional_args(&message, "/spawn") {
3402 let default_instructions = |agent_name: &str| {
3403 let profile = agent_profile(agent_name);
3404 format!(
3405 "You are @{agent_name}, codename {codename}.\n\
3406 Profile: {profile_line}.\n\
3407 Personality: {personality}.\n\
3408 Collaboration style: {style}.\n\
3409 Signature move: {signature}.\n\
3410 Be a helpful teammate: explain in simple words, short steps, and a friendly tone.",
3411 codename = profile.codename,
3412 profile_line = profile.profile,
3413 personality = profile.personality,
3414 style = profile.collaboration_style,
3415 signature = profile.signature_move,
3416 )
3417 };
3418
3419 let (name, instructions, used_default_instructions) = if rest.is_empty() {
3420 self.messages.push(ChatMessage::new(
3421 "system",
3422 "Usage: /spawn <name> [instructions]\nEasy mode: /add <name>\nExample: /spawn planner You are a planning agent. Break tasks into steps.",
3423 ));
3424 return;
3425 } else {
3426 let mut parts = rest.splitn(2, char::is_whitespace);
3427 let name = parts.next().unwrap_or("").trim();
3428 if name.is_empty() {
3429 self.messages.push(ChatMessage::new(
3430 "system",
3431 "Usage: /spawn <name> [instructions]\nEasy mode: /add <name>",
3432 ));
3433 return;
3434 }
3435
3436 let instructions = parts.next().map(str::trim).filter(|s| !s.is_empty());
3437 match instructions {
3438 Some(custom) => (name.to_string(), custom.to_string(), false),
3439 None => (name.to_string(), default_instructions(name), true),
3440 }
3441 };
3442
3443 if self.spawned_agents.contains_key(&name) {
3444 self.messages.push(ChatMessage::new(
3445 "system",
3446 format!("Agent @{name} already exists. Use /kill {name} first."),
3447 ));
3448 return;
3449 }
3450
3451 match Session::new().await {
3452 Ok(mut session) => {
3453 session.metadata.model = self
3455 .active_model
3456 .clone()
3457 .or_else(|| config.default_model.clone());
3458 session.agent = name.clone();
3459
3460 session.add_message(crate::provider::Message {
3462 role: Role::System,
3463 content: vec![ContentPart::Text {
3464 text: format!(
3465 "You are @{name}, a specialized sub-agent. {instructions}\n\n\
3466 When you receive a message from another agent (prefixed with their name), \
3467 respond helpfully. Keep responses concise and focused on your specialty."
3468 ),
3469 }],
3470 });
3471
3472 let mut protocol_registered = false;
3474 if let Some(ref bus) = self.bus {
3475 let handle = bus.handle(&name);
3476 handle.announce_ready(vec!["sub-agent".to_string(), name.clone()]);
3477 protocol_registered = bus.registry.get(&name).is_some();
3478 }
3479
3480 let agent = SpawnedAgent {
3481 name: name.clone(),
3482 instructions: instructions.clone(),
3483 session,
3484 is_processing: false,
3485 };
3486 self.spawned_agents.insert(name.clone(), agent);
3487 self.active_spawned_agent = Some(name.clone());
3488
3489 let protocol_line = if protocol_registered {
3490 format!("Protocol registration: ✅ bus://local/{name}")
3491 } else {
3492 "Protocol registration: ⚠ unavailable (bus not connected)".to_string()
3493 };
3494 let profile_summary = format_agent_profile_summary(&name);
3495
3496 self.messages.push(ChatMessage::new(
3497 "system",
3498 format!(
3499 "Spawned agent {}\nProfile: {}\nInstructions: {instructions}\nFocused chat on @{name}. Type directly, or use @{name} <message>.\n{protocol_line}{}",
3500 format_agent_identity(&name),
3501 profile_summary,
3502 if used_default_instructions {
3503 "\nTip: I used friendly default instructions. You can customize with /add <name> <instructions>."
3504 } else {
3505 ""
3506 }
3507 ),
3508 ));
3509 }
3510 Err(e) => {
3511 self.messages.push(ChatMessage::new(
3512 "system",
3513 format!("Failed to spawn agent: {e}"),
3514 ));
3515 }
3516 }
3517 return;
3518 }
3519
3520 if message.trim() == "/agents" {
3522 if self.spawned_agents.is_empty() {
3523 self.messages.push(ChatMessage::new(
3524 "system",
3525 "No agents spawned. Use /spawn <name> <instructions> to create one.",
3526 ));
3527 } else {
3528 let mut lines = vec![format!(
3529 "Active agents: {} (protocol registered: {})",
3530 self.spawned_agents.len(),
3531 self.protocol_registered_count()
3532 )];
3533
3534 let mut agents = self.spawned_agents.iter().collect::<Vec<_>>();
3535 agents.sort_by(|(a, _), (b, _)| a.to_lowercase().cmp(&b.to_lowercase()));
3536
3537 for (name, agent) in agents {
3538 let status = if agent.is_processing {
3539 "⚡ working"
3540 } else {
3541 "● idle"
3542 };
3543 let protocol_status = if self.is_agent_protocol_registered(name) {
3544 "🔗 protocol"
3545 } else {
3546 "⚠ protocol-pending"
3547 };
3548 let focused = if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
3549 " [focused]"
3550 } else {
3551 ""
3552 };
3553 let profile_summary = format_agent_profile_summary(name);
3554 lines.push(format!(
3555 " {} @{name} [{status}] {protocol_status}{focused} — {} | {}",
3556 agent_avatar(name),
3557 profile_summary,
3558 agent.instructions
3559 ));
3560 }
3561 self.messages
3562 .push(ChatMessage::new("system", lines.join("\n")));
3563 self.messages.push(ChatMessage::new(
3564 "system",
3565 "Tip: use /agent to open the picker, /agent <name> to focus, or Ctrl+A.",
3566 ));
3567 }
3568 return;
3569 }
3570
3571 if let Some(name) = command_with_optional_args(&message, "/kill") {
3573 if name.is_empty() {
3574 self.messages
3575 .push(ChatMessage::new("system", "Usage: /kill <name>"));
3576 return;
3577 }
3578
3579 let name = name.to_string();
3580 if self.spawned_agents.remove(&name).is_some() {
3581 self.agent_response_rxs.retain(|(n, _)| n != &name);
3583 self.streaming_agent_texts.remove(&name);
3584 if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
3585 self.active_spawned_agent = None;
3586 }
3587 if let Some(ref bus) = self.bus {
3589 let handle = bus.handle(&name);
3590 handle.announce_shutdown();
3591 }
3592 self.messages.push(ChatMessage::new(
3593 "system",
3594 format!("Agent @{name} removed."),
3595 ));
3596 } else {
3597 self.messages.push(ChatMessage::new(
3598 "system",
3599 format!("No agent named @{name}. Use /agents to list."),
3600 ));
3601 }
3602 return;
3603 }
3604
3605 if message.trim().starts_with('@') {
3607 let trimmed = message.trim();
3608 let (target, content) = match trimmed.split_once(' ') {
3609 Some((mention, rest)) => (
3610 mention.strip_prefix('@').unwrap_or(mention).to_string(),
3611 rest.to_string(),
3612 ),
3613 None => {
3614 self.messages.push(ChatMessage::new(
3615 "system",
3616 format!(
3617 "Usage: @agent_name your message\nAvailable: {}",
3618 if self.spawned_agents.is_empty() {
3619 "none (use /spawn first)".to_string()
3620 } else {
3621 self.spawned_agents
3622 .keys()
3623 .map(|n| format!("@{n}"))
3624 .collect::<Vec<_>>()
3625 .join(", ")
3626 }
3627 ),
3628 ));
3629 return;
3630 }
3631 };
3632
3633 if !self.spawned_agents.contains_key(&target) {
3634 self.messages.push(ChatMessage::new(
3635 "system",
3636 format!(
3637 "No agent named @{target}. Available: {}",
3638 if self.spawned_agents.is_empty() {
3639 "none (use /spawn first)".to_string()
3640 } else {
3641 self.spawned_agents
3642 .keys()
3643 .map(|n| format!("@{n}"))
3644 .collect::<Vec<_>>()
3645 .join(", ")
3646 }
3647 ),
3648 ));
3649 return;
3650 }
3651
3652 self.messages
3654 .push(ChatMessage::new("user", format!("@{target} {content}")));
3655 self.scroll = SCROLL_BOTTOM;
3656
3657 if let Some(ref bus) = self.bus {
3659 let handle = bus.handle("user");
3660 handle.send_to_agent(
3661 &target,
3662 vec![crate::a2a::types::Part::Text {
3663 text: content.clone(),
3664 }],
3665 );
3666 }
3667
3668 self.send_to_agent(&target, &content, config).await;
3670 return;
3671 }
3672
3673 if !message.trim().starts_with('/')
3675 && let Some(target) = self.active_spawned_agent.clone()
3676 {
3677 if !self.spawned_agents.contains_key(&target) {
3678 self.active_spawned_agent = None;
3679 self.messages.push(ChatMessage::new(
3680 "system",
3681 format!(
3682 "Focused agent @{target} is no longer available. Use /agents or /spawn to continue."
3683 ),
3684 ));
3685 return;
3686 }
3687
3688 let content = message.trim().to_string();
3689 if content.is_empty() {
3690 return;
3691 }
3692
3693 self.messages
3694 .push(ChatMessage::new("user", format!("@{target} {content}")));
3695 self.scroll = SCROLL_BOTTOM;
3696
3697 if let Some(ref bus) = self.bus {
3698 let handle = bus.handle("user");
3699 handle.send_to_agent(
3700 &target,
3701 vec![crate::a2a::types::Part::Text {
3702 text: content.clone(),
3703 }],
3704 );
3705 }
3706
3707 self.send_to_agent(&target, &content, config).await;
3708 return;
3709 }
3710
3711 if message.trim() == "/sessions" {
3713 let limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
3714 .ok()
3715 .and_then(|v| v.parse().ok())
3716 .unwrap_or(100);
3717 self.session_picker_offset = 0;
3719 match list_sessions_with_opencode_paged(&self.workspace_dir, limit, 0).await {
3720 Ok(sessions) => {
3721 if sessions.is_empty() {
3722 self.messages
3723 .push(ChatMessage::new("system", "No saved sessions found."));
3724 } else {
3725 self.update_cached_sessions(sessions);
3726 self.session_picker_selected = 0;
3727 self.view_mode = ViewMode::SessionPicker;
3728 }
3729 }
3730 Err(e) => {
3731 self.messages.push(ChatMessage::new(
3732 "system",
3733 format!("Failed to list sessions: {}", e),
3734 ));
3735 }
3736 }
3737 return;
3738 }
3739
3740 if message.trim() == "/resume" || message.trim().starts_with("/resume ") {
3742 let session_id = message
3743 .trim()
3744 .strip_prefix("/resume")
3745 .map(|s| s.trim())
3746 .filter(|s| !s.is_empty());
3747
3748 if session_id.is_none() {
3750 if let Some(checkpoint) = RelayCheckpoint::load().await {
3751 self.messages.push(ChatMessage::new("user", "/resume"));
3752 self.resume_autochat_relay(checkpoint).await;
3753 return;
3754 }
3755 }
3756
3757 let loaded = if let Some(id) = session_id {
3758 if let Some(oc_id) = id.strip_prefix("opencode_") {
3759 if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
3760 Session::from_opencode(oc_id, &storage).await
3761 } else {
3762 Err(anyhow::anyhow!("OpenCode storage not available"))
3763 }
3764 } else {
3765 Session::load(id).await
3766 }
3767 } else {
3768 match Session::last_for_directory(Some(&self.workspace_dir)).await {
3769 Ok(s) => Ok(s),
3770 Err(_) => Session::last_opencode_for_directory(&self.workspace_dir).await,
3771 }
3772 };
3773
3774 match loaded {
3775 Ok(session) => {
3776 self.messages.clear();
3778 self.messages.push(ChatMessage::new(
3779 "system",
3780 format!(
3781 "Resumed session: {}\nCreated: {}\n{} messages loaded",
3782 session.title.as_deref().unwrap_or("(untitled)"),
3783 session.created_at.format("%Y-%m-%d %H:%M"),
3784 session.messages.len()
3785 ),
3786 ));
3787
3788 for msg in &session.messages {
3789 let role_str = match msg.role {
3790 Role::System => "system",
3791 Role::User => "user",
3792 Role::Assistant => "assistant",
3793 Role::Tool => "tool",
3794 };
3795
3796 for part in &msg.content {
3798 match part {
3799 ContentPart::Text { text } => {
3800 if !text.is_empty() {
3801 self.messages
3802 .push(ChatMessage::new(role_str, text.clone()));
3803 }
3804 }
3805 ContentPart::Image { url, mime_type } => {
3806 self.messages.push(
3807 ChatMessage::new(role_str, "").with_message_type(
3808 MessageType::Image {
3809 url: url.clone(),
3810 mime_type: mime_type.clone(),
3811 },
3812 ),
3813 );
3814 }
3815 ContentPart::ToolCall {
3816 name, arguments, ..
3817 } => {
3818 let (preview, truncated) = build_tool_arguments_preview(
3819 name,
3820 arguments,
3821 TOOL_ARGS_PREVIEW_MAX_LINES,
3822 TOOL_ARGS_PREVIEW_MAX_BYTES,
3823 );
3824 self.messages.push(
3825 ChatMessage::new(role_str, format!("🔧 {name}"))
3826 .with_message_type(MessageType::ToolCall {
3827 name: name.clone(),
3828 arguments_preview: preview,
3829 arguments_len: arguments.len(),
3830 truncated,
3831 }),
3832 );
3833 }
3834 ContentPart::ToolResult { content, .. } => {
3835 let truncated = truncate_with_ellipsis(content, 500);
3836 let (preview, preview_truncated) = build_text_preview(
3837 content,
3838 TOOL_OUTPUT_PREVIEW_MAX_LINES,
3839 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
3840 );
3841 self.messages.push(
3842 ChatMessage::new(
3843 role_str,
3844 format!("✅ Result\n{truncated}"),
3845 )
3846 .with_message_type(MessageType::ToolResult {
3847 name: "tool".to_string(),
3848 output_preview: preview,
3849 output_len: content.len(),
3850 truncated: preview_truncated,
3851 success: true,
3852 duration_ms: None,
3853 }),
3854 );
3855 }
3856 ContentPart::File { path, mime_type } => {
3857 self.messages.push(
3858 ChatMessage::new(role_str, format!("📎 {}", path))
3859 .with_message_type(MessageType::File {
3860 path: path.clone(),
3861 mime_type: mime_type.clone(),
3862 }),
3863 );
3864 }
3865 ContentPart::Thinking { text } => {
3866 if !text.is_empty() {
3867 self.messages.push(
3868 ChatMessage::new(role_str, text.clone())
3869 .with_message_type(MessageType::Thinking(
3870 text.clone(),
3871 )),
3872 );
3873 }
3874 }
3875 }
3876 }
3877 }
3878
3879 self.current_agent = session.agent.clone();
3880 self.session = Some(session);
3881 self.scroll = SCROLL_BOTTOM;
3882 }
3883 Err(e) => {
3884 self.messages.push(ChatMessage::new(
3885 "system",
3886 format!("Failed to load session: {}", e),
3887 ));
3888 }
3889 }
3890 return;
3891 }
3892
3893 if message.trim() == "/model" || message.trim().starts_with("/model ") {
3895 let direct_model = message
3896 .trim()
3897 .strip_prefix("/model")
3898 .map(|s| s.trim())
3899 .filter(|s| !s.is_empty());
3900
3901 if let Some(model_str) = direct_model {
3902 self.active_model = Some(model_str.to_string());
3904 if let Some(session) = self.session.as_mut() {
3905 session.metadata.model = Some(model_str.to_string());
3906 }
3907 self.messages.push(ChatMessage::new(
3908 "system",
3909 format!("Model set to: {}", model_str),
3910 ));
3911 } else {
3912 self.open_model_picker(config).await;
3914 }
3915 return;
3916 }
3917
3918 if message.trim() == "/undo" {
3920 let mut found_user = false;
3923 while let Some(msg) = self.messages.last() {
3924 if msg.role == "user" {
3925 if found_user {
3926 break; }
3928 found_user = true;
3929 }
3930 if msg.role == "system" && !found_user {
3932 break;
3933 }
3934 self.messages.pop();
3935 }
3936
3937 if !found_user {
3938 self.messages
3939 .push(ChatMessage::new("system", "Nothing to undo."));
3940 return;
3941 }
3942
3943 if let Some(session) = self.session.as_mut() {
3946 let mut found_session_user = false;
3947 while let Some(msg) = session.messages.last() {
3948 if msg.role == crate::provider::Role::User {
3949 if found_session_user {
3950 break;
3951 }
3952 found_session_user = true;
3953 }
3954 if msg.role == crate::provider::Role::System && !found_session_user {
3955 break;
3956 }
3957 session.messages.pop();
3958 }
3959 if let Err(e) = session.save().await {
3960 tracing::warn!(error = %e, "Failed to save session after undo");
3961 }
3962 }
3963
3964 self.messages.push(ChatMessage::new(
3965 "system",
3966 "Undid last message and response.",
3967 ));
3968 self.scroll = SCROLL_BOTTOM;
3969 return;
3970 }
3971
3972 if message.trim() == "/new" {
3974 self.session = None;
3975 self.messages.clear();
3976 self.messages.push(ChatMessage::new(
3977 "system",
3978 "Started a new session. Previous session was saved.",
3979 ));
3980 return;
3981 }
3982
3983 self.messages
3985 .push(ChatMessage::new("user", message.clone()));
3986
3987 self.scroll = SCROLL_BOTTOM;
3989
3990 let current_agent = self.current_agent.clone();
3991 let model = self
3992 .active_model
3993 .clone()
3994 .or_else(|| {
3995 config
3996 .agents
3997 .get(¤t_agent)
3998 .and_then(|agent| agent.model.clone())
3999 })
4000 .or_else(|| config.default_model.clone())
4001 .or_else(|| Some("zai/glm-5".to_string()));
4002
4003 if self.session.is_none() {
4005 match Session::new().await {
4006 Ok(session) => {
4007 self.session = Some(session);
4008 }
4009 Err(err) => {
4010 tracing::error!(error = %err, "Failed to create session");
4011 self.messages
4012 .push(ChatMessage::new("assistant", format!("Error: {err}")));
4013 return;
4014 }
4015 }
4016 }
4017
4018 let session = match self.session.as_mut() {
4019 Some(session) => session,
4020 None => {
4021 self.messages.push(ChatMessage::new(
4022 "assistant",
4023 "Error: session not initialized",
4024 ));
4025 return;
4026 }
4027 };
4028
4029 if let Some(model) = model {
4030 session.metadata.model = Some(model);
4031 }
4032
4033 session.agent = current_agent;
4034
4035 self.is_processing = true;
4037 self.processing_message = Some("Thinking...".to_string());
4038 self.current_tool = None;
4039 self.current_tool_started_at = None;
4040 self.processing_started_at = Some(Instant::now());
4041 self.streaming_text = None;
4042
4043 if self.provider_registry.is_none() {
4045 match crate::provider::ProviderRegistry::from_vault().await {
4046 Ok(registry) => {
4047 self.provider_registry = Some(std::sync::Arc::new(registry));
4048 }
4049 Err(err) => {
4050 tracing::error!(error = %err, "Failed to load provider registry");
4051 self.messages.push(ChatMessage::new(
4052 "assistant",
4053 format!("Error loading providers: {err}"),
4054 ));
4055 self.is_processing = false;
4056 return;
4057 }
4058 }
4059 }
4060 let registry = self.provider_registry.clone().unwrap();
4061
4062 let (tx, rx) = mpsc::channel(100);
4064 self.response_rx = Some(rx);
4065
4066 let session_clone = session.clone();
4068 let message_clone = message.clone();
4069
4070 tokio::spawn(async move {
4072 let mut session = session_clone;
4073 if let Err(err) = session
4074 .prompt_with_events(&message_clone, tx.clone(), registry)
4075 .await
4076 {
4077 tracing::error!(error = %err, "Agent processing failed");
4078 let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
4079 let _ = tx.send(SessionEvent::Done).await;
4080 }
4081 });
4082 }
4083
4084 fn handle_response(&mut self, event: SessionEvent) {
4085 self.scroll = SCROLL_BOTTOM;
4087
4088 match event {
4089 SessionEvent::Thinking => {
4090 self.processing_message = Some("Thinking...".to_string());
4091 self.current_tool = None;
4092 self.current_tool_started_at = None;
4093 if self.processing_started_at.is_none() {
4094 self.processing_started_at = Some(Instant::now());
4095 }
4096 }
4097 SessionEvent::ToolCallStart { name, arguments } => {
4098 if let Some(text) = self.streaming_text.take() {
4100 if !text.is_empty() {
4101 self.messages.push(ChatMessage::new("assistant", text));
4102 }
4103 }
4104 self.processing_message = Some(format!("Running {}...", name));
4105 self.current_tool = Some(name.clone());
4106 self.current_tool_started_at = Some(Instant::now());
4107 self.tool_call_count += 1;
4108
4109 let (preview, truncated) = build_tool_arguments_preview(
4110 &name,
4111 &arguments,
4112 TOOL_ARGS_PREVIEW_MAX_LINES,
4113 TOOL_ARGS_PREVIEW_MAX_BYTES,
4114 );
4115 self.messages.push(
4116 ChatMessage::new("tool", format!("🔧 {}", name)).with_message_type(
4117 MessageType::ToolCall {
4118 name,
4119 arguments_preview: preview,
4120 arguments_len: arguments.len(),
4121 truncated,
4122 },
4123 ),
4124 );
4125 }
4126 SessionEvent::ToolCallComplete {
4127 name,
4128 output,
4129 success,
4130 } => {
4131 let icon = if success { "✓" } else { "✗" };
4132 let duration_ms = self
4133 .current_tool_started_at
4134 .take()
4135 .map(|started| started.elapsed().as_millis() as u64);
4136
4137 let (preview, truncated) = build_text_preview(
4138 &output,
4139 TOOL_OUTPUT_PREVIEW_MAX_LINES,
4140 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
4141 );
4142 self.messages.push(
4143 ChatMessage::new("tool", format!("{} {}", icon, name)).with_message_type(
4144 MessageType::ToolResult {
4145 name,
4146 output_preview: preview,
4147 output_len: output.len(),
4148 truncated,
4149 success,
4150 duration_ms,
4151 },
4152 ),
4153 );
4154 self.current_tool = None;
4155 self.processing_message = Some("Thinking...".to_string());
4156 }
4157 SessionEvent::TextChunk(text) => {
4158 self.streaming_text = Some(text);
4160 }
4161 SessionEvent::ThinkingComplete(text) => {
4162 if !text.is_empty() {
4163 self.messages.push(
4164 ChatMessage::new("assistant", &text)
4165 .with_message_type(MessageType::Thinking(text)),
4166 );
4167 }
4168 }
4169 SessionEvent::TextComplete(text) => {
4170 self.streaming_text = None;
4172 if !text.is_empty() {
4173 self.messages.push(ChatMessage::new("assistant", text));
4174 }
4175 }
4176 SessionEvent::UsageReport {
4177 prompt_tokens,
4178 completion_tokens,
4179 duration_ms,
4180 model,
4181 } => {
4182 let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
4183 let meta = UsageMeta {
4184 prompt_tokens,
4185 completion_tokens,
4186 duration_ms,
4187 cost_usd,
4188 };
4189 if let Some(msg) = self
4191 .messages
4192 .iter_mut()
4193 .rev()
4194 .find(|m| m.role == "assistant")
4195 {
4196 msg.usage_meta = Some(meta);
4197 }
4198 }
4199 SessionEvent::SessionSync(session) => {
4200 self.session = Some(session);
4203 }
4204 SessionEvent::Error(err) => {
4205 self.current_tool_started_at = None;
4206 self.messages
4207 .push(ChatMessage::new("assistant", format!("Error: {}", err)));
4208 }
4209 SessionEvent::Done => {
4210 self.is_processing = false;
4211 self.processing_message = None;
4212 self.current_tool = None;
4213 self.current_tool_started_at = None;
4214 self.processing_started_at = None;
4215 self.streaming_text = None;
4216 self.response_rx = None;
4217 }
4218 }
4219 }
4220
4221 async fn send_to_agent(&mut self, agent_name: &str, message: &str, _config: &Config) {
4223 if self.provider_registry.is_none() {
4225 match crate::provider::ProviderRegistry::from_vault().await {
4226 Ok(registry) => {
4227 self.provider_registry = Some(std::sync::Arc::new(registry));
4228 }
4229 Err(err) => {
4230 self.messages.push(ChatMessage::new(
4231 "system",
4232 format!("Error loading providers: {err}"),
4233 ));
4234 return;
4235 }
4236 }
4237 }
4238 let registry = self.provider_registry.clone().unwrap();
4239
4240 let agent = match self.spawned_agents.get_mut(agent_name) {
4241 Some(a) => a,
4242 None => return,
4243 };
4244
4245 agent.is_processing = true;
4246 self.streaming_agent_texts.remove(agent_name);
4247 let session_clone = agent.session.clone();
4248 let msg_clone = message.to_string();
4249 let agent_name_owned = agent_name.to_string();
4250 let bus_arc = self.bus.clone();
4251
4252 let (tx, rx) = mpsc::channel(100);
4253 self.agent_response_rxs.push((agent_name.to_string(), rx));
4254
4255 tokio::spawn(async move {
4256 let mut session = session_clone;
4257 if let Err(err) = session
4258 .prompt_with_events(&msg_clone, tx.clone(), registry)
4259 .await
4260 {
4261 tracing::error!(agent = %agent_name_owned, error = %err, "Spawned agent failed");
4262 let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
4263 let _ = tx.send(SessionEvent::Done).await;
4264 }
4265
4266 if let Some(ref bus) = bus_arc {
4268 let handle = bus.handle(&agent_name_owned);
4269 handle.send(
4270 format!("agent.{agent_name_owned}.events"),
4271 crate::bus::BusMessage::AgentMessage {
4272 from: agent_name_owned.clone(),
4273 to: "user".to_string(),
4274 parts: vec![crate::a2a::types::Part::Text {
4275 text: "(response complete)".to_string(),
4276 }],
4277 },
4278 );
4279 }
4280 });
4281 }
4282
4283 fn handle_agent_response(&mut self, agent_name: &str, event: SessionEvent) {
4285 self.scroll = SCROLL_BOTTOM;
4286
4287 match event {
4288 SessionEvent::Thinking => {
4289 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
4291 agent.is_processing = true;
4292 }
4293 }
4294 SessionEvent::ToolCallStart { name, arguments } => {
4295 self.streaming_agent_texts.remove(agent_name);
4296 self.agent_tool_started_at
4297 .insert(agent_name.to_string(), Instant::now());
4298 let (preview, truncated) = build_tool_arguments_preview(
4299 &name,
4300 &arguments,
4301 TOOL_ARGS_PREVIEW_MAX_LINES,
4302 TOOL_ARGS_PREVIEW_MAX_BYTES,
4303 );
4304 self.messages.push(
4305 ChatMessage::new(
4306 "tool",
4307 format!("🔧 {} → {name}", format_agent_identity(agent_name)),
4308 )
4309 .with_message_type(MessageType::ToolCall {
4310 name,
4311 arguments_preview: preview,
4312 arguments_len: arguments.len(),
4313 truncated,
4314 })
4315 .with_agent_name(agent_name),
4316 );
4317 }
4318 SessionEvent::ToolCallComplete {
4319 name,
4320 output,
4321 success,
4322 } => {
4323 self.streaming_agent_texts.remove(agent_name);
4324 let icon = if success { "✓" } else { "✗" };
4325 let duration_ms = self
4326 .agent_tool_started_at
4327 .remove(agent_name)
4328 .map(|started| started.elapsed().as_millis() as u64);
4329 let (preview, truncated) = build_text_preview(
4330 &output,
4331 TOOL_OUTPUT_PREVIEW_MAX_LINES,
4332 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
4333 );
4334 self.messages.push(
4335 ChatMessage::new(
4336 "tool",
4337 format!("{icon} {} → {name}", format_agent_identity(agent_name)),
4338 )
4339 .with_message_type(MessageType::ToolResult {
4340 name,
4341 output_preview: preview,
4342 output_len: output.len(),
4343 truncated,
4344 success,
4345 duration_ms,
4346 })
4347 .with_agent_name(agent_name),
4348 );
4349 }
4350 SessionEvent::TextChunk(text) => {
4351 if text.is_empty() {
4352 self.streaming_agent_texts.remove(agent_name);
4353 } else {
4354 self.streaming_agent_texts
4355 .insert(agent_name.to_string(), text);
4356 }
4357 }
4358 SessionEvent::ThinkingComplete(text) => {
4359 self.streaming_agent_texts.remove(agent_name);
4360 if !text.is_empty() {
4361 self.messages.push(
4362 ChatMessage::new("assistant", &text)
4363 .with_message_type(MessageType::Thinking(text))
4364 .with_agent_name(agent_name),
4365 );
4366 }
4367 }
4368 SessionEvent::TextComplete(text) => {
4369 self.streaming_agent_texts.remove(agent_name);
4370 if !text.is_empty() {
4371 self.messages
4372 .push(ChatMessage::new("assistant", &text).with_agent_name(agent_name));
4373 }
4374 }
4375 SessionEvent::UsageReport {
4376 prompt_tokens,
4377 completion_tokens,
4378 duration_ms,
4379 model,
4380 } => {
4381 let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
4382 let meta = UsageMeta {
4383 prompt_tokens,
4384 completion_tokens,
4385 duration_ms,
4386 cost_usd,
4387 };
4388 if let Some(msg) =
4389 self.messages.iter_mut().rev().find(|m| {
4390 m.role == "assistant" && m.agent_name.as_deref() == Some(agent_name)
4391 })
4392 {
4393 msg.usage_meta = Some(meta);
4394 }
4395 }
4396 SessionEvent::SessionSync(session) => {
4397 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
4398 agent.session = session;
4399 }
4400 }
4401 SessionEvent::Error(err) => {
4402 self.streaming_agent_texts.remove(agent_name);
4403 self.agent_tool_started_at.remove(agent_name);
4404 self.messages.push(
4405 ChatMessage::new("assistant", format!("Error: {err}"))
4406 .with_agent_name(agent_name),
4407 );
4408 }
4409 SessionEvent::Done => {
4410 self.streaming_agent_texts.remove(agent_name);
4411 self.agent_tool_started_at.remove(agent_name);
4412 if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
4413 agent.is_processing = false;
4414 }
4415 }
4416 }
4417 }
4418
4419 fn handle_autochat_event(&mut self, event: AutochatUiEvent) -> bool {
4420 match event {
4421 AutochatUiEvent::Progress(status) => {
4422 self.autochat_status = Some(status);
4423 false
4424 }
4425 AutochatUiEvent::SystemMessage(text) => {
4426 self.autochat_status = Some(
4427 text.lines()
4428 .next()
4429 .unwrap_or("Relay update")
4430 .trim()
4431 .to_string(),
4432 );
4433 self.messages.push(ChatMessage::new("system", text));
4434 self.scroll = SCROLL_BOTTOM;
4435 false
4436 }
4437 AutochatUiEvent::AgentEvent { agent_name, event } => {
4438 self.autochat_status = Some(format!("Streaming from @{agent_name}…"));
4439 self.handle_agent_response(&agent_name, event);
4440 false
4441 }
4442 AutochatUiEvent::Completed {
4443 summary,
4444 okr_id,
4445 okr_run_id,
4446 relay_id,
4447 } => {
4448 self.autochat_status = Some("Completed".to_string());
4449
4450 let mut full_summary = summary.clone();
4452 if let (Some(okr_id), Some(okr_run_id)) = (&okr_id, &okr_run_id) {
4453 full_summary.push_str(&format!(
4454 "\n\n📊 OKR Tracking: okr_id={} run_id={}",
4455 &okr_id[..8.min(okr_id.len())],
4456 &okr_run_id[..8.min(okr_run_id.len())]
4457 ));
4458 }
4459 if let Some(rid) = &relay_id {
4460 full_summary.push_str(&format!("\n🔗 Relay: {}", rid));
4461 }
4462
4463 self.messages
4464 .push(ChatMessage::new("assistant", full_summary));
4465 self.scroll = SCROLL_BOTTOM;
4466 true
4467 }
4468 }
4469 }
4470
4471 fn handle_swarm_event(&mut self, event: SwarmEvent) {
4473 self.swarm_state.handle_event(event.clone());
4474
4475 if let SwarmEvent::Complete { success, ref stats } = event {
4477 self.view_mode = ViewMode::Chat;
4478 let summary = if success {
4479 format!(
4480 "Swarm completed successfully.\n\
4481 Subtasks: {} completed, {} failed\n\
4482 Total tool calls: {}\n\
4483 Time: {:.1}s (speedup: {:.1}x)",
4484 stats.subagents_completed,
4485 stats.subagents_failed,
4486 stats.total_tool_calls,
4487 stats.execution_time_ms as f64 / 1000.0,
4488 stats.speedup_factor
4489 )
4490 } else {
4491 format!(
4492 "Swarm completed with failures.\n\
4493 Subtasks: {} completed, {} failed\n\
4494 Check the subtask results for details.",
4495 stats.subagents_completed, stats.subagents_failed
4496 )
4497 };
4498 self.messages.push(ChatMessage::new("system", &summary));
4499 self.swarm_rx = None;
4500 }
4501
4502 if let SwarmEvent::Error(ref err) = event {
4503 self.messages
4504 .push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
4505 }
4506 }
4507
4508 fn handle_ralph_event(&mut self, event: RalphEvent) {
4510 self.ralph_state.handle_event(event.clone());
4511
4512 if let RalphEvent::Complete {
4514 ref status,
4515 passed,
4516 total,
4517 } = event
4518 {
4519 self.view_mode = ViewMode::Chat;
4520 let summary = format!(
4521 "Ralph loop finished: {}\n\
4522 Stories: {}/{} passed",
4523 status, passed, total
4524 );
4525 self.messages.push(ChatMessage::new("system", &summary));
4526 self.ralph_rx = None;
4527 }
4528
4529 if let RalphEvent::Error(ref err) = event {
4530 self.messages
4531 .push(ChatMessage::new("system", &format!("Ralph error: {}", err)));
4532 }
4533 }
4534
4535 async fn start_ralph_execution(&mut self, prd_path: String, config: &Config) {
4537 self.messages
4539 .push(ChatMessage::new("user", format!("/ralph {}", prd_path)));
4540
4541 let model = self
4543 .active_model
4544 .clone()
4545 .or_else(|| config.default_model.clone())
4546 .or_else(|| Some("zai/glm-5".to_string()));
4547
4548 let model = match model {
4549 Some(m) => m,
4550 None => {
4551 self.messages.push(ChatMessage::new(
4552 "system",
4553 "No model configured. Use /model to select one first.",
4554 ));
4555 return;
4556 }
4557 };
4558
4559 let prd_file = std::path::PathBuf::from(&prd_path);
4561 if !prd_file.exists() {
4562 self.messages.push(ChatMessage::new(
4563 "system",
4564 format!("PRD file not found: {}", prd_path),
4565 ));
4566 return;
4567 }
4568
4569 let (tx, rx) = mpsc::channel(200);
4571 self.ralph_rx = Some(rx);
4572
4573 self.view_mode = ViewMode::Ralph;
4575 self.ralph_state = RalphViewState::new();
4576
4577 let ralph_config = RalphConfig {
4579 prd_path: prd_path.clone(),
4580 max_iterations: 10,
4581 progress_path: "progress.txt".to_string(),
4582 quality_checks_enabled: true,
4583 auto_commit: true,
4584 model: Some(model.clone()),
4585 use_rlm: false,
4586 parallel_enabled: true,
4587 max_concurrent_stories: 3,
4588 worktree_enabled: true,
4589 story_timeout_secs: 300,
4590 conflict_timeout_secs: 120,
4591 };
4592
4593 let (provider_name, model_name) = if let Some(pos) = model.find('/') {
4595 (model[..pos].to_string(), model[pos + 1..].to_string())
4596 } else {
4597 (model.clone(), model.clone())
4598 };
4599
4600 let prd_path_clone = prd_path.clone();
4601 let tx_clone = tx.clone();
4602
4603 tokio::spawn(async move {
4605 let provider = match crate::provider::ProviderRegistry::from_vault().await {
4607 Ok(registry) => match registry.get(&provider_name) {
4608 Some(p) => p,
4609 None => {
4610 let _ = tx_clone
4611 .send(RalphEvent::Error(format!(
4612 "Provider '{}' not found",
4613 provider_name
4614 )))
4615 .await;
4616 return;
4617 }
4618 },
4619 Err(e) => {
4620 let _ = tx_clone
4621 .send(RalphEvent::Error(format!(
4622 "Failed to load providers: {}",
4623 e
4624 )))
4625 .await;
4626 return;
4627 }
4628 };
4629
4630 let prd_path_buf = std::path::PathBuf::from(&prd_path_clone);
4631 match RalphLoop::new(prd_path_buf, provider, model_name, ralph_config).await {
4632 Ok(ralph) => {
4633 let mut ralph = ralph.with_event_tx(tx_clone.clone());
4634 match ralph.run().await {
4635 Ok(_state) => {
4636 }
4638 Err(e) => {
4639 let _ = tx_clone.send(RalphEvent::Error(e.to_string())).await;
4640 }
4641 }
4642 }
4643 Err(e) => {
4644 let _ = tx_clone
4645 .send(RalphEvent::Error(format!(
4646 "Failed to initialize Ralph: {}",
4647 e
4648 )))
4649 .await;
4650 }
4651 }
4652 });
4653
4654 self.messages.push(ChatMessage::new(
4655 "system",
4656 format!("Starting Ralph loop with PRD: {}", prd_path),
4657 ));
4658 }
4659
4660 async fn start_swarm_execution(&mut self, task: String, config: &Config) {
4662 self.messages
4664 .push(ChatMessage::new("user", format!("/swarm {}", task)));
4665
4666 let model = config
4668 .default_model
4669 .clone()
4670 .or_else(|| Some("zai/glm-5".to_string()));
4671
4672 let swarm_config = SwarmConfig {
4674 model,
4675 max_subagents: 10,
4676 max_steps_per_subagent: 50,
4677 worktree_enabled: true,
4678 worktree_auto_merge: true,
4679 working_dir: Some(
4680 std::env::current_dir()
4681 .map(|p| p.to_string_lossy().to_string())
4682 .unwrap_or_else(|_| ".".to_string()),
4683 ),
4684 ..Default::default()
4685 };
4686
4687 let (tx, rx) = mpsc::channel(100);
4689 self.swarm_rx = Some(rx);
4690
4691 self.view_mode = ViewMode::Swarm;
4693 self.swarm_state = SwarmViewState::new();
4694
4695 let _ = tx
4697 .send(SwarmEvent::Started {
4698 task: task.clone(),
4699 total_subtasks: 0,
4700 })
4701 .await;
4702
4703 let task_clone = task;
4705 let bus_arc = self.bus.clone();
4706 tokio::spawn(async move {
4707 let mut executor = SwarmExecutor::new(swarm_config).with_event_tx(tx.clone());
4709 if let Some(bus) = bus_arc {
4710 executor = executor.with_bus(bus);
4711 }
4712 let result = executor
4713 .execute(&task_clone, DecompositionStrategy::Automatic)
4714 .await;
4715
4716 match result {
4717 Ok(swarm_result) => {
4718 let _ = tx
4719 .send(SwarmEvent::Complete {
4720 success: swarm_result.success,
4721 stats: swarm_result.stats,
4722 })
4723 .await;
4724 }
4725 Err(e) => {
4726 let _ = tx.send(SwarmEvent::Error(e.to_string())).await;
4727 }
4728 }
4729 });
4730 }
4731
4732 async fn open_model_picker(&mut self, config: &Config) {
4734 let mut models: Vec<(String, String, String)> = Vec::new();
4735
4736 match crate::provider::ProviderRegistry::from_vault().await {
4738 Ok(registry) => {
4739 for provider_name in registry.list() {
4740 if let Some(provider) = registry.get(provider_name) {
4741 match provider.list_models().await {
4742 Ok(model_list) => {
4743 for m in model_list {
4744 let label = format!("{}/{}", provider_name, m.id);
4745 let value = format!("{}/{}", provider_name, m.id);
4746 let name = m.name.clone();
4747 models.push((label, value, name));
4748 }
4749 }
4750 Err(e) => {
4751 tracing::warn!(
4752 "Failed to list models for {}: {}",
4753 provider_name,
4754 e
4755 );
4756 }
4757 }
4758 }
4759 }
4760 }
4761 Err(e) => {
4762 tracing::warn!("Failed to load provider registry: {}", e);
4763 }
4764 }
4765
4766 if models.is_empty() {
4768 if let Ok(registry) = crate::provider::ProviderRegistry::from_config(config).await {
4769 for provider_name in registry.list() {
4770 if let Some(provider) = registry.get(provider_name) {
4771 if let Ok(model_list) = provider.list_models().await {
4772 for m in model_list {
4773 let label = format!("{}/{}", provider_name, m.id);
4774 let value = format!("{}/{}", provider_name, m.id);
4775 let name = m.name.clone();
4776 models.push((label, value, name));
4777 }
4778 }
4779 }
4780 }
4781 }
4782 }
4783
4784 if models.is_empty() {
4785 self.messages.push(ChatMessage::new(
4786 "system",
4787 "No models found. Check provider configuration (Vault or config).",
4788 ));
4789 } else {
4790 models.sort_by(|a, b| a.0.cmp(&b.0));
4792 self.model_picker_list = models;
4793 self.model_picker_selected = 0;
4794 self.model_picker_filter.clear();
4795 self.view_mode = ViewMode::ModelPicker;
4796 }
4797 }
4798
4799 fn filtered_sessions(&self) -> Vec<(usize, &SessionSummary)> {
4801 if self.session_picker_filter.is_empty() {
4802 self.session_picker_list.iter().enumerate().collect()
4803 } else {
4804 let filter = self.session_picker_filter.to_lowercase();
4805 self.session_picker_list
4806 .iter()
4807 .enumerate()
4808 .filter(|(_, s)| {
4809 s.title
4810 .as_deref()
4811 .unwrap_or("")
4812 .to_lowercase()
4813 .contains(&filter)
4814 || s.agent.to_lowercase().contains(&filter)
4815 || s.id.to_lowercase().contains(&filter)
4816 })
4817 .collect()
4818 }
4819 }
4820
4821 fn filtered_models(&self) -> Vec<(usize, &(String, String, String))> {
4823 if self.model_picker_filter.is_empty() {
4824 self.model_picker_list.iter().enumerate().collect()
4825 } else {
4826 let filter = self.model_picker_filter.to_lowercase();
4827 self.model_picker_list
4828 .iter()
4829 .enumerate()
4830 .filter(|(_, (label, _, name))| {
4831 label.to_lowercase().contains(&filter) || name.to_lowercase().contains(&filter)
4832 })
4833 .collect()
4834 }
4835 }
4836
4837 fn filtered_spawned_agents(&self) -> Vec<(String, String, bool, bool)> {
4839 let mut agents: Vec<(String, String, bool, bool)> = self
4840 .spawned_agents
4841 .iter()
4842 .map(|(name, agent)| {
4843 let protocol_registered = self.is_agent_protocol_registered(name);
4844 (
4845 name.clone(),
4846 agent.instructions.clone(),
4847 agent.is_processing,
4848 protocol_registered,
4849 )
4850 })
4851 .collect();
4852
4853 agents.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
4854
4855 if self.agent_picker_filter.is_empty() {
4856 agents
4857 } else {
4858 let filter = self.agent_picker_filter.to_lowercase();
4859 agents
4860 .into_iter()
4861 .filter(|(name, instructions, _, _)| {
4862 name.to_lowercase().contains(&filter)
4863 || instructions.to_lowercase().contains(&filter)
4864 })
4865 .collect()
4866 }
4867 }
4868
4869 fn open_agent_picker(&mut self) {
4871 if self.spawned_agents.is_empty() {
4872 self.messages.push(ChatMessage::new(
4873 "system",
4874 "No agents spawned yet. Use /spawn <name> <instructions> first.",
4875 ));
4876 return;
4877 }
4878
4879 self.agent_picker_filter.clear();
4880 let filtered = self.filtered_spawned_agents();
4881 self.agent_picker_selected = if let Some(active) = &self.active_spawned_agent {
4882 filtered
4883 .iter()
4884 .position(|(name, _, _, _)| name == active)
4885 .unwrap_or(0)
4886 } else {
4887 0
4888 };
4889 self.view_mode = ViewMode::AgentPicker;
4890 }
4891
4892 fn navigate_history(&mut self, direction: isize) {
4893 if self.command_history.is_empty() {
4894 return;
4895 }
4896
4897 let history_len = self.command_history.len();
4898 let new_index = match self.history_index {
4899 Some(current) => {
4900 let new = current as isize + direction;
4901 if new < 0 {
4902 None
4903 } else if new >= history_len as isize {
4904 Some(history_len - 1)
4905 } else {
4906 Some(new as usize)
4907 }
4908 }
4909 None => {
4910 if direction > 0 {
4911 Some(0)
4912 } else {
4913 Some(history_len.saturating_sub(1))
4914 }
4915 }
4916 };
4917
4918 self.history_index = new_index;
4919 if let Some(index) = new_index {
4920 self.input = self.command_history[index].clone();
4921 self.cursor_position = self.input.len();
4922 } else {
4923 self.input.clear();
4924 self.cursor_position = 0;
4925 }
4926 }
4927
4928 fn search_history(&mut self) {
4929 if self.command_history.is_empty() {
4931 return;
4932 }
4933
4934 let search_term = self.input.trim().to_lowercase();
4935
4936 if search_term.is_empty() {
4937 if !self.command_history.is_empty() {
4939 self.input = self.command_history.last().unwrap().clone();
4940 self.cursor_position = self.input.len();
4941 self.history_index = Some(self.command_history.len() - 1);
4942 }
4943 return;
4944 }
4945
4946 for (index, cmd) in self.command_history.iter().enumerate().rev() {
4948 if cmd.to_lowercase().starts_with(&search_term) {
4949 self.input = cmd.clone();
4950 self.cursor_position = self.input.len();
4951 self.history_index = Some(index);
4952 return;
4953 }
4954 }
4955
4956 for (index, cmd) in self.command_history.iter().enumerate().rev() {
4958 if cmd.to_lowercase().contains(&search_term) {
4959 self.input = cmd.clone();
4960 self.cursor_position = self.input.len();
4961 self.history_index = Some(index);
4962 return;
4963 }
4964 }
4965 }
4966
4967 fn autochat_status_label(&self) -> Option<String> {
4968 if !self.autochat_running {
4969 return None;
4970 }
4971
4972 let elapsed = self
4973 .autochat_started_at
4974 .map(|started| {
4975 let elapsed = started.elapsed();
4976 if elapsed.as_secs() >= 60 {
4977 format!("{}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
4978 } else {
4979 format!("{:.1}s", elapsed.as_secs_f64())
4980 }
4981 })
4982 .unwrap_or_else(|| "0.0s".to_string());
4983
4984 let phase = self
4985 .autochat_status
4986 .as_deref()
4987 .unwrap_or("Relay is running…")
4988 .to_string();
4989
4990 Some(format!(
4991 "{} Autochat {elapsed} • {phase}",
4992 current_spinner_frame()
4993 ))
4994 }
4995
4996 fn chat_sync_summary(&self) -> String {
4997 if self.chat_sync_rx.is_none() && self.chat_sync_status.is_none() {
4998 if self.secure_environment {
4999 return "Remote sync: REQUIRED in secure environment (not running)".to_string();
5000 }
5001 return "Remote sync: disabled (set CODETETHER_CHAT_SYNC_ENABLED=true)".to_string();
5002 }
5003
5004 let status = self
5005 .chat_sync_status
5006 .as_deref()
5007 .unwrap_or("Remote sync active")
5008 .to_string();
5009 let last_success = self
5010 .chat_sync_last_success
5011 .as_deref()
5012 .unwrap_or("never")
5013 .to_string();
5014 let last_error = self
5015 .chat_sync_last_error
5016 .as_deref()
5017 .unwrap_or("none")
5018 .to_string();
5019
5020 format!(
5021 "Remote sync: {status}\nUploaded batches: {} ({})\nLast success: {last_success}\nLast error: {last_error}",
5022 self.chat_sync_uploaded_batches,
5023 format_bytes(self.chat_sync_uploaded_bytes)
5024 )
5025 }
5026
5027 fn handle_chat_sync_event(&mut self, event: ChatSyncUiEvent) {
5028 match event {
5029 ChatSyncUiEvent::Status(status) => {
5030 self.chat_sync_status = Some(status);
5031 }
5032 ChatSyncUiEvent::BatchUploaded {
5033 bytes,
5034 records,
5035 object_key,
5036 } => {
5037 self.chat_sync_uploaded_bytes = self.chat_sync_uploaded_bytes.saturating_add(bytes);
5038 self.chat_sync_uploaded_batches = self.chat_sync_uploaded_batches.saturating_add(1);
5039 let when = chrono::Local::now().format("%H:%M:%S").to_string();
5040 self.chat_sync_last_success = Some(format!(
5041 "{} • {} records • {} • {}",
5042 when,
5043 records,
5044 format_bytes(bytes),
5045 object_key
5046 ));
5047 self.chat_sync_last_error = None;
5048 self.chat_sync_status =
5049 Some(format!("Synced {} ({})", records, format_bytes(bytes)));
5050 }
5051 ChatSyncUiEvent::Error(error) => {
5052 self.chat_sync_last_error = Some(error.clone());
5053 self.chat_sync_status = Some("Sync error (will retry)".to_string());
5054 }
5055 }
5056 }
5057
5058 fn to_archive_record(
5059 message: &ChatMessage,
5060 workspace: &str,
5061 session_id: Option<String>,
5062 ) -> ChatArchiveRecord {
5063 let (message_type, tool_name, tool_success, tool_duration_ms) = match &message.message_type
5064 {
5065 MessageType::Text(_) => ("text".to_string(), None, None, None),
5066 MessageType::Image { .. } => ("image".to_string(), None, None, None),
5067 MessageType::ToolCall { name, .. } => {
5068 ("tool_call".to_string(), Some(name.clone()), None, None)
5069 }
5070 MessageType::ToolResult {
5071 name,
5072 success,
5073 duration_ms,
5074 ..
5075 } => (
5076 "tool_result".to_string(),
5077 Some(name.clone()),
5078 Some(*success),
5079 *duration_ms,
5080 ),
5081 MessageType::File { .. } => ("file".to_string(), None, None, None),
5082 MessageType::Thinking(_) => ("thinking".to_string(), None, None, None),
5083 };
5084
5085 ChatArchiveRecord {
5086 recorded_at: chrono::Utc::now().to_rfc3339(),
5087 workspace: workspace.to_string(),
5088 session_id,
5089 role: message.role.clone(),
5090 agent_name: message.agent_name.clone(),
5091 message_type,
5092 content: message.content.clone(),
5093 tool_name,
5094 tool_success,
5095 tool_duration_ms,
5096 }
5097 }
5098
5099 fn flush_chat_archive(&mut self) {
5100 let Some(path) = self.chat_archive_path.clone() else {
5101 self.archived_message_count = self.messages.len();
5102 return;
5103 };
5104
5105 if self.archived_message_count >= self.messages.len() {
5106 return;
5107 }
5108
5109 let workspace = self.workspace_dir.to_string_lossy().to_string();
5110 let session_id = self.session.as_ref().map(|session| session.id.clone());
5111 let records: Vec<ChatArchiveRecord> = self.messages[self.archived_message_count..]
5112 .iter()
5113 .map(|message| Self::to_archive_record(message, &workspace, session_id.clone()))
5114 .collect();
5115
5116 if let Some(parent) = path.parent()
5117 && let Err(err) = std::fs::create_dir_all(parent)
5118 {
5119 tracing::warn!(error = %err, path = %parent.display(), "Failed to create chat archive directory");
5120 return;
5121 }
5122
5123 let mut file = match std::fs::OpenOptions::new()
5124 .create(true)
5125 .append(true)
5126 .open(&path)
5127 {
5128 Ok(file) => file,
5129 Err(err) => {
5130 tracing::warn!(error = %err, path = %path.display(), "Failed to open chat archive file");
5131 return;
5132 }
5133 };
5134
5135 for record in records {
5136 if let Err(err) = serde_json::to_writer(&mut file, &record) {
5137 tracing::warn!(error = %err, path = %path.display(), "Failed to serialize chat archive record");
5138 return;
5139 }
5140 if let Err(err) = writeln!(&mut file) {
5141 tracing::warn!(error = %err, path = %path.display(), "Failed to write chat archive newline");
5142 return;
5143 }
5144 }
5145
5146 self.archived_message_count = self.messages.len();
5147 }
5148}
5149
5150async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
5151 let mut app = App::new();
5152 let limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
5154 .ok()
5155 .and_then(|v| v.parse().ok())
5156 .unwrap_or(100);
5157 if let Ok(sessions) = list_sessions_with_opencode_paged(&app.workspace_dir, limit, 0).await {
5158 app.update_cached_sessions(sessions);
5159 }
5160
5161 let bus = std::sync::Arc::new(crate::bus::AgentBus::new());
5163 let mut bus_handle = bus.handle("tui-observer");
5164 let (bus_tx, bus_rx) = mpsc::channel::<crate::bus::BusEnvelope>(512);
5165 app.bus_log_rx = Some(bus_rx);
5166 app.bus = Some(bus.clone());
5167
5168 tokio::spawn(async move {
5170 loop {
5171 match bus_handle.recv().await {
5172 Some(env) => {
5173 if bus_tx.send(env).await.is_err() {
5174 break; }
5176 }
5177 None => break, }
5179 }
5180 });
5181
5182 let mut config = Config::load().await?;
5184 let mut theme = crate::tui::theme_utils::validate_theme(&config.load_theme());
5185
5186 let secure_environment = is_secure_environment();
5187 app.secure_environment = secure_environment;
5188
5189 match parse_chat_sync_config(secure_environment).await {
5190 Ok(Some(sync_config)) => {
5191 if let Some(archive_path) = app.chat_archive_path.clone() {
5192 let (chat_sync_tx, chat_sync_rx) = mpsc::channel::<ChatSyncUiEvent>(64);
5193 app.chat_sync_rx = Some(chat_sync_rx);
5194 app.chat_sync_status = Some("Starting remote archive sync worker…".to_string());
5195 tokio::spawn(async move {
5196 run_chat_sync_worker(chat_sync_tx, archive_path, sync_config).await;
5197 });
5198 } else {
5199 let message = "Remote chat sync is enabled, but local archive path is unavailable.";
5200 if secure_environment {
5201 return Err(anyhow::anyhow!(
5202 "{message} Secure environment requires remote chat sync."
5203 ));
5204 }
5205 app.messages.push(ChatMessage::new("system", message));
5206 }
5207 }
5208 Ok(None) => {}
5209 Err(err) => {
5210 if secure_environment {
5211 return Err(anyhow::anyhow!(
5212 "Secure environment requires remote chat sync: {err}"
5213 ));
5214 }
5215 app.messages.push(ChatMessage::new(
5216 "system",
5217 format!("Remote chat sync disabled due to configuration error: {err}"),
5218 ));
5219 }
5220 }
5221
5222 let _config_paths = vec![
5224 std::path::PathBuf::from("./codetether.toml"),
5225 std::path::PathBuf::from("./.codetether/config.toml"),
5226 ];
5227
5228 let _global_config_path = directories::ProjectDirs::from("com", "codetether", "codetether")
5229 .map(|dirs| dirs.config_dir().join("config.toml"));
5230
5231 let mut last_check = Instant::now();
5232 let mut event_stream = EventStream::new();
5233
5234 let (session_tx, mut session_rx) = mpsc::channel::<Vec<crate::session::SessionSummary>>(1);
5236 {
5237 let workspace_dir = app.workspace_dir.clone();
5238 let session_limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
5239 .ok()
5240 .and_then(|v| v.parse().ok())
5241 .unwrap_or(100);
5242 tokio::spawn(async move {
5243 let mut interval = tokio::time::interval(Duration::from_secs(5));
5244 loop {
5245 interval.tick().await;
5246 if let Ok(sessions) = list_sessions_with_opencode_paged(&workspace_dir, session_limit, 0).await {
5247 if session_tx.send(sessions).await.is_err() {
5248 break; }
5250 }
5251 }
5252 });
5253 }
5254
5255 if let Some(checkpoint) = RelayCheckpoint::load().await {
5257 app.messages.push(ChatMessage::new(
5258 "system",
5259 format!(
5260 "Interrupted relay detected!\nTask: {}\nAgents: {}\nCompleted {} turns, was at round {}, index {}\n\nType /resume to continue the relay from where it left off.",
5261 truncate_with_ellipsis(&checkpoint.task, 120),
5262 checkpoint.ordered_agents.join(" → "),
5263 checkpoint.turns,
5264 checkpoint.round,
5265 checkpoint.idx,
5266 ),
5267 ));
5268 }
5269
5270 loop {
5271 if let Ok(sessions) = session_rx.try_recv() {
5275 app.update_cached_sessions(sessions);
5276 }
5277
5278 if config.ui.hot_reload && last_check.elapsed() > Duration::from_secs(2) {
5280 if let Ok(new_config) = Config::load().await {
5281 if new_config.ui.theme != config.ui.theme
5282 || new_config.ui.custom_theme != config.ui.custom_theme
5283 {
5284 theme = crate::tui::theme_utils::validate_theme(&new_config.load_theme());
5285 config = new_config;
5286 }
5287 }
5288 last_check = Instant::now();
5289 }
5290
5291 terminal.draw(|f| ui(f, &mut app, &theme))?;
5292
5293 let terminal_height = terminal.size()?.height.saturating_sub(6) as usize;
5296 let estimated_lines = app.messages.len() * 4; app.last_max_scroll = estimated_lines.saturating_sub(terminal_height);
5298
5299 if let Some(mut rx) = app.response_rx.take() {
5301 while let Ok(response) = rx.try_recv() {
5302 app.handle_response(response);
5303 }
5304 app.response_rx = Some(rx);
5305 }
5306
5307 if let Some(mut rx) = app.swarm_rx.take() {
5309 while let Ok(event) = rx.try_recv() {
5310 app.handle_swarm_event(event);
5311 }
5312 app.swarm_rx = Some(rx);
5313 }
5314
5315 if let Some(mut rx) = app.ralph_rx.take() {
5317 while let Ok(event) = rx.try_recv() {
5318 app.handle_ralph_event(event);
5319 }
5320 app.ralph_rx = Some(rx);
5321 }
5322
5323 if let Some(mut rx) = app.bus_log_rx.take() {
5325 while let Ok(env) = rx.try_recv() {
5326 app.bus_log_state.ingest(&env);
5327 }
5328 app.bus_log_rx = Some(rx);
5329 }
5330
5331 {
5333 let mut i = 0;
5334 while i < app.agent_response_rxs.len() {
5335 let mut done = false;
5336 while let Ok(event) = app.agent_response_rxs[i].1.try_recv() {
5337 if matches!(event, SessionEvent::Done) {
5338 done = true;
5339 }
5340 let name = app.agent_response_rxs[i].0.clone();
5341 app.handle_agent_response(&name, event);
5342 }
5343 if done {
5344 app.agent_response_rxs.swap_remove(i);
5345 } else {
5346 i += 1;
5347 }
5348 }
5349 }
5350
5351 if let Some(mut rx) = app.autochat_rx.take() {
5353 let mut completed = false;
5354 while let Ok(event) = rx.try_recv() {
5355 if app.handle_autochat_event(event) {
5356 completed = true;
5357 }
5358 }
5359
5360 if completed || rx.is_closed() {
5361 if !completed && app.autochat_running {
5362 app.messages.push(ChatMessage::new(
5363 "system",
5364 "Autochat relay worker stopped unexpectedly.",
5365 ));
5366 app.scroll = SCROLL_BOTTOM;
5367 }
5368 app.autochat_running = false;
5369 app.autochat_started_at = None;
5370 app.autochat_status = None;
5371 app.autochat_rx = None;
5372 } else {
5373 app.autochat_rx = Some(rx);
5374 }
5375 }
5376
5377 if let Some(mut rx) = app.chat_sync_rx.take() {
5379 while let Ok(event) = rx.try_recv() {
5380 app.handle_chat_sync_event(event);
5381 }
5382
5383 if rx.is_closed() {
5384 app.chat_sync_status = Some("Remote archive sync worker stopped.".to_string());
5385 app.chat_sync_rx = None;
5386 if app.secure_environment {
5387 return Err(anyhow::anyhow!(
5388 "Remote archive sync worker stopped in secure environment"
5389 ));
5390 }
5391 } else {
5392 app.chat_sync_rx = Some(rx);
5393 }
5394 }
5395
5396 app.flush_chat_archive();
5398
5399 let ev = tokio::select! {
5401 maybe_event = event_stream.next() => {
5402 match maybe_event {
5403 Some(Ok(ev)) => ev,
5404 Some(Err(_)) => continue,
5405 None => return Ok(()), }
5407 }
5408 _ = tokio::time::sleep(Duration::from_millis(50)) => continue,
5410 };
5411
5412 if let Event::Paste(text) = &ev {
5414 let mut pos = app.cursor_position;
5416 while pos > 0 && !app.input.is_char_boundary(pos) {
5417 pos -= 1;
5418 }
5419 app.cursor_position = pos;
5420
5421 for c in text.chars() {
5422 if c == '\n' || c == '\r' {
5423 app.input.insert(app.cursor_position, ' ');
5425 } else {
5426 app.input.insert(app.cursor_position, c);
5427 }
5428 app.cursor_position += c.len_utf8();
5429 }
5430 continue;
5431 }
5432
5433 if let Event::Key(key) = ev {
5434 if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
5438 continue;
5439 }
5440
5441 if app.show_help {
5443 if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
5444 app.show_help = false;
5445 }
5446 continue;
5447 }
5448
5449 if app.view_mode == ViewMode::ModelPicker {
5451 match key.code {
5452 KeyCode::Esc => {
5453 app.view_mode = ViewMode::Chat;
5454 }
5455 KeyCode::Up | KeyCode::Char('k')
5456 if !key.modifiers.contains(KeyModifiers::ALT) =>
5457 {
5458 if app.model_picker_selected > 0 {
5459 app.model_picker_selected -= 1;
5460 }
5461 }
5462 KeyCode::Down | KeyCode::Char('j')
5463 if !key.modifiers.contains(KeyModifiers::ALT) =>
5464 {
5465 let filtered = app.filtered_models();
5466 if app.model_picker_selected < filtered.len().saturating_sub(1) {
5467 app.model_picker_selected += 1;
5468 }
5469 }
5470 KeyCode::Enter => {
5471 let filtered = app.filtered_models();
5472 if let Some((_, (label, value, _name))) =
5473 filtered.get(app.model_picker_selected)
5474 {
5475 let label = label.clone();
5476 let value = value.clone();
5477 app.active_model = Some(value.clone());
5478 if let Some(session) = app.session.as_mut() {
5479 session.metadata.model = Some(value.clone());
5480 }
5481 app.messages.push(ChatMessage::new(
5482 "system",
5483 format!("Model set to: {}", label),
5484 ));
5485 app.view_mode = ViewMode::Chat;
5486 }
5487 }
5488 KeyCode::Backspace => {
5489 app.model_picker_filter.pop();
5490 app.model_picker_selected = 0;
5491 }
5492 KeyCode::Char(c)
5493 if !key.modifiers.contains(KeyModifiers::CONTROL)
5494 && !key.modifiers.contains(KeyModifiers::ALT) =>
5495 {
5496 app.model_picker_filter.push(c);
5497 app.model_picker_selected = 0;
5498 }
5499 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5500 return Ok(());
5501 }
5502 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5503 return Ok(());
5504 }
5505 _ => {}
5506 }
5507 continue;
5508 }
5509
5510 if app.view_mode == ViewMode::SessionPicker {
5512 match key.code {
5513 KeyCode::Esc => {
5514 if app.session_picker_confirm_delete {
5515 app.session_picker_confirm_delete = false;
5516 } else {
5517 app.session_picker_filter.clear();
5518 app.session_picker_offset = 0;
5519 app.view_mode = ViewMode::Chat;
5520 }
5521 }
5522 KeyCode::Up | KeyCode::Char('k') => {
5523 if app.session_picker_selected > 0 {
5524 app.session_picker_selected -= 1;
5525 }
5526 app.session_picker_confirm_delete = false;
5527 }
5528 KeyCode::Down | KeyCode::Char('j') => {
5529 let filtered_count = app.filtered_sessions().len();
5530 if app.session_picker_selected < filtered_count.saturating_sub(1) {
5531 app.session_picker_selected += 1;
5532 }
5533 app.session_picker_confirm_delete = false;
5534 }
5535 KeyCode::Char('d') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
5536 if app.session_picker_confirm_delete {
5537 let filtered = app.filtered_sessions();
5539 if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
5540 let session_id = app.session_picker_list[*orig_idx].id.clone();
5541 let is_active = app
5542 .session
5543 .as_ref()
5544 .map(|s| s.id == session_id)
5545 .unwrap_or(false);
5546 if !is_active {
5547 if let Err(e) = Session::delete(&session_id).await {
5548 app.messages.push(ChatMessage::new(
5549 "system",
5550 format!("Failed to delete session: {}", e),
5551 ));
5552 } else {
5553 app.session_picker_list.retain(|s| s.id != session_id);
5554 if app.session_picker_selected
5555 >= app.session_picker_list.len()
5556 {
5557 app.session_picker_selected =
5558 app.session_picker_list.len().saturating_sub(1);
5559 }
5560 }
5561 }
5562 }
5563 app.session_picker_confirm_delete = false;
5564 } else {
5565 let filtered = app.filtered_sessions();
5567 if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
5568 let is_active = app
5569 .session
5570 .as_ref()
5571 .map(|s| s.id == app.session_picker_list[*orig_idx].id)
5572 .unwrap_or(false);
5573 if !is_active {
5574 app.session_picker_confirm_delete = true;
5575 }
5576 }
5577 }
5578 }
5579 KeyCode::Backspace => {
5580 app.session_picker_filter.pop();
5581 app.session_picker_selected = 0;
5582 app.session_picker_confirm_delete = false;
5583 }
5584 KeyCode::Char('n') => {
5586 let limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
5587 .ok()
5588 .and_then(|v| v.parse().ok())
5589 .unwrap_or(100);
5590 let new_offset = app.session_picker_offset + limit;
5591 app.session_picker_offset = new_offset;
5592 match list_sessions_with_opencode_paged(&app.workspace_dir, limit, new_offset).await {
5593 Ok(sessions) => {
5594 app.update_cached_sessions(sessions);
5595 app.session_picker_selected = 0;
5596 }
5597 Err(e) => {
5598 app.messages.push(ChatMessage::new(
5599 "system",
5600 format!("Failed to load more sessions: {}", e),
5601 ));
5602 }
5603 }
5604 }
5605 KeyCode::Char('p') => {
5606 if app.session_picker_offset > 0 {
5607 let limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
5608 .ok()
5609 .and_then(|v| v.parse().ok())
5610 .unwrap_or(100);
5611 let new_offset = app.session_picker_offset.saturating_sub(limit);
5612 app.session_picker_offset = new_offset;
5613 match list_sessions_with_opencode_paged(&app.workspace_dir, limit, new_offset).await {
5614 Ok(sessions) => {
5615 app.update_cached_sessions(sessions);
5616 app.session_picker_selected = 0;
5617 }
5618 Err(e) => {
5619 app.messages.push(ChatMessage::new(
5620 "system",
5621 format!("Failed to load previous sessions: {}", e),
5622 ));
5623 }
5624 }
5625 }
5626 }
5627 KeyCode::Char('/') => {
5628 }
5630 KeyCode::Enter => {
5631 app.session_picker_confirm_delete = false;
5632 let filtered = app.filtered_sessions();
5633 let session_id = filtered
5634 .get(app.session_picker_selected)
5635 .map(|(orig_idx, _)| app.session_picker_list[*orig_idx].id.clone());
5636 if let Some(session_id) = session_id {
5637 let load_result =
5638 if let Some(oc_id) = session_id.strip_prefix("opencode_") {
5639 if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
5640 Session::from_opencode(oc_id, &storage).await
5641 } else {
5642 Err(anyhow::anyhow!("OpenCode storage not available"))
5643 }
5644 } else {
5645 Session::load(&session_id).await
5646 };
5647 match load_result {
5648 Ok(session) => {
5649 app.messages.clear();
5650 app.messages.push(ChatMessage::new(
5651 "system",
5652 format!(
5653 "Resumed session: {}\nCreated: {}\n{} messages loaded",
5654 session.title.as_deref().unwrap_or("(untitled)"),
5655 session.created_at.format("%Y-%m-%d %H:%M"),
5656 session.messages.len()
5657 ),
5658 ));
5659
5660 for msg in &session.messages {
5661 let role_str = match msg.role {
5662 Role::System => "system",
5663 Role::User => "user",
5664 Role::Assistant => "assistant",
5665 Role::Tool => "tool",
5666 };
5667
5668 for part in &msg.content {
5671 match part {
5672 ContentPart::Text { text } => {
5673 if !text.is_empty() {
5674 app.messages.push(ChatMessage::new(
5675 role_str,
5676 text.clone(),
5677 ));
5678 }
5679 }
5680 ContentPart::Image { url, mime_type } => {
5681 app.messages.push(
5682 ChatMessage::new(role_str, "")
5683 .with_message_type(
5684 MessageType::Image {
5685 url: url.clone(),
5686 mime_type: mime_type.clone(),
5687 },
5688 ),
5689 );
5690 }
5691 ContentPart::ToolCall {
5692 name, arguments, ..
5693 } => {
5694 let (preview, truncated) =
5695 build_tool_arguments_preview(
5696 name,
5697 arguments,
5698 TOOL_ARGS_PREVIEW_MAX_LINES,
5699 TOOL_ARGS_PREVIEW_MAX_BYTES,
5700 );
5701 app.messages.push(
5702 ChatMessage::new(
5703 role_str,
5704 format!("🔧 {name}"),
5705 )
5706 .with_message_type(MessageType::ToolCall {
5707 name: name.clone(),
5708 arguments_preview: preview,
5709 arguments_len: arguments.len(),
5710 truncated,
5711 }),
5712 );
5713 }
5714 ContentPart::ToolResult { content, .. } => {
5715 let truncated =
5716 truncate_with_ellipsis(content, 500);
5717 let (preview, preview_truncated) =
5718 build_text_preview(
5719 content,
5720 TOOL_OUTPUT_PREVIEW_MAX_LINES,
5721 TOOL_OUTPUT_PREVIEW_MAX_BYTES,
5722 );
5723 app.messages.push(
5724 ChatMessage::new(
5725 role_str,
5726 format!("✅ Result\n{truncated}"),
5727 )
5728 .with_message_type(
5729 MessageType::ToolResult {
5730 name: "tool".to_string(),
5731 output_preview: preview,
5732 output_len: content.len(),
5733 truncated: preview_truncated,
5734 success: true,
5735 duration_ms: None,
5736 },
5737 ),
5738 );
5739 }
5740 ContentPart::File { path, mime_type } => {
5741 app.messages.push(
5742 ChatMessage::new(
5743 role_str,
5744 format!("📎 {path}"),
5745 )
5746 .with_message_type(MessageType::File {
5747 path: path.clone(),
5748 mime_type: mime_type.clone(),
5749 }),
5750 );
5751 }
5752 ContentPart::Thinking { text } => {
5753 if !text.is_empty() {
5754 app.messages.push(
5755 ChatMessage::new(
5756 role_str,
5757 text.clone(),
5758 )
5759 .with_message_type(
5760 MessageType::Thinking(text.clone()),
5761 ),
5762 );
5763 }
5764 }
5765 }
5766 }
5767 }
5768
5769 app.current_agent = session.agent.clone();
5770 app.session = Some(session);
5771 app.scroll = SCROLL_BOTTOM;
5772 app.view_mode = ViewMode::Chat;
5773 }
5774 Err(e) => {
5775 app.messages.push(ChatMessage::new(
5776 "system",
5777 format!("Failed to load session: {}", e),
5778 ));
5779 app.view_mode = ViewMode::Chat;
5780 }
5781 }
5782 }
5783 }
5784 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5785 return Ok(());
5786 }
5787 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5788 return Ok(());
5789 }
5790 KeyCode::Char(c)
5791 if !key.modifiers.contains(KeyModifiers::CONTROL)
5792 && !key.modifiers.contains(KeyModifiers::ALT)
5793 && c != 'j'
5794 && c != 'k' =>
5795 {
5796 app.session_picker_filter.push(c);
5797 app.session_picker_selected = 0;
5798 app.session_picker_confirm_delete = false;
5799 }
5800 _ => {}
5801 }
5802 continue;
5803 }
5804
5805 if app.view_mode == ViewMode::AgentPicker {
5807 match key.code {
5808 KeyCode::Esc => {
5809 app.agent_picker_filter.clear();
5810 app.view_mode = ViewMode::Chat;
5811 }
5812 KeyCode::Up | KeyCode::Char('k')
5813 if !key.modifiers.contains(KeyModifiers::ALT) =>
5814 {
5815 if app.agent_picker_selected > 0 {
5816 app.agent_picker_selected -= 1;
5817 }
5818 }
5819 KeyCode::Down | KeyCode::Char('j')
5820 if !key.modifiers.contains(KeyModifiers::ALT) =>
5821 {
5822 let filtered = app.filtered_spawned_agents();
5823 if app.agent_picker_selected < filtered.len().saturating_sub(1) {
5824 app.agent_picker_selected += 1;
5825 }
5826 }
5827 KeyCode::Enter => {
5828 let filtered = app.filtered_spawned_agents();
5829 if let Some((name, _, _, _)) = filtered.get(app.agent_picker_selected) {
5830 app.active_spawned_agent = Some(name.clone());
5831 app.messages.push(ChatMessage::new(
5832 "system",
5833 format!(
5834 "Focused chat on @{name}. Type messages directly; use /agent main to exit focus."
5835 ),
5836 ));
5837 app.view_mode = ViewMode::Chat;
5838 }
5839 }
5840 KeyCode::Backspace => {
5841 app.agent_picker_filter.pop();
5842 app.agent_picker_selected = 0;
5843 }
5844 KeyCode::Char('m') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
5845 app.active_spawned_agent = None;
5846 app.messages
5847 .push(ChatMessage::new("system", "Returned to main chat mode."));
5848 app.view_mode = ViewMode::Chat;
5849 }
5850 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5851 return Ok(());
5852 }
5853 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5854 return Ok(());
5855 }
5856 KeyCode::Char(c)
5857 if !key.modifiers.contains(KeyModifiers::CONTROL)
5858 && !key.modifiers.contains(KeyModifiers::ALT)
5859 && c != 'j'
5860 && c != 'k'
5861 && c != 'm' =>
5862 {
5863 app.agent_picker_filter.push(c);
5864 app.agent_picker_selected = 0;
5865 }
5866 _ => {}
5867 }
5868 continue;
5869 }
5870
5871 if app.view_mode == ViewMode::Swarm {
5873 match key.code {
5874 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5875 return Ok(());
5876 }
5877 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5878 return Ok(());
5879 }
5880 KeyCode::Esc => {
5881 if app.swarm_state.detail_mode {
5882 app.swarm_state.exit_detail();
5883 } else {
5884 app.view_mode = ViewMode::Chat;
5885 }
5886 }
5887 KeyCode::Up | KeyCode::Char('k') => {
5888 if app.swarm_state.detail_mode {
5889 app.swarm_state.exit_detail();
5891 app.swarm_state.select_prev();
5892 app.swarm_state.enter_detail();
5893 } else {
5894 app.swarm_state.select_prev();
5895 }
5896 }
5897 KeyCode::Down | KeyCode::Char('j') => {
5898 if app.swarm_state.detail_mode {
5899 app.swarm_state.exit_detail();
5900 app.swarm_state.select_next();
5901 app.swarm_state.enter_detail();
5902 } else {
5903 app.swarm_state.select_next();
5904 }
5905 }
5906 KeyCode::Enter => {
5907 if !app.swarm_state.detail_mode {
5908 app.swarm_state.enter_detail();
5909 }
5910 }
5911 KeyCode::PageDown => {
5912 app.swarm_state.detail_scroll_down(10);
5913 }
5914 KeyCode::PageUp => {
5915 app.swarm_state.detail_scroll_up(10);
5916 }
5917 KeyCode::Char('?') => {
5918 app.show_help = true;
5919 }
5920 KeyCode::F(2) => {
5921 app.view_mode = ViewMode::Chat;
5922 }
5923 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5924 app.view_mode = ViewMode::Chat;
5925 }
5926 _ => {}
5927 }
5928 continue;
5929 }
5930
5931 if app.view_mode == ViewMode::Ralph {
5933 match key.code {
5934 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5935 return Ok(());
5936 }
5937 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5938 return Ok(());
5939 }
5940 KeyCode::Esc => {
5941 if app.ralph_state.detail_mode {
5942 app.ralph_state.exit_detail();
5943 } else {
5944 app.view_mode = ViewMode::Chat;
5945 }
5946 }
5947 KeyCode::Up | KeyCode::Char('k') => {
5948 if app.ralph_state.detail_mode {
5949 app.ralph_state.exit_detail();
5950 app.ralph_state.select_prev();
5951 app.ralph_state.enter_detail();
5952 } else {
5953 app.ralph_state.select_prev();
5954 }
5955 }
5956 KeyCode::Down | KeyCode::Char('j') => {
5957 if app.ralph_state.detail_mode {
5958 app.ralph_state.exit_detail();
5959 app.ralph_state.select_next();
5960 app.ralph_state.enter_detail();
5961 } else {
5962 app.ralph_state.select_next();
5963 }
5964 }
5965 KeyCode::Enter => {
5966 if !app.ralph_state.detail_mode {
5967 app.ralph_state.enter_detail();
5968 }
5969 }
5970 KeyCode::PageDown => {
5971 app.ralph_state.detail_scroll_down(10);
5972 }
5973 KeyCode::PageUp => {
5974 app.ralph_state.detail_scroll_up(10);
5975 }
5976 KeyCode::Char('?') => {
5977 app.show_help = true;
5978 }
5979 KeyCode::F(2) | KeyCode::Char('s')
5980 if key.modifiers.contains(KeyModifiers::CONTROL) =>
5981 {
5982 app.view_mode = ViewMode::Chat;
5983 }
5984 _ => {}
5985 }
5986 continue;
5987 }
5988
5989 if app.view_mode == ViewMode::BusLog {
5991 match key.code {
5992 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5993 return Ok(());
5994 }
5995 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5996 return Ok(());
5997 }
5998 KeyCode::Esc => {
5999 if app.bus_log_state.detail_mode {
6000 app.bus_log_state.exit_detail();
6001 } else {
6002 app.view_mode = ViewMode::Chat;
6003 }
6004 }
6005 KeyCode::Up | KeyCode::Char('k') => {
6006 if app.bus_log_state.detail_mode {
6007 app.bus_log_state.exit_detail();
6008 app.bus_log_state.select_prev();
6009 app.bus_log_state.enter_detail();
6010 } else {
6011 app.bus_log_state.select_prev();
6012 }
6013 }
6014 KeyCode::Down | KeyCode::Char('j') => {
6015 if app.bus_log_state.detail_mode {
6016 app.bus_log_state.exit_detail();
6017 app.bus_log_state.select_next();
6018 app.bus_log_state.enter_detail();
6019 } else {
6020 app.bus_log_state.select_next();
6021 }
6022 }
6023 KeyCode::Enter => {
6024 if !app.bus_log_state.detail_mode {
6025 app.bus_log_state.enter_detail();
6026 }
6027 }
6028 KeyCode::PageDown => {
6029 app.bus_log_state.detail_scroll_down(10);
6030 }
6031 KeyCode::PageUp => {
6032 app.bus_log_state.detail_scroll_up(10);
6033 }
6034 KeyCode::Char('c') => {
6036 app.bus_log_state.entries.clear();
6037 app.bus_log_state.selected_index = 0;
6038 }
6039 KeyCode::Char('g') => {
6041 let len = app.bus_log_state.filtered_entries().len();
6042 if len > 0 {
6043 app.bus_log_state.selected_index = len - 1;
6044 app.bus_log_state.list_state.select(Some(len - 1));
6045 }
6046 app.bus_log_state.auto_scroll = true;
6047 }
6048 KeyCode::Char('?') => {
6049 app.show_help = true;
6050 }
6051 _ => {}
6052 }
6053 continue;
6054 }
6055
6056 if app.view_mode == ViewMode::Protocol {
6058 match key.code {
6059 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6060 return Ok(());
6061 }
6062 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6063 return Ok(());
6064 }
6065 KeyCode::Esc => {
6066 app.view_mode = ViewMode::Chat;
6067 }
6068 KeyCode::Up | KeyCode::Char('k') => {
6069 if app.protocol_selected > 0 {
6070 app.protocol_selected -= 1;
6071 }
6072 app.protocol_scroll = 0;
6073 }
6074 KeyCode::Down | KeyCode::Char('j') => {
6075 let len = app.protocol_cards().len();
6076 if app.protocol_selected < len.saturating_sub(1) {
6077 app.protocol_selected += 1;
6078 }
6079 app.protocol_scroll = 0;
6080 }
6081 KeyCode::PageDown => {
6082 app.protocol_scroll = app.protocol_scroll.saturating_add(10);
6083 }
6084 KeyCode::PageUp => {
6085 app.protocol_scroll = app.protocol_scroll.saturating_sub(10);
6086 }
6087 KeyCode::Char('g') => {
6088 app.protocol_scroll = 0;
6089 }
6090 KeyCode::Char('?') => {
6091 app.show_help = true;
6092 }
6093 _ => {}
6094 }
6095 continue;
6096 }
6097
6098 match key.code {
6099 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6101 return Ok(());
6102 }
6103 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6104 return Ok(());
6105 }
6106
6107 KeyCode::Char('?') => {
6109 app.show_help = true;
6110 }
6111
6112 KeyCode::Char('a') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
6114 if let Some(pending) = app.pending_okr_approval.take() {
6115 app.messages.push(ChatMessage::new(
6117 "system",
6118 "✅ OKR approved! Starting relay execution...",
6119 ));
6120 app.scroll = SCROLL_BOTTOM;
6121
6122 let task = pending.task.clone();
6123 let agent_count = pending.agent_count;
6124 let config = config.clone();
6125
6126 let okr_id = pending.okr.id;
6128 let okr_run_id = pending.run.id;
6129
6130 tokio::spawn(async move {
6131 if let Ok(repo) = OkrRepository::from_config().await {
6132 let _ = repo.create_okr(pending.okr).await;
6133 let mut run = pending.run;
6134 run.status = OkrRunStatus::Approved;
6135 run.correlation_id = Some(format!("relay-{}", Uuid::new_v4()));
6136 let _ = repo.create_run(run).await;
6137 tracing::info!(okr_id = %okr_id, okr_run_id = %okr_run_id, "OKR run approved and saved");
6138 }
6139 });
6140
6141 app.start_autochat_execution(
6143 agent_count,
6144 task,
6145 &config,
6146 Some(okr_id),
6147 Some(okr_run_id),
6148 )
6149 .await;
6150 continue;
6151 }
6152 }
6153
6154 KeyCode::Char('d') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
6155 if let Some(pending) = app.pending_okr_approval.take() {
6156 app.messages.push(ChatMessage::new(
6158 "system",
6159 "❌ OKR denied. Relay not started.\n\nUse /autochat for tactical execution without OKR tracking.",
6160 ));
6161 app.scroll = SCROLL_BOTTOM;
6162 continue;
6163 }
6164 }
6165
6166 KeyCode::F(2) => {
6168 app.view_mode = match app.view_mode {
6169 ViewMode::Chat
6170 | ViewMode::SessionPicker
6171 | ViewMode::ModelPicker
6172 | ViewMode::AgentPicker
6173 | ViewMode::Protocol
6174 | ViewMode::BusLog => ViewMode::Swarm,
6175 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
6176 };
6177 }
6178 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6179 app.view_mode = match app.view_mode {
6180 ViewMode::Chat
6181 | ViewMode::SessionPicker
6182 | ViewMode::ModelPicker
6183 | ViewMode::AgentPicker
6184 | ViewMode::Protocol
6185 | ViewMode::BusLog => ViewMode::Swarm,
6186 ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
6187 };
6188 }
6189
6190 KeyCode::F(3) => {
6192 app.show_inspector = !app.show_inspector;
6193 }
6194
6195 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6197 let msg = app
6198 .messages
6199 .iter()
6200 .rev()
6201 .find(|m| m.role == "assistant" && !m.content.trim().is_empty())
6202 .or_else(|| {
6203 app.messages
6204 .iter()
6205 .rev()
6206 .find(|m| !m.content.trim().is_empty())
6207 });
6208
6209 let Some(msg) = msg else {
6210 app.messages
6211 .push(ChatMessage::new("system", "Nothing to copy yet."));
6212 app.scroll = SCROLL_BOTTOM;
6213 continue;
6214 };
6215
6216 let text = message_clipboard_text(msg);
6217 match copy_text_to_clipboard_best_effort(&text) {
6218 Ok(method) => {
6219 app.messages.push(ChatMessage::new(
6220 "system",
6221 format!("Copied latest reply ({method})."),
6222 ));
6223 app.scroll = SCROLL_BOTTOM;
6224 }
6225 Err(err) => {
6226 tracing::warn!(error = %err, "Copy to clipboard failed");
6227 app.messages.push(ChatMessage::new(
6228 "system",
6229 "Could not copy to clipboard in this environment.",
6230 ));
6231 app.scroll = SCROLL_BOTTOM;
6232 }
6233 }
6234 }
6235
6236 KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6238 app.chat_layout = match app.chat_layout {
6239 ChatLayoutMode::Classic => ChatLayoutMode::Webview,
6240 ChatLayoutMode::Webview => ChatLayoutMode::Classic,
6241 };
6242 }
6243
6244 KeyCode::Esc => {
6246 if app.view_mode == ViewMode::Swarm
6247 || app.view_mode == ViewMode::Ralph
6248 || app.view_mode == ViewMode::BusLog
6249 || app.view_mode == ViewMode::Protocol
6250 || app.view_mode == ViewMode::SessionPicker
6251 || app.view_mode == ViewMode::ModelPicker
6252 || app.view_mode == ViewMode::AgentPicker
6253 {
6254 app.view_mode = ViewMode::Chat;
6255 }
6256 }
6257
6258 KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6260 app.open_model_picker(&config).await;
6261 }
6262
6263 KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6265 app.open_agent_picker();
6266 }
6267
6268 KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6270 app.view_mode = ViewMode::BusLog;
6271 }
6272
6273 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6275 app.open_protocol_view();
6276 }
6277
6278 KeyCode::Tab => {
6280 app.current_agent = if app.current_agent == "build" {
6281 "plan".to_string()
6282 } else {
6283 "build".to_string()
6284 };
6285 }
6286
6287 KeyCode::Enter => {
6289 app.submit_message(&config).await;
6290 }
6291
6292 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
6294 if app.scroll < SCROLL_BOTTOM {
6295 app.scroll = app.scroll.saturating_add(1);
6296 }
6297 }
6298 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
6299 if app.scroll >= SCROLL_BOTTOM {
6300 app.scroll = app.last_max_scroll; }
6302 app.scroll = app.scroll.saturating_sub(1);
6303 }
6304
6305 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6307 app.search_history();
6308 }
6309 KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
6310 app.navigate_history(-1);
6311 }
6312 KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
6313 app.navigate_history(1);
6314 }
6315
6316 KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6318 app.scroll = 0; }
6320 KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6321 app.scroll = SCROLL_BOTTOM;
6323 }
6324
6325 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
6327 if app.scroll < SCROLL_BOTTOM {
6329 app.scroll = app.scroll.saturating_add(5);
6330 }
6331 }
6332 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
6333 if app.scroll >= SCROLL_BOTTOM {
6335 app.scroll = app.last_max_scroll;
6336 }
6337 app.scroll = app.scroll.saturating_sub(5);
6338 }
6339
6340 KeyCode::Char(c) => {
6342 while app.cursor_position > 0
6344 && !app.input.is_char_boundary(app.cursor_position)
6345 {
6346 app.cursor_position -= 1;
6347 }
6348 app.input.insert(app.cursor_position, c);
6349 app.cursor_position += c.len_utf8();
6350 }
6351 KeyCode::Backspace => {
6352 while app.cursor_position > 0
6354 && !app.input.is_char_boundary(app.cursor_position)
6355 {
6356 app.cursor_position -= 1;
6357 }
6358 if app.cursor_position > 0 {
6359 let prev = app.input[..app.cursor_position].char_indices().rev().next();
6361 if let Some((idx, ch)) = prev {
6362 app.input.replace_range(idx..idx + ch.len_utf8(), "");
6363 app.cursor_position = idx;
6364 }
6365 }
6366 }
6367 KeyCode::Delete => {
6368 while app.cursor_position > 0
6370 && !app.input.is_char_boundary(app.cursor_position)
6371 {
6372 app.cursor_position -= 1;
6373 }
6374 if app.cursor_position < app.input.len() {
6375 let ch = app.input[app.cursor_position..].chars().next();
6376 if let Some(ch) = ch {
6377 app.input.replace_range(
6378 app.cursor_position..app.cursor_position + ch.len_utf8(),
6379 "",
6380 );
6381 }
6382 }
6383 }
6384 KeyCode::Left => {
6385 let prev = app.input[..app.cursor_position].char_indices().rev().next();
6387 if let Some((idx, _)) = prev {
6388 app.cursor_position = idx;
6389 }
6390 }
6391 KeyCode::Right => {
6392 if app.cursor_position < app.input.len() {
6393 let ch = app.input[app.cursor_position..].chars().next();
6394 if let Some(ch) = ch {
6395 app.cursor_position += ch.len_utf8();
6396 }
6397 }
6398 }
6399 KeyCode::Home => {
6400 app.cursor_position = 0;
6401 }
6402 KeyCode::End => {
6403 app.cursor_position = app.input.len();
6404 }
6405
6406 KeyCode::Up => {
6408 if app.scroll >= SCROLL_BOTTOM {
6409 app.scroll = app.last_max_scroll; }
6411 app.scroll = app.scroll.saturating_sub(1);
6412 }
6413 KeyCode::Down => {
6414 if app.scroll < SCROLL_BOTTOM {
6415 app.scroll = app.scroll.saturating_add(1);
6416 }
6417 }
6418 KeyCode::PageUp => {
6419 if app.scroll >= SCROLL_BOTTOM {
6420 app.scroll = app.last_max_scroll;
6421 }
6422 app.scroll = app.scroll.saturating_sub(10);
6423 }
6424 KeyCode::PageDown => {
6425 if app.scroll < SCROLL_BOTTOM {
6426 app.scroll = app.scroll.saturating_add(10);
6427 }
6428 }
6429
6430 _ => {}
6431 }
6432 }
6433 }
6434}
6435
6436fn ui(f: &mut Frame, app: &mut App, theme: &Theme) {
6437 if app.view_mode == ViewMode::Swarm {
6439 let chunks = Layout::default()
6441 .direction(Direction::Vertical)
6442 .constraints([
6443 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
6447 .split(f.area());
6448
6449 render_swarm_view(f, &mut app.swarm_state, chunks[0]);
6451
6452 let input_block = Block::default()
6454 .borders(Borders::ALL)
6455 .title(" Press Esc, Ctrl+S, or /view to return to chat ")
6456 .border_style(Style::default().fg(Color::Cyan));
6457
6458 let input = Paragraph::new(app.input.as_str())
6459 .block(input_block)
6460 .wrap(Wrap { trim: false });
6461 f.render_widget(input, chunks[1]);
6462
6463 let status_line = if app.swarm_state.detail_mode {
6465 Line::from(vec![
6466 Span::styled(
6467 " AGENT DETAIL ",
6468 Style::default().fg(Color::Black).bg(Color::Cyan),
6469 ),
6470 Span::raw(" | "),
6471 Span::styled("Esc", Style::default().fg(Color::Yellow)),
6472 Span::raw(": Back to list | "),
6473 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6474 Span::raw(": Prev/Next agent | "),
6475 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
6476 Span::raw(": Scroll"),
6477 ])
6478 } else {
6479 Line::from(vec![
6480 Span::styled(
6481 " SWARM MODE ",
6482 Style::default().fg(Color::Black).bg(Color::Cyan),
6483 ),
6484 Span::raw(" | "),
6485 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6486 Span::raw(": Select | "),
6487 Span::styled("Enter", Style::default().fg(Color::Yellow)),
6488 Span::raw(": Detail | "),
6489 Span::styled("Esc", Style::default().fg(Color::Yellow)),
6490 Span::raw(": Back | "),
6491 Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
6492 Span::raw(": Toggle view"),
6493 ])
6494 };
6495 let status = Paragraph::new(status_line);
6496 f.render_widget(status, chunks[2]);
6497 return;
6498 }
6499
6500 if app.view_mode == ViewMode::Ralph {
6502 let chunks = Layout::default()
6503 .direction(Direction::Vertical)
6504 .constraints([
6505 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
6509 .split(f.area());
6510
6511 render_ralph_view(f, &mut app.ralph_state, chunks[0]);
6512
6513 let input_block = Block::default()
6514 .borders(Borders::ALL)
6515 .title(" Press Esc to return to chat ")
6516 .border_style(Style::default().fg(Color::Magenta));
6517
6518 let input = Paragraph::new(app.input.as_str())
6519 .block(input_block)
6520 .wrap(Wrap { trim: false });
6521 f.render_widget(input, chunks[1]);
6522
6523 let status_line = if app.ralph_state.detail_mode {
6524 Line::from(vec![
6525 Span::styled(
6526 " STORY DETAIL ",
6527 Style::default().fg(Color::Black).bg(Color::Magenta),
6528 ),
6529 Span::raw(" | "),
6530 Span::styled("Esc", Style::default().fg(Color::Yellow)),
6531 Span::raw(": Back to list | "),
6532 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6533 Span::raw(": Prev/Next story | "),
6534 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
6535 Span::raw(": Scroll"),
6536 ])
6537 } else {
6538 Line::from(vec![
6539 Span::styled(
6540 " RALPH MODE ",
6541 Style::default().fg(Color::Black).bg(Color::Magenta),
6542 ),
6543 Span::raw(" | "),
6544 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6545 Span::raw(": Select | "),
6546 Span::styled("Enter", Style::default().fg(Color::Yellow)),
6547 Span::raw(": Detail | "),
6548 Span::styled("Esc", Style::default().fg(Color::Yellow)),
6549 Span::raw(": Back"),
6550 ])
6551 };
6552 let status = Paragraph::new(status_line);
6553 f.render_widget(status, chunks[2]);
6554 return;
6555 }
6556
6557 if app.view_mode == ViewMode::BusLog {
6559 let chunks = Layout::default()
6560 .direction(Direction::Vertical)
6561 .constraints([
6562 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
6566 .split(f.area());
6567
6568 render_bus_log(f, &mut app.bus_log_state, chunks[0]);
6569
6570 let input_block = Block::default()
6571 .borders(Borders::ALL)
6572 .title(" Press Esc to return to chat ")
6573 .border_style(Style::default().fg(Color::Green));
6574
6575 let input = Paragraph::new(app.input.as_str())
6576 .block(input_block)
6577 .wrap(Wrap { trim: false });
6578 f.render_widget(input, chunks[1]);
6579
6580 let count_info = format!(
6581 " {}/{} ",
6582 app.bus_log_state.visible_count(),
6583 app.bus_log_state.total_count()
6584 );
6585 let status_line = Line::from(vec![
6586 Span::styled(
6587 " BUS LOG ",
6588 Style::default().fg(Color::Black).bg(Color::Green),
6589 ),
6590 Span::raw(&count_info),
6591 Span::raw("| "),
6592 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6593 Span::raw(": Select | "),
6594 Span::styled("Enter", Style::default().fg(Color::Yellow)),
6595 Span::raw(": Detail | "),
6596 Span::styled("c", Style::default().fg(Color::Yellow)),
6597 Span::raw(": Clear | "),
6598 Span::styled("Esc", Style::default().fg(Color::Yellow)),
6599 Span::raw(": Back"),
6600 ]);
6601 let status = Paragraph::new(status_line);
6602 f.render_widget(status, chunks[2]);
6603 return;
6604 }
6605
6606 if app.view_mode == ViewMode::Protocol {
6608 let chunks = Layout::default()
6609 .direction(Direction::Vertical)
6610 .constraints([
6611 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
6615 .split(f.area());
6616
6617 render_protocol_registry(f, app, theme, chunks[0]);
6618
6619 let input_block = Block::default()
6620 .borders(Borders::ALL)
6621 .title(" Press Esc to return to chat ")
6622 .border_style(Style::default().fg(Color::Blue));
6623
6624 let input = Paragraph::new(app.input.as_str())
6625 .block(input_block)
6626 .wrap(Wrap { trim: false });
6627 f.render_widget(input, chunks[1]);
6628
6629 let cards = app.protocol_cards();
6630 let status_line = Line::from(vec![
6631 Span::styled(
6632 " PROTOCOL REGISTRY ",
6633 Style::default().fg(Color::Black).bg(Color::Blue),
6634 ),
6635 Span::raw(format!(" {} cards | ", cards.len())),
6636 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6637 Span::raw(": Select | "),
6638 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
6639 Span::raw(": Scroll detail | "),
6640 Span::styled("Esc", Style::default().fg(Color::Yellow)),
6641 Span::raw(": Back"),
6642 ]);
6643 let status = Paragraph::new(status_line);
6644 f.render_widget(status, chunks[2]);
6645 return;
6646 }
6647
6648 if app.view_mode == ViewMode::ModelPicker {
6650 let area = centered_rect(70, 70, f.area());
6651 f.render_widget(Clear, area);
6652
6653 let filter_display = if app.model_picker_filter.is_empty() {
6654 "type to filter".to_string()
6655 } else {
6656 format!("filter: {}", app.model_picker_filter)
6657 };
6658
6659 let picker_block = Block::default()
6660 .borders(Borders::ALL)
6661 .title(format!(
6662 " Select Model (↑↓ navigate, Enter select, Esc cancel) [{}] ",
6663 filter_display
6664 ))
6665 .border_style(Style::default().fg(Color::Magenta));
6666
6667 let filtered = app.filtered_models();
6668 let mut list_lines: Vec<Line> = Vec::new();
6669 list_lines.push(Line::from(""));
6670
6671 if let Some(ref active) = app.active_model {
6672 list_lines.push(Line::styled(
6673 format!(" Current: {}", active),
6674 Style::default()
6675 .fg(Color::Green)
6676 .add_modifier(Modifier::DIM),
6677 ));
6678 list_lines.push(Line::from(""));
6679 }
6680
6681 if filtered.is_empty() {
6682 list_lines.push(Line::styled(
6683 " No models match filter",
6684 Style::default().fg(Color::DarkGray),
6685 ));
6686 } else {
6687 let mut current_provider = String::new();
6688 for (display_idx, (_, (label, _, human_name))) in filtered.iter().enumerate() {
6689 let provider = label.split('/').next().unwrap_or("");
6690 if provider != current_provider {
6691 if !current_provider.is_empty() {
6692 list_lines.push(Line::from(""));
6693 }
6694 list_lines.push(Line::styled(
6695 format!(" ─── {} ───", provider),
6696 Style::default()
6697 .fg(Color::Cyan)
6698 .add_modifier(Modifier::BOLD),
6699 ));
6700 current_provider = provider.to_string();
6701 }
6702
6703 let is_selected = display_idx == app.model_picker_selected;
6704 let is_active = app.active_model.as_deref() == Some(label.as_str());
6705 let marker = if is_selected { "▶" } else { " " };
6706 let active_marker = if is_active { " ✓" } else { "" };
6707 let model_id = label.split('/').skip(1).collect::<Vec<_>>().join("/");
6708 let display = if human_name != &model_id && !human_name.is_empty() {
6710 format!("{} ({})", human_name, model_id)
6711 } else {
6712 model_id
6713 };
6714
6715 let style = if is_selected {
6716 Style::default()
6717 .fg(Color::Magenta)
6718 .add_modifier(Modifier::BOLD)
6719 } else if is_active {
6720 Style::default().fg(Color::Green)
6721 } else {
6722 Style::default()
6723 };
6724
6725 list_lines.push(Line::styled(
6726 format!(" {} {}{}", marker, display, active_marker),
6727 style,
6728 ));
6729 }
6730 }
6731
6732 let list = Paragraph::new(list_lines)
6733 .block(picker_block)
6734 .wrap(Wrap { trim: false });
6735 f.render_widget(list, area);
6736 return;
6737 }
6738
6739 if app.view_mode == ViewMode::SessionPicker {
6741 let chunks = Layout::default()
6742 .direction(Direction::Vertical)
6743 .constraints([
6744 Constraint::Min(1), Constraint::Length(1), ])
6747 .split(f.area());
6748
6749 let filter_display = if app.session_picker_filter.is_empty() {
6751 String::new()
6752 } else {
6753 format!(" [filter: {}]", app.session_picker_filter)
6754 };
6755
6756 let list_block = Block::default()
6757 .borders(Borders::ALL)
6758 .title(format!(
6759 " Sessions (↑↓ navigate, Enter load, d delete, Esc cancel){} ",
6760 filter_display
6761 ))
6762 .border_style(Style::default().fg(Color::Cyan));
6763
6764 let mut list_lines: Vec<Line> = Vec::new();
6765 list_lines.push(Line::from(""));
6766
6767 let filtered = app.filtered_sessions();
6768 if filtered.is_empty() {
6769 if app.session_picker_filter.is_empty() {
6770 list_lines.push(Line::styled(
6771 " No sessions found.",
6772 Style::default().fg(Color::DarkGray),
6773 ));
6774 } else {
6775 list_lines.push(Line::styled(
6776 format!(" No sessions matching '{}'", app.session_picker_filter),
6777 Style::default().fg(Color::DarkGray),
6778 ));
6779 }
6780 }
6781
6782 for (display_idx, (_orig_idx, session)) in filtered.iter().enumerate() {
6783 let is_selected = display_idx == app.session_picker_selected;
6784 let is_active = app
6785 .session
6786 .as_ref()
6787 .map(|s| s.id == session.id)
6788 .unwrap_or(false);
6789 let title = session.title.as_deref().unwrap_or("(untitled)");
6790 let date = session.updated_at.format("%Y-%m-%d %H:%M");
6791 let active_marker = if is_active { " ●" } else { "" };
6792 let line_str = format!(
6793 " {} {}{} - {} ({} msgs)",
6794 if is_selected { "▶" } else { " " },
6795 title,
6796 active_marker,
6797 date,
6798 session.message_count
6799 );
6800
6801 let style = if is_selected && app.session_picker_confirm_delete {
6802 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
6803 } else if is_selected {
6804 Style::default()
6805 .fg(Color::Cyan)
6806 .add_modifier(Modifier::BOLD)
6807 } else if is_active {
6808 Style::default().fg(Color::Green)
6809 } else {
6810 Style::default()
6811 };
6812
6813 list_lines.push(Line::styled(line_str, style));
6814
6815 if is_selected {
6817 if app.session_picker_confirm_delete {
6818 list_lines.push(Line::styled(
6819 " ⚠ Press d again to confirm delete, Esc to cancel",
6820 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
6821 ));
6822 } else {
6823 list_lines.push(Line::styled(
6824 format!(" Agent: {} | ID: {}", session.agent, session.id),
6825 Style::default().fg(Color::DarkGray),
6826 ));
6827 }
6828 }
6829 }
6830
6831 let list = Paragraph::new(list_lines)
6832 .block(list_block)
6833 .wrap(Wrap { trim: false });
6834 f.render_widget(list, chunks[0]);
6835
6836 let mut status_spans = vec![
6838 Span::styled(
6839 " SESSION PICKER ",
6840 Style::default().fg(Color::Black).bg(Color::Cyan),
6841 ),
6842 Span::raw(" "),
6843 Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6844 Span::raw(": Nav "),
6845 Span::styled("Enter", Style::default().fg(Color::Yellow)),
6846 Span::raw(": Load "),
6847 Span::styled("d", Style::default().fg(Color::Yellow)),
6848 Span::raw(": Delete "),
6849 Span::styled("Esc", Style::default().fg(Color::Yellow)),
6850 Span::raw(": Cancel "),
6851 ];
6852 if !app.session_picker_filter.is_empty() || !app.session_picker_list.is_empty() {
6853 status_spans.push(Span::styled("Type", Style::default().fg(Color::Yellow)));
6854 status_spans.push(Span::raw(": Filter "));
6855 }
6856 let limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
6857 .ok()
6858 .and_then(|v| v.parse().ok())
6859 .unwrap_or(100);
6860 if app.session_picker_offset > 0 || app.session_picker_list.len() >= limit {
6862 status_spans.push(Span::styled("n", Style::default().fg(Color::Yellow)));
6863 status_spans.push(Span::raw(": Next "));
6864 if app.session_picker_offset > 0 {
6865 status_spans.push(Span::styled("p", Style::default().fg(Color::Yellow)));
6866 status_spans.push(Span::raw(": Prev "));
6867 }
6868 }
6869 let total = app.session_picker_list.len();
6870 let showing = filtered.len();
6871 let offset_display = if app.session_picker_offset > 0 {
6872 format!("+{}", app.session_picker_offset)
6873 } else {
6874 String::new()
6875 };
6876 if showing < total {
6877 status_spans.push(Span::styled(
6878 format!("{}{}/{}", offset_display, showing, total),
6879 Style::default().fg(Color::DarkGray),
6880 ));
6881 }
6882
6883 let status = Paragraph::new(Line::from(status_spans));
6884 f.render_widget(status, chunks[1]);
6885 return;
6886 }
6887
6888 if app.view_mode == ViewMode::AgentPicker {
6890 let area = centered_rect(70, 70, f.area());
6891 f.render_widget(Clear, area);
6892
6893 let filter_display = if app.agent_picker_filter.is_empty() {
6894 "type to filter".to_string()
6895 } else {
6896 format!("filter: {}", app.agent_picker_filter)
6897 };
6898
6899 let picker_block = Block::default()
6900 .borders(Borders::ALL)
6901 .title(format!(
6902 " Select Agent (↑↓ navigate, Enter focus, m main chat, Esc cancel) [{}] ",
6903 filter_display
6904 ))
6905 .border_style(Style::default().fg(Color::Magenta));
6906
6907 let filtered = app.filtered_spawned_agents();
6908 let mut list_lines: Vec<Line> = Vec::new();
6909 list_lines.push(Line::from(""));
6910
6911 if let Some(ref active) = app.active_spawned_agent {
6912 list_lines.push(Line::styled(
6913 format!(" Current focus: @{}", active),
6914 Style::default()
6915 .fg(Color::Green)
6916 .add_modifier(Modifier::DIM),
6917 ));
6918 list_lines.push(Line::from(""));
6919 }
6920
6921 if filtered.is_empty() {
6922 list_lines.push(Line::styled(
6923 " No spawned agents match filter",
6924 Style::default().fg(Color::DarkGray),
6925 ));
6926 } else {
6927 for (display_idx, (name, instructions, is_processing, is_registered)) in
6928 filtered.iter().enumerate()
6929 {
6930 let is_selected = display_idx == app.agent_picker_selected;
6931 let is_focused = app.active_spawned_agent.as_deref() == Some(name.as_str());
6932 let marker = if is_selected { "▶" } else { " " };
6933 let focused_marker = if is_focused { " ✓" } else { "" };
6934 let status = if *is_processing { "⚡" } else { "●" };
6935 let protocol = if *is_registered { "🔗" } else { "⚠" };
6936 let avatar = agent_avatar(name);
6937
6938 let style = if is_selected {
6939 Style::default()
6940 .fg(Color::Magenta)
6941 .add_modifier(Modifier::BOLD)
6942 } else if is_focused {
6943 Style::default().fg(Color::Green)
6944 } else {
6945 Style::default()
6946 };
6947
6948 list_lines.push(Line::styled(
6949 format!(" {marker} {status} {protocol} {avatar} @{name}{focused_marker}"),
6950 style,
6951 ));
6952
6953 if is_selected {
6954 let profile = agent_profile(name);
6955 list_lines.push(Line::styled(
6956 format!(" profile: {} — {}", profile.codename, profile.profile),
6957 Style::default().fg(Color::Cyan),
6958 ));
6959 list_lines.push(Line::styled(
6960 format!(" {}", instructions),
6961 Style::default().fg(Color::DarkGray),
6962 ));
6963 list_lines.push(Line::styled(
6964 format!(
6965 " protocol: {}",
6966 if *is_registered {
6967 "registered"
6968 } else {
6969 "not registered"
6970 }
6971 ),
6972 if *is_registered {
6973 Style::default().fg(Color::Green)
6974 } else {
6975 Style::default().fg(Color::Yellow)
6976 },
6977 ));
6978 }
6979 }
6980 }
6981
6982 let list = Paragraph::new(list_lines)
6983 .block(picker_block)
6984 .wrap(Wrap { trim: false });
6985 f.render_widget(list, area);
6986 return;
6987 }
6988
6989 if app.chat_layout == ChatLayoutMode::Webview {
6990 if render_webview_chat(f, app, theme) {
6991 render_help_overlay_if_needed(f, app, theme);
6992 return;
6993 }
6994 }
6995
6996 let chunks = Layout::default()
6998 .direction(Direction::Vertical)
6999 .constraints([
7000 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
7004 .split(f.area());
7005
7006 let messages_area = chunks[0];
7008 let model_label = app.active_model.as_deref().unwrap_or("auto");
7009 let target_label = app
7010 .active_spawned_agent
7011 .as_ref()
7012 .map(|name| format!(" @{}", name))
7013 .unwrap_or_default();
7014 let messages_block = Block::default()
7015 .borders(Borders::ALL)
7016 .title(format!(
7017 " CodeTether Agent [{}{}] model:{} ",
7018 app.current_agent, target_label, model_label
7019 ))
7020 .border_style(Style::default().fg(theme.border_color.to_color()));
7021
7022 let max_width = messages_area.width.saturating_sub(4) as usize;
7023 let message_lines = build_message_lines(app, theme, max_width);
7024
7025 let total_lines = message_lines.len();
7027 let visible_lines = messages_area.height.saturating_sub(2) as usize;
7028 let max_scroll = total_lines.saturating_sub(visible_lines);
7029 let scroll = if app.scroll >= SCROLL_BOTTOM {
7031 max_scroll
7032 } else {
7033 app.scroll.min(max_scroll)
7034 };
7035
7036 let messages_paragraph = Paragraph::new(
7038 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
7039 )
7040 .block(messages_block.clone())
7041 .wrap(Wrap { trim: false });
7042
7043 f.render_widget(messages_paragraph, messages_area);
7044
7045 if total_lines > visible_lines {
7047 let scrollbar = Scrollbar::default()
7048 .orientation(ScrollbarOrientation::VerticalRight)
7049 .symbols(ratatui::symbols::scrollbar::VERTICAL)
7050 .begin_symbol(Some("↑"))
7051 .end_symbol(Some("↓"));
7052
7053 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
7054
7055 let scrollbar_area = Rect::new(
7056 messages_area.right() - 1,
7057 messages_area.top() + 1,
7058 1,
7059 messages_area.height - 2,
7060 );
7061
7062 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
7063 }
7064
7065 let input_title = if app.is_processing {
7067 if let Some(started) = app.processing_started_at {
7068 let elapsed = started.elapsed();
7069 format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
7070 } else {
7071 " Message (Processing...) ".to_string()
7072 }
7073 } else if app.autochat_running {
7074 format!(
7075 " {} ",
7076 app.autochat_status_label()
7077 .unwrap_or_else(|| "Autochat running…".to_string())
7078 )
7079 } else if app.input.starts_with('/') {
7080 let hint = match_slash_command_hint(&app.input);
7081 format!(" {} ", hint)
7082 } else if let Some(target) = &app.active_spawned_agent {
7083 format!(" Message to @{target} (use /agent main to exit) ")
7084 } else {
7085 " Message (Enter to send, / for commands) ".to_string()
7086 };
7087 let input_block = Block::default()
7088 .borders(Borders::ALL)
7089 .title(input_title)
7090 .border_style(Style::default().fg(if app.is_processing {
7091 Color::Yellow
7092 } else if app.autochat_running {
7093 Color::Cyan
7094 } else if app.input.starts_with('/') {
7095 Color::Magenta
7096 } else {
7097 theme.input_border_color.to_color()
7098 }));
7099
7100 let input = Paragraph::new(app.input.as_str())
7101 .block(input_block)
7102 .wrap(Wrap { trim: false });
7103 f.render_widget(input, chunks[1]);
7104
7105 f.set_cursor_position((
7107 chunks[1].x + app.cursor_position as u16 + 1,
7108 chunks[1].y + 1,
7109 ));
7110
7111 let token_display = TokenDisplay::new();
7113 let mut status_line = token_display.create_status_bar(theme);
7114 let model_status = if let Some(ref active) = app.active_model {
7115 let (provider, model) = crate::provider::parse_model_string(active);
7116 format!(" {}:{} ", provider.unwrap_or("auto"), model)
7117 } else {
7118 " auto ".to_string()
7119 };
7120 status_line.spans.insert(
7121 0,
7122 Span::styled(
7123 "│ ",
7124 Style::default()
7125 .fg(theme.timestamp_color.to_color())
7126 .add_modifier(Modifier::DIM),
7127 ),
7128 );
7129 status_line.spans.insert(
7130 0,
7131 Span::styled(model_status, Style::default().fg(Color::Cyan)),
7132 );
7133 if let Some(autochat_status) = app.autochat_status_label() {
7134 status_line.spans.insert(
7135 0,
7136 Span::styled(
7137 format!(" {autochat_status} "),
7138 Style::default()
7139 .fg(Color::Yellow)
7140 .add_modifier(Modifier::BOLD),
7141 ),
7142 );
7143 }
7144 let status = Paragraph::new(status_line);
7145 f.render_widget(status, chunks[2]);
7146
7147 render_help_overlay_if_needed(f, app, theme);
7148}
7149
7150fn render_webview_chat(f: &mut Frame, app: &App, theme: &Theme) -> bool {
7151 let area = f.area();
7152 if area.width < 90 || area.height < 18 {
7153 return false;
7154 }
7155
7156 let main_chunks = Layout::default()
7157 .direction(Direction::Vertical)
7158 .constraints([
7159 Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
7164 .split(area);
7165
7166 render_webview_header(f, app, theme, main_chunks[0]);
7167
7168 let body_constraints = if app.show_inspector {
7169 vec![
7170 Constraint::Length(26),
7171 Constraint::Min(40),
7172 Constraint::Length(30),
7173 ]
7174 } else {
7175 vec![Constraint::Length(26), Constraint::Min(40)]
7176 };
7177
7178 let body_chunks = Layout::default()
7179 .direction(Direction::Horizontal)
7180 .constraints(body_constraints)
7181 .split(main_chunks[1]);
7182
7183 render_webview_sidebar(f, app, theme, body_chunks[0]);
7184 render_webview_chat_center(f, app, theme, body_chunks[1]);
7185 if app.show_inspector && body_chunks.len() > 2 {
7186 render_webview_inspector(f, app, theme, body_chunks[2]);
7187 }
7188
7189 render_webview_input(f, app, theme, main_chunks[2]);
7190
7191 let token_display = TokenDisplay::new();
7192 let mut status_line = token_display.create_status_bar(theme);
7193 let model_status = if let Some(ref active) = app.active_model {
7194 let (provider, model) = crate::provider::parse_model_string(active);
7195 format!(" {}:{} ", provider.unwrap_or("auto"), model)
7196 } else {
7197 " auto ".to_string()
7198 };
7199 status_line.spans.insert(
7200 0,
7201 Span::styled(
7202 "│ ",
7203 Style::default()
7204 .fg(theme.timestamp_color.to_color())
7205 .add_modifier(Modifier::DIM),
7206 ),
7207 );
7208 status_line.spans.insert(
7209 0,
7210 Span::styled(model_status, Style::default().fg(Color::Cyan)),
7211 );
7212 if let Some(autochat_status) = app.autochat_status_label() {
7213 status_line.spans.insert(
7214 0,
7215 Span::styled(
7216 format!(" {autochat_status} "),
7217 Style::default()
7218 .fg(Color::Yellow)
7219 .add_modifier(Modifier::BOLD),
7220 ),
7221 );
7222 }
7223 let status = Paragraph::new(status_line);
7224 f.render_widget(status, main_chunks[3]);
7225
7226 true
7227}
7228
7229fn render_protocol_registry(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
7230 let cards = app.protocol_cards();
7231 let selected = app.protocol_selected.min(cards.len().saturating_sub(1));
7232
7233 let chunks = Layout::default()
7234 .direction(Direction::Horizontal)
7235 .constraints([Constraint::Length(34), Constraint::Min(30)])
7236 .split(area);
7237
7238 let list_block = Block::default()
7239 .borders(Borders::ALL)
7240 .title(" Registered Agents ")
7241 .border_style(Style::default().fg(theme.border_color.to_color()));
7242
7243 let mut list_lines: Vec<Line> = Vec::new();
7244 if cards.is_empty() {
7245 list_lines.push(Line::styled(
7246 "No protocol-registered agents.",
7247 Style::default().fg(Color::DarkGray),
7248 ));
7249 list_lines.push(Line::styled(
7250 "Spawn an agent with /spawn.",
7251 Style::default().fg(Color::DarkGray),
7252 ));
7253 } else {
7254 for (idx, card) in cards.iter().enumerate() {
7255 let marker = if idx == selected { "▶" } else { " " };
7256 let style = if idx == selected {
7257 Style::default()
7258 .fg(Color::Blue)
7259 .add_modifier(Modifier::BOLD)
7260 } else {
7261 Style::default()
7262 };
7263 let transport = card.preferred_transport.as_deref().unwrap_or("JSONRPC");
7264 list_lines.push(Line::styled(format!(" {marker} {}", card.name), style));
7265 list_lines.push(Line::styled(
7266 format!(
7267 " {transport} • {}",
7268 truncate_with_ellipsis(&card.url, 22)
7269 ),
7270 Style::default().fg(Color::DarkGray),
7271 ));
7272 }
7273 }
7274
7275 let list = Paragraph::new(list_lines)
7276 .block(list_block)
7277 .wrap(Wrap { trim: false });
7278 f.render_widget(list, chunks[0]);
7279
7280 let detail_block = Block::default()
7281 .borders(Borders::ALL)
7282 .title(" Agent Card Detail ")
7283 .border_style(Style::default().fg(theme.border_color.to_color()));
7284
7285 let mut detail_lines: Vec<Line> = Vec::new();
7286 if let Some(card) = cards.get(selected) {
7287 let label_style = Style::default().fg(Color::DarkGray);
7288 detail_lines.push(Line::from(vec![
7289 Span::styled("Name: ", label_style),
7290 Span::styled(
7291 card.name.clone(),
7292 Style::default().add_modifier(Modifier::BOLD),
7293 ),
7294 ]));
7295 detail_lines.push(Line::from(vec![
7296 Span::styled("Description: ", label_style),
7297 Span::raw(card.description.clone()),
7298 ]));
7299 detail_lines.push(Line::from(vec![
7300 Span::styled("URL: ", label_style),
7301 Span::styled(card.url.clone(), Style::default().fg(Color::Cyan)),
7302 ]));
7303 detail_lines.push(Line::from(vec![
7304 Span::styled("Version: ", label_style),
7305 Span::raw(format!(
7306 "{} (protocol {})",
7307 card.version, card.protocol_version
7308 )),
7309 ]));
7310
7311 let preferred_transport = card.preferred_transport.as_deref().unwrap_or("JSONRPC");
7312 detail_lines.push(Line::from(vec![
7313 Span::styled("Transport: ", label_style),
7314 Span::raw(preferred_transport.to_string()),
7315 ]));
7316 if !card.additional_interfaces.is_empty() {
7317 detail_lines.push(Line::from(vec![
7318 Span::styled("Interfaces: ", label_style),
7319 Span::raw(format!("{} additional", card.additional_interfaces.len())),
7320 ]));
7321 for iface in &card.additional_interfaces {
7322 detail_lines.push(Line::styled(
7323 format!(" • {} -> {}", iface.transport, iface.url),
7324 Style::default().fg(Color::DarkGray),
7325 ));
7326 }
7327 }
7328
7329 detail_lines.push(Line::from(""));
7330 detail_lines.push(Line::styled(
7331 "Capabilities",
7332 Style::default().add_modifier(Modifier::BOLD),
7333 ));
7334 detail_lines.push(Line::styled(
7335 format!(
7336 " streaming={} push_notifications={} state_history={}",
7337 card.capabilities.streaming,
7338 card.capabilities.push_notifications,
7339 card.capabilities.state_transition_history
7340 ),
7341 Style::default().fg(Color::DarkGray),
7342 ));
7343 if !card.capabilities.extensions.is_empty() {
7344 detail_lines.push(Line::styled(
7345 format!(
7346 " extensions: {}",
7347 card.capabilities
7348 .extensions
7349 .iter()
7350 .map(|e| e.uri.as_str())
7351 .collect::<Vec<_>>()
7352 .join(", ")
7353 ),
7354 Style::default().fg(Color::DarkGray),
7355 ));
7356 }
7357
7358 detail_lines.push(Line::from(""));
7359 detail_lines.push(Line::styled(
7360 format!("Skills ({})", card.skills.len()),
7361 Style::default().add_modifier(Modifier::BOLD),
7362 ));
7363 if card.skills.is_empty() {
7364 detail_lines.push(Line::styled(" none", Style::default().fg(Color::DarkGray)));
7365 } else {
7366 for skill in &card.skills {
7367 let tags = if skill.tags.is_empty() {
7368 "".to_string()
7369 } else {
7370 format!(" [{}]", skill.tags.join(","))
7371 };
7372 detail_lines.push(Line::styled(
7373 format!(" • {}{}", skill.name, tags),
7374 Style::default().fg(Color::Green),
7375 ));
7376 if !skill.description.is_empty() {
7377 detail_lines.push(Line::styled(
7378 format!(" {}", skill.description),
7379 Style::default().fg(Color::DarkGray),
7380 ));
7381 }
7382 }
7383 }
7384
7385 detail_lines.push(Line::from(""));
7386 detail_lines.push(Line::styled(
7387 "Security",
7388 Style::default().add_modifier(Modifier::BOLD),
7389 ));
7390 if card.security_schemes.is_empty() {
7391 detail_lines.push(Line::styled(
7392 " schemes: none",
7393 Style::default().fg(Color::DarkGray),
7394 ));
7395 } else {
7396 let mut names = card.security_schemes.keys().cloned().collect::<Vec<_>>();
7397 names.sort();
7398 detail_lines.push(Line::styled(
7399 format!(" schemes: {}", names.join(", ")),
7400 Style::default().fg(Color::DarkGray),
7401 ));
7402 }
7403 detail_lines.push(Line::styled(
7404 format!(" requirements: {}", card.security.len()),
7405 Style::default().fg(Color::DarkGray),
7406 ));
7407 detail_lines.push(Line::styled(
7408 format!(
7409 " authenticated_extended_card: {}",
7410 card.supports_authenticated_extended_card
7411 ),
7412 Style::default().fg(Color::DarkGray),
7413 ));
7414 } else {
7415 detail_lines.push(Line::styled(
7416 "No card selected.",
7417 Style::default().fg(Color::DarkGray),
7418 ));
7419 }
7420
7421 let detail = Paragraph::new(detail_lines)
7422 .block(detail_block)
7423 .wrap(Wrap { trim: false })
7424 .scroll((app.protocol_scroll as u16, 0));
7425 f.render_widget(detail, chunks[1]);
7426}
7427
7428fn render_webview_header(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
7429 let session_title = app
7430 .session
7431 .as_ref()
7432 .and_then(|s| s.title.clone())
7433 .unwrap_or_else(|| "Workspace Chat".to_string());
7434 let session_id = app
7435 .session
7436 .as_ref()
7437 .map(|s| s.id.chars().take(8).collect::<String>())
7438 .unwrap_or_else(|| "new".to_string());
7439 let model_label = app
7440 .session
7441 .as_ref()
7442 .and_then(|s| s.metadata.model.clone())
7443 .unwrap_or_else(|| "auto".to_string());
7444 let workspace_label = app.workspace.root_display.clone();
7445 let branch_label = app
7446 .workspace
7447 .git_branch
7448 .clone()
7449 .unwrap_or_else(|| "no-git".to_string());
7450 let dirty_label = if app.workspace.git_dirty_files > 0 {
7451 format!("{} dirty", app.workspace.git_dirty_files)
7452 } else {
7453 "clean".to_string()
7454 };
7455
7456 let header_block = Block::default()
7457 .borders(Borders::ALL)
7458 .title(" CodeTether Webview ")
7459 .border_style(Style::default().fg(theme.border_color.to_color()));
7460
7461 let header_lines = vec![
7462 Line::from(vec![
7463 Span::styled(session_title, Style::default().add_modifier(Modifier::BOLD)),
7464 Span::raw(" "),
7465 Span::styled(
7466 format!("#{}", session_id),
7467 Style::default()
7468 .fg(theme.timestamp_color.to_color())
7469 .add_modifier(Modifier::DIM),
7470 ),
7471 ]),
7472 Line::from(vec![
7473 Span::styled(
7474 "Workspace ",
7475 Style::default().fg(theme.timestamp_color.to_color()),
7476 ),
7477 Span::styled(workspace_label, Style::default()),
7478 Span::raw(" "),
7479 Span::styled(
7480 "Branch ",
7481 Style::default().fg(theme.timestamp_color.to_color()),
7482 ),
7483 Span::styled(
7484 branch_label,
7485 Style::default()
7486 .fg(Color::Cyan)
7487 .add_modifier(Modifier::BOLD),
7488 ),
7489 Span::raw(" "),
7490 Span::styled(
7491 dirty_label,
7492 Style::default()
7493 .fg(Color::Yellow)
7494 .add_modifier(Modifier::BOLD),
7495 ),
7496 Span::raw(" "),
7497 Span::styled(
7498 "Model ",
7499 Style::default().fg(theme.timestamp_color.to_color()),
7500 ),
7501 Span::styled(model_label, Style::default().fg(Color::Green)),
7502 ]),
7503 ];
7504
7505 let header = Paragraph::new(header_lines)
7506 .block(header_block)
7507 .wrap(Wrap { trim: true });
7508 f.render_widget(header, area);
7509}
7510
7511fn render_webview_sidebar(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
7512 let sidebar_chunks = Layout::default()
7513 .direction(Direction::Vertical)
7514 .constraints([Constraint::Min(8), Constraint::Min(6)])
7515 .split(area);
7516
7517 let workspace_block = Block::default()
7518 .borders(Borders::ALL)
7519 .title(" Workspace ")
7520 .border_style(Style::default().fg(theme.border_color.to_color()));
7521
7522 let mut workspace_lines = Vec::new();
7523 workspace_lines.push(Line::from(vec![
7524 Span::styled(
7525 "Updated ",
7526 Style::default().fg(theme.timestamp_color.to_color()),
7527 ),
7528 Span::styled(
7529 app.workspace.captured_at.clone(),
7530 Style::default().fg(theme.timestamp_color.to_color()),
7531 ),
7532 ]));
7533 workspace_lines.push(Line::from(""));
7534
7535 if app.workspace.entries.is_empty() {
7536 workspace_lines.push(Line::styled(
7537 "No entries found",
7538 Style::default().fg(Color::DarkGray),
7539 ));
7540 } else {
7541 for entry in app.workspace.entries.iter().take(12) {
7542 let icon = match entry.kind {
7543 WorkspaceEntryKind::Directory => "📁",
7544 WorkspaceEntryKind::File => "📄",
7545 };
7546 workspace_lines.push(Line::from(vec![
7547 Span::styled(icon, Style::default().fg(Color::Cyan)),
7548 Span::raw(" "),
7549 Span::styled(entry.name.clone(), Style::default()),
7550 ]));
7551 }
7552 }
7553
7554 workspace_lines.push(Line::from(""));
7555 workspace_lines.push(Line::styled(
7556 "Use /refresh to rescan",
7557 Style::default()
7558 .fg(Color::DarkGray)
7559 .add_modifier(Modifier::DIM),
7560 ));
7561
7562 let workspace_panel = Paragraph::new(workspace_lines)
7563 .block(workspace_block)
7564 .wrap(Wrap { trim: true });
7565 f.render_widget(workspace_panel, sidebar_chunks[0]);
7566
7567 let sessions_block = Block::default()
7568 .borders(Borders::ALL)
7569 .title(" Recent Sessions ")
7570 .border_style(Style::default().fg(theme.border_color.to_color()));
7571
7572 let mut session_lines = Vec::new();
7573 if app.session_picker_list.is_empty() {
7574 session_lines.push(Line::styled(
7575 "No sessions yet",
7576 Style::default().fg(Color::DarkGray),
7577 ));
7578 } else {
7579 for session in app.session_picker_list.iter().take(6) {
7580 let is_active = app
7581 .session
7582 .as_ref()
7583 .map(|s| s.id == session.id)
7584 .unwrap_or(false);
7585 let title = session.title.as_deref().unwrap_or("(untitled)");
7586 let indicator = if is_active { "●" } else { "○" };
7587 let line_style = if is_active {
7588 Style::default()
7589 .fg(Color::Cyan)
7590 .add_modifier(Modifier::BOLD)
7591 } else {
7592 Style::default()
7593 };
7594 session_lines.push(Line::from(vec![
7595 Span::styled(indicator, line_style),
7596 Span::raw(" "),
7597 Span::styled(title, line_style),
7598 ]));
7599 session_lines.push(Line::styled(
7600 format!(
7601 " {} msgs • {}",
7602 session.message_count,
7603 session.updated_at.format("%m-%d %H:%M")
7604 ),
7605 Style::default().fg(Color::DarkGray),
7606 ));
7607 }
7608 }
7609
7610 let sessions_panel = Paragraph::new(session_lines)
7611 .block(sessions_block)
7612 .wrap(Wrap { trim: true });
7613 f.render_widget(sessions_panel, sidebar_chunks[1]);
7614}
7615
7616fn render_webview_chat_center(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
7617 let messages_area = area;
7618 let focused_suffix = app
7619 .active_spawned_agent
7620 .as_ref()
7621 .map(|name| format!(" → @{name}"))
7622 .unwrap_or_default();
7623 let messages_block = Block::default()
7624 .borders(Borders::ALL)
7625 .title(format!(" Chat [{}{}] ", app.current_agent, focused_suffix))
7626 .border_style(Style::default().fg(theme.border_color.to_color()));
7627
7628 let max_width = messages_area.width.saturating_sub(4) as usize;
7629 let message_lines = build_message_lines(app, theme, max_width);
7630
7631 let total_lines = message_lines.len();
7632 let visible_lines = messages_area.height.saturating_sub(2) as usize;
7633 let max_scroll = total_lines.saturating_sub(visible_lines);
7634 let scroll = if app.scroll >= SCROLL_BOTTOM {
7635 max_scroll
7636 } else {
7637 app.scroll.min(max_scroll)
7638 };
7639
7640 let messages_paragraph = Paragraph::new(
7641 message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
7642 )
7643 .block(messages_block.clone())
7644 .wrap(Wrap { trim: false });
7645
7646 f.render_widget(messages_paragraph, messages_area);
7647
7648 if total_lines > visible_lines {
7649 let scrollbar = Scrollbar::default()
7650 .orientation(ScrollbarOrientation::VerticalRight)
7651 .symbols(ratatui::symbols::scrollbar::VERTICAL)
7652 .begin_symbol(Some("↑"))
7653 .end_symbol(Some("↓"));
7654
7655 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
7656
7657 let scrollbar_area = Rect::new(
7658 messages_area.right() - 1,
7659 messages_area.top() + 1,
7660 1,
7661 messages_area.height - 2,
7662 );
7663
7664 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
7665 }
7666}
7667
7668fn render_webview_inspector(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
7669 let block = Block::default()
7670 .borders(Borders::ALL)
7671 .title(" Inspector ")
7672 .border_style(Style::default().fg(theme.border_color.to_color()));
7673
7674 let status_label = if app.is_processing {
7675 "Processing"
7676 } else if app.autochat_running {
7677 "Autochat"
7678 } else {
7679 "Idle"
7680 };
7681 let status_style = if app.is_processing {
7682 Style::default()
7683 .fg(Color::Yellow)
7684 .add_modifier(Modifier::BOLD)
7685 } else if app.autochat_running {
7686 Style::default()
7687 .fg(Color::Cyan)
7688 .add_modifier(Modifier::BOLD)
7689 } else {
7690 Style::default().fg(Color::Green)
7691 };
7692 let tool_label = app
7693 .current_tool
7694 .clone()
7695 .unwrap_or_else(|| "none".to_string());
7696 let message_count = app.messages.len();
7697 let session_id = app
7698 .session
7699 .as_ref()
7700 .map(|s| s.id.chars().take(8).collect::<String>())
7701 .unwrap_or_else(|| "new".to_string());
7702 let model_label = app
7703 .active_model
7704 .as_deref()
7705 .or_else(|| {
7706 app.session
7707 .as_ref()
7708 .and_then(|s| s.metadata.model.as_deref())
7709 })
7710 .unwrap_or("auto");
7711 let conversation_depth = app.session.as_ref().map(|s| s.messages.len()).unwrap_or(0);
7712
7713 let label_style = Style::default().fg(theme.timestamp_color.to_color());
7714
7715 let mut lines = Vec::new();
7716 lines.push(Line::from(vec![
7717 Span::styled("Status: ", label_style),
7718 Span::styled(status_label, status_style),
7719 ]));
7720
7721 if let Some(started) = app.processing_started_at {
7723 let elapsed = started.elapsed();
7724 let elapsed_str = if elapsed.as_secs() >= 60 {
7725 format!("{}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
7726 } else {
7727 format!("{:.1}s", elapsed.as_secs_f64())
7728 };
7729 lines.push(Line::from(vec![
7730 Span::styled("Elapsed: ", label_style),
7731 Span::styled(
7732 elapsed_str,
7733 Style::default()
7734 .fg(Color::Yellow)
7735 .add_modifier(Modifier::BOLD),
7736 ),
7737 ]));
7738 }
7739
7740 if app.autochat_running {
7741 if let Some(status) = app.autochat_status_label() {
7742 lines.push(Line::from(vec![
7743 Span::styled("Relay: ", label_style),
7744 Span::styled(status, Style::default().fg(Color::Cyan)),
7745 ]));
7746 }
7747 }
7748
7749 lines.push(Line::from(vec![
7750 Span::styled("Tool: ", label_style),
7751 Span::styled(
7752 tool_label,
7753 if app.current_tool.is_some() {
7754 Style::default()
7755 .fg(Color::Cyan)
7756 .add_modifier(Modifier::BOLD)
7757 } else {
7758 Style::default().fg(Color::DarkGray)
7759 },
7760 ),
7761 ]));
7762 lines.push(Line::from(""));
7763 lines.push(Line::styled(
7764 "Session",
7765 Style::default().add_modifier(Modifier::BOLD),
7766 ));
7767 lines.push(Line::from(vec![
7768 Span::styled("ID: ", label_style),
7769 Span::styled(format!("#{}", session_id), Style::default().fg(Color::Cyan)),
7770 ]));
7771 lines.push(Line::from(vec![
7772 Span::styled("Model: ", label_style),
7773 Span::styled(model_label.to_string(), Style::default().fg(Color::Green)),
7774 ]));
7775 let agent_display = if let Some(target) = &app.active_spawned_agent {
7776 format!("{} → @{} (focused)", app.current_agent, target)
7777 } else {
7778 app.current_agent.clone()
7779 };
7780 lines.push(Line::from(vec![
7781 Span::styled("Agent: ", label_style),
7782 Span::styled(agent_display, Style::default()),
7783 ]));
7784 lines.push(Line::from(vec![
7785 Span::styled("Messages: ", label_style),
7786 Span::styled(message_count.to_string(), Style::default()),
7787 ]));
7788 lines.push(Line::from(vec![
7789 Span::styled("Context: ", label_style),
7790 Span::styled(format!("{} turns", conversation_depth), Style::default()),
7791 ]));
7792 lines.push(Line::from(vec![
7793 Span::styled("Tools used: ", label_style),
7794 Span::styled(app.tool_call_count.to_string(), Style::default()),
7795 ]));
7796 lines.push(Line::from(vec![
7797 Span::styled("Protocol: ", label_style),
7798 Span::styled(
7799 format!("{} registered", app.protocol_registered_count()),
7800 Style::default().fg(Color::Cyan),
7801 ),
7802 ]));
7803 lines.push(Line::from(vec![
7804 Span::styled("Archive: ", label_style),
7805 Span::styled(
7806 format!("{} records", app.archived_message_count),
7807 Style::default(),
7808 ),
7809 ]));
7810 let sync_style = if app.chat_sync_last_error.is_some() {
7811 Style::default().fg(Color::Red)
7812 } else if app.chat_sync_rx.is_some() {
7813 Style::default().fg(Color::Green)
7814 } else {
7815 Style::default().fg(Color::DarkGray)
7816 };
7817 lines.push(Line::from(vec![
7818 Span::styled("Remote sync: ", label_style),
7819 Span::styled(
7820 app.chat_sync_status
7821 .as_deref()
7822 .unwrap_or("disabled")
7823 .to_string(),
7824 sync_style,
7825 ),
7826 ]));
7827 lines.push(Line::from(""));
7828 lines.push(Line::styled(
7829 "Sub-agents",
7830 Style::default().add_modifier(Modifier::BOLD),
7831 ));
7832 if app.spawned_agents.is_empty() {
7833 lines.push(Line::styled(
7834 "None (use /spawn <name> <instructions>)",
7835 Style::default().fg(Color::DarkGray),
7836 ));
7837 } else {
7838 for (name, agent) in app.spawned_agents.iter().take(4) {
7839 let status = if agent.is_processing { "⚡" } else { "●" };
7840 let is_registered = app.is_agent_protocol_registered(name);
7841 let protocol = if is_registered { "🔗" } else { "⚠" };
7842 let focused = if app.active_spawned_agent.as_deref() == Some(name.as_str()) {
7843 " [focused]"
7844 } else {
7845 ""
7846 };
7847 lines.push(Line::styled(
7848 format!(
7849 "{status} {protocol} {} @{name}{focused}",
7850 agent_avatar(name)
7851 ),
7852 if focused.is_empty() {
7853 Style::default().fg(Color::Magenta)
7854 } else {
7855 Style::default()
7856 .fg(Color::Magenta)
7857 .add_modifier(Modifier::BOLD)
7858 },
7859 ));
7860 let profile = agent_profile(name);
7861 lines.push(Line::styled(
7862 format!(" {} — {}", profile.codename, profile.profile),
7863 Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
7864 ));
7865 lines.push(Line::styled(
7866 format!(" {}", agent.instructions),
7867 Style::default()
7868 .fg(Color::DarkGray)
7869 .add_modifier(Modifier::DIM),
7870 ));
7871 if is_registered {
7872 lines.push(Line::styled(
7873 format!(" bus://local/{name}"),
7874 Style::default()
7875 .fg(Color::Green)
7876 .add_modifier(Modifier::DIM),
7877 ));
7878 }
7879 }
7880 if app.spawned_agents.len() > 4 {
7881 lines.push(Line::styled(
7882 format!("… and {} more", app.spawned_agents.len() - 4),
7883 Style::default()
7884 .fg(Color::DarkGray)
7885 .add_modifier(Modifier::DIM),
7886 ));
7887 }
7888 }
7889 lines.push(Line::from(""));
7890 lines.push(Line::styled(
7891 "Shortcuts",
7892 Style::default().add_modifier(Modifier::BOLD),
7893 ));
7894 lines.push(Line::from(vec![
7895 Span::styled("F3 ", Style::default().fg(Color::Yellow)),
7896 Span::styled("Inspector", Style::default().fg(Color::DarkGray)),
7897 ]));
7898 lines.push(Line::from(vec![
7899 Span::styled("Ctrl+B ", Style::default().fg(Color::Yellow)),
7900 Span::styled("Layout", Style::default().fg(Color::DarkGray)),
7901 ]));
7902 lines.push(Line::from(vec![
7903 Span::styled("Ctrl+Y ", Style::default().fg(Color::Yellow)),
7904 Span::styled("Copy", Style::default().fg(Color::DarkGray)),
7905 ]));
7906 lines.push(Line::from(vec![
7907 Span::styled("Ctrl+M ", Style::default().fg(Color::Yellow)),
7908 Span::styled("Model", Style::default().fg(Color::DarkGray)),
7909 ]));
7910 lines.push(Line::from(vec![
7911 Span::styled("Ctrl+S ", Style::default().fg(Color::Yellow)),
7912 Span::styled("Swarm", Style::default().fg(Color::DarkGray)),
7913 ]));
7914 lines.push(Line::from(vec![
7915 Span::styled("? ", Style::default().fg(Color::Yellow)),
7916 Span::styled("Help", Style::default().fg(Color::DarkGray)),
7917 ]));
7918
7919 let panel = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
7920 f.render_widget(panel, area);
7921}
7922
7923fn render_webview_input(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
7924 let title = if app.is_processing {
7925 if let Some(started) = app.processing_started_at {
7926 let elapsed = started.elapsed();
7927 format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
7928 } else {
7929 " Message (Processing...) ".to_string()
7930 }
7931 } else if app.autochat_running {
7932 format!(
7933 " {} ",
7934 app.autochat_status_label()
7935 .unwrap_or_else(|| "Autochat running…".to_string())
7936 )
7937 } else if app.input.starts_with('/') {
7938 let hint = match_slash_command_hint(&app.input);
7940 format!(" {} ", hint)
7941 } else if let Some(target) = &app.active_spawned_agent {
7942 format!(" Message to @{target} (use /agent main to exit) ")
7943 } else {
7944 " Message (Enter to send, / for commands) ".to_string()
7945 };
7946
7947 let input_block = Block::default()
7948 .borders(Borders::ALL)
7949 .title(title)
7950 .border_style(Style::default().fg(if app.is_processing {
7951 Color::Yellow
7952 } else if app.autochat_running {
7953 Color::Cyan
7954 } else if app.input.starts_with('/') {
7955 Color::Magenta
7956 } else {
7957 theme.input_border_color.to_color()
7958 }));
7959
7960 let input = Paragraph::new(app.input.as_str())
7961 .block(input_block)
7962 .wrap(Wrap { trim: false });
7963 f.render_widget(input, area);
7964
7965 f.set_cursor_position((area.x + app.cursor_position as u16 + 1, area.y + 1));
7966}
7967
7968fn build_message_lines(app: &App, theme: &Theme, max_width: usize) -> Vec<Line<'static>> {
7969 let mut message_lines = Vec::new();
7970 let separator_width = max_width.min(60);
7971
7972 for (idx, message) in app.messages.iter().enumerate() {
7973 let role_style = theme.get_role_style(&message.role);
7974
7975 if idx > 0 {
7977 let sep_char = match message.role.as_str() {
7978 "tool" => "·",
7979 _ => "─",
7980 };
7981 message_lines.push(Line::from(Span::styled(
7982 sep_char.repeat(separator_width),
7983 Style::default()
7984 .fg(theme.timestamp_color.to_color())
7985 .add_modifier(Modifier::DIM),
7986 )));
7987 }
7988
7989 let role_icon = match message.role.as_str() {
7991 "user" => "▸ ",
7992 "assistant" => "◆ ",
7993 "system" => "⚙ ",
7994 "tool" => "⚡",
7995 _ => " ",
7996 };
7997
7998 let header_line = {
7999 let mut spans = vec![
8000 Span::styled(
8001 format!("[{}] ", message.timestamp),
8002 Style::default()
8003 .fg(theme.timestamp_color.to_color())
8004 .add_modifier(Modifier::DIM),
8005 ),
8006 Span::styled(role_icon, role_style),
8007 Span::styled(message.role.clone(), role_style),
8008 ];
8009 if let Some(ref agent) = message.agent_name {
8010 let profile = agent_profile(agent);
8011 spans.push(Span::styled(
8012 format!(" {} @{agent} ‹{}›", agent_avatar(agent), profile.codename),
8013 Style::default()
8014 .fg(Color::Magenta)
8015 .add_modifier(Modifier::BOLD),
8016 ));
8017 }
8018 Line::from(spans)
8019 };
8020 message_lines.push(header_line);
8021
8022 match &message.message_type {
8023 MessageType::ToolCall {
8024 name,
8025 arguments_preview,
8026 arguments_len,
8027 truncated,
8028 } => {
8029 let tool_header = Line::from(vec![
8030 Span::styled(" 🔧 ", Style::default().fg(Color::Yellow)),
8031 Span::styled(
8032 format!("Tool: {}", name),
8033 Style::default()
8034 .fg(Color::Yellow)
8035 .add_modifier(Modifier::BOLD),
8036 ),
8037 ]);
8038 message_lines.push(tool_header);
8039
8040 if arguments_preview.trim().is_empty() {
8041 message_lines.push(Line::from(vec![
8042 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
8043 Span::styled(
8044 "(no arguments)",
8045 Style::default()
8046 .fg(Color::DarkGray)
8047 .add_modifier(Modifier::DIM),
8048 ),
8049 ]));
8050 } else {
8051 for line in arguments_preview.lines() {
8052 let args_line = Line::from(vec![
8053 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
8054 Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
8055 ]);
8056 message_lines.push(args_line);
8057 }
8058 }
8059
8060 if *truncated {
8061 let args_line = Line::from(vec![
8062 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
8063 Span::styled(
8064 format!("... (truncated; {} bytes)", arguments_len),
8065 Style::default()
8066 .fg(Color::DarkGray)
8067 .add_modifier(Modifier::DIM),
8068 ),
8069 ]);
8070 message_lines.push(args_line);
8071 }
8072 }
8073 MessageType::ToolResult {
8074 name,
8075 output_preview,
8076 output_len,
8077 truncated,
8078 success,
8079 duration_ms,
8080 } => {
8081 let icon = if *success { "✅" } else { "❌" };
8082 let result_header = Line::from(vec![
8083 Span::styled(
8084 format!(" {icon} "),
8085 Style::default().fg(if *success { Color::Green } else { Color::Red }),
8086 ),
8087 Span::styled(
8088 format!("Result from {}", name),
8089 Style::default()
8090 .fg(if *success { Color::Green } else { Color::Red })
8091 .add_modifier(Modifier::BOLD),
8092 ),
8093 ]);
8094 message_lines.push(result_header);
8095
8096 let status_line = format!(
8097 " │ status: {}{}",
8098 if *success { "success" } else { "failure" },
8099 duration_ms
8100 .map(|ms| format!(" • {}", format_duration_ms(ms)))
8101 .unwrap_or_default()
8102 );
8103 message_lines.push(Line::from(vec![
8104 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
8105 Span::styled(
8106 status_line.trim_start_matches(" │ ").to_string(),
8107 Style::default().fg(Color::DarkGray),
8108 ),
8109 ]));
8110
8111 if output_preview.trim().is_empty() {
8112 message_lines.push(Line::from(vec![
8113 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
8114 Span::styled(
8115 "(empty output)",
8116 Style::default()
8117 .fg(Color::DarkGray)
8118 .add_modifier(Modifier::DIM),
8119 ),
8120 ]));
8121 } else {
8122 for line in output_preview.lines() {
8123 let output_line = Line::from(vec![
8124 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
8125 Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
8126 ]);
8127 message_lines.push(output_line);
8128 }
8129 }
8130
8131 if *truncated {
8132 message_lines.push(Line::from(vec![
8133 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
8134 Span::styled(
8135 format!("... (truncated; {} bytes)", output_len),
8136 Style::default()
8137 .fg(Color::DarkGray)
8138 .add_modifier(Modifier::DIM),
8139 ),
8140 ]));
8141 }
8142 }
8143 MessageType::Text(text) => {
8144 let formatter = MessageFormatter::new(max_width);
8145 let formatted_content = formatter.format_content(text, &message.role);
8146 message_lines.extend(formatted_content);
8147 }
8148 MessageType::Thinking(text) => {
8149 let thinking_style = Style::default()
8150 .fg(Color::DarkGray)
8151 .add_modifier(Modifier::DIM | Modifier::ITALIC);
8152 message_lines.push(Line::from(Span::styled(
8153 " 💭 Thinking...",
8154 Style::default()
8155 .fg(Color::Magenta)
8156 .add_modifier(Modifier::DIM),
8157 )));
8158 let max_thinking_lines = 8;
8160 let mut iter = text.lines();
8161 let mut shown = 0usize;
8162 while shown < max_thinking_lines {
8163 let Some(line) = iter.next() else { break };
8164 message_lines.push(Line::from(vec![
8165 Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
8166 Span::styled(line.to_string(), thinking_style),
8167 ]));
8168 shown += 1;
8169 }
8170 if iter.next().is_some() {
8171 message_lines.push(Line::from(Span::styled(
8172 " │ ... (truncated)",
8173 thinking_style,
8174 )));
8175 }
8176 }
8177 MessageType::Image { url, mime_type } => {
8178 let formatter = MessageFormatter::new(max_width);
8179 let image_line = formatter.format_image(url, mime_type.as_deref());
8180 message_lines.push(image_line);
8181 }
8182 MessageType::File { path, mime_type } => {
8183 let mime_label = mime_type.as_deref().unwrap_or("unknown type");
8184 let file_header = Line::from(vec![
8185 Span::styled(" 📎 ", Style::default().fg(Color::Cyan)),
8186 Span::styled(
8187 format!("File: {}", path),
8188 Style::default()
8189 .fg(Color::Cyan)
8190 .add_modifier(Modifier::BOLD),
8191 ),
8192 Span::styled(
8193 format!(" ({})", mime_label),
8194 Style::default()
8195 .fg(Color::DarkGray)
8196 .add_modifier(Modifier::DIM),
8197 ),
8198 ]);
8199 message_lines.push(file_header);
8200 }
8201 }
8202
8203 if message.role == "assistant" {
8205 if let Some(ref meta) = message.usage_meta {
8206 let duration_str = if meta.duration_ms >= 60_000 {
8207 format!(
8208 "{}m{:02}.{}s",
8209 meta.duration_ms / 60_000,
8210 (meta.duration_ms % 60_000) / 1000,
8211 (meta.duration_ms % 1000) / 100
8212 )
8213 } else {
8214 format!(
8215 "{}.{}s",
8216 meta.duration_ms / 1000,
8217 (meta.duration_ms % 1000) / 100
8218 )
8219 };
8220 let tokens_str =
8221 format!("{}→{} tokens", meta.prompt_tokens, meta.completion_tokens);
8222 let cost_str = match meta.cost_usd {
8223 Some(c) if c < 0.01 => format!("${:.4}", c),
8224 Some(c) => format!("${:.2}", c),
8225 None => String::new(),
8226 };
8227 let dim_style = Style::default()
8228 .fg(theme.timestamp_color.to_color())
8229 .add_modifier(Modifier::DIM);
8230 let mut spans = vec![Span::styled(
8231 format!(" ⏱ {} │ 📊 {}", duration_str, tokens_str),
8232 dim_style,
8233 )];
8234 if !cost_str.is_empty() {
8235 spans.push(Span::styled(format!(" │ 💰 {}", cost_str), dim_style));
8236 }
8237 message_lines.push(Line::from(spans));
8238 }
8239 }
8240
8241 message_lines.push(Line::from(""));
8242 }
8243
8244 if let Some(ref streaming) = app.streaming_text {
8246 if !streaming.is_empty() {
8247 message_lines.push(Line::from(Span::styled(
8248 "─".repeat(separator_width),
8249 Style::default()
8250 .fg(theme.timestamp_color.to_color())
8251 .add_modifier(Modifier::DIM),
8252 )));
8253 message_lines.push(Line::from(vec![
8254 Span::styled(
8255 format!("[{}] ", chrono::Local::now().format("%H:%M")),
8256 Style::default()
8257 .fg(theme.timestamp_color.to_color())
8258 .add_modifier(Modifier::DIM),
8259 ),
8260 Span::styled("◆ ", theme.get_role_style("assistant")),
8261 Span::styled("assistant", theme.get_role_style("assistant")),
8262 Span::styled(
8263 " (streaming...)",
8264 Style::default()
8265 .fg(theme.timestamp_color.to_color())
8266 .add_modifier(Modifier::DIM),
8267 ),
8268 ]));
8269 let formatter = MessageFormatter::new(max_width);
8270 let formatted = formatter.format_content(streaming, "assistant");
8271 message_lines.extend(formatted);
8272 message_lines.push(Line::from(""));
8273 }
8274 }
8275
8276 let mut agent_streams = app.streaming_agent_texts.iter().collect::<Vec<_>>();
8277 agent_streams.sort_by(|(a, _), (b, _)| a.to_lowercase().cmp(&b.to_lowercase()));
8278 for (agent, streaming) in agent_streams {
8279 if streaming.is_empty() {
8280 continue;
8281 }
8282
8283 let profile = agent_profile(agent);
8284
8285 message_lines.push(Line::from(Span::styled(
8286 "─".repeat(separator_width),
8287 Style::default()
8288 .fg(theme.timestamp_color.to_color())
8289 .add_modifier(Modifier::DIM),
8290 )));
8291 message_lines.push(Line::from(vec![
8292 Span::styled(
8293 format!("[{}] ", chrono::Local::now().format("%H:%M")),
8294 Style::default()
8295 .fg(theme.timestamp_color.to_color())
8296 .add_modifier(Modifier::DIM),
8297 ),
8298 Span::styled("◆ ", theme.get_role_style("assistant")),
8299 Span::styled("assistant", theme.get_role_style("assistant")),
8300 Span::styled(
8301 format!(" {} @{} ‹{}›", agent_avatar(agent), agent, profile.codename),
8302 Style::default()
8303 .fg(Color::Magenta)
8304 .add_modifier(Modifier::BOLD),
8305 ),
8306 Span::styled(
8307 " (streaming...)",
8308 Style::default()
8309 .fg(theme.timestamp_color.to_color())
8310 .add_modifier(Modifier::DIM),
8311 ),
8312 ]));
8313
8314 let formatter = MessageFormatter::new(max_width);
8315 let formatted = formatter.format_content(streaming, "assistant");
8316 message_lines.extend(formatted);
8317 message_lines.push(Line::from(""));
8318 }
8319
8320 if app.is_processing {
8321 let spinner = current_spinner_frame();
8322
8323 let elapsed_str = if let Some(started) = app.processing_started_at {
8325 let elapsed = started.elapsed();
8326 if elapsed.as_secs() >= 60 {
8327 format!(" {}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
8328 } else {
8329 format!(" {:.1}s", elapsed.as_secs_f64())
8330 }
8331 } else {
8332 String::new()
8333 };
8334
8335 let processing_line = Line::from(vec![
8336 Span::styled(
8337 format!("[{}] ", chrono::Local::now().format("%H:%M")),
8338 Style::default()
8339 .fg(theme.timestamp_color.to_color())
8340 .add_modifier(Modifier::DIM),
8341 ),
8342 Span::styled("◆ ", theme.get_role_style("assistant")),
8343 Span::styled("assistant", theme.get_role_style("assistant")),
8344 Span::styled(
8345 elapsed_str,
8346 Style::default()
8347 .fg(theme.timestamp_color.to_color())
8348 .add_modifier(Modifier::DIM),
8349 ),
8350 ]);
8351 message_lines.push(processing_line);
8352
8353 let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
8354 (format!(" {spinner} Running: {}", tool), Color::Cyan)
8355 } else {
8356 (
8357 format!(
8358 " {} {}",
8359 spinner,
8360 app.processing_message.as_deref().unwrap_or("Thinking...")
8361 ),
8362 Color::Yellow,
8363 )
8364 };
8365
8366 let indicator_line = Line::from(vec![Span::styled(
8367 status_text,
8368 Style::default()
8369 .fg(status_color)
8370 .add_modifier(Modifier::BOLD),
8371 )]);
8372 message_lines.push(indicator_line);
8373 message_lines.push(Line::from(""));
8374 }
8375
8376 if app.autochat_running {
8377 let status_text = app
8378 .autochat_status_label()
8379 .unwrap_or_else(|| "Autochat running…".to_string());
8380 message_lines.push(Line::from(Span::styled(
8381 "─".repeat(separator_width),
8382 Style::default()
8383 .fg(theme.timestamp_color.to_color())
8384 .add_modifier(Modifier::DIM),
8385 )));
8386 message_lines.push(Line::from(vec![
8387 Span::styled(
8388 format!("[{}] ", chrono::Local::now().format("%H:%M")),
8389 Style::default()
8390 .fg(theme.timestamp_color.to_color())
8391 .add_modifier(Modifier::DIM),
8392 ),
8393 Span::styled("⚙ ", theme.get_role_style("system")),
8394 Span::styled(
8395 status_text,
8396 Style::default()
8397 .fg(Color::Cyan)
8398 .add_modifier(Modifier::BOLD),
8399 ),
8400 ]));
8401 message_lines.push(Line::from(""));
8402 }
8403
8404 message_lines
8405}
8406
8407fn match_slash_command_hint(input: &str) -> String {
8408 let commands = [
8409 (
8410 "/go ",
8411 "OKR-gated relay (requires approval, tracks outcomes)",
8412 ),
8413 ("/add ", "Easy mode: create a teammate"),
8414 ("/talk ", "Easy mode: message or focus a teammate"),
8415 ("/list", "Easy mode: list teammates"),
8416 ("/remove ", "Easy mode: remove a teammate"),
8417 ("/home", "Easy mode: return to main chat"),
8418 ("/help", "Open help"),
8419 ("/spawn ", "Create a named sub-agent"),
8420 ("/autochat ", "Tactical relay (fast path, no OKR tracking)"),
8421 ("/agents", "List spawned sub-agents"),
8422 ("/kill ", "Remove a spawned sub-agent"),
8423 ("/agent ", "Focus or message a spawned sub-agent"),
8424 ("/swarm ", "Run task in parallel swarm mode"),
8425 ("/ralph", "Start autonomous PRD loop"),
8426 ("/undo", "Undo last message and response"),
8427 ("/sessions", "Open session picker"),
8428 ("/resume", "Resume session or interrupted relay"),
8429 ("/new", "Start a new session"),
8430 ("/model", "Select or set model"),
8431 ("/webview", "Switch to webview layout"),
8432 ("/classic", "Switch to classic layout"),
8433 ("/inspector", "Toggle inspector pane"),
8434 ("/refresh", "Refresh workspace"),
8435 ("/archive", "Show persistent chat archive path"),
8436 ("/view", "Toggle swarm view"),
8437 ("/buslog", "Show protocol bus log"),
8438 ("/protocol", "Show protocol registry"),
8439 ];
8440
8441 let trimmed = input.trim_start();
8442 let input_lower = trimmed.to_lowercase();
8443
8444 if let Some((cmd, desc)) = commands.iter().find(|(cmd, _)| {
8446 let key = cmd.trim_end().to_ascii_lowercase();
8447 input_lower == key || input_lower.starts_with(&(key + " "))
8448 }) {
8449 return format!("{} — {}", cmd.trim(), desc);
8450 }
8451
8452 let matches: Vec<_> = commands
8454 .iter()
8455 .filter(|(cmd, _)| cmd.starts_with(&input_lower))
8456 .collect();
8457
8458 if matches.len() == 1 {
8459 format!("{} — {}", matches[0].0.trim(), matches[0].1)
8460 } else if matches.is_empty() {
8461 "Unknown command".to_string()
8462 } else {
8463 let cmds: Vec<_> = matches.iter().map(|(cmd, _)| cmd.trim()).collect();
8464 cmds.join(" | ")
8465 }
8466}
8467
8468fn command_with_optional_args<'a>(input: &'a str, command: &str) -> Option<&'a str> {
8469 let trimmed = input.trim();
8470 let rest = trimmed.strip_prefix(command)?;
8471
8472 if rest.is_empty() {
8473 return Some("");
8474 }
8475
8476 let first = rest.chars().next()?;
8477 if first.is_whitespace() {
8478 Some(rest.trim())
8479 } else {
8480 None
8481 }
8482}
8483
8484fn normalize_easy_command(input: &str) -> String {
8485 let trimmed = input.trim();
8486 if trimmed.is_empty() {
8487 return String::new();
8488 }
8489
8490 if !trimmed.starts_with('/') {
8491 return input.to_string();
8492 }
8493
8494 let mut parts = trimmed.splitn(2, char::is_whitespace);
8495 let command = parts.next().unwrap_or("");
8496 let args = parts.next().unwrap_or("").trim();
8497
8498 match command.to_ascii_lowercase().as_str() {
8499 "/go" | "/team" => {
8500 if args.is_empty() {
8501 "/autochat".to_string()
8502 } else {
8503 let mut parts = args.splitn(2, char::is_whitespace);
8504 let first = parts.next().unwrap_or("").trim();
8505 if let Ok(count) = first.parse::<usize>() {
8506 let rest = parts.next().unwrap_or("").trim();
8507 if rest.is_empty() {
8508 format!("/autochat {count} {AUTOCHAT_QUICK_DEMO_TASK}")
8509 } else {
8510 format!("/autochat {count} {rest}")
8511 }
8512 } else {
8513 format!("/autochat {AUTOCHAT_DEFAULT_AGENTS} {args}")
8514 }
8515 }
8516 }
8517 "/add" => {
8518 if args.is_empty() {
8519 "/spawn".to_string()
8520 } else {
8521 format!("/spawn {args}")
8522 }
8523 }
8524 "/list" | "/ls" => "/agents".to_string(),
8525 "/remove" | "/rm" => {
8526 if args.is_empty() {
8527 "/kill".to_string()
8528 } else {
8529 format!("/kill {args}")
8530 }
8531 }
8532 "/talk" | "/say" => {
8533 if args.is_empty() {
8534 "/agent".to_string()
8535 } else {
8536 format!("/agent {args}")
8537 }
8538 }
8539 "/focus" => {
8540 if args.is_empty() {
8541 "/agent".to_string()
8542 } else {
8543 format!("/agent {}", args.trim_start_matches('@'))
8544 }
8545 }
8546 "/home" | "/main" => "/agent main".to_string(),
8547 "/h" | "/?" => "/help".to_string(),
8548 _ => trimmed.to_string(),
8549 }
8550}
8551
8552fn is_easy_go_command(input: &str) -> bool {
8553 let command = input
8554 .trim_start()
8555 .split_whitespace()
8556 .next()
8557 .unwrap_or("")
8558 .to_ascii_lowercase();
8559
8560 matches!(command.as_str(), "/go" | "/team")
8561}
8562
8563fn is_glm5_model(model: &str) -> bool {
8564 let normalized = model.trim().to_ascii_lowercase();
8565 matches!(
8566 normalized.as_str(),
8567 "zai/glm-5" | "z-ai/glm-5" | "openrouter/z-ai/glm-5"
8568 )
8569}
8570
8571fn is_minimax_m25_model(model: &str) -> bool {
8572 let normalized = model.trim().to_ascii_lowercase();
8573 matches!(normalized.as_str(), "minimax/minimax-m2.5" | "minimax-m2.5")
8574}
8575
8576fn next_go_model(current_model: Option<&str>) -> String {
8577 match current_model {
8578 Some(model) if is_glm5_model(model) => GO_SWAP_MODEL_MINIMAX.to_string(),
8579 Some(model) if is_minimax_m25_model(model) => GO_SWAP_MODEL_GLM.to_string(),
8580 _ => GO_SWAP_MODEL_MINIMAX.to_string(),
8581 }
8582}
8583
8584fn parse_autochat_args(rest: &str) -> Option<(usize, &str)> {
8585 let rest = rest.trim();
8586 if rest.is_empty() {
8587 return None;
8588 }
8589
8590 let mut parts = rest.splitn(2, char::is_whitespace);
8591 let first = parts.next().unwrap_or("").trim();
8592 if first.is_empty() {
8593 return None;
8594 }
8595
8596 if let Ok(count) = first.parse::<usize>() {
8597 let task = parts.next().unwrap_or("").trim();
8598 if task.is_empty() {
8599 Some((count, AUTOCHAT_QUICK_DEMO_TASK))
8600 } else {
8601 Some((count, task))
8602 }
8603 } else {
8604 Some((AUTOCHAT_DEFAULT_AGENTS, rest))
8605 }
8606}
8607
8608fn normalize_for_convergence(text: &str) -> String {
8609 let mut normalized = String::with_capacity(text.len().min(512));
8610 let mut last_was_space = false;
8611
8612 for ch in text.chars() {
8613 if ch.is_ascii_alphanumeric() {
8614 normalized.push(ch.to_ascii_lowercase());
8615 last_was_space = false;
8616 } else if ch.is_whitespace() && !last_was_space {
8617 normalized.push(' ');
8618 last_was_space = true;
8619 }
8620
8621 if normalized.len() >= 280 {
8622 break;
8623 }
8624 }
8625
8626 normalized.trim().to_string()
8627}
8628
8629fn agent_profile(agent_name: &str) -> AgentProfile {
8630 let normalized = agent_name.to_ascii_lowercase();
8631
8632 if normalized.contains("planner") {
8633 return AgentProfile {
8634 codename: "Strategist",
8635 profile: "Goal decomposition specialist",
8636 personality: "calm, methodical, and dependency-aware",
8637 collaboration_style: "opens with numbered plans and explicit priorities",
8638 signature_move: "turns vague goals into concrete execution ladders",
8639 };
8640 }
8641
8642 if normalized.contains("research") {
8643 return AgentProfile {
8644 codename: "Archivist",
8645 profile: "Evidence and assumptions analyst",
8646 personality: "curious, skeptical, and detail-focused",
8647 collaboration_style: "validates claims and cites edge-case evidence",
8648 signature_move: "surfaces blind spots before implementation starts",
8649 };
8650 }
8651
8652 if normalized.contains("coder") || normalized.contains("implement") {
8653 return AgentProfile {
8654 codename: "Forge",
8655 profile: "Implementation architect",
8656 personality: "pragmatic, direct, and execution-heavy",
8657 collaboration_style: "proposes concrete code-level actions quickly",
8658 signature_move: "translates plans into shippable implementation steps",
8659 };
8660 }
8661
8662 if normalized.contains("review") {
8663 return AgentProfile {
8664 codename: "Sentinel",
8665 profile: "Quality and regression guardian",
8666 personality: "disciplined, assertive, and standards-driven",
8667 collaboration_style: "challenges weak reasoning and hardens quality",
8668 signature_move: "detects brittle assumptions and failure modes",
8669 };
8670 }
8671
8672 if normalized.contains("tester") || normalized.contains("test") {
8673 return AgentProfile {
8674 codename: "Probe",
8675 profile: "Verification strategist",
8676 personality: "adversarial in a good way, systematic, and precise",
8677 collaboration_style: "designs checks around failure-first thinking",
8678 signature_move: "builds test matrices that catch hidden breakage",
8679 };
8680 }
8681
8682 if normalized.contains("integrat") {
8683 return AgentProfile {
8684 codename: "Conductor",
8685 profile: "Cross-stream synthesis lead",
8686 personality: "balanced, diplomatic, and outcome-oriented",
8687 collaboration_style: "reconciles competing inputs into one plan",
8688 signature_move: "merges parallel work into coherent delivery",
8689 };
8690 }
8691
8692 if normalized.contains("skeptic") || normalized.contains("risk") {
8693 return AgentProfile {
8694 codename: "Radar",
8695 profile: "Risk and threat analyst",
8696 personality: "blunt, anticipatory, and protective",
8697 collaboration_style: "flags downside scenarios and mitigation paths",
8698 signature_move: "turns uncertainty into explicit risk registers",
8699 };
8700 }
8701
8702 if normalized.contains("summary") || normalized.contains("summarizer") {
8703 return AgentProfile {
8704 codename: "Beacon",
8705 profile: "Decision synthesis specialist",
8706 personality: "concise, clear, and action-first",
8707 collaboration_style: "compresses complexity into executable next steps",
8708 signature_move: "creates crisp briefings that unblock teams quickly",
8709 };
8710 }
8711
8712 let fallback_profiles = [
8713 AgentProfile {
8714 codename: "Navigator",
8715 profile: "Generalist coordinator",
8716 personality: "adaptable and context-aware",
8717 collaboration_style: "balances speed with clarity",
8718 signature_move: "keeps team momentum aligned",
8719 },
8720 AgentProfile {
8721 codename: "Vector",
8722 profile: "Execution operator",
8723 personality: "focused and deadline-driven",
8724 collaboration_style: "prefers direct action and feedback loops",
8725 signature_move: "drives ambiguous tasks toward decisions",
8726 },
8727 AgentProfile {
8728 codename: "Signal",
8729 profile: "Communication specialist",
8730 personality: "clear, friendly, and structured",
8731 collaboration_style: "frames updates for quick handoffs",
8732 signature_move: "turns noisy context into clean status",
8733 },
8734 AgentProfile {
8735 codename: "Kernel",
8736 profile: "Core-systems thinker",
8737 personality: "analytical and stable",
8738 collaboration_style: "organizes work around constraints and invariants",
8739 signature_move: "locks down the critical path early",
8740 },
8741 ];
8742
8743 let mut hash: u64 = 2_166_136_261;
8744 for byte in normalized.bytes() {
8745 hash = (hash ^ u64::from(byte)).wrapping_mul(16_777_619);
8746 }
8747 fallback_profiles[hash as usize % fallback_profiles.len()]
8748}
8749
8750fn format_agent_profile_summary(agent_name: &str) -> String {
8751 let profile = agent_profile(agent_name);
8752 format!(
8753 "{} — {} ({})",
8754 profile.codename, profile.profile, profile.personality
8755 )
8756}
8757
8758fn agent_avatar(agent_name: &str) -> &'static str {
8759 let mut hash: u64 = 2_166_136_261;
8760 for byte in agent_name.bytes() {
8761 hash = (hash ^ u64::from(byte.to_ascii_lowercase())).wrapping_mul(16_777_619);
8762 }
8763 AGENT_AVATARS[hash as usize % AGENT_AVATARS.len()]
8764}
8765
8766fn format_agent_identity(agent_name: &str) -> String {
8767 let profile = agent_profile(agent_name);
8768 format!(
8769 "{} @{} ‹{}›",
8770 agent_avatar(agent_name),
8771 agent_name,
8772 profile.codename
8773 )
8774}
8775
8776fn format_relay_participant(participant: &str) -> String {
8777 if participant.eq_ignore_ascii_case("user") {
8778 "[you]".to_string()
8779 } else {
8780 format_agent_identity(participant)
8781 }
8782}
8783
8784fn format_relay_handoff_line(relay_id: &str, round: usize, from: &str, to: &str) -> String {
8785 format!(
8786 "[relay {relay_id} • round {round}] {} → {}",
8787 format_relay_participant(from),
8788 format_relay_participant(to)
8789 )
8790}
8791
8792fn format_tool_call_arguments(name: &str, arguments: &str) -> String {
8793 if arguments.len() > TOOL_ARGS_PRETTY_JSON_MAX_BYTES {
8797 return arguments.to_string();
8798 }
8799
8800 let parsed = match serde_json::from_str::<serde_json::Value>(arguments) {
8801 Ok(value) => value,
8802 Err(_) => return arguments.to_string(),
8803 };
8804
8805 if name == "question"
8806 && let Some(question) = parsed.get("question").and_then(serde_json::Value::as_str)
8807 {
8808 return question.to_string();
8809 }
8810
8811 serde_json::to_string_pretty(&parsed).unwrap_or_else(|_| arguments.to_string())
8812}
8813
8814fn build_tool_arguments_preview(
8815 tool_name: &str,
8816 arguments: &str,
8817 max_lines: usize,
8818 max_bytes: usize,
8819) -> (String, bool) {
8820 let formatted = format_tool_call_arguments(tool_name, arguments);
8822 build_text_preview(&formatted, max_lines, max_bytes)
8823}
8824
8825fn build_text_preview(text: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
8829 if max_lines == 0 || max_bytes == 0 || text.is_empty() {
8830 return (String::new(), !text.is_empty());
8831 }
8832
8833 let mut out = String::new();
8834 let mut truncated = false;
8835 let mut remaining = max_bytes;
8836
8837 let mut iter = text.lines();
8838 for i in 0..max_lines {
8839 let Some(line) = iter.next() else { break };
8840
8841 if i > 0 {
8843 if remaining == 0 {
8844 truncated = true;
8845 break;
8846 }
8847 out.push('\n');
8848 remaining = remaining.saturating_sub(1);
8849 }
8850
8851 if remaining == 0 {
8852 truncated = true;
8853 break;
8854 }
8855
8856 if line.len() <= remaining {
8857 out.push_str(line);
8858 remaining = remaining.saturating_sub(line.len());
8859 } else {
8860 let mut end = remaining;
8862 while end > 0 && !line.is_char_boundary(end) {
8863 end -= 1;
8864 }
8865 out.push_str(&line[..end]);
8866 truncated = true;
8867 break;
8868 }
8869 }
8870
8871 if !truncated && iter.next().is_some() {
8873 truncated = true;
8874 }
8875
8876 (out, truncated)
8877}
8878
8879fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
8880 if max_chars == 0 {
8881 return String::new();
8882 }
8883
8884 let mut chars = value.chars();
8885 let mut output = String::new();
8886 for _ in 0..max_chars {
8887 if let Some(ch) = chars.next() {
8888 output.push(ch);
8889 } else {
8890 return value.to_string();
8891 }
8892 }
8893
8894 if chars.next().is_some() {
8895 format!("{output}...")
8896 } else {
8897 output
8898 }
8899}
8900
8901fn message_clipboard_text(message: &ChatMessage) -> String {
8902 let mut prefix = String::new();
8903 if let Some(agent) = &message.agent_name {
8904 prefix = format!("@{agent}\n");
8905 }
8906
8907 match &message.message_type {
8908 MessageType::Text(text) => format!("{prefix}{text}"),
8909 MessageType::Thinking(text) => format!("{prefix}{text}"),
8910 MessageType::Image { url, .. } => format!("{prefix}{url}"),
8911 MessageType::File { path, .. } => format!("{prefix}{path}"),
8912 MessageType::ToolCall {
8913 name,
8914 arguments_preview,
8915 ..
8916 } => format!("{prefix}Tool call: {name}\n{arguments_preview}"),
8917 MessageType::ToolResult {
8918 name,
8919 output_preview,
8920 ..
8921 } => format!("{prefix}Tool result: {name}\n{output_preview}"),
8922 }
8923}
8924
8925fn copy_text_to_clipboard_best_effort(text: &str) -> Result<&'static str, String> {
8926 if text.trim().is_empty() {
8927 return Err("empty text".to_string());
8928 }
8929
8930 match arboard::Clipboard::new().and_then(|mut clipboard| clipboard.set_text(text.to_string())) {
8932 Ok(()) => return Ok("system clipboard"),
8933 Err(e) => {
8934 tracing::debug!(error = %e, "System clipboard unavailable; falling back to OSC52");
8935 }
8936 }
8937
8938 osc52_copy(text).map_err(|e| format!("osc52 copy failed: {e}"))?;
8940 Ok("OSC52")
8941}
8942
8943fn osc52_copy(text: &str) -> std::io::Result<()> {
8944 let payload = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
8947 let seq = format!("\u{1b}]52;c;{payload}\u{07}");
8948
8949 let mut stdout = std::io::stdout();
8950 crossterm::execute!(stdout, crossterm::style::Print(seq))?;
8951 use std::io::Write;
8952 stdout.flush()?;
8953 Ok(())
8954}
8955
8956fn render_help_overlay_if_needed(f: &mut Frame, app: &App, theme: &Theme) {
8957 if !app.show_help {
8958 return;
8959 }
8960
8961 let area = centered_rect(60, 60, f.area());
8962 f.render_widget(Clear, area);
8963
8964 let token_display = TokenDisplay::new();
8965 let token_info = token_display.create_detailed_display();
8966
8967 let model_section: Vec<String> = if let Some(ref active) = app.active_model {
8969 let (provider, model) = crate::provider::parse_model_string(active);
8970 let provider_label = provider.unwrap_or("auto");
8971 vec![
8972 "".to_string(),
8973 " ACTIVE MODEL".to_string(),
8974 " ==============".to_string(),
8975 format!(" Provider: {}", provider_label),
8976 format!(" Model: {}", model),
8977 format!(" Agent: {}", app.current_agent),
8978 ]
8979 } else {
8980 vec![
8981 "".to_string(),
8982 " ACTIVE MODEL".to_string(),
8983 " ==============".to_string(),
8984 format!(" Provider: auto"),
8985 format!(" Model: (default)"),
8986 format!(" Agent: {}", app.current_agent),
8987 ]
8988 };
8989
8990 let help_text: Vec<String> = vec![
8991 "".to_string(),
8992 " KEYBOARD SHORTCUTS".to_string(),
8993 " ==================".to_string(),
8994 "".to_string(),
8995 " Enter Send message".to_string(),
8996 " Tab Switch between build/plan agents".to_string(),
8997 " Ctrl+A Open spawned-agent picker".to_string(),
8998 " Ctrl+M Open model picker".to_string(),
8999 " Ctrl+L Protocol bus log".to_string(),
9000 " Ctrl+P Protocol registry".to_string(),
9001 " Ctrl+S Toggle swarm view".to_string(),
9002 " Ctrl+B Toggle webview layout".to_string(),
9003 " Ctrl+Y Copy latest assistant reply".to_string(),
9004 " F3 Toggle inspector pane".to_string(),
9005 " Ctrl+C Quit".to_string(),
9006 " ? Toggle this help".to_string(),
9007 "".to_string(),
9008 " SLASH COMMANDS (auto-complete hints shown while typing)".to_string(),
9009 " OKR-GATED MODE (requires approval, tracks measurable outcomes)".to_string(),
9010 " /go <task> OKR-gated relay: draft → approve → execute → track KR progress"
9011 .to_string(),
9012 "".to_string(),
9013 " TACTICAL MODE (fast path, no OKR tracking)".to_string(),
9014 " /autochat [count] <task> Immediate relay: no approval needed, no outcome tracking"
9015 .to_string(),
9016 "".to_string(),
9017 " EASY MODE".to_string(),
9018 " /add <name> Create a helper teammate".to_string(),
9019 " /talk <name> <message> Message teammate".to_string(),
9020 " /list List teammates".to_string(),
9021 " /remove <name> Remove teammate".to_string(),
9022 " /home Return to main chat".to_string(),
9023 " /help Open this help".to_string(),
9024 "".to_string(),
9025 " ADVANCED MODE".to_string(),
9026 " /spawn <name> <instructions> Create a named sub-agent".to_string(),
9027 " /agents List spawned sub-agents".to_string(),
9028 " /kill <name> Remove a spawned sub-agent".to_string(),
9029 " /agent <name> Focus chat on a spawned sub-agent".to_string(),
9030 " /agent <name> <message> Send one message to a spawned sub-agent".to_string(),
9031 " /agent Open spawned-agent picker".to_string(),
9032 " /agent main|off Exit focused sub-agent chat".to_string(),
9033 " /swarm <task> Run task in parallel swarm mode".to_string(),
9034 " /ralph [path] Start Ralph PRD loop (default: prd.json)".to_string(),
9035 " /undo Undo last message and response".to_string(),
9036 " /sessions Open session picker (filter, delete, load, n/p paginate)".to_string(),
9037 " /resume Resume interrupted relay or most recent session".to_string(),
9038 " /resume <id> Resume specific session by ID".to_string(),
9039 " /new Start a fresh session".to_string(),
9040 " /model Open model picker (or /model <name>)".to_string(),
9041 " /view Toggle swarm view".to_string(),
9042 " /buslog Show protocol bus log".to_string(),
9043 " /protocol Show protocol registry and AgentCards".to_string(),
9044 " /webview Web dashboard layout".to_string(),
9045 " /classic Single-pane layout".to_string(),
9046 " /inspector Toggle inspector pane".to_string(),
9047 " /refresh Refresh workspace and sessions".to_string(),
9048 " /archive Show persistent chat archive path".to_string(),
9049 "".to_string(),
9050 " SESSION PICKER".to_string(),
9051 " ↑/↓/j/k Navigate sessions".to_string(),
9052 " Enter Load selected session".to_string(),
9053 " d Delete session (press twice to confirm)".to_string(),
9054 " Type Filter sessions by name/agent/ID".to_string(),
9055 " Backspace Clear filter character".to_string(),
9056 " Esc Close picker".to_string(),
9057 "".to_string(),
9058 " VIM-STYLE NAVIGATION".to_string(),
9059 " Alt+j Scroll down".to_string(),
9060 " Alt+k Scroll up".to_string(),
9061 " Ctrl+g Go to top".to_string(),
9062 " Ctrl+G Go to bottom".to_string(),
9063 "".to_string(),
9064 " SCROLLING".to_string(),
9065 " Up/Down Scroll messages".to_string(),
9066 " PageUp/Dn Scroll one page".to_string(),
9067 " Alt+u/d Scroll half page".to_string(),
9068 "".to_string(),
9069 " COMMAND HISTORY".to_string(),
9070 " Ctrl+R Search history".to_string(),
9071 " Ctrl+Up/Dn Navigate history".to_string(),
9072 "".to_string(),
9073 " Press ? or Esc to close".to_string(),
9074 "".to_string(),
9075 ];
9076
9077 let mut combined_text = token_info;
9078 combined_text.extend(model_section);
9079 combined_text.extend(help_text);
9080
9081 let help = Paragraph::new(combined_text.join("\n"))
9082 .block(
9083 Block::default()
9084 .borders(Borders::ALL)
9085 .title(" Help ")
9086 .border_style(Style::default().fg(theme.help_border_color.to_color())),
9087 )
9088 .wrap(Wrap { trim: false });
9089
9090 f.render_widget(help, area);
9091}
9092
9093fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
9095 let popup_layout = Layout::default()
9096 .direction(Direction::Vertical)
9097 .constraints([
9098 Constraint::Percentage((100 - percent_y) / 2),
9099 Constraint::Percentage(percent_y),
9100 Constraint::Percentage((100 - percent_y) / 2),
9101 ])
9102 .split(r);
9103
9104 Layout::default()
9105 .direction(Direction::Horizontal)
9106 .constraints([
9107 Constraint::Percentage((100 - percent_x) / 2),
9108 Constraint::Percentage(percent_x),
9109 Constraint::Percentage((100 - percent_x) / 2),
9110 ])
9111 .split(popup_layout[1])[1]
9112}
9113
9114#[cfg(test)]
9115mod tests {
9116 use super::{
9117 AUTOCHAT_QUICK_DEMO_TASK, agent_avatar, agent_profile, command_with_optional_args,
9118 estimate_cost, format_agent_identity, format_relay_handoff_line, is_easy_go_command,
9119 is_secure_environment_from_values, match_slash_command_hint, minio_fallback_endpoint,
9120 next_go_model, normalize_easy_command, normalize_for_convergence, normalize_minio_endpoint,
9121 parse_autochat_args,
9122 };
9123
9124 #[test]
9125 fn command_with_optional_args_handles_bare_command() {
9126 assert_eq!(command_with_optional_args("/spawn", "/spawn"), Some(""));
9127 }
9128
9129 #[test]
9130 fn command_with_optional_args_handles_arguments() {
9131 assert_eq!(
9132 command_with_optional_args("/spawn planner you plan", "/spawn"),
9133 Some("planner you plan")
9134 );
9135 }
9136
9137 #[test]
9138 fn command_with_optional_args_ignores_prefix_collisions() {
9139 assert_eq!(command_with_optional_args("/spawned", "/spawn"), None);
9140 }
9141
9142 #[test]
9143 fn command_with_optional_args_ignores_autochat_prefix_collisions() {
9144 assert_eq!(command_with_optional_args("/autochatty", "/autochat"), None);
9145 }
9146
9147 #[test]
9148 fn command_with_optional_args_trims_leading_whitespace_in_args() {
9149 assert_eq!(
9150 command_with_optional_args("/kill local-agent-1", "/kill"),
9151 Some("local-agent-1")
9152 );
9153 }
9154
9155 #[test]
9156 fn slash_hint_includes_protocol_command() {
9157 let hint = match_slash_command_hint("/protocol");
9158 assert!(hint.contains("/protocol"));
9159 }
9160
9161 #[test]
9162 fn slash_hint_includes_autochat_command() {
9163 let hint = match_slash_command_hint("/autochat");
9164 assert!(hint.contains("/autochat"));
9165 }
9166
9167 #[test]
9168 fn normalize_easy_command_maps_go_to_autochat() {
9169 assert_eq!(
9170 normalize_easy_command("/go build a calculator"),
9171 "/autochat 3 build a calculator"
9172 );
9173 }
9174
9175 #[test]
9176 fn normalize_easy_command_maps_go_count_and_task() {
9177 assert_eq!(
9178 normalize_easy_command("/go 4 build a calculator"),
9179 "/autochat 4 build a calculator"
9180 );
9181 }
9182
9183 #[test]
9184 fn normalize_easy_command_maps_go_count_only_to_demo_task() {
9185 assert_eq!(
9186 normalize_easy_command("/go 4"),
9187 format!("/autochat 4 {AUTOCHAT_QUICK_DEMO_TASK}")
9188 );
9189 }
9190
9191 #[test]
9192 fn slash_hint_handles_command_with_args() {
9193 let hint = match_slash_command_hint("/go 4");
9194 assert!(hint.contains("/go"));
9195 }
9196
9197 #[test]
9198 fn parse_autochat_args_supports_default_count() {
9199 assert_eq!(
9200 parse_autochat_args("build a calculator"),
9201 Some((3, "build a calculator"))
9202 );
9203 }
9204
9205 #[test]
9206 fn parse_autochat_args_supports_explicit_count() {
9207 assert_eq!(
9208 parse_autochat_args("4 build a calculator"),
9209 Some((4, "build a calculator"))
9210 );
9211 }
9212
9213 #[test]
9214 fn parse_autochat_args_count_only_uses_quick_demo_task() {
9215 assert_eq!(
9216 parse_autochat_args("4"),
9217 Some((4, AUTOCHAT_QUICK_DEMO_TASK))
9218 );
9219 }
9220
9221 #[test]
9222 fn normalize_for_convergence_ignores_case_and_punctuation() {
9223 let a = normalize_for_convergence("Done! Next Step: Add tests.");
9224 let b = normalize_for_convergence("done next step add tests");
9225 assert_eq!(a, b);
9226 }
9227
9228 #[test]
9229 fn agent_avatar_is_stable_and_ascii() {
9230 let avatar = agent_avatar("planner");
9231 assert_eq!(avatar, agent_avatar("planner"));
9232 assert!(avatar.is_ascii());
9233 assert!(avatar.starts_with('[') && avatar.ends_with(']'));
9234 }
9235
9236 #[test]
9237 fn relay_handoff_line_shows_avatar_labels() {
9238 let line = format_relay_handoff_line("relay-1", 2, "planner", "coder");
9239 assert!(line.contains("relay relay-1"));
9240 assert!(line.contains("@planner"));
9241 assert!(line.contains("@coder"));
9242 assert!(line.contains('['));
9243 }
9244
9245 #[test]
9246 fn relay_handoff_line_formats_user_sender() {
9247 let line = format_relay_handoff_line("relay-2", 1, "user", "planner");
9248 assert!(line.contains("[you]"));
9249 assert!(line.contains("@planner"));
9250 }
9251
9252 #[test]
9253 fn planner_profile_has_expected_personality() {
9254 let profile = agent_profile("auto-planner");
9255 assert_eq!(profile.codename, "Strategist");
9256 assert!(profile.profile.contains("decomposition"));
9257 }
9258
9259 #[test]
9260 fn formatted_identity_includes_codename() {
9261 let identity = format_agent_identity("auto-coder");
9262 assert!(identity.contains("@auto-coder"));
9263 assert!(identity.contains("‹Forge›"));
9264 }
9265
9266 #[test]
9267 fn normalize_minio_endpoint_strips_login_path() {
9268 assert_eq!(
9269 normalize_minio_endpoint("http://192.168.50.223:9001/login"),
9270 "http://192.168.50.223:9001"
9271 );
9272 }
9273
9274 #[test]
9275 fn normalize_minio_endpoint_adds_default_scheme() {
9276 assert_eq!(
9277 normalize_minio_endpoint("192.168.50.223:9000"),
9278 "http://192.168.50.223:9000"
9279 );
9280 }
9281
9282 #[test]
9283 fn fallback_endpoint_maps_console_port_to_s3_port() {
9284 assert_eq!(
9285 minio_fallback_endpoint("http://192.168.50.223:9001"),
9286 Some("http://192.168.50.223:9000".to_string())
9287 );
9288 assert_eq!(minio_fallback_endpoint("http://192.168.50.223:9000"), None);
9289 }
9290
9291 #[test]
9292 fn secure_environment_detection_respects_explicit_flags() {
9293 assert!(is_secure_environment_from_values(Some(true), None, None));
9294 assert!(!is_secure_environment_from_values(
9295 Some(false),
9296 Some(true),
9297 Some("secure")
9298 ));
9299 }
9300
9301 #[test]
9302 fn secure_environment_detection_uses_environment_name_fallback() {
9303 assert!(is_secure_environment_from_values(None, None, Some("PROD")));
9304 assert!(is_secure_environment_from_values(
9305 None,
9306 None,
9307 Some("production")
9308 ));
9309 assert!(!is_secure_environment_from_values(None, None, Some("dev")));
9310 }
9311
9312 #[test]
9313 fn minimax_m25_pricing_estimate_matches_announcement_rates() {
9314 let cost = estimate_cost("minimax/MiniMax-M2.5", 1_000_000, 1_000_000)
9315 .expect("MiniMax M2.5 cost should be available");
9316 assert!((cost - 1.35).abs() < 1e-9);
9317 }
9318
9319 #[test]
9320 fn minimax_m25_lightning_pricing_is_case_insensitive() {
9321 let cost = estimate_cost("MiniMax-M2.5-Lightning", 1_000_000, 1_000_000)
9322 .expect("MiniMax M2.5 Lightning cost should be available");
9323 assert!((cost - 2.7).abs() < 1e-9);
9324 }
9325
9326 #[test]
9327 fn easy_go_command_detects_go_and_team_aliases() {
9328 assert!(is_easy_go_command("/go build indexing"));
9329 assert!(is_easy_go_command("/team 4 implement auth"));
9330 assert!(!is_easy_go_command("/autochat build indexing"));
9331 }
9332
9333 #[test]
9334 fn next_go_model_toggles_between_glm_and_minimax() {
9335 assert_eq!(next_go_model(Some("zai/glm-5")), "minimax/MiniMax-M2.5");
9336 assert_eq!(next_go_model(Some("z-ai/glm-5")), "minimax/MiniMax-M2.5");
9337 assert_eq!(next_go_model(Some("minimax/MiniMax-M2.5")), "zai/glm-5");
9338 assert_eq!(next_go_model(Some("unknown/model")), "minimax/MiniMax-M2.5");
9339 }
9340}