travelagent 1.10.3

Agent-first TUI code review tool
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;

/// Phase I5-2b: flat spec-row view used by both the renderer and the
/// keybinding handlers so cursor position and action targets stay in
/// lockstep. Only active (non-resolved) specs are included — resolved
/// ones are persisted on the session but don't appear in the list.
#[derive(Debug, Clone)]
pub struct SpecRow {
    /// Full spec-comment id — used by the action layer to locate the
    /// comment in `ReviewSession` and by status lookups.
    pub spec_id: String,
    /// Short text the preview column shows (single line, truncated).
    pub preview: String,
    /// Display string for the scope column (`review`, `file`, `line N`,
    /// `orphaned (was-line N)`, etc.). Precomputed so the renderer
    /// stays cheap.
    pub scope: String,
    /// Linkage status for this spec as of the last scan.
    pub status: SparringStatus,
    /// Group the row belongs to. Review-scope specs have `None`;
    /// everything else groups under the path that owns the row.
    pub group: Option<PathBuf>,
}

/// Collect the flat active-spec list. Ordering matches what the user
/// sees top-to-bottom: review-scope first, then per-path groups in
/// sorted order; within each path file-scope, then line-scope sorted
/// by line, then orphaned. Resolved specs are excluded to match the
/// `spec_count()` contract.
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();

    // Clamp cursor so list mutations (accept/drop) don't leave it dangling.
    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(""));

        // Column header: status | scope | preview.
        lines.push(Line::from(Span::styled(
            "   status    scope                  spec",
            dim,
        )));

        // Track the most recently emitted group header so we don't
        // repeat it between adjacent rows from the same path. Use a
        // bool sentinel to distinguish "haven't emitted yet" from
        // "last emission was Review-scope" — both map to `None` as
        // a path value.
        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
}

/// First non-empty line of `body`, truncated to at most `max_chars`
/// characters (not bytes) with an ellipsis on overflow.
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); // 10 + '…'
        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");
    }
}