From 8a0df0a7cfb0ea6b6ba4569ba9c542b7b35c7711 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Thu, 9 Jul 2026 09:35:50 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20TUI=20polish=20=E2=80=94=20auto-width?= =?UTF-8?q?=20columns,=20syntax=20highlighting,=20tuicr=20line-comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Miller columns auto-size: sections and list clamp narrow, detail takes the rest (and shrinks the fixed columns first on narrow terminals). Detail rendering gains syntax highlighting — the Code tab via syntect, and the Preview tab via glow (optional binary) parsed through ansi-to-tui, both cached off the render path. The Code tab supports tuicr line-comments: tag a line ISSUE/NOTE/ SUGGESTION/PRAISE and export all as a tuicr review digest to the clipboard. syntect + ansi-to-tui are tui-feature-gated; default and dashboard builds are unchanged. --- CHANGELOG.md | 5 +- Cargo.toml | 4 +- src/tui/app.rs | 510 ++++++++++++++++++++++++++++++++++++++++++----- src/tui/event.rs | 5 + src/tui/mod.rs | 1 + src/tui/rich.rs | 197 ++++++++++++++++++ src/tui/tests.rs | 69 ++++++- 7 files changed, 740 insertions(+), 51 deletions(-) create mode 100644 src/tui/rich.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9669b07..ac14a70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - Unknown `forge ` commands now dispatch to external `forge-` scripts from the module `commands/` directory, configured extension directories, or `PATH`, keeping new capabilities out of the Rust kernel. - `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. - `forge tui` now opens an instant Miller-column dashboard at full web-dashboard parity: sections, artifact lists, tabbed artifact detail, provenance chain/deploy groups, ADRs, variants, integrity attention list, search filters/sorts, git history, Settings/Hooks/Config/Schemas file-browser sections, and a `?` help overlay driven by the same keybinding table as the footer hints. Scans run on a background thread so the shell renders immediately while module discovery continues. +- TUI Miller columns now auto-size sections/list columns while leaving the detail pane widest, render markdown previews through `glow` with ANSI parsing when available, highlight Code tabs with `syntect`, and support in-memory tuicr line-comments with clipboard export. - 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) @@ -27,10 +28,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - Dashboard scan logic moved from the dashboard binary into `commands::services`, shared by the axum dashboard and reusable by a future TUI with no behavioral change. - `forge install` and `forge deploy` default `--target` to `--source` when a `.forge` consumer manifest is present and `--target` is omitted. The consumer dir IS the place the user wants provider trees written; the previous behavior forced redundant `--target .` on every consumer-mode invocation. Module-root flows (no `.forge`) are unchanged: an omitted `--target` still resolves provider directories relative to the current working directory. (#52) -### Deferred - -- Line-level annotation export from the TUI Code tab remains follow-up work. - ### Fixed - `forge provenance` verifies source-side `.provenance/` sidecars, not just deployed targets. Pointed at a source repository or an artifact subdirectory (detected by a `module.yaml` at or above the path), it walks `.provenance/*.yaml`, resolves each `subject.name` to a repo-relative file, recomputes its SHA-256 against the recorded digest, and verifies the digests of in-repo `resolvedDependencies` (the remote `upstream` dependency is left to its recorded pin). Previously the strict typed sidecar model rejected `adopt/v1` sidecars (which carry `upstream_url` rather than `source`) and the directory walk only iterated provider kind roots, so `forge provenance skills/Foo` reported "No provenance found" and source-side drift went undetected. The model now tolerates both `assemble/v1` and `adopt/v1` schemas without changing generated sidecar output, and `--json` emits a machine-readable per-sidecar report. (#44) diff --git a/Cargo.toml b/Cargo.toml index 94daafd..c73c5b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +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"] +tui = ["dep:ratatui", "dep:crossterm", "dep:syntect", "dep:ansi-to-tui"] [dependencies] # cli @@ -63,6 +63,8 @@ open = { version = "5", optional = true } # tui (optional, excluded from full) ratatui = { version = "0.30", optional = true } crossterm = { version = "0.29", optional = true } +syntect = { version = "5.3", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"], optional = true } +ansi-to-tui = { version = "8.0.1", optional = true } [build-dependencies] sha2 = "0.10" diff --git a/src/tui/app.rs b/src/tui/app.rs index 0a42caa..ccfe645 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,5 +1,8 @@ use std::{ + collections::BTreeMap, + io::Write, path::{Path, PathBuf}, + process::{Command, Stdio}, sync::mpsc::{self, Receiver}, thread, }; @@ -27,9 +30,15 @@ use super::components::{ palette::{Palette, PaletteCommand}, preview::ArtifactPreview, }; +use super::rich; const SECTION_COUNT: usize = 13; const DETAIL_TAB_COUNT: usize = 7; +const LEFT_MIN_WIDTH: u16 = 14; +const LEFT_MAX_WIDTH: u16 = 20; +const MIDDLE_MIN_WIDTH: u16 = 24; +const MIDDLE_MAX_WIDTH: u16 = 40; +const MIN_DETAIL_WIDTH: u16 = 20; pub const KEYBINDINGS: &[(&str, &[(&str, &str)])] = &[ ( @@ -73,6 +82,8 @@ pub const KEYBINDINGS: &[(&str, &[(&str, &str)])] = &[ ("d", "diff tab"), ("c", "code tab"), ("p", "preview tab"), + ("m", "comment current code line"), + ("Y", "copy tuicr comments"), ], ), ("Global", &[("?", "help"), ("F1", "help"), ("q", "quit")]), @@ -330,6 +341,67 @@ impl ListRow { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MillerColumnWidths { + pub left: u16, + pub middle: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PreviewCache { + path: String, + width: u16, + lines: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CodeCache { + path: String, + lines: Vec>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum CommentKind { + Issue, + Note, + Suggestion, + Praise, +} + +impl CommentKind { + pub fn next(self) -> Self { + match self { + Self::Issue => Self::Note, + Self::Note => Self::Suggestion, + Self::Suggestion => Self::Praise, + Self::Praise => Self::Issue, + } + } + + fn label(self) -> &'static str { + match self { + Self::Issue => "ISSUE", + Self::Note => "NOTE", + Self::Suggestion => "SUGGESTION", + Self::Praise => "PRAISE", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LineComment { + kind: CommentKind, + text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CommentPrompt { + path: String, + line_number: usize, + kind: CommentKind, + text: String, +} + pub struct App { root: PathBuf, providers: Vec<(String, String)>, @@ -341,9 +413,18 @@ pub struct App { focused: ColumnFocus, section: Section, cached_rows: Vec, + column_widths: MillerColumnWidths, rows_dirty: bool, #[cfg(test)] row_build_count: usize, + preview_cache: Option, + code_cache: Option, + comments: BTreeMap<(String, usize), LineComment>, + comment_prompt: Option, + #[cfg(test)] + preview_cache_build_count: usize, + #[cfg(test)] + code_cache_build_count: usize, list_selected: [usize; SECTION_COUNT], detail_tab: DetailTab, detail_scroll: u16, @@ -399,9 +480,18 @@ impl App { focused: ColumnFocus::Sections, section: Section::Overview, cached_rows: Vec::new(), + column_widths: default_column_widths(), rows_dirty: true, #[cfg(test)] row_build_count: 0, + preview_cache: None, + code_cache: None, + comments: BTreeMap::new(), + comment_prompt: None, + #[cfg(test)] + preview_cache_build_count: 0, + #[cfg(test)] + code_cache_build_count: 0, list_selected: [0; SECTION_COUNT], detail_tab: DetailTab::Preview, detail_scroll: 0, @@ -434,6 +524,7 @@ impl App { self.scan_receiver = None; self.toast = Some("scan complete".to_string()); self.invalidate_rows(); + self.invalidate_detail_caches(); self.clamp_list_selection(); } Ok(Err(error)) => { @@ -501,12 +592,13 @@ impl App { ]) .split(frame.area()); self.render_status(frame, layout[0]); + let fitted_widths = fit_miller_widths(layout[1].width, self.column_widths); let columns = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), + Constraint::Length(fitted_widths.left), + Constraint::Length(fitted_widths.middle), + Constraint::Min(0), ]) .split(layout[1]); self.render_sections(frame, columns[0]); @@ -541,7 +633,15 @@ impl App { } fn render_footer(&self, frame: &mut Frame<'_>, area: Rect) { - let text = if self.palette.is_open() || self.palette_error.is_some() { + let text = if let Some(prompt) = &self.comment_prompt { + format!( + " comment [{}] {}:{} > {}", + prompt.kind.label(), + prompt.path, + prompt.line_number, + prompt.text + ) + } else if self.palette.is_open() || self.palette_error.is_some() { self.palette.display_text(self.palette_error.as_deref()) } else if let Some(toast) = &self.toast { format!(" {toast}") @@ -624,7 +724,7 @@ impl App { frame.render_widget(List::new(items), inner); } - fn render_detail(&self, frame: &mut Frame<'_>, area: Rect) { + fn render_detail(&mut self, frame: &mut Frame<'_>, area: Rect) { let block = column_block(" Detail ", self.focused == ColumnFocus::Detail); let inner = block.inner(area); frame.render_widget(block, area); @@ -635,8 +735,10 @@ impl App { ListTarget::Artifact { module, kind, name } | ListTarget::ProvenanceArtifact { module, kind, name }, ) => { - if let Some((module_view, artifact)) = self.find_artifact(&module, &kind, &name) { - self.render_artifact_detail(frame, inner, module_view, artifact); + if let Some((module_index, artifact_index)) = + self.find_artifact_indices(&module, &kind, &name) + { + self.render_artifact_detail(frame, inner, module_index, artifact_index); } else { frame.render_widget(Paragraph::new("artifact not found"), inner); } @@ -696,20 +798,23 @@ impl App { } fn render_artifact_detail( - &self, + &mut self, frame: &mut Frame<'_>, area: Rect, - module: &ModuleView, - artifact: &ArtifactView, + module_index: usize, + artifact_index: usize, ) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(2), Constraint::Min(1)]) .split(area); self.render_tabs(frame, chunks[0]); + self.prepare_artifact_detail_cache(module_index, artifact_index, chunks[1].width); + let module = &self.view.modules[module_index]; + let artifact = &module.artifacts[artifact_index]; let lines = match self.detail_tab { - DetailTab::Preview => preview_lines(artifact), - DetailTab::Code => code_lines(&artifact.raw_source), + DetailTab::Preview => self.preview_cache_lines(), + DetailTab::Code => self.code_cache_lines(artifact), DetailTab::Diff => diff_lines(artifact), DetailTab::Provenance => self.provenance_lines(module, artifact), DetailTab::Frontmatter => frontmatter_lines(artifact), @@ -724,6 +829,95 @@ impl App { ); } + fn prepare_artifact_detail_cache( + &mut self, + module_index: usize, + artifact_index: usize, + width: u16, + ) { + match self.detail_tab { + DetailTab::Preview => { + let artifact = &self.view.modules[module_index].artifacts[artifact_index]; + let path = artifact.relative_path.clone(); + let cache_width = width.max(1); + let needs_build = self + .preview_cache + .as_ref() + .is_none_or(|cache| cache.path != path || cache.width != cache_width); + if needs_build { + let lines = preview_lines_for_width(artifact, cache_width); + self.preview_cache = Some(PreviewCache { + path, + width: cache_width, + lines, + }); + #[cfg(test)] + { + self.preview_cache_build_count += 1; + } + } + } + DetailTab::Code => { + let artifact = &self.view.modules[module_index].artifacts[artifact_index]; + let path = artifact.relative_path.clone(); + let needs_build = self + .code_cache + .as_ref() + .is_none_or(|cache| cache.path != path); + if needs_build { + let lines = rich::highlight_code(&path, &artifact.raw_source); + self.code_cache = Some(CodeCache { path, lines }); + #[cfg(test)] + { + self.code_cache_build_count += 1; + } + } + } + DetailTab::Diff + | DetailTab::Provenance + | DetailTab::Frontmatter + | DetailTab::History + | DetailTab::Companions => {} + } + } + + fn preview_cache_lines(&self) -> Vec> { + self.preview_cache + .as_ref() + .map_or_else(Vec::new, |cache| cache.lines.clone()) + } + + fn code_cache_lines(&self, artifact: &ArtifactView) -> Vec> { + let current_line = self.current_code_line(artifact); + let path = &artifact.relative_path; + self.code_cache.as_ref().map_or_else(Vec::new, |cache| { + cache + .lines + .iter() + .cloned() + .enumerate() + .map(|(index, mut line)| { + let line_number = index + 1; + let has_comment = self.comments.contains_key(&(path.clone(), line_number)); + if let Some(marker) = line.spans.first_mut() { + *marker = Span::styled( + if has_comment { "◆ " } else { " " }, + if has_comment { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }, + ); + } + if line_number == current_line { + line.style = selected_style(self.focused == ColumnFocus::Detail); + } + line + }) + .collect() + }) + } + fn render_tabs(&self, frame: &mut Frame<'_>, area: Rect) { let spans = DetailTab::ALL .iter() @@ -1063,6 +1257,34 @@ impl App { } } + #[must_use] + pub fn is_comment_prompt_open(&self) -> bool { + self.comment_prompt.is_some() + } + + pub fn comment_prompt_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => self.comment_prompt = None, + KeyCode::Tab => { + if let Some(prompt) = self.comment_prompt.as_mut() { + prompt.kind = prompt.kind.next(); + } + } + KeyCode::Enter => self.save_comment_prompt(), + KeyCode::Backspace => { + if let Some(prompt) = self.comment_prompt.as_mut() { + prompt.text.pop(); + } + } + KeyCode::Char(character) => { + if let Some(prompt) = self.comment_prompt.as_mut() { + prompt.text.push(character); + } + } + _ => {} + } + } + #[cfg(test)] #[must_use] pub fn row_build_count(&self) -> usize { @@ -1076,6 +1298,104 @@ impl App { } } + pub fn copy_tuicr_review(&mut self) { + if self.comments.is_empty() { + self.toast = Some("no comments to copy".to_string()); + return; + } + + let digest = self.tuicr_digest(); + let copied = copy_to_pbcopy(&digest); + if !copied { + eprintln!("{digest}"); + } + let count = self.comments.len(); + self.toast = Some(if copied { + format!("copied {count} comments") + } else { + "pbcopy unavailable".to_string() + }); + } + + #[cfg(test)] + pub fn add_comment_for_test( + &mut self, + path: impl Into, + line_number: usize, + kind: CommentKind, + text: impl Into, + ) { + self.comments.insert( + (path.into(), line_number), + LineComment { + kind, + text: text.into(), + }, + ); + } + + #[must_use] + pub fn tuicr_digest(&self) -> String { + let mut lines = vec![ + "I reviewed your code and have the following comments. Please address them." + .to_string(), + String::new(), + ]; + lines.extend(self.comments.iter().enumerate().map( + |(index, ((path, line_number), comment))| { + format!( + "{}. **[{}]** `{}:{}` - {}", + index + 1, + comment.kind.label(), + path, + line_number, + comment.text + ) + }, + )); + lines.join("\n") + } + + fn open_comment_prompt(&mut self) { + let Some(artifact) = self.selected_artifact() else { + return; + }; + let path = artifact.relative_path.clone(); + let line_number = self.current_code_line(artifact); + let (kind, text) = self + .comments + .get(&(path.clone(), line_number)) + .map_or((CommentKind::Issue, String::new()), |comment| { + (comment.kind, comment.text.clone()) + }); + self.comment_prompt = Some(CommentPrompt { + path, + line_number, + kind, + text, + }); + } + + fn save_comment_prompt(&mut self) { + let Some(prompt) = self.comment_prompt.take() else { + return; + }; + let text = prompt.text.trim().to_string(); + if text.is_empty() { + self.comments.remove(&(prompt.path, prompt.line_number)); + self.toast = Some("comment cleared".to_string()); + return; + } + self.comments.insert( + (prompt.path, prompt.line_number), + LineComment { + kind: prompt.kind, + text, + }, + ); + self.toast = Some("comment saved".to_string()); + } + fn section_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Down | KeyCode::Char('j') => { @@ -1132,6 +1452,9 @@ impl App { KeyCode::Char('6') => self.set_detail_tab(DetailTab::History), KeyCode::Char('7') => self.set_detail_tab(DetailTab::Companions), KeyCode::Tab => self.next_detail_tab(), + KeyCode::Char('m') if self.detail_tab == DetailTab::Code => { + self.open_comment_prompt(); + } _ => {} } } @@ -1151,6 +1474,7 @@ impl App { fn ensure_rows(&mut self) { if self.rows_dirty { self.cached_rows = self.build_list_rows(); + self.column_widths = column_widths_for_rows(&self.cached_rows); self.rows_dirty = false; #[cfg(test)] { @@ -1163,10 +1487,34 @@ impl App { self.rows_dirty = true; } + fn invalidate_detail_caches(&mut self) { + self.preview_cache = None; + self.code_cache = None; + } + fn cached_rows(&self) -> &[ListRow] { &self.cached_rows } + #[cfg(test)] + #[must_use] + pub fn column_widths_for_total(&mut self, total_width: u16) -> MillerColumnWidths { + self.ensure_rows(); + fit_miller_widths(total_width, self.column_widths) + } + + #[cfg(test)] + #[must_use] + pub fn preview_cache_build_count(&self) -> usize { + self.preview_cache_build_count + } + + #[cfg(test)] + #[must_use] + pub fn code_cache_build_count(&self) -> usize { + self.code_cache_build_count + } + fn build_list_rows(&self) -> Vec { match self.section { Section::Overview => self.overview_rows(), @@ -1482,6 +1830,13 @@ impl App { } } + fn current_code_line(&self, artifact: &ArtifactView) -> usize { + let line_count = artifact.raw_source.lines().count().max(1); + usize::from(self.detail_scroll) + .saturating_add(1) + .min(line_count) + } + fn find_artifact( &self, module: &str, @@ -1501,6 +1856,26 @@ impl App { }) } + fn find_artifact_indices( + &self, + module: &str, + kind: &str, + name: &str, + ) -> Option<(usize, usize)> { + self.view + .modules + .iter() + .enumerate() + .find(|(_, candidate)| candidate.name == module) + .and_then(|(module_index, module_view)| { + module_view + .artifacts + .iter() + .position(|artifact| artifact.kind == kind && artifact.name == name) + .map(|artifact_index| (module_index, artifact_index)) + }) + } + fn find_adr(&self, repo: &str, id: &str) -> Option<&Adr> { self.view .adrs @@ -1784,6 +2159,55 @@ fn column_block(title: &str, focused: bool) -> Block<'_> { }) } +fn default_column_widths() -> MillerColumnWidths { + MillerColumnWidths { + left: LEFT_MIN_WIDTH, + middle: MIDDLE_MIN_WIDTH, + } +} + +fn column_widths_for_rows(rows: &[ListRow]) -> MillerColumnWidths { + let section_label_width = Section::ALL + .iter() + .map(|section| section.label().chars().count()) + .max() + .unwrap_or_default(); + let left = + usize_to_u16(section_label_width.saturating_add(6)).clamp(LEFT_MIN_WIDTH, LEFT_MAX_WIDTH); + + let row_label_width = rows + .iter() + .map(|row| row.label.chars().count()) + .max() + .unwrap_or_default(); + let middle = + usize_to_u16(row_label_width.saturating_add(8)).clamp(MIDDLE_MIN_WIDTH, MIDDLE_MAX_WIDTH); + + MillerColumnWidths { left, middle } +} + +fn fit_miller_widths(total_width: u16, desired: MillerColumnWidths) -> MillerColumnWidths { + let mut left = desired.left; + let mut middle = desired.middle; + let required = left.saturating_add(middle).saturating_add(MIN_DETAIL_WIDTH); + if required <= total_width { + return MillerColumnWidths { left, middle }; + } + + let mut overflow = required.saturating_sub(total_width); + let middle_cut = middle.min(overflow); + middle = middle.saturating_sub(middle_cut); + overflow = overflow.saturating_sub(middle_cut); + let left_cut = left.min(overflow); + left = left.saturating_sub(left_cut); + + MillerColumnWidths { left, middle } +} + +fn usize_to_u16(value: usize) -> u16 { + u16::try_from(value).unwrap_or(u16::MAX) +} + fn selected_style(focused: bool) -> Style { if focused { Style::default() @@ -1858,7 +2282,7 @@ fn hint_row() -> String { .join(" · ") } -fn preview_lines(artifact: &ArtifactView) -> Vec> { +fn preview_lines_for_width(artifact: &ArtifactView, width: u16) -> Vec> { let mut lines = vec![ Line::from(Span::styled( format!( @@ -1889,42 +2313,19 @@ fn preview_lines(artifact: &ArtifactView) -> Vec> { lines.push(Line::from(artifact.description.clone())); } lines.push(Line::from("")); - lines.extend( - artifact - .content_body - .lines() - .map(|line| Line::from(line.to_string())), - ); - if artifact.content_body.is_empty() { - lines.extend( - artifact - .content_preview - .lines() - .map(|line| Line::from(line.to_string())), - ); + let body = if artifact.content_body.is_empty() { + artifact.content_preview.as_str() + } else { + artifact.content_body.as_str() + }; + if let Some(glow_lines) = rich::render_markdown_with_glow(body, width) { + lines.extend(glow_lines); + return lines; } + lines.extend(body.lines().map(|line| Line::from(line.to_string()))); lines } -fn code_lines(source: &str) -> Vec> { - if source.is_empty() { - return vec![Line::from("no raw source")]; - } - source - .lines() - .enumerate() - .map(|(index, line)| { - Line::from(vec![ - Span::styled( - format!("{:>4} ", index + 1), - Style::default().fg(Color::DarkGray), - ), - Span::raw(line.to_string()), - ]) - }) - .collect() -} - fn diff_lines(artifact: &ArtifactView) -> Vec> { let mut lines = vec![ Line::from(Span::styled( @@ -2020,6 +2421,25 @@ fn companion_lines(artifact: &ArtifactView) -> Vec> { lines } +fn copy_to_pbcopy(text: &str) -> bool { + let Ok(mut child) = Command::new("pbcopy") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + else { + return false; + }; + let Some(mut stdin) = child.stdin.take() else { + return false; + }; + if stdin.write_all(text.as_bytes()).is_err() { + return false; + } + drop(stdin); + child.wait().is_ok_and(|status| status.success()) +} + fn canonical_source(source: &str) -> String { source.trim_end_matches(".git").to_string() } diff --git a/src/tui/event.rs b/src/tui/event.rs index f6b456e..cc9ceba 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -18,6 +18,10 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { handle_palette_key(app, key); return; } + if app.is_comment_prompt_open() { + app.comment_prompt_key(key); + return; + } if app.is_search_input_active() && matches!( key.code, @@ -42,6 +46,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { app.set_section_by_number(9); } KeyCode::Char('r') => app.refresh(), + KeyCode::Char('Y') => app.copy_tuicr_review(), KeyCode::Char('y') => app.copy_selected(), KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => app.drill_or_expand(), KeyCode::Left | KeyCode::Char('h') => app.move_back(), diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 774ff3f..2cdd9fd 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,6 +1,7 @@ pub mod app; pub mod components; pub mod event; +mod rich; use std::{ io::{self, Stdout}, diff --git a/src/tui/rich.rs b/src/tui/rich.rs new file mode 100644 index 0000000..4cd1e17 --- /dev/null +++ b/src/tui/rich.rs @@ -0,0 +1,197 @@ +use std::{ + io::Write, + path::Path, + process::{Command, Stdio}, + sync::OnceLock, +}; + +use ansi_to_tui::IntoText as _; +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, +}; +use syntect::{ + easy::HighlightLines, + highlighting::{Color as SyntectColor, FontStyle, Style as SyntectStyle, ThemeSet}, + parsing::SyntaxSet, +}; + +pub fn render_markdown_with_glow(body: &str, width: u16) -> Option>> { + if body.is_empty() || width == 0 { + return None; + } + + let mut child = Command::new("glow") + .args(["-s", "dark", "-w", &width.to_string()]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + let mut stdin = child.stdin.take()?; + stdin.write_all(body.as_bytes()).ok()?; + drop(stdin); + + let output = child.wait_with_output().ok()?; + if !output.status.success() { + return None; + } + + let text = output.stdout.into_text().ok()?; + Some(text_to_static_lines(text)) +} + +pub fn highlight_code(path: &str, source: &str) -> Vec> { + if source.is_empty() { + return vec![Line::from(vec![ + Span::styled(" ", Style::default().fg(Color::DarkGray)), + Span::styled(" 1 ", Style::default().fg(Color::DarkGray)), + Span::raw("no raw source"), + ])]; + } + + let (syntax_set, theme_set) = syntax_assets(); + let syntax = syntax_set + .find_syntax_by_path(path) + .or_else(|| extension(path).and_then(|ext| syntax_set.find_syntax_by_extension(ext))) + .unwrap_or_else(|| syntax_set.find_syntax_plain_text()); + let theme = theme_set + .themes + .get("base16-ocean.dark") + .or_else(|| theme_set.themes.values().next()); + + let Some(theme) = theme else { + return numbered_plain_lines(source); + }; + + let mut highlighter = HighlightLines::new(syntax, theme); + source + .lines() + .enumerate() + .map(|(index, line)| { + let mut spans = vec![ + Span::styled(" ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("{:>4} ", index + 1), + Style::default().fg(Color::DarkGray), + ), + ]; + match highlighter.highlight_line(line, syntax_set) { + Ok(ranges) => { + spans.extend( + ranges + .into_iter() + .filter(|(_, text)| !text.is_empty()) + .map(|(style, text)| Span::styled(text.to_string(), tui_style(style))), + ); + } + Err(_) => spans.push(Span::raw(line.to_string())), + } + Line::from(spans) + }) + .collect() +} + +fn numbered_plain_lines(source: &str) -> Vec> { + source + .lines() + .enumerate() + .map(|(index, line)| { + Line::from(vec![ + Span::styled(" ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("{:>4} ", index + 1), + Style::default().fg(Color::DarkGray), + ), + Span::raw(line.to_string()), + ]) + }) + .collect() +} + +fn syntax_assets() -> &'static (SyntaxSet, ThemeSet) { + static ASSETS: OnceLock<(SyntaxSet, ThemeSet)> = OnceLock::new(); + ASSETS.get_or_init(|| { + ( + SyntaxSet::load_defaults_newlines(), + ThemeSet::load_defaults(), + ) + }) +} + +fn extension(path: &str) -> Option<&str> { + Path::new(path).extension().and_then(|ext| ext.to_str()) +} + +fn tui_style(style: SyntectStyle) -> Style { + let mut modifier = Modifier::empty(); + if style.font_style.contains(FontStyle::BOLD) { + modifier |= Modifier::BOLD; + } + if style.font_style.contains(FontStyle::ITALIC) { + modifier |= Modifier::ITALIC; + } + if style.font_style.contains(FontStyle::UNDERLINE) { + modifier |= Modifier::UNDERLINED; + } + + Style::default() + .fg(tui_color(style.foreground)) + .add_modifier(modifier) +} + +fn tui_color(color: SyntectColor) -> Color { + Color::Rgb(color.r, color.g, color.b) +} + +fn text_to_static_lines(text: Text<'_>) -> Vec> { + text.lines + .into_iter() + .map(|line| { + Line::from( + line.spans + .into_iter() + .map(|span| Span::styled(span.content.into_owned(), span.style)) + .collect::>(), + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + } + + #[test] + fn highlights_rust_without_losing_text() { + let lines = highlight_code("src/main.rs", "fn main() {\n let value = 1;\n}"); + let text = lines.iter().map(line_text).collect::>().join("\n"); + + assert!(text.contains("fn main()")); + assert!( + lines + .iter() + .flat_map(|line| line.spans.iter()) + .any(|span| span.style != Style::default()) + ); + } + + #[test] + fn glow_render_is_optional() { + if Command::new("glow").arg("--version").output().is_err() { + return; + } + + let lines = render_markdown_with_glow("# Title\n\nbody", 40).expect("glow output"); + let text = lines.iter().map(line_text).collect::>().join("\n"); + assert!(text.contains("Title")); + } +} diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 92398e9..643f388 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -15,7 +15,7 @@ use commands::{ }; use super::{ - app::{App, DetailTab, KEYBINDINGS, Section}, + app::{App, CommentKind, DetailTab, KEYBINDINGS, Section}, components::palette::{Palette, PaletteCommand}, event, }; @@ -367,6 +367,73 @@ fn render_reuses_cached_list_rows_between_frames() { assert_eq!(app.row_build_count(), 1); } +#[test] +fn miller_columns_give_detail_the_remaining_width() { + let mut app = fixture_app(); + app.set_section_by_number(2); + + let widths = app.column_widths_for_total(120); + let detail_width = 120_u16.saturating_sub(widths.left + widths.middle); + + assert!((14..=20).contains(&widths.left)); + assert!((24..=40).contains(&widths.middle)); + assert!(detail_width > widths.left); + assert!(detail_width > widths.middle); +} + +#[test] +fn miller_columns_shrink_fixed_columns_before_detail_on_narrow_widths() { + let mut app = fixture_app(); + app.set_section_by_number(2); + + let widths = app.column_widths_for_total(50); + let detail_width = 50_u16.saturating_sub(widths.left + widths.middle); + + assert!(detail_width >= 20); +} + +#[test] +fn rich_detail_caches_are_reused_between_frames() { + let mut app = fixture_app(); + app.set_section_by_number(2); + app.focus_next(); + app.drill_or_expand(); + + let _ = rendered(&mut app); + assert_eq!(app.preview_cache_build_count(), 1); + let _ = rendered(&mut app); + assert_eq!(app.preview_cache_build_count(), 1); + + app.set_detail_tab(DetailTab::Code); + let _ = rendered(&mut app); + assert_eq!(app.code_cache_build_count(), 1); + let _ = rendered(&mut app); + assert_eq!(app.code_cache_build_count(), 1); +} + +#[test] +fn tuicr_digest_exports_line_comments() { + let mut app = fixture_app(); + app.add_comment_for_test( + "skills/BuildSkill/SKILL.md", + 3, + CommentKind::Issue, + "tighten the wording", + ); + + let digest = app.tuicr_digest(); + + assert!(digest.contains("**[ISSUE]** `skills/BuildSkill/SKILL.md:3`")); +} + +#[test] +fn tuicr_comment_kind_cycles_in_order() { + assert_eq!(CommentKind::Issue.next(), CommentKind::Note); + assert_eq!(CommentKind::Note.next(), CommentKind::Suggestion); + assert_eq!(CommentKind::Suggestion.next(), CommentKind::Praise); + assert_eq!(CommentKind::Praise.next(), CommentKind::Issue); +} + #[test] fn settings_section_lists_fixture_file_and_detail_body() { let mut app = fixture_app();