From 1010e44b2288a38ba4546e2c30aea2266b8d0b26 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 14 Jul 2026 23:41:49 +0700 Subject: [PATCH] Implement new feature for user authentication and improve error handling --- .../plans/2026-07-14-tui-overhaul.md | 1610 +++++++++++++++++ 1 file changed, 1610 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-tui-overhaul.md diff --git a/docs/superpowers/plans/2026-07-14-tui-overhaul.md b/docs/superpowers/plans/2026-07-14-tui-overhaul.md new file mode 100644 index 0000000..339265c --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-tui-overhaul.md @@ -0,0 +1,1610 @@ +# TUI Overhaul Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild zesdex's TUI render/interaction layer into a Multi-Pane Dashboard — persistent Workflow/Tasks/Usage sidebar, a Tokyo Night palette, and a tight inline chat log — per `docs/superpowers/specs/2026-07-14-tui-overhaul-design.md`. + +**Architecture:** Pure view-layer repaint. `AppStateRest` and every `Action`/`Command` for existing behavior stay untouched; new code only reads state that's already there. Two small, deliberately-scoped additions ride along because the sidebar's design depends on them (see Global Constraints). + +**Tech Stack:** Rust 2021, ratatui 0.30.2, crossterm 0.29. No new dependencies. + +## Global Constraints + +- Scope is `src/view/` + `src/controller/command.rs` + `src/app/runtime/commands.rs` + `src/app/state/misc.rs` (one const list) + `src/resources.rs` (help text) — see Task 3 for why two non-`view` files are touched. +- **`zesdex` is a binary-only crate — there is no `[lib]` target.** Run tests as `cargo test `, never `cargo test --lib` (that errors immediately with "no library targets found"). +- `cargo clippy -- -D warnings` must pass after every task (exact CI invocation from `.github/workflows/ci.yml`). `[lints.rust]` in `Cargo.toml` denies `unused`, `dead_code`, `unreachable_code`, `unused_imports`, `unused_variables` **at the `cargo build` level, not just clippy** — confirmed empirically: an unreachable `pub fn` (or a `pub` struct field never read anywhere, including by tests) fails plain `cargo build` outright, since this is a binary crate with no external consumers to make `pub` items exempt. `#[cfg(test)]`-only usage does **not** count as reachable for `cargo build` (test code isn't compiled in that mode) — but it does for `cargo test`. **This is why Task 4 is one large task instead of three small ones**: splitting it would leave newly-added `pub fn`s unreachable at an intermediate task boundary, and `cargo build` would fail there. +- Never add `#[allow(...)]` to silence a warning — fix the underlying code (CLAUDE.md). Task 1 removes four pre-existing `#[allow(dead_code)]` constants instead of carrying them forward. +- Tests are inline `#[cfg(test)] mod tests` blocks in the production file, per CLAUDE.md — there is no `tests/` directory convention here. +- Every touched `pub fn` / `pub struct` needs a doc comment covering What/Flow/Why/Return, per CLAUDE.md. +- No automated visual/snapshot tests exist for `view/`/`controller/` and none are introduced — ratatui rendering is verified manually (Task 7). Only genuinely pure logic gets a unit test; do not invent tests for rendering glue that has nothing to assert. +- Commit convention: `(): ` — this repo uses scope `tui` for this whole area (see `git log --oneline -- src/view`). Use type `feat`/`fix`/`style` per task as specified below. + +## Two scoped additions this plan carries (read before Task 3) + +The approved spec says Workflow/Todo/Usage overlays "keep their existing trigger... as an expand view." Tracing `controller/input.rs` + `controller/command.rs` + `app/runtime/commands.rs` end-to-end: **`Overlay::Workflow` has a real trigger (`/workflow`), but `Overlay::Todo` and `Overlay::Usage` do not** — nothing in the current codebase ever sets `state.misc.overlay = Overlay::Todo` or `Overlay::Usage` during normal interaction (only a session-snapshot restore path in `main.rs` can). Without a trigger, the sidebar's "+N more" overflow hint would point at something the user can't reach. Task 3 adds `/todo` and `/usage` commands, mirroring the exact existing `/workflow` pattern — the smallest fix that makes the sidebar's expand affordance real. Also found: `Overlay::Todo`'s current body doesn't show todo content at all (it shows an unrelated message-count dump — dead/vestigial); Task 5 fixes this as part of making it a real "expand" view. + +--- + +### Task 1: Tokyo Night palette + +**Files:** +- Modify: `src/view/theme.rs` (whole file — value-only rewrite, same const names except four deletions below) + +**Interfaces:** +- Consumes: nothing new. +- Produces: same `Theme::CONST_NAME` surface every other `view/*` file already depends on, with new `Color` values. Deletes `Theme::ACCENT_PINK`, `Theme::BORDER_FOCUS`, `Theme::SCROLLBAR_BG`, `Theme::SCROLLBAR_FG` — confirmed unused anywhere in `src/` (`grep -rn "ACCENT_PINK\|BORDER_FOCUS\|SCROLLBAR_BG\|SCROLLBAR_FG" src` returns only their own definitions), currently kept alive solely by `#[allow(dead_code)]`, which CLAUDE.md forbids. No later task in this plan uses any of the four. +- Note: `src/view/status.rs` and `src/view/markdown.rs`'s color choices (not its indentation — see Task 2) need **no code changes at all** — both already reference colors exclusively as `Theme::*`, so the new palette applies automatically once this task lands. + +- [ ] **Step 1: Write the failing test** + +Add to the bottom of `src/view/theme.rs` (file still has the old neon values at this point): + +```rust +#[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)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test view::theme::tests::palette_matches_tokyo_night_spec` +Expected: FAIL — assertion mismatch (`Theme::BG` is still `Color::Rgb(24, 25, 38)`, the old value). + +- [ ] **Step 3: Replace the whole file** + +Replace all of `src/view/theme.rs` (keep the test module from Step 1 at the bottom) with: + +```rust +//! 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: 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; + +/// Central palette of terminal colors used across all TUI render functions. +/// +/// Every view module references colors as `Theme::NAME` instead of +/// hardcoding `ratatui::style::Color` values inline. +pub struct Theme; + +impl Theme { + // ── Base surface colors ────────────────────────────────────────────── + /// Deep background — used for the main chat area and overlays. + pub const BG: Color = Color::Rgb(0x1a, 0x1b, 0x26); + /// Slightly lighter surface — for panels, cards, and input bars. + pub const SURFACE: Color = Color::Rgb(0x1f, 0x23, 0x35); + /// Elevated surface — for dropdowns, toasts, and floating elements. + pub const SURFACE_ELEVATED: Color = Color::Rgb(0x29, 0x2e, 0x42); + + // ── Text colors ────────────────────────────────────────────────────── + /// Primary text color. + pub const TEXT: Color = Color::Rgb(0xc0, 0xca, 0xf5); + /// Secondary / muted text. + pub const TEXT_MUTED: Color = Color::Rgb(0xa9, 0xb1, 0xd6); + /// Dim / placeholder text. + pub const TEXT_DIM: Color = Color::Rgb(0x56, 0x5f, 0x89); + + // ── Accent colors ──────────────────────────────────────────────────── + /// 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(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(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(0xbb, 0x9a, 0xf7); + /// Orange accent. + pub const ACCENT_ORANGE: Color = Color::Rgb(0xff, 0x9e, 0x64); + /// Teal accent. + pub const ACCENT_TEAL: Color = Color::Rgb(0x73, 0xda, 0xca); + + // ── Border colors ──────────────────────────────────────────────────── + /// Standard border color. + pub const BORDER: Color = Color::Rgb(0x3b, 0x42, 0x61); + + // ── Role badge colors ──────────────────────────────────────────────── + 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(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(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(0x3d, 0x59, 0xa1); + /// Dim highlight (for non-selected items). + pub const HIGHLIGHT_DIM: Color = Color::Rgb(0x29, 0x2e, 0x42); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test view::theme::tests::palette_matches_tokyo_night_spec` +Expected: PASS + +- [ ] **Step 5: Full build + lint gate** + +Run: `cargo build && cargo clippy -- -D warnings` +Expected: both succeed with zero warnings. (This will show unresolved-reference errors in every file that used `ACCENT_PINK`/`BORDER_FOCUS`/`SCROLLBAR_BG`/`SCROLLBAR_FG` if the earlier grep missed a usage — if so, stop and re-check before proceeding; the grep in this task's Interfaces section should have caught all of them.) + +- [ ] **Step 6: Commit** + +```bash +git add src/view/theme.rs +git commit -m "$(cat <<'EOF' +feat(tui): ganti palet warna ke Tokyo Night + +Co-Authored-By: Claude Sonnet 5 +EOF +)" +``` + +--- + +### Task 2: Tight inline chat rendering + +**Files:** +- Modify: `src/view/chat.rs` (whole file) +- Modify: `src/view/markdown.rs` (whole file — see Step 4; required for this task's own correctness, not a drive-by change) + +**Interfaces:** +- Consumes: `Theme::*` from Task 1; `crate::app::state::rest::AppStateRest` (`transcript_cache.messages: Vec`, `scroll.offset: usize`, `turn_in_flight() -> bool`, `misc.tick_count: u64`); `crate::dto::chat::message::Role` (`User`/`Assistant`/`System`/`Tool`, derives `Clone, PartialEq, Eq`, **not** `Copy`); `super::markdown::render_markdown(text: &str, width: u16) -> Vec>`. +- Produces: `pub fn draw_chat(frame: &mut Frame, area: Rect, state: &AppStateRest)` — same signature as before, only the internals change. No other file calls into `chat.rs`'s private helpers. + +**Why `markdown.rs` is in scope for this task:** `render_markdown` currently adds its own `" "` lead-in before a paragraph's or heading's *first* text chunk (`first_in_paragraph` tracking), but not before subsequent wrapped lines of that same paragraph. The old card layout didn't care. The new layout does: `chat.rs` now adds a consistent `PREFIX_WIDTH`-column indent to every continuation line, so `markdown.rs`'s extra one-time `" "` would push line 1 two columns further right than line 2 — violating the spec's "wrapped content aligns under the content column" requirement. The fix is to remove `markdown.rs`'s own indentation entirely (paragraphs, headings, and the list-item bullet) and let `chat.rs`'s `PREFIX_WIDTH` be the *only* source of column alignment. Code-block lines are unaffected and untouched — every code-block text event already gets its `" "` prefix independently and consistently (no first-line-only special case there), so there's no misalignment bug to fix in that path. + +- [ ] **Step 1: Write the failing tests** + +Add to the bottom of `src/view/chat.rs` (file still has the old card-rendering body at this point — these tests target two new private functions that don't exist yet, so they won't even compile, which counts as "fails"): + +```rust +#[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); + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test view::chat::tests` +Expected: FAIL to compile — `needs_speaker_separator` and `format_role_label` are not defined yet. + +- [ ] **Step 3: Replace the whole file (keep the test module from Step 1 at the bottom)** + +```rust +#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +//! Chat transcript panel rendering — tight inline log style. +//! +//! 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: 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::{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>) -> Vec> { + let mut lines = Vec::new(); + let mut current_spans = Vec::new(); + + for span in spans { + let text = span.content.as_ref(); + let mut parts = text.split('\n').peekable(); + while let Some(part) = parts.next() { + if !part.is_empty() { + current_spans.push(Span::styled(part.to_string(), span.style)); + } + if parts.peek().is_some() { + lines.push(Line::from(std::mem::take(&mut current_spans))); + } + } + } + if !current_spans.is_empty() { + lines.push(Line::from(current_spans)); + } + if lines.is_empty() { + lines.push(Line::from(vec![])); + } + lines +} + +fn role_accent_color(role: &Role) -> Color { + match role { + Role::User => Theme::ROLE_USER, + Role::Assistant => Theme::ROLE_ASSISTANT, + Role::System => Theme::ROLE_SYSTEM, + Role::Tool => Theme::ROLE_TOOL, + } +} + +/// 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 { + Role::User => "you", + Role::Assistant => "ai", + Role::System => "sys", + Role::Tool => "tool", + } +} + +fn format_timestamp(ts: i64) -> String { + if ts <= 0 { return String::new(); } + let secs = ts / 1000; + let mins = (secs / 60) % 60; + let hrs = (secs / 3600) % 24; + format!("{hrs:02}:{mins:02}") +} + +/// 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 = Vec::new(); + let mut prev_role: Option = None; + + let title = if messages.is_empty() { + String::from(" Chat ") + } else { + format!(" Chat [{} msgs]", messages.len()) + }; + + for msg in messages { + 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. + if msg.role == Role::Tool { + let content = if msg.content.trim().is_empty() { + "(tool execution)".to_string() + } else { + msg.content.clone() + }; + display_lines.push(Line::from(vec![ + Span::raw(" ".repeat(PREFIX_WIDTH)), + Span::styled("↳ ", Style::default().fg(Theme::TEXT_DIM)), + Span::styled(content, Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC)), + ])); + 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() + } else { + "(tool execution)".to_string() + } + } else { + msg.content.clone() + }; + + 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(); + + 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)); + } + + 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 ────────────────────────────────────────────── + if state.turn_in_flight() { + let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + 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( + format!("{:<4} ", format_role_label(&Role::Assistant)), + Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD), + ), + Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)), + Span::styled("generating...", Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC)), + ])); + } + + // ── 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))); + + let total = display_lines.len(); + let max_offset = total.saturating_sub(max_visible); + let offset = scroll_offset.min(max_offset); + + let end_idx = total.saturating_sub(offset); + let start_idx = end_idx.saturating_sub(max_visible); + let visible: Vec = if start_idx < end_idx && start_idx < total { + display_lines[start_idx..end_idx].to_vec() + } else { + display_lines[total.saturating_sub(max_visible)..total].to_vec() + }; + + let scroll_pct = if total > max_visible { + ((offset as f64 / max_offset as f64) * 100.0) as u8 + } else { + 0 + }; + + let block = if scroll_pct > 0 { + let scroll_title = format!(" Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct); + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::BORDER)) + .title(Span::styled(scroll_title, Style::default().fg(Theme::TEXT_MUTED))) + } else { + block + }; + + let paragraph = Paragraph::new(visible) + .block(block) + .style(Style::default().bg(Theme::BG)) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} +``` + +- [ ] **Step 4: Remove `markdown.rs`'s own indentation so it doesn't fight `chat.rs`'s alignment** + +Replace all of `src/view/markdown.rs` with: + +```rust +//! Markdown-to-styled-spans rendering for the chat transcript. +//! +//! Flow: `render_markdown` walks a `pulldown_cmark` event stream and +//! translates each markdown construct into styled `ratatui::text::Span`s, +//! then re-wraps the flat span list to a target column width. +//! +//! 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; +use super::theme::Theme; + +/// Render a markdown string into styled terminal spans, word-wrapped to `width`. +/// +/// Flow: `pulldown_cmark` parses `text` into an event stream → each +/// Start/End/Text/Code/Break event is translated into styled `Span`s → +/// if `width > 0`, a second pass wraps long lines. +/// +/// Return: a flat vec of styled spans; `chat::split_spans_into_lines` +/// turns it back into `Line`s for the Paragraph widget. +#[allow(clippy::too_many_lines)] +pub fn render_markdown(text: &str, width: u16) -> Vec> { + let mut spans = Vec::new(); + let parser = pulldown_cmark::Parser::new(text); + let mut in_code_block = false; + let mut in_heading = false; + let mut heading_level = 0; + + for event in parser { + match event { + pulldown_cmark::Event::Start(tag) => { + match tag { + pulldown_cmark::Tag::CodeBlock(_) => { + in_code_block = true; + // Code block top bar + spans.push(Span::styled( + "\n", + Style::default(), + )); + spans.push(Span::styled( + " ┌─ code ", + Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), + )); + spans.push(Span::styled( + "\n", + Style::default(), + )); + } + pulldown_cmark::Tag::Heading { level, .. } => { + in_heading = true; + heading_level = match level { + pulldown_cmark::HeadingLevel::H1 => 1, + pulldown_cmark::HeadingLevel::H2 => 2, + pulldown_cmark::HeadingLevel::H3 => 3, + _ => 4, + }; + // No prefix, we'll handle in the text events + } + pulldown_cmark::Tag::Item => { + // List item bullet + spans.push(Span::styled( + "• ", + Style::default().fg(Theme::PRIMARY), + )); + } + pulldown_cmark::Tag::Link { dest_url, .. } => { + spans.push(Span::styled( + "[", + Style::default().fg(Theme::INFO), + )); + // We push the URL as a tooltip-like suffix + // After the link text ends, we'll add the URL + spans.push(Span::styled( + format!("]({dest_url})"), + Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), + )); + } + pulldown_cmark::Tag::BlockQuote(_) => { + spans.push(Span::styled( + "▎", + Style::default().fg(Theme::BLOCKQUOTE_BAR), + )); + } + _ => {} + } + } + pulldown_cmark::Event::End(tag) => { + match tag { + pulldown_cmark::TagEnd::CodeBlock => { + in_code_block = false; + // Code block bottom bar + spans.push(Span::styled( + "\n └─\n", + Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), + )); + } + pulldown_cmark::TagEnd::Heading(_) => { + in_heading = false; + heading_level = 0; + spans.push(Span::raw("\n")); + } + pulldown_cmark::TagEnd::Paragraph => { + spans.push(Span::raw("\n\n")); + } + pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => { + spans.push(Span::raw("\n")); + } + _ => {} + } + } + pulldown_cmark::Event::Text(text) => { + let s = text.to_string(); + if in_code_block { + spans.push(Span::styled( + format!(" {s}"), + Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), + )); + } else if in_heading { + let color = match heading_level { + 1 => Theme::PRIMARY, + 2 => Theme::INFO, + 3 => Theme::ACCENT_PURPLE, + _ => Theme::TEXT, + }; + spans.push(Span::styled( + s, + Style::default().fg(color).add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::raw(s)); + } + } + pulldown_cmark::Event::Code(text) => { + // Inline code with background + spans.push(Span::styled( + format!(" {text} "), + Style::default() + .fg(Theme::ACCENT_TEAL) + .bg(Theme::CODE_BAR) + .add_modifier(Modifier::BOLD), + )); + } + pulldown_cmark::Event::SoftBreak => { + spans.push(Span::raw(" ")); + } + pulldown_cmark::Event::HardBreak => { + spans.push(Span::raw("\n")); + } + _ => {} + } + } + + if width > 0 { + let mut spans_out = Vec::new(); + let mut line_len = 0; + let effective_width = (width as usize).saturating_sub(2); // leave margin + + for span in &spans { + let style = span.style; + let s = span.content.clone(); + let text_str = s.as_ref(); + let remaining = text_str.len(); + + if line_len + remaining > effective_width && line_len > 0 { + spans_out.push(Span::raw("\n")); + line_len = 0; + } + + spans_out.push(Span::styled(text_str.to_string(), style)); + + if text_str.contains('\n') { + line_len = text_str.split('\n').next_back().unwrap_or("").len(); + } else { + line_len += remaining; + } + } + spans = spans_out; + } + + spans +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test view::chat::tests` +Expected: PASS (4 tests) + +- [ ] **Step 6: Full build + lint gate** + +Run: `cargo build && cargo clippy -- -D warnings` +Expected: both succeed with zero warnings. + +- [ ] **Step 7: Commit** + +```bash +git add src/view/chat.rs src/view/markdown.rs +git commit -m "$(cat <<'EOF' +feat(tui): rombak rendering chat jadi format log rapat + +markdown.rs juga disesuaikan: indentasi paragraf/heading bawaannya +dilepas supaya tidak bentrok dengan indent PREFIX_WIDTH di chat.rs +(baris pertama vs baris wrap lanjutan jadi sejajar). + +Co-Authored-By: Claude Sonnet 5 +EOF +)" +``` + +--- + +### Task 3: `/todo` and `/usage` open commands + +**Files:** +- Modify: `src/controller/command.rs` (add 2 `Command` variants, 2 parse arms, new `#[cfg(test)] mod tests`) +- Modify: `src/app/runtime/commands.rs` (add 2 `apply_command` arms) +- Modify: `src/app/state/misc.rs:78-94` (`COMMANDS` const — add 2 entries) +- Modify: `src/resources.rs:31-40` (`HELP_TEXT` — add 2 lines) + +**Interfaces:** +- Consumes: `crate::app::runtime::actions::Action::OpenOverlay(Overlay)` (existing, generic — confirmed in `app/runtime/actions/mod.rs:158-164` that `OpenOverlay` needs no per-overlay-variant handling for non-list overlays); `crate::app::state::types::Overlay::{Todo, Usage}` (existing variants). +- Produces: `Command::TodoOpen`, `Command::UsageOpen` in `controller::command` — not consumed by any other task in this plan (the sidebar hint text in Task 4 is plain hard-coded text, not a type-level dependency), but must land before Task 4 so that hint text is truthful the moment it's written. + +- [ ] **Step 1: Write the failing tests** + +`src/controller/command.rs` currently has no test module. Add this to the bottom of the file: + +```rust +#[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); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test controller::command::tests` +Expected: FAIL to compile — `Command::TodoOpen` / `Command::UsageOpen` don't exist yet. + +- [ ] **Step 3: Add the two `Command` variants** + +In `src/controller/command.rs`, in the `pub enum Command` block, add two variants after `WorkflowRun`: + +```rust + WorkflowRun { + script: String, + }, + TodoOpen, + UsageOpen, + Unknown(String), +``` + +- [ ] **Step 4: Add the two parse arms** + +In the same file, in `parse_command`'s `match cmd` block, add these arms right after the `"/workflow"` arms (before the final `_ => Command::Unknown(cmd.to_string())`): + +```rust + "/todo" => Command::TodoOpen, + "/usage" => Command::UsageOpen, +``` + +- [ ] **Step 5: Wire the new commands to actions** + +`apply_command`'s `match command` in `commands.rs` has **no wildcard arm** — it's exhaustive over every `Command` variant. After Step 3 added `TodoOpen`/`UsageOpen` to the enum, the whole crate stops compiling until this match handles them too, so this step must land before the next one (running the tests requires the full crate to build, not just `command.rs`). + +In `src/app/runtime/commands.rs`, in `apply_command`'s `match command` block, add these arms right after the `Command::WorkflowRun` arm: + +```rust + Command::TodoOpen => { + vec![Action::OpenOverlay(Overlay::Todo)] + } + Command::UsageOpen => { + vec![Action::OpenOverlay(Overlay::Usage)] + } +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cargo test controller::command::tests` +Expected: PASS (2 tests) + +- [ ] **Step 7: Make the commands discoverable via autocomplete** + +In `src/app/state/misc.rs`, in the `COMMANDS` const, add two entries right after `"/workflow run"`: + +```rust +const COMMANDS: &[&str] = &[ + "/help", + "/quit", + "/clear", + "/lesson", + "/login", + "/login zen", + "/login openai", + "/edit", + "/mcp add", + "/model", + "/model ls", + "/model add", + "/workflow", + "/workflow run", + "/todo", + "/usage", + "/compact", +]; +``` + +- [ ] **Step 8: Document the commands in the in-app help screen** + +In `src/resources.rs`, in `HELP_TEXT`'s `Input:` section, add two lines right after `/mode workflow`: + +``` + /workflow Open workflow panel + /workflow run

Run a workflow with prompt

+ /mode workflow Open workflow panel + /todo Open task list + /usage Open usage details + /compact Compact conversation history +``` + +- [ ] **Step 9: Full build + lint gate** + +Run: `cargo build && cargo clippy -- -D warnings && cargo test` +Expected: all succeed with zero warnings, all tests pass. + +- [ ] **Step 10: Commit** + +```bash +git add src/controller/command.rs src/app/runtime/commands.rs src/app/state/misc.rs src/resources.rs +git commit -m "$(cat <<'EOF' +feat(tui): tambah command /todo dan /usage untuk buka overlay + +Overlay Todo dan Usage sebelumnya tidak punya trigger sama sekali di +jalur interaksi normal (cuma bisa lewat restore snapshot sesi) -- +sekarang mengikuti pola /workflow yang sudah ada. + +Co-Authored-By: Claude Sonnet 5 +EOF +)" +``` + +--- + +### Task 4: Build and wire the persistent sidebar + +**This task is intentionally large — see the Global Constraints note on why it isn't split into three.** In short: `dead_code = "deny"` fails plain `cargo build` for any unreachable `pub fn` in this binary-only crate, and the new sidebar's render functions are only reachable once they're wired into `draw()`. Splitting this into "add the widgets" / "add the module" / "wire it in" as separate tasks would leave a broken `cargo build` at each intermediate boundary. The step order below is deliberate: **all pure, independently-testable logic comes first (Steps 1–8, each with its own red/green cycle); all rendering glue and wiring comes after (Steps 9–12) with no test/build checkpoint in between, ending in one comprehensive gate (Step 13).** + +**Files:** +- Modify: `src/view/mod.rs` (two new shared helpers, module registration, `draw()` rewiring, deletion of `render_todo_panel`) +- Modify: `src/view/workflow.rs` (add `draw_workflow_widget`, extend the `use` import) +- Create: `src/view/sidebar.rs` + +**Interfaces:** +- Consumes: `Theme::*` from Task 1; `crate::app::workflow::engine::{AgentState, WorkflowAgent, AgentStatus}` (existing: `WorkflowAgent { id: String, name: String, status: AgentStatus }`); existing private `state_icon(AgentState) -> &'static str`, `state_label(AgentState) -> &'static str`, `state_color(AgentState) -> Color` in `workflow.rs` (unchanged, reused); `crate::app::state::runtime::UsageStats` (existing: `tokens_in: u64, tokens_out: u64, review_tokens: u64, api_calls: u64`, derives `Default, Copy`); `state.misc.todo_content: String`, `state.session_runtime: Option` (existing). +- Produces: + - `pub(crate) fn split_for_display(items: &[T], max_visible: usize) -> (&[T], usize)` in `view/mod.rs`. + - `pub(crate) fn overflow_hint_line(hidden: usize, command: &str) -> Line<'static>` in `view/mod.rs`. + - `pub fn draw_workflow_widget(frame: &mut Frame, area: Rect, state: &AppStateRest)` in `view/workflow.rs`. + - `pub(crate) struct UsageSummary { main_tokens, self_learning_tokens, total_tokens, api_calls: u64, elapsed_hours, elapsed_minutes, elapsed_seconds: i64 }` and `pub(crate) fn compute_usage_summary(usage: &UsageStats, session_start: i64, now_ms: i64) -> UsageSummary` in `view/sidebar.rs` — consumed by Task 5 (`mod.rs`'s `Overlay::Usage` arm) as a second call site. + - `pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &AppStateRest)` in `view/sidebar.rs`. + +#### Part A — pure logic, tested (Steps 1–8) + +- [ ] **Step 1: Write the failing tests for the shared `mod.rs` helpers** + +Add to the bottom of `src/view/mod.rs` (there is no test module in this file yet): + +```rust +#[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")); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test view::tests` +Expected: FAIL to compile — `split_for_display` and `overflow_hint_line` don't exist yet. + +- [ ] **Step 3: Implement the two helpers** + +Add this to `src/view/mod.rs` right after the `centered_rect` function (before the test module from Step 1): + +```rust +/// 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(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), + )) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test view::tests` +Expected: PASS (3 tests) + +- [ ] **Step 5: Register the new `sidebar` module and write the failing test for `compute_usage_summary`** + +In `src/view/mod.rs`, change: + +```rust +pub mod chat; +pub mod markdown; +pub mod status; +pub mod theme; +pub mod workflow; +``` + +to: + +```rust +pub mod chat; +pub mod markdown; +pub mod sidebar; +pub mod status; +pub mod theme; +pub mod workflow; +``` + +Create `src/view/sidebar.rs` with just this much: + +```rust +//! 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`. + +#[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); + } +} +``` + +- [ ] **Step 6: Run tests to verify they fail** + +Run: `cargo test view::sidebar::tests` +Expected: FAIL to compile — `compute_usage_summary` doesn't exist yet. (`view::tests` from Steps 1–4 stays green; nothing there was touched.) + +- [ ] **Step 7: Implement `compute_usage_summary` and `UsageSummary`** + +Add this to `src/view/sidebar.rs`, above the test module from Step 5: + +```rust +/// 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, + } +} +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `cargo test view::sidebar::tests` +Expected: PASS (2 tests) + +#### Part B — rendering glue and wiring, no checkpoint until the end (Steps 9–12) + +All the pure logic above is now written and green. The steps below add `pub fn`s that call it but aren't themselves reachable from `main()` until Step 11 — **do not run `cargo build`, `cargo clippy`, or `cargo test` between Steps 9 and 12.** The first check after this point is Step 13. + +- [ ] **Step 9: Add the compact Workflow widget** + +In `src/view/workflow.rs`, change the top `use` line: + +```rust +use crate::app::workflow::engine::AgentState; +``` + +to: + +```rust +use crate::app::workflow::engine::{AgentState, WorkflowAgent}; +``` + +Then add this function at the end of the file (after `build_session_lines`, before the trailing `use ratatui::style::Color;` line — leave that trailing import where it is): + +```rust +/// 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 = 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 = 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)), + ]) +} +``` + +- [ ] **Step 10: Add the Tasks widget, Usage widget, and sidebar assembly** + +Add this to `src/view/sidebar.rs`, above the test module (and above the `UsageSummary`/`compute_usage_summary` code from Step 7 — order within the file doesn't matter, but keep it above the `#[cfg(test)]` block): + +```rust +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 = 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 = 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 + 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 counters on +/// top of this same summary rather than showing more of a truncated list. +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 = 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", summary.total_tokens), + Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), + )), + 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); +} +``` + +- [ ] **Step 11: Wire the sidebar into `draw()`** + +In `src/view/mod.rs`, replace the whole `pub fn draw(...)` function (from `pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {` through its closing `}`) with: + +```rust +/// 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 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. + const SIDEBAR_MIN_WIDTH: u16 = 90; + 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([ + Constraint::Min(40), + Constraint::Length(30), + ]) + .split(area); + (h_chunks[0], Some(h_chunks[1])) + } else { + (area, None) + }; + + // ── Vertical layout: chat / input / status ─────────────────────────── + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(main_area); + + let chat_area = chunks[0]; + let input_area = chunks[1]; + let status_area = chunks[2]; + + // ── Render main area (overlay or chat) ─────────────────────────────── + if state.misc.overlay.is_active() { + let overlay = state.misc.overlay; + render_overlay(frame, chat_area, overlay, state); + } else { + render_main_panel(frame, chat_area, state); + } + + // ── Input bar ──────────────────────────────────────────────────────── + render_input_bar(frame, input_area, state); + + // ── Status bar ─────────────────────────────────────────────────────── + status::draw_status_bar(frame, status_area, state); + + // ── Dashboard sidebar ──────────────────────────────────────────────── + if let Some(sidebar_rect) = sidebar_area { + sidebar::draw_sidebar(frame, sidebar_rect, state); + } + + // ── Toasts (top-right floating) ────────────────────────────────────── + render_toasts(frame, state); +} +``` + +Note what changed from the old version: `Overlay::Todo` is no longer special-cased out of `render_overlay` (it used to be skipped so the side panel could take over) — it now behaves like every other overlay, because the sidebar's Tasks widget (always visible above the width threshold, regardless of overlay state) already covers the "glance" case, and `Overlay::Todo` is fixed in Task 5 to be the real "expand" view. + +- [ ] **Step 12: Delete the now-superseded `render_todo_panel` function** + +In the same file, delete this whole function (it's fully replaced by `sidebar::draw_tasks_widget` for the compact view and the Task-5-fixed `Overlay::Todo` arm for the expand view): + +```rust +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); +} +``` + +#### Part C — the checkpoint + +- [ ] **Step 13: Full build + lint gate** + +Run: `cargo build && cargo clippy -- -D warnings && cargo test` +Expected: all succeed with zero warnings, all tests pass (including the 5 from Steps 1–8). Every function added in Part B is now reachable from `main()` via `draw() → sidebar::draw_sidebar() → {draw_workflow_widget, draw_tasks_widget, draw_usage_widget} → {split_for_display, overflow_hint_line, compute_usage_summary}`, so there should be no `dead_code` errors. + +- [ ] **Step 14: Manual smoke check** + +Run: `cargo run` +Expected: app launches; chat renders in a bordered panel; if your terminal is wider than 90 columns, a right-hand sidebar shows "Workflow" / "Tasks" / "Usage" panels with placeholder text ("No workflow running.", "No tasks yet.", token/session line). Resize the terminal narrower than 90 columns and confirm the sidebar disappears and chat reclaims the full width. Quit with the existing quit flow (Ctrl+C then confirm). + +- [ ] **Step 15: Commit** + +```bash +git add src/view/mod.rs src/view/workflow.rs src/view/sidebar.rs +git commit -m "$(cat <<'EOF' +feat(tui): tambah dan pasang sidebar dashboard permanen + +Sidebar kanan permanen (Workflow/Tasks/Usage) menggantikan panel todo +ad-hoc yang lama. Widget baca state yang sudah ada, tidak ada perubahan +skema AppStateRest. + +Co-Authored-By: Claude Sonnet 5 +EOF +)" +``` + +--- + +### Task 5: Fix Todo/Usage overlay bodies into real "expand" views + +**Files:** +- Modify: `src/view/mod.rs` (the `Overlay::Todo` and `Overlay::Usage` arms inside `render_overlay`) + +**Interfaces:** +- Consumes: `sidebar::compute_usage_summary` (Task 4). +- Produces: no new interface. + +No new pure logic here (this reuses Task 4's already-tested `compute_usage_summary`) — verify with build + clippy + the manual pass in Task 7. This task is safe as a standalone build checkpoint: it only edits existing, already-reachable match-arm bodies inside `render_overlay` (no new `pub` items), and Task 4's Step 13 already confirmed the crate builds clean before this task starts. + +- [ ] **Step 1: Fix the `Overlay::Todo` arm** + +In `src/view/mod.rs`'s `render_overlay` function, find the `crate::app::state::types::Overlay::Todo => { ... }` arm. It currently shows an unrelated message-count dump: + +```rust + // ── Todo ────────────────────────────────────────────────────── + crate::app::state::types::Overlay::Todo => { + let block = block + .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); + frame.render_widget(paragraph, overlay_area); + } +``` + +Replace the whole arm with (this is what the now-deleted `render_todo_panel` used to do, adapted to render into `overlay_area`, with the emoji already dropped per the Task-6 rule — no need to touch this title again in Task 6): + +```rust + // ── Todo ────────────────────────────────────────────────────── + crate::app::state::types::Overlay::Todo => { + let block = block + .title(Span::styled(" Tasks ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD))) + .border_style(Style::default().fg(Theme::ACCENT_PURPLE)); + 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); + } +``` + +- [ ] **Step 2: Route the `Overlay::Usage` arm through `compute_usage_summary`** + +Find the `crate::app::state::types::Overlay::Usage => { ... }` arm (it currently computes token/elapsed math inline). Replace the whole arm with: + +```rust + // ── Usage ──────────────────────────────────────────────────── + crate::app::state::types::Overlay::Usage => { + let block = block + .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 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) + }); + let mut lines = vec![ + Line::from(Span::styled( + " Token Usage", + Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), + )), + Line::from(Span::raw("")), + ]; + 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); + } +``` + +- [ ] **Step 3: Build + lint gate** + +Run: `cargo build && cargo clippy -- -D warnings` +Expected: both succeed with zero warnings. + +- [ ] **Step 4: Manual smoke check** + +Run: `cargo run`, send at least one message so `session_runtime` has real token counts, then trigger `/todo` and `/usage` (see Task 3). Confirm: `/todo` shows the actual task list content (not a message count), `/usage` shows the same numbers the sidebar's Usage widget shows plus the extra Edits/Reviews/Lessons/Session-clock detail. + +- [ ] **Step 5: Commit** + +```bash +git add src/view/mod.rs +git commit -m "$(cat <<'EOF' +fix(tui): perbaiki isi overlay Todo dan Usage jadi tampilan detail nyata + +Overlay Todo sebelumnya menampilkan jumlah pesan yang tidak relevan, +bukan isi task list. Overlay Usage sekarang pakai compute_usage_summary +yang sama dengan widget sidebar (DRY, angka konsisten). + +Co-Authored-By: Claude Sonnet 5 +EOF +)" +``` + +--- + +### Task 6: Drop decorative emoji from overlay titles + +**Files:** +- Modify: `src/view/mod.rs` (14 of the 16 overlay-title strings inside `render_overlay` — `Todo` and `Usage` were already done emoji-free in Task 5) +- Modify: `src/view/workflow.rs` (the `draw_workflow_panel` title — the 15th; `draw_workflow_widget` from Task 4 was already emoji-free) + +Purely cosmetic string edits, no logic change — no unit test applies. Verify with build + clippy + the manual pass in Task 7. Safe as a standalone build checkpoint: no new `pub` items, only string-literal edits inside already-reachable code. + +Scope note: this only touches the 16 `Overlay`-variant titles. It does **not** touch `status.rs`'s `" ⚡zesdex "` app branding (not an overlay title — out of the approved spec's icon rule) or the autocomplete dropdown's "⌘ Commands" title (also not an `Overlay` variant). + +- [ ] **Step 1: Edit each title in `src/view/mod.rs`'s `render_overlay`** + +Apply these 13 exact string replacements (each is a distinct `.title(Span::styled(...))` call inside its own match arm — search for the emoji to locate each one unambiguously). `Learning`'s right pane (`" Details "`) has no emoji already and needs no edit — it's the 14th of the "14 of 16" non-Todo/Usage arms referenced in this task's Files note. + +| Overlay arm | Before | After | +|---|---|---| +| Help | `" ❓ Help "` | `" Help "` | +| Settings | `" ⚙ Settings "` | `" Settings "` | +| Bash | `" 💻 Bash Jobs "` | `" Bash Jobs "` | +| QuitConfirm | `" 🚪 Quit "` | `" Quit "` | +| KeyInput | `" 🔑 API Key "` | `" API Key "` | +| Editor | `" ✏️ Editor "` | `" Editor "` | +| Effort | `" 🎯 Effort Level "` | `" Effort Level "` | +| Mcp | `" 🔌 MCP Servers "` | `" MCP Servers "` | +| Rewind | `" ⏪ Rewind "` | `" Rewind "` | +| Learning (left pane) | `" 📚 Lessons "` | `" Lessons "` | +| Loading | `" ⏳ Loading "` | `" Loading "` | +| ModelSelector | `" 🧠 Model Selector "` | `" Model Selector "` | +| ClearConfirm | `" 🗑️ Clear Transcript "` | `" Clear Transcript "` | + +- [ ] **Step 2: Edit the Workflow overlay's title in `src/view/workflow.rs`** + +In `draw_workflow_panel`, change: + +```rust + let title = Span::styled(" ⚙ Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)); +``` + +to: + +```rust + let title = Span::styled(" Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)); +``` + +- [ ] **Step 3: Build + lint gate** + +Run: `cargo build && cargo clippy -- -D warnings` +Expected: both succeed with zero warnings. + +- [ ] **Step 4: Manual smoke check** + +Run: `cargo run` and open each overlay (Ctrl+C for QuitConfirm, `/help`, `/mcp`, `/model`, `/workflow`, `/todo`, `/usage`, `/clear` for ClearConfirm, `/lesson`, `/edit `) — confirm no title has a leading emoji glyph, and the status bar's `⚡zesdex` branding is unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add src/view/mod.rs src/view/workflow.rs +git commit -m "$(cat <<'EOF' +style(tui): hapus emoji dekoratif dari judul overlay + +Co-Authored-By: Claude Sonnet 5 +EOF +)" +``` + +--- + +### Task 7: Manual verification pass + +No code changes. This is the closing checklist called for by the spec's Testing section (no automated visual/snapshot coverage exists for `view/`/`controller/`, so the golden paths are verified by hand). + +- [ ] **Step 1: Full workspace gate** + +Run: `cargo build --release && cargo test && cargo clippy -- -D warnings` +Expected: all three succeed — this matches `.github/workflows/ci.yml` exactly. + +- [ ] **Step 2: Launch and exercise the golden paths** + +Run: `cargo run`, then work through each of these and confirm the described behavior: + +1. **Send a chat message.** Type text (no leading `/`) and press Enter. Confirm the transcript shows `you HH:MM ` with no card border/badge, and the assistant's reply appears as `ai HH:MM ` with a single blank line between your turn and its turn (not zero, not more than one). +2. **Tool activity renders as a sub-line.** If the assistant's turn includes a tool call, confirm it renders as an indented `↳ ...` dim line directly under the assistant's turn, with no blank line separating it and no `tool HH:MM` header of its own. +3. **Trigger a workflow.** Run `/workflow run `. Confirm the sidebar's Workflow widget (if terminal width > 90 cols) updates live with agent state icons as it runs, and `/workflow` (no args) still opens the full detail overlay. +4. **Open every overlay** and confirm each renders with the new palette and no title emoji: `/help`, `/mcp`, `/model`, `/todo`, `/usage`, `/lesson`, `/clear` (ClearConfirm), `/edit `, Ctrl+C (QuitConfirm). For `/todo`, confirm it shows real task content (or "No tasks yet."), not a message count. +5. **Resize across the sidebar threshold.** Shrink the terminal to under 90 columns — confirm the sidebar disappears and chat takes the full width with no layout glitch; widen back past 90 — confirm the sidebar reappears with Workflow/Tasks/Usage in that order. +6. **Scroll a long transcript.** Send enough messages to overflow the visible area, scroll up (per existing scroll keys) and confirm the `── N% ↑` indicator appears in the chat panel's title and wrapped lines stay aligned under the content column (not sliding under the role label). + +- [ ] **Step 3: Report** + +No commit for this task (no code changes). If any check in Step 2 fails, stop and fix it as a follow-up task before considering this plan complete — do not silently note it and move on.