mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-08 00:54:49 +08:00
feat: b5-doctor-cmd — batch 5 upstream parity
This commit is contained in:
@@ -1188,6 +1188,9 @@ pub enum SlashCommand {
|
||||
AddDir {
|
||||
path: Option<String>,
|
||||
},
|
||||
History {
|
||||
count: Option<String>,
|
||||
},
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
@@ -1421,6 +1424,9 @@ pub fn validate_slash_command_input(
|
||||
"tag" => SlashCommand::Tag { label: remainder },
|
||||
"output-style" => SlashCommand::OutputStyle { style: remainder },
|
||||
"add-dir" => SlashCommand::AddDir { path: remainder },
|
||||
"history" => SlashCommand::History {
|
||||
count: optional_single_arg(command, &args, "[count]")?,
|
||||
},
|
||||
other => SlashCommand::Unknown(other.to_string()),
|
||||
}))
|
||||
}
|
||||
@@ -3942,6 +3948,7 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::Tag { .. }
|
||||
| SlashCommand::OutputStyle { .. }
|
||||
| SlashCommand::AddDir { .. }
|
||||
| SlashCommand::History { .. }
|
||||
| SlashCommand::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -4256,6 +4263,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_history_command_without_count() {
|
||||
// given
|
||||
let input = "/history";
|
||||
|
||||
// when
|
||||
let parsed = SlashCommand::parse(input);
|
||||
|
||||
// then
|
||||
assert_eq!(parsed, Ok(Some(SlashCommand::History { count: None })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_history_command_with_numeric_count() {
|
||||
// given
|
||||
let input = "/history 25";
|
||||
|
||||
// when
|
||||
let parsed = SlashCommand::parse(input);
|
||||
|
||||
// then
|
||||
assert_eq!(
|
||||
parsed,
|
||||
Ok(Some(SlashCommand::History {
|
||||
count: Some("25".to_string())
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_history_with_extra_arguments() {
|
||||
// given
|
||||
let input = "/history 25 extra";
|
||||
|
||||
// when
|
||||
let error = parse_error_message(input);
|
||||
|
||||
// then
|
||||
assert!(error.contains("Usage: /history [count]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unexpected_arguments_for_no_arg_commands() {
|
||||
// given
|
||||
|
||||
@@ -2422,6 +2422,15 @@ fn run_resume_command(
|
||||
message: Some(render_doctor_report()?.render()),
|
||||
json: None,
|
||||
}),
|
||||
SlashCommand::History { count } => {
|
||||
let limit = parse_history_count(count.as_deref()).map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
|
||||
let entries = collect_session_prompt_history(session);
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(render_prompt_history_report(&entries, limit)),
|
||||
json: None,
|
||||
})
|
||||
}
|
||||
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
|
||||
SlashCommand::Bughunter { .. }
|
||||
| SlashCommand::Commit { .. }
|
||||
@@ -2515,6 +2524,7 @@ fn run_repl(
|
||||
}
|
||||
}
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
cli.run_turn(&trimmed)?;
|
||||
}
|
||||
input::ReadOutcome::Cancel => {}
|
||||
@@ -2551,6 +2561,13 @@ struct LiveCli {
|
||||
system_prompt: Vec<String>,
|
||||
runtime: BuiltRuntime,
|
||||
session: SessionHandle,
|
||||
prompt_history: Vec<PromptHistoryEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PromptHistoryEntry {
|
||||
timestamp_ms: u64,
|
||||
text: String,
|
||||
}
|
||||
|
||||
struct RuntimePluginState {
|
||||
@@ -3052,6 +3069,7 @@ impl LiveCli {
|
||||
system_prompt,
|
||||
runtime,
|
||||
session,
|
||||
prompt_history: Vec::new(),
|
||||
};
|
||||
cli.persist_session()?;
|
||||
Ok(cli)
|
||||
@@ -3346,6 +3364,10 @@ impl LiveCli {
|
||||
println!("{}", render_doctor_report()?.render());
|
||||
false
|
||||
}
|
||||
SlashCommand::History { count } => {
|
||||
self.print_prompt_history(count.as_deref());
|
||||
false
|
||||
}
|
||||
SlashCommand::Login
|
||||
| SlashCommand::Logout
|
||||
| SlashCommand::Vim
|
||||
@@ -3420,6 +3442,35 @@ impl LiveCli {
|
||||
);
|
||||
}
|
||||
|
||||
fn record_prompt_history(&mut self, prompt: &str) {
|
||||
let timestamp_ms = std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map_or(self.runtime.session().updated_at_ms, |duration| {
|
||||
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
||||
});
|
||||
self.prompt_history.push(PromptHistoryEntry {
|
||||
timestamp_ms,
|
||||
text: prompt.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
fn print_prompt_history(&self, count: Option<&str>) {
|
||||
let limit = match parse_history_count(count) {
|
||||
Ok(limit) => limit,
|
||||
Err(message) => {
|
||||
eprintln!("{message}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let entries = if self.prompt_history.is_empty() {
|
||||
collect_session_prompt_history(self.runtime.session())
|
||||
} else {
|
||||
self.prompt_history.clone()
|
||||
};
|
||||
println!("{}", render_prompt_history_report(&entries, limit));
|
||||
}
|
||||
|
||||
fn print_sandbox_status() {
|
||||
let cwd = env::current_dir().expect("current dir");
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
@@ -4171,9 +4222,15 @@ fn render_repl_help() -> String {
|
||||
"REPL".to_string(),
|
||||
" /exit Quit the REPL".to_string(),
|
||||
" /quit Quit the REPL".to_string(),
|
||||
" Up/Down Navigate prompt history".to_string(),
|
||||
" Ctrl-R Reverse-search prompt history".to_string(),
|
||||
" Tab Complete commands, modes, and recent sessions".to_string(),
|
||||
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
|
||||
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
|
||||
" Auto-save .claw/sessions/<session-id>.jsonl".to_string(),
|
||||
" Resume latest /resume latest".to_string(),
|
||||
" Browse sessions /session list".to_string(),
|
||||
" Show prompt history /history [count]".to_string(),
|
||||
String::new(),
|
||||
render_slash_command_help(),
|
||||
]
|
||||
@@ -4889,6 +4946,100 @@ fn write_temp_text_file(
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
const DEFAULT_HISTORY_LIMIT: usize = 10;
|
||||
|
||||
fn parse_history_count(raw: Option<&str>) -> Result<usize, String> {
|
||||
let Some(raw) = raw else {
|
||||
return Ok(DEFAULT_HISTORY_LIMIT);
|
||||
};
|
||||
let parsed: usize = raw
|
||||
.parse()
|
||||
.map_err(|_| format!("history: invalid count '{raw}'. Expected a positive integer."))?;
|
||||
if parsed == 0 {
|
||||
return Err("history: count must be greater than 0.".to_string());
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn format_history_timestamp(timestamp_ms: u64) -> String {
|
||||
let secs = timestamp_ms / 1_000;
|
||||
let subsec_ms = timestamp_ms % 1_000;
|
||||
let days_since_epoch = secs / 86_400;
|
||||
let seconds_of_day = secs % 86_400;
|
||||
let hours = seconds_of_day / 3_600;
|
||||
let minutes = (seconds_of_day % 3_600) / 60;
|
||||
let seconds = seconds_of_day % 60;
|
||||
|
||||
let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0));
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z"
|
||||
)
|
||||
}
|
||||
|
||||
// Computes civil (Gregorian) year/month/day from days since the Unix epoch
|
||||
// (1970-01-01) using Howard Hinnant's `civil_from_days` algorithm.
|
||||
fn civil_from_days(days: i64) -> (i32, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z / 146_097 } else { (z - 146_096) / 146_097 };
|
||||
let doe = (z - era * 146_097) as u64; // [0, 146_096]
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
|
||||
let y = y + i64::from(m <= 2);
|
||||
(y as i32, m as u32, d as u32)
|
||||
}
|
||||
|
||||
fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> String {
|
||||
if entries.is_empty() {
|
||||
return "Prompt history\n Result no prompts recorded yet".to_string();
|
||||
}
|
||||
|
||||
let total = entries.len();
|
||||
let start = total.saturating_sub(limit);
|
||||
let shown = &entries[start..];
|
||||
let mut lines = vec![
|
||||
"Prompt history".to_string(),
|
||||
format!(" Total {total}"),
|
||||
format!(" Showing {} most recent", shown.len()),
|
||||
format!(" Reverse search Ctrl-R in the REPL"),
|
||||
String::new(),
|
||||
];
|
||||
for (offset, entry) in shown.iter().enumerate() {
|
||||
let absolute_index = start + offset + 1;
|
||||
let timestamp = format_history_timestamp(entry.timestamp_ms);
|
||||
let first_line = entry.text.lines().next().unwrap_or("").trim();
|
||||
let display = if first_line.chars().count() > 80 {
|
||||
let truncated: String = first_line.chars().take(77).collect();
|
||||
format!("{truncated}...")
|
||||
} else {
|
||||
first_line.to_string()
|
||||
};
|
||||
lines.push(format!(" {absolute_index:>3}. [{timestamp}] {display}"));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn collect_session_prompt_history(session: &Session) -> Vec<PromptHistoryEntry> {
|
||||
let timestamp_ms = session.updated_at_ms;
|
||||
session
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|message| message.role == MessageRole::User)
|
||||
.filter_map(|message| {
|
||||
message.blocks.iter().find_map(|block| match block {
|
||||
ContentBlock::Text { text } => Some(PromptHistoryEntry {
|
||||
timestamp_ms,
|
||||
text: text.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn recent_user_context(session: &Session, limit: usize) -> String {
|
||||
let requests = session
|
||||
.messages
|
||||
@@ -6941,24 +7092,26 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
|
||||
mod tests {
|
||||
use super::{
|
||||
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
|
||||
create_managed_session_handle, describe_tool_progress, filter_tool_specs,
|
||||
format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report,
|
||||
format_compact_report, format_connected_line, format_cost_report,
|
||||
format_internal_prompt_progress_line, format_issue_report, format_model_report,
|
||||
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
|
||||
format_pr_report, format_resume_report, format_status_report, format_tool_call_start,
|
||||
format_tool_result, format_ultraplan_report, format_unknown_slash_command,
|
||||
collect_session_prompt_history, create_managed_session_handle, describe_tool_progress,
|
||||
filter_tool_specs, format_bughunter_report, format_commit_preflight_report,
|
||||
format_commit_skipped_report, format_compact_report, format_connected_line,
|
||||
format_cost_report, format_history_timestamp, format_internal_prompt_progress_line,
|
||||
format_issue_report, format_model_report, format_model_switch_report,
|
||||
format_permissions_report, format_permissions_switch_report, format_pr_report,
|
||||
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
|
||||
format_ultraplan_report, format_unknown_slash_command,
|
||||
format_unknown_slash_command_message, format_user_visible_api_error,
|
||||
normalize_permission_mode, parse_args, parse_git_status_branch,
|
||||
parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy,
|
||||
print_help_to, push_output_block, render_config_report, render_diff_report,
|
||||
render_diff_report_for, render_memory_report, render_repl_help, render_resume_usage,
|
||||
resolve_model_alias, resolve_repl_model, resolve_session_reference, response_to_events,
|
||||
parse_git_status_metadata_for, parse_git_workspace_summary, parse_history_count,
|
||||
permission_policy, print_help_to, push_output_block, render_config_report,
|
||||
render_diff_report, render_diff_report_for, render_memory_report,
|
||||
render_prompt_history_report, render_repl_help, render_resume_usage, resolve_model_alias,
|
||||
resolve_repl_model, resolve_session_reference, response_to_events,
|
||||
resume_supported_slash_commands, run_resume_command,
|
||||
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
|
||||
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
|
||||
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
|
||||
SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||
PromptHistoryEntry, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||
};
|
||||
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
||||
use plugins::{
|
||||
@@ -8811,6 +8964,168 @@ UU conflicted.rs",
|
||||
assert!(help.contains("Up/Down"));
|
||||
assert!(help.contains("Tab"));
|
||||
assert!(help.contains("Shift+Enter/Ctrl+J"));
|
||||
assert!(help.contains("Ctrl-R"));
|
||||
assert!(help.contains("Reverse-search prompt history"));
|
||||
assert!(help.contains("/history [count]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_history_count_defaults_to_ten_when_missing() {
|
||||
// given
|
||||
let raw: Option<&str> = None;
|
||||
|
||||
// when
|
||||
let parsed = parse_history_count(raw);
|
||||
|
||||
// then
|
||||
assert_eq!(parsed, Ok(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_history_count_accepts_positive_integers() {
|
||||
// given
|
||||
let raw = Some("25");
|
||||
|
||||
// when
|
||||
let parsed = parse_history_count(raw);
|
||||
|
||||
// then
|
||||
assert_eq!(parsed, Ok(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_history_count_rejects_zero() {
|
||||
// given
|
||||
let raw = Some("0");
|
||||
|
||||
// when
|
||||
let parsed = parse_history_count(raw);
|
||||
|
||||
// then
|
||||
assert!(parsed.is_err());
|
||||
assert!(parsed.unwrap_err().contains("greater than 0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_history_count_rejects_non_numeric() {
|
||||
// given
|
||||
let raw = Some("abc");
|
||||
|
||||
// when
|
||||
let parsed = parse_history_count(raw);
|
||||
|
||||
// then
|
||||
assert!(parsed.is_err());
|
||||
assert!(parsed.unwrap_err().contains("invalid count 'abc'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_history_timestamp_renders_iso8601_utc() {
|
||||
// given
|
||||
// 2023-01-15T12:34:56.789Z -> 1673786096789 ms
|
||||
let timestamp_ms: u64 = 1_673_786_096_789;
|
||||
|
||||
// when
|
||||
let formatted = format_history_timestamp(timestamp_ms);
|
||||
|
||||
// then
|
||||
assert_eq!(formatted, "2023-01-15T12:34:56.789Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_history_timestamp_renders_unix_epoch_origin() {
|
||||
// given
|
||||
let timestamp_ms: u64 = 0;
|
||||
|
||||
// when
|
||||
let formatted = format_history_timestamp(timestamp_ms);
|
||||
|
||||
// then
|
||||
assert_eq!(formatted, "1970-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_prompt_history_report_lists_entries_with_timestamps() {
|
||||
// given
|
||||
let entries = vec![
|
||||
PromptHistoryEntry {
|
||||
timestamp_ms: 1_673_786_096_000,
|
||||
text: "first prompt".to_string(),
|
||||
},
|
||||
PromptHistoryEntry {
|
||||
timestamp_ms: 1_673_786_100_000,
|
||||
text: "second prompt".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
// when
|
||||
let rendered = render_prompt_history_report(&entries, 10);
|
||||
|
||||
// then
|
||||
assert!(rendered.contains("Prompt history"));
|
||||
assert!(rendered.contains("Total 2"));
|
||||
assert!(rendered.contains("Showing 2 most recent"));
|
||||
assert!(rendered.contains("Reverse search Ctrl-R in the REPL"));
|
||||
assert!(rendered.contains("2023-01-15T12:34:56.000Z"));
|
||||
assert!(rendered.contains("first prompt"));
|
||||
assert!(rendered.contains("second prompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_prompt_history_report_truncates_to_limit_from_the_tail() {
|
||||
// given
|
||||
let entries = vec![
|
||||
PromptHistoryEntry {
|
||||
timestamp_ms: 1_000,
|
||||
text: "older".to_string(),
|
||||
},
|
||||
PromptHistoryEntry {
|
||||
timestamp_ms: 2_000,
|
||||
text: "middle".to_string(),
|
||||
},
|
||||
PromptHistoryEntry {
|
||||
timestamp_ms: 3_000,
|
||||
text: "latest".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
// when
|
||||
let rendered = render_prompt_history_report(&entries, 2);
|
||||
|
||||
// then
|
||||
assert!(rendered.contains("Total 3"));
|
||||
assert!(rendered.contains("Showing 2 most recent"));
|
||||
assert!(!rendered.contains("older"));
|
||||
assert!(rendered.contains("middle"));
|
||||
assert!(rendered.contains("latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_prompt_history_report_handles_empty_history() {
|
||||
// given
|
||||
let entries: Vec<PromptHistoryEntry> = Vec::new();
|
||||
|
||||
// when
|
||||
let rendered = render_prompt_history_report(&entries, 10);
|
||||
|
||||
// then
|
||||
assert!(rendered.contains("no prompts recorded yet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_session_prompt_history_extracts_user_text_blocks() {
|
||||
// given
|
||||
let mut session = Session::new();
|
||||
session.push_user_text("hello").unwrap();
|
||||
session.push_user_text("world").unwrap();
|
||||
|
||||
// when
|
||||
let entries = collect_session_prompt_history(&session);
|
||||
|
||||
// then
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].text, "hello");
|
||||
assert_eq!(entries[1].text, "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user