tix-tracker 0.3.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
use ratatui::Frame;
use ratatui::buffer::{Buffer, CellDiffOption};
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};

use crate::model::Issue;

use std::num::NonZeroU16;

pub struct DetailState {
    pub issue: Issue,
    pub scroll_offset: u16,
}

pub fn render(detail: &DetailState, f: &mut Frame, area: Rect) {
    let mut lines: Vec<Line<'static>> = Vec::new();
    let mut files_line_index: Option<usize> = None;

    let fields: Vec<(&str, String)> = vec![
        ("number", detail.issue.number.to_string()),
        ("title", detail.issue.title.clone()),
        ("status", detail.issue.status.to_string()),
        ("priority", detail.issue.priority.to_string()),
        ("created", detail.issue.created.clone()),
        ("updated", detail.issue.updated.clone()),
    ];

    for (key, value) in &fields {
        lines.push(Line::from(vec![
            Span::styled(format!("{key}: "), Style::default().fg(Color::DarkGray)),
            Span::raw(value.clone()),
        ]));
    }

    if let Some(rendered) = render_assignee(detail.issue.assignee.as_deref()) {
        lines.push(Line::from(vec![
            Span::styled("assignee: ", Style::default().fg(Color::DarkGray)),
            Span::raw(rendered),
        ]));
    }

    if let Some(ref estimate) = detail.issue.estimate {
        lines.push(Line::from(vec![
            Span::styled("estimate: ", Style::default().fg(Color::DarkGray)),
            Span::raw(estimate.clone()),
        ]));
    }

    if let Some(ref commit) = detail.issue.commit {
        lines.push(Line::from(vec![
            Span::styled("commit: ", Style::default().fg(Color::DarkGray)),
            Span::raw(commit.clone()),
        ]));
    }

    if !detail.issue.labels.is_empty() {
        lines.push(Line::from(vec![
            Span::styled("labels: ", Style::default().fg(Color::DarkGray)),
            Span::raw(detail.issue.labels.join(", ")),
        ]));
    }

    if !detail.issue.links.is_empty() {
        lines.push(Line::from(vec![
            Span::styled("links: ", Style::default().fg(Color::DarkGray)),
            Span::raw(detail.issue.links.join(", ")),
        ]));
    }

    if !detail.issue.files.is_empty() {
        lines.push(Line::from(vec![
            Span::styled("files: ", Style::default().fg(Color::DarkGray)),
            Span::raw(detail.issue.files.join(", ")),
        ]));
        files_line_index = Some(lines.len() - 1);
    }

    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(
        "---",
        Style::default().fg(Color::DarkGray),
    )));
    lines.push(Line::from(""));

    for line_text in detail.issue.body.lines() {
        lines.push(Line::from(Span::raw(line_text.to_string())));
    }

    let para = Paragraph::new(lines)
        .scroll((detail.scroll_offset, 0))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title("Detail [Esc=back e=edit A=agent]"),
        );

    f.render_widget(para, area);

    if let Some(idx) = files_line_index {
        inject_files_hyperlinks(
            f.buffer_mut(),
            area,
            idx,
            detail.scroll_offset,
            &detail.issue.files,
        );
    }
}

/// Render an assignee for the detail view (FR-030):
/// - a full git identity (`Name <email>`) is shown verbatim (identity form
///   sniffed on the `<>` pair),
/// - a bare handle gets the cosmetic `@` prefix,
/// - an already `@`-prefixed value passes through unchanged,
/// - unassigned renders no line at all.
pub fn render_assignee(assignee: Option<&str>) -> Option<String> {
    let raw = assignee?;
    if raw.contains('<') && raw.contains('>') {
        return Some(raw.to_string());
    }
    if raw.starts_with('@') {
        return Some(raw.to_string());
    }
    Some(format!("@{raw}"))
}

/// A zero-width OSC 8 terminal hyperlink wrapping `label` around `uri`, so a
/// `files:` entry renders as a clickable link in supporting terminals. The
/// sequence is invisible and only has an effect when emitted verbatim.
pub fn hyperlink(uri: &str, label: &str) -> String {
    format!("{}{}{}", osc8_start(uri), label, osc8_end())
}

fn osc8_start(uri: &str) -> String {
    format!("\u{1b}]8;;{uri}\u{1b}\\")
}

fn osc8_end() -> String {
    "\u{1b}]8;;\u{1b}\\".to_string()
}

/// Best-effort `file://` URI for a repo-relative path, resolved against the
/// current working directory (the repo root when tix runs from it). No path
/// validation — a missing file still gets a clickable entry (D3 non-goal).
fn file_uri(path: &str) -> String {
    if path.starts_with("file://") {
        return path.to_string();
    }
    let abs = std::path::absolute(path).unwrap_or_else(|_| path.into());
    format!("file://{}", abs.to_string_lossy())
}

/// Wrap every `files:` entry on its rendered line in an OSC 8 hyperlink.
///
/// ratatui's [`Buffer::set_stringn`] strips control characters from span
/// content, so the escape sequences are spliced into the already-rendered
/// cells instead: the opening sequence is prepended to the first cell of each
/// path and the reset sequence appended to its last cell. The text between
/// them passes through untouched, keeping the visible layout identical. Both
/// spliced cells are forced to single-cell width
/// ([`CellDiffOption::ForcedWidth`]) so the render diff treats the escape
/// sequence as zero-width and does not skip the following path cells as the
/// trailing columns of a wide glyph. Entries truncated by the block's inner
/// width are skipped best-effort.
fn inject_files_hyperlinks(
    buf: &mut Buffer,
    area: Rect,
    line_index: usize,
    scroll_offset: u16,
    files: &[String],
) {
    let inner_top = area.top() + 1;
    let scrolled = line_index as i32 - scroll_offset as i32;
    if scrolled < 0 {
        return;
    }
    let y0 = inner_top + scrolled as u16;
    if y0 >= area.bottom() {
        return;
    }
    let (x0, inner_right) = (area.left() + 1, area.right().saturating_sub(1));
    let mut x = x0 + "files: ".len() as u16;
    for entry in files {
        if entry.is_empty() {
            continue;
        }
        let len = entry.chars().count() as u16;
        if x + len > inner_right {
            break;
        }
        let uri = file_uri(entry);
        let first = &mut buf[(x, y0)];
        first.set_symbol(&format!("{}{}", osc8_start(&uri), first.symbol()));
        first.set_diff_option(CellDiffOption::ForcedWidth(
            NonZeroU16::new(1).expect("one is non-zero"),
        ));
        let last = &mut buf[(x + len - 1, y0)];
        last.set_symbol(&format!("{}{}", last.symbol(), osc8_end()));
        last.set_diff_option(CellDiffOption::ForcedWidth(
            NonZeroU16::new(1).expect("one is non-zero"),
        ));
        x += len + 2; // `, ` separator
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::*;

    fn sample_issue() -> Issue {
        Issue {
            number: 1,
            title: "Test Issue".into(),
            status: Status::new("todo"),
            labels: vec!["bug".into()],
            priority: Priority::High,
            assignee: Some("alice".into()),
            estimate: Some("2".into()),
            commit: None,
            gitlab_id: None,
            linear_id: None,
            mission: None,
            task: None,
            links: vec!["12".into(), "47".into()],
            files: vec!["openspec/changes/x/proposal.md".into()],
            created: "2026-01-01".into(),
            updated: "2026-01-01".into(),
            filename: "test.md".into(),
            body: "Line one\n\nLine two".into(),
        }
    }

    #[test]
    fn render_files_line_emits_clickable_hyperlink() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let backend = TestBackend::new(120, 40);
        let mut terminal = Terminal::new(backend).unwrap();
        let detail = DetailState {
            issue: sample_issue(),
            scroll_offset: 0,
        };
        terminal.draw(|f| render(&detail, f, f.area())).unwrap();

        let buf = terminal.backend().buffer();
        let text: String = buf
            .content()
            .iter()
            .map(|c| c.symbol().to_string())
            .collect::<Vec<_>>()
            .join("");
        let link = hyperlink(
            &file_uri("openspec/changes/x/proposal.md"),
            "openspec/changes/x/proposal.md",
        );
        assert!(
            text.contains(&link),
            "files must render as an OSC 8 hyperlink to the resolved file"
        );
        assert!(text.contains("links: 12, 47"));
    }

    #[test]
    fn hyperlink_wraps_label_and_keeps_file_uris() {
        assert_eq!(
            hyperlink("file:///a/b.md", "b.md"),
            "\u{1b}]8;;file:///a/b.md\u{1b}\\b.md\u{1b}]8;;\u{1b}\\"
        );
        assert_eq!(file_uri("file:///already/abs.md"), "file:///already/abs.md");
        assert!(file_uri("openspec/x.md").starts_with("file:///"));
    }

    #[test]
    fn render_smoke_small_frame() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let backend = TestBackend::new(10, 5);
        let mut terminal = Terminal::new(backend).unwrap();
        let detail = DetailState {
            issue: sample_issue(),
            scroll_offset: 0,
        };
        terminal.draw(|f| render(&detail, f, f.area())).unwrap();
    }

    #[test]
    fn render_smoke_large_frame() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let backend = TestBackend::new(80, 40);
        let mut terminal = Terminal::new(backend).unwrap();
        let detail = DetailState {
            issue: sample_issue(),
            scroll_offset: 0,
        };
        terminal.draw(|f| render(&detail, f, f.area())).unwrap();
    }

    #[test]
    fn render_scroll_offset() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let backend = TestBackend::new(80, 10);
        let mut terminal = Terminal::new(backend).unwrap();
        let detail = DetailState {
            issue: sample_issue(),
            scroll_offset: 5,
        };
        terminal.draw(|f| render(&detail, f, f.area())).unwrap();
    }

    #[test]
    fn assignee_render_identity_verbatim_bare_gets_at() {
        assert_eq!(render_assignee(None), None);
        assert_eq!(
            render_assignee(Some("Herbert Maschke <thyseus@pm.me>")),
            Some("Herbert Maschke <thyseus@pm.me>".to_string())
        );
        assert_eq!(render_assignee(Some("alice")), Some("@alice".to_string()));
        assert_eq!(render_assignee(Some("@alice")), Some("@alice".to_string()));
    }
}