feat: add compact UI output for Plan Mode tools
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
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1384,6 +1384,7 @@ dependencies = [
|
||||
"rustyline",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"syntect",
|
||||
"tempfile",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {}{:<width$}\x1b[0m \x1b[2m|\x1b[0m \x1b[35mempty\x1b[0m", tool_color, tool_name, width = TOOL_NAME_PADDING);
|
||||
}
|
||||
Some(yaml) => {
|
||||
// Parse the YAML to extract plan details
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PlanCompact {
|
||||
plan_id: String,
|
||||
revision: u32,
|
||||
approved_revision: Option<u32>,
|
||||
items: Vec<PlanItemCompact>,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PlanItemCompact {
|
||||
id: String,
|
||||
description: String,
|
||||
state: String,
|
||||
touches: Vec<String>,
|
||||
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::<PlanCompact>(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 {}{:<width$}\x1b[0m \x1b[2m|\x1b[0m \x1b[36m{}\x1b[0m rev {}{}",
|
||||
tool_color, tool_name, plan.plan_id, plan.revision, approved_str, width = TOOL_NAME_PADDING);
|
||||
|
||||
let items_len = plan.items.len();
|
||||
for (i, item) in plan.items.iter().enumerate() {
|
||||
let is_last_item = i == items_len - 1;
|
||||
let item_prefix = if is_last_item { "└" } else { "├" };
|
||||
let child_prefix = if is_last_item { " " } else { "│" };
|
||||
|
||||
// State indicator: □ = todo, ◐ = doing, ■ = done, ⊘ = blocked
|
||||
let (state_icon, state_color) = match item.state.as_str() {
|
||||
"todo" => ("□", "\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 {}{:<width$}\x1b[0m", tool_color, tool_name, width = TOOL_NAME_PADDING);
|
||||
for line in yaml.lines().take(20) {
|
||||
println!(" \x1b[2m│ {}\x1b[0m", line);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear tool state
|
||||
self.clear_tool_state();
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn print_tool_timing(&self, duration_str: &str, tokens_delta: u32, context_percentage: f32) {
|
||||
let color_code = duration_color(duration_str);
|
||||
|
||||
|
||||
@@ -101,11 +101,15 @@ pub fn is_compact_tool(tool_name: &str) -> 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
|
||||
// =============================================================================
|
||||
|
||||
@@ -758,16 +758,23 @@ pub async fn execute_plan_read<W: UiWriter>(
|
||||
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<W: UiWriter>(
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user