Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
340ae2fde2 | ||
|
|
1010e44b22 | ||
|
|
b355c9928e | ||
|
|
aaea300699 | ||
|
|
7fd55fa86d | ||
|
|
31c01cdf1d | ||
|
|
aa2b6acb95 | ||
|
|
2f1a4d85a1 | ||
|
|
e34708a319 | ||
|
|
6b58977875 | ||
|
|
7a9cb7bf34 | ||
|
|
04e7ff9380 |
+2
-1
@@ -3,4 +3,5 @@ target/
|
||||
.claude/settings.local.json
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
package-lock.json
|
||||
.superpowers/
|
||||
@@ -1,3 +1,21 @@
|
||||
# [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)
|
||||
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -4436,7 +4436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "zesdex"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
edition = "2021"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
|
||||
|
||||
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
|
||||
@@ -393,10 +393,10 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.workflow_engine.agents.clear();
|
||||
state.workflow_engine.findings.clear();
|
||||
}
|
||||
if message.to_lowercase().contains("complete") {
|
||||
if state.misc.overlay == Overlay::Workflow {
|
||||
state.misc.overlay = Overlay::None;
|
||||
}
|
||||
if message.to_lowercase().contains("complete")
|
||||
&& state.misc.overlay == Overlay::Workflow
|
||||
{
|
||||
state.misc.overlay = Overlay::None;
|
||||
}
|
||||
state.push_toast(Toast {
|
||||
kind: ToastKind::Info,
|
||||
|
||||
@@ -69,6 +69,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
Command::WorkflowRun { script } => {
|
||||
vec![Action::RunWorkflow { script }]
|
||||
}
|
||||
Command::TodoOpen => {
|
||||
vec![Action::OpenOverlay(Overlay::Todo)]
|
||||
}
|
||||
Command::UsageOpen => {
|
||||
vec![Action::OpenOverlay(Overlay::Usage)]
|
||||
}
|
||||
Command::Unknown(cmd) => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "error".to_string(),
|
||||
|
||||
@@ -90,6 +90,8 @@ const COMMANDS: &[&str] = &[
|
||||
"/model add",
|
||||
"/workflow",
|
||||
"/workflow run",
|
||||
"/todo",
|
||||
"/usage",
|
||||
"/compact",
|
||||
];
|
||||
|
||||
|
||||
@@ -109,17 +109,21 @@ fn is_production_code(path: &str) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
std::path::Component::Normal(seg)
|
||||
if matches!(seg.to_str(), Some("test") | Some("tests") | Some("__tests__"))
|
||||
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")
|
||||
|| 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")
|
||||
|| 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;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! are not a weaker link than the main agent.
|
||||
|
||||
use std::fmt::Write;
|
||||
use sha2::Digest;
|
||||
use tokio::sync::mpsc;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
use crate::dto::provider::request::ToolDef;
|
||||
@@ -456,7 +457,6 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
||||
.unwrap_or("unknown");
|
||||
let content_sha256 = {
|
||||
let content = args.get("content").or_else(|| args.get("new"));
|
||||
use sha2::Digest;
|
||||
let hash = sha2::Sha256::digest(
|
||||
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,8 @@ pub enum Command {
|
||||
WorkflowRun {
|
||||
script: String,
|
||||
},
|
||||
TodoOpen,
|
||||
UsageOpen,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
@@ -75,6 +77,23 @@ pub fn parse_command(text: &str) -> Command {
|
||||
"/workflow" => Command::WorkflowRun {
|
||||
script: arg1.to_string(),
|
||||
},
|
||||
"/todo" => Command::TodoOpen,
|
||||
"/usage" => Command::UsageOpen,
|
||||
_ => 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ Input:
|
||||
/workflow Open workflow panel
|
||||
/workflow run <p> Run a workflow with prompt <p>
|
||||
/mode workflow Open workflow panel
|
||||
/todo Open task list
|
||||
/usage Open usage details
|
||||
/compact Compact conversation history
|
||||
/exit Exit application
|
||||
|
||||
|
||||
+151
-105
@@ -1,23 +1,32 @@
|
||||
#![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
|
||||
//! visually rich transcript where each message is rendered as a "card"
|
||||
//! with a role-colored left accent bar, a role badge pill, timestamp,
|
||||
//! and markdown body. A streaming spinner line is appended when a turn
|
||||
//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a dense,
|
||||
//! log-like transcript: each non-tool message gets a one-line
|
||||
//! `{role} {time} {content}` header with wrapped continuation lines
|
||||
//! 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
|
||||
//! window before rendering.
|
||||
//!
|
||||
//! Design: messages are visually separated with vertical spacing, role
|
||||
//! badges are colored pills on the left, and each message has a thin
|
||||
//! role-colored border on its left side for quick visual scanning.
|
||||
//! Design: no per-message card/border/badge — role identity comes from a
|
||||
//! short colored label, and vertical space is reserved for a blank line
|
||||
//! 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::style::{Style, Modifier};
|
||||
use ratatui::style::{Color, Style, Modifier};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
fn role_badge(role: &crate::dto::chat::message::Role) -> &'static str {
|
||||
fn role_accent_color(role: &Role) -> Color {
|
||||
match role {
|
||||
crate::dto::chat::message::Role::User => " YOU ",
|
||||
crate::dto::chat::message::Role::Assistant => " AI ",
|
||||
crate::dto::chat::message::Role::System => " SYS ",
|
||||
crate::dto::chat::message::Role::Tool => " TOOL ",
|
||||
Role::User => Theme::ROLE_USER,
|
||||
Role::Assistant => Theme::ROLE_ASSISTANT,
|
||||
Role::System => Theme::ROLE_SYSTEM,
|
||||
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 {
|
||||
crate::dto::chat::message::Role::User => Theme::ROLE_USER,
|
||||
crate::dto::chat::message::Role::Assistant => Theme::ROLE_ASSISTANT,
|
||||
crate::dto::chat::message::Role::System => Theme::ROLE_SYSTEM,
|
||||
crate::dto::chat::message::Role::Tool => Theme::ROLE_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",
|
||||
Role::User => "you",
|
||||
Role::Assistant => "ai",
|
||||
Role::System => "sys",
|
||||
Role::Tool => "tool",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,60 +83,90 @@ fn format_timestamp(ts: i64) -> String {
|
||||
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)]
|
||||
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
||||
let messages = &state.transcript_cache.messages;
|
||||
let scroll_offset = state.scroll.offset;
|
||||
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 prev_role: Option<Role> = None;
|
||||
|
||||
// ── Header ───────────────────────────────────────────────────────────
|
||||
let title = if messages.is_empty() {
|
||||
String::from(" Chat ")
|
||||
} else {
|
||||
format!(" Chat [{} msgs]", messages.len())
|
||||
};
|
||||
|
||||
// ── Render messages as cards ─────────────────────────────────────────
|
||||
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());
|
||||
|
||||
// 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() {
|
||||
if is_last && state.turn_in_flight() {
|
||||
"(streaming...)".to_string()
|
||||
@@ -144,19 +177,23 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
||||
msg.content.clone()
|
||||
};
|
||||
|
||||
// Render markdown content with accent-colored prefix
|
||||
let mut content_spans = vec![
|
||||
Span::styled(" ", Style::default().fg(accent)),
|
||||
];
|
||||
content_spans.extend(super::markdown::render_markdown(&content_str, area.width));
|
||||
let message_lines = split_spans_into_lines(content_spans);
|
||||
let content_spans = super::markdown::render_markdown(&content_str, content_width);
|
||||
let content_lines = split_spans_into_lines(content_spans);
|
||||
let mut lines_iter = content_lines.into_iter();
|
||||
|
||||
for line in message_lines {
|
||||
display_lines.push(line);
|
||||
if let Some(first) = lines_iter.next() {
|
||||
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 ────────────────────────────────────────────
|
||||
display_lines.push(Line::from(Span::raw("")));
|
||||
for line in lines_iter {
|
||||
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
|
||||
spans.extend(line.spans);
|
||||
display_lines.push(Line::from(spans));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 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![
|
||||
Span::styled(
|
||||
"▎",
|
||||
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} "),
|
||||
format!("{:<4} ", format_role_label(&Role::Assistant)),
|
||||
Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
"Generating...",
|
||||
Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)),
|
||||
Span::styled("generating...", Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC)),
|
||||
]));
|
||||
display_lines.push(Line::from(Span::raw("")));
|
||||
}
|
||||
|
||||
// ── Scrolling ────────────────────────────────────────────────────────
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.title(Span::styled(
|
||||
title,
|
||||
Style::default().fg(Theme::TEXT_MUTED),
|
||||
));
|
||||
.title(Span::styled(title, Style::default().fg(Theme::TEXT_MUTED)));
|
||||
|
||||
let total = display_lines.len();
|
||||
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()
|
||||
};
|
||||
|
||||
// ── Scroll position indicator ────────────────────────────────────────
|
||||
// Show a small percentage indicator in the title if scrolled
|
||||
let scroll_pct = if total > max_visible {
|
||||
((offset as f64 / max_offset as f64) * 100.0) as u8
|
||||
} else {
|
||||
@@ -223,10 +244,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.title(Span::styled(
|
||||
scroll_title,
|
||||
Style::default().fg(Theme::TEXT_MUTED),
|
||||
))
|
||||
.title(Span::styled(scroll_title, Style::default().fg(Theme::TEXT_MUTED)))
|
||||
} else {
|
||||
block
|
||||
};
|
||||
@@ -239,5 +257,33 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
// Need to import Color for role_accent_color
|
||||
use ratatui::style::Color;
|
||||
#[cfg(test)]
|
||||
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,
|
||||
//! headings are bold with distinct colors, blockquotes get a vertical
|
||||
//! 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::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_heading = false;
|
||||
let mut heading_level = 0;
|
||||
let mut first_in_paragraph = true;
|
||||
|
||||
for event in parser {
|
||||
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
|
||||
}
|
||||
pulldown_cmark::Tag::Paragraph => {
|
||||
first_in_paragraph = true;
|
||||
}
|
||||
pulldown_cmark::Tag::Item => {
|
||||
// List item bullet
|
||||
spans.push(Span::styled(
|
||||
" • ",
|
||||
"• ",
|
||||
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"));
|
||||
}
|
||||
pulldown_cmark::TagEnd::Paragraph => {
|
||||
first_in_paragraph = true;
|
||||
spans.push(Span::raw("\n\n"));
|
||||
}
|
||||
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,
|
||||
_ => Theme::TEXT,
|
||||
};
|
||||
let prefix = " ";
|
||||
spans.push(Span::styled(
|
||||
format!("{prefix}{s}"),
|
||||
s,
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
|
||||
+151
-138
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod chat;
|
||||
pub mod markdown;
|
||||
pub mod sidebar;
|
||||
pub mod status;
|
||||
pub mod theme;
|
||||
pub mod workflow;
|
||||
@@ -20,14 +21,21 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
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.
|
||||
pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
|
||||
let area = frame.area();
|
||||
|
||||
// ── Determine if we need a side panel (todo) ─────────────────────────
|
||||
let show_todo = !state.misc.todo_content.is_empty()
|
||||
|| state.misc.overlay == crate::app::state::types::Overlay::Todo;
|
||||
let (main_area, todo_area) = if show_todo && area.width > 60 {
|
||||
// ── Determine if the terminal is wide enough for the persistent
|
||||
// dashboard sidebar (Workflow / Tasks / Usage). Below this, chat
|
||||
// reclaims the full width — same width-driven-collapse pattern the
|
||||
// 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()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
@@ -55,9 +63,7 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
|
||||
let status_area = chunks[2];
|
||||
|
||||
// ── Render main area (overlay or chat) ───────────────────────────────
|
||||
if state.misc.overlay.is_active()
|
||||
&& state.misc.overlay != crate::app::state::types::Overlay::Todo
|
||||
{
|
||||
if state.misc.overlay.is_active() {
|
||||
let overlay = state.misc.overlay;
|
||||
render_overlay(frame, chat_area, overlay, state);
|
||||
} else {
|
||||
@@ -70,9 +76,9 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
|
||||
// ── Status bar ───────────────────────────────────────────────────────
|
||||
status::draw_status_bar(frame, status_area, state);
|
||||
|
||||
// ── Todo side panel ──────────────────────────────────────────────────
|
||||
if let Some(todo_rect) = todo_area {
|
||||
render_todo_panel(frame, todo_rect, state);
|
||||
// ── Dashboard sidebar ────────────────────────────────────────────────
|
||||
if let Some(sidebar_rect) = sidebar_area {
|
||||
sidebar::draw_sidebar(frame, sidebar_rect, state);
|
||||
}
|
||||
|
||||
// ── Toasts (top-right floating) ──────────────────────────────────────
|
||||
@@ -83,30 +89,6 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
|
||||
// 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(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
@@ -148,7 +130,7 @@ fn render_overlay(
|
||||
// ── Help ──────────────────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::Help => {
|
||||
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));
|
||||
let content = crate::resources::HELP_TEXT;
|
||||
let paragraph = Paragraph::new(content)
|
||||
@@ -161,7 +143,7 @@ fn render_overlay(
|
||||
// ── Settings ──────────────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::Settings => {
|
||||
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));
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
@@ -198,7 +180,7 @@ fn render_overlay(
|
||||
// ── Bash ──────────────────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::Bash => {
|
||||
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));
|
||||
let lines: Vec<Line> = state.session_runtime.as_ref().map(|r| {
|
||||
r.bash_jobs.iter().map(|job| {
|
||||
@@ -225,7 +207,7 @@ fn render_overlay(
|
||||
// ── Quit Confirm ──────────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::QuitConfirm => {
|
||||
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));
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
@@ -250,7 +232,7 @@ fn render_overlay(
|
||||
// ── Key Input ─────────────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::KeyInput => {
|
||||
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));
|
||||
let input_text = &state.input.buffer;
|
||||
let display = if input_text.is_empty() {
|
||||
@@ -287,7 +269,7 @@ fn render_overlay(
|
||||
// ── Editor ────────────────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::Editor => {
|
||||
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));
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
@@ -316,7 +298,7 @@ fn render_overlay(
|
||||
// ── Effort ────────────────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::Effort => {
|
||||
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));
|
||||
let levels = crate::app::mode::effort::EFFORT_LEVELS;
|
||||
let current_idx = crate::app::mode::effort::current_effort(state);
|
||||
@@ -349,7 +331,7 @@ fn render_overlay(
|
||||
// ── MCP ───────────────────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::Mcp => {
|
||||
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));
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
@@ -378,32 +360,23 @@ fn render_overlay(
|
||||
// ── Todo ──────────────────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::Todo => {
|
||||
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));
|
||||
let msg_count = state.transcript_cache.messages.len();
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Session Activity",
|
||||
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
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);
|
||||
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, overlay_area);
|
||||
}
|
||||
|
||||
// ── Rewind ────────────────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::Rewind => {
|
||||
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));
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(
|
||||
@@ -461,7 +434,7 @@ fn render_overlay(
|
||||
.split(overlay_area);
|
||||
|
||||
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)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.style(Style::default().bg(Theme::BG));
|
||||
@@ -633,90 +606,75 @@ fn render_overlay(
|
||||
// ── Usage ────────────────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::Usage => {
|
||||
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));
|
||||
let runtime = state.session_runtime.as_ref();
|
||||
let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime
|
||||
.map_or((0, 0, 0, 0, 0), |r| {
|
||||
(
|
||||
r.usage.tokens_in,
|
||||
r.usage.tokens_out,
|
||||
r.usage.api_calls,
|
||||
r.usage.review_tokens,
|
||||
r.session_start,
|
||||
)
|
||||
});
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let summary = runtime.map(|r| sidebar::compute_usage_summary(&r.usage, r.session_start, now_ms));
|
||||
let (edit_count, lesson_count, review_count, consec_empty) = runtime
|
||||
.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 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![
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Token Usage",
|
||||
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
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);
|
||||
frame.render_widget(paragraph, overlay_area);
|
||||
}
|
||||
@@ -724,7 +682,7 @@ fn render_overlay(
|
||||
// ── Loading ──────────────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::Loading => {
|
||||
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));
|
||||
let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let frame_idx = (state.misc.tick_count as usize) % spinner.len();
|
||||
@@ -737,7 +695,7 @@ fn render_overlay(
|
||||
// ── Model Selector ───────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::ModelSelector => {
|
||||
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));
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(
|
||||
@@ -779,7 +737,7 @@ fn render_overlay(
|
||||
// ── Clear Confirm ────────────────────────────────────────────
|
||||
crate::app::state::types::Overlay::ClearConfirm => {
|
||||
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));
|
||||
let lines = vec![
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`
|
||||
//! consts, consumed by every `view/*` render function so styling stays
|
||||
//! consistent and changeable from one place.
|
||||
//!
|
||||
//! Design: dark-primary background (#1a1b26 / Catppuccin Mocha inspired),
|
||||
//! vibrant accent colors for semantic states, and muted tones for
|
||||
//! secondary/background elements. This gives a modern "neon dashboard"
|
||||
//! look that is easy on the eyes during long sessions.
|
||||
//! Design: muted blue-purple dark background with desaturated blue/cyan/
|
||||
//! purple accents (not neon) — the popular Tokyo Night editor/terminal
|
||||
//! theme. Chosen for a calmer "professional dev tool" read in place of
|
||||
//! the previous neon-accented palette.
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
@@ -20,75 +20,81 @@ pub struct Theme;
|
||||
impl Theme {
|
||||
// ── Base surface colors ──────────────────────────────────────────────
|
||||
/// 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.
|
||||
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.
|
||||
pub const SURFACE_ELEVATED: Color = Color::Rgb(38, 40, 58);
|
||||
pub const SURFACE_ELEVATED: Color = Color::Rgb(0x29, 0x2e, 0x42);
|
||||
|
||||
// ── Text colors ──────────────────────────────────────────────────────
|
||||
/// Primary text color (bright white).
|
||||
pub const TEXT: Color = Color::Rgb(220, 222, 245);
|
||||
/// Primary text color.
|
||||
pub const TEXT: Color = Color::Rgb(0xc0, 0xca, 0xf5);
|
||||
/// 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.
|
||||
pub const TEXT_DIM: Color = Color::Rgb(90, 92, 120);
|
||||
pub const TEXT_DIM: Color = Color::Rgb(0x56, 0x5f, 0x89);
|
||||
|
||||
// ── Accent colors ────────────────────────────────────────────────────
|
||||
/// Primary accent — cyan for borders, titles, selections.
|
||||
pub const PRIMARY: Color = Color::Rgb(0, 212, 255);
|
||||
/// Primary accent — blue for borders, titles, selections.
|
||||
pub const PRIMARY: Color = Color::Rgb(0x7a, 0xa2, 0xf7);
|
||||
/// Success / positive states — green.
|
||||
pub const SUCCESS: Color = Color::Rgb(80, 220, 130);
|
||||
/// Warning / in-progress states — yellow-orange.
|
||||
pub const WARNING: Color = Color::Rgb(255, 200, 80);
|
||||
pub const SUCCESS: Color = Color::Rgb(0x9e, 0xce, 0x6a);
|
||||
/// Warning / in-progress states — yellow.
|
||||
pub const WARNING: Color = Color::Rgb(0xe0, 0xaf, 0x68);
|
||||
/// Error / failure states — red.
|
||||
pub const ERROR: Color = Color::Rgb(255, 100, 110);
|
||||
/// Informational / neutral — blue.
|
||||
pub const INFO: Color = Color::Rgb(100, 170, 255);
|
||||
pub const ERROR: Color = Color::Rgb(0xf7, 0x76, 0x8e);
|
||||
/// Informational / neutral — cyan.
|
||||
pub const INFO: Color = Color::Rgb(0x7d, 0xcf, 0xff);
|
||||
|
||||
// ── Extended accent palette ──────────────────────────────────────────
|
||||
/// Purple accent — used for special highlights.
|
||||
pub const ACCENT_PURPLE: Color = Color::Rgb(180, 130, 255);
|
||||
#[allow(dead_code)]
|
||||
/// Pink / magenta accent.
|
||||
pub const ACCENT_PINK: Color = Color::Rgb(255, 120, 200);
|
||||
pub const ACCENT_PURPLE: Color = Color::Rgb(0xbb, 0x9a, 0xf7);
|
||||
/// Orange accent.
|
||||
pub const ACCENT_ORANGE: Color = Color::Rgb(255, 160, 60);
|
||||
pub const ACCENT_ORANGE: Color = Color::Rgb(0xff, 0x9e, 0x64);
|
||||
/// Teal accent.
|
||||
pub const ACCENT_TEAL: Color = Color::Rgb(60, 210, 200);
|
||||
pub const ACCENT_TEAL: Color = Color::Rgb(0x73, 0xda, 0xca);
|
||||
|
||||
// ── Border colors ────────────────────────────────────────────────────
|
||||
/// Standard border color.
|
||||
pub const BORDER: Color = Color::Rgb(50, 52, 72);
|
||||
#[allow(dead_code)]
|
||||
/// Focused / active border.
|
||||
pub const BORDER_FOCUS: Color = Color::Rgb(0, 180, 220);
|
||||
pub const BORDER: Color = Color::Rgb(0x3b, 0x42, 0x61);
|
||||
|
||||
// ── Role badge colors ────────────────────────────────────────────────
|
||||
pub const ROLE_USER: Color = Color::Rgb(80, 220, 130); // green
|
||||
pub const ROLE_ASSISTANT: Color = Color::Rgb(0, 212, 255); // cyan
|
||||
pub const ROLE_SYSTEM: Color = Color::Rgb(100, 170, 255); // blue
|
||||
pub const ROLE_TOOL: Color = Color::Rgb(255, 200, 80); // yellow
|
||||
pub const ROLE_USER: Color = Color::Rgb(0x9e, 0xce, 0x6a); // green
|
||||
pub const ROLE_ASSISTANT: Color = Color::Rgb(0x7a, 0xa2, 0xf7); // blue
|
||||
pub const ROLE_SYSTEM: Color = Color::Rgb(0x7d, 0xcf, 0xff); // cyan
|
||||
pub const ROLE_TOOL: Color = Color::Rgb(0xe0, 0xaf, 0x68); // yellow
|
||||
|
||||
// ── Status colors ────────────────────────────────────────────────────
|
||||
pub const STATUS_BAR_BG: Color = Color::Rgb(20, 21, 34);
|
||||
pub const MODE_AUTO: Color = Color::Rgb(80, 220, 130);
|
||||
pub const MODE_YOLO: Color = Color::Rgb(255, 100, 110);
|
||||
pub const STATUS_BAR_BG: Color = Color::Rgb(0x16, 0x16, 0x1e);
|
||||
pub const MODE_AUTO: Color = Color::Rgb(0x9e, 0xce, 0x6a);
|
||||
pub const MODE_YOLO: Color = Color::Rgb(0xf7, 0x76, 0x8e);
|
||||
|
||||
// ── Code / markdown ──────────────────────────────────────────────────
|
||||
pub const CODE_BG: Color = Color::Rgb(20, 22, 35);
|
||||
pub const CODE_BAR: Color = Color::Rgb(40, 42, 62);
|
||||
pub const BLOCKQUOTE_BAR: Color = Color::Rgb(100, 170, 255);
|
||||
pub const CODE_BG: Color = Color::Rgb(0x16, 0x16, 0x1e);
|
||||
pub const CODE_BAR: Color = Color::Rgb(0x29, 0x2e, 0x42);
|
||||
pub const BLOCKQUOTE_BAR: Color = Color::Rgb(0x7d, 0xcf, 0xff);
|
||||
|
||||
// ── Misc ─────────────────────────────────────────────────────────────
|
||||
/// 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).
|
||||
pub const HIGHLIGHT_DIM: Color = Color::Rgb(30, 40, 60);
|
||||
#[allow(dead_code)]
|
||||
/// Scrollbar track.
|
||||
pub const SCROLLBAR_BG: Color = Color::Rgb(35, 37, 55);
|
||||
#[allow(dead_code)]
|
||||
/// Scrollbar thumb.
|
||||
pub const SCROLLBAR_FG: Color = Color::Rgb(60, 62, 85);
|
||||
pub const HIGHLIGHT_DIM: Color = Color::Rgb(0x29, 0x2e, 0x42);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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::Frame;
|
||||
use super::theme::Theme;
|
||||
use crate::app::workflow::engine::AgentState;
|
||||
use crate::app::workflow::engine::{AgentState, WorkflowAgent};
|
||||
|
||||
/// Icons for agent states.
|
||||
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) {
|
||||
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()
|
||||
.borders(Borders::ALL)
|
||||
@@ -208,4 +208,49 @@ fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec<Lin
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user