mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-07 16:44:50 +08:00
Bring slash-command UX closer to the TypeScript terminal UI
Port the Rust REPL toward the TypeScript UI patterns by adding ranked slash command suggestions, canonical alias completion, trailing-space acceptance, argument hints, and clearer entry/help copy for discoverability. Constraint: Keep this worktree scoped to UI-only parity; discard unrelated plugin-loading edits Constraint: Rust terminal UI remains line-editor based, so the parity pass focuses on practical affordances instead of React modal surfaces Rejected: Rework the REPL into a full multi-pane typeahead overlay | too large for this UI-only parity slice Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep slash metadata and completion behavior aligned; new slash commands should update both descriptors and help text together Tested: cargo check; cargo test Not-tested: Interactive manual terminal pass in a live TTY
This commit is contained in:
@@ -6,6 +6,31 @@ use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifier
|
|||||||
use crossterm::queue;
|
use crossterm::queue;
|
||||||
use crossterm::terminal::{self, Clear, ClearType};
|
use crossterm::terminal::{self, Clear, ClearType};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SlashCommandDescriptor {
|
||||||
|
pub command: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub argument_hint: Option<String>,
|
||||||
|
pub aliases: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlashCommandDescriptor {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn simple(command: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
command: command.into(),
|
||||||
|
description: None,
|
||||||
|
argument_hint: None,
|
||||||
|
aliases: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn triggers(&self) -> impl Iterator<Item = &str> {
|
||||||
|
std::iter::once(self.command.as_str()).chain(self.aliases.iter().map(String::as_str))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ReadOutcome {
|
pub enum ReadOutcome {
|
||||||
Submit(String),
|
Submit(String),
|
||||||
@@ -178,14 +203,21 @@ impl EditSession {
|
|||||||
out: &mut impl Write,
|
out: &mut impl Write,
|
||||||
base_prompt: &str,
|
base_prompt: &str,
|
||||||
vim_enabled: bool,
|
vim_enabled: bool,
|
||||||
|
assist_lines: &[String],
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
self.clear_render(out)?;
|
self.clear_render(out)?;
|
||||||
|
|
||||||
let prompt = self.prompt(base_prompt, vim_enabled);
|
let prompt = self.prompt(base_prompt, vim_enabled);
|
||||||
let buffer = self.visible_buffer();
|
let buffer = self.visible_buffer();
|
||||||
write!(out, "{prompt}{buffer}")?;
|
write!(out, "{prompt}{buffer}")?;
|
||||||
|
if !assist_lines.is_empty() {
|
||||||
|
for line in assist_lines {
|
||||||
|
write!(out, "\r\n{line}")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (cursor_row, cursor_col, total_lines) = self.cursor_layout(prompt.as_ref());
|
let (cursor_row, cursor_col, total_lines) =
|
||||||
|
self.cursor_layout(prompt.as_ref(), assist_lines.len());
|
||||||
let rows_to_move_up = total_lines.saturating_sub(cursor_row + 1);
|
let rows_to_move_up = total_lines.saturating_sub(cursor_row + 1);
|
||||||
if rows_to_move_up > 0 {
|
if rows_to_move_up > 0 {
|
||||||
queue!(out, MoveUp(to_u16(rows_to_move_up)?))?;
|
queue!(out, MoveUp(to_u16(rows_to_move_up)?))?;
|
||||||
@@ -211,7 +243,7 @@ impl EditSession {
|
|||||||
writeln!(out)
|
writeln!(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cursor_layout(&self, prompt: &str) -> (usize, usize, usize) {
|
fn cursor_layout(&self, prompt: &str, assist_line_count: usize) -> (usize, usize, usize) {
|
||||||
let active_text = self.active_text();
|
let active_text = self.active_text();
|
||||||
let cursor = if self.mode == EditorMode::Command {
|
let cursor = if self.mode == EditorMode::Command {
|
||||||
self.command_cursor
|
self.command_cursor
|
||||||
@@ -225,7 +257,8 @@ impl EditSession {
|
|||||||
Some((_, suffix)) => suffix.chars().count(),
|
Some((_, suffix)) => suffix.chars().count(),
|
||||||
None => prompt.chars().count() + cursor_prefix.chars().count(),
|
None => prompt.chars().count() + cursor_prefix.chars().count(),
|
||||||
};
|
};
|
||||||
let total_lines = active_text.bytes().filter(|byte| *byte == b'\n').count() + 1;
|
let total_lines =
|
||||||
|
active_text.bytes().filter(|byte| *byte == b'\n').count() + 1 + assist_line_count;
|
||||||
(cursor_row, cursor_col, total_lines)
|
(cursor_row, cursor_col, total_lines)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,7 +273,7 @@ enum KeyAction {
|
|||||||
|
|
||||||
pub struct LineEditor {
|
pub struct LineEditor {
|
||||||
prompt: String,
|
prompt: String,
|
||||||
completions: Vec<String>,
|
slash_commands: Vec<SlashCommandDescriptor>,
|
||||||
history: Vec<String>,
|
history: Vec<String>,
|
||||||
yank_buffer: YankBuffer,
|
yank_buffer: YankBuffer,
|
||||||
vim_enabled: bool,
|
vim_enabled: bool,
|
||||||
@@ -255,11 +288,24 @@ struct CompletionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LineEditor {
|
impl LineEditor {
|
||||||
|
#[allow(dead_code)]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
|
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
|
||||||
|
let slash_commands = completions
|
||||||
|
.into_iter()
|
||||||
|
.map(SlashCommandDescriptor::simple)
|
||||||
|
.collect();
|
||||||
|
Self::with_slash_commands(prompt, slash_commands)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_slash_commands(
|
||||||
|
prompt: impl Into<String>,
|
||||||
|
slash_commands: Vec<SlashCommandDescriptor>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
prompt: prompt.into(),
|
prompt: prompt.into(),
|
||||||
completions,
|
slash_commands,
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
yank_buffer: YankBuffer::default(),
|
yank_buffer: YankBuffer::default(),
|
||||||
vim_enabled: false,
|
vim_enabled: false,
|
||||||
@@ -284,7 +330,12 @@ impl LineEditor {
|
|||||||
let _raw_mode = RawModeGuard::new()?;
|
let _raw_mode = RawModeGuard::new()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
let mut session = EditSession::new(self.vim_enabled);
|
let mut session = EditSession::new(self.vim_enabled);
|
||||||
session.render(&mut stdout, &self.prompt, self.vim_enabled)?;
|
session.render(
|
||||||
|
&mut stdout,
|
||||||
|
&self.prompt,
|
||||||
|
self.vim_enabled,
|
||||||
|
&self.command_assist_lines(&session),
|
||||||
|
)?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let Event::Key(key) = event::read()? else {
|
let Event::Key(key) = event::read()? else {
|
||||||
@@ -296,7 +347,12 @@ impl LineEditor {
|
|||||||
|
|
||||||
match self.handle_key_event(&mut session, key) {
|
match self.handle_key_event(&mut session, key) {
|
||||||
KeyAction::Continue => {
|
KeyAction::Continue => {
|
||||||
session.render(&mut stdout, &self.prompt, self.vim_enabled)?;
|
session.render(
|
||||||
|
&mut stdout,
|
||||||
|
&self.prompt,
|
||||||
|
self.vim_enabled,
|
||||||
|
&self.command_assist_lines(&session),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
KeyAction::Submit(line) => {
|
KeyAction::Submit(line) => {
|
||||||
session.finalize_render(&mut stdout, &self.prompt, self.vim_enabled)?;
|
session.finalize_render(&mut stdout, &self.prompt, self.vim_enabled)?;
|
||||||
@@ -325,7 +381,12 @@ impl LineEditor {
|
|||||||
}
|
}
|
||||||
)?;
|
)?;
|
||||||
session = EditSession::new(self.vim_enabled);
|
session = EditSession::new(self.vim_enabled);
|
||||||
session.render(&mut stdout, &self.prompt, self.vim_enabled)?;
|
session.render(
|
||||||
|
&mut stdout,
|
||||||
|
&self.prompt,
|
||||||
|
self.vim_enabled,
|
||||||
|
&self.command_assist_lines(&session),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -699,25 +760,21 @@ impl LineEditor {
|
|||||||
state
|
state
|
||||||
.matches
|
.matches
|
||||||
.iter()
|
.iter()
|
||||||
.any(|candidate| candidate == &session.text)
|
.any(|candidate| session.text == *candidate || session.text == format!("{candidate} "))
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
let candidate = state.matches[state.next_index % state.matches.len()].clone();
|
let candidate = state.matches[state.next_index % state.matches.len()].clone();
|
||||||
state.next_index += 1;
|
state.next_index += 1;
|
||||||
session.text.replace_range(..session.cursor, &candidate);
|
let replacement = completed_command(&candidate);
|
||||||
session.cursor = candidate.len();
|
session.text.replace_range(..session.cursor, &replacement);
|
||||||
|
session.cursor = replacement.len();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(prefix) = slash_command_prefix(&session.text, session.cursor) else {
|
let Some(prefix) = slash_command_prefix(&session.text, session.cursor) else {
|
||||||
self.completion_state = None;
|
self.completion_state = None;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let matches = self
|
let matches = self.matching_commands(prefix);
|
||||||
.completions
|
|
||||||
.iter()
|
|
||||||
.filter(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
|
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if matches.is_empty() {
|
if matches.is_empty() {
|
||||||
self.completion_state = None;
|
self.completion_state = None;
|
||||||
return;
|
return;
|
||||||
@@ -741,8 +798,111 @@ impl LineEditor {
|
|||||||
candidate
|
candidate
|
||||||
};
|
};
|
||||||
|
|
||||||
session.text.replace_range(..session.cursor, &candidate);
|
let replacement = completed_command(&candidate);
|
||||||
session.cursor = candidate.len();
|
session.text.replace_range(..session.cursor, &replacement);
|
||||||
|
session.cursor = replacement.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matching_commands(&self, prefix: &str) -> Vec<String> {
|
||||||
|
let normalized = prefix.to_ascii_lowercase();
|
||||||
|
let mut ranked = self
|
||||||
|
.slash_commands
|
||||||
|
.iter()
|
||||||
|
.filter_map(|descriptor| {
|
||||||
|
let command = descriptor.command.clone();
|
||||||
|
let mut best_rank = None::<(u8, usize)>;
|
||||||
|
for trigger in descriptor.triggers() {
|
||||||
|
let trigger_lower = trigger.to_ascii_lowercase();
|
||||||
|
let rank = if trigger_lower == normalized {
|
||||||
|
if trigger == descriptor.command {
|
||||||
|
Some((0, trigger.len()))
|
||||||
|
} else {
|
||||||
|
Some((1, trigger.len()))
|
||||||
|
}
|
||||||
|
} else if trigger_lower.starts_with(&normalized) {
|
||||||
|
if trigger == descriptor.command {
|
||||||
|
Some((2, trigger.len()))
|
||||||
|
} else {
|
||||||
|
Some((3, trigger.len()))
|
||||||
|
}
|
||||||
|
} else if trigger_lower.contains(&normalized) {
|
||||||
|
Some((4, trigger.len()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(rank) = rank {
|
||||||
|
best_rank = Some(best_rank.map_or(rank, |current| current.min(rank)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best_rank.map(|(bucket, len)| (bucket, len, command))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
ranked.sort_by(|left, right| left.cmp(right));
|
||||||
|
ranked.dedup_by(|left, right| left.2 == right.2);
|
||||||
|
ranked.into_iter().map(|(_, _, command)| command).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_assist_lines(&self, session: &EditSession) -> Vec<String> {
|
||||||
|
if session.mode == EditorMode::Command || session.cursor != session.text.len() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = session.text.as_str();
|
||||||
|
if !input.starts_with('/') {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((command, args)) = command_and_args(input) {
|
||||||
|
if input.ends_with(' ') && args.is_empty() {
|
||||||
|
if let Some(descriptor) = self.find_command_descriptor(command) {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
if let Some(argument_hint) = &descriptor.argument_hint {
|
||||||
|
lines.push(dimmed_line(format!("Arguments: {argument_hint}")));
|
||||||
|
}
|
||||||
|
if let Some(description) = &descriptor.description {
|
||||||
|
lines.push(dimmed_line(description));
|
||||||
|
}
|
||||||
|
if !lines.is_empty() {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.contains(char::is_whitespace) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches = self.matching_commands(input);
|
||||||
|
if matches.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = vec![dimmed_line("Suggestions")];
|
||||||
|
lines.extend(matches.into_iter().take(3).map(|command| {
|
||||||
|
let description = self
|
||||||
|
.find_command_descriptor(command.trim_start_matches('/'))
|
||||||
|
.and_then(|descriptor| descriptor.description.as_deref())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if description.is_empty() {
|
||||||
|
dimmed_line(format!(" {command}"))
|
||||||
|
} else {
|
||||||
|
dimmed_line(format!(" {command:<18} {description}"))
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_command_descriptor(&self, name: &str) -> Option<&SlashCommandDescriptor> {
|
||||||
|
let normalized = name.trim().trim_start_matches('/').to_ascii_lowercase();
|
||||||
|
self.slash_commands.iter().find(|descriptor| {
|
||||||
|
descriptor.command.trim_start_matches('/').eq_ignore_ascii_case(&normalized)
|
||||||
|
|| descriptor
|
||||||
|
.aliases
|
||||||
|
.iter()
|
||||||
|
.any(|alias| alias.trim_start_matches('/').eq_ignore_ascii_case(&normalized))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn history_up(&self, session: &mut EditSession) {
|
fn history_up(&self, session: &mut EditSession) {
|
||||||
@@ -964,6 +1124,27 @@ fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> {
|
|||||||
Some(prefix)
|
Some(prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn command_and_args(input: &str) -> Option<(&str, &str)> {
|
||||||
|
let trimmed = input.trim_start();
|
||||||
|
let without_slash = trimmed.strip_prefix('/')?;
|
||||||
|
let (command, args) = without_slash
|
||||||
|
.split_once(' ')
|
||||||
|
.map_or((without_slash, ""), |(command, args)| (command, args));
|
||||||
|
Some((command, args))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn completed_command(command: &str) -> String {
|
||||||
|
if command.ends_with(' ') {
|
||||||
|
command.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{command} ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dimmed_line(text: impl AsRef<str>) -> String {
|
||||||
|
format!("\x1b[2m{}\x1b[0m", text.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
fn to_u16(value: usize) -> io::Result<u16> {
|
fn to_u16(value: usize) -> io::Result<u16> {
|
||||||
u16::try_from(value).map_err(|_| {
|
u16::try_from(value).map_err(|_| {
|
||||||
io::Error::new(
|
io::Error::new(
|
||||||
@@ -977,6 +1158,7 @@ fn to_u16(value: usize) -> io::Result<u16> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
selection_bounds, slash_command_prefix, EditSession, EditorMode, KeyAction, LineEditor,
|
selection_bounds, slash_command_prefix, EditSession, EditorMode, KeyAction, LineEditor,
|
||||||
|
SlashCommandDescriptor,
|
||||||
};
|
};
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
@@ -1148,8 +1330,8 @@ mod tests {
|
|||||||
editor.complete_slash_command(&mut session);
|
editor.complete_slash_command(&mut session);
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(session.text, "/help");
|
assert_eq!(session.text, "/help ");
|
||||||
assert_eq!(session.cursor, 5);
|
assert_eq!(session.cursor, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1171,8 +1353,65 @@ mod tests {
|
|||||||
let second = session.text.clone();
|
let second = session.text.clone();
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(first, "/permissions");
|
assert_eq!(first, "/plugin ");
|
||||||
assert_eq!(second, "/plugin");
|
assert_eq!(second, "/permissions ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_completion_prefers_canonical_command_over_alias() {
|
||||||
|
let mut editor = LineEditor::with_slash_commands(
|
||||||
|
"> ",
|
||||||
|
vec![SlashCommandDescriptor {
|
||||||
|
command: "/plugin".to_string(),
|
||||||
|
description: Some("Manage plugins".to_string()),
|
||||||
|
argument_hint: Some("[list]".to_string()),
|
||||||
|
aliases: vec!["/plugins".to_string(), "/marketplace".to_string()],
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let mut session = EditSession::new(false);
|
||||||
|
session.text = "/plugins".to_string();
|
||||||
|
session.cursor = session.text.len();
|
||||||
|
|
||||||
|
editor.complete_slash_command(&mut session);
|
||||||
|
|
||||||
|
assert_eq!(session.text, "/plugin ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_assist_lines_show_suggestions_and_argument_hints() {
|
||||||
|
let editor = LineEditor::with_slash_commands(
|
||||||
|
"> ",
|
||||||
|
vec![
|
||||||
|
SlashCommandDescriptor {
|
||||||
|
command: "/help".to_string(),
|
||||||
|
description: Some("Show help and available commands".to_string()),
|
||||||
|
argument_hint: None,
|
||||||
|
aliases: Vec::new(),
|
||||||
|
},
|
||||||
|
SlashCommandDescriptor {
|
||||||
|
command: "/model".to_string(),
|
||||||
|
description: Some("Show or switch the active model".to_string()),
|
||||||
|
argument_hint: Some("[model]".to_string()),
|
||||||
|
aliases: Vec::new(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut prefix_session = EditSession::new(false);
|
||||||
|
prefix_session.text = "/h".to_string();
|
||||||
|
prefix_session.cursor = prefix_session.text.len();
|
||||||
|
let prefix_lines = editor.command_assist_lines(&prefix_session);
|
||||||
|
assert!(prefix_lines.iter().any(|line| line.contains("Suggestions")));
|
||||||
|
assert!(prefix_lines.iter().any(|line| line.contains("/help")));
|
||||||
|
|
||||||
|
let mut hint_session = EditSession::new(false);
|
||||||
|
hint_session.text = "/model ".to_string();
|
||||||
|
hint_session.cursor = hint_session.text.len();
|
||||||
|
let hint_lines = editor.command_assist_lines(&hint_session);
|
||||||
|
assert!(hint_lines.iter().any(|line| line.contains("Arguments: [model]")));
|
||||||
|
assert!(hint_lines
|
||||||
|
.iter()
|
||||||
|
.any(|line| line.contains("Show or switch the active model")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use commands::{
|
|||||||
};
|
};
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use init::initialize_repo;
|
use init::initialize_repo;
|
||||||
|
use input::SlashCommandDescriptor;
|
||||||
use plugins::{PluginManager, PluginManagerConfig};
|
use plugins::{PluginManager, PluginManagerConfig};
|
||||||
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
@@ -1009,7 +1010,7 @@ fn run_repl(
|
|||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
|
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
|
||||||
let mut editor = input::LineEditor::new("> ", slash_command_completion_candidates());
|
let mut editor = input::LineEditor::with_slash_commands("> ", slash_command_descriptors());
|
||||||
println!("{}", cli.startup_banner());
|
println!("{}", cli.startup_banner());
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -1141,13 +1142,14 @@ impl LiveCli {
|
|||||||
format!(
|
format!(
|
||||||
" Quick start {}",
|
" Quick start {}",
|
||||||
if has_claw_md {
|
if has_claw_md {
|
||||||
"/help · /status · ask for a task"
|
"Type / to browse commands · /help for shortcuts · ask for a task"
|
||||||
} else {
|
} else {
|
||||||
"/init · /help · /status"
|
"/init · then type / to browse commands"
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
" Editor Tab completes slash commands · /vim toggles modal editing"
|
" Autocomplete Type / for command suggestions · Tab accepts or cycles"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
" Editor /vim toggles modal editing · Esc clears menus first".to_string(),
|
||||||
" Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(),
|
" Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(),
|
||||||
];
|
];
|
||||||
if !has_claw_md {
|
if !has_claw_md {
|
||||||
@@ -1973,14 +1975,15 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
|
|||||||
fn render_repl_help() -> String {
|
fn render_repl_help() -> String {
|
||||||
[
|
[
|
||||||
"Interactive REPL".to_string(),
|
"Interactive REPL".to_string(),
|
||||||
" Quick start Ask a task in plain English or use one of the core commands below."
|
" Quick start Ask a task in plain English, or type / to browse slash commands."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
" Core commands /help · /status · /model · /permissions · /compact".to_string(),
|
" Core commands /help · /status · /model · /permissions · /compact".to_string(),
|
||||||
" Exit /exit or /quit".to_string(),
|
" Exit /exit or /quit".to_string(),
|
||||||
|
" Autocomplete Type / for suggestions · Tab accepts or cycles matches".to_string(),
|
||||||
" Vim mode /vim toggles modal editing".to_string(),
|
" Vim mode /vim toggles modal editing".to_string(),
|
||||||
" History Up/Down recalls previous prompts".to_string(),
|
" History Up/Down recalls previous prompts".to_string(),
|
||||||
" Completion Tab cycles slash command matches".to_string(),
|
" Cancel Esc dismisses menus first · Ctrl-C clears input (or exits on empty)"
|
||||||
" Cancel Ctrl-C clears input (or exits on an empty prompt)".to_string(),
|
.to_string(),
|
||||||
" Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(),
|
" Multiline Shift+Enter or Ctrl+J inserts a newline".to_string(),
|
||||||
String::new(),
|
String::new(),
|
||||||
render_slash_command_help(),
|
render_slash_command_help(),
|
||||||
@@ -3283,21 +3286,44 @@ fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec<serde_json::Value
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn slash_command_completion_candidates() -> Vec<String> {
|
fn slash_command_descriptors() -> Vec<SlashCommandDescriptor> {
|
||||||
let mut candidates = slash_command_specs()
|
let mut descriptors = slash_command_specs()
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|spec| {
|
.map(|spec| SlashCommandDescriptor {
|
||||||
std::iter::once(spec.name)
|
command: format!("/{}", spec.name),
|
||||||
.chain(spec.aliases.iter().copied())
|
description: Some(spec.summary.to_string()),
|
||||||
.map(|name| format!("/{name}"))
|
argument_hint: spec.argument_hint.map(ToOwned::to_owned),
|
||||||
|
aliases: spec.aliases.iter().map(|alias| format!("/{alias}")).collect(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
descriptors.extend([
|
||||||
|
SlashCommandDescriptor {
|
||||||
|
command: "/vim".to_string(),
|
||||||
|
description: Some("Toggle modal editing".to_string()),
|
||||||
|
argument_hint: None,
|
||||||
|
aliases: Vec::new(),
|
||||||
|
},
|
||||||
|
SlashCommandDescriptor {
|
||||||
|
command: "/exit".to_string(),
|
||||||
|
description: Some("Exit the interactive REPL".to_string()),
|
||||||
|
argument_hint: None,
|
||||||
|
aliases: vec!["/quit".to_string()],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
descriptors.sort_by(|left, right| left.command.cmp(&right.command));
|
||||||
|
descriptors.dedup_by(|left, right| left.command == right.command);
|
||||||
|
descriptors
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slash_command_completion_candidates() -> Vec<String> {
|
||||||
|
let mut candidates = slash_command_descriptors()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|descriptor| {
|
||||||
|
std::iter::once(descriptor.command)
|
||||||
|
.chain(descriptor.aliases)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
candidates.extend([
|
|
||||||
String::from("/vim"),
|
|
||||||
String::from("/exit"),
|
|
||||||
String::from("/quit"),
|
|
||||||
]);
|
|
||||||
candidates.sort();
|
candidates.sort();
|
||||||
candidates.dedup();
|
candidates.dedup();
|
||||||
candidates
|
candidates
|
||||||
@@ -3986,6 +4012,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
out,
|
out,
|
||||||
" /help Browse the full slash command map"
|
" /help Browse the full slash command map"
|
||||||
)?;
|
)?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" / Open slash suggestions in the REPL"
|
||||||
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" /status Inspect session + workspace state"
|
" /status Inspect session + workspace state"
|
||||||
@@ -4000,7 +4030,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" Tab Complete slash commands"
|
" Tab Accept or cycle slash command suggestions"
|
||||||
)?;
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
@@ -4115,7 +4145,8 @@ mod tests {
|
|||||||
normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy,
|
normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy,
|
||||||
print_help_to, push_output_block, render_config_report, render_memory_report,
|
print_help_to, push_output_block, render_config_report, render_memory_report,
|
||||||
render_repl_help, render_unknown_repl_command, resolve_model_alias, response_to_events,
|
render_repl_help, render_unknown_repl_command, resolve_model_alias, response_to_events,
|
||||||
resume_supported_slash_commands, slash_command_completion_candidates, status_context,
|
resume_supported_slash_commands, slash_command_completion_candidates,
|
||||||
|
slash_command_descriptors, status_context,
|
||||||
CliAction, CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState,
|
CliAction, CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState,
|
||||||
SlashCommand, StatusUsage, DEFAULT_MODEL,
|
SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||||
};
|
};
|
||||||
@@ -4439,6 +4470,7 @@ mod tests {
|
|||||||
fn repl_help_includes_shared_commands_and_exit() {
|
fn repl_help_includes_shared_commands_and_exit() {
|
||||||
let help = render_repl_help();
|
let help = render_repl_help();
|
||||||
assert!(help.contains("Interactive REPL"));
|
assert!(help.contains("Interactive REPL"));
|
||||||
|
assert!(help.contains("type / to browse slash commands"));
|
||||||
assert!(help.contains("/help"));
|
assert!(help.contains("/help"));
|
||||||
assert!(help.contains("/status"));
|
assert!(help.contains("/status"));
|
||||||
assert!(help.contains("/model [model]"));
|
assert!(help.contains("/model [model]"));
|
||||||
@@ -4460,7 +4492,8 @@ mod tests {
|
|||||||
assert!(help.contains("/agents"));
|
assert!(help.contains("/agents"));
|
||||||
assert!(help.contains("/skills"));
|
assert!(help.contains("/skills"));
|
||||||
assert!(help.contains("/exit"));
|
assert!(help.contains("/exit"));
|
||||||
assert!(help.contains("Tab cycles slash command matches"));
|
assert!(help.contains("Type / for suggestions"));
|
||||||
|
assert!(help.contains("Tab accepts or cycles matches"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4472,6 +4505,27 @@ mod tests {
|
|||||||
assert!(candidates.contains(&"/quit".to_string()));
|
assert!(candidates.contains(&"/quit".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slash_command_descriptors_include_descriptions_and_aliases() {
|
||||||
|
let descriptors = slash_command_descriptors();
|
||||||
|
let plugin = descriptors
|
||||||
|
.iter()
|
||||||
|
.find(|descriptor| descriptor.command == "/plugin")
|
||||||
|
.expect("plugin descriptor should exist");
|
||||||
|
assert_eq!(
|
||||||
|
plugin.description.as_deref(),
|
||||||
|
Some("Manage Claw Code plugins")
|
||||||
|
);
|
||||||
|
assert!(plugin.aliases.contains(&"/plugins".to_string()));
|
||||||
|
assert!(plugin.aliases.contains(&"/marketplace".to_string()));
|
||||||
|
|
||||||
|
let exit = descriptors
|
||||||
|
.iter()
|
||||||
|
.find(|descriptor| descriptor.command == "/exit")
|
||||||
|
.expect("exit descriptor should exist");
|
||||||
|
assert!(exit.aliases.contains(&"/quit".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_repl_command_suggestions_include_repl_shortcuts() {
|
fn unknown_repl_command_suggestions_include_repl_shortcuts() {
|
||||||
let rendered = render_unknown_repl_command("exi");
|
let rendered = render_unknown_repl_command("exi");
|
||||||
@@ -4559,6 +4613,7 @@ mod tests {
|
|||||||
print_help_to(&mut help).expect("help should render");
|
print_help_to(&mut help).expect("help should render");
|
||||||
let help = String::from_utf8(help).expect("help should be utf8");
|
let help = String::from_utf8(help).expect("help should be utf8");
|
||||||
assert!(help.contains("claw init"));
|
assert!(help.contains("claw init"));
|
||||||
|
assert!(help.contains("Open slash suggestions in the REPL"));
|
||||||
assert!(help.contains("claw agents"));
|
assert!(help.contains("claw agents"));
|
||||||
assert!(help.contains("claw skills"));
|
assert!(help.contains("claw skills"));
|
||||||
assert!(help.contains("claw /skills"));
|
assert!(help.contains("claw /skills"));
|
||||||
@@ -4762,7 +4817,7 @@ mod tests {
|
|||||||
fn repl_help_mentions_history_completion_and_multiline() {
|
fn repl_help_mentions_history_completion_and_multiline() {
|
||||||
let help = render_repl_help();
|
let help = render_repl_help();
|
||||||
assert!(help.contains("Up/Down"));
|
assert!(help.contains("Up/Down"));
|
||||||
assert!(help.contains("Tab cycles"));
|
assert!(help.contains("Tab accepts or cycles"));
|
||||||
assert!(help.contains("Shift+Enter or Ctrl+J"));
|
assert!(help.contains("Shift+Enter or Ctrl+J"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user