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:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user