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
This commit is contained in:
Dhanji R. Prasanna
2026-01-30 12:03:36 +11:00
parent 2e21502357
commit 4e1694248f
3 changed files with 206 additions and 0 deletions

View File

@@ -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: &regex::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: &regex::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**"));
}
}

View File

@@ -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<W: UiWriter>(
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<W: UiWriter>(
}
}
// Reprint input with formatting
reprint_formatted_input(&input, &prompt);
execute_user_input(
&mut agent, &input, show_prompt, show_code, &output, from_agent_mode
).await;

View File

@@ -23,6 +23,7 @@ mod g3_status;
mod template;
mod completion;
mod project;
mod input_formatter;
use anyhow::Result;
use std::path::PathBuf;