重构main 方法
This commit is contained in:
parent
00718b4c3e
commit
2380566626
|
|
@ -31,45 +31,45 @@
|
||||||
|
|
||||||
## 6. Create args.rs — extract all argument parsing
|
## 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)
|
- [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)
|
||||||
- [ ] 6.2 Add `mod args;` to `main.rs` and re-export
|
- [x] 6.2 Add `mod args;` to `main.rs` and re-export
|
||||||
- [ ] 6.3 Run `cargo build` and `cargo test` — must pass
|
- [x] 6.3 Run `cargo build` and `cargo test` — must pass
|
||||||
|
|
||||||
## 7. Create session.rs — extract session management
|
## 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)
|
- [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)
|
||||||
- [ ] 7.2 Add `mod session;` to `main.rs` and re-export
|
- [x] 7.2 Add `mod session;` to `main.rs` and re-export
|
||||||
- [ ] 7.3 Run `cargo build` and `cargo test` — must pass
|
- [x] 7.3 Run `cargo build` and `cargo test` — must pass
|
||||||
|
|
||||||
## 8. Create status.rs — extract status snapshot and git parsing
|
## 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)
|
- [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)
|
||||||
- [ ] 8.2 Add `mod status;` to `main.rs` and re-export
|
- [x] 8.2 Add `mod status;` to `main.rs` and re-export
|
||||||
- [ ] 8.3 Run `cargo build` and `cargo test` — must pass
|
- [x] 8.3 Run `cargo build` and `cargo test` — must pass
|
||||||
|
|
||||||
## 9. Create formatting.rs — extract tool result formatting and help text
|
## 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)
|
- [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)
|
||||||
- [ ] 9.2 Add `mod formatting;` to `main.rs` and re-export
|
- [x] 9.2 Add `mod formatting;` to `main.rs` and re-export
|
||||||
- [ ] 9.3 Run `cargo build` and `cargo test` — must pass
|
- [x] 9.3 Run `cargo build` and `cargo test` — must pass
|
||||||
|
|
||||||
## 10. Create config.rs — extract config and memory reporting
|
## 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)
|
- [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)
|
||||||
- [ ] 10.2 Add `mod config;` to `main.rs` and re-export
|
- [x] 10.2 Add `mod config;` to `main.rs` and re-export
|
||||||
- [ ] 10.3 Run `cargo build` and `cargo test` — must pass
|
- [x] 10.3 Run `cargo build` and `cargo test` — must pass
|
||||||
|
|
||||||
## 11. Create diff.rs — extract git diff reporting
|
## 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)
|
- [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)
|
||||||
- [ ] 11.2 Add `mod diff;` to `main.rs` and re-export
|
- [x] 11.2 Add `mod diff;` to `main.rs` and re-export
|
||||||
- [ ] 11.3 Run `cargo build` and `cargo test` — must pass
|
- [x] 11.3 Run `cargo build` and `cargo test` — must pass
|
||||||
|
|
||||||
## 12. Create export.rs — extract session export
|
## 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)
|
- [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)
|
||||||
- [ ] 12.2 Add `mod export;` to `main.rs` and re-export
|
- [x] 12.2 Add `mod export;` to `main.rs` and re-export
|
||||||
- [ ] 12.3 Run `cargo build` and `cargo test` — must pass
|
- [x] 12.3 Run `cargo build` and `cargo test` — must pass
|
||||||
|
|
||||||
## 13. Create api_client.rs — extract AnthropicRuntimeClient
|
## 13. Create api_client.rs — extract AnthropicRuntimeClient
|
||||||
|
|
||||||
|
|
@ -85,16 +85,16 @@
|
||||||
|
|
||||||
## 15. Create runtime_builder.rs — extract runtime construction
|
## 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)
|
- [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)
|
||||||
- [ ] 15.2 Add `mod runtime_builder;` to `main.rs` and re-export
|
- [x] 15.2 Add `mod runtime_builder;` to `main.rs` and re-export
|
||||||
- [ ] 15.3 Run `cargo build` and `cargo test` — must pass
|
- [x] 15.3 Run `cargo build` and `cargo test` — must pass
|
||||||
|
|
||||||
## 16. Create live_cli.rs — extract LiveCli core and REPL loop
|
## 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)
|
- [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)
|
||||||
- [ ] 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.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
|
- [x] 16.3 Add `mod live_cli;` to `main.rs` and re-export
|
||||||
- [ ] 16.4 Run `cargo build` and `cargo test` — must pass
|
- [x] 16.4 Run `cargo build` and `cargo test` — must pass
|
||||||
|
|
||||||
## 17. Create repl_commands.rs — extract REPL command handlers
|
## 17. Create repl_commands.rs — extract REPL command handlers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@ use tools::GlobalToolRegistry;
|
||||||
|
|
||||||
use crate::constants::*;
|
use crate::constants::*;
|
||||||
use crate::model::max_tokens_for_model;
|
use crate::model::max_tokens_for_model;
|
||||||
use crate::render::{
|
use crate::{
|
||||||
format_tool_call_start, format_user_visible_api_error, push_output_block,
|
format_tool_call_start, push_output_block,
|
||||||
render_thinking_block_summary,
|
render_thinking_block_summary,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::InternalPromptProgressReporter;
|
use crate::InternalPromptProgressReporter;
|
||||||
|
|
||||||
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
|
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
|
||||||
|
|
@ -306,4 +307,98 @@ pub(crate) fn request_ends_with_tool_result(request: &ApiRequest) -> bool {
|
||||||
.messages
|
.messages
|
||||||
.last()
|
.last()
|
||||||
.is_some_and(|message| message.role == MessageRole::Tool)
|
.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::build_runtime_plugin_state_with_loader;
|
||||||
use crate::constants::*;
|
use crate::constants::*;
|
||||||
use crate::looks_like_slash_command_token;
|
|
||||||
use crate::model::*;
|
use crate::model::*;
|
||||||
use crate::normalize_permission_mode;
|
use crate::runtime_builder::build_system_prompt;
|
||||||
use crate::resume_command_can_absorb_token;
|
use crate::formatting::{render_help_topic, render_version_report, full_help_text};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) enum CliAction {
|
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({
|
Ok(Some(command)) => Err({
|
||||||
let _ = command;
|
let _ = command;
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -808,8 +807,8 @@ pub(crate) fn format_unknown_option(option: &str) -> String {
|
||||||
message
|
message
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String {
|
pub(crate) fn format_unknown_slash_command_message(name: &str) -> String {
|
||||||
let mut message = format!("unknown slash command outside the REPL: /{name}");
|
let mut message = format!("unknown slash command: /{name}");
|
||||||
if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name))
|
if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name))
|
||||||
{
|
{
|
||||||
message.push('\n');
|
message.push('\n');
|
||||||
|
|
@ -819,7 +818,7 @@ pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String {
|
||||||
message.push('\n');
|
message.push('\n');
|
||||||
message.push_str(note);
|
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
|
message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1226,3 +1225,381 @@ pub(crate) fn parse_resume_args(args: &[String], output_format: CliOutputFormat)
|
||||||
output_format,
|
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",
|
"-p",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
pub(crate) const DEFAULT_HISTORY_LIMIT: usize = 20;
|
||||||
pub(crate) type AllowedToolSet = BTreeSet<String>;
|
pub(crate) type AllowedToolSet = BTreeSet<String>;
|
||||||
|
|
||||||
pub(crate) const STUB_COMMANDS: &[&str] = &[
|
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 {
|
pub(crate) fn json_value(&self) -> Value {
|
||||||
let report = self.render();
|
let report = self.render();
|
||||||
let (ok_count, warn_count, fail_count) = self.counts();
|
let (ok_count, warn_count, fail_count) = self.counts();
|
||||||
json!({
|
serde_json::json!({
|
||||||
"kind": "doctor",
|
"kind": "doctor",
|
||||||
"message": report,
|
"message": report,
|
||||||
"report": 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(),
|
"Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed".to_string(),
|
||||||
])
|
])
|
||||||
.with_data(Map::from_iter([
|
.with_data(Map::from_iter([
|
||||||
("api_key_present".to_string(), json!(api_key_present)),
|
("api_key_present".to_string(),serde_json::json!(api_key_present)),
|
||||||
("auth_token_present".to_string(), json!(auth_token_present)),
|
("auth_token_present".to_string(),serde_json::json!(auth_token_present)),
|
||||||
("legacy_saved_oauth_present".to_string(), json!(true)),
|
("legacy_saved_oauth_present".to_string(),serde_json::json!(true)),
|
||||||
("legacy_saved_oauth_expires_at".to_string(), json!(token_set.expires_at)),
|
("legacy_saved_oauth_expires_at".to_string(),serde_json::json!(token_set.expires_at)),
|
||||||
("legacy_refresh_token_present".to_string(), json!(token_set.refresh_token.is_some())),
|
("legacy_refresh_token_present".to_string(),serde_json::json!(token_set.refresh_token.is_some())),
|
||||||
("legacy_scopes".to_string(), json!(token_set.scopes)),
|
("legacy_scopes".to_string(),serde_json::json!(token_set.scopes)),
|
||||||
])),
|
])),
|
||||||
Ok(None) => DiagnosticCheck::new(
|
Ok(None) => DiagnosticCheck::new(
|
||||||
"Auth",
|
"Auth",
|
||||||
|
|
@ -231,12 +231,12 @@ fn check_auth_health() -> DiagnosticCheck {
|
||||||
)
|
)
|
||||||
.with_details(vec![env_details])
|
.with_details(vec![env_details])
|
||||||
.with_data(Map::from_iter([
|
.with_data(Map::from_iter([
|
||||||
("api_key_present".to_string(), json!(api_key_present)),
|
("api_key_present".to_string(),serde_json::json!(api_key_present)),
|
||||||
("auth_token_present".to_string(), json!(auth_token_present)),
|
("auth_token_present".to_string(),serde_json::json!(auth_token_present)),
|
||||||
("legacy_saved_oauth_present".to_string(), json!(false)),
|
("legacy_saved_oauth_present".to_string(),serde_json::json!(false)),
|
||||||
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
|
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
|
||||||
("legacy_refresh_token_present".to_string(), json!(false)),
|
("legacy_refresh_token_present".to_string(),serde_json::json!(false)),
|
||||||
("legacy_scopes".to_string(), json!(Vec::<String>::new())),
|
("legacy_scopes".to_string(),serde_json::json!(Vec::<String>::new())),
|
||||||
])),
|
])),
|
||||||
Err(error) => DiagnosticCheck::new(
|
Err(error) => DiagnosticCheck::new(
|
||||||
"Auth",
|
"Auth",
|
||||||
|
|
@ -244,13 +244,13 @@ fn check_auth_health() -> DiagnosticCheck {
|
||||||
format!("failed to inspect legacy saved credentials: {error}"),
|
format!("failed to inspect legacy saved credentials: {error}"),
|
||||||
)
|
)
|
||||||
.with_data(Map::from_iter([
|
.with_data(Map::from_iter([
|
||||||
("api_key_present".to_string(), json!(api_key_present)),
|
("api_key_present".to_string(),serde_json::json!(api_key_present)),
|
||||||
("auth_token_present".to_string(), json!(auth_token_present)),
|
("auth_token_present".to_string(),serde_json::json!(auth_token_present)),
|
||||||
("legacy_saved_oauth_present".to_string(), Value::Null),
|
("legacy_saved_oauth_present".to_string(), Value::Null),
|
||||||
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
|
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
|
||||||
("legacy_refresh_token_present".to_string(), Value::Null),
|
("legacy_refresh_token_present".to_string(), Value::Null),
|
||||||
("legacy_scopes".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" })
|
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_details(details)
|
||||||
.with_data(Map::from_iter([
|
.with_data(Map::from_iter([
|
||||||
("discovered_files".to_string(), json!(present_paths)),
|
("discovered_files".to_string(),serde_json::json!(present_paths)),
|
||||||
("discovered_files_count".to_string(), json!(present_paths.len())),
|
("discovered_files_count".to_string(),serde_json::json!(present_paths.len())),
|
||||||
("loaded_config_files".to_string(), json!(loaded_entries.len())),
|
("loaded_config_files".to_string(),serde_json::json!(loaded_entries.len())),
|
||||||
("resolved_model".to_string(), json!(runtime_config.model())),
|
("resolved_model".to_string(),serde_json::json!(runtime_config.model())),
|
||||||
("mcp_servers".to_string(), json!(runtime_config.mcp().servers().len())),
|
("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}"))
|
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_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([
|
.with_data(Map::from_iter([
|
||||||
("discovered_files".to_string(), json!(discovered_paths)),
|
("discovered_files".to_string(),serde_json::json!(discovered_paths)),
|
||||||
("discovered_files_count".to_string(), json!(discovered_paths.len())),
|
("discovered_files_count".to_string(),serde_json::json!(discovered_paths.len())),
|
||||||
("loaded_config_files".to_string(), json!(0)),
|
("loaded_config_files".to_string(),serde_json::json!(0)),
|
||||||
("resolved_model".to_string(), Value::Null),
|
("resolved_model".to_string(), Value::Null),
|
||||||
("mcp_servers".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"),
|
format!("Deprecated crate `{DEPRECATED_INSTALL_COMMAND}` installs a deprecated stub and does not provide the `claw` binary"),
|
||||||
])
|
])
|
||||||
.with_data(Map::from_iter([
|
.with_data(Map::from_iter([
|
||||||
("official_repo".to_string(), json!(OFFICIAL_REPO_URL)),
|
("official_repo".to_string(),serde_json::json!(OFFICIAL_REPO_URL)),
|
||||||
("deprecated_install".to_string(), json!(DEPRECATED_INSTALL_COMMAND)),
|
("deprecated_install".to_string(),serde_json::json!(DEPRECATED_INSTALL_COMMAND)),
|
||||||
("recommended_install".to_string(), json!("build from source or follow the upstream binary instructions in README.md")),
|
("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),
|
format!("Memory files {} · config files loaded {}/{}", context.memory_file_count, context.loaded_config_files, context.discovered_config_files),
|
||||||
])
|
])
|
||||||
.with_data(Map::from_iter([
|
.with_data(Map::from_iter([
|
||||||
("cwd".to_string(), json!(context.cwd.display().to_string())),
|
("cwd".to_string(),serde_json::json!(context.cwd.display().to_string())),
|
||||||
("project_root".to_string(), json!(context.project_root.as_ref().map(|p| p.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(), json!(in_repo)),
|
("in_git_repo".to_string(),serde_json::json!(in_repo)),
|
||||||
("git_branch".to_string(), json!(context.git_branch)),
|
("git_branch".to_string(),serde_json::json!(context.git_branch)),
|
||||||
("git_state".to_string(), json!(context.git_summary.headline())),
|
("git_state".to_string(),serde_json::json!(context.git_summary.headline())),
|
||||||
("changed_files".to_string(), json!(context.git_summary.changed_files)),
|
("changed_files".to_string(),serde_json::json!(context.git_summary.changed_files)),
|
||||||
("memory_file_count".to_string(), json!(context.memory_file_count)),
|
("memory_file_count".to_string(),serde_json::json!(context.memory_file_count)),
|
||||||
("loaded_config_files".to_string(), json!(context.loaded_config_files)),
|
("loaded_config_files".to_string(),serde_json::json!(context.loaded_config_files)),
|
||||||
("discovered_config_files".to_string(), json!(context.discovered_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_details(details)
|
||||||
.with_data(Map::from_iter([
|
.with_data(Map::from_iter([
|
||||||
("enabled".to_string(), json!(status.enabled)),
|
("enabled".to_string(),serde_json::json!(status.enabled)),
|
||||||
("active".to_string(), json!(status.active)),
|
("active".to_string(),serde_json::json!(status.active)),
|
||||||
("supported".to_string(), json!(status.supported)),
|
("supported".to_string(),serde_json::json!(status.supported)),
|
||||||
("namespace_supported".to_string(), json!(status.namespace_supported)),
|
("namespace_supported".to_string(),serde_json::json!(status.namespace_supported)),
|
||||||
("namespace_active".to_string(), json!(status.namespace_active)),
|
("namespace_active".to_string(),serde_json::json!(status.namespace_active)),
|
||||||
("network_supported".to_string(), json!(status.network_supported)),
|
("network_supported".to_string(),serde_json::json!(status.network_supported)),
|
||||||
("network_active".to_string(), json!(status.network_active)),
|
("network_active".to_string(),serde_json::json!(status.network_active)),
|
||||||
("filesystem_mode".to_string(), json!(status.filesystem_mode.as_str())),
|
("filesystem_mode".to_string(),serde_json::json!(status.filesystem_mode.as_str())),
|
||||||
("filesystem_active".to_string(), json!(status.filesystem_active)),
|
("filesystem_active".to_string(),serde_json::json!(status.filesystem_active)),
|
||||||
("allowed_mounts".to_string(), json!(status.allowed_mounts)),
|
("allowed_mounts".to_string(),serde_json::json!(status.allowed_mounts)),
|
||||||
("in_container".to_string(), json!(status.in_container)),
|
("in_container".to_string(),serde_json::json!(status.in_container)),
|
||||||
("container_markers".to_string(), json!(status.container_markers)),
|
("container_markers".to_string(),serde_json::json!(status.container_markers)),
|
||||||
("fallback_reason".to_string(), json!(status.fallback_reason)),
|
("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")
|
DiagnosticCheck::new("System", DiagnosticLevel::Ok, "captured local runtime metadata")
|
||||||
.with_details(details)
|
.with_details(details)
|
||||||
.with_data(Map::from_iter([
|
.with_data(Map::from_iter([
|
||||||
("os".to_string(), json!(env::consts::OS)),
|
("os".to_string(),serde_json::json!(env::consts::OS)),
|
||||||
("arch".to_string(), json!(env::consts::ARCH)),
|
("arch".to_string(),serde_json::json!(env::consts::ARCH)),
|
||||||
("working_dir".to_string(), json!(cwd.display().to_string())),
|
("working_dir".to_string(),serde_json::json!(cwd.display().to_string())),
|
||||||
("version".to_string(), json!(VERSION)),
|
("version".to_string(),serde_json::json!(VERSION)),
|
||||||
("build_target".to_string(), json!(BUILD_TARGET)),
|
("build_target".to_string(),serde_json::json!(BUILD_TARGET)),
|
||||||
("git_sha".to_string(), json!(GIT_SHA)),
|
("git_sha".to_string(),serde_json::json!(GIT_SHA)),
|
||||||
("default_model".to_string(), json!(default_model)),
|
("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::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::args::CliOutputFormat;
|
||||||
|
|
||||||
const STARTER_CLAW_JSON: &str = concat!(
|
const STARTER_CLAW_JSON: &str = concat!(
|
||||||
"{\n",
|
"{\n",
|
||||||
" \"permissions\": {\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> {
|
fn ensure_dir(path: &Path) -> Result<InitStatus, std::io::Error> {
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
return Ok(InitStatus::Skipped);
|
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 {
|
PluginToolDefinition {
|
||||||
name: "plugin_echo".to_string(),
|
name: "plugin_echo".to_string(),
|
||||||
description: Some("Echo plugin payload".to_string()),
|
description: Some("Echo plugin payload".to_string()),
|
||||||
input_schema: json!({
|
input_schema:serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"message": { "type": "string" }
|
"message": { "type": "string" }
|
||||||
|
|
@ -3492,7 +3492,7 @@ UU conflicted.rs",
|
||||||
.map(|index| format!("line {index:03}"))
|
.map(|index| format!("line {index:03}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
let output = json!({
|
let output =serde_json::json!({
|
||||||
"file": {
|
"file": {
|
||||||
"filePath": "src/main.rs",
|
"filePath": "src/main.rs",
|
||||||
"content": content,
|
"content": content,
|
||||||
|
|
@ -3518,7 +3518,7 @@ UU conflicted.rs",
|
||||||
.map(|index| format!("stdout {index:03}"))
|
.map(|index| format!("stdout {index:03}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
let output = json!({
|
let output =serde_json::json!({
|
||||||
"stdout": stdout,
|
"stdout": stdout,
|
||||||
"stderr": "",
|
"stderr": "",
|
||||||
"returnCodeInterpretation": "completed successfully"
|
"returnCodeInterpretation": "completed successfully"
|
||||||
|
|
@ -3539,7 +3539,7 @@ UU conflicted.rs",
|
||||||
let items = (0..120)
|
let items = (0..120)
|
||||||
.map(|index| format!("payload {index:03}"))
|
.map(|index| format!("payload {index:03}"))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let output = json!({
|
let output =serde_json::json!({
|
||||||
"summary": "plugin payload",
|
"summary": "plugin payload",
|
||||||
"items": items,
|
"items": items,
|
||||||
})
|
})
|
||||||
|
|
@ -3671,7 +3671,7 @@ UU conflicted.rs",
|
||||||
OutputContentBlock::ToolUse {
|
OutputContentBlock::ToolUse {
|
||||||
id: "tool-1".to_string(),
|
id: "tool-1".to_string(),
|
||||||
name: "read_file".to_string(),
|
name: "read_file".to_string(),
|
||||||
input: json!({}),
|
input:serde_json::json!({}),
|
||||||
},
|
},
|
||||||
&mut out,
|
&mut out,
|
||||||
&mut events,
|
&mut events,
|
||||||
|
|
@ -3700,7 +3700,7 @@ UU conflicted.rs",
|
||||||
content: vec![OutputContentBlock::ToolUse {
|
content: vec![OutputContentBlock::ToolUse {
|
||||||
id: "tool-1".to_string(),
|
id: "tool-1".to_string(),
|
||||||
name: "read_file".to_string(),
|
name: "read_file".to_string(),
|
||||||
input: json!({}),
|
input:serde_json::json!({}),
|
||||||
}],
|
}],
|
||||||
stop_reason: Some("tool_use".to_string()),
|
stop_reason: Some("tool_use".to_string()),
|
||||||
stop_sequence: None,
|
stop_sequence: None,
|
||||||
|
|
@ -3735,7 +3735,7 @@ UU conflicted.rs",
|
||||||
content: vec![OutputContentBlock::ToolUse {
|
content: vec![OutputContentBlock::ToolUse {
|
||||||
id: "tool-2".to_string(),
|
id: "tool-2".to_string(),
|
||||||
name: "read_file".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_reason: Some("tool_use".to_string()),
|
||||||
stop_sequence: None,
|
stop_sequence: None,
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ impl SessionLifecycleSummary {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn json_value(&self) -> serde_json::Value {
|
pub(crate) fn json_value(&self) -> serde_json::Value {
|
||||||
json!({
|
serde_json::json!({
|
||||||
"kind": self.kind.as_str(),
|
"kind": self.kind.as_str(),
|
||||||
"pane_id": self.pane_id,
|
"pane_id": self.pane_id,
|
||||||
"pane_command": self.pane_command,
|
"pane_command": self.pane_command,
|
||||||
|
|
@ -130,8 +130,4 @@ pub(crate) struct ManagedSessionSummary {
|
||||||
pub(crate) lifecycle: SessionLifecycleSummary,
|
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::fs;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
use std::time::UNIX_EPOCH;
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
use commands::{render_slash_command_help_filtered, SlashCommand};
|
use commands::{render_slash_command_help_filtered, SlashCommand};
|
||||||
use runtime::Session;
|
use runtime::{ContentBlock, MessageRole, Session};
|
||||||
|
|
||||||
use crate::constants::*;
|
use crate::constants::*;
|
||||||
|
use crate::live_cli::PromptHistoryEntry;
|
||||||
use crate::models::*;
|
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>> {
|
pub(crate) fn current_session_store() -> Result<runtime::SessionStore, Box<dyn std::error::Error>> {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
|
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>> {
|
pub(crate) fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
|
||||||
Ok(current_session_store()?
|
let store = current_session_store()?;
|
||||||
.list_sessions()
|
let sessions = store.list_sessions().map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?
|
let panes = discover_tmux_panes();
|
||||||
|
|
||||||
|
Ok(sessions
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|session| ManagedSessionSummary {
|
.map(|session| {
|
||||||
id: session.id,
|
let lifecycle = classify_session_lifecycle_from_panes(&session.path, panes.clone());
|
||||||
path: session.path,
|
ManagedSessionSummary {
|
||||||
updated_at_ms: session.updated_at_ms,
|
id: session.id,
|
||||||
modified_epoch_millis: session.modified_epoch_millis,
|
path: session.path,
|
||||||
message_count: session.message_count,
|
updated_at_ms: session.updated_at_ms,
|
||||||
parent_session_id: session.parent_session_id,
|
modified_epoch_millis: session.modified_epoch_millis,
|
||||||
branch_name: session.branch_name,
|
message_count: session.message_count,
|
||||||
lifecycle: SessionLifecycleSummary {
|
parent_session_id: session.parent_session_id,
|
||||||
kind: SessionLifecycleKind::SavedOnly,
|
branch_name: session.branch_name,
|
||||||
pane_id: None,
|
lifecycle,
|
||||||
pane_command: None,
|
}
|
||||||
pane_path: None,
|
|
||||||
workspace_dirty: false,
|
|
||||||
abandoned: false,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>> {
|
pub(crate) fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>> {
|
||||||
let session = current_session_store()?
|
let store = current_session_store()?;
|
||||||
.latest_session()
|
let session = store.latest_session().map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
let lifecycle = classify_session_lifecycle_for(&session.path);
|
||||||
|
|
||||||
Ok(ManagedSessionSummary {
|
Ok(ManagedSessionSummary {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
path: session.path,
|
path: session.path,
|
||||||
|
|
@ -86,14 +184,7 @@ pub(crate) fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn
|
||||||
message_count: session.message_count,
|
message_count: session.message_count,
|
||||||
parent_session_id: session.parent_session_id,
|
parent_session_id: session.parent_session_id,
|
||||||
branch_name: session.branch_name,
|
branch_name: session.branch_name,
|
||||||
lifecycle: SessionLifecycleSummary {
|
lifecycle,
|
||||||
kind: SessionLifecycleKind::SavedOnly,
|
|
||||||
pane_id: None,
|
|
||||||
pane_command: None,
|
|
||||||
pane_path: None,
|
|
||||||
workspace_dirty: false,
|
|
||||||
abandoned: false,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,6 +221,120 @@ pub(crate) fn confirm_session_deletion(session_id: &str) -> bool {
|
||||||
matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
|
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>> {
|
pub(crate) fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
let sessions = list_managed_sessions()?;
|
let sessions = list_managed_sessions()?;
|
||||||
let mut lines = vec![
|
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(),
|
(None, None) => String::new(),
|
||||||
};
|
};
|
||||||
lines.push(format!(
|
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,
|
id = session.id,
|
||||||
msgs = session.message_count,
|
msgs = session.message_count,
|
||||||
modified = format_session_modified_age(session.modified_epoch_millis),
|
modified = format_session_modified_age(session.modified_epoch_millis),
|
||||||
lineage = lineage,
|
lineage = lineage,
|
||||||
|
lifecycle = session.lifecycle.signal(),
|
||||||
path = session.path.display(),
|
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"))
|
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::model::*;
|
||||||
use crate::*;
|
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>) {
|
pub(crate) fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
|
||||||
parse_git_status_metadata_for(
|
parse_git_status_metadata_for(
|
||||||
&env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
&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_source = provenance.map(|p| p.source.as_str());
|
||||||
let model_raw = provenance.and_then(|p| p.raw.clone());
|
let model_raw = provenance.and_then(|p| p.raw.clone());
|
||||||
let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::<Vec<_>>());
|
let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::<Vec<_>>());
|
||||||
json!({
|
serde_json::json!({
|
||||||
"kind": "status",
|
"kind": "status",
|
||||||
"status": if degraded { "degraded" } else { "ok" },
|
"status": if degraded { "degraded" } else { "ok" },
|
||||||
"config_load_error": context.config_load_error,
|
"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).
|
// .claw/sessions/. Extract the stem (drop the .jsonl extension).
|
||||||
path.file_stem().map(|n| n.to_string_lossy().into_owned())
|
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,
|
"loaded_config_files": context.loaded_config_files,
|
||||||
"discovered_config_files": context.discovered_config_files,
|
"discovered_config_files": context.discovered_config_files,
|
||||||
"memory_file_count": context.memory_file_count,
|
"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(
|
pub(crate) fn format_status_report(
|
||||||
model: &str,
|
model: &str,
|
||||||
usage: StatusUsage,
|
usage: StatusUsage,
|
||||||
|
|
@ -486,6 +397,7 @@ pub(crate) fn format_status_report(
|
||||||
Unstaged {}
|
Unstaged {}
|
||||||
Untracked {}
|
Untracked {}
|
||||||
Session {}
|
Session {}
|
||||||
|
Lifecycle {}
|
||||||
Config files loaded {}/{}
|
Config files loaded {}/{}
|
||||||
Memory files {}
|
Memory files {}
|
||||||
Suggested flow /status → /diff → /commit",
|
Suggested flow /status → /diff → /commit",
|
||||||
|
|
@ -504,6 +416,7 @@ pub(crate) fn format_status_report(
|
||||||
|| "live-repl".to_string(),
|
|| "live-repl".to_string(),
|
||||||
|path| path.display().to_string()
|
|path| path.display().to_string()
|
||||||
),
|
),
|
||||||
|
context.session_lifecycle.signal(),
|
||||||
context.loaded_config_files,
|
context.loaded_config_files,
|
||||||
context.discovered_config_files,
|
context.discovered_config_files,
|
||||||
context.memory_file_count,
|
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 {
|
pub(crate) fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
|
||||||
json!({
|
serde_json::json!({
|
||||||
"kind": "sandbox",
|
"kind": "sandbox",
|
||||||
"enabled": status.enabled,
|
"enabled": status.enabled,
|
||||||
"active": status.active,
|
"active": status.active,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ use runtime::{
|
||||||
use tools::GlobalToolRegistry;
|
use tools::GlobalToolRegistry;
|
||||||
|
|
||||||
use crate::constants::*;
|
use crate::constants::*;
|
||||||
use crate::render::{format_tool_result, TerminalRenderer};
|
use crate::format_tool_result;
|
||||||
|
use crate::render::TerminalRenderer;
|
||||||
use crate::RuntimeMcpState;
|
use crate::RuntimeMcpState;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue