From 571188305a0ee1fdf89151edcae32947774746a7 Mon Sep 17 00:00:00 2001 From: "Dhanji R. Prasanna" Date: Mon, 2 Feb 2026 15:30:05 +1100 Subject: [PATCH] feat: add compact UI output for Plan Mode tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan tools (plan_read, plan_write) now display with elegant tree-style formatting similar to the old todo_write UI: - State indicators: □ (todo), ◐ (doing), ■ (done), ⊘ (blocked) - Tree prefixes (├/└) for items with child details - Strikethrough for completed items - Shows touches and all three checks (happy/negative/boundary) - Displays plan file path link at the end plan_approve uses compact single-line format like read_file: - Shows approval status and revision number - Handles already-approved and error cases Changes: - Add print_plan_compact() to UiWriter trait with default impl - Implement print_plan_compact() in ConsoleUiWriter - Call print_plan_compact() from execute_plan_read/write - Add plan_read/plan_write to is_self_handled_tool() - Add plan_approve to is_compact_tool() with format_plan_approve_summary() - Add serde_yaml dependency to g3-cli --- Cargo.lock | 1 + crates/g3-cli/Cargo.toml | 1 + crates/g3-cli/src/ui_writer_impl.rs | 116 ++++++++++++++++++++++++++++ crates/g3-core/src/streaming.rs | 26 ++++++- crates/g3-core/src/tools/plan.rs | 15 +++- crates/g3-core/src/ui_writer.rs | 10 +++ 6 files changed, 167 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9c5220b..2e9e9d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1384,6 +1384,7 @@ dependencies = [ "rustyline", "serde", "serde_json", + "serde_yaml", "sha2", "syntect", "tempfile", diff --git a/crates/g3-cli/Cargo.toml b/crates/g3-cli/Cargo.toml index 0969f92..a4a08f7 100644 --- a/crates/g3-cli/Cargo.toml +++ b/crates/g3-cli/Cargo.toml @@ -17,6 +17,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_yaml = "0.9" rustyline = { version = "17.0.1", features = ["derive", "with-dirs"] } dirs = "5.0" tokio-util = "0.7" diff --git a/crates/g3-cli/src/ui_writer_impl.rs b/crates/g3-cli/src/ui_writer_impl.rs index c375add..6173934 100644 --- a/crates/g3-cli/src/ui_writer_impl.rs +++ b/crates/g3-cli/src/ui_writer_impl.rs @@ -689,6 +689,122 @@ impl UiWriter for ConsoleUiWriter { true } + fn print_plan_compact(&self, plan_yaml: Option<&str>, plan_file_path: Option<&str>, is_write: bool) -> bool { + let tool_name = if is_write { "plan_write" } else { "plan_read" }; + // Clear any streaming hint that might be showing + self.hint_state.handle_hint(ToolParsingHint::Complete); + + let is_agent_mode = self.hint_state.is_agent_mode.load(Ordering::Relaxed); + let tool_color = if is_agent_mode { TOOL_COLOR_AGENT } else { TOOL_COLOR_NORMAL }; + + // Add blank line if last output was text (for visual separation) + if self.hint_state.last_output_was_text.load(Ordering::Relaxed) { + println!(); + } + self.hint_state.last_output_was_text.store(false, Ordering::Relaxed); + self.hint_state.last_output_was_tool.store(true, Ordering::Relaxed); + // Reset read_file continuation tracking + *self.last_read_file_path.lock().unwrap() = None; + + match plan_yaml { + None => { + // No plan exists + println!(" \x1b[2m●\x1b[0m {}{: { + // Parse the YAML to extract plan details + #[derive(serde::Deserialize)] + struct PlanCompact { + plan_id: String, + revision: u32, + approved_revision: Option, + items: Vec, + } + #[derive(serde::Deserialize)] + struct PlanItemCompact { + id: String, + description: String, + state: String, + touches: Vec, + checks: ChecksCompact, + } + #[derive(serde::Deserialize)] + struct ChecksCompact { + happy: CheckCompact, + negative: CheckCompact, + boundary: CheckCompact, + } + #[derive(serde::Deserialize)] + struct CheckCompact { + desc: String, + #[allow(dead_code)] + target: String, + } + + if let Ok(plan) = serde_yaml::from_str::(yaml) { + // Header with plan info + let approved_str = if let Some(rev) = plan.approved_revision { + format!(" \x1b[32m✓ approved@{}\x1b[0m", rev) + } else { + " \x1b[33m⚠ NOT APPROVED\x1b[0m".to_string() + }; + println!(" \x1b[2m●\x1b[0m {}{: ("□", "\x1b[0m"), // default + "doing" => ("◐", "\x1b[33m"), // yellow + "done" => ("■", "\x1b[32m"), // green + "blocked" => ("⊘", "\x1b[31m"), // red + _ => ("?", "\x1b[0m"), + }; + + // Item line: state icon, ID, description + let desc_style = if item.state == "done" { "\x1b[9m" } else { "" }; // strikethrough if done + println!(" \x1b[2m{}\x1b[0m {}{} \x1b[1m{}\x1b[0m {}{}\x1b[0m", + item_prefix, state_color, state_icon, item.id, desc_style, item.description); + + // Touches (dimmed) + let touches_str = item.touches.join(", "); + println!(" \x1b[2m{} → {}\x1b[0m", child_prefix, touches_str); + + // Checks (dimmed, compact) + println!(" \x1b[2m{} ✓ happy: {}\x1b[0m", child_prefix, item.checks.happy.desc); + println!(" \x1b[2m{} ✗ negative: {}\x1b[0m", child_prefix, item.checks.negative.desc); + println!(" \x1b[2m{} ◇ boundary: {}\x1b[0m", child_prefix, item.checks.boundary.desc); + } + + // File path link at the end + if let Some(path) = plan_file_path { + println!(" \x1b[2m 📄 {}\x1b[0m", path); + } + + // Add blank line after content for readability + println!(); + } else { + // Failed to parse - fall back to simple display + println!(" \x1b[2m●\x1b[0m {}{: bool { | "coverage" | "rehydrate" | "code_search" + | "plan_approve" ) } pub fn is_self_handled_tool(tool_name: &str) -> bool { - tool_name == "todo_read" || tool_name == "todo_write" + matches!(tool_name, + "todo_read" | "todo_write" | + "plan_read" | "plan_write" + ) } /// Format a compact summary for a successful compact tool @@ -125,6 +129,7 @@ fn format_compact_tool_summary(tool_name: &str, tool_result: &str) -> String { "coverage" => format_coverage_summary(tool_result), "rehydrate" => format_rehydrate_summary(tool_result), "code_search" => format_code_search_summary(tool_result), + "plan_approve" => format_plan_approve_summary(tool_result), _ => "✅ completed".to_string(), } } @@ -512,6 +517,25 @@ pub fn format_code_search_summary(result: &str) -> String { } } +/// Format a plan_approve result summary. +pub fn format_plan_approve_summary(result: &str) -> String { + // Result formats: + // "✅ Plan approved at revision N. You may now begin implementation." + // "ℹ️ Plan already approved at revision N. Current revision: M" + // "❌ No plan exists to approve..." + if result.contains("❌") { + "❌ failed".to_string() + } else if result.contains("already approved") { + "ℹ️ already approved".to_string() + } else if let Some(pos) = result.find("revision ") { + let after = &result[pos + 9..]; + let rev: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); + format!("✅ approved rev {}", rev) + } else { + "✅ approved".to_string() + } +} + // ============================================================================= // Tool Call Deduplication // ============================================================================= diff --git a/crates/g3-core/src/tools/plan.rs b/crates/g3-core/src/tools/plan.rs index 2e0407e..b785929 100644 --- a/crates/g3-core/src/tools/plan.rs +++ b/crates/g3-core/src/tools/plan.rs @@ -758,16 +758,23 @@ pub async fn execute_plan_read( None => return Ok("❌ No active session - plans are session-scoped.".to_string()), }; + let plan_path = get_plan_path(session_id); + let plan_path_str = plan_path.to_string_lossy().to_string(); + match read_plan(session_id)? { Some(plan) => { let yaml = serde_yaml::to_string(&plan)?; + ctx.ui_writer.print_plan_compact(Some(&yaml), Some(&plan_path_str), false); Ok(format!( "📋 {}\n\n```yaml\n{}```", plan.status_summary(), yaml )) } - None => Ok("📋 No plan exists for this session. Use plan_write to create one.".to_string()), + None => { + ctx.ui_writer.print_plan_compact(None, None, false); + Ok("📋 No plan exists for this session. Use plan_write to create one.".to_string()) + } } } @@ -826,6 +833,12 @@ pub async fn execute_plan_write( return Ok(format!("❌ Failed to write plan: {}", e)); } + // Display the plan in compact format + let plan_path = get_plan_path(session_id); + let plan_path_str = plan_path.to_string_lossy().to_string(); + let yaml = serde_yaml::to_string(&plan)?; + ctx.ui_writer.print_plan_compact(Some(&yaml), Some(&plan_path_str), true); + // Check if plan is now complete and trigger verification if plan.is_complete() && plan.is_approved() { let verification = plan_verify(&plan, ctx.working_dir); diff --git a/crates/g3-core/src/ui_writer.rs b/crates/g3-core/src/ui_writer.rs index 5a71534..968ad48 100644 --- a/crates/g3-core/src/ui_writer.rs +++ b/crates/g3-core/src/ui_writer.rs @@ -68,6 +68,16 @@ pub trait UiWriter: Send + Sync { false } + /// Print a compact Plan tool output with styled content + /// Format: " ● plan_read" or " ● plan_write" header, then styled plan items + /// plan_yaml: None for no plan, Some(yaml) for plan content + /// plan_file_path: Path to the plan file for user reference + /// is_write: true for plan_write, false for plan_read + /// Returns true if handled, false to fall back to normal format + fn print_plan_compact(&self, _plan_yaml: Option<&str>, _plan_file_path: Option<&str>, _is_write: bool) -> bool { + false + } + /// Print tool execution timing fn print_tool_timing(&self, duration_str: &str, tokens_delta: u32, context_percentage: f32);