From 31c01cdf1df6827c3a949820378c8b85ebcfdf87 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 14 Jul 2026 22:51:56 +0700 Subject: [PATCH] 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 --- src/view/mod.rs | 105 +++++++++++++++++--------- src/view/sidebar.rs | 170 +++++++++++++++++++++++++++++++++++++++++++ src/view/workflow.rs | 47 +++++++++++- 3 files changed, 287 insertions(+), 35 deletions(-) create mode 100644 src/view/sidebar.rs diff --git a/src/view/mod.rs b/src/view/mod.rs index 5851d20..deaaa23 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -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, @@ -973,3 +955,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(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")); + } +} diff --git a/src/view/sidebar.rs b/src/view/sidebar.rs new file mode 100644 index 0000000..1a50267 --- /dev/null +++ b/src/view/sidebar.rs @@ -0,0 +1,170 @@ +//! 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 = 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: 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. +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 ({} main / {} learn)", summary.total_tokens, summary.main_tokens, summary.self_learning_tokens), + Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), + )), + 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); + } +} diff --git a/src/view/workflow.rs b/src/view/workflow.rs index edca521..21437a7 100644 --- a/src/view/workflow.rs +++ b/src/view/workflow.rs @@ -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 { @@ -208,4 +208,49 @@ fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> 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)), + ]) +} + use ratatui::style::Color;