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