diff --git a/analysis/memory.md b/analysis/memory.md index 9aa0294..81b4332 100644 --- a/analysis/memory.md +++ b/analysis/memory.md @@ -1,5 +1,5 @@ # Project Memory -> Updated: 2026-01-19T08:32:03Z | Size: 14.0k chars +> Updated: 2026-01-20T04:10:35Z | Size: 15.8k chars ### Remember Tool Wiring - `crates/g3-core/src/tools/memory.rs` [0..5000] - `execute_remember()`, `get_memory_path()`, `merge_memory()` @@ -252,4 +252,37 @@ let provider = MockProvider::new() let mut registry = ProviderRegistry::new(); registry.register(provider); let agent = Agent::new_for_test(config, NullUiWriter, registry).await?; -``` \ No newline at end of file +``` + +### G3 Status Message Formatting +Centralized formatting for all "g3:" prefixed system status messages. + +- `crates/g3-cli/src/g3_status.rs` + - `G3Status` - static methods for consistent status message formatting + - `Status` enum - `Done`, `Failed`, `Error(String)`, `Custom(String)`, `Resolved`, `Insufficient` + - `progress()` [64..76] - prints "g3: ..." (no newline, stays on same line) + - `progress_ln()` [79..90] - prints "g3: ..." with newline + - `done()` [93..101] - prints bold green "[done]" + - `failed()` [104..111] - prints red "[failed]" + - `error()` [114..122] - prints red "[error: ]" + - `status()` [125..152] - dispatches to appropriate status method + - `complete()` [155..158] - one-shot progress + status + - `info_inline()` [168..178] - ANSI escape to append to previous line + - `format_status()` [181..214] - returns formatted status string + - `resuming()` [227..236] - session resume message with cyan session ID + - `resuming_summary()` [239..248] - resume with "(summary)" note + +### ThinResult Struct +Semantic data for context thinning operations, replacing pre-formatted strings. + +- `crates/g3-core/src/context_window.rs` + - `ThinResult` [16..36] - struct with scope, before/after percentages, counts, chars_saved, had_changes + - `thin_context_with_scope()` [373..450] - returns `ThinResult` instead of `(String, usize)` + - `build_thin_result()` [720..740] - constructs `ThinResult` from operation data + +- `crates/g3-core/src/ui_writer.rs` + - `print_thin_result(&self, result: &ThinResult)` [31] - trait method for UI formatting + +- `crates/g3-cli/src/g3_status.rs` + - `Status::NoChanges` [42] - new status variant for thinning with no changes + - `G3Status::thin_result()` [265..292] - formats ThinResult with proper colors/styling \ No newline at end of file diff --git a/crates/g3-cli/src/g3_status.rs b/crates/g3-cli/src/g3_status.rs new file mode 100644 index 0000000..01065fe --- /dev/null +++ b/crates/g3-cli/src/g3_status.rs @@ -0,0 +1,328 @@ +//! Centralized formatting for g3 system status messages. +//! +//! This module provides consistent formatting for all "g3:" prefixed status messages, +//! including progress indicators, completion statuses, and inline updates. +//! +//! # Usage +//! +//! ```ignore +//! use crate::g3_status::G3Status; +//! +//! // Start a progress message (stays on same line, no newline) +//! G3Status::progress("compacting session"); +//! +//! // Complete with status (adds to same line, then newline) +//! G3Status::done(); +//! // or +//! G3Status::failed(); +//! // or +//! G3Status::error("timeout"); +//! +//! // One-shot status message (progress + status on same line) +//! G3Status::complete("compacting session", Status::Done); +//! ``` + +use crossterm::style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor}; +use std::io::{self, Write}; + +/// Status types for g3 system messages +#[derive(Debug, Clone, PartialEq)] +pub enum Status { + /// Success - bold green "[done]" + Done, + /// Failure - red "[failed]" + Failed, + /// Error with message - red "[error: ]" + Error(String), + /// Custom status - plain "[]" + Custom(String), + /// Resolved status - for thinning operations + Resolved, + /// Insufficient - for thinning operations + Insufficient, + /// No changes - for thinning operations that didn't modify anything + NoChanges, +} + +impl Status { + /// Parse a status string into a Status enum + pub fn from_str(s: &str) -> Self { + match s { + "done" => Status::Done, + "failed" => Status::Failed, + "resolved" => Status::Resolved, + "insufficient" => Status::Insufficient, + s if s.starts_with("error:") => Status::Error(s[6..].trim().to_string()), + s if s.starts_with("error") => Status::Error(s[5..].trim().to_string()), + other => Status::Custom(other.to_string()), + } + } +} + +/// Centralized g3 system status message formatting +pub struct G3Status; + +impl G3Status { + /// Print a progress message that stays on the same line. + /// Format: "g3: ..." + /// - "g3:" is bold green + /// - No trailing newline (use `done()`, `failed()`, etc. to complete) + pub fn progress(message: &str) { + print!( + "{}{}g3:{}{} {} ...", + SetAttribute(Attribute::Bold), + SetForegroundColor(Color::Green), + ResetColor, + SetAttribute(Attribute::Reset), + message + ); + let _ = io::stdout().flush(); + } + + /// Print a progress message with a newline at the end. + /// Use this when you don't plan to add a status on the same line. + pub fn progress_ln(message: &str) { + println!( + "{}{}g3:{}{} {} ...", + SetAttribute(Attribute::Bold), + SetForegroundColor(Color::Green), + ResetColor, + SetAttribute(Attribute::Reset), + message + ); + } + + /// Complete a progress message with "[done]" in bold green. + pub fn done() { + println!( + " {}{}[done]{}", + SetForegroundColor(Color::Green), + SetAttribute(Attribute::Bold), + ResetColor + ); + } + + /// Complete a progress message with "[failed]" in red. + pub fn failed() { + println!( + " {}[failed]{}", + SetForegroundColor(Color::Red), + ResetColor + ); + } + + /// Complete a progress message with "[error: ]" in red. + pub fn error(msg: &str) { + println!( + " {}[error: {}]{}", + SetForegroundColor(Color::Red), + msg, + ResetColor + ); + } + + /// Complete a progress message with a custom status. + pub fn status(status: &Status) { + match status { + Status::Done => Self::done(), + Status::Failed => Self::failed(), + Status::Error(msg) => Self::error(msg), + Status::Resolved => { + println!( + " {}{}[resolved]{}", + SetForegroundColor(Color::Green), + SetAttribute(Attribute::Bold), + ResetColor + ); + } + Status::Insufficient => { + println!( + " {}[insufficient]{}", + SetForegroundColor(Color::Yellow), + ResetColor + ); + } + Status::Custom(s) => { + println!(" [{}]", s); + } + Status::NoChanges => { + println!( + " {}[no changes]{}", + SetForegroundColor(Color::DarkGrey), + ResetColor + ); + } + } + } + + /// Print a complete status message (progress + status) on one line. + /// Format: "g3: ... [status]" + pub fn complete(message: &str, status: Status) { + Self::progress(message); + Self::status(&status); + } + + /// Print an info message in dimmed/grey text. + /// Format: "... " + pub fn info(message: &str) { + println!( + "{}... {}{}", + SetForegroundColor(Color::DarkGrey), + message, + ResetColor + ); + } + + /// Print an info message inline (no newline, for appending to user input). + /// Uses ANSI escape to move cursor up and to end of previous line. + pub fn info_inline(message: &str) { + // Move cursor up one line, to end of line, then print + print!( + "\x1b[1A\x1b[999C {}... {}{}\n", + SetForegroundColor(Color::DarkGrey), + message, + ResetColor + ); + let _ = io::stdout().flush(); + } + + /// Format a status string for inline use (returns the formatted string). + /// Useful when building custom messages. + pub fn format_status(status: &Status) -> String { + match status { + Status::Done => format!( + "{}{}[done]{}", + SetForegroundColor(Color::Green), + SetAttribute(Attribute::Bold), + ResetColor + ), + Status::Failed => format!( + "{}[failed]{}", + SetForegroundColor(Color::Red), + ResetColor + ), + Status::Error(msg) => format!( + "{}[error: {}]{}", + SetForegroundColor(Color::Red), + msg, + ResetColor + ), + Status::Resolved => format!( + "{}{}[resolved]{}", + SetForegroundColor(Color::Green), + SetAttribute(Attribute::Bold), + ResetColor + ), + Status::Insufficient => format!( + "{}[insufficient]{}", + SetForegroundColor(Color::Yellow), + ResetColor + ), + Status::Custom(s) => format!("[{}]", s), + Status::NoChanges => format!( + "{}[no changes]{}", + SetForegroundColor(Color::DarkGrey), + ResetColor + ), + } + } + + /// Format the "g3:" prefix for inline use. + pub fn format_prefix() -> String { + format!( + "{}{}g3:{}{}", + SetAttribute(Attribute::Bold), + SetForegroundColor(Color::Green), + ResetColor, + SetAttribute(Attribute::Reset), + ) + } + + /// Print a resuming session message with session ID highlighted. + /// Format: "... resuming [done/error]" + pub fn resuming(session_id: &str, status: Status) { + let status_str = Self::format_status(&status); + println!( + "... resuming {}{}{} {}", + SetForegroundColor(Color::Cyan), + session_id, + ResetColor, + status_str + ); + } + + /// Print a resuming session message with "(summary)" note. + pub fn resuming_summary(session_id: &str) { + let status_str = Self::format_status(&Status::Done); + println!( + "... resuming {}{}{} (summary) {}", + SetForegroundColor(Color::Cyan), + session_id, + ResetColor, + status_str + ); + } + + /// Print a context thinning result. + /// Format: "g3: thinning context ... 70% -> 40% ... [done]" + /// or: "g3: thinning context (full) ... 70% ... [no changes]" + pub fn thin_result(result: &g3_core::ThinResult) { + use g3_core::ThinScope; + + let scope_desc = match result.scope { + ThinScope::FirstThird => "thinning context", + ThinScope::All => "thinning context (full)", + }; + + if result.had_changes { + // Format: "g3: thinning context ... 70% -> 40% ... [done]" + print!( + "{} {} ... {}% -> {}% ...", + Self::format_prefix(), + scope_desc, + result.before_percentage, + result.after_percentage + ); + Self::done(); + } else { + // Format: "g3: thinning context ... 70% ... [no changes]" + Self::complete(&format!("{} ... {}%", scope_desc, result.before_percentage), Status::NoChanges); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_status_from_str() { + assert_eq!(Status::from_str("done"), Status::Done); + assert_eq!(Status::from_str("failed"), Status::Failed); + assert_eq!(Status::from_str("resolved"), Status::Resolved); + assert_eq!(Status::from_str("insufficient"), Status::Insufficient); + assert_eq!(Status::from_str("error: timeout"), Status::Error("timeout".to_string())); + assert_eq!(Status::from_str("error timeout"), Status::Error("timeout".to_string())); + assert_eq!(Status::from_str("custom"), Status::Custom("custom".to_string())); + } + + #[test] + fn test_format_status_contains_ansi() { + let done = G3Status::format_status(&Status::Done); + assert!(done.contains("[done]")); + assert!(done.contains("\x1b")); // Contains ANSI escape + + let failed = G3Status::format_status(&Status::Failed); + assert!(failed.contains("[failed]")); + + let error = G3Status::format_status(&Status::Error("test".to_string())); + assert!(error.contains("[error: test]")); + } + + #[test] + fn test_format_prefix() { + let prefix = G3Status::format_prefix(); + assert!(prefix.contains("g3:")); + assert!(prefix.contains("\x1b")); // Contains ANSI escape + } +} diff --git a/crates/g3-cli/src/interactive.rs b/crates/g3-cli/src/interactive.rs index 7fc42db..36cce18 100644 --- a/crates/g3-cli/src/interactive.rs +++ b/crates/g3-cli/src/interactive.rs @@ -10,6 +10,7 @@ use tracing::{debug, error}; use g3_core::ui_writer::UiWriter; use g3_core::Agent; +use crate::g3_status::{G3Status, Status}; use crate::project_files::extract_readme_heading; use crate::simple_output::SimpleOutput; use crate::task_execution::execute_task_with_retry; @@ -54,21 +55,20 @@ pub async fn run_interactive( // Resume the session match agent.restore_from_continuation(&continuation) { Ok(true) => { - // Print bold green [done] - println!("{}... resuming ... [done]{}", SetForegroundColor(Color::Green), ResetColor); + G3Status::resuming(&continuation.session_id, Status::Done); } Ok(false) => { - println!("{}... resuming ... [done]{}", SetForegroundColor(Color::Green), ResetColor); + G3Status::resuming_summary(&continuation.session_id); } Err(e) => { - println!("{}... failed: {}{}", SetForegroundColor(Color::Yellow), e, ResetColor); + G3Status::resuming(&continuation.session_id, Status::Error(e.to_string())); // Clear the invalid continuation let _ = g3_core::clear_continuation(); } } } else { // User declined, clear the continuation - println!("{}... starting fresh{}", SetForegroundColor(Color::DarkGrey), ResetColor); + G3Status::info_inline("starting fresh"); let _ = g3_core::clear_continuation(); } } @@ -371,13 +371,13 @@ async fn handle_command( Ok(true) } "/thinnify" => { - let summary = agent.force_thin(); - println!("{}", summary); + let result = agent.force_thin(); + G3Status::thin_result(&result); Ok(true) } "/skinnify" => { - let summary = agent.force_thin_all(); - println!("{}", summary); + let result = agent.force_thin_all(); + G3Status::thin_result(&result); Ok(true) } "/fragments" => { @@ -566,22 +566,13 @@ async fn handle_command( let selected = &sessions[num - 1]; match agent.switch_to_session(selected) { Ok(true) => { - output.print_inline(&format!( - "... resuming \x1b[36m{}\x1b[0m \x1b[1;32m[done]\x1b[0m\n", - selected.session_id - )); + G3Status::resuming(&selected.session_id, Status::Done); } Ok(false) => { - output.print_inline(&format!( - "... resuming \x1b[36m{}\x1b[0m (summary) \x1b[1;32m[done]\x1b[0m\n", - selected.session_id - )); + G3Status::resuming_summary(&selected.session_id); } Err(e) => { - output.print_inline(&format!( - "... resuming \x1b[36m{}\x1b[0m \x1b[1;31m[error: {}]\x1b[0m\n", - selected.session_id, e - )); + G3Status::resuming(&selected.session_id, Status::Error(e.to_string())); } } } else { diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 687dd5c..313c1ea 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -17,6 +17,7 @@ mod simple_output; mod task_execution; mod ui_writer_impl; mod utils; +mod g3_status; use anyhow::Result; use std::path::PathBuf; diff --git a/crates/g3-cli/src/simple_output.rs b/crates/g3-cli/src/simple_output.rs index c242a1d..66377c7 100644 --- a/crates/g3-cli/src/simple_output.rs +++ b/crates/g3-cli/src/simple_output.rs @@ -1,4 +1,4 @@ -use crossterm::style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor}; +use crate::g3_status::{G3Status, Status}; /// Simple output helper for printing messages #[derive(Clone)] @@ -25,45 +25,16 @@ impl SimpleOutput { /// Print a g3 status message with colored tag and status /// Format: "g3: ... [status]" - /// - "g3:" is bold green - /// - "done" status is normal - /// - "failed" and "error" statuses are red + /// Uses centralized G3Status formatting. pub fn print_g3_status(&self, message: &str, status: &str) { - let status_colored = match status { - s if s.starts_with("error") || s == "failed" => { - format!( - "{}[{}]{}", - SetForegroundColor(Color::Red), - status, - ResetColor - ) - } - _ => format!("[{}]", status), - }; - - println!( - "{}{}g3:{}{} {} ... {}", - SetAttribute(Attribute::Bold), - SetForegroundColor(Color::Green), - ResetColor, - SetAttribute(Attribute::Reset), - message, - status_colored - ); + G3Status::complete(message, Status::from_str(status)); } /// Print a g3 status message in progress (no status yet) /// Format: "g3: ..." - /// - "g3:" is bold green + /// Uses centralized G3Status formatting. pub fn print_g3_progress(&self, message: &str) { - println!( - "{}{}g3:{}{} {} ...", - SetAttribute(Attribute::Bold), - SetForegroundColor(Color::Green), - ResetColor, - SetAttribute(Attribute::Reset), - message - ); + G3Status::progress_ln(message); } } diff --git a/crates/g3-cli/src/ui_writer_impl.rs b/crates/g3-cli/src/ui_writer_impl.rs index 41db99c..0776478 100644 --- a/crates/g3-cli/src/ui_writer_impl.rs +++ b/crates/g3-cli/src/ui_writer_impl.rs @@ -212,38 +212,18 @@ impl UiWriter for ConsoleUiWriter { } fn print_g3_progress(&self, message: &str) { - use crossterm::style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor}; - use std::io::Write; - print!( - "{}{}g3:{}{} {} ...", - SetAttribute(Attribute::Bold), - SetForegroundColor(Color::Green), - ResetColor, - SetAttribute(Attribute::Reset), - message - ); - let _ = std::io::stdout().flush(); + crate::g3_status::G3Status::progress(message); } fn print_g3_status(&self, message: &str, status: &str) { - use crossterm::style::{Color, ResetColor, SetForegroundColor}; + use crate::g3_status::Status; let _ = message; // unused now - progress already printed the message - let status_colored = if status.starts_with("error") || status == "failed" { - format!( - " {}[{}]{}", - SetForegroundColor(Color::Red), - status, - ResetColor - ) - } else { - format!(" [{}]", status) - }; - println!("{}", status_colored); + crate::g3_status::G3Status::status(&Status::from_str(status)); } - fn print_context_thinning(&self, message: &str) { - // Simple status line output - message already contains ANSI formatting - println!("{}", message); + fn print_thin_result(&self, result: &g3_core::ThinResult) { + // Use centralized G3Status formatting + crate::g3_status::G3Status::thin_result(result); } fn print_tool_header(&self, tool_name: &str, _tool_args: Option<&serde_json::Value>) { diff --git a/crates/g3-core/src/compaction.rs b/crates/g3-core/src/compaction.rs index 66d461f..361047b 100644 --- a/crates/g3-core/src/compaction.rs +++ b/crates/g3-core/src/compaction.rs @@ -234,9 +234,9 @@ fn apply_summary_fallback_sequence( // Step 1: Try thinnify (first third of context) ui_writer.print_context_status("🥒 Step 1: Trying thinnify...\n"); - let (thin_msg, chars_saved) = context_window.thin_context(None); - thinning_events.push(chars_saved); - ui_writer.print_context_thinning(&thin_msg); + let thin_result = context_window.thin_context(None); + thinning_events.push(thin_result.chars_saved); + ui_writer.print_thin_result(&thin_result); // Recalculate after thinnify let (new_max, still_needs_reduction) = provider_config::calculate_summary_max_tokens( @@ -253,9 +253,9 @@ fn apply_summary_fallback_sequence( // Step 2: Try skinnify (entire context) ui_writer.print_context_status("🦴 Step 2: Trying skinnify...\n"); - let (skinny_msg, chars_saved) = context_window.thin_context_all(None); - thinning_events.push(chars_saved); - ui_writer.print_context_thinning(&skinny_msg); + let skinny_result = context_window.thin_context_all(None); + thinning_events.push(skinny_result.chars_saved); + ui_writer.print_thin_result(&skinny_result); // Recalculate after skinnify let (final_max, final_needs_reduction) = provider_config::calculate_summary_max_tokens( diff --git a/crates/g3-core/src/context_window.rs b/crates/g3-core/src/context_window.rs index d638c82..e0c7cb3 100644 --- a/crates/g3-core/src/context_window.rs +++ b/crates/g3-core/src/context_window.rs @@ -13,6 +13,26 @@ use tracing::{debug, warn}; use crate::paths::get_thinned_dir; use crate::ToolCall; +/// Result of a context thinning operation. +/// Contains semantic data for the UI layer to format. +#[derive(Debug, Clone)] +pub struct ThinResult { + /// Scope of the thinning operation + pub scope: ThinScope, + /// Context percentage before thinning + pub before_percentage: u32, + /// Context percentage after thinning + pub after_percentage: u32, + /// Number of tool result messages that were thinned + pub leaned_count: usize, + /// Number of tool calls in assistant messages that were thinned + pub tool_call_leaned_count: usize, + /// Total characters saved + pub chars_saved: usize, + /// Whether any changes were made + pub had_changes: bool, +} + /// Scope for context thinning operations #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ThinScope { @@ -349,12 +369,12 @@ Format this as a detailed but concise summary that can be used to resume the con /// * `scope` - Controls which messages to process (first third or all) /// /// # Returns - /// A tuple of (summary message, chars saved) + /// A `ThinResult` with semantic data about the operation pub fn thin_context_with_scope( &mut self, session_id: Option<&str>, scope: ThinScope, - ) -> (String, usize) { + ) -> ThinResult { let current_percentage = self.percentage_used() as u32; // Only update last_thinning_percentage for incremental thinning @@ -373,7 +393,17 @@ Format this as a detailed but concise summary that can be used to resume the con // Determine output directory: use session dir if available, otherwise ~/tmp let tmp_dir = match Self::resolve_thinned_dir(session_id, scope) { Ok(dir) => dir, - Err(msg) => return (msg, 0), + Err(_) => { + return ThinResult { + scope, + before_percentage: current_percentage, + after_percentage: current_percentage, + leaned_count: 0, + tool_call_leaned_count: 0, + chars_saved: 0, + had_changes: false, + }; + } }; // Collect modifications to apply (avoids borrow checker issues) @@ -409,7 +439,7 @@ Format this as a detailed but concise summary that can be used to resume the con let new_percentage = self.percentage_used() as u32; // Build result message - self.build_thin_result_message( + self.build_thin_result( scope, current_percentage, new_percentage, @@ -466,13 +496,13 @@ Format this as a detailed but concise summary that can be used to resume the con modifications } - /// Backward-compatible wrapper for thin_context (first third only) - pub fn thin_context(&mut self, session_id: Option<&str>) -> (String, usize) { + /// Thin context (first third only) + pub fn thin_context(&mut self, session_id: Option<&str>) -> ThinResult { self.thin_context_with_scope(session_id, ThinScope::FirstThird) } - /// Backward-compatible wrapper for thin_context_all (entire history) - pub fn thin_context_all(&mut self, session_id: Option<&str>) -> (String, usize) { + /// Thin entire context (all messages) + pub fn thin_context_all(&mut self, session_id: Option<&str>) -> ThinResult { self.thin_context_with_scope(session_id, ThinScope::All) } @@ -697,7 +727,7 @@ Format this as a detailed but concise summary that can be used to resume the con } /// Build the result message for thinning operations - fn build_thin_result_message( + fn build_thin_result( &self, scope: ThinScope, current_percentage: u32, @@ -705,27 +735,17 @@ Format this as a detailed but concise summary that can be used to resume the con leaned_count: usize, tool_call_leaned_count: usize, chars_saved: usize, - ) -> (String, usize) { - // Nothing was thinned - if leaned_count == 0 && tool_call_leaned_count == 0 { - let scope_desc = match scope { - ThinScope::FirstThird => "", - ThinScope::All => " (full)", - }; - let msg = format!( - "\x1b[1;32mg3:\x1b[0m thinning context{} ... {}% ... \x1b[38;5;208m[no changes]\x1b[0m", - scope_desc, current_percentage - ); - return (msg, 0); + ) -> ThinResult { + let had_changes = leaned_count > 0 || tool_call_leaned_count > 0; + ThinResult { + scope, + before_percentage: current_percentage, + after_percentage: new_percentage, + leaned_count, + tool_call_leaned_count, + chars_saved: if had_changes { chars_saved } else { 0 }, + had_changes, } - - // Format: "g3: thinning context ... 70% -> 40% ... [done]" - // with "g3:" and "[done]" in bold green - let msg = format!( - "\x1b[1;32mg3:\x1b[0m thinning context ... {}% -> {}% ... \x1b[1;32m[done]\x1b[0m", - current_percentage, new_percentage - ); - (msg, chars_saved) } /// Recalculate token usage based on current conversation history diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index 9982346..4a1b37c 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -35,7 +35,7 @@ pub use session_continuation::{ pub use task_result::TaskResult; // Re-export context window types -pub use context_window::{ContextWindow, ThinScope}; +pub use context_window::{ContextWindow, ThinResult, ThinScope}; // Export agent prompt generation for CLI use pub use prompts::get_agent_system_prompt; @@ -500,8 +500,8 @@ impl Agent { // Step 1: Try thinnify (first third of context) self.ui_writer .print_context_status("🥒 Step 1: Trying thinnify...\n"); - let thin_msg = self.do_thin_context(); - self.ui_writer.print_context_thinning(&thin_msg); + let thin_result = self.do_thin_context(); + self.ui_writer.print_thin_result(&thin_result); // Recalculate after thinnify let (new_max, still_needs_reduction) = @@ -516,8 +516,8 @@ impl Agent { // Step 2: Try skinnify (entire context) self.ui_writer .print_context_status("🦴 Step 2: Trying skinnify...\n"); - let skinny_msg = self.do_thin_context_all(); - self.ui_writer.print_context_thinning(&skinny_msg); + let skinny_result = self.do_thin_context_all(); + self.ui_writer.print_thin_result(&skinny_result); // Recalculate after skinnify let (final_max, final_needs_reduction) = @@ -1084,32 +1084,32 @@ impl Agent { } } /// Manually trigger context thinning regardless of thresholds - pub fn force_thin(&mut self) -> String { + pub fn force_thin(&mut self) -> ThinResult { debug!("Manual context thinning triggered"); self.do_thin_context() } /// Manually trigger context thinning for the ENTIRE context window /// Unlike force_thin which only processes the first third, this processes all messages - pub fn force_thin_all(&mut self) -> String { + pub fn force_thin_all(&mut self) -> ThinResult { debug!("Manual full context skinnifying triggered"); self.do_thin_context_all() } /// Internal helper: thin context and track the event - fn do_thin_context(&mut self) -> String { - let (message, chars_saved) = self.context_window.thin_context(self.session_id.as_deref()); - self.thinning_events.push(chars_saved); - message + fn do_thin_context(&mut self) -> ThinResult { + let result = self.context_window.thin_context(self.session_id.as_deref()); + self.thinning_events.push(result.chars_saved); + result } /// Internal helper: thin all context and track the event - fn do_thin_context_all(&mut self) -> String { - let (message, chars_saved) = self + fn do_thin_context_all(&mut self) -> ThinResult { + let result = self .context_window .thin_context_all(self.session_id.as_deref()); - self.thinning_events.push(chars_saved); - message + self.thinning_events.push(result.chars_saved); + result } /// Ensure context window has capacity before streaming. @@ -1127,8 +1127,8 @@ impl Agent { self.context_window.percentage_used() as u32 )); - let thin_summary = self.do_thin_context(); - self.ui_writer.print_context_thinning(&thin_summary); + let thin_result = self.do_thin_context(); + self.ui_writer.print_thin_result(&thin_result); if !self.context_window.should_compact() { self.ui_writer.print_g3_status("thinning", "resolved"); @@ -2096,8 +2096,8 @@ Skip if nothing new. Be brief."#; // Thin context if needed before tool execution if self.context_window.should_thin() { - let thin_summary = self.do_thin_context(); - self.ui_writer.print_context_thinning(&thin_summary); + let thin_result = self.do_thin_context(); + self.ui_writer.print_thin_result(&thin_result); } // Calculate new content to display (skip already-shown text) diff --git a/crates/g3-core/src/ui_writer.rs b/crates/g3-core/src/ui_writer.rs index 8dc347a..f186c4e 100644 --- a/crates/g3-core/src/ui_writer.rs +++ b/crates/g3-core/src/ui_writer.rs @@ -27,8 +27,11 @@ pub trait UiWriter: Send + Sync { /// - "g3:" should be bold green, "failed"/"error" status should be red fn print_g3_status(&self, message: &str, status: &str); - /// Print a context thinning success message with highlight and animation - fn print_context_thinning(&self, message: &str); + /// Print a context thinning result + fn print_thin_result(&self, result: &crate::ThinResult); + + /// Print a context thinning message (legacy - for pre-formatted messages) + fn print_context_thinning(&self, _message: &str) {} /// Print a tool execution header fn print_tool_header(&self, tool_name: &str, tool_args: Option<&serde_json::Value>); @@ -136,7 +139,7 @@ impl UiWriter for NullUiWriter { fn print_context_status(&self, _message: &str) {} fn print_g3_progress(&self, _message: &str) {} fn print_g3_status(&self, _message: &str, _status: &str) {} - fn print_context_thinning(&self, _message: &str) {} + fn print_thin_result(&self, _result: &crate::ThinResult) {} fn print_tool_header(&self, _tool_name: &str, _tool_args: Option<&serde_json::Value>) {} fn print_tool_arg(&self, _key: &str, _value: &str) {} fn print_tool_output_header(&self) {} diff --git a/crates/g3-core/tests/streaming_completion_test.rs b/crates/g3-core/tests/streaming_completion_test.rs index 353ff11..9279478 100644 --- a/crates/g3-core/tests/streaming_completion_test.rs +++ b/crates/g3-core/tests/streaming_completion_test.rs @@ -274,7 +274,7 @@ impl UiWriter for TrackingUiWriter { fn print_context_status(&self, _message: &str) {} fn print_g3_progress(&self, _message: &str) {} fn print_g3_status(&self, _message: &str, _status: &str) {} - fn print_context_thinning(&self, _message: &str) {} + fn print_thin_result(&self, _result: &g3_core::ThinResult) {} fn print_tool_header(&self, _tool_name: &str, _tool_args: Option<&serde_json::Value>) { // Count each tool call diff --git a/crates/g3-core/tests/test_context_thinning.rs b/crates/g3-core/tests/test_context_thinning.rs index 3ca2142..6e8e498 100644 --- a/crates/g3-core/tests/test_context_thinning.rs +++ b/crates/g3-core/tests/test_context_thinning.rs @@ -69,14 +69,13 @@ fn test_thin_context_basic() { // Trigger thinning at 50% context.used_tokens = 5000; - let (summary, _chars_saved) = context.thin_context(None); + let result = context.thin_context(None); - println!("Thinning summary: {}", summary); + println!("Thinning result: {:?}", result); - // Should show the new format with percentage change - assert!(summary.contains("g3:"), "Summary was: {}", summary); - assert!(summary.contains("thinning context")); - assert!(summary.contains("[done]")); + // Should have made changes + assert!(result.had_changes, "Expected thinning to make changes"); + assert!(result.chars_saved > 0, "Expected chars to be saved"); // Check that the large tool results were replaced let first_third_end = context.conversation_history.len() / 3; @@ -127,14 +126,13 @@ fn test_thin_write_file_tool_calls() { // Trigger thinning at 50% context.used_tokens = 5000; - let (summary, _chars_saved) = context.thin_context(None); + let result = context.thin_context(None); - println!("Thinning summary: {}", summary); + println!("Thinning result: {:?}", result); - // Should show the new format with percentage change - assert!(summary.contains("g3:")); - assert!(summary.contains("thinning context")); - assert!(summary.contains("[done]")); + // Should have made changes + assert!(result.had_changes, "Expected thinning to make changes"); + assert!(result.chars_saved > 0, "Expected chars to be saved"); // Check that the large content was replaced with a file reference let first_third_end = context.conversation_history.len() / 3; @@ -189,14 +187,13 @@ fn test_thin_str_replace_tool_calls() { // Trigger thinning at 50% context.used_tokens = 5000; - let (summary, _chars_saved) = context.thin_context(None); + let result = context.thin_context(None); - println!("Thinning summary: {}", summary); + println!("Thinning result: {:?}", result); - // Should show the new format with percentage change - assert!(summary.contains("g3:")); - assert!(summary.contains("thinning context")); - assert!(summary.contains("[done]")); + // Should have made changes + assert!(result.had_changes, "Expected thinning to make changes"); + assert!(result.chars_saved > 0, "Expected chars to be saved"); // Check that the large diff was replaced with a file reference let first_third_end = context.conversation_history.len() / 3; @@ -225,12 +222,12 @@ fn test_thin_context_no_large_results() { } context.used_tokens = 5000; - let (summary, _chars_saved) = context.thin_context(None); + let result = context.thin_context(None); - // Should report no large results found - assert!(summary.contains("g3:")); - assert!(summary.contains("thinning context")); - assert!(summary.contains("[no changes]")); + // Should report no changes (no large results found) + assert!(!result.had_changes, "Expected no changes"); + assert_eq!(result.chars_saved, 0, "Expected no chars saved"); + assert_eq!(result.leaned_count, 0, "Expected no messages thinned"); } #[test] @@ -256,11 +253,10 @@ fn test_thin_context_only_affects_first_third() { } context.used_tokens = 5000; - let (summary, _chars_saved) = context.thin_context(None); + let result = context.thin_context(None); - // Should show the new format with percentage change - assert!(summary.contains("g3:")); - assert!(summary.contains("[done]")); + // Should have made changes + assert!(result.had_changes, "Expected thinning to make changes"); // Check that messages after the first third are NOT thinned let first_third_end = context.conversation_history.len() / 3; diff --git a/crates/g3-core/tests/test_todo_context_thinning.rs b/crates/g3-core/tests/test_todo_context_thinning.rs index 3cd4e8f..2778d1e 100644 --- a/crates/g3-core/tests/test_todo_context_thinning.rs +++ b/crates/g3-core/tests/test_todo_context_thinning.rs @@ -30,9 +30,9 @@ fn test_todo_read_results_not_thinned() { // Trigger thinning at 50% context.used_tokens = 5000; - let (summary, _chars_saved) = context.thin_context(None); + let result = context.thin_context(None); - println!("Thinning summary: {}", summary); + println!("Thinning result: {:?}", result); // Check that the TODO result was NOT thinned let first_third_end = context.conversation_history.len() / 3; @@ -87,9 +87,9 @@ fn test_todo_write_results_not_thinned() { // Trigger thinning at 50% context.used_tokens = 5000; - let (summary, _chars_saved) = context.thin_context(None); + let result = context.thin_context(None); - println!("Thinning summary: {}", summary); + println!("Thinning result: {:?}", result); // Check that the TODO write result was NOT thinned let first_third_end = context.conversation_history.len() / 3; @@ -135,14 +135,13 @@ fn test_non_todo_results_still_thinned() { // Trigger thinning at 50% context.used_tokens = 5000; - let (summary, _chars_saved) = context.thin_context(None); + let result = context.thin_context(None); - println!("Thinning summary: {}", summary); + println!("Thinning result: {:?}", result); - // Should show the new format with percentage change (indicating thinning happened) - assert!(summary.contains("g3:"), "Non-TODO results should be thinned"); - assert!(summary.contains("thinning context")); - assert!(summary.contains("[done]")); + // Should have made changes (non-TODO results should be thinned) + assert!(result.had_changes, "Non-TODO results should be thinned"); + assert!(result.chars_saved > 0, "Expected chars to be saved"); // Check that the result was actually thinned let first_third_end = context.conversation_history.len() / 3; @@ -184,7 +183,7 @@ fn test_todo_read_with_spaces_in_tool_name() { // Trigger thinning context.used_tokens = 5000; - let (_summary, _chars_saved) = context.thin_context(None); + let _result = context.thin_context(None); // Verify TODO result was not thinned let first_third_end = context.conversation_history.len() / 3; diff --git a/crates/g3-core/tests/todo_staleness_test.rs b/crates/g3-core/tests/todo_staleness_test.rs index b93a6a1..177a33a 100644 --- a/crates/g3-core/tests/todo_staleness_test.rs +++ b/crates/g3-core/tests/todo_staleness_test.rs @@ -54,7 +54,7 @@ impl UiWriter for MockUiWriter { } fn print_g3_progress(&self, _message: &str) {} fn print_g3_status(&self, _message: &str, _status: &str) {} - fn print_context_thinning(&self, _message: &str) {} + fn print_thin_result(&self, _result: &g3_core::ThinResult) {} fn print_tool_header(&self, _tool_name: &str, _tool_args: Option<&serde_json::Value>) {} fn print_tool_arg(&self, _key: &str, _value: &str) {} fn print_tool_output_header(&self) {} diff --git a/crates/g3-planner/src/llm.rs b/crates/g3-planner/src/llm.rs index 0bbd004..808f352 100644 --- a/crates/g3-planner/src/llm.rs +++ b/crates/g3-planner/src/llm.rs @@ -242,8 +242,16 @@ impl g3_core::ui_writer::UiWriter for PlannerUiWriter { println!("g3: {} ... [{}]", message, status); } - fn print_context_thinning(&self, message: &str) { - println!("🗜️ {}", message); + fn print_thin_result(&self, result: &g3_core::ThinResult) { + // Simple text output for planner + if result.had_changes { + println!( + "🗜️ thinning context ... {}% -> {}% ... [done]", + result.before_percentage, result.after_percentage + ); + } else { + println!("🗜️ thinning context ... {}% ... [no changes]", result.before_percentage); + } } fn print_tool_header(&self, tool_name: &str, tool_args: Option<&serde_json::Value>) {