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.
This commit is contained in:
Dhanji R. Prasanna
2026-02-06 14:09:12 +11:00
parent abfac197ab
commit 31bdcb651b
4 changed files with 50 additions and 13 deletions

View File

@@ -18,7 +18,7 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_yaml = "0.9" 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" dirs = "5.0"
tokio-util = "0.7" tokio-util = "0.7"
sha2 = "0.10" sha2 = "0.10"

View File

@@ -3,7 +3,7 @@
use anyhow::Result; use anyhow::Result;
use crossterm::style::{Color, ResetColor, SetForegroundColor}; use crossterm::style::{Color, ResetColor, SetForegroundColor};
use rustyline::error::ReadlineError; use rustyline::error::ReadlineError;
use rustyline::DefaultEditor; use rustyline::{Cmd, Config, Editor, EventHandler, KeyCode, KeyEvent, Modifiers};
use std::path::PathBuf; use std::path::PathBuf;
use tracing::error; use tracing::error;
@@ -47,7 +47,14 @@ pub async fn run_accumulative_mode(
output.print(""); output.print("");
// Initialize rustyline editor with history // 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| { let history_file = dirs::home_dir().map(|mut path| {
path.push(".g3_accumulative_history"); path.push(".g3_accumulative_history");
path path

View File

@@ -87,11 +87,21 @@ pub fn format_input(input: &str) -> String {
/// Calculate the number of visual lines that text occupies in a terminal. /// Calculate the number of visual lines that text occupies in a terminal.
/// Accounts for line wrapping and the cursor position after typing. /// 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 { if term_width == 0 {
return 1; 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 // 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 // wraps to the next line, so we need to clear one additional line
if text_len > 0 && text_len % term_width == 0 { 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) // Calculate visual lines (prompt + input may wrap across terminal rows)
let term_width = terminal::size().map(|(w, _)| w as usize).unwrap_or(80); 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 // Move up and clear each line
for _ in 0..visual_lines { for _ in 0..visual_lines {
@@ -262,28 +273,43 @@ mod tests {
#[test] #[test]
fn test_visual_lines_shorter_than_width() { fn test_visual_lines_shorter_than_width() {
// 50 chars on 80-char terminal = 1 line // 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] #[test]
fn test_visual_lines_longer_than_width() { fn test_visual_lines_longer_than_width() {
// 100 chars on 80-char terminal = 2 lines (wraps once) // 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 // 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] #[test]
fn test_visual_lines_exactly_equals_width() { fn test_visual_lines_exactly_equals_width() {
// 80 chars on 80-char terminal = 2 lines (cursor wraps to next line) // 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) // 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] #[test]
fn test_visual_lines_empty_input() { fn test_visual_lines_empty_input() {
// Empty input should still be 1 line (the prompt line) // 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"
} }
} }

View File

@@ -3,7 +3,7 @@
use anyhow::Result; use anyhow::Result;
use crossterm::style::{Color, ResetColor, SetForegroundColor}; use crossterm::style::{Color, ResetColor, SetForegroundColor};
use rustyline::error::ReadlineError; use rustyline::error::ReadlineError;
use rustyline::{Config, Editor}; use rustyline::{Cmd, Config, Editor, EventHandler, KeyCode, KeyEvent, Modifiers};
use crate::completion::G3Helper; use crate::completion::G3Helper;
use std::path::Path; use std::path::Path;
use tracing::{debug, error}; use tracing::{debug, error};
@@ -226,6 +226,10 @@ pub async fn run_interactive<W: UiWriter>(
let mut rl = Editor::with_config(config)?; let mut rl = Editor::with_config(config)?;
rl.set_helper(Some(G3Helper::new())); 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 // Try to load history from a file in the user's home directory
let history_file = dirs::home_dir().map(|mut path| { let history_file = dirs::home_dir().map(|mut path| {
path.push(".g3_history"); path.push(".g3_history");