From b9071351e69294f277ece991a364995a0bea6d37 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Tue, 3 Feb 2026 03:28:06 -0600 Subject: [PATCH 1/2] feat: wire config options, context compaction, and auto-index on startup Connect ResolvedConfig to the TUI App so config values (autoApprove, systemPromptAdditions, projectContext, commandAliases, noTools, etc.) are used when creating the agent instead of being ignored. Add context compaction in the agent chat loop: estimate token count before each API call and compact older messages into a summary when the limit is exceeded. Auto-index the symbol index in a background task on startup so /symbols commands work without manual rebuild. Co-Authored-By: Claude Opus 4.5 --- codi-rs/src/agent/mod.rs | 130 ++++++++++++++++++++++++++++++++++++ codi-rs/src/main.rs | 26 +++++++- codi-rs/src/tui/app.rs | 100 ++++++++++++++++++++++++++- codi-rs/src/tui/commands.rs | 5 ++ 4 files changed, 256 insertions(+), 5 deletions(-) diff --git a/codi-rs/src/agent/mod.rs b/codi-rs/src/agent/mod.rs index 8db8246..4122802 100644 --- a/codi-rs/src/agent/mod.rs +++ b/codi-rs/src/agent/mod.rs @@ -133,6 +133,131 @@ 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() + } + } + } + + /// 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() { + // Truncate individual messages in the summary + let truncated = if text.len() > 200 { + format!("{}...", &text[..200]) + } else { + text + }; + summary_parts.push(format!("{}: {}", role, truncated)); + } + } + + // Build combined summary, truncating to ~2000 chars + let mut new_summary = summary_parts.join("\n"); + if new_summary.len() > 2000 { + new_summary.truncate(2000); + new_summary.push_str("..."); + } + + // Prepend existing summary if there is one + if let Some(ref existing) = self.state.conversation_summary { + let combined = format!("{}\n\n{}", existing, new_summary); + // Keep combined summary under 4000 chars + self.state.conversation_summary = Some(if combined.len() > 4000 { + let mut truncated = combined; + truncated.truncate(4000); + truncated.push_str("..."); + truncated + } else { + combined + }); + } 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 +436,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(); 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..5ce3581 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() diff --git a/codi-rs/src/tui/commands.rs b/codi-rs/src/tui/commands.rs index 4cc076c..f6b7283 100644 --- a/codi-rs/src/tui/commands.rs +++ b/codi-rs/src/tui/commands.rs @@ -60,6 +60,11 @@ 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 { + // Check for command aliases from config before parsing + if let Some(expanded) = app.resolve_command_alias(input) { + return handle_command(app, &expanded); + } + let parts: Vec<&str> = input.trim().splitn(2, ' ').collect(); let command = parts[0].to_lowercase(); let args = parts.get(1).copied().unwrap_or(""); From 10a6ebeeaadd83ee1bb5c74b8d2aff4e7fa58885 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Tue, 3 Feb 2026 03:32:32 -0600 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20issues=20?= =?UTF-8?q?=E2=80=94=20UTF-8=20safety,=20alias=20recursion=20guard,=20test?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace byte-index truncation with char-boundary-safe truncate_str() to prevent panics on multi-byte UTF-8 content (emoji, CJK, accents) - Add recursion depth limit (max 5) to command alias expansion to prevent stack overflow on self-referencing or cyclic aliases - Add 16 new tests: alias resolution, system prompt building, agent config wiring, UTF-8 truncation edge cases, alias cycle safety Co-Authored-By: Claude Opus 4.5 --- codi-rs/src/agent/mod.rs | 67 +++++++++++++++++-------- codi-rs/src/tui/app.rs | 98 +++++++++++++++++++++++++++++++++++++ codi-rs/src/tui/commands.rs | 52 ++++++++++++++++++-- 3 files changed, 193 insertions(+), 24 deletions(-) diff --git a/codi-rs/src/agent/mod.rs b/codi-rs/src/agent/mod.rs index 4122802..006fde5 100644 --- a/codi-rs/src/agent/mod.rs +++ b/codi-rs/src/agent/mod.rs @@ -171,6 +171,17 @@ impl Agent { } } + /// 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) { @@ -213,35 +224,17 @@ impl Agent { } }; if !text.is_empty() { - // Truncate individual messages in the summary - let truncated = if text.len() > 200 { - format!("{}...", &text[..200]) - } else { - text - }; - summary_parts.push(format!("{}: {}", role, truncated)); + summary_parts.push(format!("{}: {}", role, Self::truncate_str(&text, 200))); } } // Build combined summary, truncating to ~2000 chars - let mut new_summary = summary_parts.join("\n"); - if new_summary.len() > 2000 { - new_summary.truncate(2000); - new_summary.push_str("..."); - } + 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); - // Keep combined summary under 4000 chars - self.state.conversation_summary = Some(if combined.len() > 4000 { - let mut truncated = combined; - truncated.truncate(4000); - truncated.push_str("..."); - truncated - } else { - combined - }); + self.state.conversation_summary = Some(Self::truncate_str(&combined, 4000)); } else { self.state.conversation_summary = Some(new_summary); } @@ -612,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/tui/app.rs b/codi-rs/src/tui/app.rs index 5ce3581..c5705ad 100644 --- a/codi-rs/src/tui/app.rs +++ b/codi-rs/src/tui/app.rs @@ -1363,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 f6b7283..0f312b5 100644 --- a/codi-rs/src/tui/commands.rs +++ b/codi-rs/src/tui/commands.rs @@ -60,9 +60,16 @@ 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 { - // Check for command aliases from config before parsing - if let Some(expanded) = app.resolve_command_alias(input) { - return handle_command(app, &expanded); + 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(); @@ -1249,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(_))); + } }