feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
//! Overlay: list of active / completed bash background jobs.
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Bash Jobs overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = super::overlay_block(block, "Bash Jobs", Theme::ACCENT_ORANGE);
|
||||
let lines: Vec<Line> = state
|
||||
.session_runtime
|
||||
.as_ref()
|
||||
.map(|r| {
|
||||
r.bash_jobs
|
||||
.iter()
|
||||
.map(|job| {
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
" [{}] {} — {}",
|
||||
job.id,
|
||||
job.command,
|
||||
if job.running { "running" } else { "done" },
|
||||
),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let paragraph = if lines.is_empty() {
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
" No active bash jobs.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)))
|
||||
.block(block)
|
||||
} else {
|
||||
Paragraph::new(lines).block(block)
|
||||
};
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Overlay: confirm-before-clear dialog for the chat transcript.
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Clear Transcript confirmation dialog.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
_state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Clear Transcript ",
|
||||
Style::default()
|
||||
.fg(Theme::WARNING)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::WARNING));
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Clear all messages from the transcript?",
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
" Enter to confirm · Esc to cancel",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
];
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Overlay: inline editor mode — shows the current input buffer with cursor
|
||||
//! position and save/dismiss key hints.
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Editor overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Editor ",
|
||||
Style::default()
|
||||
.fg(Theme::PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::PRIMARY));
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Editor Mode — Ctrl+S save, Esc dismiss",
|
||||
Style::default()
|
||||
.fg(Theme::TEXT_MUTED)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
" Buffer:",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" {}", state.input.buffer),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
" Cursor: pos {} / {}",
|
||||
state.input.cursor,
|
||||
state.input.buffer.len()
|
||||
),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
];
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Overlay: effort-level selector — lets the user pick a reasoning/quality tier.
|
||||
use crate::state::{current_effort, EFFORT_LEVELS};
|
||||
use crate::view::theme::Theme;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Render the Effort Level overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Effort Level ",
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_PURPLE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
|
||||
let levels = EFFORT_LEVELS;
|
||||
let current_idx = current_effort(state);
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(
|
||||
" Use ↑↓ to change effort level",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
];
|
||||
for (i, l) in levels.iter().enumerate() {
|
||||
let selected = i == current_idx;
|
||||
lines.push(Line::from(Span::styled(
|
||||
if selected {
|
||||
format!(" ▸ {l} (active)")
|
||||
} else {
|
||||
format!(" {l}")
|
||||
},
|
||||
if selected {
|
||||
Style::default()
|
||||
.fg(Theme::HIGHLIGHT)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Theme::TEXT)
|
||||
},
|
||||
)));
|
||||
}
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Overlay: keyboard shortcut reference.
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::{Block, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Help overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = super::overlay_block(block, "Help", Theme::INFO);
|
||||
let content = state.help_text;
|
||||
let paragraph = Paragraph::new(content)
|
||||
.block(block)
|
||||
.style(Style::default().bg(Theme::BG))
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Overlay: API key input dialog — prompts the user for a provider API key
|
||||
//! with masked display (shows first 4 chars only).
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the API Key input overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" API Key ",
|
||||
Style::default()
|
||||
.fg(Theme::WARNING)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::WARNING));
|
||||
let input_text = &state.input.buffer;
|
||||
let display = if input_text.is_empty() {
|
||||
" Type your API key..."
|
||||
} else {
|
||||
if input_text.len() > 8 {
|
||||
&input_text[..4]
|
||||
} else {
|
||||
input_text.as_str()
|
||||
}
|
||||
};
|
||||
let masked = if input_text.is_empty() {
|
||||
display.to_string()
|
||||
} else {
|
||||
let suffix = if input_text.len() > 8 { "****" } else { "" };
|
||||
format!("{display}{suffix}")
|
||||
};
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Enter API key for authentication:",
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(vec![
|
||||
Span::styled(" Key: ", Style::default().fg(Theme::TEXT_DIM)),
|
||||
Span::styled(
|
||||
masked,
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
];
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Overlay: Learning / lesson management — two-panel view with a scrollable
|
||||
//! lesson list (left) and detail pane (right).
|
||||
use crate::state::{get_learning_items, LearningItem};
|
||||
use crate::view::theme::Theme;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Render the Learning overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
drop(block);
|
||||
|
||||
let h_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
|
||||
.split(area);
|
||||
|
||||
let left_block = Block::default()
|
||||
.title(Span::styled(
|
||||
" Lessons ",
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_PURPLE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.style(Style::default().bg(Theme::BG));
|
||||
|
||||
let right_block = Block::default()
|
||||
.title(Span::styled(
|
||||
" Details ",
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.style(Style::default().bg(Theme::BG));
|
||||
|
||||
let items = get_learning_items(state);
|
||||
let mut left_lines = Vec::new();
|
||||
if items.is_empty() {
|
||||
left_lines.push(Line::from(Span::styled(
|
||||
" No lessons found.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
} else {
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let is_selected = i == state.misc.selected_index;
|
||||
let prefix = if is_selected { " ▸ " } else { " " };
|
||||
let (label, style) = match item {
|
||||
LearningItem::Pending { name, .. } => (
|
||||
format!("{prefix}[Pending] {name}"),
|
||||
if is_selected {
|
||||
Style::default()
|
||||
.fg(Theme::WARNING)
|
||||
.bg(Theme::HIGHLIGHT_DIM)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Theme::WARNING)
|
||||
},
|
||||
),
|
||||
LearningItem::Stored { name, lifecycle, .. } => {
|
||||
let status = if lifecycle == "stale" { "Stale" } else { "Active" };
|
||||
(
|
||||
format!("{prefix}[{status}] {name}"),
|
||||
if is_selected {
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.bg(Theme::HIGHLIGHT_DIM)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Theme::TEXT)
|
||||
},
|
||||
)
|
||||
}
|
||||
};
|
||||
left_lines.push(Line::from(Span::styled(label, style)));
|
||||
}
|
||||
}
|
||||
|
||||
let max_lines = h_chunks[0].height.saturating_sub(2) as usize;
|
||||
let selected = state.misc.selected_index;
|
||||
let start_idx = if selected >= max_lines {
|
||||
selected - max_lines + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end_idx = (start_idx + max_lines).min(left_lines.len());
|
||||
let visible_lines = if left_lines.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
left_lines[start_idx..end_idx].to_vec()
|
||||
};
|
||||
|
||||
let left_paragraph = Paragraph::new(visible_lines).block(left_block);
|
||||
frame.render_widget(left_paragraph, h_chunks[0]);
|
||||
|
||||
let mut right_lines = Vec::new();
|
||||
if let Some(item) = items.get(selected) {
|
||||
match item {
|
||||
LearningItem::Pending { name, content, scope, confidence } => {
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Name:",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" {name}"),
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Status: Pending Approval",
|
||||
Style::default().fg(Theme::WARNING),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Scope: {scope}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Confidence: {confidence}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Content:",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
for line in content.lines() {
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" {line}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
}
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" [Enter]/[a] Accept · [r]/[Del] Reject",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
LearningItem::Stored { name, content, lifecycle, scope, description } => {
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Name:",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" {name}"),
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
let status_color = if lifecycle == "stale" { Theme::WARNING } else { Theme::SUCCESS };
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Status: {lifecycle}"),
|
||||
Style::default().fg(status_color),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Scope: {scope}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Description: {description}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Content:",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
for line in content.lines() {
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" {line}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
}
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" [d]/[Del] Delete Lesson",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
" Select a lesson on the left.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
let right_paragraph = Paragraph::new(right_lines)
|
||||
.block(right_block)
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(right_paragraph, h_chunks[1]);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Overlay: loading / processing spinner — shown during blocking operations.
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Loading overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Loading ",
|
||||
Style::default()
|
||||
.fg(Theme::WARNING)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::WARNING));
|
||||
let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let frame_idx = (state.misc.tick_count as usize) % spinner.len();
|
||||
let content = format!(" {} Processing, please wait...", spinner[frame_idx]);
|
||||
let paragraph = Paragraph::new(content).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Overlay: MCP (Model Context Protocol) server management.
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the MCP Servers overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" MCP Servers ",
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::INFO));
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" MCP Server Management",
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
format!(" Session dir: {}", state.session_dir.display()),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
" No MCP servers configured.",
|
||||
Style::default().fg(Theme::TEXT_MUTED),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
" Press Ctrl+P to configure provider settings.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
];
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Overlay rendering: each overlay variant gets its own module with a
|
||||
//! `pub fn render(frame, area, block, state)` entry point, dispatched by
|
||||
//! the top-level `render_overlay` function in this module.
|
||||
|
||||
pub mod bash;
|
||||
pub mod clear_confirm;
|
||||
pub mod editor;
|
||||
pub mod effort;
|
||||
pub mod help;
|
||||
pub mod key_input;
|
||||
pub mod learning;
|
||||
pub mod loading;
|
||||
pub mod mcp;
|
||||
pub mod model_selector;
|
||||
pub mod quit_confirm;
|
||||
pub mod rewind;
|
||||
pub mod settings;
|
||||
pub mod todo;
|
||||
pub mod usage;
|
||||
|
||||
use crate::state::{AppStateRest, Overlay};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Borders, Clear};
|
||||
use ratatui::Frame;
|
||||
use super::theme::Theme;
|
||||
|
||||
/// Decorate an overlay block with a styled title and matching border color.
|
||||
pub fn overlay_block(block: Block<'static>, title: &str, color: ratatui::style::Color) -> Block<'static> {
|
||||
block
|
||||
.title(Span::styled(
|
||||
format!(" {title} "),
|
||||
Style::default()
|
||||
.fg(color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(color))
|
||||
}
|
||||
|
||||
/// Compute a centered rectangle within `area` at the given percentage width and height.
|
||||
pub fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
|
||||
let x_pad = (area.width.saturating_sub(area.width * percent_x / 100)) / 2;
|
||||
let y_pad = (area.height.saturating_sub(area.height * percent_y / 100)) / 2;
|
||||
|
||||
Rect {
|
||||
x: area.x.saturating_add(x_pad),
|
||||
y: area.y.saturating_add(y_pad),
|
||||
width: area.width.saturating_sub(x_pad * 2).max(40),
|
||||
height: area.height.saturating_sub(y_pad * 2).max(10),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the active modal overlay as a centered panel.
|
||||
pub fn render_overlay(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
overlay: Overlay,
|
||||
state: &AppStateRest,
|
||||
) {
|
||||
let overlay_area = centered_rect(area, 75, 70);
|
||||
frame.render_widget(Clear, overlay_area);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Theme::BORDER))
|
||||
.style(Style::default().bg(Theme::BG));
|
||||
|
||||
match overlay {
|
||||
Overlay::None => {}
|
||||
Overlay::Help => {
|
||||
help::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Settings => {
|
||||
settings::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Bash => {
|
||||
bash::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::QuitConfirm => {
|
||||
quit_confirm::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::KeyInput => {
|
||||
key_input::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Editor => {
|
||||
editor::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Effort => {
|
||||
effort::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Mcp => {
|
||||
mcp::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Todo => {
|
||||
todo::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Rewind => {
|
||||
rewind::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Learning => {
|
||||
learning::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Usage => {
|
||||
usage::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::Loading => {
|
||||
loading::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::ModelSelector => {
|
||||
model_selector::render(frame, overlay_area, block, state);
|
||||
}
|
||||
Overlay::ClearConfirm => {
|
||||
clear_confirm::render(frame, overlay_area, block, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Overlay: model/provider selector — lists available providers from config
|
||||
//! and lets the user pick one with ↑/↓/Enter.
|
||||
use crate::view::theme::Theme;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Render the Model Selector overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Model Selector ",
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_PURPLE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
" Current: {} / {}",
|
||||
state.settings.provider, state.settings.model
|
||||
),
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
" Providers:",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
];
|
||||
let providers: Vec<(&String, &zesdex_domain::cms::ProviderConfig)> =
|
||||
state.app_config.providers.iter().collect();
|
||||
for (i, (name, cfg)) in providers.iter().enumerate() {
|
||||
let is_current = *name == &state.settings.provider;
|
||||
let is_selected = i == state.misc.selected_index;
|
||||
let prefix = if is_selected { " ▸ " } else { " " };
|
||||
let model_str = cfg.default_model.as_deref().unwrap_or("(any)");
|
||||
let label = format!("{prefix}{name} ({model_str})");
|
||||
let style = if is_current {
|
||||
Style::default()
|
||||
.fg(Theme::HIGHLIGHT)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_selected {
|
||||
Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT)
|
||||
} else {
|
||||
Style::default().fg(Theme::TEXT)
|
||||
};
|
||||
lines.push(Line::from(Span::styled(label, style)));
|
||||
}
|
||||
lines.push(Line::from(Span::raw("")));
|
||||
lines.push(Line::from(Span::styled(
|
||||
" ↑↓ navigate · Enter select · Esc close",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Overlay: quit confirmation dialog.
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Quit confirmation overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
_state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Quit ",
|
||||
Style::default()
|
||||
.fg(Theme::ERROR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::ERROR));
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Are you sure you want to quit?",
|
||||
Style::default()
|
||||
.fg(Theme::ERROR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
" Press Enter to confirm, Esc to cancel.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
];
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Overlay: Rewind / session history — shows recent messages and lets the user
|
||||
//! pick a point to rewind the transcript back to.
|
||||
use crate::view::theme::Theme;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use zesdex_domain::core::Role;
|
||||
|
||||
/// Render the Rewind overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Rewind ",
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_ORANGE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::ACCENT_ORANGE));
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(
|
||||
" Use ↑↓ to navigate, Enter to rewind to that point",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
];
|
||||
let messages = &state.transcript_cache.messages;
|
||||
if messages.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" No messages in current session.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
} else {
|
||||
let start = if messages.len() > 8 { messages.len() - 8 } else { 0 };
|
||||
for msg in &messages[start..] {
|
||||
let role_str = match msg.role {
|
||||
Role::User => "User",
|
||||
Role::Assistant => "Asst",
|
||||
Role::System => "Sys",
|
||||
Role::Tool => "Tool",
|
||||
};
|
||||
let preview: String = msg.content.chars().take(70).collect();
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" [{role_str}] {preview}"),
|
||||
Style::default().fg(if msg.role == Role::User { Theme::INFO } else { Theme::TEXT }),
|
||||
)));
|
||||
}
|
||||
if messages.len() > 8 {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" ... and {} more messages", messages.len() - 8),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
}
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Overlay: settings overview — displays the current provider, model,
|
||||
//! max tokens, temperature, internet mode, and review toggle.
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Settings overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = super::overlay_block(block, "Settings", Theme::PRIMARY);
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
format!(" Provider: {}", state.settings.provider),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Model: {}", state.settings.model),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
" Max tokens: {}",
|
||||
state.settings.max_tokens.map_or_else(|| "auto".to_string(), |v| v.to_string())
|
||||
),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
" Temperature: {}",
|
||||
state.settings.temperature.map_or_else(|| "auto".to_string(), |v| format!("{v:.1}"))
|
||||
),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Internet: {:?}", state.settings.internet_mode),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Review: {}", state.settings.flags.review_enabled),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
];
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Overlay: Tasks (todo) view — shows the full todo list content.
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
use crate::view::theme::Theme;
|
||||
|
||||
/// Render the Tasks / Todo overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Tasks ",
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_PURPLE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Overlay: usage statistics — detailed token usage, API call count, edit/
|
||||
//! review/lesson activity counters, and session elapsed time.
|
||||
use crate::view::sidebar::compute_usage_summary;
|
||||
use crate::view::theme::Theme;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Render the Usage overlay.
|
||||
pub fn render(
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
block: Block<'static>,
|
||||
state: &crate::state::AppStateRest,
|
||||
) {
|
||||
let block = block
|
||||
.title(Span::styled(
|
||||
" Usage ",
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.border_style(Style::default().fg(Theme::INFO));
|
||||
let runtime = state.session_runtime.as_ref();
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let summary = runtime.map(|r| compute_usage_summary(&r.usage, r.session_start, now_ms));
|
||||
let (edit_count, lesson_count, review_count, consec_empty) =
|
||||
runtime.map_or((0, 0, 0, 0), |r| {
|
||||
(r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews)
|
||||
});
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Token Usage",
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
];
|
||||
if let Some(s) = &summary {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Main agent: {} tokens", s.main_tokens),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Self-learning: {} tokens", s.self_learning_tokens),
|
||||
Style::default().fg(Theme::TEXT_MUTED),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Total: {} tokens", s.total_tokens),
|
||||
Style::default()
|
||||
.fg(Theme::TEXT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" API calls: {}", s.api_calls),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
} else {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" No active session.",
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
lines.push(Line::from(Span::raw("")));
|
||||
lines.push(Line::from(Span::styled(
|
||||
" Activity",
|
||||
Style::default()
|
||||
.fg(Theme::INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Edits: {edit_count}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Reviews: {review_count}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Lessons: {lesson_count}"),
|
||||
Style::default().fg(Theme::TEXT_MUTED),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Empty reviews: {consec_empty}"),
|
||||
Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::TEXT_DIM }),
|
||||
)));
|
||||
if let Some(s) = &summary {
|
||||
lines.push(Line::from(Span::raw("")));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Session: {}h {}m {}s", s.elapsed_hours, s.elapsed_minutes, s.elapsed_seconds),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
Reference in New Issue
Block a user