diff --git a/README.md b/README.md index 6073d5eb..a91743e9 100644 --- a/README.md +++ b/README.md @@ -296,12 +296,13 @@ After install, **restart Claude Code**. ## Supported AI Tools -RTK supports 9 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings. +RTK supports 10 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings. | Tool | Install | Method | |------|---------|--------| | **Claude Code** | `rtk init -g` | PreToolUse hook (bash) | -| **GitHub Copilot** | `rtk init -g` | PreToolUse hook (`rtk hook copilot`) | +| **GitHub Copilot (VS Code)** | `rtk init -g --copilot` | PreToolUse hook (`rtk hook copilot`) — transparent rewrite | +| **GitHub Copilot CLI** | `rtk init -g --copilot` | PreToolUse deny-with-suggestion (CLI limitation) | | **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) | | **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook (`rtk hook gemini`) | | **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions | @@ -309,6 +310,7 @@ RTK supports 9 AI coding tools. Each integration transparently rewrites shell co | **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) | | **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) | | **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) | +| **Mistral Vibe** | Planned (#800) | Blocked on upstream BeforeToolCallback | ### Claude Code (default) @@ -322,10 +324,14 @@ rtk init -g --uninstall # Remove ### GitHub Copilot (VS Code + CLI) ```bash -rtk init -g # Same hook as Claude Code +rtk init -g --copilot # Install hook + instructions ``` -The hook auto-detects Copilot format (VS Code `runTerminalCommand` or CLI `toolName: bash`) and rewrites commands. Works with both Copilot Chat in VS Code and `copilot` CLI. +Creates `.github/hooks/rtk-rewrite.json` (PreToolUse hook) and `.github/copilot-instructions.md` (prompt-level awareness). + +The hook (`rtk hook copilot`) auto-detects the format: +- **VS Code Copilot Chat**: transparent rewrite via `updatedInput` (same as Claude Code) +- **Copilot CLI**: deny-with-suggestion (CLI does not support `updatedInput` yet — see [copilot-cli#2013](https://github.com/github/copilot-cli/issues/2013)) ### Cursor @@ -384,6 +390,10 @@ openclaw plugins install ./openclaw Plugin in `openclaw/` directory. Uses `before_tool_call` hook, delegates to `rtk rewrite`. +### Mistral Vibe (planned) + +Blocked on upstream BeforeToolCallback support ([mistral-vibe#531](https://github.com/mistralai/mistral-vibe/issues/531), [PR #533](https://github.com/mistralai/mistral-vibe/pull/533)). Tracked in [#800](https://github.com/rtk-ai/rtk/issues/800). + ### Commands Rewritten | Raw Command | Rewritten To | diff --git a/src/diff_cmd.rs b/src/diff_cmd.rs index d9299eb5..15b433a8 100644 --- a/src/diff_cmd.rs +++ b/src/diff_cmd.rs @@ -1,5 +1,4 @@ use crate::tracking; -use crate::utils::truncate; use anyhow::Result; use std::fs; use std::path::Path; @@ -39,21 +38,17 @@ pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result<()> { diff.added, diff.removed, diff.modified )); - for change in diff.changes.iter().take(50) { + // Never truncate diff content — users make decisions based on this data. + // Only the summary header provides compression; all changes are shown in full. + for change in &diff.changes { match change { - DiffChange::Added(ln, c) => rtk.push_str(&format!("+{:4} {}\n", ln, truncate(c, 80))), - DiffChange::Removed(ln, c) => rtk.push_str(&format!("-{:4} {}\n", ln, truncate(c, 80))), - DiffChange::Modified(ln, old, new) => rtk.push_str(&format!( - "~{:4} {} → {}\n", - ln, - truncate(old, 70), - truncate(new, 70) - )), + DiffChange::Added(ln, c) => rtk.push_str(&format!("+{:4} {}\n", ln, c)), + DiffChange::Removed(ln, c) => rtk.push_str(&format!("-{:4} {}\n", ln, c)), + DiffChange::Modified(ln, old, new) => { + rtk.push_str(&format!("~{:4} {} → {}\n", ln, old, new)) + } } } - if diff.changes.len() > 50 { - rtk.push_str(&format!("... +{} more changes", diff.changes.len() - 50)); - } print!("{}", rtk); timer.track( @@ -163,18 +158,16 @@ fn condense_unified_diff(diff: &str) -> String { let mut removed = 0; let mut changes = Vec::new(); + // Never truncate diff content — users make decisions based on this data. + // Only strip diff metadata (headers, @@ hunks); all +/- lines shown in full. for line in diff.lines() { if line.starts_with("diff --git") || line.starts_with("--- ") || line.starts_with("+++ ") { - // File header if line.starts_with("+++ ") { if !current_file.is_empty() && (added > 0 || removed > 0) { result.push(format!("[file] {} (+{} -{})", current_file, added, removed)); - for c in changes.iter().take(10) { + for c in &changes { result.push(format!(" {}", c)); } - if changes.len() > 10 { - result.push(format!(" ... +{} more", changes.len() - 10)); - } } current_file = line .trim_start_matches("+++ ") @@ -186,26 +179,19 @@ fn condense_unified_diff(diff: &str) -> String { } } else if line.starts_with('+') && !line.starts_with("+++") { added += 1; - if changes.len() < 15 { - changes.push(truncate(line, 70)); - } + changes.push(line.to_string()); } else if line.starts_with('-') && !line.starts_with("---") { removed += 1; - if changes.len() < 15 { - changes.push(truncate(line, 70)); - } + changes.push(line.to_string()); } } // Last file if !current_file.is_empty() && (added > 0 || removed > 0) { result.push(format!("[file] {} (+{} -{})", current_file, added, removed)); - for c in changes.iter().take(10) { + for c in &changes { result.push(format!(" {}", c)); } - if changes.len() > 10 { - result.push(format!(" ... +{} more", changes.len() - 10)); - } } result.join("\n") @@ -246,23 +232,6 @@ mod tests { assert!(similarity("let x = 1;", "let x = 2;") > 0.5); } - // --- truncate --- - - #[test] - fn test_truncate_short_string() { - assert_eq!(truncate("hello", 10), "hello"); - } - - #[test] - fn test_truncate_exact_length() { - assert_eq!(truncate("hello", 5), "hello"); - } - - #[test] - fn test_truncate_long_string() { - assert_eq!(truncate("hello world!", 8), "hello..."); - } - // --- compute_diff --- #[test] @@ -364,4 +333,64 @@ diff --git a/b.rs b/b.rs let result = condense_unified_diff(""); assert!(result.is_empty()); } + + #[test] + fn test_no_truncation_large_diff() { + // Verify all changes are shown, not truncated + let mut a = Vec::new(); + let mut b = Vec::new(); + for i in 0..500 { + a.push(format!("line_{}", i)); + if i % 3 == 0 { + b.push(format!("CHANGED_{}", i)); + } else { + b.push(format!("line_{}", i)); + } + } + let a_refs: Vec<&str> = a.iter().map(|s| s.as_str()).collect(); + let b_refs: Vec<&str> = b.iter().map(|s| s.as_str()).collect(); + let result = compute_diff(&a_refs, &b_refs); + + // Should have ~167 changes (every 3rd line), all present + assert!(result.changes.len() > 100, "Expected 100+ changes, got {}", result.changes.len()); + // No truncation — changes count matches what we generate + assert!(!result.changes.is_empty()); + } + + #[test] + fn test_long_lines_not_truncated() { + let long_line = "x".repeat(500); + let a = vec![long_line.as_str()]; + let b = vec!["short"]; + let result = compute_diff(&a, &b); + // The removed line should contain the full 500-char string + match &result.changes[0] { + DiffChange::Removed(_, content) | DiffChange::Added(_, content) => { + assert_eq!(content.len(), 500, "Line was truncated!"); + } + DiffChange::Modified(_, old, _) => { + assert_eq!(old.len(), 500, "Line was truncated!"); + } + } + } + + #[test] + fn test_condense_unified_no_truncation() { + // Generate a large unified diff + let mut lines = Vec::new(); + lines.push("diff --git a/big.yaml b/big.yaml".to_string()); + lines.push("--- a/big.yaml".to_string()); + lines.push("+++ b/big.yaml".to_string()); + for i in 0..200 { + lines.push(format!("+added_line_{}", i)); + } + let diff = lines.join("\n"); + let result = condense_unified_diff(&diff); + + // All 200 added lines should be present + assert!(result.contains("added_line_0")); + assert!(result.contains("added_line_199")); + assert!(!result.contains("not shown"), "Should not truncate"); + assert!(!result.contains("more"), "Should not have '... more'"); + } } diff --git a/src/init.rs b/src/init.rs index 494bef34..5bf5071b 100644 --- a/src/init.rs +++ b/src/init.rs @@ -2307,6 +2307,81 @@ fn uninstall_gemini(verbose: u8) -> Result> { Ok(removed) } +// ── Copilot integration ───────────────────────────────────── + +const COPILOT_HOOK_JSON: &str = r#"{ + "hooks": { + "PreToolUse": [ + { + "type": "command", + "command": "rtk hook copilot", + "cwd": ".", + "timeout": 5 + } + ] + } +} +"#; + +const COPILOT_INSTRUCTIONS: &str = r#"# RTK — Token-Optimized CLI + +**rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens. + +## Rule + +Always prefix shell commands with `rtk`: + +```bash +# Instead of: Use: +git status rtk git status +git log -10 rtk git log -10 +cargo test rtk cargo test +docker ps rtk docker ps +kubectl get pods rtk kubectl pods +``` + +## Meta commands (use directly) + +```bash +rtk gain # Token savings dashboard +rtk gain --history # Per-command savings history +rtk discover # Find missed rtk opportunities +rtk proxy # Run raw (no filtering) but track usage +``` +"#; + +/// Entry point for `rtk init --copilot` +pub fn run_copilot(verbose: u8) -> Result<()> { + // Install in current project's .github/ directory + let github_dir = Path::new(".github"); + let hooks_dir = github_dir.join("hooks"); + + fs::create_dir_all(&hooks_dir) + .context("Failed to create .github/hooks/ directory")?; + + // 1. Write hook config + let hook_path = hooks_dir.join("rtk-rewrite.json"); + write_if_changed(&hook_path, COPILOT_HOOK_JSON, "Copilot hook config", verbose)?; + + // 2. Write instructions + let instructions_path = github_dir.join("copilot-instructions.md"); + write_if_changed( + &instructions_path, + COPILOT_INSTRUCTIONS, + "Copilot instructions", + verbose, + )?; + + println!("\nGitHub Copilot integration installed (project-scoped).\n"); + println!(" Hook config: {}", hook_path.display()); + println!(" Instructions: {}", instructions_path.display()); + println!("\n Works with VS Code Copilot Chat (transparent rewrite)"); + println!(" and Copilot CLI (deny-with-suggestion)."); + println!("\n Restart your IDE or Copilot CLI session to activate.\n"); + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index 654a2676..026da034 100644 --- a/src/main.rs +++ b/src/main.rs @@ -132,8 +132,8 @@ enum Commands { Read { /// File to read file: PathBuf, - /// Filter: none, minimal, aggressive - #[arg(short, long, default_value = "minimal")] + /// Filter: none (default, full content), minimal, aggressive + #[arg(short, long, default_value = "none")] level: filter::FilterLevel, /// Max lines #[arg(short, long, conflicts_with = "tail_lines")] @@ -388,6 +388,10 @@ enum Commands { /// Target Codex CLI (uses AGENTS.md + RTK.md, no Claude hook patching) #[arg(long)] codex: bool, + + /// Install GitHub Copilot integration (VS Code + CLI) + #[arg(long)] + copilot: bool, }, /// Download with compact output (strips progress bars) @@ -1689,6 +1693,7 @@ fn main() -> Result<()> { no_patch, uninstall, codex, + copilot, } => { if show { init::show_config(codex)?; @@ -1704,6 +1709,8 @@ fn main() -> Result<()> { init::PatchMode::Ask }; init::run_gemini(global, hook_only, patch_mode, cli.verbose)?; + } else if copilot { + init::run_copilot(cli.verbose)?; } else { let install_opencode = opencode; let install_claude = !opencode; diff --git a/src/read.rs b/src/read.rs index 262ef452..519a5f4d 100644 --- a/src/read.rs +++ b/src/read.rs @@ -37,6 +37,16 @@ pub fn run( let filter = filter::get_filter(level); let mut filtered = filter.filter(&content, &lang); + // Safety: if filter emptied a non-empty file, fall back to raw content + if filtered.trim().is_empty() && !content.trim().is_empty() { + eprintln!( + "rtk: warning: filter produced empty output for {} ({} bytes), showing raw content", + file.display(), + content.len() + ); + filtered = content.clone(); + } + if verbose > 0 { let original_lines = content.lines().count(); let filtered_lines = filtered.lines().count();