diff --git a/codi-rs/src/agent/mod.rs b/codi-rs/src/agent/mod.rs index 2532285..deb8aa2 100644 --- a/codi-rs/src/agent/mod.rs +++ b/codi-rs/src/agent/mod.rs @@ -112,75 +112,26 @@ impl Agent { self.system_prompt = prompt.into(); } - /// Get tool definitions if tools are enabled and supported. - fn get_tool_definitions(&self) -> Option> { - if self.config.use_tools && self.provider.supports_tool_use() { - Some(self.tool_registry.definitions()) - } else { - None - } + /// Get the conversation summary if available. + pub fn conversation_summary(&self) -> Option<&str> { + self.state.conversation_summary.as_deref() } - /// Build the system context including any conversation summary. - fn build_system_context(&self) -> String { - let mut context = self.system_prompt.clone(); - - if let Some(ref summary) = self.state.conversation_summary { - context.push_str("\n\n## Previous Conversation Summary\n"); - context.push_str(summary); - } - - context + /// Get the number of messages in the conversation. + pub fn message_count(&self) -> usize { + self.state.messages.len() } - /// Estimate the current token count of all messages. - /// Uses the standard approximation of ~4 characters per token. - /// Leverages `running_char_count` to avoid re-serializing every message each iteration. - fn estimate_tokens(&self) -> usize { - let mut total_chars: usize = self.state.running_char_count; - - // Add system prompt + summary (not cached — these are cheap to measure) - total_chars += self.system_prompt.len(); - if let Some(ref summary) = self.state.conversation_summary { - total_chars += summary.len(); - } - - total_chars / 4 + /// Force context compaction to reduce token usage. + /// Returns the number of messages that were summarized. + pub fn compact_context(&mut self) -> usize { + let msg_count_before = self.state.messages.len(); + self.compact_context_internal(); + msg_count_before.saturating_sub(self.state.messages.len()) } - /// 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) { + /// Internal implementation of context compaction. + fn compact_context_internal(&mut self) { // Notify that compaction is starting if let Some(ref on_compaction) = self.callbacks.on_compaction { on_compaction(true); @@ -252,6 +203,72 @@ impl Agent { } } + /// Get tool definitions if tools are enabled and supported. + fn get_tool_definitions(&self) -> Option> { + if self.config.use_tools && self.provider.supports_tool_use() { + Some(self.tool_registry.definitions()) + } else { + None + } + } + + /// Build the system context including any conversation summary. + fn build_system_context(&self) -> String { + let mut context = self.system_prompt.clone(); + + if let Some(ref summary) = self.state.conversation_summary { + context.push_str("\n\n## Previous Conversation Summary\n"); + context.push_str(summary); + } + + context + } + + /// Estimate the current token count of all messages. + /// Uses the standard approximation of ~4 characters per token. + /// Leverages `running_char_count` to avoid re-serializing every message each iteration. + fn estimate_tokens(&self) -> usize { + let mut total_chars: usize = self.state.running_char_count; + + // Add system prompt + summary (not cached — these are cheap to measure) + total_chars += self.system_prompt.len(); + if let Some(ref summary) = self.state.conversation_summary { + total_chars += summary.len(); + } + + 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) + } + } + /// Check whether a tool call needs confirmation and, if so, ask the user. /// /// Returns `None` when no confirmation is needed (tool is auto-approved and diff --git a/codi-rs/src/main.rs b/codi-rs/src/main.rs index 1911e38..96c8ef8 100644 --- a/codi-rs/src/main.rs +++ b/codi-rs/src/main.rs @@ -12,7 +12,8 @@ use codi::agent::AgentConfig; use codi::config::{self, CliOptions}; use codi::providers::{create_provider_from_config, ProviderType}; use codi::tools::ToolRegistry; -use codi::tui::{App, build_system_prompt_from_config, run as run_tui}; +use codi::tui::{App, build_system_prompt_from_config}; +use codi::tui::terminal_ui::run_terminal_repl; /// Codi version string. const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -227,11 +228,6 @@ async fn main() -> anyhow::Result<()> { let workspace_root = std::env::current_dir()?; let config = config::load_config(&workspace_root, cli_options)?; - // Print startup message in non-quiet mode - if !cli.quiet { - print_startup_message(&config); - } - // Handle non-interactive mode if let Some(prompt) = cli.prompt { return handle_prompt(&config, &prompt, cli.output_format, cli.quiet, cli.yes).await; @@ -241,20 +237,6 @@ async fn main() -> anyhow::Result<()> { run_repl(&config, cli.yes).await } -fn print_startup_message(config: &config::ResolvedConfig) { - println!( - "{} {} - Your AI coding wingman", - "codi".cyan().bold(), - format!("v{}", VERSION ).dimmed() - ); - println!( - "Provider: {} | Model: {}", - config.provider.green(), - config.model.as_deref().unwrap_or("default").yellow() - ); - println!(); -} - async fn handle_command(command: Commands) -> anyhow::Result<()> { match command { Commands::Config { action } => { @@ -514,50 +496,6 @@ async fn handle_prompt( } 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 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 { - if let Err(e) = load_session(&mut app, session_name).await { - eprintln!("{} Failed to load session '{}': {}", "⚠".yellow(), session_name, e); - } - } - - // 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(()), - Err(e) => Err(anyhow::anyhow!("TUI error: {}", e)), - } -} - -async fn load_session(app: &mut App, session_name: &str) -> anyhow::Result<()> { - // Try to load by exact name first, then by fuzzy match - if let Err(_) = app.load_session(session_name).await { - return Err(anyhow::anyhow!("Session not found: {}", session_name)); - } - Ok(()) + // Use new terminal-style REPL instead of full-screen TUI + run_terminal_repl(config, auto_approve).await } diff --git a/codi-rs/src/providers/mod.rs b/codi-rs/src/providers/mod.rs index f4b9580..ae639e3 100644 --- a/codi-rs/src/providers/mod.rs +++ b/codi-rs/src/providers/mod.rs @@ -451,6 +451,151 @@ pub fn create_provider_from_config(config: &ResolvedConfig) -> Result Vec { + vec![ + // Anthropic models + AvailableModel { + provider: "anthropic", + model_id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + description: "Most capable Claude model, balanced performance", + supports_tools: true, + supports_vision: true, + context_window: 200_000, + }, + AvailableModel { + provider: "anthropic", + model_id: "claude-haiku-4-20250514", + name: "Claude Haiku 4", + description: "Fast, efficient Claude model", + supports_tools: true, + supports_vision: true, + context_window: 200_000, + }, + AvailableModel { + provider: "anthropic", + model_id: "claude-opus-4-20250514", + name: "Claude Opus 4", + description: "Most powerful Claude model for complex tasks", + supports_tools: true, + supports_vision: true, + context_window: 200_000, + }, + // OpenAI models + AvailableModel { + provider: "openai", + model_id: "gpt-4o", + name: "GPT-4o", + description: "Multimodal model with vision support", + supports_tools: true, + supports_vision: true, + context_window: 128_000, + }, + AvailableModel { + provider: "openai", + model_id: "gpt-4o-mini", + name: "GPT-4o Mini", + description: "Faster, more affordable GPT-4o", + supports_tools: true, + supports_vision: true, + context_window: 128_000, + }, + AvailableModel { + provider: "openai", + model_id: "gpt-4.1", + name: "GPT-4.1", + description: "Latest GPT-4.1 model", + supports_tools: true, + supports_vision: true, + context_window: 1_000_000, + }, + AvailableModel { + provider: "openai", + model_id: "o3-mini", + name: "o3 Mini", + description: "Reasoning model for complex tasks", + supports_tools: true, + supports_vision: false, + context_window: 200_000, + }, + // Ollama models (popular choices) + AvailableModel { + provider: "ollama", + model_id: "llama3.2", + name: "Llama 3.2", + description: "Latest Llama model (local)", + supports_tools: false, + supports_vision: false, + context_window: 8_000, + }, + AvailableModel { + provider: "ollama", + model_id: "llama3.1", + name: "Llama 3.1", + description: "Previous Llama generation (local)", + supports_tools: false, + supports_vision: false, + context_window: 8_000, + }, + AvailableModel { + provider: "ollama", + model_id: "qwen2.5", + name: "Qwen 2.5", + description: "Alibaba's Qwen model (local)", + supports_tools: false, + supports_vision: false, + context_window: 8_000, + }, + AvailableModel { + provider: "ollama", + model_id: "deepseek-coder", + name: "DeepSeek Coder", + description: "Code-focused model (local)", + supports_tools: false, + supports_vision: false, + context_window: 8_000, + }, + ] +} + +/// Get available models filtered by provider. +pub fn get_models_for_provider(provider: &str) -> Vec { + get_available_models() + .into_iter() + .filter(|m| m.provider.eq_ignore_ascii_case(provider)) + .collect() +} + +/// Check if a provider is available (has API key if required). +pub fn is_provider_available(provider: &str) -> bool { + match provider.to_lowercase().as_str() { + "anthropic" | "claude" => std::env::var("ANTHROPIC_API_KEY").is_ok(), + "openai" | "gpt" => std::env::var("OPENAI_API_KEY").is_ok(), + "ollama" => true, // Ollama doesn't require an API key + _ => false, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/codi-rs/src/tui/app.rs b/codi-rs/src/tui/app.rs index 1504908..827e7ff 100644 --- a/codi-rs/src/tui/app.rs +++ b/codi-rs/src/tui/app.rs @@ -195,6 +195,8 @@ pub struct App { pending_confirmation: Option, /// Last turn stats. pub last_turn_stats: Option, + /// Turn start time for elapsed time display. + pub turn_start_time: Option, /// Input history. pub input_history: Vec, /// Current position in input history. @@ -258,6 +260,7 @@ impl App { event_tx: Some(tx), pending_confirmation: None, last_turn_stats: None, + turn_start_time: None, input_history: Vec::new(), history_index: None, saved_input: String::new(), @@ -495,6 +498,7 @@ impl App { self.last_turn_stats = Some(stats); self.mode = AppMode::Normal; self.status = None; + self.turn_start_time = None; // Reset turn start time // Finalize streaming self.finalize_streaming(); @@ -861,6 +865,7 @@ impl App { } } self.mode = AppMode::Waiting; + self.turn_start_time = Some(std::time::Instant::now()); } /// Submit the current input. @@ -1001,14 +1006,65 @@ impl App { /// Get model info string for status bar. pub fn model_info(&self) -> String { - if self.agent.is_some() { - // TODO: Add method to get provider name and model from agent - String::new() + if let Some(ref config) = self.config { + let provider = config.provider.clone(); + let model = config.model.as_deref().unwrap_or("default"); + format!("{} | {}", provider, model) } else { String::new() } } + /// Get the auto-approve-all flag value. + pub fn auto_approve_all(&self) -> bool { + self.auto_approve_all + } + + /// Compact conversation context by summarizing older messages. + /// Returns the number of messages that were summarized. + pub fn compact_conversation(&mut self) -> usize { + if let Some(ref mut agent) = self.agent { + agent.compact_context() + } else { + 0 + } + } + + /// Check if the agent has a conversation summary. + pub fn has_conversation_summary(&self) -> bool { + if let Some(ref agent) = self.agent { + agent.conversation_summary().is_some() + } else { + false + } + } + + /// Check if an agent is configured. + pub fn has_agent(&self) -> bool { + self.agent.is_some() + } + + /// Get current provider and model information. + pub fn get_current_model_info(&self) -> Option<(String, Option)> { + self.config.as_ref().map(|c| { + (c.provider.clone(), c.model.clone()) + }) + } + + /// Get the current configuration for model switching. + pub fn get_config(&self) -> Option<&ResolvedConfig> { + self.config.as_ref() + } + + /// Update the configuration with a new provider/model. + /// Note: This only updates the config. To apply changes, call set_provider() afterwards. + pub fn update_config(&mut self, provider: String, model: Option) { + if let Some(ref mut config) = self.config { + config.provider = provider; + config.model = model; + } + } + /// Get streaming buffer preview (partial line being typed). pub fn streaming_buffer(&self) -> &str { self.stream_controller diff --git a/codi-rs/src/tui/commands.rs b/codi-rs/src/tui/commands.rs index 0f312b5..df4d188 100644 --- a/codi-rs/src/tui/commands.rs +++ b/codi-rs/src/tui/commands.rs @@ -584,15 +584,38 @@ fn handle_compact(app: &mut App, args: &str) -> CommandResult { match args { "status" | "" => { // Show current context status + let msg_count = app.messages.len(); + let summary_status = if app.has_conversation_summary() { + " (with summary)" + } else { + "" + }; + app.status = Some(format!( - "Context: {} messages", - app.messages.len() + "Context: {} messages{}", + msg_count, summary_status )); CommandResult::Ok } "summarize" => { - // TODO: Implement context summarization - app.status = Some("Context summarization not yet implemented".to_string()); + // Trigger context summarization + let summarized = app.compact_conversation(); + if summarized > 0 { + let remaining = app.messages.len(); + app.status = Some(format!( + "Context summarized: {} older messages condensed, {} messages retained", + summarized, remaining + )); + } else if !app.has_agent() { + app.status = Some("No agent available to summarize context".to_string()); + } else if app.messages.len() <= 10 { + app.status = Some(format!( + "Not enough messages to summarize ({} messages, need > 10)", + app.messages.len() + )); + } else { + app.status = Some("Context already summarized".to_string()); + } CommandResult::Ok } _ => { @@ -614,19 +637,102 @@ fn handle_model(app: &mut App, args: &str) -> CommandResult { } CommandResult::Ok } else { - // TODO: Implement model switching - app.status = Some(format!("Model switching not yet implemented. Requested: {}", args)); - CommandResult::Ok + // Parse provider and optional model + let parts: Vec<&str> = args.splitn(2, ' ').collect(); + let provider = parts[0].to_lowercase(); + let model = parts.get(1).map(|m| m.to_string()); + + // Validate provider + match provider.as_str() { + "anthropic" | "claude" | "openai" | "gpt" | "ollama" => { + // Update config + app.update_config(provider.clone(), model.clone()); + + let model_msg = model.as_ref().map(|m| format!(" with model {}", m)) + .unwrap_or_else(|| " with default model".to_string()); + + app.status = Some(format!("Switched to {}{}. Provider will be updated on next message.", provider, model_msg)); + CommandResult::Ok + } + _ => { + app.status = Some(format!("Unknown provider: {}. Valid providers: anthropic, openai, ollama", provider)); + CommandResult::Error(format!("Unknown provider: {}", provider)) + } + } } } /// Handle /models command - list available models. fn handle_models(app: &mut App) -> CommandResult { - // TODO: Implement model listing - app.status = Some("Model listing not yet implemented".to_string()); + use crate::providers::{get_available_models, is_provider_available}; + + let models = get_available_models(); + let current_info = app.get_current_model_info(); + + let mut output = Vec::new(); + output.push("Available Models:\n".to_string()); + + // Group by provider + let mut current_provider: Option = None; + for model in &models { + // New provider section + if current_provider.as_ref() != Some(&model.provider.to_string()) { + current_provider = Some(model.provider.to_string()); + output.push(format!("\n{}:", model.provider.to_uppercase())); + + // Show availability + if is_provider_available(model.provider) { + output.push(" ✓ (configured)".to_string()); + } else { + output.push(" ⚠ (not configured)".to_string()); + } + } + + // Mark current model + let is_current = current_info.as_ref() + .map(|(p, m)| { + let current_model_lower = m.as_ref().map(|m| m.to_lowercase()); + p.to_lowercase() == model.provider.to_lowercase() && + current_model_lower.map(|cm| cm == model.model_id.to_lowercase()) + .unwrap_or(false) + }) + .unwrap_or(false); + + let marker = if is_current { "→ " } else { " " }; + + output.push(format!( + "{}{} ({}): {} [context: {}]", + marker, + model.name, + model.model_id, + model.description, + format_context_window(model.context_window) + )); + + // Show capabilities + let mut caps = Vec::new(); + if model.supports_tools { caps.push("tools"); } + if model.supports_vision { caps.push("vision"); } + if !caps.is_empty() { + output.push(format!(" Supports: {}", caps.join(", "))); + } + } + + app.status = Some(output.join("\n")); CommandResult::Ok } +/// Format context window size for display. +fn format_context_window(tokens: u32) -> String { + if tokens >= 1_000_000 { + format!("{}M", tokens / 1_000_000) + } else if tokens >= 1_000 { + format!("{}k", tokens / 1_000) + } else { + tokens.to_string() + } +} + /// Handle /session commands. fn handle_session(app: &mut App, args: &str) -> CommandResult { let parts: Vec<&str> = args.splitn(2, ' ').collect(); diff --git a/codi-rs/src/tui/mod.rs b/codi-rs/src/tui/mod.rs index 3712b8b..4290b3c 100644 --- a/codi-rs/src/tui/mod.rs +++ b/codi-rs/src/tui/mod.rs @@ -44,6 +44,7 @@ pub mod input; pub mod search; pub mod streaming; pub mod syntax; +pub mod terminal_ui; pub mod ui; pub use app::{App, AppMode, Message as ChatMessage, build_system_prompt_from_config}; @@ -53,7 +54,7 @@ pub use search::{SearchResult, SearchState, SearchableContent}; pub use streaming::{MarkdownStreamCollector, StreamController, StreamState, StreamStatus}; pub use syntax::{HighlightType, SupportedLanguage, SyntaxHighlighter, Theme}; -use std::io; +use std::io::{self, IsTerminal}; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, execute, @@ -63,6 +64,14 @@ use ratatui::prelude::*; /// Initialize the terminal for TUI mode. pub fn init_terminal() -> io::Result>> { + // Check if we have a proper TTY + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err(io::Error::new( + io::ErrorKind::Other, + "No TTY available. Codi requires an interactive terminal. Try running without input/output redirection." + )); + } + enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; diff --git a/codi-rs/src/tui/terminal_ui.rs b/codi-rs/src/tui/terminal_ui.rs new file mode 100644 index 0000000..03ff0ab --- /dev/null +++ b/codi-rs/src/tui/terminal_ui.rs @@ -0,0 +1,402 @@ +// Copyright 2026 Layne Penney +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Terminal-style UI for Codi. +//! +//! This module provides a traditional terminal interface (like a shell REPL) +//! instead of a full-screen TUI. It behaves like a normal terminal with: +//! - Scrollable history (you can scroll up to see previous output) +//! - Standard line input with visible typing +//! - No alternate screen mode +//! - Normal terminal behavior + +use std::io::{self, Write}; +use std::sync::Arc; +use std::time::Instant; + +use crossterm::{ + style::{Color, Print, ResetColor, SetForegroundColor}, + ExecutableCommand, +}; + +use crate::agent::{AgentCallbacks, AgentConfig, AgentOptions, TurnStats}; +use crate::config::ResolvedConfig; +use crate::providers::create_provider_from_config; +use crate::tools::ToolRegistry; + +use super::app::App; +use super::commands::{execute_async_command, handle_command, CommandResult}; + +/// Run the terminal-style REPL. +pub async fn run_terminal_repl( + config: &ResolvedConfig, + auto_approve: bool, +) -> anyhow::Result<()> { + // Print welcome banner + print_welcome(config)?; + + // Check if debug mode is enabled via environment variable + let debug_mode = std::env::var("CODI_DEBUG").is_ok(); + if debug_mode { + println!("{} Debug mode enabled - tool calls will be shown", "⚙".yellow()); + println!(); + } + + // Create app state + let mut app = TerminalApp::new(config.clone(), auto_approve, debug_mode).await?; + + // Main loop + loop { + // Get input from user with visible prompt + let input = get_input_with_prompt()?; + + if input.trim().is_empty() { + continue; + } + + let trimmed = input.trim(); + + // Handle commands + if trimmed.starts_with('/') { + match handle_command(&mut app.app, trimmed) { + CommandResult::Ok => { + // Check for debug toggle command + if trimmed == "/debug" { + app.debug_mode = !app.debug_mode; + if app.debug_mode { + println!("{} Debug mode enabled - tool calls will be shown", "⚙".yellow()); + } else { + println!("{} Debug mode disabled", "⚙".yellow()); + } + continue; + } + + // Check if we should exit + if app.app.should_quit { + println!("Goodbye!"); + break; + } + } + CommandResult::Async(cmd) => { + // Execute async command and handle result + match execute_async_command(&mut app.app, cmd).await { + CommandResult::Ok => { + if app.app.should_quit { + println!("Goodbye!"); + break; + } + } + CommandResult::Error(msg) => { + eprintln!("Error: {}", msg); + } + _ => {} + } + } + CommandResult::Prompt(prompt) => { + // Send prompt to AI + if let Err(e) = app.send_message(&prompt).await { + eprintln!("Error: {}", e); + } + } + CommandResult::Error(msg) => { + eprintln!("Error: {}", msg); + } + } + } else { + // Regular chat message + if let Err(e) = app.send_message(trimmed).await { + eprintln!("Error: {}", e); + } + } + } + + Ok(()) +} + +/// Get input with a prompt, handling visible typing +fn get_input_with_prompt() -> anyhow::Result { + use std::io::{self, Write}; + + // Print prompt + let mut stdout = io::stdout(); + stdout.execute(SetForegroundColor(Color::Cyan))?; + stdout.execute(Print("› "))?; + stdout.execute(ResetColor)?; + stdout.flush()?; + + // Read line - this shows visible typing + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + Ok(input) +} + +/// Terminal app wrapper. +pub struct TerminalApp { + pub app: App, + pub config: ResolvedConfig, + pub tool_registry: Arc, + pub debug_mode: bool, +} + +impl TerminalApp { + pub async fn new(config: ResolvedConfig, auto_approve: bool, debug_mode: bool) -> anyhow::Result { + let mut app = App::with_project_path(std::env::current_dir()?); + + app.set_config(config.clone()); + app.set_auto_approve(auto_approve); + + let tool_registry = Arc::new(ToolRegistry::with_defaults()); + + Ok(Self { + app, + config, + tool_registry, + debug_mode, + }) + } + + pub async fn send_message( + &mut self, + content: &str, + ) -> anyhow::Result<()> { + // Print user message with prefix + print_user_message(content); + + // Create provider fresh each time (can't clone it) + let provider = create_provider_from_config(&self.config)?; + + // Setup agent callbacks for streaming + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + + // Clone content for the spawned task + let content_owned = content.to_string(); + + let callbacks = if self.debug_mode { + AgentCallbacks { + on_text: Some(Arc::new({ + let tx = tx.clone(); + move |text: &str| { + let _ = tx.send(StreamEvent::Text(text.to_string())); + } + })), + on_tool_call: Some(Arc::new({ + let tx = tx.clone(); + move |_id: &str, name: &str, input: &serde_json::Value| { + let _ = tx.send(StreamEvent::ToolStart(name.to_string(), input.clone())); + } + })), + on_tool_result: Some(Arc::new({ + let tx = tx.clone(); + move |_id: &str, _name: &str, result: &str, is_error: bool| { + let _ = tx.send(StreamEvent::ToolResult(result.to_string(), is_error)); + } + })), + on_turn_complete: Some(Arc::new({ + let tx = tx.clone(); + move |stats: &TurnStats| { + let _ = tx.send(StreamEvent::TurnComplete(stats.clone())); + } + })), + ..Default::default() + } + } else { + AgentCallbacks { + on_text: Some(Arc::new({ + let tx = tx.clone(); + move |text: &str| { + let _ = tx.send(StreamEvent::Text(text.to_string())); + } + })), + on_tool_call: None, + on_tool_result: None, + on_turn_complete: Some(Arc::new({ + let tx = tx.clone(); + move |stats: &TurnStats| { + let _ = tx.send(StreamEvent::TurnComplete(stats.clone())); + } + })), + ..Default::default() + } + }; + + // Create agent config + let agent_config = AgentConfig { + use_tools: true, + auto_approve_all: self.app.auto_approve_all(), + ..Default::default() + }; + + // Create agent + let mut agent = crate::agent::Agent::new(AgentOptions { + provider, + tool_registry: self.tool_registry.clone(), + system_prompt: None, + config: agent_config, + callbacks, + }); + + // Run chat in background with owned content + let chat_handle = tokio::spawn(async move { + agent.chat(&content_owned).await + }); + + // Print assistant prefix + print_assistant_start(); + + // Stream output + let mut stdout = io::stdout(); + let start_time = Instant::now(); + let mut in_tool_call = false; + + while let Some(event) = rx.recv().await { + match event { + StreamEvent::Text(text) => { + if in_tool_call { + println!(); + print_assistant_start(); + in_tool_call = false; + } + print!("{}", text); + stdout.flush()?; + } + StreamEvent::ToolStart(name, _input) => { + in_tool_call = true; + println!(); + print_tool_start(&name); + } + StreamEvent::ToolResult(_result, is_error) => { + if is_error { + print_tool_error(); + } else { + print_tool_success(); + } + } + StreamEvent::TurnComplete(_stats) => { + break; + } + } + } + + // Wait for chat to complete + let _ = chat_handle.await?; + + // Print elapsed time + let elapsed = start_time.elapsed(); + if elapsed.as_secs() > 0 { + print_elapsed(elapsed.as_secs_f64()); + } + + println!(); // Final newline + + Ok(()) + } +} + +#[derive(Debug)] +enum StreamEvent { + Text(String), + ToolStart(String, serde_json::Value), + ToolResult(String, bool), + TurnComplete(TurnStats), +} + +fn print_welcome(config: &ResolvedConfig) -> anyhow::Result<()> { + use std::io::{self, Write}; + + let mut stdout = io::stdout(); + stdout.execute(SetForegroundColor(Color::Cyan))?; + stdout.execute(Print("╭─────────────────────────────────────╮\n"))?; + stdout.execute(Print("│ Codi - AI Coding Wingman │\n"))?; + stdout.execute(Print("╰─────────────────────────────────────╯\n"))?; + stdout.execute(ResetColor)?; + + writeln!(stdout, "Model: {}", config.provider)?; + if let Some(ref model) = config.model { + writeln!(stdout, " → {}", model)?; + } + writeln!(stdout)?; + writeln!(stdout, "Type /help for commands, /debug to toggle tool visibility, or just start chatting!")?; + writeln!(stdout)?; + stdout.flush()?; + + Ok(()) +} + +fn print_user_message(content: &str) { + use crossterm::style::{Color, Print, ResetColor, SetForegroundColor}; + use crossterm::ExecutableCommand; + use std::io::{self, Write}; + + let mut stdout = io::stdout(); + let _ = stdout.execute(SetForegroundColor(Color::Cyan)); + let _ = stdout.execute(Print("› ")); + let _ = stdout.execute(ResetColor); + let _ = stdout.execute(Print(content)); + let _ = stdout.execute(Print("\n")); + let _ = stdout.flush(); +} + +fn print_assistant_start() { + use crossterm::style::{Color, Print, ResetColor, SetForegroundColor}; + use crossterm::ExecutableCommand; + use std::io::{self, Write}; + + let mut stdout = io::stdout(); + let _ = stdout.execute(SetForegroundColor(Color::Grey)); + let _ = stdout.execute(Print("• ")); + let _ = stdout.execute(ResetColor); + let _ = stdout.flush(); +} + +fn print_tool_start(name: &str) { + use crossterm::style::{Color, Print, ResetColor, SetForegroundColor}; + use crossterm::ExecutableCommand; + use std::io::{self, Write}; + + let mut stdout = io::stdout(); + let _ = stdout.execute(SetForegroundColor(Color::Yellow)); + let _ = stdout.execute(Print(format!("◐ Running: {}...", name))); + let _ = stdout.execute(ResetColor); + let _ = stdout.flush(); +} + +fn print_tool_success() { + use crossterm::style::{Color, Print, ResetColor, SetForegroundColor}; + use crossterm::ExecutableCommand; + use std::io::{self, Write}; + + let mut stdout = io::stdout(); + let _ = stdout.execute(Print("\r")); + let _ = stdout.execute(SetForegroundColor(Color::Green)); + let _ = stdout.execute(Print("✓ Completed")); + let _ = stdout.execute(ResetColor); + let _ = stdout.execute(Print("\n")); + let _ = stdout.flush(); +} + +fn print_tool_error() { + use crossterm::style::{Color, Print, ResetColor, SetForegroundColor}; + use crossterm::ExecutableCommand; + use std::io::{self, Write}; + + let mut stdout = io::stdout(); + let _ = stdout.execute(Print("\r")); + let _ = stdout.execute(SetForegroundColor(Color::Red)); + let _ = stdout.execute(Print("✗ Failed")); + let _ = stdout.execute(ResetColor); + let _ = stdout.execute(Print("\n")); + let _ = stdout.flush(); +} + +fn print_elapsed(seconds: f64) { + use crossterm::style::{Color, Print, ResetColor, SetForegroundColor}; + use crossterm::ExecutableCommand; + use std::io::{self, Write}; + + let mut stdout = io::stdout(); + let _ = stdout.execute(SetForegroundColor(Color::DarkGrey)); + let _ = stdout.execute(Print(format!(" ({:.1}s)", seconds))); + let _ = stdout.execute(ResetColor); + let _ = stdout.flush(); +} diff --git a/codi-rs/src/tui/ui.rs b/codi-rs/src/tui/ui.rs index 37db898..82669a5 100644 --- a/codi-rs/src/tui/ui.rs +++ b/codi-rs/src/tui/ui.rs @@ -16,7 +16,7 @@ use crate::types::Role; use super::app::{App, AppMode}; use super::components::ExecCellWidget; -/// Draw the main UI. +/// Draw the main UI with Codex-style layout. pub fn draw(f: &mut Frame, app: &App) { let has_exec_cells = !app.exec_cells.cells().is_empty(); @@ -28,7 +28,7 @@ pub fn draw(f: &mut Frame, app: &App) { .constraints([ Constraint::Min(3), // Messages area Constraint::Length(exec_height), // Tool execution cells - Constraint::Length(3), // Input area + Constraint::Length(1), // Input area (minimal height) Constraint::Length(1), // Status bar ]) .split(f.area()) @@ -37,7 +37,7 @@ pub fn draw(f: &mut Frame, app: &App) { .direction(Direction::Vertical) .constraints([ Constraint::Min(3), // Messages area - Constraint::Length(3), // Input area + Constraint::Length(1), // Input area (minimal height) Constraint::Length(1), // Status bar ]) .split(f.area()) @@ -62,7 +62,7 @@ pub fn draw(f: &mut Frame, app: &App) { } } -/// Draw the messages area. +/// Draw the messages area with Codex-style rendering. fn draw_messages(f: &mut Frame, app: &App, area: Rect) { let block = Block::default() .borders(Borders::ALL) @@ -79,47 +79,75 @@ fn draw_messages(f: &mut Frame, app: &App, area: Rect) { let mut lines: Vec = Vec::new(); for msg in &app.messages { - let (prefix, style) = match msg.role { - Role::User => ("You: ", Style::default().fg(Color::Green)), - Role::Assistant => ("Codi: ", Style::default().fg(Color::Blue)), - Role::System => ("System: ", Style::default().fg(Color::Yellow)), + // Codex-style prefixes: › for user, • for assistant + let (prefix, prefix_style, content_style) = match msg.role { + Role::User => ( + "› ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + Style::default(), + ), + Role::Assistant => ("• ", Style::default().fg(Color::Gray), Style::default()), + Role::System => ( + "⚠ ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + Style::default().fg(Color::Yellow), + ), }; - // Add prefix line - lines.push(Line::from(vec![Span::styled( - prefix, - style.add_modifier(Modifier::BOLD), - )])); - // Use pre-rendered lines if available (from streaming) if !msg.rendered_lines.is_empty() { - for line in &msg.rendered_lines { + // First line gets the prefix + let first_line = msg + .rendered_lines + .first() + .cloned() + .unwrap_or_else(|| Line::from("")); + let mut prefixed_spans = vec![Span::styled(prefix, prefix_style)]; + prefixed_spans.extend(first_line.spans.clone()); + lines.push(Line::from(prefixed_spans)); + + // Remaining lines get indentation + for line in msg.rendered_lines.iter().skip(1) { lines.push(line.clone()); } } else { - // Fallback to plain content rendering - for line in msg.content.lines() { - lines.push(Line::from(Span::raw(format!(" {}", line)))); + // Render content line by line with prefix on first line + let content_lines: Vec<&str> = msg.content.lines().collect(); + if !content_lines.is_empty() { + // First line with prefix + let mut prefixed_spans = vec![Span::styled(prefix, prefix_style)]; + prefixed_spans.push(Span::styled(content_lines[0].to_string(), content_style)); + lines.push(Line::from(prefixed_spans)); + + // Remaining lines + for line in content_lines.iter().skip(1) { + lines.push(Line::from(Span::styled(line.to_string(), content_style))); + } } } - // Add streaming indicator + // Add streaming indicator for active messages if msg.streaming { // Show partial buffer if available let buffer = app.streaming_buffer(); if !buffer.is_empty() { lines.push(Line::from(Span::styled( - format!(" {}", buffer), + buffer.to_string(), Style::default().fg(Color::DarkGray), ))); } + // Blinking cursor indicator lines.push(Line::from(Span::styled( - " ▌", + "▌", Style::default().fg(Color::DarkGray), ))); } - // Add blank line between messages + // Add blank line between messages (compact spacing) lines.push(Line::from("")); } @@ -148,42 +176,108 @@ fn draw_messages(f: &mut Frame, app: &App, area: Rect) { f.render_widget(messages, area); } -/// Draw the input area. +/// Draw the input area with Codex-style minimal design. fn draw_input(f: &mut Frame, app: &App, area: Rect) { - let (title, border_style) = match app.mode { - AppMode::Normal => ( - " Input (Enter to send, Esc to quit, /help for commands) ", - Style::default(), - ), - AppMode::Waiting => ( - " Waiting... (Esc to cancel) ", - Style::default().fg(Color::Yellow), - ), - AppMode::Help => (" Help ", Style::default().fg(Color::Cyan)), - AppMode::ConfirmTool => (" Confirm Tool ", Style::default().fg(Color::Red)), + let (_title, border_style) = match app.mode { + AppMode::Normal => ("", Style::default().fg(Color::DarkGray)), + AppMode::Waiting => ("", Style::default().fg(Color::Yellow)), + AppMode::Help => ("", Style::default().fg(Color::Cyan)), + AppMode::ConfirmTool => ("", Style::default().fg(Color::Red)), }; let block = Block::default() - .borders(Borders::ALL) - .title(title) + .borders(Borders::TOP) .border_style(border_style); - let input = Paragraph::new(app.input.as_str()) - .block(block) - .style(Style::default()); + let inner = block.inner(area); + + // Show input with prefix indicator + let input_text = if app.input.is_empty() { + if app.mode == AppMode::Normal { + Line::from(vec![ + Span::styled( + "› ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "Type a message or / for commands...", + Style::default().fg(Color::DarkGray), + ), + ]) + } else { + Line::from("") + } + } else { + let prefix = match app.mode { + AppMode::Normal => "› ", + _ => " ", + }; + Line::from(vec![ + Span::styled( + prefix, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(&app.input), + ]) + }; + + let input = Paragraph::new(input_text).block(block); f.render_widget(input, area); // Show cursor in normal mode if app.mode == AppMode::Normal { - f.set_cursor_position((area.x + 1 + app.cursor_pos as u16, area.y + 1)); + let cursor_x = inner.x + 2 + app.cursor_pos as u16; // +2 for "› " prefix + let cursor_y = inner.y; + f.set_cursor_position((cursor_x, cursor_y)); } } -/// Draw the status bar. +/// Draw the status bar with Codex-style working indicator. fn draw_status(f: &mut Frame, app: &App, area: Rect) { let mut spans: Vec = Vec::new(); - spans.push(Span::styled(" ", Style::default())); + + // Show working indicator when waiting (Codex-style) + if app.mode == AppMode::Waiting { + // Animated spinner characters + let spinner_frames = ["◐", "◓", "◑", "◒"]; + let frame_idx = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + / 250) as usize + % spinner_frames.len(); + let spinner = spinner_frames[frame_idx]; + + spans.push(Span::styled( + format!("{} Working ", spinner), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + + // Show elapsed time if available + if let Some(start_time) = app.turn_start_time { + let elapsed = std::time::Instant::now().duration_since(start_time); + let elapsed_str = format!("({}.{})", elapsed.as_secs(), elapsed.subsec_millis() / 100); + spans.push(Span::styled( + elapsed_str, + Style::default().fg(Color::DarkGray), + )); + spans.push(Span::raw(" ")); + } + + // Show interrupt hint + spans.push(Span::styled( + "Esc to cancel", + Style::default().fg(Color::DarkGray), + )); + spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray))); + } // Show session info if available if let Some(session_status) = app.session_status() { @@ -202,10 +296,13 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) { "No provider configured" } }); - spans.push(Span::styled(status_text, Style::default().fg(Color::Gray))); + + if app.mode != AppMode::Waiting { + spans.push(Span::styled(status_text, Style::default().fg(Color::Gray))); + } // Show turn stats if available and no custom status - if app.status.is_none() { + if app.status.is_none() && app.mode != AppMode::Waiting { if let Some(ref stats) = app.last_turn_stats { spans.push(Span::styled( format!( @@ -217,7 +314,7 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) { } } - let status = Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::DarkGray)); + let status = Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Black)); f.render_widget(status, area); }