From 31bdcb651b24006943e13c785aee7f6c82dffdf5 Mon Sep 17 00:00:00 2001 From: "Dhanji R. Prasanna" Date: Fri, 6 Feb 2026 14:09:12 +1100 Subject: [PATCH] feat(cli): add multiline input support with Alt+Enter - Enable custom-bindings feature in rustyline - Bind Alt+Enter to insert newlines in interactive and accumulative modes - Update calculate_visual_lines() to handle embedded newlines correctly - Add tests for multiline visual line calculation Note: Shift+Enter is not distinguishable in standard terminals, so Alt+Enter is used as the multiline input trigger. --- crates/g3-cli/Cargo.toml | 2 +- crates/g3-cli/src/accumulative.rs | 11 +++++-- crates/g3-cli/src/input_formatter.rs | 44 ++++++++++++++++++++++------ crates/g3-cli/src/interactive.rs | 6 +++- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/crates/g3-cli/Cargo.toml b/crates/g3-cli/Cargo.toml index a4a08f7..639b982 100644 --- a/crates/g3-cli/Cargo.toml +++ b/crates/g3-cli/Cargo.toml @@ -18,7 +18,7 @@ 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"] } +rustyline = { version = "17.0.1", features = ["derive", "with-dirs", "custom-bindings"] } dirs = "5.0" tokio-util = "0.7" sha2 = "0.10" diff --git a/crates/g3-cli/src/accumulative.rs b/crates/g3-cli/src/accumulative.rs index 19d8e3e..2e7410e 100644 --- a/crates/g3-cli/src/accumulative.rs +++ b/crates/g3-cli/src/accumulative.rs @@ -3,7 +3,7 @@ use anyhow::Result; use crossterm::style::{Color, ResetColor, SetForegroundColor}; use rustyline::error::ReadlineError; -use rustyline::DefaultEditor; +use rustyline::{Cmd, Config, Editor, EventHandler, KeyCode, KeyEvent, Modifiers}; use std::path::PathBuf; use tracing::error; @@ -47,7 +47,14 @@ pub async fn run_accumulative_mode( output.print(""); // Initialize rustyline editor with history - let mut rl = DefaultEditor::new()?; + let config = Config::builder() + .completion_type(rustyline::CompletionType::List) + .build(); + let mut rl = Editor::<(), rustyline::history::DefaultHistory>::with_config(config)?; + + // Bind Alt+Enter to insert a newline (for multi-line input) + rl.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::ALT), EventHandler::Simple(Cmd::Newline)); + let history_file = dirs::home_dir().map(|mut path| { path.push(".g3_accumulative_history"); path diff --git a/crates/g3-cli/src/input_formatter.rs b/crates/g3-cli/src/input_formatter.rs index 5468b8a..2194b0d 100644 --- a/crates/g3-cli/src/input_formatter.rs +++ b/crates/g3-cli/src/input_formatter.rs @@ -87,11 +87,21 @@ pub fn format_input(input: &str) -> String { /// Calculate the number of visual lines that text occupies in a terminal. /// Accounts for line wrapping and the cursor position after typing. -pub fn calculate_visual_lines(text_len: usize, term_width: usize) -> usize { +/// For multi-line input (with embedded newlines), calculates lines for each segment. +pub fn calculate_visual_lines(text: &str, term_width: usize) -> usize { if term_width == 0 { return 1; } - let mut visual_lines = text_len.div_ceil(term_width).max(1); + + // Split by newlines and calculate visual lines for each segment + let mut visual_lines = 0; + for (i, line) in text.split('\n').enumerate() { + let line_len = if i == 0 { line.len() } else { line.len() }; + visual_lines += line_len.div_ceil(term_width).max(1); + } + visual_lines = visual_lines.max(1); + + let text_len = text.len(); // When text exactly fills the terminal width (or a multiple), the cursor // wraps to the next line, so we need to clear one additional line if text_len > 0 && text_len % term_width == 0 { @@ -111,7 +121,8 @@ pub fn reprint_formatted_input(input: &str, prompt: &str) { // Calculate visual lines (prompt + input may wrap across terminal rows) let term_width = terminal::size().map(|(w, _)| w as usize).unwrap_or(80); - let visual_lines = calculate_visual_lines(prompt.len() + input.len(), term_width); + let full_input = format!("{}{}", prompt, input); + let visual_lines = calculate_visual_lines(&full_input, term_width); // Move up and clear each line for _ in 0..visual_lines { @@ -262,28 +273,43 @@ mod tests { #[test] fn test_visual_lines_shorter_than_width() { // 50 chars on 80-char terminal = 1 line - assert_eq!(calculate_visual_lines(50, 80), 1); + let text = "a".repeat(50); + assert_eq!(calculate_visual_lines(&text, 80), 1); } #[test] fn test_visual_lines_longer_than_width() { // 100 chars on 80-char terminal = 2 lines (wraps once) - assert_eq!(calculate_visual_lines(100, 80), 2); + let text = "a".repeat(100); + assert_eq!(calculate_visual_lines(&text, 80), 2); // 170 chars on 80-char terminal = 3 lines - assert_eq!(calculate_visual_lines(170, 80), 3); + let text = "a".repeat(170); + assert_eq!(calculate_visual_lines(&text, 80), 3); } #[test] fn test_visual_lines_exactly_equals_width() { // 80 chars on 80-char terminal = 2 lines (cursor wraps to next line) - assert_eq!(calculate_visual_lines(80, 80), 2); + let text = "a".repeat(80); + assert_eq!(calculate_visual_lines(&text, 80), 2); // 160 chars on 80-char terminal = 3 lines (fills 2 lines exactly, cursor on 3rd) - assert_eq!(calculate_visual_lines(160, 80), 3); + let text = "a".repeat(160); + assert_eq!(calculate_visual_lines(&text, 80), 3); } #[test] fn test_visual_lines_empty_input() { // Empty input should still be 1 line (the prompt line) - assert_eq!(calculate_visual_lines(0, 80), 1); + assert_eq!(calculate_visual_lines("", 80), 1); + } + + #[test] + fn test_visual_lines_multiline_input() { + // Multi-line input with embedded newlines + assert_eq!(calculate_visual_lines("line1\nline2", 80), 2); + assert_eq!(calculate_visual_lines("line1\nline2\nline3", 80), 3); + // First line wraps, second doesn't + let text = format!("{}\nshort", "a".repeat(100)); + assert_eq!(calculate_visual_lines(&text, 80), 3); // 100 chars = 2 lines, + 1 for "short" } } diff --git a/crates/g3-cli/src/interactive.rs b/crates/g3-cli/src/interactive.rs index 4d9c2bd..717346b 100644 --- a/crates/g3-cli/src/interactive.rs +++ b/crates/g3-cli/src/interactive.rs @@ -3,7 +3,7 @@ use anyhow::Result; use crossterm::style::{Color, ResetColor, SetForegroundColor}; use rustyline::error::ReadlineError; -use rustyline::{Config, Editor}; +use rustyline::{Cmd, Config, Editor, EventHandler, KeyCode, KeyEvent, Modifiers}; use crate::completion::G3Helper; use std::path::Path; use tracing::{debug, error}; @@ -226,6 +226,10 @@ pub async fn run_interactive( let mut rl = Editor::with_config(config)?; rl.set_helper(Some(G3Helper::new())); + // Bind Alt+Enter to insert a newline (for multi-line input) + // Note: Shift+Enter is not distinguishable in standard terminals + rl.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::ALT), EventHandler::Simple(Cmd::Newline)); + // Try to load history from a file in the user's home directory let history_file = dirs::home_dir().map(|mut path| { path.push(".g3_history");