mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-03 15:24:47 +08:00
Make the REPL feel more reliable and discoverable
This pass hardens the interactive UX instead of chasing feature breadth. It preserves raw REPL input whitespace, honors the configured editorMode for vim-oriented sessions, improves slash-command help readability, and turns unknown slash commands into actionable guidance instead of noisy stderr output. Constraint: Keep the existing slash-command surface and avoid new dependencies Rejected: Full TUI/input rewrite | too broad for a polish-and-reliability pass Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve user prompt text exactly in the REPL path; do not reintroduce blanket trimming before runtime submission Tested: cargo check Tested: cargo test Tested: Manual QA of /help, /status, /statu suggestion flow, and editorMode=vim banner/help/status behavior Not-tested: Live network-backed assistant turns against a real provider
This commit is contained in:
@@ -2,7 +2,7 @@ use std::io::{self, Write};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::args::{OutputFormat, PermissionMode};
|
use crate::args::{OutputFormat, PermissionMode};
|
||||||
use crate::input::{LineEditor, ReadOutcome};
|
use crate::input::{EditorMode, LineEditor, ReadOutcome};
|
||||||
use crate::render::{Spinner, TerminalRenderer};
|
use crate::render::{Spinner, TerminalRenderer};
|
||||||
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
|
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ impl CliApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_repl(&mut self) -> io::Result<()> {
|
pub fn run_repl(&mut self) -> io::Result<()> {
|
||||||
let mut editor = LineEditor::new("› ", Vec::new());
|
let mut editor = LineEditor::new("› ", Vec::new(), EditorMode::Emacs);
|
||||||
println!("Claw Code interactive mode");
|
println!("Claw Code interactive mode");
|
||||||
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,38 @@ pub enum ReadOutcome {
|
|||||||
Exit,
|
Exit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EditorMode {
|
||||||
|
Emacs,
|
||||||
|
Vim,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditorMode {
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_config_value(value: Option<&str>) -> Self {
|
||||||
|
match value {
|
||||||
|
Some("vim") => Self::Vim,
|
||||||
|
Some("emacs") | Some("default") | None => Self::Emacs,
|
||||||
|
Some(_) => Self::Emacs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Emacs => "emacs",
|
||||||
|
Self::Vim => "vim",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn rustyline_mode(self) -> EditMode {
|
||||||
|
match self {
|
||||||
|
Self::Emacs => EditMode::Emacs,
|
||||||
|
Self::Vim => EditMode::Vi,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SlashCommandHelper {
|
struct SlashCommandHelper {
|
||||||
completions: Vec<String>,
|
completions: Vec<String>,
|
||||||
current_line: RefCell<String>,
|
current_line: RefCell<String>,
|
||||||
@@ -100,10 +132,10 @@ pub struct LineEditor {
|
|||||||
|
|
||||||
impl LineEditor {
|
impl LineEditor {
|
||||||
#[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>, mode: EditorMode) -> Self {
|
||||||
let config = Config::builder()
|
let config = Config::builder()
|
||||||
.completion_type(CompletionType::List)
|
.completion_type(CompletionType::List)
|
||||||
.edit_mode(EditMode::Emacs)
|
.edit_mode(mode.rustyline_mode())
|
||||||
.build();
|
.build();
|
||||||
let mut editor = Editor::<SlashCommandHelper, DefaultHistory>::with_config(config)
|
let mut editor = Editor::<SlashCommandHelper, DefaultHistory>::with_config(config)
|
||||||
.expect("rustyline editor should initialize");
|
.expect("rustyline editor should initialize");
|
||||||
@@ -201,7 +233,7 @@ fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{slash_command_prefix, LineEditor, SlashCommandHelper};
|
use super::{slash_command_prefix, EditorMode, LineEditor, SlashCommandHelper};
|
||||||
use rustyline::completion::Completer;
|
use rustyline::completion::Completer;
|
||||||
use rustyline::highlight::Highlighter;
|
use rustyline::highlight::Highlighter;
|
||||||
use rustyline::history::{DefaultHistory, History};
|
use rustyline::history::{DefaultHistory, History};
|
||||||
@@ -260,10 +292,28 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn push_history_ignores_blank_entries() {
|
fn push_history_ignores_blank_entries() {
|
||||||
let mut editor = LineEditor::new("> ", vec!["/help".to_string()]);
|
let mut editor = LineEditor::new("> ", vec!["/help".to_string()], EditorMode::Emacs);
|
||||||
editor.push_history(" ");
|
editor.push_history(" ");
|
||||||
editor.push_history("/help");
|
editor.push_history("/help");
|
||||||
|
|
||||||
assert_eq!(editor.editor.history().len(), 1);
|
assert_eq!(editor.editor.history().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_editor_mode_from_config_values() {
|
||||||
|
assert_eq!(EditorMode::from_config_value(Some("vim")), EditorMode::Vim);
|
||||||
|
assert_eq!(
|
||||||
|
EditorMode::from_config_value(Some("emacs")),
|
||||||
|
EditorMode::Emacs
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
EditorMode::from_config_value(Some("default")),
|
||||||
|
EditorMode::Emacs
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
EditorMode::from_config_value(Some("wat")),
|
||||||
|
EditorMode::Emacs
|
||||||
|
);
|
||||||
|
assert_eq!(EditorMode::from_config_value(None), EditorMode::Emacs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use api::{
|
use api::{
|
||||||
resolve_startup_auth_source, ClawApiClient, AuthSource, ContentBlockDelta, InputContentBlock,
|
resolve_startup_auth_source, AuthSource, ClawApiClient, ContentBlockDelta, InputContentBlock,
|
||||||
InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
|
InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
|
||||||
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||||
};
|
};
|
||||||
@@ -828,7 +828,7 @@ fn run_resume_command(
|
|||||||
match command {
|
match command {
|
||||||
SlashCommand::Help => Ok(ResumeCommandOutcome {
|
SlashCommand::Help => Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(render_repl_help()),
|
message: Some(render_repl_help(resolve_editor_mode())),
|
||||||
}),
|
}),
|
||||||
SlashCommand::Compact => {
|
SlashCommand::Compact => {
|
||||||
let result = runtime::compact_session(
|
let result = runtime::compact_session(
|
||||||
@@ -881,6 +881,7 @@ fn run_resume_command(
|
|||||||
estimated_tokens: 0,
|
estimated_tokens: 0,
|
||||||
},
|
},
|
||||||
default_permission_mode().as_str(),
|
default_permission_mode().as_str(),
|
||||||
|
resolve_editor_mode().label(),
|
||||||
&status_context(Some(session_path))?,
|
&status_context(Some(session_path))?,
|
||||||
)),
|
)),
|
||||||
})
|
})
|
||||||
@@ -960,28 +961,29 @@ 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::new("> ", slash_command_completion_candidates(), cli.editor_mode);
|
||||||
println!("{}", cli.startup_banner());
|
println!("{}", cli.startup_banner());
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match editor.read_line()? {
|
match editor.read_line()? {
|
||||||
input::ReadOutcome::Submit(input) => {
|
input::ReadOutcome::Submit(input) => {
|
||||||
let trimmed = input.trim().to_string();
|
let trimmed = input.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if matches!(trimmed.as_str(), "/exit" | "/quit") {
|
if matches!(trimmed, "/exit" | "/quit") {
|
||||||
cli.persist_session()?;
|
cli.persist_session()?;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Some(command) = SlashCommand::parse(&trimmed) {
|
if let Some(command) = SlashCommand::parse(trimmed) {
|
||||||
if cli.handle_repl_command(command)? {
|
if cli.handle_repl_command(command)? {
|
||||||
cli.persist_session()?;
|
cli.persist_session()?;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
editor.push_history(input);
|
editor.push_history(&input);
|
||||||
cli.run_turn(&trimmed)?;
|
cli.run_turn(&input)?;
|
||||||
}
|
}
|
||||||
input::ReadOutcome::Cancel => {}
|
input::ReadOutcome::Cancel => {}
|
||||||
input::ReadOutcome::Exit => {
|
input::ReadOutcome::Exit => {
|
||||||
@@ -1012,6 +1014,7 @@ struct LiveCli {
|
|||||||
model: String,
|
model: String,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
|
editor_mode: input::EditorMode,
|
||||||
system_prompt: Vec<String>,
|
system_prompt: Vec<String>,
|
||||||
runtime: ConversationRuntime<DefaultRuntimeClient, CliToolExecutor>,
|
runtime: ConversationRuntime<DefaultRuntimeClient, CliToolExecutor>,
|
||||||
session: SessionHandle,
|
session: SessionHandle,
|
||||||
@@ -1025,6 +1028,7 @@ impl LiveCli {
|
|||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let system_prompt = build_system_prompt()?;
|
let system_prompt = build_system_prompt()?;
|
||||||
|
let editor_mode = resolve_editor_mode();
|
||||||
let session = create_managed_session_handle()?;
|
let session = create_managed_session_handle()?;
|
||||||
let runtime = build_runtime(
|
let runtime = build_runtime(
|
||||||
Session::new(),
|
Session::new(),
|
||||||
@@ -1040,6 +1044,7 @@ impl LiveCli {
|
|||||||
model,
|
model,
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
permission_mode,
|
permission_mode,
|
||||||
|
editor_mode,
|
||||||
system_prompt,
|
system_prompt,
|
||||||
runtime,
|
runtime,
|
||||||
session,
|
session,
|
||||||
@@ -1063,11 +1068,13 @@ impl LiveCli {
|
|||||||
╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\
|
╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\
|
||||||
\x1b[2mModel\x1b[0m {}\n\
|
\x1b[2mModel\x1b[0m {}\n\
|
||||||
\x1b[2mPermissions\x1b[0m {}\n\
|
\x1b[2mPermissions\x1b[0m {}\n\
|
||||||
|
\x1b[2mInput mode\x1b[0m {}\n\
|
||||||
\x1b[2mDirectory\x1b[0m {}\n\
|
\x1b[2mDirectory\x1b[0m {}\n\
|
||||||
\x1b[2mSession\x1b[0m {}\n\n\
|
\x1b[2mSession\x1b[0m {}\n\n\
|
||||||
Type \x1b[1m/help\x1b[0m for commands · \x1b[2mShift+Enter\x1b[0m for newline",
|
Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/exit\x1b[0m to quit · \x1b[2mShift+Enter\x1b[0m for newline",
|
||||||
self.model,
|
self.model,
|
||||||
self.permission_mode.as_str(),
|
self.permission_mode.as_str(),
|
||||||
|
self.editor_mode.label(),
|
||||||
cwd,
|
cwd,
|
||||||
self.session.id,
|
self.session.id,
|
||||||
)
|
)
|
||||||
@@ -1157,7 +1164,7 @@ impl LiveCli {
|
|||||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
Ok(match command {
|
Ok(match command {
|
||||||
SlashCommand::Help => {
|
SlashCommand::Help => {
|
||||||
println!("{}", render_repl_help());
|
println!("{}", render_repl_help(self.editor_mode));
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
SlashCommand::Status => {
|
SlashCommand::Status => {
|
||||||
@@ -1243,7 +1250,7 @@ impl LiveCli {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
SlashCommand::Unknown(name) => {
|
SlashCommand::Unknown(name) => {
|
||||||
eprintln!("unknown slash command: /{name}");
|
println!("{}", render_unknown_repl_command(&name));
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1269,6 +1276,7 @@ impl LiveCli {
|
|||||||
estimated_tokens: self.runtime.estimated_tokens(),
|
estimated_tokens: self.runtime.estimated_tokens(),
|
||||||
},
|
},
|
||||||
self.permission_mode.as_str(),
|
self.permission_mode.as_str(),
|
||||||
|
self.editor_mode.label(),
|
||||||
&status_context(Some(&self.session.path)).expect("status context should load"),
|
&status_context(Some(&self.session.path)).expect("status context should load"),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -1849,22 +1857,24 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
|
|||||||
Ok(lines.join("\n"))
|
Ok(lines.join("\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_repl_help() -> String {
|
fn render_repl_help(editor_mode: input::EditorMode) -> String {
|
||||||
[
|
let mut lines = vec![
|
||||||
"REPL".to_string(),
|
"REPL".to_string(),
|
||||||
|
format!(" Input mode {}", editor_mode.label()),
|
||||||
" /exit Quit the REPL".to_string(),
|
" /exit Quit the REPL".to_string(),
|
||||||
" /quit Quit the REPL".to_string(),
|
" /quit Quit the REPL".to_string(),
|
||||||
" Up/Down Navigate prompt history".to_string(),
|
" Up/Down Navigate prompt history".to_string(),
|
||||||
" Tab Complete slash commands".to_string(),
|
" Tab Complete slash commands".to_string(),
|
||||||
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
|
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
|
||||||
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
|
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
|
||||||
String::new(),
|
];
|
||||||
render_slash_command_help(),
|
if editor_mode == input::EditorMode::Vim {
|
||||||
]
|
lines.push(" Esc Switch to normal mode".to_string());
|
||||||
.join(
|
lines.push(" i / a Return to insert mode".to_string());
|
||||||
"
|
}
|
||||||
",
|
lines.push(String::new());
|
||||||
)
|
lines.push(render_slash_command_help());
|
||||||
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn status_context(
|
fn status_context(
|
||||||
@@ -1892,6 +1902,7 @@ fn format_status_report(
|
|||||||
model: &str,
|
model: &str,
|
||||||
usage: StatusUsage,
|
usage: StatusUsage,
|
||||||
permission_mode: &str,
|
permission_mode: &str,
|
||||||
|
editor_mode: &str,
|
||||||
context: &StatusContext,
|
context: &StatusContext,
|
||||||
) -> String {
|
) -> String {
|
||||||
[
|
[
|
||||||
@@ -1899,6 +1910,7 @@ fn format_status_report(
|
|||||||
"Status
|
"Status
|
||||||
Model {model}
|
Model {model}
|
||||||
Permission mode {permission_mode}
|
Permission mode {permission_mode}
|
||||||
|
Input mode {editor_mode}
|
||||||
Messages {}
|
Messages {}
|
||||||
Turns {}
|
Turns {}
|
||||||
Estimated tokens {}",
|
Estimated tokens {}",
|
||||||
@@ -2037,8 +2049,7 @@ fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
|
|||||||
if project_context.instruction_files.is_empty() {
|
if project_context.instruction_files.is_empty() {
|
||||||
lines.push("Discovered files".to_string());
|
lines.push("Discovered files".to_string());
|
||||||
lines.push(
|
lines.push(
|
||||||
" No CLAW instruction files discovered in the current directory ancestry."
|
" No CLAW instruction files discovered in the current directory ancestry.".to_string(),
|
||||||
.to_string(),
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
lines.push("Discovered files".to_string());
|
lines.push("Discovered files".to_string());
|
||||||
@@ -2790,7 +2801,8 @@ fn build_runtime(
|
|||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||||
) -> Result<ConversationRuntime<DefaultRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> {
|
) -> Result<ConversationRuntime<DefaultRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
|
||||||
|
{
|
||||||
let (feature_config, tool_registry) = build_runtime_plugin_state()?;
|
let (feature_config, tool_registry) = build_runtime_plugin_state()?;
|
||||||
Ok(ConversationRuntime::new_with_features(
|
Ok(ConversationRuntime::new_with_features(
|
||||||
session,
|
session,
|
||||||
@@ -3101,7 +3113,7 @@ fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec<serde_json::Value
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn slash_command_completion_candidates() -> Vec<String> {
|
fn slash_command_completion_candidates() -> Vec<String> {
|
||||||
slash_command_specs()
|
let mut candidates = slash_command_specs()
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|spec| {
|
.flat_map(|spec| {
|
||||||
std::iter::once(spec.name)
|
std::iter::once(spec.name)
|
||||||
@@ -3109,9 +3121,90 @@ fn slash_command_completion_candidates() -> Vec<String> {
|
|||||||
.map(|name| format!("/{name}"))
|
.map(|name| format!("/{name}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
candidates.extend([String::from("/exit"), String::from("/quit")]);
|
||||||
|
candidates.sort();
|
||||||
|
candidates.dedup();
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_editor_mode() -> input::EditorMode {
|
||||||
|
let cwd = match env::current_dir() {
|
||||||
|
Ok(cwd) => cwd,
|
||||||
|
Err(_) => return input::EditorMode::Emacs,
|
||||||
|
};
|
||||||
|
let loader = ConfigLoader::default_for(cwd);
|
||||||
|
loader
|
||||||
|
.load()
|
||||||
|
.ok()
|
||||||
|
.map(|config| input::EditorMode::from_config_value(config.get_string("editorMode")))
|
||||||
|
.unwrap_or(input::EditorMode::Emacs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_unknown_repl_command(name: &str) -> String {
|
||||||
|
let suggestions = suggest_repl_commands(name);
|
||||||
|
let mut lines = vec![format!("Unknown slash command: /{name}")];
|
||||||
|
if !suggestions.is_empty() {
|
||||||
|
lines.push(format!(" Did you mean {}?", suggestions.join(", ")));
|
||||||
|
}
|
||||||
|
lines.push(" Type /help to list available commands.".to_string());
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn suggest_repl_commands(name: &str) -> Vec<String> {
|
||||||
|
let normalized = name.trim().trim_start_matches('/').to_ascii_lowercase();
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ranked = slash_command_completion_candidates()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|candidate| {
|
||||||
|
let raw = candidate.trim_start_matches('/').to_ascii_lowercase();
|
||||||
|
let distance = edit_distance(&normalized, &raw);
|
||||||
|
let prefix_match = raw.starts_with(&normalized) || normalized.starts_with(&raw);
|
||||||
|
let near_match = distance <= 2;
|
||||||
|
(prefix_match || near_match).then_some((distance, candidate))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
ranked.sort();
|
||||||
|
ranked.dedup_by(|left, right| left.1 == right.1);
|
||||||
|
ranked
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, candidate)| candidate)
|
||||||
|
.take(3)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn edit_distance(left: &str, right: &str) -> usize {
|
||||||
|
if left == right {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if left.is_empty() {
|
||||||
|
return right.chars().count();
|
||||||
|
}
|
||||||
|
if right.is_empty() {
|
||||||
|
return left.chars().count();
|
||||||
|
}
|
||||||
|
|
||||||
|
let right_chars = right.chars().collect::<Vec<_>>();
|
||||||
|
let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
|
||||||
|
let mut current = vec![0; right_chars.len() + 1];
|
||||||
|
|
||||||
|
for (left_index, left_char) in left.chars().enumerate() {
|
||||||
|
current[0] = left_index + 1;
|
||||||
|
for (right_index, right_char) in right_chars.iter().enumerate() {
|
||||||
|
let substitution_cost = usize::from(left_char != *right_char);
|
||||||
|
current[right_index + 1] = (previous[right_index + 1] + 1)
|
||||||
|
.min(current[right_index] + 1)
|
||||||
|
.min(previous[right_index] + substitution_cost);
|
||||||
|
}
|
||||||
|
std::mem::swap(&mut previous, &mut current);
|
||||||
|
}
|
||||||
|
|
||||||
|
previous[right_chars.len()]
|
||||||
|
}
|
||||||
|
|
||||||
fn format_tool_call_start(name: &str, input: &str) -> String {
|
fn format_tool_call_start(name: &str, input: &str) -> String {
|
||||||
let parsed: serde_json::Value =
|
let parsed: serde_json::Value =
|
||||||
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
|
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
|
||||||
@@ -3816,10 +3909,12 @@ mod tests {
|
|||||||
format_status_report, format_tool_call_start, format_tool_result,
|
format_status_report, format_tool_call_start, format_tool_result,
|
||||||
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, resolve_model_alias, response_to_events, resume_supported_slash_commands,
|
render_repl_help, render_unknown_repl_command, resolve_model_alias, response_to_events,
|
||||||
status_context, CliAction, CliOutputFormat, InternalPromptProgressEvent,
|
resume_supported_slash_commands, slash_command_completion_candidates, status_context,
|
||||||
InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
CliAction, CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState,
|
||||||
|
SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||||
};
|
};
|
||||||
|
use crate::input::EditorMode;
|
||||||
use api::{MessageResponse, OutputContentBlock, Usage};
|
use api::{MessageResponse, OutputContentBlock, Usage};
|
||||||
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
||||||
use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode};
|
use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode};
|
||||||
@@ -4131,13 +4226,14 @@ mod tests {
|
|||||||
fn shared_help_uses_resume_annotation_copy() {
|
fn shared_help_uses_resume_annotation_copy() {
|
||||||
let help = commands::render_slash_command_help();
|
let help = commands::render_slash_command_help();
|
||||||
assert!(help.contains("Slash commands"));
|
assert!(help.contains("Slash commands"));
|
||||||
assert!(help.contains("works with --resume SESSION.json"));
|
assert!(help.contains("available via claw --resume SESSION.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
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(EditorMode::Emacs);
|
||||||
assert!(help.contains("REPL"));
|
assert!(help.contains("REPL"));
|
||||||
|
assert!(help.contains("Input mode emacs"));
|
||||||
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]"));
|
||||||
@@ -4161,6 +4257,30 @@ mod tests {
|
|||||||
assert!(help.contains("/exit"));
|
assert!(help.contains("/exit"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repl_help_includes_vim_key_hints_in_vim_mode() {
|
||||||
|
let help = render_repl_help(EditorMode::Vim);
|
||||||
|
assert!(help.contains("Input mode vim"));
|
||||||
|
assert!(help.contains("Esc Switch to normal mode"));
|
||||||
|
assert!(help.contains("i / a Return to insert mode"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_candidates_include_repl_exit_commands() {
|
||||||
|
let candidates = slash_command_completion_candidates();
|
||||||
|
assert!(candidates.contains(&"/exit".to_string()));
|
||||||
|
assert!(candidates.contains(&"/quit".to_string()));
|
||||||
|
assert!(candidates.contains(&"/help".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_repl_command_reports_helpful_suggestions() {
|
||||||
|
let rendered = render_unknown_repl_command("statu");
|
||||||
|
assert!(rendered.contains("Unknown slash command: /statu"));
|
||||||
|
assert!(rendered.contains("/status"));
|
||||||
|
assert!(rendered.contains("Type /help"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resume_supported_command_list_matches_expected_surface() {
|
fn resume_supported_command_list_matches_expected_surface() {
|
||||||
let names = resume_supported_slash_commands()
|
let names = resume_supported_slash_commands()
|
||||||
@@ -4283,6 +4403,7 @@ mod tests {
|
|||||||
estimated_tokens: 128,
|
estimated_tokens: 128,
|
||||||
},
|
},
|
||||||
"workspace-write",
|
"workspace-write",
|
||||||
|
"vim",
|
||||||
&super::StatusContext {
|
&super::StatusContext {
|
||||||
cwd: PathBuf::from("/tmp/project"),
|
cwd: PathBuf::from("/tmp/project"),
|
||||||
session_path: Some(PathBuf::from("session.json")),
|
session_path: Some(PathBuf::from("session.json")),
|
||||||
@@ -4296,6 +4417,7 @@ mod tests {
|
|||||||
assert!(status.contains("Status"));
|
assert!(status.contains("Status"));
|
||||||
assert!(status.contains("Model sonnet"));
|
assert!(status.contains("Model sonnet"));
|
||||||
assert!(status.contains("Permission mode workspace-write"));
|
assert!(status.contains("Permission mode workspace-write"));
|
||||||
|
assert!(status.contains("Input mode vim"));
|
||||||
assert!(status.contains("Messages 7"));
|
assert!(status.contains("Messages 7"));
|
||||||
assert!(status.contains("Latest total 10"));
|
assert!(status.contains("Latest total 10"));
|
||||||
assert!(status.contains("Cumulative total 31"));
|
assert!(status.contains("Cumulative total 31"));
|
||||||
@@ -4438,7 +4560,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
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(EditorMode::Emacs);
|
||||||
assert!(help.contains("Up/Down"));
|
assert!(help.contains("Up/Down"));
|
||||||
assert!(help.contains("Tab"));
|
assert!(help.contains("Tab"));
|
||||||
assert!(help.contains("Shift+Enter/Ctrl+J"));
|
assert!(help.contains("Shift+Enter/Ctrl+J"));
|
||||||
|
|||||||
@@ -389,35 +389,33 @@ pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
|
|||||||
pub fn render_slash_command_help() -> String {
|
pub fn render_slash_command_help() -> String {
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
"Slash commands".to_string(),
|
"Slash commands".to_string(),
|
||||||
" [resume] means the command also works with --resume SESSION.json".to_string(),
|
" [resume] = also available via claw --resume SESSION.json".to_string(),
|
||||||
];
|
];
|
||||||
for spec in slash_command_specs() {
|
for spec in slash_command_specs() {
|
||||||
let name = match spec.argument_hint {
|
let name = match spec.argument_hint {
|
||||||
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
||||||
None => format!("/{}", spec.name),
|
None => format!("/{}", spec.name),
|
||||||
};
|
};
|
||||||
let alias_suffix = if spec.aliases.is_empty() {
|
lines.push(format!(" {name}"));
|
||||||
String::new()
|
lines.push(format!(" {}", spec.summary));
|
||||||
} else {
|
if !spec.aliases.is_empty() || spec.resume_supported {
|
||||||
format!(
|
let mut details = Vec::new();
|
||||||
" (aliases: {})",
|
if !spec.aliases.is_empty() {
|
||||||
|
details.push(format!(
|
||||||
|
"aliases: {}",
|
||||||
spec.aliases
|
spec.aliases
|
||||||
.iter()
|
.iter()
|
||||||
.map(|alias| format!("/{alias}"))
|
.map(|alias| format!("/{alias}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
)
|
|
||||||
};
|
|
||||||
let resume = if spec.resume_supported {
|
|
||||||
" [resume]"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
lines.push(format!(
|
|
||||||
" {name:<20} {}{alias_suffix}{resume}",
|
|
||||||
spec.summary
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if spec.resume_supported {
|
||||||
|
details.push("[resume]".to_string());
|
||||||
|
}
|
||||||
|
lines.push(format!(" {}", details.join(" · ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1413,7 +1411,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn renders_help_from_shared_specs() {
|
fn renders_help_from_shared_specs() {
|
||||||
let help = render_slash_command_help();
|
let help = render_slash_command_help();
|
||||||
assert!(help.contains("works with --resume SESSION.json"));
|
assert!(help.contains("available via claw --resume SESSION.json"));
|
||||||
assert!(help.contains("/help"));
|
assert!(help.contains("/help"));
|
||||||
assert!(help.contains("/status"));
|
assert!(help.contains("/status"));
|
||||||
assert!(help.contains("/compact"));
|
assert!(help.contains("/compact"));
|
||||||
|
|||||||
@@ -284,6 +284,11 @@ impl RuntimeConfig {
|
|||||||
self.merged.get(key)
|
self.merged.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn get_string(&self, key: &str) -> Option<&str> {
|
||||||
|
self.get(key).and_then(JsonValue::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn as_json(&self) -> JsonValue {
|
pub fn as_json(&self) -> JsonValue {
|
||||||
JsonValue::Object(self.merged.clone())
|
JsonValue::Object(self.merged.clone())
|
||||||
|
|||||||
Reference in New Issue
Block a user