mod data;
pub(super) use data::{read_memory_facts, read_project_counts, MemoryFact, ProjectCounts};
use super::render::{AMBER, JADE, SLATE, VIOLET, WATER};
use super::{App, TurnState};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
use ratatui::Frame;
pub(super) fn render_session_box(frame: &mut Frame, area: Rect, app: &App) {
let locality = if app.provider.requires_key() {
"REMOTE"
} else {
"LOCAL"
};
let lines = vec![
Line::styled(
"● Local chat",
Style::default().fg(JADE).add_modifier(Modifier::BOLD),
),
Line::raw(app.provider.name().to_string()),
Line::styled(app.model.clone(), Style::default().fg(WATER)),
Line::raw(""),
Line::styled(
format!(
"{locality} · {}",
&app.session_id[..8.min(app.session_id.len())]
),
Style::default().fg(SLATE),
),
];
frame.render_widget(
Paragraph::new(lines).block(
Block::bordered()
.title(" Session ")
.border_style(Style::default().fg(JADE)),
),
area,
);
}
pub(super) fn render_sidebar(frame: &mut Frame, area: Rect, app: &App) {
let [session_area, panel_area] =
Layout::vertical([Constraint::Length(6), Constraint::Min(4)]).areas(area);
render_session_box(frame, session_area, app);
match app.sidebar_tab {
SidebarTab::Activity => render_activity_panel(frame, panel_area, app),
SidebarTab::Approval => render_approval_panel(frame, panel_area, app),
SidebarTab::Memory => render_memory_panel(frame, panel_area, app),
SidebarTab::Project => render_project_panel(frame, panel_area, app),
}
}
fn panel_block(title: String, accent: Color) -> Block<'static> {
Block::default()
.title(format!(" {title} · Tab "))
.borders(Borders::ALL)
.border_style(Style::default().fg(accent))
}
fn render_activity_panel(frame: &mut Frame, area: Rect, app: &App) {
let (status, tone) = match &app.turn {
TurnState::Idle => ("Ready for your message", JADE),
TurnState::Streaming { .. } => ("Receiving response", AMBER),
TurnState::AwaitingApproval(_) => ("Approval required", AMBER),
TurnState::ExecutingTool { .. } => ("Running approved tool", AMBER),
};
let mut lines = vec![
Line::styled(
status,
Style::default().fg(tone).add_modifier(Modifier::BOLD),
),
Line::raw(""),
Line::styled("Controls", Style::default().add_modifier(Modifier::BOLD)),
Line::raw("Enter send"),
Line::raw("PgUp/Dn scroll"),
Line::raw("Tab cycle sidebar"),
Line::raw("/model switch model"),
Line::raw("/memory search L1 facts"),
Line::raw("/status project counts"),
Line::raw("Ctrl-C quit"),
];
if app.provider.name() == "ollama" {
lines.push(Line::raw("/models pull · Delete removes"));
}
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.block(panel_block(SidebarTab::Activity.label().to_string(), tone)),
area,
);
}
fn render_approval_panel(frame: &mut Frame, area: Rect, app: &App) {
let lines = match &app.turn {
TurnState::AwaitingApproval(pending) => vec![
Line::from(Span::styled(
"AWAITING APPROVAL",
Style::default().fg(AMBER).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
&pending.command,
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
if pending.guard_verdict.is_some() {
"guard denied — Enter/Esc to acknowledge"
} else {
"y = run · n/Esc = decline"
},
Style::default().fg(SLATE),
)),
],
TurnState::ExecutingTool { .. } => vec![Line::from(Span::styled(
"Tool executing…",
Style::default().fg(WATER),
))],
TurnState::Idle | TurnState::Streaming { .. } => vec![
Line::from(Span::styled(
"No pending approval",
Style::default().fg(SLATE),
)),
Line::from(""),
Line::from(Span::styled(
"run_command proposals appear here — y to run, n/Esc to decline",
Style::default().fg(SLATE),
)),
],
};
let accent = if matches!(app.turn, TurnState::AwaitingApproval(_)) {
AMBER
} else {
JADE
};
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(panel_block(
SidebarTab::Approval.label().to_string(),
accent,
)),
area,
);
}
fn render_memory_panel(frame: &mut Frame, area: Rect, app: &App) {
let title = if app.memory_filter.is_empty() {
"Memory (L1)".to_string()
} else {
format!("Memory · '{}'", app.memory_filter)
};
let items: Vec<ListItem> = if app.memory_results.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
"/memory [query] to search L1 facts",
Style::default().fg(SLATE),
)))]
} else {
app.memory_results
.iter()
.map(|fact| {
ListItem::new(vec![
Line::from(Span::styled(
&fact.id,
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
&fact.statement,
Style::default().fg(Color::White),
)),
])
})
.collect()
};
frame.render_widget(List::new(items).block(panel_block(title, VIOLET)), area);
}
fn render_project_panel(frame: &mut Frame, area: Rect, app: &App) {
let lines = match &app.project_counts {
Some(c) => vec![
Line::from(Span::styled(
format!("v{}", c.version),
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(format!("{} agents", c.agents)),
Line::from(format!("{} skills", c.skills)),
Line::from(format!("{} rules", c.rules)),
Line::from(format!("{} hooks", c.hooks)),
Line::from(format!("{} scripts", c.scripts)),
Line::from(format!("{} commands", c.commands)),
],
None => vec![Line::from(Span::styled(
"MANIFEST.json unavailable",
Style::default().fg(SLATE),
))],
};
frame.render_widget(
Paragraph::new(lines).block(panel_block(SidebarTab::Project.label().to_string(), WATER)),
area,
);
}
const MEMORY_RESULTS_LIMIT: usize = 12;
impl App {
pub(super) fn handle_memory_command(&mut self, query: &str) {
self.memory_filter = query.to_string();
self.memory_results = read_memory_facts(&self.repo_root, query, MEMORY_RESULTS_LIMIT);
self.sidebar_tab = SidebarTab::Memory;
self.status = if self.memory_results.is_empty() {
format!("no L1 facts match '{query}'")
} else {
format!(
"{} L1 fact(s) — Tab to cycle sidebar",
self.memory_results.len()
)
};
}
pub(super) fn handle_status_command(&mut self) {
self.status = match &self.project_counts {
Some(c) => format!(
"v{} · {} agents · {} skills · {} rules · {} hooks · {} scripts · {} commands",
c.version, c.agents, c.skills, c.rules, c.hooks, c.scripts, c.commands
),
None => "MANIFEST.json unavailable — run from the repo root".to_string(),
};
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(super) enum SidebarTab {
#[default]
Activity,
Approval,
Memory,
Project,
}
impl SidebarTab {
pub(super) fn label(self) -> &'static str {
match self {
SidebarTab::Activity => "Activity",
SidebarTab::Approval => "Approval",
SidebarTab::Memory => "Memory",
SidebarTab::Project => "Project",
}
}
pub(super) fn next(self) -> Self {
match self {
SidebarTab::Activity => SidebarTab::Approval,
SidebarTab::Approval => SidebarTab::Memory,
SidebarTab::Memory => SidebarTab::Project,
SidebarTab::Project => SidebarTab::Activity,
}
}
}