Files
zesdex/docs/superpowers/plans/2026-07-15-diff-mention-clipboard.md
T

80 KiB

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 }:

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
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 }):

    #[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::<Vec<_>>().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 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):

/// 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 tool::fs::helpers::tests Expected: PASS (all tests in the module, old and new)

  • Step 5: Commit
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):

#[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 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:

use super::helpers::arg_str;

to:

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):

        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:

        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 tool::fs::edit::tests Expected: PASS

  • Step 6: Commit
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:

#[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 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:

use super::helpers::arg_str;

to:

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):

        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:

        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 tool::fs::write::tests Expected: PASS

  • Step 6: Commit
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<Span<'static>> (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):

#[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 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:

/// 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<Style> {
    if line.starts_with("@@") {
        Some(Style::default().fg(Theme::INFO).bg(Theme::CODE_BG))
    } else if line.starts_with('+') && !line.starts_with("+++") {
        Some(Style::default().fg(Theme::SUCCESS).bg(Theme::CODE_BG))
    } else if line.starts_with('-') && !line.starts_with("---") {
        Some(Style::default().fg(Theme::ERROR).bg(Theme::CODE_BG))
    } else {
        None
    }
}
  • Step 4: Change the render_markdown signature and doc comment

Replace:

/// Render a markdown string into styled terminal spans, word-wrapped to `width`.
///
/// Flow: `pulldown_cmark` parses `text` into an event stream → each
/// Start/End/Text/Code/Break event is translated into styled `Span`s →
/// if `width > 0`, a second pass wraps long lines.
///
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
/// turns it back into `Line`s for the Paragraph widget.
#[allow(clippy::too_many_lines)]
pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
    let mut spans = Vec::new();
    let mut options = pulldown_cmark::Options::empty();
    options.insert(pulldown_cmark::Options::ENABLE_TABLES);
    let parser = pulldown_cmark::Parser::new_ext(text, options);
    let mut in_code_block = false;
    let mut in_heading = false;
    let mut heading_level = 0;

with:

/// Render a markdown string into styled terminal spans, word-wrapped to `width`.
///
/// Flow: `pulldown_cmark` parses `text` into an event stream → each
/// Start/End/Text/Code/Break event is translated into styled `Span`s →
/// if `width > 0`, a second pass wraps long lines.
///
/// `dim`: when `true`, every span falls back to `Theme::TEXT_DIM` + italic
/// (the "tool output" look) *except* lines inside a ` ```diff ` fenced
/// block, which always keep their +/-/@@ diff color regardless of `dim` —
/// this is what lets diff output stay colored inside otherwise-dimmed
/// `Role::Tool` chat messages.
///
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
/// turns it back into `Line`s for the Paragraph widget.
#[allow(clippy::too_many_lines)]
pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>> {
    let mut spans = Vec::new();
    let mut options = pulldown_cmark::Options::empty();
    options.insert(pulldown_cmark::Options::ENABLE_TABLES);
    let parser = pulldown_cmark::Parser::new_ext(text, options);
    let mut in_code_block = false;
    let mut in_diff_block = false;
    let mut in_heading = false;
    let mut heading_level = 0;
  • Step 5: Detect the diff fence language on CodeBlock start/end

Replace:

                    pulldown_cmark::Tag::CodeBlock(_) => {
                        in_code_block = true;
                        // Code block top bar
                        spans.push(Span::styled(
                            "\n",
                            Style::default(),
                        ));
                        spans.push(Span::styled(
                            "  ┌─ code ",
                            Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
                        ));
                        spans.push(Span::styled(
                            "\n",
                            Style::default(),
                        ));
                    }

with:

                    pulldown_cmark::Tag::CodeBlock(kind) => {
                        in_code_block = true;
                        in_diff_block = matches!(
                            &kind,
                            pulldown_cmark::CodeBlockKind::Fenced(lang) if lang.as_ref() == "diff"
                        );
                        // Code block top bar
                        spans.push(Span::styled(
                            "\n",
                            Style::default(),
                        ));
                        spans.push(Span::styled(
                            "  ┌─ code ",
                            apply_dim(Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), dim),
                        ));
                        spans.push(Span::styled(
                            "\n",
                            Style::default(),
                        ));
                    }

Replace:

                    pulldown_cmark::TagEnd::CodeBlock => {
                        in_code_block = false;
                        // Code block bottom bar
                        spans.push(Span::styled(
                            "\n  └─\n",
                            Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
                        ));
                    }

with:

                    pulldown_cmark::TagEnd::CodeBlock => {
                        in_code_block = false;
                        in_diff_block = false;
                        // Code block bottom bar
                        spans.push(Span::styled(
                            "\n  └─\n",
                            apply_dim(Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), dim),
                        ));
                    }
  • Step 6: Dim-wrap the remaining decorative Start/End spans

Replace:

                    pulldown_cmark::Tag::Item => {
                        // List item bullet
                        spans.push(Span::styled(
                            "• ",
                            Style::default().fg(Theme::PRIMARY),
                        ));
                    }
                    pulldown_cmark::Tag::Link { dest_url, .. } => {
                        spans.push(Span::styled(
                            "[",
                            Style::default().fg(Theme::INFO),
                        ));
                        // We push the URL as a tooltip-like suffix
                        // After the link text ends, we'll add the URL
                        spans.push(Span::styled(
                            format!("]({dest_url})"),
                            Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC),
                        ));
                    }
                    pulldown_cmark::Tag::BlockQuote(_) => {
                        spans.push(Span::styled(
                            "▎",
                            Style::default().fg(Theme::BLOCKQUOTE_BAR),
                        ));
                    }

with:

                    pulldown_cmark::Tag::Item => {
                        // List item bullet
                        spans.push(Span::styled(
                            "• ",
                            apply_dim(Style::default().fg(Theme::PRIMARY), dim),
                        ));
                    }
                    pulldown_cmark::Tag::Link { dest_url, .. } => {
                        spans.push(Span::styled(
                            "[",
                            apply_dim(Style::default().fg(Theme::INFO), dim),
                        ));
                        // We push the URL as a tooltip-like suffix
                        // After the link text ends, we'll add the URL
                        spans.push(Span::styled(
                            format!("]({dest_url})"),
                            apply_dim(Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), dim),
                        ));
                    }
                    pulldown_cmark::Tag::BlockQuote(_) => {
                        spans.push(Span::styled(
                            "▎",
                            apply_dim(Style::default().fg(Theme::BLOCKQUOTE_BAR), dim),
                        ));
                    }
  • Step 7: Rewrite the Text event handler with diff-aware, per-line coloring

Replace:

            pulldown_cmark::Event::Text(text) => {
                let s = text.to_string();
                if in_code_block {
                    let indented = format!("  {}", s.replace('\n', "\n  "));
                    spans.push(Span::styled(
                        indented,
                        Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG),
                    ));
                } else if in_heading {
                    let color = match heading_level {
                        1 => Theme::PRIMARY,
                        2 => Theme::INFO,
                        3 => Theme::ACCENT_PURPLE,
                        _ => Theme::TEXT,
                    };
                    spans.push(Span::styled(
                        s,
                        Style::default().fg(color).add_modifier(Modifier::BOLD),
                    ));
                } else if in_table_cell {
                    current_cell.push(Span::raw(s));
                } else {
                    spans.push(Span::raw(s));
                }
            }

with:

            pulldown_cmark::Event::Text(text) => {
                let s = text.to_string();
                if in_code_block {
                    if in_diff_block {
                        for (i, line) in s.split('\n').enumerate() {
                            if i > 0 {
                                spans.push(Span::raw("\n"));
                            }
                            if line.is_empty() {
                                continue;
                            }
                            let style = diff_line_style(line)
                                .unwrap_or_else(|| Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG));
                            spans.push(Span::styled(format!("  {line}"), style));
                        }
                    } else {
                        let indented = format!("  {}", s.replace('\n', "\n  "));
                        spans.push(Span::styled(
                            indented,
                            apply_dim(Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), dim),
                        ));
                    }
                } else if in_heading {
                    let color = match heading_level {
                        1 => Theme::PRIMARY,
                        2 => Theme::INFO,
                        3 => Theme::ACCENT_PURPLE,
                        _ => Theme::TEXT,
                    };
                    spans.push(Span::styled(
                        s,
                        apply_dim(Style::default().fg(color).add_modifier(Modifier::BOLD), dim),
                    ));
                } else if in_table_cell {
                    current_cell.push(Span::styled(s, apply_dim(Style::default(), dim)));
                } else {
                    spans.push(Span::styled(s, apply_dim(Style::default(), dim)));
                }
            }
  • Step 8: Dim-wrap the inline Code event

Replace:

            pulldown_cmark::Event::Code(text) => {
                let span = Span::styled(
                    format!(" {text} "),
                    Style::default()
                        .fg(Theme::ACCENT_TEAL)
                        .bg(Theme::CODE_BAR)
                        .add_modifier(Modifier::BOLD),
                );
                if in_table_cell {
                    current_cell.push(span);
                } else {
                    spans.push(span);
                }
            }

with:

            pulldown_cmark::Event::Code(text) => {
                let span = Span::styled(
                    format!(" {text} "),
                    apply_dim(
                        Style::default()
                            .fg(Theme::ACCENT_TEAL)
                            .bg(Theme::CODE_BAR)
                            .add_modifier(Modifier::BOLD),
                        dim,
                    ),
                );
                if in_table_cell {
                    current_cell.push(span);
                } else {
                    spans.push(span);
                }
            }
  • Step 9: Dim-wrap the table border spans

In the TagEnd::Table arm, there are three Style::default().fg(Theme::BORDER) usages (the " | " row prefix, the " | " cell separator, and the " |" / "{w}-|" header separator row). Replace all three occurrences of Style::default().fg(Theme::BORDER) in that arm with apply_dim(Style::default().fg(Theme::BORDER), dim).

  • Step 10: Run tests to verify they pass

Run: cargo test view::markdown::tests Expected: PASS

  • Step 11: Commit
git add src/view/markdown.rs
git commit -m "feat: Tambah parameter dim dan pewarnaan baris diff di markdown renderer"

Task 6: Wire the dim flag through chat.rs

Files:

  • Modify: src/view/chat.rs

Interfaces:

  • Consumes: render_markdown(text, width, dim) (Task 5's new signature).

  • Produces: Role::Tool messages now render with dim = true (and diff lines inside them keep color); every other role renders with dim = false (behavior unchanged).

  • Step 1: Update the Role::Tool branch and drop the manual dim-remap loops

Replace this block (currently around lines 126-155):

        if msg.role == Role::Tool {
            let content = if msg.content.trim().is_empty() {
                "(tool execution)".to_string()
            } else {
                msg.content.clone()
            };
            let dim = Style::default().fg(Theme::TEXT_DIM);
            let dim_italic = dim.add_modifier(Modifier::ITALIC);

            let content_spans = super::markdown::render_markdown(&content, content_width);
            let content_lines = split_spans_into_lines(content_spans);
            let mut lines_iter = content_lines.into_iter();

            let first_spans = lines_iter.next().map_or_else(Vec::new, |line| {
                line.spans.into_iter().map(|s| Span::styled(s.content, dim_italic)).collect()
            });
            let mut spans = vec![
                Span::raw(" ".repeat(PREFIX_WIDTH)),
                Span::styled("↳ ", dim),
            ];
            spans.extend(first_spans);
            display_lines.push(Line::from(spans));

            for line in lines_iter {
                let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
                spans.extend(line.spans.into_iter().map(|s| Span::styled(s.content, dim_italic)));
                display_lines.push(Line::from(spans));
            }
            continue;
        }

with:

        if msg.role == Role::Tool {
            let content = if msg.content.trim().is_empty() {
                "(tool execution)".to_string()
            } else {
                msg.content.clone()
            };
            let dim = Style::default().fg(Theme::TEXT_DIM);

            let content_spans = super::markdown::render_markdown(&content, content_width, true);
            let content_lines = split_spans_into_lines(content_spans);
            let mut lines_iter = content_lines.into_iter();

            let first_spans = lines_iter.next().map_or_else(Vec::new, |line| line.spans);
            let mut spans = vec![
                Span::raw(" ".repeat(PREFIX_WIDTH)),
                Span::styled("↳ ", dim),
            ];
            spans.extend(first_spans);
            display_lines.push(Line::from(spans));

            for line in lines_iter {
                let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
                spans.extend(line.spans);
                display_lines.push(Line::from(spans));
            }
            continue;
        }
  • Step 2: Update the other call site

Change:

        let content_spans = super::markdown::render_markdown(&content_str, content_width);

to:

        let content_spans = super::markdown::render_markdown(&content_str, content_width, false);
  • Step 3: Run the full test suite to check nothing broke

Run: cargo test Expected: PASS (existing chat.rs tests like format_role_label are unaffected; markdown.rs tests from Task 5 still pass)

  • Step 4: Verify manually

Run: cargo build then launch the app (cargo run) in a real repo, make an edit via the agent, and confirm the diff block in the tool-output sub-line renders with green +/red -/cyan @@ lines while surrounding tool text stays dim.

  • Step 5: Commit
git add src/view/chat.rs
git commit -m "refactor: Pakai parameter dim render_markdown, hapus override style manual di chat"

Part B — Fuzzy @file-mention autocomplete

Task 7: Add nucleo-matcher dependency

Files:

  • Modify: Cargo.toml

Interfaces:

  • Produces: nucleo_matcher::{Matcher, Config}, nucleo_matcher::pattern::{Pattern, CaseMatching, Normalization} available to src/app/state/misc.rs.

  • Step 1: Add the dependency

In Cargo.toml, add this line right after globset = "0.4":

nucleo-matcher = "0.3"
  • Step 2: Verify it builds

Run: cargo check Expected: compiles with no errors.

  • Step 3: Commit
git add Cargo.toml Cargo.lock
git commit -m "chore: Tambah dependency nucleo-matcher untuk fuzzy file matching"

Task 8: MentionIndex, AutocompleteKind, and mention-aware InputState

Files:

  • Modify: src/app/state/misc.rs

Interfaces:

  • Produces: pub struct MentionIndex with new(), set(&self, paths: Vec<String>), push(&self, path: String), snapshot(&self) -> Vec<String>; pub enum AutocompleteKind { Command, FileMention }; InputState::mention_query_at_cursor(&self) -> Option<(usize, String)>; InputState::open_mention_autocomplete(&mut self, files: &[String]); InputState::select_autocomplete becomes kind-aware (signature unchanged: -> bool).

  • Step 1: Write the failing tests

Add a new #[cfg(test)] mod tests block at the end of src/app/state/misc.rs:

#[cfg(test)]
mod tests {
    use super::*;

    fn input_with(buffer: &str, cursor: usize) -> InputState {
        let mut input = InputState::new();
        input.buffer = buffer.to_string();
        input.cursor = cursor;
        input
    }

    #[test]
    fn mention_at_buffer_start_triggers() {
        let input = input_with("@mai", 4);
        assert_eq!(input.mention_query_at_cursor(), Some((0, "mai".to_string())));
    }

    #[test]
    fn mention_after_space_mid_sentence_triggers() {
        let input = input_with("look at @read", 13);
        assert_eq!(input.mention_query_at_cursor(), Some((8, "read".to_string())));
    }

    #[test]
    fn mid_word_at_does_not_trigger() {
        let input = input_with("foo@bar", 7);
        assert_eq!(input.mention_query_at_cursor(), None);
    }

    #[test]
    fn whitespace_between_at_and_cursor_does_not_trigger() {
        let input = input_with("@foo bar", 8);
        assert_eq!(input.mention_query_at_cursor(), None);
    }

    #[test]
    fn select_file_mention_splices_into_buffer() {
        let mut input = input_with("look at @rea and fix it", 12);
        input.autocomplete_candidates = vec!["src/main.rs".to_string()];
        input.autocomplete_idx = 0;
        input.autocomplete_kind = AutocompleteKind::FileMention;
        input.mention_start = 8;
        assert!(input.select_autocomplete());
        assert_eq!(input.buffer, "look at @src/main.rs  and fix it");
        assert_eq!(input.cursor, 8 + "@src/main.rs ".len());
    }

    #[test]
    fn select_command_still_replaces_whole_buffer() {
        let mut input = input_with("/mo", 3);
        input.autocomplete_candidates = vec!["/model".to_string()];
        input.autocomplete_idx = 0;
        input.autocomplete_kind = AutocompleteKind::Command;
        assert!(input.select_autocomplete());
        assert_eq!(input.buffer, "/model");
        assert_eq!(input.cursor, "/model".len());
    }
}
  • Step 2: Run tests to verify they fail

Run: cargo test app::state::misc::tests Expected: FAIL to compile — mention_query_at_cursor, AutocompleteKind, autocomplete_kind, mention_start don't exist yet.

  • Step 3: Add MentionIndex and AutocompleteKind

Add this right after the DirCache impl block (after its closing }, before ScrollState):

/// A shared, whole-workspace file-path index used for `@file` mention
/// autocomplete. Built once by a background thread at startup (see
/// `AppStateRest::new`) and incrementally appended to when tools create
/// new files (see `tool/fs/write.rs`).
#[derive(Clone)]
pub struct MentionIndex {
    entries: Arc<std::sync::RwLock<Vec<String>>>,
}

impl MentionIndex {
    /// Create an empty `MentionIndex`.
    pub fn new() -> Self {
        MentionIndex {
            entries: Arc::new(std::sync::RwLock::new(Vec::new())),
        }
    }

    /// Replace the indexed paths (used by the startup background walk).
    pub fn set(&self, paths: Vec<String>) {
        if let Ok(mut w) = self.entries.write() {
            *w = paths;
        }
    }

    /// Append a single newly created file's path (used by the `write` tool).
    pub fn push(&self, path: String) {
        if let Ok(mut w) = self.entries.write() {
            w.push(path);
        }
    }

    /// Take a snapshot of the current indexed paths for fuzzy matching.
    pub fn snapshot(&self) -> Vec<String> {
        self.entries.read().map(|r| r.clone()).unwrap_or_default()
    }
}

/// Which source populated the autocomplete dropdown, since selecting a
/// candidate is spliced into the buffer differently for each.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutocompleteKind {
    Command,
    FileMention,
}
  • Step 4: Extend InputState's fields

Change:

pub struct InputState {
    pub buffer: String,
    pub cursor: usize,
    pub history: Vec<String>,
    pub history_idx: Option<usize>,
    pub autocomplete_prefix: String,
    pub autocomplete_candidates: Vec<String>,
    pub autocomplete_idx: usize,
    pub autocomplete_visible: bool,
    pub history_file: Option<PathBuf>,
}

to:

pub struct InputState {
    pub buffer: String,
    pub cursor: usize,
    pub history: Vec<String>,
    pub history_idx: Option<usize>,
    pub autocomplete_prefix: String,
    pub autocomplete_candidates: Vec<String>,
    pub autocomplete_idx: usize,
    pub autocomplete_visible: bool,
    pub autocomplete_kind: AutocompleteKind,
    pub mention_start: usize,
    pub history_file: Option<PathBuf>,
}
  • Step 5: Initialize the new fields in InputState::new()

Change:

    pub fn new() -> Self {
        InputState {
            buffer: String::new(),
            cursor: 0,
            history: Vec::new(),
            history_idx: None,
            autocomplete_prefix: String::new(),
            autocomplete_candidates: Vec::new(),
            autocomplete_idx: 0,
            autocomplete_visible: false,
            history_file: None,
        }
    }

to:

    pub fn new() -> Self {
        InputState {
            buffer: String::new(),
            cursor: 0,
            history: Vec::new(),
            history_idx: None,
            autocomplete_prefix: String::new(),
            autocomplete_candidates: Vec::new(),
            autocomplete_idx: 0,
            autocomplete_visible: false,
            autocomplete_kind: AutocompleteKind::Command,
            mention_start: 0,
            history_file: None,
        }
    }
  • Step 6: Reset the new fields in close_autocomplete and set autocomplete_kind explicitly in open_autocomplete

Change:

    pub fn close_autocomplete(&mut self) {
        self.autocomplete_visible = false;
        self.autocomplete_candidates.clear();
        self.autocomplete_prefix.clear();
        self.autocomplete_idx = 0;
    }

to:

    pub fn close_autocomplete(&mut self) {
        self.autocomplete_visible = false;
        self.autocomplete_candidates.clear();
        self.autocomplete_prefix.clear();
        self.autocomplete_idx = 0;
        self.autocomplete_kind = AutocompleteKind::Command;
        self.mention_start = 0;
    }

Change:

        let prefix = trimmed.to_lowercase();
        self.autocomplete_candidates = COMMANDS
            .iter()
            .filter(|c| c.starts_with(&prefix))
            .map(std::string::ToString::to_string)
            .collect();
        self.autocomplete_prefix = prefix;
        self.autocomplete_idx = 0;
        self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
    }

to:

        let prefix = trimmed.to_lowercase();
        self.autocomplete_candidates = COMMANDS
            .iter()
            .filter(|c| c.starts_with(&prefix))
            .map(std::string::ToString::to_string)
            .collect();
        self.autocomplete_prefix = prefix;
        self.autocomplete_kind = AutocompleteKind::Command;
        self.autocomplete_idx = 0;
        self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
    }
  • Step 7: Add mention_query_at_cursor and open_mention_autocomplete

Add these two methods to impl InputState, right after open_autocomplete:

    /// Find the `@mention` token (if any) immediately before the cursor.
    ///
    /// Flow: find the nearest `@` before the cursor → if there's whitespace
    /// between that `@` and the cursor, no trigger → the `@` only counts as
    /// a trigger if it's at buffer start or immediately preceded by
    /// whitespace (so `foo@bar` mid-word never triggers).
    ///
    /// Return: `Some((byte offset of '@', query text between '@' and cursor))`
    /// or `None` if the cursor isn't inside a mention token.
    pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> {
        let before_cursor = &self.buffer[..self.cursor];
        let at_pos = before_cursor.rfind('@')?;
        let between = &before_cursor[at_pos + 1..];
        if between.chars().any(char::is_whitespace) {
            return None;
        }
        let boundary_ok = at_pos == 0
            || before_cursor[..at_pos].chars().next_back().is_some_and(char::is_whitespace);
        if !boundary_ok {
            return None;
        }
        Some((at_pos, between.to_string()))
    }

    /// Open or refresh the `@file` mention dropdown from `files`, fuzzy-matched
    /// against the mention query at the cursor.
    ///
    /// Flow: `mention_query_at_cursor` finds the trigger `@` and query text →
    /// if none, close and return → otherwise fuzzy-match `query` against
    /// `files` via `nucleo-matcher`, keep the top 10 by score.
    pub fn open_mention_autocomplete(&mut self, files: &[String]) {
        let Some((start, query)) = self.mention_query_at_cursor() else {
            self.close_autocomplete();
            return;
        };
        use nucleo_matcher::{Config, Matcher};
        use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
        let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
        let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
        let matches = pattern.match_list(files.iter(), &mut matcher);
        self.autocomplete_candidates = matches.into_iter().take(10).map(|(f, _)| f.clone()).collect();
        self.autocomplete_kind = AutocompleteKind::FileMention;
        self.mention_start = start;
        self.autocomplete_idx = 0;
        self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
    }
  • Step 8: Make select_autocomplete kind-aware

Replace:

    /// Accept the currently selected autocomplete candidate, placing it
    /// in the buffer and closing the dropdown.
    ///
    /// Return: `true` if a candidate was selected, `false` if none existed.
    pub fn select_autocomplete(&mut self) -> bool {
        if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) {
            self.buffer = candidate.clone();
            self.cursor = self.buffer.len();
            self.close_autocomplete();
            true
        } else {
            false
        }
    }

with:

    /// Accept the currently selected autocomplete candidate.
    ///
    /// `Command` candidates replace the whole buffer; `FileMention`
    /// candidates splice `@path ` in at the mention's start position so the
    /// rest of the sentence around it is preserved.
    ///
    /// Return: `true` if a candidate was selected, `false` if none existed.
    pub fn select_autocomplete(&mut self) -> bool {
        let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx).cloned() else {
            return false;
        };
        match self.autocomplete_kind {
            AutocompleteKind::Command => {
                self.buffer = candidate;
                self.cursor = self.buffer.len();
            }
            AutocompleteKind::FileMention => {
                let replacement = format!("@{candidate} ");
                self.buffer.replace_range(self.mention_start..self.cursor, &replacement);
                self.cursor = self.mention_start + replacement.len();
            }
        }
        self.close_autocomplete();
        true
    }
  • Step 9: Run tests to verify they pass

Run: cargo test app::state::misc::tests Expected: PASS

  • Step 10: Commit
git add src/app/state/misc.rs
git commit -m "feat: Tambah MentionIndex, AutocompleteKind, dan deteksi @mention di InputState"

Task 9: Thread mention_index through ToolCtx and AppStateRest, build it in the background

Files:

  • Modify: src/tool/mod.rs
  • Modify: src/app/state/rest.rs

Interfaces:

  • Consumes: MentionIndex (Task 8).

  • Produces: ToolCtx.mention_index: MentionIndex, ToolCtxBuilder.mention_index, AppStateRest.mention_index: MentionIndex — populated by a background thread spawned in AppStateRest::new().

  • Step 1: Add mention_index to ToolCtx and ToolCtxBuilder

In src/tool/mod.rs, add a field to ToolCtx (right after dir_cache):

    pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
    pub mention_index: super::app::state::misc::MentionIndex,

Add the same field to ToolCtxBuilder (right after its dir_cache):

    pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
    pub mention_index: super::app::state::misc::MentionIndex,

In impl Default for ToolCtxBuilder, add (right after dir_cache: ...):

            dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())),
            mention_index: super::app::state::misc::MentionIndex::new(),

In ToolCtxBuilder::build(), add (right after dir_cache: self.dir_cache,):

            dir_cache: self.dir_cache,
            mention_index: self.mention_index,
  • Step 2: Verify tool/mod.rs compiles on its own

Run: cargo check --lib Expected: FAIL — AppStateRest::tool_ctx_for in rest.rs constructs a ToolCtx { ... } literal missing the new mention_index field. This confirms the field was added correctly; Step 3 fixes the caller.

  • Step 3: Add mention_index to AppStateRest

In src/app/state/rest.rs, change the import:

use super::misc::{DirCache, InputState, MiscState, ScrollState};

to:

use super::misc::{DirCache, InputState, MentionIndex, MiscState, ScrollState};

Add a field to AppStateRest (right after dir_cache):

    pub dir_cache: Arc<RwLock<DirCache>>,
    pub mention_index: MentionIndex,

In AppStateRest::new()'s struct literal, add (right after dir_cache: Arc::new(RwLock::new(dir_cache)),):

            dir_cache: Arc::new(RwLock::new(dir_cache)),
            mention_index: MentionIndex::new(),

In tool_ctx_for(), add (right after dir_cache: self.dir_cache.clone(),):

            dir_cache: self.dir_cache.clone(),
            mention_index: self.mention_index.clone(),
  • Step 4: Spawn the background index-build thread

In AppStateRest::new(), replace the tail of the function:

                if connected.is_empty() {
                    let m = "LSP: no servers available — install manually or check prerequisites".to_string(); push_msg(&msg_queue, &m);
                } else {
                    let m = format!("LSP: {} server(s) connected", connected.len()); push_msg(&msg_queue, &m);
                }
            });
        }

        state
    }

with:

                if connected.is_empty() {
                    let m = "LSP: no servers available — install manually or check prerequisites".to_string(); push_msg(&msg_queue, &m);
                } else {
                    let m = format!("LSP: {} server(s) connected", connected.len()); push_msg(&msg_queue, &m);
                }
            });
        }

        // Fire-and-forget background file index build for `@file` mention
        // autocomplete.
        //
        // Flow: spawn OS thread -> `ignore::Walk` each workspace root,
        // collecting file paths (workspace-index-prefixed for roots beyond
        // the first, matching `resolve_path`'s `[N]path` convention) -> stop
        // once 50,000 entries are collected -> store the result in
        // `mention_index`.
        //
        // Why a raw thread and not a background tokio task: there is no
        // persistent async runtime driving the render loop, and this is
        // blocking filesystem I/O -- a dedicated thread keeps startup
        // non-blocking. Not joined, same rationale as the LSP provisioning
        // thread above: a slow/huge repo must not delay the TUI appearing.
        {
            let mention_index = state.mention_index.clone();
            let roots = state.workspace_roots.clone();
            std::thread::spawn(move || {
                const MAX_MENTION_ENTRIES: usize = 50_000;
                let mut paths = Vec::new();
                'roots: for (i, root) in roots.iter().enumerate() {
                    for entry in ignore::Walk::new(root).flatten() {
                        if !entry.path().is_file() {
                            continue;
                        }
                        let rel = entry.path().strip_prefix(root).unwrap_or(entry.path());
                        let rel_str = rel.display().to_string();
                        let formatted = if i == 0 { rel_str } else { format!("[{i}]{rel_str}") };
                        paths.push(formatted);
                        if paths.len() >= MAX_MENTION_ENTRIES {
                            break 'roots;
                        }
                    }
                }
                mention_index.set(paths);
            });
        }

        state
    }
  • Step 5: Run the full test suite

Run: cargo test Expected: PASS — including the existing tool_ctx_for_shares_the_session_abort_flag test in rest.rs and tool_ctx_builder_defaults_abort_flag_to_none in tool/mod.rs, which now also implicitly construct/carry the new mention_index field.

  • Step 6: Verify manually

Run: cargo run in a real project directory, wait a couple seconds for the background walk to finish, and confirm (in the next task, once wired to the input) that typing @ starts finding real files.

  • Step 7: Commit
git add src/tool/mod.rs src/app/state/rest.rs
git commit -m "feat: Alirkan mention_index lewat ToolCtx dan AppStateRest, bangun index di background thread"

Task 10: Push newly created files into the mention index from write

Files:

  • Modify: src/tool/fs/write.rs

Interfaces:

  • Consumes: ctx.mention_index: MentionIndex (Task 9), MentionIndex::push (Task 8).

  • Produces: write creating a brand-new file (not an overwrite) appends its rel path to ctx.mention_index.

  • Step 1: Write the failing test

Add to the #[cfg(test)] mod tests block in src/tool/fs/write.rs (added in Task 4), after write_overwriting_a_non_utf8_file_has_no_diff_block:

    #[test]
    fn write_creating_a_new_file_appends_to_the_mention_index() {
        let workspace = temp_workspace();
        let ctx = test_ctx(workspace.clone());
        let args = json!({"path": "brand_new.txt", "content": "hi\n", "reason": "test mention index"});
        Write.run(&ctx, &args).unwrap();
        assert_eq!(ctx.mention_index.snapshot(), vec!["brand_new.txt".to_string()]);
        fs::remove_dir_all(&workspace).ok();
    }

    #[test]
    fn write_overwriting_a_file_does_not_duplicate_the_mention_index_entry() {
        let workspace = temp_workspace();
        fs::write(workspace.join("existing.txt"), "old\n").unwrap();
        let ctx = test_ctx(workspace.clone());
        let args = json!({"path": "existing.txt", "content": "new\n", "reason": "test no duplicate"});
        Write.run(&ctx, &args).unwrap();
        assert!(ctx.mention_index.snapshot().is_empty());
        fs::remove_dir_all(&workspace).ok();
    }
  • Step 2: Run tests to verify they fail

Run: cargo test tool::fs::write::tests Expected: FAIL — write_creating_a_new_file_appends_to_the_mention_index fails because nothing pushes to mention_index yet.

  • Step 3: Capture existence-before-write and push on new-file creation

Change:

        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}"))?;

to:

        let path = resolve_path(&ctx.workspaces, &rel)?;
        let old_content = fs::read_to_string(&path).ok();
        let existed_before = path.exists();
        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}"))?;
        if !existed_before {
            ctx.mention_index.push(rel.clone());
        }
  • Step 4: Run tests to verify they pass

Run: cargo test tool::fs::write::tests Expected: PASS

  • Step 5: Commit
git add src/tool/fs/write.rs
git commit -m "feat: Tambahkan file baru ke mention_index saat tool write membuatnya"

Task 11: Wire the mention trigger and Tab-cycling into controller/input.rs

Files:

  • Modify: src/controller/input.rs

Interfaces:

  • Consumes: InputState::mention_query_at_cursor, InputState::open_mention_autocomplete, AutocompleteKind (Task 8), state.mention_index.snapshot() (Task 9).

  • Produces: typing @word opens the file-mention dropdown; Tab cycles it when visible.

  • Step 1: Import AutocompleteKind

Change the import block at the top of src/controller/input.rs:

use crate::app::mode;
use crate::app::runtime::actions::Action;
use crate::app::runtime::commands::apply_command;
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
use crate::controller::command::parse_command;

to:

use crate::app::mode;
use crate::app::runtime::actions::Action;
use crate::app::runtime::commands::apply_command;
use crate::app::state::misc::AutocompleteKind;
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
use crate::controller::command::parse_command;
  • Step 2: Extend the Char(c) handler to detect mention triggers

Change:

        KeyCode::Char(c) => {
            if state.input.autocomplete_visible {
                state.input.close_autocomplete();
                state.dirty = true;
            }
            // Insert the character inline so we can immediately check the
            // new buffer state for autocomplete triggers.
            state.input.insert(c);
            state.dirty = true;
            // Show autocomplete immediately when the buffer starts with `/`,
            // without requiring an extra Tab press.
            if state.input.buffer.starts_with('/') {
                state.input.open_autocomplete();
            }
            Vec::new()
        }

to:

        KeyCode::Char(c) => {
            if state.input.autocomplete_visible {
                state.input.close_autocomplete();
                state.dirty = true;
            }
            // Insert the character inline so we can immediately check the
            // new buffer state for autocomplete triggers.
            state.input.insert(c);
            state.dirty = true;
            // Show autocomplete immediately when the buffer starts with `/`,
            // without requiring an extra Tab press.
            if state.input.buffer.starts_with('/') {
                state.input.open_autocomplete();
            } else if state.input.mention_query_at_cursor().is_some() {
                state.input.open_mention_autocomplete(&state.mention_index.snapshot());
            }
            Vec::new()
        }
  • Step 3: Extend the Tab handler to cycle file-mention candidates too

Change:

        KeyCode::Tab => {
            if state.input.buffer.starts_with('/') {
                if state.input.autocomplete_visible {
                    state.input.cycle_autocomplete(true);
                } else {
                    state.input.tab_complete();
                }
                state.dirty = true;
            }
            Vec::new()
        }

to:

        KeyCode::Tab => {
            if state.input.buffer.starts_with('/') {
                if state.input.autocomplete_visible {
                    state.input.cycle_autocomplete(true);
                } else {
                    state.input.tab_complete();
                }
                state.dirty = true;
            } else if state.input.autocomplete_kind == AutocompleteKind::FileMention
                && state.input.autocomplete_visible
            {
                state.input.cycle_autocomplete(true);
                state.dirty = true;
            }
            Vec::new()
        }
  • Step 4: Verify it builds

Run: cargo build Expected: compiles with no errors or new warnings.

  • Step 5: Verify manually (no automated test for this step)

This step wires dispatch logic that's already fully covered by Task 8's unit tests (mention_query_at_cursor, open_mention_autocomplete, select_autocomplete splicing) — the only new risk here is the handle_key routing itself, which is thin and depends on AppStateRest::new()'s background-spawned mention_index (a real filesystem walk racing against a test would be flaky, so this is deliberately verified manually instead of automated, matching the file-mention design spec's own testing scope).

Run: cargo run in a real project directory, wait a couple seconds, type look at @ma in the chat input, and confirm the "📁 Files" dropdown appears with matching file paths; press Tab/Down to cycle, Enter to select, and confirm the buffer becomes look at @<path> with the rest of the sentence preserved (type more text afterward to confirm the cursor lands in the right place).

  • Step 6: Commit
git add src/controller/input.rs
git commit -m "feat: Deteksi trigger @mention dan Tab-cycle di input handler"

Task 12: Dropdown title reflects the autocomplete kind

Files:

  • Modify: src/view/mod.rs

Interfaces:

  • Consumes: state.input.autocomplete_kind (Task 8).

  • Produces: the dropdown title is " ⌘ Commands " for Command and " 📁 Files " for FileMention.

  • Step 1: Change the dropdown title to be kind-dependent

Change:

        let dropdown_block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Theme::BORDER))
            .title(Span::styled(
                " ⌘ Commands ",
                Style::default().fg(Theme::PRIMARY),
            ))
            .style(Style::default().bg(Theme::SURFACE_ELEVATED));

to:

        let dropdown_title = match state.input.autocomplete_kind {
            crate::app::state::misc::AutocompleteKind::Command => " ⌘ Commands ",
            crate::app::state::misc::AutocompleteKind::FileMention => " 📁 Files ",
        };
        let dropdown_block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Theme::BORDER))
            .title(Span::styled(
                dropdown_title,
                Style::default().fg(Theme::PRIMARY),
            ))
            .style(Style::default().bg(Theme::SURFACE_ELEVATED));
  • Step 2: Verify it builds

Run: cargo build Expected: compiles with no errors.

  • Step 3: Verify manually

Run: cargo run, trigger both dropdowns (/ for commands, @word for files) and confirm the title switches correctly.

  • Step 4: Commit
git add src/view/mod.rs
git commit -m "feat: Judul dropdown autocomplete mengikuti jenisnya (Commands vs Files)"

Part C — Clipboard copy via OSC52

Task 13: pending_clipboard_copy state field

Files:

  • Modify: src/app/state/misc.rs

Interfaces:

  • Produces: MiscState.pending_clipboard_copy: Option<String>.

  • Step 1: Write the failing test

Add to the #[cfg(test)] mod tests block in src/app/state/misc.rs (created in Task 8), after select_command_still_replaces_whole_buffer:

    #[test]
    fn misc_state_starts_with_no_pending_clipboard_copy() {
        let misc = MiscState::new();
        assert!(misc.pending_clipboard_copy.is_none());
    }
  • Step 2: Run test to verify it fails

Run: cargo test app::state::misc::tests::misc_state_starts_with_no_pending_clipboard_copy Expected: FAIL to compile — pending_clipboard_copy field doesn't exist.

  • Step 3: Add the field

Change:

pub struct MiscState {
    pub overlay: Overlay,
    pub toasts: Vec<super::types::Toast>,
    pub last_staleness_sweep_ms: i64,
    pub thinking: bool,
    pub effort_level: usize,
    pub selected_index: usize,
    pub editor: Option<super::super::mode::editor::EditorState>,
    pub api_connected: bool,
    #[allow(dead_code)]
    pub api_context_length: Option<u32>,
    pub tick_count: u64,
    pub todo_content: String,
    pub lesson_running: bool,
}

to:

pub struct MiscState {
    pub overlay: Overlay,
    pub toasts: Vec<super::types::Toast>,
    pub last_staleness_sweep_ms: i64,
    pub thinking: bool,
    pub effort_level: usize,
    pub selected_index: usize,
    pub editor: Option<super::super::mode::editor::EditorState>,
    pub api_connected: bool,
    #[allow(dead_code)]
    pub api_context_length: Option<u32>,
    pub tick_count: u64,
    pub todo_content: String,
    pub lesson_running: bool,
    pub pending_clipboard_copy: Option<String>,
}

Change:

    pub fn new() -> Self {
        MiscState {
            overlay: Overlay::None,
            toasts: Vec::new(),
            last_staleness_sweep_ms: 0,
            thinking: false,
            effort_level: 1,
            selected_index: 0,
            editor: None,
            api_connected: false,
            api_context_length: None,
            tick_count: 0,
            todo_content: String::new(),
            lesson_running: false,
        }
    }

to:

    pub fn new() -> Self {
        MiscState {
            overlay: Overlay::None,
            toasts: Vec::new(),
            last_staleness_sweep_ms: 0,
            thinking: false,
            effort_level: 1,
            selected_index: 0,
            editor: None,
            api_connected: false,
            api_context_length: None,
            tick_count: 0,
            todo_content: String::new(),
            lesson_running: false,
            pending_clipboard_copy: None,
        }
    }
  • Step 4: Run test to verify it passes

Run: cargo test app::state::misc::tests Expected: PASS (all tests in the module)

  • Step 5: Commit
git add src/app/state/misc.rs
git commit -m "feat: Tambah field pending_clipboard_copy di MiscState"

Task 14: Ctrl+Y handler in controller/input.rs

Files:

  • Modify: src/controller/input.rs

Interfaces:

  • Consumes: state.misc.pending_clipboard_copy (Task 13), state.transcript_cache.messages, crate::dto::chat::message::Role::Assistant.

  • Produces: Ctrl+Y sets state.misc.pending_clipboard_copy to the last assistant message's content, or pushes an info toast if there is none.

  • Step 1: Write the failing tests

Add a new #[cfg(test)] mod tests block at the end of src/controller/input.rs (there is no existing test module in this file):

#[cfg(test)]
mod tests {
    use super::*;

    fn test_state() -> AppStateRest {
        let tmp = std::env::temp_dir().join(format!("zesdex-input-test-{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&tmp).unwrap();
        AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory"))
    }

    #[test]
    fn ctrl_y_sets_pending_clipboard_copy_to_last_assistant_message() {
        let mut state = test_state();
        state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new(
            crate::dto::chat::message::Role::User,
            "hi".to_string(),
        ));
        state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new(
            crate::dto::chat::message::Role::Assistant,
            "first reply".to_string(),
        ));
        state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new(
            crate::dto::chat::message::Role::Tool,
            "tool output".to_string(),
        ));
        state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new(
            crate::dto::chat::message::Role::Assistant,
            "second reply".to_string(),
        ));
        handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), &mut state);
        assert_eq!(state.misc.pending_clipboard_copy, Some("second reply".to_string()));
    }

    #[test]
    fn ctrl_y_with_no_assistant_message_pushes_info_toast() {
        let mut state = test_state();
        handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), &mut state);
        assert!(state.misc.pending_clipboard_copy.is_none());
        assert_eq!(state.misc.toasts.len(), 1);
    }
}
  • Step 2: Run tests to verify they fail

Run: cargo test controller::input::tests Expected: FAIL — ctrl_y_sets_pending_clipboard_copy_to_last_assistant_message fails because Ctrl+Y isn't handled yet (no state change occurs); ctrl_y_with_no_assistant_message_pushes_info_toast fails because no toast is pushed.

  • Step 3: Add the Ctrl+Y handler

Change:

        KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
            vec![Action::CloseOverlay]
        }

to:

        KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
            vec![Action::CloseOverlay]
        }
        KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
            let last_assistant = state.transcript_cache.messages.iter()
                .rev()
                .find(|m| m.role == crate::dto::chat::message::Role::Assistant);
            match last_assistant {
                Some(msg) => {
                    state.misc.pending_clipboard_copy = Some(msg.content.clone());
                }
                None => {
                    state.push_toast(crate::app::state::types::Toast::new(
                        crate::app::state::types::ToastKind::Info,
                        "No assistant message to copy yet".to_string(),
                    ));
                }
            }
            Vec::new()
        }
  • Step 4: Run tests to verify they pass

Run: cargo test controller::input::tests Expected: PASS

  • Step 5: Commit
git add src/controller/input.rs
git commit -m "feat: Tambah Ctrl+Y untuk menyalin pesan assistant terakhir"

Task 15: write_osc52 helper, wired into single-process mode

Files:

  • Modify: src/main.rs

Interfaces:

  • Consumes: state.misc.pending_clipboard_copy (Task 13), base64 crate (existing dependency).

  • Produces: fn write_osc52(stdout: &mut impl Write, text: &str) -> io::Result<()>; single-process mode (run_loop_inner) writes it after every key event that sets pending_clipboard_copy.

  • Step 1: Write the failing test

Add a new #[cfg(test)] mod tests block at the very end of src/main.rs (there is no existing test module in this file):

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn write_osc52_formats_the_escape_sequence() {
        let mut buf: Vec<u8> = Vec::new();
        write_osc52(&mut buf, "hello").unwrap();
        use base64::Engine as _;
        let b64 = base64::engine::general_purpose::STANDARD.encode("hello");
        let expected = format!("\x1b]52;c;{b64}\x07");
        assert_eq!(String::from_utf8(buf).unwrap(), expected);
    }
}
  • Step 2: Run test to verify it fails

Run: cargo test tests::write_osc52_formats_the_escape_sequence Expected: FAIL to compile — write_osc52 doesn't exist.

  • Step 3: Add the write_osc52 helper

Add this function right before fn run_loop_inner in src/main.rs:

/// Write text to the system clipboard via an OSC52 terminal escape sequence.
///
/// Flow: base64-encode `text` -> wrap in `\x1b]52;c;<b64>\x07` -> write and
/// flush to `stdout`.
///
/// Why: OSC52 asks the terminal emulator itself to set the clipboard, so no
/// OS-level clipboard library (X11/Wayland/win32) is needed. Terminals that
/// don't support it silently ignore the sequence.
fn write_osc52(stdout: &mut impl Write, text: &str) -> io::Result<()> {
    use base64::Engine as _;
    let b64 = base64::engine::general_purpose::STANDARD.encode(text);
    write!(stdout, "\x1b]52;c;{b64}\x07")?;
    stdout.flush()
}
  • Step 4: Run test to verify it passes

Run: cargo test tests::write_osc52_formats_the_escape_sequence Expected: PASS

  • Step 5: Wire it into run_loop_inner

Change:

                Event::Key(key) => {
                    if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat {
                        let actions = handle_key(key, state);
                        for action in actions {
                            apply_action(state, action);
                        }
                    }
                }

to:

                Event::Key(key) => {
                    if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat {
                        let actions = handle_key(key, state);
                        for action in actions {
                            apply_action(state, action);
                        }
                        if let Some(text) = state.misc.pending_clipboard_copy.take() {
                            let _ = write_osc52(&mut io::stdout(), &text);
                            state.push_toast(app::state::types::Toast::new(
                                app::state::types::ToastKind::Success,
                                "Copied to clipboard".to_string(),
                            ));
                        }
                    }
                }
  • Step 6: Verify it builds

Run: cargo build Expected: compiles with no errors.

  • Step 7: Verify manually

Run: cargo run in a real project, get the agent to respond at least once, press Ctrl+Y, confirm a "Copied to clipboard" toast appears, then paste (Ctrl+V or your terminal's paste) somewhere outside the app and confirm the assistant message text is there. (Requires an OSC52-supporting terminal — e.g. iTerm2, kitty, wezterm, Windows Terminal, or a tmux with passthrough enabled.)

  • Step 8: Commit
git add src/main.rs
git commit -m "feat: Tambah write_osc52 dan salin ke clipboard di mode single-process"

Task 16: DaemonFrame::ClipboardCopy for --daemon/--attach mode

Files:

  • Modify: src/ipc/protocol.rs
  • Modify: src/main.rs

Interfaces:

  • Consumes: write_osc52 (Task 15), state.misc.pending_clipboard_copy (Task 13).

  • Produces: DaemonFrame::ClipboardCopy(String); the daemon sends it after a key press that sets pending_clipboard_copy; the attach client writes the OSC52 sequence to its own stdout on receipt.

  • Step 1: Add the DaemonFrame variant

In src/ipc/protocol.rs, change:

pub enum DaemonFrame {
    StateUpdate(Box<StatePayload>),
    StreamToken(String),
    SystemNote { kind: String, message: String },
    Closed,
}

to:

pub enum DaemonFrame {
    StateUpdate(Box<StatePayload>),
    StreamToken(String),
    SystemNote { kind: String, message: String },
    ClipboardCopy(String),
    Closed,
}
  • Step 2: Send it from the daemon after each request

In src/main.rs, in handle_daemon_client, change:

                    ClientRequest::Close => {
                        running = false;
                    }
                }
                send_daemon_update(&mut conn, state)?;

to:

                    ClientRequest::Close => {
                        running = false;
                    }
                }
                if let Some(text) = state.misc.pending_clipboard_copy.take() {
                    conn.send(&ipc::protocol::DaemonFrame::ClipboardCopy(text))?;
                }
                send_daemon_update(&mut conn, state)?;
  • Step 3: Handle it in the attach client

In src/main.rs, in run_attach, change:

        match client.receive::<ipc::protocol::DaemonFrame>()? {
            Some(ipc::protocol::DaemonFrame::StateUpdate(payload)) => {
                apply_client_update(&mut client_state, *payload);
            }
            Some(ipc::protocol::DaemonFrame::StreamToken(_token)) => {}
            Some(ipc::protocol::DaemonFrame::SystemNote { kind: _, message }) => {
                client_state.push_toast(
                    app::state::types::Toast::new(
                        app::state::types::ToastKind::Info,
                        message,
                    ),
                );
            }
            Some(ipc::protocol::DaemonFrame::Closed) | None => {
                client_state.quit = true;
            }
        }

to:

        match client.receive::<ipc::protocol::DaemonFrame>()? {
            Some(ipc::protocol::DaemonFrame::StateUpdate(payload)) => {
                apply_client_update(&mut client_state, *payload);
            }
            Some(ipc::protocol::DaemonFrame::StreamToken(_token)) => {}
            Some(ipc::protocol::DaemonFrame::SystemNote { kind: _, message }) => {
                client_state.push_toast(
                    app::state::types::Toast::new(
                        app::state::types::ToastKind::Info,
                        message,
                    ),
                );
            }
            Some(ipc::protocol::DaemonFrame::ClipboardCopy(text)) => {
                let _ = write_osc52(&mut io::stdout(), &text);
                client_state.push_toast(
                    app::state::types::Toast::new(
                        app::state::types::ToastKind::Success,
                        "Copied to clipboard".to_string(),
                    ),
                );
            }
            Some(ipc::protocol::DaemonFrame::Closed) | None => {
                client_state.quit = true;
            }
        }
  • Step 4: Verify it builds

Run: cargo build Expected: compiles with no errors (the new DaemonFrame variant is handled everywhere it's matched — ipc/protocol.rs and main.rs are the only two files referencing DaemonFrame).

  • Step 5: Verify manually (no automated test — matches existing coverage for this file)

handle_daemon_client/run_attach/send_daemon_update have no existing unit tests in this codebase (they're full Unix-socket network loops); this change follows the same pattern and is verified manually instead, consistent with the rest of this file.

In one terminal: cargo run -- --daemon and note the session id it prints (or check ~/.local/share/zesdex/... per the daemon's session dir logic). In another terminal: cargo run -- --attach <session_id>. Get the agent to respond once in the attached client, press Ctrl+Y, and confirm the "Copied to clipboard" toast appears in the attach client's terminal (not silently on the daemon side), then confirm the paste works.

  • Step 6: Commit
git add src/ipc/protocol.rs src/main.rs
git commit -m "feat: Dukung Ctrl+Y clipboard copy di mode daemon/attach"

Final verification

  • Run the full test suite: cargo test — expect all tests (existing + new) to pass.
  • Run cargo build — expect a clean build with no new warnings (the [lints] block in Cargo.toml denies unused/dead_code/etc., so any leftover unused import or field will fail the build, not just warn).
  • Manually re-verify all three features together in one session per the individual tasks' manual-verification steps (diff coloring, @mention autocomplete, Ctrl+Y copy) in both single-process and --daemon/--attach modes where applicable.