refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture
Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
//! 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 super::theme::Theme;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// 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 has_workflow = !state.workflow_engine.agents.is_empty();
|
||||
|
||||
let constraints = if has_workflow {
|
||||
vec![
|
||||
Constraint::Ratio(1, 2),
|
||||
Constraint::Ratio(1, 4),
|
||||
Constraint::Ratio(1, 4),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
]
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(area);
|
||||
|
||||
super::workflow::draw_workflow_panel(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: total tokens, 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.
|
||||
///
|
||||
/// Each number gets its own short line rather than being crammed onto
|
||||
/// one — the sidebar column is only ~28 usable characters wide after
|
||||
/// borders, and a single `"{total} tok ({main} main / {learn} learn)"`
|
||||
/// line silently clips (no `.wrap()` on this `Paragraph`) once token
|
||||
/// counts reach 5-6 digits, which is routine for an agent session.
|
||||
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!(" {:>6}: {} tok", "total", summary.total_tokens),
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" {:>6}: {} tok", "main", summary.main_tokens),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" {:>6}: {} tok", "learn", summary.self_learning_tokens),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" {:>6}: {}", "calls", summary.api_calls),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
" {:>6}: {}h {:02}m {:02}s",
|
||||
"time", 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user