重构main 方法
This commit is contained in:
parent
00718b4c3e
commit
2380566626
|
|
@ -31,45 +31,45 @@
|
|||
|
||||
## 6. Create args.rs — extract all argument parsing
|
||||
|
||||
- [ ] 6.1 Create `src/args.rs` with CliAction enum, CliOutputFormat, LocalHelpTopic, `parse_args`, all parse_* helpers, format_unknown_* functions, suggest_*/levenshtein helpers, normalize_* tools, permission_mode helpers, provider_label, filter_tool_specs (main.rs lines 506-1828)
|
||||
- [ ] 6.2 Add `mod args;` to `main.rs` and re-export
|
||||
- [ ] 6.3 Run `cargo build` and `cargo test` — must pass
|
||||
- [x] 6.1 Create `src/args.rs` with CliAction enum, CliOutputFormat, LocalHelpTopic, `parse_args`, all parse_* helpers, format_unknown_* functions, suggest_*/levenshtein helpers, normalize_* tools, permission_mode helpers, provider_label, filter_tool_specs (main.rs lines 506-1828)
|
||||
- [x] 6.2 Add `mod args;` to `main.rs` and re-export
|
||||
- [x] 6.3 Run `cargo build` and `cargo test` — must pass
|
||||
|
||||
## 7. Create session.rs — extract session management
|
||||
|
||||
- [ ] 7.1 Create `src/session.rs` with SessionHandle, ManagedSessionSummary, all session CRUD functions: `sessions_dir`, `current_session_store`, `new_cli_session`, `create_managed_session_handle`, `resolve_session_reference`, `list_managed_sessions`, `latest_managed_session`, `load_session_reference`, `delete_managed_session`, `confirm_session_deletion`, `render_session_list`, `format_session_modified_age`, `write_session_clear_backup`, `session_clear_backup_path` (main.rs lines 3678-3692, 5257-5427)
|
||||
- [ ] 7.2 Add `mod session;` to `main.rs` and re-export
|
||||
- [ ] 7.3 Run `cargo build` and `cargo test` — must pass
|
||||
- [x] 7.1 Create `src/session.rs` with SessionHandle, ManagedSessionSummary, all session CRUD functions: `sessions_dir`, `current_session_store`, `new_cli_session`, `create_managed_session_handle`, `resolve_session_reference`, `list_managed_sessions`, `latest_managed_session`, `load_session_reference`, `delete_managed_session`, `confirm_session_deletion`, `render_session_list`, `format_session_modified_age`, `write_session_clear_backup`, `session_clear_backup_path` (main.rs lines 3678-3692, 5257-5427)
|
||||
- [x] 7.2 Add `mod session;` to `main.rs` and re-export
|
||||
- [x] 7.3 Run `cargo build` and `cargo test` — must pass
|
||||
|
||||
## 8. Create status.rs — extract status snapshot and git parsing
|
||||
|
||||
- [ ] 8.1 Create `src/status.rs` with StatusContext, StatusUsage, GitWorkspaceSummary, `print_status_snapshot`, `status_json_value`, `status_context`, `format_status_report`, `format_sandbox_report`, `print_sandbox_status_snapshot`, `sandbox_json_value`, and all git parsing helpers (main.rs lines 2842-3200, 5462-5839)
|
||||
- [ ] 8.2 Add `mod status;` to `main.rs` and re-export
|
||||
- [ ] 8.3 Run `cargo build` and `cargo test` — must pass
|
||||
- [x] 8.1 Create `src/status.rs` with StatusContext, StatusUsage, GitWorkspaceSummary, `print_status_snapshot`, `status_json_value`, `status_context`, `format_status_report`, `format_sandbox_report`, `print_sandbox_status_snapshot`, `sandbox_json_value`, and all git parsing helpers (main.rs lines 2842-3200, 5462-5839)
|
||||
- [x] 8.2 Add `mod status;` to `main.rs` and re-export
|
||||
- [x] 8.3 Run `cargo build` and `cargo test` — must pass
|
||||
|
||||
## 9. Create formatting.rs — extract tool result formatting and help text
|
||||
|
||||
- [ ] 9.1 Create `src/formatting.rs` with all format_tool_* functions, push_output_block, response_to_events, push_prompt_cache_record, prompt_cache_record_to_runtime_event, final_assistant_text, collect_tool_uses/results/cache_events, short_tool_id, extract_tool_path, first_visible_line, truncate_* helpers, and full help text functions (main.rs lines 8184-8730, 8923-9122)
|
||||
- [ ] 9.2 Add `mod formatting;` to `main.rs` and re-export
|
||||
- [ ] 9.3 Run `cargo build` and `cargo test` — must pass
|
||||
- [x] 9.1 Create `src/formatting.rs` with all format_tool_* functions, push_output_block, response_to_events, push_prompt_cache_record, prompt_cache_record_to_runtime_event, final_assistant_text, collect_tool_uses/results/cache_events, short_tool_id, extract_tool_path, first_visible_line, truncate_* helpers, and full help text functions (main.rs lines 8184-8730, 8923-9122)
|
||||
- [x] 9.2 Add `mod formatting;` to `main.rs` and re-export
|
||||
- [x] 9.3 Run `cargo build` and `cargo test` — must pass
|
||||
|
||||
## 10. Create config.rs — extract config and memory reporting
|
||||
|
||||
- [ ] 10.1 Create `src/config.rs` with `render_config_report`, `render_config_json`, `render_memory_report`, `render_memory_json` (main.rs lines 5962-6143)
|
||||
- [ ] 10.2 Add `mod config;` to `main.rs` and re-export
|
||||
- [ ] 10.3 Run `cargo build` and `cargo test` — must pass
|
||||
- [x] 10.1 Create `src/config.rs` with `render_config_report`, `render_config_json`, `render_memory_report`, `render_memory_json` (main.rs lines 5962-6143)
|
||||
- [x] 10.2 Add `mod config;` to `main.rs` and re-export
|
||||
- [x] 10.3 Run `cargo build` and `cargo test` — must pass
|
||||
|
||||
## 11. Create diff.rs — extract git diff reporting
|
||||
|
||||
- [ ] 11.1 Create `src/diff.rs` with `render_diff_report`, `render_diff_report_for`, `render_diff_json_for`, `run_git_diff_command_in` (main.rs lines 6189-6270)
|
||||
- [ ] 11.2 Add `mod diff;` to `main.rs` and re-export
|
||||
- [ ] 11.3 Run `cargo build` and `cargo test` — must pass
|
||||
- [x] 11.1 Create `src/diff.rs` with `render_diff_report`, `render_diff_report_for`, `render_diff_json_for`, `run_git_diff_command_in` (main.rs lines 6189-6270)
|
||||
- [x] 11.2 Add `mod diff;` to `main.rs` and re-export
|
||||
- [x] 11.3 Run `cargo build` and `cargo test` — must pass
|
||||
|
||||
## 12. Create export.rs — extract session export
|
||||
|
||||
- [ ] 12.1 Create `src/export.rs` with `run_export`, `render_export_text`, `render_session_markdown`, `default_export_filename`, `resolve_export_path`, `summarize_tool_payload_for_markdown` (main.rs lines 6646-6914)
|
||||
- [ ] 12.2 Add `mod export;` to `main.rs` and re-export
|
||||
- [ ] 12.3 Run `cargo build` and `cargo test` — must pass
|
||||
- [x] 12.1 Create `src/export.rs` with `run_export`, `render_export_text`, `render_session_markdown`, `default_export_filename`, `resolve_export_path`, `summarize_tool_payload_for_markdown` (main.rs lines 6646-6914)
|
||||
- [x] 12.2 Add `mod export;` to `main.rs` and re-export
|
||||
- [x] 12.3 Run `cargo build` and `cargo test` — must pass
|
||||
|
||||
## 13. Create api_client.rs — extract AnthropicRuntimeClient
|
||||
|
||||
|
|
@ -85,16 +85,16 @@
|
|||
|
||||
## 15. Create runtime_builder.rs — extract runtime construction
|
||||
|
||||
- [ ] 15.1 Create `src/runtime_builder.rs` with RuntimePluginState, RuntimeMcpState, BuiltRuntime struct+impl+Deref+DerefMut+Drop, HookAbortMonitor, all `build_runtime*` functions, `build_plugin_manager`, `resolve_plugin_path`, `runtime_hook_config_from_plugin_hooks`, `build_system_prompt`, `build_runtime_mcp_state`, `mcp_runtime_tool_definition`, `mcp_wrapper_tool_definitions`, `permission_mode_for_mcp_tool`, `mcp_annotation_flag` (main.rs lines 3710-4180, 6924-6995, 7329-7450, 4017-4125)
|
||||
- [ ] 15.2 Add `mod runtime_builder;` to `main.rs` and re-export
|
||||
- [ ] 15.3 Run `cargo build` and `cargo test` — must pass
|
||||
- [x] 15.1 Create `src/runtime_builder.rs` with RuntimePluginState, RuntimeMcpState, BuiltRuntime struct+impl+Deref+DerefMut+Drop, HookAbortMonitor, all `build_runtime*` functions, `build_plugin_manager`, `resolve_plugin_path`, `runtime_hook_config_from_plugin_hooks`, `build_system_prompt`, `build_runtime_mcp_state`, `mcp_runtime_tool_definition`, `mcp_wrapper_tool_definitions`, `permission_mode_for_mcp_tool`, `mcp_annotation_flag` (main.rs lines 3710-4180, 6924-6995, 7329-7450, 4017-4125)
|
||||
- [x] 15.2 Add `mod runtime_builder;` to `main.rs` and re-export
|
||||
- [x] 15.3 Run `cargo build` and `cargo test` — must pass
|
||||
|
||||
## 16. Create live_cli.rs — extract LiveCli core and REPL loop
|
||||
|
||||
- [ ] 16.1 Create `src/live_cli.rs` with LiveCli struct, PromptHistoryEntry, and core impl methods: `new`, `startup_banner`, `prepare_turn_runtime`, `replace_runtime`, `run_turn`, `run_turn_with_output`, `run_prompt_compact/json`, `repl_completion_candidates`, `persist_session`, `record_prompt_history`, `run_internal_prompt_text*`, `reload_runtime_features` (main.rs lines 3694-3710, 4181-4440, 4580-4610, 5240-5260)
|
||||
- [ ] 16.2 Extract free functions: `run_repl`, `run_resume_command`, `resume_session`, `detect_broad_cwd`, `enforce_broad_cwd_policy`, `run_stale_base_preflight` (main.rs lines 3520-3675, 3597-3678, 3175-3520, 3609-3610)
|
||||
- [ ] 16.3 Add `mod live_cli;` to `main.rs` and re-export
|
||||
- [ ] 16.4 Run `cargo build` and `cargo test` — must pass
|
||||
- [x] 16.1 Create `src/live_cli.rs` with LiveCli struct, PromptHistoryEntry, and core impl methods: `new`, `startup_banner`, `prepare_turn_runtime`, `replace_runtime`, `run_turn`, `run_turn_with_output`, `run_prompt_compact/json`, `repl_completion_candidates`, `persist_session`, `record_prompt_history`, `run_internal_prompt_text*`, `reload_runtime_features` (main.rs lines 3694-3710, 4181-4440, 4580-4610, 5240-5260)
|
||||
- [x] 16.2 Extract free functions: `run_repl`, `run_resume_command`, `resume_session`, `detect_broad_cwd`, `enforce_broad_cwd_policy`, `run_stale_base_preflight` (main.rs lines 3520-3675, 3597-3678, 3175-3520, 3609-3610)
|
||||
- [x] 16.3 Add `mod live_cli;` to `main.rs` and re-export
|
||||
- [x] 16.4 Run `cargo build` and `cargo test` — must pass
|
||||
|
||||
## 17. Create repl_commands.rs — extract REPL command handlers
|
||||
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@ use tools::GlobalToolRegistry;
|
|||
|
||||
use crate::constants::*;
|
||||
use crate::model::max_tokens_for_model;
|
||||
use crate::render::{
|
||||
format_tool_call_start, format_user_visible_api_error, push_output_block,
|
||||
use crate::{
|
||||
format_tool_call_start, push_output_block,
|
||||
render_thinking_block_summary,
|
||||
};
|
||||
|
||||
use crate::InternalPromptProgressReporter;
|
||||
|
||||
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
|
||||
|
|
@ -306,4 +307,98 @@ pub(crate) fn request_ends_with_tool_result(request: &ApiRequest) -> bool {
|
|||
.messages
|
||||
.last()
|
||||
.is_some_and(|message| message.role == MessageRole::Tool)
|
||||
}
|
||||
|
||||
pub(crate) fn format_user_visible_api_error(session_id: &str, error: &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: &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 {
|
||||
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"));
|
||||
}
|
||||
api::ApiError::Api { message, body, .. } => {
|
||||
let detail = message.as_deref().unwrap_or(body).trim();
|
||||
if !detail.is_empty() {
|
||||
lines.push(format!(
|
||||
" Detail {}",
|
||||
crate::formatting::truncate_for_summary(detail, 120)
|
||||
));
|
||||
}
|
||||
}
|
||||
api::ApiError::RetriesExhausted { last_error, .. } => {
|
||||
let detail = match last_error.as_ref() {
|
||||
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 {}",
|
||||
crate::formatting::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 claw --resume {session_id} /compact"
|
||||
));
|
||||
lines.push(" Fresh session /clear --confirm".to_string());
|
||||
lines.push(
|
||||
" Reduce scope remove large pasted context/files or ask for smaller slice"
|
||||
.to_string(),
|
||||
);
|
||||
lines.push(" Retry rerun after compacting or reducing request".to_string());
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
|
@ -13,10 +13,9 @@ use tools::GlobalToolRegistry;
|
|||
|
||||
use crate::build_runtime_plugin_state_with_loader;
|
||||
use crate::constants::*;
|
||||
use crate::looks_like_slash_command_token;
|
||||
use crate::model::*;
|
||||
use crate::normalize_permission_mode;
|
||||
use crate::resume_command_can_absorb_token;
|
||||
use crate::runtime_builder::build_system_prompt;
|
||||
use crate::formatting::{render_help_topic, render_version_report, full_help_text};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum CliAction {
|
||||
|
|
@ -783,7 +782,7 @@ pub(crate) fn parse_direct_slash_cli_action(
|
|||
}),
|
||||
}
|
||||
}
|
||||
Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)),
|
||||
Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_slash_command_message(&name)),
|
||||
Ok(Some(command)) => Err({
|
||||
let _ = command;
|
||||
format!(
|
||||
|
|
@ -808,8 +807,8 @@ pub(crate) fn format_unknown_option(option: &str) -> String {
|
|||
message
|
||||
}
|
||||
|
||||
pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String {
|
||||
let mut message = format!("unknown slash command outside the REPL: /{name}");
|
||||
pub(crate) fn format_unknown_slash_command_message(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');
|
||||
|
|
@ -819,7 +818,7 @@ pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String {
|
|||
message.push('\n');
|
||||
message.push_str(note);
|
||||
}
|
||||
message.push_str("\nRun `claw --help` for CLI usage, or start `claw` and use /help.");
|
||||
message.push_str("\nRun `claw --help` for CLI usage. Use /help in the REPL.");
|
||||
message
|
||||
}
|
||||
|
||||
|
|
@ -1226,3 +1225,381 @@ pub(crate) fn parse_resume_args(args: &[String], output_format: CliOutputFormat)
|
|||
output_format,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
|
||||
match mode.trim() {
|
||||
"read-only" => Some("read-only"),
|
||||
"workspace-write" => Some("workspace-write"),
|
||||
"danger-full-access" => Some("danger-full-access"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_no_args(
|
||||
command_name: &str,
|
||||
args: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(args) = args.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
return Err(format!(
|
||||
"{command_name} does not accept arguments. Received: {args}\nUsage: {command_name}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_help_topic(topic: LocalHelpTopic) {
|
||||
println!("{}", render_help_topic(topic));
|
||||
}
|
||||
|
||||
pub(crate) fn print_acp_status(
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let message = "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` is only a discoverability alias today; it does not launch a daemon or Zed-specific protocol endpoint. Use the normal terminal surfaces for now and track ROADMAP #76 for real ACP support.";
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
println!(
|
||||
"ACP / Zed\n Status discoverability only\n Launch `claw acp serve` / `claw --acp` / `claw -acp` report status only; no editor daemon is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Tracking ROADMAP #76\n Message {message}"
|
||||
);
|
||||
}
|
||||
CliOutputFormat::Json => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"kind": "acp",
|
||||
"status": "discoverability_only",
|
||||
"supported": false,
|
||||
"serve_alias_only": true,
|
||||
"message": message,
|
||||
"launch_command": serde_json::Value::Null,
|
||||
"aliases": ["acp", "--acp", "-acp"],
|
||||
"discoverability_tracking": "ROADMAP #64a",
|
||||
"tracking": "ROADMAP #76",
|
||||
"recommended_workflows": [
|
||||
"claw prompt TEXT",
|
||||
"claw",
|
||||
"claw doctor"
|
||||
],
|
||||
}))?
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn slash_command_completion_candidates_with_sessions(
|
||||
model: &str,
|
||||
active_session_id: Option<&str>,
|
||||
recent_session_ids: Vec<String>,
|
||||
) -> Vec<String> {
|
||||
use std::collections::BTreeSet;
|
||||
let mut completions = BTreeSet::new();
|
||||
|
||||
for spec in commands::slash_command_specs() {
|
||||
if STUB_COMMANDS.contains(&spec.name) {
|
||||
continue;
|
||||
}
|
||||
completions.insert(format!("/{}", spec.name));
|
||||
for alias in spec.aliases {
|
||||
if !STUB_COMMANDS.contains(alias) {
|
||||
completions.insert(format!("/{alias}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for candidate in [
|
||||
"/bughunter ",
|
||||
"/clear --confirm",
|
||||
"/config ",
|
||||
"/config env",
|
||||
"/config hooks",
|
||||
"/config model",
|
||||
"/config plugins",
|
||||
"/mcp ",
|
||||
"/mcp list",
|
||||
"/mcp show ",
|
||||
"/export ",
|
||||
"/issue ",
|
||||
"/model ",
|
||||
"/model opus",
|
||||
"/model sonnet",
|
||||
"/model haiku",
|
||||
"/permissions ",
|
||||
"/permissions read-only",
|
||||
"/permissions workspace-write",
|
||||
"/permissions danger-full-access",
|
||||
"/plugin list",
|
||||
"/plugin install ",
|
||||
"/plugin enable ",
|
||||
"/plugin disable ",
|
||||
"/plugin uninstall ",
|
||||
"/plugin update ",
|
||||
"/plugins list",
|
||||
"/pr ",
|
||||
"/resume ",
|
||||
"/session list",
|
||||
"/session switch ",
|
||||
"/session fork ",
|
||||
"/teleport ",
|
||||
"/ultraplan ",
|
||||
"/agents help",
|
||||
"/mcp help",
|
||||
"/skills help",
|
||||
] {
|
||||
completions.insert(candidate.to_string());
|
||||
}
|
||||
|
||||
if !model.trim().is_empty() {
|
||||
completions.insert(format!("/model {}", resolve_model_alias(model)));
|
||||
completions.insert(format!("/model {model}"));
|
||||
}
|
||||
|
||||
if let Some(active_session_id) = active_session_id.filter(|value| !value.trim().is_empty()) {
|
||||
completions.insert(format!("/resume {active_session_id}"));
|
||||
completions.insert(format!("/session switch {active_session_id}"));
|
||||
}
|
||||
|
||||
for session_id in recent_session_ids
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.take(10)
|
||||
{
|
||||
completions.insert(format!("/resume {session_id}"));
|
||||
completions.insert(format!("/session switch {session_id}"));
|
||||
}
|
||||
|
||||
completions.into_iter().collect()
|
||||
}
|
||||
|
||||
pub(crate) fn run_worker_state(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let state_path = cwd.join(".claw").join("worker-state.json");
|
||||
if !state_path.exists() {
|
||||
return Err(format!(
|
||||
"no worker state file found at {path}\n Hint: worker state is written by the interactive REPL or a non-interactive prompt.\n Run: claw # start the REPL (writes state on first turn)\n Or: claw prompt <text> # run one non-interactive turn\n Then rerun: claw state [--output-format json]",
|
||||
path = state_path.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let raw = std::fs::read_to_string(&state_path)?;
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{raw}"),
|
||||
CliOutputFormat::Json => {
|
||||
let _: serde_json::Value = serde_json::from_str(&raw)?;
|
||||
println!("{raw}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool {
|
||||
matches!(
|
||||
commands::SlashCommand::parse(current_command),
|
||||
Ok(Some(commands::SlashCommand::Export { path: None }))
|
||||
) && !looks_like_slash_command_token(token)
|
||||
}
|
||||
|
||||
pub(crate) fn looks_like_slash_command_token(token: &str) -> bool {
|
||||
let trimmed = token.trim_start();
|
||||
let Some(name) = trimmed.strip_prefix('/').and_then(|value| {
|
||||
value
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
commands::slash_command_specs()
|
||||
.iter()
|
||||
.any(|spec| spec.name == name || spec.aliases.contains(&name))
|
||||
}
|
||||
|
||||
pub(crate) fn dump_manifests(
|
||||
manifests_dir: Option<&Path>,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
dump_manifests_at_path(&workspace_dir, manifests_dir, output_format)
|
||||
}
|
||||
|
||||
const DUMP_MANIFESTS_OVERRIDE_HINT: &str =
|
||||
"Hint: set CLAUDE_CODE_UPSTREAM=/path/to/upstream or pass `claw dump-manifests --manifests-dir /path/to/upstream`.";
|
||||
|
||||
fn dump_manifests_at_path(
|
||||
workspace_dir: &std::path::Path,
|
||||
manifests_dir: Option<&Path>,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let paths = if let Some(dir) = manifests_dir {
|
||||
let resolved = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
|
||||
compat_harness::UpstreamPaths::from_repo_root(resolved)
|
||||
} else {
|
||||
let resolved = workspace_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace_dir.to_path_buf());
|
||||
compat_harness::UpstreamPaths::from_workspace_dir(&resolved)
|
||||
};
|
||||
|
||||
let source_root = paths.repo_root();
|
||||
if !source_root.exists() {
|
||||
return Err(format!(
|
||||
"Manifest source directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}",
|
||||
source_root.display(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let required_paths = [
|
||||
("src/commands.ts", paths.commands_path()),
|
||||
("src/tools.ts", paths.tools_path()),
|
||||
("src/entrypoints/cli.tsx", paths.cli_path()),
|
||||
];
|
||||
let missing = required_paths
|
||||
.iter()
|
||||
.filter_map(|(label, path)| (!path.is_file()).then_some(*label))
|
||||
.collect::<Vec<_>>();
|
||||
if !missing.is_empty() {
|
||||
return Err(format!(
|
||||
"Manifest source files are missing.\n repo root: {}\n missing: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}",
|
||||
source_root.display(),
|
||||
missing.join(", "),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
match compat_harness::extract_manifest(&paths) {
|
||||
Ok(manifest) => {
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
println!("commands: {}", manifest.commands.entries().len());
|
||||
println!("tools: {}", manifest.tools.entries().len());
|
||||
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
|
||||
}
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"kind": "dump-manifests",
|
||||
"commands": manifest.commands.entries().len(),
|
||||
"tools": manifest.tools.entries().len(),
|
||||
"bootstrap_phases": manifest.bootstrap.phases().len(),
|
||||
}))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(format!("failed to extract manifests: {error}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn print_bootstrap_plan(
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
println!("Bootstrap Plan\n Status not implemented\n Message the bootstrap plan surface is not implemented in this build yet.");
|
||||
}
|
||||
CliOutputFormat::Json => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&bootstrap_plan_json_value())?
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bootstrap_plan_json_value() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"kind": "bootstrap_plan",
|
||||
"phases": [],
|
||||
"message": "the bootstrap plan surface is not implemented in this build yet.",
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn print_system_prompt(
|
||||
_cwd: Option<PathBuf>,
|
||||
_date: Option<String>,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let prompt = build_system_prompt()?;
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
println!("{}", prompt.join("\n\n"));
|
||||
}
|
||||
CliOutputFormat::Json => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"kind": "system_prompt",
|
||||
"prompt": prompt,
|
||||
}))?
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_version(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
println!("{}", render_version_report());
|
||||
}
|
||||
CliOutputFormat::Json => {
|
||||
println!("{}", serde_json::to_string_pretty(&version_json_value())?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn version_json_value() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"kind": "version",
|
||||
"version": VERSION,
|
||||
"git_sha": GIT_SHA,
|
||||
"build_target": BUILD_TARGET,
|
||||
"build_date": DEFAULT_DATE,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
println!("{}", full_help_text());
|
||||
}
|
||||
CliOutputFormat::Json => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"kind": "help",
|
||||
"text": full_help_text(),
|
||||
}))?
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_connected_line(model: &str) -> String {
|
||||
let provider = if model.to_lowercase().contains("grok") {
|
||||
"xAI"
|
||||
} else {
|
||||
"Anthropic"
|
||||
};
|
||||
format!("\x1b[2mConnected to {provider} ({model})\x1b[0m")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
use crate::*;
|
||||
use runtime::{ConfigLoader, ConfigSource, ProjectContext};
|
||||
use serde_json;
|
||||
use std::env;
|
||||
|
||||
pub(crate) fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
let discovered = loader.discover();
|
||||
let runtime_config = loader.load()?;
|
||||
|
||||
let mut lines = vec![
|
||||
format!(
|
||||
"Config
|
||||
Working directory {}
|
||||
Loaded files {}
|
||||
Merged keys {}",
|
||||
cwd.display(),
|
||||
runtime_config.loaded_entries().len(),
|
||||
runtime_config.merged().len()
|
||||
),
|
||||
"Discovered files".to_string(),
|
||||
];
|
||||
for entry in discovered {
|
||||
let source = match entry.source {
|
||||
ConfigSource::User => "user",
|
||||
ConfigSource::Project => "project",
|
||||
ConfigSource::Local => "local",
|
||||
};
|
||||
let status = if runtime_config
|
||||
.loaded_entries()
|
||||
.iter()
|
||||
.any(|loaded_entry| loaded_entry.path == entry.path)
|
||||
{
|
||||
"loaded"
|
||||
} else {
|
||||
"missing"
|
||||
};
|
||||
lines.push(format!(
|
||||
" {source:<7} {status:<7} {}",
|
||||
entry.path.display()
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(section) = section {
|
||||
lines.push(format!("Merged section: {section}"));
|
||||
let value = match section {
|
||||
"env" => runtime_config.get("env"),
|
||||
"hooks" => runtime_config.get("hooks"),
|
||||
"model" => runtime_config.get("model"),
|
||||
"plugins" => runtime_config
|
||||
.get("plugins")
|
||||
.or_else(|| runtime_config.get("enabledPlugins")),
|
||||
other => {
|
||||
lines.push(format!(
|
||||
" Unsupported config section '{other}'. Use env, hooks, model, or plugins."
|
||||
));
|
||||
return Ok(lines.join("\n"));
|
||||
}
|
||||
};
|
||||
lines.push(format!(
|
||||
" {}",
|
||||
match value {
|
||||
Some(value) => value.render(),
|
||||
None => "<unset>".to_string(),
|
||||
}
|
||||
));
|
||||
return Ok(lines.join("\n"));
|
||||
}
|
||||
|
||||
lines.push("Merged JSON".to_string());
|
||||
lines.push(format!(" {}", runtime_config.as_json().render()));
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn render_config_json(
|
||||
_section: Option<&str>,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
let discovered = loader.discover();
|
||||
let runtime_config = loader.load()?;
|
||||
|
||||
let loaded_paths: Vec<_> = runtime_config
|
||||
.loaded_entries()
|
||||
.iter()
|
||||
.map(|e| e.path.display().to_string())
|
||||
.collect();
|
||||
|
||||
let files: Vec<_> = discovered
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let source = match e.source {
|
||||
ConfigSource::User => "user",
|
||||
ConfigSource::Project => "project",
|
||||
ConfigSource::Local => "local",
|
||||
};
|
||||
let is_loaded = runtime_config
|
||||
.loaded_entries()
|
||||
.iter()
|
||||
.any(|le| le.path == e.path);
|
||||
serde_json::json!({
|
||||
"path": e.path.display().to_string(),
|
||||
"source": source,
|
||||
"loaded": is_loaded,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"kind": "config",
|
||||
"cwd": cwd.display().to_string(),
|
||||
"loaded_files": loaded_paths.len(),
|
||||
"merged_keys": runtime_config.merged().len(),
|
||||
"files": files,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
|
||||
let mut lines = vec![format!(
|
||||
"Memory
|
||||
Working directory {}
|
||||
Instruction files {}",
|
||||
cwd.display(),
|
||||
project_context.instruction_files.len()
|
||||
)];
|
||||
if project_context.instruction_files.is_empty() {
|
||||
lines.push("Discovered files".to_string());
|
||||
lines.push(
|
||||
" No CLAUDE instruction files discovered in the current directory ancestry."
|
||||
.to_string(),
|
||||
);
|
||||
} else {
|
||||
lines.push("Discovered files".to_string());
|
||||
for (index, file) in project_context.instruction_files.iter().enumerate() {
|
||||
let preview = file.content.lines().next().unwrap_or("").trim();
|
||||
let preview = if preview.is_empty() {
|
||||
"<empty>"
|
||||
} else {
|
||||
preview
|
||||
};
|
||||
lines.push(format!(" {}. {}", index + 1, file.path.display(),));
|
||||
lines.push(format!(
|
||||
" lines={} preview={}",
|
||||
file.content.lines().count(),
|
||||
preview
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn render_memory_json() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
|
||||
let files: Vec<_> = project_context
|
||||
.instruction_files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
serde_json::json!({
|
||||
"path": f.path.display().to_string(),
|
||||
"lines": f.content.lines().count(),
|
||||
"preview": f.content.lines().next().unwrap_or("").trim(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({
|
||||
"kind": "memory",
|
||||
"cwd": cwd.display().to_string(),
|
||||
"instruction_files": files.len(),
|
||||
"files": files,
|
||||
}))
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ pub(crate) const CLI_OPTION_SUGGESTIONS: &[&str] = &[
|
|||
"-p",
|
||||
];
|
||||
|
||||
pub(crate) const DEFAULT_HISTORY_LIMIT: usize = 20;
|
||||
pub(crate) type AllowedToolSet = BTreeSet<String>;
|
||||
|
||||
pub(crate) const STUB_COMMANDS: &[&str] = &[
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
use crate::*;
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub(crate) fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
|
||||
render_diff_report_for(&env::current_dir()?)
|
||||
}
|
||||
|
||||
pub(crate) fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Verify we are inside a git repository before calling `git diff`.
|
||||
// Running `git diff --cached` outside a git tree produces a misleading
|
||||
// "unknown option `cached`" error because git falls back to --no-index mode.
|
||||
let in_git_repo = Command::new("git")
|
||||
.args(["rev-parse", "--is-inside-work-tree"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if !in_git_repo {
|
||||
return Ok(format!(
|
||||
"Diff\n Result no git repository\n Detail {} is not inside a git project",
|
||||
cwd.display()
|
||||
));
|
||||
}
|
||||
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
|
||||
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
|
||||
if staged.trim().is_empty() && unstaged.trim().is_empty() {
|
||||
return Ok(
|
||||
"Diff\n Result clean working tree\n Detail no current changes"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut sections = Vec::new();
|
||||
if !staged.trim().is_empty() {
|
||||
sections.push(format!("Staged changes:\n{}", staged.trim_end()));
|
||||
}
|
||||
if !unstaged.trim().is_empty() {
|
||||
sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end()));
|
||||
}
|
||||
|
||||
Ok(format!("Diff\n\n{}", sections.join("\n\n")))
|
||||
}
|
||||
|
||||
pub(crate) fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let in_git_repo = Command::new("git")
|
||||
.args(["rev-parse", "--is-inside-work-tree"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if !in_git_repo {
|
||||
return Ok(serde_json::json!({
|
||||
"kind": "diff",
|
||||
"result": "no_git_repo",
|
||||
"detail": format!("{} is not inside a git project", cwd.display()),
|
||||
}));
|
||||
}
|
||||
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
|
||||
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
|
||||
Ok(serde_json::json!({
|
||||
"kind": "diff",
|
||||
"result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" },
|
||||
"staged": staged.trim(),
|
||||
"unstaged": unstaged.trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn run_git_diff_command_in(
|
||||
cwd: &Path,
|
||||
args: &[&str],
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
|
||||
}
|
||||
Ok(String::from_utf8(output.stdout)?)
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ impl DoctorReport {
|
|||
pub(crate) fn json_value(&self) -> Value {
|
||||
let report = self.render();
|
||||
let (ok_count, warn_count, fail_count) = self.counts();
|
||||
json!({
|
||||
serde_json::json!({
|
||||
"kind": "doctor",
|
||||
"message": report,
|
||||
"report": report,
|
||||
|
|
@ -213,12 +213,12 @@ fn check_auth_health() -> DiagnosticCheck {
|
|||
"Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed".to_string(),
|
||||
])
|
||||
.with_data(Map::from_iter([
|
||||
("api_key_present".to_string(), json!(api_key_present)),
|
||||
("auth_token_present".to_string(), json!(auth_token_present)),
|
||||
("legacy_saved_oauth_present".to_string(), json!(true)),
|
||||
("legacy_saved_oauth_expires_at".to_string(), json!(token_set.expires_at)),
|
||||
("legacy_refresh_token_present".to_string(), json!(token_set.refresh_token.is_some())),
|
||||
("legacy_scopes".to_string(), json!(token_set.scopes)),
|
||||
("api_key_present".to_string(),serde_json::json!(api_key_present)),
|
||||
("auth_token_present".to_string(),serde_json::json!(auth_token_present)),
|
||||
("legacy_saved_oauth_present".to_string(),serde_json::json!(true)),
|
||||
("legacy_saved_oauth_expires_at".to_string(),serde_json::json!(token_set.expires_at)),
|
||||
("legacy_refresh_token_present".to_string(),serde_json::json!(token_set.refresh_token.is_some())),
|
||||
("legacy_scopes".to_string(),serde_json::json!(token_set.scopes)),
|
||||
])),
|
||||
Ok(None) => DiagnosticCheck::new(
|
||||
"Auth",
|
||||
|
|
@ -231,12 +231,12 @@ fn check_auth_health() -> DiagnosticCheck {
|
|||
)
|
||||
.with_details(vec![env_details])
|
||||
.with_data(Map::from_iter([
|
||||
("api_key_present".to_string(), json!(api_key_present)),
|
||||
("auth_token_present".to_string(), json!(auth_token_present)),
|
||||
("legacy_saved_oauth_present".to_string(), json!(false)),
|
||||
("api_key_present".to_string(),serde_json::json!(api_key_present)),
|
||||
("auth_token_present".to_string(),serde_json::json!(auth_token_present)),
|
||||
("legacy_saved_oauth_present".to_string(),serde_json::json!(false)),
|
||||
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
|
||||
("legacy_refresh_token_present".to_string(), json!(false)),
|
||||
("legacy_scopes".to_string(), json!(Vec::<String>::new())),
|
||||
("legacy_refresh_token_present".to_string(),serde_json::json!(false)),
|
||||
("legacy_scopes".to_string(),serde_json::json!(Vec::<String>::new())),
|
||||
])),
|
||||
Err(error) => DiagnosticCheck::new(
|
||||
"Auth",
|
||||
|
|
@ -244,13 +244,13 @@ fn check_auth_health() -> DiagnosticCheck {
|
|||
format!("failed to inspect legacy saved credentials: {error}"),
|
||||
)
|
||||
.with_data(Map::from_iter([
|
||||
("api_key_present".to_string(), json!(api_key_present)),
|
||||
("auth_token_present".to_string(), json!(auth_token_present)),
|
||||
("api_key_present".to_string(),serde_json::json!(api_key_present)),
|
||||
("auth_token_present".to_string(),serde_json::json!(auth_token_present)),
|
||||
("legacy_saved_oauth_present".to_string(), Value::Null),
|
||||
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
|
||||
("legacy_refresh_token_present".to_string(), Value::Null),
|
||||
("legacy_scopes".to_string(), Value::Null),
|
||||
("legacy_saved_oauth_error".to_string(), json!(error.to_string())),
|
||||
("legacy_saved_oauth_error".to_string(),serde_json::json!(error.to_string())),
|
||||
])),
|
||||
}
|
||||
}
|
||||
|
|
@ -278,22 +278,22 @@ fn check_config_health(
|
|||
DiagnosticCheck::new("Config", DiagnosticLevel::Ok, if present_paths.is_empty() { "no config files present; defaults are active" } else { "runtime config loaded successfully" })
|
||||
.with_details(details)
|
||||
.with_data(Map::from_iter([
|
||||
("discovered_files".to_string(), json!(present_paths)),
|
||||
("discovered_files_count".to_string(), json!(present_paths.len())),
|
||||
("loaded_config_files".to_string(), json!(loaded_entries.len())),
|
||||
("resolved_model".to_string(), json!(runtime_config.model())),
|
||||
("mcp_servers".to_string(), json!(runtime_config.mcp().servers().len())),
|
||||
("discovered_files".to_string(),serde_json::json!(present_paths)),
|
||||
("discovered_files_count".to_string(),serde_json::json!(present_paths.len())),
|
||||
("loaded_config_files".to_string(),serde_json::json!(loaded_entries.len())),
|
||||
("resolved_model".to_string(),serde_json::json!(runtime_config.model())),
|
||||
("mcp_servers".to_string(),serde_json::json!(runtime_config.mcp().servers().len())),
|
||||
]))
|
||||
}
|
||||
Err(error) => DiagnosticCheck::new("Config", DiagnosticLevel::Fail, format!("runtime config failed to load: {error}"))
|
||||
.with_details(if discovered_paths.is_empty() { vec!["Discovered files <none>".to_string()] } else { discovered_paths.iter().map(|p| format!("Discovered file {p}")).collect() })
|
||||
.with_data(Map::from_iter([
|
||||
("discovered_files".to_string(), json!(discovered_paths)),
|
||||
("discovered_files_count".to_string(), json!(discovered_paths.len())),
|
||||
("loaded_config_files".to_string(), json!(0)),
|
||||
("discovered_files".to_string(),serde_json::json!(discovered_paths)),
|
||||
("discovered_files_count".to_string(),serde_json::json!(discovered_paths.len())),
|
||||
("loaded_config_files".to_string(),serde_json::json!(0)),
|
||||
("resolved_model".to_string(), Value::Null),
|
||||
("mcp_servers".to_string(), Value::Null),
|
||||
("load_error".to_string(), json!(error.to_string())),
|
||||
("load_error".to_string(),serde_json::json!(error.to_string())),
|
||||
])),
|
||||
}
|
||||
}
|
||||
|
|
@ -306,9 +306,9 @@ fn check_install_source_health() -> DiagnosticCheck {
|
|||
format!("Deprecated crate `{DEPRECATED_INSTALL_COMMAND}` installs a deprecated stub and does not provide the `claw` binary"),
|
||||
])
|
||||
.with_data(Map::from_iter([
|
||||
("official_repo".to_string(), json!(OFFICIAL_REPO_URL)),
|
||||
("deprecated_install".to_string(), json!(DEPRECATED_INSTALL_COMMAND)),
|
||||
("recommended_install".to_string(), json!("build from source or follow the upstream binary instructions in README.md")),
|
||||
("official_repo".to_string(),serde_json::json!(OFFICIAL_REPO_URL)),
|
||||
("deprecated_install".to_string(),serde_json::json!(DEPRECATED_INSTALL_COMMAND)),
|
||||
("recommended_install".to_string(),serde_json::json!("build from source or follow the upstream binary instructions in README.md")),
|
||||
]))
|
||||
}
|
||||
|
||||
|
|
@ -326,15 +326,15 @@ fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
|
|||
format!("Memory files {} · config files loaded {}/{}", context.memory_file_count, context.loaded_config_files, context.discovered_config_files),
|
||||
])
|
||||
.with_data(Map::from_iter([
|
||||
("cwd".to_string(), json!(context.cwd.display().to_string())),
|
||||
("project_root".to_string(), json!(context.project_root.as_ref().map(|p| p.display().to_string()))),
|
||||
("in_git_repo".to_string(), json!(in_repo)),
|
||||
("git_branch".to_string(), json!(context.git_branch)),
|
||||
("git_state".to_string(), json!(context.git_summary.headline())),
|
||||
("changed_files".to_string(), json!(context.git_summary.changed_files)),
|
||||
("memory_file_count".to_string(), json!(context.memory_file_count)),
|
||||
("loaded_config_files".to_string(), json!(context.loaded_config_files)),
|
||||
("discovered_config_files".to_string(), json!(context.discovered_config_files)),
|
||||
("cwd".to_string(),serde_json::json!(context.cwd.display().to_string())),
|
||||
("project_root".to_string(),serde_json::json!(context.project_root.as_ref().map(|p| p.display().to_string()))),
|
||||
("in_git_repo".to_string(),serde_json::json!(in_repo)),
|
||||
("git_branch".to_string(),serde_json::json!(context.git_branch)),
|
||||
("git_state".to_string(),serde_json::json!(context.git_summary.headline())),
|
||||
("changed_files".to_string(),serde_json::json!(context.git_summary.changed_files)),
|
||||
("memory_file_count".to_string(),serde_json::json!(context.memory_file_count)),
|
||||
("loaded_config_files".to_string(),serde_json::json!(context.loaded_config_files)),
|
||||
("discovered_config_files".to_string(),serde_json::json!(context.discovered_config_files)),
|
||||
]))
|
||||
}
|
||||
|
||||
|
|
@ -356,19 +356,19 @@ fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck {
|
|||
)
|
||||
.with_details(details)
|
||||
.with_data(Map::from_iter([
|
||||
("enabled".to_string(), json!(status.enabled)),
|
||||
("active".to_string(), json!(status.active)),
|
||||
("supported".to_string(), json!(status.supported)),
|
||||
("namespace_supported".to_string(), json!(status.namespace_supported)),
|
||||
("namespace_active".to_string(), json!(status.namespace_active)),
|
||||
("network_supported".to_string(), json!(status.network_supported)),
|
||||
("network_active".to_string(), json!(status.network_active)),
|
||||
("filesystem_mode".to_string(), json!(status.filesystem_mode.as_str())),
|
||||
("filesystem_active".to_string(), json!(status.filesystem_active)),
|
||||
("allowed_mounts".to_string(), json!(status.allowed_mounts)),
|
||||
("in_container".to_string(), json!(status.in_container)),
|
||||
("container_markers".to_string(), json!(status.container_markers)),
|
||||
("fallback_reason".to_string(), json!(status.fallback_reason)),
|
||||
("enabled".to_string(),serde_json::json!(status.enabled)),
|
||||
("active".to_string(),serde_json::json!(status.active)),
|
||||
("supported".to_string(),serde_json::json!(status.supported)),
|
||||
("namespace_supported".to_string(),serde_json::json!(status.namespace_supported)),
|
||||
("namespace_active".to_string(),serde_json::json!(status.namespace_active)),
|
||||
("network_supported".to_string(),serde_json::json!(status.network_supported)),
|
||||
("network_active".to_string(),serde_json::json!(status.network_active)),
|
||||
("filesystem_mode".to_string(),serde_json::json!(status.filesystem_mode.as_str())),
|
||||
("filesystem_active".to_string(),serde_json::json!(status.filesystem_active)),
|
||||
("allowed_mounts".to_string(),serde_json::json!(status.allowed_mounts)),
|
||||
("in_container".to_string(),serde_json::json!(status.in_container)),
|
||||
("container_markers".to_string(),serde_json::json!(status.container_markers)),
|
||||
("fallback_reason".to_string(),serde_json::json!(status.fallback_reason)),
|
||||
]))
|
||||
}
|
||||
|
||||
|
|
@ -387,12 +387,12 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D
|
|||
DiagnosticCheck::new("System", DiagnosticLevel::Ok, "captured local runtime metadata")
|
||||
.with_details(details)
|
||||
.with_data(Map::from_iter([
|
||||
("os".to_string(), json!(env::consts::OS)),
|
||||
("arch".to_string(), json!(env::consts::ARCH)),
|
||||
("working_dir".to_string(), json!(cwd.display().to_string())),
|
||||
("version".to_string(), json!(VERSION)),
|
||||
("build_target".to_string(), json!(BUILD_TARGET)),
|
||||
("git_sha".to_string(), json!(GIT_SHA)),
|
||||
("default_model".to_string(), json!(default_model)),
|
||||
("os".to_string(),serde_json::json!(env::consts::OS)),
|
||||
("arch".to_string(),serde_json::json!(env::consts::ARCH)),
|
||||
("working_dir".to_string(),serde_json::json!(cwd.display().to_string())),
|
||||
("version".to_string(),serde_json::json!(VERSION)),
|
||||
("build_target".to_string(),serde_json::json!(BUILD_TARGET)),
|
||||
("git_sha".to_string(),serde_json::json!(GIT_SHA)),
|
||||
("default_model".to_string(),serde_json::json!(default_model)),
|
||||
]))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
use crate::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
||||
pub(crate) fn run_export(
|
||||
session_reference: &str,
|
||||
output_path: Option<&Path>,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (handle, session) = load_session_reference(session_reference)?;
|
||||
let markdown = render_session_markdown(&session, &handle.id, &handle.path);
|
||||
|
||||
if let Some(path) = output_path {
|
||||
fs::write(path, &markdown)?;
|
||||
let report = format!(
|
||||
"Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}",
|
||||
path.display(),
|
||||
handle.id,
|
||||
session.messages.len(),
|
||||
);
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{report}"),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"kind": "export",
|
||||
"message": report,
|
||||
"session_id": handle.id,
|
||||
"file": path.display().to_string(),
|
||||
"messages": session.messages.len(),
|
||||
}))?
|
||||
),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
print!("{markdown}");
|
||||
if !markdown.ends_with('\n') {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"kind": "export",
|
||||
"session_id": handle.id,
|
||||
"file": handle.path.display().to_string(),
|
||||
"messages": session.messages.len(),
|
||||
"markdown": markdown,
|
||||
}))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn default_export_filename(session: &Session) -> String {
|
||||
let stem = session
|
||||
.messages
|
||||
.iter()
|
||||
.find_map(|message| match message.role {
|
||||
MessageRole::User => message.blocks.iter().find_map(|block| match block {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.map_or("conversation", |text| {
|
||||
text.lines().next().unwrap_or("conversation")
|
||||
})
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|part| !part.is_empty())
|
||||
.take(8)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
let fallback = if stem.is_empty() {
|
||||
"conversation"
|
||||
} else {
|
||||
&stem
|
||||
};
|
||||
format!("{fallback}.txt")
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_export_path(
|
||||
requested_path: Option<&str>,
|
||||
session: &Session,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
let file_name =
|
||||
requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned);
|
||||
let final_name = if Path::new(&file_name)
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
|
||||
{
|
||||
file_name
|
||||
} else {
|
||||
format!("{file_name}.txt")
|
||||
};
|
||||
Ok(cwd.join(final_name))
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
use std::path::PathBuf;
|
||||
use runtime::TokenUsage;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::constants::*;
|
||||
use crate::models::*;
|
||||
use crate::omc_compatibility_note_for_unknown_slash_command;
|
||||
use crate::suggest_slash_commands;
|
||||
|
||||
pub(crate) fn format_unknown_slash_command_message(name: &str) -> String {
|
||||
let suggestions = suggest_slash_commands(name);
|
||||
let mut message = format!("unknown slash command: /{name}.");
|
||||
if !suggestions.is_empty() {
|
||||
message.push_str(" Did you mean ");
|
||||
message.push_str(&suggestions.join(", "));
|
||||
message.push('?');
|
||||
}
|
||||
if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) {
|
||||
message.push(' ');
|
||||
message.push_str(note);
|
||||
}
|
||||
message.push_str(" Use /help to list available commands.");
|
||||
message
|
||||
}
|
||||
|
||||
pub(crate) fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
|
||||
format!(
|
||||
"Model
|
||||
Current model {model}
|
||||
Session messages {message_count}
|
||||
Session turns {turns}
|
||||
|
||||
Usage
|
||||
Inspect current model with /model
|
||||
Switch models with /model <name>"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String {
|
||||
format!(
|
||||
"Model updated
|
||||
Previous {previous}
|
||||
Current {next}
|
||||
Preserved msgs {message_count}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_permissions_report(mode: &str) -> String {
|
||||
let modes = [
|
||||
("read-only", "Read/search tools only", mode == "read-only"),
|
||||
(
|
||||
"workspace-write",
|
||||
"Edit files inside the workspace",
|
||||
mode == "workspace-write",
|
||||
),
|
||||
(
|
||||
"danger-full-access",
|
||||
"Unrestricted tool access",
|
||||
mode == "danger-full-access",
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, description, is_current)| {
|
||||
let marker = if is_current {
|
||||
"● current"
|
||||
} else {
|
||||
"○ available"
|
||||
};
|
||||
format!(" {name:<18} {marker:<11} {description}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(
|
||||
"
|
||||
",
|
||||
);
|
||||
|
||||
format!(
|
||||
"Permissions
|
||||
Active mode {mode}
|
||||
Mode status live session default
|
||||
|
||||
Modes
|
||||
{modes}
|
||||
|
||||
Usage
|
||||
Inspect current mode with /permissions
|
||||
Switch modes with /permissions <mode>"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_permissions_switch_report(previous: &str, next: &str) -> String {
|
||||
format!(
|
||||
"Permissions updated
|
||||
Result mode switched
|
||||
Previous mode {previous}
|
||||
Active mode {next}
|
||||
Applies to subsequent tool calls
|
||||
Usage /permissions to inspect current mode"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_cost_report(usage: TokenUsage) -> String {
|
||||
format!(
|
||||
"Cost
|
||||
Input tokens {}
|
||||
Output tokens {}
|
||||
Cache create {}
|
||||
Cache read {}
|
||||
Total tokens {}",
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.cache_creation_input_tokens,
|
||||
usage.cache_read_input_tokens,
|
||||
usage.total_tokens(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
|
||||
format!(
|
||||
"Session resumed
|
||||
Session file {session_path}
|
||||
Messages {message_count}
|
||||
Turns {turns}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_resume_usage() -> String {
|
||||
format!(
|
||||
"Resume
|
||||
Usage /resume <session-path|session-id|{LATEST_SESSION_REFERENCE}>
|
||||
Auto-save .claw/sessions/<session-id>.{PRIMARY_SESSION_EXTENSION}
|
||||
Tip use /session list to inspect saved sessions"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
|
||||
if skipped {
|
||||
format!(
|
||||
"Compact
|
||||
Result skipped
|
||||
Reason session below compaction threshold
|
||||
Messages kept {resulting_messages}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Compact
|
||||
Result compacted
|
||||
Messages removed {removed}
|
||||
Messages kept {resulting_messages}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_auto_compaction_notice(removed: usize) -> String {
|
||||
format!("[auto-compacted: removed {removed} messages]")
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
use serde_json::Value;
|
||||
|
||||
pub(crate) 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)
|
||||
}
|
||||
|
||||
pub(crate) fn indent_block(value: &str, spaces: usize) -> String {
|
||||
let indent = " ".repeat(spaces);
|
||||
value
|
||||
.lines()
|
||||
.map(|line| format!("{indent}{line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_for_prompt(value: &str, limit: usize) -> String {
|
||||
if value.chars().count() <= limit {
|
||||
value.trim().to_string()
|
||||
} else {
|
||||
let truncated = value.chars().take(limit).collect::<String>();
|
||||
format!("{}\n…[truncated]", truncated.trim_end())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn extract_tool_path(parsed: &Value) -> String {
|
||||
parsed
|
||||
.get("file_path")
|
||||
.or_else(|| parsed.get("filePath"))
|
||||
.or_else(|| parsed.get("path"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("?")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn first_visible_line(text: &str) -> &str {
|
||||
text.lines().find(|l| !l.trim().is_empty()).unwrap_or("")
|
||||
}
|
||||
|
||||
pub(crate) fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
|
||||
summary
|
||||
.assistant_messages
|
||||
.last()
|
||||
.map(|message| {
|
||||
message
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
runtime::ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
|
||||
summary
|
||||
.assistant_messages
|
||||
.iter()
|
||||
.flat_map(|message| message.blocks.iter())
|
||||
.filter_map(|block| match block {
|
||||
runtime::ContentBlock::ToolUse { id, name, input } => Some(serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"input": input,
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
|
||||
summary
|
||||
.tool_results
|
||||
.iter()
|
||||
.flat_map(|message| message.blocks.iter())
|
||||
.filter_map(|block| match block {
|
||||
runtime::ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
} => Some(serde_json::json!({
|
||||
"tool_use_id": tool_use_id,
|
||||
"tool_name": tool_name,
|
||||
"output": output,
|
||||
"is_error": is_error,
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
|
||||
summary
|
||||
.prompt_cache_events
|
||||
.iter()
|
||||
.map(|event| {
|
||||
serde_json::json!({
|
||||
"unexpected": event.unexpected,
|
||||
"reason": event.reason,
|
||||
"previous_cache_read_input_tokens": event.previous_cache_read_input_tokens,
|
||||
"current_cache_read_input_tokens": event.current_cache_read_input_tokens,
|
||||
"token_drop": event.token_drop,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_generated_message(value: &str) -> String {
|
||||
value.trim().trim_matches('`').trim().replace("\r\n", "\n")
|
||||
}
|
||||
|
||||
pub(crate) fn parse_titled_body(value: &str) -> Option<(String, String)> {
|
||||
let normalized = sanitize_generated_message(value);
|
||||
let title = normalized
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?;
|
||||
let body_start = normalized.find("BODY:")?;
|
||||
let body = normalized[body_start + "BODY:".len()..].trim();
|
||||
Some((title.to_string(), body.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize) -> String {
|
||||
let original = content.trim_end_matches('\n');
|
||||
if original.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut preview_lines = Vec::new();
|
||||
let mut used_chars = 0usize;
|
||||
let mut truncated = false;
|
||||
|
||||
for (index, line) in original.lines().enumerate() {
|
||||
if index >= max_lines {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let newline_cost = usize::from(!preview_lines.is_empty());
|
||||
let available = max_chars.saturating_sub(used_chars + newline_cost);
|
||||
if available == 0 {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let line_chars = line.chars().count();
|
||||
if line_chars > available {
|
||||
preview_lines.push(line.chars().take(available).collect::<String>());
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
preview_lines.push(line.to_string());
|
||||
used_chars += newline_cost + line_chars;
|
||||
}
|
||||
|
||||
let mut preview = preview_lines.join("\n");
|
||||
if truncated {
|
||||
if !preview.is_empty() {
|
||||
preview.push('\n');
|
||||
}
|
||||
preview.push_str(super::DISPLAY_TRUNCATION_NOTICE);
|
||||
}
|
||||
preview
|
||||
}
|
||||
|
||||
pub(crate) fn summarize_tool_payload(payload: &str) -> String {
|
||||
let compact = match serde_json::from_str::<Value>(payload) {
|
||||
Ok(value) => value.to_string(),
|
||||
Err(_) => payload.trim().to_string(),
|
||||
};
|
||||
truncate_for_summary(&compact, 96)
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_for_summary(value: &str, limit: usize) -> String {
|
||||
let mut chars = value.chars();
|
||||
let truncated = chars.by_ref().take(limit).collect::<String>();
|
||||
if chars.next().is_some() {
|
||||
format!("{truncated}…")
|
||||
} else {
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
pub(crate) mod helpers;
|
||||
pub(crate) mod reports;
|
||||
pub(crate) mod tools;
|
||||
|
||||
pub(crate) use helpers::*;
|
||||
pub(crate) use reports::*;
|
||||
pub(crate) use tools::*;
|
||||
|
||||
pub(crate) fn full_help_text() -> String {
|
||||
reports::full_help_text()
|
||||
}
|
||||
|
||||
pub(crate) const DISPLAY_TRUNCATION_NOTICE: &str =
|
||||
"\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m";
|
||||
pub(crate) const READ_DISPLAY_MAX_LINES: usize = 80;
|
||||
pub(crate) const READ_DISPLAY_MAX_CHARS: usize = 6_000;
|
||||
pub(crate) const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60;
|
||||
pub(crate) const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000;
|
||||
pub(crate) const SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT: usize = 280;
|
||||
|
|
@ -0,0 +1,755 @@
|
|||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::env;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use runtime::{Session, TokenUsage};
|
||||
use commands::render_slash_command_help_filtered;
|
||||
use crate::constants::*;
|
||||
use crate::models::*;
|
||||
use crate::live_cli::PromptHistoryEntry;
|
||||
use crate::args::LocalHelpTopic;
|
||||
use crate::omc_compatibility_note_for_unknown_slash_command;
|
||||
use crate::render::TerminalRenderer;
|
||||
use super::helpers::*;
|
||||
use super::tools::format_tool_result;
|
||||
|
||||
|
||||
|
||||
pub(crate) fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
|
||||
format!(
|
||||
"Model
|
||||
Current model {model}
|
||||
Session messages {message_count}
|
||||
Session turns {turns}
|
||||
|
||||
Usage
|
||||
Inspect current model with /model
|
||||
Switch models with /model <name>"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String {
|
||||
format!(
|
||||
"Model updated
|
||||
Previous {previous}
|
||||
Current {next}
|
||||
Preserved msgs {message_count}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_permissions_report(mode: &str) -> String {
|
||||
let modes = [
|
||||
("read-only", "Read/search tools only", mode == "read-only"),
|
||||
(
|
||||
"workspace-write",
|
||||
"Edit files inside the workspace",
|
||||
mode == "workspace-write",
|
||||
),
|
||||
(
|
||||
"danger-full-access",
|
||||
"Unrestricted tool access",
|
||||
mode == "danger-full-access",
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, description, is_current)| {
|
||||
let marker = if is_current {
|
||||
"● current"
|
||||
} else {
|
||||
"○ available"
|
||||
};
|
||||
format!(" {name:<18} {marker:<11} {description}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
format!(
|
||||
"Permissions
|
||||
Active mode {mode}
|
||||
Mode status live session default
|
||||
|
||||
Modes
|
||||
{modes}
|
||||
|
||||
Usage
|
||||
Inspect current mode with /permissions
|
||||
Switch modes with /permissions <mode>"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_permissions_switch_report(previous: &str, next: &str) -> String {
|
||||
format!(
|
||||
"Permissions updated
|
||||
Result mode switched
|
||||
Previous mode {previous}
|
||||
Active mode {next}
|
||||
Applies to subsequent tool calls
|
||||
Usage /permissions to inspect current mode"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_cost_report(usage: TokenUsage) -> String {
|
||||
format!(
|
||||
"Cost
|
||||
Input tokens {}
|
||||
Output tokens {}
|
||||
Cache create {}
|
||||
Cache read {}
|
||||
Total tokens {}",
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.cache_creation_input_tokens,
|
||||
usage.cache_read_input_tokens,
|
||||
usage.total_tokens(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
|
||||
format!(
|
||||
"Session resumed
|
||||
Session file {session_path}
|
||||
Messages {message_count}
|
||||
Turns {turns}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_resume_usage() -> String {
|
||||
format!(
|
||||
"Resume
|
||||
Usage /resume <session-path|session-id|{LATEST_SESSION_REFERENCE}>
|
||||
Auto-save .claw/sessions/<session-id>.{PRIMARY_SESSION_EXTENSION}
|
||||
Tip use /session list to inspect saved sessions"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) 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_filtered(STUB_COMMANDS),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn full_help_text() -> String {
|
||||
let mut out = Vec::new();
|
||||
let _ = print_help_to(&mut out);
|
||||
String::from_utf8_lossy(&out).to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn print_help_to(out: &mut impl std::io::Write) -> std::io::Result<()> {
|
||||
writeln!(out, "claw v{VERSION}")?;
|
||||
writeln!(out)?;
|
||||
writeln!(out, "Usage:")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]"
|
||||
)?;
|
||||
writeln!(out, " Start the interactive REPL")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw [--model MODEL] [--output-format text|json] prompt TEXT"
|
||||
)?;
|
||||
writeln!(out, " Send one prompt and exit")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw [--model MODEL] [--output-format text|json] TEXT"
|
||||
)?;
|
||||
writeln!(out, " Shorthand non-interactive prompt mode")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw --resume [SESSION.jsonl|session-id|latest] [/status] [/compact] [...]"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" Inspect or maintain a saved session without entering the REPL"
|
||||
)?;
|
||||
writeln!(out, " claw help")?;
|
||||
writeln!(out, " Alias for --help")?;
|
||||
writeln!(out, " claw version")?;
|
||||
writeln!(out, " Alias for --version")?;
|
||||
writeln!(out, " claw status")?;
|
||||
writeln!(
|
||||
out,
|
||||
" Show the current local workspace status snapshot"
|
||||
)?;
|
||||
writeln!(out, " claw sandbox")?;
|
||||
writeln!(out, " Show the current sandbox isolation snapshot")?;
|
||||
writeln!(out, " claw doctor")?;
|
||||
writeln!(
|
||||
out,
|
||||
" Diagnose local auth, config, workspace, and sandbox health"
|
||||
)?;
|
||||
writeln!(out, " claw acp [serve]")?;
|
||||
writeln!(
|
||||
out,
|
||||
" Show ACP/Zed editor integration status (currently unsupported; aliases: --acp, -acp)"
|
||||
)?;
|
||||
writeln!(out, " Source of truth: {OFFICIAL_REPO_SLUG}")?;
|
||||
writeln!(
|
||||
out,
|
||||
" Warning: do not `{DEPRECATED_INSTALL_COMMAND}` (deprecated stub)"
|
||||
)?;
|
||||
writeln!(out, " claw dump-manifests [--manifests-dir PATH]")?;
|
||||
writeln!(out, " claw bootstrap-plan")?;
|
||||
writeln!(out, " claw agents")?;
|
||||
writeln!(out, " claw mcp")?;
|
||||
writeln!(out, " claw skills")?;
|
||||
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
|
||||
writeln!(out, " claw init")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw export [PATH] [--session SESSION] [--output PATH]"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" Dump the latest (or named) session as markdown; writes to PATH or stdout"
|
||||
)?;
|
||||
writeln!(out)?;
|
||||
writeln!(out, "Flags:")?;
|
||||
writeln!(
|
||||
out,
|
||||
" --model MODEL Override the active model"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" --output-format FORMAT Non-interactive output format: text or json"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" --compact Strip tool call details; print only the final assistant text (text mode only; useful for piping)"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" --dangerously-skip-permissions Skip all permission checks"
|
||||
)?;
|
||||
writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?;
|
||||
writeln!(
|
||||
out,
|
||||
" --version, -V Print version and build information locally"
|
||||
)?;
|
||||
writeln!(out)?;
|
||||
writeln!(out, "Interactive slash commands:")?;
|
||||
writeln!(out, "{}", render_slash_command_help_filtered(STUB_COMMANDS))?;
|
||||
writeln!(out)?;
|
||||
let resume_commands = commands::resume_supported_slash_commands()
|
||||
.into_iter()
|
||||
.map(|spec| match spec.argument_hint {
|
||||
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
||||
None => format!("/{}", spec.name),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
writeln!(out, "Resume-safe commands: {resume_commands}")?;
|
||||
writeln!(out)?;
|
||||
writeln!(out, "Session shortcuts:")?;
|
||||
writeln!(
|
||||
out,
|
||||
" REPL turns auto-save to .claw/sessions/<session-id>.{PRIMARY_SESSION_EXTENSION}"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" Use `{LATEST_SESSION_REFERENCE}` with --resume, /resume, or /session switch to target the newest saved session"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" Use /session list in the REPL to browse managed sessions"
|
||||
)?;
|
||||
writeln!(out, "Examples:")?;
|
||||
writeln!(out, " claw --model claude-opus \"summarize this repo\"")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw --output-format json prompt \"explain src/main.rs\""
|
||||
)?;
|
||||
writeln!(out, " claw --compact \"summarize Cargo.toml\" | wc -l")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw --allowedTools read,glob \"summarize Cargo.toml\""
|
||||
)?;
|
||||
writeln!(out, " claw --resume {LATEST_SESSION_REFERENCE}")?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt"
|
||||
)?;
|
||||
writeln!(out, " claw agents")?;
|
||||
writeln!(out, " claw mcp show my-server")?;
|
||||
writeln!(out, " claw /skills")?;
|
||||
writeln!(out, " claw doctor")?;
|
||||
writeln!(out, " source of truth: {OFFICIAL_REPO_URL}")?;
|
||||
writeln!(
|
||||
out,
|
||||
" do not run `{DEPRECATED_INSTALL_COMMAND}` — it installs a deprecated stub"
|
||||
)?;
|
||||
writeln!(out, " claw init")?;
|
||||
writeln!(out, " claw export")?;
|
||||
writeln!(out, " claw export conversation.md")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
pub(crate) fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
|
||||
if skipped {
|
||||
format!(
|
||||
"Compact
|
||||
Result skipped
|
||||
Reason session below compaction threshold
|
||||
Messages kept {resulting_messages}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Compact
|
||||
Result compacted
|
||||
Messages removed {removed}
|
||||
Messages kept {resulting_messages}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_auto_compaction_notice(removed: usize) -> String {
|
||||
format!("[auto-compacted: removed {removed} messages]")
|
||||
}
|
||||
|
||||
pub(crate) fn render_help_topic(topic: LocalHelpTopic) -> String {
|
||||
match topic {
|
||||
LocalHelpTopic::Status => "Status
|
||||
Usage claw status [--output-format <format>]
|
||||
Purpose show local workspace snapshot without entering REPL
|
||||
Output model, permissions, git state, config files, and sandbox status
|
||||
Formats text (default), json
|
||||
Related /status · claw --resume latest /status"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Sandbox => "Sandbox
|
||||
Usage claw sandbox [--output-format <format>]
|
||||
Purpose inspect resolved sandbox and isolation state for current directory
|
||||
Output namespace, network, filesystem, and fallback details
|
||||
Formats text (default), json
|
||||
Related /sandbox · claw status"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Doctor => "Doctor
|
||||
Usage claw doctor [--output-format <format>]
|
||||
Purpose diagnose local auth, config, workspace, sandbox, and build metadata
|
||||
Output local-only health report; no provider request or session resume required
|
||||
Formats text (default), json
|
||||
Related /doctor · claw --resume latest /doctor"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Acp => "ACP / Zed
|
||||
Usage claw acp [serve] [--output-format <format>]
|
||||
Aliases claw --acp · claw -acp
|
||||
Purpose explain current editor-facing ACP/Zed launch contract without starting runtime
|
||||
Status discoverability only; `serve` is a status alias and does not launch daemon yet
|
||||
Formats text (default), json
|
||||
Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Init => "Init
|
||||
Usage claw init [--output-format <format>]
|
||||
Purpose create .claw/, .claw.json, .gitignore, and CLAUDE.md in current project
|
||||
Output list of created vs skipped files (idempotent: safe to re-run)
|
||||
Formats text (default), json
|
||||
Related claw status · claw doctor"
|
||||
.to_string(),
|
||||
LocalHelpTopic::State => "State
|
||||
Usage claw state [--output-format <format>]
|
||||
Purpose read .claw/worker-state.json written by interactive REPL or one-shot prompt
|
||||
Output worker id, model, permissions, session reference (text or json)
|
||||
Formats text (default), json
|
||||
Produces state `claw` (interactive REPL) or `claw prompt <text>` (one non-interactive turn)
|
||||
Observes state `claw state` reads; clawhip/CI may poll this file without HTTP
|
||||
Exit codes 0 if state file exists and parses; 1 with actionable hint otherwise
|
||||
Related claw status · ROADMAP #139 (this worker-concept contract)"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Export => "Export
|
||||
Usage claw export [--session <id|latest>] [--output <path>] [--output-format <format>]
|
||||
Purpose serialize managed session to JSON for review, transfer, or archival
|
||||
Defaults --session latest (most recent managed session in .claw/sessions/)
|
||||
Formats text (default), json
|
||||
Related /session list · claw --resume latest"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Version => "Version
|
||||
Usage claw version [--output-format <format>]
|
||||
Aliases claw --version · claw -V
|
||||
Purpose print claw CLI version and build metadata
|
||||
Formats text (default), json
|
||||
Related claw doctor (full build/auth/config diagnostic)"
|
||||
.to_string(),
|
||||
LocalHelpTopic::SystemPrompt => "System Prompt
|
||||
Usage claw system-prompt [--cwd <path>] [--date YYYY-MM-DD] [--output-format <format>]
|
||||
Purpose render resolved system prompt that `claw` would send for given cwd + date
|
||||
Options --cwd overrides workspace dir · --date injects deterministic date stamp
|
||||
Formats text (default), json
|
||||
Related claw doctor · claw dump-manifests"
|
||||
.to_string(),
|
||||
LocalHelpTopic::DumpManifests => "Dump Manifests
|
||||
Usage claw dump-manifests [--manifests-dir <path>] [--output-format <format>]
|
||||
Purpose emit every skill/agent/tool manifest resolver would load for current cwd
|
||||
Options --manifests-dir scopes discovery to specific directory
|
||||
Formats text (default), json
|
||||
Related claw skills · claw agents · claw doctor"
|
||||
.to_string(),
|
||||
LocalHelpTopic::BootstrapPlan => "Bootstrap Plan
|
||||
Usage claw bootstrap-plan [--output-format <format>]
|
||||
Purpose list ordered startup phases CLI would execute before dispatch
|
||||
Output phase names (text) or structured phase list (json) — primary output is plan itself
|
||||
Formats text (default), json
|
||||
Related claw doctor · claw status"
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render_teleport_report(target: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
|
||||
let file_list = Command::new("rg")
|
||||
.args(["--files"])
|
||||
.current_dir(&cwd)
|
||||
.output()?;
|
||||
let file_matches = if file_list.status.success() {
|
||||
String::from_utf8(file_list.stdout)?
|
||||
.lines()
|
||||
.filter(|line| line.contains(target))
|
||||
.take(10)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let content_output = Command::new("rg")
|
||||
.args(["-n", "-S", "--color", "never", target, "."])
|
||||
.current_dir(&cwd)
|
||||
.output()?;
|
||||
|
||||
let mut lines = vec![
|
||||
"Teleport".to_string(),
|
||||
format!(" Target {target}"),
|
||||
" Action search workspace files and content for target".to_string(),
|
||||
];
|
||||
if !file_matches.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("File matches".to_string());
|
||||
lines.extend(file_matches.into_iter().map(|path| format!(" {path}")));
|
||||
}
|
||||
|
||||
if content_output.status.success() {
|
||||
let matches = String::from_utf8(content_output.stdout)?;
|
||||
if !matches.trim().is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("Content matches".to_string());
|
||||
lines.push(truncate_for_prompt(&matches, 4_000));
|
||||
}
|
||||
}
|
||||
|
||||
if lines.len() == 1 {
|
||||
lines.push(" Result no matches found".to_string());
|
||||
}
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn render_last_tool_debug_report(session: &Session) -> Result<String, Box<dyn std::error::Error>> {
|
||||
use runtime::ContentBlock;
|
||||
let last_tool_use = session
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|message| {
|
||||
message.blocks.iter().rev().find_map(|block| match block {
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
Some((id.clone(), name.clone(), input.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| "no prior tool call found in session".to_string())?;
|
||||
|
||||
let tool_result = session.messages.iter().rev().find_map(|message| {
|
||||
message.blocks.iter().rev().find_map(|block| match block {
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
} if tool_use_id == &last_tool_use.0 => {
|
||||
Some((tool_name.clone(), output.clone(), *is_error))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
|
||||
let mut lines = vec![
|
||||
"Debug tool call".to_string(),
|
||||
" Action inspect last recorded tool call and its result".to_string(),
|
||||
format!(" Tool id {}", last_tool_use.0),
|
||||
format!(" Tool name {}", last_tool_use.1),
|
||||
" Input".to_string(),
|
||||
indent_block(&last_tool_use.2, 4),
|
||||
];
|
||||
|
||||
match tool_result {
|
||||
Some((tool_name, output, is_error)) => {
|
||||
lines.push(" Result".to_string());
|
||||
lines.push(format!(" name {tool_name}"));
|
||||
lines.push(format!(
|
||||
" status {}",
|
||||
if is_error { "error" } else { "ok" }
|
||||
));
|
||||
lines.push(indent_block(&output, 4));
|
||||
}
|
||||
None => lines.push(" Result missing tool result".to_string()),
|
||||
}
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn format_bughunter_report(scope: Option<&str>) -> String {
|
||||
format!(
|
||||
"Bughunter
|
||||
Scope {}
|
||||
Action inspect the selected code for likely bugs and correctness issues
|
||||
Output findings should include file paths, severity, and suggested fixes",
|
||||
scope.unwrap_or("current repository")
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_ultraplan_report(task: Option<&str>) -> String {
|
||||
format!(
|
||||
"Ultraplan
|
||||
Task {}
|
||||
Action break work into a multi-step execution plan
|
||||
Output plan should cover goals, risks, sequencing, verification, and rollback",
|
||||
task.unwrap_or("current repo work")
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_pr_report(branch: &str, context: Option<&str>) -> String {
|
||||
format!(
|
||||
"PR
|
||||
Branch {branch}
|
||||
Context {}
|
||||
Action draft or create a pull request for current branch
|
||||
Output title and markdown body suitable for GitHub",
|
||||
context.unwrap_or("none")
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_issue_report(context: Option<&str>) -> String {
|
||||
format!(
|
||||
"Issue
|
||||
Context {}
|
||||
Action draft or create a GitHub issue from current context
|
||||
Output title and markdown body suitable for GitHub",
|
||||
context.unwrap_or("none")
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) 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")
|
||||
}
|
||||
|
||||
pub(crate) 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")
|
||||
}
|
||||
|
||||
pub(crate) fn render_export_text(session: &Session) -> String {
|
||||
use runtime::MessageRole;
|
||||
use runtime::ContentBlock;
|
||||
|
||||
let mut lines = vec!["# Conversation Export".to_string(), String::new()];
|
||||
for (index, message) in session.messages.iter().enumerate() {
|
||||
let role = match message.role {
|
||||
MessageRole::System => "system",
|
||||
MessageRole::User => "user",
|
||||
MessageRole::Assistant => "assistant",
|
||||
MessageRole::Tool => "tool",
|
||||
};
|
||||
lines.push(format!("## {}. {role}", index + 1));
|
||||
for block in &message.blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text } => lines.push(text.clone()),
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
lines.push(format!("[tool_use id={id} name={name}] {input}"));
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
} => {
|
||||
lines.push(format!(
|
||||
"[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}"
|
||||
));
|
||||
}
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
lines.push(format!("[thinking signature={signature:?}] {thinking}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn render_version_report() -> String {
|
||||
let git_sha = GIT_SHA.unwrap_or("unknown");
|
||||
let target = BUILD_TARGET.unwrap_or("unknown");
|
||||
format!(
|
||||
"Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_session_markdown(session: &Session, session_id: &str, session_path: &Path) -> String {
|
||||
use runtime::{MessageRole, ContentBlock};
|
||||
use super::tools::short_tool_id;
|
||||
use super::tools::summarize_tool_payload_for_markdown;
|
||||
|
||||
let mut lines = vec![
|
||||
"# Conversation Export".to_string(),
|
||||
String::new(),
|
||||
format!("- **Session**: `{session_id}`"),
|
||||
format!("- **File**: `{}`", session_path.display()),
|
||||
format!("- **Messages**: {}", session.messages.len()),
|
||||
];
|
||||
if let Some(workspace_root) = session.workspace_root() {
|
||||
lines.push(format!("- **Workspace**: `{}`", workspace_root.display()));
|
||||
}
|
||||
if let Some(fork) = &session.fork {
|
||||
let branch = fork.branch_name.as_deref().unwrap_or("(unnamed)");
|
||||
lines.push(format!(
|
||||
"- **Forked from**: `{}` (branch `{branch}`)",
|
||||
fork.parent_session_id
|
||||
));
|
||||
}
|
||||
if let Some(compaction) = &session.compaction {
|
||||
lines.push(format!(
|
||||
"- **Compactions**: {} (last removed {} messages)",
|
||||
compaction.count, compaction.removed_message_count
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push("---".to_string());
|
||||
lines.push(String::new());
|
||||
|
||||
for (index, message) in session.messages.iter().enumerate() {
|
||||
let role = match message.role {
|
||||
MessageRole::System => "System",
|
||||
MessageRole::User => "User",
|
||||
MessageRole::Assistant => "Assistant",
|
||||
MessageRole::Tool => "Tool",
|
||||
};
|
||||
lines.push(format!("## {}. {role}", index + 1));
|
||||
lines.push(String::new());
|
||||
for block in &message.blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text } => {
|
||||
let trimmed = text.trim_end();
|
||||
if !trimmed.is_empty() {
|
||||
lines.push(trimmed.to_string());
|
||||
lines.push(String::new());
|
||||
}
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
lines.push(format!(
|
||||
"**Tool call** `{name}` _(id `{}`)_",
|
||||
short_tool_id(id)
|
||||
));
|
||||
let summary = summarize_tool_payload_for_markdown(input);
|
||||
if !summary.is_empty() {
|
||||
lines.push(format!("> {summary}"));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
} => {
|
||||
let status = if *is_error { "error" } else { "ok" };
|
||||
lines.push(format!(
|
||||
"**Tool result** `{tool_name}` _(id `{}`, {status})_",
|
||||
short_tool_id(tool_use_id)
|
||||
));
|
||||
let summary = summarize_tool_payload_for_markdown(output);
|
||||
if !summary.is_empty() {
|
||||
lines.push(format!("> {summary}"));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature: _,
|
||||
} => {
|
||||
lines.push(format!("**Thinking**"));
|
||||
let summary = summarize_tool_payload_for_markdown(thinking);
|
||||
if !summary.is_empty() {
|
||||
lines.push(format!("> {summary}"));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(usage) = message.usage {
|
||||
lines.push(format!(
|
||||
"_tokens: in={} out={} cache_create={} cache_read={}_",
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.cache_creation_input_tokens,
|
||||
usage.cache_read_input_tokens,
|
||||
));
|
||||
lines.push(String::new());
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
|
@ -0,0 +1,492 @@
|
|||
use std::io::{self, Write};
|
||||
use serde_json::json;
|
||||
use runtime::{RuntimeError, AssistantEvent};
|
||||
use api::OutputContentBlock;
|
||||
use crate::render::TerminalRenderer;
|
||||
use super::helpers::*;
|
||||
use super::{
|
||||
DISPLAY_TRUNCATION_NOTICE, READ_DISPLAY_MAX_CHARS, READ_DISPLAY_MAX_LINES,
|
||||
SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT, TOOL_OUTPUT_DISPLAY_MAX_CHARS,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_LINES,
|
||||
};
|
||||
|
||||
pub(crate) fn short_tool_id(id: &str) -> String {
|
||||
let char_count = id.chars().count();
|
||||
if char_count <= 12 {
|
||||
return id.to_string();
|
||||
}
|
||||
let prefix: String = id.chars().take(12).collect();
|
||||
format!("{prefix}…")
|
||||
}
|
||||
|
||||
pub(crate) fn summarize_tool_payload_for_markdown(payload: &str) -> String {
|
||||
let compact = match serde_json::from_str::<serde_json::Value>(payload) {
|
||||
Ok(value) => value.to_string(),
|
||||
Err(_) => payload.split_whitespace().collect::<Vec<_>>().join(" "),
|
||||
};
|
||||
if compact.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
truncate_for_summary(&compact, SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT)
|
||||
}
|
||||
|
||||
pub(crate) fn format_tool_call_start(name: &str, input: &str) -> String {
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
|
||||
|
||||
let detail = match name {
|
||||
"bash" | "Bash" => format_bash_call(&parsed),
|
||||
"read_file" | "Read" => {
|
||||
let path = extract_tool_path(&parsed);
|
||||
format!("\x1b[2m📄 Reading {path}…\x1b[0m")
|
||||
}
|
||||
"write_file" | "Write" => {
|
||||
let path = extract_tool_path(&parsed);
|
||||
let lines = parsed
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.map_or(0, |content| content.lines().count());
|
||||
format!("\x1b[1;32m✏️ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m")
|
||||
}
|
||||
"edit_file" | "Edit" => {
|
||||
let path = extract_tool_path(&parsed);
|
||||
let old_value = parsed
|
||||
.get("old_string")
|
||||
.or_else(|| parsed.get("oldString"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let new_value = parsed
|
||||
.get("new_string")
|
||||
.or_else(|| parsed.get("newString"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"\x1b[1;33m📝 Editing {path}\x1b[0m{}",
|
||||
format_patch_preview(old_value, new_value)
|
||||
.map(|preview| format!("\n{preview}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
"glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed),
|
||||
"grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed),
|
||||
"web_search" | "WebSearch" => parsed
|
||||
.get("query")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("?")
|
||||
.to_string(),
|
||||
_ => summarize_tool_payload(input),
|
||||
};
|
||||
|
||||
let border = "─".repeat(name.len() + 8);
|
||||
format!(
|
||||
"\x1b[38;5;245m╭─ \x1b[1;36m{name}\x1b[0;38;5;245m ─╮\x1b[0m\n\x1b[38;5;245m│\x1b[0m {detail}\n\x1b[38;5;245m╰{border}╯\x1b[0m"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_tool_result(name: &str, output: &str, is_error: bool) -> String {
|
||||
let icon = if is_error {
|
||||
"\x1b[1;31m✗\x1b[0m"
|
||||
} else {
|
||||
"\x1b[1;32m✓\x1b[0m"
|
||||
};
|
||||
if is_error {
|
||||
let summary = truncate_for_summary(output.trim(), 160);
|
||||
return if summary.is_empty() {
|
||||
format!("{icon} \x1b[38;5;245m{name}\x1b[0m")
|
||||
} else {
|
||||
format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n\x1b[38;5;203m{summary}\x1b[0m")
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(output).unwrap_or(serde_json::Value::String(output.to_string()));
|
||||
match name {
|
||||
"bash" | "Bash" => format_bash_result(icon, &parsed),
|
||||
"read_file" | "Read" => format_read_result(icon, &parsed),
|
||||
"write_file" | "Write" => format_write_result(icon, &parsed),
|
||||
"edit_file" | "Edit" => format_edit_result(icon, &parsed),
|
||||
"glob_search" | "Glob" => format_glob_result(icon, &parsed),
|
||||
"grep_search" | "Grep" => format_grep_result(icon, &parsed),
|
||||
_ => format_generic_tool_result(icon, name, &parsed),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_search_start(label: &str, parsed: &serde_json::Value) -> String {
|
||||
let pattern = parsed
|
||||
.get("pattern")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("?");
|
||||
let scope = parsed
|
||||
.get("path")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or(".");
|
||||
format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m")
|
||||
}
|
||||
|
||||
pub(crate) fn format_patch_preview(old_value: &str, new_value: &str) -> Option<String> {
|
||||
if old_value.is_empty() && new_value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"\x1b[38;5;203m- {}\x1b[0m\n\x1b[38;5;70m+ {}\x1b[0m",
|
||||
truncate_for_summary(first_visible_line(old_value), 72),
|
||||
truncate_for_summary(first_visible_line(new_value), 72)
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn format_bash_call(parsed: &serde_json::Value) -> String {
|
||||
let command = parsed
|
||||
.get("command")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
if command.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"\x1b[48;5;236;38;5;255m $ {} \x1b[0m",
|
||||
truncate_for_summary(command, 160)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
let mut lines = vec![format!("{icon} \x1b[38;5;245mbash\x1b[0m")];
|
||||
if let Some(task_id) = parsed
|
||||
.get("backgroundTaskId")
|
||||
.and_then(|value| value.as_str())
|
||||
{
|
||||
write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string");
|
||||
} else if let Some(status) = parsed
|
||||
.get("returnCodeInterpretation")
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|status| !status.is_empty())
|
||||
{
|
||||
write!(&mut lines[0], " {status}").expect("write to string");
|
||||
}
|
||||
|
||||
if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) {
|
||||
if !stdout.trim().is_empty() {
|
||||
lines.push(truncate_output_for_display(
|
||||
stdout,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_LINES,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_CHARS,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) {
|
||||
if !stderr.trim().is_empty() {
|
||||
lines.push(format!(
|
||||
"\x1b[38;5;203m{}\x1b[0m",
|
||||
truncate_output_for_display(
|
||||
stderr,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_LINES,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_CHARS,
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n\n")
|
||||
}
|
||||
|
||||
pub(crate) fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||
let file = parsed.get("file").unwrap_or(parsed);
|
||||
let path = extract_tool_path(file);
|
||||
let start_line = file
|
||||
.get("startLine")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(1);
|
||||
let num_lines = file
|
||||
.get("numLines")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_lines = file
|
||||
.get("totalLines")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(num_lines);
|
||||
let content = file
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let end_line = start_line.saturating_add(num_lines.saturating_sub(1));
|
||||
|
||||
format!(
|
||||
"{icon} \x1b[2m📄 Read {path} (lines {}-{} of {})\x1b[0m\n{}",
|
||||
start_line,
|
||||
end_line.max(start_line),
|
||||
total_lines,
|
||||
truncate_output_for_display(content, READ_DISPLAY_MAX_LINES, READ_DISPLAY_MAX_CHARS)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||
let path = extract_tool_path(parsed);
|
||||
let kind = parsed
|
||||
.get("type")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("write");
|
||||
let line_count = parsed
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.map_or(0, |content| content.lines().count());
|
||||
format!(
|
||||
"{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m",
|
||||
if kind == "create" { "Wrote" } else { "Updated" },
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option<String> {
|
||||
let hunks = parsed.get("structuredPatch")?.as_array()?;
|
||||
let mut preview = Vec::new();
|
||||
for hunk in hunks.iter().take(2) {
|
||||
let lines = hunk.get("lines")?.as_array()?;
|
||||
for line in lines.iter().filter_map(|value| value.as_str()).take(6) {
|
||||
match line.chars().next() {
|
||||
Some('+') => preview.push(format!("\x1b[38;5;70m{line}\x1b[0m")),
|
||||
Some('-') => preview.push(format!("\x1b[38;5;203m{line}\x1b[0m")),
|
||||
_ => preview.push(line.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
if preview.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(preview.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||
let path = extract_tool_path(parsed);
|
||||
let suffix = if parsed
|
||||
.get("replaceAll")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
" (replace all)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let preview = format_structured_patch_preview(parsed).or_else(|| {
|
||||
let old_value = parsed
|
||||
.get("oldString")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let new_value = parsed
|
||||
.get("newString")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
format_patch_preview(old_value, new_value)
|
||||
});
|
||||
|
||||
match preview {
|
||||
Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"),
|
||||
None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||
let num_files = parsed
|
||||
.get("numFiles")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let filenames = parsed
|
||||
.get("filenames")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|files| {
|
||||
files
|
||||
.iter()
|
||||
.filter_map(|value| value.as_str())
|
||||
.take(8)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if filenames.is_empty() {
|
||||
format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files")
|
||||
} else {
|
||||
format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files\n{filenames}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||
let num_matches = parsed
|
||||
.get("numMatches")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let num_files = parsed
|
||||
.get("numFiles")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let content = parsed
|
||||
.get("content")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let filenames = parsed
|
||||
.get("filenames")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|files| {
|
||||
files
|
||||
.iter()
|
||||
.filter_map(|value| value.as_str())
|
||||
.take(8)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let summary = format!(
|
||||
"{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files"
|
||||
);
|
||||
if !content.trim().is_empty() {
|
||||
format!(
|
||||
"{summary}\n{}",
|
||||
truncate_output_for_display(
|
||||
content,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_LINES,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_CHARS,
|
||||
)
|
||||
)
|
||||
} else if !filenames.is_empty() {
|
||||
format!("{summary}\n{filenames}")
|
||||
} else {
|
||||
summary
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_generic_tool_result(icon: &str, name: &str, parsed: &serde_json::Value) -> String {
|
||||
let rendered_output = match parsed {
|
||||
serde_json::Value::String(text) => text.clone(),
|
||||
serde_json::Value::Null => String::new(),
|
||||
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
|
||||
serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string())
|
||||
}
|
||||
_ => parsed.to_string(),
|
||||
};
|
||||
let preview = truncate_output_for_display(
|
||||
&rendered_output,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_LINES,
|
||||
TOOL_OUTPUT_DISPLAY_MAX_CHARS,
|
||||
);
|
||||
|
||||
if preview.is_empty() {
|
||||
format!("{icon} \x1b[38;5;245m{name}\x1b[0m")
|
||||
} else if preview.contains('\n') {
|
||||
format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n{preview}")
|
||||
} else {
|
||||
format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {preview}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render_thinking_block_summary(
|
||||
out: &mut (impl Write + ?Sized),
|
||||
char_count: Option<usize>,
|
||||
redacted: bool,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let summary = if redacted {
|
||||
"\n▶ Thinking block hidden by provider\n".to_string()
|
||||
} else if let Some(char_count) = char_count {
|
||||
format!("\n▶ Thinking ({char_count} chars hidden)\n")
|
||||
} else {
|
||||
"\n▶ Thinking hidden\n".to_string()
|
||||
};
|
||||
write!(out, "{summary}")
|
||||
.and_then(|()| out.flush())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn push_output_block(
|
||||
block: OutputContentBlock,
|
||||
out: &mut (impl Write + ?Sized),
|
||||
events: &mut Vec<AssistantEvent>,
|
||||
pending_tool: &mut Option<(String, String, String)>,
|
||||
streaming_tool_input: bool,
|
||||
block_has_thinking_summary: &mut bool,
|
||||
) -> Result<(), RuntimeError> {
|
||||
match block {
|
||||
OutputContentBlock::Text { text } => {
|
||||
if !text.is_empty() {
|
||||
let rendered = TerminalRenderer::new().markdown_to_ansi(&text);
|
||||
write!(out, "{rendered}")
|
||||
.and_then(|()| out.flush())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
events.push(AssistantEvent::TextDelta(text));
|
||||
}
|
||||
}
|
||||
OutputContentBlock::ToolUse { id, name, input } => {
|
||||
// During streaming, the initial content_block_start has an empty input ({}).
|
||||
// The real input arrives via input_json_delta events. In
|
||||
// non-streaming responses, preserve a legitimate empty object.
|
||||
let initial_input = if streaming_tool_input
|
||||
&& input.is_object()
|
||||
&& input.as_object().is_some_and(serde_json::Map::is_empty)
|
||||
{
|
||||
String::new()
|
||||
} else {
|
||||
input.to_string()
|
||||
};
|
||||
*pending_tool = Some((id, name, initial_input));
|
||||
}
|
||||
OutputContentBlock::Thinking { thinking, .. } => {
|
||||
render_thinking_block_summary(out, Some(thinking.chars().count()), false)?;
|
||||
*block_has_thinking_summary = true;
|
||||
}
|
||||
OutputContentBlock::RedactedThinking { .. } => {
|
||||
render_thinking_block_summary(out, None, true)?;
|
||||
*block_has_thinking_summary = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn response_to_events(
|
||||
response: api::MessageResponse,
|
||||
out: &mut (impl Write + ?Sized),
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
let mut events = Vec::new();
|
||||
let mut pending_tool = None;
|
||||
|
||||
for block in response.content {
|
||||
let mut block_has_thinking_summary = false;
|
||||
push_output_block(
|
||||
block,
|
||||
out,
|
||||
&mut events,
|
||||
&mut pending_tool,
|
||||
false,
|
||||
&mut block_has_thinking_summary,
|
||||
)?;
|
||||
if let Some((id, name, input)) = pending_tool.take() {
|
||||
events.push(AssistantEvent::ToolUse { id, name, input });
|
||||
}
|
||||
}
|
||||
|
||||
events.push(AssistantEvent::Usage(response.usage.token_usage()));
|
||||
events.push(AssistantEvent::MessageStop);
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
pub(crate) fn push_prompt_cache_record(
|
||||
client: &api::ProviderClient,
|
||||
events: &mut Vec<AssistantEvent>,
|
||||
) {
|
||||
if let Some(record) = client.take_last_prompt_cache_record() {
|
||||
if let Some(event) = prompt_cache_record_to_runtime_event(record) {
|
||||
events.push(AssistantEvent::PromptCache(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_cache_record_to_runtime_event(
|
||||
record: api::PromptCacheRecord,
|
||||
) -> Option<runtime::PromptCacheEvent> {
|
||||
let cache_break = record.cache_break?;
|
||||
Some(runtime::PromptCacheEvent {
|
||||
unexpected: cache_break.unexpected,
|
||||
reason: cache_break.reason,
|
||||
previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens,
|
||||
current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens,
|
||||
token_drop: cache_break.token_drop,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::args::CliOutputFormat;
|
||||
|
||||
const STARTER_CLAW_JSON: &str = concat!(
|
||||
"{\n",
|
||||
" \"permissions\": {\n",
|
||||
|
|
@ -162,6 +164,38 @@ pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::err
|
|||
})
|
||||
}
|
||||
|
||||
pub(crate) fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
Ok(initialize_repo(&cwd)?.render())
|
||||
}
|
||||
|
||||
pub(crate) fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
let report = initialize_repo(&cwd)?;
|
||||
let message = report.render();
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{message}"),
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&init_json_value(&report, &message))?
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn init_json_value(report: &InitReport, message: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"kind": "init",
|
||||
"project_root": report.project_root.display().to_string(),
|
||||
"created": report.artifacts_with_status(InitStatus::Created),
|
||||
"updated": report.artifacts_with_status(InitStatus::Updated),
|
||||
"skipped": report.artifacts_with_status(InitStatus::Skipped),
|
||||
"artifacts": report.artifact_json_entries(),
|
||||
"next_step": InitReport::NEXT_STEP,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_dir(path: &Path) -> Result<InitStatus, std::io::Error> {
|
||||
if path.is_dir() {
|
||||
return Ok(InitStatus::Skipped);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,518 @@
|
|||
use crate::*;
|
||||
use std::env;
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
use serde_json::json;
|
||||
use crate::render::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PromptHistoryEntry {
|
||||
pub(crate) timestamp_ms: u64,
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub(crate) struct LiveCli {
|
||||
pub(crate) model: String,
|
||||
pub(crate) allowed_tools: Option<AllowedToolSet>,
|
||||
pub(crate) permission_mode: PermissionMode,
|
||||
pub(crate) system_prompt: Vec<String>,
|
||||
pub(crate) runtime: BuiltRuntime,
|
||||
pub(crate) session: SessionHandle,
|
||||
pub(crate) prompt_history: Vec<PromptHistoryEntry>,
|
||||
}
|
||||
|
||||
impl LiveCli {
|
||||
pub(crate) fn new(
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let system_prompt = build_system_prompt()?;
|
||||
let session_state = new_cli_session()?;
|
||||
let session = create_managed_session_handle(&session_state.session_id)?;
|
||||
let runtime = build_runtime(
|
||||
session_state.with_persistence_path(session.path.clone()),
|
||||
&session.id,
|
||||
model.clone(),
|
||||
system_prompt.clone(),
|
||||
enable_tools,
|
||||
true,
|
||||
allowed_tools.clone(),
|
||||
permission_mode,
|
||||
None,
|
||||
)?;
|
||||
let cli = Self {
|
||||
model,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
system_prompt,
|
||||
runtime,
|
||||
session,
|
||||
prompt_history: Vec::new(),
|
||||
};
|
||||
cli.persist_session()?;
|
||||
Ok(cli)
|
||||
}
|
||||
|
||||
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<String>) {
|
||||
if let Some(rt) = self.runtime.runtime.as_mut() {
|
||||
rt.api_client_mut().set_reasoning_effort(effort);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn startup_banner(&self) -> String {
|
||||
let cwd = env::current_dir().map_or_else(
|
||||
|_| "<unknown>".to_string(),
|
||||
|path| path.display().to_string(),
|
||||
);
|
||||
let status = status_context(None).ok();
|
||||
let git_branch = status
|
||||
.as_ref()
|
||||
.and_then(|context| context.git_branch.as_deref())
|
||||
.unwrap_or("unknown");
|
||||
let workspace = status.as_ref().map_or_else(
|
||||
|| "unknown".to_string(),
|
||||
|context| context.git_summary.headline(),
|
||||
);
|
||||
let session_path = self.session.path.strip_prefix(Path::new(&cwd)).map_or_else(
|
||||
|_| self.session.path.display().to_string(),
|
||||
|path| path.display().to_string(),
|
||||
);
|
||||
format!(
|
||||
"\x1b[38;5;196m\
|
||||
██████╗██╗ █████╗ ██╗ ██╗\n\
|
||||
██╔════╝██║ ██╔══██╗██║ ██║\n\
|
||||
██║ ██║ ███████║██║ █╗ ██║\n\
|
||||
██║ ██║ ██╔══██║██║███╗██║\n\
|
||||
╚██████╗███████╗██║ ██║╚███╔███╔╝\n\
|
||||
╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\
|
||||
\x1b[2mModel\x1b[0m {}\n\
|
||||
\x1b[2mPermissions\x1b[0m {}\n\
|
||||
\x1b[2mBranch\x1b[0m {}\n\
|
||||
\x1b[2mWorkspace\x1b[0m {}\n\
|
||||
\x1b[2mDirectory\x1b[0m {}\n\
|
||||
\x1b[2mSession\x1b[0m {}\n\
|
||||
\x1b[2mAuto-save\x1b[0m {}\n\n\
|
||||
Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[2m/resume latest\x1b[0m jumps back to the newest session · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline",
|
||||
self.model,
|
||||
self.permission_mode.as_str(),
|
||||
git_branch,
|
||||
workspace,
|
||||
cwd,
|
||||
self.session.id,
|
||||
session_path,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn repl_completion_candidates(
|
||||
&self,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
Ok(slash_command_completion_candidates_with_sessions(
|
||||
&self.model,
|
||||
Some(&self.session.id),
|
||||
list_managed_sessions()?
|
||||
.into_iter()
|
||||
.map(|session| session.id)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_turn_runtime(
|
||||
&self,
|
||||
emit_output: bool,
|
||||
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
|
||||
let hook_abort_signal = runtime::HookAbortSignal::new();
|
||||
let runtime = build_runtime(
|
||||
self.runtime.session().clone(),
|
||||
&self.session.id,
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
emit_output,
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
)?
|
||||
.with_hook_abort_signal(hook_abort_signal.clone());
|
||||
let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal);
|
||||
|
||||
Ok((runtime, hook_abort_monitor))
|
||||
}
|
||||
|
||||
pub(crate) fn replace_runtime(
|
||||
&mut self,
|
||||
runtime: BuiltRuntime,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.runtime.shutdown_plugins()?;
|
||||
self.runtime = runtime;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
|
||||
let mut spinner = Spinner::new();
|
||||
let mut stdout = io::stdout();
|
||||
spinner.tick(
|
||||
"🦀 Thinking...",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
self.replace_runtime(runtime)?;
|
||||
spinner.finish(
|
||||
"✨ Done",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
println!();
|
||||
if let Some(event) = summary.auto_compaction {
|
||||
println!(
|
||||
"{}",
|
||||
format_auto_compaction_notice(event.removed_message_count)
|
||||
);
|
||||
}
|
||||
self.persist_session()?;
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
runtime.shutdown_plugins()?;
|
||||
spinner.fail(
|
||||
"❌ Request failed",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
Err(Box::new(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_turn_with_output(
|
||||
&mut self,
|
||||
input: &str,
|
||||
output_format: CliOutputFormat,
|
||||
compact: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match output_format {
|
||||
CliOutputFormat::Json if compact => self.run_prompt_compact_json(input),
|
||||
CliOutputFormat::Text if compact => self.run_prompt_compact(input),
|
||||
CliOutputFormat::Text => self.run_turn(input),
|
||||
CliOutputFormat::Json => self.run_prompt_json(input),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_prompt_compact(
|
||||
&mut self,
|
||||
input: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
let summary = result?;
|
||||
self.replace_runtime(runtime)?;
|
||||
self.persist_session()?;
|
||||
let final_text = final_assistant_text(&summary);
|
||||
println!("{final_text}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run_prompt_compact_json(
|
||||
&mut self,
|
||||
input: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
let summary = result?;
|
||||
self.replace_runtime(runtime)?;
|
||||
self.persist_session()?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"message": final_assistant_text(&summary),
|
||||
"compact": true,
|
||||
"model": self.model,
|
||||
"usage": {
|
||||
"input_tokens": summary.usage.input_tokens,
|
||||
"output_tokens": summary.usage.output_tokens,
|
||||
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
|
||||
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
|
||||
},
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run_prompt_json(
|
||||
&mut self,
|
||||
input: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
let summary = result?;
|
||||
self.replace_runtime(runtime)?;
|
||||
self.persist_session()?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"message": final_assistant_text(&summary),
|
||||
"model": self.model,
|
||||
"iterations": summary.iterations,
|
||||
"auto_compaction": summary.auto_compaction.map(|event| serde_json::json!({
|
||||
"removed_messages": event.removed_message_count,
|
||||
"notice": format_auto_compaction_notice(event.removed_message_count),
|
||||
})),
|
||||
"tool_uses": collect_tool_uses(&summary),
|
||||
"tool_results": collect_tool_results(&summary),
|
||||
"prompt_cache_events": collect_prompt_cache_events(&summary),
|
||||
"usage": {
|
||||
"input_tokens": summary.usage.input_tokens,
|
||||
"output_tokens": summary.usage.output_tokens,
|
||||
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
|
||||
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
|
||||
},
|
||||
"estimated_cost": format_usd(
|
||||
summary.usage.estimate_cost_usd_with_pricing(
|
||||
pricing_for_model(&self.model)
|
||||
.unwrap_or_else(runtime::ModelPricing::default_sonnet_tier)
|
||||
).total_cost_usd()
|
||||
)
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.runtime.session().save_to_path(&self.session.path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
});
|
||||
let entry = PromptHistoryEntry {
|
||||
timestamp_ms,
|
||||
text: prompt.to_string(),
|
||||
};
|
||||
self.prompt_history.push(entry);
|
||||
if let Err(error) = self.runtime.session_mut().push_prompt_entry(prompt) {
|
||||
eprintln!("warning: failed to persist prompt history: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reload_runtime_features(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = build_runtime(
|
||||
self.runtime.session().clone(),
|
||||
&self.session.id,
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
true,
|
||||
true,
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
None,
|
||||
)?;
|
||||
self.replace_runtime(runtime)?;
|
||||
self.persist_session()
|
||||
}
|
||||
|
||||
pub(crate) fn run_internal_prompt_text_with_progress(
|
||||
&self,
|
||||
prompt: &str,
|
||||
enable_tools: bool,
|
||||
progress: Option<InternalPromptProgressReporter>,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let session = self.runtime.session().clone();
|
||||
let mut runtime = build_runtime(
|
||||
session,
|
||||
&self.session.id,
|
||||
self.model.clone(),
|
||||
self.system_prompt.clone(),
|
||||
enable_tools,
|
||||
false,
|
||||
self.allowed_tools.clone(),
|
||||
self.permission_mode,
|
||||
progress,
|
||||
)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
|
||||
let text = final_assistant_text(&summary).trim().to_string();
|
||||
runtime.shutdown_plugins()?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub(crate) fn run_internal_prompt_text(
|
||||
&self,
|
||||
prompt: &str,
|
||||
enable_tools: bool,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
self.run_internal_prompt_text_with_progress(prompt, enable_tools, None)
|
||||
}
|
||||
|
||||
pub(crate) fn run_stale_base_preflight(flag_value: Option<&str>) {
|
||||
let Ok(cwd) = env::current_dir() else {
|
||||
return;
|
||||
};
|
||||
let source = resolve_expected_base(flag_value, &cwd);
|
||||
let state = check_base_commit(&cwd, source.as_ref());
|
||||
if let Some(warning) = format_stale_base_warning(&state) {
|
||||
eprintln!("{warning}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_repl(
|
||||
model: String,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
base_commit: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
allow_broad_cwd: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?;
|
||||
run_stale_base_preflight(base_commit.as_deref());
|
||||
let resolved_model = resolve_repl_model(model);
|
||||
let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?;
|
||||
cli.set_reasoning_effort(reasoning_effort);
|
||||
let mut editor =
|
||||
input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default());
|
||||
println!("{}", cli.startup_banner());
|
||||
println!("{}", format_connected_line(&cli.model));
|
||||
|
||||
loop {
|
||||
editor.set_completions(cli.repl_completion_candidates().unwrap_or_default());
|
||||
match editor.read_line()? {
|
||||
input::ReadOutcome::Submit(input) => {
|
||||
let trimmed = input.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if matches!(trimmed.as_str(), "/exit" | "/quit") {
|
||||
cli.persist_session()?;
|
||||
break;
|
||||
}
|
||||
match SlashCommand::parse(&trimmed) {
|
||||
Ok(Some(command)) => {
|
||||
if cli.handle_repl_command(command)? {
|
||||
cli.persist_session()?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Bare-word skill dispatch: if the first token of the input
|
||||
// matches a known skill name, invoke it as `/skills <input>`
|
||||
// rather than forwarding raw text to the LLM (ROADMAP #36).
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) {
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
cli.run_turn(&prompt)?;
|
||||
continue;
|
||||
}
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
cli.run_turn(&trimmed)?;
|
||||
}
|
||||
input::ReadOutcome::Cancel => {}
|
||||
input::ReadOutcome::Exit => {
|
||||
cli.persist_session()?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn detect_broad_cwd() -> Option<PathBuf> {
|
||||
let Ok(cwd) = env::current_dir() else {
|
||||
return None;
|
||||
};
|
||||
let is_home = env::var_os("HOME")
|
||||
.or_else(|| env::var_os("USERPROFILE"))
|
||||
.is_some_and(|h| Path::new(&h) == cwd);
|
||||
let is_root = cwd.parent().is_none();
|
||||
if is_home || is_root {
|
||||
Some(cwd)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn enforce_broad_cwd_policy(
|
||||
allow_broad_cwd: bool,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if allow_broad_cwd {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(cwd) = detect_broad_cwd() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let is_interactive = io::stdin().is_terminal();
|
||||
|
||||
if is_interactive {
|
||||
// Interactive mode: print warning and ask for confirmation
|
||||
eprintln!(
|
||||
"Warning: claw is running from a very broad directory ({}).\n\
|
||||
The agent can read and search everything under this path.\n\
|
||||
Consider running from inside your project: cd /path/to/project && claw",
|
||||
cwd.display()
|
||||
);
|
||||
eprint!("Continue anyway? [y/N]: ");
|
||||
io::stderr().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
if trimmed != "y" && trimmed != "yes" {
|
||||
eprintln!("Aborted.");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
// Non-interactive mode: exit with error (JSON or text)
|
||||
let message = format!(
|
||||
"claw is running from a very broad directory ({}). \
|
||||
The agent can read and search everything under this path. \
|
||||
Use --allow-broad-cwd to proceed anyway, \
|
||||
or run from inside your project: cd /path/to/project && claw",
|
||||
cwd.display()
|
||||
);
|
||||
match output_format {
|
||||
CliOutputFormat::Json => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"error": message,
|
||||
})
|
||||
);
|
||||
}
|
||||
CliOutputFormat::Text => {
|
||||
eprintln!("error: {message}");
|
||||
}
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -53,7 +53,7 @@ mod tests {
|
|||
PluginToolDefinition {
|
||||
name: "plugin_echo".to_string(),
|
||||
description: Some("Echo plugin payload".to_string()),
|
||||
input_schema: json!({
|
||||
input_schema:serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": { "type": "string" }
|
||||
|
|
@ -3492,7 +3492,7 @@ UU conflicted.rs",
|
|||
.map(|index| format!("line {index:03}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let output = json!({
|
||||
let output =serde_json::json!({
|
||||
"file": {
|
||||
"filePath": "src/main.rs",
|
||||
"content": content,
|
||||
|
|
@ -3518,7 +3518,7 @@ UU conflicted.rs",
|
|||
.map(|index| format!("stdout {index:03}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let output = json!({
|
||||
let output =serde_json::json!({
|
||||
"stdout": stdout,
|
||||
"stderr": "",
|
||||
"returnCodeInterpretation": "completed successfully"
|
||||
|
|
@ -3539,7 +3539,7 @@ UU conflicted.rs",
|
|||
let items = (0..120)
|
||||
.map(|index| format!("payload {index:03}"))
|
||||
.collect::<Vec<_>>();
|
||||
let output = json!({
|
||||
let output =serde_json::json!({
|
||||
"summary": "plugin payload",
|
||||
"items": items,
|
||||
})
|
||||
|
|
@ -3671,7 +3671,7 @@ UU conflicted.rs",
|
|||
OutputContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: json!({}),
|
||||
input:serde_json::json!({}),
|
||||
},
|
||||
&mut out,
|
||||
&mut events,
|
||||
|
|
@ -3700,7 +3700,7 @@ UU conflicted.rs",
|
|||
content: vec![OutputContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: json!({}),
|
||||
input:serde_json::json!({}),
|
||||
}],
|
||||
stop_reason: Some("tool_use".to_string()),
|
||||
stop_sequence: None,
|
||||
|
|
@ -3735,7 +3735,7 @@ UU conflicted.rs",
|
|||
content: vec![OutputContentBlock::ToolUse {
|
||||
id: "tool-2".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: json!({ "path": "rust/Cargo.toml" }),
|
||||
input:serde_json::json!({ "path": "rust/Cargo.toml" }),
|
||||
}],
|
||||
stop_reason: Some("tool_use".to_string()),
|
||||
stop_sequence: None,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ impl SessionLifecycleSummary {
|
|||
}
|
||||
|
||||
pub(crate) fn json_value(&self) -> serde_json::Value {
|
||||
json!({
|
||||
serde_json::json!({
|
||||
"kind": self.kind.as_str(),
|
||||
"pane_id": self.pane_id,
|
||||
"pane_command": self.pane_command,
|
||||
|
|
@ -130,8 +130,4 @@ pub(crate) struct ManagedSessionSummary {
|
|||
pub(crate) lifecycle: SessionLifecycleSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PromptHistoryEntry {
|
||||
pub(crate) timestamp_ms: u64,
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,631 @@
|
|||
use crate::api_client::AnthropicRuntimeClient;
|
||||
use crate::constants::{AllowedToolSet, DEFAULT_DATE};
|
||||
use crate::permission::permission_policy;
|
||||
use crate::progress::InternalPromptProgressReporter;
|
||||
use crate::tool_executor::CliToolExecutor;
|
||||
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
|
||||
use runtime::{
|
||||
load_system_prompt, ConfigLoader, ConversationRuntime, McpServerManager, McpTool, MessageRole,
|
||||
PermissionMode, Session, ToolError,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeSet;
|
||||
use std::env;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use tools::{GlobalToolRegistry, RuntimeToolDefinition};
|
||||
|
||||
pub(crate) type RuntimePluginStateBuildOutput = (
|
||||
Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
Vec<RuntimeToolDefinition>,
|
||||
);
|
||||
|
||||
pub(crate) struct RuntimePluginState {
|
||||
pub(crate) feature_config: runtime::RuntimeFeatureConfig,
|
||||
pub(crate) tool_registry: GlobalToolRegistry,
|
||||
pub(crate) plugin_registry: PluginRegistry,
|
||||
pub(crate) mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
}
|
||||
|
||||
pub(crate) struct RuntimeMcpState {
|
||||
pub(crate) runtime: tokio::runtime::Runtime,
|
||||
pub(crate) manager: McpServerManager,
|
||||
pub(crate) pending_servers: Vec<String>,
|
||||
pub(crate) degraded_report: Option<runtime::McpDegradedReport>,
|
||||
}
|
||||
|
||||
pub(crate) struct BuiltRuntime {
|
||||
pub(crate) runtime: Option<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>>,
|
||||
pub(crate) plugin_registry: PluginRegistry,
|
||||
pub(crate) plugins_active: bool,
|
||||
pub(crate) mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
pub(crate) mcp_active: bool,
|
||||
}
|
||||
|
||||
impl BuiltRuntime {
|
||||
pub(crate) fn new(
|
||||
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
|
||||
plugin_registry: PluginRegistry,
|
||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime: Some(runtime),
|
||||
plugin_registry,
|
||||
plugins_active: true,
|
||||
mcp_state,
|
||||
mcp_active: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self {
|
||||
let runtime = self
|
||||
.runtime
|
||||
.take()
|
||||
.expect("runtime should exist before installing hook abort signal");
|
||||
self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal));
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown_plugins(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.plugins_active {
|
||||
self.plugin_registry.shutdown()?;
|
||||
self.plugins_active = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown_mcp(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.mcp_active {
|
||||
if let Some(mcp_state) = &self.mcp_state {
|
||||
mcp_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.shutdown()?;
|
||||
}
|
||||
self.mcp_active = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for BuiltRuntime {
|
||||
type Target = ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.runtime
|
||||
.as_ref()
|
||||
.expect("runtime should exist while built runtime is alive")
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for BuiltRuntime {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
self.runtime
|
||||
.as_mut()
|
||||
.expect("runtime should exist while built runtime is alive")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BuiltRuntime {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.shutdown_mcp();
|
||||
let _ = self.shutdown_plugins();
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeMcpState {
|
||||
pub(crate) fn new(
|
||||
runtime_config: &runtime::RuntimeConfig,
|
||||
) -> Result<Option<(Self, runtime::McpToolDiscoveryReport)>, Box<dyn std::error::Error>> {
|
||||
let mut manager = McpServerManager::from_runtime_config(runtime_config);
|
||||
if manager.server_names().is_empty() && manager.unsupported_servers().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
let discovery = runtime.block_on(manager.discover_tools_best_effort());
|
||||
let pending_servers = discovery
|
||||
.failed_servers
|
||||
.iter()
|
||||
.map(|failure| failure.server_name.clone())
|
||||
.chain(
|
||||
discovery
|
||||
.unsupported_servers
|
||||
.iter()
|
||||
.map(|server| server.server_name.clone()),
|
||||
)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let available_tools = discovery
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.qualified_name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let failed_server_names = pending_servers.iter().cloned().collect::<BTreeSet<_>>();
|
||||
let working_servers = manager
|
||||
.server_names()
|
||||
.into_iter()
|
||||
.filter(|server_name| !failed_server_names.contains(server_name))
|
||||
.collect::<Vec<_>>();
|
||||
let failed_servers =
|
||||
discovery
|
||||
.failed_servers
|
||||
.iter()
|
||||
.map(|failure| runtime::McpFailedServer {
|
||||
server_name: failure.server_name.clone(),
|
||||
phase: runtime::McpLifecyclePhase::ToolDiscovery,
|
||||
error: runtime::McpErrorSurface::new(
|
||||
runtime::McpLifecyclePhase::ToolDiscovery,
|
||||
Some(failure.server_name.clone()),
|
||||
failure.error.clone(),
|
||||
std::collections::BTreeMap::new(),
|
||||
true,
|
||||
),
|
||||
})
|
||||
.chain(discovery.unsupported_servers.iter().map(|server| {
|
||||
runtime::McpFailedServer {
|
||||
server_name: server.server_name.clone(),
|
||||
phase: runtime::McpLifecyclePhase::ServerRegistration,
|
||||
error: runtime::McpErrorSurface::new(
|
||||
runtime::McpLifecyclePhase::ServerRegistration,
|
||||
Some(server.server_name.clone()),
|
||||
server.reason.clone(),
|
||||
std::collections::BTreeMap::from([(
|
||||
"transport".to_string(),
|
||||
format!("{:?}", server.transport).to_ascii_lowercase(),
|
||||
)]),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}))
|
||||
.collect::<Vec<_>>();
|
||||
let degraded_report = (!failed_servers.is_empty()).then(|| {
|
||||
runtime::McpDegradedReport::new(
|
||||
working_servers,
|
||||
failed_servers,
|
||||
available_tools.clone(),
|
||||
available_tools,
|
||||
)
|
||||
});
|
||||
|
||||
Ok(Some((
|
||||
Self {
|
||||
runtime,
|
||||
manager,
|
||||
pending_servers,
|
||||
degraded_report,
|
||||
},
|
||||
discovery,
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.runtime.block_on(self.manager.shutdown())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn pending_servers(&self) -> Option<Vec<String>> {
|
||||
(!self.pending_servers.is_empty()).then(|| self.pending_servers.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn degraded_report(&self) -> Option<runtime::McpDegradedReport> {
|
||||
self.degraded_report.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn server_names(&self) -> Vec<String> {
|
||||
self.manager.server_names()
|
||||
}
|
||||
|
||||
pub(crate) fn call_tool(
|
||||
&mut self,
|
||||
qualified_tool_name: &str,
|
||||
arguments: Option<serde_json::Value>,
|
||||
) -> Result<String, ToolError> {
|
||||
let response = self
|
||||
.runtime
|
||||
.block_on(self.manager.call_tool(qualified_tool_name, arguments))
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
if let Some(error) = response.error {
|
||||
return Err(ToolError::new(format!(
|
||||
"MCP tool `{qualified_tool_name}` returned JSON-RPC error: {} ({})",
|
||||
error.message, error.code
|
||||
)));
|
||||
}
|
||||
|
||||
let result = response.result.ok_or_else(|| {
|
||||
ToolError::new(format!(
|
||||
"MCP tool `{qualified_tool_name}` returned no result payload"
|
||||
))
|
||||
})?;
|
||||
serde_json::to_string_pretty(&result).map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn list_resources_for_server(&mut self, server_name: &str) -> Result<String, ToolError> {
|
||||
let result = self
|
||||
.runtime
|
||||
.block_on(self.manager.list_resources(server_name))
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"server": server_name,
|
||||
"resources": result.resources,
|
||||
}))
|
||||
.map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn list_resources_for_all_servers(&mut self) -> Result<String, ToolError> {
|
||||
let mut resources = Vec::new();
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for server_name in self.server_names() {
|
||||
match self
|
||||
.runtime
|
||||
.block_on(self.manager.list_resources(&server_name))
|
||||
{
|
||||
Ok(result) => resources.push(serde_json::json!({
|
||||
"server": server_name,
|
||||
"resources": result.resources,
|
||||
})),
|
||||
Err(error) => failures.push(serde_json::json!({
|
||||
"server": server_name,
|
||||
"error": error.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
if resources.is_empty() && !failures.is_empty() {
|
||||
let message = failures
|
||||
.iter()
|
||||
.filter_map(|failure| failure.get("error").and_then(serde_json::Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
return Err(ToolError::new(message));
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"resources": resources,
|
||||
"failures": failures,
|
||||
}))
|
||||
.map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn read_resource(&mut self, server_name: &str, uri: &str) -> Result<String, ToolError> {
|
||||
let result = self
|
||||
.runtime
|
||||
.block_on(self.manager.read_resource(server_name, uri))
|
||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"server": server_name,
|
||||
"contents": result.contents,
|
||||
}))
|
||||
.map_err(|error| ToolError::new(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_mcp_state(
|
||||
runtime_config: &runtime::RuntimeConfig,
|
||||
) -> Result<RuntimePluginStateBuildOutput, Box<dyn std::error::Error>> {
|
||||
let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else {
|
||||
return Ok((None, Vec::new()));
|
||||
};
|
||||
|
||||
let mut runtime_tools = discovery
|
||||
.tools
|
||||
.iter()
|
||||
.map(mcp_runtime_tool_definition)
|
||||
.collect::<Vec<_>>();
|
||||
if !mcp_state.server_names().is_empty() {
|
||||
runtime_tools.extend(mcp_wrapper_tool_definitions());
|
||||
}
|
||||
|
||||
Ok((Some(Arc::new(Mutex::new(mcp_state))), runtime_tools))
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_runtime_tool_definition(tool: &runtime::ManagedMcpTool) -> RuntimeToolDefinition {
|
||||
RuntimeToolDefinition {
|
||||
name: tool.qualified_name.clone(),
|
||||
description: Some(
|
||||
tool.tool
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("Invoke MCP tool `{}`.", tool.qualified_name)),
|
||||
),
|
||||
input_schema: tool
|
||||
.tool
|
||||
.input_schema
|
||||
.clone()
|
||||
.unwrap_or_else(|| serde_json::json!({ "type": "object", "additionalProperties": true })),
|
||||
required_permission: permission_mode_for_mcp_tool(&tool.tool),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_wrapper_tool_definitions() -> Vec<RuntimeToolDefinition> {
|
||||
vec![
|
||||
RuntimeToolDefinition {
|
||||
name: "MCPTool".to_string(),
|
||||
description: Some(
|
||||
"Call a configured MCP tool by its qualified name and JSON arguments.".to_string(),
|
||||
),
|
||||
input_schema:serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"qualifiedName": { "type": "string" },
|
||||
"arguments": {}
|
||||
},
|
||||
"required": ["qualifiedName"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::DangerFullAccess,
|
||||
},
|
||||
RuntimeToolDefinition {
|
||||
name: "ListMcpResourcesTool".to_string(),
|
||||
description: Some(
|
||||
"List MCP resources from one configured server or from every connected server."
|
||||
.to_string(),
|
||||
),
|
||||
input_schema:serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::ReadOnly,
|
||||
},
|
||||
RuntimeToolDefinition {
|
||||
name: "ReadMcpResourceTool".to_string(),
|
||||
description: Some("Read a specific MCP resource from a configured server.".to_string()),
|
||||
input_schema:serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string" },
|
||||
"uri": { "type": "string" }
|
||||
},
|
||||
"required": ["server", "uri"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
required_permission: PermissionMode::ReadOnly,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn permission_mode_for_mcp_tool(tool: &McpTool) -> PermissionMode {
|
||||
let read_only = mcp_annotation_flag(tool, "readOnlyHint");
|
||||
let destructive = mcp_annotation_flag(tool, "destructiveHint");
|
||||
let open_world = mcp_annotation_flag(tool, "openWorldHint");
|
||||
|
||||
if read_only && !destructive && !open_world {
|
||||
PermissionMode::ReadOnly
|
||||
} else if destructive || open_world {
|
||||
PermissionMode::DangerFullAccess
|
||||
} else {
|
||||
PermissionMode::WorkspaceWrite
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_annotation_flag(tool: &McpTool, key: &str) -> bool {
|
||||
tool.annotations
|
||||
.as_ref()
|
||||
.and_then(|annotations| annotations.get(key))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) struct HookAbortMonitor {
|
||||
pub(crate) stop_tx: Option<Sender<()>>,
|
||||
pub(crate) join_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl HookAbortMonitor {
|
||||
pub(crate) fn spawn(abort_signal: runtime::HookAbortSignal) -> Self {
|
||||
Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| {
|
||||
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
runtime.block_on(async move {
|
||||
let wait_for_stop = tokio::task::spawn_blocking(move || {
|
||||
let _ = stop_rx.recv();
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
result = tokio::signal::ctrl_c() => {
|
||||
if result.is_ok() {
|
||||
abort_signal.abort();
|
||||
}
|
||||
}
|
||||
_ = wait_for_stop => {}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_with_waiter<F>(abort_signal: runtime::HookAbortSignal, wait_for_interrupt: F) -> Self
|
||||
where
|
||||
F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static,
|
||||
{
|
||||
let (stop_tx, stop_rx) = mpsc::channel();
|
||||
let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal));
|
||||
|
||||
Self {
|
||||
stop_tx: Some(stop_tx),
|
||||
join_handle: Some(join_handle),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stop(mut self) {
|
||||
if let Some(stop_tx) = self.stop_tx.take() {
|
||||
let _ = stop_tx.send(());
|
||||
}
|
||||
if let Some(join_handle) = self.join_handle.take() {
|
||||
let _ = join_handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
Ok(load_system_prompt(
|
||||
env::current_dir()?,
|
||||
DEFAULT_DATE,
|
||||
env::consts::OS,
|
||||
"unknown",
|
||||
)?)
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_plugin_state() -> Result<RuntimePluginState, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
let runtime_config = loader.load()?;
|
||||
build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config)
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_plugin_state_with_loader(
|
||||
cwd: &Path,
|
||||
loader: &ConfigLoader,
|
||||
runtime_config: &runtime::RuntimeConfig,
|
||||
) -> Result<RuntimePluginState, Box<dyn std::error::Error>> {
|
||||
let plugin_manager = build_plugin_manager(cwd, loader, runtime_config);
|
||||
let plugin_registry = plugin_manager.plugin_registry()?;
|
||||
let plugin_hook_config =
|
||||
runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?);
|
||||
let feature_config = runtime_config
|
||||
.feature_config()
|
||||
.clone()
|
||||
.with_hooks(runtime_config.hooks().merged(&plugin_hook_config));
|
||||
let (mcp_state, runtime_tools) = build_runtime_mcp_state(runtime_config)?;
|
||||
let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)?
|
||||
.with_runtime_tools(runtime_tools)?;
|
||||
Ok(RuntimePluginState {
|
||||
feature_config,
|
||||
tool_registry,
|
||||
plugin_registry,
|
||||
mcp_state,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_plugin_manager(
|
||||
cwd: &Path,
|
||||
loader: &ConfigLoader,
|
||||
runtime_config: &runtime::RuntimeConfig,
|
||||
) -> PluginManager {
|
||||
let plugin_settings = runtime_config.plugins();
|
||||
let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
|
||||
plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
|
||||
plugin_config.external_dirs = plugin_settings
|
||||
.external_directories()
|
||||
.iter()
|
||||
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
|
||||
.collect();
|
||||
plugin_config.install_root = plugin_settings
|
||||
.install_root()
|
||||
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
|
||||
plugin_config.registry_path = plugin_settings
|
||||
.registry_path()
|
||||
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
|
||||
plugin_config.bundled_root = plugin_settings
|
||||
.bundled_root()
|
||||
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
|
||||
PluginManager::new(plugin_config)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
|
||||
let path = PathBuf::from(value);
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else if value.starts_with('.') {
|
||||
cwd.join(path)
|
||||
} else {
|
||||
config_home.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_hook_config_from_plugin_hooks(hooks: PluginHooks) -> runtime::RuntimeHookConfig {
|
||||
runtime::RuntimeHookConfig::new(
|
||||
hooks.pre_tool_use,
|
||||
hooks.post_tool_use,
|
||||
hooks.post_tool_use_failure,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime(
|
||||
session: Session,
|
||||
session_id: &str,
|
||||
model: String,
|
||||
system_prompt: Vec<String>,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
) -> Result<BuiltRuntime, Box<dyn std::error::Error>> {
|
||||
let runtime_plugin_state = build_runtime_plugin_state()?;
|
||||
build_runtime_with_plugin_state(
|
||||
session,
|
||||
session_id,
|
||||
model,
|
||||
system_prompt,
|
||||
enable_tools,
|
||||
emit_output,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
progress_reporter,
|
||||
runtime_plugin_state,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_with_plugin_state(
|
||||
session: Session,
|
||||
session_id: &str,
|
||||
model: String,
|
||||
system_prompt: Vec<String>,
|
||||
enable_tools: bool,
|
||||
emit_output: bool,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
runtime_plugin_state: RuntimePluginState,
|
||||
) -> Result<BuiltRuntime, Box<dyn std::error::Error>> {
|
||||
let RuntimePluginState {
|
||||
feature_config,
|
||||
tool_registry,
|
||||
plugin_registry,
|
||||
mcp_state,
|
||||
} = runtime_plugin_state;
|
||||
plugin_registry.initialize()?;
|
||||
let policy = permission_policy(permission_mode, &feature_config, &tool_registry)
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
let executor = CliToolExecutor::new(
|
||||
allowed_tools.clone(),
|
||||
emit_output,
|
||||
tool_registry.clone(),
|
||||
mcp_state.clone(),
|
||||
);
|
||||
|
||||
let client = AnthropicRuntimeClient::new(
|
||||
session_id,
|
||||
model,
|
||||
enable_tools,
|
||||
emit_output,
|
||||
allowed_tools,
|
||||
tool_registry,
|
||||
progress_reporter,
|
||||
)?;
|
||||
|
||||
let runtime = ConversationRuntime::new_with_features(
|
||||
session,
|
||||
client,
|
||||
executor,
|
||||
policy,
|
||||
system_prompt,
|
||||
&feature_config,
|
||||
);
|
||||
|
||||
Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state))
|
||||
}
|
||||
|
|
@ -2,14 +2,113 @@ use std::env;
|
|||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use commands::{render_slash_command_help_filtered, SlashCommand};
|
||||
use runtime::Session;
|
||||
use runtime::{ContentBlock, MessageRole, Session};
|
||||
|
||||
use crate::constants::*;
|
||||
use crate::live_cli::PromptHistoryEntry;
|
||||
use crate::models::*;
|
||||
|
||||
pub(crate) fn collect_session_prompt_history(session: &Session) -> Vec<PromptHistoryEntry> {
|
||||
if !session.prompt_history.is_empty() {
|
||||
return session
|
||||
.prompt_history
|
||||
.iter()
|
||||
.map(|entry| PromptHistoryEntry {
|
||||
timestamp_ms: entry.timestamp_ms,
|
||||
text: entry.text.clone(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
pub(crate) fn recent_user_context(session: &Session, limit: usize) -> String {
|
||||
let requests = session
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|message| message.role == MessageRole::User)
|
||||
.filter_map(|message| {
|
||||
message.blocks.iter().find_map(|block| match block {
|
||||
ContentBlock::Text { text } => Some(text.trim().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.rev()
|
||||
.take(limit)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if requests.is_empty() {
|
||||
"<no prior user messages>".to_string()
|
||||
} else {
|
||||
requests
|
||||
.into_iter()
|
||||
.rev()
|
||||
.enumerate()
|
||||
.map(|(index, text)| format!("{}. {}", index + 1, text))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(env::current_dir()?)
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
|
||||
}
|
||||
Ok(String::from_utf8(output.stdout)?)
|
||||
}
|
||||
|
||||
pub(crate) fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(env::current_dir()?)
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn command_exists(name: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(name)
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn write_temp_text_file(
|
||||
filename: &str,
|
||||
contents: &str,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let path = env::temp_dir().join(filename);
|
||||
fs::write(&path, contents)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub(crate) fn current_session_store() -> Result<runtime::SessionStore, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
|
||||
|
|
@ -50,34 +149,33 @@ pub(crate) fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf,
|
|||
}
|
||||
|
||||
pub(crate) fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
|
||||
Ok(current_session_store()?
|
||||
.list_sessions()
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?
|
||||
let store = current_session_store()?;
|
||||
let sessions = store.list_sessions().map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
let panes = discover_tmux_panes();
|
||||
|
||||
Ok(sessions
|
||||
.into_iter()
|
||||
.map(|session| ManagedSessionSummary {
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis: session.modified_epoch_millis,
|
||||
message_count: session.message_count,
|
||||
parent_session_id: session.parent_session_id,
|
||||
branch_name: session.branch_name,
|
||||
lifecycle: SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::SavedOnly,
|
||||
pane_id: None,
|
||||
pane_command: None,
|
||||
pane_path: None,
|
||||
workspace_dirty: false,
|
||||
abandoned: false,
|
||||
},
|
||||
.map(|session| {
|
||||
let lifecycle = classify_session_lifecycle_from_panes(&session.path, panes.clone());
|
||||
ManagedSessionSummary {
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis: session.modified_epoch_millis,
|
||||
message_count: session.message_count,
|
||||
parent_session_id: session.parent_session_id,
|
||||
branch_name: session.branch_name,
|
||||
lifecycle,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>> {
|
||||
let session = current_session_store()?
|
||||
.latest_session()
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
let store = current_session_store()?;
|
||||
let session = store.latest_session().map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
let lifecycle = classify_session_lifecycle_for(&session.path);
|
||||
|
||||
Ok(ManagedSessionSummary {
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
|
|
@ -86,14 +184,7 @@ pub(crate) fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn
|
|||
message_count: session.message_count,
|
||||
parent_session_id: session.parent_session_id,
|
||||
branch_name: session.branch_name,
|
||||
lifecycle: SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::SavedOnly,
|
||||
pane_id: None,
|
||||
pane_command: None,
|
||||
pane_path: None,
|
||||
workspace_dirty: false,
|
||||
abandoned: false,
|
||||
},
|
||||
lifecycle,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -130,6 +221,120 @@ pub(crate) fn confirm_session_deletion(session_id: &str) -> bool {
|
|||
matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
|
||||
}
|
||||
|
||||
pub(crate) fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary {
|
||||
classify_session_lifecycle_from_panes(workspace, discover_tmux_panes())
|
||||
}
|
||||
|
||||
pub(crate) fn classify_session_lifecycle_from_panes(
|
||||
workspace: &Path,
|
||||
panes: Vec<TmuxPaneSnapshot>,
|
||||
) -> SessionLifecycleSummary {
|
||||
let workspace_dirty = git_worktree_is_dirty(workspace);
|
||||
let mut idle_shell = None;
|
||||
for pane in panes {
|
||||
if !pane_path_matches_workspace(&pane.current_path, workspace) {
|
||||
continue;
|
||||
}
|
||||
if is_idle_shell_command(&pane.current_command) {
|
||||
idle_shell.get_or_insert(pane);
|
||||
} else {
|
||||
return SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::RunningProcess,
|
||||
pane_id: Some(pane.pane_id),
|
||||
pane_command: Some(pane.current_command),
|
||||
pane_path: Some(pane.current_path),
|
||||
workspace_dirty,
|
||||
abandoned: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pane) = idle_shell {
|
||||
SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::IdleShell,
|
||||
pane_id: Some(pane.pane_id),
|
||||
pane_command: Some(pane.current_command),
|
||||
pane_path: Some(pane.current_path),
|
||||
workspace_dirty,
|
||||
abandoned: workspace_dirty,
|
||||
}
|
||||
} else {
|
||||
SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::SavedOnly,
|
||||
pane_id: None,
|
||||
pane_command: None,
|
||||
pane_path: None,
|
||||
workspace_dirty,
|
||||
abandoned: workspace_dirty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn discover_tmux_panes() -> Vec<TmuxPaneSnapshot> {
|
||||
let output = Command::new("tmux")
|
||||
.args([
|
||||
"list-panes",
|
||||
"-a",
|
||||
"-F",
|
||||
"#{pane_id}\t#{pane_current_command}\t#{pane_current_path}",
|
||||
])
|
||||
.output();
|
||||
let Ok(output) = output else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_tmux_pane_snapshots(&stdout)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_tmux_pane_snapshots(output: &str) -> Vec<TmuxPaneSnapshot> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut fields = line.splitn(3, '\t');
|
||||
let pane_id = fields.next()?.trim();
|
||||
let current_command = fields.next()?.trim();
|
||||
let current_path = fields.next()?.trim();
|
||||
if pane_id.is_empty() || current_path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(TmuxPaneSnapshot {
|
||||
pane_id: pane_id.to_string(),
|
||||
current_command: current_command.to_string(),
|
||||
current_path: PathBuf::from(current_path),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool {
|
||||
let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf());
|
||||
let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
|
||||
pane_path == workspace || pane_path.starts_with(&workspace)
|
||||
}
|
||||
|
||||
pub(crate) fn is_idle_shell_command(command: &str) -> bool {
|
||||
let command = command.rsplit('/').next().unwrap_or(command);
|
||||
matches!(
|
||||
command,
|
||||
"bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn git_worktree_is_dirty(workspace: &Path) -> bool {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(workspace)
|
||||
.args(["status", "--porcelain"])
|
||||
.output();
|
||||
output
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.is_some_and(|output| !output.stdout.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let sessions = list_managed_sessions()?;
|
||||
let mut lines = vec![
|
||||
|
|
@ -158,11 +363,12 @@ pub(crate) fn render_session_list(active_session_id: &str) -> Result<String, Box
|
|||
(None, None) => String::new(),
|
||||
};
|
||||
lines.push(format!(
|
||||
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}",
|
||||
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} lifecycle={lifecycle} path={path}",
|
||||
id = session.id,
|
||||
msgs = session.message_count,
|
||||
modified = format_session_modified_age(session.modified_epoch_millis),
|
||||
lineage = lineage,
|
||||
lifecycle = session.lifecycle.signal(),
|
||||
path = session.path.display(),
|
||||
));
|
||||
}
|
||||
|
|
@ -208,26 +414,4 @@ pub(crate) fn session_clear_backup_path(session_path: &Path) -> PathBuf {
|
|||
session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak"))
|
||||
}
|
||||
|
||||
pub(crate) 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_filtered(STUB_COMMANDS),
|
||||
]
|
||||
.join(
|
||||
"
|
||||
",
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,30 @@ use crate::args::*;
|
|||
use crate::model::*;
|
||||
use crate::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct StatusContext {
|
||||
pub(crate) cwd: PathBuf,
|
||||
pub(crate) session_path: Option<PathBuf>,
|
||||
pub(crate) loaded_config_files: usize,
|
||||
pub(crate) discovered_config_files: usize,
|
||||
pub(crate) memory_file_count: usize,
|
||||
pub(crate) project_root: Option<PathBuf>,
|
||||
pub(crate) git_branch: Option<String>,
|
||||
pub(crate) git_summary: GitWorkspaceSummary,
|
||||
pub(crate) session_lifecycle: SessionLifecycleSummary,
|
||||
pub(crate) sandbox_status: runtime::SandboxStatus,
|
||||
pub(crate) config_load_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct StatusUsage {
|
||||
pub(crate) message_count: usize,
|
||||
pub(crate) turns: u32,
|
||||
pub(crate) latest: TokenUsage,
|
||||
pub(crate) cumulative: TokenUsage,
|
||||
pub(crate) estimated_tokens: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
|
||||
parse_git_status_metadata_for(
|
||||
&env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
|
|
@ -198,7 +222,7 @@ pub(crate) fn status_json_value(
|
|||
let model_source = provenance.map(|p| p.source.as_str());
|
||||
let model_raw = provenance.and_then(|p| p.raw.clone());
|
||||
let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::<Vec<_>>());
|
||||
json!({
|
||||
serde_json::json!({
|
||||
"kind": "status",
|
||||
"status": if degraded { "degraded" } else { "ok" },
|
||||
"config_load_error": context.config_load_error,
|
||||
|
|
@ -235,6 +259,7 @@ pub(crate) fn status_json_value(
|
|||
// .claw/sessions/. Extract the stem (drop the .jsonl extension).
|
||||
path.file_stem().map(|n| n.to_string_lossy().into_owned())
|
||||
}),
|
||||
"session_lifecycle": context.session_lifecycle.json_value(),
|
||||
"loaded_config_files": context.loaded_config_files,
|
||||
"discovered_config_files": context.discovered_config_files,
|
||||
"memory_file_count": context.memory_file_count,
|
||||
|
|
@ -305,120 +330,6 @@ pub(crate) fn status_context(
|
|||
})
|
||||
}
|
||||
|
||||
pub(crate) fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary {
|
||||
classify_session_lifecycle_from_panes(workspace, discover_tmux_panes())
|
||||
}
|
||||
|
||||
pub(crate) fn classify_session_lifecycle_from_panes(
|
||||
workspace: &Path,
|
||||
panes: Vec<TmuxPaneSnapshot>,
|
||||
) -> SessionLifecycleSummary {
|
||||
let workspace_dirty = git_worktree_is_dirty(workspace);
|
||||
let mut idle_shell = None;
|
||||
for pane in panes {
|
||||
if !pane_path_matches_workspace(&pane.current_path, workspace) {
|
||||
continue;
|
||||
}
|
||||
if is_idle_shell_command(&pane.current_command) {
|
||||
idle_shell.get_or_insert(pane);
|
||||
} else {
|
||||
return SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::RunningProcess,
|
||||
pane_id: Some(pane.pane_id),
|
||||
pane_command: Some(pane.current_command),
|
||||
pane_path: Some(pane.current_path),
|
||||
workspace_dirty,
|
||||
abandoned: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pane) = idle_shell {
|
||||
SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::IdleShell,
|
||||
pane_id: Some(pane.pane_id),
|
||||
pane_command: Some(pane.current_command),
|
||||
pane_path: Some(pane.current_path),
|
||||
workspace_dirty,
|
||||
abandoned: workspace_dirty,
|
||||
}
|
||||
} else {
|
||||
SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::SavedOnly,
|
||||
pane_id: None,
|
||||
pane_command: None,
|
||||
pane_path: None,
|
||||
workspace_dirty,
|
||||
abandoned: workspace_dirty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn discover_tmux_panes() -> Vec<TmuxPaneSnapshot> {
|
||||
let output = Command::new("tmux")
|
||||
.args([
|
||||
"list-panes",
|
||||
"-a",
|
||||
"-F",
|
||||
"#{pane_id}\t#{pane_current_command}\t#{pane_current_path}",
|
||||
])
|
||||
.output();
|
||||
let Ok(output) = output else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_tmux_pane_snapshots(&stdout)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_tmux_pane_snapshots(output: &str) -> Vec<TmuxPaneSnapshot> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut fields = line.splitn(3, '\t');
|
||||
let pane_id = fields.next()?.trim();
|
||||
let current_command = fields.next()?.trim();
|
||||
let current_path = fields.next()?.trim();
|
||||
if pane_id.is_empty() || current_path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(TmuxPaneSnapshot {
|
||||
pane_id: pane_id.to_string(),
|
||||
current_command: current_command.to_string(),
|
||||
current_path: PathBuf::from(current_path),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool {
|
||||
let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf());
|
||||
let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
|
||||
pane_path == workspace || pane_path.starts_with(&workspace)
|
||||
}
|
||||
|
||||
pub(crate) fn is_idle_shell_command(command: &str) -> bool {
|
||||
let command = command.rsplit('/').next().unwrap_or(command);
|
||||
matches!(
|
||||
command,
|
||||
"bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn git_worktree_is_dirty(workspace: &Path) -> bool {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(workspace)
|
||||
.args(["status", "--porcelain"])
|
||||
.output();
|
||||
output
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.is_some_and(|output| !output.stdout.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn format_status_report(
|
||||
model: &str,
|
||||
usage: StatusUsage,
|
||||
|
|
@ -486,6 +397,7 @@ pub(crate) fn format_status_report(
|
|||
Unstaged {}
|
||||
Untracked {}
|
||||
Session {}
|
||||
Lifecycle {}
|
||||
Config files loaded {}/{}
|
||||
Memory files {}
|
||||
Suggested flow /status → /diff → /commit",
|
||||
|
|
@ -504,6 +416,7 @@ pub(crate) fn format_status_report(
|
|||
|| "live-repl".to_string(),
|
||||
|path| path.display().to_string()
|
||||
),
|
||||
context.session_lifecycle.signal(),
|
||||
context.loaded_config_files,
|
||||
context.discovered_config_files,
|
||||
context.memory_file_count,
|
||||
|
|
@ -599,7 +512,7 @@ pub(crate) fn print_sandbox_status_snapshot(
|
|||
}
|
||||
|
||||
pub(crate) fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
|
||||
json!({
|
||||
serde_json::json!({
|
||||
"kind": "sandbox",
|
||||
"enabled": status.enabled,
|
||||
"active": status.active,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ use runtime::{
|
|||
use tools::GlobalToolRegistry;
|
||||
|
||||
use crate::constants::*;
|
||||
use crate::render::{format_tool_result, TerminalRenderer};
|
||||
use crate::format_tool_result;
|
||||
use crate::render::TerminalRenderer;
|
||||
use crate::RuntimeMcpState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
|
|||
Loading…
Reference in New Issue