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