Add tab completion for commands and file paths
Implement tab completion in interactive mode using rustyline: - Command completion: /<TAB> shows all commands, /com<TAB> -> /compact - File path completion: /run <TAB> completes file/directory paths - Supports tilde expansion for home directory Architecture is extensible for future semantic completions: - /resume <TAB> -> session IDs (Phase 2) - /rehydrate <TAB> -> fragment IDs (Phase 2) New module: completion.rs with G3Helper struct implementing rustyline's Completer trait.
This commit is contained in:
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -3085,12 +3085,24 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"nix",
|
"nix",
|
||||||
"radix_trie",
|
"radix_trie",
|
||||||
|
"rustyline-derive",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width 0.2.0",
|
"unicode-width 0.2.0",
|
||||||
"utf8parse",
|
"utf8parse",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustyline-derive"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d66de233f908aebf9cc30ac75ef9103185b4b715c6f2fb7a626aa5e5ede53ab"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.20"
|
version = "1.0.20"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ tracing = { workspace = true }
|
|||||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
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 }
|
||||||
rustyline = "17.0.1"
|
rustyline = { version = "17.0.1", features = ["derive", "with-dirs"] }
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|||||||
148
crates/g3-cli/src/completion.rs
Normal file
148
crates/g3-cli/src/completion.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
//! Tab completion support for g3 interactive mode.
|
||||||
|
//!
|
||||||
|
//! Provides:
|
||||||
|
//! - Command completion for `/` commands
|
||||||
|
//! - File path completion for `/run <path>`
|
||||||
|
//! - Extensible for future semantic completions (sessions, fragments, etc.)
|
||||||
|
|
||||||
|
use rustyline::completion::{Completer, FilenameCompleter, Pair};
|
||||||
|
use rustyline::error::ReadlineError;
|
||||||
|
use rustyline::highlight::Highlighter;
|
||||||
|
use rustyline::hint::Hinter;
|
||||||
|
use rustyline::validate::Validator;
|
||||||
|
use rustyline::{Context, Helper};
|
||||||
|
|
||||||
|
/// Available `/` commands for completion
|
||||||
|
const COMMANDS: &[&str] = &[
|
||||||
|
"/clear",
|
||||||
|
"/compact",
|
||||||
|
"/dump",
|
||||||
|
"/fragments",
|
||||||
|
"/help",
|
||||||
|
"/readme",
|
||||||
|
"/rehydrate",
|
||||||
|
"/resume",
|
||||||
|
"/run",
|
||||||
|
"/skinnify",
|
||||||
|
"/stats",
|
||||||
|
"/thinnify",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Helper struct for rustyline that provides tab completion.
|
||||||
|
pub struct G3Helper {
|
||||||
|
/// File path completer for `/run` command
|
||||||
|
file_completer: FilenameCompleter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl G3Helper {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
file_completer: FilenameCompleter::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for G3Helper {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Completer for G3Helper {
|
||||||
|
type Candidate = Pair;
|
||||||
|
|
||||||
|
fn complete(
|
||||||
|
&self,
|
||||||
|
line: &str,
|
||||||
|
pos: usize,
|
||||||
|
ctx: &Context<'_>,
|
||||||
|
) -> Result<(usize, Vec<Pair>), ReadlineError> {
|
||||||
|
// Only complete up to cursor position
|
||||||
|
let line_to_cursor = &line[..pos];
|
||||||
|
|
||||||
|
// Case 1: `/run <path>` - complete file paths
|
||||||
|
if line_to_cursor.starts_with("/run ") {
|
||||||
|
// Delegate to file completer
|
||||||
|
return self.file_completer.complete(line, pos, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: `/rehydrate <fragment_id>` - future: complete fragment IDs
|
||||||
|
// Case 3: `/resume <session>` - future: complete session IDs
|
||||||
|
|
||||||
|
// Case 4: `/` commands - complete command names
|
||||||
|
if line_to_cursor.starts_with('/') {
|
||||||
|
let prefix = line_to_cursor;
|
||||||
|
let matches: Vec<Pair> = COMMANDS
|
||||||
|
.iter()
|
||||||
|
.filter(|cmd| cmd.starts_with(prefix))
|
||||||
|
.map(|cmd| Pair {
|
||||||
|
display: cmd.to_string(),
|
||||||
|
replacement: cmd.to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
return Ok((0, matches));
|
||||||
|
}
|
||||||
|
|
||||||
|
// No completion for regular prompts
|
||||||
|
Ok((pos, vec![]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required trait implementations for Helper
|
||||||
|
impl Hinter for G3Helper {
|
||||||
|
type Hint = String;
|
||||||
|
|
||||||
|
fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Highlighter for G3Helper {}
|
||||||
|
|
||||||
|
impl Validator for G3Helper {}
|
||||||
|
|
||||||
|
impl Helper for G3Helper {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_completion() {
|
||||||
|
let helper = G3Helper::new();
|
||||||
|
let history = rustyline::history::DefaultHistory::new();
|
||||||
|
let ctx = Context::new(&history);
|
||||||
|
|
||||||
|
// Complete "/com" -> "/compact"
|
||||||
|
let (start, matches) = helper.complete("/com", 4, &ctx).unwrap();
|
||||||
|
assert_eq!(start, 0);
|
||||||
|
assert_eq!(matches.len(), 1);
|
||||||
|
assert_eq!(matches[0].replacement, "/compact");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_completion_multiple() {
|
||||||
|
let helper = G3Helper::new();
|
||||||
|
let history = rustyline::history::DefaultHistory::new();
|
||||||
|
let ctx = Context::new(&history);
|
||||||
|
|
||||||
|
// Complete "/s" -> "/skinnify", "/stats"
|
||||||
|
let (start, matches) = helper.complete("/s", 2, &ctx).unwrap();
|
||||||
|
assert_eq!(start, 0);
|
||||||
|
assert_eq!(matches.len(), 2);
|
||||||
|
assert!(matches.iter().any(|m| m.replacement == "/skinnify"));
|
||||||
|
assert!(matches.iter().any(|m| m.replacement == "/stats"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_completion_for_regular_input() {
|
||||||
|
let helper = G3Helper::new();
|
||||||
|
let history = rustyline::history::DefaultHistory::new();
|
||||||
|
let ctx = Context::new(&history);
|
||||||
|
|
||||||
|
// Regular text should not complete
|
||||||
|
let (start, matches) = helper.complete("hello world", 11, &ctx).unwrap();
|
||||||
|
assert_eq!(start, 11);
|
||||||
|
assert!(matches.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
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::{Config, Editor};
|
||||||
|
use crate::completion::G3Helper;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
@@ -166,7 +167,11 @@ pub async fn run_interactive<W: UiWriter>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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::with_config(config)?;
|
||||||
|
rl.set_helper(Some(G3Helper::new()));
|
||||||
|
|
||||||
// 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| {
|
||||||
@@ -334,7 +339,7 @@ async fn handle_command<W: UiWriter>(
|
|||||||
input: &str,
|
input: &str,
|
||||||
agent: &mut Agent<W>,
|
agent: &mut Agent<W>,
|
||||||
output: &SimpleOutput,
|
output: &SimpleOutput,
|
||||||
rl: &mut DefaultEditor,
|
rl: &mut Editor<G3Helper, rustyline::history::DefaultHistory>,
|
||||||
show_prompt: bool,
|
show_prompt: bool,
|
||||||
show_code: bool,
|
show_code: bool,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ mod task_execution;
|
|||||||
mod ui_writer_impl;
|
mod ui_writer_impl;
|
||||||
mod utils;
|
mod utils;
|
||||||
mod g3_status;
|
mod g3_status;
|
||||||
|
mod completion;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|||||||
Reference in New Issue
Block a user