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,
);
}
}
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}"))
}
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()
}
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())
}
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; }
}
#[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()));
}
}