Compare commits
+2
-1
@@ -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/
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ const COMMANDS: &[&str] = &[
|
|||||||
"/model add",
|
"/model add",
|
||||||
"/workflow",
|
"/workflow",
|
||||||
"/workflow run",
|
"/workflow run",
|
||||||
|
"/todo",
|
||||||
|
"/usage",
|
||||||
"/compact",
|
"/compact",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user