use std::fs;
use std::time::{Duration, Instant};
use crossterm::event::{Event, KeyCode, KeyModifiers};
use scrin::command_palette::{Command, CommandPalette};
use scrin::core::color::Color;
use scrin::core::rect::Rect;
use scrin::effects::{LoaderKind, LoaderPlayer};
use scrin::input::{AppAction, EventRouter};
use scrin::layout::{Constraint, Layout};
use scrin::overlays::modal::{Modal, ModalStyle};
use scrin::overlays::toast::{Toast, ToastKind};
use scrin::overlays::{Overlay, OverlayPosition};
use scrin::scroll_state::ScrollState;
use scrin::status_bar::{StatusBar, StatusBarPosition};
use scrin::style::Style;
use scrin::terminal::{Terminal, TerminalOptions};
use scrin::widgets::block::Block;
use scrin::widgets::markdown_output::MarkdownOutput;
use scrin::widgets::paragraph::Paragraph;
use scrin::widgets::popup::{Popup, PopupPosition, PopupStyle};
use scrin::widgets::tabs::Tabs;
use crate::archive::{list_archives, verify_archive, ArchiveLayout, ArchiveManifest};
use crate::config::Config;
use crate::errors::{Result, SiteforgeError};
use crate::export::{create_ai_packs, export_archive, ExportFormat};
use crate::search::search_archives;
use crate::storage::{JobSnapshot, SearchResult, Storage, StoredAsset, StoredPage};
const BG: Color = Color::rgb(8, 10, 16);
const PANEL: Color = Color::rgb(13, 17, 23);
const ACCENT: Color = Color::rgb(88, 166, 255);
const GREEN: Color = Color::rgb(63, 185, 80);
const GOLD: Color = Color::rgb(255, 178, 72);
const RED: Color = Color::rgb(248, 81, 73);
const TEXT: Color = Color::rgb(201, 209, 217);
const DIM: Color = Color::rgb(139, 148, 158);
const SCREEN_TITLES: [&str; 5] = ["Jobs", "Progress", "Browser", "Search", "Config"];
const TUI_JOB_LIMIT: usize = 20;
const TUI_PAGE_LIMIT: usize = 500;
const TUI_ASSET_LIMIT: usize = 500;
const TUI_LOG_LIMIT: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Screen {
Jobs,
Progress,
Browser,
Search,
Config,
}
impl Screen {
fn index(self) -> usize {
match self {
Screen::Jobs => 0,
Screen::Progress => 1,
Screen::Browser => 2,
Screen::Search => 3,
Screen::Config => 4,
}
}
fn from_index(index: usize) -> Self {
match index.min(SCREEN_TITLES.len().saturating_sub(1)) {
0 => Screen::Jobs,
1 => Screen::Progress,
2 => Screen::Browser,
3 => Screen::Search,
_ => Screen::Config,
}
}
fn next(self) -> Self {
Self::from_index((self.index() + 1) % SCREEN_TITLES.len())
}
fn previous(self) -> Self {
Self::from_index((self.index() + SCREEN_TITLES.len() - 1) % SCREEN_TITLES.len())
}
fn title(self) -> &'static str {
match self {
Screen::Jobs => "Jobs",
Screen::Progress => "Progress",
Screen::Browser => "Archive Browser",
Screen::Search => "Search",
Screen::Config => "Config",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ScrollDirection {
Up,
Down,
PageUp,
PageDown,
Top,
Bottom,
}
struct App {
config: Config,
archives: Vec<ArchiveManifest>,
jobs: Vec<JobSnapshot>,
pages: Vec<StoredPage>,
assets: Vec<StoredAsset>,
logs: Vec<String>,
total_page_count: usize,
total_asset_count: usize,
search_results: Vec<SearchResult>,
selected_archive: usize,
selected_page: usize,
selected_search: usize,
preview_scroll: ScrollState,
result_scroll: ScrollState,
screen: Screen,
search_query: String,
search_editing: bool,
preview_markdown: String,
error: Option<String>,
tick: usize,
palette: CommandPalette,
help_popup: Popup,
detail_popup: Popup,
quit_modal: Modal,
toasts: Vec<Toast>,
}
impl App {
fn new(config: Config) -> Self {
Self {
config,
archives: Vec::new(),
jobs: Vec::new(),
pages: Vec::new(),
assets: Vec::new(),
logs: Vec::new(),
total_page_count: 0,
total_asset_count: 0,
search_results: Vec::new(),
selected_archive: 0,
selected_page: 0,
selected_search: 0,
preview_scroll: ScrollState::new(),
result_scroll: ScrollState::new(),
screen: Screen::Jobs,
search_query: String::new(),
search_editing: false,
preview_markdown: String::new(),
error: None,
tick: 0,
palette: command_palette(),
help_popup: Popup::new("siteforge help", help_text())
.with_style(PopupStyle::Panel)
.with_position(PopupPosition::Center)
.with_size(78, 24)
.with_border_color(ACCENT),
detail_popup: Popup::new("selection details", "")
.with_style(PopupStyle::Floating)
.with_position(PopupPosition::Center)
.with_size(82, 20)
.with_border_color(GOLD),
quit_modal: Modal::new(
"Quit siteforge",
"Leave the TUI? Background CLI jobs keep their durable archive state.",
)
.with_style(ModalStyle::Confirm)
.with_options(vec!["Cancel".to_string(), "Quit".to_string()])
.with_border_color(RED),
toasts: vec![Toast::new(
"Tab switches screens. Ctrl+K opens commands. ? opens help.",
ToastKind::Info,
)
.with_position(OverlayPosition::BottomRight)
.with_lifetime(Duration::from_secs(5))],
}
}
fn refresh(&mut self) {
match self.try_refresh() {
Ok(()) => {
self.error = None;
}
Err(err) => {
self.error = Some(err.to_string());
self.toast("refresh failed", ToastKind::Error);
}
}
}
fn try_refresh(&mut self) -> Result<()> {
self.archives = list_archives(&self.config)?;
if self.selected_archive >= self.archives.len() {
self.selected_archive = self.archives.len().saturating_sub(1);
}
self.jobs.clear();
self.pages.clear();
self.assets.clear();
self.logs.clear();
self.total_page_count = 0;
self.total_asset_count = 0;
let Some(archive) = self.archives.get(self.selected_archive) else {
self.preview_markdown.clear();
self.preview_scroll.scroll_to_top();
return Ok(());
};
let layout = ArchiveLayout::new(
self.config.resolved_archive_root()?,
archive.archive_id.clone(),
);
if layout.database().exists() {
let storage = Storage::open(&layout.database())?;
self.jobs = storage.job_snapshots_limited(TUI_JOB_LIMIT)?;
self.pages = storage.list_pages_limited(TUI_PAGE_LIMIT)?;
self.assets = storage.list_assets_limited(TUI_ASSET_LIMIT)?;
self.logs = storage.recent_logs(TUI_LOG_LIMIT)?;
self.total_page_count = storage.page_count()?;
self.total_asset_count = storage.asset_count()?;
}
if self.selected_page >= self.pages.len() {
self.selected_page = self.pages.len().saturating_sub(1);
}
self.load_preview()?;
Ok(())
}
fn load_preview(&mut self) -> Result<()> {
self.preview_markdown.clear();
let Some(archive) = self.archives.get(self.selected_archive) else {
return Ok(());
};
let Some(page) = self.pages.get(self.selected_page) else {
return Ok(());
};
let Some(markdown_path) = page.markdown_path.as_deref() else {
return Ok(());
};
let layout = ArchiveLayout::new(
self.config.resolved_archive_root()?,
archive.archive_id.clone(),
);
self.preview_markdown = fs::read_to_string(layout.base().join(markdown_path))
.unwrap_or_else(|err| format!("# Preview unavailable\n\n{}", err));
Ok(())
}
fn run_search(&mut self) {
if self.search_query.trim().is_empty() {
self.search_results.clear();
return;
}
let archive_id = self
.archives
.get(self.selected_archive)
.map(|archive| archive.archive_id.as_str());
match search_archives(&self.config, archive_id, &self.search_query, 50) {
Ok(results) => {
self.search_results = results;
self.selected_search = 0;
self.result_scroll.scroll_to_top();
self.error = None;
}
Err(err) => self.error = Some(err.to_string()),
}
}
fn handle_action(&mut self, action: AppAction) {
match action {
AppAction::TabNext => self.set_screen(self.screen.next()),
AppAction::TabPrev => self.set_screen(self.screen.previous()),
AppAction::FocusDown => self.move_selection(1),
AppAction::FocusUp => self.move_selection(-1),
AppAction::ScrollDown => match self.screen {
Screen::Browser => self.scroll_preview(ScrollDirection::PageDown),
Screen::Search => self.scroll_result_preview(ScrollDirection::PageDown),
_ => self.move_selection(5),
},
AppAction::ScrollUp => match self.screen {
Screen::Browser => self.scroll_preview(ScrollDirection::PageUp),
Screen::Search => self.scroll_result_preview(ScrollDirection::PageUp),
_ => self.move_selection(-5),
},
AppAction::ScrollLeft => match self.screen {
Screen::Browser => self.scroll_preview(ScrollDirection::Top),
Screen::Search => self.scroll_result_preview(ScrollDirection::Top),
_ => {}
},
AppAction::ScrollRight => match self.screen {
Screen::Browser => self.scroll_preview(ScrollDirection::Bottom),
Screen::Search => self.scroll_result_preview(ScrollDirection::Bottom),
_ => {}
},
AppAction::FocusLeft => self.set_screen(self.screen.previous()),
AppAction::FocusRight => self.set_screen(self.screen.next()),
AppAction::Select => {
if self.screen == Screen::Search {
self.run_search();
} else {
self.show_details();
}
}
AppAction::Back => self.search_editing = false,
_ => {}
}
}
fn move_selection(&mut self, delta: isize) {
match self.screen {
Screen::Jobs | Screen::Progress | Screen::Config => {
let previous_archive = self.selected_archive;
self.selected_archive =
move_index(self.selected_archive, self.archives.len(), delta);
if self.selected_archive != previous_archive {
self.preview_scroll.scroll_to_top();
}
self.refresh();
}
Screen::Browser => {
let previous_page = self.selected_page;
self.selected_page = move_index(self.selected_page, self.pages.len(), delta);
if self.selected_page != previous_page {
self.preview_scroll.scroll_to_top();
}
if let Err(err) = self.load_preview() {
self.error = Some(err.to_string());
}
}
Screen::Search => {
let previous_search = self.selected_search;
self.selected_search =
move_index(self.selected_search, self.search_results.len(), delta);
if self.selected_search != previous_search {
self.result_scroll.scroll_to_top();
}
}
}
}
fn scroll_preview(&mut self, direction: ScrollDirection) {
match direction {
ScrollDirection::Up => self.preview_scroll.scroll_up(4),
ScrollDirection::Down => self.preview_scroll.scroll_down(4),
ScrollDirection::PageUp => self.preview_scroll.page_up(),
ScrollDirection::PageDown => self.preview_scroll.page_down(),
ScrollDirection::Top => self.preview_scroll.scroll_to_top(),
ScrollDirection::Bottom => self.preview_scroll.scroll_to_bottom(),
}
}
fn scroll_result_preview(&mut self, direction: ScrollDirection) {
match direction {
ScrollDirection::Up => self.result_scroll.scroll_up(4),
ScrollDirection::Down => self.result_scroll.scroll_down(4),
ScrollDirection::PageUp => self.result_scroll.page_up(),
ScrollDirection::PageDown => self.result_scroll.page_down(),
ScrollDirection::Top => self.result_scroll.scroll_to_top(),
ScrollDirection::Bottom => self.result_scroll.scroll_to_bottom(),
}
}
fn scroll_active_preview(&mut self, direction: ScrollDirection) -> bool {
match self.screen {
Screen::Browser => self.scroll_preview(direction),
Screen::Search => self.scroll_result_preview(direction),
_ => return false,
}
true
}
fn set_screen(&mut self, screen: Screen) {
self.screen = screen;
self.search_editing = false;
self.toast(screen.title(), ToastKind::Info);
}
fn toast(&mut self, message: &str, kind: ToastKind) {
self.toasts.push(
Toast::new(message, kind)
.with_position(OverlayPosition::BottomRight)
.with_lifetime(Duration::from_secs(3)),
);
if self.toasts.len() > 4 {
self.toasts.remove(0);
}
}
fn update_overlays(&mut self, delta: Duration) {
self.help_popup.update(delta);
self.detail_popup.update(delta);
self.quit_modal.update(delta);
for toast in &mut self.toasts {
toast.update(delta);
}
self.toasts.retain(|toast| !toast.is_expired());
}
fn open_palette(&mut self) {
self.palette.open();
}
fn selected_archive_id(&self) -> Result<String> {
self.archives
.get(self.selected_archive)
.map(|archive| archive.archive_id.clone())
.ok_or_else(|| SiteforgeError::message("No archive selected"))
}
fn execute_command(&mut self, action_id: &str) -> bool {
match action_id {
"screen.jobs" => self.set_screen(Screen::Jobs),
"screen.progress" => self.set_screen(Screen::Progress),
"screen.browser" => self.set_screen(Screen::Browser),
"screen.search" => self.set_screen(Screen::Search),
"screen.config" => self.set_screen(Screen::Config),
"refresh" => {
self.refresh();
self.toast("refreshed archive state", ToastKind::Success);
}
"search.run" => {
self.set_screen(Screen::Search);
self.run_search();
self.toast("search complete", ToastKind::Success);
}
"export.markdown" => self.export_selected_archive(ExportFormat::Markdown),
"export.basic_markdown" => self.export_selected_archive(ExportFormat::BasicMarkdown),
"export.jsonl" => self.export_selected_archive(ExportFormat::Jsonl),
"export.agent_tar" => self.export_selected_archive(ExportFormat::AgentTar),
"export.all" => self.export_selected_archive(ExportFormat::All),
"pack.ai" => self.create_selected_ai_packs(),
"verify.archive" => self.verify_selected_archive(),
"help" => self.help_popup.show(),
"details" => self.show_details(),
"quit" => self.quit_modal.show(),
"quit.now" => return true,
_ => self.toast("unknown command", ToastKind::Warning),
}
false
}
fn export_selected_archive(&mut self, format: ExportFormat) {
let result = self.selected_archive_id().and_then(|archive_id| {
export_archive(&self.config, &archive_id, format, None).map(|path| (archive_id, path))
});
match result {
Ok((archive_id, path)) => {
self.error = None;
self.toast(
&format!(
"exported {archive_id} as {} to {}",
format.as_str(),
path.display()
),
ToastKind::Success,
);
}
Err(err) => {
self.error = Some(err.to_string());
self.toast("export failed", ToastKind::Error);
}
}
}
fn create_selected_ai_packs(&mut self) {
let result = self.selected_archive_id().and_then(|archive_id| {
create_ai_packs(&self.config, &archive_id, None).map(|packs| (archive_id, packs))
});
match result {
Ok((archive_id, packs)) => {
self.error = None;
self.toast(
&format!("created {} AI packs for {archive_id}", packs.len()),
ToastKind::Success,
);
}
Err(err) => {
self.error = Some(err.to_string());
self.toast("pack creation failed", ToastKind::Error);
}
}
}
fn verify_selected_archive(&mut self) {
let result = self.selected_archive_id().and_then(|archive_id| {
verify_archive(&self.config, &archive_id).map(|problems| (archive_id, problems))
});
match result {
Ok((archive_id, problems)) if problems.is_empty() => {
self.error = None;
self.toast(&format!("verified {archive_id}"), ToastKind::Success);
}
Ok((archive_id, problems)) => {
self.error = Some(format!(
"{} verification problems in {archive_id}",
problems.len()
));
self.detail_popup.content = format!(
"Archive: {archive_id}\n\nVerification problems:\n{}",
problems.join("\n")
);
self.detail_popup.show();
self.toast("verification found problems", ToastKind::Warning);
}
Err(err) => {
self.error = Some(err.to_string());
self.toast("verification failed", ToastKind::Error);
}
}
}
fn show_details(&mut self) {
self.detail_popup.content = selected_detail_text(self);
self.detail_popup.show();
}
}
pub async fn run(config: Config) -> Result<()> {
let mut terminal = Terminal::init_with(TerminalOptions::default())
.map_err(|err| SiteforgeError::Tui(err.to_string()))?;
let mut router = EventRouter::new();
let mut app = App::new(config);
app.refresh();
let mut last_refresh = Instant::now();
let result = loop {
if last_refresh.elapsed() >= Duration::from_secs(1) {
app.refresh();
last_refresh = Instant::now();
}
terminal
.draw(|frame| render(frame, &mut app))
.map_err(|err| SiteforgeError::Tui(err.to_string()))?;
if let Some(event) = terminal
.poll_event()
.map_err(|err| SiteforgeError::Tui(err.to_string()))?
{
if dispatch_event(&event, &mut app, &mut router) {
break Ok(());
}
}
app.tick = app.tick.wrapping_add(1);
app.update_overlays(Duration::from_millis(80));
tokio::time::sleep(Duration::from_millis(80)).await;
};
terminal
.restore()
.map_err(|err| SiteforgeError::Tui(err.to_string()))?;
result
}
fn dispatch_event(event: &Event, app: &mut App, router: &mut EventRouter) -> bool {
if handle_quit_modal_event(event, app) {
return app
.quit_modal
.selected_option()
.is_some_and(|option| option == "Quit")
&& !app.quit_modal.is_visible();
}
if handle_popup_event(event, app)
|| handle_palette_event(event, app)
|| handle_raw_event(event, app)
{
return false;
}
if let Some(action) = router.handle_event(event) {
match action {
AppAction::Quit => app.quit_modal.show(),
AppAction::OpenCommandPalette => app.open_palette(),
other => app.handle_action(other),
}
}
false
}
fn handle_quit_modal_event(event: &Event, app: &mut App) -> bool {
if !app.quit_modal.is_visible() {
return false;
}
let Event::Key(key) = event else {
return true;
};
match key.code {
KeyCode::Esc => app.quit_modal.hide(),
KeyCode::Left | KeyCode::Up => app.quit_modal.select_prev(),
KeyCode::Right | KeyCode::Down | KeyCode::Tab => app.quit_modal.select_next(),
KeyCode::Enter => {
if app.quit_modal.selected_option() == Some("Quit") {
app.quit_modal.hide();
} else {
app.quit_modal.hide();
app.toast("quit cancelled", ToastKind::Info);
}
}
KeyCode::Char('q') => {
app.quit_modal.selected_index = 1;
app.quit_modal.hide();
}
_ => {}
}
true
}
fn handle_popup_event(event: &Event, app: &mut App) -> bool {
let help_visible = app.help_popup.is_visible();
let detail_visible = app.detail_popup.is_visible();
if !help_visible && !detail_visible {
return false;
}
let Event::Key(key) = event else {
return true;
};
match key.code {
KeyCode::Esc => {
app.help_popup.hide();
app.detail_popup.hide();
}
KeyCode::Char('?') | KeyCode::Char('h') if help_visible => app.help_popup.hide(),
KeyCode::Char('d') if detail_visible => app.detail_popup.hide(),
_ => {}
}
true
}
fn handle_palette_event(event: &Event, app: &mut App) -> bool {
if !app.palette.is_open() {
return false;
}
let Event::Key(key) = event else {
return true;
};
match key.code {
KeyCode::Esc => app.palette.close(),
KeyCode::Up => app.palette.select_prev(),
KeyCode::Down | KeyCode::Tab => app.palette.select_next(),
KeyCode::Backspace => app.palette.backspace(),
KeyCode::Enter => {
if let Some(action_id) = app.palette.execute_selected().map(str::to_string) {
app.palette.close();
app.execute_command(&action_id);
}
}
KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
app.palette.input_char(ch)
}
_ => {}
}
true
}
fn handle_raw_event(event: &Event, app: &mut App) -> bool {
let Event::Key(key) = event else {
return false;
};
if key.modifiers.contains(KeyModifiers::CONTROL) {
return false;
}
match key.code {
KeyCode::Char('/') if app.screen == Screen::Search && !app.search_editing => {
app.search_editing = true;
true
}
KeyCode::Char(ch) if app.screen == Screen::Search && app.search_editing => {
app.search_query.push(ch);
true
}
KeyCode::Backspace if app.screen == Screen::Search && app.search_editing => {
app.search_query.pop();
true
}
KeyCode::Enter if app.screen == Screen::Search && app.search_editing => {
app.search_editing = false;
app.run_search();
true
}
KeyCode::Char('j') if preview_scroll_keys_enabled(app) => {
app.scroll_active_preview(ScrollDirection::Down);
true
}
KeyCode::Char('k') if preview_scroll_keys_enabled(app) => {
app.scroll_active_preview(ScrollDirection::Up);
true
}
KeyCode::Char('g') if preview_scroll_keys_enabled(app) => {
app.scroll_active_preview(ScrollDirection::Top);
true
}
KeyCode::Char('G') if preview_scroll_keys_enabled(app) => {
app.scroll_active_preview(ScrollDirection::Bottom);
true
}
KeyCode::Char('1') => {
app.set_screen(Screen::Jobs);
true
}
KeyCode::Char('2') => {
app.set_screen(Screen::Progress);
true
}
KeyCode::Char('3') => {
app.set_screen(Screen::Browser);
true
}
KeyCode::Char('4') => {
app.set_screen(Screen::Search);
true
}
KeyCode::Char('5') => {
app.set_screen(Screen::Config);
true
}
KeyCode::Char('?') | KeyCode::Char('h') => {
app.help_popup.show();
true
}
KeyCode::Char('d') => {
app.show_details();
true
}
KeyCode::Char(':') => {
app.open_palette();
true
}
KeyCode::Char('r') => {
app.refresh();
app.toast("refreshed archive state", ToastKind::Success);
true
}
KeyCode::Char('q') => {
app.quit_modal.show();
true
}
_ => false,
}
}
fn preview_scroll_keys_enabled(app: &App) -> bool {
app.screen == Screen::Browser || (app.screen == Screen::Search && !app.search_editing)
}
fn render(frame: &mut scrin::terminal::Frame<'_>, app: &mut App) {
let area = frame.area();
frame.buffer().fill(area, ' ', TEXT, Some(BG));
let rows = Layout::vertical(vec![
Constraint::Length(4),
Constraint::Min(5),
Constraint::Length(1),
])
.split(area);
render_header(frame, app, rows[0]);
match app.screen {
Screen::Jobs => render_jobs(frame, app, rows[1]),
Screen::Progress => render_progress(frame, app, rows[1]),
Screen::Browser => render_browser(frame, app, rows[1]),
Screen::Search => render_search(frame, app, rows[1]),
Screen::Config => render_config(frame, app, rows[1]),
}
render_status(frame, app, rows[2]);
render_overlays(frame, app, area);
}
fn render_header(frame: &mut scrin::terminal::Frame<'_>, app: &App, area: Rect) {
let block = Block::new("siteforge")
.with_bg(PANEL)
.with_border_color(ACCENT)
.with_title_right("Ctrl+K commands ? help d details r refresh q quit");
frame.render_widget(block.clone(), area);
let inner = block.inner(area);
let rows = Layout::vertical(vec![Constraint::Length(1), Constraint::Length(1)]).split(inner);
let archive = app
.archives
.get(app.selected_archive)
.map(|archive| archive.archive_id.as_str())
.unwrap_or("no archive");
let text = format!(
"{} | selected archive: {} | pages={}/{} assets={}/{} logs={}",
app.screen.title(),
archive,
app.pages.len(),
app.total_page_count,
app.assets.len(),
app.total_asset_count,
app.logs.len()
);
frame.render_widget(Paragraph::new(&text).with_word_wrap(true), rows[0]);
let tabs = Tabs::new(&SCREEN_TITLES)
.with_selected(app.screen.index())
.with_style(Style::new().fg(DIM))
.with_highlight_style(Style::new().fg(ACCENT).bold());
frame.render_widget(tabs, rows[1]);
}
fn render_jobs(frame: &mut scrin::terminal::Frame<'_>, app: &App, area: Rect) {
let columns = Layout::horizontal(vec![
Constraint::Percentage(36),
Constraint::Percentage(34),
Constraint::Percentage(30),
])
.split(area);
render_panel(frame, columns[0], "Job Queue", &jobs_text(app), ACCENT);
render_panel(frame, columns[1], "Archives", &archives_text(app), GREEN);
render_panel(frame, columns[2], "Logs", &logs_text(app), GOLD);
}
fn render_progress(frame: &mut scrin::terminal::Frame<'_>, app: &App, area: Rect) {
let rows = Layout::vertical(vec![Constraint::Length(8), Constraint::Min(6)]).split(area);
let block = Block::new("Crawl Progress")
.with_bg(PANEL)
.with_border_color(ACCENT);
frame.render_widget(block.clone(), rows[0]);
let inner = block.inner(rows[0]);
let stats = app
.archives
.get(app.selected_archive)
.map(|archive| archive.stats.clone());
if let Some(stats) = stats {
let loader = LoaderPlayer::new(LoaderKind::Bar)
.with_size(inner.width as usize, inner.height.max(1) as usize)
.with_label("crawl".to_string())
.with_unit("pages")
.with_fraction(true)
.with_accent(ACCENT);
let progress = aisling::LoaderProgress::from_counts(
stats.pages_fetched as u64,
stats.pages_discovered.max(1) as u64,
);
loader.render(app.tick, progress, frame.buffer(), inner);
} else {
frame.render_widget(Paragraph::new("No archive selected."), inner);
}
let columns = Layout::horizontal(vec![Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[1]);
render_panel(
frame,
columns[0],
"Current URL",
¤t_url_text(app),
GREEN,
);
render_panel(
frame,
columns[1],
"Asset / Code Extraction",
&assets_text(app),
GOLD,
);
}
fn render_browser(frame: &mut scrin::terminal::Frame<'_>, app: &mut App, area: Rect) {
let columns = Layout::horizontal(vec![Constraint::Percentage(34), Constraint::Percentage(66)])
.split(area);
render_selected_list_panel(
frame,
columns[0],
"Pages",
&pages_text(app),
ACCENT,
(app.selected_page, app.pages.len(), 2),
);
let block = Block::new("Page Preview")
.with_bg(PANEL)
.with_border_color(GREEN)
.with_title_right("j/k line PgUp/PgDn page Home/End or g/G");
frame.render_widget(block.clone(), columns[1]);
let inner = block.inner(columns[1]);
let markdown = if app.preview_markdown.is_empty() {
"# No page selected\n\nArchive a page first, then select it here.".to_string()
} else {
app.preview_markdown.clone()
};
let preview = MarkdownOutput::new(&markdown).with_code_line_numbers(true);
let row_count = preview.rows_for_width(inner.width as usize).len();
app.preview_scroll
.set_bounds(row_count, inner.height as usize);
frame.render_widget(preview.with_scroll(app.preview_scroll), inner);
}
fn render_search(frame: &mut scrin::terminal::Frame<'_>, app: &mut App, area: Rect) {
let rows = Layout::vertical(vec![Constraint::Length(5), Constraint::Min(5)]).split(area);
let prompt = format!(
"Query: {}{}\nPress / to edit, Enter to search, arrows to select.",
app.search_query,
if app.search_editing { "_" } else { "" }
);
render_panel(frame, rows[0], "Search", &prompt, ACCENT);
let columns = Layout::horizontal(vec![Constraint::Percentage(42), Constraint::Percentage(58)])
.split(rows[1]);
render_selected_list_panel(
frame,
columns[0],
"Results",
&search_results_text(app),
GREEN,
(app.selected_search, app.search_results.len(), 2),
);
let preview = app
.search_results
.get(app.selected_search)
.map(|result| {
format!(
"# {}\n\nSource: {}\n\n{}\n\nLocal: {}",
result.title,
result.source_url,
result.snippet,
result.local_file_path.as_deref().unwrap_or("not available")
)
})
.unwrap_or_else(|| "# No result selected".to_string());
let block = Block::new("Result Preview")
.with_bg(PANEL)
.with_border_color(GOLD)
.with_title_right("j/k line PgUp/PgDn page Home/End or g/G");
frame.render_widget(block.clone(), columns[1]);
let inner = block.inner(columns[1]);
let output = MarkdownOutput::new(&preview);
let row_count = output.rows_for_width(inner.width as usize).len();
app.result_scroll
.set_bounds(row_count, inner.height as usize);
frame.render_widget(output.with_scroll(app.result_scroll), inner);
}
fn render_config(frame: &mut scrin::terminal::Frame<'_>, app: &App, area: Rect) {
let config_text = serde_yaml::to_string(&app.config).unwrap_or_else(|err| err.to_string());
render_panel(frame, area, "Config", &config_text, ACCENT);
}
fn render_status(frame: &mut scrin::terminal::Frame<'_>, app: &App, area: Rect) {
let mut status = StatusBar::new()
.with_position(StatusBarPosition::Bottom)
.with_bg(PANEL);
status.set_left("siteforge tui", ACCENT);
if let Some(error) = app.error.as_deref() {
status.set_center(error, RED);
} else {
status.set_center("ready", GREEN);
}
let overlay_state = if app.palette.is_open() {
"palette"
} else if app.help_popup.is_visible() {
"help"
} else if app.detail_popup.is_visible() {
"details"
} else {
"normal"
};
status.set_right(&format!("{} | tick {}", overlay_state, app.tick), GOLD);
status.render(frame.buffer(), area);
}
fn render_overlays(frame: &mut scrin::terminal::Frame<'_>, app: &mut App, area: Rect) {
app.palette.render(frame.buffer(), area);
app.help_popup.render(frame.buffer(), area);
app.detail_popup.render(frame.buffer(), area);
app.quit_modal.render(frame.buffer(), area);
for toast in &app.toasts {
toast.render(frame.buffer(), area);
}
}
fn render_panel(
frame: &mut scrin::terminal::Frame<'_>,
area: Rect,
title: &str,
text: &str,
color: Color,
) {
let block = Block::new(title).with_bg(PANEL).with_border_color(color);
frame.render_widget(block.clone(), area);
frame.render_widget(Paragraph::new(text).with_word_wrap(true), block.inner(area));
}
fn render_selected_list_panel(
frame: &mut scrin::terminal::Frame<'_>,
area: Rect,
title: &str,
text: &str,
color: Color,
viewport: (usize, usize, usize),
) {
let block = Block::new(title).with_bg(PANEL).with_border_color(color);
frame.render_widget(block.clone(), area);
let inner = block.inner(area);
let (selected_index, len, rows_per_item) = viewport;
let scroll_y = selected_list_offset(selected_index, len, rows_per_item, inner.height as usize)
.min(u16::MAX as usize) as u16;
frame.render_widget(Paragraph::new(text).with_scroll(0, scroll_y), inner);
}
fn selected_list_offset(
selected_index: usize,
len: usize,
rows_per_item: usize,
viewport_rows: usize,
) -> usize {
if len == 0 || viewport_rows == 0 {
return 0;
}
let rows_per_item = rows_per_item.max(1);
let total_rows = len.saturating_mul(rows_per_item);
let max_offset = total_rows.saturating_sub(viewport_rows);
let selected_row = selected_index
.min(len.saturating_sub(1))
.saturating_mul(rows_per_item);
let preferred_offset =
selected_row.saturating_sub(viewport_rows.saturating_sub(rows_per_item) / 2);
preferred_offset.min(max_offset)
}
fn archives_text(app: &App) -> String {
if app.archives.is_empty() {
return "No archives found.".to_string();
}
app.archives
.iter()
.enumerate()
.map(|(index, archive)| {
let marker = if index == app.selected_archive {
">"
} else {
" "
};
format!(
"{marker} {} pages={} failed={}\n {}",
archive.archive_id,
archive.stats.pages_parsed,
archive.stats.pages_failed,
archive.seeds.first().map(String::as_str).unwrap_or("")
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn jobs_text(app: &App) -> String {
if app.jobs.is_empty() {
return "No jobs recorded for selected archive.".to_string();
}
app.jobs
.iter()
.map(|job| {
format!(
"{} [{}]\n active: {}\n discovered={} fetched={} failed={}",
job.job_id,
job.status,
job.active_url.as_deref().unwrap_or("none"),
job.pages_discovered,
job.pages_fetched,
job.pages_failed
)
})
.collect::<Vec<_>>()
.join("\n\n")
}
fn logs_text(app: &App) -> String {
if app.logs.is_empty() {
return "No logs.".to_string();
}
app.logs
.iter()
.rev()
.take(30)
.cloned()
.collect::<Vec<_>>()
.join("\n")
}
fn current_url_text(app: &App) -> String {
app.jobs
.iter()
.find_map(|job| job.active_url.clone())
.unwrap_or_else(|| "No active crawl URL.".to_string())
}
fn assets_text(app: &App) -> String {
if app.assets.is_empty() {
return "No assets indexed.".to_string();
}
let mut text = app
.assets
.iter()
.take(40)
.map(|asset| {
format!(
"{}\n {}\n ocr={}",
asset.asset_id,
asset
.local_path
.as_deref()
.unwrap_or(asset.source_url.as_str()),
asset.ocr_status.as_deref().unwrap_or("not-run")
)
})
.collect::<Vec<_>>()
.join("\n");
if app.total_asset_count > app.assets.len() {
text.push_str(&format!(
"\n\n{} additional assets omitted from TUI view.",
app.total_asset_count - app.assets.len()
));
}
text
}
fn pages_text(app: &App) -> String {
if app.pages.is_empty() {
return "No pages archived.".to_string();
}
let mut text = app
.pages
.iter()
.enumerate()
.map(|(index, page)| {
let marker = if index == app.selected_page { ">" } else { " " };
format!(
"{marker} {} [{}]\n {}",
page.title, page.status, page.source_url
)
})
.collect::<Vec<_>>()
.join("\n");
if app.total_page_count > app.pages.len() {
text.push_str(&format!(
"\n\n{} additional pages omitted from TUI view.",
app.total_page_count - app.pages.len()
));
}
text
}
fn search_results_text(app: &App) -> String {
if app.search_results.is_empty() {
return "No search results yet.".to_string();
}
app.search_results
.iter()
.enumerate()
.map(|(index, result)| {
let marker = if index == app.selected_search {
">"
} else {
" "
};
format!("{marker} {}\n {}", result.title, result.source_url)
})
.collect::<Vec<_>>()
.join("\n")
}
fn command_palette() -> CommandPalette {
CommandPalette::new().with_commands(vec![
command("Jobs", "1", "Show queue, archives, and logs", "screen.jobs"),
command(
"Progress",
"2",
"Show crawl counters, active URL, and extraction status",
"screen.progress",
),
command(
"Browser",
"3",
"Browse archived pages and preview Markdown",
"screen.browser",
),
command("Search", "4", "Open archive search", "screen.search"),
command(
"Config",
"5",
"Inspect active configuration",
"screen.config",
),
command("Refresh", "r", "Reload archive state from disk", "refresh"),
command(
"Run Search",
"Enter",
"Run current search query",
"search.run",
),
command(
"Export Markdown",
"xm",
"Export selected archive readable Markdown",
"export.markdown",
),
command(
"Export Basic Markdown",
"xb",
"Export plain no-frontmatter Markdown",
"export.basic_markdown",
),
command(
"Export JSONL",
"xj",
"Export selected archive semantic chunks",
"export.jsonl",
),
command(
"Export Agent Tar",
"xt",
"Create compressed agent-ready tar bundle",
"export.agent_tar",
),
command(
"Export All",
"xa",
"Create all export formats for selected archive",
"export.all",
),
command(
"Create AI Pack",
"p",
"Create token-budgeted AI context packs",
"pack.ai",
),
command(
"Verify Archive",
"v",
"Verify selected archive files and checksums",
"verify.archive",
),
command(
"Details",
"d",
"Open details for current selection",
"details",
),
command("Help", "?", "Show keyboard help", "help"),
command("Quit", "q", "Show quit confirmation", "quit"),
])
}
fn command(name: &str, shortcut: &str, description: &str, action_id: &str) -> Command {
Command {
name: name.to_string(),
shortcut: Some(shortcut.to_string()),
description: description.to_string(),
action_id: action_id.to_string(),
}
}
fn help_text() -> &'static str {
"Navigation\n\nTab / Shift+Tab / Left / Right: switch tabs\n1..5: jump to Jobs, Progress, Browser, Search, Config\nUp / Down: move selection\nEnter: details, or run search on Search tab\n\nMarkdown Previews\n\nPageUp / PageDown: scroll Browser or Search preview by one page\nHome / End or g / G: jump preview to top or bottom\nj / k: scroll preview by a few lines when not editing search\n\nArchive Actions\n\nCtrl+K opens commands for exporting full Markdown, basic Markdown, JSONL chunks, compressed agent tar bundles, all formats, creating AI packs, and verifying checksums for the selected archive.\n\nOverlays\n\nCtrl+K / Ctrl+P / :: command palette\n?: toggle this help popup\nd: details popup for selected archive/page/search result\nEsc: close active overlay or stop search editing\nr: refresh archive state\nq / Ctrl+C: quit confirmation\n\nSearch\n\nOpen Search, press /, type a query, Enter to run. Results preview the matched readable file.\n\nDesign\n\nThis TUI uses Scrin tabs, popups, modals, command palette, toasts, Markdown preview, and Aisling loaders."
}
fn selected_detail_text(app: &App) -> String {
match app.screen {
Screen::Jobs | Screen::Progress | Screen::Config => app
.archives
.get(app.selected_archive)
.map(|archive| {
format!(
"Archive: {}\nCreated: {}\nUpdated: {}\nSeeds: {}\n\nPages: discovered={} parsed={} failed={} skipped={}\nAssets: downloaded={} failed={}\nOCR: succeeded={} unavailable={}\n\nScope: full_site={} same_domain={} max_depth={}",
archive.archive_id,
archive.created_at,
archive.updated_at,
archive.seeds.join(", "),
archive.stats.pages_discovered,
archive.stats.pages_parsed,
archive.stats.pages_failed,
archive.stats.pages_skipped,
archive.stats.assets_downloaded,
archive.stats.assets_failed,
archive.stats.ocr_succeeded,
archive.stats.ocr_unavailable,
archive.scope.full_site,
archive.scope.same_domain,
archive.scope.max_depth
)
})
.unwrap_or_else(|| "No archive selected.".to_string()),
Screen::Browser => app
.pages
.get(app.selected_page)
.map(|page| {
format!(
"Page: {}\nStatus: {}\nSource: {}\nFinal: {}\nDepth: {}\nFetched: {}\n\nMarkdown: {}\nJSON: {}\nText: {}\nRaw: {}\nHash: {}\nError: {}",
page.title,
page.status,
page.source_url,
page.final_url,
page.depth,
page.fetched_at.as_deref().unwrap_or("unknown"),
page.markdown_path.as_deref().unwrap_or("missing"),
page.json_path.as_deref().unwrap_or("missing"),
page.text_path.as_deref().unwrap_or("missing"),
page.raw_path.as_deref().unwrap_or("missing"),
page.content_hash.as_deref().unwrap_or("unknown"),
page.error.as_deref().unwrap_or("none")
)
})
.unwrap_or_else(|| "No page selected.".to_string()),
Screen::Search => app
.search_results
.get(app.selected_search)
.map(|result| {
format!(
"Result: {}\nArchive: {}\nPage: {}\nSource: {}\nHeading: {}\nLocal: {}\n\nSnippet:\n{}",
result.title,
result.archive_id,
result.page_id,
result.source_url,
result.matched_heading.as_deref().unwrap_or("unknown"),
result.local_file_path.as_deref().unwrap_or("not available"),
result.snippet
)
})
.unwrap_or_else(|| "No search result selected.".to_string()),
}
}
fn move_index(current: usize, len: usize, delta: isize) -> usize {
if len == 0 {
return 0;
}
let next = current as isize + delta;
next.clamp(0, len.saturating_sub(1) as isize) as usize
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn screen_cycles_through_tabs() {
assert_eq!(Screen::Jobs.next(), Screen::Progress);
assert_eq!(Screen::Jobs.previous(), Screen::Config);
assert_eq!(Screen::from_index(2), Screen::Browser);
assert_eq!(Screen::Config.next(), Screen::Jobs);
}
#[test]
fn command_actions_update_tui_state() {
let mut app = App::new(Config::default());
assert_eq!(app.screen, Screen::Jobs);
assert!(!app.help_popup.is_visible());
assert!(!app.execute_command("screen.search"));
assert_eq!(app.screen, Screen::Search);
assert!(!app.execute_command("help"));
assert!(app.help_popup.is_visible());
assert!(!app.execute_command("quit"));
assert!(app.quit_modal.is_visible());
}
#[test]
fn archive_actions_require_selected_archive() {
let mut app = App::new(Config::default());
for action_id in [
"export.markdown",
"export.basic_markdown",
"export.jsonl",
"export.agent_tar",
"export.all",
"pack.ai",
"verify.archive",
] {
app.error = None;
assert!(!app.execute_command(action_id));
assert_eq!(app.error.as_deref(), Some("No archive selected"));
}
}
#[test]
fn detail_popup_uses_current_selection() {
let mut app = App::new(Config::default());
app.show_details();
assert!(app.detail_popup.is_visible());
assert!(app.detail_popup.content.contains("No archive"));
}
#[test]
fn browser_preview_scrolls_with_page_actions() {
let mut app = App::new(Config::default());
app.screen = Screen::Browser;
app.preview_scroll.set_bounds(80, 20);
app.handle_action(AppAction::ScrollDown);
assert_eq!(app.preview_scroll.offset, 20);
app.scroll_preview(ScrollDirection::Bottom);
assert_eq!(app.preview_scroll.offset, 60);
app.handle_action(AppAction::ScrollUp);
assert_eq!(app.preview_scroll.offset, 40);
app.scroll_preview(ScrollDirection::Top);
assert_eq!(app.preview_scroll.offset, 0);
}
#[test]
fn browser_page_selection_resets_preview_scroll() {
let mut app = App::new(Config::default());
app.screen = Screen::Browser;
app.pages = vec![test_page("one"), test_page("two")];
app.preview_scroll.set_bounds(100, 10);
app.preview_scroll.scroll_down(50);
app.move_selection(1);
assert_eq!(app.selected_page, 1);
assert_eq!(app.preview_scroll.offset, 0);
}
#[test]
fn search_result_preview_scrolls_and_resets_on_selection() {
let mut app = App::new(Config::default());
app.screen = Screen::Search;
app.search_results = vec![test_result("one"), test_result("two")];
app.result_scroll.set_bounds(60, 15);
app.handle_action(AppAction::ScrollDown);
assert_eq!(app.result_scroll.offset, 15);
app.move_selection(1);
assert_eq!(app.selected_search, 1);
assert_eq!(app.result_scroll.offset, 0);
}
#[test]
fn selected_list_offset_keeps_selection_visible() {
assert_eq!(selected_list_offset(0, 100, 2, 10), 0);
let offset = selected_list_offset(50, 100, 2, 10);
let selected_row = 50 * 2;
assert!(offset <= selected_row);
assert!(selected_row + 2 <= offset + 10);
assert_eq!(selected_list_offset(99, 100, 2, 10), 190);
assert_eq!(selected_list_offset(0, 0, 2, 10), 0);
}
#[test]
fn move_index_clamps_bounds() {
assert_eq!(move_index(0, 0, 1), 0);
assert_eq!(move_index(0, 3, -1), 0);
assert_eq!(move_index(1, 3, 1), 2);
assert_eq!(move_index(2, 3, 1), 2);
}
fn test_page(id: &str) -> StoredPage {
StoredPage {
page_id: id.to_string(),
source_url: format!("https://example.com/{id}"),
final_url: format!("https://example.com/{id}"),
title: id.to_string(),
status: 200,
markdown_path: None,
json_path: None,
text_path: None,
raw_path: None,
content_hash: None,
depth: 0,
fetched_at: None,
error: None,
}
}
fn test_result(id: &str) -> SearchResult {
SearchResult {
archive_id: "archive".to_string(),
page_id: id.to_string(),
source_url: format!("https://example.com/{id}"),
title: id.to_string(),
matched_heading: None,
snippet: format!("snippet for {id}"),
local_file_path: None,
}
}
}