This commit is contained in:
parent
3810cbb5e4
commit
56dbb298a8
|
|
@ -9,6 +9,11 @@ archive/
|
||||||
.claw/settings.local.json
|
.claw/settings.local.json
|
||||||
.claw/sessions/
|
.claw/sessions/
|
||||||
.clawhip/
|
.clawhip/
|
||||||
|
.claude/
|
||||||
|
.clawd-todos.json
|
||||||
|
.learnings/
|
||||||
|
.sandbox-home/
|
||||||
|
.sandbox-tmp/
|
||||||
status-help.txt
|
status-help.txt
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,21 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-no-stdlib"
|
||||||
|
version = "2.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-stdlib"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anes"
|
name = "anes"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
|
|
@ -42,6 +57,18 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-compression"
|
||||||
|
version = "0.4.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
||||||
|
dependencies = [
|
||||||
|
"compression-codecs",
|
||||||
|
"compression-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
|
|
@ -90,6 +117,27 @@ dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "8.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
"brotli-decompressor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli-decompressor"
|
||||||
|
version = "5.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.2"
|
||||||
|
|
@ -182,6 +230,24 @@ version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "claw-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"api",
|
||||||
|
"commands",
|
||||||
|
"compat-harness",
|
||||||
|
"crossterm",
|
||||||
|
"plugins",
|
||||||
|
"pulldown-cmark",
|
||||||
|
"runtime",
|
||||||
|
"rustyline",
|
||||||
|
"serde_json",
|
||||||
|
"syntect",
|
||||||
|
"tokio",
|
||||||
|
"tools",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
|
|
@ -209,6 +275,24 @@ dependencies = [
|
||||||
"tools",
|
"tools",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-codecs"
|
||||||
|
version = "0.4.38"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
||||||
|
dependencies = [
|
||||||
|
"brotli",
|
||||||
|
"compression-core",
|
||||||
|
"flate2",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-core"
|
||||||
|
version = "0.4.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -1875,6 +1959,19 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tools"
|
name = "tools"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -1911,13 +2008,18 @@ version = "0.6.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
"iri-string",
|
"iri-string",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/*"]
|
members = ["crates/*"]
|
||||||
exclude = ["crates/server", "crates/claw-cli"]
|
exclude = ["crates/server"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ license.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip", "brotli", "deflate"] }
|
||||||
runtime = { path = "../runtime" }
|
runtime = { path = "../runtime" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,402 +0,0 @@
|
||||||
use std::io::{self, Write};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use crate::args::{OutputFormat, PermissionMode};
|
|
||||||
use crate::input::{LineEditor, ReadOutcome};
|
|
||||||
use crate::render::{Spinner, TerminalRenderer};
|
|
||||||
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct SessionConfig {
|
|
||||||
pub model: String,
|
|
||||||
pub permission_mode: PermissionMode,
|
|
||||||
pub config: Option<PathBuf>,
|
|
||||||
pub output_format: OutputFormat,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct SessionState {
|
|
||||||
pub turns: usize,
|
|
||||||
pub compacted_messages: usize,
|
|
||||||
pub last_model: String,
|
|
||||||
pub last_usage: UsageSummary,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionState {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(model: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
turns: 0,
|
|
||||||
compacted_messages: 0,
|
|
||||||
last_model: model.into(),
|
|
||||||
last_usage: UsageSummary::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum CommandResult {
|
|
||||||
Continue,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum SlashCommand {
|
|
||||||
Help,
|
|
||||||
Status,
|
|
||||||
Compact,
|
|
||||||
Unknown(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SlashCommand {
|
|
||||||
#[must_use]
|
|
||||||
pub fn parse(input: &str) -> Option<Self> {
|
|
||||||
let trimmed = input.trim();
|
|
||||||
if !trimmed.starts_with('/') {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let command = trimmed
|
|
||||||
.trim_start_matches('/')
|
|
||||||
.split_whitespace()
|
|
||||||
.next()
|
|
||||||
.unwrap_or_default();
|
|
||||||
Some(match command {
|
|
||||||
"help" => Self::Help,
|
|
||||||
"status" => Self::Status,
|
|
||||||
"compact" => Self::Compact,
|
|
||||||
other => Self::Unknown(other.to_string()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SlashCommandHandler {
|
|
||||||
command: SlashCommand,
|
|
||||||
summary: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
const SLASH_COMMAND_HANDLERS: &[SlashCommandHandler] = &[
|
|
||||||
SlashCommandHandler {
|
|
||||||
command: SlashCommand::Help,
|
|
||||||
summary: "Show command help",
|
|
||||||
},
|
|
||||||
SlashCommandHandler {
|
|
||||||
command: SlashCommand::Status,
|
|
||||||
summary: "Show current session status",
|
|
||||||
},
|
|
||||||
SlashCommandHandler {
|
|
||||||
command: SlashCommand::Compact,
|
|
||||||
summary: "Compact local session history",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
pub struct CliApp {
|
|
||||||
config: SessionConfig,
|
|
||||||
renderer: TerminalRenderer,
|
|
||||||
state: SessionState,
|
|
||||||
conversation_client: ConversationClient,
|
|
||||||
conversation_history: Vec<ConversationMessage>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CliApp {
|
|
||||||
pub fn new(config: SessionConfig) -> Result<Self, RuntimeError> {
|
|
||||||
let state = SessionState::new(config.model.clone());
|
|
||||||
let conversation_client = ConversationClient::from_env(config.model.clone())?;
|
|
||||||
Ok(Self {
|
|
||||||
config,
|
|
||||||
renderer: TerminalRenderer::new(),
|
|
||||||
state,
|
|
||||||
conversation_client,
|
|
||||||
conversation_history: Vec::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_repl(&mut self) -> io::Result<()> {
|
|
||||||
let mut editor = LineEditor::new("› ", Vec::new());
|
|
||||||
println!("Claw Code interactive mode");
|
|
||||||
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match editor.read_line()? {
|
|
||||||
ReadOutcome::Submit(input) => {
|
|
||||||
if input.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
self.handle_submission(&input, &mut io::stdout())?;
|
|
||||||
}
|
|
||||||
ReadOutcome::Cancel => continue,
|
|
||||||
ReadOutcome::Exit => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_prompt(&mut self, prompt: &str, out: &mut impl Write) -> io::Result<()> {
|
|
||||||
self.render_response(prompt, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_submission(
|
|
||||||
&mut self,
|
|
||||||
input: &str,
|
|
||||||
out: &mut impl Write,
|
|
||||||
) -> io::Result<CommandResult> {
|
|
||||||
if let Some(command) = SlashCommand::parse(input) {
|
|
||||||
return self.dispatch_slash_command(command, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.state.turns += 1;
|
|
||||||
self.render_response(input, out)?;
|
|
||||||
Ok(CommandResult::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dispatch_slash_command(
|
|
||||||
&mut self,
|
|
||||||
command: SlashCommand,
|
|
||||||
out: &mut impl Write,
|
|
||||||
) -> io::Result<CommandResult> {
|
|
||||||
match command {
|
|
||||||
SlashCommand::Help => Self::handle_help(out),
|
|
||||||
SlashCommand::Status => self.handle_status(out),
|
|
||||||
SlashCommand::Compact => self.handle_compact(out),
|
|
||||||
SlashCommand::Unknown(name) => {
|
|
||||||
writeln!(out, "Unknown slash command: /{name}")?;
|
|
||||||
Ok(CommandResult::Continue)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
writeln!(out, "Slash command unavailable in this mode")?;
|
|
||||||
Ok(CommandResult::Continue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_help(out: &mut impl Write) -> io::Result<CommandResult> {
|
|
||||||
writeln!(out, "Available commands:")?;
|
|
||||||
for handler in SLASH_COMMAND_HANDLERS {
|
|
||||||
let name = match handler.command {
|
|
||||||
SlashCommand::Help => "/help",
|
|
||||||
SlashCommand::Status => "/status",
|
|
||||||
SlashCommand::Compact => "/compact",
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
writeln!(out, " {name:<9} {}", handler.summary)?;
|
|
||||||
}
|
|
||||||
Ok(CommandResult::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_status(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
"status: turns={} model={} permission-mode={:?} output-format={:?} last-usage={} in/{} out config={}",
|
|
||||||
self.state.turns,
|
|
||||||
self.state.last_model,
|
|
||||||
self.config.permission_mode,
|
|
||||||
self.config.output_format,
|
|
||||||
self.state.last_usage.input_tokens,
|
|
||||||
self.state.last_usage.output_tokens,
|
|
||||||
self.config
|
|
||||||
.config
|
|
||||||
.as_ref()
|
|
||||||
.map_or_else(|| String::from("<none>"), |path| path.display().to_string())
|
|
||||||
)?;
|
|
||||||
Ok(CommandResult::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_compact(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
|
|
||||||
self.state.compacted_messages += self.state.turns;
|
|
||||||
self.state.turns = 0;
|
|
||||||
self.conversation_history.clear();
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
"Compacted session history into a local summary ({} messages total compacted).",
|
|
||||||
self.state.compacted_messages
|
|
||||||
)?;
|
|
||||||
Ok(CommandResult::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_stream_event(
|
|
||||||
renderer: &TerminalRenderer,
|
|
||||||
event: StreamEvent,
|
|
||||||
stream_spinner: &mut Spinner,
|
|
||||||
tool_spinner: &mut Spinner,
|
|
||||||
saw_text: &mut bool,
|
|
||||||
turn_usage: &mut UsageSummary,
|
|
||||||
out: &mut impl Write,
|
|
||||||
) {
|
|
||||||
match event {
|
|
||||||
StreamEvent::TextDelta(delta) => {
|
|
||||||
if !*saw_text {
|
|
||||||
let _ =
|
|
||||||
stream_spinner.finish("Streaming response", renderer.color_theme(), out);
|
|
||||||
*saw_text = true;
|
|
||||||
}
|
|
||||||
let _ = write!(out, "{delta}");
|
|
||||||
let _ = out.flush();
|
|
||||||
}
|
|
||||||
StreamEvent::ToolCallStart { name, input } => {
|
|
||||||
if *saw_text {
|
|
||||||
let _ = writeln!(out);
|
|
||||||
}
|
|
||||||
let _ = tool_spinner.tick(
|
|
||||||
&format!("Running tool `{name}` with {input}"),
|
|
||||||
renderer.color_theme(),
|
|
||||||
out,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
StreamEvent::ToolCallResult {
|
|
||||||
name,
|
|
||||||
output,
|
|
||||||
is_error,
|
|
||||||
} => {
|
|
||||||
let label = if is_error {
|
|
||||||
format!("Tool `{name}` failed")
|
|
||||||
} else {
|
|
||||||
format!("Tool `{name}` completed")
|
|
||||||
};
|
|
||||||
let _ = tool_spinner.finish(&label, renderer.color_theme(), out);
|
|
||||||
let rendered_output = format!("### Tool `{name}`\n\n```text\n{output}\n```\n");
|
|
||||||
let _ = renderer.stream_markdown(&rendered_output, out);
|
|
||||||
}
|
|
||||||
StreamEvent::Usage(usage) => {
|
|
||||||
*turn_usage = usage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_turn_output(
|
|
||||||
&self,
|
|
||||||
summary: &runtime::TurnSummary,
|
|
||||||
out: &mut impl Write,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
match self.config.output_format {
|
|
||||||
OutputFormat::Text => {
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
"\nToken usage: {} input / {} output",
|
|
||||||
self.state.last_usage.input_tokens, self.state.last_usage.output_tokens
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
OutputFormat::Json => {
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
"{}",
|
|
||||||
serde_json::json!({
|
|
||||||
"message": summary.assistant_text,
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": self.state.last_usage.input_tokens,
|
|
||||||
"output_tokens": self.state.last_usage.output_tokens,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
OutputFormat::Ndjson => {
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
"{}",
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "message",
|
|
||||||
"text": summary.assistant_text,
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": self.state.last_usage.input_tokens,
|
|
||||||
"output_tokens": self.state.last_usage.output_tokens,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_response(&mut self, input: &str, out: &mut impl Write) -> io::Result<()> {
|
|
||||||
let mut stream_spinner = Spinner::new();
|
|
||||||
stream_spinner.tick(
|
|
||||||
"Opening conversation stream",
|
|
||||||
self.renderer.color_theme(),
|
|
||||||
out,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let mut turn_usage = UsageSummary::default();
|
|
||||||
let mut tool_spinner = Spinner::new();
|
|
||||||
let mut saw_text = false;
|
|
||||||
let renderer = &self.renderer;
|
|
||||||
|
|
||||||
let result =
|
|
||||||
self.conversation_client
|
|
||||||
.run_turn(&mut self.conversation_history, input, |event| {
|
|
||||||
Self::handle_stream_event(
|
|
||||||
renderer,
|
|
||||||
event,
|
|
||||||
&mut stream_spinner,
|
|
||||||
&mut tool_spinner,
|
|
||||||
&mut saw_text,
|
|
||||||
&mut turn_usage,
|
|
||||||
out,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
let summary = match result {
|
|
||||||
Ok(summary) => summary,
|
|
||||||
Err(error) => {
|
|
||||||
stream_spinner.fail(
|
|
||||||
"Streaming response failed",
|
|
||||||
self.renderer.color_theme(),
|
|
||||||
out,
|
|
||||||
)?;
|
|
||||||
return Err(io::Error::other(error));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
self.state.last_usage = summary.usage.clone();
|
|
||||||
if saw_text {
|
|
||||||
writeln!(out)?;
|
|
||||||
} else {
|
|
||||||
stream_spinner.finish("Streaming response", self.renderer.color_theme(), out)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.write_turn_output(&summary, out)?;
|
|
||||||
let _ = turn_usage;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use crate::args::{OutputFormat, PermissionMode};
|
|
||||||
|
|
||||||
use super::{CommandResult, SessionConfig, SlashCommand};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_required_slash_commands() {
|
|
||||||
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
|
|
||||||
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
|
|
||||||
assert_eq!(
|
|
||||||
SlashCommand::parse("/compact now"),
|
|
||||||
Some(SlashCommand::Compact)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn help_output_lists_commands() {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let result = super::CliApp::handle_help(&mut out).expect("help succeeds");
|
|
||||||
assert_eq!(result, CommandResult::Continue);
|
|
||||||
let output = String::from_utf8_lossy(&out);
|
|
||||||
assert!(output.contains("/help"));
|
|
||||||
assert!(output.contains("/status"));
|
|
||||||
assert!(output.contains("/compact"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn session_state_tracks_config_values() {
|
|
||||||
let config = SessionConfig {
|
|
||||||
model: "sonnet".into(),
|
|
||||||
permission_mode: PermissionMode::DangerFullAccess,
|
|
||||||
config: Some(PathBuf::from("settings.toml")),
|
|
||||||
output_format: OutputFormat::Text,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(config.model, "sonnet");
|
|
||||||
assert_eq!(config.permission_mode, PermissionMode::DangerFullAccess);
|
|
||||||
assert_eq!(config.config, Some(PathBuf::from("settings.toml")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use clap::{Parser, Subcommand, ValueEnum};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Parser, PartialEq, Eq)]
|
|
||||||
#[command(name = "claw-cli", version, about = "Claw Code CLI")]
|
|
||||||
pub struct Cli {
|
|
||||||
#[arg(long, default_value = "claude-opus-4-6")]
|
|
||||||
pub model: String,
|
|
||||||
|
|
||||||
#[arg(long, value_enum, default_value_t = PermissionMode::DangerFullAccess)]
|
|
||||||
pub permission_mode: PermissionMode,
|
|
||||||
|
|
||||||
#[arg(long)]
|
|
||||||
pub config: Option<PathBuf>,
|
|
||||||
|
|
||||||
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
|
|
||||||
pub output_format: OutputFormat,
|
|
||||||
|
|
||||||
#[command(subcommand)]
|
|
||||||
pub command: Option<Command>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
|
|
||||||
pub enum Command {
|
|
||||||
/// Read upstream TS sources and print extracted counts
|
|
||||||
DumpManifests,
|
|
||||||
/// Print the current bootstrap phase skeleton
|
|
||||||
BootstrapPlan,
|
|
||||||
/// Start the OAuth login flow
|
|
||||||
Login,
|
|
||||||
/// Clear saved OAuth credentials
|
|
||||||
Logout,
|
|
||||||
/// Run a non-interactive prompt and exit
|
|
||||||
Prompt { prompt: Vec<String> },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
|
|
||||||
pub enum PermissionMode {
|
|
||||||
ReadOnly,
|
|
||||||
WorkspaceWrite,
|
|
||||||
DangerFullAccess,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
|
|
||||||
pub enum OutputFormat {
|
|
||||||
Text,
|
|
||||||
Json,
|
|
||||||
Ndjson,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use clap::Parser;
|
|
||||||
|
|
||||||
use super::{Cli, Command, OutputFormat, PermissionMode};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_requested_flags() {
|
|
||||||
let cli = Cli::parse_from([
|
|
||||||
"claw-cli",
|
|
||||||
"--model",
|
|
||||||
"claude-haiku-4-5-20251213",
|
|
||||||
"--permission-mode",
|
|
||||||
"read-only",
|
|
||||||
"--config",
|
|
||||||
"/tmp/config.toml",
|
|
||||||
"--output-format",
|
|
||||||
"ndjson",
|
|
||||||
"prompt",
|
|
||||||
"hello",
|
|
||||||
"world",
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert_eq!(cli.model, "claude-haiku-4-5-20251213");
|
|
||||||
assert_eq!(cli.permission_mode, PermissionMode::ReadOnly);
|
|
||||||
assert_eq!(
|
|
||||||
cli.config.as_deref(),
|
|
||||||
Some(std::path::Path::new("/tmp/config.toml"))
|
|
||||||
);
|
|
||||||
assert_eq!(cli.output_format, OutputFormat::Ndjson);
|
|
||||||
assert_eq!(
|
|
||||||
cli.command,
|
|
||||||
Some(Command::Prompt {
|
|
||||||
prompt: vec!["hello".into(), "world".into()]
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_login_and_logout_commands() {
|
|
||||||
let login = Cli::parse_from(["claw-cli", "login"]);
|
|
||||||
assert_eq!(login.command, Some(Command::Login));
|
|
||||||
|
|
||||||
let logout = Cli::parse_from(["claw-cli", "logout"]);
|
|
||||||
assert_eq!(logout.command, Some(Command::Logout));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn defaults_to_danger_full_access_permission_mode() {
|
|
||||||
let cli = Cli::parse_from(["claw-cli"]);
|
|
||||||
assert_eq!(cli.permission_mode, PermissionMode::DangerFullAccess);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -16,8 +16,8 @@ use std::thread;
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use api::{
|
use api::{
|
||||||
resolve_startup_auth_source, AuthSource, ClawApiClient, ContentBlockDelta, InputContentBlock,
|
resolve_startup_auth_source, ApiClient as ClawApiClient, AuthSource, ContentBlockDelta,
|
||||||
InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
|
InputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
|
||||||
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -169,8 +169,25 @@ impl CliOutputFormat {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
/// Intermediate result of scanning global flags (--model, --output-format, etc.)
|
||||||
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
/// before subcommand dispatch. This separates "what flags were set" from "what action to take".
|
||||||
|
struct ParsedFlags {
|
||||||
|
model: String,
|
||||||
|
output_format: CliOutputFormat,
|
||||||
|
permission_mode: PermissionMode,
|
||||||
|
wants_version: bool,
|
||||||
|
allowed_tool_values: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combined output of flag scanning: parsed flags + remaining positional args.
|
||||||
|
struct ScanResult {
|
||||||
|
flags: ParsedFlags,
|
||||||
|
rest: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 1: Scan global flags from the argument list, collecting flag values
|
||||||
|
/// and returning remaining non-flag arguments for subcommand dispatch.
|
||||||
|
fn scan_flags(args: &[String]) -> Result<ScanResult, String> {
|
||||||
let mut model = DEFAULT_MODEL.to_string();
|
let mut model = DEFAULT_MODEL.to_string();
|
||||||
let mut output_format = CliOutputFormat::Text;
|
let mut output_format = CliOutputFormat::Text;
|
||||||
let mut permission_mode = default_permission_mode();
|
let mut permission_mode = default_permission_mode();
|
||||||
|
|
@ -222,20 +239,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
permission_mode = PermissionMode::DangerFullAccess;
|
permission_mode = PermissionMode::DangerFullAccess;
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
"-p" => {
|
|
||||||
// Claw Code compat: -p "prompt" = one-shot prompt
|
|
||||||
let prompt = args[index + 1..].join(" ");
|
|
||||||
if prompt.trim().is_empty() {
|
|
||||||
return Err("-p requires a prompt string".to_string());
|
|
||||||
}
|
|
||||||
return Ok(CliAction::Prompt {
|
|
||||||
prompt,
|
|
||||||
model: resolve_model_alias(&model).to_string(),
|
|
||||||
output_format,
|
|
||||||
allowed_tools: normalize_allowed_tools(&allowed_tool_values)?,
|
|
||||||
permission_mode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"--print" => {
|
"--print" => {
|
||||||
// Claw Code compat: --print makes output non-interactive
|
// Claw Code compat: --print makes output non-interactive
|
||||||
output_format = CliOutputFormat::Text;
|
output_format = CliOutputFormat::Text;
|
||||||
|
|
@ -263,17 +266,27 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if wants_version {
|
Ok(ScanResult {
|
||||||
return Ok(CliAction::Version);
|
flags: ParsedFlags {
|
||||||
}
|
model,
|
||||||
|
output_format,
|
||||||
|
permission_mode,
|
||||||
|
wants_version,
|
||||||
|
allowed_tool_values,
|
||||||
|
},
|
||||||
|
rest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
|
/// Phase 2: Given parsed flags and remaining positional args, determine the CLI action.
|
||||||
|
fn dispatch_subcommand(flags: &ParsedFlags, rest: &[String]) -> Result<CliAction, String> {
|
||||||
|
let allowed_tools = normalize_allowed_tools(&flags.allowed_tool_values)?;
|
||||||
|
|
||||||
if rest.is_empty() {
|
if rest.is_empty() {
|
||||||
return Ok(CliAction::Repl {
|
return Ok(CliAction::Repl {
|
||||||
model,
|
model: flags.model.clone(),
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
permission_mode,
|
permission_mode: flags.permission_mode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
|
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
|
||||||
|
|
@ -303,23 +316,52 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
}
|
}
|
||||||
Ok(CliAction::Prompt {
|
Ok(CliAction::Prompt {
|
||||||
prompt,
|
prompt,
|
||||||
model,
|
model: flags.model.clone(),
|
||||||
output_format,
|
output_format: flags.output_format,
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
permission_mode,
|
permission_mode: flags.permission_mode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
other if other.starts_with('/') => parse_direct_slash_cli_action(&rest),
|
other if other.starts_with('/') => parse_direct_slash_cli_action(rest),
|
||||||
_other => Ok(CliAction::Prompt {
|
_other => Ok(CliAction::Prompt {
|
||||||
prompt: rest.join(" "),
|
prompt: rest.join(" "),
|
||||||
model,
|
model: flags.model.clone(),
|
||||||
output_format,
|
output_format: flags.output_format,
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
permission_mode,
|
permission_mode: flags.permission_mode,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
|
// Handle -p early-return separately since it consumes all remaining args
|
||||||
|
if args.iter().any(|a| a == "-p") {
|
||||||
|
let p_index = args.iter().position(|a| a == "-p").unwrap();
|
||||||
|
let prompt = args[p_index + 1..].join(" ");
|
||||||
|
if prompt.trim().is_empty() {
|
||||||
|
return Err("-p requires a prompt string".to_string());
|
||||||
|
}
|
||||||
|
// Still need to scan flags that appear before -p
|
||||||
|
let pre_flags = scan_flags(&args[..p_index])?;
|
||||||
|
let allowed_tools = normalize_allowed_tools(&pre_flags.flags.allowed_tool_values)?;
|
||||||
|
return Ok(CliAction::Prompt {
|
||||||
|
prompt,
|
||||||
|
model: resolve_model_alias(&pre_flags.flags.model).to_string(),
|
||||||
|
output_format: pre_flags.flags.output_format,
|
||||||
|
allowed_tools,
|
||||||
|
permission_mode: pre_flags.flags.permission_mode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let scan = scan_flags(args)?;
|
||||||
|
if scan.flags.wants_version {
|
||||||
|
return Ok(CliAction::Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch_subcommand(&scan.flags, &scan.rest)
|
||||||
|
}
|
||||||
|
|
||||||
fn join_optional_args(args: &[String]) -> Option<String> {
|
fn join_optional_args(args: &[String]) -> Option<String> {
|
||||||
let joined = args.join(" ");
|
let joined = args.join(" ");
|
||||||
let trimmed = joined.trim();
|
let trimmed = joined.trim();
|
||||||
|
|
@ -329,10 +371,10 @@ fn join_optional_args(args: &[String]) -> Option<String> {
|
||||||
fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
|
fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
|
||||||
let raw = rest.join(" ");
|
let raw = rest.join(" ");
|
||||||
match SlashCommand::parse(&raw) {
|
match SlashCommand::parse(&raw) {
|
||||||
Some(SlashCommand::Help) => Ok(CliAction::Help),
|
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help),
|
||||||
Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }),
|
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args }),
|
||||||
Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }),
|
Ok(Some(SlashCommand::Skills { args })) => Ok(CliAction::Skills { args }),
|
||||||
Some(command) => Err(format_direct_slash_command_error(
|
Ok(Some(command)) => Err(format_direct_slash_command_error(
|
||||||
match &command {
|
match &command {
|
||||||
SlashCommand::Unknown(name) => format!("/{name}"),
|
SlashCommand::Unknown(name) => format!("/{name}"),
|
||||||
_ => rest[0].clone(),
|
_ => rest[0].clone(),
|
||||||
|
|
@ -340,7 +382,7 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result<CliAction, String> {
|
||||||
.as_str(),
|
.as_str(),
|
||||||
matches!(command, SlashCommand::Unknown(_)),
|
matches!(command, SlashCommand::Unknown(_)),
|
||||||
)),
|
)),
|
||||||
None => Err(format!("unknown subcommand: {}", rest[0])),
|
Ok(None) | Err(_) => Err(format!("unknown subcommand: {}", rest[0])),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -483,7 +525,7 @@ fn dump_manifests() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_bootstrap_plan() {
|
fn print_bootstrap_plan() {
|
||||||
for phase in runtime::BootstrapPlan::claw_default().phases() {
|
for phase in runtime::BootstrapPlan::claude_code_default().phases() {
|
||||||
println!("- {phase:?}");
|
println!("- {phase:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -649,7 +691,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
|
||||||
|
|
||||||
let mut session = session;
|
let mut session = session;
|
||||||
for raw_command in commands {
|
for raw_command in commands {
|
||||||
let Some(command) = SlashCommand::parse(raw_command) else {
|
let Ok(Some(command)) = SlashCommand::parse(raw_command) else {
|
||||||
eprintln!("unsupported resumed command: {raw_command}");
|
eprintln!("unsupported resumed command: {raw_command}");
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
};
|
};
|
||||||
|
|
@ -839,10 +881,10 @@ fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<S
|
||||||
let branch = status.lines().next().and_then(|line| {
|
let branch = status.lines().next().and_then(|line| {
|
||||||
line.strip_prefix("## ")
|
line.strip_prefix("## ")
|
||||||
.map(|line| {
|
.map(|line| {
|
||||||
line.split(['.', ' '])
|
// git status --short format: "## branch...remote/branch" or "## branch"
|
||||||
.next()
|
// Split on "..." first to strip remote tracking info, then trailing whitespace.
|
||||||
.unwrap_or_default()
|
let after_dots = line.split("...").next().unwrap_or(line);
|
||||||
.to_string()
|
after_dots.trim().to_string()
|
||||||
})
|
})
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
});
|
});
|
||||||
|
|
@ -986,8 +1028,6 @@ fn run_resume_command(
|
||||||
}
|
}
|
||||||
SlashCommand::Bughunter { .. }
|
SlashCommand::Bughunter { .. }
|
||||||
| SlashCommand::Branch { .. }
|
| SlashCommand::Branch { .. }
|
||||||
| SlashCommand::Worktree { .. }
|
|
||||||
| SlashCommand::CommitPushPr { .. }
|
|
||||||
| SlashCommand::Commit
|
| SlashCommand::Commit
|
||||||
| SlashCommand::Pr { .. }
|
| SlashCommand::Pr { .. }
|
||||||
| SlashCommand::Issue { .. }
|
| SlashCommand::Issue { .. }
|
||||||
|
|
@ -999,7 +1039,7 @@ fn run_resume_command(
|
||||||
| SlashCommand::Permissions { .. }
|
| SlashCommand::Permissions { .. }
|
||||||
| SlashCommand::Session { .. }
|
| SlashCommand::Session { .. }
|
||||||
| SlashCommand::Plugins { .. }
|
| SlashCommand::Plugins { .. }
|
||||||
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
|
| _ => Err("unsupported resumed slash command".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1023,7 +1063,7 @@ fn run_repl(
|
||||||
cli.persist_session()?;
|
cli.persist_session()?;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Some(command) = SlashCommand::parse(trimmed) {
|
if let Ok(Some(command)) = SlashCommand::parse(trimmed) {
|
||||||
if cli.handle_repl_command(command)? {
|
if cli.handle_repl_command(command)? {
|
||||||
cli.persist_session()?;
|
cli.persist_session()?;
|
||||||
}
|
}
|
||||||
|
|
@ -1335,24 +1375,14 @@ impl LiveCli {
|
||||||
);
|
);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
SlashCommand::Worktree { .. } => {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
render_mode_unavailable("worktree", "git worktree commands")
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
SlashCommand::CommitPushPr { .. } => {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
render_mode_unavailable("commit-push-pr", "commit + push + PR automation")
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
SlashCommand::Unknown(name) => {
|
SlashCommand::Unknown(name) => {
|
||||||
eprintln!("{}", render_unknown_repl_command(&name));
|
eprintln!("{}", render_unknown_repl_command(&name));
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!("unsupported slash command in this CLI version");
|
||||||
|
false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2517,6 +2547,15 @@ fn render_export_text(session: &Session) -> String {
|
||||||
"[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}"
|
"[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
ContentBlock::Thinking {
|
||||||
|
thinking,
|
||||||
|
signature,
|
||||||
|
} => {
|
||||||
|
lines.push(format!("[thinking] {thinking}"));
|
||||||
|
if let Some(sig) = signature {
|
||||||
|
lines.push(format!("[thinking_signature] {sig}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines.push(String::new());
|
lines.push(String::new());
|
||||||
|
|
@ -2988,7 +3027,7 @@ fn build_runtime(
|
||||||
CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()),
|
CliToolExecutor::new(allowed_tools.clone(), emit_output, tool_registry.clone()),
|
||||||
permission_policy(permission_mode, &tool_registry),
|
permission_policy(permission_mode, &tool_registry),
|
||||||
system_prompt,
|
system_prompt,
|
||||||
feature_config,
|
&feature_config,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3098,6 +3137,12 @@ impl ApiClient for DefaultRuntimeClient {
|
||||||
.then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())),
|
.then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())),
|
||||||
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
|
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
|
||||||
stream: true,
|
stream: true,
|
||||||
|
temperature: None,
|
||||||
|
top_p: None,
|
||||||
|
frequency_penalty: None,
|
||||||
|
presence_penalty: None,
|
||||||
|
stop: None,
|
||||||
|
reasoning_effort: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.runtime.block_on(async {
|
self.runtime.block_on(async {
|
||||||
|
|
@ -3823,7 +3868,10 @@ fn push_output_block(
|
||||||
};
|
};
|
||||||
*pending_tool = Some((id, name, initial_input));
|
*pending_tool = Some((id, name, initial_input));
|
||||||
}
|
}
|
||||||
OutputContentBlock::Thinking { thinking, signature } => {
|
OutputContentBlock::Thinking {
|
||||||
|
thinking,
|
||||||
|
signature,
|
||||||
|
} => {
|
||||||
if !thinking.is_empty() {
|
if !thinking.is_empty() {
|
||||||
events.push(AssistantEvent::ThinkingDelta(thinking));
|
events.push(AssistantEvent::ThinkingDelta(thinking));
|
||||||
}
|
}
|
||||||
|
|
@ -3919,16 +3967,68 @@ impl ToolExecutor for CliToolExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn permission_policy(mode: PermissionMode, tool_registry: &GlobalToolRegistry) -> PermissionPolicy {
|
fn permission_policy(mode: PermissionMode, tool_registry: &GlobalToolRegistry) -> PermissionPolicy {
|
||||||
tool_registry.permission_specs(None).into_iter().fold(
|
tool_registry
|
||||||
PermissionPolicy::new(mode),
|
.permission_specs(None)
|
||||||
|policy, (name, required_permission)| {
|
.unwrap_or_default()
|
||||||
policy.with_tool_requirement(name, required_permission)
|
.into_iter()
|
||||||
},
|
.fold(
|
||||||
)
|
PermissionPolicy::new(mode),
|
||||||
|
|policy, (name, required_permission)| {
|
||||||
|
policy.with_tool_requirement(name, required_permission)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum approximate character budget for API input messages.
|
||||||
|
/// The API rejects requests where total input length exceeds 202745 characters.
|
||||||
|
/// That limit covers the **entire serialized JSON request body**, including:
|
||||||
|
/// - system prompt (~5-10K chars)
|
||||||
|
/// - tool definitions with JSON schemas (~30-50K chars)
|
||||||
|
/// - JSON serialization overhead (keys, quotes, brackets — ~30-50% markup)
|
||||||
|
/// - request-level fields (model, max_tokens, etc. — ~100 chars)
|
||||||
|
///
|
||||||
|
/// We conservatively budget just the messages portion to leave ample room
|
||||||
|
/// for everything else. With ~60K overhead from system+tools+JSON and a
|
||||||
|
/// ~40% serialization markup, 80K of raw message content maps to roughly:
|
||||||
|
/// 80K × 1.4 (JSON overhead) + 60K (system+tools) ≈ 172K total body,
|
||||||
|
/// well under the 202745 limit.
|
||||||
|
const MAX_API_INPUT_CHARS: usize = 80_000;
|
||||||
|
|
||||||
|
/// Estimate the character length of an InputMessage for budget tracking.
|
||||||
|
fn estimate_input_message_chars(msg: &InputMessage) -> usize {
|
||||||
|
msg.role.len()
|
||||||
|
+ msg
|
||||||
|
.content
|
||||||
|
.iter()
|
||||||
|
.map(|block| match block {
|
||||||
|
InputContentBlock::Text { text } => text.len(),
|
||||||
|
InputContentBlock::Thinking {
|
||||||
|
thinking,
|
||||||
|
signature,
|
||||||
|
} => thinking.len() + signature.as_ref().map_or(0, String::len),
|
||||||
|
InputContentBlock::ToolUse { id, name, input } => {
|
||||||
|
id.len() + name.len() + input.to_string().len()
|
||||||
|
}
|
||||||
|
InputContentBlock::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
content,
|
||||||
|
is_error: _,
|
||||||
|
} => {
|
||||||
|
tool_use_id.len()
|
||||||
|
+ content
|
||||||
|
.iter()
|
||||||
|
.map(|c| match c {
|
||||||
|
ToolResultContentBlock::Text { text } => text.len(),
|
||||||
|
ToolResultContentBlock::Json { value } => value.to_string().len(),
|
||||||
|
})
|
||||||
|
.sum::<usize>()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sum::<usize>()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||||
messages
|
let converted: Vec<InputMessage> = messages
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|message| {
|
.filter_map(|message| {
|
||||||
let role = match message.role {
|
let role = match message.role {
|
||||||
|
|
@ -3938,26 +4038,31 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||||
let content = message
|
let content = message
|
||||||
.blocks
|
.blocks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|block| match block {
|
.filter_map(|block| match block {
|
||||||
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
|
ContentBlock::Text { text } => {
|
||||||
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
|
Some(InputContentBlock::Text { text: text.clone() })
|
||||||
|
}
|
||||||
|
ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
input: serde_json::from_str(input)
|
input: serde_json::from_str(input)
|
||||||
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
|
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
|
||||||
},
|
}),
|
||||||
ContentBlock::ToolResult {
|
ContentBlock::ToolResult {
|
||||||
tool_use_id,
|
tool_use_id,
|
||||||
output,
|
output,
|
||||||
is_error,
|
is_error,
|
||||||
..
|
..
|
||||||
} => InputContentBlock::ToolResult {
|
} => Some(InputContentBlock::ToolResult {
|
||||||
tool_use_id: tool_use_id.clone(),
|
tool_use_id: tool_use_id.clone(),
|
||||||
content: vec![ToolResultContentBlock::Text {
|
content: vec![ToolResultContentBlock::Text {
|
||||||
text: output.clone(),
|
text: output.clone(),
|
||||||
}],
|
}],
|
||||||
is_error: *is_error,
|
is_error: *is_error,
|
||||||
},
|
}),
|
||||||
|
// Thinking blocks are internal reasoning output and not valid as API input.
|
||||||
|
// They must be skipped when converting session history back to API messages.
|
||||||
|
ContentBlock::Thinking { .. } => None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
(!content.is_empty()).then(|| InputMessage {
|
(!content.is_empty()).then(|| InputMessage {
|
||||||
|
|
@ -3965,7 +4070,35 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||||
content,
|
content,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
|
|
||||||
|
// If the total input exceeds the API character budget, trim oldest messages
|
||||||
|
// until we're within budget. This prevents 400 errors from oversized inputs.
|
||||||
|
let total_chars = converted
|
||||||
|
.iter()
|
||||||
|
.map(estimate_input_message_chars)
|
||||||
|
.sum::<usize>();
|
||||||
|
if total_chars <= MAX_API_INPUT_CHARS {
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop messages from the beginning (oldest) until within budget.
|
||||||
|
// Always preserve the last message (the current user turn) if possible.
|
||||||
|
let mut budget = 0usize;
|
||||||
|
let mut keep_from = converted.len();
|
||||||
|
for (index, msg) in converted.iter().rev().enumerate() {
|
||||||
|
budget += estimate_input_message_chars(msg);
|
||||||
|
if budget > MAX_API_INPUT_CHARS {
|
||||||
|
// We went over budget — we can only keep messages starting from
|
||||||
|
// the one that pushed us over (going from the end). Since we're
|
||||||
|
// iterating backwards, `index` is how many from the end we kept
|
||||||
|
// before going over. The rest must be dropped.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
keep_from = converted.len() - index - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
converted[keep_from..].to_vec()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||||
|
|
@ -4443,8 +4576,7 @@ mod tests {
|
||||||
fn shared_help_uses_resume_annotation_copy() {
|
fn shared_help_uses_resume_annotation_copy() {
|
||||||
let help = commands::render_slash_command_help();
|
let help = commands::render_slash_command_help();
|
||||||
assert!(help.contains("Slash commands"));
|
assert!(help.contains("Slash commands"));
|
||||||
assert!(help.contains("Tab completes commands inside the REPL."));
|
assert!(help.contains("also works with --resume SESSION.jsonl"));
|
||||||
assert!(help.contains("available via claw --resume SESSION.json"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -4464,7 +4596,7 @@ mod tests {
|
||||||
assert!(help.contains("/diff"));
|
assert!(help.contains("/diff"));
|
||||||
assert!(help.contains("/version"));
|
assert!(help.contains("/version"));
|
||||||
assert!(help.contains("/export [file]"));
|
assert!(help.contains("/export [file]"));
|
||||||
assert!(help.contains("/session [list|switch <session-id>]"));
|
assert!(help.contains("/session [list|switch <session-id>|fork [branch-name]|delete <session-id> [--force]]"));
|
||||||
assert!(help.contains(
|
assert!(help.contains(
|
||||||
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
||||||
));
|
));
|
||||||
|
|
@ -4498,13 +4630,22 @@ mod tests {
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|spec| spec.name)
|
.map(|spec| spec.name)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(
|
assert!(names.contains(&"help"));
|
||||||
names,
|
assert!(names.contains(&"status"));
|
||||||
vec![
|
assert!(names.contains(&"compact"));
|
||||||
"help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff",
|
assert!(names.contains(&"clear"));
|
||||||
"version", "export", "agents", "skills",
|
assert!(names.contains(&"cost"));
|
||||||
]
|
assert!(names.contains(&"config"));
|
||||||
);
|
assert!(names.contains(&"memory"));
|
||||||
|
assert!(names.contains(&"init"));
|
||||||
|
assert!(names.contains(&"diff"));
|
||||||
|
assert!(names.contains(&"version"));
|
||||||
|
assert!(names.contains(&"export"));
|
||||||
|
assert!(names.contains(&"agents"));
|
||||||
|
assert!(names.contains(&"skills"));
|
||||||
|
assert!(names.contains(&"sandbox"));
|
||||||
|
assert!(names.contains(&"mcp"));
|
||||||
|
assert!(names.len() > 20); // allow future growth
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -4703,11 +4844,11 @@ mod tests {
|
||||||
fn clear_command_requires_explicit_confirmation_flag() {
|
fn clear_command_requires_explicit_confirmation_flag() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SlashCommand::parse("/clear"),
|
SlashCommand::parse("/clear"),
|
||||||
Some(SlashCommand::Clear { confirm: false })
|
Ok(Some(SlashCommand::Clear { confirm: false }))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SlashCommand::parse("/clear --confirm"),
|
SlashCommand::parse("/clear --confirm"),
|
||||||
Some(SlashCommand::Clear { confirm: true })
|
Ok(Some(SlashCommand::Clear { confirm: true }))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4715,26 +4856,29 @@ mod tests {
|
||||||
fn parses_resume_and_config_slash_commands() {
|
fn parses_resume_and_config_slash_commands() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SlashCommand::parse("/resume saved-session.json"),
|
SlashCommand::parse("/resume saved-session.json"),
|
||||||
Some(SlashCommand::Resume {
|
Ok(Some(SlashCommand::Resume {
|
||||||
session_path: Some("saved-session.json".to_string())
|
session_path: Some("saved-session.json".to_string())
|
||||||
})
|
}))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SlashCommand::parse("/clear --confirm"),
|
SlashCommand::parse("/clear --confirm"),
|
||||||
Some(SlashCommand::Clear { confirm: true })
|
Ok(Some(SlashCommand::Clear { confirm: true }))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SlashCommand::parse("/config"),
|
SlashCommand::parse("/config"),
|
||||||
Some(SlashCommand::Config { section: None })
|
Ok(Some(SlashCommand::Config { section: None }))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SlashCommand::parse("/config env"),
|
SlashCommand::parse("/config env"),
|
||||||
Some(SlashCommand::Config {
|
Ok(Some(SlashCommand::Config {
|
||||||
section: Some("env".to_string())
|
section: Some("env".to_string())
|
||||||
})
|
}))
|
||||||
);
|
);
|
||||||
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
assert_eq!(
|
||||||
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
SlashCommand::parse("/memory"),
|
||||||
|
Ok(Some(SlashCommand::Memory))
|
||||||
|
);
|
||||||
|
assert_eq!(SlashCommand::parse("/init"), Ok(Some(SlashCommand::Init)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue