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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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: 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<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 ({} 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);
|
||||
}
|
||||
}
|
||||
+46
-1
@@ -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<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