Compare commits

...
24 Commits
Author SHA1 Message Date
semantic-release-bot 340ae2fde2 chore(release): 1.6.0 [skip ci]
# [1.6.0](https://github.com/asepharyana/zesdex/compare/v1.5.0...v1.6.0) (2026-07-14)

### Bug Fixes

* perbaiki 5 warning clippy pre-existing (base untuk TUI overhaul) ([6b58977](https://github.com/asepharyana/zesdex/commit/6b58977875f809f19cc2d7bb9b2a7dd057d0229e))
* **tui:** perbaiki isi overlay Todo dan Usage jadi tampilan detail nyata ([aaea300](https://github.com/asepharyana/zesdex/commit/aaea300699f7e76cdc689e225e7c4c3bc164e8d4))
* **tui:** perbaiki potensi terpotongnya baris token di widget Usage sidebar ([7fd55fa](https://github.com/asepharyana/zesdex/commit/7fd55fa86dfe8f9a4581f02fa9220cd5d1ba600c))
* **tui:** perbaiki rendering multi-baris pada pesan Tool ([2f1a4d8](https://github.com/asepharyana/zesdex/commit/2f1a4d85a1fdc9cbfb81912a3206c057ad9d1ed5))

### Features

* **tui:** ganti palet warna ke Tokyo Night ([7a9cb7b](https://github.com/asepharyana/zesdex/commit/7a9cb7bf342367c81fb4a1568675e46f132a8afc))
* **tui:** rombak rendering chat jadi format log rapat ([e34708a](https://github.com/asepharyana/zesdex/commit/e34708a3191bef63d191e76dee39a58a33e4ad5f))
* **tui:** tambah command /todo dan /usage untuk buka overlay ([aa2b6ac](https://github.com/asepharyana/zesdex/commit/aa2b6acb95f8518950b8b7d9c3c9e10968936162))
* **tui:** tambah dan pasang sidebar dashboard permanen ([31c01cd](https://github.com/asepharyana/zesdex/commit/31c01cdf1df6827c3a949820378c8b85ebcfdf87))
2026-07-14 16:45:55 +00:00
asepharyana 1010e44b22 Implement new feature for user authentication and improve error handling 2026-07-14 23:41:53 +07:00
asepharyanaandClaude Sonnet 5 b355c9928e style(tui): hapus emoji dekoratif dari judul overlay
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:41:53 +07:00
asepharyanaandClaude Sonnet 5 aaea300699 fix(tui): perbaiki isi overlay Todo dan Usage jadi tampilan detail nyata
Overlay Todo sebelumnya menampilkan jumlah pesan yang tidak relevan,
bukan isi task list. Overlay Usage sekarang pakai compute_usage_summary
yang sama dengan widget sidebar (DRY, angka konsisten).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:41:53 +07:00
asepharyanaandClaude Sonnet 5 7fd55fa86d fix(tui): perbaiki potensi terpotongnya baris token di widget Usage sidebar
Baris pertama widget Usage sebelumnya bisa melebihi lebar kolom sidebar
(30 kolom) pada sesi dengan jumlah token besar, menyebabkan teks
terpotong diam-diam tanpa wrap. Sekarang dipecah jadi beberapa baris
pendek yang aman di lebar berapa pun yang realistis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:41:53 +07:00
asepharyanaandClaude Sonnet 5 31c01cdf1d feat(tui): tambah dan pasang sidebar dashboard permanen
Sidebar kanan permanen (Workflow/Tasks/Usage) menggantikan panel todo
ad-hoc yang lama. Widget baca state yang sudah ada, tidak ada perubahan
skema AppStateRest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:41:53 +07:00
asepharyanaandClaude Sonnet 5 aa2b6acb95 feat(tui): tambah command /todo dan /usage untuk buka overlay
Overlay Todo dan Usage sebelumnya tidak punya trigger sama sekali di
jalur interaksi normal (cuma bisa lewat restore snapshot sesi) --
sekarang mengikuti pola /workflow yang sudah ada.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:41:53 +07:00
asepharyanaandClaude Sonnet 5 2f1a4d85a1 fix(tui): perbaiki rendering multi-baris pada pesan Tool
Konten Tool sebelumnya didorong sebagai satu Span tanpa split newline,
sehingga output tool multi-baris (stdout bash, hasil grep, diff)
tampil sebagai karakter \n literal alih-alih baris terpisah. Sekarang
mengikuti pola render_markdown + split_spans_into_lines yang sama
dengan role lain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:41:53 +07:00
asepharyanaandClaude Sonnet 5 e34708a319 feat(tui): rombak rendering chat jadi format log rapat
markdown.rs juga disesuaikan: indentasi paragraf/heading bawaannya
dilepas supaya tidak bentrok dengan indent PREFIX_WIDTH di chat.rs
(baris pertama vs baris wrap lanjutan jadi sejajar).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:41:53 +07:00
asepharyanaandClaude Sonnet 5 6b58977875 fix: perbaiki 5 warning clippy pre-existing (base untuk TUI overhaul)
unnested_or_patterns, collapsible_if, items_after_statements, dan
case_sensitive_file_extension_comparisons di auto.rs, engine.rs, dan
actions/mod.rs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:41:53 +07:00
asepharyanaandClaude Sonnet 5 7a9cb7bf34 feat(tui): ganti palet warna ke Tokyo Night
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:41:53 +07:00
asepharyanaandClaude Sonnet 5 04e7ff9380 docs: tambah spec desain rombak TUI (Multi-Pane Dashboard, Tokyo Night)
Spec brainstorming untuk rombak total src/view + src/controller: layout
Multi-Pane Dashboard dengan sidebar Workflow/Tasks/Usage, palet Tokyo
Night, dan format chat inline rapat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:41:53 +07:00
semantic-release-bot 7070c96460 chore(release): 1.5.0 [skip ci]
# [1.5.0](https://github.com/asepharyana/zesdex/compare/v1.4.0...v1.5.0) (2026-07-14)

### Bug Fixes

* **hive-mind:** ganti gerbang pipeline berbasis jumlah pesan dengan deteksi konvergensi sebelumnya ([5498088](https://github.com/asepharyana/zesdex/commit/5498088532314f8dbc005d5e8058c0170ca92320))
* **hive-mind:** gunakan flag SessionRuntime sebagai sinyal konvergensi otoritatif ([28e763a](https://github.com/asepharyana/zesdex/commit/28e763a695f56adfbecd4efb14edbde13bbd63dc))
* **hive-mind:** hapus penulisan docs/runs ganda dan sambungkan abort_flag ke tool hive_mind manual ([a125f5d](https://github.com/asepharyana/zesdex/commit/a125f5d4400b0c417ba4049e67480bd18479e90b))
* **hive-mind:** tambah timeout per-node dan jamin dokumentasi convergence tetap tertulis saat sintesis gagal ([b1c0265](https://github.com/asepharyana/zesdex/commit/b1c0265e8cdf9278664e64f77fbde4ec8c22fcfd))
* **subagent:** panic-proof overlap guards and update stale docs ([e023f2c](https://github.com/asepharyana/zesdex/commit/e023f2c5a8f036d89e925bb9ceec253343476a74))
* **subagent:** perbaiki filter is_production_code berbasis substring dan tambah pembatalan/anti-tumpang-tindih pada background review ([1039f67](https://github.com/asepharyana/zesdex/commit/1039f67c12749c6b2c93e3ab7037feded8ab01c6))
* **tui:** perbaiki roster workflow yang tidak pernah ter-reset karena substring "started" tidak pernah cocok ([fdd62f8](https://github.com/asepharyana/zesdex/commit/fdd62f830330b5b3e2b4f9fcc7274daf7f7842a5))

### Features

* **settings:** tambah hive_mind_node_timeout_ms dengan fallback serde default ([e2878a3](https://github.com/asepharyana/zesdex/commit/e2878a3d83f171aa181ac29ca689828e0cb1408f))
* **tool:** tambah abort_flag ke ToolCtx dan sambungkan dari session state ([79e2bfc](https://github.com/asepharyana/zesdex/commit/79e2bfcc9ca67424ca2b34a5652d3c2bb93291bf))
2026-07-14 03:59:10 +00:00
asepharyanaandClaude Sonnet 5 28e763a695 fix(hive-mind): gunakan flag SessionRuntime sebagai sinyal konvergensi otoritatif
Pesan sistem bertanda [Hive-Mind Consensus] hanya di-push ke variabel lokal
run_agent_turn dan diarsipkan ke SQLite, tidak pernah masuk ke
rt.messages lewat TurnEvent — sehingga hive_mind_already_ran selalu
memindai daftar pesan yang kosong dan gerbang "converge sekali per sesi"
tidak pernah aktif. Tambahkan SessionRuntime.hive_mind_converged yang
diset dari event TurnEvent::SystemNote { kind: "hive_mind_converged" }
setelah konvergensi selesai, disalurkan lewat TurnCtx, dan dijadikan
sinyal utama di run_agent_turn (pemindaian pesan lama tetap sebagai
fallback defensif).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:55:05 +07:00
asepharyanaandClaude Sonnet 5 3e6f9a6a5f test(subagent): tambah pengujian invarian read⊆write⊆full pada tool_scope
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:55:05 +07:00
asepharyanaandClaude Sonnet 5 e023f2c5a8 fix(subagent): panic-proof overlap guards and update stale docs
RunningGuard resets TEST_GEN_RUNNING/ARCH_REVIEW_RUNNING/SECURITY_REVIEW_RUNNING
via Drop so a subagent panic can no longer wedge that review kind disabled
for the rest of the process. Doc comments on run_subagent_with_retry and the
three spawn_background_* / spawn_all_background functions now describe the
abort_flag and overlap-guard behavior added in Task 7.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:55:05 +07:00
asepharyanaandClaude Sonnet 5 1039f67c12 fix(subagent): perbaiki filter is_production_code berbasis substring dan tambah pembatalan/anti-tumpang-tindih pada background review
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:55:05 +07:00
asepharyanaandClaude Sonnet 5 fdd62f8303 fix(tui): perbaiki roster workflow yang tidak pernah ter-reset karena substring "started" tidak pernah cocok
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:55:05 +07:00
asepharyanaandClaude Sonnet 5 5498088532 fix(hive-mind): ganti gerbang pipeline berbasis jumlah pesan dengan deteksi konvergensi sebelumnya
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:55:05 +07:00
asepharyanaandClaude Sonnet 5 a125f5d440 fix(hive-mind): hapus penulisan docs/runs ganda dan sambungkan abort_flag ke tool hive_mind manual
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:55:05 +07:00
asepharyanaandClaude Sonnet 5 79e2bfcc9c feat(tool): tambah abort_flag ke ToolCtx dan sambungkan dari session state
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:55:05 +07:00
asepharyanaandClaude Sonnet 5 b1c0265e8c fix(hive-mind): tambah timeout per-node dan jamin dokumentasi convergence tetap tertulis saat sintesis gagal
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:55:05 +07:00
asepharyanaandClaude Sonnet 5 e2878a3d83 feat(settings): tambah hive_mind_node_timeout_ms dengan fallback serde default
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:55:05 +07:00
asepharyanaandClaude Sonnet 5 6790fe481b docs: tambah rencana implementasi perbaikan hive-mind dan subagent
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:55:05 +07:00
27 changed files with 4415 additions and 363 deletions
+2 -1
View File
@@ -3,4 +3,5 @@ target/
.claude/settings.local.json .claude/settings.local.json
node_modules/ node_modules/
package.json package.json
package-lock.json package-lock.json
.superpowers/
+37
View File
@@ -1,3 +1,40 @@
# [1.6.0](https://github.com/asepharyana/zesdex/compare/v1.5.0...v1.6.0) (2026-07-14)
### Bug Fixes
* perbaiki 5 warning clippy pre-existing (base untuk TUI overhaul) ([6b58977](https://github.com/asepharyana/zesdex/commit/6b58977875f809f19cc2d7bb9b2a7dd057d0229e))
* **tui:** perbaiki isi overlay Todo dan Usage jadi tampilan detail nyata ([aaea300](https://github.com/asepharyana/zesdex/commit/aaea300699f7e76cdc689e225e7c4c3bc164e8d4))
* **tui:** perbaiki potensi terpotongnya baris token di widget Usage sidebar ([7fd55fa](https://github.com/asepharyana/zesdex/commit/7fd55fa86dfe8f9a4581f02fa9220cd5d1ba600c))
* **tui:** perbaiki rendering multi-baris pada pesan Tool ([2f1a4d8](https://github.com/asepharyana/zesdex/commit/2f1a4d85a1fdc9cbfb81912a3206c057ad9d1ed5))
### Features
* **tui:** ganti palet warna ke Tokyo Night ([7a9cb7b](https://github.com/asepharyana/zesdex/commit/7a9cb7bf342367c81fb4a1568675e46f132a8afc))
* **tui:** rombak rendering chat jadi format log rapat ([e34708a](https://github.com/asepharyana/zesdex/commit/e34708a3191bef63d191e76dee39a58a33e4ad5f))
* **tui:** tambah command /todo dan /usage untuk buka overlay ([aa2b6ac](https://github.com/asepharyana/zesdex/commit/aa2b6acb95f8518950b8b7d9c3c9e10968936162))
* **tui:** tambah dan pasang sidebar dashboard permanen ([31c01cd](https://github.com/asepharyana/zesdex/commit/31c01cdf1df6827c3a949820378c8b85ebcfdf87))
# [1.5.0](https://github.com/asepharyana/zesdex/compare/v1.4.0...v1.5.0) (2026-07-14)
### Bug Fixes
* **hive-mind:** ganti gerbang pipeline berbasis jumlah pesan dengan deteksi konvergensi sebelumnya ([5498088](https://github.com/asepharyana/zesdex/commit/5498088532314f8dbc005d5e8058c0170ca92320))
* **hive-mind:** gunakan flag SessionRuntime sebagai sinyal konvergensi otoritatif ([28e763a](https://github.com/asepharyana/zesdex/commit/28e763a695f56adfbecd4efb14edbde13bbd63dc))
* **hive-mind:** hapus penulisan docs/runs ganda dan sambungkan abort_flag ke tool hive_mind manual ([a125f5d](https://github.com/asepharyana/zesdex/commit/a125f5d4400b0c417ba4049e67480bd18479e90b))
* **hive-mind:** tambah timeout per-node dan jamin dokumentasi convergence tetap tertulis saat sintesis gagal ([b1c0265](https://github.com/asepharyana/zesdex/commit/b1c0265e8cdf9278664e64f77fbde4ec8c22fcfd))
* **subagent:** panic-proof overlap guards and update stale docs ([e023f2c](https://github.com/asepharyana/zesdex/commit/e023f2c5a8f036d89e925bb9ceec253343476a74))
* **subagent:** perbaiki filter is_production_code berbasis substring dan tambah pembatalan/anti-tumpang-tindih pada background review ([1039f67](https://github.com/asepharyana/zesdex/commit/1039f67c12749c6b2c93e3ab7037feded8ab01c6))
* **tui:** perbaiki roster workflow yang tidak pernah ter-reset karena substring "started" tidak pernah cocok ([fdd62f8](https://github.com/asepharyana/zesdex/commit/fdd62f830330b5b3e2b4f9fcc7274daf7f7842a5))
### Features
* **settings:** tambah hive_mind_node_timeout_ms dengan fallback serde default ([e2878a3](https://github.com/asepharyana/zesdex/commit/e2878a3d83f171aa181ac29ca689828e0cb1408f))
* **tool:** tambah abort_flag ke ToolCtx dan sambungkan dari session state ([79e2bfc](https://github.com/asepharyana/zesdex/commit/79e2bfcc9ca67424ca2b34a5652d3c2bb93291bf))
# [1.4.0](https://github.com/asepharyana/zesdex/compare/v1.3.0...v1.4.0) (2026-07-14) # [1.4.0](https://github.com/asepharyana/zesdex/compare/v1.3.0...v1.4.0) (2026-07-14)
Generated
+1 -1
View File
@@ -4436,7 +4436,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex" name = "zesdex"
version = "1.4.0" version = "1.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "zesdex" name = "zesdex"
version = "1.4.0" version = "1.6.0"
edition = "2021" edition = "2021"
authors = ["asepharyana <superaseph@gmail.com>"] authors = ["asepharyana <superaseph@gmail.com>"]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,187 @@
# TUI Overhaul — Design
**Status:** Approved, pending implementation plan
**Date:** 2026-07-14
**Scope:** `src/view/`, `src/controller/` (render/interaction layer only)
## Context
The TUI went through a "modern design" pass the day before this spec (commit `3f5f27c`:
dark palette, neon accents, message cards, segmented status bar). The request for this
overhaul covers all three axes at once: aesthetics, UX/navigation, and layout paradigm —
not a re-skin of the existing structure.
## Goals
- Replace the current 3-zone layout (chat / input / status, everything else as a
full-block centered modal) with a **Multi-Pane Dashboard**: chat stays central, a
persistent right sidebar surfaces live status that today requires opening a modal.
- Replace the current "neon dusk" palette with a **Tokyo Night** palette.
- Replace the current per-message card rendering (badge pill, left accent bar, blank-line
gaps) with a **tight inline log** format.
- Drop decorative emoji from overlay titles in favor of plain colored text — the accent
border/text color already carries identity.
- Restyle (not restructure) the overlays that stay modal.
## Non-goals
- No `AppStateRest` shape changes, no new `Action` variants, no controller/state-mutation
changes. This is a view-layer repaint; `theme.rs` constants are the only "API" the rest
of the app depends on, and their names don't change, only their values.
- No new keybindings and no mouse support. Sidebar widgets are read-only/glanceable —
none of the three (Workflow, Todo, Usage) are interactive today, so they don't need
focus or selection state in their new form either.
- No overlay is removed. Workflow/Todo/Usage keep their existing overlay trigger as an
"expand" view (see below); the other 13 overlays are untouched functionally.
- No automated visual/snapshot tests are being introduced (none exist today for
`view/`/`controller/`; see Testing below).
## Layout architecture
```
┌───────────────────────────────────────────┬──────────────┐
│ │ WORKFLOW │
│ Chat transcript (tight inline log) │ ▶ Node-0-1 │
│ │ ✓ Node-0-2 │
│ ├──────────────┤
│ │ TASKS │
│ │ ☐ Fix bug │
│ │ ☑ Repro │
│ ├──────────────┤
│ │ USAGE │
│ │ 12.3k tok │
├─────────────────────────────────────────────┴──────────────┤
input bar │
├───────────────────────────────────────────────────────────┤
│ status bar │
└───────────────────────────────────────────────────────────┘
```
- The sidebar is a fixed-width column (generalizing the existing `show_todo`
two-column split in `view/mod.rs::draw`) holding three stacked widgets, in this
order: **Workflow**, **Tasks**, **Usage**.
- **Responsive collapse**: below a width threshold (~90 cols — extending the existing
`show_todo && area.width > 60` precedent, widened because the new sidebar holds three
stacked widgets instead of one), the sidebar doesn't render and chat takes full width.
No manual toggle key — purely width-driven, matching current behavior.
- Each sidebar widget truncates its content to what fits and shows a `+N more, press
<key> to expand` hint (same pattern `Rewind` already uses for `"... and N more
messages"`) when there's more than fits — that's what the kept overlay is for.
### Workflow / Todo / Usage: sidebar glance + overlay expand
These three overlays are **not removed**. Their existing trigger (same keys/commands as
today) still opens the full-screen version — now serving as the "expand" view for when
the sidebar column is too narrow to show everything (many hive-mind nodes, a long task
list). The sidebar widget and the overlay both read the same state
(`workflow_engine`, `misc.todo_content`, `session_runtime.usage` +
`session_runtime.session_start`); the sidebar version is a new compact rendering, factored
out so both call sites share it where the content is identical (e.g. per-agent card
formatting in `workflow.rs`).
### Remaining 13 overlays: restyled modals, unchanged behavior
`Help, Settings, Bash, QuitConfirm, KeyInput, Editor, Effort, Mcp, Rewind, Learning,
Loading, ModelSelector, ClearConfirm` keep their current centered-modal mechanic and
content logic exactly as-is. Only their chrome changes: new palette values (same
semantic-color-per-overlay mapping as today — e.g. `QuitConfirm` stays `ERROR`, `Settings`
stays `PRIMARY`), and emoji dropped from their title strings.
## Visual language
### Palette — Tokyo Night
Values only; `Theme` constant names in `view/theme.rs` are unchanged, so every call site
across `view/*` keeps working without edits beyond the const definitions themselves.
| Constant | Value | Constant | Value |
|---|---|---|---|
| `BG` | `#1a1b26` | `ROLE_USER` | `#9ece6a` |
| `SURFACE` | `#1f2335` | `ROLE_ASSISTANT` | `#7aa2f7` |
| `SURFACE_ELEVATED` | `#292e42` | `ROLE_SYSTEM` | `#7dcfff` |
| `TEXT` | `#c0caf5` | `ROLE_TOOL` | `#e0af68` |
| `TEXT_MUTED` | `#a9b1d6` | `PRIMARY` | `#7aa2f7` |
| `TEXT_DIM` | `#565f89` | `SUCCESS` | `#9ece6a` |
| `BORDER` | `#3b4261` | `WARNING` | `#e0af68` |
| `BORDER_FOCUS` | `#7aa2f7` | `ERROR` | `#f7768e` |
| `HIGHLIGHT` | `#3d59a1` | `INFO` | `#7dcfff` |
| `HIGHLIGHT_DIM` | `#292e42` | `ACCENT_PURPLE` | `#bb9af7` |
| `STATUS_BAR_BG` | `#16161e` | `ACCENT_PINK` | `#ff007c` |
| `MODE_AUTO` | `#9ece6a` | `ACCENT_ORANGE` | `#ff9e64` |
| `MODE_YOLO` | `#f7768e` | `ACCENT_TEAL` | `#73daca` |
| `CODE_BG` | `#16161e` | `CODE_BAR` | `#292e42` |
| `BLOCKQUOTE_BAR` | `#7dcfff` | `SCROLLBAR_BG` / `SCROLLBAR_FG` | `#1f2335` / `#3b4261` |
### Message density — tight inline log
Replaces the per-message card (role badge pill + left accent bar + blank-line gap)
in `chat.rs`:
```
you 09:14 fix the login bug
ai 09:14 Looking at src/auth.rs now.
↳ Reading src/auth.rs
you 09:15 ok try again
```
- Role rendered as a short lowercase colored label (`ROLE_*` colors), timestamp dim,
inline with the first content line.
- Wrapped/multi-line content aligns under the content column (not under the role label).
- Tool-call sub-lines get a dim `↳` prefix.
- No blank line within a turn; a single blank line only between different speakers (not
after every message).
- The chat panel's outer bordered `Block` is unchanged — only the messages inside it lose
per-message decoration.
- The streaming indicator becomes `ai 09:14 ⠋ generating...` inline, matching the new
format, instead of the current padded badge line.
### Icons
Overlay titles drop decorative emoji (❓⚙💻🚪✏️🎯🔌📋⏪📚📊⏳🧠🗑️⚡) and render as plain
bold colored text (e.g. `Settings` in `PRIMARY`, no ⚙). The border/text accent color is
the identity signal, consistent with the muted Tokyo Night + tight-density direction.
## File impact
| File | Change |
|---|---|
| `view/theme.rs` | Palette values swap (table above). Const names/count unchanged. |
| `view/chat.rs` | Rewrite message rendering to the tight inline format. |
| `view/markdown.rs` | Re-themed code/quote colors; tightened padding. No structural rewrite. |
| `view/mod.rs` | `draw()` grows the persistent sidebar column (generalizes `show_todo` split). `render_overlay()` match arms restyled in place (palette + title text), content logic untouched. Todo/Usage compact-widget rendering factored out of the current inline overlay code so it's callable from both the sidebar and the kept overlay. |
| `view/status.rs` | Restyle to new palette; structurally unchanged. |
| `view/workflow.rs` | Add a compact-card render function for the sidebar widget, reusing the existing per-agent formatting logic. |
| `controller/*` | No changes. Interaction model is unchanged; sidebar is non-interactive. |
## Edge cases
- Empty states per sidebar widget (no workflow running, no tasks, zero usage) — compact
one-line placeholders, consistent with the tight density (not the current multi-line
placeholder paragraphs).
- Sidebar auto-collapses below ~90 cols; chat reclaims full width.
- Sidebar widget overflow (e.g. a hive-mind run with many nodes, a long task list)
truncates with a `+N more` hint pointing at the existing expand-overlay trigger.
- Long chat content wraps with continuation lines aligned under the content column.
## Testing / verification
No automated visual or snapshot tests exist for `view/`/`controller/` today (confirmed:
zero `#[cfg(test)] mod tests` in either directory), and none are introduced by this
change — ratatui rendering isn't meaningfully unit-testable without a snapshot harness
this repo doesn't have. Verification is manual: run the TUI (`cargo run`) and exercise
the golden paths (send a chat message, trigger a workflow/hive-mind run, open each of the
13 remaining overlays, resize the terminal across the sidebar-collapse threshold).
`cargo clippy` must stay clean (warnings-as-errors per repo config), and every touched
`pub fn`/`struct` keeps the doc-comment convention from CLAUDE.md (What/Flow/Why/Return).
## Suggested implementation order
Not binding — the implementation plan owns sequencing — but a sensible build order given
the dependency shape (palette first, since everything else reads `Theme` consts):
1. `theme.rs` palette swap
2. `chat.rs` tight-inline rewrite
3. `mod.rs` sidebar scaffolding + Workflow/Tasks/Usage compact widgets (+ `workflow.rs`
compact-card fn)
4. `status.rs` restyle + remaining 13 overlay restyle (mechanical: palette + title text)
5. Manual TUI verification pass across golden paths above
+89 -21
View File
@@ -383,16 +383,20 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
} }
} else if kind == "connectivity" { } else if kind == "connectivity" {
state.misc.api_connected = message == "connected"; state.misc.api_connected = message == "connected";
} else if kind == "hive_mind_converged" {
if let Some(ref mut rt) = state.session_runtime {
rt.hive_mind_converged = true;
}
} else if kind == "pipeline" { } else if kind == "pipeline" {
// Clear old workflow agents when a new pipeline starts. // Clear old workflow agents when a new pipeline starts.
if message.contains("started") { if message == HIVE_MIND_KICKOFF_NOTE {
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") { if message.to_lowercase().contains("complete")
if state.misc.overlay == Overlay::Workflow { && state.misc.overlay == Overlay::Workflow
state.misc.overlay = Overlay::None; {
} state.misc.overlay = Overlay::None;
} }
state.push_toast(Toast { state.push_toast(Toast {
kind: ToastKind::Info, kind: ToastKind::Info,
@@ -748,6 +752,7 @@ fn spawn_turn(state: &AppStateRest) {
let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.clone(); let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.clone();
let abort_flag = state.abort_flag.clone(); let abort_flag = state.abort_flag.clone();
abort_flag.store(false, std::sync::atomic::Ordering::SeqCst); abort_flag.store(false, std::sync::atomic::Ordering::SeqCst);
let hive_mind_converged = state.session_runtime.as_ref().is_some_and(|rt| rt.hive_mind_converged);
*in_flight_flag.lock().unwrap_or_else(|e| { *in_flight_flag.lock().unwrap_or_else(|e| {
tracing::error!("[spawn_turn] in_flight_flag mutex poisoned: {}", e); tracing::error!("[spawn_turn] in_flight_flag mutex poisoned: {}", e);
@@ -774,6 +779,7 @@ fn spawn_turn(state: &AppStateRest) {
temperature, temperature,
max_tokens, max_tokens,
abort_flag, abort_flag,
hive_mind_converged,
}; };
let result = run_agent_turn(&tc, &messages, &events_q); let result = run_agent_turn(&tc, &messages, &events_q);
if let Err(e) = result { if let Err(e) = result {
@@ -802,6 +808,10 @@ struct TurnCtx {
temperature: f32, temperature: f32,
max_tokens: Option<u32>, max_tokens: Option<u32>,
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>, abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// Snapshot of `SessionRuntime.hive_mind_converged` taken at the start
/// of this turn — whether a hive-mind convergence already completed
/// earlier in this session.
hive_mind_converged: bool,
} }
/// Build an ASCII tree of the workspace directory structure for the /// Build an ASCII tree of the workspace directory structure for the
@@ -937,6 +947,16 @@ fn archive_message(db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connect
/// background subagents still fire at the end of the turn. /// background subagents still fire at the end of the turn.
const MAX_AUTO_REVIEWS_PER_TURN: usize = 2; const MAX_AUTO_REVIEWS_PER_TURN: usize = 2;
/// Exact text of the "pipeline started" `SystemNote` pushed once per
/// hive-mind kickoff. Matched by exact equality (not a loose substring)
/// when deciding whether to reset the workflow panel's agent roster —
/// shared between the push site and the check site so they cannot drift
/// out of sync the way the previous `.contains("started")` check did
/// (no real pipeline message ever contained that word, so the roster
/// never cleared and agent cards accumulated across every hive-mind run
/// in a session).
const HIVE_MIND_KICKOFF_NOTE: &str = "Core Intelligence is compiling a cognitive cycle plan...";
/// Execute one full agent turn: stream the conversation to the LLM, /// Execute one full agent turn: stream the conversation to the LLM,
/// handle tool calls, and loop until the LLM produces a non-tool response /// handle tool calls, and loop until the LLM produces a non-tool response
/// or runs out of unfinished todo items. /// or runs out of unfinished todo items.
@@ -992,10 +1012,26 @@ fn run_agent_turn(
// ── AUTO CEO PIPELINE ── // ── AUTO CEO PIPELINE ──
// Before the main agent starts working, check if the pipeline should run. // Before the main agent starts working, check if the pipeline should run.
let user_msg_count = msgs.iter() // Gated on whether a hive-mind convergence has already happened earlier
.filter(|m| matches!(m.role, crate::dto::chat::message::Role::User)) // in this session, not an arbitrary message-count cutoff — a complex
.count(); // request in message 5 deserves the same treatment as one in message 1,
let should_pipeline = if user_msg_count <= 2 { // as long as this session hasn't already converged once.
//
// `tc.hive_mind_converged` is the authoritative signal (see its doc
// comment on `SessionRuntime` for why). The message-content scan is
// kept as a defensive fallback in case a future change starts
// persisting tagged system messages into `rt.messages` (e.g. via
// compaction) — today it is a no-op since that never happens, but it's
// still correct and still tested in isolation.
let already_ran_hive_mind = tc.hive_mind_converged
|| crate::app::workflow::hive_mind::hive_mind_already_ran(
msgs.iter()
.filter(|m| matches!(m.role, crate::dto::chat::message::Role::System))
.filter_map(|m| m.content.as_deref())
);
let should_pipeline = if already_ran_hive_mind {
false
} else {
let user_request = msgs.iter() let user_request = msgs.iter()
.rev().find(|m| matches!(m.role, crate::dto::chat::message::Role::User)) .rev().find(|m| matches!(m.role, crate::dto::chat::message::Role::User))
.and_then(|m| m.content.as_deref()) .and_then(|m| m.content.as_deref())
@@ -1006,8 +1042,6 @@ fn run_agent_turn(
} else { } else {
crate::app::workflow::hive_mind::is_complex_request(user_request) crate::app::workflow::hive_mind::is_complex_request(user_request)
} }
} else {
false
}; };
if should_pipeline { if should_pipeline {
@@ -1021,7 +1055,7 @@ fn run_agent_turn(
if let Ok(mut q) = events_q.lock() { if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote { q.push_back(TurnEvent::SystemNote {
kind: "pipeline".to_string(), kind: "pipeline".to_string(),
message: "Core Intelligence is compiling a cognitive cycle plan...".to_string(), message: HIVE_MIND_KICKOFF_NOTE.to_string(),
}); });
} }
@@ -1105,18 +1139,15 @@ fn run_agent_turn(
}; };
match pipeline_result { match pipeline_result {
Ok((consensus, reports)) => { Ok((consensus, _reports)) => {
// run_hive_mind already wrote docs/runs/*.md internally
// (guaranteed, even on synthesis failure) — nothing to do
// here besides feeding the consensus back to the LLM.
tracing::info!("[hive-mind] convergence completed successfully"); tracing::info!("[hive-mind] convergence completed successfully");
if let Some(workspace_root) = tc.workspace_roots.first() {
match crate::app::workflow::docs::write_hive_mind_convergence(workspace_root, user_request, &reports, &consensus) {
Ok(path) => tracing::info!("[hive-mind] convergence documented at {}", path.display()),
Err(e) => tracing::warn!("[hive-mind] failed to write docs/runs report: {e}"),
}
}
let pipeline_msg = ChatMessage::system(format!( let pipeline_msg = ChatMessage::system(format!(
"[Hive-Mind Consensus]\n{consensus}", "{}\n{consensus}",
crate::app::workflow::hive_mind::HIVE_MIND_CONSENSUS_TAG,
)); ));
archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg); archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg);
msgs.push(pipeline_msg); msgs.push(pipeline_msg);
@@ -1127,6 +1158,12 @@ fn run_agent_turn(
message: "Hive-mind convergence complete. Core Intelligence reviewing consensus...".to_string(), message: "Hive-mind convergence complete. Core Intelligence reviewing consensus...".to_string(),
}); });
} }
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "hive_mind_converged".to_string(),
message: String::new(),
});
}
} }
Err(e) => { Err(e) => {
tracing::warn!("[hive-mind] convergence failed: {}", e); tracing::warn!("[hive-mind] convergence failed: {}", e);
@@ -1495,12 +1532,14 @@ fn run_agent_turn(
let bg_session_dir = tc.edit_log_session_dir.clone(); let bg_session_dir = tc.edit_log_session_dir.clone();
let bg_workspaces = tc.workspace_roots.clone(); let bg_workspaces = tc.workspace_roots.clone();
let bg_events = events_q.clone(); let bg_events = events_q.clone();
let bg_abort = tc.abort_flag.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
crate::app::subagent::auto::spawn_all_background( crate::app::subagent::auto::spawn_all_background(
&bg_paths, &bg_paths,
&bg_session_dir, &bg_session_dir,
&bg_workspaces, &bg_workspaces,
&bg_events, &bg_events,
bg_abort,
); );
}); });
} }
@@ -1806,4 +1845,33 @@ fn rand_bytes(n: usize) -> Vec<u8> {
(0..n).map(|i| ((base >> ((i as u64 % 8) * 8)) ^ (i as u64 * 2_654_435_761)) as u8).collect() (0..n).map(|i| ((base >> ((i as u64 % 8) * 8)) ^ (i as u64 * 2_654_435_761)) as u8).collect()
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::SessionRuntime;
#[test]
fn hive_mind_converged_system_note_sets_session_flag() {
let tmp = std::env::temp_dir().join(format!("zesdex-actions-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
let mut state = AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory"));
state.session_runtime = Some(SessionRuntime::new(tmp.clone()));
assert!(!state.session_runtime.as_ref().unwrap().hive_mind_converged);
if let Ok(mut q) = state.turn_events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "hive_mind_converged".to_string(),
message: String::new(),
});
}
apply_action(&mut state, Action::Tick);
assert!(state.session_runtime.as_ref().unwrap().hive_mind_converged);
std::fs::remove_dir_all(&tmp).ok();
}
}
+6
View File
@@ -69,6 +69,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::WorkflowRun { script } => { Command::WorkflowRun { script } => {
vec![Action::RunWorkflow { script }] vec![Action::RunWorkflow { script }]
} }
Command::TodoOpen => {
vec![Action::OpenOverlay(Overlay::Todo)]
}
Command::UsageOpen => {
vec![Action::OpenOverlay(Overlay::Usage)]
}
Command::Unknown(cmd) => { Command::Unknown(cmd) => {
vec![Action::SystemNote { vec![Action::SystemNote {
kind: "error".to_string(), kind: "error".to_string(),
+2
View File
@@ -90,6 +90,8 @@ const COMMANDS: &[&str] = &[
"/model add", "/model add",
"/workflow", "/workflow",
"/workflow run", "/workflow run",
"/todo",
"/usage",
"/compact", "/compact",
]; ];
+23
View File
@@ -283,6 +283,29 @@ impl AppStateRest {
lsp_manager: self.lsp_manager.clone(), lsp_manager: self.lsp_manager.clone(),
turn_events: Some(self.turn_events.clone()), turn_events: Some(self.turn_events.clone()),
workflow_findings: None, workflow_findings: None,
abort_flag: Some(self.abort_flag.clone()),
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_ctx_for_shares_the_session_abort_flag() {
let tmp = std::env::temp_dir().join(format!("zesdex-rest-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
let state = AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory"));
let ctx = state.tool_ctx_for(Origin::Main);
assert!(ctx.abort_flag.is_some());
assert!(std::sync::Arc::ptr_eq(
ctx.abort_flag.as_ref().unwrap(),
&state.abort_flag,
));
std::fs::remove_dir_all(&tmp).ok();
}
}
+9
View File
@@ -46,6 +46,14 @@ pub struct SessionRuntime {
pub review_count: u32, pub review_count: u32,
pub session_dir: PathBuf, pub session_dir: PathBuf,
pub usage: UsageStats, pub usage: UsageStats,
/// Whether a hive-mind convergence has completed at least once in this
/// session. Set by the main-thread event loop when it receives a
/// `TurnEvent::SystemNote { kind: "hive_mind_converged", .. }` — the
/// only reliable way to detect this across turns, since system messages
/// pushed mid-turn inside `run_agent_turn` are NOT persisted into
/// `rt.messages` (they stay local to that turn's background thread and
/// are only archived to `SQLite`).
pub hive_mind_converged: bool,
} }
/// Record of one completed tool invocation, kept for transcript/history. /// Record of one completed tool invocation, kept for transcript/history.
@@ -139,6 +147,7 @@ impl SessionRuntime {
review_count: 0, review_count: 0,
session_dir, session_dir,
usage: UsageStats::default(), usage: UsageStats::default(),
hive_mind_converged: false,
} }
} }
+190 -13
View File
@@ -18,6 +18,7 @@
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::collections::VecDeque; use std::collections::VecDeque;
use crate::app::state::runtime::TurnEvent; use crate::app::state::runtime::TurnEvent;
use crate::app::subagent::context::build_subagent_context; use crate::app::subagent::context::build_subagent_context;
@@ -37,9 +38,37 @@ const SKIP_REVIEW_FILES: &[&str] = &[
".gitignore", ".env", ".env.example", ".gitignore", ".env", ".env.example",
]; ];
/// Prevents a second background subagent of the same kind from spawning
/// while one is already in flight. Without this, a chatty multi-turn edit
/// session could stack overlapping test-gen/arch/security reviews of
/// overlapping file sets, none of which could be told apart in the
/// `SystemNote` toast stream.
static TEST_GEN_RUNNING: AtomicBool = AtomicBool::new(false);
static ARCH_REVIEW_RUNNING: AtomicBool = AtomicBool::new(false);
static SECURITY_REVIEW_RUNNING: AtomicBool = AtomicBool::new(false);
/// RAII guard that resets a per-kind overlap flag back to `false` on drop —
/// including during a panic-triggered unwind inside the spawned thread — so
/// a background review can never wedge itself permanently disabled for the
/// rest of the process if the subagent run panics before reaching its
/// normal completion path.
struct RunningGuard(&'static AtomicBool);
impl Drop for RunningGuard {
fn drop(&mut self) {
self.0.store(false, Ordering::SeqCst);
}
}
/// ─── Helpers ─── /// ─── Helpers ───
/// ///
/// Check whether a file path is worth auto-reviewing (not config/lock/data). /// Check whether a file path is worth auto-reviewing (not config/lock/data).
///
/// Vendored/generated directories are matched by path *segment* rather than
/// a `/target/`-style substring check — the substring form misses paths
/// where the directory is the first component (e.g. `target/debug/build.rs`,
/// which has no leading slash), the same class of bug fixed in
/// `is_production_code` below.
pub fn is_reviewable_path(path: &str) -> bool { pub fn is_reviewable_path(path: &str) -> bool {
let lower = path.to_lowercase(); let lower = path.to_lowercase();
if SKIP_REVIEW_FILES.iter().any(|f| lower.ends_with(f)) { if SKIP_REVIEW_FILES.iter().any(|f| lower.ends_with(f)) {
@@ -49,9 +78,14 @@ pub fn is_reviewable_path(path: &str) -> bool {
return false; return false;
} }
// Skip paths that are clearly generated or vendored // Skip paths that are clearly generated or vendored
if lower.contains("/target/") || lower.contains("/node_modules/") let in_vendored_dir = std::path::Path::new(&lower).components().any(|c| {
|| lower.contains("/.git/") || lower.contains("/vendor/") matches!(
{ c,
std::path::Component::Normal(seg)
if matches!(seg.to_str(), Some("target" | "node_modules" | ".git" | "vendor"))
)
});
if in_vendored_dir {
return false; return false;
} }
true true
@@ -60,15 +94,44 @@ pub fn is_reviewable_path(path: &str) -> bool {
/// Determine whether a file change looks like it modifies production logic /// Determine whether a file change looks like it modifies production logic
/// (vs. tests, config, or documentation) — used to decide if a test-gen /// (vs. tests, config, or documentation) — used to decide if a test-gen
/// or security-review background subagent should fire. /// or security-review background subagent should fire.
///
/// Matches test-ness by path *segment* (a directory literally named
/// "test"/"tests"/"__tests__") or by filename convention
/// (`foo_test.rs`, `foo.test.ts`, `test_foo.py`, `foo_spec.rb`), not by a
/// raw substring check — a plain `.contains("test")` would wrongly exclude
/// legitimate production files like `src/attestation.rs` or
/// `src/latest/foo.rs`.
fn is_production_code(path: &str) -> bool { fn is_production_code(path: &str) -> bool {
let lower = path.to_lowercase(); let lower = path.to_lowercase();
// Skip test files — they don't need test-gen from another agent let path_obj = std::path::Path::new(&lower);
if lower.contains("test") || lower.contains("spec") || lower.contains("_test.") {
let in_test_dir = path_obj.components().any(|c| {
matches!(
c,
std::path::Component::Normal(seg)
if matches!(seg.to_str(), Some("test" | "tests" | "__tests__"))
)
});
let file_stem = path_obj.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let is_test_filename = file_stem.starts_with("test_")
|| file_stem.ends_with("_test")
|| std::path::Path::new(file_stem)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("test"))
|| file_stem == "spec"
|| file_stem.ends_with("_spec")
|| std::path::Path::new(file_stem)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("spec"));
if in_test_dir || is_test_filename {
return false; return false;
} }
// Only source files — use Path::extension() to avoid clippy // Only source files — use Path::extension() to avoid clippy
// case_sensitive_file_extension_comparisons lint // case_sensitive_file_extension_comparisons lint
std::path::Path::new(&lower) path_obj
.extension() .extension()
.and_then(|ext| ext.to_str()) .and_then(|ext| ext.to_str())
.is_some_and(|ext| { .is_some_and(|ext| {
@@ -148,19 +211,30 @@ pub fn spawn_quick_review(
/// silently swallowing the error into a note string, so a single transient /// silently swallowing the error into a note string, so a single transient
/// LLM/tool failure doesn't just disappear. /// LLM/tool failure doesn't just disappear.
/// ///
/// `abort_flag` is checked before every attempt (including the first) and
/// forwarded into the subagent's own context, so a cancelled turn stops
/// retrying immediately instead of burning a second attempt.
///
/// Return: `Ok(output)` if either attempt succeeded, `Err(message)` /// Return: `Ok(output)` if either attempt succeeded, `Err(message)`
/// describing the final failure if both attempts failed. /// describing the final failure if both attempts failed, or the literal
/// message `"aborted by user"` if `abort_flag` was already set before an
/// attempt could start.
fn run_subagent_with_retry( fn run_subagent_with_retry(
def: &AgentDefinition, def: &AgentDefinition,
session_dir: &Path, session_dir: &Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
label: &str, label: &str,
abort_flag: Option<&Arc<AtomicBool>>,
) -> Result<String, String> { ) -> Result<String, String> {
let mut last_err = String::new(); let mut last_err = String::new();
for attempt in 1..=2 { for attempt in 1..=2 {
if abort_flag.is_some_and(|f| f.load(Ordering::SeqCst)) {
return Err("aborted by user".to_string());
}
let mut ctx = build_subagent_context(def); let mut ctx = build_subagent_context(def);
ctx.session_dir = session_dir.to_path_buf(); ctx.session_dir = session_dir.to_path_buf();
ctx.workspaces = workspaces.to_vec(); ctx.workspaces = workspaces.to_vec();
ctx.abort_flag = abort_flag.cloned();
let (tx, mut rx) = tokio::sync::mpsc::channel(32); let (tx, mut rx) = tokio::sync::mpsc::channel(32);
let drain_label = label.to_string(); let drain_label = label.to_string();
@@ -188,15 +262,25 @@ fn run_subagent_with_retry(
/// Uses the test-generator prompt and has read-write access so it can /// Uses the test-generator prompt and has read-write access so it can
/// create test files. Runs in a separate OS thread and reports completion /// create test files. Runs in a separate OS thread and reports completion
/// via `TurnEvent::SystemNote { kind: "bg-test-gen" }`. /// via `TurnEvent::SystemNote { kind: "bg-test-gen" }`.
///
/// Skipped (no-op) if a test-gen run is already in flight (guarded by
/// `TEST_GEN_RUNNING`) — prevents a chatty multi-turn edit session from
/// stacking overlapping runs. `abort_flag` is forwarded to
/// `run_subagent_with_retry` so the run can be cancelled if the turn aborts.
pub fn spawn_background_test_gen( pub fn spawn_background_test_gen(
file_paths: &[String], file_paths: &[String],
session_dir: &Path, session_dir: &Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>, turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) { ) {
if file_paths.is_empty() { if file_paths.is_empty() {
return; return;
} }
if TEST_GEN_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
tracing::debug!("[bg-test-gen] skipped — a test-gen run is already in flight");
return;
}
let paths = file_paths.to_vec(); let paths = file_paths.to_vec();
let sd = session_dir.to_path_buf(); let sd = session_dir.to_path_buf();
@@ -204,6 +288,7 @@ pub fn spawn_background_test_gen(
let events = turn_events.clone(); let events = turn_events.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let _running_guard = RunningGuard(&TEST_GEN_RUNNING);
tracing::info!( tracing::info!(
"[bg-test-gen] spawning for {} file(s): {:?}", "[bg-test-gen] spawning for {} file(s): {:?}",
paths.len(), paths.len(),
@@ -224,12 +309,13 @@ pub fn spawn_background_test_gen(
.with_system_prompt(prompt) .with_system_prompt(prompt)
; ;
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-test-gen"); let result = run_subagent_with_retry(&def, &sd, &ws, "bg-test-gen", Some(&abort_flag));
let message = match &result { let message = match &result {
Ok(output) => { Ok(output) => {
let first = output.lines().next().unwrap_or(output); let first = output.lines().next().unwrap_or(output);
format!("Auto test-gen: {first}") format!("Auto test-gen: {first}")
} }
Err(e) if e.contains("aborted") => format!("Auto test-gen cancelled: {e}"),
Err(e) => format!("ESCALATED: Auto test-gen {e}"), Err(e) => format!("ESCALATED: Auto test-gen {e}"),
}; };
@@ -247,15 +333,24 @@ pub fn spawn_background_test_gen(
/// Inspects the modified files for architectural consistency (layering, /// Inspects the modified files for architectural consistency (layering,
/// coupling, module boundaries). Reports via /// coupling, module boundaries). Reports via
/// `TurnEvent::SystemNote { kind: "bg-arch-review" }`. /// `TurnEvent::SystemNote { kind: "bg-arch-review" }`.
///
/// Skipped (no-op) if an arch-review run is already in flight (guarded by
/// `ARCH_REVIEW_RUNNING`). `abort_flag` is forwarded to
/// `run_subagent_with_retry` so the run can be cancelled if the turn aborts.
pub fn spawn_background_arch_review( pub fn spawn_background_arch_review(
file_paths: &[String], file_paths: &[String],
session_dir: &Path, session_dir: &Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>, turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) { ) {
if file_paths.is_empty() { if file_paths.is_empty() {
return; return;
} }
if ARCH_REVIEW_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
tracing::debug!("[bg-arch-review] skipped — an arch-review run is already in flight");
return;
}
let paths = file_paths.to_vec(); let paths = file_paths.to_vec();
let sd = session_dir.to_path_buf(); let sd = session_dir.to_path_buf();
@@ -263,6 +358,7 @@ pub fn spawn_background_arch_review(
let events = turn_events.clone(); let events = turn_events.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let _running_guard = RunningGuard(&ARCH_REVIEW_RUNNING);
let file_list = paths.join("\n"); let file_list = paths.join("\n");
let prompt = format!( let prompt = format!(
"{}\n\nModified files for architecture review:\n{}", "{}\n\nModified files for architecture review:\n{}",
@@ -277,12 +373,13 @@ pub fn spawn_background_arch_review(
.with_system_prompt(prompt) .with_system_prompt(prompt)
; ;
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-arch-review"); let result = run_subagent_with_retry(&def, &sd, &ws, "bg-arch-review", Some(&abort_flag));
let message = match &result { let message = match &result {
Ok(output) => { Ok(output) => {
let first = output.lines().next().unwrap_or(output); let first = output.lines().next().unwrap_or(output);
format!("Architecture review: {first}") format!("Architecture review: {first}")
} }
Err(e) if e.contains("aborted") => format!("Architecture review cancelled: {e}"),
Err(e) => format!("ESCALATED: Architecture review {e}"), Err(e) => format!("ESCALATED: Architecture review {e}"),
}; };
@@ -299,11 +396,16 @@ pub fn spawn_background_arch_review(
/// ///
/// Checks modified files for security vulnerabilities. Reports via /// Checks modified files for security vulnerabilities. Reports via
/// `TurnEvent::SystemNote { kind: "bg-security-review" }`. /// `TurnEvent::SystemNote { kind: "bg-security-review" }`.
///
/// Skipped (no-op) if a security-review run is already in flight (guarded by
/// `SECURITY_REVIEW_RUNNING`). `abort_flag` is forwarded to
/// `run_subagent_with_retry` so the run can be cancelled if the turn aborts.
pub fn spawn_background_security_review( pub fn spawn_background_security_review(
file_paths: &[String], file_paths: &[String],
session_dir: &Path, session_dir: &Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>, turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) { ) {
if file_paths.is_empty() { if file_paths.is_empty() {
return; return;
@@ -320,6 +422,10 @@ pub fn spawn_background_security_review(
if prod_paths.is_empty() { if prod_paths.is_empty() {
return; return;
} }
if SECURITY_REVIEW_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
tracing::debug!("[bg-security-review] skipped — a security-review run is already in flight");
return;
}
let paths = prod_paths; let paths = prod_paths;
let sd = session_dir.to_path_buf(); let sd = session_dir.to_path_buf();
@@ -327,6 +433,7 @@ pub fn spawn_background_security_review(
let events = turn_events.clone(); let events = turn_events.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let _running_guard = RunningGuard(&SECURITY_REVIEW_RUNNING);
let file_list = paths.join("\n"); let file_list = paths.join("\n");
let prompt = format!( let prompt = format!(
"{}\n\nModified files for security review:\n{}", "{}\n\nModified files for security review:\n{}",
@@ -341,12 +448,13 @@ pub fn spawn_background_security_review(
.with_system_prompt(prompt) .with_system_prompt(prompt)
; ;
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-security-review"); let result = run_subagent_with_retry(&def, &sd, &ws, "bg-security-review", Some(&abort_flag));
let message = match &result { let message = match &result {
Ok(output) => { Ok(output) => {
let first = output.lines().next().unwrap_or(output); let first = output.lines().next().unwrap_or(output);
format!("Security review: {first}") format!("Security review: {first}")
} }
Err(e) if e.contains("aborted") => format!("Security review cancelled: {e}"),
Err(e) => format!("ESCALATED: Security review {e}"), Err(e) => format!("ESCALATED: Security review {e}"),
}; };
@@ -365,11 +473,15 @@ pub fn spawn_background_security_review(
/// Flow: always spawns arch-review and security-review if there are /// Flow: always spawns arch-review and security-review if there are
/// reviewable production files → spawns test-gen only if there are source /// reviewable production files → spawns test-gen only if there are source
/// files that aren't already tests. /// files that aren't already tests.
///
/// `abort_flag` is cloned and forwarded to all three spawn calls so a
/// single cancellation source stops every kind of background review.
pub fn spawn_all_background( pub fn spawn_all_background(
file_paths: &[String], file_paths: &[String],
session_dir: &Path, session_dir: &Path,
workspaces: &[std::path::PathBuf], workspaces: &[std::path::PathBuf],
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>, turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) { ) {
if file_paths.is_empty() { if file_paths.is_empty() {
return; return;
@@ -381,7 +493,7 @@ pub fn spawn_all_background(
.filter(|p| is_production_code(p)) .filter(|p| is_production_code(p))
.cloned() .cloned()
.collect(); .collect();
spawn_background_test_gen(&source_paths, session_dir, workspaces, turn_events); spawn_background_test_gen(&source_paths, session_dir, workspaces, turn_events, abort_flag.clone());
// Background arch review: for all files that are reviewable // Background arch review: for all files that are reviewable
let reviewable: Vec<String> = file_paths let reviewable: Vec<String> = file_paths
@@ -389,8 +501,73 @@ pub fn spawn_all_background(
.filter(|p| is_reviewable_path(p)) .filter(|p| is_reviewable_path(p))
.cloned() .cloned()
.collect(); .collect();
spawn_background_arch_review(&reviewable, session_dir, workspaces, turn_events); spawn_background_arch_review(&reviewable, session_dir, workspaces, turn_events, abort_flag.clone());
// Background security review: only production source files // Background security review: only production source files
spawn_background_security_review(&source_paths, session_dir, workspaces, turn_events); spawn_background_security_review(&source_paths, session_dir, workspaces, turn_events, abort_flag);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reviewable_path_skips_lockfiles_and_known_extensions() {
assert!(!is_reviewable_path("Cargo.lock"));
assert!(!is_reviewable_path("package.json"));
assert!(!is_reviewable_path("logo.svg"));
}
#[test]
fn reviewable_path_skips_vendored_and_generated_dirs() {
assert!(!is_reviewable_path("target/debug/build.rs"));
assert!(!is_reviewable_path("node_modules/foo/index.js"));
}
#[test]
fn reviewable_path_accepts_ordinary_source_files() {
assert!(is_reviewable_path("src/main.rs"));
}
#[test]
fn production_code_excludes_dedicated_test_directories() {
assert!(!is_production_code("src/tests/foo.rs"));
assert!(!is_production_code("__tests__/baz.test.ts"));
}
#[test]
fn production_code_excludes_test_filename_conventions() {
assert!(!is_production_code("src/foo_test.rs"));
assert!(!is_production_code("src/test_foo.py"));
assert!(!is_production_code("src/foo.spec.ts"));
}
#[test]
fn production_code_does_not_false_positive_on_substring_test() {
// Regression: a plain `.contains("test")` would wrongly exclude
// these legitimate production files.
assert!(is_production_code("src/attestation.rs"));
assert!(is_production_code("src/latest/foo.rs"));
}
#[test]
fn production_code_requires_known_source_extension() {
assert!(!is_production_code("README.md"));
assert!(is_production_code("src/main.rs"));
}
#[test]
fn running_guard_resets_flag_on_drop_even_after_panic() {
static TEST_FLAG: AtomicBool = AtomicBool::new(false);
TEST_FLAG.store(true, Ordering::SeqCst);
let result = std::panic::catch_unwind(|| {
let _guard = RunningGuard(&TEST_FLAG);
panic!("simulated failure inside guarded region");
});
assert!(result.is_err());
assert!(
!TEST_FLAG.load(Ordering::SeqCst),
"guard must reset the flag even when the guarded closure panics"
);
}
} }
+10
View File
@@ -88,4 +88,14 @@ mod tests {
assert!(!tools.contains(&"write".to_string())); assert!(!tools.contains(&"write".to_string()));
assert!(!tools.contains(&"delete".to_string())); assert!(!tools.contains(&"delete".to_string()));
} }
#[test]
fn read_tier_is_subset_of_write_tier_and_write_is_subset_of_full() {
use std::collections::HashSet;
let read: HashSet<_> = tools_for(READ).into_iter().collect();
let write: HashSet<_> = tools_for(WRITE).into_iter().collect();
let full: HashSet<_> = tools_for(FULL).into_iter().collect();
assert!(read.is_subset(&write), "read tier must be a subset of write tier");
assert!(write.is_subset(&full), "write tier must be a subset of full tier");
}
} }
+1 -1
View File
@@ -8,6 +8,7 @@
//! are not a weaker link than the main agent. //! are not a weaker link than the main agent.
use std::fmt::Write; use std::fmt::Write;
use sha2::Digest;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::dto::chat::message::ChatMessage; use crate::dto::chat::message::ChatMessage;
use crate::dto::provider::request::ToolDef; use crate::dto::provider::request::ToolDef;
@@ -456,7 +457,6 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
.unwrap_or("unknown"); .unwrap_or("unknown");
let content_sha256 = { let content_sha256 = {
let content = args.get("content").or_else(|| args.get("new")); let content = args.get("content").or_else(|| args.get("new"));
use sha2::Digest;
let hash = sha2::Sha256::digest( let hash = sha2::Sha256::digest(
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(), content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
); );
+83 -10
View File
@@ -69,6 +69,27 @@ pub struct NodeReport {
pub output: String, pub output: String,
} }
/// Tag prefixing the system message `run_hive_mind`'s caller pushes into
/// the conversation after a successful convergence. Shared between the
/// push site (`actions/mod.rs`) and `hive_mind_already_ran` below so the
/// two can never drift out of sync.
pub const HIVE_MIND_CONSENSUS_TAG: &str = "[Hive-Mind Consensus]";
/// Detect whether a hive-mind convergence has already run earlier in this
/// conversation, by checking prior system-message bodies for the
/// consensus tag.
///
/// Why: gates re-triggering the Core Intelligence pipeline more than once
/// per session on message *content* actually observed, rather than an
/// arbitrary "first two user messages" cutoff that silently disabled the
/// pipeline for any complex request phrased later in a long conversation.
///
/// Return: `true` if any prior system message starts with
/// `HIVE_MIND_CONSENSUS_TAG`.
pub fn hive_mind_already_ran<'a>(system_message_bodies: impl Iterator<Item = &'a str>) -> bool {
system_message_bodies.into_iter().any(|body| body.starts_with(HIVE_MIND_CONSENSUS_TAG))
}
/// Build the live-state callback that forwards node status updates to the /// Build the live-state callback that forwards node status updates to the
/// TUI's workflow panel. /// TUI's workflow panel.
fn build_live( fn build_live(
@@ -105,10 +126,19 @@ fn build_live(
/// directive is to reconcile the complete collective state into a single /// directive is to reconcile the complete collective state into a single
/// consensus, not list what each node said. /// consensus, not list what each node said.
/// ///
/// Return: `(consensus, all_node_reports)`. `consensus` is the synthesis /// Concurrency per cycle and the per-node timeout both come from
/// node's reconciled output — what the Core Intelligence actually /// `Settings::load()` (`workflow_max_concurrency`, `hive_mind_node_timeout_ms`)
/// receives. `all_node_reports` is the complete per-node record, /// rather than a hardcoded cap/no-timeout — a stuck node can no longer hang
/// persisted verbatim to `docs/runs/*.md`. /// the whole convergence forever.
///
/// Return: `(consensus, all_node_reports)` on success. `consensus` is the
/// synthesis node's reconciled output — what the Core Intelligence
/// actually receives. `all_node_reports` is the complete per-node record.
///
/// The convergence doc under `docs/runs/*.md` is written unconditionally
/// before this function returns — even when synthesis itself fails — so a
/// synthesis-node error never discards the work already done by cycle
/// nodes. Callers must not write their own copy of this doc.
pub fn run_hive_mind( pub fn run_hive_mind(
user_request: &str, user_request: &str,
plan: &CognitiveCyclePlan, plan: &CognitiveCyclePlan,
@@ -121,6 +151,10 @@ pub fn run_hive_mind(
anyhow::bail!("cognitive cycle plan has no cycles"); anyhow::bail!("cognitive cycle plan has no cycles");
} }
let settings = crate::model::settings::Settings::load();
let node_timeout_ms = Some(settings.hive_mind_node_timeout_ms);
let max_cycle_concurrency = settings.workflow_max_concurrency.max(1);
let live = build_live(turn_events); let live = build_live(turn_events);
let collective_state: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new())); let collective_state: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let args: HashMap<String, String> = HashMap::new(); let args: HashMap<String, String> = HashMap::new();
@@ -162,14 +196,14 @@ pub fn run_hive_mind(
let results = execute_primitive( let results = execute_primitive(
&cycle_primitive, &cycle_primitive,
&args, &args,
directives.len().clamp(1, 10), directives.len().clamp(1, max_cycle_concurrency),
true, true,
&abort_owned, &abort_owned,
live.as_ref(), live.as_ref(),
session_dir, session_dir,
workspaces, workspaces,
&collective_state, &collective_state,
None, node_timeout_ms,
)?; )?;
// engine::execute_primitive's ScopedAgent arm already merged each // engine::execute_primitive's ScopedAgent arm already merged each
@@ -185,9 +219,29 @@ pub fn run_hive_mind(
} }
} }
let consensus = synthesize_consensus( let consensus_result = synthesize_consensus(
user_request, session_dir, workspaces, &collective_state, live.as_ref(), abort_flag, user_request, session_dir, workspaces, &collective_state, live.as_ref(), abort_flag, node_timeout_ms,
)?; );
// Guaranteed documentation: write the convergence doc for whatever
// reports/consensus we actually have, whether synthesis succeeded or
// failed. A synthesis-node failure must not silently discard every
// completed cycle node's work — this is the durable audit trail
// CLAUDE.md promises for every convergence.
let doc_consensus = match &consensus_result {
Ok(c) => c.clone(),
Err(e) => format!(
"Synthesis failed: {e}. See individual node reports above for partial results.",
),
};
if let Some(workspace_root) = workspaces.first() {
match crate::app::workflow::docs::write_hive_mind_convergence(workspace_root, user_request, &reports, &doc_consensus) {
Ok(path) => tracing::info!("[hive-mind] convergence documented at {}", path.display()),
Err(e) => tracing::warn!("[hive-mind] failed to write docs/runs report: {e}"),
}
}
let consensus = consensus_result?;
Ok((consensus, reports)) Ok((consensus, reports))
} }
@@ -200,6 +254,9 @@ pub fn run_hive_mind(
/// reasoning can reconcile that into a coherent answer; deterministic /// reasoning can reconcile that into a coherent answer; deterministic
/// formatting can only concatenate, not resolve conflicts. /// formatting can only concatenate, not resolve conflicts.
/// ///
/// `node_timeout_ms` is forwarded from `run_hive_mind`'s `Settings::load()`
/// read so the synthesis node is bound by the same deadline as cycle nodes.
///
/// Return: the synthesis node's reconciled consensus text. /// Return: the synthesis node's reconciled consensus text.
fn synthesize_consensus( fn synthesize_consensus(
user_request: &str, user_request: &str,
@@ -208,6 +265,7 @@ fn synthesize_consensus(
collective_state: &Arc<Mutex<Vec<String>>>, collective_state: &Arc<Mutex<Vec<String>>>,
live: Option<&LiveStateFn>, live: Option<&LiveStateFn>,
abort_flag: Option<&Arc<AtomicBool>>, abort_flag: Option<&Arc<AtomicBool>>,
node_timeout_ms: Option<u64>,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
let synthesis = ScriptPrimitive::ScopedAgent { let synthesis = ScriptPrimitive::ScopedAgent {
prompt: format!( prompt: format!(
@@ -227,7 +285,7 @@ fn synthesize_consensus(
let args: HashMap<String, String> = HashMap::new(); let args: HashMap<String, String> = HashMap::new();
let abort_owned: Option<Arc<AtomicBool>> = abort_flag.cloned(); let abort_owned: Option<Arc<AtomicBool>> = abort_flag.cloned();
let results = execute_primitive( let results = execute_primitive(
&synthesis, &args, 1, false, &abort_owned, live, session_dir, workspaces, collective_state, None, &synthesis, &args, 1, false, &abort_owned, live, session_dir, workspaces, collective_state, node_timeout_ms,
)?; )?;
Ok(results.into_iter().next().unwrap_or_default()) Ok(results.into_iter().next().unwrap_or_default())
} }
@@ -371,4 +429,19 @@ mod tests {
let node_id = format!("Node-{}-{}", 2, 1); let node_id = format!("Node-{}-{}", 2, 1);
assert_eq!(node_id, "Node-2-1"); assert_eq!(node_id, "Node-2-1");
} }
#[test]
fn hive_mind_already_ran_detects_prior_consensus_tag() {
let bodies = vec![
"you are a helpful assistant".to_string(),
format!("{HIVE_MIND_CONSENSUS_TAG}\nthe bug is a null check"),
];
assert!(hive_mind_already_ran(bodies.iter().map(std::string::String::as_str)));
}
#[test]
fn hive_mind_already_ran_false_when_no_prior_convergence() {
let bodies = vec!["you are a helpful assistant".to_string()];
assert!(!hive_mind_already_ran(bodies.iter().map(std::string::String::as_str)));
}
} }
+19
View File
@@ -22,6 +22,8 @@ pub enum Command {
WorkflowRun { WorkflowRun {
script: String, script: String,
}, },
TodoOpen,
UsageOpen,
Unknown(String), Unknown(String),
} }
@@ -75,6 +77,23 @@ pub fn parse_command(text: &str) -> Command {
"/workflow" => Command::WorkflowRun { "/workflow" => Command::WorkflowRun {
script: arg1.to_string(), script: arg1.to_string(),
}, },
"/todo" => Command::TodoOpen,
"/usage" => Command::UsageOpen,
_ => Command::Unknown(cmd.to_string()), _ => Command::Unknown(cmd.to_string()),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_todo_open() {
assert_eq!(parse_command("/todo"), Command::TodoOpen);
}
#[test]
fn parses_usage_open() {
assert_eq!(parse_command("/usage"), Command::UsageOpen);
}
}
+49 -1
View File
@@ -19,7 +19,10 @@ pub enum InternetMode {
Full, Full,
} }
/// Default per-node timeout for hive-mind nodes: 10 minutes.
fn default_hive_mind_node_timeout_ms() -> u64 {
600_000
}
/// Top-level application settings, serialized to `settings.json` in the store dir. /// Top-level application settings, serialized to `settings.json` in the store dir.
/// ///
@@ -42,6 +45,11 @@ pub struct Settings {
pub session_archive_enabled: bool, pub session_archive_enabled: bool,
pub lsp_auto_provision: bool, pub lsp_auto_provision: bool,
pub lsp_languages: Vec<String>, pub lsp_languages: Vec<String>,
/// Wall-clock deadline for a single hive-mind processing node (cycle
/// node or synthesis node). Prevents one stuck node from hanging an
/// entire hive-mind convergence forever.
#[serde(default = "default_hive_mind_node_timeout_ms")]
pub hive_mind_node_timeout_ms: u64,
} }
impl Default for Settings { impl Default for Settings {
@@ -62,6 +70,7 @@ impl Default for Settings {
session_archive_enabled: true, session_archive_enabled: true,
lsp_auto_provision: true, lsp_auto_provision: true,
lsp_languages: Vec::new(), lsp_languages: Vec::new(),
hive_mind_node_timeout_ms: default_hive_mind_node_timeout_ms(),
} }
} }
} }
@@ -103,3 +112,42 @@ impl Settings {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_hive_mind_node_timeout_is_ten_minutes() {
let settings = Settings::default();
assert_eq!(settings.hive_mind_node_timeout_ms, 600_000);
}
#[test]
fn missing_hive_mind_node_timeout_field_falls_back_to_default() {
// Simulates loading a settings.json written before this field
// existed — #[serde(default = ...)] must fill it in rather than
// failing the whole parse (which would silently reset every
// other saved setting to default too).
let old_json = r#"{
"internet_mode": "Off",
"provider": "zen",
"model": "deepseek-v4-flash-free",
"api_keys": {},
"max_tokens": null,
"temperature": null,
"review_enabled": true,
"review_max_lessons_per_run": 5,
"adaptive_review_max_skip": 3,
"verify_command": null,
"verify_timeout_ms": 30000,
"workflow_max_concurrency": 5,
"session_archive_enabled": true,
"lsp_auto_provision": true,
"lsp_languages": []
}"#;
let parsed: Settings = serde_json::from_str(old_json)
.expect("must parse even without the new field present");
assert_eq!(parsed.hive_mind_node_timeout_ms, 600_000);
}
}
+2
View File
@@ -36,6 +36,8 @@ Input:
/workflow Open workflow panel /workflow Open workflow panel
/workflow run <p> Run a workflow with prompt <p> /workflow run <p> Run a workflow with prompt <p>
/mode workflow Open workflow panel /mode workflow Open workflow panel
/todo Open task list
/usage Open usage details
/compact Compact conversation history /compact Compact conversation history
/exit Exit application /exit Exit application
+20
View File
@@ -2,6 +2,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::sync::atomic::AtomicBool;
use serde_json::Value; use serde_json::Value;
use anyhow::Result; use anyhow::Result;
@@ -56,6 +57,11 @@ pub struct ToolCtx {
/// reference earlier results. `None` means "not inside a workflow" — /// reference earlier results. `None` means "not inside a workflow" —
/// `note_finding` becomes a no-op. /// `note_finding` becomes a no-op.
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>, pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
/// The current turn's abort flag, threaded through so tools that
/// delegate to long-running orchestration (e.g. the `hive_mind` tool)
/// can be cancelled the same way the main agent loop is. `None` when
/// no turn-level abort flag is available.
pub abort_flag: Option<Arc<AtomicBool>>,
} }
/// Find which graduated checks apply to a given file path/content pair. /// Find which graduated checks apply to a given file path/content pair.
@@ -93,6 +99,7 @@ pub struct ToolCtxBuilder {
pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>, pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>,
pub turn_events: Option<Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>, pub turn_events: Option<Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>, pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
pub abort_flag: Option<Arc<AtomicBool>>,
} }
impl Default for ToolCtxBuilder { impl Default for ToolCtxBuilder {
@@ -108,6 +115,7 @@ impl Default for ToolCtxBuilder {
lsp_manager: Arc::new(Mutex::new(crate::app::lsp::LspManager::new())), lsp_manager: Arc::new(Mutex::new(crate::app::lsp::LspManager::new())),
turn_events: None, turn_events: None,
workflow_findings: None, workflow_findings: None,
abort_flag: None,
} }
} }
} }
@@ -135,6 +143,7 @@ impl ToolCtxBuilder {
lsp_manager: self.lsp_manager, lsp_manager: self.lsp_manager,
turn_events: self.turn_events, turn_events: self.turn_events,
workflow_findings: self.workflow_findings, workflow_findings: self.workflow_findings,
abort_flag: self.abort_flag,
} }
} }
} }
@@ -264,3 +273,14 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
anyhow::bail!("path '{rel}' is outside all workspace roots") anyhow::bail!("path '{rel}' is outside all workspace roots")
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_ctx_builder_defaults_abort_flag_to_none() {
let ctx = ToolCtx::builder().build();
assert!(ctx.abort_flag.is_none());
}
}
+5 -8
View File
@@ -219,21 +219,18 @@ impl Tool for HiveMind {
json!({ "cycles": cycles_value }) json!({ "cycles": cycles_value })
).map_err(|e| anyhow!("failed to parse cycles: {e}"))?; ).map_err(|e| anyhow!("failed to parse cycles: {e}"))?;
let (consensus, reports) = crate::app::workflow::hive_mind::run_hive_mind( // run_hive_mind now writes the docs/runs/*.md convergence report
// itself (guaranteed, even if synthesis fails) — do not write it
// again here.
let (consensus, _reports) = crate::app::workflow::hive_mind::run_hive_mind(
request, request,
&plan, &plan,
&ctx.session_dir, &ctx.session_dir,
&ctx.workspaces, &ctx.workspaces,
ctx.turn_events.as_ref(), ctx.turn_events.as_ref(),
None, ctx.abort_flag.as_ref(),
)?; )?;
if let Some(workspace_root) = ctx.workspaces.first() {
if let Err(e) = crate::app::workflow::docs::write_hive_mind_convergence(workspace_root, request, &reports, &consensus) {
tracing::warn!("[hive_mind] failed to write docs/runs report: {e}");
}
}
Ok(consensus) Ok(consensus)
} }
} }
+151 -105
View File
@@ -1,23 +1,32 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] #![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Chat transcript panel rendering — message cards with role badges. //! Chat transcript panel rendering — tight inline log style.
//! //!
//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a //! Flow: `draw_chat` turns `state.transcript_cache.messages` into a dense,
//! visually rich transcript where each message is rendered as a "card" //! log-like transcript: each non-tool message gets a one-line
//! with a role-colored left accent bar, a role badge pill, timestamp, //! `{role} {time} {content}` header with wrapped continuation lines
//! and markdown body. A streaming spinner line is appended when a turn //! aligned under the content column; `Role::Tool` messages render as a
//! dim `↳`-prefixed sub-line attached to whatever came before, with no
//! header of their own. A streaming spinner line is appended when a turn
//! is in flight. The combined line list is sliced to the visible scroll //! is in flight. The combined line list is sliced to the visible scroll
//! window before rendering. //! window before rendering.
//! //!
//! Design: messages are visually separated with vertical spacing, role //! Design: no per-message card/border/badge — role identity comes from a
//! badges are colored pills on the left, and each message has a thin //! short colored label, and vertical space is reserved for a blank line
//! role-colored border on its left side for quick visual scanning. //! only when the speaker actually changes (Tool sub-lines never count as
//! a speaker change), keeping more history on screen at once.
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{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, Borders, Paragraph, Wrap};
use ratatui::Frame; use ratatui::Frame;
use super::theme::Theme; use super::theme::Theme;
use crate::dto::chat::message::Role;
/// Column width reserved for the `{role} {time} ` header prefix; wrapped
/// continuation lines and Tool sub-lines indent to this width so content
/// stays aligned under the first line's content column.
const PREFIX_WIDTH: usize = 12;
/// 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<'_>> {
@@ -45,30 +54,24 @@ fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
lines lines
} }
fn role_badge(role: &crate::dto::chat::message::Role) -> &'static str { fn role_accent_color(role: &Role) -> Color {
match role { match role {
crate::dto::chat::message::Role::User => " YOU ", Role::User => Theme::ROLE_USER,
crate::dto::chat::message::Role::Assistant => " AI ", Role::Assistant => Theme::ROLE_ASSISTANT,
crate::dto::chat::message::Role::System => " SYS ", Role::System => Theme::ROLE_SYSTEM,
crate::dto::chat::message::Role::Tool => " TOOL ", Role::Tool => Theme::ROLE_TOOL,
} }
} }
fn role_accent_color(role: &crate::dto::chat::message::Role) -> Color { /// Short lowercase label for the `{role} {time}` header column. Callers pad
/// it to a fixed width themselves (not padded here so tests can assert the
/// raw label).
fn format_role_label(role: &Role) -> &'static str {
match role { match role {
crate::dto::chat::message::Role::User => Theme::ROLE_USER, Role::User => "you",
crate::dto::chat::message::Role::Assistant => Theme::ROLE_ASSISTANT, Role::Assistant => "ai",
crate::dto::chat::message::Role::System => Theme::ROLE_SYSTEM, Role::System => "sys",
crate::dto::chat::message::Role::Tool => Theme::ROLE_TOOL, Role::Tool => "tool",
}
}
fn role_label(role: &crate::dto::chat::message::Role) -> &'static str {
match role {
crate::dto::chat::message::Role::User => "You",
crate::dto::chat::message::Role::Assistant => "Assistant",
crate::dto::chat::message::Role::System => "System",
crate::dto::chat::message::Role::Tool => "Tool Call",
} }
} }
@@ -80,60 +83,90 @@ fn format_timestamp(ts: i64) -> String {
format!("{hrs:02}:{mins:02}") format!("{hrs:02}:{mins:02}")
} }
/// Render the scrollable chat transcript panel with message card styling. /// Whether a blank separator line should be inserted before rendering a
/// message from `role`, given the last non-Tool role that was rendered.
///
/// Why: `Role::Tool` messages render as an attached sub-line (see
/// `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
/// before the next real turn either.
fn needs_speaker_separator(prev_role: Option<&Role>, role: &Role) -> bool {
matches!(prev_role, Some(p) if p != role)
}
/// Render the scrollable chat transcript panel in tight inline-log style.
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
let messages = &state.transcript_cache.messages; let messages = &state.transcript_cache.messages;
let scroll_offset = state.scroll.offset; let scroll_offset = state.scroll.offset;
let max_visible = (area.height as usize).saturating_sub(3); let max_visible = (area.height as usize).saturating_sub(3);
// Wrap width for content: total width minus the header/indent prefix
// and minus the panel's left+right border columns.
let content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2);
let mut display_lines: Vec<Line> = Vec::new(); let mut display_lines: Vec<Line> = Vec::new();
let mut prev_role: Option<Role> = None;
// ── Header ───────────────────────────────────────────────────────────
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())
}; };
// ── Render messages as cards ─────────────────────────────────────────
for msg in messages { for msg in messages {
let accent = role_accent_color(&msg.role);
let badge = role_badge(&msg.role);
let label = role_label(&msg.role);
let ts_str = format_timestamp(msg.timestamp);
// ── Role header line ─────────────────────────────────────────────
// Left accent bar + badge pill + role name + timestamp
let header = Line::from(vec![
// Thin accent bar on the left
Span::styled(
"",
Style::default().fg(accent),
),
// Role badge pill
Span::styled(
badge,
Style::default()
.fg(Theme::BG)
.bg(accent)
.add_modifier(Modifier::BOLD),
),
// Role name
Span::styled(
format!(" {label}"),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
),
// Timestamp
Span::styled(
if ts_str.is_empty() { String::new() } else { format!(" {ts_str}") },
Style::default().fg(Theme::TEXT_DIM),
),
]);
display_lines.push(header);
// ── Message content ──────────────────────────────────────────────
let is_last = std::ptr::eq(msg, messages.last().unwrap()); let is_last = std::ptr::eq(msg, messages.last().unwrap());
// Tool messages render as a dim sub-line attached to whatever came
// before — no header, no speaker-change bookkeeping. Content is run
// through the same render_markdown + split_spans_into_lines pipeline
// as every other role so multi-line tool output (bash stdout, grep
// matches, diffs) becomes real wrapped `Line`s instead of a literal
// `\n` inside one Span; every rendered span is then re-styled dim
// italic to preserve the original single-line look.
if msg.role == Role::Tool {
let content = if msg.content.trim().is_empty() {
"(tool execution)".to_string()
} else {
msg.content.clone()
};
let dim = Style::default().fg(Theme::TEXT_DIM);
let dim_italic = dim.add_modifier(Modifier::ITALIC);
let content_spans = super::markdown::render_markdown(&content, content_width);
let content_lines = split_spans_into_lines(content_spans);
let mut lines_iter = content_lines.into_iter();
let first_spans = lines_iter.next().map_or_else(Vec::new, |line| {
line.spans.into_iter().map(|s| Span::styled(s.content, dim_italic)).collect()
});
let mut spans = vec![
Span::raw(" ".repeat(PREFIX_WIDTH)),
Span::styled("", dim),
];
spans.extend(first_spans);
display_lines.push(Line::from(spans));
for line in lines_iter {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans.into_iter().map(|s| Span::styled(s.content, dim_italic)));
display_lines.push(Line::from(spans));
}
continue;
}
if needs_speaker_separator(prev_role.as_ref(), &msg.role) {
display_lines.push(Line::from(Span::raw("")));
}
prev_role = Some(msg.role.clone());
let accent = role_accent_color(&msg.role);
let label = format_role_label(&msg.role);
let ts_str = format_timestamp(msg.timestamp);
let header_prefix = vec![
Span::styled(format!("{label:<4} "), Style::default().fg(accent).add_modifier(Modifier::BOLD)),
Span::styled(format!("{ts_str:<5} "), Style::default().fg(Theme::TEXT_DIM)),
];
let content_str = if msg.content.trim().is_empty() { let content_str = if msg.content.trim().is_empty() {
if is_last && state.turn_in_flight() { if is_last && state.turn_in_flight() {
"(streaming...)".to_string() "(streaming...)".to_string()
@@ -144,19 +177,23 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
msg.content.clone() msg.content.clone()
}; };
// Render markdown content with accent-colored prefix let content_spans = super::markdown::render_markdown(&content_str, content_width);
let mut content_spans = vec![ let content_lines = split_spans_into_lines(content_spans);
Span::styled(" ", Style::default().fg(accent)), let mut lines_iter = content_lines.into_iter();
];
content_spans.extend(super::markdown::render_markdown(&content_str, area.width));
let message_lines = split_spans_into_lines(content_spans);
for line in message_lines { if let Some(first) = lines_iter.next() {
display_lines.push(line); let mut spans = header_prefix;
spans.extend(first.spans);
display_lines.push(Line::from(spans));
} else {
display_lines.push(Line::from(header_prefix));
} }
// ── Message separator ──────────────────────────────────────────── for line in lines_iter {
display_lines.push(Line::from(Span::raw(""))); let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans);
display_lines.push(Line::from(spans));
}
} }
// ── Streaming indicator ────────────────────────────────────────────── // ── Streaming indicator ──────────────────────────────────────────────
@@ -165,38 +202,24 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len(); let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len();
let spinner = spinner_frames[frame_idx]; let spinner = spinner_frames[frame_idx];
if needs_speaker_separator(prev_role.as_ref(), &Role::Assistant) {
display_lines.push(Line::from(Span::raw("")));
}
display_lines.push(Line::from(vec![ display_lines.push(Line::from(vec![
Span::styled( Span::styled(
"", format!("{:<4} ", format_role_label(&Role::Assistant)),
Style::default().fg(Theme::ROLE_ASSISTANT),
),
Span::styled(
" AI ",
Style::default()
.fg(Theme::BG)
.bg(Theme::ROLE_ASSISTANT)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {spinner} "),
Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD), Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)),
"Generating...", Span::styled("generating...", Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC)),
Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC),
),
])); ]));
display_lines.push(Line::from(Span::raw("")));
} }
// ── Scrolling ──────────────────────────────────────────────────────── // ── Scrolling ────────────────────────────────────────────────────────
let block = Block::default() let 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(title, Style::default().fg(Theme::TEXT_MUTED)));
title,
Style::default().fg(Theme::TEXT_MUTED),
));
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);
@@ -210,8 +233,6 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
display_lines[total.saturating_sub(max_visible)..total].to_vec() display_lines[total.saturating_sub(max_visible)..total].to_vec()
}; };
// ── Scroll position indicator ────────────────────────────────────────
// Show a small percentage indicator in the title if scrolled
let scroll_pct = if total > max_visible { let scroll_pct = if total > max_visible {
((offset as f64 / max_offset as f64) * 100.0) as u8 ((offset as f64 / max_offset as f64) * 100.0) as u8
} else { } else {
@@ -223,10 +244,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
Block::default() 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(scroll_title, Style::default().fg(Theme::TEXT_MUTED)))
scroll_title,
Style::default().fg(Theme::TEXT_MUTED),
))
} else { } else {
block block
}; };
@@ -239,5 +257,33 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
} }
// Need to import Color for role_accent_color #[cfg(test)]
use ratatui::style::Color; mod tests {
use super::*;
#[test]
fn no_separator_when_no_previous_message() {
assert!(!needs_speaker_separator(None, &Role::User));
}
#[test]
fn no_separator_when_same_speaker_repeats() {
assert!(!needs_speaker_separator(Some(&Role::Assistant), &Role::Assistant));
}
#[test]
fn separator_when_speaker_changes() {
assert!(needs_speaker_separator(Some(&Role::User), &Role::Assistant));
}
#[test]
fn role_labels_are_lowercase_and_fit_prefix_width() {
assert_eq!(format_role_label(&Role::User), "you");
assert_eq!(format_role_label(&Role::Assistant), "ai");
assert_eq!(format_role_label(&Role::System), "sys");
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);
}
}
}
+9 -13
View File
@@ -7,6 +7,13 @@
//! Design: code blocks get a dark background with a labeled top bar, //! Design: code blocks get a dark background with a labeled top bar,
//! headings are bold with distinct colors, blockquotes get a vertical //! headings are bold with distinct colors, blockquotes get a vertical
//! accent bar prefix, and inline code is highlighted with a background. //! accent bar prefix, and inline code is highlighted with a background.
//! Deliberately adds no leading indentation of its own for paragraphs,
//! headings, or list bullets — the caller (`chat.rs`) owns column
//! alignment via its `PREFIX_WIDTH` scheme, so any indent added here
//! would only apply to a construct's first rendered line and throw
//! wrapped continuation lines out of alignment with it. Code-block lines
//! are the exception: every line gets its `" "` prefix independently
//! and consistently, so there's no first-line-only misalignment there.
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use ratatui::text::Span; use ratatui::text::Span;
@@ -27,7 +34,6 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
let mut in_code_block = false; let mut in_code_block = false;
let mut in_heading = false; let mut in_heading = false;
let mut heading_level = 0; let mut heading_level = 0;
let mut first_in_paragraph = true;
for event in parser { for event in parser {
match event { match event {
@@ -59,13 +65,10 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
}; };
// No prefix, we'll handle in the text events // No prefix, we'll handle in the text events
} }
pulldown_cmark::Tag::Paragraph => {
first_in_paragraph = true;
}
pulldown_cmark::Tag::Item => { pulldown_cmark::Tag::Item => {
// List item bullet // List item bullet
spans.push(Span::styled( spans.push(Span::styled(
" ", "",
Style::default().fg(Theme::PRIMARY), Style::default().fg(Theme::PRIMARY),
)); ));
} }
@@ -106,7 +109,6 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
spans.push(Span::raw("\n")); spans.push(Span::raw("\n"));
} }
pulldown_cmark::TagEnd::Paragraph => { pulldown_cmark::TagEnd::Paragraph => {
first_in_paragraph = true;
spans.push(Span::raw("\n\n")); spans.push(Span::raw("\n\n"));
} }
pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => { pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => {
@@ -129,17 +131,11 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
3 => Theme::ACCENT_PURPLE, 3 => Theme::ACCENT_PURPLE,
_ => Theme::TEXT, _ => Theme::TEXT,
}; };
let prefix = " ";
spans.push(Span::styled( spans.push(Span::styled(
format!("{prefix}{s}"), s,
Style::default().fg(color).add_modifier(Modifier::BOLD), Style::default().fg(color).add_modifier(Modifier::BOLD),
)); ));
} else { } else {
// Handle first word detection for paragraph indentation
if first_in_paragraph {
spans.push(Span::raw(" "));
first_in_paragraph = false;
}
spans.push(Span::raw(s)); spans.push(Span::raw(s));
} }
} }
+151 -138
View File
@@ -9,6 +9,7 @@
pub mod chat; pub mod chat;
pub mod markdown; pub mod markdown;
pub mod sidebar;
pub mod status; pub mod status;
pub mod theme; pub mod theme;
pub mod workflow; pub mod workflow;
@@ -20,14 +21,21 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use ratatui::Frame; use ratatui::Frame;
use theme::Theme; use theme::Theme;
/// Minimum terminal width (columns) at which the persistent dashboard
/// sidebar is shown; below this, chat reclaims the full width.
const SIDEBAR_MIN_WIDTH: u16 = 90;
/// Top-level render entry point called once per TUI frame. /// Top-level render entry point called once per TUI frame.
pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
let area = frame.area(); let area = frame.area();
// ── Determine if we need a side panel (todo) ───────────────────────── // ── Determine if the terminal is wide enough for the persistent
let show_todo = !state.misc.todo_content.is_empty() // dashboard sidebar (Workflow / Tasks / Usage). Below this, chat
|| state.misc.overlay == crate::app::state::types::Overlay::Todo; // reclaims the full width — same width-driven-collapse pattern the
let (main_area, todo_area) = if show_todo && area.width > 60 { // old single-widget todo panel used, just with a wider threshold
// since this sidebar holds three stacked widgets, not one.
let show_sidebar = area.width > SIDEBAR_MIN_WIDTH;
let (main_area, sidebar_area) = if show_sidebar {
let h_chunks = Layout::default() let h_chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
@@ -55,9 +63,7 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
let status_area = chunks[2]; let status_area = chunks[2];
// ── Render main area (overlay or chat) ─────────────────────────────── // ── Render main area (overlay or chat) ───────────────────────────────
if state.misc.overlay.is_active() if state.misc.overlay.is_active() {
&& state.misc.overlay != crate::app::state::types::Overlay::Todo
{
let overlay = state.misc.overlay; let overlay = state.misc.overlay;
render_overlay(frame, chat_area, overlay, state); render_overlay(frame, chat_area, overlay, state);
} else { } else {
@@ -70,9 +76,9 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
// ── Status bar ─────────────────────────────────────────────────────── // ── Status bar ───────────────────────────────────────────────────────
status::draw_status_bar(frame, status_area, state); status::draw_status_bar(frame, status_area, state);
// ── Todo side panel ────────────────────────────────────────────────── // ── Dashboard sidebar ────────────────────────────────────────────────
if let Some(todo_rect) = todo_area { if let Some(sidebar_rect) = sidebar_area {
render_todo_panel(frame, todo_rect, state); sidebar::draw_sidebar(frame, sidebar_rect, state);
} }
// ── Toasts (top-right floating) ────────────────────────────────────── // ── Toasts (top-right floating) ──────────────────────────────────────
@@ -83,30 +89,6 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
// Panel helpers // Panel helpers
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
fn render_todo_panel(
frame: &mut Frame,
area: Rect,
state: &crate::app::state::rest::AppStateRest,
) {
let block = Block::default()
.title(" 📋 Tasks ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::ACCENT_PURPLE))
.style(Style::default().bg(Theme::BG));
let content = if state.misc.todo_content.is_empty() {
" No tasks yet."
} else {
&state.misc.todo_content
};
let paragraph = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn render_main_panel( fn render_main_panel(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
@@ -148,7 +130,7 @@ fn render_overlay(
// ── Help ────────────────────────────────────────────────────── // ── Help ──────────────────────────────────────────────────────
crate::app::state::types::Overlay::Help => { crate::app::state::types::Overlay::Help => {
let block = block let block = block
.title(Span::styled(" Help ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD))) .title(Span::styled(" Help ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::INFO)); .border_style(Style::default().fg(Theme::INFO));
let content = crate::resources::HELP_TEXT; let content = crate::resources::HELP_TEXT;
let paragraph = Paragraph::new(content) let paragraph = Paragraph::new(content)
@@ -161,7 +143,7 @@ fn render_overlay(
// ── Settings ────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────
crate::app::state::types::Overlay::Settings => { crate::app::state::types::Overlay::Settings => {
let block = block let block = block
.title(Span::styled(" Settings ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD))) .title(Span::styled(" Settings ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::PRIMARY)); .border_style(Style::default().fg(Theme::PRIMARY));
let lines = vec![ let lines = vec![
Line::from(Span::styled( Line::from(Span::styled(
@@ -198,7 +180,7 @@ fn render_overlay(
// ── Bash ────────────────────────────────────────────────────── // ── Bash ──────────────────────────────────────────────────────
crate::app::state::types::Overlay::Bash => { crate::app::state::types::Overlay::Bash => {
let block = block let block = block
.title(Span::styled(" 💻 Bash Jobs ", Style::default().fg(Theme::ACCENT_ORANGE).add_modifier(Modifier::BOLD))) .title(Span::styled(" Bash Jobs ", Style::default().fg(Theme::ACCENT_ORANGE).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::ACCENT_ORANGE)); .border_style(Style::default().fg(Theme::ACCENT_ORANGE));
let lines: Vec<Line> = state.session_runtime.as_ref().map(|r| { let lines: Vec<Line> = state.session_runtime.as_ref().map(|r| {
r.bash_jobs.iter().map(|job| { r.bash_jobs.iter().map(|job| {
@@ -225,7 +207,7 @@ fn render_overlay(
// ── Quit Confirm ────────────────────────────────────────────── // ── Quit Confirm ──────────────────────────────────────────────
crate::app::state::types::Overlay::QuitConfirm => { crate::app::state::types::Overlay::QuitConfirm => {
let block = block let block = block
.title(Span::styled(" 🚪 Quit ", Style::default().fg(Theme::ERROR).add_modifier(Modifier::BOLD))) .title(Span::styled(" Quit ", Style::default().fg(Theme::ERROR).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::ERROR)); .border_style(Style::default().fg(Theme::ERROR));
let lines = vec![ let lines = vec![
Line::from(Span::styled( Line::from(Span::styled(
@@ -250,7 +232,7 @@ fn render_overlay(
// ── Key Input ───────────────────────────────────────────────── // ── Key Input ─────────────────────────────────────────────────
crate::app::state::types::Overlay::KeyInput => { crate::app::state::types::Overlay::KeyInput => {
let block = block let block = block
.title(Span::styled(" 🔑 API Key ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD))) .title(Span::styled(" API Key ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::WARNING)); .border_style(Style::default().fg(Theme::WARNING));
let input_text = &state.input.buffer; let input_text = &state.input.buffer;
let display = if input_text.is_empty() { let display = if input_text.is_empty() {
@@ -287,7 +269,7 @@ fn render_overlay(
// ── Editor ──────────────────────────────────────────────────── // ── Editor ────────────────────────────────────────────────────
crate::app::state::types::Overlay::Editor => { crate::app::state::types::Overlay::Editor => {
let block = block let block = block
.title(Span::styled(" ✏️ Editor ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD))) .title(Span::styled(" Editor ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::PRIMARY)); .border_style(Style::default().fg(Theme::PRIMARY));
let lines = vec![ let lines = vec![
Line::from(Span::styled( Line::from(Span::styled(
@@ -316,7 +298,7 @@ fn render_overlay(
// ── Effort ──────────────────────────────────────────────────── // ── Effort ────────────────────────────────────────────────────
crate::app::state::types::Overlay::Effort => { crate::app::state::types::Overlay::Effort => {
let block = block let block = block
.title(Span::styled(" 🎯 Effort Level ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD))) .title(Span::styled(" Effort Level ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::ACCENT_PURPLE)); .border_style(Style::default().fg(Theme::ACCENT_PURPLE));
let levels = crate::app::mode::effort::EFFORT_LEVELS; let levels = crate::app::mode::effort::EFFORT_LEVELS;
let current_idx = crate::app::mode::effort::current_effort(state); let current_idx = crate::app::mode::effort::current_effort(state);
@@ -349,7 +331,7 @@ fn render_overlay(
// ── MCP ─────────────────────────────────────────────────────── // ── MCP ───────────────────────────────────────────────────────
crate::app::state::types::Overlay::Mcp => { crate::app::state::types::Overlay::Mcp => {
let block = block let block = block
.title(Span::styled(" 🔌 MCP Servers ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD))) .title(Span::styled(" MCP Servers ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::INFO)); .border_style(Style::default().fg(Theme::INFO));
let lines = vec![ let lines = vec![
Line::from(Span::styled( Line::from(Span::styled(
@@ -378,32 +360,23 @@ fn render_overlay(
// ── Todo ────────────────────────────────────────────────────── // ── Todo ──────────────────────────────────────────────────────
crate::app::state::types::Overlay::Todo => { crate::app::state::types::Overlay::Todo => {
let block = block let block = block
.title(Span::styled(" 📋 Tasks ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD))) .title(Span::styled(" Tasks ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::ACCENT_PURPLE)); .border_style(Style::default().fg(Theme::ACCENT_PURPLE));
let msg_count = state.transcript_cache.messages.len(); let content = if state.misc.todo_content.is_empty() {
let lines = vec![ " No tasks yet."
Line::from(Span::styled( } else {
" Session Activity", &state.misc.todo_content
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), };
)), let paragraph = Paragraph::new(content)
Line::from(Span::raw("")), .block(block)
Line::from(Span::styled( .wrap(Wrap { trim: false });
format!(" Messages: {msg_count}"),
Style::default().fg(Theme::INFO),
)),
Line::from(Span::styled(
format!(" Overlay: {:?}", state.misc.overlay),
Style::default().fg(Theme::TEXT_DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area); frame.render_widget(paragraph, overlay_area);
} }
// ── Rewind ──────────────────────────────────────────────────── // ── Rewind ────────────────────────────────────────────────────
crate::app::state::types::Overlay::Rewind => { crate::app::state::types::Overlay::Rewind => {
let block = block let block = block
.title(Span::styled(" Rewind ", Style::default().fg(Theme::ACCENT_ORANGE).add_modifier(Modifier::BOLD))) .title(Span::styled(" Rewind ", Style::default().fg(Theme::ACCENT_ORANGE).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::ACCENT_ORANGE)); .border_style(Style::default().fg(Theme::ACCENT_ORANGE));
let mut lines: Vec<Line> = vec![ let mut lines: Vec<Line> = vec![
Line::from(Span::styled( Line::from(Span::styled(
@@ -461,7 +434,7 @@ fn render_overlay(
.split(overlay_area); .split(overlay_area);
let left_block = Block::default() let left_block = Block::default()
.title(Span::styled(" 📚 Lessons ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD))) .title(Span::styled(" Lessons ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD)))
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER)) .border_style(Style::default().fg(Theme::BORDER))
.style(Style::default().bg(Theme::BG)); .style(Style::default().bg(Theme::BG));
@@ -633,90 +606,75 @@ fn render_overlay(
// ── Usage ──────────────────────────────────────────────────── // ── Usage ────────────────────────────────────────────────────
crate::app::state::types::Overlay::Usage => { crate::app::state::types::Overlay::Usage => {
let block = block let block = block
.title(Span::styled(" 📊 Usage ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD))) .title(Span::styled(" Usage ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::INFO)); .border_style(Style::default().fg(Theme::INFO));
let runtime = state.session_runtime.as_ref(); let runtime = state.session_runtime.as_ref();
let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime let now_ms = chrono::Utc::now().timestamp_millis();
.map_or((0, 0, 0, 0, 0), |r| { let summary = runtime.map(|r| sidebar::compute_usage_summary(&r.usage, r.session_start, now_ms));
(
r.usage.tokens_in,
r.usage.tokens_out,
r.usage.api_calls,
r.usage.review_tokens,
r.session_start,
)
});
let (edit_count, lesson_count, review_count, consec_empty) = runtime let (edit_count, lesson_count, review_count, consec_empty) = runtime
.map_or((0, 0, 0, 0), |r| { .map_or((0, 0, 0, 0), |r| {
( (r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews)
r.edit_count,
r.lesson_count,
r.review_count,
r.consecutive_empty_reviews,
)
}); });
let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start); let mut lines = vec![
let hours = elapsed_ms / 3_600_000;
let minutes = (elapsed_ms % 3_600_000) / 60_000;
let seconds = (elapsed_ms % 60000) / 1000;
let total_tokens = tokens_in.saturating_add(tokens_out);
let self_learning_total = review_tokens;
let main_tokens = total_tokens.saturating_sub(self_learning_total);
let lines = vec![
Line::from(Span::styled( Line::from(Span::styled(
" Token Usage", " Token Usage",
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
)), )),
Line::from(Span::raw("")), Line::from(Span::raw("")),
Line::from(Span::styled(
format!(" Main agent: {main_tokens} tokens"),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Self-learning: {self_learning_total} tokens"),
Style::default().fg(Theme::TEXT_MUTED),
)),
Line::from(Span::styled(
format!(" Total: {total_tokens} tokens"),
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" API calls: {api_calls}"),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
" Activity",
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" Edits: {edit_count}"),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Reviews: {review_count}"),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Lessons: {lesson_count}"),
Style::default().fg(Theme::TEXT_MUTED),
)),
Line::from(Span::styled(
format!(" Empty reviews: {}",
if consec_empty > 3 {
format!("{consec_empty}")
} else {
consec_empty.to_string()
},
),
Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::TEXT_DIM }),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
format!(" Session: {hours}h {minutes}m {seconds}s"),
Style::default().fg(Theme::TEXT_DIM),
)),
]; ];
if let Some(s) = &summary {
lines.push(Line::from(Span::styled(
format!(" Main agent: {} tokens", s.main_tokens),
Style::default().fg(Theme::TEXT),
)));
lines.push(Line::from(Span::styled(
format!(" Self-learning: {} tokens", s.self_learning_tokens),
Style::default().fg(Theme::TEXT_MUTED),
)));
lines.push(Line::from(Span::styled(
format!(" Total: {} tokens", s.total_tokens),
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" API calls: {}", s.api_calls),
Style::default().fg(Theme::TEXT),
)));
} else {
lines.push(Line::from(Span::styled(
" No active session.",
Style::default().fg(Theme::TEXT_DIM),
)));
}
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
" Activity",
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" Edits: {edit_count}"),
Style::default().fg(Theme::TEXT),
)));
lines.push(Line::from(Span::styled(
format!(" Reviews: {review_count}"),
Style::default().fg(Theme::TEXT),
)));
lines.push(Line::from(Span::styled(
format!(" Lessons: {lesson_count}"),
Style::default().fg(Theme::TEXT_MUTED),
)));
lines.push(Line::from(Span::styled(
format!(" Empty reviews: {}",
if consec_empty > 3 { format!("{consec_empty}") } else { consec_empty.to_string() },
),
Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::TEXT_DIM }),
)));
if let Some(s) = &summary {
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
format!(" Session: {}h {}m {}s", s.elapsed_hours, s.elapsed_minutes, s.elapsed_seconds),
Style::default().fg(Theme::TEXT_DIM),
)));
}
let paragraph = Paragraph::new(lines).block(block); let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area); frame.render_widget(paragraph, overlay_area);
} }
@@ -724,7 +682,7 @@ fn render_overlay(
// ── Loading ────────────────────────────────────────────────── // ── Loading ──────────────────────────────────────────────────
crate::app::state::types::Overlay::Loading => { crate::app::state::types::Overlay::Loading => {
let block = block let block = block
.title(Span::styled(" Loading ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD))) .title(Span::styled(" Loading ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::WARNING)); .border_style(Style::default().fg(Theme::WARNING));
let spinner = ["", "", "", "", "", "", "", "", "", ""]; let spinner = ["", "", "", "", "", "", "", "", "", ""];
let frame_idx = (state.misc.tick_count as usize) % spinner.len(); let frame_idx = (state.misc.tick_count as usize) % spinner.len();
@@ -737,7 +695,7 @@ fn render_overlay(
// ── Model Selector ─────────────────────────────────────────── // ── Model Selector ───────────────────────────────────────────
crate::app::state::types::Overlay::ModelSelector => { crate::app::state::types::Overlay::ModelSelector => {
let block = block let block = block
.title(Span::styled(" 🧠 Model Selector ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD))) .title(Span::styled(" Model Selector ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::ACCENT_PURPLE)); .border_style(Style::default().fg(Theme::ACCENT_PURPLE));
let mut lines: Vec<Line> = vec![ let mut lines: Vec<Line> = vec![
Line::from(Span::styled( Line::from(Span::styled(
@@ -779,7 +737,7 @@ fn render_overlay(
// ── Clear Confirm ──────────────────────────────────────────── // ── Clear Confirm ────────────────────────────────────────────
crate::app::state::types::Overlay::ClearConfirm => { crate::app::state::types::Overlay::ClearConfirm => {
let block = block let block = block
.title(Span::styled(" 🗑️ Clear Transcript ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD))) .title(Span::styled(" Clear Transcript ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)))
.border_style(Style::default().fg(Theme::WARNING)); .border_style(Style::default().fg(Theme::WARNING));
let lines = vec![ let lines = vec![
Line::from(Span::styled( Line::from(Span::styled(
@@ -973,3 +931,58 @@ fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
height: area.height.saturating_sub(y_pad * 2).max(10), height: area.height.saturating_sub(y_pad * 2).max(10),
} }
} }
/// Split `items` into the slice that fits within `max_visible` entries and
/// the count of items hidden beyond that limit.
///
/// Used by sidebar widgets (Workflow, Tasks) to cap their content to the
/// available panel height instead of overflowing it.
///
/// Return: `(visible_slice, hidden_count)` — `hidden_count` is `0` when
/// everything fits.
pub(crate) fn split_for_display<T>(items: &[T], max_visible: usize) -> (&[T], usize) {
if items.len() <= max_visible {
(items, 0)
} else {
(&items[..max_visible], items.len() - max_visible)
}
}
/// Build the dim trailing hint line a sidebar widget shows when its
/// content is truncated, pointing at the slash command that opens the
/// full "expand" overlay for that widget (e.g. `"/workflow"`, `"/todo"`).
pub(crate) fn overflow_hint_line(hidden: usize, command: &str) -> Line<'static> {
Line::from(Span::styled(
format!(" +{hidden} more — {command}"),
Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_for_display_returns_everything_when_it_fits() {
let items = vec![1, 2, 3];
let (visible, hidden) = split_for_display(&items, 5);
assert_eq!(visible, &[1, 2, 3]);
assert_eq!(hidden, 0);
}
#[test]
fn split_for_display_truncates_and_counts_hidden() {
let items = vec![1, 2, 3, 4, 5];
let (visible, hidden) = split_for_display(&items, 2);
assert_eq!(visible, &[1, 2]);
assert_eq!(hidden, 3);
}
#[test]
fn overflow_hint_line_mentions_hidden_count_and_command() {
let line = overflow_hint_line(3, "/todo");
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("+3 more"));
assert!(text.contains("/todo"));
}
}
+185
View File
@@ -0,0 +1,185 @@
//! Persistent right-hand dashboard sidebar: Workflow, Tasks, and Usage
//! widgets stacked in three vertical thirds — the "glance" view that
//! complements the `Overlay::Todo` / `Overlay::Usage` "expand" views in
//! `view/mod.rs`.
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Style, Modifier};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use super::theme::Theme;
/// Render the persistent right-hand dashboard: Workflow, Tasks, and Usage
/// widgets stacked in three roughly-equal vertical thirds.
pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(area);
super::workflow::draw_workflow_widget(frame, chunks[0], state);
draw_tasks_widget(frame, chunks[1], state);
draw_usage_widget(frame, chunks[2], state);
}
/// Compact Tasks widget: `misc.todo_content` split into lines, truncated
/// to whatever fits with a trailing "+N more" hint pointing at `/todo`.
fn draw_tasks_widget(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
let block = Block::default()
.title(Span::styled(" Tasks ", Style::default().fg(Theme::ACCENT_PURPLE).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 content = &state.misc.todo_content;
let task_lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
let lines: Vec<Line> = if task_lines.is_empty() {
vec![Line::from(Span::styled(" No tasks yet.", Style::default().fg(Theme::TEXT_DIM)))]
} else {
let show_hint = task_lines.len() > budget;
let item_budget = if show_hint { budget.saturating_sub(1).max(1) } else { budget };
let (visible, hidden) = super::split_for_display(&task_lines, item_budget);
let mut lines: Vec<Line> = visible.iter()
.map(|l| Line::from(Span::styled(format!(" {l}"), Style::default().fg(Theme::TEXT))))
.collect();
if show_hint {
lines.push(super::overflow_hint_line(hidden, "/todo"));
}
lines
};
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
/// Compact Usage widget: total tokens, main/self-learning token split,
/// API call count, and session clock. Always fits (the summary is a
/// fixed handful of lines), so there is no overflow hint — the
/// `Overlay::Usage` "expand" view adds edit/review/lesson activity
/// counters on top of this same summary rather than showing more of a
/// truncated list.
///
/// Each number gets its own short line rather than being crammed onto
/// one — the sidebar column is only ~28 usable characters wide after
/// borders, and a single `"{total} tok ({main} main / {learn} learn)"`
/// line silently clips (no `.wrap()` on this `Paragraph`) once token
/// counts reach 5-6 digits, which is routine for an agent session.
fn draw_usage_widget(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
let block = Block::default()
.title(Span::styled(" Usage ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER));
let lines: Vec<Line> = if let Some(ref rt) = state.session_runtime {
let now_ms = chrono::Utc::now().timestamp_millis();
let summary = compute_usage_summary(&rt.usage, rt.session_start, now_ms);
vec![
Line::from(Span::styled(
format!(" {} tok total", summary.total_tokens),
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" main: {} tok", summary.main_tokens),
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::styled(
format!(" learn: {} tok", summary.self_learning_tokens),
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::styled(
format!(" {} API calls", summary.api_calls),
Style::default().fg(Theme::TEXT_DIM),
)),
Line::from(Span::styled(
format!(" {}h {}m {}s", summary.elapsed_hours, summary.elapsed_minutes, summary.elapsed_seconds),
Style::default().fg(Theme::TEXT_DIM),
)),
]
} else {
vec![Line::from(Span::styled(" No active session.", Style::default().fg(Theme::TEXT_DIM)))]
};
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
/// Derived, display-ready usage numbers shared by the compact Usage
/// widget and the `Overlay::Usage` expand view.
pub(crate) struct UsageSummary {
pub main_tokens: u64,
pub self_learning_tokens: u64,
pub total_tokens: u64,
pub api_calls: u64,
pub elapsed_hours: i64,
pub elapsed_minutes: i64,
pub elapsed_seconds: i64,
}
/// Compute display-ready usage numbers from raw session counters.
///
/// Flow: total = `tokens_in` + `tokens_out` → main = total - `review_tokens`
/// (the self-learning share) → elapsed = `now_ms` - `session_start`, split
/// into h/m/s.
///
/// Why `now_ms` is a parameter instead of reading the clock internally:
/// keeps this function pure and deterministic for testing.
pub(crate) fn compute_usage_summary(
usage: &crate::app::state::runtime::UsageStats,
session_start: i64,
now_ms: i64,
) -> UsageSummary {
let total_tokens = usage.tokens_in.saturating_add(usage.tokens_out);
let self_learning_tokens = usage.review_tokens;
let main_tokens = total_tokens.saturating_sub(self_learning_tokens);
let elapsed_ms = now_ms.saturating_sub(session_start);
let elapsed_hours = elapsed_ms / 3_600_000;
let elapsed_minutes = (elapsed_ms % 3_600_000) / 60_000;
let elapsed_seconds = (elapsed_ms % 60_000) / 1000;
UsageSummary {
main_tokens,
self_learning_tokens,
total_tokens,
api_calls: usage.api_calls,
elapsed_hours,
elapsed_minutes,
elapsed_seconds,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::state::runtime::UsageStats;
#[test]
fn compute_usage_summary_splits_main_and_self_learning_tokens() {
let usage = UsageStats {
tokens_in: 100,
tokens_out: 50,
review_tokens: 30,
api_calls: 4,
..UsageStats::default()
};
let summary = compute_usage_summary(&usage, 0, 0);
assert_eq!(summary.total_tokens, 150);
assert_eq!(summary.self_learning_tokens, 30);
assert_eq!(summary.main_tokens, 120);
assert_eq!(summary.api_calls, 4);
}
#[test]
fn compute_usage_summary_splits_elapsed_time() {
let usage = UsageStats::default();
// 1h 2m 3s = 3_600_000 + 120_000 + 3_000 ms
let summary = compute_usage_summary(&usage, 0, 3_723_000);
assert_eq!(summary.elapsed_hours, 1);
assert_eq!(summary.elapsed_minutes, 2);
assert_eq!(summary.elapsed_seconds, 3);
}
}
+54 -48
View File
@@ -1,13 +1,13 @@
//! Central color theme for the TUI — modern dark palette with neon accents. //! Central color theme for the TUI — Tokyo Night palette.
//! //!
//! Flow: defines a single `Theme` marker struct with associated `Color` //! Flow: defines a single `Theme` marker struct with associated `Color`
//! consts, consumed by every `view/*` render function so styling stays //! consts, consumed by every `view/*` render function so styling stays
//! consistent and changeable from one place. //! consistent and changeable from one place.
//! //!
//! Design: dark-primary background (#1a1b26 / Catppuccin Mocha inspired), //! Design: muted blue-purple dark background with desaturated blue/cyan/
//! vibrant accent colors for semantic states, and muted tones for //! purple accents (not neon) — the popular Tokyo Night editor/terminal
//! secondary/background elements. This gives a modern "neon dashboard" //! theme. Chosen for a calmer "professional dev tool" read in place of
//! look that is easy on the eyes during long sessions. //! the previous neon-accented palette.
use ratatui::style::Color; use ratatui::style::Color;
@@ -20,75 +20,81 @@ pub struct Theme;
impl Theme { impl Theme {
// ── Base surface colors ────────────────────────────────────────────── // ── Base surface colors ──────────────────────────────────────────────
/// Deep background — used for the main chat area and overlays. /// Deep background — used for the main chat area and overlays.
pub const BG: Color = Color::Rgb(24, 25, 38); pub const BG: Color = Color::Rgb(0x1a, 0x1b, 0x26);
/// Slightly lighter surface — for panels, cards, and input bars. /// Slightly lighter surface — for panels, cards, and input bars.
pub const SURFACE: Color = Color::Rgb(30, 32, 48); pub const SURFACE: Color = Color::Rgb(0x1f, 0x23, 0x35);
/// Elevated surface — for dropdowns, toasts, and floating elements. /// Elevated surface — for dropdowns, toasts, and floating elements.
pub const SURFACE_ELEVATED: Color = Color::Rgb(38, 40, 58); pub const SURFACE_ELEVATED: Color = Color::Rgb(0x29, 0x2e, 0x42);
// ── Text colors ────────────────────────────────────────────────────── // ── Text colors ──────────────────────────────────────────────────────
/// Primary text color (bright white). /// Primary text color.
pub const TEXT: Color = Color::Rgb(220, 222, 245); pub const TEXT: Color = Color::Rgb(0xc0, 0xca, 0xf5);
/// Secondary / muted text. /// Secondary / muted text.
pub const TEXT_MUTED: Color = Color::Rgb(150, 152, 180); pub const TEXT_MUTED: Color = Color::Rgb(0xa9, 0xb1, 0xd6);
/// Dim / placeholder text. /// Dim / placeholder text.
pub const TEXT_DIM: Color = Color::Rgb(90, 92, 120); pub const TEXT_DIM: Color = Color::Rgb(0x56, 0x5f, 0x89);
// ── Accent colors ──────────────────────────────────────────────────── // ── Accent colors ────────────────────────────────────────────────────
/// Primary accent — cyan for borders, titles, selections. /// Primary accent — blue for borders, titles, selections.
pub const PRIMARY: Color = Color::Rgb(0, 212, 255); pub const PRIMARY: Color = Color::Rgb(0x7a, 0xa2, 0xf7);
/// Success / positive states — green. /// Success / positive states — green.
pub const SUCCESS: Color = Color::Rgb(80, 220, 130); pub const SUCCESS: Color = Color::Rgb(0x9e, 0xce, 0x6a);
/// Warning / in-progress states — yellow-orange. /// Warning / in-progress states — yellow.
pub const WARNING: Color = Color::Rgb(255, 200, 80); pub const WARNING: Color = Color::Rgb(0xe0, 0xaf, 0x68);
/// Error / failure states — red. /// Error / failure states — red.
pub const ERROR: Color = Color::Rgb(255, 100, 110); pub const ERROR: Color = Color::Rgb(0xf7, 0x76, 0x8e);
/// Informational / neutral — blue. /// Informational / neutral — cyan.
pub const INFO: Color = Color::Rgb(100, 170, 255); pub const INFO: Color = Color::Rgb(0x7d, 0xcf, 0xff);
// ── Extended accent palette ────────────────────────────────────────── // ── Extended accent palette ──────────────────────────────────────────
/// Purple accent — used for special highlights. /// Purple accent — used for special highlights.
pub const ACCENT_PURPLE: Color = Color::Rgb(180, 130, 255); pub const ACCENT_PURPLE: Color = Color::Rgb(0xbb, 0x9a, 0xf7);
#[allow(dead_code)]
/// Pink / magenta accent.
pub const ACCENT_PINK: Color = Color::Rgb(255, 120, 200);
/// Orange accent. /// Orange accent.
pub const ACCENT_ORANGE: Color = Color::Rgb(255, 160, 60); pub const ACCENT_ORANGE: Color = Color::Rgb(0xff, 0x9e, 0x64);
/// Teal accent. /// Teal accent.
pub const ACCENT_TEAL: Color = Color::Rgb(60, 210, 200); pub const ACCENT_TEAL: Color = Color::Rgb(0x73, 0xda, 0xca);
// ── Border colors ──────────────────────────────────────────────────── // ── Border colors ────────────────────────────────────────────────────
/// Standard border color. /// Standard border color.
pub const BORDER: Color = Color::Rgb(50, 52, 72); pub const BORDER: Color = Color::Rgb(0x3b, 0x42, 0x61);
#[allow(dead_code)]
/// Focused / active border.
pub const BORDER_FOCUS: Color = Color::Rgb(0, 180, 220);
// ── Role badge colors ──────────────────────────────────────────────── // ── Role badge colors ────────────────────────────────────────────────
pub const ROLE_USER: Color = Color::Rgb(80, 220, 130); // green pub const ROLE_USER: Color = Color::Rgb(0x9e, 0xce, 0x6a); // green
pub const ROLE_ASSISTANT: Color = Color::Rgb(0, 212, 255); // cyan pub const ROLE_ASSISTANT: Color = Color::Rgb(0x7a, 0xa2, 0xf7); // blue
pub const ROLE_SYSTEM: Color = Color::Rgb(100, 170, 255); // blue pub const ROLE_SYSTEM: Color = Color::Rgb(0x7d, 0xcf, 0xff); // cyan
pub const ROLE_TOOL: Color = Color::Rgb(255, 200, 80); // yellow pub const ROLE_TOOL: Color = Color::Rgb(0xe0, 0xaf, 0x68); // yellow
// ── Status colors ──────────────────────────────────────────────────── // ── Status colors ────────────────────────────────────────────────────
pub const STATUS_BAR_BG: Color = Color::Rgb(20, 21, 34); pub const STATUS_BAR_BG: Color = Color::Rgb(0x16, 0x16, 0x1e);
pub const MODE_AUTO: Color = Color::Rgb(80, 220, 130); pub const MODE_AUTO: Color = Color::Rgb(0x9e, 0xce, 0x6a);
pub const MODE_YOLO: Color = Color::Rgb(255, 100, 110); pub const MODE_YOLO: Color = Color::Rgb(0xf7, 0x76, 0x8e);
// ── Code / markdown ────────────────────────────────────────────────── // ── Code / markdown ──────────────────────────────────────────────────
pub const CODE_BG: Color = Color::Rgb(20, 22, 35); pub const CODE_BG: Color = Color::Rgb(0x16, 0x16, 0x1e);
pub const CODE_BAR: Color = Color::Rgb(40, 42, 62); pub const CODE_BAR: Color = Color::Rgb(0x29, 0x2e, 0x42);
pub const BLOCKQUOTE_BAR: Color = Color::Rgb(100, 170, 255); pub const BLOCKQUOTE_BAR: Color = Color::Rgb(0x7d, 0xcf, 0xff);
// ── Misc ───────────────────────────────────────────────────────────── // ── Misc ─────────────────────────────────────────────────────────────
/// Highlight / selection background. /// Highlight / selection background.
pub const HIGHLIGHT: Color = Color::Rgb(0, 140, 180); pub const HIGHLIGHT: Color = Color::Rgb(0x3d, 0x59, 0xa1);
/// Dim highlight (for non-selected items). /// Dim highlight (for non-selected items).
pub const HIGHLIGHT_DIM: Color = Color::Rgb(30, 40, 60); pub const HIGHLIGHT_DIM: Color = Color::Rgb(0x29, 0x2e, 0x42);
#[allow(dead_code)] }
/// Scrollbar track.
pub const SCROLLBAR_BG: Color = Color::Rgb(35, 37, 55); #[cfg(test)]
#[allow(dead_code)] mod tests {
/// Scrollbar thumb. use super::*;
pub const SCROLLBAR_FG: Color = Color::Rgb(60, 62, 85);
#[test]
fn palette_matches_tokyo_night_spec() {
assert_eq!(Theme::BG, Color::Rgb(0x1a, 0x1b, 0x26));
assert_eq!(Theme::SURFACE, Color::Rgb(0x1f, 0x23, 0x35));
assert_eq!(Theme::PRIMARY, Color::Rgb(0x7a, 0xa2, 0xf7));
assert_eq!(Theme::SUCCESS, Color::Rgb(0x9e, 0xce, 0x6a));
assert_eq!(Theme::WARNING, Color::Rgb(0xe0, 0xaf, 0x68));
assert_eq!(Theme::ERROR, Color::Rgb(0xf7, 0x76, 0x8e));
assert_eq!(Theme::INFO, Color::Rgb(0x7d, 0xcf, 0xff));
assert_eq!(Theme::ACCENT_PURPLE, Color::Rgb(0xbb, 0x9a, 0xf7));
assert_eq!(Theme::BORDER, Color::Rgb(0x3b, 0x42, 0x61));
}
} }
+47 -2
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; use crate::app::workflow::engine::{AgentState, WorkflowAgent};
/// Icons for agent states. /// Icons for agent states.
fn state_icon(state: AgentState) -> &'static str { fn state_icon(state: AgentState) -> &'static str {
@@ -49,7 +49,7 @@ fn state_color(state: AgentState) -> Color {
pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::layout::{Constraint, Direction, Layout};
let title = Span::styled(" Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)); let title = Span::styled(" Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD));
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
@@ -208,4 +208,49 @@ 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)),
])
}
use ratatui::style::Color; use ratatui::style::Color;