From 2148ef7ac938c08c46cd1b5063ff611e712f10c0 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Thu, 9 Jul 2026 01:11:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20forge=20tui=20=E2=80=94=20ratatui=20ter?= =?UTF-8?q?minal=20dashboard=20over=20commands::services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare `forge` (with --features tui) and `forge tui` launch a ratatui TUI: four panes (artifacts, provenance+integrity, projects/ontology placeholder, sources/watch/find) over the shared services scan model, plus a command palette. Sync crossterm event loop, gitui-style Component panes, panic-guarded terminal teardown. TestBackend snapshot tests assert pane render content. Also fixes a pedantic items-after-statements/format-push lint in the #61 drift scope test so the --all-targets clippy gate stays clean. --- CHANGELOG.md | 1 + Cargo.toml | 7 +- src/cli/drift/scope/tests.rs | 10 +- src/cli/mod.rs | 31 +++- src/main.rs | 2 + src/tui/app.rs | 285 +++++++++++++++++++++++++++++++ src/tui/components/artifacts.rs | 188 ++++++++++++++++++++ src/tui/components/mod.rs | 20 +++ src/tui/components/palette.rs | 115 +++++++++++++ src/tui/components/preview.rs | 108 ++++++++++++ src/tui/components/projects.rs | 118 +++++++++++++ src/tui/components/provenance.rs | 161 +++++++++++++++++ src/tui/components/sources.rs | 172 +++++++++++++++++++ src/tui/event.rs | 49 ++++++ src/tui/mod.rs | 82 +++++++++ src/tui/tests.rs | 104 +++++++++++ 16 files changed, 1444 insertions(+), 9 deletions(-) create mode 100644 src/tui/app.rs create mode 100644 src/tui/components/artifacts.rs create mode 100644 src/tui/components/mod.rs create mode 100644 src/tui/components/palette.rs create mode 100644 src/tui/components/preview.rs create mode 100644 src/tui/components/projects.rs create mode 100644 src/tui/components/provenance.rs create mode 100644 src/tui/components/sources.rs create mode 100644 src/tui/event.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c43615c..e221b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- `forge tui` and bare `forge` under `--features tui` launch a ratatui terminal dashboard over the shared `commands::services` scan model, with artifacts, provenance, projects/ontology placeholder, sources/watch/find panes, and a command palette. - Model-level qualifier resolution for rules and agents (PROV-0005 Phase 1). Assembly now resolves the full `user/` > `provider//` > `provider/` > base precedence: a file at `rules///Rule.md` overrides the base for that provider and model, and the long-documented `user/` overlay is finally wired for rules and agents too. Each provider gains a default `model` in `defaults.yaml` (an exact ID from `config/models.yaml`); `forge assemble --model ` and `forge install --model ` override it for providers that list that model. Qualifier directory names are validated as exact model IDs: model IDs are no longer split into segments, so a directory named `4` or `6` (from `claude-opus-4-6`) is no longer a valid qualifier, and a model-only file under `rules///` is collected instead of silently dropped. An unrecognized model-qualifier subdirectory is skipped with a warning rather than dropped silently. Skill overlays, recording the model in `.manifest`/provenance sidecars, and the per-model release matrix are deferred to Phase 2. (#60) - `forge drift --target ` verifies a module's assembled `build/` against where it was deployed, scoped to the module's own files. It mirrors `forge install --target`: each provider's `build/` is compared to `/`, so files built but not yet deployed surface as local-only, deployment edits surface as frontmatter/body drift, and this module's deployed files (per the target `.manifest` plus provenance attribution) that are no longer built surface as drift. Unlike `--upstream` against a multi-module tree such as `~/.claude`, other modules' files are never reported. `--target` and `--upstream` are mutually exclusive. (#61) - `forge validate` sanity-checks Claude Code plugin scaffolding when `.claude-plugin/plugin.json` is present: each manifest (`plugin.json`, `.claude-plugin/marketplace.json`, `hooks/hooks.json`) must be valid JSON, and every hook script referenced via `${CLAUDE_PLUGIN_ROOT}` must exist and be executable (the most common cause of hooks silently not firing). It deliberately makes no assertions about the plugin or marketplace field schema, so it does not break when Claude Code's plugin format changes. Non-plugin modules are unaffected: the check runs only when `plugin.json` exists. (#59) diff --git a/Cargo.toml b/Cargo.toml index a7681a6..7b7bdae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ validate = ["dep:rust-embed"] deploy = [] embed = ["dep:rust-embed"] dashboard = ["full", "dep:axum", "dep:tokio", "dep:askama", "dep:tower-http", "dep:open"] +tui = ["dep:ratatui", "dep:crossterm"] [dependencies] # cli @@ -58,6 +59,10 @@ askama = { version = "0.13", features = ["urlencode"], optional = true } tower-http = { version = "0.6", features = ["set-header"], optional = true } open = { version = "5", optional = true } +# tui (optional, excluded from full) +ratatui = { version = "0.30", optional = true } +crossterm = { version = "0.29", optional = true } + [build-dependencies] sha2 = "0.10" @@ -71,7 +76,7 @@ unsafe_code = "forbid" [lints.rust.unexpected_cfgs] level = "deny" -check-cfg = ["cfg(ci)", "cfg(feature, values(\"dashboard\"))"] +check-cfg = ["cfg(ci)", "cfg(feature, values(\"dashboard\", \"tui\"))"] [lints.clippy] all = { level = "warn", priority = -1 } diff --git a/src/cli/drift/scope/tests.rs b/src/cli/drift/scope/tests.rs index 1b1e6ff..68b64ab 100644 --- a/src/cli/drift/scope/tests.rs +++ b/src/cli/drift/scope/tests.rs @@ -16,6 +16,7 @@ fn deploy_owned_file( content: &str, source_uri: &str, ) { + use std::fmt::Write as _; write(&deployed_base.join(relative), content); let artifact = std::path::Path::new(relative); @@ -32,10 +33,11 @@ fn deploy_owned_file( let manifest_path = deployed_base.join(".manifest"); let mut manifest_yaml = std::fs::read_to_string(&manifest_path).unwrap_or_default(); - manifest_yaml.push_str(&format!( - "{relative}:\n fingerprint: {digest}\n provenance: {provenance_relative}\n", - digest = manifest::content_sha256(content) - )); + let digest = manifest::content_sha256(content); + let _ = write!( + manifest_yaml, + "{relative}:\n fingerprint: {digest}\n provenance: {provenance_relative}\n" + ); std::fs::write(&manifest_path, manifest_yaml).unwrap(); } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c4c9dae..2c32aa5 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,5 +1,5 @@ mod assemble; -mod config; +pub(crate) mod config; mod copy; #[cfg(feature = "dashboard")] mod dashboard; @@ -12,11 +12,13 @@ mod output; mod provenance; mod release; mod validate; -mod watchlist; +pub(crate) mod watchlist; #[cfg(test)] mod tests; +#[cfg(not(feature = "tui"))] +use clap::CommandFactory; use clap::{Parser, Subcommand}; use commands::error::Error; use commands::result::ActionResult; @@ -25,7 +27,7 @@ use commands::result::ActionResult; #[command(name = "forge", about = "Forge module toolkit", version)] struct Cli { #[command(subcommand)] - command: Command, + command: Option, /// Output results as JSON #[arg(long, global = true)] @@ -34,6 +36,10 @@ struct Cli { #[derive(Subcommand)] enum Command { + /// Launch the terminal dashboard + #[cfg(feature = "tui")] + Tui, + /// Initialize a new forge module with required files and schemas Init { /// Directory to scaffold the new module into (created if missing). @@ -280,7 +286,13 @@ enum WatchAction { pub fn run() -> i32 { let args = Cli::parse(); - let (result, verb) = match args.command { + let Some(command) = args.command else { + return bare(); + }; + + let (result, verb) = match command { + #[cfg(feature = "tui")] + Command::Tui => return crate::tui::run(), Command::Init { target } => (init::execute(&target), "initialized"), Command::Install { source, @@ -373,6 +385,17 @@ pub fn run() -> i32 { report(result, args.json, verb) } +#[cfg(feature = "tui")] +fn bare() -> i32 { + crate::tui::run() +} + +#[cfg(not(feature = "tui"))] +fn bare() -> i32 { + eprintln!("{}", Cli::command().render_help()); + 2 +} + /// Collapse a subcommand's `Result` into a process exit code, /// printing a `fatal:` line on `Err`. fn exit_code(result: Result) -> i32 { diff --git a/src/main.rs b/src/main.rs index 7669016..130eb97 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,6 @@ mod cli; +#[cfg(feature = "tui")] +mod tui; fn main() { let exit_code = cli::run(); diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..31b7b5c --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,285 @@ +use std::path::{Path, PathBuf}; + +use crossterm::event::KeyEvent; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, +}; + +use commands::{error::Error, services, view::DashboardView}; + +use crate::cli::{config, watchlist}; + +use super::components::{ + Component, + artifacts::ArtifactsPane, + palette::{Palette, PaletteCommand}, + preview::ArtifactPreview, + projects::ProjectsPane, + provenance::ProvenancePane, + sources::SourcesPane, +}; + +const PANE_COUNT: usize = 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pane { + Artifacts = 0, + Provenance = 1, + Projects = 2, + Sources = 3, +} + +#[derive(Debug, Clone)] +pub struct App { + root: PathBuf, + providers: Vec<(String, String)>, + watched_locations: Vec, + view: DashboardView, + focused: Pane, + should_quit: bool, + pub palette_error: Option, + preview: Option, + artifacts: ArtifactsPane, + provenance: ProvenancePane, + projects: ProjectsPane, + sources: SourcesPane, + palette: Palette, +} + +impl App { + pub fn load(root: PathBuf) -> Result { + let providers = load_provider_targets(&root); + let watched_locations = watchlist::watched_locations(); + let view = services::build_view(&root, &providers, &watched_locations)?; + Ok(Self::from_view(root, providers, watched_locations, view)) + } + + #[must_use] + pub fn from_view( + root: PathBuf, + providers: Vec<(String, String)>, + watched_locations: Vec, + view: DashboardView, + ) -> Self { + let artifacts = ArtifactsPane::new(view.clone()); + let provenance = ProvenancePane::new(view.clone()); + let projects = ProjectsPane::new(view.clone()); + let sources = SourcesPane::new(view.clone(), watched_locations.clone()); + let mut app = Self { + root, + providers, + watched_locations, + view, + focused: Pane::Artifacts, + should_quit: false, + palette_error: None, + preview: None, + artifacts, + provenance, + projects, + sources, + palette: Palette::new(), + }; + app.sync_components(); + app + } + + pub fn refresh(&mut self) -> Result<(), Error> { + self.view = services::build_view(&self.root, &self.providers, &self.watched_locations)?; + self.sync_snapshot(); + self.sync_components(); + Ok(()) + } + + pub fn render(&mut self, frame: &mut Frame<'_>) { + if let Some(preview) = self.preview.as_mut() { + preview.render(frame, frame.area()); + return; + } + self.sync_components(); + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(3)]) + .split(frame.area()); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(layout[0]); + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(rows[0]); + let bottom = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(rows[1]); + + self.artifacts.render(frame, top[0]); + self.provenance.render(frame, top[1]); + self.projects.render(frame, bottom[0]); + self.sources.render(frame, bottom[1]); + self.palette + .render_with_error(frame, layout[1], self.palette_error.as_deref()); + } + + pub fn request_quit(&mut self) { + self.should_quit = true; + } + + #[must_use] + pub fn is_preview_open(&self) -> bool { + self.preview.is_some() + } + + pub fn open_preview(&mut self) { + if let Some(artifact) = self.artifacts.selected_artifact() { + self.preview = Some(ArtifactPreview::from_artifact(artifact)); + } + } + + pub fn close_preview(&mut self) { + self.preview = None; + } + + pub fn preview_scroll_down(&mut self, amount: u16) { + if let Some(preview) = self.preview.as_mut() { + preview.scroll_down(amount); + } + } + + pub fn preview_scroll_up(&mut self, amount: u16) { + if let Some(preview) = self.preview.as_mut() { + preview.scroll_up(amount); + } + } + + pub fn preview_scroll_to_top(&mut self) { + if let Some(preview) = self.preview.as_mut() { + preview.scroll_to_top(); + } + } + + pub fn preview_scroll_to_bottom(&mut self) { + if let Some(preview) = self.preview.as_mut() { + preview.scroll_to_bottom(); + } + } + + #[must_use] + pub fn should_quit(&self) -> bool { + self.should_quit + } + + #[must_use] + pub fn is_palette_open(&self) -> bool { + self.palette.is_open() + } + + pub fn open_palette(&mut self) { + self.palette_error = None; + self.palette.open(); + } + + pub fn close_palette(&mut self) { + self.palette.close(); + } + + pub fn palette_key(&mut self, key: KeyEvent) { + let _ = self.palette.on_key(key); + } + + pub fn execute_palette(&mut self) -> Result<(), Error> { + let command = self.palette.take_command(); + self.execute_palette_command(command) + } + + pub fn execute_palette_command(&mut self, command: PaletteCommand) -> Result<(), Error> { + self.palette_error = None; + match command { + PaletteCommand::Refresh => self.refresh(), + PaletteCommand::Quit => { + self.request_quit(); + Ok(()) + } + PaletteCommand::Find(query) => { + self.sources.set_query(query); + self.focused = Pane::Sources; + Ok(()) + } + PaletteCommand::Empty => Ok(()), + PaletteCommand::Unknown(verb) => { + self.palette_error = Some(format!("unknown command: {verb}")); + Ok(()) + } + } + } + + pub fn focus_next(&mut self) { + self.focused = pane_from_index((self.focused as usize + 1) % PANE_COUNT); + } + + pub fn focus_previous(&mut self) { + self.focused = pane_from_index((self.focused as usize + PANE_COUNT - 1) % PANE_COUNT); + } + + pub fn focused_key(&mut self, key: KeyEvent) { + match self.focused { + Pane::Artifacts => { + let _ = self.artifacts.on_key(key); + } + Pane::Provenance => { + let _ = self.provenance.on_key(key); + } + Pane::Projects => { + let _ = self.projects.on_key(key); + } + Pane::Sources => { + let _ = self.sources.on_key(key); + } + } + } + + fn sync_components(&mut self) { + self.artifacts.set_focused(self.focused == Pane::Artifacts); + self.provenance + .set_focused(self.focused == Pane::Provenance); + self.projects.set_focused(self.focused == Pane::Projects); + self.sources.set_focused(self.focused == Pane::Sources); + + let selected = self.artifacts.selected_artifact().cloned(); + self.provenance.set_selected_artifact(selected); + } + + fn sync_snapshot(&mut self) { + self.artifacts.set_view(self.view.clone()); + self.provenance.set_view(self.view.clone()); + self.projects.set_view(self.view.clone()); + self.sources + .set_view(self.view.clone(), self.watched_locations.clone()); + } +} + +#[must_use] +pub fn load_provider_targets(root: &Path) -> Vec<(String, String)> { + let merged = config::load_merged_config(root).unwrap_or_default(); + let Ok(providers) = config::load_providers(&merged) else { + return Vec::new(); + }; + let mut targets: Vec<(String, String)> = providers + .into_iter() + .map(|(name, config)| (name, config.target)) + .collect(); + targets.sort_by(|a, b| a.0.cmp(&b.0)); + targets +} + +fn pane_from_index(index: usize) -> Pane { + match index { + 0 => Pane::Artifacts, + 1 => Pane::Provenance, + 2 => Pane::Projects, + _ => Pane::Sources, + } +} diff --git a/src/tui/components/artifacts.rs b/src/tui/components/artifacts.rs new file mode 100644 index 0000000..14caad5 --- /dev/null +++ b/src/tui/components/artifacts.rs @@ -0,0 +1,188 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, +}; + +use commands::view::{ArtifactView, DashboardView}; + +use super::{Component, Outcome}; + +#[derive(Debug, Clone)] +pub struct ArtifactsPane { + view: DashboardView, + selected: usize, + focused: bool, +} + +impl ArtifactsPane { + #[must_use] + pub fn new(view: DashboardView) -> Self { + Self { + view, + selected: 0, + focused: false, + } + } + + pub fn set_view(&mut self, view: DashboardView) { + self.view = view; + self.clamp_selection(); + } + + pub fn set_focused(&mut self, focused: bool) { + self.focused = focused; + } + + #[must_use] + pub fn selected_artifact(&self) -> Option<&ArtifactView> { + self.artifact_refs() + .get(self.selected) + .and_then(|(module_index, artifact_index)| { + self.view + .modules + .get(*module_index) + .and_then(|module| module.artifacts.get(*artifact_index)) + }) + } + + fn artifact_refs(&self) -> Vec<(usize, usize)> { + self.view + .modules + .iter() + .enumerate() + .flat_map(|(module_index, module)| { + module + .artifacts + .iter() + .enumerate() + .map(move |(artifact_index, _)| (module_index, artifact_index)) + }) + .collect() + } + + fn clamp_selection(&mut self) { + let len = self.artifact_refs().len(); + if len == 0 { + self.selected = 0; + } else if self.selected >= len { + self.selected = len - 1; + } + } + + fn move_down(&mut self) { + let len = self.artifact_refs().len(); + if self.selected + 1 < len { + self.selected += 1; + } + } + + fn move_up(&mut self) { + self.selected = self.selected.saturating_sub(1); + } + + fn border_style(&self) -> Style { + if self.focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + } + } +} + +impl Component for ArtifactsPane { + fn render(&self, frame: &mut Frame<'_>, area: Rect) { + let outer = Block::default() + .title(" Artifacts ") + .borders(Borders::ALL) + .border_style(self.border_style()); + let inner = outer.inner(area); + frame.render_widget(outer, area); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(inner); + + let mut selected_seen = 0usize; + let mut items = Vec::new(); + for module in &self.view.modules { + items.push(ListItem::new(Line::from(Span::styled( + module.name.clone(), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )))); + for (kind, artifacts) in module.artifacts_by_kind() { + for artifact in artifacts { + let is_selected = selected_seen == self.selected; + selected_seen += 1; + let style = if is_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + items.push(ListItem::new(Line::from(vec![ + Span::raw(" "), + Span::styled(artifact.name.clone(), style), + Span::styled(format!(" {kind}"), style.fg(Color::Gray)), + ]))); + } + } + } + if items.is_empty() { + items.push(ListItem::new("no artifacts")); + } + + frame.render_widget(List::new(items), chunks[0]); + + let detail = match self.selected_artifact() { + Some(artifact) => { + let body = if artifact.content_body.is_empty() { + artifact.content_preview.as_str() + } else { + artifact.content_body.as_str() + }; + Text::from(vec![ + Line::from(Span::styled( + artifact.name.clone(), + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(format!("kind: {}", artifact.kind)), + Line::from(format!("module: {}", artifact.module)), + Line::from(format!("path: {}", artifact.relative_path)), + Line::from(Span::styled( + "↵ open full preview", + Style::default().fg(Color::Cyan), + )), + Line::from(""), + Line::from(artifact.description.clone()), + Line::from(""), + Line::from(body.to_string()), + ]) + } + None => Text::from("no artifact selected"), + }; + frame.render_widget(Paragraph::new(detail).wrap(Wrap { trim: false }), chunks[1]); + } + + fn on_key(&mut self, key: KeyEvent) -> Outcome { + match key.code { + KeyCode::Down | KeyCode::Char('j') => { + self.move_down(); + Outcome::Handled + } + KeyCode::Up | KeyCode::Char('k') => { + self.move_up(); + Outcome::Handled + } + _ => Outcome::Ignored, + } + } +} diff --git a/src/tui/components/mod.rs b/src/tui/components/mod.rs new file mode 100644 index 0000000..d92b733 --- /dev/null +++ b/src/tui/components/mod.rs @@ -0,0 +1,20 @@ +pub mod artifacts; +pub mod palette; +pub mod preview; +pub mod projects; +pub mod provenance; +pub mod sources; + +use crossterm::event::KeyEvent; +use ratatui::{Frame, layout::Rect}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + Handled, + Ignored, +} + +pub trait Component { + fn render(&self, frame: &mut Frame<'_>, area: Rect); + fn on_key(&mut self, key: KeyEvent) -> Outcome; +} diff --git a/src/tui/components/palette.rs b/src/tui/components/palette.rs new file mode 100644 index 0000000..a6ddb55 --- /dev/null +++ b/src/tui/components/palette.rs @@ -0,0 +1,115 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Style}, + widgets::{Block, Borders, Paragraph}, +}; + +use super::{Component, Outcome}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaletteCommand { + Refresh, + Quit, + Find(String), + Empty, + Unknown(String), +} + +#[derive(Debug, Clone, Default)] +pub struct Palette { + input: String, + open: bool, +} + +impl Palette { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn is_open(&self) -> bool { + self.open + } + + pub fn open(&mut self) { + self.open = true; + self.input.clear(); + } + + pub fn close(&mut self) { + self.open = false; + self.input.clear(); + } + + pub fn take_command(&mut self) -> PaletteCommand { + let command = Self::parse_command(&self.input); + self.close(); + command + } + + #[must_use] + pub fn parse_command(input: &str) -> PaletteCommand { + let trimmed = input.trim(); + if trimmed.is_empty() { + return PaletteCommand::Empty; + } + let mut parts = trimmed.splitn(2, char::is_whitespace); + let verb = parts.next().unwrap_or_default(); + let rest = parts.next().unwrap_or_default().trim(); + match verb { + "r" | "refresh" => PaletteCommand::Refresh, + "q" | "quit" => PaletteCommand::Quit, + "find" => PaletteCommand::Find(rest.to_string()), + other => PaletteCommand::Unknown(other.to_string()), + } + } + + pub fn render_with_error(&self, frame: &mut Frame<'_>, area: Rect, error: Option<&str>) { + let border_style = if self.open { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + let text = if let Some(error) = error { + format!("error: {error}") + } else { + let prefix = if self.open { ":" } else { "" }; + format!("{prefix}{}", self.input) + }; + frame.render_widget( + Paragraph::new(text).block( + Block::default() + .title(" Command ") + .borders(Borders::ALL) + .border_style(border_style), + ), + area, + ); + } +} + +impl Component for Palette { + fn render(&self, frame: &mut Frame<'_>, area: Rect) { + self.render_with_error(frame, area, None); + } + + fn on_key(&mut self, key: KeyEvent) -> Outcome { + match key.code { + KeyCode::Backspace => { + self.input.pop(); + Outcome::Handled + } + KeyCode::Char(character) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + self.input.push(character); + Outcome::Handled + } + // TODO: numbered selection and 3-level tab-completion for v2. + _ => Outcome::Ignored, + } + } +} diff --git a/src/tui/components/preview.rs b/src/tui/components/preview.rs new file mode 100644 index 0000000..97c500a --- /dev/null +++ b/src/tui/components/preview.rs @@ -0,0 +1,108 @@ +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +use commands::view::ArtifactView; + +/// Approximate the number of rows the body occupies when word-wrapped to +/// `width`, so the scroll offset can be clamped to a reachable bottom. Counts +/// each source line as at least one row plus a row per `width` characters +/// beyond the first; wide glyphs are treated as one column (close enough to +/// keep the last line reachable without pulling in a unicode-width dependency). +fn wrapped_line_count(body: &str, width: u16) -> u16 { + if width == 0 { + return u16::try_from(body.lines().count()).unwrap_or(u16::MAX); + } + let width = usize::from(width); + let mut rows: usize = 0; + for line in body.lines() { + let chars = line.chars().count().max(1); + rows = rows.saturating_add(chars.div_ceil(width)); + } + u16::try_from(rows).unwrap_or(u16::MAX) +} + +/// Full-window scrollable view of a single artifact's body. Opened from the +/// artifacts pane with Enter, so a skill's full content is readable instead of +/// clipped into a quarter-pane detail column. +#[derive(Debug, Clone)] +pub struct ArtifactPreview { + title: String, + body: String, + scroll: u16, +} + +impl ArtifactPreview { + #[must_use] + pub fn from_artifact(artifact: &ArtifactView) -> Self { + let body = if artifact.content_body.is_empty() { + artifact.content_preview.clone() + } else { + artifact.content_body.clone() + }; + let title = format!( + " {} · {} · {} ", + artifact.name, artifact.kind, artifact.relative_path + ); + Self { + title, + body, + scroll: 0, + } + } + + pub fn scroll_down(&mut self, amount: u16) { + self.scroll = self.scroll.saturating_add(amount); + } + + pub fn scroll_up(&mut self, amount: u16) { + self.scroll = self.scroll.saturating_sub(amount); + } + + pub fn scroll_to_top(&mut self) { + self.scroll = 0; + } + + pub fn scroll_to_bottom(&mut self) { + self.scroll = u16::MAX; + } + + /// Render takes `&mut self` so the scroll offset can be clamped against the + /// real wrapped line count at the current width — the only place the true + /// content height is known. + pub fn render(&mut self, frame: &mut Frame<'_>, area: Rect) { + let block = Block::default() + .title(self.title.as_str()) + .title_bottom(Line::from(Span::styled( + " j/k · ␣/b page · g/G ends · Esc close ", + Style::default().fg(Color::DarkGray), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let inner_width = area.width.saturating_sub(2); + let inner_height = area.height.saturating_sub(2); + + let total = wrapped_line_count(&self.body, inner_width); + let max_scroll = total.saturating_sub(inner_height); + if self.scroll > max_scroll { + self.scroll = max_scroll; + } + + let paragraph = Paragraph::new(self.body.as_str()).wrap(Wrap { trim: false }); + + frame.render_widget( + paragraph + .block(block.title_top(Line::from(Span::styled( + format!(" {}/{} ", self.scroll.saturating_add(1), total.max(1)), + Style::default().fg(Color::Gray).add_modifier(Modifier::DIM), + )))) + .scroll((self.scroll, 0)), + area, + ); + } +} diff --git a/src/tui/components/projects.rs b/src/tui/components/projects.rs new file mode 100644 index 0000000..9a1306b --- /dev/null +++ b/src/tui/components/projects.rs @@ -0,0 +1,118 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +use commands::view::DashboardView; + +use super::{Component, Outcome}; + +#[derive(Debug, Clone)] +pub struct ProjectsPane { + view: DashboardView, + selected: usize, + focused: bool, +} + +impl ProjectsPane { + #[must_use] + pub fn new(view: DashboardView) -> Self { + Self { + view, + selected: 0, + focused: false, + } + } + + pub fn set_view(&mut self, view: DashboardView) { + self.view = view; + self.clamp_selection(); + } + + pub fn set_focused(&mut self, focused: bool) { + self.focused = focused; + } + + fn clamp_selection(&mut self) { + if self.view.modules.is_empty() { + self.selected = 0; + } else if self.selected >= self.view.modules.len() { + self.selected = self.view.modules.len() - 1; + } + } + + fn border_style(&self) -> Style { + if self.focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + } + } +} + +impl Component for ProjectsPane { + fn render(&self, frame: &mut Frame<'_>, area: Rect) { + let block = Block::default() + .title(" Projects + ontology ") + .borders(Borders::ALL) + .border_style(self.border_style()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let mut lines = vec![ + Line::from(Span::styled( + "no ontology configured", + Style::default().fg(Color::Yellow), + )), + Line::from(""), + Line::from(Span::styled( + "modules", + Style::default().add_modifier(Modifier::BOLD), + )), + ]; + + if self.view.modules.is_empty() { + lines.push(Line::from(" no modules")); + } else { + for (index, module) in self.view.modules.iter().enumerate() { + let style = if index == self.selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + lines.push(Line::from(Span::styled( + format!(" {}", module.name), + style, + ))); + } + } + + frame.render_widget( + Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), + inner, + ); + } + + fn on_key(&mut self, key: KeyEvent) -> Outcome { + match key.code { + KeyCode::Down | KeyCode::Char('j') => { + if self.selected + 1 < self.view.modules.len() { + self.selected += 1; + } + Outcome::Handled + } + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + Outcome::Handled + } + _ => Outcome::Ignored, + } + } +} diff --git a/src/tui/components/provenance.rs b/src/tui/components/provenance.rs new file mode 100644 index 0000000..688c3b2 --- /dev/null +++ b/src/tui/components/provenance.rs @@ -0,0 +1,161 @@ +use crossterm::event::KeyEvent; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +use commands::view::{ArtifactView, DashboardView}; + +use super::{Component, Outcome}; + +#[derive(Debug, Clone)] +pub struct ProvenancePane { + view: DashboardView, + selected_artifact: Option, + focused: bool, +} + +impl ProvenancePane { + #[must_use] + pub fn new(view: DashboardView) -> Self { + Self { + view, + selected_artifact: None, + focused: false, + } + } + + pub fn set_view(&mut self, view: DashboardView) { + self.view = view; + } + + pub fn set_selected_artifact(&mut self, artifact: Option) { + self.selected_artifact = artifact; + } + + pub fn set_focused(&mut self, focused: bool) { + self.focused = focused; + } + + fn border_style(&self) -> Style { + if self.focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + } + } +} + +impl Component for ProvenancePane { + fn render(&self, frame: &mut Frame<'_>, area: Rect) { + let block = Block::default() + .title(" Provenance + integrity ") + .borders(Borders::ALL) + .border_style(self.border_style()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let Some(artifact) = &self.selected_artifact else { + frame.render_widget(Paragraph::new("no artifact selected"), inner); + return; + }; + + let mut lines = vec![ + Line::from(vec![ + Span::styled("status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(artifact.overall_status()), + ]), + Line::from(vec![ + Span::styled("staleness: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(artifact.staleness_label()), + ]), + ]; + + if !artifact.broken_refs.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "broken references", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ))); + for broken_ref in &artifact.broken_refs { + lines.push(Line::from(format!(" {broken_ref}"))); + } + } + + if !artifact.sidecar_warning.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("sidecar: ", Style::default().fg(Color::Yellow)), + Span::raw(artifact.sidecar_warning.clone()), + ])); + } + + if !artifact.providers.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "providers", + Style::default().add_modifier(Modifier::BOLD), + ))); + for (provider, status) in &artifact.providers { + lines.push(Line::from(format!( + " {provider} -> {}", + status.status.label() + ))); + } + } + + if let Some(adoption) = &artifact.adoption { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "adoption", + Style::default().add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(format!(" source: {}", adoption.source_label))); + lines.push(Line::from(format!(" sha: {}", adoption.source_sha))); + if !adoption.transforms.is_empty() { + lines.push(Line::from(format!( + " transforms: {}", + adoption.transforms.join(", ") + ))); + } + } + + let provenance_rows: Vec<_> = self + .view + .provenance + .iter() + .flat_map(|provenance| provenance.artifacts.iter()) + .filter(|row| row.source_path.ends_with(&artifact.relative_path)) + .collect(); + if !provenance_rows.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "deployments", + Style::default().add_modifier(Modifier::BOLD), + ))); + for row in provenance_rows { + let verified = if row.verified { "verified" } else { "mismatch" }; + lines.push(Line::from(format!( + " {} -> {} [{}]", + row.source_path, row.deployed_path, row.harness + ))); + lines.push(Line::from(format!( + " {verified}; deployed {}; expected {}", + row.deployed_sha, row.expected_sha + ))); + } + } + + frame.render_widget( + Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), + inner, + ); + } + + fn on_key(&mut self, _key: KeyEvent) -> Outcome { + Outcome::Ignored + } +} diff --git a/src/tui/components/sources.rs b/src/tui/components/sources.rs new file mode 100644 index 0000000..2d6dc7a --- /dev/null +++ b/src/tui/components/sources.rs @@ -0,0 +1,172 @@ +use std::path::PathBuf; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, +}; + +use commands::view::DashboardView; + +use super::{Component, Outcome}; + +#[derive(Debug, Clone)] +pub struct SourcesPane { + view: DashboardView, + watched_locations: Vec, + query: String, + selected: usize, + focused: bool, +} + +impl SourcesPane { + #[must_use] + pub fn new(view: DashboardView, watched_locations: Vec) -> Self { + Self { + view, + watched_locations, + query: String::new(), + selected: 0, + focused: false, + } + } + + pub fn set_view(&mut self, view: DashboardView, watched_locations: Vec) { + self.view = view; + self.watched_locations = watched_locations; + self.clamp_selection(); + } + + pub fn set_focused(&mut self, focused: bool) { + self.focused = focused; + } + + pub fn set_query(&mut self, query: impl Into) { + self.query = query.into(); + self.selected = 0; + self.clamp_selection(); + } + + fn filtered_matches(&self) -> Vec { + let query = self.query.to_lowercase(); + self.view + .all_artifacts() + .into_iter() + .filter(|(artifact, _module)| query.is_empty() || artifact.matches_query(&query)) + .map(|(artifact, module)| format!("{module} / {}", artifact.name)) + .collect() + } + + fn clamp_selection(&mut self) { + let len = self.filtered_matches().len(); + if len == 0 { + self.selected = 0; + } else if self.selected >= len { + self.selected = len - 1; + } + } + + fn border_style(&self) -> Style { + if self.focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + } + } +} + +impl Component for SourcesPane { + fn render(&self, frame: &mut Frame<'_>, area: Rect) { + let block = Block::default() + .title(" Sources + watch + find ") + .borders(Borders::ALL) + .border_style(self.border_style()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Length(3), + Constraint::Min(1), + ]) + .split(inner); + + let mut watch_lines = Vec::new(); + for module in self.view.target_modules() { + watch_lines.push(Line::from(format!("target: {}", module.name))); + } + for path in &self.watched_locations { + watch_lines.push(Line::from(format!("watch: {}", path.display()))); + } + if watch_lines.is_empty() { + watch_lines.push(Line::from("no watched sources")); + } + frame.render_widget( + Paragraph::new(Text::from(watch_lines)).wrap(Wrap { trim: false }), + chunks[0], + ); + + frame.render_widget( + Paragraph::new(format!("find: {}", self.query)) + .block(Block::default().borders(Borders::ALL)), + chunks[1], + ); + + let matches = self.filtered_matches(); + let items: Vec> = if matches.is_empty() { + vec![ListItem::new("no matches")] + } else { + matches + .into_iter() + .enumerate() + .map(|(index, label)| { + let style = if index == self.selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(Line::from(Span::styled(label, style))) + }) + .collect() + }; + frame.render_widget(List::new(items), chunks[2]); + } + + fn on_key(&mut self, key: KeyEvent) -> Outcome { + match key.code { + KeyCode::Down | KeyCode::Char('j') => { + let len = self.filtered_matches().len(); + if self.selected + 1 < len { + self.selected += 1; + } + Outcome::Handled + } + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + Outcome::Handled + } + KeyCode::Backspace => { + self.query.pop(); + self.clamp_selection(); + Outcome::Handled + } + KeyCode::Char(character) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + self.query.push(character); + self.selected = 0; + self.clamp_selection(); + Outcome::Handled + } + _ => Outcome::Ignored, + } + } +} diff --git a/src/tui/event.rs b/src/tui/event.rs new file mode 100644 index 0000000..3ae0c8c --- /dev/null +++ b/src/tui/event.rs @@ -0,0 +1,49 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use commands::error::Error; + +use super::app::App; + +pub fn handle_key(app: &mut App, key: KeyEvent) -> Result<(), Error> { + if app.is_preview_open() { + handle_preview_key(app, key); + return Ok(()); + } + if app.is_palette_open() { + return handle_palette_key(app, key); + } + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => app.request_quit(), + KeyCode::Char(':') => app.open_palette(), + KeyCode::Char('r') => app.refresh()?, + KeyCode::Enter => app.open_preview(), + KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => app.focus_previous(), + KeyCode::BackTab => app.focus_previous(), + KeyCode::Tab => app.focus_next(), + _ => app.focused_key(key), + } + Ok(()) +} + +fn handle_preview_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter => app.close_preview(), + KeyCode::Down | KeyCode::Char('j') => app.preview_scroll_down(1), + KeyCode::Up | KeyCode::Char('k') => app.preview_scroll_up(1), + KeyCode::PageDown | KeyCode::Char(' ') => app.preview_scroll_down(10), + KeyCode::PageUp | KeyCode::Char('b') => app.preview_scroll_up(10), + KeyCode::Home | KeyCode::Char('g') => app.preview_scroll_to_top(), + KeyCode::End | KeyCode::Char('G') => app.preview_scroll_to_bottom(), + _ => {} + } +} + +fn handle_palette_key(app: &mut App, key: KeyEvent) -> Result<(), Error> { + match key.code { + KeyCode::Esc => app.close_palette(), + KeyCode::Enter => app.execute_palette()?, + _ => app.palette_key(key), + } + Ok(()) +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..57cd73a --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,82 @@ +pub mod app; +pub mod components; +pub mod event; + +use std::{ + io::{self, Stdout}, + path::PathBuf, + time::Duration, +}; + +use crossterm::{ + cursor::{Hide, Show}, + event as terminal_event, execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; + +use app::App; + +#[cfg(test)] +mod tests; + +type TuiTerminal = Terminal>; + +pub fn run() -> i32 { + match launch() { + Ok(()) => 0, + Err(error) => { + eprintln!("fatal: {error}"); + 2 + } + } +} + +fn launch() -> Result<(), Box> { + let mut app = App::load(PathBuf::from("."))?; + let mut terminal = setup_terminal()?; + install_panic_hook(); + + let result = event_loop(&mut terminal, &mut app); + restore_terminal(&mut terminal); + result +} + +fn setup_terminal() -> io::Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, Hide)?; + let backend = CrosstermBackend::new(stdout); + Terminal::new(backend) +} + +fn restore_terminal(terminal: &mut TuiTerminal) { + let _ = disable_raw_mode(); + let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, Show); + let _ = terminal.show_cursor(); +} + +fn restore_terminal_without_backend() { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen, Show); +} + +fn install_panic_hook() { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + restore_terminal_without_backend(); + default_hook(panic_info); + })); +} + +fn event_loop(terminal: &mut TuiTerminal, app: &mut App) -> Result<(), Box> { + while !app.should_quit() { + terminal.draw(|frame| app.render(frame))?; + if terminal_event::poll(Duration::from_millis(200))? + && let terminal_event::Event::Key(key) = terminal_event::read()? + { + event::handle_key(app, key)?; + } + } + Ok(()) +} diff --git a/src/tui/tests.rs b/src/tui/tests.rs new file mode 100644 index 0000000..acdbbd0 --- /dev/null +++ b/src/tui/tests.rs @@ -0,0 +1,104 @@ +use std::path::PathBuf; + +use ratatui::{Terminal, backend::TestBackend}; + +use commands::view::{ArtifactView, DashboardView, ModuleView, StatusSummary}; + +use super::{ + app::App, + components::palette::{Palette, PaletteCommand}, +}; + +fn fixture_view() -> DashboardView { + let artifact = ArtifactView { + name: "BuildSkill".to_string(), + kind: "skills".to_string(), + module: "forge-core".to_string(), + relative_path: "skills/BuildSkill/SKILL.md".to_string(), + description: "Build forge skills".to_string(), + content_preview: "preview".to_string(), + content_body: "full body".to_string(), + ..ArtifactView::default() + }; + + DashboardView { + modules: vec![ModuleView { + name: "forge-core".to_string(), + version: "0.1.0".to_string(), + description: "core module".to_string(), + source_uri: "https://github.com/N4M3Z/forge-core".to_string(), + is_target: false, + artifacts: vec![artifact], + }], + summary: StatusSummary::default(), + provenance: Vec::new(), + adrs: Vec::new(), + } +} + +fn fixture_app() -> App { + App::from_view(PathBuf::from("."), Vec::new(), Vec::new(), fixture_view()) +} + +#[test] +fn app_constructs_from_fixture_snapshot() { + let app = fixture_app(); + assert!(!app.should_quit()); +} + +#[test] +fn artifacts_pane_renders_artifact_name() { + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).expect("test backend"); + let mut app = fixture_app(); + + terminal.draw(|frame| app.render(frame)).expect("render"); + + let rendered: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect(); + assert!(rendered.contains("BuildSkill")); +} + +#[test] +fn palette_parses_refresh() { + assert_eq!(Palette::parse_command("refresh"), PaletteCommand::Refresh); + assert_eq!(Palette::parse_command(" r "), PaletteCommand::Refresh); +} + +#[test] +fn unknown_palette_command_sets_error() { + let mut app = fixture_app(); + app.execute_palette_command(PaletteCommand::Unknown("wat".to_string())) + .expect("unknown command is nonfatal"); + assert_eq!(app.palette_error.as_deref(), Some("unknown command: wat")); +} + +#[test] +fn enter_opens_full_preview_rendering_the_body() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).expect("test backend"); + let mut app = fixture_app(); + assert!(!app.is_preview_open()); + + app.open_preview(); + assert!(app.is_preview_open()); + + terminal.draw(|frame| app.render(frame)).expect("render"); + let rendered: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect(); + assert!(rendered.contains("full body")); + assert!(rendered.contains("SKILL.md")); + + app.close_preview(); + assert!(!app.is_preview_open()); +}