Files
g3/crates/g3-cli/src/ui_writer_impl.rs
Dhanji R. Prasanna 9325a43ff3 feat(cli): shorten file paths in tool output display
Add three-level path shortening hierarchy for cleaner CLI output:
1. Project path -> <project_name>/... (when project loaded via /project)
2. Workspace path -> ./... (relative to current working directory)
3. Home path -> ~/... (fallback for paths under home directory)

Changes:
- Add shorten_path() and shorten_paths_in_command() functions in display.rs
- Add project_path/project_name fields to ConsoleUiWriter
- Add set_workspace_path(), set_project_path(), clear_project() to UiWriter trait
- Add ui_writer() getter to Agent struct
- Wire up project path setting in /project and /unproject commands
- Set workspace path when creating agents in all CLI modes

Before: ● read_file | /Users/dhanji/icloud/butler/projects/appa_estate/status.md
After:  ● read_file | appa_estate/status.md (with project loaded)
        ● read_file | ./src/main.rs (workspace-relative)
        ● read_file | ~/Documents/file.txt (home-relative)
2026-01-21 21:27:16 +05:30

862 lines
35 KiB
Rust

use crate::filter_json::{filter_json_tool_calls, reset_json_tool_state, ToolParsingHint};
use crate::display::{shorten_path, shorten_paths_in_command};
use crate::streaming_markdown::StreamingMarkdownFormatter;
use g3_core::ui_writer::UiWriter;
use std::io::{self, Write};
use std::sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU8, Ordering}};
use termimad::MadSkin;
/// Padding width for tool names in compact display (longest tool: "str_replace" = 11 chars)
const TOOL_NAME_PADDING: usize = 11;
/// ANSI escape codes
mod ansi {
pub const YELLOW: &str = "\x1b[33m";
pub const ORANGE: &str = "\x1b[38;5;208m";
pub const RED: &str = "\x1b[31m";
}
/// Colorize a str_replace summary (e.g., "+5 | -3" -> green "+5" | red "-3")
fn colorize_str_replace_summary(summary: &str) -> String {
// Parse patterns like "+5 | -3", "+5", "-3"
if summary.contains(" | ") {
let parts: Vec<&str> = summary.split(" | ").collect();
if parts.len() == 2 {
return format!("\x1b[32m{}\x1b[0m \x1b[2m|\x1b[0m \x1b[31m{}\x1b[0m", parts[0], parts[1]);
}
} else if summary.starts_with('+') {
return format!("\x1b[32m{}\x1b[0m", summary);
} else if summary.starts_with('-') {
return format!("\x1b[31m{}\x1b[0m", summary);
}
summary.to_string()
}
/// ANSI color codes for tool names
const TOOL_COLOR_NORMAL: &str = "\x1b[32m";
const TOOL_COLOR_NORMAL_BOLD: &str = "\x1b[1;32m";
const TOOL_COLOR_AGENT: &str = "\x1b[38;5;250m";
const TOOL_COLOR_AGENT_BOLD: &str = "\x1b[1;38;5;250m";
/// Blink state values for the streaming indicator
const BLINK_INACTIVE: u8 = 0;
const BLINK_SHOW_PIPE: u8 = 1;
const BLINK_SHOW_SPACE: u8 = 2;
/// Shared state for tool parsing hints that can be used in callbacks.
/// This is separate from ConsoleUiWriter so it can be captured by Arc in closures.
#[derive(Clone)]
struct ParsingHintState {
parsing_indicator_printed: Arc<AtomicBool>,
last_output_was_text: Arc<AtomicBool>,
last_output_was_tool: Arc<AtomicBool>,
is_agent_mode: Arc<AtomicBool>,
/// Blink state: 0 = inactive, 1 = show pipe, 2 = show space
blink_state: Arc<AtomicU8>,
}
impl ParsingHintState {
fn new() -> Self {
Self {
parsing_indicator_printed: Arc::new(AtomicBool::new(false)),
last_output_was_text: Arc::new(AtomicBool::new(false)),
last_output_was_tool: Arc::new(AtomicBool::new(false)),
is_agent_mode: Arc::new(AtomicBool::new(false)),
blink_state: Arc::new(AtomicU8::new(BLINK_INACTIVE)),
}
}
fn clear(&self) {
self.parsing_indicator_printed.store(false, Ordering::Relaxed);
self.blink_state.store(BLINK_INACTIVE, Ordering::Relaxed);
}
/// Handle a tool parsing hint - this is the core logic extracted for use in callbacks
fn handle_hint(&self, hint: ToolParsingHint) {
match hint {
ToolParsingHint::Detected(tool_name) => {
// Stop any previous blinking
self.blink_state.store(BLINK_INACTIVE, Ordering::Relaxed);
// Check if we've already printed an indicator (this is an update)
let already_printed = self.parsing_indicator_printed.load(Ordering::Relaxed);
if already_printed {
// Update in place: clear line and reprint with new name
print!("\r\x1b[2K");
} else {
// First time: add blank line if last output was text
if self.last_output_was_text.load(Ordering::Relaxed) {
println!();
}
self.last_output_was_text.store(false, Ordering::Relaxed);
self.last_output_was_tool.store(true, Ordering::Relaxed);
}
// Get color based on agent mode
let tool_color = if self.is_agent_mode.load(Ordering::Relaxed) {
TOOL_COLOR_AGENT
} else {
TOOL_COLOR_NORMAL
};
// Print the indicator: " ● tool_name |"
print!(" \x1b[2m●\x1b[0m {}{:<width$}\x1b[0m \x1b[2m|\x1b[0m", tool_color, tool_name, width = TOOL_NAME_PADDING);
let _ = io::stdout().flush();
self.parsing_indicator_printed.store(true, Ordering::Relaxed);
self.blink_state.store(BLINK_SHOW_PIPE, Ordering::Relaxed);
}
ToolParsingHint::Active => {
// Toggle blink state for visual feedback
let current = self.blink_state.load(Ordering::Relaxed);
if current != BLINK_INACTIVE {
let new_state = if current == BLINK_SHOW_PIPE { BLINK_SHOW_SPACE } else { BLINK_SHOW_PIPE };
self.blink_state.store(new_state, Ordering::Relaxed);
let indicator = if new_state == BLINK_SHOW_PIPE { "|" } else { " " };
// Move back one char and reprint
print!("\x1b[1D\x1b[2m{}\x1b[0m", indicator);
let _ = io::stdout().flush();
}
}
ToolParsingHint::Complete => {
// Stop blinking
self.blink_state.store(BLINK_INACTIVE, Ordering::Relaxed);
// Clear the parsing indicator line - the actual tool output will follow
if self.parsing_indicator_printed.load(Ordering::Relaxed) {
// Clear the current line and move to start
print!("\r\x1b[2K");
let _ = io::stdout().flush();
}
self.clear();
}
}
}
}
/// Console implementation of UiWriter that prints to stdout
pub struct ConsoleUiWriter {
current_tool_name: std::sync::Mutex<Option<String>>,
current_tool_args: std::sync::Mutex<Vec<(String, String)>>,
/// Workspace path for shortening displayed paths
workspace_path: std::sync::Mutex<Option<std::path::PathBuf>>,
/// Project path for shortening displayed paths (takes priority over workspace)
project_path: std::sync::Mutex<Option<std::path::PathBuf>>,
/// Project name for display (e.g., "appa_estate")
project_name: std::sync::Mutex<Option<String>>,
current_output_line: std::sync::Mutex<Option<String>>,
output_line_printed: std::sync::Mutex<bool>,
/// Track if we're in shell compact mode (for appending timing to output line)
is_shell_compact: std::sync::Mutex<bool>,
/// Streaming markdown formatter for agent responses
markdown_formatter: Mutex<Option<StreamingMarkdownFormatter>>,
/// Track the last read_file path for continuation display
last_read_file_path: std::sync::Mutex<Option<String>>,
/// Shared state for tool parsing hints (used by real-time callback)
hint_state: ParsingHintState,
}
/// ANSI color code for duration display based on elapsed time.
/// Returns empty string for fast operations, yellow/orange/red for slower ones.
fn duration_color(duration_str: &str) -> &'static str {
if duration_str.ends_with("ms") {
return "";
}
if let Some(m_pos) = duration_str.find('m') {
if let Ok(minutes) = duration_str[..m_pos].trim().parse::<u32>() {
return match minutes {
5.. => ansi::RED,
1.. => ansi::ORANGE,
_ => "",
};
}
} else if let Some(s_value) = duration_str.strip_suffix('s') {
if let Ok(seconds) = s_value.trim().parse::<f64>() {
if seconds >= 1.0 {
return ansi::YELLOW;
}
}
}
""
}
impl ConsoleUiWriter {
/// Clear all stored tool state after output is complete.
fn clear_tool_state(&self) {
*self.current_tool_name.lock().unwrap() = None;
self.current_tool_args.lock().unwrap().clear();
*self.current_output_line.lock().unwrap() = None;
*self.output_line_printed.lock().unwrap() = false;
}
}
impl ConsoleUiWriter {
pub fn new() -> Self {
Self {
current_tool_name: std::sync::Mutex::new(None),
current_tool_args: std::sync::Mutex::new(Vec::new()),
workspace_path: std::sync::Mutex::new(None),
project_path: std::sync::Mutex::new(None),
project_name: std::sync::Mutex::new(None),
current_output_line: std::sync::Mutex::new(None),
output_line_printed: std::sync::Mutex::new(false),
is_shell_compact: std::sync::Mutex::new(false),
markdown_formatter: Mutex::new(None),
last_read_file_path: std::sync::Mutex::new(None),
hint_state: ParsingHintState::new(),
}
}
}
impl ConsoleUiWriter {
fn get_workspace_path(&self) -> Option<std::path::PathBuf> {
self.workspace_path.lock().unwrap().clone()
}
fn get_project_info(&self) -> Option<(std::path::PathBuf, String)> {
let path = self.project_path.lock().unwrap().clone()?;
let name = self.project_name.lock().unwrap().clone()?;
Some((path, name))
}
}
impl UiWriter for ConsoleUiWriter {
fn print(&self, message: &str) {
print!("{}", message);
}
fn println(&self, message: &str) {
println!("{}", message);
}
fn print_inline(&self, message: &str) {
print!("{}", message);
let _ = io::stdout().flush();
}
fn print_system_prompt(&self, prompt: &str) {
println!("🔍 System Prompt:");
println!("================");
println!("{}", prompt);
println!("================");
println!();
}
fn print_context_status(&self, message: &str) {
println!("{}", message);
}
fn print_g3_progress(&self, message: &str) {
crate::g3_status::G3Status::progress(message);
}
fn print_g3_status(&self, message: &str, status: &str) {
use crate::g3_status::Status;
let _ = message; // unused now - progress already printed the message
crate::g3_status::G3Status::status(&Status::parse(status));
}
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>) {
// Store the tool name and clear args for collection
*self.current_tool_name.lock().unwrap() = Some(tool_name.to_string());
self.current_tool_args.lock().unwrap().clear();
}
fn print_tool_arg(&self, key: &str, value: &str) {
// Collect arguments instead of printing immediately
// Filter out any keys that look like they might be agent message content
// (e.g., keys that are suspiciously long or contain message-like content)
let is_valid_arg_key = key.len() < 50
&& !key.contains('\n')
&& !key.contains("I'll")
&& !key.contains("Let me")
&& !key.contains("Here's")
&& !key.contains("I can");
if is_valid_arg_key {
self.current_tool_args
.lock()
.unwrap()
.push((key.to_string(), value.to_string()));
}
}
fn print_tool_output_header(&self) {
// Clear any streaming hint that might be showing
// This ensures we don't duplicate the tool name on the line
self.hint_state.handle_hint(ToolParsingHint::Complete);
// Add blank line if last output was text (for visual separation)
let last_was_text = self.hint_state.last_output_was_text.load(Ordering::Relaxed);
if last_was_text {
println!();
}
self.hint_state.last_output_was_text.store(false, Ordering::Relaxed);
self.hint_state.last_output_was_tool.store(true, Ordering::Relaxed);
// Reset output_line_printed at the start of a new tool output
// This ensures the header isn't cleared by update_tool_output_line
*self.output_line_printed.lock().unwrap() = false;
// Reset shell compact mode
*self.is_shell_compact.lock().unwrap() = false;
// Now print the tool header with the most important arg
// Use light gray/silver in agent mode, bold green otherwise
let is_agent_mode = self.hint_state.is_agent_mode.load(Ordering::Relaxed);
// Light gray/silver: \x1b[38;5;250m, Bold green: \x1b[1;32m
let tool_color = if is_agent_mode {
TOOL_COLOR_AGENT_BOLD
} else {
TOOL_COLOR_NORMAL_BOLD
};
if let Some(tool_name) = self.current_tool_name.lock().unwrap().as_ref() {
let args = self.current_tool_args.lock().unwrap();
// Find the most important argument - prioritize file_path if available
let important_arg = args
.iter()
.find(|(k, _)| k == "file_path")
.or_else(|| args.iter().find(|(k, _)| k == "command" || k == "path"))
.or_else(|| args.first());
if let Some((_, value)) = important_arg {
// For multi-line values, only show the first line
let first_line = value.lines().next().unwrap_or("");
// Get workspace path for shortening
let workspace = self.get_workspace_path();
let workspace_ref = workspace.as_deref();
// Get project info for shortening
let project_info = self.get_project_info();
let project_ref = project_info.as_ref().map(|(p, n)| (p.as_path(), n.as_str()));
// Shorten paths in the value (handles both file paths and shell commands)
let shortened = shorten_paths_in_command(first_line, workspace_ref, project_ref);
// Truncate long values for display (after shortening)
let display_value = if shortened.chars().count() > 80 {
// Use char_indices to safely truncate at character boundary
let truncate_at = shortened
.char_indices()
.nth(77)
.map(|(i, _)| i)
.unwrap_or(shortened.len());
format!("{}...", &shortened[..truncate_at])
} else {
shortened
};
// Add range information for read_file tool calls
let header_suffix = if tool_name == "read_file" {
// Check if start or end parameters are present
let has_start = args.iter().any(|(k, _)| k == "start");
let has_end = args.iter().any(|(k, _)| k == "end");
if has_start || has_end {
let start_val = args
.iter()
.find(|(k, _)| k == "start")
.map(|(_, v)| v.as_str())
.unwrap_or("0");
let end_val = args
.iter()
.find(|(k, _)| k == "end")
.map(|(_, v)| v.as_str())
.unwrap_or("end");
format!(" [{}..{}]", start_val, end_val)
} else {
String::new()
}
} else {
String::new()
};
// Check if this is a shell command - use compact format
if tool_name == "shell" {
*self.is_shell_compact.lock().unwrap() = true;
// Print compact shell header: "● shell | command"
// Pad to align with longest compact tool (str_replace = 11 chars)
println!(
" \x1b[2m●\x1b[0m {}{:<11}\x1b[0m \x1b[2m|\x1b[0m \x1b[35m{}\x1b[0m",
tool_color, tool_name, display_value
);
return;
}
// Print with tool name in color (royal blue for agent mode, green otherwise)
println!(
"┌─{} {}\x1b[0m\x1b[35m | {}{}\x1b[0m",
tool_color, tool_name, display_value, header_suffix
);
} else {
// Print with tool name in color
println!("┌─{} {}\x1b[0m", tool_color, tool_name);
}
}
}
fn update_tool_output_line(&self, line: &str) {
// Truncate long lines to prevent terminal wrapping issues
// When lines wrap, the cursor-up escape code only moves up one visual line
const MAX_LINE_WIDTH: usize = 120;
let mut current_line = self.current_output_line.lock().unwrap();
let mut line_printed = self.output_line_printed.lock().unwrap();
let is_shell = *self.is_shell_compact.lock().unwrap();
// If we've already printed a line, clear it first
if *line_printed {
if is_shell {
// For shell, we printed without newline, so just clear the line
print!("\r\x1b[2K");
} else {
// Move cursor up one line and clear it
print!("\x1b[1A\x1b[2K");
}
}
// Truncate line if needed to prevent wrapping
let display_line = if line.chars().count() > MAX_LINE_WIDTH {
let truncated: String = line.chars().take(MAX_LINE_WIDTH - 3).collect();
format!("{}...", truncated)
} else {
line.to_string()
};
// Use different prefix for shell (└─) vs other tools (│)
if is_shell {
// For shell, print without newline so timing can be appended
print!(" \x1b[2m└─ {}\x1b[0m", display_line);
} else {
println!("\x1b[2m{}\x1b[0m", display_line);
}
let _ = io::stdout().flush();
// Update state
*current_line = Some(line.to_string());
*line_printed = true;
}
fn print_tool_output_line(&self, line: &str) {
// Skip the TODO list header line
if line.starts_with("📝 TODO list:") {
return;
}
println!("\x1b[2m{}\x1b[0m", line);
}
fn print_tool_output_summary(&self, count: usize) {
let is_shell = *self.is_shell_compact.lock().unwrap();
if is_shell {
// For shell, append to the same line (no newline)
print!(" \x1b[2m({} line{})\x1b[0m", count, if count == 1 { "" } else { "s" });
let _ = io::stdout().flush();
} else {
println!(
"\x1b[2m({} line{})\x1b[0m",
count,
if count == 1 { "" } else { "s" }
);
}
}
fn print_tool_compact(&self, tool_name: &str, summary: &str, duration_str: &str, tokens_delta: u32, _context_percentage: f32) -> bool {
// Clear any streaming hint that might be showing
// This ensures we don't duplicate the tool name on the line
self.hint_state.handle_hint(ToolParsingHint::Complete);
// Handle file operation tools and other compact tools
let is_compact_tool = matches!(tool_name, "read_file" | "write_file" | "str_replace" | "remember" | "screenshot" | "coverage" | "rehydrate" | "code_search");
if !is_compact_tool {
// Reset continuation tracking for non-compact tools
*self.last_read_file_path.lock().unwrap() = None;
return false;
}
// 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);
let args = self.current_tool_args.lock().unwrap();
let is_agent_mode = self.hint_state.is_agent_mode.load(Ordering::Relaxed);
// Get file path (for file operation tools)
let file_path = args
.iter()
.find(|(k, _)| k == "file_path")
.map(|(_, v)| v.as_str())
.unwrap_or("");
// Check if this is a continuation of reading the same file
let mut last_read_path = self.last_read_file_path.lock().unwrap();
let is_continuation = tool_name == "read_file" && !file_path.is_empty() && last_read_path.as_deref() == Some(file_path);
// For tools without file_path, get other relevant args
let display_arg = if file_path.is_empty() {
// For code_search, extract language and name from searches
if tool_name == "code_search" {
// searches arg is JSON array, try to extract first search's language and name
if let Some((_, searches_json)) = args.iter().find(|(k, _)| k == "searches") {
if let Ok(searches) = serde_json::from_str::<serde_json::Value>(searches_json) {
if let Some(first_search) = searches.as_array().and_then(|arr| arr.first()) {
let lang = first_search.get("language").and_then(|v| v.as_str()).unwrap_or("?");
let name = first_search.get("name").and_then(|v| v.as_str()).unwrap_or("?");
// Truncate name if too long
let display_name = if name.len() > 30 {
let truncate_at = name.char_indices().nth(27).map(|(i, _)| i).unwrap_or(name.len());
format!("{}...", &name[..truncate_at])
} else {
name.to_string()
};
format!("{}:\"{}\"", lang, display_name)
} else {
String::new()
}
} else {
String::new()
}
} else {
String::new()
}
} else {
// For remember, screenshot, etc. - no path to show
String::new()
}
} else {
// Shorten path (project -> name/, workspace -> ./, home -> ~) then truncate if still long
let workspace = self.get_workspace_path();
let project_info = self.get_project_info();
let project_ref = project_info.as_ref().map(|(p, n)| (p.as_path(), n.as_str()));
let shortened = shorten_path(file_path, workspace.as_deref(), project_ref);
if shortened.chars().count() > 60 {
let truncate_at = shortened
.char_indices()
.nth(57)
.map(|(i, _)| i)
.unwrap_or(shortened.len());
format!("{}...", &shortened[..truncate_at])
} else {
shortened
}
};
// Build range suffix for read_file
let range_suffix = if tool_name == "read_file" {
let has_start = args.iter().any(|(k, _)| k == "start");
let has_end = args.iter().any(|(k, _)| k == "end");
if has_start || has_end {
let start_val = args
.iter()
.find(|(k, _)| k == "start")
.map(|(_, v)| v.as_str())
.unwrap_or("0");
let end_val = args
.iter()
.find(|(k, _)| k == "end")
.map(|(_, v)| v.as_str())
.unwrap_or("end");
format!(" [{}..{}]", start_val, end_val)
} else {
String::new()
}
} else {
String::new()
};
// Color for tool name
let tool_color = if is_agent_mode { TOOL_COLOR_AGENT } else { TOOL_COLOR_NORMAL };
// Colorize summary for str_replace (green insertions, red deletions)
let display_summary = if tool_name == "str_replace" {
colorize_str_replace_summary(summary)
} else {
summary.to_string()
};
// Print compact single line
if is_continuation {
// Continuation line for consecutive read_file on same file:
// " └─ reading further [range] | summary | tokens ◉ time"
println!(
" \x1b[2m└─ reading further\x1b[0m\x1b[35m{}\x1b[0m \x1b[2m| {}\x1b[0m \x1b[2m| {}{}\x1b[0m",
range_suffix,
display_summary,
tokens_delta,
duration_str
);
} else if display_arg.is_empty() {
// Tools without file path: " ● tool_name | summary | tokens ◉ time"
// Pad to align with longest compact tool (str_replace = 11 chars)
println!(
" \x1b[2m●\x1b[0m {}{:<11}\x1b[0m \x1b[2m| {}\x1b[0m \x1b[2m| {}{}\x1b[0m",
tool_color, tool_name, display_summary, tokens_delta, duration_str
);
} else {
// Tools with file path: " ● tool_name | path [range] | summary | tokens ◉ time"
// Pad to align with longest compact tool (str_replace = 11 chars)
println!(
" \x1b[2m●\x1b[0m {}{:<11}\x1b[0m \x1b[2m|\x1b[0m \x1b[35m{}{}\x1b[0m \x1b[2m| {}\x1b[0m \x1b[2m| {}{}\x1b[0m",
tool_color, tool_name, display_arg, range_suffix, display_summary, tokens_delta, duration_str
);
}
// Update last_read_file_path for continuation tracking
if tool_name == "read_file" && !file_path.is_empty() {
*last_read_path = Some(file_path.to_string());
} else {
// Reset for non-read_file tools
*last_read_path = None;
}
// Clear the stored tool info
drop(args); // Release the lock before clearing
drop(last_read_path); // Release this lock too
self.clear_tool_state();
true
}
fn print_todo_compact(&self, content: Option<&str>, is_write: bool) -> bool {
let tool_name = if is_write { "todo_write" } else { "todo_read" };
// Clear any streaming hint that might be showing
// This ensures we don't duplicate the tool name on the line
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 content {
None => {
// Empty TODO
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(text) => {
// Header
println!(" \x1b[2m●\x1b[0m {}{:<width$}\x1b[0m", tool_color, tool_name, width = TOOL_NAME_PADDING);
let lines: Vec<&str> = text.lines().collect();
let last_idx = lines.len().saturating_sub(1);
for (i, line) in lines.iter().enumerate() {
let is_last = i == last_idx;
let prefix = if is_last { "" } else { "" };
// Convert checkboxes to styled symbols and strikethrough completed items
let is_completed = line.contains("- [x]") || line.contains("- [X]");
let styled_line = if is_completed {
// Replace checkbox and apply strikethrough to the task text
let task_text = line
.replace("- [x]", "")
.replace("- [X]", "")
.trim_start()
.to_string();
format!("\x1b[9m{}\x1b[0m\x1b[2m", task_text) // \x1b[9m is strikethrough
} else {
line.replace("- [ ]", "")
};
// Dim the line content
println!(" \x1b[2m{} {}\x1b[0m", prefix, styled_line);
}
// Add blank line after content for readability
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);
// Reset read_file continuation tracking for non-read_file tools
// (read_file tools handle this in print_tool_compact)
if let Some(tool_name) = self.current_tool_name.lock().unwrap().as_ref() {
if tool_name != "read_file" {
*self.last_read_file_path.lock().unwrap() = None;
}
}
// Add blank line before footer for research tool (its output is a full report)
if let Some(tool_name) = self.current_tool_name.lock().unwrap().as_ref() {
if tool_name == "research" {
println!();
}
}
// Check if we're in shell compact mode - append timing to the output line
let is_shell = *self.is_shell_compact.lock().unwrap();
if is_shell {
// Append timing to the same line as shell output
println!(" \x1b[2m| {}{}{}\x1b[0m", tokens_delta, color_code, duration_str);
println!();
} else {
println!("└─ ⚡️ {}{}\x1b[0m \x1b[2m{} ◉ | {:.0}%\x1b[0m", color_code, duration_str, tokens_delta, context_percentage);
println!();
}
// Clear the stored tool info
self.clear_tool_state();
*self.is_shell_compact.lock().unwrap() = false;
}
fn print_agent_prompt(&self) {
let _ = io::stdout().flush();
}
fn print_agent_response(&self, content: &str) {
let mut formatter_guard = self.markdown_formatter.lock().unwrap();
// Initialize formatter if not already done
if formatter_guard.is_none() {
let mut skin = MadSkin::default();
skin.bold.set_fg(termimad::crossterm::style::Color::Green);
skin.italic.set_fg(termimad::crossterm::style::Color::Cyan);
skin.inline_code.set_fg(termimad::crossterm::style::Color::Rgb { r: 216, g: 177, b: 114 });
*formatter_guard = Some(StreamingMarkdownFormatter::new(skin));
}
// Process the chunk through the formatter
if let Some(ref mut formatter) = *formatter_guard {
// Add blank line if last output was a tool call (for visual separation)
// Only do this once at the start of new text content
let last_was_tool = self.hint_state.last_output_was_tool.load(Ordering::Relaxed);
if last_was_tool && !content.trim().is_empty() {
println!();
self.hint_state.last_output_was_tool.store(false, Ordering::Relaxed);
}
let formatted = formatter.process(content);
print!("{}", formatted);
// Track that we just output text (only if non-empty)
if !content.trim().is_empty() {
self.hint_state.last_output_was_text.store(true, Ordering::Relaxed);
// Reset read_file continuation tracking when text is output between tool calls
*self.last_read_file_path.lock().unwrap() = None;
}
let _ = io::stdout().flush();
}
}
fn finish_streaming_markdown(&self) {
let mut formatter_guard = self.markdown_formatter.lock().unwrap();
if let Some(ref mut formatter) = *formatter_guard {
// Flush any remaining buffered content
let remaining = formatter.finish();
print!("{}", remaining);
let _ = io::stdout().flush();
}
// Reset the formatter for the next response
*formatter_guard = None;
}
fn notify_sse_received(&self) {
// No-op for console - we don't track SSEs in console mode
}
fn print_tool_streaming_hint(&self, tool_name: &str) {
// Use the hint state to show the streaming indicator
self.hint_state.handle_hint(ToolParsingHint::Detected(tool_name.to_string()));
}
fn print_tool_streaming_active(&self) {
// Trigger the blink animation
self.hint_state.handle_hint(ToolParsingHint::Active);
}
fn flush(&self) {
let _ = io::stdout().flush();
}
fn prompt_user_yes_no(&self, message: &str) -> bool {
print!("{} [y/N] ", message);
let _ = io::stdout().flush();
let mut input = String::new();
if io::stdin().read_line(&mut input).is_ok() {
let trimmed = input.trim().to_lowercase();
trimmed == "y" || trimmed == "yes"
} else {
false
}
}
fn prompt_user_choice(&self, message: &str, options: &[&str]) -> usize {
println!("{} ", message);
for (i, option) in options.iter().enumerate() {
println!(" [{}] {}", i + 1, option);
}
print!("Select an option (1-{}): ", options.len());
let _ = io::stdout().flush();
loop {
let mut input = String::new();
if io::stdin().read_line(&mut input).is_ok() {
if let Ok(choice) = input.trim().parse::<usize>() {
if choice > 0 && choice <= options.len() {
return choice - 1;
}
}
}
print!("Invalid choice. Please select (1-{}): ", options.len());
let _ = io::stdout().flush();
}
}
fn filter_json_tool_calls(&self, content: &str) -> String {
// Filter the content to remove JSON tool calls from display.
// Tool streaming hints are now handled via the provider's tool_call_streaming
// field in CompletionChunk, not via callbacks during JSON filtering.
filter_json_tool_calls(content)
}
fn reset_json_filter(&self) {
// Reset the filter state for a new response
reset_json_tool_state();
}
fn set_agent_mode(&self, is_agent_mode: bool) {
self.hint_state.is_agent_mode.store(is_agent_mode, Ordering::Relaxed);
}
fn set_workspace_path(&self, path: std::path::PathBuf) {
*self.workspace_path.lock().unwrap() = Some(path);
}
fn set_project_path(&self, path: std::path::PathBuf, name: String) {
*self.project_path.lock().unwrap() = Some(path);
*self.project_name.lock().unwrap() = Some(name);
}
fn clear_project(&self) {
*self.project_path.lock().unwrap() = None;
*self.project_name.lock().unwrap() = None;
}
}