519 lines
19 KiB
Rust
519 lines
19 KiB
Rust
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);
|
|
}
|
|
}
|