Centralize g3 status message formatting

Extract a new g3_status module in g3-cli that provides consistent formatting
for all 'g3:' prefixed system status messages.

Key changes:
- Add G3Status struct with methods for progress, done, failed, error, etc.
- Add Status enum with Done, Failed, Error, Resolved, Insufficient, NoChanges
- Add ThinResult struct in g3-core for semantic thinning data
- Update UiWriter trait with print_thin_result() method
- Refactor context thinning to return ThinResult instead of formatted strings
- Update all callers to use the new centralized formatting
- Session resume/decline messages now use G3Status
- Compaction status messages now use G3Status

This maintains clean separation of concerns: g3-core emits semantic data,
g3-cli handles all terminal formatting and colors.
This commit is contained in:
Dhanji R. Prasanna
2026-01-20 09:50:55 +05:30
parent 7bd72a4a51
commit 182f5f98fe
15 changed files with 512 additions and 182 deletions

View File

@@ -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: <msg>]"
Error(String),
/// Custom status - plain "[<status>]"
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: <message> ..."
/// - "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: <msg>]" 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: <message> ... [status]"
pub fn complete(message: &str, status: Status) {
Self::progress(message);
Self::status(&status);
}
/// Print an info message in dimmed/grey text.
/// Format: "... <message>"
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 <session_id> [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
}
}

View File

@@ -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<W: UiWriter>(
// 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<W: UiWriter>(
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<W: UiWriter>(
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 {

View File

@@ -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;

View File

@@ -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: <message> ... [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: <message> ..."
/// - "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);
}
}

View File

@@ -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>) {