Make the CLI feel guided and navigable before release

This redesign pass tightens the first-run and interactive experience
without changing the core execution model. The startup banner is now a
compact readiness summary instead of a large logo block, help output is
layered into quick-start and grouped slash-command sections, status and
permissions views read like operator dashboards, and direct/interactive
error surfaces now point users toward the next useful action.

The REPL also gains cycling slash-command completion so discoverability
improves even before a user has memorized the command set. Shared slash
command metadata now drives grouped help rendering and lightweight
command suggestions, which keeps interactive and non-interactive copy in
sync.

Constraint: Pre-release UX pass had to stay inside the existing Rust workspace with no new dependencies
Constraint: Existing slash command behavior and tests had to remain compatible while improving presentation
Rejected: Introduce a full-screen TUI command palette | too large and risky for this release pass
Rejected: Add trailing-space smart completion for argument-taking commands | conflicted with reliable completion cycling
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep startup hints, grouped slash help, and completion behavior aligned with slash_command_specs as commands evolve
Tested: cargo check
Tested: cargo test
Tested: Manual QA of `claw --help`, piped REPL `/help` `/status` `/permissions` `/session list` `/wat`, direct `/wat`, and interactive Tab cycling in the REPL
Not-tested: Live network-backed conversation turns and long streaming sessions
This commit is contained in:
Yeachan-Heo
2026-04-01 13:36:17 +00:00
parent c0d30934e7
commit a121285a0e
3 changed files with 624 additions and 182 deletions

View File

@@ -244,6 +244,14 @@ pub struct LineEditor {
history: Vec<String>,
yank_buffer: YankBuffer,
vim_enabled: bool,
completion_state: Option<CompletionState>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CompletionState {
prefix: String,
matches: Vec<String>,
next_index: usize,
}
impl LineEditor {
@@ -255,6 +263,7 @@ impl LineEditor {
history: Vec::new(),
yank_buffer: YankBuffer::default(),
vim_enabled: false,
completion_state: None,
}
}
@@ -357,6 +366,10 @@ impl LineEditor {
}
fn handle_key_event(&mut self, session: &mut EditSession, key: KeyEvent) -> KeyAction {
if key.code != KeyCode::Tab {
self.completion_state = None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') | KeyCode::Char('C') => {
@@ -673,22 +686,62 @@ impl LineEditor {
session.cursor = insert_at + self.yank_buffer.text.len();
}
fn complete_slash_command(&self, session: &mut EditSession) {
fn complete_slash_command(&mut self, session: &mut EditSession) {
if session.mode == EditorMode::Command {
self.completion_state = None;
return;
}
if let Some(state) = self
.completion_state
.as_mut()
.filter(|_| session.cursor == session.text.len())
.filter(|state| {
state
.matches
.iter()
.any(|candidate| candidate == &session.text)
})
{
let candidate = state.matches[state.next_index % state.matches.len()].clone();
state.next_index += 1;
session.text.replace_range(..session.cursor, &candidate);
session.cursor = candidate.len();
return;
}
let Some(prefix) = slash_command_prefix(&session.text, session.cursor) else {
self.completion_state = None;
return;
};
let Some(candidate) = self
let matches = self
.completions
.iter()
.find(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
else {
.filter(|candidate| candidate.starts_with(prefix) && candidate.as_str() != prefix)
.cloned()
.collect::<Vec<_>>();
if matches.is_empty() {
self.completion_state = None;
return;
}
let candidate = if let Some(state) = self
.completion_state
.as_mut()
.filter(|state| state.prefix == prefix && state.matches == matches)
{
let index = state.next_index % state.matches.len();
state.next_index += 1;
state.matches[index].clone()
} else {
let candidate = matches[0].clone();
self.completion_state = Some(CompletionState {
prefix: prefix.to_string(),
matches,
next_index: 1,
});
candidate
};
session.text.replace_range(..session.cursor, candidate);
session.text.replace_range(..session.cursor, &candidate);
session.cursor = candidate.len();
}
@@ -1086,7 +1139,7 @@ mod tests {
#[test]
fn tab_completes_matching_slash_commands() {
// given
let editor = LineEditor::new("> ", vec!["/help".to_string(), "/hello".to_string()]);
let mut editor = LineEditor::new("> ", vec!["/help".to_string(), "/hello".to_string()]);
let mut session = EditSession::new(false);
session.text = "/he".to_string();
session.cursor = session.text.len();
@@ -1099,6 +1152,29 @@ mod tests {
assert_eq!(session.cursor, 5);
}
#[test]
fn tab_cycles_between_matching_slash_commands() {
// given
let mut editor = LineEditor::new(
"> ",
vec!["/permissions".to_string(), "/plugin".to_string()],
);
let mut session = EditSession::new(false);
session.text = "/p".to_string();
session.cursor = session.text.len();
// when
editor.complete_slash_command(&mut session);
let first = session.text.clone();
session.cursor = session.text.len();
editor.complete_slash_command(&mut session);
let second = session.text.clone();
// then
assert_eq!(first, "/permissions");
assert_eq!(second, "/plugin");
}
#[test]
fn ctrl_c_cancels_when_input_exists() {
// given