feat(tui): tambah dan pasang sidebar dashboard permanen

Sidebar kanan permanen (Workflow/Tasks/Usage) menggantikan panel todo
ad-hoc yang lama. Widget baca state yang sudah ada, tidak ada perubahan
skema AppStateRest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
asepharyana
2026-07-14 23:41:53 +07:00
co-authored by Claude Sonnet 5
parent aa2b6acb95
commit 31c01cdf1d
3 changed files with 287 additions and 35 deletions
+71 -34
View File
@@ -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<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"));
}
}