diff --git a/docs/superpowers/plans/2026-07-15-diff-mention-clipboard.md b/docs/superpowers/plans/2026-07-15-diff-mention-clipboard.md new file mode 100644 index 0000000..ae24896 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-diff-mention-clipboard.md @@ -0,0 +1,2342 @@ +# Diff View, @File-Mention & Clipboard Copy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `edit`/`write` real unified diffs (with colored rendering in chat), add fuzzy `@file` mention autocomplete to the chat input, and add an OSC52 clipboard-copy hotkey for the last assistant message — three independent, small-to-medium features bundled into one plan per user request. + +**Architecture:** Diff view wraps `similar`'s unified-diff output in a ` ```diff ` fenced block returned by `edit`/`write`, rendered through an extended `markdown.rs` that keeps diff-line colors even in the "dim" tool-output mode. File mention reuses `ignore::Walk` (already used by `search.rs`) to build a background-populated file index, fuzzy-matched via `nucleo-matcher`, spliced into the input buffer at the `@` trigger. Clipboard copy sets a `pending_clipboard_copy` state field in `handle_key` (which may run daemon-side) and the actual OSC52 terminal write happens wherever the real terminal lives (single-process loop, or the attach client after a new `DaemonFrame::ClipboardCopy`). + +**Tech Stack:** Rust, ratatui/crossterm TUI, `similar` (new), `nucleo-matcher` (new), `ignore` (existing), `base64` (existing). + +## Global Constraints + +- New dependencies: `similar = "3"`, `nucleo-matcher = "0.3"`. No other new dependencies (clipboard uses the existing `base64` crate). +- `MAX_DIFF_LINES = 200` — diffs longer than this are truncated with `"... ({N} more lines truncated)"`. +- Mention index cap: 50,000 entries across all workspace roots. +- Every `pub fn`/`pub struct`/`pub enum` needs a doc comment (CLAUDE.md convention); non-trivial private functions (≥10 lines) too. +- Tests are inline `#[cfg(test)] mod tests` blocks in the same file, per CLAUDE.md — no separate `tests/` directory. +- No compiler/clippy bypass attributes (`#[allow(dead_code)]` etc.) to silence warnings — fix the underlying issue instead. +- Specs: `docs/superpowers/specs/2026-07-15-diff-view-design.md`, `docs/superpowers/specs/2026-07-15-file-mention-design.md`, `docs/superpowers/specs/2026-07-15-clipboard-osc52-design.md`. + +--- + +## Part A — Diff view for `edit`/`write` + +### Task 1: Add `similar` dependency + +**Files:** +- Modify: `Cargo.toml` + +**Interfaces:** +- Produces: `similar::TextDiff`, `similar::udiff::UnifiedDiff` available to `src/tool/fs/*.rs`. + +- [ ] **Step 1: Add the dependency** + +In `Cargo.toml`, in the `[dependencies]` block, add this line right after `pulldown-cmark = { version = "0.13", default-features = false }`: + +```toml +similar = "3" +``` + +- [ ] **Step 2: Verify it builds** + +Run: `cargo check` +Expected: compiles with no errors (a new `similar` entry appears in `Cargo.lock`). + +- [ ] **Step 3: Commit** + +```bash +git add Cargo.toml Cargo.lock +git commit -m "chore: Tambah dependency similar untuk diff computation" +``` + +--- + +### Task 2: Shared diff-truncation helper + +**Files:** +- Modify: `src/tool/fs/helpers.rs` + +**Interfaces:** +- Produces: `pub const MAX_DIFF_LINES: usize`, `pub fn truncate_diff(diff: &str) -> String`. + +- [ ] **Step 1: Write the failing tests** + +Add to the existing `#[cfg(test)] mod tests` block in `src/tool/fs/helpers.rs` (after the existing `test_arg_str_null` test, before the closing `}`): + +```rust + #[test] + fn test_truncate_diff_under_limit_unchanged() { + let diff = "line1\nline2\nline3"; + assert_eq!(truncate_diff(diff), diff); + } + + #[test] + fn test_truncate_diff_over_limit_truncates() { + let diff = (0..250).map(|i| format!("line{i}")).collect::>().join("\n"); + let result = truncate_diff(&diff); + assert!(result.contains("... (50 more lines truncated)")); + assert_eq!(result.lines().count(), MAX_DIFF_LINES + 1); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib tool::fs::helpers::tests -- truncate_diff` +Expected: FAIL with "cannot find function `truncate_diff`" / "cannot find value `MAX_DIFF_LINES`" + +- [ ] **Step 3: Implement `truncate_diff`** + +Add this above the `#[cfg(test)]` line in `src/tool/fs/helpers.rs` (after the existing `not_found_help` function): + +```rust +/// Maximum number of lines a diff block may contain before being truncated. +pub const MAX_DIFF_LINES: usize = 200; + +/// Cap a unified diff at `MAX_DIFF_LINES` lines, appending a truncation note. +/// +/// Return: `diff` unchanged if it's within the limit; otherwise the first +/// `MAX_DIFF_LINES` lines followed by `"... ({N} more lines truncated)"`. +pub fn truncate_diff(diff: &str) -> String { + let lines: Vec<&str> = diff.lines().collect(); + if lines.len() <= MAX_DIFF_LINES { + return diff.to_string(); + } + let remaining = lines.len() - MAX_DIFF_LINES; + format!("{}\n... ({remaining} more lines truncated)", lines[..MAX_DIFF_LINES].join("\n")) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --lib tool::fs::helpers::tests` +Expected: PASS (all tests in the module, old and new) + +- [ ] **Step 5: Commit** + +```bash +git add src/tool/fs/helpers.rs +git commit -m "feat: Tambah helper truncate_diff untuk membatasi panjang diff" +``` + +--- + +### Task 3: Embed diff in `edit` tool + +**Files:** +- Modify: `src/tool/fs/edit.rs` + +**Interfaces:** +- Consumes: `helpers::truncate_diff` (Task 2), `similar::TextDiff::from_lines`, `.unified_diff().context_radius(3).header(a, b)` (Display). +- Produces: `edit`'s success message now contains a ` ```diff ` fenced block instead of a byte-delta note. + +- [ ] **Step 1: Write the failing tests** + +Add at the end of `src/tool/fs/edit.rs` (there is no existing test module in this file): + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn test_ctx(workspace: std::path::PathBuf) -> crate::tool::ToolCtx { + crate::tool::ToolCtx::builder().workspaces(vec![workspace]).build() + } + + fn temp_workspace() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("zesdex-edit-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn edit_returns_a_diff_block_for_a_single_replace() { + let workspace = temp_workspace(); + fs::write(workspace.join("a.txt"), "line1\nline2\nline3\n").unwrap(); + let ctx = test_ctx(workspace.clone()); + let args = json!({ + "path": "a.txt", + "old": "line2", + "new": "changed", + "reason": "test edit" + }); + let result = Edit.run(&ctx, &args).unwrap(); + assert!(result.contains("```diff")); + assert!(result.contains("-line2")); + assert!(result.contains("+changed")); + fs::remove_dir_all(&workspace).ok(); + } + + #[test] + fn edit_truncates_a_very_large_diff() { + let workspace = temp_workspace(); + let old_content: String = (0..300).map(|i| format!("line{i}\n")).collect(); + let new_content: String = (0..300).map(|i| format!("changed{i}\n")).collect(); + fs::write(workspace.join("big.txt"), &old_content).unwrap(); + let ctx = test_ctx(workspace.clone()); + let args = json!({ + "path": "big.txt", + "old": &old_content, + "new": &new_content, + "reason": "test large replace" + }); + let result = Edit.run(&ctx, &args).unwrap(); + assert!(result.contains("more lines truncated")); + fs::remove_dir_all(&workspace).ok(); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib tool::fs::edit::tests` +Expected: FAIL — `edit_returns_a_diff_block_for_a_single_replace` fails because the current message contains "byte delta" not "```diff"; `edit_truncates_a_very_large_diff` fails the same way. + +- [ ] **Step 3: Add the `similar` import and `helpers` module import** + +In `src/tool/fs/edit.rs`, change line 12 from: + +```rust +use super::helpers::arg_str; +``` + +to: + +```rust +use super::helpers::{self, arg_str}; +use similar::TextDiff; +``` + +- [ ] **Step 4: Replace the byte-delta message with a diff block** + +Replace this block (currently lines 100-121): + +```rust + fs::write(&path, &new_content) + .map_err(|e| anyhow!("failed to write '{rel}': {e}"))?; + let bytes_diff = if new_content.len() > content.len() { + new_content.len() - content.len() + } else { + content.len() - new_content.len() + }; + // Notify the LSP server of the on-disk change so diagnostics stay fresh. + // Never fail the edit because of this — LSP errors are surfaced as a + // trailing annotation on the success message instead. + let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() { + lsp.did_change_file(&path); + String::new() + } else { + String::new() + }; + if check_matches.is_empty() { + Ok(format!("edited {} ({} byte delta){}", rel, bytes_diff as isize, lsp_note)) + } else { + Ok(format!("edited {} ({} byte delta). Graduated checks matched: {}{}", rel, bytes_diff as isize, check_matches.join(", "), lsp_note)) + } + } +} +``` + +with: + +```rust + fs::write(&path, &new_content) + .map_err(|e| anyhow!("failed to write '{rel}': {e}"))?; + let text_diff = TextDiff::from_lines(content.as_str(), new_content.as_str()); + let diff_text = format!( + "{}", + text_diff.unified_diff().context_radius(3).header(&rel, &rel) + ); + let diff_block = format!("```diff\n{}\n```", helpers::truncate_diff(&diff_text)); + // Notify the LSP server of the on-disk change so diagnostics stay fresh. + // Never fail the edit because of this — LSP errors are surfaced as a + // trailing annotation on the success message instead. + let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() { + lsp.did_change_file(&path); + String::new() + } else { + String::new() + }; + if check_matches.is_empty() { + Ok(format!("edited {rel}\n{diff_block}{lsp_note}")) + } else { + Ok(format!("edited {rel}. Graduated checks matched: {}\n{diff_block}{lsp_note}", check_matches.join(", "))) + } + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test --lib tool::fs::edit::tests` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/tool/fs/edit.rs +git commit -m "feat: Tampilkan unified diff pada hasil tool edit" +``` + +--- + +### Task 4: Embed diff in `write` tool (overwrite case) + +**Files:** +- Modify: `src/tool/fs/write.rs` + +**Interfaces:** +- Consumes: `helpers::truncate_diff` (Task 2), `similar::TextDiff` (same API as Task 3). +- Produces: `write`'s success message gets an appended ` ```diff ` block when overwriting an existing UTF-8 file; unchanged for new files or non-UTF-8 overwrites. + +- [ ] **Step 1: Write the failing tests** + +Add at the end of `src/tool/fs/write.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn test_ctx(workspace: std::path::PathBuf) -> crate::tool::ToolCtx { + crate::tool::ToolCtx::builder().workspaces(vec![workspace]).build() + } + + fn temp_workspace() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("zesdex-write-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn write_to_a_new_file_has_no_diff_block() { + let workspace = temp_workspace(); + let ctx = test_ctx(workspace.clone()); + let args = json!({"path": "new.txt", "content": "hello\n", "reason": "test new file"}); + let result = Write.run(&ctx, &args).unwrap(); + assert!(result.contains("wrote 6 bytes")); + assert!(!result.contains("```diff")); + fs::remove_dir_all(&workspace).ok(); + } + + #[test] + fn write_overwriting_an_existing_utf8_file_includes_a_diff_block() { + let workspace = temp_workspace(); + fs::write(workspace.join("existing.txt"), "old content\n").unwrap(); + let ctx = test_ctx(workspace.clone()); + let args = json!({"path": "existing.txt", "content": "new content\n", "reason": "test overwrite"}); + let result = Write.run(&ctx, &args).unwrap(); + assert!(result.contains("```diff")); + assert!(result.contains("-old content")); + assert!(result.contains("+new content")); + fs::remove_dir_all(&workspace).ok(); + } + + #[test] + fn write_overwriting_a_non_utf8_file_has_no_diff_block() { + let workspace = temp_workspace(); + fs::write(workspace.join("binary.dat"), [0xFFu8, 0xFE, 0xFD]).unwrap(); + let ctx = test_ctx(workspace.clone()); + let args = json!({"path": "binary.dat", "content": "now text\n", "reason": "test binary overwrite"}); + let result = Write.run(&ctx, &args).unwrap(); + assert!(!result.contains("```diff")); + assert!(result.contains("wrote")); + fs::remove_dir_all(&workspace).ok(); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib tool::fs::write::tests` +Expected: FAIL — `write_overwriting_an_existing_utf8_file_includes_a_diff_block` fails (no diff block exists yet); the other two pass already (current behavior already matches them), which is fine. + +- [ ] **Step 3: Add the `similar` import** + +In `src/tool/fs/write.rs`, change line 10 from: + +```rust +use super::helpers::arg_str; +``` + +to: + +```rust +use super::helpers::{self, arg_str}; +use similar::TextDiff; +``` + +- [ ] **Step 4: Capture old content and append a diff block** + +Replace this block (currently lines 60-81): + +```rust + let path = resolve_path(&ctx.workspaces, &rel)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| anyhow!("failed to create parent directories for '{rel}': {e}"))?; + } + fs::write(&path, &content) + .map_err(|e| anyhow!("failed to write '{rel}': {e}"))?; + // Notify the LSP server of the on-disk change so diagnostics stay in + // sync. Never fails the write itself: a lock failure or LSP error is + // folded into the returned message instead of propagated as an Err. + let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() { + lsp.did_change_file(&path); + String::new() + } else { + String::new() + }; + if check_matches.is_empty() { + Ok(format!("wrote {} bytes to {}{}", content.len(), rel, lsp_note)) + } else { + Ok(format!("wrote {} bytes to {}{}. Graduated checks matched: {}", content.len(), rel, lsp_note, check_matches.join(", "))) + } + } +} +``` + +with: + +```rust + let path = resolve_path(&ctx.workspaces, &rel)?; + let old_content = fs::read_to_string(&path).ok(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| anyhow!("failed to create parent directories for '{rel}': {e}"))?; + } + fs::write(&path, &content) + .map_err(|e| anyhow!("failed to write '{rel}': {e}"))?; + // Notify the LSP server of the on-disk change so diagnostics stay in + // sync. Never fails the write itself: a lock failure or LSP error is + // folded into the returned message instead of propagated as an Err. + let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() { + lsp.did_change_file(&path); + String::new() + } else { + String::new() + }; + // Only emit a diff when the file existed before and was valid UTF-8; + // new files and binary overwrites fall back to the byte-count message. + let diff_note = if let Some(old) = old_content { + let text_diff = TextDiff::from_lines(old.as_str(), content.as_str()); + let diff_text = format!( + "{}", + text_diff.unified_diff().context_radius(3).header(&rel, &rel) + ); + format!("\n```diff\n{}\n```", helpers::truncate_diff(&diff_text)) + } else { + String::new() + }; + if check_matches.is_empty() { + Ok(format!("wrote {} bytes to {}{}{}", content.len(), rel, lsp_note, diff_note)) + } else { + Ok(format!("wrote {} bytes to {}{}. Graduated checks matched: {}{}", content.len(), rel, lsp_note, check_matches.join(", "), diff_note)) + } + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test --lib tool::fs::write::tests` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/tool/fs/write.rs +git commit -m "feat: Tampilkan unified diff saat tool write menimpa file yang sudah ada" +``` + +--- + +### Task 5: Diff-aware coloring in `markdown.rs` + +**Files:** +- Modify: `src/view/markdown.rs` + +**Interfaces:** +- Consumes: `Theme::SUCCESS`, `Theme::ERROR`, `Theme::INFO`, `Theme::TEXT_DIM`, `Theme::ACCENT_TEAL`, `Theme::CODE_BG` (all exist in `src/view/theme.rs`). +- Produces: `pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec>` (signature change — was `(text: &str, width: u16)`). Diff lines (` ```diff ` fenced blocks) keep their color even when `dim = true`; every other span falls back to `Theme::TEXT_DIM` + italic when `dim = true`, and is unchanged from today's behavior when `dim = false`. + +- [ ] **Step 1: Write the failing tests** + +Add at the end of `src/view/markdown.rs` (there is no existing test module in this file): + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn span_text(spans: &[Span<'static>]) -> String { + spans.iter().map(|s| s.content.as_ref()).collect() + } + + #[test] + fn dim_false_plain_text_has_no_color() { + let spans = render_markdown("hello world", 0, false); + assert_eq!(span_text(&spans), "hello world"); + assert_eq!(spans[0].style, Style::default()); + } + + #[test] + fn dim_true_plain_text_is_dim_italic() { + let spans = render_markdown("hello", 0, true); + let expected = Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC); + assert_eq!(spans[0].style, expected); + } + + #[test] + fn dim_true_diff_lines_keep_their_own_color() { + let md = "```diff\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context line\n```"; + let spans = render_markdown(md, 0, true); + let plus_span = spans.iter().find(|s| s.content.contains("+new line")).expect("plus span present"); + assert_eq!(plus_span.style.fg, Some(Theme::SUCCESS)); + let minus_span = spans.iter().find(|s| s.content.contains("-old line")).expect("minus span present"); + assert_eq!(minus_span.style.fg, Some(Theme::ERROR)); + let hunk_span = spans.iter().find(|s| s.content.contains("@@")).expect("hunk header span present"); + assert_eq!(hunk_span.style.fg, Some(Theme::INFO)); + } + + #[test] + fn dim_true_non_diff_code_block_is_dimmed() { + let md = "```rust\nfn main() {}\n```"; + let spans = render_markdown(md, 0, true); + let code_span = spans.iter().find(|s| s.content.contains("fn main")).expect("code span present"); + assert_eq!(code_span.style, Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC)); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib view::markdown::tests` +Expected: FAIL to compile — `render_markdown` takes 2 arguments, not 3, at every call site in the new tests. + +- [ ] **Step 3: Add the `apply_dim` and `diff_line_style` helpers** + +Add these two functions right above `pub fn render_markdown` in `src/view/markdown.rs`: + +```rust +/// Apply the "tool output" dim/italic style, or pass `style` through +/// unchanged, depending on `dim`. +fn apply_dim(style: Style, dim: bool) -> Style { + if dim { + Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC) + } else { + style + } +} + +/// Classify a single line inside a ` ```diff ` fenced block by its unified-diff +/// prefix, returning the color it should always render with (even when the +/// surrounding tool output is dimmed) — or `None` for context lines and the +/// `+++`/`---` file-header lines, which use the normal code-block color. +fn diff_line_style(line: &str) -> Option