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:
co-authored by
Claude Sonnet 5
parent
aa2b6acb95
commit
31c01cdf1d
+71
-34
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user