Files
zesdex/src/view/sidebar.rs
T

171 lines
6.7 KiB
Rust
Raw Normal View History

//! 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);
}
}