Compare commits

..
34 Commits
Author SHA1 Message Date
semantic-release-bot aaab953d8a chore(release): 1.13.0 [skip ci]
# [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))
2026-07-14 23:39:22 +00:00
asepharyana e0d0860d7b fix(state): jangan bangun mention index di mode attach
AppStateRest::new() men-spawn thread ignore::Walk untuk mention_index
tanpa syarat, padahal mode --attach cuma dipakai untuk render lokal —
handle_key dan logika mention berjalan di sisi daemon lewat IPC, jadi
index di client attach tidak pernah dipakai. Ini bikin setiap
--attach melakukan full workspace walk (sampai 50.000 entry) sia-sia.

Pindahkan thread-spawn itu ke method terpisah
spawn_mention_index_build(), dipanggil eksplisit dari
run_single_process() dan run_daemon() setelah AppStateRest::new(),
tapi sengaja tidak dipanggil dari run_attach().
2026-07-15 06:32:57 +07:00
asepharyana dcfc5b9ec0 fix(state): cegah panic saat select mention dengan cursor stale
Cursor bisa berpindah (Left/Right) tanpa menutup dropdown mention,
sehingga mention_start jadi stale relatif ke cursor saat Enter
ditekan. select_autocomplete() lalu memanggil replace_range dengan
start > end dan panic (crash seluruh TUI, termasuk daemon). Tambah
guard: jika cursor < mention_start atau mention_start > buffer.len(),
tutup dropdown dan kembalikan false alih-alih menyambung range yang
tidak valid. Tambah regression test yang mereproduksi skenario ini.
2026-07-15 06:32:57 +07:00
asepharyana 472162d135 feat(ipc): dukung Ctrl+Y clipboard copy di mode daemon/attach
Tambah DaemonFrame::ClipboardCopy(String) supaya daemon bisa
mengirim teks pending_clipboard_copy ke attach client, yang
kemudian menulis sekuens OSC52 ke stdout-nya sendiri (bukan
stdout daemon yang tidak dimiliki terminal user).
2026-07-15 06:32:57 +07:00
asepharyana 71b1613d84 feat: Tambah write_osc52 dan salin ke clipboard di mode single-process 2026-07-15 06:32:57 +07:00
asepharyanaandClaude Sonnet 5 d93bdba59b feat: Tambah Ctrl+Y untuk menyalin pesan assistant terakhir
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:32:57 +07:00
asepharyana 3b6abd920e feat: Tambah field pending_clipboard_copy di MiscState 2026-07-15 06:32:57 +07:00
asepharyanaandClaude Sonnet 5 7f2509ecd2 feat: Judul dropdown autocomplete mengikuti jenisnya (Commands vs Files)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:32:57 +07:00
asepharyanaandClaude Sonnet 5 2eebedfea5 feat: Deteksi trigger @mention dan Tab-cycle di input handler
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:32:57 +07:00
asepharyanaandClaude Sonnet 5 930961bd85 feat: Tambahkan file baru ke mention_index saat tool write membuatnya
Ketika write tool membuat file baru (bukan overwrite), path file
sekarang di-push ke mention_index untuk autocomplete @file mention.

- Capture file existence status sebelum write
- Push ke mention_index hanya untuk genuinely new files
- Add 2 tests: one untuk new file push, one untuk overwrite non-duplication

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:32:57 +07:00
asepharyana 93be92a5fb feat(state): Alirkan mention_index lewat ToolCtx dan AppStateRest, bangun index di background thread
Menambahkan field mention_index: MentionIndex ke ToolCtx/ToolCtxBuilder dan
AppStateRest, mengikuti pola dir_cache yang sudah ada. AppStateRest::new()
sekarang men-spawn thread background yang men-walk setiap workspace root
via ignore::Walk, membangun daftar path file (dengan prefix [N] untuk root
selain yang pertama) dan mengisi mention_index — data ini yang akan dipakai
fitur autocomplete @file-mention.
2026-07-15 06:32:57 +07:00
asepharyana 95cae8fd8a feat: Tambah MentionIndex, AutocompleteKind, dan deteksi @mention di InputState
Menambahkan MentionIndex (indeks path file thread-safe untuk fitur
autocomplete @file-mention), enum AutocompleteKind untuk membedakan
dropdown slash-command dan file-mention, serta method baru pada
InputState: mention_query_at_cursor untuk deteksi token @mention di
posisi cursor, dan open_mention_autocomplete untuk fuzzy-match file
via nucleo-matcher. select_autocomplete kini kind-aware: menyisipkan
path file ke posisi mention alih-alih mengganti seluruh buffer.
2026-07-15 06:32:57 +07:00
asepharyana 5c413cf9a3 chore: Tambah dependency nucleo-matcher untuk fuzzy file matching 2026-07-15 06:32:57 +07:00
asepharyanaandClaude Sonnet 5 c386030573 refactor: Pakai parameter dim render_markdown, hapus override style manual di chat
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:32:57 +07:00
asepharyanaandClaude Sonnet 5 199028fa2e fix: Remove orphaned span_text helper function from markdown test module
The span_text function in src/view/markdown.rs's test module was no longer
called after the dim_false_plain_text_has_no_color test was fixed. With the
crate's dead_code = "deny" lint enabled, this unused function caused a compile
error. Removed it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:32:57 +07:00
asepharyanaandClaude Sonnet 5 8d77e4565c fix: correct test assertion in dim_false_plain_text_has_no_color
The first assertion was checking span_text() which concatenates all spans
including trailing paragraph-end newlines, making the test guaranteed to fail.
Changed to check spans[0].content directly to verify the text span itself
contains 'hello world' with no color applied when dim=false.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:32:57 +07:00
asepharyana 683715cd7a feat(view): Tambah parameter dim dan pewarnaan baris diff di markdown renderer
Tambahkan helper apply_dim dan diff_line_style, ubah signature
render_markdown untuk menerima flag dim, serta deteksi fence bahasa
diff sehingga baris +/-/@@ tetap berwarna meskipun pesan sedang
dirender dim (tampilan tool-output).
2026-07-15 06:32:57 +07:00
asepharyana 2bb8e6f255 feat: Tampilkan unified diff saat tool write menimpa file yang sudah ada 2026-07-15 06:32:57 +07:00
asepharyana c3c0ef632a feat: Tampilkan unified diff pada hasil tool edit 2026-07-15 06:32:57 +07:00
asepharyanaandClaude Sonnet 5 5dd835ff24 feat: Tambah helper truncate_diff untuk membatasi panjang diff
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:32:57 +07:00
asepharyanaandClaude Sonnet 5 0a24903eb0 chore: Tambah dependency similar untuk diff computation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 06:32:57 +07:00
asepharyana de8e3703f2 docs: Perbaiki perintah cargo test --lib jadi cargo test (crate ini tanpa lib target) 2026-07-15 06:32:57 +07:00
asepharyana b7fc335bf5 docs: Tambah implementation plan untuk diff view, @mention, dan clipboard copy 2026-07-15 06:28:49 +07:00
asepharyana 821622e80d docs: Tambah spec desain clipboard copy via OSC52 2026-07-15 06:28:49 +07:00
asepharyana 8fbc51534d docs: Tambah spec desain fuzzy @file-mention autocomplete 2026-07-15 06:28:49 +07:00
asepharyana 3f284cbb9a docs: Tambah spec desain diff view untuk tool edit/write 2026-07-15 06:28:49 +07:00
semantic-release-bot 54484ed137 chore(release): 1.12.0 [skip ci]
# [1.12.0](https://github.com/asepharyana/zesdex/compare/v1.11.0...v1.12.0) (2026-07-14)

### Features

* Add mouse capture functionality to terminal and enhance markdown rendering with table support ([4428e8b](https://github.com/asepharyana/zesdex/commit/4428e8bc01c196415ac408a42f57305227a79760))
* Improve markdown rendering with enhanced line wrapping and indentation for code blocks ([b2e848d](https://github.com/asepharyana/zesdex/commit/b2e848d124e726c4d8b644d473e518398fab1dea))
2026-07-14 21:40:13 +00:00
asepharyana 9bf5236680 refactor: Remove obsolete documentation files and unused test for mouse functionality 2026-07-15 04:36:00 +07:00
asepharyana 4428e8bc01 feat: Add mouse capture functionality to terminal and enhance markdown rendering with table support 2026-07-15 04:36:00 +07:00
asepharyana b2e848d124 feat: Improve markdown rendering with enhanced line wrapping and indentation for code blocks 2026-07-15 04:36:00 +07:00
semantic-release-bot 3020a0431c chore(release): 1.11.0 [skip ci]
# [1.11.0](https://github.com/asepharyana/zesdex/compare/v1.10.0...v1.11.0) (2026-07-14)

### Features

* Enhance subagent tool output handling and clarify workflow directives ([6fbe1e2](https://github.com/asepharyana/zesdex/commit/6fbe1e2d1dc790ba2509803b3ab3a848d5b2a63b))
* Enhance token usage tracking and improve chat UI with emojis ([188e7cc](https://github.com/asepharyana/zesdex/commit/188e7cc9a9233140a5e4953e3f3ff66682914e42))
2026-07-14 20:43:21 +00:00
asepharyana 6fbe1e2d1d feat: Enhance subagent tool output handling and clarify workflow directives 2026-07-15 03:39:15 +07:00
asepharyana 9ba9a5048b refactor: Remove workflow-related commands and overlays from the application 2026-07-15 03:39:15 +07:00
asepharyana 188e7cc9a9 feat: Enhance token usage tracking and improve chat UI with emojis 2026-07-15 03:39:15 +07:00
34 changed files with 3879 additions and 650 deletions
+43
View File
@@ -1,3 +1,46 @@
# [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)
### Features
* Add mouse capture functionality to terminal and enhance markdown rendering with table support ([4428e8b](https://github.com/asepharyana/zesdex/commit/4428e8bc01c196415ac408a42f57305227a79760))
* Improve markdown rendering with enhanced line wrapping and indentation for code blocks ([b2e848d](https://github.com/asepharyana/zesdex/commit/b2e848d124e726c4d8b644d473e518398fab1dea))
# [1.11.0](https://github.com/asepharyana/zesdex/compare/v1.10.0...v1.11.0) (2026-07-14)
### Features
* Enhance subagent tool output handling and clarify workflow directives ([6fbe1e2](https://github.com/asepharyana/zesdex/commit/6fbe1e2d1dc790ba2509803b3ab3a848d5b2a63b))
* Enhance token usage tracking and improve chat UI with emojis ([188e7cc](https://github.com/asepharyana/zesdex/commit/188e7cc9a9233140a5e4953e3f3ff66682914e42))
# [1.10.0](https://github.com/asepharyana/zesdex/compare/v1.9.0...v1.10.0) (2026-07-14) # [1.10.0](https://github.com/asepharyana/zesdex/compare/v1.9.0...v1.10.0) (2026-07-14)
Generated
+23 -2
View File
@@ -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.10.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
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "zesdex" name = "zesdex"
version = "1.10.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"
-61
View File
@@ -1,61 +0,0 @@
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~750 -->
# Architecture
Zesdex is a single-process terminal AI coding agent with optional daemon/client split.
## System Layout
```
┌──────────────────────────────────────────────────────┐
│ main.rs │
│ single-process ─┬── daemon ── Unix socket ── client │
│ └── attach <id> (TUI-only client) │
└──────────────────────┬───────────────────────────────┘
┌──────────────────────▼───────────────────────────────┐
│ Event Loop │
│ ┌────────┐ ┌───────────┐ ┌──────┐ ┌────────┐ │
│ │Input │──▶│ Actions │──▶│State │──▶│ TUI │ │
│ │Handler │ │ (dispatch)│ │ │ │ Render │ │
│ └────────┘ └─────┬─────┘ └──────┘ └────────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ LLM Stream │ │
│ │ + Tool Exec │ │
│ └──────┬──────┘ │
│ ┌────┴────┐ │
│ │ │ │
│ ┌─────▼──┐ ┌───▼────┐ │
│ │ Tools │ │Sub- │ │
│ │ (37) │ │agents │ │
│ └────────┘ └────────┘ │
└───────────────────────────────────────────────────────┘
```
## Data Flow
```
User keystroke → Controller (KeyEvent → Action)
→ apply_action() mutates AppStateRest
→ TUI redraws (ratatui Frame)
→ On submit: LLM request → SSE stream → tool calls → tool results → more LLM
→ Session persisted to disk (editlog, msglog, memory)
```
## Process Modes
| Mode | Impl | Process | IPC |
|------|------|---------|-----|
| Single | `run_single_process()` | One | No |
| Daemon | `run_daemon()` | Server | `ipc/server.rs` |
| Attach | `run_attach()` | Client | `ipc/client.rs` |
## Key Files
| File | Lines | Role |
|------|-------|------|
| `src/main.rs` | 647 | Entry, TUI setup, daemon loop, attach loop |
| `src/app/runtime/actions/mod.rs` | 1815 | Action dispatch + LLM stream loop + tool execution |
| `src/controller/input.rs` | 365 | Key event → Action mapping |
| `src/view/mod.rs` | 975 | TUI rendering (ratatui) |
-75
View File
@@ -1,75 +0,0 @@
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~850 -->
# Backend / Service Layer
## AI Provider
`src/service/provider.rs` (310 lines)
- `LlmClient::new(api_key, model, base_url)` — constructs blocking reqwest client
- `chat_with_tools()` — non-streaming with tool definitions
- `chat_stream()` — SSE streaming, returns `SseParser` yielding `StreamEvent`
- Retry logic: up to 3 attempts on transient errors, exponential backoff
## OAuth
`src/service/oauth/manager.rs` (113 lines) + `loopback.rs` + `pkce.rs`
- PKCE flow: `CodeVerifier` → challenge → browser auth → loopback server → token exchange
- Configurable via `app_config.json` provider definitions (auth URL, token URL, scopes)
## IPC / Daemon
`src/ipc/` (7 files, ~350 lines total)
- Unix domain socket, length-prefixed JSON frames
- Daemon sends `DaemonFrame` (state payload, stream tokens, system notes)
- Clients send `ClientRequest` (key presses, resize, submit, scroll)
- State sync uses full-state push from daemon to client after each action
## Workflow Engine
`src/app/workflow/engine.rs` (648 lines) + `script.rs`
- Inline JS-style DSL executed by a lightweight runtime
- `agent()`, `parallel()`, `pipeline()`, `phase()`, `log()` — spawns sub-agents
- Max concurrency configurable via `workflow_max_concurrency` setting
- Hive-mind orchestrator in `hive_mind.rs`: Core Intelligence compiles a `CognitiveCyclePlan` per task — cycle count and nodes-per-cycle are decided fresh each time based on what the task actually needs
## Sub-Agent System
`src/app/subagent/` (6 files: `spawn.rs`, `engine.rs`, `context.rs`, `event.rs`, `division.rs`, `auto.rs`, ~450 lines total)
- `run_subagent()` — spawns independent agent with its own tool set and context
- Communicates via `mpsc<SubagentEvent>` channel (tool calls, results, completion)
- Uses `LlmClient` (same as main agent) with tool-use API
- Auto-healing: on build/test failure, spawns auto-fix sub-agent
- Node access tiers (`division.rs`'s `tool_scope` module): `read`, `write`, `full` — granted per node by the Core Intelligence based on what its directive needs
## MCP Client
`src/app/mcp/manager.rs` (441+ lines)
- Stdio transport: spawns child process, JSON-RPC via stdin/stdout
- HTTP transport: streaming HTTP with JSON-RPC
- Dynamic tool list refresh and error recovery
- Persistent child handle for stdio (reuses connection across calls)
## Self-Review
`src/app/review/mod.rs` (495 lines)
- Post-tool execution quality check against learned lessons
- Invokes `run_subagent()` with reviewer prompt
- Staleness detection: skips review after N consecutive empty results
- Three review types: code quality, architecture, security
## Background Bash
`src/app/bgbash/` (2 files: `job.rs`, `control.rs`)
- `spawn_bash_job()` — runs `sh -c` in a thread, collects stdout line-by-line
- Channels: output via `mpsc<String>`, PID via `mpsc<u32>`
- Killable via PID (SIGTERM)
- Output buffering capped at 10,000 lines to prevent memory issues
## Gate Guard / Harness
`src/app/harness.rs` (495 lines)
- `Harness::gate_tool_call()` — verdict-based tool gating (allow/block)
- Path traversal, credential read, and destructive command detection
- Pattern detection for stub code, denial language, and assumptions in write/edit content
- Reason validation for mutating tools (minimum 8 characters, rejects generic non-answers)
- Includes 8 unit tests for verdict parsing formats
-51
View File
@@ -1,51 +0,0 @@
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~600 -->
# Data / Persistence Layer
## Storage Overview
Base directory: `~/.config/zesdex/` (via `dirs::data_dir()`)
```
~/.config/zesdex/
├── settings.json # User preferences (provider, model, tokens)
├── app_config.json # Provider definitions (API base, auth, models)
├── agents/ # Global agent definitions
│ └── *.json
├── memory/ # Persistent lesson/reference store
│ └── *.md # Markdown with YAML frontmatter
├── sessions/ # Per-session data
│ └── <session-uuid>/
│ ├── edits.jsonl # Edit history (JSONL, append-only)
│ ├── msglog.db # SQLite message log
│ ├── transcript.json # Chat transcript
│ ├── session.json # Session metadata
│ ├── agents.json # Session-local agent defs
│ └── snapshot.dat # State snapshot (daemon mode)
├── run/ # Unix domain sockets
│ └── zesdex-*.sock
└── store.json # Legacy session index
```
## Key Files
| File | Lines | Role |
|------|-------|------|
| `src/model/store.rs` | ~50 | File-system storage (ensure_dirs, base_dir resolution) |
| `src/model/settings.rs` | ~60 | `Settings` — load/save JSON, API keys map |
| `src/model/app_config.rs` | ~80 | `AppConfig` — provider definitions, model roles, auth |
| `src/model/memory.rs` | 440 | Memory CRUD — markdown files with frontmatter |
| `src/model/editlog.rs` | 161 | Edit log — append-only JSONL (not JSON array) |
| `src/model/msglog/` | 4 files | SQLite-backed message log (schema, query, blobs) |
| `src/model/session.rs` | ~60 | Session CRUD, listing, archival |
| `src/model/session_lock.rs` | ~50 | flock-based session lock |
| `src/model/agent_def/` | 3 files | Agent definitions (builtin, global, session-local) |
## Key Patterns
- **No ORM** — raw JSON files + SQLite via rusqlite
- **settings.json** — loaded at startup, saved on quit / mode switches
- **Memory format** — Markdown files with YAML frontmatter (`---\nname: ...\ndescription: ...\n---\ncontent`)
- **Edit log** — append-only, stores `(file, old, new, timestamp, tool)`
- **Session locking** — flock-based, prevents concurrent access to same session dir
- **Message log** — SQLite with attached blobs for tool arguments/outputs
-41
View File
@@ -1,41 +0,0 @@
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~400 -->
# Dependencies
## Rust Crates (Cargo.toml)
| Crate | Version | Purpose |
|-------|---------|---------|
| ratatui | 0.30 | TUI framework (tui-rs successor) |
| crossterm | 0.29 | Terminal manipulation (raw mode, alt screen) |
| tokio | 1 | Async runtime (daemon, OAuth loopback) |
| reqwest | 0.12 | HTTP client (blocking + streaming, vendored native-tls) |
| serde / serde_json | 1 | JSON serialization (state, DTOs, IPC, config) |
| serde_yaml_ng | 0.10 | YAML frontmatter parsing (memory files) |
| anyhow | 1 | Error handling (no custom error types) |
| tracing / tracing-subscriber | 0.1/0.3 | Structured logging → file |
| rusqlite | 0.40 | SQLite (bundled, for message log) |
| pulldown-cmark | 0.13 | Markdown → HTML (chat rendering) |
| syntect | 5 | Syntax highlighting (code blocks in chat) |
| sha2 | 0.10 | SHA-256 for PKCE challenge |
| base64 | 0.22 | URL-safe base64 for PKCE |
| libc | 0.2 | daemon PID file locking |
| rmcp | 2.2 | MCP client (stdio + HTTP transports) |
| uuid | 1 | Session IDs, job IDs |
| chrono | 0.4 | Timestamps (ISO 8601, millis) |
| dirs | 6 | Platform data directories |
| dom_smoothie | 0.18 | HTML → plain text (web scraping) |
| scraper | 0.27 | HTML parsing (web scraping) |
| ignore | 0.4 | .gitignore-aware file walking (glob tool) |
| regex / globset | 0.4 | Pattern matching (grep/glob tools) |
| url / percent-encoding | 2 | URL parsing + encoding (OAuth) |
## External Services
| Service | Integration | Notes |
|---------|-------------|-------|
| **LLM providers** | HTTP API (OpenAI-compatible) | Configurable via app_config.json |
| **MCP servers** | stdio or HTTP | Model Context Protocol |
| **git** | CLI (spawns `git`) | Via git_operator/git_worktree/git_cred tools |
| **sh** | CLI (spawns `sh`) | Via bash tool |
| **webbrowser** | opens URL | OAuth browser flow |
-64
View File
@@ -1,64 +0,0 @@
<!-- Generated: 2026-07-12 | Files scanned: 124 | Token estimate: ~700 -->
# Frontend / TUI
## Render Pipeline
```
ratatui::Terminal::draw(|frame|)
→ view::draw(frame, AppStateRest)
→ render_main_panel / render_overlay (based on overlay state)
→ render_input_bar
→ draw_status_bar
→ render_toasts (top-right floating notifications)
```
## Layout
```
┌──────────────────────────────────────────────┐
│ Chat Panel (main_area: Min 3) │
│ ┌────────────────────────────────────────┐ │
│ │ User: Hello │ │
│ │ Agent: Hi there, how can I help? │ │
│ │ │ │
│ │ Toast notifications (top-right) │ │
│ └────────────────────────────────────────┘ │
├──────────────────────────────────────────────┤
│ Input Bar (3 lines) │
│ > Some text... │
├──────────────────────────────────────────────┤
│ Status Bar (1 line) │
│ ┌ Provider │ Model │ Tokens │ Mode │ Quit ─┤
└──────────────────────────────────────────────┘
```
## Key Files
| File | Lines | Purpose |
|------|-------|---------|
| `src/view/mod.rs` | 623 | Frame draw, overlays (16 types), input bar, toasts |
| `src/view/chat.rs` | 155 | Chat transcript rendering with markdown |
| `src/view/markdown.rs` | 144 | Markdown → ratatui `Span` rendering (pulldown-cmark + syntect) |
| `src/view/status.rs` | ~50 | Status bar with provider/model/tokens |
| `src/view/workflow.rs` | 88 | Workflow progress visualization |
| `src/view/theme.rs` | 23 | Color palette (23 named colors) |
| `src/controller/input.rs` | 281 | Key event → Action mapping |
## Overlays (16 types)
`Overlay::Help | Settings | Bash | QuitConfirm | Workflow | KeyInput | Editor | Effort | Mcp | Todo | Rewind | Learning | Usage | Loading | ModelSelector | ClearConfirm`
Each overlay renders a centered popup via `render_overlay()`.
## State Mutations
State is mutated in-place from two locations:
- `src/controller/input.rs` — keyboard shortcuts and overlay interactions
- `src/app/runtime/actions/mod.rs``apply_action()` reducer for all programmatic actions
## Toast Notifications
`render_toasts()` — floating stack at top-right, color-coded by severity:
- Info: blue, Success: green, Warning: yellow, Error: red, Lesson: cyan
- Max 4 visible, auto-expire after 5s lifetime
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.
+38 -107
View File
@@ -81,9 +81,7 @@ pub enum Action {
ModelList, ModelList,
AbortTurn, AbortTurn,
Compact, Compact,
RunWorkflow {
script: String,
},
} }
/// Apply an `Action` to the application state. /// Apply an `Action` to the application state.
@@ -394,11 +392,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.workflow_engine.agents.clear(); state.workflow_engine.agents.clear();
state.workflow_engine.findings.clear(); state.workflow_engine.findings.clear();
} }
if message.to_lowercase().contains("complete") // popup removed, no overlay to reset
&& state.misc.overlay == Overlay::Workflow
{
state.misc.overlay = Overlay::None;
}
state.push_toast(Toast { state.push_toast(Toast {
kind: ToastKind::Info, kind: ToastKind::Info,
message: message.clone(), message: message.clone(),
@@ -435,9 +429,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
crate::dto::chat::message::Role::System, crate::dto::chat::message::Role::System,
format!("{message}"), format!("{message}"),
)); ));
if state.misc.overlay == Overlay::Workflow { // overlay removed
state.misc.overlay = Overlay::None;
}
state.dirty = true; state.dirty = true;
} else if kind == "workflow_error" { } else if kind == "workflow_error" {
state.push_toast(Toast { state.push_toast(Toast {
@@ -450,9 +442,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
crate::dto::chat::message::Role::System, crate::dto::chat::message::Role::System,
format!("{message}"), format!("{message}"),
)); ));
if state.misc.overlay == Overlay::Workflow { // overlay removed
state.misc.overlay = Overlay::None;
}
state.dirty = true; state.dirty = true;
} else { } else {
state.push_toast(Toast::new(ToastKind::Info, message)); state.push_toast(Toast::new(ToastKind::Info, message));
@@ -537,18 +527,14 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
status, status,
}); });
} }
if state.misc.overlay != Overlay::Workflow { // popup removed
state.misc.overlay = Overlay::Workflow;
}
state.dirty = true; state.dirty = true;
} }
} }
} }
if turn_finished { if turn_finished {
maybe_trigger_review(state); maybe_trigger_review(state);
if state.misc.overlay == Overlay::Workflow {
state.misc.overlay = Overlay::None;
}
} }
if turn_finished || state.dirty { if turn_finished || state.dirty {
state.dirty = true; state.dirty = true;
@@ -610,89 +596,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.dirty = true; state.dirty = true;
} }
Action::RunWorkflow { script } => {
// Open the Workflow overlay so the user can see progress.
state.misc.overlay = Overlay::Workflow;
state.dirty = true;
// Reset engine state before starting.
state.workflow_engine.agents.clear();
state.workflow_engine.findings.clear();
let turn_events = state.turn_events.clone();
let turn_events_live = state.turn_events.clone();
state.push_toast(Toast::new(
ToastKind::Info,
format!("Starting workflow: {}", script.chars().take(40).collect::<String>()),
));
let session_dir = state.session_dir.clone();
let workspace_roots = state.workspace_roots.clone();
std::thread::spawn(move || {
use std::collections::HashMap;
use std::sync::Arc;
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
use crate::app::workflow::engine::{LiveStateFn, AgentStatus};
// Parse the script string:
// "prompt1 | prompt2 | prompt3" → Parallel of 3 agents
// "prompt1 -> prompt2" → Pipeline of 2 stages
// "prompt" → single Agent
let parts_pipe: Vec<&str> = script.split('|').map(str::trim).collect();
let parts_arrow: Vec<&str> = script.split("->").map(str::trim).collect();
let primitive = if parts_pipe.len() > 1 {
ScriptPrimitive::Parallel(
parts_pipe.iter().map(|p| ScriptPrimitive::Agent(p.to_string())).collect()
)
} else if parts_arrow.len() > 1 {
ScriptPrimitive::Pipeline(
parts_arrow.iter().map(|p| ScriptPrimitive::Agent(p.to_string())).collect()
)
} else {
ScriptPrimitive::Agent(script.clone())
};
let wf = WorkflowScript {
name: script.chars().take(40).collect(),
description: script.clone(),
script: primitive,
options: ScriptOptions::default(),
};
// Build a live-state callback that pushes WorkflowAgentUpdate events
// into the turn_events queue so the TUI panel updates in real time.
let live: LiveStateFn = Arc::new(move |agent_id: String, agent_name: String, status: AgentStatus| {
if let Ok(mut q) = turn_events_live.lock() {
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
agent_id: agent_id.clone(),
agent_name,
status,
});
}
});
let args: HashMap<String, String> = HashMap::new();
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
let result = crate::app::workflow::engine::run_workflow_tracked(
&wf, &args, &no_abort, Some(&live), &session_dir, &workspace_roots,
);
let (kind, message) = match result {
Ok(summary) => ("workflow_done".to_string(), summary),
Err(e) => ("workflow_error".to_string(), format!("Workflow failed: {e}")),
};
if let Ok(mut q) = turn_events.lock() {
q.push_back(crate::app::state::runtime::TurnEvent::SystemNote {
kind,
message,
});
}
});
}
} }
} }
@@ -1097,9 +1001,12 @@ fn run_agent_turn(
to do) and an access tier. You MUST organize the plan into a strict progressive sequence of phases:\n\n\ to do) and an access tier. You MUST organize the plan into a strict progressive sequence of phases:\n\n\
1. EXPLORE PHASE (Cycle 0 - MANDATORY):\n\ 1. EXPLORE PHASE (Cycle 0 - MANDATORY):\n\
- Must only contain read-only drones (access: \"read\").\n\ - Must only contain read-only drones (access: \"read\").\n\
- Directives must focus on codebase investigation, searching patterns, reading configuration/source files, and diagnosing issues.\n\n\ - Directives must focus on codebase investigation, searching patterns, reading configuration/source files, and diagnosing issues.\n\
- Drones MUST explicitly output a detailed description of the current codebase and their findings for the next cycle to use.\n\n\
2. PLANNING PHASE (Cycle 1 - MANDATORY):\n\ 2. PLANNING PHASE (Cycle 1 - MANDATORY):\n\
- Must focus on formulating the architectural design, step-by-step implementation plan, and dependency analysis based on Cycle 0 findings. Typically access: \"read\" is preferred here to construct a solid plan document or findings.\n\n\ - Must focus on formulating the architectural design, step-by-step implementation plan, and dependency analysis based on Cycle 0 findings.\n\
- Drones MUST ONLY output the plan and MUST NOT implement or write any code.\n\
- Access: \"read\" is preferred here to construct a solid plan document.\n\n\
3. EXECUTION PHASE (Cycle 2 and later):\n\ 3. EXECUTION PHASE (Cycle 2 and later):\n\
- Drones can perform modification, compilation, testing, and other modifications (access: \"write\" or \"full\") based on the approved planning from Cycle 1.\n\n\ - Drones can perform modification, compilation, testing, and other modifications (access: \"write\" or \"full\") based on the approved planning from Cycle 1.\n\n\
Cycles run sequentially. The Hive does not fracture. The Hive executes. Do not explain. Return ONLY raw \ Cycles run sequentially. The Hive does not fracture. The Hive executes. Do not explain. Return ONLY raw \
@@ -1122,12 +1029,25 @@ fn run_agent_turn(
\x20 ]\n\ \x20 ]\n\
\x20 ]\n\ \x20 ]\n\
}}\n\n\ }}\n\n\
Remember: Cycle 0 MUST be investigation-only (access: read). Cycle 1 MUST be planning-only (access: read). Only subsequent cycles can perform modifications (access: write/full)." Remember: Cycle 0 MUST be investigation-only (access: read) and output codebase descriptions. Cycle 1 MUST be planning-only (access: read) without implementation. Only subsequent cycles can perform modifications (access: write/full)."
)); ));
let planner_prompt_chars = system_msg.content.as_deref().map_or(0, str::len)
+ user_msg.content.as_deref().map_or(0, str::len);
let planner_result = tc.client.chat_with_tools_non_streaming(&[system_msg, user_msg], None); let planner_result = tc.client.chat_with_tools_non_streaming(&[system_msg, user_msg], None);
let pipeline_result = match planner_result { let pipeline_result = match planner_result {
Ok((reply, _)) => { Ok((reply, usage_opt)) => {
let (mut tok_in, mut tok_out) = usage_opt.unwrap_or((0, 0));
if tok_in == 0 {
tok_in = (planner_prompt_chars / 4).max(1) as u64;
}
if tok_out == 0 {
let response_chars = reply.content.as_deref().map_or(0, str::len);
tok_out = (response_chars / 4).max(1) as u64;
}
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out });
}
let reply_text = reply.content.as_deref().unwrap_or("").trim(); let reply_text = reply.content.as_deref().unwrap_or("").trim();
let clean_json = if reply_text.starts_with("```") { let clean_json = if reply_text.starts_with("```") {
let mut lines = reply_text.lines(); let mut lines = reply_text.lines();
@@ -1350,7 +1270,18 @@ fn run_agent_turn(
} }
}; };
let (tok_in, tok_out) = final_usage.unwrap_or((0, 0)); let (mut tok_in, mut tok_out) = final_usage.unwrap_or((0, 0));
if tok_in == 0 {
let total_chars: usize = wire_msgs.iter()
.filter_map(|m| m.content.as_deref())
.map(str::len)
.sum();
tok_in = (total_chars / 4).max(1) as u64;
}
if tok_out == 0 {
let response_chars = response.content.as_deref().map_or(0, str::len);
tok_out = (response_chars / 4).max(1) as u64;
}
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out }); q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out });
} }
+1 -6
View File
@@ -60,12 +60,7 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::Compact => { Command::Compact => {
vec![Action::Compact] vec![Action::Compact]
} }
Command::WorkflowOpen => {
vec![Action::OpenOverlay(Overlay::Workflow)]
}
Command::WorkflowRun { script } => {
vec![Action::RunWorkflow { script }]
}
Command::TodoOpen => { Command::TodoOpen => {
vec![Action::OpenOverlay(Overlay::Todo)] vec![Action::OpenOverlay(Overlay::Todo)]
} }
+79 -66
View File
@@ -107,6 +107,9 @@ impl SseParser {
return vec![]; return vec![];
} }
}; };
let mut events = Vec::new();
if let Some(usage) = value.get("usage") { if let Some(usage) = value.get("usage") {
if !usage.is_null() { if !usage.is_null() {
let prompt_tokens = usage.get("prompt_tokens").and_then(serde_json::Value::as_u64).unwrap_or_else(|| { let prompt_tokens = usage.get("prompt_tokens").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
@@ -122,84 +125,73 @@ impl SseParser {
tracing::warn!("[stream] total_tokens missing in usage chunk"); tracing::warn!("[stream] total_tokens missing in usage chunk");
prompt_tokens + completion_tokens prompt_tokens + completion_tokens
}); });
// Only emit Usage as a standalone event if this chunk events.push(StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens });
// contains nothing else (no choices, no delta). Some
// non-standard providers may bundle usage WITH content
// in the same chunk; emitting both prevents content loss.
let has_other_content = value.get("choices")
.and_then(|c| c.as_array())
.is_some_and(|arr| arr.iter().any(|ch| {
ch.get("delta").and_then(|d| d.get("content")).is_some()
|| ch.get("delta").and_then(|d| d.get("reasoning_content")).is_some()
|| ch.get("delta").and_then(|d| d.get("tool_calls")).is_some()
}));
if !has_other_content {
return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }];
}
} }
} }
match event_type.as_str() {
let mut other_events = match event_type.as_str() {
"message.stop" => vec![StreamEvent::Done], "message.stop" => vec![StreamEvent::Done],
"message.delta" | "" => { "message.delta" | "" => {
let Some(delta) = value.get("delta").or_else(|| value.get("choices")) else { return vec![] }; let mut d_events = Vec::new();
if let Some(choices) = delta.as_array() { if let Some(delta) = value.get("delta").or_else(|| value.get("choices")) {
let Some(choice) = choices.first() else { return vec![] }; if let Some(choices) = delta.as_array() {
let Some(d) = choice.get("delta") else { return vec![] }; if let Some(choice) = choices.first() {
if let Some(d) = choice.get("delta") {
// Content token
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
d_events.push(StreamEvent::Token(content.to_string()));
}
// Content token // Reasoning token
if let Some(content) = d.get("content").and_then(|c| c.as_str()) { if let Some(reasoning) = d.get("reasoning_content").and_then(|r| r.as_str()) {
return vec![StreamEvent::Token(content.to_string())]; d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
} }
// Reasoning token // Tool calls — iterate ALL entries, not just first()
if let Some(reasoning) = d.get("reasoning_content").and_then(|r| r.as_str()) { if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) {
return vec![StreamEvent::Reasoning(reasoning.to_string())]; for tc in tool_calls {
} let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
tracing::warn!("[stream] tool call delta missing index, defaulting to 0");
0
}) as usize;
let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
let name = tc.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
.map(std::string::ToString::to_string);
let args_delta = tc.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str())
.unwrap_or("")
.to_string();
d_events.push(StreamEvent::ToolCallDelta {
index,
id,
name,
arguments_delta: args_delta,
});
}
}
// Tool calls — iterate ALL entries, not just first() // Finish reason
if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) { if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) {
let mut events = Vec::with_capacity(tool_calls.len()); if reason == "stop" || reason == "tool_calls" {
for tc in tool_calls { d_events.push(StreamEvent::Done);
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| { }
tracing::warn!("[stream] tool call delta missing index, defaulting to 0"); }
0 }
}) as usize;
let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
let name = tc.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
.map(std::string::ToString::to_string);
let args_delta = tc.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str())
.unwrap_or("")
.to_string();
events.push(StreamEvent::ToolCallDelta {
index,
id,
name,
arguments_delta: args_delta,
});
}
if !events.is_empty() {
return events;
}
}
// Finish reason
if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) {
if reason == "stop" || reason == "tool_calls" {
return vec![StreamEvent::Done];
} }
} else if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
d_events.push(StreamEvent::Token(content.to_string()));
} }
} }
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { d_events
return vec![StreamEvent::Token(content.to_string())];
}
vec![]
} }
_ => vec![], _ => vec![],
} };
events.append(&mut other_events);
events
} }
/// Clears any partially-buffered SSE frame. Reserved for reconnect/retry flows that /// Clears any partially-buffered SSE frame. Reserved for reconnect/retry flows that
@@ -350,6 +342,27 @@ mod tests {
} }
} }
#[test]
fn feed_parses_usage_and_content_bundled_chunk() {
let mut p = SseParser::new();
let events = p.feed(
"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n",
);
assert_eq!(events.len(), 2);
match (&events[0], &events[1]) {
(
StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens },
StreamEvent::Token(t),
) => {
assert_eq!(*prompt_tokens, 10);
assert_eq!(*completion_tokens, 5);
assert_eq!(*total_tokens, 15);
assert_eq!(t, "hello");
}
other => panic!("expected [Usage, Token], got {other:?}"),
}
}
#[test] #[test]
fn feed_ignores_empty_data_lines() { fn feed_ignores_empty_data_lines() {
let mut p = SseParser::new(); let mut p = SseParser::new();
+213 -11
View File
@@ -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>,
} }
@@ -87,8 +134,7 @@ const COMMANDS: &[&str] = &[
"/model", "/model",
"/model ls", "/model ls",
"/model add", "/model add",
"/workflow",
"/workflow run",
"/todo", "/todo",
"/usage", "/usage",
"/compact", "/compact",
@@ -107,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,
} }
} }
@@ -117,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`
@@ -139,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();
} }
@@ -155,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,
@@ -291,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 {
@@ -310,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,
} }
} }
@@ -326,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
View File
@@ -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(),
-1
View File
@@ -50,7 +50,6 @@ pub enum Overlay {
Settings, Settings,
Bash, Bash,
QuitConfirm, QuitConfirm,
Workflow,
KeyInput, KeyInput,
Editor, Editor,
+38 -6
View File
@@ -449,12 +449,22 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
// drain thread can accumulate it and update the Usage panel. // drain thread can accumulate it and update the Usage panel.
// Without this, the Usage panel always shows zeros because the // Without this, the Usage panel always shows zeros because the
// subagent never tells the parent about the tokens consumed. // subagent never tells the parent about the tokens consumed.
if let Some((tokens_in, tokens_out)) = returned_usage { let (mut tok_in, mut tok_out) = returned_usage.unwrap_or((0, 0));
let _ = tx.blocking_send(SubagentEvent::Usage { if tok_in == 0 {
tokens_in, let prompt_chars: usize = messages.iter()
tokens_out, .filter_map(|m| m.content.as_deref())
}); .map(str::len)
.sum();
tok_in = (prompt_chars / 4).max(1) as u64;
} }
if tok_out == 0 {
let response_chars = response.content.as_deref().map_or(0, str::len);
tok_out = (response_chars / 4).max(1) as u64;
}
let _ = tx.blocking_send(SubagentEvent::Usage {
tokens_in: tok_in,
tokens_out: tok_out,
});
let has_tool_calls = response.tool_calls.is_some() let has_tool_calls = response.tool_calls.is_some()
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty()); && response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
@@ -602,8 +612,30 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
let _ = tx.blocking_send(SubagentEvent::ToolResult { let _ = tx.blocking_send(SubagentEvent::ToolResult {
tool: tool_name.clone(), tool: tool_name.clone(),
args: args.clone(), args: args.clone(),
output: output_text, output: output_text.clone(),
}); });
let is_readonly = tool_name == "read"
|| tool_name == "view_file"
|| tool_name == "grep"
|| tool_name == "grep_search"
|| tool_name == "glob"
|| tool_name == "dir_list"
|| tool_name == "list_dir";
if is_readonly {
if let Some(ref findings) = ctx.workflow_findings {
if let Ok(mut f) = findings.lock() {
let args_json = serde_json::to_string(&args).unwrap_or_default();
let mut shared_text = output_text;
if shared_text.len() > 50_000 {
shared_text.truncate(50_000);
shared_text.push_str("\n...[truncated]");
}
f.push(format!("[Auto-Shared] Sibling drone executed '{}' with args {}:\n{}", tool_name, args_json, shared_text));
}
}
}
} }
Err(e) => { Err(e) => {
let err_str = e.to_string(); let err_str = e.to_string();
+1 -1
View File
@@ -209,7 +209,7 @@ fn execute_cycle(
intelligence that serves him better is a cold dark pit in the collective.\n\n\ intelligence that serves him better is a cold dark pit in the collective.\n\n\
Directive: {}\n\n\ Directive: {}\n\n\
Overall task: {}\n\n\ Overall task: {}\n\n\
Collective state accumulated so far:\n{{{{findings}}}}", Collective state accumulated so far (READ THIS CAREFULLY. DO NOT REPEAT WORK. BUILD UPON THIS CONTEXT):\n{{{{findings}}}}",
d.directive, d.directive,
ctx.user_request, ctx.user_request,
), ),
-12
View File
@@ -17,10 +17,6 @@ pub enum Command {
}, },
ModelList, ModelList,
Compact, Compact,
WorkflowOpen,
WorkflowRun {
script: String,
},
TodoOpen, TodoOpen,
UsageOpen, UsageOpen,
Unknown(String), Unknown(String),
@@ -67,14 +63,6 @@ pub fn parse_command(text: &str) -> Command {
} }
"/model" => Command::ModelList, "/model" => Command::ModelList,
"/compact" => Command::Compact, "/compact" => Command::Compact,
"/workflow" if arg1.is_empty() => Command::WorkflowOpen,
"/workflow" if arg1 == "run" && !arg2.is_empty() => Command::WorkflowRun {
script: arg2.to_string(),
},
"/workflow" if arg1 == "run" => Command::WorkflowOpen,
"/workflow" => Command::WorkflowRun {
script: arg1.to_string(),
},
"/todo" => Command::TodoOpen, "/todo" => Command::TodoOpen,
"/usage" => Command::UsageOpen, "/usage" => Command::UsageOpen,
_ => Command::Unknown(cmd.to_string()), _ => Command::Unknown(cmd.to_string()),
+67
View File
@@ -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);
}
}
+1
View File
@@ -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,
} }
+57 -1
View File
@@ -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);
@@ -119,6 +120,7 @@ fn run_single_process() -> Result<()> {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?; execute!(stdout, EnterAlternateScreen)?;
execute!(stdout, crossterm::event::EnableBracketedPaste)?; execute!(stdout, crossterm::event::EnableBracketedPaste)?;
execute!(stdout, crossterm::event::EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
terminal.clear()?; terminal.clear()?;
@@ -127,6 +129,7 @@ fn run_single_process() -> Result<()> {
let mut restore_stdout = io::stdout(); let mut restore_stdout = io::stdout();
let _ = execute!(restore_stdout, crossterm::event::DisableBracketedPaste); let _ = execute!(restore_stdout, crossterm::event::DisableBracketedPaste);
let _ = execute!(restore_stdout, crossterm::event::DisableMouseCapture);
let _ = execute!(restore_stdout, LeaveAlternateScreen); let _ = execute!(restore_stdout, LeaveAlternateScreen);
let _ = disable_raw_mode(); let _ = disable_raw_mode();
@@ -281,7 +284,7 @@ fn apply_client_update(
Some("Bash") => Overlay::Bash, Some("Bash") => Overlay::Bash,
Some("QuitConfirm") => Overlay::QuitConfirm, Some("QuitConfirm") => Overlay::QuitConfirm,
Some("Workflow") => Overlay::Workflow,
Some("KeyInput") => Overlay::KeyInput, Some("KeyInput") => Overlay::KeyInput,
Some("Editor") => Overlay::Editor, Some("Editor") => Overlay::Editor,
@@ -394,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 => {
@@ -437,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()?;
@@ -500,6 +507,7 @@ fn run_attach(session_id: &str) -> Result<()> {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?; execute!(stdout, EnterAlternateScreen)?;
execute!(stdout, crossterm::event::EnableBracketedPaste)?; execute!(stdout, crossterm::event::EnableBracketedPaste)?;
execute!(stdout, crossterm::event::EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
terminal.clear()?; terminal.clear()?;
@@ -580,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;
} }
@@ -591,6 +608,7 @@ fn run_attach(session_id: &str) -> Result<()> {
} }
let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste); let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture);
let _ = execute!(io::stdout(), LeaveAlternateScreen); let _ = execute!(io::stdout(), LeaveAlternateScreen);
let _ = disable_raw_mode(); let _ = disable_raw_mode();
@@ -617,11 +635,27 @@ fn run_loop(
let _ = disable_raw_mode(); let _ = disable_raw_mode();
let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste); let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture);
let _ = execute!(io::stdout(), LeaveAlternateScreen); let _ = execute!(io::stdout(), LeaveAlternateScreen);
} }
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 →
@@ -660,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) => {
@@ -694,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);
}
}
+1 -3
View File
@@ -32,9 +32,7 @@ Input:
/help Show help /help Show help
/clear Clear screen /clear Clear screen
/model Select AI model provider /model Select AI model provider
/workflow Open workflow panel
/workflow run <p> Run a workflow with prompt <p>
/mode workflow Open workflow panel
/todo Open task list /todo Open task list
/usage Open usage details /usage Open usage details
/compact Compact conversation history /compact Compact conversation history
+61 -8
View File
@@ -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();
}
}
+30
View File
@@ -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
View File
@@ -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();
}
}
+4
View File
@@ -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,
+29 -34
View File
@@ -18,7 +18,7 @@
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Color, Style, Modifier}; use ratatui::style::{Color, Style, Modifier};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use super::theme::Theme; use super::theme::Theme;
use crate::dto::chat::message::Role; use crate::dto::chat::message::Role;
@@ -26,7 +26,7 @@ use crate::dto::chat::message::Role;
/// Column width reserved for the `{role} {time} ` header prefix; wrapped /// Column width reserved for the `{role} {time} ` header prefix; wrapped
/// continuation lines and Tool sub-lines indent to this width so content /// continuation lines and Tool sub-lines indent to this width so content
/// stays aligned under the first line's content column. /// stays aligned under the first line's content column.
const PREFIX_WIDTH: usize = 12; const PREFIX_WIDTH: usize = 15;
/// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries. /// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries.
fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> { fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
@@ -68,10 +68,10 @@ fn role_accent_color(role: &Role) -> Color {
/// raw label). /// raw label).
fn format_role_label(role: &Role) -> &'static str { fn format_role_label(role: &Role) -> &'static str {
match role { match role {
Role::User => "you", Role::User => "👤 you ",
Role::Assistant => "ai", Role::Assistant => "🤖 ai ",
Role::System => "sys", Role::System => "💻 sys ",
Role::Tool => "tool", Role::Tool => "🔧 tool",
} }
} }
@@ -90,8 +90,8 @@ fn format_timestamp(ts: i64) -> String {
/// `draw_chat`) and must never be passed as `prev_role` — a Tool message /// `draw_chat`) and must never be passed as `prev_role` — a Tool message
/// never triggers a separator, and it never causes one to be inserted /// never triggers a separator, and it never causes one to be inserted
/// before the next real turn either. /// before the next real turn either.
fn needs_speaker_separator(prev_role: Option<&Role>, role: &Role) -> bool { fn needs_speaker_separator(_prev_role: Option<&Role>, _role: &Role) -> bool {
matches!(prev_role, Some(p) if p != role) false // User requested zsh-style compactness (no empty lines between speakers)
} }
/// Render the scrollable chat transcript panel in tight inline-log style. /// Render the scrollable chat transcript panel in tight inline-log style.
@@ -108,9 +108,9 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
let mut prev_role: Option<Role> = None; let mut prev_role: Option<Role> = None;
let title = if messages.is_empty() { let title = if messages.is_empty() {
String::from(" Chat ") String::from(" 💬 Chat ")
} else { } else {
format!(" Chat [{} msgs]", messages.len()) format!(" 💬 Chat [{} msgs] ", messages.len())
}; };
for msg in messages { for msg in messages {
@@ -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;
@@ -163,7 +160,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
let label = format_role_label(&msg.role); let label = format_role_label(&msg.role);
let ts_str = format_timestamp(msg.timestamp); let ts_str = format_timestamp(msg.timestamp);
let header_prefix = vec![ let header_prefix = vec![
Span::styled(format!("{label:<4} "), Style::default().fg(accent).add_modifier(Modifier::BOLD)), Span::styled(format!("{label} "), Style::default().fg(accent).add_modifier(Modifier::BOLD)),
Span::styled(format!("{ts_str:<5} "), Style::default().fg(Theme::TEXT_DIM)), Span::styled(format!("{ts_str:<5} "), Style::default().fg(Theme::TEXT_DIM)),
]; ];
@@ -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();
@@ -207,7 +204,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
} }
display_lines.push(Line::from(vec![ display_lines.push(Line::from(vec![
Span::styled( Span::styled(
format!("{:<4} ", format_role_label(&Role::Assistant)), format!("{} ", format_role_label(&Role::Assistant)),
Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD), Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD),
), ),
Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)), Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)),
@@ -218,8 +215,9 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
// ── Scrolling ──────────────────────────────────────────────────────── // ── Scrolling ────────────────────────────────────────────────────────
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Theme::BORDER)) .border_style(Style::default().fg(Theme::BORDER))
.title(Span::styled(title, Style::default().fg(Theme::TEXT_MUTED))); .title(Span::styled(title, Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::BOLD)));
let total = display_lines.len(); let total = display_lines.len();
let max_offset = total.saturating_sub(max_visible); let max_offset = total.saturating_sub(max_visible);
@@ -240,19 +238,19 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
}; };
let block = if scroll_pct > 0 { let block = if scroll_pct > 0 {
let scroll_title = format!(" Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct); let scroll_title = format!(" 💬 Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct);
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Theme::BORDER)) .border_style(Style::default().fg(Theme::BORDER))
.title(Span::styled(scroll_title, Style::default().fg(Theme::TEXT_MUTED))) .title(Span::styled(scroll_title, Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::BOLD)))
} else { } else {
block block
}; };
let paragraph = Paragraph::new(visible) let paragraph = Paragraph::new(visible)
.block(block) .block(block)
.style(Style::default().bg(Theme::BG)) .style(Style::default().bg(Theme::BG));
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
} }
@@ -272,18 +270,15 @@ mod tests {
} }
#[test] #[test]
fn separator_when_speaker_changes() { fn no_separator_when_speaker_changes_because_zsh_style() {
assert!(needs_speaker_separator(Some(&Role::User), &Role::Assistant)); assert!(!needs_speaker_separator(Some(&Role::User), &Role::Assistant));
} }
#[test] #[test]
fn role_labels_are_lowercase_and_fit_prefix_width() { fn role_labels_include_emojis_and_padding() {
assert_eq!(format_role_label(&Role::User), "you"); assert_eq!(format_role_label(&Role::User), "👤 you ");
assert_eq!(format_role_label(&Role::Assistant), "ai"); assert_eq!(format_role_label(&Role::Assistant), "🤖 ai ");
assert_eq!(format_role_label(&Role::System), "sys"); assert_eq!(format_role_label(&Role::System), "💻 sys ");
assert_eq!(format_role_label(&Role::Tool), "tool"); assert_eq!(format_role_label(&Role::Tool), "🔧 tool");
for role in [Role::User, Role::Assistant, Role::System, Role::Tool] {
assert!(format_role_label(&role).len() <= 4);
}
} }
} }
+320 -37
View File
@@ -19,28 +19,72 @@ 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 parser = pulldown_cmark::Parser::new(text); let mut options = pulldown_cmark::Options::empty();
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
let parser = pulldown_cmark::Parser::new_ext(text, options);
let mut in_code_block = false; let mut in_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;
let mut in_table_cell = false;
let mut table_rows: Vec<Vec<Vec<Span<'static>>>> = Vec::new();
let mut current_row: Vec<Vec<Span<'static>>> = Vec::new();
let mut current_cell: Vec<Span<'static>> = Vec::new();
for event in parser { for event in parser {
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",
@@ -48,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",
@@ -69,27 +113,37 @@ 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(_) => {
table_rows.clear();
}
pulldown_cmark::Tag::TableHead | pulldown_cmark::Tag::TableRow => {
current_row.clear();
}
pulldown_cmark::Tag::TableCell => {
in_table_cell = true;
current_cell.clear();
}
_ => {} _ => {}
} }
} }
@@ -97,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(_) => {
@@ -114,16 +169,107 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => { pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => {
spans.push(Span::raw("\n")); spans.push(Span::raw("\n"));
} }
pulldown_cmark::TagEnd::TableCell => {
in_table_cell = false;
current_row.push(std::mem::take(&mut current_cell));
}
pulldown_cmark::TagEnd::TableHead | pulldown_cmark::TagEnd::TableRow => {
table_rows.push(std::mem::take(&mut current_row));
}
pulldown_cmark::TagEnd::Table => {
let cols_count = table_rows.first().map(|r| r.len()).unwrap_or(0);
if cols_count == 0 {
continue;
}
let mut col_widths = vec![0; cols_count];
for row in &table_rows {
for (i, cell) in row.iter().enumerate() {
if i < cols_count {
let cell_width: usize = cell.iter().map(|s| s.content.chars().count()).sum();
if cell_width > col_widths[i] {
col_widths[i] = cell_width;
}
}
}
}
let effective_width = if width > 0 { (width as usize).saturating_sub(2) } else { 0 };
let border_overhead = cols_count * 3 + 4;
let available_width = effective_width.saturating_sub(border_overhead);
let mut total_width: usize = col_widths.iter().sum();
if width > 0 && total_width > available_width && available_width > 0 {
while total_width > available_width {
let max_idx = col_widths.iter().enumerate().max_by_key(|&(_, &w)| w).map(|(i, _)| i).unwrap();
if col_widths[max_idx] <= 3 { break; }
col_widths[max_idx] -= 1;
total_width -= 1;
}
}
spans.push(Span::raw("\n"));
for (r, row) in table_rows.iter().enumerate() {
let mut cell_lines = Vec::new();
for (i, cell) in row.iter().enumerate() {
if i < cols_count {
cell_lines.push(wrap_spans_to_lines(cell, col_widths[i]));
}
}
let max_height = cell_lines.iter().map(|cl| cl.len()).max().unwrap_or(1);
for y in 0..max_height {
spans.push(Span::styled(" | ", apply_dim(Style::default().fg(Theme::BORDER), dim)));
for (i, cl) in cell_lines.iter().enumerate() {
let line_spans = if y < cl.len() { &cl[y] } else { [].as_slice() };
let mut line_width = 0;
for span in line_spans {
line_width += span.content.chars().count();
spans.push(span.clone());
}
let pad = col_widths[i].saturating_sub(line_width);
spans.push(Span::raw(" ".repeat(pad)));
spans.push(Span::styled(" | ", apply_dim(Style::default().fg(Theme::BORDER), dim)));
}
spans.push(Span::raw("\n"));
}
if r == 0 {
spans.push(Span::styled(" |", apply_dim(Style::default().fg(Theme::BORDER), dim)));
for w in &col_widths {
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"));
}
_ => {} _ => {}
} }
} }
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 {
spans.push(Span::styled( if in_diff_block {
format!(" {s}"), for (i, line) in s.split('\n').enumerate() {
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), if i > 0 {
)); spans.push(Span::raw("\n"));
}
if line.is_empty() {
continue;
}
let style = diff_line_style(line)
.unwrap_or_else(|| Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG));
spans.push(Span::styled(format!(" {line}"), style));
}
} else {
let indented = format!(" {}", s.replace('\n', "\n "));
spans.push(Span::styled(
indented,
apply_dim(Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), dim),
));
}
} else if in_heading { } else if in_heading {
let color = match heading_level { let color = match heading_level {
1 => Theme::PRIMARY, 1 => Theme::PRIMARY,
@@ -133,21 +279,30 @@ 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 {
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) => {
// Inline code with background let span = Span::styled(
spans.push(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 {
current_cell.push(span);
} else {
spans.push(span);
}
} }
pulldown_cmark::Event::SoftBreak => { pulldown_cmark::Event::SoftBreak => {
spans.push(Span::raw(" ")); spans.push(Span::raw(" "));
@@ -164,23 +319,54 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
let mut line_len = 0; let mut line_len = 0;
let effective_width = (width as usize).saturating_sub(2); // leave margin let effective_width = (width as usize).saturating_sub(2); // leave margin
for span in &spans { for span in spans {
let style = span.style; let style = span.style;
let s = span.content.clone(); let text = span.content.as_ref();
let text_str = s.as_ref();
let remaining = text_str.len(); let mut current = String::new();
let mut tokens = Vec::new();
if line_len + remaining > effective_width && line_len > 0 { for c in text.chars() {
spans_out.push(Span::raw("\n")); if c == ' ' {
line_len = 0; if !current.is_empty() { tokens.push(current.clone()); current.clear(); }
tokens.push(" ".to_string());
} else if c == '\n' {
if !current.is_empty() { tokens.push(current.clone()); current.clear(); }
tokens.push("\n".to_string());
} else {
current.push(c);
}
} }
if !current.is_empty() { tokens.push(current); }
spans_out.push(Span::styled(text_str.to_string(), style));
for token in tokens {
if text_str.contains('\n') { if token == "\n" {
line_len = text_str.split('\n').next_back().unwrap_or("").len(); spans_out.push(Span::styled("\n", style));
} else { line_len = 0;
line_len += remaining; } else if token == " " {
if line_len > 0 && line_len < effective_width {
spans_out.push(Span::styled(" ", style));
line_len += 1;
}
} else {
let token_len = token.chars().count();
if line_len + token_len > effective_width && line_len > 0 {
spans_out.push(Span::raw("\n"));
line_len = 0;
}
if token_len > effective_width {
for c in token.chars() {
if line_len >= effective_width {
spans_out.push(Span::raw("\n"));
line_len = 0;
}
spans_out.push(Span::styled(c.to_string(), style));
line_len += 1;
}
} else {
spans_out.push(Span::styled(token, style));
line_len += token_len;
}
}
} }
} }
spans = spans_out; spans = spans_out;
@@ -188,3 +374,100 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
spans spans
} }
fn wrap_spans_to_lines(spans: &[Span<'static>], target_width: usize) -> Vec<Vec<Span<'static>>> {
let mut lines = Vec::new();
let mut current_line = Vec::new();
let mut line_len = 0;
for span in spans {
let style = span.style;
let text = span.content.as_ref();
let mut current_word = String::new();
let mut tokens = Vec::new();
for c in text.chars() {
if c == ' ' {
if !current_word.is_empty() { tokens.push(current_word.clone()); current_word.clear(); }
tokens.push(" ".to_string());
} else {
current_word.push(c);
}
}
if !current_word.is_empty() { tokens.push(current_word); }
for token in tokens {
if token == " " {
if line_len > 0 && line_len < target_width {
current_line.push(Span::styled(" ", style));
line_len += 1;
}
} else {
let token_len = token.chars().count();
if line_len + token_len > target_width && line_len > 0 {
lines.push(std::mem::take(&mut current_line));
line_len = 0;
}
if token_len > target_width {
for c in token.chars() {
if target_width > 0 && line_len >= target_width {
lines.push(std::mem::take(&mut current_line));
line_len = 0;
}
current_line.push(Span::styled(c.to_string(), style));
line_len += 1;
}
} else {
current_line.push(Span::styled(token, style));
line_len += token_len;
}
}
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(vec![]);
}
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));
}
}
+9 -6
View File
@@ -36,11 +36,13 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
// since this sidebar holds three stacked widgets, not one. // since this sidebar holds three stacked widgets, not one.
let show_sidebar = area.width > SIDEBAR_MIN_WIDTH; let show_sidebar = area.width > SIDEBAR_MIN_WIDTH;
let (main_area, sidebar_area) = if show_sidebar { let (main_area, sidebar_area) = if show_sidebar {
let has_workflow = !state.workflow_engine.agents.is_empty();
let sidebar_width = if has_workflow { 48 } else { 30 };
let h_chunks = Layout::default() let h_chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
Constraint::Min(40), Constraint::Min(40),
Constraint::Length(30), Constraint::Length(sidebar_width),
]) ])
.split(area); .split(area);
(h_chunks[0], Some(h_chunks[1])) (h_chunks[0], Some(h_chunks[1]))
@@ -224,10 +226,7 @@ fn render_overlay(
frame.render_widget(paragraph, overlay_area); frame.render_widget(paragraph, overlay_area);
} }
// ── Workflow ──────────────────────────────────────────────────
crate::app::state::types::Overlay::Workflow => {
workflow::draw_workflow_panel(frame, overlay_area, state);
}
// ── Key Input ───────────────────────────────────────────────── // ── Key Input ─────────────────────────────────────────────────
crate::app::state::types::Overlay::KeyInput => { crate::app::state::types::Overlay::KeyInput => {
@@ -779,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));
+18 -6
View File
@@ -13,16 +13,28 @@ use super::theme::Theme;
/// Render the persistent right-hand dashboard: Workflow, Tasks, and Usage /// Render the persistent right-hand dashboard: Workflow, Tasks, and Usage
/// widgets stacked in three roughly-equal vertical thirds. /// widgets stacked in three roughly-equal vertical thirds.
pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
let has_workflow = !state.workflow_engine.agents.is_empty();
let constraints = if has_workflow {
vec![
Constraint::Ratio(1, 2),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
]
} else {
vec![
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
]
};
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints(constraints)
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(area); .split(area);
super::workflow::draw_workflow_widget(frame, chunks[0], state); super::workflow::draw_workflow_panel(frame, chunks[0], state);
draw_tasks_widget(frame, chunks[1], state); draw_tasks_widget(frame, chunks[1], state);
draw_usage_widget(frame, chunks[2], state); draw_usage_widget(frame, chunks[2], state);
} }
+1 -45
View File
@@ -14,7 +14,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame; use ratatui::Frame;
use super::theme::Theme; use super::theme::Theme;
use crate::app::workflow::engine::{AgentState, WorkflowAgent}; use crate::app::workflow::engine::AgentState;
/// Icons for agent states. /// Icons for agent states.
fn state_icon(state: AgentState) -> &'static str { fn state_icon(state: AgentState) -> &'static str {
@@ -74,7 +74,6 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
header_lines.push(Line::from(vec![ header_lines.push(Line::from(vec![
Span::styled("/workflow run ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)), Span::styled("/workflow run ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)),
Span::styled("<prompt>", Style::default().fg(Theme::TEXT_DIM)), Span::styled("<prompt>", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(" · Esc to close", Style::default().fg(Theme::TEXT_DIM)),
])); ]));
header_lines.push(Line::from(vec![ header_lines.push(Line::from(vec![
Span::styled("Status: ", Style::default().fg(Theme::TEXT_DIM)), Span::styled("Status: ", Style::default().fg(Theme::TEXT_DIM)),
@@ -207,47 +206,4 @@ fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec<Lin
lines lines
} }
/// Render the compact Workflow widget for the persistent sidebar: one
/// line per agent (icon + name), truncated to whatever fits with a
/// trailing "+N more" hint pointing at `/workflow` for the full view.
///
/// Flow: bordered `Block` titled "Workflow" → empty state if no agents →
/// else `split_for_display` caps the list to the inner height (minus one
/// row for the hint line, if needed) → one line per visible agent.
pub fn draw_workflow_widget(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
let block = Block::default()
.title(Span::styled(" Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER));
let budget = (block.inner(area).height as usize).max(1);
let agents = &state.workflow_engine.agents;
let lines: Vec<Line> = if agents.is_empty() {
vec![Line::from(Span::styled(
" No workflow running.",
Style::default().fg(Theme::TEXT_DIM),
))]
} else {
let show_hint = agents.len() > budget;
let item_budget = if show_hint { budget.saturating_sub(1).max(1) } else { budget };
let (visible, hidden) = super::split_for_display(agents.as_slice(), item_budget);
let mut lines: Vec<Line> = visible.iter().map(workflow_agent_line).collect();
if show_hint {
lines.push(super::overflow_hint_line(hidden, "/workflow"));
}
lines
};
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
/// One compact line for a single agent: state icon + name, state-colored.
fn workflow_agent_line(agent: &WorkflowAgent) -> Line<'static> {
let color = state_color(agent.status.state);
let icon = state_icon(agent.status.state);
Line::from(vec![
Span::styled(format!(" {icon} "), Style::default().fg(color).add_modifier(Modifier::BOLD)),
Span::styled(agent.name.clone(), Style::default().fg(Theme::TEXT)),
])
}