Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaab953d8a | ||
|
|
e0d0860d7b | ||
|
|
dcfc5b9ec0 | ||
|
|
472162d135 | ||
|
|
71b1613d84 | ||
|
|
d93bdba59b | ||
|
|
3b6abd920e | ||
|
|
7f2509ecd2 | ||
|
|
2eebedfea5 | ||
|
|
930961bd85 | ||
|
|
93be92a5fb | ||
|
|
95cae8fd8a | ||
|
|
5c413cf9a3 | ||
|
|
c386030573 | ||
|
|
199028fa2e | ||
|
|
8d77e4565c | ||
|
|
683715cd7a | ||
|
|
2bb8e6f255 | ||
|
|
c3c0ef632a | ||
|
|
5dd835ff24 | ||
|
|
0a24903eb0 | ||
|
|
de8e3703f2 | ||
|
|
b7fc335bf5 | ||
|
|
821622e80d | ||
|
|
8fbc51534d | ||
|
|
3f284cbb9a |
@@ -1,3 +1,30 @@
|
|||||||
|
# [1.13.0](https://github.com/asepharyana/zesdex/compare/v1.12.0...v1.13.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* correct test assertion in dim_false_plain_text_has_no_color ([8d77e45](https://github.com/asepharyana/zesdex/commit/8d77e4565c2e3ca49058022c71e8277bdd790312))
|
||||||
|
* Remove orphaned span_text helper function from markdown test module ([199028f](https://github.com/asepharyana/zesdex/commit/199028fa2eb6056dad2bdf0753554939c153a163))
|
||||||
|
* **state:** cegah panic saat select mention dengan cursor stale ([dcfc5b9](https://github.com/asepharyana/zesdex/commit/dcfc5b9ec0d35f4c66b1648ded00f4f1279e0413))
|
||||||
|
* **state:** jangan bangun mention index di mode attach ([e0d0860](https://github.com/asepharyana/zesdex/commit/e0d0860d7ba0ab6a4c9b9f1562f6a767a6424d99))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Deteksi trigger [@mention](https://github.com/mention) dan Tab-cycle di input handler ([2eebedf](https://github.com/asepharyana/zesdex/commit/2eebedfea56c7d4827f94a2739dbb5fa006e3069))
|
||||||
|
* **ipc:** dukung Ctrl+Y clipboard copy di mode daemon/attach ([472162d](https://github.com/asepharyana/zesdex/commit/472162d135478666913a888a68867e635e50d14a))
|
||||||
|
* Judul dropdown autocomplete mengikuti jenisnya (Commands vs Files) ([7f2509e](https://github.com/asepharyana/zesdex/commit/7f2509ecd2688495545333debee0c81016b28641))
|
||||||
|
* **state:** Alirkan mention_index lewat ToolCtx dan AppStateRest, bangun index di background thread ([93be92a](https://github.com/asepharyana/zesdex/commit/93be92a5fb8644c9020478c49421585ade2d0fd1))
|
||||||
|
* Tambah Ctrl+Y untuk menyalin pesan assistant terakhir ([d93bdba](https://github.com/asepharyana/zesdex/commit/d93bdba59b7dd14b4a76dc91c6b7f70a3c0f721c))
|
||||||
|
* Tambah field pending_clipboard_copy di MiscState ([3b6abd9](https://github.com/asepharyana/zesdex/commit/3b6abd920ea92329ebcf888c3bb50470675c4152))
|
||||||
|
* Tambah helper truncate_diff untuk membatasi panjang diff ([5dd835f](https://github.com/asepharyana/zesdex/commit/5dd835ff244a138bb5f1a8dbfd7cf252c2671e5e))
|
||||||
|
* Tambah MentionIndex, AutocompleteKind, dan deteksi [@mention](https://github.com/mention) di InputState ([95cae8f](https://github.com/asepharyana/zesdex/commit/95cae8fd8a8ce737ba60c3ac8bea77f4c2e14b1a))
|
||||||
|
* Tambah write_osc52 dan salin ke clipboard di mode single-process ([71b1613](https://github.com/asepharyana/zesdex/commit/71b1613d8467cfcb2383b3fce153a25c7883ac3d))
|
||||||
|
* Tambahkan file baru ke mention_index saat tool write membuatnya ([930961b](https://github.com/asepharyana/zesdex/commit/930961bd85701ab8906b8d81095e46d3635a951d))
|
||||||
|
* Tampilkan unified diff pada hasil tool edit ([c3c0ef6](https://github.com/asepharyana/zesdex/commit/c3c0ef632a605d8bf32712049035e4fc814af9d5))
|
||||||
|
* Tampilkan unified diff saat tool write menimpa file yang sudah ada ([2bb8e6f](https://github.com/asepharyana/zesdex/commit/2bb8e6f2555eac01baf2311d41811afad4aa3041))
|
||||||
|
* **view:** Tambah parameter dim dan pewarnaan baris diff di markdown renderer ([683715c](https://github.com/asepharyana/zesdex/commit/683715cd7af77d87a2ae3834360bb6f7a4388bd0))
|
||||||
|
|
||||||
# [1.12.0](https://github.com/asepharyana/zesdex/compare/v1.11.0...v1.12.0) (2026-07-14)
|
# [1.12.0](https://github.com/asepharyana/zesdex/compare/v1.11.0...v1.12.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+23
-2
@@ -1915,6 +1915,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nucleo-matcher"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"unicode-segmentation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -3149,6 +3159,15 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "similar"
|
||||||
|
version = "3.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6505efef05804732ed8a3f2d4f279429eb485bd69d5b0cc6b19cc02005cda16"
|
||||||
|
dependencies = [
|
||||||
|
"bstr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -3362,7 +3381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -4436,7 +4455,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex"
|
name = "zesdex"
|
||||||
version = "1.12.0"
|
version = "1.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -4453,6 +4472,7 @@ dependencies = [
|
|||||||
"infer",
|
"infer",
|
||||||
"libc",
|
"libc",
|
||||||
"lsp-types",
|
"lsp-types",
|
||||||
|
"nucleo-matcher",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
@@ -4465,6 +4485,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml_ng",
|
"serde_yaml_ng",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
|
"similar",
|
||||||
"syntect",
|
"syntect",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "zesdex"
|
name = "zesdex"
|
||||||
version = "1.12.0"
|
version = "1.13.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||||
|
|
||||||
@@ -40,11 +40,13 @@ uuid = { version = "1", features = ["v4", "v5"] }
|
|||||||
dirs = "6"
|
dirs = "6"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
pulldown-cmark = { version = "0.13", default-features = false }
|
pulldown-cmark = { version = "0.13", default-features = false }
|
||||||
|
similar = "3"
|
||||||
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
||||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
globset = "0.4"
|
globset = "0.4"
|
||||||
|
nucleo-matcher = "0.3"
|
||||||
infer = "0.19"
|
infer = "0.19"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
|||||||
|
# Clipboard Copy via OSC52 — Design
|
||||||
|
|
||||||
|
**Status:** Approved, pending implementation plan
|
||||||
|
**Date:** 2026-07-15
|
||||||
|
**Scope:** `src/app/state/misc.rs`, `src/controller/input.rs`, `src/main.rs`,
|
||||||
|
`src/ipc/protocol.rs`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
There is no clipboard support anywhere in the TUI today, and mouse capture is enabled
|
||||||
|
(`EnableMouseCapture` in `main.rs`), which in most terminal emulators suppresses native
|
||||||
|
click-drag text selection unless the user holds a modifier — making an in-app copy action
|
||||||
|
more valuable than it would be in a plain scrollback. OSC52 is a terminal escape sequence
|
||||||
|
(`\x1b]52;c;<base64>\x07`) that asks the terminal emulator itself to set the system
|
||||||
|
clipboard; it needs no OS-level clipboard library (no X11/Wayland/win32 dependency) and
|
||||||
|
the `base64` crate is already a dependency (used in `service/oauth/pkce.rs`), so no new
|
||||||
|
crate is needed for this feature.
|
||||||
|
|
||||||
|
Key architectural constraint discovered while designing this: `controller::input::handle_key`
|
||||||
|
runs on the **daemon** process in `--daemon`/`--attach` mode (`main.rs:359`, inside
|
||||||
|
`handle_daemon_client`), not on the process that owns the user's actual terminal. A raw
|
||||||
|
`io::stdout()` write inside `handle_key` would go to the headless daemon's stdout in that
|
||||||
|
mode, not the user's terminal. The copy action therefore can't write the escape sequence
|
||||||
|
directly from `handle_key` — it has to signal intent via state, and the terminal-owning
|
||||||
|
process (single-process `run_loop_inner`, or the attach client's loop) performs the actual
|
||||||
|
write.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- `Ctrl+Y` copies the most recent `Role::Assistant` message's raw text (not the rendered
|
||||||
|
markdown spans) to the system clipboard via OSC52.
|
||||||
|
- Works identically in single-process mode and in `--daemon`/`--attach` mode.
|
||||||
|
- No new dependency.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No native clipboard fallback (e.g. `arboard`) for terminals that don't honor OSC52 —
|
||||||
|
unsupported terminals silently swallow the escape sequence; no error surfaces to the
|
||||||
|
user beyond the optimistic "Copied to clipboard" toast (there's no ack mechanism in the
|
||||||
|
OSC52 protocol to verify the terminal actually did it).
|
||||||
|
- No copy-last-code-block variant — out of scope for this pass; the whole-message copy
|
||||||
|
covers the common case and is simple to extend later if needed.
|
||||||
|
- No mouse-drag text selection — unrelated, much larger feature; not being built here.
|
||||||
|
|
||||||
|
## State (`misc.rs`)
|
||||||
|
|
||||||
|
- `MiscState` gains `pub pending_clipboard_copy: Option<String>`, initialized to `None` in
|
||||||
|
`MiscState::new()`.
|
||||||
|
|
||||||
|
## `input.rs`
|
||||||
|
|
||||||
|
- New top-level arm alongside the existing `Ctrl+C`/`Ctrl+D` handlers:
|
||||||
|
`KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL)`. It finds the last
|
||||||
|
message in `state.transcript_cache.messages` with `role == Role::Assistant`:
|
||||||
|
- If found: `state.misc.pending_clipboard_copy = Some(msg.content.clone())`.
|
||||||
|
- If not found: push an `Info` toast ("No assistant message to copy yet") and leave
|
||||||
|
`pending_clipboard_copy` as `None`.
|
||||||
|
- Returns `Vec::new()` — this is a direct state mutation inside `handle_key`, matching
|
||||||
|
the existing `Ctrl+S` editor-save precedent (`main.rs`'s editor branch also mutates
|
||||||
|
state/does I/O directly rather than going through an `Action`).
|
||||||
|
|
||||||
|
## OSC52 write helper (`main.rs`)
|
||||||
|
|
||||||
|
```
|
||||||
|
fn write_osc52(stdout: &mut impl Write, text: &str) -> io::Result<()> {
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(text);
|
||||||
|
write!(stdout, "\x1b]52;c;{b64}\x07")?;
|
||||||
|
stdout.flush()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Generic over `impl Write` so both the single-process loop (writing to `io::stdout()`) and
|
||||||
|
tests (writing to a `Vec<u8>` to assert the formatted sequence) can use it without a real
|
||||||
|
terminal.
|
||||||
|
|
||||||
|
## Single-process mode (`run_loop_inner`)
|
||||||
|
|
||||||
|
After the existing `for action in actions { apply_action(state, action); }` block, add:
|
||||||
|
|
||||||
|
```
|
||||||
|
if let Some(text) = state.misc.pending_clipboard_copy.take() {
|
||||||
|
let _ = write_osc52(&mut io::stdout(), &text);
|
||||||
|
state.push_toast(Toast::new(ToastKind::Success, "Copied to clipboard".into()));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Daemon/attach mode
|
||||||
|
|
||||||
|
- `ipc/protocol.rs`: add `DaemonFrame::ClipboardCopy(String)` (alongside `StateUpdate`,
|
||||||
|
`StreamToken`, `SystemNote`, `Closed` — same `Serialize`/`Deserialize` derive).
|
||||||
|
- `handle_daemon_client` (`main.rs`): after each branch that calls `handle_key`/`apply_action`
|
||||||
|
(`KeyPress` and `Submit`, the only two that can reach the input handler), before the
|
||||||
|
existing `send_daemon_update(&mut conn, state)?;` call, add:
|
||||||
|
```
|
||||||
|
if let Some(text) = state.misc.pending_clipboard_copy.take() {
|
||||||
|
conn.send(&DaemonFrame::ClipboardCopy(text))?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Attach-client loop (`main.rs`, the function matching on `DaemonFrame::StateUpdate` /
|
||||||
|
`SystemNote` / `Closed` around line 573): add a `DaemonFrame::ClipboardCopy(text) => {
|
||||||
|
let _ = write_osc52(&mut io::stdout(), &text); client_state.push_toast(...); }` arm,
|
||||||
|
mirroring the existing `SystemNote` handling but performing the actual terminal write
|
||||||
|
since this process — not the daemon — owns the user's terminal.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Inline `#[cfg(test)] mod tests` per CLAUDE.md convention:
|
||||||
|
|
||||||
|
- `input.rs`: `Ctrl+Y` with a transcript containing multiple messages sets
|
||||||
|
`pending_clipboard_copy` to the *last* assistant message's content, ignoring later
|
||||||
|
user/tool messages that might follow it; with no assistant message present, it pushes
|
||||||
|
an info toast and leaves `pending_clipboard_copy` as `None`.
|
||||||
|
- `main.rs`: `write_osc52` writing into a `Vec<u8>` buffer produces the exact expected
|
||||||
|
`\x1b]52;c;<base64>\x07` byte sequence for a known input string.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Diff View for edit/write Tools — Design
|
||||||
|
|
||||||
|
**Status:** Approved, pending implementation plan
|
||||||
|
**Date:** 2026-07-15
|
||||||
|
**Scope:** `src/tool/fs/edit.rs`, `src/tool/fs/write.rs`, `src/view/markdown.rs`, `src/view/chat.rs`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`edit` currently reports only a byte-delta (`"edited {rel} ({N} byte delta)"`), and `write`
|
||||||
|
reports only a byte count. Neither the model nor the user sees what actually changed —
|
||||||
|
just a number. This makes it hard for the model to self-verify an edit landed correctly,
|
||||||
|
and hard for the user to review a change without opening the file. No diff-computing
|
||||||
|
library exists in the dependency tree today.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- `edit` returns a real unified diff (git-style, 3 lines of context) of the change it just
|
||||||
|
made, in place of the byte-delta note.
|
||||||
|
- `write` returns the same kind of diff when it overwrites a file that already existed
|
||||||
|
with valid UTF-8 content; falls back to the current "wrote N bytes" message for new
|
||||||
|
files or non-UTF-8 (binary) overwrites.
|
||||||
|
- Diffs render in the chat view with real color (green add / red remove / cyan hunk
|
||||||
|
header) instead of being flattened to dim/italic like other tool output.
|
||||||
|
- Large diffs are truncated with a trailing count, matching the existing pattern in
|
||||||
|
`read.rs` (`"... ({N} more lines, total {total})"`).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No diff view for any tool besides `edit`/`write` (e.g. no retroactive diffing of
|
||||||
|
`bash_tools.rs` shell edits).
|
||||||
|
- No side-by-side diff layout — unified format only, matching how every other tool
|
||||||
|
output already renders as a single text stream.
|
||||||
|
- No persistence of diff history; each diff is only the delta of the single tool call
|
||||||
|
that produced it, not a cumulative session diff.
|
||||||
|
- No changes to non-tool (assistant/user/system) message rendering or coloring.
|
||||||
|
|
||||||
|
## Dependency
|
||||||
|
|
||||||
|
Add `similar = "3"` (line/word diff crate; permissive MIT/Apache-2.0, no heavy
|
||||||
|
transitive deps). Use `TextDiff::from_lines(old, new).unified_diff().context_radius(3)`,
|
||||||
|
which produces standard `@@ -a,b +c,d @@` hunk headers and `-`/`+`/` `-prefixed lines —
|
||||||
|
no custom diff algorithm needed.
|
||||||
|
|
||||||
|
## Tool changes
|
||||||
|
|
||||||
|
### `edit.rs`
|
||||||
|
|
||||||
|
After computing `new_content` and writing it to disk:
|
||||||
|
|
||||||
|
1. Compute `similar::TextDiff::from_lines(&content, &new_content).unified_diff().context_radius(3).to_string()`.
|
||||||
|
2. Split into lines; if `> MAX_DIFF_LINES` (200), keep the first 200 and append
|
||||||
|
`"... ({N} more lines truncated)"`.
|
||||||
|
3. Wrap the (possibly truncated) diff text in a fenced ` ```diff ` block.
|
||||||
|
4. Replace the byte-delta note in the returned message with this block; keep the
|
||||||
|
existing "Graduated checks matched" / LSP note suffixes in their current position
|
||||||
|
(after the diff block).
|
||||||
|
|
||||||
|
### `write.rs`
|
||||||
|
|
||||||
|
Before overwriting:
|
||||||
|
|
||||||
|
1. If `path.exists()` and `fs::read_to_string(&path)` succeeds (valid UTF-8), capture it
|
||||||
|
as `old_content` and note `is_overwrite = true`.
|
||||||
|
2. If the file doesn't exist, or reading it fails (binary/non-UTF-8), `is_overwrite = false`
|
||||||
|
— no error, just skip the diff path silently.
|
||||||
|
3. After writing, if `is_overwrite`, compute and truncate the diff exactly as in `edit.rs`
|
||||||
|
and append the fenced block to the return message (in addition to the existing
|
||||||
|
"wrote N bytes" line, not instead of it — for `write`, unlike `edit`, the byte count is
|
||||||
|
still useful since it can be a full-file rewrite).
|
||||||
|
4. If not `is_overwrite`, return message is unchanged from today.
|
||||||
|
|
||||||
|
The truncation constant (`MAX_DIFF_LINES = 200`) and truncation message format are
|
||||||
|
shared — factor into a small helper in `tool/fs/helpers.rs` used by both tools.
|
||||||
|
|
||||||
|
## Rendering changes
|
||||||
|
|
||||||
|
### `markdown.rs`
|
||||||
|
|
||||||
|
- `render_markdown` gains a `dim: bool` parameter: `render_markdown(text, width, dim)`.
|
||||||
|
- Capture the fence language from `Tag::CodeBlock(CodeBlockKind::Fenced(lang))` (today
|
||||||
|
matched as `CodeBlock(_)`, discarding the language). Track `in_diff_block: bool` when
|
||||||
|
`lang == "diff"`.
|
||||||
|
- Inside a diff block, process text line-by-line instead of as one blob: a line starting
|
||||||
|
with `+` (not `+++`) is styled green, `-` (not `---`) red, `@@` cyan/muted, everything
|
||||||
|
else (context lines, `+++`/`---` file headers) uses the existing code-block teal.
|
||||||
|
- When `dim` is `true`: every span keeps its assigned color as computed above, but
|
||||||
|
non-diff spans (headings, links, plain text, non-diff code blocks, table cells) fall
|
||||||
|
back to `Theme::TEXT_DIM` + `Modifier::ITALIC` instead of their normal palette color —
|
||||||
|
this replicates today's "tool output is always dim" behavior for everything except
|
||||||
|
diff lines.
|
||||||
|
- When `dim` is `false`: behavior is unchanged from today (full color, used for
|
||||||
|
assistant/user/system messages).
|
||||||
|
|
||||||
|
### `chat.rs`
|
||||||
|
|
||||||
|
- `Role::Tool` branch: replace the two manual span-remapping loops (that force every
|
||||||
|
span to `dim_italic`) with a direct call to `render_markdown(&content, content_width, true)`
|
||||||
|
and use the returned spans as-is.
|
||||||
|
- All other roles: call `render_markdown(&content_str, content_width, false)` — same
|
||||||
|
call as today, just with the new explicit `false` argument.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Inline `#[cfg(test)] mod tests` per CLAUDE.md convention:
|
||||||
|
|
||||||
|
- `edit.rs`: a normal single-replace edit produces a diff block with matching
|
||||||
|
`-`/`+` lines; a `replace_all` across 250+ lines truncates at 200 with the correct
|
||||||
|
trailing count.
|
||||||
|
- `write.rs`: writing a brand-new file keeps the old "wrote N bytes" message with no
|
||||||
|
diff block; overwriting an existing UTF-8 file produces a diff block; overwriting
|
||||||
|
a path that reads as invalid UTF-8 (simulate via non-UTF-8 bytes) falls back to the
|
||||||
|
byte-count message without erroring.
|
||||||
|
- `markdown.rs`: a fenced ` ```diff ` block with `+`/`-`/`@@` lines produces spans with
|
||||||
|
the expected fg colors under `dim=true` (diff lines colored) and confirms non-diff
|
||||||
|
text in the same call falls back to `TEXT_DIM` + italic.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Fuzzy @file-mention Autocomplete — Design
|
||||||
|
|
||||||
|
**Status:** Approved, pending implementation plan
|
||||||
|
**Date:** 2026-07-15
|
||||||
|
**Scope:** `src/app/state/misc.rs`, `src/app/state/rest.rs`, `src/controller/input.rs`,
|
||||||
|
`src/view/mod.rs`, `src/tool/mod.rs`, `src/tool/fs/write.rs`, `src/main.rs`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The chat input already has a dropdown autocomplete (`InputState` in `misc.rs`), but it
|
||||||
|
only covers slash commands: it requires the whole buffer to start with `/` and filters a
|
||||||
|
fixed `COMMANDS` list by prefix. There's no way to reference a project file from the chat
|
||||||
|
input without typing its exact path from memory. The existing `dir_cache` (used by the
|
||||||
|
`dir_cache_update` tool) looks like it could serve this but doesn't: it's a single,
|
||||||
|
non-recursive directory snapshot, overwritten on each LLM-driven `dir_cache_update` call —
|
||||||
|
not a standing, recursive, whole-workspace file index. `search.rs`'s `Grep`/`Glob` tools
|
||||||
|
already do the recursive, `.gitignore`-respecting walk this feature needs, via
|
||||||
|
`ignore::Walk`.
|
||||||
|
|
||||||
|
Also relevant: there is no persistent async runtime driving the TUI loop. `main.rs`
|
||||||
|
constructs a `tokio::runtime::Runtime` but never `.enter()`s or `block_on`s it in the
|
||||||
|
main loop — `run_loop` is fully synchronous. The one existing async-flavored pattern
|
||||||
|
(`dir_cache_update.rs`) spins up a throwaway one-shot runtime purely to satisfy
|
||||||
|
`tokio::sync::RwLock`'s API, then discards it. This feature does not need that ceremony:
|
||||||
|
a plain `std::sync::RwLock` is enough, since every reader/writer here is synchronous
|
||||||
|
(`handle_key`, `Tool::run`, and the index-build thread all being plain sync code).
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Typing `@` at a word boundary (start of buffer or after whitespace) in the chat input,
|
||||||
|
followed by non-whitespace characters, opens a dropdown of fuzzy-matched project file
|
||||||
|
paths, live-updating as the query changes.
|
||||||
|
- Selecting a candidate splices `@relative/path ` into the buffer at the mention's
|
||||||
|
position (not a whole-buffer replace) and the user keeps typing.
|
||||||
|
- Candidates come from a background-built, whole-workspace file index — not the
|
||||||
|
LLM-facing `dir_cache`.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No auto-reading of the selected file's content into the conversation — the inserted
|
||||||
|
`@path` is plain text; the model reads it via the `read` tool if it wants to, same as
|
||||||
|
any other path reference.
|
||||||
|
- No live re-filter on Backspace/Delete while a mention dropdown is open — mirrors the
|
||||||
|
slash-command dropdown's existing behavior (closes on Backspace/Delete rather than
|
||||||
|
refiltering). Not fixing that for commands here; file mentions just inherit it for
|
||||||
|
consistency.
|
||||||
|
- No periodic re-walk of the index after startup — only single-file incremental updates
|
||||||
|
on file creation (see below). A deleted or renamed file may show a stale entry until
|
||||||
|
restart; acceptable since selecting it just inserts text, it doesn't touch the
|
||||||
|
filesystem.
|
||||||
|
- No fuzzy matching over directories, only files.
|
||||||
|
|
||||||
|
## Dependency
|
||||||
|
|
||||||
|
Add `nucleo-matcher = "0.3"` (the fuzzy-matching engine from the Helix editor project;
|
||||||
|
small, actively maintained, no heavy transitive deps).
|
||||||
|
|
||||||
|
## Index storage & construction
|
||||||
|
|
||||||
|
- New type in `misc.rs`: `MentionIndex { entries: Arc<std::sync::RwLock<Vec<String>>> }`, with `MentionIndex::new()`, `set(&self, paths: Vec<String>)`, and `snapshot(&self) -> Vec<String>` (both plain sync `.write()`/`.read()`, no `try_`/async — a std `RwLock` doesn't block indefinitely here since every hold is a quick vec swap or clone).
|
||||||
|
- `AppStateRest` gets a `pub mention_index: MentionIndex` field, initialized in `AppStateRest::new()`, threaded into `ToolCtx`/`ToolCtxBuilder` the same way `dir_cache` is (new `mention_index` field on both, wired through `tool_ctx()`/`tool_ctx_for()`/`build()`).
|
||||||
|
- In `main.rs`, right after `AppStateRest::new(...)` in the single-process TUI path and the daemon path (not the attach-only client path, which has no local `ToolCtx`), spawn `std::thread::spawn` that:
|
||||||
|
1. For each workspace root (index `i`, path `w`): `ignore::Walk::new(w)`, keep only files, strip `w` as prefix, format as `rel` for `i == 0` or `[i]rel` for `i > 0` (matching `resolve_path`'s existing workspace-index convention).
|
||||||
|
2. Stop collecting once the total across all workspaces hits 50,000 entries (repos larger than that are rare here; this is a soft cap to bound memory/scan time, not a hard requirement).
|
||||||
|
3. Call `mention_index.set(all_paths)`.
|
||||||
|
- `write.rs`: after a successful write, if the target path did **not** exist before the write (i.e. this created a new file, not an overwrite), compute its relative/workspace-prefixed form and push it onto `ctx.mention_index`'s vec directly (read-modify-write under the same lock) rather than re-walking.
|
||||||
|
|
||||||
|
## `InputState` changes (`misc.rs`)
|
||||||
|
|
||||||
|
- New `pub enum AutocompleteKind { Command, FileMention }`.
|
||||||
|
- `InputState` gains `pub autocomplete_kind: AutocompleteKind` (default `Command`) and
|
||||||
|
`pub mention_start: usize` (byte offset of the triggering `@`).
|
||||||
|
- New `fn mention_query_at_cursor(&self) -> Option<(usize, String)>`: scans backward from
|
||||||
|
`self.cursor` for an `@`; the scan stops (returns `None`) if it hits whitespace before
|
||||||
|
finding `@`. The `@` only counts as a trigger if it's at buffer start or immediately
|
||||||
|
preceded by whitespace. Returns `(byte offset of '@', query text between '@' and cursor)`.
|
||||||
|
- New `fn open_mention_autocomplete(&mut self, files: &[String])`: calls
|
||||||
|
`mention_query_at_cursor()`; if `None`, calls `close_autocomplete()` and returns. If
|
||||||
|
`Some((start, query))`, fuzzy-matches `query` against `files` via `nucleo-matcher`,
|
||||||
|
keeps the top 10 by score, sets `autocomplete_candidates`, `autocomplete_kind =
|
||||||
|
FileMention`, `mention_start = start`, `autocomplete_visible = !candidates.is_empty()`.
|
||||||
|
- `select_autocomplete()` becomes kind-aware:
|
||||||
|
- `Command` (today's behavior, unchanged): `buffer = candidate.clone()`, `cursor =
|
||||||
|
buffer.len()`.
|
||||||
|
- `FileMention`: `buffer.replace_range(mention_start..cursor, &format!("@{candidate} "))`,
|
||||||
|
`cursor = mention_start + candidate.len() + 2` (the `@` plus the candidate plus the
|
||||||
|
trailing space).
|
||||||
|
- Both paths end with `close_autocomplete()`, same as today.
|
||||||
|
|
||||||
|
## `input.rs` wiring
|
||||||
|
|
||||||
|
- `KeyCode::Char(c)` handler: after `state.input.insert(c)`, keep the existing
|
||||||
|
`if buffer.starts_with('/') { open_autocomplete() }` check, and add an `else if let
|
||||||
|
Some(_) = state.input.mention_query_at_cursor() { state.input.open_mention_autocomplete(&state.mention_index.snapshot()) }` branch. These are mutually exclusive in practice (a
|
||||||
|
buffer starting with `/` is a slash command, not a sentence with an `@mention` in it).
|
||||||
|
- `KeyCode::Backspace` / `KeyCode::Delete`: unchanged — both already just call
|
||||||
|
`close_autocomplete()` when a dropdown is visible, regardless of kind. No new branching
|
||||||
|
needed since `close_autocomplete()` already resets `autocomplete_kind` isn't touched but
|
||||||
|
becomes irrelevant once `autocomplete_visible` is false.
|
||||||
|
- `KeyCode::Tab`: currently gated on `buffer.starts_with('/')`. Extend the condition to
|
||||||
|
also fire when `autocomplete_kind == FileMention && autocomplete_visible` so Tab cycles
|
||||||
|
file-mention candidates too.
|
||||||
|
- `KeyCode::Enter`: unchanged — already calls `select_autocomplete()` whenever
|
||||||
|
`autocomplete_visible`, which is now kind-aware internally.
|
||||||
|
|
||||||
|
## Rendering (`view/mod.rs`)
|
||||||
|
|
||||||
|
- `render_input_bar`'s dropdown block reuses the exact same list-rendering code (already
|
||||||
|
generic over `autocomplete_candidates`/`autocomplete_idx`); only the title changes based
|
||||||
|
on `state.input.autocomplete_kind`: `" ⌘ Commands "` (unchanged) vs `" 📁 Files "`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Inline `#[cfg(test)] mod tests` per CLAUDE.md convention:
|
||||||
|
|
||||||
|
- `misc.rs`: `mention_query_at_cursor` returns the right `(start, query)` for `@` at
|
||||||
|
buffer start, `@` after a space mid-sentence, and correctly returns `None` when the `@`
|
||||||
|
is mid-word (e.g. `foo@bar`) or when whitespace exists between the `@` and the cursor.
|
||||||
|
`select_autocomplete` for `FileMention` splices correctly into a buffer with text before
|
||||||
|
and after the mention span; `Command` selection still replaces the whole buffer as
|
||||||
|
before.
|
||||||
|
- `write.rs`: creating a new file appends its path to the shared `mention_index`;
|
||||||
|
overwriting an existing file does not add a duplicate entry.
|
||||||
|
- Index construction: not unit-tested directly (it's a `std::thread::spawn` walking the
|
||||||
|
real filesystem at startup) — covered implicitly by exercising the app manually per the
|
||||||
|
`verify` skill during implementation.
|
||||||
+212
-9
@@ -27,6 +27,51 @@ impl DirCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages the viewport scroll offset.
|
/// Manages the viewport scroll offset.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ScrollState {
|
pub struct ScrollState {
|
||||||
@@ -72,6 +117,8 @@ pub struct InputState {
|
|||||||
pub autocomplete_candidates: Vec<String>,
|
pub autocomplete_candidates: Vec<String>,
|
||||||
pub autocomplete_idx: usize,
|
pub autocomplete_idx: usize,
|
||||||
pub autocomplete_visible: bool,
|
pub autocomplete_visible: bool,
|
||||||
|
pub autocomplete_kind: AutocompleteKind,
|
||||||
|
pub mention_start: usize,
|
||||||
pub history_file: Option<PathBuf>,
|
pub history_file: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +153,8 @@ impl InputState {
|
|||||||
autocomplete_candidates: Vec::new(),
|
autocomplete_candidates: Vec::new(),
|
||||||
autocomplete_idx: 0,
|
autocomplete_idx: 0,
|
||||||
autocomplete_visible: false,
|
autocomplete_visible: false,
|
||||||
|
autocomplete_kind: AutocompleteKind::Command,
|
||||||
|
mention_start: 0,
|
||||||
history_file: None,
|
history_file: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +165,8 @@ impl InputState {
|
|||||||
self.autocomplete_candidates.clear();
|
self.autocomplete_candidates.clear();
|
||||||
self.autocomplete_prefix.clear();
|
self.autocomplete_prefix.clear();
|
||||||
self.autocomplete_idx = 0;
|
self.autocomplete_idx = 0;
|
||||||
|
self.autocomplete_kind = AutocompleteKind::Command;
|
||||||
|
self.mention_start = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open or refresh the autocomplete dropdown by filtering `COMMANDS`
|
/// Open or refresh the autocomplete dropdown by filtering `COMMANDS`
|
||||||
@@ -138,6 +189,54 @@ impl InputState {
|
|||||||
.map(std::string::ToString::to_string)
|
.map(std::string::ToString::to_string)
|
||||||
.collect();
|
.collect();
|
||||||
self.autocomplete_prefix = prefix;
|
self.autocomplete_prefix = prefix;
|
||||||
|
self.autocomplete_kind = AutocompleteKind::Command;
|
||||||
|
self.autocomplete_idx = 0;
|
||||||
|
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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_idx = 0;
|
||||||
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
||||||
}
|
}
|
||||||
@@ -154,19 +253,41 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accept the currently selected autocomplete candidate, placing it
|
/// Accept the currently selected autocomplete candidate.
|
||||||
/// in the buffer and closing the dropdown.
|
///
|
||||||
|
/// `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.
|
/// Return: `true` if a candidate was selected, `false` if none existed.
|
||||||
pub fn select_autocomplete(&mut self) -> bool {
|
pub fn select_autocomplete(&mut self) -> bool {
|
||||||
if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) {
|
let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx).cloned() else {
|
||||||
self.buffer = candidate.clone();
|
return false;
|
||||||
self.cursor = self.buffer.len();
|
};
|
||||||
self.close_autocomplete();
|
match self.autocomplete_kind {
|
||||||
true
|
AutocompleteKind::Command => {
|
||||||
} else {
|
self.buffer = candidate;
|
||||||
false
|
self.cursor = self.buffer.len();
|
||||||
|
}
|
||||||
|
AutocompleteKind::FileMention => {
|
||||||
|
// Cursor movement (Left/Right) does not close the dropdown, so
|
||||||
|
// by the time Enter is pressed `mention_start` may no longer
|
||||||
|
// describe a valid range against the current cursor/buffer
|
||||||
|
// (e.g. the cursor moved left past the '@'). Splicing on a
|
||||||
|
// stale range would panic (`start > end`) or, even when it
|
||||||
|
// doesn't panic, produce a nonsensical replacement. Treat a
|
||||||
|
// stale mention context the same as "nothing selected".
|
||||||
|
if self.cursor < self.mention_start || self.mention_start > self.buffer.len() {
|
||||||
|
self.close_autocomplete();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Legacy inline tab-complete — opens the dropdown on first Tab press,
|
/// Legacy inline tab-complete — opens the dropdown on first Tab press,
|
||||||
@@ -290,6 +411,7 @@ pub struct MiscState {
|
|||||||
pub tick_count: u64,
|
pub tick_count: u64,
|
||||||
pub todo_content: String,
|
pub todo_content: String,
|
||||||
pub lesson_running: bool,
|
pub lesson_running: bool,
|
||||||
|
pub pending_clipboard_copy: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MiscState {
|
impl MiscState {
|
||||||
@@ -309,6 +431,7 @@ impl MiscState {
|
|||||||
tick_count: 0,
|
tick_count: 0,
|
||||||
todo_content: String::new(),
|
todo_content: String::new(),
|
||||||
lesson_running: false,
|
lesson_running: false,
|
||||||
|
pending_clipboard_copy: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,3 +448,83 @@ impl MiscState {
|
|||||||
expired
|
expired
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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_file_mention_with_stale_cursor_before_mention_start_does_not_panic() {
|
||||||
|
// Simulates: user typed "foo @rea" (mention_start = 4, cursor = 8,
|
||||||
|
// dropdown open), then pressed Left 5 times without closing the
|
||||||
|
// dropdown, moving the cursor to byte 3 (before the '@'). Selecting
|
||||||
|
// now must not panic on `replace_range(4..3, ...)`.
|
||||||
|
let mut input = input_with("foo @rea", 3);
|
||||||
|
input.autocomplete_candidates = vec!["src/main.rs".to_string()];
|
||||||
|
input.autocomplete_idx = 0;
|
||||||
|
input.autocomplete_kind = AutocompleteKind::FileMention;
|
||||||
|
input.mention_start = 4;
|
||||||
|
assert!(!input.select_autocomplete());
|
||||||
|
assert!(!input.autocomplete_visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn misc_state_starts_with_no_pending_clipboard_copy() {
|
||||||
|
let misc = MiscState::new();
|
||||||
|
assert!(misc.pending_clipboard_copy.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+51
-1
@@ -9,7 +9,7 @@ use std::path::PathBuf;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use super::misc::{DirCache, InputState, MiscState, ScrollState};
|
use super::misc::{DirCache, InputState, MentionIndex, MiscState, ScrollState};
|
||||||
use super::runtime::{SessionRuntime, TurnEvent};
|
use super::runtime::{SessionRuntime, TurnEvent};
|
||||||
use super::types::{Origin, Toast, TranscriptCache};
|
use super::types::{Origin, Toast, TranscriptCache};
|
||||||
use crate::app::lsp::LspManager;
|
use crate::app::lsp::LspManager;
|
||||||
@@ -54,6 +54,7 @@ pub struct AppStateRest {
|
|||||||
pub memory_dir: PathBuf,
|
pub memory_dir: PathBuf,
|
||||||
pub worktrees_dir: PathBuf,
|
pub worktrees_dir: PathBuf,
|
||||||
pub dir_cache: Arc<RwLock<DirCache>>,
|
pub dir_cache: Arc<RwLock<DirCache>>,
|
||||||
|
pub mention_index: MentionIndex,
|
||||||
pub edit_log: EditLog,
|
pub edit_log: EditLog,
|
||||||
pub session_runtime: Option<SessionRuntime>,
|
pub session_runtime: Option<SessionRuntime>,
|
||||||
pub sessions: Vec<crate::model::session::Session>,
|
pub sessions: Vec<crate::model::session::Session>,
|
||||||
@@ -110,6 +111,7 @@ impl AppStateRest {
|
|||||||
turn_in_flight: Arc::new(Mutex::new(false)),
|
turn_in_flight: Arc::new(Mutex::new(false)),
|
||||||
abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
||||||
|
mention_index: MentionIndex::new(),
|
||||||
edit_log: EditLog::new(session_dir),
|
edit_log: EditLog::new(session_dir),
|
||||||
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
|
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
|
||||||
workflow_engine: WorkflowEngine::new(),
|
workflow_engine: WorkflowEngine::new(),
|
||||||
@@ -207,6 +209,53 @@ impl AppStateRest {
|
|||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn the background thread that walks every workspace root and
|
||||||
|
/// populates `mention_index` for `@file` mention autocomplete.
|
||||||
|
///
|
||||||
|
/// Why a separate method, not called from `new()`: the attach-only
|
||||||
|
/// TUI client also constructs an `AppStateRest` (for local rendering
|
||||||
|
/// state) but never runs tools or `handle_key` locally — it forwards
|
||||||
|
/// keystrokes to the daemon over IPC, which has its own `AppStateRest`
|
||||||
|
/// with its own index. Spawning this walk in the attach client would
|
||||||
|
/// waste a full workspace scan for an index nothing there consumes.
|
||||||
|
/// Callers that DO need the index (single-process mode, the daemon)
|
||||||
|
/// call this explicitly after construction.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
pub fn spawn_mention_index_build(&self) {
|
||||||
|
let mention_index = self.mention_index.clone();
|
||||||
|
let roots = self.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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether an agent turn is currently running.
|
/// Whether an agent turn is currently running.
|
||||||
///
|
///
|
||||||
/// Return: `false` (and logs a warning) if the mutex is poisoned, rather
|
/// Return: `false` (and logs a warning) if the mutex is poisoned, rather
|
||||||
@@ -278,6 +327,7 @@ impl AppStateRest {
|
|||||||
memory_dir: self.memory_dir.clone(),
|
memory_dir: self.memory_dir.clone(),
|
||||||
worktrees_dir: self.worktrees_dir.clone(),
|
worktrees_dir: self.worktrees_dir.clone(),
|
||||||
dir_cache: self.dir_cache.clone(),
|
dir_cache: self.dir_cache.clone(),
|
||||||
|
mention_index: self.mention_index.clone(),
|
||||||
origin,
|
origin,
|
||||||
graduated_checks: Vec::new(),
|
graduated_checks: Vec::new(),
|
||||||
lsp_manager: self.lsp_manager.clone(),
|
lsp_manager: self.lsp_manager.clone(),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|||||||
use crate::app::mode;
|
use crate::app::mode;
|
||||||
use crate::app::runtime::actions::Action;
|
use crate::app::runtime::actions::Action;
|
||||||
use crate::app::runtime::commands::apply_command;
|
use crate::app::runtime::commands::apply_command;
|
||||||
|
use crate::app::state::misc::AutocompleteKind;
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
use crate::app::state::types::Overlay;
|
use crate::app::state::types::Overlay;
|
||||||
use crate::controller::command::parse_command;
|
use crate::controller::command::parse_command;
|
||||||
@@ -131,6 +132,23 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
vec![Action::CloseOverlay]
|
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()
|
||||||
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if state.input.autocomplete_visible {
|
if state.input.autocomplete_visible {
|
||||||
state.input.select_autocomplete();
|
state.input.select_autocomplete();
|
||||||
@@ -247,6 +265,11 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
state.input.tab_complete();
|
state.input.tab_complete();
|
||||||
}
|
}
|
||||||
state.dirty = true;
|
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()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -263,6 +286,8 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
// without requiring an extra Tab press.
|
// without requiring an extra Tab press.
|
||||||
if state.input.buffer.starts_with('/') {
|
if state.input.buffer.starts_with('/') {
|
||||||
state.input.open_autocomplete();
|
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()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -363,3 +388,45 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,5 +91,6 @@ pub enum DaemonFrame {
|
|||||||
StateUpdate(Box<StatePayload>),
|
StateUpdate(Box<StatePayload>),
|
||||||
StreamToken(String),
|
StreamToken(String),
|
||||||
SystemNote { kind: String, message: String },
|
SystemNote { kind: String, message: String },
|
||||||
|
ClipboardCopy(String),
|
||||||
Closed,
|
Closed,
|
||||||
}
|
}
|
||||||
|
|||||||
+51
@@ -109,6 +109,7 @@ fn run_single_process() -> Result<()> {
|
|||||||
&session_dir,
|
&session_dir,
|
||||||
store.memory_dir,
|
store.memory_dir,
|
||||||
);
|
);
|
||||||
|
state.spawn_mention_index_build();
|
||||||
state.sessions = model::session::Session::list(&store.base_dir);
|
state.sessions = model::session::Session::list(&store.base_dir);
|
||||||
|
|
||||||
|
|
||||||
@@ -396,6 +397,9 @@ fn handle_daemon_client(
|
|||||||
running = false;
|
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)?;
|
send_daemon_update(&mut conn, state)?;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -439,6 +443,7 @@ fn run_daemon() -> Result<()> {
|
|||||||
&session_dir,
|
&session_dir,
|
||||||
store.memory_dir,
|
store.memory_dir,
|
||||||
);
|
);
|
||||||
|
state.spawn_mention_index_build();
|
||||||
state.sessions = model::session::Session::list(&store.base_dir);
|
state.sessions = model::session::Session::list(&store.base_dir);
|
||||||
|
|
||||||
let _rt = tokio::runtime::Runtime::new()?;
|
let _rt = tokio::runtime::Runtime::new()?;
|
||||||
@@ -583,6 +588,15 @@ fn run_attach(session_id: &str) -> Result<()> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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 => {
|
Some(ipc::protocol::DaemonFrame::Closed) | None => {
|
||||||
client_state.quit = true;
|
client_state.quit = true;
|
||||||
}
|
}
|
||||||
@@ -627,6 +641,21 @@ fn run_loop(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
|
||||||
/// The core single-process render/input loop.
|
/// The core single-process render/input loop.
|
||||||
///
|
///
|
||||||
/// Flow: until `state.quit` → drain expired toasts → draw the frame →
|
/// Flow: until `state.quit` → drain expired toasts → draw the frame →
|
||||||
@@ -665,6 +694,13 @@ fn run_loop_inner(
|
|||||||
for action in actions {
|
for action in actions {
|
||||||
apply_action(state, action);
|
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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Paste(text) => {
|
Event::Paste(text) => {
|
||||||
@@ -699,3 +735,18 @@ fn run_loop_inner(
|
|||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::write_osc52;
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+61
-8
@@ -9,7 +9,8 @@ use super::super::Tool;
|
|||||||
use super::super::ToolCtx;
|
use super::super::ToolCtx;
|
||||||
use super::super::resolve_path;
|
use super::super::resolve_path;
|
||||||
use super::super::check_graduated_checks;
|
use super::super::check_graduated_checks;
|
||||||
use super::helpers::arg_str;
|
use super::helpers::{self, arg_str};
|
||||||
|
use similar::TextDiff;
|
||||||
|
|
||||||
/// Tool: replace text in a file. Requires the old string to be unique unless `replace_all` is true.
|
/// Tool: replace text in a file. Requires the old string to be unique unless `replace_all` is true.
|
||||||
pub struct Edit;
|
pub struct Edit;
|
||||||
@@ -99,11 +100,12 @@ impl Tool for Edit {
|
|||||||
};
|
};
|
||||||
fs::write(&path, &new_content)
|
fs::write(&path, &new_content)
|
||||||
.map_err(|e| anyhow!("failed to write '{rel}': {e}"))?;
|
.map_err(|e| anyhow!("failed to write '{rel}': {e}"))?;
|
||||||
let bytes_diff = if new_content.len() > content.len() {
|
let text_diff = TextDiff::from_lines(content.as_str(), new_content.as_str());
|
||||||
new_content.len() - content.len()
|
let diff_text = format!(
|
||||||
} else {
|
"{}",
|
||||||
content.len() - new_content.len()
|
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.
|
// 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
|
// Never fail the edit because of this — LSP errors are surfaced as a
|
||||||
// trailing annotation on the success message instead.
|
// trailing annotation on the success message instead.
|
||||||
@@ -114,9 +116,60 @@ impl Tool for Edit {
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
if check_matches.is_empty() {
|
if check_matches.is_empty() {
|
||||||
Ok(format!("edited {} ({} byte delta){}", rel, bytes_diff as isize, lsp_note))
|
Ok(format!("edited {rel}\n{diff_block}{lsp_note}"))
|
||||||
} else {
|
} else {
|
||||||
Ok(format!("edited {} ({} byte delta). Graduated checks matched: {}{}", rel, bytes_diff as isize, check_matches.join(", "), lsp_note))
|
Ok(format!("edited {rel}. Graduated checks matched: {}\n{diff_block}{lsp_note}", check_matches.join(", ")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,22 @@ pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> St
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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"))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -73,4 +89,18 @@ mod tests {
|
|||||||
let args = json!({"key": null});
|
let args = json!({"key": null});
|
||||||
assert!(arg_str(&args, "key").is_err());
|
assert!(arg_str(&args, "key").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-4
@@ -7,7 +7,8 @@ use super::super::Tool;
|
|||||||
use super::super::ToolCtx;
|
use super::super::ToolCtx;
|
||||||
use super::super::resolve_path;
|
use super::super::resolve_path;
|
||||||
use super::super::check_graduated_checks;
|
use super::super::check_graduated_checks;
|
||||||
use super::helpers::arg_str;
|
use super::helpers::{self, arg_str};
|
||||||
|
use similar::TextDiff;
|
||||||
|
|
||||||
/// Tool: write content to a file, auto-creating parent directories as needed.
|
/// Tool: write content to a file, auto-creating parent directories as needed.
|
||||||
pub struct Write;
|
pub struct Write;
|
||||||
@@ -58,12 +59,17 @@ impl Tool for Write {
|
|||||||
}
|
}
|
||||||
let check_matches = check_graduated_checks(&rel, &content, &ctx.graduated_checks);
|
let check_matches = check_graduated_checks(&rel, &content, &ctx.graduated_checks);
|
||||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
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() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
.map_err(|e| anyhow!("failed to create parent directories for '{rel}': {e}"))?;
|
.map_err(|e| anyhow!("failed to create parent directories for '{rel}': {e}"))?;
|
||||||
}
|
}
|
||||||
fs::write(&path, &content)
|
fs::write(&path, &content)
|
||||||
.map_err(|e| anyhow!("failed to write '{rel}': {e}"))?;
|
.map_err(|e| anyhow!("failed to write '{rel}': {e}"))?;
|
||||||
|
if !existed_before {
|
||||||
|
ctx.mention_index.push(rel.clone());
|
||||||
|
}
|
||||||
// Notify the LSP server of the on-disk change so diagnostics stay in
|
// 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
|
// sync. Never fails the write itself: a lock failure or LSP error is
|
||||||
// folded into the returned message instead of propagated as an Err.
|
// folded into the returned message instead of propagated as an Err.
|
||||||
@@ -73,10 +79,94 @@ impl Tool for Write {
|
|||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
if check_matches.is_empty() {
|
// Only emit a diff when the file existed before and was valid UTF-8;
|
||||||
Ok(format!("wrote {} bytes to {}{}", content.len(), rel, lsp_note))
|
// 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 {
|
} else {
|
||||||
Ok(format!("wrote {} bytes to {}{}. Graduated checks matched: {}", content.len(), rel, lsp_note, check_matches.join(", ")))
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub struct ToolCtx {
|
|||||||
pub memory_dir: PathBuf,
|
pub memory_dir: PathBuf,
|
||||||
pub worktrees_dir: PathBuf,
|
pub worktrees_dir: PathBuf,
|
||||||
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
||||||
|
pub mention_index: super::app::state::misc::MentionIndex,
|
||||||
pub origin: crate::app::state::types::Origin,
|
pub origin: crate::app::state::types::Origin,
|
||||||
pub graduated_checks: Vec<GraduatedCheck>,
|
pub graduated_checks: Vec<GraduatedCheck>,
|
||||||
pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>,
|
pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>,
|
||||||
@@ -94,6 +95,7 @@ pub struct ToolCtxBuilder {
|
|||||||
pub memory_dir: PathBuf,
|
pub memory_dir: PathBuf,
|
||||||
pub worktrees_dir: PathBuf,
|
pub worktrees_dir: PathBuf,
|
||||||
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
||||||
|
pub mention_index: super::app::state::misc::MentionIndex,
|
||||||
pub origin: crate::app::state::types::Origin,
|
pub origin: crate::app::state::types::Origin,
|
||||||
pub graduated_checks: Vec<GraduatedCheck>,
|
pub graduated_checks: Vec<GraduatedCheck>,
|
||||||
pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>,
|
pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>,
|
||||||
@@ -110,6 +112,7 @@ impl Default for ToolCtxBuilder {
|
|||||||
memory_dir: PathBuf::new(),
|
memory_dir: PathBuf::new(),
|
||||||
worktrees_dir: PathBuf::new(),
|
worktrees_dir: PathBuf::new(),
|
||||||
dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())),
|
dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())),
|
||||||
|
mention_index: super::app::state::misc::MentionIndex::new(),
|
||||||
origin: crate::app::state::types::Origin::Main,
|
origin: crate::app::state::types::Origin::Main,
|
||||||
graduated_checks: Vec::new(),
|
graduated_checks: Vec::new(),
|
||||||
lsp_manager: Arc::new(Mutex::new(crate::app::lsp::LspManager::new())),
|
lsp_manager: Arc::new(Mutex::new(crate::app::lsp::LspManager::new())),
|
||||||
@@ -138,6 +141,7 @@ impl ToolCtxBuilder {
|
|||||||
memory_dir: self.memory_dir,
|
memory_dir: self.memory_dir,
|
||||||
worktrees_dir: self.worktrees_dir,
|
worktrees_dir: self.worktrees_dir,
|
||||||
dir_cache: self.dir_cache,
|
dir_cache: self.dir_cache,
|
||||||
|
mention_index: self.mention_index,
|
||||||
origin: self.origin,
|
origin: self.origin,
|
||||||
graduated_checks: self.graduated_checks,
|
graduated_checks: self.graduated_checks,
|
||||||
lsp_manager: self.lsp_manager,
|
lsp_manager: self.lsp_manager,
|
||||||
|
|||||||
+4
-7
@@ -130,15 +130,12 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
|||||||
msg.content.clone()
|
msg.content.clone()
|
||||||
};
|
};
|
||||||
let dim = Style::default().fg(Theme::TEXT_DIM);
|
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_spans = super::markdown::render_markdown(&content, content_width, true);
|
||||||
let content_lines = split_spans_into_lines(content_spans);
|
let content_lines = split_spans_into_lines(content_spans);
|
||||||
let mut lines_iter = content_lines.into_iter();
|
let mut lines_iter = content_lines.into_iter();
|
||||||
|
|
||||||
let first_spans = lines_iter.next().map_or_else(Vec::new, |line| {
|
let first_spans = lines_iter.next().map_or_else(Vec::new, |line| line.spans);
|
||||||
line.spans.into_iter().map(|s| Span::styled(s.content, dim_italic)).collect()
|
|
||||||
});
|
|
||||||
let mut spans = vec![
|
let mut spans = vec![
|
||||||
Span::raw(" ".repeat(PREFIX_WIDTH)),
|
Span::raw(" ".repeat(PREFIX_WIDTH)),
|
||||||
Span::styled("↳ ", dim),
|
Span::styled("↳ ", dim),
|
||||||
@@ -148,7 +145,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
|||||||
|
|
||||||
for line in lines_iter {
|
for line in lines_iter {
|
||||||
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
|
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
|
||||||
spans.extend(line.spans.into_iter().map(|s| Span::styled(s.content, dim_italic)));
|
spans.extend(line.spans);
|
||||||
display_lines.push(Line::from(spans));
|
display_lines.push(Line::from(spans));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -177,7 +174,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
|||||||
msg.content.clone()
|
msg.content.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let content_spans = super::markdown::render_markdown(&content_str, content_width);
|
let content_spans = super::markdown::render_markdown(&content_str, content_width, false);
|
||||||
let content_lines = split_spans_into_lines(content_spans);
|
let content_lines = split_spans_into_lines(content_spans);
|
||||||
let mut lines_iter = content_lines.into_iter();
|
let mut lines_iter = content_lines.into_iter();
|
||||||
|
|
||||||
|
|||||||
+118
-24
@@ -19,21 +19,54 @@ use ratatui::style::{Modifier, Style};
|
|||||||
use ratatui::text::Span;
|
use ratatui::text::Span;
|
||||||
use super::theme::Theme;
|
use super::theme::Theme;
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Render a markdown string into styled terminal spans, word-wrapped to `width`.
|
/// Render a markdown string into styled terminal spans, word-wrapped to `width`.
|
||||||
///
|
///
|
||||||
/// Flow: `pulldown_cmark` parses `text` into an event stream → each
|
/// Flow: `pulldown_cmark` parses `text` into an event stream → each
|
||||||
/// Start/End/Text/Code/Break event is translated into styled `Span`s →
|
/// Start/End/Text/Code/Break event is translated into styled `Span`s →
|
||||||
/// if `width > 0`, a second pass wraps long lines.
|
/// 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`
|
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
|
||||||
/// turns it back into `Line`s for the Paragraph widget.
|
/// turns it back into `Line`s for the Paragraph widget.
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>> {
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
let mut options = pulldown_cmark::Options::empty();
|
let mut options = pulldown_cmark::Options::empty();
|
||||||
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
|
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
|
||||||
let parser = pulldown_cmark::Parser::new_ext(text, options);
|
let parser = pulldown_cmark::Parser::new_ext(text, options);
|
||||||
let mut in_code_block = false;
|
let mut in_code_block = false;
|
||||||
|
let mut in_diff_block = false;
|
||||||
let mut in_heading = false;
|
let mut in_heading = false;
|
||||||
let mut heading_level = 0;
|
let mut heading_level = 0;
|
||||||
|
|
||||||
@@ -46,8 +79,12 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
match event {
|
match event {
|
||||||
pulldown_cmark::Event::Start(tag) => {
|
pulldown_cmark::Event::Start(tag) => {
|
||||||
match tag {
|
match tag {
|
||||||
pulldown_cmark::Tag::CodeBlock(_) => {
|
pulldown_cmark::Tag::CodeBlock(kind) => {
|
||||||
in_code_block = true;
|
in_code_block = true;
|
||||||
|
in_diff_block = matches!(
|
||||||
|
&kind,
|
||||||
|
pulldown_cmark::CodeBlockKind::Fenced(lang) if lang.as_ref() == "diff"
|
||||||
|
);
|
||||||
// Code block top bar
|
// Code block top bar
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"\n",
|
"\n",
|
||||||
@@ -55,7 +92,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
));
|
));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
" ┌─ code ",
|
" ┌─ code ",
|
||||||
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
|
apply_dim(Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), dim),
|
||||||
));
|
));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"\n",
|
"\n",
|
||||||
@@ -76,25 +113,25 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
// List item bullet
|
// List item bullet
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"• ",
|
"• ",
|
||||||
Style::default().fg(Theme::PRIMARY),
|
apply_dim(Style::default().fg(Theme::PRIMARY), dim),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
pulldown_cmark::Tag::Link { dest_url, .. } => {
|
pulldown_cmark::Tag::Link { dest_url, .. } => {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"[",
|
"[",
|
||||||
Style::default().fg(Theme::INFO),
|
apply_dim(Style::default().fg(Theme::INFO), dim),
|
||||||
));
|
));
|
||||||
// We push the URL as a tooltip-like suffix
|
// We push the URL as a tooltip-like suffix
|
||||||
// After the link text ends, we'll add the URL
|
// After the link text ends, we'll add the URL
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
format!("]({dest_url})"),
|
format!("]({dest_url})"),
|
||||||
Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC),
|
apply_dim(Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), dim),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
pulldown_cmark::Tag::BlockQuote(_) => {
|
pulldown_cmark::Tag::BlockQuote(_) => {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"▎",
|
"▎",
|
||||||
Style::default().fg(Theme::BLOCKQUOTE_BAR),
|
apply_dim(Style::default().fg(Theme::BLOCKQUOTE_BAR), dim),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
pulldown_cmark::Tag::Table(_) => {
|
pulldown_cmark::Tag::Table(_) => {
|
||||||
@@ -114,10 +151,11 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
match tag {
|
match tag {
|
||||||
pulldown_cmark::TagEnd::CodeBlock => {
|
pulldown_cmark::TagEnd::CodeBlock => {
|
||||||
in_code_block = false;
|
in_code_block = false;
|
||||||
|
in_diff_block = false;
|
||||||
// Code block bottom bar
|
// Code block bottom bar
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"\n └─\n",
|
"\n └─\n",
|
||||||
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
|
apply_dim(Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), dim),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
pulldown_cmark::TagEnd::Heading(_) => {
|
pulldown_cmark::TagEnd::Heading(_) => {
|
||||||
@@ -182,7 +220,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
let max_height = cell_lines.iter().map(|cl| cl.len()).max().unwrap_or(1);
|
let max_height = cell_lines.iter().map(|cl| cl.len()).max().unwrap_or(1);
|
||||||
|
|
||||||
for y in 0..max_height {
|
for y in 0..max_height {
|
||||||
spans.push(Span::styled(" | ", Style::default().fg(Theme::BORDER)));
|
spans.push(Span::styled(" | ", apply_dim(Style::default().fg(Theme::BORDER), dim)));
|
||||||
for (i, cl) in cell_lines.iter().enumerate() {
|
for (i, cl) in cell_lines.iter().enumerate() {
|
||||||
let line_spans = if y < cl.len() { &cl[y] } else { [].as_slice() };
|
let line_spans = if y < cl.len() { &cl[y] } else { [].as_slice() };
|
||||||
let mut line_width = 0;
|
let mut line_width = 0;
|
||||||
@@ -192,15 +230,15 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
}
|
}
|
||||||
let pad = col_widths[i].saturating_sub(line_width);
|
let pad = col_widths[i].saturating_sub(line_width);
|
||||||
spans.push(Span::raw(" ".repeat(pad)));
|
spans.push(Span::raw(" ".repeat(pad)));
|
||||||
spans.push(Span::styled(" | ", Style::default().fg(Theme::BORDER)));
|
spans.push(Span::styled(" | ", apply_dim(Style::default().fg(Theme::BORDER), dim)));
|
||||||
}
|
}
|
||||||
spans.push(Span::raw("\n"));
|
spans.push(Span::raw("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if r == 0 {
|
if r == 0 {
|
||||||
spans.push(Span::styled(" |", Style::default().fg(Theme::BORDER)));
|
spans.push(Span::styled(" |", apply_dim(Style::default().fg(Theme::BORDER), dim)));
|
||||||
for w in &col_widths {
|
for w in &col_widths {
|
||||||
spans.push(Span::styled(format!("{}-|", "-".repeat(*w + 2)), Style::default().fg(Theme::BORDER)));
|
spans.push(Span::styled(format!("{}-|", "-".repeat(*w + 2)), apply_dim(Style::default().fg(Theme::BORDER), dim)));
|
||||||
}
|
}
|
||||||
spans.push(Span::raw("\n"));
|
spans.push(Span::raw("\n"));
|
||||||
}
|
}
|
||||||
@@ -213,11 +251,25 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
pulldown_cmark::Event::Text(text) => {
|
pulldown_cmark::Event::Text(text) => {
|
||||||
let s = text.to_string();
|
let s = text.to_string();
|
||||||
if in_code_block {
|
if in_code_block {
|
||||||
let indented = format!(" {}", s.replace('\n', "\n "));
|
if in_diff_block {
|
||||||
spans.push(Span::styled(
|
for (i, line) in s.split('\n').enumerate() {
|
||||||
indented,
|
if i > 0 {
|
||||||
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG),
|
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 {
|
} else if in_heading {
|
||||||
let color = match heading_level {
|
let color = match heading_level {
|
||||||
1 => Theme::PRIMARY,
|
1 => Theme::PRIMARY,
|
||||||
@@ -227,21 +279,24 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
|||||||
};
|
};
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
s,
|
s,
|
||||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
apply_dim(Style::default().fg(color).add_modifier(Modifier::BOLD), dim),
|
||||||
));
|
));
|
||||||
} else if in_table_cell {
|
} else if in_table_cell {
|
||||||
current_cell.push(Span::raw(s));
|
current_cell.push(Span::styled(s, apply_dim(Style::default(), dim)));
|
||||||
} else {
|
} else {
|
||||||
spans.push(Span::raw(s));
|
spans.push(Span::styled(s, apply_dim(Style::default(), dim)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pulldown_cmark::Event::Code(text) => {
|
pulldown_cmark::Event::Code(text) => {
|
||||||
let span = Span::styled(
|
let span = Span::styled(
|
||||||
format!(" {text} "),
|
format!(" {text} "),
|
||||||
Style::default()
|
apply_dim(
|
||||||
.fg(Theme::ACCENT_TEAL)
|
Style::default()
|
||||||
.bg(Theme::CODE_BAR)
|
.fg(Theme::ACCENT_TEAL)
|
||||||
.add_modifier(Modifier::BOLD),
|
.bg(Theme::CODE_BAR)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
dim,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if in_table_cell {
|
if in_table_cell {
|
||||||
current_cell.push(span);
|
current_cell.push(span);
|
||||||
@@ -377,3 +432,42 @@ fn wrap_spans_to_lines(spans: &[Span<'static>], target_width: usize) -> Vec<Vec<
|
|||||||
}
|
}
|
||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dim_false_plain_text_has_no_color() {
|
||||||
|
let spans = render_markdown("hello world", 0, false);
|
||||||
|
assert_eq!(spans[0].content.as_ref(), "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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+5
-1
@@ -778,11 +778,15 @@ fn render_input_bar(
|
|||||||
width: area.width.min(45),
|
width: area.width.min(45),
|
||||||
height: dropdown_height,
|
height: dropdown_height,
|
||||||
};
|
};
|
||||||
|
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()
|
let dropdown_block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(Theme::BORDER))
|
.border_style(Style::default().fg(Theme::BORDER))
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
" ⌘ Commands ",
|
dropdown_title,
|
||||||
Style::default().fg(Theme::PRIMARY),
|
Style::default().fg(Theme::PRIMARY),
|
||||||
))
|
))
|
||||||
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
|
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
|
||||||
|
|||||||
Reference in New Issue
Block a user