2026-07-15 05:08:29 +07:00
# 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 \n line2 \n line3" ;
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**
2026-07-15 05:13:41 +07:00
Run: `cargo test tool::fs::helpers::tests -- truncate_diff`
2026-07-15 05:08:29 +07:00
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**
2026-07-15 05:13:41 +07:00
Run: `cargo test tool::fs::helpers::tests`
2026-07-15 05:08:29 +07:00
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 \n line2 \n line3 \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**
2026-07-15 05:13:41 +07:00
Run: `cargo test tool::fs::edit::tests`
2026-07-15 05:08:29 +07:00
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**
2026-07-15 05:13:41 +07:00
Run: ` cargo test tool::fs::edit::tests`
2026-07-15 05:08:29 +07:00
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" ), [ 0xFF u8 , 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**
2026-07-15 05:13:41 +07:00
Run: `cargo test tool::fs::write::tests`
2026-07-15 05:08:29 +07:00
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**
2026-07-15 05:13:41 +07:00
Run: `cargo test tool::fs::write::tests`
2026-07-15 05:08:29 +07:00
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<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):
```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 \n fn 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**
2026-07-15 05:13:41 +07:00
Run: `cargo test view::markdown::tests`
2026-07-15 05:08:29 +07:00
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 < 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:
```rust
/// 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:
```rust
/// 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:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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**
2026-07-15 05:13:41 +07:00
Run: `cargo test view::markdown::tests`
2026-07-15 05:08:29 +07:00
Expected: PASS
- [ ] **Step 11: Commit**
```bash
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):
```rust
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:
```rust
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:
```rust
let content_spans = super ::markdown ::render_markdown ( & content_str , content_width );
```
to:
```rust
let content_spans = super ::markdown ::render_markdown ( & content_str , content_width , false );
```
- [ ] **Step 3: Run the full test suite to check nothing broke**
2026-07-15 05:13:41 +07:00
Run: `cargo test`
2026-07-15 05:08:29 +07:00
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**
```bash
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"` :
```toml
nucleo-matcher = "0.3"
```
- [ ] **Step 2: Verify it builds**
Run: `cargo check`
Expected: compiles with no errors.
- [ ] **Step 3: Commit**
```bash
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` :
```rust
#[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**
2026-07-15 05:13:41 +07:00
Run: `cargo test app::state::misc::tests`
2026-07-15 05:08:29 +07:00
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` ):
```rust
/// 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:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
pub fn close_autocomplete ( & mut self ) {
self . autocomplete_visible = false ;
self . autocomplete_candidates . clear ();
self . autocomplete_prefix . clear ();
self . autocomplete_idx = 0 ;
}
```
to:
```rust
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:
```rust
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:
```rust
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` :
```rust
/// 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:
```rust
/// 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:
```rust
/// 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**
2026-07-15 05:13:41 +07:00
Run: `cargo test app::state::misc::tests`
2026-07-15 05:08:29 +07:00
Expected: PASS
- [ ] **Step 10: Commit**
```bash
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` ):
```rust
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` ):
```rust
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: ...` ):
```rust
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,` ):
```rust
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:
```rust
use super ::misc ::{ DirCache , InputState , MiscState , ScrollState };
```
to:
```rust
use super ::misc ::{ DirCache , InputState , MentionIndex , MiscState , ScrollState };
```
Add a field to `AppStateRest` (right after `dir_cache` ):
```rust
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)),` ):
```rust
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(),` ):
```rust
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:
```rust
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:
```rust
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**
2026-07-15 05:13:41 +07:00
Run: `cargo test`
2026-07-15 05:08:29 +07:00
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**
```bash
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` :
```rust
#[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**
2026-07-15 05:13:41 +07:00
Run: `cargo test tool::fs::write::tests`
2026-07-15 05:08:29 +07:00
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:
```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}" )) ? ;
```
to:
```rust
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**
2026-07-15 05:13:41 +07:00
Run: `cargo test tool::fs::write::tests`
2026-07-15 05:08:29 +07:00
Expected: PASS
- [ ] **Step 5: Commit**
```bash
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` :
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```rust
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**
```bash
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:
```rust
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:
```rust
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**
```bash
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` :
```rust
#[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**
2026-07-15 05:13:41 +07:00
Run: `cargo test app::state::misc::tests::misc_state_starts_with_no_pending_clipboard_copy`
2026-07-15 05:08:29 +07:00
Expected: FAIL to compile — `pending_clipboard_copy` field doesn't exist.
- [ ] **Step 3: Add the field**
Change:
```rust
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:
```rust
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:
```rust
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:
```rust
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**
2026-07-15 05:13:41 +07:00
Run: `cargo test app::state::misc::tests`
2026-07-15 05:08:29 +07:00
Expected: PASS (all tests in the module)
- [ ] **Step 5: Commit**
```bash
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):
```rust
#[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**
2026-07-15 05:13:41 +07:00
Run: `cargo test controller::input::tests`
2026-07-15 05:08:29 +07:00
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:
```rust
KeyCode ::Char ( 'd' ) if key . modifiers . contains ( KeyModifiers ::CONTROL ) => {
vec! [ Action ::CloseOverlay ]
}
```
to:
```rust
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**
2026-07-15 05:13:41 +07:00
Run: `cargo test controller::input::tests`
2026-07-15 05:08:29 +07:00
Expected: PASS
- [ ] **Step 5: Commit**
```bash
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):
```rust
#[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**
2026-07-15 05:13:41 +07:00
Run: `cargo test tests::write_osc52_formats_the_escape_sequence`
2026-07-15 05:08:29 +07:00
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` :
```rust
/// 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**
2026-07-15 05:13:41 +07:00
Run: `cargo test tests::write_osc52_formats_the_escape_sequence`
2026-07-15 05:08:29 +07:00
Expected: PASS
- [ ] **Step 5: Wire it into `run_loop_inner`**
Change:
```rust
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:
```rust
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**
```bash
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:
```rust
pub enum DaemonFrame {
StateUpdate ( Box < StatePayload > ),
StreamToken ( String ),
SystemNote { kind : String , message : String },
Closed ,
}
```
to:
```rust
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:
```rust
ClientRequest ::Close => {
running = false ;
}
}
send_daemon_update ( & mut conn , state ) ? ;
```
to:
```rust
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:
```rust
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:
```rust
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**
```bash
git add src/ipc/protocol.rs src/main.rs
git commit -m "feat: Dukung Ctrl+Y clipboard copy di mode daemon/attach"
```
---
## Final verification
2026-07-15 05:13:41 +07:00
- [ ] Run the full test suite: `cargo test` — expect all tests (existing + new) to pass.
2026-07-15 05:08:29 +07:00
- [ ] 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.