From 992681c4fd291d8c77925132c4ba4f037550d995 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 03:48:50 +0000 Subject: [PATCH] Prevent long sessions from stalling and expose the requested internal command surface The runtime now auto-compacts completed conversations once cumulative input usage crosses a configurable threshold, preserving recent context while surfacing an explicit user notice. The CLI also publishes the requested ant-only slash commands through the shared commands crate and main dispatch, using meaningful local implementations for commit/PR/issue/teleport/debug workflows. Constraint: Reuse the existing Rust compaction pipeline instead of introducing a new summarization stack Constraint: No new dependencies or broad command-framework rewrite Rejected: Implement API-driven compaction inside ConversationRuntime now | too much new plumbing for this delivery Rejected: Expose new commands as parse-only stubs | would not satisfy the requested command availability Confidence: medium Scope-risk: moderate Reversibility: clean Directive: If runtime later gains true API-backed compaction, preserve the TurnSummary auto-compaction metadata shape so CLI call sites stay stable Tested: cargo test; cargo build --release; cargo fmt --all; git diff --check; LSP diagnostics directory check Not-tested: Live Anthropic-backed specialist command flows; gh-authenticated PR/issue creation in a real repo --- rust/crates/commands/src/lib.rs | 152 ++++++++- rust/crates/runtime/src/conversation.rs | 175 +++++++++- rust/crates/runtime/src/lib.rs | 4 +- rust/crates/rusty-claude-cli/src/main.rs | 411 ++++++++++++++++++++++- 4 files changed, 733 insertions(+), 9 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index b396bb0..e7f8d13 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -117,6 +117,48 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: None, resume_supported: true, }, + SlashCommandSpec { + name: "bughunter", + summary: "Inspect the codebase for likely bugs", + argument_hint: Some("[scope]"), + resume_supported: false, + }, + SlashCommandSpec { + name: "commit", + summary: "Generate a commit message and create a git commit", + argument_hint: None, + resume_supported: false, + }, + SlashCommandSpec { + name: "pr", + summary: "Draft or create a pull request from the conversation", + argument_hint: Some("[context]"), + resume_supported: false, + }, + SlashCommandSpec { + name: "issue", + summary: "Draft or create a GitHub issue from the conversation", + argument_hint: Some("[context]"), + resume_supported: false, + }, + SlashCommandSpec { + name: "ultraplan", + summary: "Run a deep planning prompt with multi-step reasoning", + argument_hint: Some("[task]"), + resume_supported: false, + }, + SlashCommandSpec { + name: "teleport", + summary: "Jump to a file or symbol by searching the workspace", + argument_hint: Some(""), + resume_supported: false, + }, + SlashCommandSpec { + name: "debug-tool-call", + summary: "Replay the last tool call with debug details", + argument_hint: None, + resume_supported: false, + }, SlashCommandSpec { name: "export", summary: "Export the current conversation to a file", @@ -136,6 +178,23 @@ pub enum SlashCommand { Help, Status, Compact, + Bughunter { + scope: Option, + }, + Commit, + Pr { + context: Option, + }, + Issue { + context: Option, + }, + Ultraplan { + task: Option, + }, + Teleport { + target: Option, + }, + DebugToolCall, Model { model: Option, }, @@ -180,6 +239,23 @@ impl SlashCommand { "help" => Self::Help, "status" => Self::Status, "compact" => Self::Compact, + "bughunter" => Self::Bughunter { + scope: remainder_after_command(trimmed, command), + }, + "commit" => Self::Commit, + "pr" => Self::Pr { + context: remainder_after_command(trimmed, command), + }, + "issue" => Self::Issue { + context: remainder_after_command(trimmed, command), + }, + "ultraplan" => Self::Ultraplan { + task: remainder_after_command(trimmed, command), + }, + "teleport" => Self::Teleport { + target: remainder_after_command(trimmed, command), + }, + "debug-tool-call" => Self::DebugToolCall, "model" => Self::Model { model: parts.next().map(ToOwned::to_owned), }, @@ -212,6 +288,15 @@ impl SlashCommand { } } +fn remainder_after_command(input: &str, command: &str) -> Option { + input + .trim() + .strip_prefix(&format!("/{command}")) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + #[must_use] pub fn slash_command_specs() -> &'static [SlashCommandSpec] { SLASH_COMMAND_SPECS @@ -279,6 +364,13 @@ pub fn handle_slash_command( session: session.clone(), }), SlashCommand::Status + | SlashCommand::Bughunter { .. } + | SlashCommand::Commit + | SlashCommand::Pr { .. } + | SlashCommand::Issue { .. } + | SlashCommand::Ultraplan { .. } + | SlashCommand::Teleport { .. } + | SlashCommand::DebugToolCall | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Clear { .. } @@ -307,6 +399,41 @@ mod tests { fn parses_supported_slash_commands() { assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status)); + assert_eq!( + SlashCommand::parse("/bughunter runtime"), + Some(SlashCommand::Bughunter { + scope: Some("runtime".to_string()) + }) + ); + assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit)); + assert_eq!( + SlashCommand::parse("/pr ready for review"), + Some(SlashCommand::Pr { + context: Some("ready for review".to_string()) + }) + ); + assert_eq!( + SlashCommand::parse("/issue flaky test"), + Some(SlashCommand::Issue { + context: Some("flaky test".to_string()) + }) + ); + assert_eq!( + SlashCommand::parse("/ultraplan ship both features"), + Some(SlashCommand::Ultraplan { + task: Some("ship both features".to_string()) + }) + ); + assert_eq!( + SlashCommand::parse("/teleport conversation.rs"), + Some(SlashCommand::Teleport { + target: Some("conversation.rs".to_string()) + }) + ); + assert_eq!( + SlashCommand::parse("/debug-tool-call"), + Some(SlashCommand::DebugToolCall) + ); assert_eq!( SlashCommand::parse("/model claude-opus"), Some(SlashCommand::Model { @@ -374,6 +501,13 @@ mod tests { assert!(help.contains("/help")); assert!(help.contains("/status")); assert!(help.contains("/compact")); + assert!(help.contains("/bughunter [scope]")); + assert!(help.contains("/commit")); + assert!(help.contains("/pr [context]")); + assert!(help.contains("/issue [context]")); + assert!(help.contains("/ultraplan [task]")); + assert!(help.contains("/teleport ")); + assert!(help.contains("/debug-tool-call")); assert!(help.contains("/model [model]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear [--confirm]")); @@ -386,7 +520,7 @@ mod tests { assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); - assert_eq!(slash_command_specs().len(), 15); + assert_eq!(slash_command_specs().len(), 22); assert_eq!(resume_supported_slash_commands().len(), 11); } @@ -434,6 +568,22 @@ mod tests { let session = Session::new(); assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none()); + assert!( + handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none() + ); + assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none()); + assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none()); + assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none()); + assert!( + handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none() + ); + assert!( + handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none() + ); + assert!( + handle_slash_command("/debug-tool-call", &session, CompactionConfig::default()) + .is_none() + ); assert!( handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none() ); diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index b979e61..1db2e77 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -8,6 +8,9 @@ use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter use crate::session::{ContentBlock, ConversationMessage, Session}; use crate::usage::{TokenUsage, UsageTracker}; +const DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD: u32 = 100_000; +const AUTO_COMPACTION_THRESHOLD_ENV_VAR: &str = "CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS"; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ApiRequest { pub system_prompt: Vec, @@ -84,6 +87,12 @@ pub struct TurnSummary { pub tool_results: Vec, pub iterations: usize, pub usage: TokenUsage, + pub auto_compaction: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AutoCompactionEvent { + pub removed_message_count: usize, } pub struct ConversationRuntime { @@ -94,6 +103,7 @@ pub struct ConversationRuntime { system_prompt: Vec, max_iterations: usize, usage_tracker: UsageTracker, + auto_compaction_input_tokens_threshold: u32, } impl ConversationRuntime @@ -118,6 +128,7 @@ where system_prompt, max_iterations: usize::MAX, usage_tracker, + auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(), } } @@ -127,6 +138,12 @@ where self } + #[must_use] + pub fn with_auto_compaction_input_tokens_threshold(mut self, threshold: u32) -> Self { + self.auto_compaction_input_tokens_threshold = threshold; + self + } + pub fn run_turn( &mut self, user_input: impl Into, @@ -209,11 +226,14 @@ where } } + let auto_compaction = self.maybe_auto_compact(); + Ok(TurnSummary { assistant_messages, tool_results, iterations, usage: self.usage_tracker.cumulative_usage(), + auto_compaction, }) } @@ -241,6 +261,48 @@ where pub fn into_session(self) -> Session { self.session } + + fn maybe_auto_compact(&mut self) -> Option { + if self.usage_tracker.cumulative_usage().input_tokens + < self.auto_compaction_input_tokens_threshold + { + return None; + } + + let result = compact_session( + &self.session, + CompactionConfig { + max_estimated_tokens: 0, + ..CompactionConfig::default() + }, + ); + + if result.removed_message_count == 0 { + return None; + } + + self.session = result.compacted_session; + Some(AutoCompactionEvent { + removed_message_count: result.removed_message_count, + }) + } +} + +#[must_use] +pub fn auto_compaction_threshold_from_env() -> u32 { + parse_auto_compaction_threshold( + std::env::var(AUTO_COMPACTION_THRESHOLD_ENV_VAR) + .ok() + .as_deref(), + ) +} + +#[must_use] +fn parse_auto_compaction_threshold(value: Option<&str>) -> u32 { + value + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|threshold| *threshold > 0) + .unwrap_or(DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD) } fn build_assistant_message( @@ -325,8 +387,9 @@ impl ToolExecutor for StaticToolExecutor { #[cfg(test)] mod tests { use super::{ - ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, - StaticToolExecutor, + parse_auto_compaction_threshold, ApiClient, ApiRequest, AssistantEvent, + AutoCompactionEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, + DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD, }; use crate::compact::CompactionConfig; use crate::permissions::{ @@ -436,6 +499,7 @@ mod tests { assert_eq!(summary.tool_results.len(), 1); assert_eq!(runtime.session().messages.len(), 4); assert_eq!(summary.usage.output_tokens, 10); + assert_eq!(summary.auto_compaction, None); assert!(matches!( runtime.session().messages[1].blocks[1], ContentBlock::ToolUse { .. } @@ -581,4 +645,111 @@ mod tests { MessageRole::System ); } + + #[test] + fn auto_compacts_when_cumulative_input_threshold_is_crossed() { + struct SimpleApi; + impl ApiClient for SimpleApi { + fn stream( + &mut self, + _request: ApiRequest, + ) -> Result, RuntimeError> { + Ok(vec![ + AssistantEvent::TextDelta("done".to_string()), + AssistantEvent::Usage(TokenUsage { + input_tokens: 120_000, + output_tokens: 4, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }), + AssistantEvent::MessageStop, + ]) + } + } + + let session = Session { + version: 1, + messages: vec![ + crate::session::ConversationMessage::user_text("one"), + crate::session::ConversationMessage::assistant(vec![ContentBlock::Text { + text: "two".to_string(), + }]), + crate::session::ConversationMessage::user_text("three"), + crate::session::ConversationMessage::assistant(vec![ContentBlock::Text { + text: "four".to_string(), + }]), + ], + }; + + let mut runtime = ConversationRuntime::new( + session, + SimpleApi, + StaticToolExecutor::new(), + PermissionPolicy::new(PermissionMode::DangerFullAccess), + vec!["system".to_string()], + ) + .with_auto_compaction_input_tokens_threshold(100_000); + + let summary = runtime + .run_turn("trigger", None) + .expect("turn should succeed"); + + assert_eq!( + summary.auto_compaction, + Some(AutoCompactionEvent { + removed_message_count: 2, + }) + ); + assert_eq!(runtime.session().messages[0].role, MessageRole::System); + } + + #[test] + fn skips_auto_compaction_below_threshold() { + struct SimpleApi; + impl ApiClient for SimpleApi { + fn stream( + &mut self, + _request: ApiRequest, + ) -> Result, RuntimeError> { + Ok(vec![ + AssistantEvent::TextDelta("done".to_string()), + AssistantEvent::Usage(TokenUsage { + input_tokens: 99_999, + output_tokens: 4, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }), + AssistantEvent::MessageStop, + ]) + } + } + + let mut runtime = ConversationRuntime::new( + Session::new(), + SimpleApi, + StaticToolExecutor::new(), + PermissionPolicy::new(PermissionMode::DangerFullAccess), + vec!["system".to_string()], + ) + .with_auto_compaction_input_tokens_threshold(100_000); + + let summary = runtime + .run_turn("trigger", None) + .expect("turn should succeed"); + assert_eq!(summary.auto_compaction, None); + assert_eq!(runtime.session().messages.len(), 2); + } + + #[test] + fn auto_compaction_threshold_defaults_and_parses_values() { + assert_eq!( + parse_auto_compaction_threshold(None), + DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD + ); + assert_eq!(parse_auto_compaction_threshold(Some("4321")), 4321); + assert_eq!( + parse_auto_compaction_threshold(Some("not-a-number")), + DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD + ); + } } diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 2861d47..8193d57 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -30,8 +30,8 @@ pub use config::{ CLAUDE_CODE_SETTINGS_SCHEMA_NAME, }; pub use conversation::{ - ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, - ToolError, ToolExecutor, TurnSummary, + auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent, + ConversationRuntime, RuntimeError, StaticToolExecutor, ToolError, ToolExecutor, TurnSummary, }; pub use file_ops::{ edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 6a1fdc1..6c65202 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -27,8 +27,8 @@ use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, - ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, - OAuthConfig, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, + ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig, + OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde_json::json; @@ -775,6 +775,10 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo } } +fn format_auto_compaction_notice(removed: usize) -> String { + format!("[auto-compacted: removed {removed} messages]") +} + fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option) { let Some(status) = status else { return (None, None); @@ -913,7 +917,14 @@ fn run_resume_command( )), }) } - SlashCommand::Resume { .. } + SlashCommand::Bughunter { .. } + | SlashCommand::Commit + | SlashCommand::Pr { .. } + | SlashCommand::Issue { .. } + | SlashCommand::Ultraplan { .. } + | SlashCommand::Teleport { .. } + | SlashCommand::DebugToolCall + | SlashCommand::Resume { .. } | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } @@ -1050,13 +1061,19 @@ impl LiveCli { let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let result = self.runtime.run_turn(input, Some(&mut permission_prompter)); match result { - Ok(_) => { + Ok(summary) => { 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(()) } @@ -1103,6 +1120,10 @@ impl LiveCli { "message": final_assistant_text(&summary), "model": self.model, "iterations": summary.iterations, + "auto_compaction": summary.auto_compaction.map(|event| 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), "usage": { @@ -1129,6 +1150,34 @@ impl LiveCli { self.print_status(); false } + SlashCommand::Bughunter { scope } => { + self.run_bughunter(scope.as_deref())?; + false + } + SlashCommand::Commit => { + self.run_commit()?; + true + } + SlashCommand::Pr { context } => { + self.run_pr(context.as_deref())?; + false + } + SlashCommand::Issue { context } => { + self.run_issue(context.as_deref())?; + false + } + SlashCommand::Ultraplan { task } => { + self.run_ultraplan(task.as_deref())?; + false + } + SlashCommand::Teleport { target } => { + self.run_teleport(target.as_deref())?; + false + } + SlashCommand::DebugToolCall => { + self.run_debug_tool_call()?; + false + } SlashCommand::Compact => { self.compact()?; false @@ -1448,6 +1497,160 @@ impl LiveCli { println!("{}", format_compact_report(removed, kept, skipped)); Ok(()) } + + fn run_internal_prompt_text( + &self, + prompt: &str, + enable_tools: bool, + ) -> Result> { + let session = self.runtime.session().clone(); + let mut runtime = build_runtime( + session, + self.model.clone(), + self.system_prompt.clone(), + enable_tools, + false, + self.allowed_tools.clone(), + self.permission_mode, + )?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; + Ok(final_assistant_text(&summary).trim().to_string()) + } + + fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box> { + let scope = scope.unwrap_or("the current repository"); + let prompt = format!( + "You are /bughunter. Inspect {scope} and identify the most likely bugs or correctness issues. Prioritize concrete findings with file paths, severity, and suggested fixes. Use tools if needed." + ); + println!("{}", self.run_internal_prompt_text(&prompt, true)?); + Ok(()) + } + + fn run_ultraplan(&self, task: Option<&str>) -> Result<(), Box> { + let task = task.unwrap_or("the current repo work"); + let prompt = format!( + "You are /ultraplan. Produce a deep multi-step execution plan for {task}. Include goals, risks, implementation sequence, verification steps, and rollback considerations. Use tools if needed." + ); + println!("{}", self.run_internal_prompt_text(&prompt, true)?); + Ok(()) + } + + fn run_teleport(&self, target: Option<&str>) -> Result<(), Box> { + let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else { + println!("Usage: /teleport "); + return Ok(()); + }; + + println!("{}", render_teleport_report(target)?); + Ok(()) + } + + fn run_debug_tool_call(&self) -> Result<(), Box> { + println!("{}", render_last_tool_debug_report(self.runtime.session())?); + Ok(()) + } + + fn run_commit(&mut self) -> Result<(), Box> { + let status = git_output(&["status", "--short"])?; + if status.trim().is_empty() { + println!("Commit\n Result skipped\n Reason no workspace changes"); + return Ok(()); + } + + git_status_ok(&["add", "-A"])?; + let staged_stat = git_output(&["diff", "--cached", "--stat"])?; + let prompt = format!( + "Generate a git commit message in plain text Lore format only. Base it on this staged diff summary:\n\n{}\n\nRecent conversation context:\n{}", + truncate_for_prompt(&staged_stat, 8_000), + recent_user_context(self.runtime.session(), 6) + ); + let message = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); + if message.trim().is_empty() { + return Err("generated commit message was empty".into()); + } + + let path = write_temp_text_file("claw-commit-message.txt", &message)?; + let output = Command::new("git") + .args(["commit", "--file"]) + .arg(&path) + .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 commit failed: {stderr}").into()); + } + + println!( + "Commit\n Result created\n Message file {}\n\n{}", + path.display(), + message.trim() + ); + Ok(()) + } + + fn run_pr(&self, context: Option<&str>) -> Result<(), Box> { + let staged = git_output(&["diff", "--stat"])?; + let prompt = format!( + "Generate a pull request title and body from this conversation and diff summary. Output plain text in this format exactly:\nTITLE: \nBODY:\n<body markdown>\n\nContext hint: {}\n\nDiff summary:\n{}", + context.unwrap_or("none"), + truncate_for_prompt(&staged, 10_000) + ); + let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); + let (title, body) = parse_titled_body(&draft) + .ok_or_else(|| "failed to parse generated PR title/body".to_string())?; + + if command_exists("gh") { + let body_path = write_temp_text_file("claw-pr-body.md", &body)?; + let output = Command::new("gh") + .args(["pr", "create", "--title", &title, "--body-file"]) + .arg(&body_path) + .current_dir(env::current_dir()?) + .output()?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + println!( + "PR\n Result created\n Title {title}\n URL {}", + if stdout.is_empty() { "<unknown>" } else { &stdout } + ); + return Ok(()); + } + } + + println!("PR draft\n Title {title}\n\n{body}"); + Ok(()) + } + + fn run_issue(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { + let prompt = format!( + "Generate a GitHub issue title and body from this conversation. Output plain text in this format exactly:\nTITLE: <title>\nBODY:\n<body markdown>\n\nContext hint: {}\n\nConversation context:\n{}", + context.unwrap_or("none"), + truncate_for_prompt(&recent_user_context(self.runtime.session(), 10), 10_000) + ); + let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); + let (title, body) = parse_titled_body(&draft) + .ok_or_else(|| "failed to parse generated issue title/body".to_string())?; + + if command_exists("gh") { + let body_path = write_temp_text_file("claw-issue-body.md", &body)?; + let output = Command::new("gh") + .args(["issue", "create", "--title", &title, "--body-file"]) + .arg(&body_path) + .current_dir(env::current_dir()?) + .output()?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + println!( + "Issue\n Result created\n Title {title}\n URL {}", + if stdout.is_empty() { "<unknown>" } else { &stdout } + ); + return Ok(()); + } + } + + println!("Issue draft\n Title {title}\n\n{body}"); + Ok(()) + } } fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> { @@ -1799,6 +2002,206 @@ fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> { Ok(format!("Diff\n\n{}", diff.trim_end())) } +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![format!("Teleport\n Target {target}")]; + 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")) +} + +fn render_last_tool_debug_report(session: &Session) -> Result<String, Box<dyn std::error::Error>> { + 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(), + 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")) +} + +fn indent_block(value: &str, spaces: usize) -> String { + let indent = " ".repeat(spaces); + value + .lines() + .map(|line| format!("{indent}{line}")) + .collect::<Vec<_>>() + .join("\n") +} + +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)?) +} + +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(()) +} + +fn command_exists(name: &str) -> bool { + Command::new("which") + .arg(name) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +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) +} + +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") + } +} + +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()) + } +} + +fn sanitize_generated_message(value: &str) -> String { + value.trim().trim_matches('`').trim().replace("\r\n", "\n") +} + +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())) +} + fn render_version_report() -> String { let git_sha = GIT_SHA.unwrap_or("unknown"); let target = BUILD_TARGET.unwrap_or("unknown");