use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use std::path::PathBuf;
use crate::app::App;
use crate::ui::styles;
use travelagent_core::model::{AnchorState, Comment, CommentType, LineSide};
use travelagent_core::sparring::SparringStatus;
#[derive(Debug, Clone)]
pub struct SpecRow {
pub spec_id: String,
pub preview: String,
pub scope: String,
pub status: SparringStatus,
pub group: Option<PathBuf>,
}
pub fn build_spec_rows(app: &App) -> Vec<SpecRow> {
let session = app.engine.session();
let mut rows: Vec<SpecRow> = Vec::new();
let status_of = |app: &App, id: &str| {
*app.spec_statuses
.get(id)
.unwrap_or(&SparringStatus::Unlinked)
};
for c in &session.review_comments {
if !is_active_spec(c) {
continue;
}
rows.push(SpecRow {
spec_id: c.id.clone(),
preview: single_line_preview(&c.content, 60),
scope: "review".to_string(),
status: status_of(app, &c.id),
group: None,
});
}
let mut paths: Vec<_> = session.files.keys().collect();
paths.sort();
for path in paths {
let Some(fr) = session.files.get(path) else {
continue;
};
for c in fr.file_comments.iter().filter(|c| is_active_spec(c)) {
rows.push(SpecRow {
spec_id: c.id.clone(),
preview: single_line_preview(&c.content, 60),
scope: "file".to_string(),
status: status_of(app, &c.id),
group: Some(path.clone()),
});
}
let mut line_rows: Vec<(u32, &Comment)> = fr
.line_comments
.iter()
.flat_map(|(line, cs)| {
cs.iter()
.filter(|c| is_active_spec(c))
.map(move |c| (*line, c))
})
.collect();
line_rows.sort_by_key(|(line, _)| *line);
for (line, c) in line_rows {
let side = match c.side {
Some(LineSide::Old) => "old",
_ => "new",
};
rows.push(SpecRow {
spec_id: c.id.clone(),
preview: single_line_preview(&c.content, 60),
scope: format!("line {line} {side}"),
status: status_of(app, &c.id),
group: Some(path.clone()),
});
}
for c in fr.orphaned_comments.iter().filter(|c| is_active_spec(c)) {
let was_line = match c.anchor.as_ref() {
Some(AnchorState::Orphaned { was_line, .. }) => Some(*was_line),
_ => None,
};
let scope = match was_line {
Some(l) => format!("orphaned was-line {l}"),
None => "orphaned".to_string(),
};
rows.push(SpecRow {
spec_id: c.id.clone(),
preview: single_line_preview(&c.content, 60),
scope,
status: status_of(app, &c.id),
group: Some(path.clone()),
});
}
}
rows
}
fn is_active_spec(c: &Comment) -> bool {
matches!(c.comment_type, CommentType::Spec) && !c.resolved
}
pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
let theme = &app.theme;
let dim = styles::dim_style(theme);
let panel = styles::panel_style(theme);
let rows = build_spec_rows(app);
let total = rows.len();
if total == 0 {
app.sparring_cursor = 0;
} else if app.sparring_cursor >= total {
app.sparring_cursor = total - 1;
}
let linked = rows
.iter()
.filter(|r| matches!(r.status, SparringStatus::Linked))
.count();
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Sparring Review — reconciliation",
Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
)));
lines.push(Line::from(""));
if total == 0 {
lines.push(Line::from(Span::styled(" No active specs.", dim)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Use `:spec` in Sparring Review mode, then add a comment on the diff.",
dim,
)));
lines.push(Line::from(Span::styled(
" Agents generate tests via `trv_write_test_from_spec` / `trv_propose_accept_test`.",
dim,
)));
} else {
lines.push(Line::from(format!(
" {} spec{}, {linked} linked.",
total,
if total == 1 { "" } else { "s" }
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" status scope spec",
dim,
)));
let mut emitted_group: Option<Option<PathBuf>> = None;
for (i, row) in rows.iter().enumerate() {
let changed = match &emitted_group {
None => true,
Some(prev) => prev != &row.group,
};
if changed {
let label = match &row.group {
None => " Review-scope specs".to_string(),
Some(p) => format!(" `{}`", p.display()),
};
lines.push(Line::from(Span::styled(
label,
Style::default().add_modifier(Modifier::BOLD),
)));
emitted_group = Some(row.group.clone());
}
let status_label = match row.status {
SparringStatus::Unlinked => "[unlinked]",
SparringStatus::Linked => "[linked] ",
SparringStatus::Reconciling => "[reconcil]",
};
let status_style = match row.status {
SparringStatus::Unlinked => Style::default().fg(theme.fg_dim),
SparringStatus::Linked => Style::default()
.fg(theme.fg_primary)
.add_modifier(Modifier::BOLD),
SparringStatus::Reconciling => Style::default().fg(theme.pending),
};
let cursor_mark = if i == app.sparring_cursor { "> " } else { " " };
let row_spans = vec![
Span::raw(cursor_mark),
Span::styled(status_label.to_string(), status_style),
Span::raw(format!(" {:<18} ", truncate_scope(&row.scope, 18))),
Span::raw(row.preview.clone()),
];
let mut line = Line::from(row_spans);
if i == app.sparring_cursor {
line = line.style(styles::selected_style(theme));
}
lines.push(line);
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" j/k: move a: accept d: drop r: reshape",
dim,
)));
}
let paragraph = Paragraph::new(lines).style(panel);
frame.render_widget(paragraph, area);
}
fn truncate_scope(s: &str, max_chars: usize) -> String {
let mut iter = s.chars();
let mut out: String = iter.by_ref().take(max_chars.saturating_sub(1)).collect();
if iter.next().is_some() {
out.push('…');
}
out
}
fn single_line_preview(body: &str, max_chars: usize) -> String {
let first = body.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
let mut iter = first.chars();
let mut out: String = iter.by_ref().take(max_chars).collect();
if iter.next().is_some() {
out.push('…');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_line_preview_truncates_with_ellipsis() {
let long = "a".repeat(100);
let out = single_line_preview(&long, 10);
assert_eq!(out.chars().count(), 11); assert!(out.ends_with('…'));
}
#[test]
fn single_line_preview_skips_empty_leading_lines() {
let body = "\n\n hello world\nnext";
assert_eq!(single_line_preview(body, 50), " hello world");
}
#[test]
fn single_line_preview_handles_empty() {
assert_eq!(single_line_preview("", 10), "");
}
#[test]
fn truncate_scope_never_exceeds_max_chars() {
let out = truncate_scope("orphaned was-line 12345", 10);
assert_eq!(out.chars().count(), 10);
assert!(out.ends_with('…'));
}
#[test]
fn truncate_scope_short_strings_unchanged() {
assert_eq!(truncate_scope("review", 18), "review");
}
}