diff --git a/codi-rs/src/agent/mod.rs b/codi-rs/src/agent/mod.rs index 8db8246..006fde5 100644 --- a/codi-rs/src/agent/mod.rs +++ b/codi-rs/src/agent/mod.rs @@ -133,6 +133,124 @@ impl Agent { context } + /// Estimate the current token count of all messages. + /// Uses the standard approximation of ~4 characters per token. + fn estimate_tokens(&self) -> usize { + let mut total_chars: usize = 0; + + // Count system prompt + total_chars += self.system_prompt.len(); + if let Some(ref summary) = self.state.conversation_summary { + total_chars += summary.len(); + } + + // Count all messages + for msg in &self.state.messages { + total_chars += self.message_char_count(msg); + } + + total_chars / 4 + } + + /// Count the characters in a message's content. + fn message_char_count(&self, msg: &Message) -> usize { + match &msg.content { + crate::types::MessageContent::Text(s) => s.len(), + crate::types::MessageContent::Blocks(blocks) => { + blocks.iter().map(|b| { + let mut n = 0; + if let Some(ref t) = b.text { n += t.len(); } + if let Some(ref name) = b.name { n += name.len(); } + if let Some(ref input) = b.input { + n += input.to_string().len(); + } + if let Some(ref content) = b.content { n += content.len(); } + n + }).sum() + } + } + } + + /// Truncate a string to at most `max_chars` characters, appending "..." if truncated. + /// Safe for multi-byte UTF-8 (truncates at char boundary). + fn truncate_str(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + let truncated: String = s.chars().take(max_chars).collect(); + format!("{}...", truncated) + } + } + + /// Compact the conversation context when it exceeds the token limit. + /// Keeps the system prompt and recent messages, summarizes older ones. + fn compact_context(&mut self) { + // Notify that compaction is starting + if let Some(ref on_compaction) = self.callbacks.on_compaction { + on_compaction(true); + } + + let keep_recent = 10; // Keep the last N messages intact + let msg_count = self.state.messages.len(); + + if msg_count <= keep_recent { + // Not enough messages to compact + if let Some(ref on_compaction) = self.callbacks.on_compaction { + on_compaction(false); + } + return; + } + + // Split messages: older ones to summarize, recent ones to keep + let split_at = msg_count - keep_recent; + let older_messages: Vec = self.state.messages.drain(..split_at).collect(); + + // Build a simple summary from older messages by extracting text content + let mut summary_parts: Vec = Vec::new(); + for msg in &older_messages { + let role = match msg.role { + Role::User => "User", + Role::Assistant => "Assistant", + Role::System => "System", + }; + let text = match &msg.content { + crate::types::MessageContent::Text(s) => s.clone(), + crate::types::MessageContent::Blocks(blocks) => { + blocks.iter() + .filter_map(|b| b.text.as_ref()) + .cloned() + .collect::>() + .join(" ") + } + }; + if !text.is_empty() { + summary_parts.push(format!("{}: {}", role, Self::truncate_str(&text, 200))); + } + } + + // Build combined summary, truncating to ~2000 chars + let new_summary = Self::truncate_str(&summary_parts.join("\n"), 2000); + + // Prepend existing summary if there is one + if let Some(ref existing) = self.state.conversation_summary { + let combined = format!("{}\n\n{}", existing, new_summary); + self.state.conversation_summary = Some(Self::truncate_str(&combined, 4000)); + } else { + self.state.conversation_summary = Some(new_summary); + } + + tracing::info!( + "Context compacted: removed {} messages, {} remaining", + split_at, + self.state.messages.len() + ); + + // Notify that compaction is complete + if let Some(ref on_compaction) = self.callbacks.on_compaction { + on_compaction(false); + } + } + /// Check if a tool call should be confirmed. fn should_confirm(&self, tool_name: &str) -> bool { self.config.requires_confirmation(tool_name) && self.callbacks.on_confirm.is_some() @@ -311,6 +429,11 @@ impl Agent { break; } + // Check if context needs compaction + if self.estimate_tokens() > self.config.max_context_tokens { + self.compact_context(); + } + // Build request parameters let tools = self.get_tool_definitions(); let system_context = self.build_system_context(); @@ -482,4 +605,36 @@ mod tests { assert_eq!(ConfirmationResult::Approve, ConfirmationResult::Approve); assert_ne!(ConfirmationResult::Approve, ConfirmationResult::Deny); } + + #[test] + fn test_truncate_str_short() { + assert_eq!(Agent::truncate_str("hello", 10), "hello"); + } + + #[test] + fn test_truncate_str_exact() { + assert_eq!(Agent::truncate_str("hello", 5), "hello"); + } + + #[test] + fn test_truncate_str_long() { + let result = Agent::truncate_str("hello world", 5); + assert_eq!(result, "hello..."); + } + + #[test] + fn test_truncate_str_multibyte() { + // "café" is 5 bytes but 4 chars — should not panic + let result = Agent::truncate_str("café!", 4); + assert_eq!(result, "café..."); + } + + #[test] + fn test_truncate_str_emoji() { + // Emoji are multi-byte — slicing at byte boundary would panic + let input = "hello 🌍 world"; + let result = Agent::truncate_str(input, 7); + assert!(result.ends_with("...")); + assert!(!result.contains("world")); + } } diff --git a/codi-rs/src/main.rs b/codi-rs/src/main.rs index 8f7e33e..b4601ed 100644 --- a/codi-rs/src/main.rs +++ b/codi-rs/src/main.rs @@ -512,12 +512,17 @@ async fn handle_prompt( Ok(()) } -async fn run_repl(config: &config::ResolvedConfig, _auto_approve: bool) -> anyhow::Result<()> { +async fn run_repl(config: &config::ResolvedConfig, auto_approve: bool) -> anyhow::Result<()> { // Create provider from configuration let provider = create_provider_from_config(config)?; - // Create TUI app with provider - let mut app = App::with_provider_and_path(provider, std::env::current_dir()?); + // Create TUI app with project path, set config before provider + let mut app = App::with_project_path(std::env::current_dir()?); + + // Set config and auto_approve flag, then set provider (which uses stored config) + app.set_config(config.clone()); + app.set_auto_approve(auto_approve); + app.set_provider(provider); // Load session if specified if let Some(ref session_name) = config.default_session { @@ -526,6 +531,21 @@ async fn run_repl(config: &config::ResolvedConfig, _auto_approve: bool) -> anyho } } + // Auto-index symbol index in background + let project_path_str = std::env::current_dir()?.to_string_lossy().to_string(); + tokio::spawn(async move { + match codi::symbol_index::SymbolIndexService::new(&project_path_str).await { + Ok(service) => { + if let Err(e) = service.build(false).await { + tracing::warn!("Symbol index build failed: {}", e); + } else { + tracing::info!("Symbol index built successfully"); + } + } + Err(e) => tracing::debug!("Symbol index init skipped: {}", e), + } + }); + // Run TUI match run_tui(&mut app).await { Ok(_) => Ok(()), diff --git a/codi-rs/src/tui/app.rs b/codi-rs/src/tui/app.rs index ca3f3df..c5705ad 100644 --- a/codi-rs/src/tui/app.rs +++ b/codi-rs/src/tui/app.rs @@ -16,6 +16,7 @@ use crate::agent::{ Agent, AgentCallbacks, AgentConfig, AgentOptions, ConfirmationResult, ToolConfirmation, TurnStats, }; +use crate::config::ResolvedConfig; use crate::error::{Result as CodiResult, ToolError}; use crate::completion::{complete_line, get_completion_matches}; use crate::orchestrate::{Commander, CommanderConfig, WorkerConfig, WorkerStatus, WorkspaceInfo, PermissionResult}; @@ -207,6 +208,11 @@ pub struct App { /// Tab completion hint to display. pub completion_hint: Option, + /// Resolved configuration from config files and CLI. + config: Option, + /// Auto-approve all tool operations (from --yes CLI flag). + auto_approve_all: bool, + // Background agent task /// Receiver for agent returning from a background chat task. pending_agent: Option)>>, @@ -255,6 +261,8 @@ impl App { current_session: None, project_path, completion_hint: None, + config: None, + auto_approve_all: false, pending_agent: None, commander: None, pending_worker_permissions: Vec::new(), @@ -275,6 +283,55 @@ impl App { app } + /// Set the resolved configuration. Call before `set_provider` to apply config values. + pub fn set_config(&mut self, config: ResolvedConfig) { + self.config = Some(config); + } + + /// Set auto-approve-all flag (from --yes CLI flag). Call before `set_provider`. + pub fn set_auto_approve(&mut self, auto_approve: bool) { + self.auto_approve_all = auto_approve; + } + + /// Build an `AgentConfig` from the stored `ResolvedConfig`, or use defaults. + fn build_agent_config(&self) -> AgentConfig { + if let Some(ref config) = self.config { + AgentConfig { + max_iterations: 50, + max_consecutive_errors: 3, + max_turn_duration_ms: 120_000, + max_context_tokens: config.max_context_tokens as usize, + use_tools: !config.no_tools, + extract_tools_from_text: config.extract_tools_from_text, + auto_approve_all: self.auto_approve_all, + auto_approve_tools: config.auto_approve.clone(), + } + } else { + let mut default_config = AgentConfig::default(); + default_config.auto_approve_all = self.auto_approve_all; + default_config + } + } + + /// Build the system prompt, incorporating config additions and project context. + fn build_system_prompt(&self) -> String { + let mut prompt = "You are Codi, a helpful AI coding assistant. Help the user with their programming tasks.".to_string(); + + if let Some(ref config) = self.config { + if let Some(ref additions) = config.system_prompt_additions { + prompt.push_str("\n\n"); + prompt.push_str(additions); + } + + if let Some(ref project_context) = config.project_context { + prompt.push_str("\n\n## Project Context\n"); + prompt.push_str(project_context); + } + } + + prompt + } + /// Set the AI provider and create an agent. pub fn set_provider(&mut self, provider: BoxedProvider) { let registry = Arc::new(ToolRegistry::with_defaults()); @@ -313,8 +370,8 @@ impl App { self.agent = Some(Agent::new(AgentOptions { provider, tool_registry: registry, - system_prompt: Some("You are Codi, a helpful AI coding assistant. Help the user with their programming tasks.".to_string()), - config: AgentConfig::default(), + system_prompt: Some(self.build_system_prompt()), + config: self.build_agent_config(), callbacks, })); } @@ -867,6 +924,45 @@ impl App { self.pending_confirmation.as_ref().map(|p| &p.confirmation) } + /// Resolve a command alias from config. Returns the expanded command if an alias matches, + /// or `None` if no alias applies. Aliases are checked against the command portion after `/`. + pub fn resolve_command_alias(&self, input: &str) -> Option { + let config = self.config.as_ref()?; + if config.command_aliases.is_empty() { + return None; + } + + let trimmed = input.trim(); + if !trimmed.starts_with('/') { + return None; + } + + // Extract the command name (without /) and any trailing args + let without_slash = &trimmed[1..]; + let (cmd, extra_args) = match without_slash.split_once(' ') { + Some((c, a)) => (c, Some(a)), + None => (without_slash, None), + }; + + // Check if this command matches an alias + if let Some(expansion) = config.command_aliases.get(cmd) { + let expanded = if let Some(args) = extra_args { + format!("{} {}", expansion, args) + } else { + expansion.clone() + }; + // Ensure the expansion starts with / + let result = if expanded.starts_with('/') { + expanded + } else { + format!("/{}", expanded) + }; + Some(result) + } else { + None + } + } + /// Check if a provider is configured. pub fn has_provider(&self) -> bool { self.agent.is_some() @@ -1267,6 +1363,104 @@ mod tests { assert!(app.status.is_some()); } + #[test] + fn test_resolve_command_alias_no_config() { + let app = App::new(); + // No config set, should return None + assert!(app.resolve_command_alias("/t").is_none()); + } + + #[test] + fn test_resolve_command_alias_basic() { + let mut app = App::new(); + let mut config = crate::config::default_config(); + config.command_aliases.insert("t".to_string(), "/test src/".to_string()); + config.command_aliases.insert("b".to_string(), "/build".to_string()); + app.set_config(config); + + // Basic alias + assert_eq!(app.resolve_command_alias("/t"), Some("/test src/".to_string())); + assert_eq!(app.resolve_command_alias("/b"), Some("/build".to_string())); + + // Non-matching command + assert!(app.resolve_command_alias("/help").is_none()); + + // Non-slash input + assert!(app.resolve_command_alias("hello").is_none()); + } + + #[test] + fn test_resolve_command_alias_with_extra_args() { + let mut app = App::new(); + let mut config = crate::config::default_config(); + config.command_aliases.insert("t".to_string(), "/test src/".to_string()); + app.set_config(config); + + // Extra args appended + assert_eq!( + app.resolve_command_alias("/t --verbose"), + Some("/test src/ --verbose".to_string()) + ); + } + + #[test] + fn test_resolve_command_alias_bare_expansion() { + let mut app = App::new(); + let mut config = crate::config::default_config(); + // Alias without leading / + config.command_aliases.insert("x".to_string(), "exit".to_string()); + app.set_config(config); + + // Should auto-prepend / + assert_eq!(app.resolve_command_alias("/x"), Some("/exit".to_string())); + } + + #[test] + fn test_build_system_prompt_no_config() { + let app = App::new(); + let prompt = app.build_system_prompt(); + assert!(prompt.contains("Codi")); + assert!(!prompt.contains("Project Context")); + } + + #[test] + fn test_build_system_prompt_with_additions() { + let mut app = App::new(); + let mut config = crate::config::default_config(); + config.system_prompt_additions = Some("Always use strict mode.".to_string()); + config.project_context = Some("This is a React app.".to_string()); + app.set_config(config); + + let prompt = app.build_system_prompt(); + assert!(prompt.contains("Always use strict mode.")); + assert!(prompt.contains("## Project Context")); + assert!(prompt.contains("This is a React app.")); + } + + #[test] + fn test_build_agent_config_defaults() { + let app = App::new(); + let config = app.build_agent_config(); + assert!(config.use_tools); + assert!(!config.auto_approve_all); + assert!(config.auto_approve_tools.is_empty()); + } + + #[test] + fn test_build_agent_config_from_resolved() { + let mut app = App::new(); + let mut config = crate::config::default_config(); + config.no_tools = true; + config.auto_approve = vec!["read_file".to_string()]; + app.set_config(config); + app.set_auto_approve(true); + + let agent_config = app.build_agent_config(); + assert!(!agent_config.use_tools); + assert!(agent_config.auto_approve_all); + assert_eq!(agent_config.auto_approve_tools, vec!["read_file".to_string()]); + } + #[test] fn test_input_history() { let mut app = App::new(); diff --git a/codi-rs/src/tui/commands.rs b/codi-rs/src/tui/commands.rs index 4cc076c..0f312b5 100644 --- a/codi-rs/src/tui/commands.rs +++ b/codi-rs/src/tui/commands.rs @@ -60,6 +60,18 @@ fn has_help_flag(args: &str) -> bool { /// Handle a slash command synchronously. Returns `CommandResult::Async` for /// commands that need async execution. pub fn handle_command(app: &mut App, input: &str) -> CommandResult { + handle_command_inner(app, input, 0) +} + +/// Inner handler with recursion depth limit for alias expansion. +fn handle_command_inner(app: &mut App, input: &str, depth: usize) -> CommandResult { + // Check for command aliases from config before parsing (with recursion guard) + if depth < 5 { + if let Some(expanded) = app.resolve_command_alias(input) { + return handle_command_inner(app, &expanded, depth + 1); + } + } + let parts: Vec<&str> = input.trim().splitn(2, ' ').collect(); let command = parts[0].to_lowercase(); let args = parts.get(1).copied().unwrap_or(""); @@ -1244,4 +1256,43 @@ mod tests { let result = handle_command(&mut app, "/wt"); assert!(matches!(result, CommandResult::Async(AsyncCommand::WorktreesList))); } + + #[test] + fn test_command_alias_expansion() { + let mut app = App::default(); + let mut config = crate::config::default_config(); + config.command_aliases.insert("h".to_string(), "/help".to_string()); + app.set_config(config); + + // /h should expand to /help and show help mode + let _result = handle_command(&mut app, "/h"); + assert_eq!(app.mode, super::super::app::AppMode::Help); + } + + #[test] + fn test_command_alias_self_reference_no_stackoverflow() { + let mut app = App::default(); + let mut config = crate::config::default_config(); + // Self-referencing alias: /x -> /x (would recurse infinitely without guard) + config.command_aliases.insert("x".to_string(), "/x".to_string()); + app.set_config(config); + + // Should NOT stack overflow — recursion guard limits depth to 5 + // After max depth, /x is treated as unknown command + let result = handle_command(&mut app, "/x"); + assert!(matches!(result, CommandResult::Error(_))); + } + + #[test] + fn test_command_alias_cycle_no_stackoverflow() { + let mut app = App::default(); + let mut config = crate::config::default_config(); + config.command_aliases.insert("a".to_string(), "/b".to_string()); + config.command_aliases.insert("b".to_string(), "/a".to_string()); + app.set_config(config); + + // Cycle: /a -> /b -> /a -> /b -> /a -> /b (depth 5, stops expanding) + let result = handle_command(&mut app, "/a"); + assert!(matches!(result, CommandResult::Error(_))); + } }