From 4e1694248f669c44fe44174dff882181d5748d09 Mon Sep 17 00:00:00 2001 From: "Dhanji R. Prasanna" Date: Fri, 30 Jan 2026 12:03:36 +1100 Subject: [PATCH] Add input formatting for interactive CLI When users type prompts in interactive mode, the input is now reformatted in place with enhanced highlighting: - ALL CAPS words (2+ chars) become bold green (e.g., FIX, BUG, HTTP2) - Quoted text ("..." or ...) becomes cyan - Standard markdown formatting is also supported New module: input_formatter.rs with 10 unit tests Integrated into interactive.rs for both single-line and multiline input --- crates/g3-cli/src/input_formatter.rs | 198 +++++++++++++++++++++++++++ crates/g3-cli/src/interactive.rs | 7 + crates/g3-cli/src/lib.rs | 1 + 3 files changed, 206 insertions(+) create mode 100644 crates/g3-cli/src/input_formatter.rs diff --git a/crates/g3-cli/src/input_formatter.rs b/crates/g3-cli/src/input_formatter.rs new file mode 100644 index 0000000..2395a19 --- /dev/null +++ b/crates/g3-cli/src/input_formatter.rs @@ -0,0 +1,198 @@ +//! Input formatting for interactive mode. +//! +//! Formats user input with markdown-style highlighting: +//! - ALL CAPS words become bold +//! - Quoted text ("..." or '...') becomes cyan +//! - Standard markdown formatting (bold, italic, code) is applied + +use regex::Regex; +use std::io::Write; +use termimad::MadSkin; + +use crate::streaming_markdown::StreamingMarkdownFormatter; + +/// Pre-process input text to add markdown markers for special formatting. +/// +/// This pass runs BEFORE markdown formatting: +/// 1. ALL CAPS words (2+ chars) → wrapped in ** for bold +/// 2. Quoted text "..." or '...' → wrapped in special markers for cyan +/// +/// Returns the preprocessed text ready for markdown formatting. +pub fn preprocess_input(input: &str) -> String { + let mut result = input.to_string(); + + // First, handle ALL CAPS words (2+ uppercase letters, may include numbers) + // Must be a standalone word (word boundaries) + let caps_re = Regex::new(r"\b([A-Z][A-Z0-9]{1,}[A-Z0-9]*)\b").unwrap(); + result = caps_re.replace_all(&result, "**$1**").to_string(); + + // Then, handle quoted text - wrap in a special marker that we'll process after markdown + // Use lowercase placeholders that won't be matched by the ALL CAPS regex + let double_quote_re = Regex::new(r#""([^"]+)""#).unwrap(); + result = double_quote_re.replace_all(&result, "\x00qdbl\x00$1\x00qend\x00").to_string(); + + let single_quote_re = Regex::new(r"'([^']+)'").unwrap(); + result = single_quote_re.replace_all(&result, "\x00qsgl\x00$1\x00qend\x00").to_string(); + + result +} + +/// Apply cyan highlighting to quoted text markers. +/// This runs AFTER markdown formatting to apply the cyan color. +fn apply_quote_highlighting(text: &str) -> String { + let mut result = text.to_string(); + + // Replace double-quote markers with cyan formatting + // \x1b[36m = cyan, \x1b[0m = reset + result = result.replace("\x00qdbl\x00", "\x1b[36m\""); + result = result.replace("\x00qsgl\x00", "\x1b[36m'"); + result = result.replace("\x00qend\x00", "\x1b[0m"); + + // Add back the closing quotes + // We need to insert them before the reset code + let re = Regex::new(r#"(\x1b\[36m")([^\x1b]*)\x1b\[0m"#).unwrap(); + result = re.replace_all(&result, |caps: ®ex::Captures| { + format!("{}{}\"\x1b[0m", &caps[1], &caps[2]) + }).to_string(); + + let re = Regex::new(r"(\x1b\[36m')([^\x1b]*)\x1b\[0m").unwrap(); + result = re.replace_all(&result, |caps: ®ex::Captures| { + format!("{}{}'\x1b[0m", &caps[1], &caps[2]) + }).to_string(); + + result +} + +/// Format user input with markdown and special highlighting. +/// +/// Applies: +/// 1. ALL CAPS → bold (green) +/// 2. Quoted text → cyan +/// 3. Standard markdown (bold, italic, inline code) +pub fn format_input(input: &str) -> String { + // Pre-process to add markdown markers + let preprocessed = preprocess_input(input); + + // Apply markdown formatting using the streaming formatter + let skin = MadSkin::default(); + let mut formatter = StreamingMarkdownFormatter::new(skin); + let formatted = formatter.process(&preprocessed); + let formatted = formatted + &formatter.finish(); + + // Apply quote highlighting (after markdown so colors don't interfere) + apply_quote_highlighting(&formatted) +} + +/// Reprint user input in place with formatting. +/// +/// This moves the cursor up to overwrite the original input line, +/// then prints the formatted version. +pub fn reprint_formatted_input(input: &str, prompt: &str) { + // Format the input + let formatted = format_input(input); + + // Count how many lines the input spans (for multiline input) + let line_count = input.lines().count().max(1); + + // Move cursor up by the number of lines and clear + for _ in 0..line_count { + // Move up one line and clear it + print!("\x1b[1A\x1b[2K"); + } + + // Reprint with prompt and formatted input + // Use dim color for the prompt to distinguish from the formatted input + print!("\x1b[2m{}\x1b[0m{}", prompt, formatted); + + // Ensure output is flushed + let _ = std::io::stdout().flush(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_preprocess_all_caps() { + let input = "please FIX the BUG in this CODE"; + let result = preprocess_input(input); + assert!(result.contains("**FIX**")); + assert!(result.contains("**BUG**")); + assert!(result.contains("**CODE**")); + // "please", "the", "in", "this" should not be wrapped + assert!(!result.contains("**please**")); + } + + #[test] + fn test_preprocess_single_caps_not_matched() { + // Single letter caps should not be matched + let input = "I am A person"; + let result = preprocess_input(input); + // "I" and "A" are single letters, should not be wrapped + assert!(!result.contains("**I**")); + assert!(!result.contains("**A**")); + } + + #[test] + fn test_preprocess_double_quotes() { + let input = r#"say "hello world" please"#; + let result = preprocess_input(input); + assert!(result.contains("\x00qdbl\x00hello world\x00qend\x00")); + } + + #[test] + fn test_preprocess_single_quotes() { + let input = "use the 'special' method"; + let result = preprocess_input(input); + assert!(result.contains("\x00qsgl\x00special\x00qend\x00")); + } + + #[test] + fn test_preprocess_mixed() { + let input = r#"FIX the "critical" BUG"#; + let result = preprocess_input(input); + assert!(result.contains("**FIX**")); + assert!(result.contains("**BUG**")); + assert!(result.contains("\x00qdbl\x00critical\x00qend\x00")); + } + + #[test] + fn test_apply_quote_highlighting() { + let input = "\x00qdbl\x00hello\x00qend\x00"; + let result = apply_quote_highlighting(input); + assert!(result.contains("\x1b[36m")); + assert!(result.contains("\x1b[0m")); + } + + #[test] + fn test_format_input_caps_become_bold() { + let input = "FIX this"; + let result = format_input(input); + // Should contain bold ANSI code (\x1b[1;32m for bold green) + assert!(result.contains("\x1b[1;32m") || result.contains("FIX")); + } + + #[test] + fn test_format_input_quotes_become_cyan() { + let input = r#"say "hello""#; + let result = format_input(input); + // Should contain cyan ANSI code + assert!(result.contains("\x1b[36m")); + } + + #[test] + fn test_caps_with_numbers() { + let input = "check HTTP2 and TLS13"; + let result = preprocess_input(input); + assert!(result.contains("**HTTP2**")); + assert!(result.contains("**TLS13**")); + } + + #[test] + fn test_two_letter_caps() { + let input = "use IO and DB"; + let result = preprocess_input(input); + assert!(result.contains("**IO**")); + assert!(result.contains("**DB**")); + } +} diff --git a/crates/g3-cli/src/interactive.rs b/crates/g3-cli/src/interactive.rs index e55a509..729f685 100644 --- a/crates/g3-cli/src/interactive.rs +++ b/crates/g3-cli/src/interactive.rs @@ -17,6 +17,7 @@ use crate::g3_status::{G3Status, Status}; use crate::project::Project; use crate::project_files::extract_project_heading; use crate::simple_output::SimpleOutput; +use crate::input_formatter::reprint_formatted_input; use crate::template::process_template; use crate::task_execution::execute_task_with_retry; use crate::utils::display_context_progress; @@ -246,6 +247,9 @@ pub async fn run_interactive( break; } + // Reprint input with formatting + reprint_formatted_input(&input, &prompt); + execute_user_input( &mut agent, &input, show_prompt, show_code, &output, from_agent_mode ).await; @@ -271,6 +275,9 @@ pub async fn run_interactive( } } + // Reprint input with formatting + reprint_formatted_input(&input, &prompt); + execute_user_input( &mut agent, &input, show_prompt, show_code, &output, from_agent_mode ).await; diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 4e8d30f..846cfcb 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -23,6 +23,7 @@ mod g3_status; mod template; mod completion; mod project; +mod input_formatter; use anyhow::Result; use std::path::PathBuf;