use crate::format::tool_fmt::truncate_for_summary;
use ninmu_commands::slash_command_specs;
pub(crate) fn classify_error_kind(message: &str) -> &'static str {
if message.contains("missing Anthropic credentials") {
"missing_credentials"
} else if message.contains("Manifest source files are missing") {
"missing_manifests"
} else if message.contains("no worker state file found") {
"missing_worker_state"
} else if message.contains("session not found") {
"session_not_found"
} else if message.contains("failed to restore session") {
"session_load_failed"
} else if message.contains("no managed sessions found") {
"no_managed_sessions"
} else if message.contains("unrecognized argument") || message.contains("unknown option") {
"cli_parse"
} else if message.contains("invalid model syntax") {
"invalid_model_syntax"
} else if message.contains("is not yet implemented") {
"unsupported_command"
} else if message.contains("unsupported resumed command") {
"unsupported_resumed_command"
} else if message.contains("confirmation required") {
"confirmation_required"
} else if message.contains("api failed") || message.contains("api returned") {
"api_http_error"
} else {
"unknown"
}
}
pub(crate) fn split_error_hint(message: &str) -> (String, Option<String>) {
match message.split_once('\n') {
Some((short, hint)) => (short.to_string(), Some(hint.trim().to_string())),
None => (message.to_string(), None),
}
}
pub(crate) fn format_unknown_option(option: &str) -> String {
let mut message = format!("unknown option: {option}");
if let Some(suggestion) = suggest_closest_term(option, CLI_OPTION_SUGGESTIONS) {
message.push_str("\nDid you mean ");
message.push_str(suggestion);
message.push('?');
}
message.push_str("\nRun `ninmu --help` for usage.");
message
}
pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String {
let mut message = format!("unknown slash command outside the REPL: /{name}");
if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name))
{
message.push('\n');
message.push_str(&suggestions);
}
if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) {
message.push('\n');
message.push_str(note);
}
message.push_str("\nRun `ninmu --help` for CLI usage, or start `ninmu` and use /help.");
message
}
pub(crate) fn format_unknown_slash_command(name: &str) -> String {
let mut message = format!("Unknown slash command: /{name}");
if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name))
{
message.push('\n');
message.push_str(&suggestions);
}
if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) {
message.push('\n');
message.push_str(note);
}
message.push_str("\n Help /help lists available slash commands");
message
}
pub(crate) fn omc_compatibility_note_for_unknown_slash_command(name: &str) -> Option<&'static str> {
name.starts_with("oh-my-claudecode:")
.then_some(
"Compatibility note: `/oh-my-claudecode:*` is a Claude Code/OMC plugin command. `ninmu` does not yet load plugin slash commands, Claude statusline stdin, or OMC session hooks.",
)
}
pub(crate) fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option<String> {
(!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),))
}
pub(crate) fn suggest_slash_commands(input: &str) -> Vec<String> {
let mut candidates = slash_command_specs()
.iter()
.flat_map(|spec| {
std::iter::once(spec.name)
.chain(spec.aliases.iter().copied())
.map(|name| format!("/{name}"))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
candidates.sort();
candidates.dedup();
let candidate_refs = candidates.iter().map(String::as_str).collect::<Vec<_>>();
ranked_suggestions(input.trim_start_matches('/'), &candidate_refs)
.into_iter()
.map(str::to_string)
.collect()
}
pub(crate) fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> {
ranked_suggestions(input, candidates).into_iter().next()
}
pub(crate) fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
const KNOWN_SUBCOMMANDS: &[&str] = &[
"help",
"version",
"status",
"sandbox",
"doctor",
"state",
"dump-manifests",
"bootstrap-plan",
"agents",
"mcp",
"skills",
"system-prompt",
"acp",
"init",
"export",
"prompt",
];
let normalized_input = input.to_ascii_lowercase();
let mut ranked = KNOWN_SUBCOMMANDS
.iter()
.filter_map(|candidate| {
let normalized_candidate = candidate.to_ascii_lowercase();
let distance = levenshtein_distance(&normalized_input, &normalized_candidate);
let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4;
let substring_match = normalized_candidate.contains(&normalized_input)
|| normalized_input.contains(&normalized_candidate);
((distance <= 2) || prefix_match || substring_match).then_some((distance, *candidate))
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
ranked.dedup_by(|left, right| left.1 == right.1);
let suggestions = ranked
.into_iter()
.map(|(_, candidate)| candidate.to_string())
.take(3)
.collect::<Vec<_>>();
(!suggestions.is_empty()).then_some(suggestions)
}
pub(crate) fn common_prefix_len(left: &str, right: &str) -> usize {
left.chars()
.zip(right.chars())
.take_while(|(l, r)| l == r)
.count()
}
pub(crate) fn looks_like_subcommand_typo(input: &str) -> bool {
!input.is_empty()
&& input
.chars()
.all(|ch| ch.is_ascii_alphabetic() || ch == '-')
}
pub(crate) fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> {
let normalized_input = input.trim_start_matches('/').to_ascii_lowercase();
let mut ranked = candidates
.iter()
.filter_map(|candidate| {
let normalized_candidate = candidate.trim_start_matches('/').to_ascii_lowercase();
let distance = levenshtein_distance(&normalized_input, &normalized_candidate);
let prefix_bonus = usize::from(
!(normalized_candidate.starts_with(&normalized_input)
|| normalized_input.starts_with(&normalized_candidate)),
);
let score = distance + prefix_bonus;
(score <= 4).then_some((score, *candidate))
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
ranked
.into_iter()
.map(|(_, candidate)| candidate)
.take(3)
.collect()
}
pub(crate) fn levenshtein_distance(left: &str, right: &str) -> usize {
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);
}
previous.clone_from(¤t);
}
previous[right_chars.len()]
}
pub(crate) fn format_user_visible_api_error(
session_id: &str,
error: &ninmu_api::ApiError,
) -> String {
if error.is_context_window_failure() {
format_context_window_blocked_error(session_id, error)
} else if error.is_generic_fatal_wrapper() {
let mut qualifiers = vec![format!("session {session_id}")];
if let Some(request_id) = error.request_id() {
qualifiers.push(format!("trace {request_id}"));
}
format!(
"{} ({}): {}",
error.safe_failure_class(),
qualifiers.join(", "),
error
)
} else {
error.to_string()
}
}
pub(crate) fn format_context_window_blocked_error(
session_id: &str,
error: &ninmu_api::ApiError,
) -> String {
let mut lines = vec![
"Context window blocked".to_string(),
" Failure class context_window_blocked".to_string(),
format!(" Session {session_id}"),
];
if let Some(request_id) = error.request_id() {
lines.push(format!(" Trace {request_id}"));
}
match error {
ninmu_api::ApiError::ContextWindowExceeded {
model,
estimated_input_tokens,
requested_output_tokens,
estimated_total_tokens,
context_window_tokens,
} => {
lines.push(format!(" Model {model}"));
lines.push(format!(
" Input estimate ~{estimated_input_tokens} tokens (heuristic)"
));
lines.push(format!(
" Requested output {requested_output_tokens} tokens"
));
lines.push(format!(
" Total estimate ~{estimated_total_tokens} tokens (heuristic)"
));
lines.push(format!(" Context window {context_window_tokens} tokens"));
}
ninmu_api::ApiError::Api { message, body, .. } => {
let detail = message.as_deref().unwrap_or(body).trim();
if !detail.is_empty() {
lines.push(format!(
" Detail {}",
truncate_for_summary(detail, 120)
));
}
}
ninmu_api::ApiError::RetriesExhausted { last_error, .. } => {
let detail = match last_error.as_ref() {
ninmu_api::ApiError::Api { message, body, .. } => {
message.as_deref().unwrap_or(body)
}
other => return format_context_window_blocked_error(session_id, other),
}
.trim();
if !detail.is_empty() {
lines.push(format!(
" Detail {}",
truncate_for_summary(detail, 120)
));
}
}
_ => {}
}
lines.push(String::new());
lines.push("Recovery".to_string());
lines.push(" Compact /compact".to_string());
lines.push(format!(
" Resume compact ninmu --resume {session_id} /compact"
));
lines.push(" Fresh session /clear --confirm".to_string());
lines.push(
" Reduce scope remove large pasted context/files or ask for a smaller slice"
.to_string(),
);
lines.push(" Retry rerun after compacting or reducing the request".to_string());
lines.join("\n")
}
const CLI_OPTION_SUGGESTIONS: &[&str] = &[
"--help",
"-h",
"--version",
"-V",
"--model",
"--output-format",
"--compact",
"--permission-mode",
"--dangerously-skip-permissions",
"--allowedTools",
"--resume",
"--acp",
"-acp",
];