use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Margin, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{
Block, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
};
use super::input::clipped_input_line;
use super::scroll::{clamp_scroll_start, scrollbar_thumb_position};
use super::task_display::{description_or_placeholder, labels_display};
use super::timestamps::local_timestamp_display;
use super::truncate::truncate_width;
use crate::query::TaskListItem;
use crate::tui::app::WidgetState;
use crate::tui::markdown::render_markdown;
use crate::tui::overlay::TextInputView;
use crate::tui::store::TuiStore;
use crate::tui::theme::{
self, ACCENT, BG, BG_PANEL, BORDER, FG, FG_DIM, FG_MUTED, INVERSE_FG, ORANGE, RED,
};
use crate::tui::widgets::{priority_short, status_chip, status_span};
use unicode_width::UnicodeWidthStr;
const DETAIL_DEPENDENCY_TREE_CAP: usize = 3;
#[derive(Debug, Clone, Copy)]
enum DependencyDirection {
Blocker,
Dependent,
}
impl DependencyDirection {
fn marker(self) -> &'static str {
match self {
Self::Blocker => "←",
Self::Dependent => "→",
}
}
}
#[derive(Debug)]
struct DetailContentLayout {
body_area: Rect,
content_area: Rect,
metadata_area: Rect,
}
#[derive(Debug)]
struct DetailContentRenderModel {
sticky_lines: Vec<Line<'static>>,
lines: Vec<Line<'static>>,
content_height: usize,
body_start: usize,
scrollbar_position: usize,
}
pub(crate) struct DetailChildHit {
pub(crate) task_id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DetailMetadataTarget {
Status,
Priority,
}
fn render_detail(
frame: &mut Frame,
item: &TaskListItem,
scroll: u16,
inline_title_editor: Option<&TextInputView>,
hovered_child_task_id: Option<&str>,
) {
let layout = detail_content_layout(frame.area());
frame.render_widget(Clear, layout.body_area);
frame.render_widget(Block::new().style(Style::new().bg(BG)), layout.body_area);
if layout.body_area.width == 0 || layout.body_area.height == 0 {
return;
}
let model = build_detail_content_model(
item,
layout.content_area,
scroll,
inline_title_editor,
hovered_child_task_id,
);
render_detail_content_from_model(frame, layout.content_area, &model);
if layout.metadata_area.width > 0 {
render_detail_metadata(frame, item, layout.metadata_area);
}
}
fn detail_content_layout(frame_area: Rect) -> DetailContentLayout {
let body = detail_body_area(frame_area);
let [content_area, metadata_area] = if body.width >= 96 {
Layout::horizontal([Constraint::Fill(1), Constraint::Length(34)]).areas(body)
} else {
[body, Rect::default()]
};
let content_area = content_area.inner(detail_content_margin());
DetailContentLayout {
body_area: body,
content_area,
metadata_area,
}
}
fn detail_body_area(frame_area: Rect) -> Rect {
let [_, body, _] = Layout::vertical([
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Length(2),
])
.areas(frame_area);
body
}
fn keycap_style() -> Style {
Style::new()
.fg(FG)
.bg(BG_PANEL)
.add_modifier(Modifier::BOLD)
}
pub(crate) fn detail_scroll_cap(
item: &TaskListItem,
terminal_width: u16,
terminal_height: u16,
) -> u16 {
let layout = detail_content_layout(Rect::new(0, 0, terminal_width, terminal_height));
let model = build_detail_content_model(item, layout.content_area, 0, None, None);
let sticky_height = model
.sticky_lines
.len()
.min(layout.content_area.height as usize);
model.content_height.saturating_sub(
layout
.content_area
.height
.saturating_sub(sticky_height as u16) as usize,
) as u16
}
fn detail_content_margin() -> Margin {
Margin {
horizontal: 2,
vertical: 1,
}
}
fn build_detail_content_model(
item: &TaskListItem,
area: Rect,
scroll: u16,
inline_title_editor: Option<&TextInputView>,
hovered_child_task_id: Option<&str>,
) -> DetailContentRenderModel {
let sticky_lines = detail_header_options(item, area.width as usize, inline_title_editor);
let body_lines = detail_body_lines(item, area.width as usize, hovered_child_task_id);
let content_height = body_lines.len().max(1);
let sticky_height = sticky_lines.len().min(area.height as usize);
let visible = (area.height as usize).saturating_sub(sticky_height);
let start = clamp_scroll_start(scroll, content_height, visible.max(1));
let lines = body_lines.into_iter().skip(start).collect();
let scrollbar_position = if content_height > visible {
scrollbar_thumb_position(start, content_height, visible.max(1))
} else {
0
};
DetailContentRenderModel {
sticky_lines,
lines,
content_height,
body_start: start,
scrollbar_position,
}
}
fn render_detail_content_from_model(
frame: &mut Frame,
area: Rect,
model: &DetailContentRenderModel,
) {
let visible = area.height as usize;
let sticky_height = model.sticky_lines.len().min(visible);
let [sticky_area, body_area] = Layout::vertical([
Constraint::Length(sticky_height as u16),
Constraint::Fill(1),
])
.areas(area);
frame.render_widget(
Paragraph::new(Text::from(model.sticky_lines.clone())).style(Style::new().fg(FG).bg(BG)),
sticky_area,
);
frame.render_widget(
Paragraph::new(Text::from(model.lines.clone())).style(Style::new().fg(FG).bg(BG)),
body_area,
);
let body_visible = body_area.height as usize;
if model.content_height > body_visible {
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.style(Style::new().fg(FG_DIM).bg(BG))
.thumb_style(Style::new().fg(FG_MUTED)),
body_area,
&mut ScrollbarState::new(model.content_height)
.position(model.scrollbar_position)
.viewport_content_length(body_visible.max(1)),
);
}
}
#[cfg(test)]
fn detail_content_lines(
item: &TaskListItem,
width: usize,
inline_title_editor: Option<&TextInputView>,
) -> Vec<Line<'static>> {
let mut lines = detail_header_options(item, width, inline_title_editor);
lines.extend(detail_body_lines(item, width, None));
lines
}
fn detail_body_lines(
item: &TaskListItem,
width: usize,
hovered_child_task_id: Option<&str>,
) -> Vec<Line<'static>> {
let mut lines = detail_epic_child_lines(item, width, hovered_child_task_id);
if !lines.is_empty() {
lines.push(Line::from(""));
}
lines.extend(quoted_block_lines(
&description_or_placeholder(&item.task.description),
width,
Style::new().fg(FG_MUTED),
));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
"NOTES",
Style::new().fg(FG_DIM).add_modifier(Modifier::BOLD),
),
Span::styled(" (", Style::new().fg(FG_DIM)),
Span::styled("n", keycap_style()),
Span::styled(" add)", Style::new().fg(FG_DIM)),
]));
if item.notes.is_empty() {
lines.push(Line::from(Span::styled("none", Style::new().fg(FG_MUTED))));
} else {
for note in &item.notes {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
local_timestamp_display(¬e.created_at),
Style::new().fg(FG_DIM),
),
Span::styled(" you", Style::new().fg(ACCENT)),
]));
lines.extend(quoted_block_lines(¬e.body, width, Style::new().fg(FG)));
}
}
lines.extend(detail_dependency_lines(item, width));
lines
}
fn detail_epic_child_lines(
item: &TaskListItem,
width: usize,
hovered_child_task_id: Option<&str>,
) -> Vec<Line<'static>> {
if !item.task.is_epic {
return Vec::new();
}
let open = item
.epic_children
.iter()
.filter(|link| link.unresolved)
.count();
let mut lines = vec![Line::from(vec![
Span::styled(
"CHILD TASKS",
Style::new().fg(FG_DIM).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" open={open} total={}", item.epic_children.len()),
Style::new().fg(FG_DIM),
),
])];
if item.epic_children.is_empty() {
lines.push(Line::from(Span::styled("none", Style::new().fg(FG_MUTED))));
return lines;
}
let links = item
.epic_children
.iter()
.filter(|link| link.unresolved)
.chain(item.epic_children.iter().filter(|link| !link.unresolved))
.collect::<Vec<_>>();
for (index, link) in links.iter().enumerate() {
let is_last = index + 1 == links.len();
let hovered = hovered_child_task_id == Some(link.task_id.as_str());
lines.extend(epic_child_tree_item_lines(link, is_last, width, hovered));
}
lines
}
fn epic_child_tree_item_lines(
link: &crate::query::TaskDependencyLink,
is_last: bool,
width: usize,
hovered: bool,
) -> Vec<Line<'static>> {
let tree_glyph = if is_last { "└─ " } else { "├─ " };
let ref_style = if hovered {
Style::new()
.fg(ACCENT)
.bg(BG_PANEL)
.add_modifier(Modifier::BOLD)
} else {
Style::new().fg(ACCENT)
};
let title_style = if hovered {
Style::new().fg(FG).bg(BG_PANEL)
} else {
Style::new().fg(FG)
};
let tree_style = if hovered {
Style::new().fg(BORDER).bg(BG_PANEL)
} else {
Style::new().fg(BORDER)
};
let gap_style = if hovered {
Style::new().fg(FG_DIM).bg(BG_PANEL)
} else {
Style::new().fg(FG_DIM)
};
let prefix = vec![
Span::styled(tree_glyph, tree_style),
Span::styled(link.display_ref.clone(), ref_style),
Span::styled(" ", gap_style),
];
dependency_node_lines_with_title_style(
prefix,
&link.title,
&link.status,
&link.priority,
width,
title_style,
)
}
fn detail_dependency_lines(item: &TaskListItem, width: usize) -> Vec<Line<'static>> {
if item.depends_on.is_empty() && item.blocks.is_empty() {
return Vec::new();
}
let mut lines = vec![Line::from("")];
if !item.depends_on.is_empty() {
lines.push(dependency_heading("WHY BLOCKED", &item.depends_on));
lines.extend(dependency_branch_lines(
&item.depends_on,
DependencyDirection::Blocker,
width,
));
}
if !item.blocks.is_empty() {
lines.push(Line::from(""));
lines.push(dependency_heading("WHAT THIS UNLOCKS", &item.blocks));
lines.extend(dependency_branch_lines(
&item.blocks,
DependencyDirection::Dependent,
width,
));
}
lines
}
fn dependency_branch_lines(
links: &[crate::query::TaskDependencyLink],
direction: DependencyDirection,
width: usize,
) -> Vec<Line<'static>> {
let visible = links.len().min(DETAIL_DEPENDENCY_TREE_CAP);
let hidden = links.len().saturating_sub(visible);
let rendered_len = visible + usize::from(hidden > 0);
let mut lines = Vec::with_capacity(rendered_len);
for (index, link) in links.iter().take(visible).enumerate() {
let is_last = index + 1 == rendered_len;
lines.extend(dependency_tree_item_lines(link, direction, is_last, width));
}
if hidden > 0 {
lines.push(Line::from(vec![
Span::styled("└─ ", Style::new().fg(BORDER)),
Span::styled(format!("+{hidden} more"), Style::new().fg(FG_MUTED)),
]));
}
lines
}
fn dependency_heading(
label: &'static str,
links: &[crate::query::TaskDependencyLink],
) -> Line<'static> {
let open = links.iter().filter(|link| link.unresolved).count();
Line::from(vec![
Span::styled(label, Style::new().fg(FG_DIM).add_modifier(Modifier::BOLD)),
Span::styled(
format!(" open={open} total={}", links.len()),
Style::new().fg(FG_DIM),
),
])
}
fn dependency_tree_item_lines(
link: &crate::query::TaskDependencyLink,
direction: DependencyDirection,
is_last: bool,
width: usize,
) -> Vec<Line<'static>> {
let tree_glyph = if is_last { "└─ " } else { "├─ " };
let prefix = vec![
Span::styled(tree_glyph, Style::new().fg(BORDER)),
Span::styled(direction.marker(), Style::new().fg(FG_DIM)),
Span::styled(" ", Style::new().fg(FG_DIM)),
Span::styled(link.display_ref.clone(), Style::new().fg(ACCENT)),
Span::styled(" ", Style::new().fg(FG_DIM)),
];
dependency_node_lines(prefix, &link.title, &link.status, &link.priority, width)
}
fn dependency_node_lines(
spans: Vec<Span<'static>>,
title: &str,
status: &str,
priority: &str,
width: usize,
) -> Vec<Line<'static>> {
dependency_node_lines_with_title_style(
spans,
title,
status,
priority,
width,
Style::new().fg(FG),
)
}
fn dependency_node_lines_with_title_style(
mut spans: Vec<Span<'static>>,
title: &str,
status: &str,
priority: &str,
width: usize,
title_style: Style,
) -> Vec<Line<'static>> {
let title_width = dependency_title_width(&spans, status, priority, width);
if title_width > 0 {
spans.push(Span::styled(
truncate_width(title, title_width),
title_style,
));
spans.push(Span::styled(" ", Style::new().fg(FG_DIM)));
spans.push(status_span(status));
spans.push(Span::styled(" ", Style::new().fg(FG_DIM)));
spans.push(Span::styled(
priority_short(priority),
theme::priority_style(priority).add_modifier(Modifier::BOLD),
));
return vec![Line::from(spans)];
}
let continuation_prefix = dependency_continuation_prefix(&spans);
let mut lines = vec![Line::from(spans)];
lines.push(Line::from(vec![
Span::styled(continuation_prefix.clone(), Style::new().fg(BORDER)),
Span::styled(
truncate_width(title, width.saturating_sub(continuation_prefix.width())),
title_style,
),
]));
lines.push(Line::from(vec![
Span::styled(continuation_prefix.clone(), Style::new().fg(BORDER)),
status_span(status),
Span::styled(" ", Style::new().fg(FG_DIM)),
Span::styled(
priority_short(priority),
theme::priority_style(priority).add_modifier(Modifier::BOLD),
),
]));
lines
}
fn dependency_continuation_prefix(prefix: &[Span<'static>]) -> String {
" ".repeat(prefix.iter().map(Span::width).sum::<usize>().min(4))
}
fn dependency_title_width(
prefix: &[Span<'static>],
status: &str,
priority: &str,
width: usize,
) -> usize {
let prefix_width: usize = prefix.iter().map(Span::width).sum();
let trailing_width = 4 + status_span(status).width() + priority_short(priority).width();
width.saturating_sub(prefix_width + trailing_width)
}
fn detail_header_options(
item: &TaskListItem,
width: usize,
inline_title_editor: Option<&TextInputView>,
) -> Vec<Line<'static>> {
vec![
detail_title_line(item, width, inline_title_editor),
Line::from(Span::styled("─".repeat(width), Style::new().fg(BORDER))),
Line::from(vec![
Span::styled(item.display_ref.clone(), Style::new().fg(FG_DIM)),
Span::styled(" ", Style::new().fg(FG_DIM)),
status_span(item.task.status.as_str()),
Span::styled(" ", Style::new().fg(FG_DIM)),
Span::styled(
priority_short(item.task.priority.as_str()),
theme::priority_style(item.task.priority.as_str()).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
]
}
fn detail_title_line(
item: &TaskListItem,
width: usize,
inline_title_editor: Option<&TextInputView>,
) -> Line<'static> {
if let Some(editor) = inline_title_editor {
let mut line = clipped_input_line(&editor.input, editor.cursor, width);
for span in &mut line.spans {
span.style = Style::new()
.fg(FG)
.add_modifier(Modifier::BOLD)
.patch(span.style);
}
return line;
}
Line::from(vec![Span::styled(
item.task.title.clone(),
Style::new().fg(FG).add_modifier(Modifier::BOLD),
)])
}
fn quoted_block_lines(body: &str, width: usize, style: Style) -> Vec<Line<'static>> {
let content_width = width.saturating_sub(3).max(1);
render_markdown(body, content_width)
.into_iter()
.map(|line| {
let mut spans = line_with_base_style(line, style).spans;
spans.insert(0, Span::styled("│ ", Style::new().fg(BORDER)));
Line::from(spans)
})
.collect()
}
fn line_with_base_style(mut line: Line<'static>, base: Style) -> Line<'static> {
for span in &mut line.spans {
span.style = base.patch(span.style);
}
line
}
fn render_detail_metadata(frame: &mut Frame, item: &TaskListItem, area: Rect) {
let block = Block::new()
.borders(Borders::LEFT)
.border_style(Style::new().fg(BORDER))
.padding(Padding::horizontal(1))
.style(Style::new().bg(BG));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(Text::from(detail_metadata_lines(item))).style(Style::new().fg(FG).bg(BG)),
inner,
);
}
fn detail_metadata_lines(item: &TaskListItem) -> Vec<Line<'static>> {
let mut lines = vec![
Line::from(Span::styled(
if item.task.is_epic {
" EPIC "
} else {
" TASK "
},
Style::new()
.fg(INVERSE_FG)
.bg(BORDER)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
metadata_label("PROJECT"),
Line::from(vec![
Span::styled(
"● ",
Style::new().fg(theme::project_color(&item.task.project_key)),
),
Span::styled(
item.task.project_key.clone(),
Style::new().fg(FG).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
metadata_label("STATUS"),
status_chip(item.task.status.as_str()),
Line::from(""),
metadata_label("PRIORITY"),
Line::from(Span::styled(
priority_short(item.task.priority.as_str()),
theme::priority_style(item.task.priority.as_str()).add_modifier(Modifier::BOLD),
)),
Line::from(""),
metadata_label("LABELS"),
Line::from(labels_display(&item.labels, ", ")),
Line::from(""),
metadata_label("REF"),
Line::from(Span::styled(
item.display_ref.clone(),
Style::new().fg(FG).add_modifier(Modifier::BOLD),
)),
Line::from(""),
metadata_label("CREATED"),
Line::from(Span::styled(
local_timestamp_display(&item.task.created_at),
Style::new().fg(FG_MUTED),
)),
Line::from(""),
metadata_label("UPDATED"),
Line::from(Span::styled(
local_timestamp_display(&item.task.updated_at),
Style::new().fg(FG_MUTED),
)),
];
lines.extend(detail_epic_metadata_lines(item));
if item.has_conflict {
lines.extend([
Line::from(""),
metadata_label("CONFLICTS"),
Line::from(Span::styled(
"yes",
Style::new().fg(ORANGE).add_modifier(Modifier::BOLD),
)),
]);
}
if item.task.deleted {
lines.extend([
Line::from(""),
metadata_label("DELETED"),
Line::from(Span::styled(
"yes",
Style::new().fg(RED).add_modifier(Modifier::BOLD),
)),
]);
}
lines
}
fn detail_epic_metadata_lines(item: &TaskListItem) -> Vec<Line<'static>> {
if !item.task.is_epic {
return Vec::new();
}
let open = item
.epic_children
.iter()
.filter(|link| link.unresolved)
.count();
let lines = vec![
Line::from(""),
metadata_label("CHILDREN"),
Line::from(Span::styled(
format!("open={open} total={}", item.epic_children.len()),
Style::new().fg(FG_DIM),
)),
];
if item.epic_children.is_empty() {
return lines
.into_iter()
.chain(std::iter::once(Line::from(Span::styled(
"none",
Style::new().fg(FG_MUTED),
))))
.collect();
}
lines
}
fn metadata_label(label: &'static str) -> Line<'static> {
Line::from(Span::styled(
label,
Style::new().fg(FG_DIM).add_modifier(Modifier::BOLD),
))
}
pub(crate) fn detail_child_task_at_position(
item: &TaskListItem,
terminal_width: u16,
terminal_height: u16,
column: u16,
row: u16,
scroll: u16,
) -> Option<DetailChildHit> {
let layout = detail_content_layout(Rect::new(0, 0, terminal_width, terminal_height));
if column < layout.content_area.x
|| column
>= layout
.content_area
.x
.saturating_add(layout.content_area.width)
|| row < layout.content_area.y
|| row
>= layout
.content_area
.y
.saturating_add(layout.content_area.height)
{
return None;
}
let model = build_detail_content_model(item, layout.content_area, scroll, None, None);
let sticky_height = model
.sticky_lines
.len()
.min(layout.content_area.height as usize) as u16;
let body_y = layout.content_area.y.saturating_add(sticky_height);
if row < body_y {
return None;
}
let body_row = row.saturating_sub(body_y) as usize;
let body_index = model.body_start.saturating_add(body_row);
let task_id = detail_epic_child_task_id_at_body_line(
item,
layout.content_area.width as usize,
body_index,
)?;
Some(DetailChildHit { task_id })
}
fn detail_epic_child_task_id_at_body_line(
item: &TaskListItem,
width: usize,
body_line_index: usize,
) -> Option<String> {
if !item.task.is_epic || body_line_index == 0 {
return None;
}
let links = item
.epic_children
.iter()
.filter(|link| link.unresolved)
.chain(item.epic_children.iter().filter(|link| !link.unresolved))
.collect::<Vec<_>>();
let mut line_index = 1;
for (index, link) in links.iter().enumerate() {
let is_last = index + 1 == links.len();
let line_count = epic_child_tree_item_lines(link, is_last, width, false).len();
if (line_index..line_index + line_count).contains(&body_line_index) {
return Some(link.task_id.clone());
}
line_index += line_count;
}
None
}
pub(crate) fn detail_metadata_target_at(
terminal_width: u16,
terminal_height: u16,
column: u16,
row: u16,
) -> Option<(DetailMetadataTarget, u16, u16)> {
let layout = detail_content_layout(Rect::new(0, 0, terminal_width, terminal_height));
if layout.metadata_area.width == 0 {
return None;
}
let body = detail_body_area(Rect::new(0, 0, terminal_width, terminal_height));
let line = metadata_content_row(layout.metadata_area, body, column, row)?;
let target = match line {
6 => DetailMetadataTarget::Status,
9 => DetailMetadataTarget::Priority,
_ => return None,
};
Some((target, column, row))
}
fn metadata_content_row(metadata_area: Rect, body: Rect, column: u16, row: u16) -> Option<u16> {
if column <= metadata_area.x
|| column >= metadata_area.x.saturating_add(metadata_area.width)
|| row < body.y
|| row >= body.y.saturating_add(body.height)
{
return None;
}
Some(row.saturating_sub(body.y))
}
pub(super) fn render_detail_underlay(
frame: &mut Frame,
store: &TuiStore,
widgets: &mut WidgetState,
scroll: u16,
inline_title_editor: Option<&TextInputView>,
hovered_child_task_id: Option<&str>,
) {
if let Some(task) = store.selected_task(widgets.table.selected()) {
render_detail(
frame,
task,
scroll,
inline_title_editor,
hovered_child_task_id,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::choices::{TaskPriority, TaskStatus};
#[test]
fn detail_content_includes_notes() {
let item = detail_test_item();
let rendered = detail_content_lines(&item, 60, None)
.iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Fix token refresh race"));
assert!(rendered.contains("Confirmed race in useTokenRefresh.ts"));
assert!(!rendered.contains("2026-06-20T12:00:00Z"));
}
#[test]
fn detail_content_renders_dependency_tree() {
let item = detail_test_item();
let rendered = detail_content_lines(&item, 80, None)
.iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("WHY BLOCKED open=1 total=1"));
assert!(rendered.contains("└─ ← APP-7KQ1"));
assert!(rendered.contains("Ship auth service"));
assert!(rendered.contains("WHAT THIS UNLOCKS open=1 total=1"));
assert!(rendered.contains("└─ → APP-7KQ2"));
assert!(rendered.contains("Write rollout notes"));
}
#[test]
fn detail_dependency_tree_caps_long_blockers() {
let mut item = detail_test_item();
item.depends_on = (0..5)
.map(|index| crate::query::TaskDependencyLink {
task_id: format!("blocker-id-{index}"),
display_ref: format!("APP-B{index}"),
title: format!("blocker {index}"),
status: "todo".to_string(),
priority: "medium".to_string(),
unresolved: true,
})
.collect();
item.blocks.clear();
let rendered = detail_content_lines(&item, 80, None)
.iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("├─ ← APP-B0"));
assert!(rendered.contains("├─ ← APP-B2"));
assert!(rendered.contains("└─ +2 more"));
assert!(!rendered.contains("APP-B3"));
}
#[test]
fn detail_dependency_tree_caps_long_dependents() {
let mut item = detail_test_item();
item.blocks = (0..5)
.map(|index| crate::query::TaskDependencyLink {
task_id: format!("dependent-id-{index}"),
display_ref: format!("APP-D{index}"),
title: format!("dependent {index}"),
status: "inbox".to_string(),
priority: "low".to_string(),
unresolved: true,
})
.collect();
item.depends_on.clear();
let rendered = detail_content_lines(&item, 80, None)
.iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("├─ → APP-D0"));
assert!(rendered.contains("├─ → APP-D2"));
assert!(rendered.contains("└─ +2 more"));
assert!(!rendered.contains("APP-D3"));
}
#[test]
fn detail_dependency_tree_truncates_titles_in_narrow_width() {
let mut item = detail_test_item();
item.depends_on[0].title =
"A very long title that should fit the dependency row".to_string();
let rendered = detail_content_lines(&item, 40, None);
let blocker_line = rendered
.iter()
.find(|line| line.to_string().contains("APP-7KQ1"))
.expect("blocker line rendered");
assert!(
blocker_line.width() <= 40,
"line width {} exceeded 40",
blocker_line.width()
);
assert!(blocker_line.to_string().contains('…'));
}
#[test]
fn detail_dependency_tree_stacks_when_narrow() {
let mut item = detail_test_item();
item.depends_on[0].title =
"A very long title that should stack in the dependency tree".to_string();
let rendered = detail_dependency_lines(&item, 20);
let rendered_text = rendered
.iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered_text.contains("└─ ← APP-7KQ1"));
assert!(rendered_text.contains("A very long tit…"));
assert!(rendered_text.contains("□ todo ● high"));
for line in rendered.into_iter().filter(|line| {
let text = line.to_string();
text.contains("APP-") || text.contains("todo")
}) {
assert!(
line.width() <= 20,
"line width {} exceeded 20: {line:?}",
line.width()
);
}
}
#[test]
fn detail_dependency_tree_is_omitted_without_links() {
let mut item = detail_test_item();
item.depends_on.clear();
item.blocks.clear();
let rendered = detail_content_lines(&item, 60, None)
.iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(!rendered.contains("WHY BLOCKED"));
assert!(!rendered.contains("WHAT THIS UNLOCKS"));
}
#[test]
fn detail_content_renders_markdown_description_and_notes() {
let mut item = detail_test_item();
item.task.description = "## Context\n- **One** item".to_string();
item.notes[0].body = "Use `aven show` after edits".to_string();
let rendered = detail_content_lines(&item, 60, None)
.iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Context"));
assert!(rendered.contains("- One item"));
assert!(rendered.contains("aven show"));
assert!(!rendered.contains("`aven show`"));
}
#[test]
fn detail_description_lines_keep_quote_rail() {
let mut item = detail_test_item();
item.task.description = "## Context\nsecond line".to_string();
let lines = detail_content_lines(&item, 60, None);
let description_lines: Vec<_> = lines
.into_iter()
.filter(|line| {
let text = line.to_string();
text.contains("Context") || text.contains("second")
})
.collect();
assert!(!description_lines.is_empty());
for line in description_lines {
assert!(
line.spans
.first()
.is_some_and(|span| span.content.as_ref() == "│ "),
"missing quote rail: {line:?}"
);
}
}
#[test]
fn detail_note_lines_keep_quote_rail() {
let mut item = detail_test_item();
item.notes[0].body = "Use `aven` here".to_string();
let lines = detail_content_lines(&item, 60, None);
let note_lines: Vec<_> = lines
.into_iter()
.filter(|line| line.to_string().contains("aven"))
.collect();
assert_eq!(note_lines.len(), 1);
let line = ¬e_lines[0];
assert!(
line.spans
.first()
.is_some_and(|span| span.content.as_ref() == "│ "),
"missing quote rail: {line:?}"
);
let code_span = line
.spans
.iter()
.find(|span| span.content.as_ref() == "aven")
.expect("missing rendered inline code span");
assert_eq!(
code_span.style.fg,
Some(crate::tui::theme::BLUE),
"inline code foreground style was not preserved"
);
assert!(
!line.to_string().contains('`'),
"inline code markers leaked into rendered note text"
);
}
#[test]
fn detail_content_lists_epic_children() {
let item = detail_test_epic_item();
let lines = detail_content_lines(&item, 80, None)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>();
let rendered = lines.join("\n");
let child_heading_index = lines
.iter()
.position(|line| line.contains("CHILD TASKS"))
.unwrap();
assert_eq!(lines[child_heading_index.saturating_sub(1)], "");
assert!(lines[child_heading_index.saturating_sub(2)].contains("active"));
assert!(rendered.contains("CHILD TASKS open=1 total=2"));
assert!(rendered.contains("├─ APP-CHLD"));
assert!(rendered.contains("Build the first child task"));
assert!(rendered.contains("└─ APP-DONE"));
assert!(rendered.contains("Finished child task"));
assert!(
rendered.find("CHILD TASKS").unwrap()
< rendered.find("Two token refresh requests").unwrap()
);
}
#[test]
fn detail_child_hit_maps_child_rows() {
let item = detail_test_epic_item();
let hit = detail_child_task_at_position(&item, 120, 30, 4, 8, 0).unwrap();
assert_eq!(hit.task_id, "child-task-id");
}
#[test]
fn hovered_detail_child_uses_link_style() {
let item = detail_test_epic_item();
let lines = detail_body_lines(&item, 80, Some("child-task-id"));
let line = lines
.iter()
.find(|line| line.to_string().contains("APP-CHLD"))
.unwrap();
let ref_span = line
.spans
.iter()
.find(|span| span.content.as_ref() == "APP-CHLD")
.unwrap();
assert_eq!(ref_span.style.bg, Some(BG_PANEL));
}
#[test]
fn detail_metadata_includes_operational_fields() {
let item = detail_test_item();
let rendered = detail_metadata_lines(&item)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("PROJECT\n● app"));
assert!(rendered.contains("STATUS\n● active"));
assert!(rendered.contains("PRIORITY\n▲ urgent"));
assert!(rendered.contains("LABELS\nbug, mobile"));
assert!(rendered.contains("CONFLICTS\nyes"));
}
#[test]
fn detail_metadata_marks_epics_and_counts_children() {
let mut item = detail_test_item();
item.task.is_epic = true;
item.epic_children = vec![
crate::query::TaskDependencyLink {
task_id: "child-task-id".to_string(),
display_ref: "APP-CHLD".to_string(),
title: "Build the first child task".to_string(),
status: "todo".to_string(),
priority: "medium".to_string(),
unresolved: true,
},
crate::query::TaskDependencyLink {
task_id: "done-child-task-id".to_string(),
display_ref: "APP-DONE".to_string(),
title: "Finished child task".to_string(),
status: "done".to_string(),
priority: "none".to_string(),
unresolved: false,
},
];
let rendered = detail_metadata_lines(&item)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains(" EPIC "));
assert!(rendered.contains("CHILDREN\nopen=1 total=2"));
assert!(!rendered.contains("APP-CHLD"));
assert!(!rendered.contains("Build the first child task"));
assert!(!rendered.contains("APP-DONE"));
}
#[test]
fn detail_metadata_marks_epics_without_children() {
let mut item = detail_test_item();
item.task.is_epic = true;
let rendered = detail_metadata_lines(&item)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains(" EPIC "));
assert!(rendered.contains("CHILDREN\nopen=0 total=0\nnone"));
}
#[test]
fn detail_content_layout_matches_wide_and_narrow_metadata_rules() {
let wide = detail_content_layout(Rect::new(0, 0, 120, 30));
assert_eq!(wide.body_area, Rect::new(0, 2, 120, 26));
assert!(wide.metadata_area.width > 0);
assert_eq!(wide.content_area.y, 3);
let narrow = detail_content_layout(Rect::new(0, 0, 80, 30));
assert_eq!(narrow.metadata_area, Rect::default());
assert_eq!(narrow.content_area.x, 2);
}
#[test]
fn detail_metadata_target_maps_status_and_priority_values() {
assert_eq!(
detail_metadata_target_at(120, 30, 88, 8),
Some((DetailMetadataTarget::Status, 88, 8))
);
assert_eq!(
detail_metadata_target_at(120, 30, 88, 11),
Some((DetailMetadataTarget::Priority, 88, 11))
);
assert_eq!(detail_metadata_target_at(120, 30, 88, 7), None);
assert_eq!(detail_metadata_target_at(80, 30, 70, 9), None);
}
#[test]
fn detail_content_model_prepares_render_lines_and_scrollbar() {
let mut item = detail_test_item();
item.task.description = (0..20)
.map(|index| format!("line {index}"))
.collect::<Vec<_>>()
.join("\n");
let model = build_detail_content_model(&item, Rect::new(0, 0, 60, 5), 4, None, None);
assert_eq!(
model.content_height,
detail_body_lines(&item, 60, None).len()
);
assert_eq!(
model.sticky_lines.len(),
detail_header_options(&item, 60, None).len()
);
assert_eq!(model.sticky_lines[0].to_string(), "Fix token refresh race");
assert_eq!(model.lines.len(), model.content_height.saturating_sub(4));
assert!(model.scrollbar_position > 0);
}
fn detail_test_epic_item() -> TaskListItem {
let mut item = detail_test_item();
item.task.is_epic = true;
item.epic_children = vec![
crate::query::TaskDependencyLink {
task_id: "child-task-id".to_string(),
display_ref: "APP-CHLD".to_string(),
title: "Build the first child task".to_string(),
status: "todo".to_string(),
priority: "medium".to_string(),
unresolved: true,
},
crate::query::TaskDependencyLink {
task_id: "done-child-task-id".to_string(),
display_ref: "APP-DONE".to_string(),
title: "Finished child task".to_string(),
status: "done".to_string(),
priority: "none".to_string(),
unresolved: false,
},
];
item
}
fn detail_test_item() -> TaskListItem {
TaskListItem {
task: crate::types::Task {
id: "7KQ9A1X".to_string(),
workspace_id: "workspace-1".to_string(),
title: "Fix token refresh race".to_string(),
description: "Two token refresh requests fire together.".to_string(),
project_id: "project-id".to_string(),
project_key: "app".to_string(),
project_prefix: "APP".to_string(),
status: TaskStatus::Active,
priority: TaskPriority::Urgent,
created_at: "2026-06-19T12:00:00Z".to_string(),
updated_at: "2026-06-20T12:00:00Z".to_string(),
queue_activity_at: "2026-06-20T12:00:00Z".to_string(),
deleted: false,
is_epic: false,
},
display_ref: "APP-7KQ9A1X".to_string(),
labels: vec!["bug".to_string(), "mobile".to_string()],
notes: vec![crate::query::TaskNote {
body: "Confirmed race in useTokenRefresh.ts".to_string(),
created_at: "2026-06-20T12:00:00Z".to_string(),
}],
has_conflict: true,
unresolved_blocker_count: 0,
dependent_count: 0,
depends_on: vec![crate::query::TaskDependencyLink {
task_id: "blocker-task-id".to_string(),
display_ref: "APP-7KQ1".to_string(),
title: "Ship auth service".to_string(),
status: "todo".to_string(),
priority: "high".to_string(),
unresolved: true,
}],
blocks: vec![crate::query::TaskDependencyLink {
task_id: "dependent-task-id".to_string(),
display_ref: "APP-7KQ2".to_string(),
title: "Write rollout notes".to_string(),
status: "inbox".to_string(),
priority: "none".to_string(),
unresolved: true,
}],
epic_children: Vec::new(),
epic_parent: None,
queue: Default::default(),
}
}
}