Skip to main content

bvr/
tui.rs

1use std::cell::Cell;
2use std::collections::BTreeMap;
3#[cfg(not(test))]
4use std::collections::VecDeque;
5use std::fmt::Write as _;
6use std::io::Write as _;
7use std::path::PathBuf;
8
9use crate::analysis::Analyzer;
10use crate::analysis::git_history::{
11    GitCommitRecord, HistoryBeadCompat, HistoryCommitCompat, HistoryMilestonesCompat,
12    build_workspace_id_aliases, correlate_histories_with_git_aliases, finalize_history_entries,
13    load_git_commits,
14};
15use crate::analysis::history::IssueHistory;
16use crate::analysis::triage::TriageOptions;
17use crate::loader;
18use crate::model::{Issue, Sprint};
19#[cfg(not(test))]
20use crate::robot::compute_data_hash;
21use crate::{BvrError, Result};
22use chrono::{DateTime, Utc};
23use ftui::core::event::{
24    Event, KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseButton, MouseEvent, MouseEventKind,
25};
26use ftui::core::geometry::Rect;
27use ftui::layout::{Constraint, Flex};
28use ftui::render::frame::Frame;
29#[cfg(not(test))]
30use ftui::runtime::TaskSpec;
31use ftui::runtime::{App, Cmd, Model, ScreenMode};
32use ftui::text::{
33    Line as RichLine, Span as RichSpan, Text as RichText, display_width, truncate_to_width,
34    truncate_with_ellipsis,
35};
36use ftui::widgets::Widget;
37use ftui::widgets::block::Block;
38use ftui::widgets::paragraph::Paragraph;
39
40#[cfg(not(test))]
41use std::sync::{
42    Arc,
43    atomic::{AtomicBool, Ordering},
44};
45
46#[derive(Debug, Clone)]
47pub struct BackgroundModeConfig {
48    pub beads_file: Option<PathBuf>,
49    pub workspace_config: Option<PathBuf>,
50    pub repo_path: Option<PathBuf>,
51    pub repo_filter: Option<String>,
52    pub poll_interval_ms: u64,
53}
54
55impl BackgroundModeConfig {
56    pub const DEFAULT_POLL_INTERVAL_MS: u64 = 2_000;
57
58    #[cfg(not(test))]
59    fn normalized(mut self) -> Self {
60        if self.poll_interval_ms == 0 {
61            self.poll_interval_ms = Self::DEFAULT_POLL_INTERVAL_MS;
62        }
63        self
64    }
65
66    #[cfg(not(test))]
67    fn poll_interval(&self) -> std::time::Duration {
68        std::time::Duration::from_millis(self.poll_interval_ms.max(1))
69    }
70
71    #[cfg(not(test))]
72    fn load_issues(&self) -> Result<Vec<Issue>> {
73        let issues = if let Some(path) = self.beads_file.as_deref() {
74            loader::load_issues_from_file(path)?
75        } else if let Some(path) = self.workspace_config.as_deref() {
76            loader::load_workspace_issues(path)?
77        } else {
78            loader::load_issues(self.repo_path.as_deref())?
79        };
80
81        if let Some(repo_filter) = self.repo_filter.as_deref() {
82            Ok(filter_issues_by_repo(issues, repo_filter))
83        } else {
84            Ok(issues)
85        }
86    }
87}
88
89#[cfg(not(test))]
90fn filter_issues_by_repo(issues: Vec<Issue>, repo_filter: &str) -> Vec<Issue> {
91    let filter = repo_filter.trim().to_ascii_lowercase();
92    if filter.is_empty() {
93        return issues;
94    }
95
96    let needs_flexible_match =
97        !filter.ends_with('-') && !filter.ends_with(':') && !filter.ends_with('_');
98    let with_dash = format!("{filter}-");
99    let with_colon = format!("{filter}:");
100    let with_underscore = format!("{filter}_");
101
102    issues
103        .into_iter()
104        .filter(|issue| {
105            let id = issue.id.to_ascii_lowercase();
106            if id.starts_with(&filter) {
107                return true;
108            }
109            if needs_flexible_match
110                && (id.starts_with(&with_dash)
111                    || id.starts_with(&with_colon)
112                    || id.starts_with(&with_underscore))
113            {
114                return true;
115            }
116
117            let source_repo = issue.source_repo.trim();
118            if source_repo.is_empty() || source_repo == "." {
119                return false;
120            }
121
122            let source_repo = source_repo.to_ascii_lowercase();
123            if source_repo.starts_with(&filter) {
124                return true;
125            }
126
127            needs_flexible_match
128                && (source_repo.starts_with(&with_dash)
129                    || source_repo.starts_with(&with_colon)
130                    || source_repo.starts_with(&with_underscore))
131        })
132        .collect()
133}
134
135#[cfg(not(test))]
136#[derive(Debug)]
137struct BackgroundRuntimeState {
138    config: BackgroundModeConfig,
139    in_flight: bool,
140    cancel_requested: Arc<AtomicBool>,
141    last_data_hash: String,
142    timeline: VecDeque<String>,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum BackgroundTickDecision {
147    Stop,
148    TickOnly,
149    ReloadAndTick,
150}
151
152fn decide_background_tick(cancel_requested: bool, in_flight: bool) -> BackgroundTickDecision {
153    if cancel_requested {
154        BackgroundTickDecision::Stop
155    } else if in_flight {
156        BackgroundTickDecision::TickOnly
157    } else {
158        BackgroundTickDecision::ReloadAndTick
159    }
160}
161
162fn should_apply_background_reload(
163    cancel_requested: bool,
164    new_hash: &str,
165    previous_hash: &str,
166) -> bool {
167    !cancel_requested && new_hash != previous_hash
168}
169
170fn background_warning_message(cancel_requested: bool, error: &str) -> Option<String> {
171    if cancel_requested || error == "canceled" {
172        None
173    } else {
174        Some(format!("background reload warning: {error}"))
175    }
176}
177
178#[cfg(test)]
179fn sprint_reference_now() -> DateTime<Utc> {
180    chrono::DateTime::parse_from_rfc3339("2026-03-09T00:00:00Z")
181        .expect("valid fixed sprint test timestamp")
182        .with_timezone(&Utc)
183}
184
185#[cfg(not(test))]
186fn sprint_reference_now() -> DateTime<Utc> {
187    Utc::now()
188}
189
190#[cfg(test)]
191fn ui_reference_now() -> DateTime<Utc> {
192    chrono::DateTime::parse_from_rfc3339("2026-04-02T00:00:00Z")
193        .expect("valid fixed ui test timestamp")
194        .with_timezone(&Utc)
195}
196
197#[cfg(not(test))]
198fn ui_reference_now() -> DateTime<Utc> {
199    Utc::now()
200}
201
202#[cfg(not(test))]
203const BACKGROUND_TIMELINE_MAX_EVENTS: usize = 32;
204
205#[cfg(not(test))]
206fn background_timeline_entry(event: &str) -> String {
207    let ts = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
208    format!("{ts} | {event}")
209}
210
211#[cfg(not(test))]
212fn push_background_timeline(runtime: &mut BackgroundRuntimeState, event: &str) -> String {
213    let entry = background_timeline_entry(event);
214    runtime.timeline.push_back(entry.clone());
215    while runtime.timeline.len() > BACKGROUND_TIMELINE_MAX_EVENTS {
216        runtime.timeline.pop_front();
217    }
218    entry
219}
220
221// ---------------------------------------------------------------------------
222// Visual Tokens — centralised style constants for the TUI
223// ---------------------------------------------------------------------------
224
225/// Terminal width breakpoints for responsive layout.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227enum Breakpoint {
228    /// < 80 columns — compact single-pane emphasis
229    Narrow,
230    /// 80..120 columns — standard two-pane
231    Medium,
232    /// >= 120 columns — roomy two-pane with extra detail
233    Wide,
234}
235
236impl Breakpoint {
237    fn from_width(w: u16) -> Self {
238        if w < 80 {
239            Self::Narrow
240        } else if w < 120 {
241            Self::Medium
242        } else {
243            Self::Wide
244        }
245    }
246
247    /// List pane percentage for the horizontal split.
248    fn list_pct(self) -> f32 {
249        match self {
250            Self::Narrow => 35.0,
251            Self::Medium => 42.0,
252            Self::Wide => 38.0,
253        }
254    }
255
256    #[cfg(test)]
257    /// Detail pane percentage for the horizontal split.
258    fn detail_pct(self) -> f32 {
259        100.0 - self.list_pct()
260    }
261}
262
263#[derive(Debug, Clone, Copy, PartialEq)]
264struct PaneSplitState {
265    narrow_list_pct: f32,
266    medium_list_pct: f32,
267    wide_list_pct: f32,
268    history_standard: [f32; 3],
269    history_wide_git: [f32; 3],
270    history_wide_bead: [f32; 4],
271}
272
273impl Default for PaneSplitState {
274    fn default() -> Self {
275        Self {
276            narrow_list_pct: Breakpoint::Narrow.list_pct(),
277            medium_list_pct: Breakpoint::Medium.list_pct(),
278            wide_list_pct: Breakpoint::Wide.list_pct(),
279            history_standard: [30.0, 35.0, 35.0],
280            history_wide_git: [25.0, 30.0, 45.0],
281            history_wide_bead: [20.0, 22.0, 25.0, 33.0],
282        }
283    }
284}
285
286impl PaneSplitState {
287    fn two_pane_list_pct(self, breakpoint: Breakpoint) -> f32 {
288        match breakpoint {
289            Breakpoint::Narrow => self.narrow_list_pct,
290            Breakpoint::Medium => self.medium_list_pct,
291            Breakpoint::Wide => self.wide_list_pct,
292        }
293    }
294
295    fn two_pane_detail_pct(self, breakpoint: Breakpoint) -> f32 {
296        100.0 - self.two_pane_list_pct(breakpoint)
297    }
298
299    fn adjust_two_pane(&mut self, breakpoint: Breakpoint, delta_pct: f32) -> bool {
300        let list = self.two_pane_list_pct(breakpoint);
301        let clamped = (list + delta_pct).clamp(25.0, 75.0);
302        if (clamped - list).abs() < f32::EPSILON {
303            return false;
304        }
305        match breakpoint {
306            Breakpoint::Narrow => self.narrow_list_pct = clamped,
307            Breakpoint::Medium => self.medium_list_pct = clamped,
308            Breakpoint::Wide => self.wide_list_pct = clamped,
309        }
310        true
311    }
312
313    fn history_pcts(self, layout: HistoryLayout, view_mode: HistoryViewMode) -> PaneSplitPreset {
314        match (layout, view_mode) {
315            (HistoryLayout::Wide, HistoryViewMode::Bead) => {
316                PaneSplitPreset::Four(self.history_wide_bead)
317            }
318            (HistoryLayout::Wide, HistoryViewMode::Git) => {
319                PaneSplitPreset::Three(self.history_wide_git)
320            }
321            (HistoryLayout::Standard, _) => PaneSplitPreset::Three(self.history_standard),
322            (HistoryLayout::Narrow, _) => {
323                PaneSplitPreset::Two([self.medium_list_pct, 100.0 - self.medium_list_pct])
324            }
325        }
326    }
327
328    fn adjust_history(
329        &mut self,
330        layout: HistoryLayout,
331        view_mode: HistoryViewMode,
332        focus: FocusPane,
333        delta_pct: f32,
334    ) -> bool {
335        match (layout, view_mode) {
336            (HistoryLayout::Wide, HistoryViewMode::Bead) => {
337                let (primary, secondary) = match focus {
338                    FocusPane::List => (0, 3),
339                    FocusPane::Middle => (2, 3),
340                    FocusPane::Detail => (3, 2),
341                };
342                adjust_split_pair(
343                    &mut self.history_wide_bead,
344                    primary,
345                    secondary,
346                    delta_pct,
347                    15.0,
348                )
349            }
350            (HistoryLayout::Standard, _) | (HistoryLayout::Wide, HistoryViewMode::Git) => {
351                let (primary, secondary) = match focus {
352                    FocusPane::List => (0, 2),
353                    FocusPane::Middle => (1, 2),
354                    FocusPane::Detail => (2, 1),
355                };
356                let target = if matches!(layout, HistoryLayout::Wide) {
357                    &mut self.history_wide_git
358                } else {
359                    &mut self.history_standard
360                };
361                adjust_split_pair(target, primary, secondary, delta_pct, 18.0)
362            }
363            (HistoryLayout::Narrow, _) => false,
364        }
365    }
366}
367
368#[derive(Debug, Clone, Copy, PartialEq)]
369enum PaneSplitPreset {
370    Two([f32; 2]),
371    Three([f32; 3]),
372    Four([f32; 4]),
373}
374
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376enum SplitterTarget {
377    TwoPane { breakpoint: Breakpoint },
378    HistoryThree { wide: bool, divider: usize },
379    HistoryFour { divider: usize },
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383struct SplitterHitBox {
384    target: SplitterTarget,
385    rect: Rect,
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389struct HeaderModeTab {
390    mode: ViewMode,
391    rect: Rect,
392}
393
394impl PaneSplitState {
395    fn adjust_splitter_target(&mut self, target: SplitterTarget, delta_pct: f32) -> bool {
396        match target {
397            SplitterTarget::TwoPane { breakpoint } => self.adjust_two_pane(breakpoint, delta_pct),
398            SplitterTarget::HistoryThree { wide, divider } => {
399                let target = if wide {
400                    &mut self.history_wide_git
401                } else {
402                    &mut self.history_standard
403                };
404                adjust_split_pair(target, divider, divider + 1, delta_pct, 18.0)
405            }
406            SplitterTarget::HistoryFour { divider } => adjust_split_pair(
407                &mut self.history_wide_bead,
408                divider,
409                divider + 1,
410                delta_pct,
411                15.0,
412            ),
413        }
414    }
415}
416
417fn adjust_split_pair<const N: usize>(
418    ratios: &mut [f32; N],
419    primary: usize,
420    secondary: usize,
421    delta_pct: f32,
422    min_pct: f32,
423) -> bool {
424    let max_increase = ratios[secondary] - min_pct;
425    let max_decrease = ratios[primary] - min_pct;
426    let applied = delta_pct.clamp(-max_decrease, max_increase);
427    if applied.abs() < f32::EPSILON {
428        return false;
429    }
430    ratios[primary] += applied;
431    ratios[secondary] -= applied;
432    true
433}
434
435thread_local! {
436    static LAST_VIEW_WIDTH: Cell<u16> = const { Cell::new(80) };
437    static LAST_VIEW_HEIGHT: Cell<u16> = const { Cell::new(24) };
438    static LAST_DETAIL_CONTENT_AREA: Cell<Rect> = const { Cell::new(Rect::new(0, 0, 0, 0)) };
439    static PANE_SPLIT_STATE: Cell<PaneSplitState> = const { Cell::new(PaneSplitState {
440        narrow_list_pct: 35.0,
441        medium_list_pct: 42.0,
442        wide_list_pct: 38.0,
443        history_standard: [30.0, 35.0, 35.0],
444        history_wide_git: [25.0, 30.0, 45.0],
445        history_wide_bead: [20.0, 22.0, 25.0, 33.0],
446    }) };
447}
448
449fn record_view_size(width: u16, height: u16) {
450    LAST_VIEW_WIDTH.with(|cell| cell.set(width));
451    LAST_VIEW_HEIGHT.with(|cell| cell.set(height));
452}
453
454fn record_detail_content_area(area: Rect) {
455    LAST_DETAIL_CONTENT_AREA.with(|cell| cell.set(area));
456}
457
458fn cached_detail_content_area() -> Rect {
459    LAST_DETAIL_CONTENT_AREA.with(Cell::get)
460}
461
462fn cached_view_width() -> u16 {
463    LAST_VIEW_WIDTH.with(Cell::get)
464}
465
466fn cached_view_height() -> u16 {
467    LAST_VIEW_HEIGHT.with(Cell::get)
468}
469
470fn pane_split_state() -> PaneSplitState {
471    PANE_SPLIT_STATE.with(Cell::get)
472}
473
474fn set_pane_split_state(state: PaneSplitState) {
475    PANE_SPLIT_STATE.with(|cell| cell.set(state));
476}
477
478const fn block_inner_rect(area: Rect) -> Rect {
479    Rect::new(
480        area.x.saturating_add(1),
481        area.y.saturating_add(1),
482        area.width.saturating_sub(2),
483        area.height.saturating_sub(2),
484    )
485}
486
487fn saturating_scroll_offset(offset: usize) -> u16 {
488    u16::try_from(offset).unwrap_or(u16::MAX)
489}
490
491const fn rect_contains(area: Rect, x: u16, y: u16) -> bool {
492    x >= area.x
493        && y >= area.y
494        && x < area.x.saturating_add(area.width)
495        && y < area.y.saturating_add(area.height)
496}
497
498fn splitter_rect_between(left: Rect, right: Rect) -> Rect {
499    let x = left.x.saturating_add(left.width).saturating_sub(1);
500    let max_right = right.x.saturating_add(right.width);
501    let width = if x < max_right {
502        (max_right - x).min(2)
503    } else {
504        1
505    };
506    Rect::new(x, left.y, width.max(1), left.height)
507}
508
509fn splitter_hit_boxes(app: &BvrApp, width: u16, height: u16) -> Vec<SplitterHitBox> {
510    let full = Rect::from_size(width, height);
511    let rows = Flex::vertical()
512        .constraints([
513            Constraint::Fixed(1),
514            Constraint::Min(3),
515            Constraint::Fixed(1),
516        ])
517        .split(full);
518    let body = rows[1];
519    let bp = Breakpoint::from_width(width);
520    let split_state = pane_split_state();
521    let graph_single_pane = matches!(app.mode, ViewMode::Graph) && matches!(bp, Breakpoint::Narrow);
522    let history_layout = if matches!(app.mode, ViewMode::History) {
523        HistoryLayout::from_width(body.width)
524    } else {
525        HistoryLayout::Narrow
526    };
527    let history_multi_pane =
528        matches!(app.mode, ViewMode::History) && history_layout.has_middle_pane();
529
530    if graph_single_pane {
531        return Vec::new();
532    }
533
534    if history_multi_pane {
535        if matches!(history_layout, HistoryLayout::Wide)
536            && matches!(app.history_view_mode, HistoryViewMode::Bead)
537        {
538            let PaneSplitPreset::Four(pcts) =
539                split_state.history_pcts(history_layout, app.history_view_mode)
540            else {
541                unreachable!("wide bead history should use four-pane split");
542            };
543            let panes = Flex::horizontal()
544                .constraints([
545                    Constraint::Percentage(pcts[0]),
546                    Constraint::Percentage(pcts[1]),
547                    Constraint::Percentage(pcts[2]),
548                    Constraint::Percentage(pcts[3]),
549                ])
550                .split(body);
551            return vec![
552                SplitterHitBox {
553                    target: SplitterTarget::HistoryFour { divider: 0 },
554                    rect: splitter_rect_between(panes[0], panes[1]),
555                },
556                SplitterHitBox {
557                    target: SplitterTarget::HistoryFour { divider: 1 },
558                    rect: splitter_rect_between(panes[1], panes[2]),
559                },
560                SplitterHitBox {
561                    target: SplitterTarget::HistoryFour { divider: 2 },
562                    rect: splitter_rect_between(panes[2], panes[3]),
563                },
564            ];
565        }
566
567        let PaneSplitPreset::Three(pane_widths) =
568            split_state.history_pcts(history_layout, app.history_view_mode)
569        else {
570            unreachable!("multi-pane history should use three-pane split");
571        };
572        let panes = Flex::horizontal()
573            .constraints([
574                Constraint::Percentage(pane_widths[0]),
575                Constraint::Percentage(pane_widths[1]),
576                Constraint::Percentage(pane_widths[2]),
577            ])
578            .split(body);
579        return vec![
580            SplitterHitBox {
581                target: SplitterTarget::HistoryThree {
582                    wide: matches!(history_layout, HistoryLayout::Wide),
583                    divider: 0,
584                },
585                rect: splitter_rect_between(panes[0], panes[1]),
586            },
587            SplitterHitBox {
588                target: SplitterTarget::HistoryThree {
589                    wide: matches!(history_layout, HistoryLayout::Wide),
590                    divider: 1,
591                },
592                rect: splitter_rect_between(panes[1], panes[2]),
593            },
594        ];
595    }
596
597    let panes = Flex::horizontal()
598        .constraints([
599            Constraint::Percentage(split_state.two_pane_list_pct(bp)),
600            Constraint::Percentage(split_state.two_pane_detail_pct(bp)),
601        ])
602        .split(body);
603    vec![SplitterHitBox {
604        target: SplitterTarget::TwoPane { breakpoint: bp },
605        rect: splitter_rect_between(panes[0], panes[1]),
606    }]
607}
608
609fn header_tab_candidate_modes(mode: ViewMode, bp: Breakpoint) -> Vec<ViewMode> {
610    let mut modes = match bp {
611        Breakpoint::Narrow => vec![
612            ViewMode::Main,
613            ViewMode::Board,
614            ViewMode::Insights,
615            ViewMode::Graph,
616            ViewMode::History,
617        ],
618        Breakpoint::Medium => vec![
619            ViewMode::Main,
620            ViewMode::Board,
621            ViewMode::Insights,
622            ViewMode::Graph,
623            ViewMode::History,
624            ViewMode::Actionable,
625            ViewMode::Attention,
626            ViewMode::Tree,
627            ViewMode::Sprint,
628        ],
629        Breakpoint::Wide => ViewMode::navigation_order().to_vec(),
630    };
631    if !modes.contains(&mode) {
632        modes.push(mode);
633    }
634    modes
635}
636
637fn header_mode_tabs(app: &BvrApp, width: u16) -> Vec<HeaderModeTab> {
638    let bp = Breakpoint::from_width(width);
639    // Keep enough horizontal budget for the status chips so the active
640    // filter/sort state remains visible at common terminal widths.
641    let reserved_width = match bp {
642        Breakpoint::Narrow => 16u16,
643        Breakpoint::Medium => 70u16,
644        Breakpoint::Wide if width < 132 => 68u16,
645        Breakpoint::Wide => 56u16,
646    };
647    let max_x = width.saturating_sub(reserved_width);
648    let mut x = 4u16;
649    let mut tabs = Vec::new();
650
651    for mode in header_tab_candidate_modes(app.mode, bp) {
652        let label = mode.tab_text(bp);
653        let tab_width = u16::try_from(display_width(&label)).unwrap_or(u16::MAX);
654        if tab_width == 0 {
655            continue;
656        }
657
658        let next_end = x.saturating_add(tab_width);
659        if !tabs.is_empty() && next_end >= max_x {
660            break;
661        }
662
663        tabs.push(HeaderModeTab {
664            mode,
665            rect: Rect::new(x, 0, tab_width, 1),
666        });
667        x = next_end.saturating_add(1);
668    }
669
670    if !tabs.iter().any(|tab| tab.mode == app.mode) {
671        let label = app.mode.tab_text(bp);
672        let tab_width = u16::try_from(display_width(&label)).unwrap_or(u16::MAX);
673        let start_x = width.saturating_sub(tab_width.saturating_add(1));
674        tabs.push(HeaderModeTab {
675            mode: app.mode,
676            rect: Rect::new(start_x, 0, tab_width.min(width.saturating_sub(start_x)), 1),
677        });
678    }
679
680    tabs
681}
682
683fn is_http_url(value: &str) -> bool {
684    value.starts_with("https://") || value.starts_with("http://")
685}
686
687#[derive(Debug, Clone, Copy, PartialEq, Eq)]
688struct CommandHint<'a> {
689    key: &'a str,
690    desc: &'a str,
691}
692
693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
694enum SemanticTone {
695    Neutral,
696    Accent,
697    Success,
698    Warning,
699    Danger,
700    Muted,
701}
702
703/// Semantic colour tokens with light/dark palette support.
704///
705/// Detects terminal background via `BV_THEME` env var (`light` | `dark`)
706/// with dark-mode default.  All 9+ issue statuses, 5 priority levels,
707/// 5 issue types, and distinct footer styling tokens are included.
708mod tokens {
709    use super::SemanticTone;
710    use ftui::{PackedRgba, Style};
711
712    // ── Theme detection ──────────────────────────────────────────────
713
714    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
715    pub enum ThemeMode {
716        Dark,
717        Light,
718    }
719
720    /// Detect theme from `BV_THEME` env var.  Falls back to dark.
721    /// Result is cached for the lifetime of the process.
722    pub fn detect_theme() -> ThemeMode {
723        static CACHED: std::sync::OnceLock<ThemeMode> = std::sync::OnceLock::new();
724        *CACHED.get_or_init(|| match std::env::var("BV_THEME").as_deref() {
725            Ok("light") => ThemeMode::Light,
726            _ => ThemeMode::Dark,
727        })
728    }
729
730    fn is_light() -> bool {
731        detect_theme() == ThemeMode::Light
732    }
733
734    /// Runtime light/dark picker (zero-cost after first call).
735    fn p(light: PackedRgba, dark: PackedRgba) -> PackedRgba {
736        if is_light() { light } else { dark }
737    }
738
739    // ── Palette primitives (dark defaults, light alternatives) ───────
740
741    // Foreground
742    pub const FG_DEFAULT: PackedRgba = PackedRgba::rgb(248, 248, 242); // #f8f8f2
743    pub const FG_DEFAULT_LIGHT: PackedRgba = PackedRgba::rgb(26, 26, 26); // #1a1a1a
744    pub const FG_DIM: PackedRgba = PackedRgba::rgb(136, 136, 136); // #888888
745    pub const FG_ACCENT: PackedRgba = PackedRgba::rgb(189, 147, 249); // #bd93f9 purple
746    pub const FG_ACCENT_LIGHT: PackedRgba = PackedRgba::rgb(107, 71, 217); // #6b47d9
747    pub const FG_SUCCESS: PackedRgba = PackedRgba::rgb(80, 250, 123); // #50fa7b green
748    pub const FG_SUCCESS_LIGHT: PackedRgba = PackedRgba::rgb(0, 119, 0); // #007700
749    pub const FG_WARNING: PackedRgba = PackedRgba::rgb(255, 184, 108); // #ffb86c orange
750    pub const FG_WARNING_LIGHT: PackedRgba = PackedRgba::rgb(176, 104, 0); // #b06800
751    pub const FG_ERROR: PackedRgba = PackedRgba::rgb(255, 85, 85); // #ff5555 red
752    pub const FG_ERROR_LIGHT: PackedRgba = PackedRgba::rgb(204, 0, 0); // #cc0000
753    pub const FG_INFO: PackedRgba = PackedRgba::rgb(139, 233, 253); // #8be9fd cyan
754    pub const FG_INFO_LIGHT: PackedRgba = PackedRgba::rgb(0, 96, 128); // #006080
755    pub const FG_MUTED: PackedRgba = PackedRgba::rgb(98, 114, 164); // #6272a4
756    pub const FG_MUTED_LIGHT: PackedRgba = PackedRgba::rgb(102, 102, 102); // #666666
757    pub const FG_SUBTEXT: PackedRgba = PackedRgba::rgb(191, 191, 191); // #bfbfbf
758    pub const FG_SUBTEXT_LIGHT: PackedRgba = PackedRgba::rgb(85, 85, 85); // #555555
759
760    // Background
761    pub const BG_SURFACE: PackedRgba = PackedRgba::rgb(54, 57, 73); // #363949
762    pub const BG_SURFACE_LIGHT: PackedRgba = PackedRgba::rgb(232, 232, 232); // #e8e8e8
763    pub const BG_HIGHLIGHT: PackedRgba = PackedRgba::rgb(68, 71, 90); // #44475a
764    pub const BG_HIGHLIGHT_LIGHT: PackedRgba = PackedRgba::rgb(208, 208, 208); // #d0d0d0
765    pub const BG_DARK: PackedRgba = PackedRgba::rgb(30, 31, 41); // #1e1f29
766
767    // Semantic surface tints (dark)
768    pub const BG_SURFACE_ACCENT: PackedRgba = PackedRgba::rgb(42, 26, 68); // #2a1a44
769    pub const BG_SURFACE_SUCCESS: PackedRgba = PackedRgba::rgb(26, 61, 42); // #1a3d2a
770    pub const BG_SURFACE_WARNING: PackedRgba = PackedRgba::rgb(61, 42, 26); // #3d2a1a
771    pub const BG_SURFACE_DANGER: PackedRgba = PackedRgba::rgb(61, 26, 26); // #3d1a1a
772    pub const BG_SURFACE_INFO: PackedRgba = PackedRgba::rgb(26, 51, 68); // #1a3344
773    pub const BG_SURFACE_MUTED: PackedRgba = PackedRgba::rgb(42, 42, 61); // #2a2a3d
774
775    // Semantic surface tints (light)
776    pub const BG_SURFACE_ACCENT_L: PackedRgba = PackedRgba::rgb(232, 221, 255); // #e8ddff
777    pub const BG_SURFACE_SUCCESS_L: PackedRgba = PackedRgba::rgb(212, 237, 218); // #d4edda
778    pub const BG_SURFACE_WARNING_L: PackedRgba = PackedRgba::rgb(255, 232, 204); // #ffe8cc
779    pub const BG_SURFACE_DANGER_L: PackedRgba = PackedRgba::rgb(248, 215, 218); // #f8d7da
780    pub const BG_SURFACE_INFO_L: PackedRgba = PackedRgba::rgb(209, 236, 241); // #d1ecf1
781    pub const BG_SURFACE_MUTED_L: PackedRgba = PackedRgba::rgb(226, 227, 229); // #e2e3e5
782
783    // ── Status colours (9 statuses, matching Go bv) ──────────────────
784
785    pub fn status_fg(status: &str) -> PackedRgba {
786        let s = status.to_ascii_lowercase();
787        let s = s.as_str();
788        match s {
789            "open" => p(FG_SUCCESS_LIGHT, FG_SUCCESS),
790            "in_progress" => p(FG_INFO_LIGHT, FG_INFO),
791            "blocked" => p(FG_ERROR_LIGHT, FG_ERROR),
792            "deferred" | "draft" => p(FG_WARNING_LIGHT, FG_WARNING),
793            "pinned" => p(PackedRgba::rgb(0, 102, 204), PackedRgba::rgb(102, 153, 255)),
794            "hooked" => p(PackedRgba::rgb(0, 128, 128), PackedRgba::rgb(0, 206, 209)),
795            "review" => p(FG_ACCENT_LIGHT, FG_ACCENT),
796            "closed" => p(FG_SUBTEXT_LIGHT, FG_MUTED),
797            "tombstone" => p(FG_DIM, PackedRgba::rgb(68, 71, 90)),
798            _ => p(FG_DIM, FG_DIM),
799        }
800    }
801
802    pub fn status_bg(status: &str) -> PackedRgba {
803        let s = status.to_ascii_lowercase();
804        let s = s.as_str();
805        match s {
806            "open" => p(BG_SURFACE_SUCCESS_L, BG_SURFACE_SUCCESS),
807            "in_progress" => p(BG_SURFACE_INFO_L, BG_SURFACE_INFO),
808            "blocked" => p(BG_SURFACE_DANGER_L, BG_SURFACE_DANGER),
809            "deferred" | "draft" => p(BG_SURFACE_WARNING_L, BG_SURFACE_WARNING),
810            "pinned" => p(PackedRgba::rgb(204, 229, 255), PackedRgba::rgb(26, 42, 68)),
811            "hooked" => p(PackedRgba::rgb(204, 255, 255), PackedRgba::rgb(26, 61, 61)),
812            "review" => p(BG_SURFACE_ACCENT_L, BG_SURFACE_ACCENT),
813            "closed" => p(BG_SURFACE_MUTED_L, BG_SURFACE_MUTED),
814            "tombstone" => p(BG_HIGHLIGHT_LIGHT, BG_DARK),
815            _ => p(BG_SURFACE_LIGHT, BG_SURFACE),
816        }
817    }
818
819    /// Short status badge label (4 chars).
820    pub fn status_badge_label(status: &str) -> &'static str {
821        let s = status.to_ascii_lowercase();
822        match s.as_str() {
823            "open" => "OPEN",
824            "in_progress" => "PROG",
825            "blocked" => "BLKD",
826            "deferred" => "DEFR",
827            "draft" => "DRFT",
828            "pinned" => "PIN ",
829            "hooked" => "HOOK",
830            "review" => "REVW",
831            "closed" => "DONE",
832            "tombstone" => "TOMB",
833            _ => "????",
834        }
835    }
836
837    // ── Priority colours (P0-P4 with bg) ─────────────────────────────
838
839    pub const PRIO_P0: PackedRgba = FG_ERROR; // critical red
840    pub const PRIO_P1: PackedRgba = FG_WARNING; // high orange
841    pub const PRIO_P2: PackedRgba = PackedRgba::rgb(241, 250, 140); // #f1fa8c medium yellow
842    pub const PRIO_P3: PackedRgba = FG_SUCCESS; // low green
843    pub const PRIO_P4: PackedRgba = FG_MUTED; // backlog gray
844
845    pub fn priority_fg(prio: u8) -> PackedRgba {
846        match prio {
847            0 => p(FG_ERROR_LIGHT, PRIO_P0),
848            1 => p(FG_WARNING_LIGHT, PRIO_P1),
849            2 => p(PackedRgba::rgb(128, 128, 0), PRIO_P2),
850            3 => p(FG_SUCCESS_LIGHT, PRIO_P3),
851            _ => p(FG_MUTED_LIGHT, PRIO_P4),
852        }
853    }
854
855    pub fn priority_bg(prio: u8) -> PackedRgba {
856        match prio {
857            0 => p(BG_SURFACE_DANGER_L, BG_SURFACE_DANGER),
858            1 => p(BG_SURFACE_WARNING_L, BG_SURFACE_WARNING),
859            2 => p(PackedRgba::rgb(255, 243, 205), PackedRgba::rgb(61, 61, 26)),
860            3 => p(BG_SURFACE_SUCCESS_L, BG_SURFACE_SUCCESS),
861            _ => p(BG_SURFACE_LIGHT, BG_SURFACE),
862        }
863    }
864
865    pub fn priority_style(prio: u8) -> Style {
866        Style::new().fg(priority_fg(prio)).bold()
867    }
868
869    /// Priority badge style with background.
870    pub fn priority_badge(prio: u8) -> Style {
871        Style::new()
872            .fg(priority_fg(prio))
873            .bg(priority_bg(prio))
874            .bold()
875    }
876
877    // ── Type colours and icons ───────────────────────────────────────
878
879    pub fn type_fg(issue_type: &str) -> PackedRgba {
880        let t = issue_type.to_ascii_lowercase();
881        match t.as_str() {
882            "bug" => p(FG_ERROR_LIGHT, FG_ERROR),
883            "feature" => p(FG_WARNING_LIGHT, FG_WARNING),
884            "task" => p(PackedRgba::rgb(128, 128, 0), PackedRgba::rgb(241, 250, 140)),
885            "epic" => p(FG_ACCENT_LIGHT, FG_ACCENT),
886            "docs" => p(FG_INFO_LIGHT, FG_INFO),
887            _ => p(FG_SUBTEXT_LIGHT, FG_SUBTEXT),
888        }
889    }
890
891    // ── Footer tokens (4 distinct styles matching Go bv) ─────────────
892
893    /// Footer keybinding label (bright, bold).
894    pub fn footer_key() -> Style {
895        Style::new()
896            .fg(p(
897                PackedRgba::rgb(51, 51, 51),
898                PackedRgba::rgb(224, 224, 232),
899            ))
900            .bold()
901    }
902
903    /// Footer navigation hint text (medium brightness).
904    pub fn footer_hint() -> Style {
905        Style::new().fg(p(
906            PackedRgba::rgb(68, 68, 68),
907            PackedRgba::rgb(200, 200, 208),
908        ))
909    }
910
911    /// Footer separator (`│` between hints).
912    pub fn footer_sep() -> Style {
913        Style::new().fg(p(
914            PackedRgba::rgb(136, 136, 136),
915            PackedRgba::rgb(136, 136, 160),
916        ))
917    }
918
919    /// Footer secondary text (issue counts, dim info).
920    pub fn footer_dim() -> Style {
921        Style::new().fg(p(
922            PackedRgba::rgb(85, 85, 85),
923            PackedRgba::rgb(160, 160, 184),
924        ))
925    }
926
927    // ── Heatmap gradient for sparkline/importance ─────────────────────
928
929    /// Map a 0.0–1.0 score to a heatmap colour.
930    pub fn heatmap_color(score: f64) -> PackedRgba {
931        if score > 0.8 {
932            p(FG_ACCENT_LIGHT, FG_ACCENT)
933        } else if score > 0.5 {
934            p(FG_WARNING_LIGHT, FG_WARNING)
935        } else if score > 0.2 {
936            p(FG_INFO_LIGHT, FG_INFO)
937        } else {
938            p(FG_SUBTEXT_LIGHT, FG_MUTED)
939        }
940    }
941
942    // ── Sparkline characters ─────────────────────────────────────────
943
944    const SPARK_CHARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
945
946    /// Render a sparkline string for a 0.0–1.0 value at the given width.
947    pub fn render_sparkline(value: f64, width: usize) -> String {
948        let clamped = value.clamp(0.0, 1.0);
949        let total = (clamped * width as f64 * 8.0) as usize;
950        let full_blocks = total / 8;
951        let remainder = total % 8;
952        let mut s = String::with_capacity(width);
953        for _ in 0..full_blocks.min(width) {
954            s.push('█');
955        }
956        if full_blocks < width && remainder > 0 {
957            s.push(SPARK_CHARS[remainder.saturating_sub(1)]);
958        }
959        let drawn = s.chars().count();
960        for _ in drawn..width {
961            s.push(' ');
962        }
963        s
964    }
965
966    // ── Priority hint arrows ─────────────────────────────────────────
967
968    // ── Preserved original API (backward compat) ─────────────────────
969
970    pub fn fg_default() -> PackedRgba {
971        p(FG_DEFAULT_LIGHT, FG_DEFAULT)
972    }
973    pub fn fg_dim() -> PackedRgba {
974        p(FG_MUTED_LIGHT, FG_DIM)
975    }
976    pub fn fg_accent() -> PackedRgba {
977        p(FG_ACCENT_LIGHT, FG_ACCENT)
978    }
979    pub fn bg_surface() -> PackedRgba {
980        p(BG_SURFACE_LIGHT, BG_SURFACE)
981    }
982    pub fn bg_highlight() -> PackedRgba {
983        p(BG_HIGHLIGHT_LIGHT, BG_HIGHLIGHT)
984    }
985
986    // ── Semantic styles (runtime theme-aware) ────────────────────────
987
988    pub fn header() -> Style {
989        Style::new().fg(fg_accent()).bold()
990    }
991
992    pub fn header_bg() -> Style {
993        Style::new().fg(fg_accent()).bg(bg_surface()).bold()
994    }
995
996    pub fn footer() -> Style {
997        footer_hint()
998    }
999
1000    pub fn selected() -> Style {
1001        Style::new().fg(fg_default()).bg(bg_highlight()).bold()
1002    }
1003
1004    pub fn panel_title() -> Style {
1005        Style::new().fg(fg_default()).bold()
1006    }
1007
1008    pub fn panel_title_focused() -> Style {
1009        Style::new().fg(fg_accent()).bold()
1010    }
1011
1012    pub fn semantic_fg(tone: SemanticTone) -> PackedRgba {
1013        match tone {
1014            SemanticTone::Neutral => fg_default(),
1015            SemanticTone::Accent => fg_accent(),
1016            SemanticTone::Success => p(FG_SUCCESS_LIGHT, FG_SUCCESS),
1017            SemanticTone::Warning => p(FG_WARNING_LIGHT, FG_WARNING),
1018            SemanticTone::Danger => p(FG_ERROR_LIGHT, FG_ERROR),
1019            SemanticTone::Muted => fg_dim(),
1020        }
1021    }
1022
1023    pub fn semantic_bg(tone: SemanticTone) -> PackedRgba {
1024        match tone {
1025            SemanticTone::Neutral => bg_surface(),
1026            SemanticTone::Accent => p(BG_SURFACE_ACCENT_L, BG_SURFACE_ACCENT),
1027            SemanticTone::Success => p(BG_SURFACE_SUCCESS_L, BG_SURFACE_SUCCESS),
1028            SemanticTone::Warning => p(BG_SURFACE_WARNING_L, BG_SURFACE_WARNING),
1029            SemanticTone::Danger => p(BG_SURFACE_DANGER_L, BG_SURFACE_DANGER),
1030            SemanticTone::Muted => p(BG_SURFACE_MUTED_L, BG_SURFACE_MUTED),
1031        }
1032    }
1033
1034    pub fn chip_style(tone: SemanticTone) -> Style {
1035        Style::new()
1036            .fg(semantic_fg(tone))
1037            .bg(semantic_bg(tone))
1038            .bold()
1039    }
1040
1041    pub fn panel_border_for(tone: SemanticTone, focused: bool) -> Style {
1042        let tone = if focused { tone } else { SemanticTone::Muted };
1043        Style::new().fg(semantic_fg(tone))
1044    }
1045
1046    pub fn panel_title_for(tone: SemanticTone, focused: bool) -> Style {
1047        let tone = if focused { tone } else { SemanticTone::Neutral };
1048        Style::new().fg(semantic_fg(tone)).bold()
1049    }
1050
1051    pub fn status_style(status: &str) -> Style {
1052        Style::new().fg(status_fg(status))
1053    }
1054
1055    /// Status badge style with foreground and background.
1056    pub fn status_badge(status: &str) -> Style {
1057        Style::new()
1058            .fg(status_fg(status))
1059            .bg(status_bg(status))
1060            .bold()
1061    }
1062
1063    pub fn help_desc() -> Style {
1064        Style::new().fg(fg_default())
1065    }
1066
1067    pub fn dim() -> Style {
1068        Style::new().fg(fg_dim())
1069    }
1070}
1071
1072fn semantic_panel_block(title: &str, focused: bool, tone: SemanticTone) -> Block<'_> {
1073    Block::bordered()
1074        .title(title)
1075        .border_style(tokens::panel_border_for(tone, focused))
1076        .style(tokens::panel_title_for(tone, focused))
1077}
1078
1079fn push_chip(line: &mut RichLine, label: &str, tone: SemanticTone) {
1080    line.push_span(RichSpan::styled(
1081        truncate_display(label, 32),
1082        tokens::chip_style(tone),
1083    ));
1084}
1085
1086fn push_metric_chip(line: &mut RichLine, label: &str, value: &str, tone: SemanticTone) {
1087    push_chip(line, &format!("{label}={value}"), tone);
1088}
1089
1090fn build_header_text(app: &BvrApp, width: u16) -> RichText {
1091    let bp = Breakpoint::from_width(width);
1092    let visible_count = app.visible_issue_indices().len();
1093    let total_count = app.analyzer.issues.len();
1094    let mode_tabs = header_mode_tabs(app, width);
1095
1096    if matches!(bp, Breakpoint::Narrow) {
1097        let mut line = RichLine::new();
1098        line.push_span(RichSpan::styled("bvr", tokens::header()));
1099        line.push_span(RichSpan::raw(" "));
1100        for tab in &mode_tabs {
1101            push_chip(
1102                &mut line,
1103                &tab.mode.tab_text(bp),
1104                if tab.mode == app.mode {
1105                    SemanticTone::Accent
1106                } else {
1107                    SemanticTone::Muted
1108                },
1109            );
1110            line.push_span(RichSpan::raw(" "));
1111        }
1112        line.push_span(RichSpan::styled(" | ", tokens::dim()));
1113        push_chip(
1114            &mut line,
1115            &format!("{visible_count}/{total_count}"),
1116            SemanticTone::Neutral,
1117        );
1118        line.push_span(RichSpan::styled(" | ", tokens::dim()));
1119        push_chip(&mut line, app.list_filter.label(), SemanticTone::Muted);
1120        return RichText::from_lines([line]);
1121    }
1122
1123    let mut filter_label = app.list_filter.label().to_string();
1124    if let Some(ref label) = app.modal_label_filter {
1125        filter_label = format!("{filter_label}+label:{label}");
1126    }
1127    if let Some(ref repo) = app.modal_repo_filter {
1128        filter_label = format!("{filter_label}+repo:{repo}");
1129    }
1130    let mode_label = if matches!(app.mode, ViewMode::History) {
1131        format!("{} {}", app.mode.label(), app.history_view_mode.indicator())
1132    } else {
1133        app.mode.label().to_string()
1134    };
1135
1136    let mut line = RichLine::new();
1137    line.push_span(RichSpan::styled("bvr", tokens::header()));
1138    line.push_span(RichSpan::raw(" "));
1139    for tab in &mode_tabs {
1140        push_chip(
1141            &mut line,
1142            &tab.mode.tab_text(bp),
1143            if tab.mode == app.mode {
1144                SemanticTone::Accent
1145            } else {
1146                SemanticTone::Muted
1147            },
1148        );
1149        line.push_span(RichSpan::raw(" "));
1150    }
1151    line.push_span(RichSpan::styled("| mode=", tokens::dim()));
1152    push_chip(&mut line, &mode_label, SemanticTone::Accent);
1153    line.push_span(RichSpan::styled(" | focus=", tokens::dim()));
1154    push_chip(&mut line, app.focus.label(), SemanticTone::Warning);
1155    line.push_span(RichSpan::styled(" | ", tokens::dim()));
1156    push_metric_chip(
1157        &mut line,
1158        "issues",
1159        &format!("{visible_count}/{total_count}"),
1160        SemanticTone::Neutral,
1161    );
1162    if matches!(bp, Breakpoint::Medium | Breakpoint::Wide) {
1163        line.push_span(RichSpan::styled(" | ", tokens::dim()));
1164        push_metric_chip(&mut line, "filter", &filter_label, SemanticTone::Muted);
1165    }
1166    if matches!(bp, Breakpoint::Wide) && !app.slow_metrics_pending {
1167        line.push_span(RichSpan::styled(" | ", tokens::dim()));
1168        push_metric_chip(
1169            &mut line,
1170            "sort",
1171            app.list_sort.label(),
1172            SemanticTone::Neutral,
1173        );
1174    }
1175    if app.slow_metrics_pending {
1176        line.push_span(RichSpan::styled(" | metrics: ", tokens::dim()));
1177        push_chip(&mut line, "computing...", SemanticTone::Warning);
1178    }
1179    line.push_span(RichSpan::styled(" |", tokens::dim()));
1180    RichText::from_lines([line])
1181}
1182
1183// ---------------------------------------------------------------------------
1184// Reusable visual primitives — shared building blocks for TUI surfaces.
1185// Each returns RichSpan(s) or RichLine so callers can compose them freely.
1186// ---------------------------------------------------------------------------
1187
1188#[cfg_attr(not(test), allow(dead_code))]
1189/// Status chip: coloured icon + abbreviated status text.
1190/// Example output: `● open` (blue), `▶ in_progress` (yellow), `✖ closed` (green).
1191fn status_chip(status: &str) -> Vec<RichSpan<'static>> {
1192    let normalized = status.trim().to_ascii_lowercase();
1193    let (icon, label) = match normalized.as_str() {
1194        "open" => ("●", "open"),
1195        "in_progress" => ("▶", "prog"),
1196        "blocked" => ("■", "blkd"),
1197        "closed" => ("✔", "done"),
1198        "deferred" => ("◇", "defr"),
1199        "review" => ("◎", "revw"),
1200        "pinned" => ("⊤", "pind"),
1201        "tombstone" => ("†", "tomb"),
1202        "hooked" => ("⊙", "hook"),
1203        _ => ("?", "unkn"),
1204    };
1205    vec![
1206        RichSpan::styled(icon, tokens::status_style(&normalized)),
1207        RichSpan::styled(format!("{label}"), tokens::status_style(&normalized)),
1208    ]
1209}
1210
1211#[cfg_attr(not(test), allow(dead_code))]
1212/// Priority badge: coloured priority indicator.
1213/// Example output: `P0` (red), `P2` (blue).
1214fn priority_badge(priority: i32) -> RichSpan<'static> {
1215    let prio = priority.clamp(0, 4) as u8;
1216    RichSpan::styled(format!("P{prio}"), tokens::priority_style(prio).bold())
1217}
1218
1219#[cfg_attr(not(test), allow(dead_code))]
1220/// Type badge: single-letter issue type with type-specific colour.
1221/// Example output: `T` (task), `B` (bug), `E` (epic).
1222fn type_badge(issue_type: &str) -> RichSpan<'static> {
1223    let icon = type_icon(issue_type);
1224    RichSpan::styled(icon, ftui::Style::new().fg(tokens::type_fg(issue_type)))
1225}
1226
1227#[cfg_attr(not(test), allow(dead_code))]
1228/// Metric strip: compact inline metric display with label and mini bar.
1229/// Example output: `PR ██░░░░ 0.42` for PageRank.
1230fn metric_strip(label: &str, value: f64, max_value: f64) -> Vec<RichSpan<'static>> {
1231    let bar = mini_bar(value, max_value);
1232    let formatted = format!("{value:.2}");
1233    vec![
1234        RichSpan::styled(format!("{label} "), tokens::dim()),
1235        RichSpan::raw(bar),
1236        RichSpan::styled(format!(" {formatted}"), tokens::dim()),
1237    ]
1238}
1239
1240#[cfg_attr(not(test), allow(dead_code))]
1241/// Blocker indicator: shows blocking state with colour coding.
1242/// Returns empty vec if the issue has no blockers and blocks nothing.
1243fn blocker_indicator(open_blockers: usize, blocks_count: usize) -> Vec<RichSpan<'static>> {
1244    if open_blockers > 0 {
1245        vec![RichSpan::styled(
1246            format!("⊘{open_blockers}"),
1247            tokens::status_style("blocked"),
1248        )]
1249    } else if blocks_count > 0 {
1250        vec![RichSpan::styled(
1251            format!("↓{blocks_count}"),
1252            tokens::status_style("open"),
1253        )]
1254    } else {
1255        Vec::new()
1256    }
1257}
1258
1259#[cfg_attr(not(test), allow(dead_code))]
1260/// Section separator: dim horizontal rule spanning the given width.
1261fn section_separator(width: usize) -> RichLine {
1262    let rule = "─".repeat(width.min(120));
1263    RichLine::from_spans([RichSpan::styled(rule, tokens::dim())])
1264}
1265
1266#[cfg_attr(not(test), allow(dead_code))]
1267/// Panel header: bold title with optional subtitle.
1268fn panel_header<'a>(title: &'a str, subtitle: Option<&'a str>) -> RichLine {
1269    let mut spans = vec![RichSpan::styled(title, tokens::panel_title().bold())];
1270    if let Some(sub) = subtitle {
1271        spans.push(RichSpan::styled(format!("  {sub}"), tokens::dim()));
1272    }
1273    RichLine::from_spans(spans)
1274}
1275
1276#[cfg_attr(not(test), allow(dead_code))]
1277/// Label chips: coloured label tags inline.
1278fn label_chips(labels: &[String]) -> Vec<RichSpan<'static>> {
1279    let mut spans = Vec::new();
1280    for (i, label) in labels.iter().enumerate() {
1281        if i > 0 {
1282            spans.push(RichSpan::styled(" ", tokens::dim()));
1283        }
1284        spans.push(RichSpan::styled(format!("[{label}]"), tokens::header()));
1285    }
1286    spans
1287}
1288
1289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1290enum ScanLineVariant {
1291    Narrow,
1292    Medium,
1293    Wide,
1294}
1295
1296impl ScanLineVariant {
1297    fn from_width(width: usize) -> Self {
1298        if width < 54 {
1299            Self::Narrow
1300        } else if width < 92 {
1301            Self::Medium
1302        } else {
1303            Self::Wide
1304        }
1305    }
1306}
1307
1308#[derive(Debug, Clone, PartialEq, Eq)]
1309struct ScanSegment {
1310    label: String,
1311    kind: ScanSegmentKind,
1312}
1313
1314#[derive(Debug, Clone, Copy, PartialEq)]
1315struct ScanLineContext {
1316    open_blockers: usize,
1317    blocks_count: usize,
1318    triage_rank: usize,
1319    pagerank_rank: usize,
1320    critical_depth: usize,
1321    graph_score: f64,
1322    search_match_position: Option<usize>,
1323    total_search_matches: usize,
1324    diff_tag: Option<DiffTag>,
1325    available_width: usize,
1326}
1327
1328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1329enum DiffTag {
1330    New,
1331    Modified,
1332    Closed,
1333    Reopened,
1334}
1335
1336#[derive(Debug, Clone, PartialEq, Eq)]
1337enum ScanSegmentKind {
1338    Marker {
1339        selected: bool,
1340    },
1341    Chip(SemanticTone),
1342    Dim,
1343    Title {
1344        selected: bool,
1345    },
1346    Priority,
1347    Type,
1348    /// Badge styled by actual status (not the display label).
1349    StatusBadge {
1350        status: String,
1351    },
1352    Sparkline,
1353}
1354
1355fn push_scan_segment(line: &mut RichLine, segment: &ScanSegment, row_selected: bool) {
1356    let base_style = match segment.kind {
1357        ScanSegmentKind::Marker { selected } => {
1358            if selected {
1359                tokens::selected()
1360            } else {
1361                tokens::dim()
1362            }
1363        }
1364        ScanSegmentKind::Chip(tone) => tokens::chip_style(tone),
1365        ScanSegmentKind::Dim => tokens::dim(),
1366        ScanSegmentKind::Title { selected } => {
1367            if selected {
1368                tokens::panel_title()
1369            } else {
1370                tokens::help_desc()
1371            }
1372        }
1373        ScanSegmentKind::Priority => {
1374            let prio = segment
1375                .label
1376                .trim_start_matches('P')
1377                .parse::<i32>()
1378                .unwrap_or_default();
1379            tokens::priority_badge(prio as u8)
1380        }
1381        ScanSegmentKind::Type => {
1382            // Use type-specific colour from label (B/F/T/E/Q/D/R)
1383            let type_name = match segment.label.as_str() {
1384                "B" => "bug",
1385                "F" => "feature",
1386                "T" => "task",
1387                "E" => "epic",
1388                "Q" => "question",
1389                "D" => "docs",
1390                "R" => "refactor",
1391                _ => "",
1392            };
1393            ftui::Style::new().fg(tokens::type_fg(type_name))
1394        }
1395        ScanSegmentKind::StatusBadge { ref status } => tokens::status_badge(status),
1396        ScanSegmentKind::Sparkline => {
1397            // Estimate visual fill ratio from sparkline characters for heatmap colour.
1398            let char_count = segment.label.chars().count().max(1);
1399            let filled = segment.label.chars().filter(|c| *c != ' ').count();
1400            let score = filled as f64 / char_count as f64;
1401            ftui::Style::new().fg(tokens::heatmap_color(score))
1402        }
1403    };
1404    // Apply highlight background to entire row when selected
1405    let style = if row_selected {
1406        base_style.bg(tokens::bg_highlight())
1407    } else {
1408        base_style
1409    };
1410    line.push_span(RichSpan::styled(segment.label.clone(), style));
1411}
1412
1413fn scan_segments_width(segments: &[ScanSegment]) -> usize {
1414    if segments.is_empty() {
1415        return 0;
1416    }
1417    segments
1418        .iter()
1419        .map(|segment| display_width(&segment.label))
1420        .sum::<usize>()
1421        + segments.len().saturating_sub(1)
1422}
1423
1424fn issue_action_state(issue: &crate::model::Issue, open_blockers: usize) -> &'static str {
1425    if issue.is_closed_like() {
1426        "closed"
1427    } else if open_blockers > 0 {
1428        "blocked"
1429    } else {
1430        "ready"
1431    }
1432}
1433
1434fn action_state_tone(state: &str) -> SemanticTone {
1435    match state {
1436        "ready" => SemanticTone::Success,
1437        "blocked" => SemanticTone::Danger,
1438        "closed" => SemanticTone::Muted,
1439        _ => SemanticTone::Neutral,
1440    }
1441}
1442
1443fn dependency_signal_segments(
1444    open_blockers: usize,
1445    blocks_count: usize,
1446    variant: ScanLineVariant,
1447) -> Vec<ScanSegment> {
1448    let mut segments = Vec::new();
1449    if open_blockers > 0 {
1450        segments.push(ScanSegment {
1451            label: format!("⊘{open_blockers}"),
1452            kind: ScanSegmentKind::Chip(SemanticTone::Danger),
1453        });
1454    }
1455    if blocks_count > 0 && !matches!(variant, ScanLineVariant::Narrow) {
1456        segments.push(ScanSegment {
1457            label: format!("↓{blocks_count}"),
1458            kind: ScanSegmentKind::Chip(SemanticTone::Accent),
1459        });
1460    }
1461    segments
1462}
1463
1464fn issue_label_summary(issue: &crate::model::Issue) -> Option<String> {
1465    issue.labels.first().map(|label| {
1466        if issue.labels.len() > 1 {
1467            format!(
1468                "[{}+{}]",
1469                truncate_display(label, 10),
1470                issue.labels.len() - 1
1471            )
1472        } else {
1473            format!("[{}]", truncate_display(label, 12))
1474        }
1475    })
1476}
1477
1478/// Issue scan line: dense single-line summary for list views.
1479/// Format adapts by width to surface rank, state, ownership, freshness, and scope.
1480fn issue_scan_line(
1481    issue: &crate::model::Issue,
1482    is_selected: bool,
1483    context: ScanLineContext,
1484) -> RichLine {
1485    let variant = ScanLineVariant::from_width(context.available_width);
1486    let action_state = issue_action_state(issue, context.open_blockers);
1487    let mut prefix = vec![
1488        ScanSegment {
1489            label: if is_selected {
1490                "▸".to_string()
1491            } else {
1492                " ".to_string()
1493            },
1494            kind: ScanSegmentKind::Marker {
1495                selected: is_selected,
1496            },
1497        },
1498        ScanSegment {
1499            label: format!("#{:02}", context.triage_rank),
1500            kind: ScanSegmentKind::Chip(SemanticTone::Accent),
1501        },
1502        ScanSegment {
1503            label: action_state.to_string(),
1504            kind: ScanSegmentKind::Chip(action_state_tone(action_state)),
1505        },
1506        ScanSegment {
1507            label: format!("P{}", issue.priority.clamp(0, 4)),
1508            kind: ScanSegmentKind::Priority,
1509        },
1510        ScanSegment {
1511            label: truncate_display(&issue.id, 14),
1512            kind: ScanSegmentKind::Dim,
1513        },
1514    ];
1515
1516    if !matches!(variant, ScanLineVariant::Narrow) {
1517        prefix.push(ScanSegment {
1518            label: type_icon(&issue.issue_type).to_string(),
1519            kind: ScanSegmentKind::Type,
1520        });
1521    }
1522    if matches!(variant, ScanLineVariant::Wide) {
1523        prefix.push(ScanSegment {
1524            label: tokens::status_badge_label(&issue.status).to_string(),
1525            kind: ScanSegmentKind::StatusBadge {
1526                status: issue.status.clone(),
1527            },
1528        });
1529        // Sparkline for graph importance (5 chars)
1530        if context.graph_score > 0.0 {
1531            prefix.push(ScanSegment {
1532                label: tokens::render_sparkline(context.graph_score, 5),
1533                kind: ScanSegmentKind::Sparkline,
1534            });
1535        }
1536    }
1537
1538    let mut suffix =
1539        dependency_signal_segments(context.open_blockers, context.blocks_count, variant);
1540    if !issue.assignee.trim().is_empty() {
1541        suffix.push(ScanSegment {
1542            label: format!("@{}", truncate_display(issue.assignee.trim(), 12)),
1543            kind: ScanSegmentKind::Chip(SemanticTone::Neutral),
1544        });
1545    } else if matches!(variant, ScanLineVariant::Wide) {
1546        suffix.push(ScanSegment {
1547            label: "@unassigned".to_string(),
1548            kind: ScanSegmentKind::Chip(SemanticTone::Muted),
1549        });
1550    }
1551    if matches!(variant, ScanLineVariant::Medium | ScanLineVariant::Wide) {
1552        // Comment count (only if > 0)
1553        if !issue.comments.is_empty() {
1554            suffix.push(ScanSegment {
1555                label: format!("💬{}", issue.comments.len()),
1556                kind: ScanSegmentKind::Dim,
1557            });
1558        }
1559        // Updated timestamp
1560        suffix.push(ScanSegment {
1561            label: format!(
1562                "↻{}",
1563                format_compact_timestamp(issue.updated_at.or(issue.created_at))
1564            ),
1565            kind: ScanSegmentKind::Dim,
1566        });
1567    }
1568    if matches!(variant, ScanLineVariant::Wide) {
1569        suffix.push(ScanSegment {
1570            label: format!(
1571                "repo:{}",
1572                truncate_display(&display_or_fallback(&issue.source_repo, "local"), 10)
1573            ),
1574            kind: ScanSegmentKind::Chip(SemanticTone::Muted),
1575        });
1576        suffix.push(ScanSegment {
1577            label: format!("pr#{}", context.pagerank_rank),
1578            kind: ScanSegmentKind::Chip(SemanticTone::Neutral),
1579        });
1580        suffix.push(ScanSegment {
1581            label: format!("d{}", context.critical_depth),
1582            kind: ScanSegmentKind::Chip(if context.critical_depth > 0 {
1583                SemanticTone::Warning
1584            } else {
1585                SemanticTone::Muted
1586            }),
1587        });
1588        if let Some(label_summary) = issue_label_summary(issue) {
1589            suffix.push(ScanSegment {
1590                label: label_summary,
1591                kind: ScanSegmentKind::Chip(SemanticTone::Accent),
1592            });
1593        }
1594    }
1595    if let Some(position) = context.search_match_position {
1596        suffix.push(ScanSegment {
1597            label: if is_selected {
1598                format!("hit {position}/{}", context.total_search_matches)
1599            } else {
1600                "hit".to_string()
1601            },
1602            kind: ScanSegmentKind::Chip(if is_selected {
1603                SemanticTone::Warning
1604            } else {
1605                SemanticTone::Accent
1606            }),
1607        });
1608    }
1609
1610    // Time-travel diff marker
1611    if let Some(tag) = context.diff_tag {
1612        let (label, tone) = match tag {
1613            DiffTag::New => ("NEW", SemanticTone::Success),
1614            DiffTag::Modified => ("MOD", SemanticTone::Warning),
1615            DiffTag::Closed => ("CLO", SemanticTone::Muted),
1616            DiffTag::Reopened => ("RE!", SemanticTone::Danger),
1617        };
1618        suffix.push(ScanSegment {
1619            label: label.to_string(),
1620            kind: ScanSegmentKind::Chip(tone),
1621        });
1622    }
1623
1624    let min_title_width = match variant {
1625        ScanLineVariant::Narrow => 10,
1626        ScanLineVariant::Medium => 16,
1627        ScanLineVariant::Wide => 22,
1628    };
1629    while !suffix.is_empty()
1630        && context.available_width
1631            < scan_segments_width(&prefix)
1632                + scan_segments_width(&suffix)
1633                + min_title_width
1634                + usize::from(!prefix.is_empty())
1635                + usize::from(!suffix.is_empty())
1636    {
1637        suffix.pop();
1638    }
1639
1640    let reserved_width = scan_segments_width(&prefix)
1641        + scan_segments_width(&suffix)
1642        + usize::from(!prefix.is_empty())
1643        + usize::from(!suffix.is_empty());
1644    let title_width = context
1645        .available_width
1646        .saturating_sub(reserved_width)
1647        .max(min_title_width);
1648    let title = truncate_display(&issue.title, title_width);
1649
1650    let mut line = RichLine::new();
1651    let sep = if is_selected {
1652        RichSpan::styled(" ", tokens::selected())
1653    } else {
1654        RichSpan::raw(" ")
1655    };
1656    for (index, segment) in prefix.iter().enumerate() {
1657        if index > 0 {
1658            line.push_span(sep.clone());
1659        }
1660        push_scan_segment(&mut line, segment, is_selected);
1661    }
1662    if !prefix.is_empty() {
1663        line.push_span(sep.clone());
1664    }
1665    push_scan_segment(
1666        &mut line,
1667        &ScanSegment {
1668            label: title,
1669            kind: ScanSegmentKind::Title {
1670                selected: is_selected,
1671            },
1672        },
1673        is_selected,
1674    );
1675    if !suffix.is_empty() {
1676        line.push_span(sep.clone());
1677    }
1678    for (index, segment) in suffix.iter().enumerate() {
1679        if index > 0 {
1680            line.push_span(sep.clone());
1681        }
1682        push_scan_segment(&mut line, segment, is_selected);
1683    }
1684
1685    line
1686}
1687
1688#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1689pub enum ViewMode {
1690    Main,
1691    Board,
1692    Insights,
1693    Graph,
1694    History,
1695    Actionable,
1696    Attention,
1697    Tree,
1698    LabelDashboard,
1699    FlowMatrix,
1700    TimeTravelDiff,
1701    Sprint,
1702}
1703
1704impl ViewMode {
1705    /// Parse a CLI `--view` value into a `ViewMode`.
1706    pub fn from_cli(s: &str) -> Option<Self> {
1707        match s {
1708            "main" => Some(Self::Main),
1709            "board" => Some(Self::Board),
1710            "insights" => Some(Self::Insights),
1711            "graph" => Some(Self::Graph),
1712            "history" => Some(Self::History),
1713            "actionable" => Some(Self::Actionable),
1714            "attention" => Some(Self::Attention),
1715            "tree" => Some(Self::Tree),
1716            "labels" => Some(Self::LabelDashboard),
1717            "flow" => Some(Self::FlowMatrix),
1718            "timediff" => Some(Self::TimeTravelDiff),
1719            "sprint" => Some(Self::Sprint),
1720            _ => None,
1721        }
1722    }
1723}
1724
1725impl ViewMode {
1726    const fn navigation_order() -> [Self; 12] {
1727        [
1728            Self::Main,
1729            Self::Board,
1730            Self::Insights,
1731            Self::Graph,
1732            Self::History,
1733            Self::Actionable,
1734            Self::LabelDashboard,
1735            Self::FlowMatrix,
1736            Self::Attention,
1737            Self::Tree,
1738            Self::TimeTravelDiff,
1739            Self::Sprint,
1740        ]
1741    }
1742
1743    fn label(self) -> &'static str {
1744        match self {
1745            Self::Main => "Main",
1746            Self::Board => "Board",
1747            Self::Insights => "Insights",
1748            Self::Graph => "Graph",
1749            Self::History => "History",
1750            Self::Actionable => "Actionable",
1751            Self::Attention => "Attention",
1752            Self::Tree => "Tree",
1753            Self::LabelDashboard => "Labels",
1754            Self::FlowMatrix => "Flow",
1755            Self::TimeTravelDiff => "TimeTravel",
1756            Self::Sprint => "Sprint",
1757        }
1758    }
1759
1760    const fn shortcut(self) -> &'static str {
1761        match self {
1762            Self::Main => "1",
1763            Self::Board => "b",
1764            Self::Insights => "i",
1765            Self::Graph => "g",
1766            Self::History => "h",
1767            Self::Actionable => "a",
1768            Self::Attention => "!",
1769            Self::Tree => "T",
1770            Self::LabelDashboard => "[",
1771            Self::FlowMatrix => "]",
1772            Self::TimeTravelDiff => "t",
1773            Self::Sprint => "S",
1774        }
1775    }
1776
1777    const fn short_label(self) -> &'static str {
1778        match self {
1779            Self::Main => "Main",
1780            Self::Board => "Board",
1781            Self::Insights => "In",
1782            Self::Graph => "Graph",
1783            Self::History => "Hist",
1784            Self::Actionable => "Act",
1785            Self::Attention => "Attn",
1786            Self::Tree => "Tree",
1787            Self::LabelDashboard => "Lbl",
1788            Self::FlowMatrix => "Flow",
1789            Self::TimeTravelDiff => "Diff",
1790            Self::Sprint => "Sprint",
1791        }
1792    }
1793
1794    fn tab_text(self, bp: Breakpoint) -> String {
1795        let label = if matches!(bp, Breakpoint::Narrow) {
1796            self.short_label()
1797        } else {
1798            self.label()
1799        };
1800        format!("{} {label}", self.shortcut())
1801    }
1802}
1803
1804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1805enum FocusPane {
1806    List,
1807    Middle,
1808    Detail,
1809}
1810
1811impl FocusPane {
1812    fn label(self) -> &'static str {
1813        match self {
1814            Self::List => "list",
1815            Self::Middle => "middle",
1816            Self::Detail => "detail",
1817        }
1818    }
1819}
1820
1821#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1822pub enum ListFilter {
1823    All,
1824    Open,
1825    InProgress,
1826    Blocked,
1827    Closed,
1828    Ready,
1829}
1830
1831impl ListFilter {
1832    fn label(self) -> &'static str {
1833        match self {
1834            Self::All => "all",
1835            Self::Open => "open",
1836            Self::InProgress => "in-progress",
1837            Self::Blocked => "blocked",
1838            Self::Closed => "closed",
1839            Self::Ready => "ready",
1840        }
1841    }
1842
1843    /// Parse a CLI `--list-filter` value into a `ListFilter`.
1844    pub fn from_cli(s: &str) -> Option<Self> {
1845        match s {
1846            "all" => Some(Self::All),
1847            "open" => Some(Self::Open),
1848            "in-progress" | "in_progress" | "inprogress" => Some(Self::InProgress),
1849            "blocked" => Some(Self::Blocked),
1850            "closed" => Some(Self::Closed),
1851            "ready" => Some(Self::Ready),
1852            _ => None,
1853        }
1854    }
1855}
1856
1857#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1858enum ListSort {
1859    Default,
1860    CreatedAsc,
1861    CreatedDesc,
1862    Priority,
1863    Updated,
1864    PageRank,
1865    Blockers,
1866}
1867
1868impl ListSort {
1869    fn label(self) -> &'static str {
1870        match self {
1871            Self::Default => "default",
1872            Self::CreatedAsc => "created-asc",
1873            Self::CreatedDesc => "created-desc",
1874            Self::Priority => "priority",
1875            Self::Updated => "updated",
1876            Self::PageRank => "pagerank",
1877            Self::Blockers => "blockers",
1878        }
1879    }
1880
1881    fn next(self) -> Self {
1882        match self {
1883            Self::Default => Self::CreatedAsc,
1884            Self::CreatedAsc => Self::CreatedDesc,
1885            Self::CreatedDesc => Self::Priority,
1886            Self::Priority => Self::Updated,
1887            Self::Updated => Self::PageRank,
1888            Self::PageRank => Self::Blockers,
1889            Self::Blockers => Self::Default,
1890        }
1891    }
1892}
1893
1894#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1895enum BoardGrouping {
1896    Status,
1897    Priority,
1898    Type,
1899}
1900
1901impl BoardGrouping {
1902    fn label(self) -> &'static str {
1903        match self {
1904            Self::Status => "status",
1905            Self::Priority => "priority",
1906            Self::Type => "type",
1907        }
1908    }
1909
1910    fn next(self) -> Self {
1911        match self {
1912            Self::Status => Self::Priority,
1913            Self::Priority => Self::Type,
1914            Self::Type => Self::Status,
1915        }
1916    }
1917}
1918
1919/// 3-state empty lane visibility: Auto → `ShowAll` → `HideEmpty` → Auto.
1920/// Auto: status grouping shows all, priority/type grouping hides empty.
1921#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1922enum EmptyLaneVisibility {
1923    Auto,
1924    ShowAll,
1925    HideEmpty,
1926}
1927
1928impl EmptyLaneVisibility {
1929    fn next(self) -> Self {
1930        match self {
1931            Self::Auto => Self::ShowAll,
1932            Self::ShowAll => Self::HideEmpty,
1933            Self::HideEmpty => Self::Auto,
1934        }
1935    }
1936
1937    fn label(self) -> &'static str {
1938        match self {
1939            Self::Auto => "Auto",
1940            Self::ShowAll => "Show All",
1941            Self::HideEmpty => "Hide Empty",
1942        }
1943    }
1944
1945    fn should_show_empty(self, grouping: BoardGrouping) -> bool {
1946        match self {
1947            Self::ShowAll => true,
1948            Self::HideEmpty => false,
1949            Self::Auto => matches!(grouping, BoardGrouping::Status),
1950        }
1951    }
1952}
1953
1954#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1955enum HistoryViewMode {
1956    Bead,
1957    Git,
1958}
1959
1960impl HistoryViewMode {
1961    fn label(self) -> &'static str {
1962        match self {
1963            Self::Bead => "bead",
1964            Self::Git => "git",
1965        }
1966    }
1967
1968    fn indicator(self) -> &'static str {
1969        match self {
1970            Self::Bead => "◈ Beads",
1971            Self::Git => "◉ Git",
1972        }
1973    }
1974
1975    fn toggle(self) -> Self {
1976        match self {
1977            Self::Bead => Self::Git,
1978            Self::Git => Self::Bead,
1979        }
1980    }
1981}
1982
1983#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1984enum HistoryLayout {
1985    Narrow,
1986    Standard,
1987    Wide,
1988}
1989
1990impl HistoryLayout {
1991    fn from_width(width: u16) -> Self {
1992        if width < 100 {
1993            Self::Narrow
1994        } else if width < 150 {
1995            Self::Standard
1996        } else {
1997            Self::Wide
1998        }
1999    }
2000
2001    fn has_middle_pane(self) -> bool {
2002        !matches!(self, Self::Narrow)
2003    }
2004}
2005
2006/// Search mode for history view — determines which fields the search query matches against.
2007#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2008enum HistorySearchMode {
2009    /// Search across all fields (default).
2010    #[default]
2011    All,
2012    /// Search commit messages only.
2013    Commit,
2014    /// Search by SHA prefix.
2015    Sha,
2016    /// Search bead ID/title only.
2017    Bead,
2018    /// Search by author name.
2019    Author,
2020}
2021
2022impl HistorySearchMode {
2023    fn label(self) -> &'static str {
2024        match self {
2025            Self::All => "all",
2026            Self::Commit => "msg",
2027            Self::Sha => "sha",
2028            Self::Bead => "bead",
2029            Self::Author => "author",
2030        }
2031    }
2032
2033    fn cycle(self) -> Self {
2034        match self {
2035            Self::All => Self::Commit,
2036            Self::Commit => Self::Sha,
2037            Self::Sha => Self::Bead,
2038            Self::Bead => Self::Author,
2039            Self::Author => Self::All,
2040        }
2041    }
2042}
2043
2044/// A node in the history file tree (port of Go `FileTreeNode`).
2045#[derive(Debug, Clone)]
2046struct FileTreeNode {
2047    name: String,
2048    path: String,
2049    is_dir: bool,
2050    change_count: usize,
2051    expanded: bool,
2052    level: usize,
2053    children: Vec<Self>,
2054}
2055
2056impl FileTreeNode {
2057    /// Flatten the tree into a list of visible (expanded) nodes for navigation.
2058    fn flatten_visible(&self) -> Vec<FlatFileEntry> {
2059        let mut out = Vec::new();
2060        self.flatten_into(&mut out);
2061        out
2062    }
2063
2064    fn flatten_into(&self, out: &mut Vec<FlatFileEntry>) {
2065        out.push(FlatFileEntry {
2066            name: self.name.clone(),
2067            path: self.path.clone(),
2068            is_dir: self.is_dir,
2069            change_count: self.change_count,
2070            level: self.level,
2071        });
2072        if self.is_dir && self.expanded {
2073            for child in &self.children {
2074                child.flatten_into(out);
2075            }
2076        }
2077    }
2078}
2079
2080/// A single visible entry in the flattened file tree.
2081#[derive(Debug, Clone)]
2082struct FlatFileEntry {
2083    name: String,
2084    path: String,
2085    is_dir: bool,
2086    change_count: usize,
2087    level: usize,
2088}
2089
2090#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2091enum InsightsPanel {
2092    Bottlenecks,
2093    Keystones,
2094    CriticalPath,
2095    Influencers,
2096    Betweenness,
2097    Hubs,
2098    Authorities,
2099    Cores,
2100    CutPoints,
2101    Slack,
2102    Cycles,
2103    Priority,
2104}
2105
2106impl InsightsPanel {
2107    fn label(self) -> &'static str {
2108        match self {
2109            Self::Bottlenecks => "Bottlenecks",
2110            Self::Keystones => "Keystones",
2111            Self::CriticalPath => "Critical Path",
2112            Self::Influencers => "Influencers (PageRank)",
2113            Self::Betweenness => "Betweenness",
2114            Self::Hubs => "Hubs (HITS)",
2115            Self::Authorities => "Authorities (HITS)",
2116            Self::Cores => "K-Core Cohesion",
2117            Self::CutPoints => "Cut Points",
2118            Self::Slack => "Slack (Zero)",
2119            Self::Cycles => "Cycles",
2120            Self::Priority => "Priority",
2121        }
2122    }
2123
2124    fn short_label(self) -> &'static str {
2125        match self {
2126            Self::Bottlenecks => "bottlenecks",
2127            Self::Keystones => "keystones",
2128            Self::CriticalPath => "crit-path",
2129            Self::Influencers => "influencers",
2130            Self::Betweenness => "betweenness",
2131            Self::Hubs => "hubs",
2132            Self::Authorities => "authorities",
2133            Self::Cores => "k-core",
2134            Self::CutPoints => "cut-pts",
2135            Self::Slack => "slack",
2136            Self::Cycles => "cycles",
2137            Self::Priority => "priority",
2138        }
2139    }
2140
2141    fn next(self) -> Self {
2142        match self {
2143            Self::Bottlenecks => Self::Keystones,
2144            Self::Keystones => Self::CriticalPath,
2145            Self::CriticalPath => Self::Influencers,
2146            Self::Influencers => Self::Betweenness,
2147            Self::Betweenness => Self::Hubs,
2148            Self::Hubs => Self::Authorities,
2149            Self::Authorities => Self::Cores,
2150            Self::Cores => Self::CutPoints,
2151            Self::CutPoints => Self::Slack,
2152            Self::Slack => Self::Cycles,
2153            Self::Cycles => Self::Priority,
2154            Self::Priority => Self::Bottlenecks,
2155        }
2156    }
2157
2158    fn prev(self) -> Self {
2159        match self {
2160            Self::Bottlenecks => Self::Priority,
2161            Self::Keystones => Self::Bottlenecks,
2162            Self::CriticalPath => Self::Keystones,
2163            Self::Influencers => Self::CriticalPath,
2164            Self::Betweenness => Self::Influencers,
2165            Self::Hubs => Self::Betweenness,
2166            Self::Authorities => Self::Hubs,
2167            Self::Cores => Self::Authorities,
2168            Self::CutPoints => Self::Cores,
2169            Self::Slack => Self::CutPoints,
2170            Self::Cycles => Self::Slack,
2171            Self::Priority => Self::Cycles,
2172        }
2173    }
2174}
2175
2176const INSIGHTS_HEATMAP_DEPTH_LABELS: [&str; 5] = ["D=0", "D1-2", "D3-5", "D6-10", "D10+"];
2177const INSIGHTS_HEATMAP_SCORE_LABELS: [&str; 5] = ["0-.2", ".2-.4", ".4-.6", ".6-.8", ".8-1"];
2178
2179#[derive(Debug, Clone, Default)]
2180struct InsightsHeatmapState {
2181    row: usize,
2182    col: usize,
2183    drill_active: bool,
2184    drill_cursor: usize,
2185}
2186
2187#[derive(Debug, Clone)]
2188struct InsightsHeatmapData {
2189    counts: Vec<Vec<usize>>,
2190    issue_ids: Vec<Vec<Vec<String>>>,
2191}
2192
2193const HISTORY_CONFIDENCE_STEPS: [f64; 4] = [0.0, 0.5, 0.75, 0.9];
2194
2195#[derive(Debug, Clone)]
2196struct HistoryGitCache {
2197    commits: Vec<GitCommitRecord>,
2198    histories: BTreeMap<String, HistoryBeadCompat>,
2199    commit_bead_confidence: BTreeMap<String, Vec<(String, f64)>>,
2200}
2201
2202// ---------------------------------------------------------------------------
2203// Modal overlays
2204// ---------------------------------------------------------------------------
2205
2206/// Modal overlays that take over the full screen.
2207#[derive(Debug, Clone, PartialEq, Eq)]
2208enum ModalOverlay {
2209    /// Welcome / first-run tutorial.
2210    Tutorial,
2211    /// Reusable Y/N confirmation dialog.
2212    Confirm {
2213        title: String,
2214        message: String,
2215        resume_overlay: Option<Box<ModalOverlay>>,
2216    },
2217    /// Interactive pages export wizard.
2218    PagesWizard(PagesWizardState),
2219    /// Recipe picker: shows available triage recipes.
2220    RecipePicker {
2221        items: Vec<(String, String)>,
2222        cursor: usize,
2223    },
2224    /// Label picker: shows all labels for quick filtering.
2225    LabelPicker {
2226        items: Vec<(String, usize)>,
2227        cursor: usize,
2228        filter: String,
2229    },
2230    /// Repo picker: shows workspace repos for filtering.
2231    RepoPicker {
2232        items: Vec<String>,
2233        cursor: usize,
2234        filter: String,
2235    },
2236}
2237
2238/// State for the multi-step pages export wizard.
2239#[derive(Debug, Clone, PartialEq, Eq)]
2240struct PagesWizardState {
2241    step: usize,
2242    export_dir: String,
2243    title: String,
2244    include_closed: bool,
2245    include_history: bool,
2246}
2247
2248impl PagesWizardState {
2249    fn new() -> Self {
2250        Self {
2251            step: 0,
2252            export_dir: "./bv-pages".to_string(),
2253            title: String::new(),
2254            include_closed: true,
2255            include_history: true,
2256        }
2257    }
2258
2259    fn step_count() -> usize {
2260        4
2261    }
2262
2263    fn step_label(&self) -> &'static str {
2264        match self.step {
2265            0 => "Export Directory",
2266            1 => "Page Title",
2267            2 => "Options",
2268            3 => "Review & Export",
2269            _ => "Done",
2270        }
2271    }
2272}
2273
2274#[derive(Debug, Clone)]
2275struct HistoryTimelineEvent {
2276    issue_id: String,
2277    issue_title: String,
2278    issue_status: String,
2279    event_kind: String,
2280    event_timestamp: Option<DateTime<Utc>>,
2281    event_details: String,
2282}
2283
2284/// A flattened node in the dependency tree for rendering.
2285#[derive(Debug, Clone)]
2286struct TreeFlatNode {
2287    /// Issue index into `analyzer.issues`.
2288    issue_index: usize,
2289    /// Depth in the tree (0 = root).
2290    depth: usize,
2291    /// Whether this node has children.
2292    has_children: bool,
2293    /// Whether this node's children are currently collapsed.
2294    is_collapsed: bool,
2295    /// Whether this is the last sibling at its depth (for box-drawing).
2296    is_last_sibling: bool,
2297    /// The prefix ancestry for box drawing (true = parent was last sibling at that depth).
2298    ancestry_last: Vec<bool>,
2299}
2300
2301#[derive(Debug)]
2302enum Msg {
2303    KeyPress(KeyCode, Modifiers),
2304    Mouse(MouseEvent),
2305    #[cfg(not(test))]
2306    Tick,
2307    #[cfg(not(test))]
2308    BackgroundReloaded(std::result::Result<Vec<Issue>, String>),
2309    Noop,
2310}
2311
2312impl From<Event> for Msg {
2313    fn from(event: Event) -> Self {
2314        match event {
2315            Event::Key(KeyEvent {
2316                code,
2317                modifiers,
2318                kind: KeyEventKind::Press,
2319                ..
2320            }) => Self::KeyPress(code, modifiers),
2321            Event::Mouse(event) => Self::Mouse(event),
2322            #[cfg(not(test))]
2323            Event::Tick => Self::Tick,
2324            _ => Self::Noop,
2325        }
2326    }
2327}
2328
2329#[derive(Debug)]
2330struct BvrApp {
2331    analyzer: Analyzer,
2332    repo_root: Option<PathBuf>,
2333    selected: usize,
2334    list_filter: ListFilter,
2335    list_sort: ListSort,
2336    board_grouping: BoardGrouping,
2337    board_empty_visibility: EmptyLaneVisibility,
2338    mode: ViewMode,
2339    mode_before_history: ViewMode,
2340    /// Navigation back stack: tracks which modes led to the current one.
2341    mode_back_stack: Vec<ViewMode>,
2342    focus: FocusPane,
2343    focus_before_help: FocusPane,
2344    show_help: bool,
2345    help_scroll_offset: usize,
2346    show_quit_confirm: bool,
2347    modal_overlay: Option<ModalOverlay>,
2348    modal_confirm_result: Option<bool>,
2349    history_confidence_index: usize,
2350    history_view_mode: HistoryViewMode,
2351    history_event_cursor: usize,
2352    history_related_bead_cursor: usize,
2353    history_bead_commit_cursor: usize,
2354    history_git_cache: Option<HistoryGitCache>,
2355    history_search_active: bool,
2356    history_search_query: String,
2357    history_search_match_cursor: usize,
2358    history_search_mode: HistorySearchMode,
2359    history_show_file_tree: bool,
2360    history_file_tree_cursor: usize,
2361    history_file_tree_filter: Option<String>,
2362    history_file_tree_focus: bool,
2363    history_status_msg: String,
2364    board_search_active: bool,
2365    board_search_query: String,
2366    board_search_match_cursor: usize,
2367    board_detail_scroll_offset: usize,
2368    /// Universal detail pane scroll offset — works in all modes when focus is Detail.
2369    detail_scroll_offset: usize,
2370    main_search_active: bool,
2371    main_search_query: String,
2372    main_search_match_cursor: usize,
2373    list_scroll_offset: Cell<usize>,
2374    list_viewport_height: Cell<usize>,
2375    graph_search_active: bool,
2376    graph_search_query: String,
2377    graph_search_match_cursor: usize,
2378    insights_search_active: bool,
2379    insights_search_query: String,
2380    insights_search_match_cursor: usize,
2381    insights_panel: InsightsPanel,
2382    insights_heatmap: Option<InsightsHeatmapState>,
2383    insights_show_explanations: bool,
2384    insights_show_calc_proof: bool,
2385    detail_dep_cursor: usize,
2386    actionable_plan: Option<crate::analysis::plan::ExecutionPlan>,
2387    actionable_track_cursor: usize,
2388    actionable_item_cursor: usize,
2389    attention_result: Option<crate::analysis::label_intel::LabelAttentionResult>,
2390    attention_cursor: usize,
2391    tree_flat_nodes: Vec<TreeFlatNode>,
2392    tree_cursor: usize,
2393    tree_collapsed: std::collections::HashSet<String>,
2394    tree_search_active: bool,
2395    tree_search_query: String,
2396    tree_search_match_cursor: usize,
2397    /// When true, the previous key was `g`; a second `g` triggers `gg` (jump-to-top).
2398    pending_g: bool,
2399    /// Saved mode before the speculative graph toggle so `gg` can restore it.
2400    g_pre_toggle_mode: Option<ViewMode>,
2401    /// When true, the previous key was `z`; the next key completes a z-command.
2402    pending_z: bool,
2403    label_dashboard: Option<crate::analysis::label_intel::LabelHealthResult>,
2404    label_dashboard_cursor: usize,
2405    flow_matrix: Option<crate::analysis::label_intel::CrossLabelFlow>,
2406    flow_matrix_row_cursor: usize,
2407    flow_matrix_col_cursor: usize,
2408    sprint_data: Vec<Sprint>,
2409    sprint_cursor: usize,
2410    sprint_issue_cursor: usize,
2411    modal_label_filter: Option<String>,
2412    modal_repo_filter: Option<String>,
2413    time_travel_ref_input: String,
2414    time_travel_input_active: bool,
2415    time_travel_diff: Option<crate::analysis::diff::SnapshotDiff>,
2416    time_travel_category_cursor: usize,
2417    time_travel_issue_cursor: usize,
2418    time_travel_last_ref: Option<String>,
2419    priority_hints_visible: bool,
2420    status_msg: String,
2421    slow_metrics_pending: bool,
2422    #[cfg(not(test))]
2423    slow_metrics_rx: Option<std::sync::mpsc::Receiver<crate::analysis::graph::GraphMetrics>>,
2424    #[cfg(not(test))]
2425    background_runtime: Option<BackgroundRuntimeState>,
2426    /// Per-key event trace log for e2e debugging. Each entry records
2427    /// the key pressed and resulting state snapshot.
2428    #[cfg(test)]
2429    key_trace: Vec<KeyTraceEntry>,
2430}
2431
2432/// A single entry in the per-key event trace log.
2433#[cfg(test)]
2434#[derive(Debug, Clone)]
2435#[allow(dead_code)]
2436struct KeyTraceEntry {
2437    key: String,
2438    mode: ViewMode,
2439    focus: FocusPane,
2440    selected: usize,
2441    filter: ListFilter,
2442}
2443
2444impl Model for BvrApp {
2445    type Message = Msg;
2446
2447    fn init(&mut self) -> Cmd<Self::Message> {
2448        #[cfg(not(test))]
2449        {
2450            self.background_tick_command()
2451        }
2452        #[cfg(test)]
2453        {
2454            Cmd::None
2455        }
2456    }
2457
2458    fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
2459        match msg {
2460            Msg::KeyPress(code, modifiers) => {
2461                let mode_before = self.mode;
2462                let cmd = self.handle_key(code, modifiers);
2463                if self.mode != mode_before {
2464                    self.list_scroll_offset.set(0);
2465                }
2466                #[cfg(test)]
2467                self.key_trace.push(KeyTraceEntry {
2468                    key: format!("{code:?}"),
2469                    mode: self.mode,
2470                    focus: self.focus,
2471                    selected: self.selected,
2472                    filter: self.list_filter,
2473                });
2474                #[cfg(not(test))]
2475                {
2476                    return self.wrap_quit_with_background_cancel(cmd);
2477                }
2478                #[cfg(test)]
2479                {
2480                    return cmd;
2481                }
2482            }
2483            Msg::Mouse(event) => return self.handle_mouse(event),
2484            #[cfg(not(test))]
2485            Msg::Tick => return self.handle_background_tick(),
2486            #[cfg(not(test))]
2487            Msg::BackgroundReloaded(result) => {
2488                self.handle_background_reload_result(result);
2489                return Cmd::None;
2490            }
2491            Msg::Noop => {}
2492        }
2493
2494        Cmd::None
2495    }
2496
2497    fn view(&self, frame: &mut Frame) {
2498        let full = Rect::from_size(frame.buffer.width(), frame.buffer.height());
2499        record_view_size(full.width, full.height);
2500        record_detail_content_area(Rect::default());
2501        let bp = Breakpoint::from_width(full.width);
2502
2503        let rows = Flex::vertical()
2504            .constraints([
2505                Constraint::Fixed(1),
2506                Constraint::Min(3),
2507                Constraint::Fixed(1),
2508            ])
2509            .split(full);
2510
2511        // -- Header ----------------------------------------------------------
2512        let header_text = build_header_text(self, full.width);
2513        Paragraph::new(header_text)
2514            .style(tokens::header_bg())
2515            .render(rows[0], frame);
2516
2517        // -- Help overlay ----------------------------------------------------
2518        if self.show_help {
2519            let inner_width = rows[1].width.saturating_sub(2) as usize;
2520            let full_help = self.help_overlay_text(inner_width);
2521            let help_lines: Vec<&str> = full_help.lines().collect();
2522            let visible_height = rows[1].height.saturating_sub(2) as usize; // border
2523            let max_offset = help_lines.len().saturating_sub(visible_height);
2524            let offset = self.help_scroll_offset.min(max_offset);
2525            let visible: String = help_lines
2526                .iter()
2527                .skip(offset)
2528                .take(visible_height)
2529                .copied()
2530                .collect::<Vec<&str>>()
2531                .join("\n");
2532            Paragraph::new(visible)
2533                .block(semantic_panel_block("Help", true, SemanticTone::Accent))
2534                .render(rows[1], frame);
2535            let scroll_hint = if help_lines.len() > visible_height {
2536                format!(
2537                    "? or Esc close | j/k scroll | Ctrl+d/u page | line {}/{}",
2538                    offset + 1,
2539                    help_lines.len()
2540                )
2541            } else {
2542                "? or Esc to close help".to_string()
2543            };
2544            Paragraph::new(scroll_hint)
2545                .style(tokens::footer())
2546                .render(rows[2], frame);
2547            return;
2548        }
2549
2550        // -- Quit confirmation -----------------------------------------------
2551        if self.show_quit_confirm {
2552            Paragraph::new("Quit bvr?\n\nPress Esc or Y to quit.\nPress any other key to cancel.")
2553                .block(semantic_panel_block(
2554                    "Confirm Quit",
2555                    false,
2556                    SemanticTone::Danger,
2557                ))
2558                .render(rows[1], frame);
2559            Paragraph::new("Esc/Y confirms quit. Any other key cancels.")
2560                .style(tokens::footer())
2561                .render(rows[2], frame);
2562            return;
2563        }
2564
2565        // -- Modal overlays --------------------------------------------------
2566        if let Some(ref overlay) = self.modal_overlay {
2567            match overlay {
2568                ModalOverlay::Tutorial => {
2569                    let text = concat!(
2570                        "Welcome to bvr!\n\n",
2571                        "Modes:  b=board  i=insights  g=graph  h=history\n",
2572                        "Filter: o=open   c=closed    r=ready  a=all\n",
2573                        "Nav:    j/k=up/down  Tab=focus  /=search  n/N=cycle\n",
2574                        "Other:  ?=help   s=sort  Enter=select  Esc=back  q=quit\n\n",
2575                        "Press any key to dismiss."
2576                    );
2577                    Paragraph::new(text)
2578                        .block(semantic_panel_block("Tutorial", true, SemanticTone::Accent))
2579                        .render(rows[1], frame);
2580                    Paragraph::new("Press any key to continue.")
2581                        .style(tokens::footer())
2582                        .render(rows[2], frame);
2583                    return;
2584                }
2585                ModalOverlay::Confirm { title, message, .. } => {
2586                    let text = format!("{message}\n\nPress Y to confirm, N or Esc to cancel.");
2587                    Paragraph::new(text)
2588                        .block(semantic_panel_block(
2589                            title.as_str(),
2590                            false,
2591                            SemanticTone::Danger,
2592                        ))
2593                        .render(rows[1], frame);
2594                    Paragraph::new("Y=confirm | N/Esc=cancel")
2595                        .style(tokens::footer())
2596                        .render(rows[2], frame);
2597                    return;
2598                }
2599                ModalOverlay::PagesWizard(wiz) => {
2600                    let text = Self::pages_wizard_text(wiz);
2601                    let wiz_title = format!(
2602                        "Pages Wizard ({}/{}): {}",
2603                        wiz.step + 1,
2604                        PagesWizardState::step_count(),
2605                        wiz.step_label()
2606                    );
2607                    Paragraph::new(text)
2608                        .block(semantic_panel_block(
2609                            wiz_title.as_str(),
2610                            true,
2611                            SemanticTone::Accent,
2612                        ))
2613                        .render(rows[1], frame);
2614                    let footer = if wiz.step == PagesWizardState::step_count() - 1 {
2615                        "Enter=export | Esc=cancel | Backspace=prev step"
2616                    } else {
2617                        "Enter=next step | Esc=cancel | Backspace=prev step"
2618                    };
2619                    Paragraph::new(footer)
2620                        .style(tokens::footer())
2621                        .render(rows[2], frame);
2622                    return;
2623                }
2624                ModalOverlay::RecipePicker { items, cursor } => {
2625                    let mut lines = Vec::new();
2626                    for (i, (name, desc)) in items.iter().enumerate() {
2627                        let marker = if i == *cursor { "▸" } else { " " };
2628                        lines.push(format!(" {marker} {name:16} {desc}"));
2629                    }
2630                    let text = if lines.is_empty() {
2631                        " No recipes available.".to_string()
2632                    } else {
2633                        lines.join("\n")
2634                    };
2635                    Paragraph::new(text)
2636                        .block(semantic_panel_block(
2637                            "Recipe Picker",
2638                            true,
2639                            SemanticTone::Accent,
2640                        ))
2641                        .render(rows[1], frame);
2642                    Paragraph::new("j/k=navigate | Enter=apply | Esc=close")
2643                        .style(tokens::footer())
2644                        .render(rows[2], frame);
2645                    return;
2646                }
2647                ModalOverlay::LabelPicker {
2648                    items,
2649                    cursor,
2650                    filter,
2651                } => {
2652                    let needle = filter.to_ascii_lowercase();
2653                    let mut lines = Vec::new();
2654                    if !filter.is_empty() {
2655                        lines.push(format!(" Filter: /{filter}"));
2656                    }
2657                    let mut vis_idx = 0usize;
2658                    for (label, count) in items {
2659                        if !needle.is_empty() && !label.to_ascii_lowercase().contains(&needle) {
2660                            continue;
2661                        }
2662                        let marker = if vis_idx == *cursor { "▸" } else { " " };
2663                        lines.push(format!(" {marker} {label:24} ({count} issues)"));
2664                        vis_idx += 1;
2665                    }
2666                    let text = if vis_idx == 0 {
2667                        if filter.is_empty() {
2668                            " No labels found.".to_string()
2669                        } else {
2670                            format!(" No labels match: /{filter}")
2671                        }
2672                    } else {
2673                        lines.join("\n")
2674                    };
2675                    Paragraph::new(text)
2676                        .block(semantic_panel_block(
2677                            "Label Picker",
2678                            true,
2679                            SemanticTone::Accent,
2680                        ))
2681                        .render(rows[1], frame);
2682                    Paragraph::new("Type to filter | ↑/↓=navigate | Enter=apply | Esc=close")
2683                        .style(tokens::footer())
2684                        .render(rows[2], frame);
2685                    return;
2686                }
2687                ModalOverlay::RepoPicker {
2688                    items,
2689                    cursor,
2690                    filter,
2691                } => {
2692                    let needle = filter.to_ascii_lowercase();
2693                    let mut lines = Vec::new();
2694                    if !filter.is_empty() {
2695                        lines.push(format!(" Filter: /{filter}"));
2696                    }
2697                    let mut vis_idx = 0usize;
2698                    for repo in items {
2699                        if !needle.is_empty() && !repo.to_ascii_lowercase().contains(&needle) {
2700                            continue;
2701                        }
2702                        let marker = if vis_idx == *cursor { "▸" } else { " " };
2703                        lines.push(format!(" {marker} {repo}"));
2704                        vis_idx += 1;
2705                    }
2706                    let text = if vis_idx == 0 {
2707                        if filter.is_empty() {
2708                            " No repos found.".to_string()
2709                        } else {
2710                            format!(" No repos match: /{filter}")
2711                        }
2712                    } else {
2713                        lines.join("\n")
2714                    };
2715                    Paragraph::new(text)
2716                        .block(semantic_panel_block(
2717                            "Repo Picker",
2718                            true,
2719                            SemanticTone::Accent,
2720                        ))
2721                        .render(rows[1], frame);
2722                    Paragraph::new("Type to filter | ↑/↓=navigate | Enter=apply | Esc=close")
2723                        .style(tokens::footer())
2724                        .render(rows[2], frame);
2725                    return;
2726                }
2727            }
2728        }
2729
2730        // -- Body: mode-aware panes with breakpoint-aware widths --------------
2731        let body = rows[1];
2732        let graph_single_pane =
2733            matches!(self.mode, ViewMode::Graph) && matches!(bp, Breakpoint::Narrow);
2734        let history_layout = if matches!(self.mode, ViewMode::History) {
2735            HistoryLayout::from_width(body.width)
2736        } else {
2737            HistoryLayout::Narrow
2738        };
2739        let split_state = pane_split_state();
2740        let history_multi_pane =
2741            matches!(self.mode, ViewMode::History) && history_layout.has_middle_pane();
2742        let mut detail_viewport_height = body.height.saturating_sub(2) as usize;
2743        let mut board_detail_line = None;
2744
2745        let detail_title = match self.mode {
2746            ViewMode::Board => "Board Focus",
2747            ViewMode::Insights => "Insight Detail",
2748            ViewMode::Graph if graph_single_pane => "Graph View",
2749            ViewMode::Graph => "Graph Focus",
2750            ViewMode::History => self.history_detail_panel_title(),
2751            ViewMode::Actionable => "Track Detail",
2752            ViewMode::Attention => "Label Detail",
2753            ViewMode::Tree => "Issue Detail",
2754            ViewMode::LabelDashboard => "Label Detail",
2755            ViewMode::FlowMatrix => "Flow Detail",
2756            ViewMode::TimeTravelDiff => "Diff Detail",
2757            ViewMode::Sprint => "Sprint Detail",
2758            ViewMode::Main => "Details",
2759        };
2760        let detail_focused = self.focus == FocusPane::Detail
2761            || graph_single_pane
2762            || (matches!(self.mode, ViewMode::History) && self.history_file_tree_focus);
2763        let detail_title = if detail_focused {
2764            format!("{detail_title} [focus]")
2765        } else {
2766            detail_title.to_string()
2767        };
2768        if graph_single_pane {
2769            record_detail_content_area(block_inner_rect(body));
2770            Paragraph::new(self.graph_detail_render_text())
2771                .block(semantic_panel_block(
2772                    &detail_title,
2773                    detail_focused,
2774                    SemanticTone::Accent,
2775                ))
2776                .render(body, frame);
2777        } else if history_multi_pane {
2778            let render_history_panel =
2779                |frame: &mut Frame, area: Rect, title: String, focused: bool, text: RichText| {
2780                    Paragraph::new(text)
2781                        .block(semantic_panel_block(
2782                            title.as_str(),
2783                            focused,
2784                            SemanticTone::Accent,
2785                        ))
2786                        .render(area, frame);
2787                };
2788
2789            if matches!(history_layout, HistoryLayout::Wide)
2790                && matches!(self.history_view_mode, HistoryViewMode::Bead)
2791            {
2792                let PaneSplitPreset::Four(pcts) =
2793                    split_state.history_pcts(history_layout, self.history_view_mode)
2794                else {
2795                    unreachable!("wide bead history should use four-pane split");
2796                };
2797                let panes = Flex::horizontal()
2798                    .constraints([
2799                        Constraint::Percentage(pcts[0]),
2800                        Constraint::Percentage(pcts[1]),
2801                        Constraint::Percentage(pcts[2]),
2802                        Constraint::Percentage(pcts[3]),
2803                    ])
2804                    .split(body);
2805
2806                render_history_panel(
2807                    frame,
2808                    panes[0],
2809                    if matches!(self.focus, FocusPane::List) {
2810                        format!("{} [focus]", self.history_list_panel_title())
2811                    } else {
2812                        self.history_list_panel_title().to_string()
2813                    },
2814                    matches!(self.focus, FocusPane::List),
2815                    RichText::raw(self.history_list_text()),
2816                );
2817                render_history_panel(
2818                    frame,
2819                    panes[1],
2820                    self.history_timeline_panel_title(),
2821                    false,
2822                    RichText::raw(self.history_timeline_text(panes[1].width, panes[1].height)),
2823                );
2824                render_history_panel(
2825                    frame,
2826                    panes[2],
2827                    if matches!(self.focus, FocusPane::Middle) {
2828                        format!("{} [focus]", self.history_middle_panel_title())
2829                    } else {
2830                        self.history_middle_panel_title().to_string()
2831                    },
2832                    matches!(self.focus, FocusPane::Middle),
2833                    RichText::raw(self.history_middle_text(panes[2].width, panes[2].height)),
2834                );
2835                detail_viewport_height = panes[3].height.saturating_sub(2) as usize;
2836                render_history_panel(
2837                    frame,
2838                    panes[3],
2839                    detail_title.clone(),
2840                    detail_focused,
2841                    self.detail_panel_render_text(),
2842                );
2843                record_detail_content_area(block_inner_rect(panes[3]));
2844            } else {
2845                let PaneSplitPreset::Three(pane_widths) =
2846                    split_state.history_pcts(history_layout, self.history_view_mode)
2847                else {
2848                    unreachable!("multi-pane history should use three-pane split");
2849                };
2850                let panes = Flex::horizontal()
2851                    .constraints([
2852                        Constraint::Percentage(pane_widths[0]),
2853                        Constraint::Percentage(pane_widths[1]),
2854                        Constraint::Percentage(pane_widths[2]),
2855                    ])
2856                    .split(body);
2857
2858                render_history_panel(
2859                    frame,
2860                    panes[0],
2861                    if matches!(self.focus, FocusPane::List) {
2862                        format!("{} [focus]", self.history_list_panel_title())
2863                    } else {
2864                        self.history_list_panel_title().to_string()
2865                    },
2866                    matches!(self.focus, FocusPane::List),
2867                    RichText::raw(self.history_list_text()),
2868                );
2869                render_history_panel(
2870                    frame,
2871                    panes[1],
2872                    if matches!(self.focus, FocusPane::Middle) {
2873                        format!("{} [focus]", self.history_middle_panel_title())
2874                    } else {
2875                        self.history_middle_panel_title().to_string()
2876                    },
2877                    matches!(self.focus, FocusPane::Middle),
2878                    RichText::raw(self.history_middle_text(panes[1].width, panes[1].height)),
2879                );
2880                detail_viewport_height = panes[2].height.saturating_sub(2) as usize;
2881                render_history_panel(
2882                    frame,
2883                    panes[2],
2884                    detail_title.clone(),
2885                    detail_focused,
2886                    self.detail_panel_render_text(),
2887                );
2888                record_detail_content_area(block_inner_rect(panes[2]));
2889            }
2890        } else {
2891            let panes = Flex::horizontal()
2892                .constraints([
2893                    Constraint::Percentage(split_state.two_pane_list_pct(bp)),
2894                    Constraint::Percentage(split_state.two_pane_detail_pct(bp)),
2895                ])
2896                .split(body);
2897
2898            let list_text = self.list_panel_render_text(panes[0].width);
2899            let list_title = match self.mode {
2900                ViewMode::Board => "Board Lanes",
2901                ViewMode::Insights => "Insight Queue",
2902                ViewMode::Graph => "Graph Nodes",
2903                ViewMode::History => self.history_list_panel_title(),
2904                ViewMode::Actionable => "Execution Tracks",
2905                ViewMode::Attention => "Label Attention",
2906                ViewMode::Tree => "Dependency Tree",
2907                ViewMode::LabelDashboard => "Label Health",
2908                ViewMode::FlowMatrix => "Flow Matrix",
2909                ViewMode::TimeTravelDiff => "Diff Categories",
2910                ViewMode::Sprint => "Sprints",
2911                ViewMode::Main => "Issues",
2912            };
2913            let list_focused = self.focus == FocusPane::List;
2914            let list_title = if list_focused {
2915                format!("{list_title} [focus]")
2916            } else {
2917                list_title.to_string()
2918            };
2919
2920            let vp_height = panes[0].height.saturating_sub(2) as usize;
2921            self.list_viewport_height.set(vp_height);
2922            // Auto-scroll: find the line with the '>' cursor marker and
2923            // ensure it is within the visible viewport.
2924            if vp_height > 0 {
2925                let scroll = self.list_scroll_offset.get();
2926                if let Some(cursor_line) = list_text.to_plain_text().lines().position(|line| {
2927                    line.starts_with('>')
2928                        || line.starts_with(" >")
2929                        || line.starts_with("  >")
2930                        || line.starts_with("   >")
2931                        || line.starts_with("    >")
2932                        || line.contains('\u{25b6}')
2933                        || line.contains('▸')
2934                }) {
2935                    if cursor_line < scroll {
2936                        self.list_scroll_offset.set(cursor_line);
2937                    } else if cursor_line >= scroll + vp_height {
2938                        self.list_scroll_offset
2939                            .set(cursor_line.saturating_sub(vp_height - 1));
2940                    }
2941                }
2942            }
2943            Paragraph::new(list_text)
2944                .block(semantic_panel_block(
2945                    &list_title,
2946                    list_focused,
2947                    SemanticTone::Accent,
2948                ))
2949                .scroll((saturating_scroll_offset(self.list_scroll_offset.get()), 0))
2950                .render(panes[0], frame);
2951
2952            detail_viewport_height = panes[1].height.saturating_sub(2) as usize;
2953            let detail_text = if matches!(self.mode, ViewMode::Board) {
2954                let rendered = self.board_detail_render_text();
2955                let total_lines = rendered.lines().len();
2956                let max_offset = total_lines.saturating_sub(detail_viewport_height);
2957                let offset = self.board_detail_scroll_offset.min(max_offset);
2958                board_detail_line = Some((offset, total_lines));
2959                rendered
2960            } else if matches!(self.mode, ViewMode::Graph) {
2961                self.graph_detail_render_text()
2962            } else {
2963                self.detail_panel_render_text()
2964            };
2965            let detail_scroll = if matches!(self.mode, ViewMode::Board) {
2966                // Use the clamped offset to prevent scrolling past content end.
2967                board_detail_line.map_or(0, |(o, _)| o)
2968            } else {
2969                usize::from(saturating_scroll_offset(self.detail_scroll_offset))
2970            };
2971            Paragraph::new(detail_text)
2972                .block(semantic_panel_block(
2973                    &detail_title,
2974                    detail_focused,
2975                    SemanticTone::Accent,
2976                ))
2977                .scroll((saturating_scroll_offset(detail_scroll), 0))
2978                .render(panes[1], frame);
2979            record_detail_content_area(block_inner_rect(panes[1]));
2980        }
2981
2982        // -- Footer ----------------------------------------------------------
2983        let footer_text = match self.mode {
2984            ViewMode::Main => {
2985                if self.status_msg.is_empty() {
2986                    None
2987                } else {
2988                    Some(RichText::raw(self.status_msg.clone()))
2989                }
2990            }
2991            ViewMode::Board => {
2992                let detail_hint_desc = board_detail_line.map_or_else(
2993                    || "detail scroll".to_string(),
2994                    |(offset, total_lines)| {
2995                        if total_lines > detail_viewport_height {
2996                            format!("detail scroll ({}/{})", offset + 1, total_lines)
2997                        } else {
2998                            "detail scroll".to_string()
2999                        }
3000                    },
3001                );
3002                if self.status_msg.is_empty() {
3003                    let grouping_label = self.board_grouping.label();
3004                    let empty_label = self.board_empty_visibility.label();
3005                    let mut hints = vec![
3006                        CommandHint {
3007                            key: "Tab",
3008                            desc: "focus",
3009                        },
3010                        CommandHint {
3011                            key: "/",
3012                            desc: "search",
3013                        },
3014                    ];
3015                    let grouping_desc = format!("grouping={grouping_label}");
3016                    let empty_desc = format!("empty-lanes={empty_label}");
3017                    hints.push(CommandHint {
3018                        key: "s",
3019                        desc: &grouping_desc,
3020                    });
3021                    hints.push(CommandHint {
3022                        key: "e",
3023                        desc: &empty_desc,
3024                    });
3025                    hints.push(CommandHint {
3026                        key: "H/L",
3027                        desc: "lanes",
3028                    });
3029                    hints.push(CommandHint {
3030                        key: "0/$",
3031                        desc: "edges",
3032                    });
3033                    hints.push(CommandHint {
3034                        key: "^j/k",
3035                        desc: &detail_hint_desc,
3036                    });
3037                    if self.should_open_selected_issue_external_ref() {
3038                        hints.push(CommandHint {
3039                            key: "o",
3040                            desc: "open link",
3041                        });
3042                        hints.push(CommandHint {
3043                            key: "y",
3044                            desc: "copy link",
3045                        });
3046                    }
3047                    Some(styled_mode_footer("Board", &hints, rows[2].width))
3048                } else {
3049                    Some(RichText::raw(self.status_msg.clone()))
3050                }
3051            }
3052            ViewMode::Insights => {
3053                if self.status_msg.is_empty() {
3054                    let panel_label = self.insights_panel.short_label();
3055                    let expl_flag = if self.insights_show_explanations {
3056                        "on"
3057                    } else {
3058                        "off"
3059                    };
3060                    let proof_flag = if self.insights_show_calc_proof {
3061                        "on"
3062                    } else {
3063                        "off"
3064                    };
3065                    let expl_desc = format!("explanations={expl_flag}");
3066                    let proof_desc = format!("proof={proof_flag}");
3067                    let mut hints = vec![
3068                        CommandHint {
3069                            key: "Tab",
3070                            desc: "focus",
3071                        },
3072                        CommandHint {
3073                            key: "/",
3074                            desc: "search",
3075                        },
3076                        CommandHint {
3077                            key: "s/S",
3078                            desc: "panel",
3079                        },
3080                        CommandHint {
3081                            key: "e",
3082                            desc: &expl_desc,
3083                        },
3084                        CommandHint {
3085                            key: "x",
3086                            desc: &proof_desc,
3087                        },
3088                    ];
3089                    if self.should_open_selected_issue_external_ref() {
3090                        hints.push(CommandHint {
3091                            key: "o",
3092                            desc: "open link",
3093                        });
3094                        hints.push(CommandHint {
3095                            key: "y",
3096                            desc: "copy link",
3097                        });
3098                    }
3099                    Some(styled_mode_footer(
3100                        &format!("Insights [{panel_label}]"),
3101                        &hints,
3102                        rows[2].width,
3103                    ))
3104                } else {
3105                    Some(RichText::raw(self.status_msg.clone()))
3106                }
3107            }
3108            ViewMode::Graph => {
3109                if self.status_msg.is_empty() {
3110                    None
3111                } else {
3112                    Some(RichText::raw(self.status_msg.clone()))
3113                }
3114            }
3115            ViewMode::History => {
3116                if self.history_search_active {
3117                    let mode_label = self.history_view_mode.label();
3118                    let search_label = self.history_search_mode.label();
3119                    Some(styled_mode_footer(
3120                        &format!("History ({mode_label}): [{search_label}]"),
3121                        &[
3122                            CommandHint {
3123                                key: "Tab",
3124                                desc: "cycles mode",
3125                            },
3126                            CommandHint {
3127                                key: "Enter",
3128                                desc: "confirm",
3129                            },
3130                            CommandHint {
3131                                key: "Esc",
3132                                desc: "cancel",
3133                            },
3134                        ],
3135                        rows[2].width,
3136                    ))
3137                } else {
3138                    None
3139                }
3140            }
3141            ViewMode::Actionable => {
3142                let plan = self.actionable_plan.as_ref();
3143                let track_count = plan.map_or(0, |p| p.tracks.len());
3144                let item_count = plan.map_or(0, |p| p.summary.actionable_count);
3145                Some(styled_mode_footer(
3146                    &format!("Actionable: {track_count} tracks, {item_count} items"),
3147                    &[
3148                        CommandHint {
3149                            key: "j/k",
3150                            desc: "navigate",
3151                        },
3152                        CommandHint {
3153                            key: "Tab",
3154                            desc: "focus",
3155                        },
3156                        CommandHint {
3157                            key: "a/Esc",
3158                            desc: "back",
3159                        },
3160                    ],
3161                    rows[2].width,
3162                ))
3163            }
3164            ViewMode::Attention => {
3165                let label_count = self.attention_result.as_ref().map_or(0, |r| r.labels.len());
3166                Some(styled_mode_footer(
3167                    &format!("Attention: {label_count} labels ranked"),
3168                    &[
3169                        CommandHint {
3170                            key: "j/k",
3171                            desc: "navigate",
3172                        },
3173                        CommandHint {
3174                            key: "Tab",
3175                            desc: "focus",
3176                        },
3177                        CommandHint {
3178                            key: "!/Esc",
3179                            desc: "back",
3180                        },
3181                    ],
3182                    rows[2].width,
3183                ))
3184            }
3185            ViewMode::Tree => {
3186                let node_count = self.tree_flat_nodes.len();
3187                Some(styled_mode_footer(
3188                    &format!("Tree: {node_count} nodes"),
3189                    &[
3190                        CommandHint {
3191                            key: "j/k",
3192                            desc: "navigate",
3193                        },
3194                        CommandHint {
3195                            key: "Enter",
3196                            desc: "expand/collapse",
3197                        },
3198                        CommandHint {
3199                            key: "Tab",
3200                            desc: "focus",
3201                        },
3202                        CommandHint {
3203                            key: "T/Esc",
3204                            desc: "back",
3205                        },
3206                    ],
3207                    rows[2].width,
3208                ))
3209            }
3210            ViewMode::LabelDashboard => {
3211                let label_count = self.label_dashboard.as_ref().map_or(0, |r| r.labels.len());
3212                Some(styled_mode_footer(
3213                    &format!("Labels: {label_count}"),
3214                    &[
3215                        CommandHint {
3216                            key: "j/k",
3217                            desc: "navigate",
3218                        },
3219                        CommandHint {
3220                            key: "Tab",
3221                            desc: "focus",
3222                        },
3223                        CommandHint {
3224                            key: "[/Esc",
3225                            desc: "back",
3226                        },
3227                    ],
3228                    rows[2].width,
3229                ))
3230            }
3231            ViewMode::FlowMatrix => {
3232                let label_count = self.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
3233                let dep_count = self
3234                    .flow_matrix
3235                    .as_ref()
3236                    .map_or(0, |f| f.total_cross_label_deps);
3237                Some(styled_mode_footer(
3238                    &format!("Flow: {label_count} labels, {dep_count} cross-deps"),
3239                    &[
3240                        CommandHint {
3241                            key: "j/k",
3242                            desc: "rows",
3243                        },
3244                        CommandHint {
3245                            key: "h/l",
3246                            desc: "cols",
3247                        },
3248                        CommandHint {
3249                            key: "Tab",
3250                            desc: "focus",
3251                        },
3252                        CommandHint {
3253                            key: "]/Esc",
3254                            desc: "back",
3255                        },
3256                    ],
3257                    rows[2].width,
3258                ))
3259            }
3260            ViewMode::TimeTravelDiff => {
3261                if self.time_travel_input_active {
3262                    Some(styled_mode_footer(
3263                        "Time-travel: enter git ref or file path",
3264                        &[
3265                            CommandHint {
3266                                key: "Enter",
3267                                desc: "confirm",
3268                            },
3269                            CommandHint {
3270                                key: "Esc",
3271                                desc: "cancel",
3272                            },
3273                        ],
3274                        rows[2].width,
3275                    ))
3276                } else if self.time_travel_diff.is_some() {
3277                    Some(styled_mode_footer(
3278                        "Time-travel",
3279                        &[
3280                            CommandHint {
3281                                key: "j/k",
3282                                desc: "navigate",
3283                            },
3284                            CommandHint {
3285                                key: "Tab",
3286                                desc: "focus",
3287                            },
3288                            CommandHint {
3289                                key: "T",
3290                                desc: "reload",
3291                            },
3292                            CommandHint {
3293                                key: "t/Esc",
3294                                desc: "back",
3295                            },
3296                        ],
3297                        rows[2].width,
3298                    ))
3299                } else {
3300                    Some(styled_mode_footer(
3301                        "Time-travel: no diff loaded",
3302                        &[
3303                            CommandHint {
3304                                key: "t",
3305                                desc: "enter ref",
3306                            },
3307                            CommandHint {
3308                                key: "Esc",
3309                                desc: "back",
3310                            },
3311                        ],
3312                        rows[2].width,
3313                    ))
3314                }
3315            }
3316            ViewMode::Sprint => {
3317                let sprint_count = self.sprint_data.len();
3318                Some(styled_mode_footer(
3319                    &format!("Sprint: {sprint_count} sprint(s)"),
3320                    &[
3321                        CommandHint {
3322                            key: "j/k",
3323                            desc: "navigate",
3324                        },
3325                        CommandHint {
3326                            key: "Tab",
3327                            desc: "focus",
3328                        },
3329                        CommandHint {
3330                            key: "S/Esc",
3331                            desc: "back",
3332                        },
3333                    ],
3334                    rows[2].width,
3335                ))
3336            }
3337        };
3338        let footer_text = footer_text.unwrap_or_else(|| match self.mode {
3339            ViewMode::Main => {
3340                let hints = self.main_footer_command_hints();
3341                wrap_command_hints(&hints, rows[2].width.saturating_sub(1) as usize)
3342            }
3343            ViewMode::Graph => {
3344                let hints = self.graph_footer_command_hints();
3345                wrap_command_hints(&hints, rows[2].width.saturating_sub(1) as usize)
3346            }
3347            ViewMode::History => {
3348                if self.history_file_tree_focus {
3349                    let mut hints = vec![
3350                        CommandHint {
3351                            key: "j/k",
3352                            desc: "tree",
3353                        },
3354                        CommandHint {
3355                            key: "Enter",
3356                            desc: "filter",
3357                        },
3358                        CommandHint {
3359                            key: "Tab",
3360                            desc: "panes",
3361                        },
3362                        CommandHint {
3363                            key: "Esc",
3364                            desc: "close tree",
3365                        },
3366                    ];
3367                    hints.extend([
3368                        CommandHint {
3369                            key: "^←/→",
3370                            desc: "resize",
3371                        },
3372                        CommandHint {
3373                            key: "^0",
3374                            desc: "reset split",
3375                        },
3376                    ]);
3377                    wrap_command_hints(&hints, rows[2].width.saturating_sub(1) as usize)
3378                } else {
3379                    let confidence = format!(
3380                        "confidence >= {:.0}%",
3381                        self.history_min_confidence() * 100.0
3382                    );
3383                    let mut hints = vec![
3384                        CommandHint {
3385                            key: "c",
3386                            desc: confidence.as_str(),
3387                        },
3388                        CommandHint {
3389                            key: "v",
3390                            desc: "bead/git",
3391                        },
3392                        CommandHint {
3393                            key: "/",
3394                            desc: "search",
3395                        },
3396                        CommandHint {
3397                            key: "Tab",
3398                            desc: "mode",
3399                        },
3400                        CommandHint {
3401                            key: "y",
3402                            desc: "copy",
3403                        },
3404                    ];
3405                    if self.history_selected_commit_url().is_some() {
3406                        hints.push(CommandHint {
3407                            key: "o",
3408                            desc: "open commit",
3409                        });
3410                    }
3411                    hints.extend([
3412                        CommandHint {
3413                            key: "f",
3414                            desc: "file-tree",
3415                        },
3416                        CommandHint {
3417                            key: "h/Esc",
3418                            desc: "back",
3419                        },
3420                        CommandHint {
3421                            key: "^←/→",
3422                            desc: "resize",
3423                        },
3424                        CommandHint {
3425                            key: "^0",
3426                            desc: "reset split",
3427                        },
3428                    ]);
3429                    wrap_command_hints(&hints, rows[2].width.saturating_sub(1) as usize)
3430                }
3431            }
3432            _ => unreachable!("footer rich hints only apply to main/graph/history"),
3433        });
3434        Paragraph::new(footer_text)
3435            .style(tokens::footer())
3436            .render(rows[2], frame);
3437    }
3438}
3439
3440impl BvrApp {
3441    fn header_mode_tab_at(&self, x: u16, y: u16) -> Option<ViewMode> {
3442        if y != 0 {
3443            return None;
3444        }
3445
3446        header_mode_tabs(self, cached_view_width())
3447            .into_iter()
3448            .find(|tab| rect_contains(tab.rect, x, y))
3449            .map(|tab| tab.mode)
3450    }
3451
3452    /// Push current mode onto back stack before switching.
3453    fn push_mode_stack(&mut self) {
3454        if self.mode_back_stack.last() != Some(&self.mode) {
3455            self.mode_back_stack.push(self.mode);
3456            // Cap stack at 10 to prevent unbounded growth
3457            if self.mode_back_stack.len() > 10 {
3458                self.mode_back_stack.remove(0);
3459            }
3460        }
3461    }
3462
3463    fn activate_mode_tab(&mut self, mode: ViewMode) {
3464        self.push_mode_stack();
3465        match mode {
3466            ViewMode::Main => {
3467                self.mode = ViewMode::Main;
3468                self.focus = FocusPane::List;
3469            }
3470            ViewMode::Board => {
3471                self.mode = ViewMode::Board;
3472                self.focus = FocusPane::List;
3473            }
3474            ViewMode::Insights => {
3475                self.mode = ViewMode::Insights;
3476                self.focus = FocusPane::List;
3477            }
3478            ViewMode::Graph => {
3479                self.mode = ViewMode::Graph;
3480                self.focus = FocusPane::List;
3481            }
3482            ViewMode::History => {
3483                if !matches!(self.mode, ViewMode::History) {
3484                    self.toggle_history_mode();
3485                }
3486                self.focus = FocusPane::List;
3487            }
3488            ViewMode::Actionable => {
3489                if !matches!(self.mode, ViewMode::Actionable) {
3490                    self.compute_actionable_plan();
3491                    self.mode = ViewMode::Actionable;
3492                }
3493                self.detail_scroll_offset = 0;
3494                self.focus = FocusPane::List;
3495            }
3496            ViewMode::Attention => {
3497                if !matches!(self.mode, ViewMode::Attention) {
3498                    self.compute_attention();
3499                    self.mode = ViewMode::Attention;
3500                }
3501                self.focus = FocusPane::List;
3502            }
3503            ViewMode::Tree => {
3504                if !matches!(self.mode, ViewMode::Tree) {
3505                    self.toggle_tree_mode();
3506                }
3507                self.focus = FocusPane::List;
3508            }
3509            ViewMode::LabelDashboard => {
3510                if !matches!(self.mode, ViewMode::LabelDashboard) {
3511                    self.toggle_label_dashboard();
3512                }
3513                self.focus = FocusPane::List;
3514            }
3515            ViewMode::FlowMatrix => {
3516                if !matches!(self.mode, ViewMode::FlowMatrix) {
3517                    self.toggle_flow_matrix();
3518                }
3519                self.focus = FocusPane::List;
3520            }
3521            ViewMode::TimeTravelDiff => {
3522                if !matches!(self.mode, ViewMode::TimeTravelDiff) {
3523                    self.toggle_time_travel_mode();
3524                }
3525            }
3526            ViewMode::Sprint => {
3527                if !matches!(self.mode, ViewMode::Sprint) {
3528                    self.toggle_sprint_mode();
3529                }
3530                self.focus = FocusPane::List;
3531            }
3532        }
3533        self.status_msg = format!("Switched to {}", self.mode.label());
3534    }
3535
3536    fn splitter_hit_target_at(&self, x: u16, y: u16) -> Option<SplitterHitBox> {
3537        splitter_hit_boxes(self, cached_view_width(), cached_view_height())
3538            .into_iter()
3539            .find(|hit_box| rect_contains(hit_box.rect, x, y))
3540    }
3541
3542    fn adjust_splitter_target(
3543        &mut self,
3544        target: SplitterTarget,
3545        delta_pct: f32,
3546        source: &'static str,
3547    ) -> bool {
3548        let mut state = pane_split_state();
3549        let changed = state.adjust_splitter_target(target, delta_pct);
3550        if changed {
3551            set_pane_split_state(state);
3552            self.status_msg = format!("Pane split adjusted ({source}, {delta_pct:+.0}%)");
3553        }
3554        changed
3555    }
3556
3557    fn handle_splitter_mouse_scroll(&mut self, event: MouseEvent) -> Option<Cmd<Msg>> {
3558        let hit_box = self.splitter_hit_target_at(event.x, event.y)?;
3559        let delta_pct = match event.kind {
3560            MouseEventKind::ScrollUp => 4.0,
3561            MouseEventKind::ScrollDown => -4.0,
3562            _ => return None,
3563        };
3564        self.adjust_splitter_target(hit_box.target, delta_pct, "mouse");
3565        Some(Cmd::None)
3566    }
3567
3568    fn handle_splitter_mouse_click(&mut self, event: MouseEvent) -> Option<Cmd<Msg>> {
3569        if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) {
3570            return None;
3571        }
3572
3573        let hit_box = self.splitter_hit_target_at(event.x, event.y)?;
3574        let midpoint = hit_box.rect.x.saturating_add(hit_box.rect.width / 2);
3575        let expand_leading = event.x < midpoint;
3576        let delta_pct = if expand_leading { 4.0 } else { -4.0 };
3577
3578        self.focus = match (hit_box.target, expand_leading) {
3579            (SplitterTarget::TwoPane { .. }, true) => FocusPane::List,
3580            (SplitterTarget::TwoPane { .. }, false) => FocusPane::Detail,
3581            (SplitterTarget::HistoryThree { divider, .. }, true) if divider == 0 => FocusPane::List,
3582            (SplitterTarget::HistoryThree { divider, .. }, false) if divider == 0 => {
3583                FocusPane::Middle
3584            }
3585            (SplitterTarget::HistoryThree { .. }, true) => FocusPane::Middle,
3586            (SplitterTarget::HistoryThree { .. }, false) => FocusPane::Detail,
3587            (SplitterTarget::HistoryFour { divider }, true) if divider == 0 => FocusPane::List,
3588            (SplitterTarget::HistoryFour { divider }, false) if divider == 0 => FocusPane::Middle,
3589            (SplitterTarget::HistoryFour { divider }, true) if divider == 1 => FocusPane::Middle,
3590            (SplitterTarget::HistoryFour { divider }, false) if divider == 1 => FocusPane::Middle,
3591            (SplitterTarget::HistoryFour { .. }, true) => FocusPane::Middle,
3592            (SplitterTarget::HistoryFour { .. }, false) => FocusPane::Detail,
3593        };
3594
3595        self.adjust_splitter_target(hit_box.target, delta_pct, "mouse");
3596        Some(Cmd::None)
3597    }
3598
3599    fn handle_header_mouse_click(&mut self, event: MouseEvent) -> Option<Cmd<Msg>> {
3600        if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) {
3601            return None;
3602        }
3603
3604        let mode = self.header_mode_tab_at(event.x, event.y)?;
3605        self.activate_mode_tab(mode);
3606        Some(Cmd::None)
3607    }
3608
3609    fn adjust_active_pane_split(&mut self, delta_pct: f32) -> bool {
3610        let mut state = pane_split_state();
3611        let changed = if matches!(self.mode, ViewMode::History) {
3612            state.adjust_history(
3613                self.history_layout(),
3614                self.history_view_mode,
3615                self.focus,
3616                delta_pct,
3617            )
3618        } else if matches!(self.mode, ViewMode::Graph)
3619            && matches!(
3620                Breakpoint::from_width(cached_view_width()),
3621                Breakpoint::Narrow
3622            )
3623        {
3624            false
3625        } else {
3626            state.adjust_two_pane(Breakpoint::from_width(cached_view_width()), delta_pct)
3627        };
3628        if changed {
3629            set_pane_split_state(state);
3630            self.status_msg = format!("Pane split adjusted ({delta_pct:+.0}%)");
3631        }
3632        changed
3633    }
3634
3635    fn history_layout(&self) -> HistoryLayout {
3636        HistoryLayout::from_width(cached_view_width())
3637    }
3638
3639    fn history_has_middle_pane(&self) -> bool {
3640        matches!(self.mode, ViewMode::History) && self.history_layout().has_middle_pane()
3641    }
3642
3643    fn history_list_panel_title(&self) -> &'static str {
3644        if matches!(self.history_view_mode, HistoryViewMode::Git) {
3645            "Commits"
3646        } else {
3647            "Beads With History"
3648        }
3649    }
3650
3651    fn history_middle_panel_title(&self) -> &'static str {
3652        if matches!(self.history_view_mode, HistoryViewMode::Git) {
3653            "Related Beads"
3654        } else {
3655            "Commits"
3656        }
3657    }
3658
3659    fn history_detail_panel_title(&self) -> &'static str {
3660        "Commit Details"
3661    }
3662
3663    fn history_timeline_panel_title(&self) -> String {
3664        self.selected_issue()
3665            .map(|issue| format!("Timeline: {}", issue.id))
3666            .unwrap_or_else(|| "Timeline".to_string())
3667    }
3668
3669    fn reset_pane_split_state(&mut self) -> bool {
3670        let default_state = PaneSplitState::default();
3671        if pane_split_state() == default_state {
3672            return false;
3673        }
3674
3675        set_pane_split_state(default_state);
3676        self.status_msg = "Pane splits reset".to_string();
3677        true
3678    }
3679
3680    #[cfg(not(test))]
3681    fn wrap_quit_with_background_cancel(&self, cmd: Cmd<Msg>) -> Cmd<Msg> {
3682        if matches!(cmd, Cmd::Quit)
3683            && let Some(runtime) = self.background_runtime.as_ref()
3684        {
3685            runtime.cancel_requested.store(true, Ordering::Relaxed);
3686        }
3687        cmd
3688    }
3689
3690    #[cfg(not(test))]
3691    fn background_tick_command(&self) -> Cmd<Msg> {
3692        self.background_runtime
3693            .as_ref()
3694            .map_or(Cmd::None, |runtime| {
3695                Cmd::tick(runtime.config.poll_interval())
3696            })
3697    }
3698
3699    #[cfg(not(test))]
3700    fn handle_background_tick(&mut self) -> Cmd<Msg> {
3701        // Poll for slow metric completion
3702        if self.slow_metrics_pending {
3703            if let Some(rx) = self.slow_metrics_rx.as_ref() {
3704                if let Ok(slow) = rx.try_recv() {
3705                    self.analyzer.apply_slow_metrics(slow);
3706                    self.slow_metrics_pending = false;
3707                    self.slow_metrics_rx = None;
3708                    self.status_msg = "Background metrics computed".to_string();
3709                }
3710            }
3711        }
3712
3713        let next_tick = self.background_tick_command();
3714        let Some(runtime) = self.background_runtime.as_mut() else {
3715            return Cmd::None;
3716        };
3717
3718        let decision = decide_background_tick(
3719            runtime.cancel_requested.load(Ordering::Relaxed),
3720            runtime.in_flight,
3721        );
3722
3723        let (config, cancel_requested) = match decision {
3724            BackgroundTickDecision::Stop => {
3725                self.history_status_msg =
3726                    push_background_timeline(runtime, "tick skipped: cancellation requested");
3727                return Cmd::None;
3728            }
3729            BackgroundTickDecision::TickOnly => {
3730                self.history_status_msg =
3731                    push_background_timeline(runtime, "tick observed: reload already in flight");
3732                return next_tick;
3733            }
3734            BackgroundTickDecision::ReloadAndTick => {
3735                runtime.in_flight = true;
3736                self.history_status_msg =
3737                    push_background_timeline(runtime, "tick scheduled: starting background reload");
3738                (runtime.config.clone(), runtime.cancel_requested.clone())
3739            }
3740        };
3741
3742        let task = Cmd::task_with_spec(
3743            TaskSpec::new(1.0, 50.0).with_name("background-issue-reload"),
3744            move || {
3745                if cancel_requested.load(Ordering::Relaxed) {
3746                    return Msg::BackgroundReloaded(Err("canceled".to_string()));
3747                }
3748
3749                let result = config.load_issues().map_err(|error| error.to_string());
3750                Msg::BackgroundReloaded(result)
3751            },
3752        );
3753
3754        Cmd::batch(vec![task, next_tick])
3755    }
3756
3757    #[cfg(not(test))]
3758    fn handle_background_reload_result(&mut self, result: std::result::Result<Vec<Issue>, String>) {
3759        let mut issues_to_apply: Option<Vec<Issue>> = None;
3760        let status_update: String;
3761
3762        {
3763            let Some(runtime) = self.background_runtime.as_mut() else {
3764                return;
3765            };
3766
3767            runtime.in_flight = false;
3768            let cancel_requested = runtime.cancel_requested.load(Ordering::Relaxed);
3769
3770            match result {
3771                Ok(issues) => {
3772                    let hash = compute_data_hash(&issues);
3773                    if should_apply_background_reload(
3774                        cancel_requested,
3775                        &hash,
3776                        &runtime.last_data_hash,
3777                    ) {
3778                        runtime.last_data_hash = hash;
3779                        status_update = push_background_timeline(
3780                            runtime,
3781                            "reload applied: issue snapshot changed",
3782                        );
3783                        issues_to_apply = Some(issues);
3784                    } else if cancel_requested {
3785                        status_update = push_background_timeline(
3786                            runtime,
3787                            "reload ignored: cancellation requested",
3788                        );
3789                    } else {
3790                        status_update =
3791                            push_background_timeline(runtime, "reload ignored: no data change");
3792                    }
3793                }
3794                Err(error) => {
3795                    if let Some(warning) = background_warning_message(cancel_requested, &error) {
3796                        status_update = push_background_timeline(runtime, &warning);
3797                    } else {
3798                        status_update = push_background_timeline(
3799                            runtime,
3800                            "reload ignored: cancellation acknowledged",
3801                        );
3802                    }
3803                }
3804            }
3805        }
3806
3807        self.history_status_msg = status_update;
3808        if let Some(issues) = issues_to_apply {
3809            self.apply_background_reload(issues);
3810        }
3811    }
3812
3813    #[cfg(not(test))]
3814    fn apply_background_reload(&mut self, issues: Vec<Issue>) {
3815        let selected_id = self.selected_issue().map(|issue| issue.id.clone());
3816
3817        let use_two_phase =
3818            issues.len() > crate::analysis::graph::AnalysisConfig::background_threshold();
3819        if use_two_phase {
3820            self.analyzer = Analyzer::new_fast(issues);
3821            #[cfg(not(test))]
3822            {
3823                self.slow_metrics_rx = Some(self.analyzer.spawn_slow_computation());
3824            }
3825            self.slow_metrics_pending = true;
3826        } else {
3827            self.analyzer = Analyzer::new(issues);
3828            self.slow_metrics_pending = false;
3829            #[cfg(not(test))]
3830            {
3831                self.slow_metrics_rx = None;
3832            }
3833        }
3834        self.history_git_cache = None;
3835        self.detail_dep_cursor = 0;
3836        self.board_detail_scroll_offset = 0;
3837        self.detail_scroll_offset = 0;
3838        self.selected = 0;
3839
3840        if let Some(id) = selected_id.as_deref() {
3841            self.select_issue_by_id(id);
3842        }
3843
3844        if !self.preserve_off_queue_ranked_context() {
3845            self.ensure_selected_visible();
3846        }
3847        self.sync_insights_heatmap_selection();
3848
3849        // Rebuild tree flat nodes after the analyzer was replaced so
3850        // stale issue_index values don't cause out-of-bounds panics
3851        // when Tree view renders with the new, potentially shorter,
3852        // issue list.
3853        self.rebuild_tree_if_active();
3854    }
3855
3856    fn board_shortcut_focus(&self) -> bool {
3857        matches!(self.mode, ViewMode::Board)
3858            && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3859    }
3860
3861    fn actionable_shortcut_focus(&self) -> bool {
3862        matches!(self.mode, ViewMode::Actionable)
3863            && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3864    }
3865
3866    fn attention_shortcut_focus(&self) -> bool {
3867        matches!(self.mode, ViewMode::Attention)
3868            && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3869    }
3870
3871    fn tree_shortcut_focus(&self) -> bool {
3872        matches!(self.mode, ViewMode::Tree)
3873            && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3874    }
3875
3876    fn label_dashboard_shortcut_focus(&self) -> bool {
3877        matches!(self.mode, ViewMode::LabelDashboard)
3878            && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3879    }
3880
3881    fn flow_matrix_shortcut_focus(&self) -> bool {
3882        matches!(self.mode, ViewMode::FlowMatrix)
3883            && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3884    }
3885
3886    fn time_travel_shortcut_focus(&self) -> bool {
3887        matches!(self.mode, ViewMode::TimeTravelDiff)
3888            && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3889            && !self.time_travel_input_active
3890    }
3891
3892    fn sprint_shortcut_focus(&self) -> bool {
3893        matches!(self.mode, ViewMode::Sprint)
3894            && matches!(self.focus, FocusPane::List | FocusPane::Detail)
3895    }
3896
3897    fn insights_heatmap_shortcut_focus(&self) -> bool {
3898        matches!(self.mode, ViewMode::Insights)
3899            && self.focus == FocusPane::List
3900            && self.insights_heatmap.is_some()
3901    }
3902
3903    fn insights_heatmap_data(&self) -> InsightsHeatmapData {
3904        let mut counts =
3905            vec![vec![0; INSIGHTS_HEATMAP_SCORE_LABELS.len()]; INSIGHTS_HEATMAP_DEPTH_LABELS.len()];
3906        let mut issue_ids = vec![
3907            vec![Vec::new(); INSIGHTS_HEATMAP_SCORE_LABELS.len()];
3908            INSIGHTS_HEATMAP_DEPTH_LABELS.len()
3909        ];
3910        let recommendations = self.analyzer.triage(TriageOptions {
3911            max_recommendations: self.analyzer.issues.len().max(50),
3912            ..TriageOptions::default()
3913        });
3914
3915        for recommendation in recommendations.result.recommendations {
3916            let Some(issue) = self
3917                .analyzer
3918                .issues
3919                .iter()
3920                .find(|issue| issue.id == recommendation.id)
3921            else {
3922                continue;
3923            };
3924
3925            if !issue.is_open_like() || !self.issue_matches_filter(issue) {
3926                continue;
3927            }
3928
3929            let depth = self
3930                .analyzer
3931                .metrics
3932                .critical_depth
3933                .get(&issue.id)
3934                .copied()
3935                .unwrap_or_default();
3936            let row = match depth {
3937                0 => 0,
3938                1 | 2 => 1,
3939                3..=5 => 2,
3940                6..=10 => 3,
3941                _ => 4,
3942            };
3943
3944            let score = recommendation.score.clamp(0.0, 1.0);
3945            let col = if score <= 0.2 {
3946                0
3947            } else if score <= 0.4 {
3948                1
3949            } else if score <= 0.6 {
3950                2
3951            } else if score <= 0.8 {
3952                3
3953            } else {
3954                4
3955            };
3956
3957            counts[row][col] += 1;
3958            issue_ids[row][col].push(issue.id.clone());
3959        }
3960
3961        InsightsHeatmapData { counts, issue_ids }
3962    }
3963
3964    fn insights_heatmap_issue_ids_for_current_cell(&self) -> Vec<String> {
3965        let Some(state) = self.insights_heatmap.as_ref() else {
3966            return Vec::new();
3967        };
3968        let data = self.insights_heatmap_data();
3969        let row = state
3970            .row
3971            .min(INSIGHTS_HEATMAP_DEPTH_LABELS.len().saturating_sub(1));
3972        let col = state
3973            .col
3974            .min(INSIGHTS_HEATMAP_SCORE_LABELS.len().saturating_sub(1));
3975        data.issue_ids[row][col].clone()
3976    }
3977
3978    fn sync_insights_heatmap_selection(&mut self) {
3979        let Some(mut state) = self.insights_heatmap.clone() else {
3980            return;
3981        };
3982        let data = self.insights_heatmap_data();
3983        let row = state
3984            .row
3985            .min(INSIGHTS_HEATMAP_DEPTH_LABELS.len().saturating_sub(1));
3986        let col = state
3987            .col
3988            .min(INSIGHTS_HEATMAP_SCORE_LABELS.len().saturating_sub(1));
3989        let cell_issue_ids = data.issue_ids[row][col].clone();
3990
3991        state.row = row;
3992        state.col = col;
3993
3994        if cell_issue_ids.is_empty() {
3995            for (next_row, row_issue_ids) in data.issue_ids.iter().enumerate() {
3996                if let Some((next_col, next_cell_ids)) = row_issue_ids
3997                    .iter()
3998                    .enumerate()
3999                    .find(|(_, ids)| !ids.is_empty())
4000                {
4001                    state.row = next_row;
4002                    state.col = next_col;
4003                    state.drill_active = false;
4004                    state.drill_cursor = 0;
4005                    let issue_id = next_cell_ids[0].clone();
4006                    self.insights_heatmap = Some(state);
4007                    self.select_issue_by_id(&issue_id);
4008                    return;
4009                }
4010            }
4011
4012            state.drill_active = false;
4013            state.drill_cursor = 0;
4014            self.insights_heatmap = Some(state);
4015            return;
4016        }
4017
4018        if !state.drill_active {
4019            state.drill_cursor = 0;
4020        } else {
4021            state.drill_cursor = state
4022                .drill_cursor
4023                .min(cell_issue_ids.len().saturating_sub(1));
4024        }
4025
4026        let issue_id = cell_issue_ids[state.drill_cursor].clone();
4027        self.insights_heatmap = Some(state);
4028        self.select_issue_by_id(&issue_id);
4029    }
4030
4031    fn toggle_insights_heatmap(&mut self) {
4032        if self.insights_heatmap.is_some() {
4033            self.insights_heatmap = None;
4034            return;
4035        }
4036
4037        self.insights_heatmap = Some(InsightsHeatmapState::default());
4038        self.sync_insights_heatmap_selection();
4039    }
4040
4041    fn enter_insights_heatmap_drill(&mut self) {
4042        let cell_issue_ids = self.insights_heatmap_issue_ids_for_current_cell();
4043        if cell_issue_ids.is_empty() {
4044            return;
4045        }
4046
4047        if let Some(state) = self.insights_heatmap.as_mut() {
4048            state.drill_active = true;
4049            state.drill_cursor = 0;
4050        }
4051        self.sync_insights_heatmap_selection();
4052    }
4053
4054    fn exit_insights_heatmap_drill(&mut self) -> bool {
4055        let Some(state) = self.insights_heatmap.as_mut() else {
4056            return false;
4057        };
4058        if !state.drill_active {
4059            return false;
4060        }
4061
4062        state.drill_active = false;
4063        state.drill_cursor = 0;
4064        self.sync_insights_heatmap_selection();
4065        true
4066    }
4067
4068    fn move_insights_heatmap_row(&mut self, delta: isize) {
4069        let Some(state) = self.insights_heatmap.as_mut() else {
4070            return;
4071        };
4072        if state.drill_active || delta == 0 {
4073            return;
4074        }
4075
4076        let max_row = INSIGHTS_HEATMAP_DEPTH_LABELS.len().saturating_sub(1);
4077        state.row = if delta >= 0 {
4078            state.row.saturating_add(delta.unsigned_abs()).min(max_row)
4079        } else {
4080            state.row.saturating_sub(delta.unsigned_abs())
4081        };
4082        self.sync_insights_heatmap_selection();
4083    }
4084
4085    fn move_insights_heatmap_col(&mut self, delta: isize) {
4086        let Some(state) = self.insights_heatmap.as_mut() else {
4087            return;
4088        };
4089        if state.drill_active || delta == 0 {
4090            return;
4091        }
4092
4093        let max_col = INSIGHTS_HEATMAP_SCORE_LABELS.len().saturating_sub(1);
4094        state.col = if delta >= 0 {
4095            state.col.saturating_add(delta.unsigned_abs()).min(max_col)
4096        } else {
4097            state.col.saturating_sub(delta.unsigned_abs())
4098        };
4099        self.sync_insights_heatmap_selection();
4100    }
4101
4102    fn move_insights_heatmap_drill(&mut self, delta: isize) {
4103        let cell_issue_ids = self.insights_heatmap_issue_ids_for_current_cell();
4104        let Some(state) = self.insights_heatmap.as_mut() else {
4105            return;
4106        };
4107        if !state.drill_active || delta == 0 || cell_issue_ids.is_empty() {
4108            return;
4109        }
4110
4111        let max_slot = cell_issue_ids.len().saturating_sub(1);
4112        state.drill_cursor = if delta >= 0 {
4113            state
4114                .drill_cursor
4115                .saturating_add(delta.unsigned_abs())
4116                .min(max_slot)
4117        } else {
4118            state.drill_cursor.saturating_sub(delta.unsigned_abs())
4119        };
4120        self.sync_insights_heatmap_selection();
4121    }
4122
4123    fn handle_mouse(&mut self, event: MouseEvent) -> Cmd<Msg> {
4124        if let Some(cmd) = self.handle_header_mouse_click(event) {
4125            return cmd;
4126        }
4127
4128        if let Some(cmd) = self.handle_splitter_mouse_scroll(event) {
4129            return cmd;
4130        }
4131
4132        if let Some(cmd) = self.handle_splitter_mouse_click(event) {
4133            return cmd;
4134        }
4135
4136        match event.kind {
4137            MouseEventKind::ScrollUp => self.handle_key(KeyCode::Up, Modifiers::NONE),
4138            MouseEventKind::ScrollDown => self.handle_key(KeyCode::Down, Modifiers::NONE),
4139            MouseEventKind::Down(MouseButton::Left)
4140                if self.mouse_open_detail_link(event.x, event.y) =>
4141            {
4142                Cmd::None
4143            }
4144            MouseEventKind::Down(MouseButton::Right)
4145                if self.mouse_copy_detail_link(event.x, event.y) =>
4146            {
4147                Cmd::None
4148            }
4149            _ => Cmd::None,
4150        }
4151    }
4152
4153    fn handle_key(&mut self, code: KeyCode, modifiers: Modifiers) -> Cmd<Msg> {
4154        // Clear one-shot status message on any key press.
4155        if !self.status_msg.is_empty() {
4156            self.status_msg.clear();
4157        }
4158
4159        if self.show_quit_confirm {
4160            match code {
4161                KeyCode::Escape | KeyCode::Char('y' | 'Y') => return Cmd::Quit,
4162                _ => {
4163                    self.show_quit_confirm = false;
4164                    self.focus = FocusPane::List;
4165                    return Cmd::None;
4166                }
4167            }
4168        }
4169
4170        if self.show_help {
4171            match code {
4172                KeyCode::Char('?' | 'q') | KeyCode::Escape | KeyCode::F(1) => {
4173                    self.show_help = false;
4174                    self.help_scroll_offset = 0;
4175                    self.focus = self.focus_before_help;
4176                }
4177                KeyCode::Char('j') | KeyCode::Down => {
4178                    self.help_scroll_offset = self.help_scroll_offset.saturating_add(1);
4179                }
4180                KeyCode::Char('k') | KeyCode::Up => {
4181                    self.help_scroll_offset = self.help_scroll_offset.saturating_sub(1);
4182                }
4183                KeyCode::Char('d') if modifiers.contains(Modifiers::CTRL) => {
4184                    self.help_scroll_offset = self.help_scroll_offset.saturating_add(10);
4185                }
4186                KeyCode::Char('u') if modifiers.contains(Modifiers::CTRL) => {
4187                    self.help_scroll_offset = self.help_scroll_offset.saturating_sub(10);
4188                }
4189                KeyCode::Char('g') | KeyCode::Home => {
4190                    self.help_scroll_offset = 0;
4191                }
4192                KeyCode::Char('G') | KeyCode::End => {
4193                    self.help_scroll_offset = 999;
4194                }
4195                _ => {}
4196            }
4197            return Cmd::None;
4198        }
4199
4200        // -- Modal overlay handling ------------------------------------------
4201        if let Some(ref overlay) = self.modal_overlay.clone() {
4202            match overlay {
4203                ModalOverlay::Tutorial => {
4204                    // Any key dismisses tutorial
4205                    self.modal_overlay = None;
4206                    return Cmd::None;
4207                }
4208                ModalOverlay::Confirm { resume_overlay, .. } => {
4209                    match code {
4210                        KeyCode::Char('y' | 'Y') => {
4211                            self.modal_confirm_result = Some(true);
4212                            self.modal_overlay = None;
4213                        }
4214                        KeyCode::Char('n' | 'N') | KeyCode::Escape => {
4215                            self.modal_confirm_result = Some(false);
4216                            self.modal_overlay = resume_overlay.clone().map(|overlay| *overlay);
4217                        }
4218                        _ => {}
4219                    }
4220                    return Cmd::None;
4221                }
4222                ModalOverlay::PagesWizard(wiz) => {
4223                    return self.handle_pages_wizard_key(code, wiz.clone());
4224                }
4225                ModalOverlay::RecipePicker { items, cursor } => {
4226                    let len = items.len();
4227                    let cur = *cursor;
4228                    match code {
4229                        KeyCode::Escape => self.modal_overlay = None,
4230                        KeyCode::Char('j') | KeyCode::Down => {
4231                            if let Some(ModalOverlay::RecipePicker { cursor, items }) =
4232                                &mut self.modal_overlay
4233                            {
4234                                if *cursor + 1 < items.len() {
4235                                    *cursor += 1;
4236                                }
4237                            }
4238                        }
4239                        KeyCode::Char('k') | KeyCode::Up => {
4240                            if let Some(ModalOverlay::RecipePicker { cursor, .. }) =
4241                                &mut self.modal_overlay
4242                            {
4243                                *cursor = cursor.saturating_sub(1);
4244                            }
4245                        }
4246                        KeyCode::Enter if cur < len => {
4247                            let recipe_name = items[cur].0.clone();
4248                            self.modal_overlay = None;
4249                            self.status_msg = format!("Recipe: {recipe_name}");
4250                        }
4251                        _ => {}
4252                    }
4253                    return Cmd::None;
4254                }
4255                ModalOverlay::LabelPicker {
4256                    items,
4257                    cursor,
4258                    filter,
4259                } => {
4260                    let needle = filter.to_ascii_lowercase();
4261                    let filtered: Vec<usize> = items
4262                        .iter()
4263                        .enumerate()
4264                        .filter(|(_, (name, _))| {
4265                            needle.is_empty() || name.to_ascii_lowercase().contains(&needle)
4266                        })
4267                        .map(|(i, _)| i)
4268                        .collect();
4269                    let flen = filtered.len();
4270                    match code {
4271                        KeyCode::Escape => self.modal_overlay = None,
4272                        KeyCode::Down => {
4273                            if let Some(ModalOverlay::LabelPicker { cursor, .. }) =
4274                                &mut self.modal_overlay
4275                            {
4276                                if *cursor + 1 < flen {
4277                                    *cursor += 1;
4278                                }
4279                            }
4280                        }
4281                        KeyCode::Up => {
4282                            if let Some(ModalOverlay::LabelPicker { cursor, .. }) =
4283                                &mut self.modal_overlay
4284                            {
4285                                *cursor = cursor.saturating_sub(1);
4286                            }
4287                        }
4288                        KeyCode::Backspace => {
4289                            if let Some(ModalOverlay::LabelPicker { filter, cursor, .. }) =
4290                                &mut self.modal_overlay
4291                            {
4292                                filter.pop();
4293                                *cursor = 0;
4294                            }
4295                        }
4296                        KeyCode::Enter => {
4297                            let actual_idx = filtered.get(*cursor).copied();
4298                            if let Some(idx) = actual_idx {
4299                                let label = items[idx].0.clone();
4300                                self.modal_overlay = None;
4301                                self.set_label_filter(&label);
4302                            }
4303                        }
4304                        KeyCode::Char(c) => {
4305                            if let Some(ModalOverlay::LabelPicker { filter, cursor, .. }) =
4306                                &mut self.modal_overlay
4307                            {
4308                                filter.push(c);
4309                                *cursor = 0;
4310                            }
4311                        }
4312                        _ => {}
4313                    }
4314                    return Cmd::None;
4315                }
4316                ModalOverlay::RepoPicker {
4317                    items,
4318                    cursor,
4319                    filter,
4320                } => {
4321                    let needle = filter.to_ascii_lowercase();
4322                    let filtered: Vec<usize> = items
4323                        .iter()
4324                        .enumerate()
4325                        .filter(|(_, name)| {
4326                            needle.is_empty() || name.to_ascii_lowercase().contains(&needle)
4327                        })
4328                        .map(|(i, _)| i)
4329                        .collect();
4330                    let flen = filtered.len();
4331                    match code {
4332                        KeyCode::Escape => self.modal_overlay = None,
4333                        KeyCode::Down => {
4334                            if let Some(ModalOverlay::RepoPicker { cursor, .. }) =
4335                                &mut self.modal_overlay
4336                            {
4337                                if *cursor + 1 < flen {
4338                                    *cursor += 1;
4339                                }
4340                            }
4341                        }
4342                        KeyCode::Up => {
4343                            if let Some(ModalOverlay::RepoPicker { cursor, .. }) =
4344                                &mut self.modal_overlay
4345                            {
4346                                *cursor = cursor.saturating_sub(1);
4347                            }
4348                        }
4349                        KeyCode::Backspace => {
4350                            if let Some(ModalOverlay::RepoPicker { filter, cursor, .. }) =
4351                                &mut self.modal_overlay
4352                            {
4353                                filter.pop();
4354                                *cursor = 0;
4355                            }
4356                        }
4357                        KeyCode::Enter => {
4358                            let actual_idx = filtered.get(*cursor).copied();
4359                            if let Some(idx) = actual_idx {
4360                                let repo = items[idx].clone();
4361                                self.modal_overlay = None;
4362                                self.set_repo_filter(&repo);
4363                            }
4364                        }
4365                        KeyCode::Char(c) => {
4366                            if let Some(ModalOverlay::RepoPicker { filter, cursor, .. }) =
4367                                &mut self.modal_overlay
4368                            {
4369                                filter.push(c);
4370                                *cursor = 0;
4371                            }
4372                        }
4373                        _ => {}
4374                    }
4375                    return Cmd::None;
4376                }
4377            }
4378        }
4379
4380        // -- Force refresh (Ctrl+R / F5) ------------------------------------
4381        if matches!(
4382            (code, modifiers.contains(Modifiers::CTRL)),
4383            (KeyCode::Char('r'), true) | (KeyCode::F(5), _)
4384        ) {
4385            self.refresh_from_disk();
4386            return Cmd::None;
4387        }
4388
4389        if matches!(
4390            (code, modifiers.contains(Modifiers::CTRL)),
4391            (KeyCode::Char('0'), true)
4392        ) {
4393            self.reset_pane_split_state();
4394            return Cmd::None;
4395        }
4396
4397        if !self.preserve_off_queue_ranked_context() {
4398            self.ensure_selected_visible();
4399        }
4400
4401        if matches!(self.mode, ViewMode::Main)
4402            && self.focus == FocusPane::List
4403            && self.main_search_active
4404        {
4405            match code {
4406                KeyCode::Escape => self.cancel_main_search(),
4407                KeyCode::Enter => self.finish_main_search(),
4408                KeyCode::Backspace => {
4409                    self.main_search_query.pop();
4410                    self.main_search_match_cursor = 0;
4411                    self.select_current_main_search_match();
4412                }
4413                KeyCode::Char('n') => self.move_main_search_match_relative(1),
4414                KeyCode::Char('N') => self.move_main_search_match_relative(-1),
4415                KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4416                    self.main_search_query.push(ch);
4417                    self.main_search_match_cursor = 0;
4418                    self.select_current_main_search_match();
4419                }
4420                _ => {}
4421            }
4422            return Cmd::None;
4423        }
4424
4425        if self.board_shortcut_focus() && self.board_search_active {
4426            match code {
4427                KeyCode::Escape => self.cancel_board_search(),
4428                KeyCode::Enter => self.finish_board_search(),
4429                KeyCode::Backspace => {
4430                    self.board_search_query.pop();
4431                    self.board_search_match_cursor = 0;
4432                    self.select_current_board_search_match();
4433                }
4434                KeyCode::Char('n') => self.move_board_search_match_relative(1),
4435                KeyCode::Char('N') => self.move_board_search_match_relative(-1),
4436                KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4437                    self.board_search_query.push(ch);
4438                    self.board_search_match_cursor = 0;
4439                    self.select_current_board_search_match();
4440                }
4441                _ => {}
4442            }
4443            return Cmd::None;
4444        }
4445
4446        if matches!(self.mode, ViewMode::History)
4447            && self.focus == FocusPane::List
4448            && self.history_search_active
4449        {
4450            match code {
4451                KeyCode::Escape => self.cancel_history_search(),
4452                KeyCode::Enter => self.finish_history_search(),
4453                KeyCode::Tab | KeyCode::BackTab => {
4454                    self.history_search_mode = self.history_search_mode.cycle();
4455                    self.refresh_history_search_selection();
4456                }
4457                KeyCode::Backspace => {
4458                    self.history_search_query.pop();
4459                    self.refresh_history_search_selection();
4460                }
4461                KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4462                    self.history_search_query.push(ch);
4463                    self.refresh_history_search_selection();
4464                }
4465                _ => {}
4466            }
4467            return Cmd::None;
4468        }
4469
4470        if matches!(self.mode, ViewMode::Graph)
4471            && self.focus == FocusPane::List
4472            && self.graph_search_active
4473        {
4474            match code {
4475                KeyCode::Escape => self.cancel_graph_search(),
4476                KeyCode::Enter => self.finish_graph_search(),
4477                KeyCode::Backspace => {
4478                    self.graph_search_query.pop();
4479                    self.graph_search_match_cursor = 0;
4480                    self.select_current_graph_search_match();
4481                }
4482                KeyCode::Char('n') => self.move_graph_search_match_relative(1),
4483                KeyCode::Char('N') => self.move_graph_search_match_relative(-1),
4484                KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4485                    self.graph_search_query.push(ch);
4486                    self.graph_search_match_cursor = 0;
4487                    self.select_current_graph_search_match();
4488                }
4489                _ => {}
4490            }
4491            return Cmd::None;
4492        }
4493
4494        if matches!(self.mode, ViewMode::Insights)
4495            && self.focus == FocusPane::List
4496            && self.insights_search_active
4497        {
4498            match code {
4499                KeyCode::Escape => self.cancel_insights_search(),
4500                KeyCode::Enter => self.finish_insights_search(),
4501                KeyCode::Backspace => {
4502                    self.insights_search_query.pop();
4503                    self.insights_search_match_cursor = 0;
4504                    self.select_current_insights_search_match();
4505                }
4506                KeyCode::Char('n') => self.move_insights_search_match_relative(1),
4507                KeyCode::Char('N') => self.move_insights_search_match_relative(-1),
4508                KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4509                    self.insights_search_query.push(ch);
4510                    self.insights_search_match_cursor = 0;
4511                    self.select_current_insights_search_match();
4512                }
4513                _ => {}
4514            }
4515            return Cmd::None;
4516        }
4517
4518        // -- Tree search input -------------------------------------------------------
4519        if matches!(self.mode, ViewMode::Tree)
4520            && self.focus == FocusPane::List
4521            && self.tree_search_active
4522        {
4523            match code {
4524                KeyCode::Escape => self.cancel_tree_search(),
4525                KeyCode::Enter => self.finish_tree_search(),
4526                KeyCode::Backspace => {
4527                    self.tree_search_query.pop();
4528                    self.tree_search_match_cursor = 0;
4529                    self.select_current_tree_search_match();
4530                }
4531                KeyCode::Char('n') => self.move_tree_search_match_relative(1),
4532                KeyCode::Char('N') => self.move_tree_search_match_relative(-1),
4533                KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4534                    self.tree_search_query.push(ch);
4535                    self.tree_search_match_cursor = 0;
4536                    self.select_current_tree_search_match();
4537                }
4538                _ => {}
4539            }
4540            return Cmd::None;
4541        }
4542
4543        // -- Time-travel ref input -----------------------------------------------
4544        if matches!(self.mode, ViewMode::TimeTravelDiff) && self.time_travel_input_active {
4545            match code {
4546                KeyCode::Escape => {
4547                    self.time_travel_input_active = false;
4548                    if self.time_travel_diff.is_none() {
4549                        self.mode = ViewMode::Main;
4550                        self.focus = FocusPane::List;
4551                    }
4552                }
4553                KeyCode::Enter => self.execute_time_travel(),
4554                KeyCode::Backspace => {
4555                    self.time_travel_ref_input.pop();
4556                }
4557                KeyCode::Char(ch) if !modifiers.contains(Modifiers::CTRL) && !ch.is_control() => {
4558                    self.time_travel_ref_input.push(ch);
4559                }
4560                _ => {}
4561            }
4562            return Cmd::None;
4563        }
4564
4565        // -- Vim z-prefix handling -----------------------------------------------
4566        if self.pending_z {
4567            self.pending_z = false;
4568            if self.tree_shortcut_focus() {
4569                match code {
4570                    KeyCode::Char('o') => self.tree_expand_current(),
4571                    KeyCode::Char('c') => self.tree_collapse_current(),
4572                    KeyCode::Char('a') => self.tree_toggle_collapse(),
4573                    KeyCode::Char('R') => self.tree_expand_all(),
4574                    KeyCode::Char('M') => self.tree_collapse_all(),
4575                    KeyCode::Char('z') => self.tree_recenter_cursor(),
4576                    _ => {}
4577                }
4578            }
4579            return Cmd::None;
4580        }
4581
4582        // -- Vim gg (jump-to-top) handling ---------------------------------------
4583        // First `g` speculatively toggles graph and latches pending_g. A rapid
4584        // second `g` undoes the toggle and jumps to the top of the current list.
4585        // Any other key after `g` just clears the latch (the toggle stands).
4586        if self.pending_g {
4587            self.pending_g = false;
4588            if code == KeyCode::Char('g') {
4589                // Restore mode that was saved before the speculative graph toggle.
4590                if let Some(prev) = self.g_pre_toggle_mode.take() {
4591                    self.mode = prev;
4592                }
4593                self.vim_jump_to_top();
4594                return Cmd::None;
4595            }
4596            self.g_pre_toggle_mode = None;
4597            // Not gg — the speculative graph toggle stands. Fall through to
4598            // handle the current key normally.
4599        }
4600
4601        match code {
4602            KeyCode::Escape if self.exit_insights_heatmap_drill() => return Cmd::None,
4603            KeyCode::Char('?') => {
4604                self.show_help = true;
4605                self.focus_before_help = self.focus;
4606            }
4607            KeyCode::Enter if self.insights_heatmap_shortcut_focus() => {
4608                self.enter_insights_heatmap_drill();
4609            }
4610            KeyCode::Enter
4611                if matches!(self.mode, ViewMode::History)
4612                    && matches!(self.history_view_mode, HistoryViewMode::Bead)
4613                    && matches!(self.focus, FocusPane::Middle) =>
4614            {
4615                self.jump_from_history_bead_commit_to_git();
4616            }
4617            KeyCode::Enter
4618                if !(matches!(self.mode, ViewMode::History) && self.history_file_tree_focus)
4619                    && !matches!(self.mode, ViewMode::Tree) =>
4620            {
4621                if matches!(self.mode, ViewMode::History)
4622                    && matches!(self.history_view_mode, HistoryViewMode::Git)
4623                    && let Some(bead_id) = self
4624                        .selected_history_git_related_bead_id()
4625                        .or_else(|| self.selected_history_event().map(|event| event.issue_id))
4626                {
4627                    self.select_issue_by_id(&bead_id);
4628                }
4629                self.mode = ViewMode::Main;
4630                self.focus = FocusPane::Detail;
4631            }
4632            KeyCode::Char('q') => {
4633                if matches!(self.mode, ViewMode::Main) {
4634                    return Cmd::Quit;
4635                }
4636                self.mode = ViewMode::Main;
4637                self.focus = FocusPane::List;
4638            }
4639            KeyCode::Char('c') if modifiers.contains(Modifiers::CTRL) => return Cmd::Quit,
4640            KeyCode::Escape => {
4641                if matches!(self.mode, ViewMode::History) && self.history_show_file_tree {
4642                    self.history_show_file_tree = false;
4643                    self.history_file_tree_focus = false;
4644                    self.history_status_msg = "File tree hidden".into();
4645                } else if !matches!(self.mode, ViewMode::Main) {
4646                    // Pop from back stack if available, otherwise go to Main
4647                    let prev = self.mode_back_stack.pop().unwrap_or(ViewMode::Main);
4648                    self.mode = prev;
4649                    self.focus = FocusPane::List;
4650                    self.detail_scroll_offset = 0;
4651                } else if matches!(self.focus, FocusPane::Detail) {
4652                    self.focus = FocusPane::List;
4653                    self.status_msg = "Focus returned to list".into();
4654                } else if !self.main_search_query.is_empty() {
4655                    self.cancel_main_search();
4656                    self.status_msg = "Main search cleared".into();
4657                } else if self.has_active_filter() {
4658                    self.set_list_filter(ListFilter::All);
4659                } else {
4660                    self.show_quit_confirm = true;
4661                }
4662            }
4663            KeyCode::Char('j') | KeyCode::Down
4664                if self.insights_heatmap_shortcut_focus()
4665                    && self
4666                        .insights_heatmap
4667                        .as_ref()
4668                        .is_some_and(|state| state.drill_active) =>
4669            {
4670                self.move_insights_heatmap_drill(1);
4671            }
4672            KeyCode::Char('k') | KeyCode::Up
4673                if self.insights_heatmap_shortcut_focus()
4674                    && self
4675                        .insights_heatmap
4676                        .as_ref()
4677                        .is_some_and(|state| state.drill_active) =>
4678            {
4679                self.move_insights_heatmap_drill(-1);
4680            }
4681            KeyCode::Char('j') | KeyCode::Down if self.insights_heatmap_shortcut_focus() => {
4682                self.move_insights_heatmap_row(1);
4683            }
4684            KeyCode::Char('k') | KeyCode::Up if self.insights_heatmap_shortcut_focus() => {
4685                self.move_insights_heatmap_row(-1);
4686            }
4687            KeyCode::Char('h') if self.insights_heatmap_shortcut_focus() => {
4688                self.move_insights_heatmap_col(-1);
4689            }
4690            KeyCode::Char('l')
4691                if self.insights_heatmap_shortcut_focus()
4692                    && self
4693                        .insights_heatmap
4694                        .as_ref()
4695                        .is_some_and(|state| !state.drill_active) =>
4696            {
4697                self.move_insights_heatmap_col(1);
4698            }
4699            KeyCode::Right if modifiers.contains(Modifiers::CTRL) => {
4700                self.adjust_active_pane_split(4.0);
4701            }
4702            KeyCode::Left if modifiers.contains(Modifiers::CTRL) => {
4703                self.adjust_active_pane_split(-4.0);
4704            }
4705            KeyCode::Tab | KeyCode::BackTab => {
4706                let reverse = matches!(code, KeyCode::BackTab);
4707                if matches!(self.mode, ViewMode::History) && self.history_show_file_tree {
4708                    if self.history_file_tree_focus {
4709                        self.history_file_tree_focus = false;
4710                        self.focus = if reverse {
4711                            FocusPane::Detail
4712                        } else {
4713                            FocusPane::List
4714                        };
4715                    } else if self.history_has_middle_pane() {
4716                        match (self.focus, reverse) {
4717                            (FocusPane::List, false) => self.focus = FocusPane::Middle,
4718                            (FocusPane::Middle, false) => self.focus = FocusPane::Detail,
4719                            (FocusPane::Detail, false) => self.history_file_tree_focus = true,
4720                            (FocusPane::List, true) => self.history_file_tree_focus = true,
4721                            (FocusPane::Middle, true) => self.focus = FocusPane::List,
4722                            (FocusPane::Detail, true) => self.focus = FocusPane::Middle,
4723                        }
4724                    } else if reverse && self.focus == FocusPane::List {
4725                        self.history_file_tree_focus = true;
4726                    } else if self.focus == FocusPane::Detail {
4727                        self.focus = FocusPane::List;
4728                    } else if !reverse {
4729                        self.history_file_tree_focus = true;
4730                    } else {
4731                        self.focus = FocusPane::Detail;
4732                    }
4733                } else if self.history_has_middle_pane() {
4734                    self.focus = match (self.focus, reverse) {
4735                        (FocusPane::List, false) => FocusPane::Middle,
4736                        (FocusPane::Middle, false) => FocusPane::Detail,
4737                        (FocusPane::Detail, false) => FocusPane::List,
4738                        (FocusPane::List, true) => FocusPane::Detail,
4739                        (FocusPane::Middle, true) => FocusPane::List,
4740                        (FocusPane::Detail, true) => FocusPane::Middle,
4741                    };
4742                } else {
4743                    self.focus = match self.focus {
4744                        FocusPane::List => FocusPane::Detail,
4745                        FocusPane::Middle => FocusPane::Detail,
4746                        FocusPane::Detail => FocusPane::List,
4747                    };
4748                }
4749            }
4750            KeyCode::Char('j')
4751                if modifiers.contains(Modifiers::CTRL)
4752                    && matches!(self.mode, ViewMode::Board)
4753                    && self.focus == FocusPane::Detail =>
4754            {
4755                self.scroll_board_detail(3);
4756            }
4757            KeyCode::Char('k')
4758                if modifiers.contains(Modifiers::CTRL)
4759                    && matches!(self.mode, ViewMode::Board)
4760                    && self.focus == FocusPane::Detail =>
4761            {
4762                self.scroll_board_detail(-3);
4763            }
4764            // Universal detail pane scroll — works in any mode with Detail focus
4765            KeyCode::Char('j')
4766                if modifiers.contains(Modifiers::CTRL)
4767                    && !matches!(self.mode, ViewMode::Board)
4768                    && self.focus == FocusPane::Detail =>
4769            {
4770                self.scroll_detail(3);
4771            }
4772            KeyCode::Char('k')
4773                if modifiers.contains(Modifiers::CTRL)
4774                    && !matches!(self.mode, ViewMode::Board)
4775                    && self.focus == FocusPane::Detail =>
4776            {
4777                self.scroll_detail(-3);
4778            }
4779            KeyCode::Char('h') if self.board_shortcut_focus() => {
4780                self.move_board_lane_relative(-1);
4781            }
4782            KeyCode::Char('l') if self.board_shortcut_focus() => {
4783                self.move_board_lane_relative(1);
4784            }
4785            KeyCode::Char('/') if self.board_shortcut_focus() => {
4786                self.start_board_search();
4787            }
4788            KeyCode::Char('/')
4789                if matches!(self.mode, ViewMode::History) && self.focus == FocusPane::List =>
4790            {
4791                self.start_history_search();
4792            }
4793            KeyCode::Char('/')
4794                if matches!(self.mode, ViewMode::Graph)
4795                    && matches!(self.focus, FocusPane::List | FocusPane::Detail) =>
4796            {
4797                self.start_graph_search();
4798            }
4799            KeyCode::Char('/')
4800                if matches!(self.mode, ViewMode::Insights)
4801                    && matches!(self.focus, FocusPane::List | FocusPane::Detail) =>
4802            {
4803                self.start_insights_search();
4804            }
4805            KeyCode::Char('/')
4806                if matches!(self.mode, ViewMode::Main) && self.focus == FocusPane::List =>
4807            {
4808                self.start_main_search();
4809            }
4810            KeyCode::Char('n') if self.board_shortcut_focus() => {
4811                self.move_board_search_match_relative(1);
4812            }
4813            KeyCode::Char('N') if self.board_shortcut_focus() => {
4814                self.move_board_search_match_relative(-1);
4815            }
4816            KeyCode::Char('n')
4817                if matches!(self.mode, ViewMode::History)
4818                    && self.focus == FocusPane::List
4819                    && !self.history_search_query.is_empty() =>
4820            {
4821                self.move_history_search_match_relative(1);
4822            }
4823            KeyCode::Char('N')
4824                if matches!(self.mode, ViewMode::History)
4825                    && self.focus == FocusPane::List
4826                    && !self.history_search_query.is_empty() =>
4827            {
4828                self.move_history_search_match_relative(-1);
4829            }
4830            KeyCode::Char('n')
4831                if matches!(self.mode, ViewMode::Graph)
4832                    && self.focus == FocusPane::List
4833                    && !self.graph_search_query.is_empty() =>
4834            {
4835                self.move_graph_search_match_relative(1);
4836            }
4837            KeyCode::Char('N')
4838                if matches!(self.mode, ViewMode::Graph)
4839                    && self.focus == FocusPane::List
4840                    && !self.graph_search_query.is_empty() =>
4841            {
4842                self.move_graph_search_match_relative(-1);
4843            }
4844            KeyCode::Char('n')
4845                if matches!(self.mode, ViewMode::Insights)
4846                    && self.focus == FocusPane::List
4847                    && !self.insights_search_query.is_empty() =>
4848            {
4849                self.move_insights_search_match_relative(1);
4850            }
4851            KeyCode::Char('N')
4852                if matches!(self.mode, ViewMode::Insights)
4853                    && self.focus == FocusPane::List
4854                    && !self.insights_search_query.is_empty() =>
4855            {
4856                self.move_insights_search_match_relative(-1);
4857            }
4858            KeyCode::Char('n')
4859                if matches!(self.mode, ViewMode::Main)
4860                    && self.focus == FocusPane::List
4861                    && !self.main_search_query.is_empty() =>
4862            {
4863                self.move_main_search_match_relative(1);
4864            }
4865            KeyCode::Char('N')
4866                if matches!(self.mode, ViewMode::Main)
4867                    && self.focus == FocusPane::List
4868                    && !self.main_search_query.is_empty() =>
4869            {
4870                self.move_main_search_match_relative(-1);
4871            }
4872            KeyCode::Char('j') | KeyCode::Down if self.board_shortcut_focus() => {
4873                self.move_board_row_relative(1);
4874            }
4875            KeyCode::Char('k') | KeyCode::Up if self.board_shortcut_focus() => {
4876                self.move_board_row_relative(-1);
4877            }
4878            KeyCode::Char('j') | KeyCode::Down if self.actionable_shortcut_focus() => {
4879                self.move_actionable_cursor(1);
4880            }
4881            KeyCode::Char('k') | KeyCode::Up if self.actionable_shortcut_focus() => {
4882                self.move_actionable_cursor(-1);
4883            }
4884            KeyCode::Char('j') | KeyCode::Down if self.attention_shortcut_focus() => {
4885                self.move_attention_cursor(1);
4886            }
4887            KeyCode::Char('k') | KeyCode::Up if self.attention_shortcut_focus() => {
4888                self.move_attention_cursor(-1);
4889            }
4890            // -- Tree mode navigation
4891            KeyCode::Char('j') | KeyCode::Down
4892                if self.tree_shortcut_focus()
4893                    && self.tree_cursor + 1 < self.tree_flat_nodes.len() =>
4894            {
4895                self.tree_cursor += 1;
4896            }
4897            KeyCode::Char('k') | KeyCode::Up if self.tree_shortcut_focus() => {
4898                self.tree_cursor = self.tree_cursor.saturating_sub(1);
4899            }
4900            KeyCode::Home if self.tree_shortcut_focus() => {
4901                self.tree_cursor = 0;
4902            }
4903            KeyCode::End | KeyCode::Char('G') if self.tree_shortcut_focus() => {
4904                self.tree_cursor = self.tree_flat_nodes.len().saturating_sub(1);
4905            }
4906            KeyCode::PageDown if self.tree_shortcut_focus() => {
4907                let step = self.list_page_step();
4908                self.tree_cursor =
4909                    (self.tree_cursor + step).min(self.tree_flat_nodes.len().saturating_sub(1));
4910            }
4911            KeyCode::PageUp if self.tree_shortcut_focus() => {
4912                let step = self.list_page_step();
4913                self.tree_cursor = self.tree_cursor.saturating_sub(step);
4914            }
4915            KeyCode::Enter
4916                if matches!(self.mode, ViewMode::Tree) && self.focus == FocusPane::List =>
4917            {
4918                self.tree_toggle_collapse();
4919            }
4920            KeyCode::Char('z') if self.tree_shortcut_focus() => {
4921                self.pending_z = true;
4922            }
4923            KeyCode::Char('/')
4924                if matches!(self.mode, ViewMode::Tree) && self.focus == FocusPane::List =>
4925            {
4926                self.start_tree_search();
4927            }
4928            KeyCode::Char('n')
4929                if matches!(self.mode, ViewMode::Tree)
4930                    && self.focus == FocusPane::List
4931                    && !self.tree_search_query.is_empty() =>
4932            {
4933                self.move_tree_search_match_relative(1);
4934            }
4935            KeyCode::Char('N')
4936                if matches!(self.mode, ViewMode::Tree)
4937                    && self.focus == FocusPane::List
4938                    && !self.tree_search_query.is_empty() =>
4939            {
4940                self.move_tree_search_match_relative(-1);
4941            }
4942            // -- LabelDashboard mode navigation
4943            KeyCode::Char('j') | KeyCode::Down if self.label_dashboard_shortcut_focus() => {
4944                let count = self.label_dashboard.as_ref().map_or(0, |r| r.labels.len());
4945                if count > 0 && self.label_dashboard_cursor + 1 < count {
4946                    self.label_dashboard_cursor += 1;
4947                }
4948            }
4949            KeyCode::Char('k') | KeyCode::Up if self.label_dashboard_shortcut_focus() => {
4950                self.label_dashboard_cursor = self.label_dashboard_cursor.saturating_sub(1);
4951            }
4952            // -- FlowMatrix mode navigation
4953            KeyCode::Char('j') | KeyCode::Down if self.flow_matrix_shortcut_focus() => {
4954                let count = self.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
4955                if count > 0 && self.flow_matrix_row_cursor + 1 < count {
4956                    self.flow_matrix_row_cursor += 1;
4957                }
4958            }
4959            KeyCode::Char('k') | KeyCode::Up if self.flow_matrix_shortcut_focus() => {
4960                self.flow_matrix_row_cursor = self.flow_matrix_row_cursor.saturating_sub(1);
4961            }
4962            KeyCode::Char('l') | KeyCode::Right if self.flow_matrix_shortcut_focus() => {
4963                let count = self.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
4964                if count > 0 && self.flow_matrix_col_cursor + 1 < count {
4965                    self.flow_matrix_col_cursor += 1;
4966                }
4967            }
4968            KeyCode::Char('h') | KeyCode::Left if self.flow_matrix_shortcut_focus() => {
4969                self.flow_matrix_col_cursor = self.flow_matrix_col_cursor.saturating_sub(1);
4970            }
4971            KeyCode::Char('j') | KeyCode::Down if self.sprint_shortcut_focus() => {
4972                if self.focus == FocusPane::List {
4973                    let count = self.sprint_data.len();
4974                    if count > 0 && self.sprint_cursor + 1 < count {
4975                        self.sprint_cursor += 1;
4976                        self.sprint_issue_cursor = 0;
4977                    }
4978                } else {
4979                    let issue_count = self.sprint_visible_issues().len();
4980                    if issue_count > 0 && self.sprint_issue_cursor + 1 < issue_count {
4981                        self.sprint_issue_cursor += 1;
4982                    }
4983                }
4984            }
4985            KeyCode::Char('k') | KeyCode::Up if self.sprint_shortcut_focus() => {
4986                if self.focus == FocusPane::List {
4987                    self.sprint_cursor = self.sprint_cursor.saturating_sub(1);
4988                    self.sprint_issue_cursor = 0;
4989                } else {
4990                    self.sprint_issue_cursor = self.sprint_issue_cursor.saturating_sub(1);
4991                }
4992            }
4993            KeyCode::Char('j') | KeyCode::Down if self.time_travel_shortcut_focus() => {
4994                if self.focus == FocusPane::List {
4995                    let count = self.time_travel_categories().len();
4996                    if count > 0 && self.time_travel_category_cursor + 1 < count {
4997                        self.time_travel_category_cursor += 1;
4998                        self.time_travel_issue_cursor = 0;
4999                    }
5000                } else {
5001                    let categories = self.time_travel_categories();
5002                    let issue_count = categories
5003                        .get(self.time_travel_category_cursor)
5004                        .map_or(0, |(_, count)| *count);
5005                    if issue_count > 0 && self.time_travel_issue_cursor + 1 < issue_count {
5006                        self.time_travel_issue_cursor += 1;
5007                    }
5008                }
5009            }
5010            KeyCode::Char('k') | KeyCode::Up if self.time_travel_shortcut_focus() => {
5011                if self.focus == FocusPane::List {
5012                    self.time_travel_category_cursor =
5013                        self.time_travel_category_cursor.saturating_sub(1);
5014                    self.time_travel_issue_cursor = 0;
5015                } else {
5016                    self.time_travel_issue_cursor = self.time_travel_issue_cursor.saturating_sub(1);
5017                }
5018            }
5019            KeyCode::Char('d')
5020                if modifiers.contains(Modifiers::CTRL)
5021                    && matches!(self.mode, ViewMode::Board)
5022                    && self.focus == FocusPane::Detail =>
5023            {
5024                self.scroll_board_detail(10);
5025            }
5026            KeyCode::Char('u')
5027                if modifiers.contains(Modifiers::CTRL)
5028                    && matches!(self.mode, ViewMode::Board)
5029                    && self.focus == FocusPane::Detail =>
5030            {
5031                self.scroll_board_detail(-10);
5032            }
5033            // Universal detail pane page scroll — works in any non-Board mode
5034            KeyCode::Char('d')
5035                if modifiers.contains(Modifiers::CTRL)
5036                    && !matches!(self.mode, ViewMode::Board)
5037                    && self.focus == FocusPane::Detail =>
5038            {
5039                self.scroll_detail(10);
5040            }
5041            KeyCode::Char('u')
5042                if modifiers.contains(Modifiers::CTRL)
5043                    && !matches!(self.mode, ViewMode::Board)
5044                    && self.focus == FocusPane::Detail =>
5045            {
5046                self.scroll_detail(-10);
5047            }
5048            KeyCode::Char('d')
5049                if modifiers.contains(Modifiers::CTRL) && self.board_shortcut_focus() =>
5050            {
5051                self.move_board_row_relative(10);
5052            }
5053            KeyCode::Char('u')
5054                if modifiers.contains(Modifiers::CTRL) && self.board_shortcut_focus() =>
5055            {
5056                self.move_board_row_relative(-10);
5057            }
5058            KeyCode::Char('d')
5059                if modifiers.contains(Modifiers::CTRL)
5060                    && matches!(self.mode, ViewMode::Tree)
5061                    && self.focus == FocusPane::List =>
5062            {
5063                let half = self.list_page_step() / 2;
5064                self.tree_cursor =
5065                    (self.tree_cursor + half).min(self.tree_flat_nodes.len().saturating_sub(1));
5066            }
5067            KeyCode::Char('u')
5068                if modifiers.contains(Modifiers::CTRL)
5069                    && matches!(self.mode, ViewMode::Tree)
5070                    && self.focus == FocusPane::List =>
5071            {
5072                let half = self.list_page_step() / 2;
5073                self.tree_cursor = self.tree_cursor.saturating_sub(half);
5074            }
5075            KeyCode::Char('d')
5076                if modifiers.contains(Modifiers::CTRL)
5077                    && !matches!(self.mode, ViewMode::Board)
5078                    && self.focus == FocusPane::List =>
5079            {
5080                self.move_selection_relative(self.list_page_step() as isize);
5081            }
5082            KeyCode::Char('u')
5083                if modifiers.contains(Modifiers::CTRL)
5084                    && !matches!(self.mode, ViewMode::Board)
5085                    && self.focus == FocusPane::List =>
5086            {
5087                self.move_selection_relative(-(self.list_page_step() as isize));
5088            }
5089            // Ctrl-F / Ctrl-B — full page scroll (vim aliases for PageDown/PageUp)
5090            // Ordering mirrors Ctrl-D/Ctrl-U above: specific detail handlers
5091            // MUST come before broad board_shortcut_focus() guards, which match
5092            // both List and Detail focus.
5093            KeyCode::Char('f')
5094                if modifiers.contains(Modifiers::CTRL)
5095                    && matches!(self.mode, ViewMode::Tree)
5096                    && self.focus == FocusPane::List =>
5097            {
5098                let step = self.list_page_step();
5099                self.tree_cursor =
5100                    (self.tree_cursor + step).min(self.tree_flat_nodes.len().saturating_sub(1));
5101            }
5102            KeyCode::Char('b')
5103                if modifiers.contains(Modifiers::CTRL)
5104                    && matches!(self.mode, ViewMode::Tree)
5105                    && self.focus == FocusPane::List =>
5106            {
5107                let step = self.list_page_step();
5108                self.tree_cursor = self.tree_cursor.saturating_sub(step);
5109            }
5110            KeyCode::Char('f')
5111                if modifiers.contains(Modifiers::CTRL)
5112                    && matches!(self.mode, ViewMode::Board)
5113                    && self.focus == FocusPane::Detail =>
5114            {
5115                self.scroll_board_detail(self.list_page_step() as isize);
5116            }
5117            KeyCode::Char('b')
5118                if modifiers.contains(Modifiers::CTRL)
5119                    && matches!(self.mode, ViewMode::Board)
5120                    && self.focus == FocusPane::Detail =>
5121            {
5122                self.scroll_board_detail(-(self.list_page_step() as isize));
5123            }
5124            KeyCode::Char('f')
5125                if modifiers.contains(Modifiers::CTRL) && self.board_shortcut_focus() =>
5126            {
5127                self.move_board_row_relative(self.list_page_step() as isize);
5128            }
5129            KeyCode::Char('b')
5130                if modifiers.contains(Modifiers::CTRL) && self.board_shortcut_focus() =>
5131            {
5132                self.move_board_row_relative(-(self.list_page_step() as isize));
5133            }
5134            KeyCode::Char('f')
5135                if modifiers.contains(Modifiers::CTRL)
5136                    && !matches!(self.mode, ViewMode::Board)
5137                    && self.focus == FocusPane::Detail =>
5138            {
5139                self.scroll_detail(self.list_page_step() as isize);
5140            }
5141            KeyCode::Char('b')
5142                if modifiers.contains(Modifiers::CTRL)
5143                    && !matches!(self.mode, ViewMode::Board)
5144                    && self.focus == FocusPane::Detail =>
5145            {
5146                self.scroll_detail(-(self.list_page_step() as isize));
5147            }
5148            KeyCode::Char('f')
5149                if modifiers.contains(Modifiers::CTRL)
5150                    && !matches!(self.mode, ViewMode::Board)
5151                    && self.focus == FocusPane::List =>
5152            {
5153                self.move_selection_relative(self.list_page_step() as isize);
5154            }
5155            KeyCode::Char('b')
5156                if modifiers.contains(Modifiers::CTRL)
5157                    && !matches!(self.mode, ViewMode::Board)
5158                    && self.focus == FocusPane::List =>
5159            {
5160                self.move_selection_relative(-(self.list_page_step() as isize));
5161            }
5162            KeyCode::Char('h')
5163                if matches!(self.mode, ViewMode::Graph) && self.focus == FocusPane::List =>
5164            {
5165                self.move_selection_relative(-1);
5166            }
5167            KeyCode::Char('h')
5168                if matches!(self.mode, ViewMode::Graph) && self.focus == FocusPane::Detail =>
5169            {
5170                self.focus = FocusPane::List;
5171            }
5172            KeyCode::Char('l')
5173                if matches!(self.mode, ViewMode::Graph) && self.focus == FocusPane::List =>
5174            {
5175                self.move_selection_relative(1);
5176            }
5177            KeyCode::Char('H')
5178                if matches!(self.mode, ViewMode::Graph) && self.focus == FocusPane::List =>
5179            {
5180                self.move_selection_relative(-10);
5181            }
5182            KeyCode::Char('L')
5183                if matches!(self.mode, ViewMode::Graph) && self.focus == FocusPane::List =>
5184            {
5185                self.move_selection_relative(10);
5186            }
5187            KeyCode::Char('h') if matches!(self.mode, ViewMode::Insights) => {
5188                self.focus = FocusPane::List;
5189            }
5190            KeyCode::Char('l') if matches!(self.mode, ViewMode::Insights) => {
5191                self.focus = FocusPane::Detail;
5192            }
5193            KeyCode::Char('h') if matches!(self.mode, ViewMode::Main) => {
5194                self.toggle_history_mode();
5195            }
5196            KeyCode::Char('h')
5197                if matches!(self.mode, ViewMode::History) && !self.history_file_tree_focus =>
5198            {
5199                self.toggle_history_mode();
5200            }
5201            KeyCode::Char('c')
5202                if matches!(self.mode, ViewMode::History) && !self.history_file_tree_focus =>
5203            {
5204                self.cycle_history_confidence();
5205            }
5206            KeyCode::Char('v')
5207                if matches!(self.mode, ViewMode::History) && !self.history_file_tree_focus =>
5208            {
5209                self.toggle_history_view_mode();
5210            }
5211            KeyCode::Char('s') if matches!(self.mode, ViewMode::Main) => self.cycle_list_sort(),
5212            KeyCode::Char('m') if matches!(self.mode, ViewMode::Insights) => {
5213                self.toggle_insights_heatmap();
5214            }
5215            KeyCode::Char('y')
5216                if matches!(self.mode, ViewMode::History) && !self.history_file_tree_focus =>
5217            {
5218                self.history_copy_to_clipboard();
5219            }
5220            KeyCode::Char('y') if self.should_copy_selected_issue_external_ref() => {
5221                self.copy_selected_issue_external_ref();
5222            }
5223            KeyCode::Char('o')
5224                if matches!(self.mode, ViewMode::History) && !self.history_file_tree_focus =>
5225            {
5226                self.history_open_in_browser();
5227            }
5228            KeyCode::Char('o') if self.should_open_selected_issue_external_ref() => {
5229                self.open_selected_issue_external_ref();
5230            }
5231            KeyCode::Char('f' | 'F') if matches!(self.mode, ViewMode::History) => {
5232                self.toggle_history_file_tree();
5233            }
5234            // File tree navigation (when file tree has focus in history mode)
5235            KeyCode::Char('j') | KeyCode::Down
5236                if matches!(self.mode, ViewMode::History) && self.history_file_tree_focus =>
5237            {
5238                self.move_file_tree_cursor_relative(1);
5239            }
5240            KeyCode::Char('k') | KeyCode::Up
5241                if matches!(self.mode, ViewMode::History) && self.history_file_tree_focus =>
5242            {
5243                self.move_file_tree_cursor_relative(-1);
5244            }
5245            KeyCode::Enter
5246                if matches!(self.mode, ViewMode::History) && self.history_file_tree_focus =>
5247            {
5248                self.file_tree_toggle_or_filter();
5249            }
5250            KeyCode::Char('o') => self.set_list_filter(ListFilter::Open),
5251            KeyCode::Char('I') => self.set_list_filter(ListFilter::InProgress),
5252            KeyCode::Char('B') => self.set_list_filter(ListFilter::Blocked),
5253            KeyCode::Char('c') => self.set_list_filter(ListFilter::Closed),
5254            KeyCode::Char('r') => self.set_list_filter(ListFilter::Ready),
5255            KeyCode::Char('a') if self.should_clear_filter_with_all_shortcut() => {
5256                self.set_list_filter(ListFilter::All);
5257            }
5258            KeyCode::Char('j') | KeyCode::Down
5259                if matches!(self.mode, ViewMode::History)
5260                    && matches!(self.history_view_mode, HistoryViewMode::Git)
5261                    && self.focus == FocusPane::List =>
5262            {
5263                self.move_history_cursor_relative(1);
5264            }
5265            KeyCode::Char('k') | KeyCode::Up
5266                if matches!(self.mode, ViewMode::History)
5267                    && matches!(self.history_view_mode, HistoryViewMode::Git)
5268                    && self.focus == FocusPane::List =>
5269            {
5270                self.move_history_cursor_relative(-1);
5271            }
5272            KeyCode::PageUp
5273                if matches!(self.mode, ViewMode::History)
5274                    && matches!(self.history_view_mode, HistoryViewMode::Git)
5275                    && self.focus == FocusPane::List =>
5276            {
5277                self.move_history_cursor_relative(-10);
5278            }
5279            KeyCode::PageDown
5280                if matches!(self.mode, ViewMode::History)
5281                    && matches!(self.history_view_mode, HistoryViewMode::Git)
5282                    && self.focus == FocusPane::List =>
5283            {
5284                self.move_history_cursor_relative(10);
5285            }
5286            KeyCode::Char('J')
5287                if matches!(self.mode, ViewMode::History)
5288                    && matches!(self.history_view_mode, HistoryViewMode::Git) =>
5289            {
5290                self.move_history_related_bead_relative(1);
5291            }
5292            KeyCode::Char('K')
5293                if matches!(self.mode, ViewMode::History)
5294                    && matches!(self.history_view_mode, HistoryViewMode::Git) =>
5295            {
5296                self.move_history_related_bead_relative(-1);
5297            }
5298            KeyCode::Char('j') | KeyCode::Down
5299                if matches!(self.mode, ViewMode::History)
5300                    && matches!(self.history_view_mode, HistoryViewMode::Git)
5301                    && matches!(self.focus, FocusPane::Middle | FocusPane::Detail) =>
5302            {
5303                self.move_history_related_bead_relative(1);
5304            }
5305            KeyCode::Char('k') | KeyCode::Up
5306                if matches!(self.mode, ViewMode::History)
5307                    && matches!(self.history_view_mode, HistoryViewMode::Git)
5308                    && matches!(self.focus, FocusPane::Middle | FocusPane::Detail) =>
5309            {
5310                self.move_history_related_bead_relative(-1);
5311            }
5312            KeyCode::Char('J')
5313                if matches!(self.mode, ViewMode::History)
5314                    && matches!(self.history_view_mode, HistoryViewMode::Bead) =>
5315            {
5316                self.move_history_bead_commit_relative(1);
5317            }
5318            KeyCode::Char('K')
5319                if matches!(self.mode, ViewMode::History)
5320                    && matches!(self.history_view_mode, HistoryViewMode::Bead) =>
5321            {
5322                self.move_history_bead_commit_relative(-1);
5323            }
5324            KeyCode::Char('j') | KeyCode::Down
5325                if matches!(self.mode, ViewMode::History)
5326                    && matches!(self.history_view_mode, HistoryViewMode::Bead)
5327                    && matches!(self.focus, FocusPane::Middle | FocusPane::Detail) =>
5328            {
5329                self.move_history_bead_commit_relative(1);
5330            }
5331            KeyCode::Char('k') | KeyCode::Up
5332                if matches!(self.mode, ViewMode::History)
5333                    && matches!(self.history_view_mode, HistoryViewMode::Bead)
5334                    && matches!(self.focus, FocusPane::Middle | FocusPane::Detail) =>
5335            {
5336                self.move_history_bead_commit_relative(-1);
5337            }
5338            // Board/Graph/Insights detail dependency navigation
5339            KeyCode::Char('J')
5340                if matches!(
5341                    self.mode,
5342                    ViewMode::Board | ViewMode::Graph | ViewMode::Insights
5343                ) =>
5344            {
5345                self.move_detail_dep_relative(1);
5346            }
5347            KeyCode::Char('K')
5348                if matches!(
5349                    self.mode,
5350                    ViewMode::Board | ViewMode::Graph | ViewMode::Insights
5351                ) =>
5352            {
5353                self.move_detail_dep_relative(-1);
5354            }
5355            KeyCode::Char('j') | KeyCode::Down
5356                if matches!(self.mode, ViewMode::Graph | ViewMode::Insights)
5357                    && self.focus == FocusPane::Detail
5358                    && !self.detail_dep_list().is_empty() =>
5359            {
5360                self.move_detail_dep_relative(1);
5361            }
5362            KeyCode::Char('k') | KeyCode::Up
5363                if matches!(self.mode, ViewMode::Graph | ViewMode::Insights)
5364                    && self.focus == FocusPane::Detail
5365                    && !self.detail_dep_list().is_empty() =>
5366            {
5367                self.move_detail_dep_relative(-1);
5368            }
5369            KeyCode::Home
5370                if matches!(self.mode, ViewMode::History)
5371                    && matches!(self.history_view_mode, HistoryViewMode::Git)
5372                    && self.focus == FocusPane::List =>
5373            {
5374                self.history_event_cursor = 0;
5375                self.history_related_bead_cursor = 0;
5376            }
5377            KeyCode::End | KeyCode::Char('G')
5378                if matches!(self.mode, ViewMode::History)
5379                    && matches!(self.history_view_mode, HistoryViewMode::Git)
5380                    && self.focus == FocusPane::List =>
5381            {
5382                self.select_last_history_event();
5383            }
5384            KeyCode::Char('j') | KeyCode::Down if self.focus == FocusPane::List => {
5385                self.move_selection_relative(1);
5386            }
5387            KeyCode::Char('k') | KeyCode::Up if self.focus == FocusPane::List => {
5388                self.move_selection_relative(-1);
5389            }
5390            KeyCode::PageUp if self.focus == FocusPane::List => {
5391                self.move_selection_relative(-(self.list_page_step() as isize));
5392            }
5393            KeyCode::PageDown if self.focus == FocusPane::List => {
5394                self.move_selection_relative(self.list_page_step() as isize);
5395            }
5396            KeyCode::Home | KeyCode::Char('0') if self.board_shortcut_focus() => {
5397                self.select_edge_in_current_board_lane(false);
5398            }
5399            KeyCode::End | KeyCode::Char('G' | '$') if self.board_shortcut_focus() => {
5400                self.select_edge_in_current_board_lane(true);
5401            }
5402            KeyCode::Home if self.focus == FocusPane::List => {
5403                self.select_first_visible();
5404            }
5405            KeyCode::End | KeyCode::Char('G') if self.focus == FocusPane::List => {
5406                self.select_last_visible();
5407            }
5408            KeyCode::Char('1') if self.board_shortcut_focus() => {
5409                self.select_first_in_board_lane(1);
5410            }
5411            KeyCode::Char('2') if self.board_shortcut_focus() => {
5412                self.select_first_in_board_lane(2);
5413            }
5414            KeyCode::Char('3') if self.board_shortcut_focus() => {
5415                self.select_first_in_board_lane(3);
5416            }
5417            KeyCode::Char('4') if self.board_shortcut_focus() => {
5418                self.select_first_in_board_lane(4);
5419            }
5420            KeyCode::Char('H') if self.board_shortcut_focus() => {
5421                self.select_first_in_non_empty_board_lane();
5422            }
5423            KeyCode::Char('L') if self.board_shortcut_focus() => {
5424                self.select_last_in_non_empty_board_lane();
5425            }
5426            KeyCode::Char('s') if matches!(self.mode, ViewMode::Board) => {
5427                self.cycle_board_grouping();
5428            }
5429            KeyCode::Char('e') if matches!(self.mode, ViewMode::Board) => {
5430                self.toggle_board_empty_visibility();
5431            }
5432            KeyCode::Char('s')
5433                if matches!(self.mode, ViewMode::Insights) && self.insights_heatmap.is_none() =>
5434            {
5435                self.insights_panel = self.insights_panel.next();
5436                self.reselect_insights_panel_context();
5437            }
5438            KeyCode::Char('S')
5439                if matches!(self.mode, ViewMode::Insights) && self.insights_heatmap.is_none() =>
5440            {
5441                self.insights_panel = self.insights_panel.prev();
5442                self.reselect_insights_panel_context();
5443            }
5444            KeyCode::Char('e') if matches!(self.mode, ViewMode::Insights) => {
5445                self.toggle_insights_explanations();
5446            }
5447            KeyCode::Char('x') if matches!(self.mode, ViewMode::Insights) => {
5448                self.toggle_insights_calc_proof();
5449            }
5450            KeyCode::Char('1') => {
5451                self.mode = ViewMode::Main;
5452                self.focus = FocusPane::List;
5453                self.sync_ranked_list_context();
5454            }
5455            KeyCode::Char('b') => {
5456                self.mode = if matches!(self.mode, ViewMode::Board) {
5457                    ViewMode::Main
5458                } else {
5459                    ViewMode::Board
5460                };
5461                self.focus = FocusPane::List;
5462            }
5463            KeyCode::Char('i') => {
5464                let entering_insights = !matches!(self.mode, ViewMode::Insights);
5465                let previous_mode = self.mode;
5466                let previous_selected = self.selected;
5467                self.mode = if entering_insights {
5468                    ViewMode::Insights
5469                } else {
5470                    ViewMode::Main
5471                };
5472                self.focus = FocusPane::List;
5473                if entering_insights {
5474                    if matches!(previous_mode, ViewMode::Main) {
5475                        self.reselect_ranked_list_context();
5476                    } else {
5477                        self.sync_insights_heatmap_selection();
5478                        if self.insights_heatmap.is_none() {
5479                            self.set_selected_index(previous_selected);
5480                        }
5481                    }
5482                } else {
5483                    self.sync_ranked_list_context();
5484                }
5485            }
5486            KeyCode::Char('g') if matches!(self.mode, ViewMode::History) => {
5487                let saved = self.mode;
5488                if matches!(self.history_view_mode, HistoryViewMode::Git)
5489                    && let Some(bead_id) = self
5490                        .selected_history_event()
5491                        .map(|event| event.issue_id)
5492                        .or_else(|| self.selected_history_git_related_bead_id())
5493                {
5494                    self.select_issue_by_id(&bead_id);
5495                }
5496                self.mode = ViewMode::Graph;
5497                self.focus = FocusPane::List;
5498                self.sync_ranked_list_context();
5499                self.pending_g = true;
5500                self.g_pre_toggle_mode = Some(saved);
5501            }
5502            KeyCode::Char('g') => {
5503                let saved = self.mode;
5504                let entering_graph = !matches!(self.mode, ViewMode::Graph);
5505                let previous_mode = self.mode;
5506                let previous_selected = self.selected;
5507                self.mode = if entering_graph {
5508                    ViewMode::Graph
5509                } else {
5510                    ViewMode::Main
5511                };
5512                self.focus = FocusPane::List;
5513                if entering_graph {
5514                    if matches!(previous_mode, ViewMode::Main) {
5515                        self.reselect_ranked_list_context();
5516                    } else if !self.graph_search_query.trim().is_empty() {
5517                        self.select_current_graph_search_match();
5518                    } else {
5519                        self.set_selected_index(previous_selected);
5520                        self.sync_insights_heatmap_selection();
5521                    }
5522                } else {
5523                    self.sync_ranked_list_context();
5524                }
5525                self.pending_g = true;
5526                self.g_pre_toggle_mode = Some(saved);
5527            }
5528            KeyCode::Char('a') => {
5529                self.mode = if matches!(self.mode, ViewMode::Actionable) {
5530                    ViewMode::Main
5531                } else {
5532                    self.compute_actionable_plan();
5533                    ViewMode::Actionable
5534                };
5535                self.detail_scroll_offset = 0;
5536                self.focus = FocusPane::List;
5537            }
5538            KeyCode::Char('!') => {
5539                self.mode = if matches!(self.mode, ViewMode::Attention) {
5540                    ViewMode::Main
5541                } else {
5542                    self.compute_attention();
5543                    ViewMode::Attention
5544                };
5545                self.focus = FocusPane::List;
5546            }
5547            KeyCode::Char('T') => {
5548                self.toggle_tree_mode();
5549                self.focus = FocusPane::List;
5550            }
5551            KeyCode::Char('t') if modifiers.contains(Modifiers::CTRL) => {
5552                self.open_tutorial();
5553            }
5554            KeyCode::Char('t') => {
5555                self.toggle_time_travel_mode();
5556            }
5557            KeyCode::Char('[') => {
5558                self.toggle_label_dashboard();
5559                self.focus = FocusPane::List;
5560            }
5561            KeyCode::Char(']') => {
5562                self.toggle_flow_matrix();
5563                self.focus = FocusPane::List;
5564            }
5565            KeyCode::Char('S') if !matches!(self.mode, ViewMode::Insights) => {
5566                self.toggle_sprint_mode();
5567                self.focus = FocusPane::List;
5568            }
5569            KeyCode::Char('\'') => {
5570                self.open_recipe_picker();
5571            }
5572            KeyCode::Char('P') => {
5573                self.open_pages_wizard();
5574            }
5575            KeyCode::Char('L') => {
5576                self.open_label_picker();
5577            }
5578            KeyCode::Char('w') => {
5579                self.open_repo_picker();
5580            }
5581            KeyCode::Char('p') if matches!(self.mode, ViewMode::Main) => {
5582                self.priority_hints_visible = !self.priority_hints_visible;
5583            }
5584            KeyCode::Char('C') => {
5585                self.copy_selected_issue_id();
5586            }
5587            KeyCode::Char('x') if matches!(self.mode, ViewMode::Main) => {
5588                self.export_selected_issue_markdown();
5589            }
5590            KeyCode::Char('O') => {
5591                self.open_selected_in_editor();
5592            }
5593            _ => {}
5594        }
5595
5596        if !matches!(self.mode, ViewMode::History) {
5597            self.mode_before_history = self.mode;
5598        }
5599
5600        Cmd::None
5601    }
5602
5603    fn toggle_history_mode(&mut self) {
5604        if matches!(self.mode, ViewMode::History) {
5605            self.mode = self.mode_before_history;
5606            self.focus = FocusPane::List;
5607            return;
5608        }
5609
5610        self.mode_before_history = self.mode;
5611        self.mode = ViewMode::History;
5612        self.history_view_mode = HistoryViewMode::Bead;
5613        self.history_event_cursor = 0;
5614        self.history_related_bead_cursor = 0;
5615        self.history_bead_commit_cursor = 0;
5616        self.history_search_active = false;
5617        self.history_search_query.clear();
5618        self.history_search_match_cursor = 0;
5619        self.history_search_mode = HistorySearchMode::All;
5620        self.history_show_file_tree = false;
5621        self.history_file_tree_cursor = 0;
5622        self.history_file_tree_filter = None;
5623        self.history_file_tree_focus = false;
5624        self.history_status_msg.clear();
5625        self.focus = FocusPane::List;
5626        self.ensure_git_history_loaded();
5627    }
5628
5629    fn cycle_history_confidence(&mut self) {
5630        self.history_confidence_index =
5631            (self.history_confidence_index + 1) % HISTORY_CONFIDENCE_STEPS.len();
5632        self.history_related_bead_cursor = 0;
5633        self.history_bead_commit_cursor = 0;
5634
5635        if matches!(self.history_view_mode, HistoryViewMode::Git) {
5636            let visible = self.history_git_visible_commit_indices();
5637            self.history_event_cursor = self
5638                .history_event_cursor
5639                .min(visible.len().saturating_sub(1));
5640        } else {
5641            let visible = self.history_visible_issue_indices();
5642            if !visible.contains(&self.selected)
5643                && let Some(&first_visible) = visible.first()
5644            {
5645                self.set_selected_index(first_visible);
5646            }
5647        }
5648
5649        let flat = self.history_flat_file_list();
5650        self.history_file_tree_cursor = self
5651            .history_file_tree_cursor
5652            .min(flat.len().saturating_sub(1));
5653        self.refresh_history_search_selection();
5654    }
5655
5656    fn history_min_confidence(&self) -> f64 {
5657        HISTORY_CONFIDENCE_STEPS
5658            .get(self.history_confidence_index)
5659            .copied()
5660            .unwrap_or(0.0)
5661    }
5662
5663    fn toggle_history_view_mode(&mut self) {
5664        self.history_view_mode = self.history_view_mode.toggle();
5665        self.history_event_cursor = 0;
5666        self.history_related_bead_cursor = 0;
5667        self.history_bead_commit_cursor = 0;
5668        self.focus = FocusPane::List;
5669        self.ensure_git_history_loaded();
5670        self.refresh_history_search_selection();
5671    }
5672
5673    fn jump_from_history_bead_commit_to_git(&mut self) {
5674        let Some(commit) = self.selected_history_bead_commit() else {
5675            self.history_status_msg = "No correlated commit selected".to_string();
5676            return;
5677        };
5678
5679        self.ensure_git_history_loaded();
5680        self.history_view_mode = HistoryViewMode::Git;
5681        self.focus = FocusPane::List;
5682        self.history_related_bead_cursor = 0;
5683        self.history_bead_commit_cursor = 0;
5684
5685        let visible = self.history_git_visible_commit_indices();
5686        let Some(target_slot) = visible.iter().position(|index| {
5687            self.history_git_cache
5688                .as_ref()
5689                .and_then(|cache| cache.commits.get(*index))
5690                .is_some_and(|entry| entry.sha == commit.sha)
5691        }) else {
5692            self.history_status_msg =
5693                format!("Commit {} not visible in git view", commit.short_sha);
5694            return;
5695        };
5696
5697        self.history_event_cursor = target_slot;
5698        self.history_status_msg = format!("Backtraced to commit {}", commit.short_sha);
5699    }
5700
5701    fn start_history_search(&mut self) {
5702        if !matches!(self.mode, ViewMode::History) || self.focus != FocusPane::List {
5703            return;
5704        }
5705
5706        self.history_search_active = true;
5707        self.history_search_query.clear();
5708        self.history_search_match_cursor = 0;
5709        self.history_search_mode = HistorySearchMode::All;
5710        self.history_event_cursor = 0;
5711        self.history_related_bead_cursor = 0;
5712        self.history_bead_commit_cursor = 0;
5713    }
5714
5715    fn finish_history_search(&mut self) {
5716        self.history_search_active = false;
5717    }
5718
5719    fn cancel_history_search(&mut self) {
5720        self.history_search_active = false;
5721        self.history_search_match_cursor = 0;
5722        self.history_search_query.clear();
5723        self.history_event_cursor = 0;
5724        self.history_related_bead_cursor = 0;
5725        self.history_bead_commit_cursor = 0;
5726    }
5727
5728    fn open_tutorial(&mut self) {
5729        self.modal_overlay = Some(ModalOverlay::Tutorial);
5730    }
5731
5732    fn open_confirm_with_resume(
5733        &mut self,
5734        title: impl Into<String>,
5735        message: impl Into<String>,
5736        resume_overlay: Option<ModalOverlay>,
5737    ) {
5738        self.modal_confirm_result = None;
5739        self.modal_overlay = Some(ModalOverlay::Confirm {
5740            title: title.into(),
5741            message: message.into(),
5742            resume_overlay: resume_overlay.map(Box::new),
5743        });
5744    }
5745
5746    fn open_pages_wizard(&mut self) {
5747        self.modal_overlay = Some(ModalOverlay::PagesWizard(PagesWizardState::new()));
5748    }
5749
5750    fn toggle_history_file_tree(&mut self) {
5751        self.history_show_file_tree = !self.history_show_file_tree;
5752        if self.history_show_file_tree {
5753            self.history_status_msg = "File tree: j/k navigate, Enter filter, Esc close".into();
5754        } else {
5755            self.history_file_tree_focus = false;
5756            self.history_status_msg = "File tree hidden".into();
5757        }
5758    }
5759
5760    fn move_file_tree_cursor_relative(&mut self, delta: isize) {
5761        let flat = self.history_flat_file_list();
5762        if flat.is_empty() {
5763            return;
5764        }
5765        let len = flat.len();
5766        let cur = self.history_file_tree_cursor.min(len.saturating_sub(1));
5767        self.history_file_tree_cursor = if delta > 0 {
5768            cur.saturating_add(delta.unsigned_abs())
5769                .min(len.saturating_sub(1))
5770        } else {
5771            cur.saturating_sub(delta.unsigned_abs())
5772        };
5773    }
5774
5775    fn file_tree_toggle_or_filter(&mut self) {
5776        let flat = self.history_flat_file_list();
5777        let cursor = self
5778            .history_file_tree_cursor
5779            .min(flat.len().saturating_sub(1));
5780        if let Some(entry) = flat.get(cursor) {
5781            let path = entry.path.clone();
5782            if self.history_file_tree_filter.as_deref() == Some(&path) {
5783                self.history_file_tree_filter = None;
5784                self.history_status_msg = "Filter cleared".into();
5785            } else {
5786                self.history_file_tree_filter = Some(path.clone());
5787                self.history_status_msg = format!("Filtered to: {path}");
5788            }
5789            self.history_event_cursor = 0;
5790            self.history_bead_commit_cursor = 0;
5791            if matches!(self.history_view_mode, HistoryViewMode::Bead) {
5792                let visible = self.history_visible_issue_indices();
5793                if !visible.contains(&self.selected)
5794                    && let Some(&first_visible) = visible.first()
5795                {
5796                    self.set_selected_index(first_visible);
5797                }
5798            }
5799        }
5800    }
5801
5802    fn history_path_matches_file_filter(&self, path: &str) -> bool {
5803        self.history_file_tree_filter
5804            .as_deref()
5805            .is_none_or(|filter| path == filter || path.starts_with(&format!("{filter}/")))
5806    }
5807
5808    fn history_filtered_bead_commits<'a>(&'a self, issue_id: &str) -> Vec<&'a HistoryCommitCompat> {
5809        let min_confidence = self.history_min_confidence();
5810        self.history_git_cache
5811            .as_ref()
5812            .and_then(|cache| cache.histories.get(issue_id))
5813            .and_then(|history| history.commits.as_deref())
5814            .map(|commits| {
5815                commits
5816                    .iter()
5817                    .filter(|commit| {
5818                        commit.confidence >= min_confidence
5819                            && commit
5820                                .files
5821                                .iter()
5822                                .any(|file| self.history_path_matches_file_filter(&file.path))
5823                    })
5824                    .collect()
5825            })
5826            .unwrap_or_default()
5827    }
5828
5829    fn history_file_tree_nodes(&self) -> Vec<FileTreeNode> {
5830        let Some(cache) = &self.history_git_cache else {
5831            return Vec::new();
5832        };
5833
5834        let mut file_counts: BTreeMap<String, usize> = BTreeMap::new();
5835        for commit in &cache.commits {
5836            if self
5837                .history_git_related_beads_for_commit(&commit.sha)
5838                .is_empty()
5839            {
5840                continue;
5841            }
5842            for file in &commit.files {
5843                *file_counts.entry(file.path.clone()).or_default() += 1;
5844            }
5845        }
5846
5847        fn insert_path(
5848            nodes: &mut Vec<FileTreeNode>,
5849            parts: &[&str],
5850            level: usize,
5851            prefix: &str,
5852            count: usize,
5853        ) {
5854            let Some((name, rest)) = parts.split_first() else {
5855                return;
5856            };
5857
5858            let path = if prefix.is_empty() {
5859                (*name).to_string()
5860            } else {
5861                format!("{prefix}/{name}")
5862            };
5863            let is_dir = !rest.is_empty();
5864            let index = nodes
5865                .iter()
5866                .position(|node| node.path == path)
5867                .unwrap_or_else(|| {
5868                    nodes.push(FileTreeNode {
5869                        name: (*name).to_string(),
5870                        path: path.clone(),
5871                        is_dir,
5872                        change_count: 0,
5873                        expanded: true,
5874                        level,
5875                        children: Vec::new(),
5876                    });
5877                    nodes.len() - 1
5878                });
5879
5880            nodes[index].change_count += count;
5881            if is_dir {
5882                insert_path(&mut nodes[index].children, rest, level + 1, &path, count);
5883            }
5884        }
5885
5886        fn sort_nodes(nodes: &mut [FileTreeNode]) {
5887            for node in nodes.iter_mut() {
5888                sort_nodes(&mut node.children);
5889            }
5890            nodes.sort_by(|left, right| {
5891                right
5892                    .is_dir
5893                    .cmp(&left.is_dir)
5894                    .then_with(|| left.name.cmp(&right.name))
5895            });
5896        }
5897
5898        let mut roots = Vec::new();
5899        for (path, count) in file_counts {
5900            let parts = path.split('/').collect::<Vec<_>>();
5901            insert_path(&mut roots, &parts, 0, "", count);
5902        }
5903        sort_nodes(&mut roots);
5904        roots
5905    }
5906
5907    fn history_flat_file_list(&self) -> Vec<FlatFileEntry> {
5908        self.history_file_tree_nodes()
5909            .iter()
5910            .flat_map(FileTreeNode::flatten_visible)
5911            .collect()
5912    }
5913
5914    /// Copy selected bead ID or commit SHA to clipboard via external command.
5915    fn history_copy_to_clipboard(&mut self) {
5916        let text = if matches!(self.history_view_mode, HistoryViewMode::Git) {
5917            self.selected_history_git_commit_sha()
5918        } else {
5919            self.selected_issue().map(|issue| issue.id.clone())
5920        };
5921
5922        if let Some(text) = text {
5923            if copy_text_to_clipboard(&text) {
5924                let short = if text.len() > 7 { &text[..7] } else { &text };
5925                self.history_status_msg = format!("Copied {short} to clipboard");
5926            } else {
5927                self.history_status_msg = "Clipboard not available".into();
5928            }
5929        } else {
5930            self.history_status_msg = "No item selected".into();
5931        }
5932    }
5933
5934    fn selected_history_git_commit_sha(&self) -> Option<String> {
5935        let cache = self.history_git_cache.as_ref()?;
5936        let commit = cache.commits.get(self.history_event_cursor)?;
5937        Some(commit.sha.clone())
5938    }
5939
5940    fn history_selected_commit_sha(&self) -> Option<String> {
5941        if matches!(self.history_view_mode, HistoryViewMode::Git) {
5942            self.selected_history_git_commit_sha()
5943        } else {
5944            self.selected_history_bead_commit().map(|commit| commit.sha)
5945        }
5946    }
5947
5948    fn history_selected_commit_url(&self) -> Option<String> {
5949        self.history_selected_commit_sha()
5950            .and_then(|sha| self.history_commit_url_for_sha(&sha))
5951    }
5952
5953    fn history_commit_for_bead(&self, bead_id: &str, sha: &str) -> Option<HistoryCommitCompat> {
5954        self.history_git_cache
5955            .as_ref()
5956            .and_then(|cache| cache.histories.get(bead_id))
5957            .and_then(|history| history.commits.as_deref())
5958            .and_then(|commits| commits.iter().find(|commit| commit.sha == sha))
5959            .cloned()
5960    }
5961
5962    fn selected_history_git_bead_commit(&self) -> Option<HistoryCommitCompat> {
5963        let commit = self.selected_history_git_commit()?;
5964        let bead_id = self.selected_history_git_related_bead_id()?;
5965        self.history_commit_for_bead(&bead_id, &commit.sha)
5966    }
5967
5968    fn history_commit_url_for_sha(&self, sha: &str) -> Option<String> {
5969        let repo_root = self
5970            .repo_root
5971            .clone()
5972            .or_else(|| std::env::current_dir().ok())?;
5973        let remote_url = std::process::Command::new("git")
5974            .args(["config", "--get", "remote.origin.url"])
5975            .current_dir(&repo_root)
5976            .output()
5977            .ok()
5978            .and_then(|output| {
5979                output
5980                    .status
5981                    .success()
5982                    .then(|| String::from_utf8(output.stdout).ok())
5983                    .flatten()
5984            })
5985            .map(|s| s.trim().to_string())?;
5986        remote_to_commit_url(&remote_url, sha)
5987    }
5988
5989    /// Open selected commit in browser via git remote URL.
5990    fn history_open_in_browser(&mut self) {
5991        let sha = self.history_selected_commit_sha();
5992
5993        let Some(sha) = sha else {
5994            self.history_status_msg = "No commit selected".into();
5995            return;
5996        };
5997
5998        let Some(url) = self.history_commit_url_for_sha(&sha) else {
5999            self.history_status_msg = "Cannot build commit URL from remote".into();
6000            return;
6001        };
6002
6003        if open_url_in_browser(&url) {
6004            let short = if sha.len() > 7 { &sha[..7] } else { &sha };
6005            self.history_status_msg = format!("Opened {short} in browser");
6006        } else {
6007            self.history_status_msg = "Could not open browser".into();
6008        }
6009    }
6010
6011    fn history_copy_commit_url(&mut self) {
6012        let sha = self.history_selected_commit_sha();
6013
6014        let Some(sha) = sha else {
6015            self.history_status_msg = "No commit selected".into();
6016            return;
6017        };
6018
6019        let Some(url) = self.history_commit_url_for_sha(&sha) else {
6020            self.history_status_msg = "Cannot build commit URL from remote".into();
6021            return;
6022        };
6023
6024        if copy_text_to_clipboard(&url) {
6025            let short = if sha.len() > 7 { &sha[..7] } else { &sha };
6026            self.history_status_msg = format!("Copied {short} commit URL");
6027        } else {
6028            self.history_status_msg = "Clipboard not available".into();
6029        }
6030    }
6031
6032    fn refresh_history_search_selection(&mut self) {
6033        if self.history_search_query.trim().is_empty() {
6034            return;
6035        }
6036
6037        if matches!(self.history_view_mode, HistoryViewMode::Git) {
6038            self.history_event_cursor = 0;
6039            self.history_related_bead_cursor = 0;
6040            return;
6041        }
6042
6043        let visible = self.history_visible_issue_indices();
6044        if let Some(index) = visible.first().copied() {
6045            self.set_selected_index(index);
6046            self.focus = FocusPane::List;
6047            self.history_bead_commit_cursor = 0;
6048        }
6049    }
6050
6051    /// Compute indices matching the current history search query.
6052    /// In bead mode: returns matching issue indices.
6053    /// In git mode: returns matching commit indices.
6054    fn history_search_matches(&self) -> Vec<usize> {
6055        let query = self.history_search_query.trim().to_ascii_lowercase();
6056        if query.is_empty() {
6057            return Vec::new();
6058        }
6059
6060        if matches!(self.history_view_mode, HistoryViewMode::Git) {
6061            self.history_git_visible_commit_indices()
6062        } else {
6063            self.history_visible_issue_indices()
6064        }
6065    }
6066
6067    fn move_history_search_match_relative(&mut self, delta: isize) {
6068        let matches = self.history_search_matches();
6069        if matches.is_empty() || delta == 0 {
6070            return;
6071        }
6072
6073        let len = matches.len();
6074        let current = self.history_search_match_cursor.min(len.saturating_sub(1));
6075        let step = delta.unsigned_abs() % len;
6076        let next = if delta > 0 {
6077            (current + step) % len
6078        } else {
6079            (current + len - step) % len
6080        };
6081
6082        self.history_search_match_cursor = next;
6083
6084        if matches!(self.history_view_mode, HistoryViewMode::Git) {
6085            self.history_event_cursor = matches[next];
6086            self.history_related_bead_cursor = 0;
6087        } else {
6088            self.set_selected_index(matches[next]);
6089            self.history_bead_commit_cursor = 0;
6090        }
6091    }
6092
6093    fn ensure_git_history_loaded(&mut self) {
6094        if self.history_git_cache.is_some() {
6095            return;
6096        }
6097
6098        let repo_root = self
6099            .repo_root
6100            .clone()
6101            .or_else(|| std::env::current_dir().ok());
6102        let Some(repo_root) = repo_root else {
6103            return;
6104        };
6105
6106        let commits = load_git_commits(&repo_root, 500, None).unwrap_or_default();
6107        let mut histories = self
6108            .analyzer
6109            .issues
6110            .iter()
6111            .map(|issue| {
6112                (
6113                    issue.id.clone(),
6114                    HistoryBeadCompat {
6115                        bead_id: issue.id.clone(),
6116                        title: issue.title.clone(),
6117                        status: issue.status.clone(),
6118                        events: Vec::new(),
6119                        milestones: HistoryMilestonesCompat::default(),
6120                        commits: None,
6121                        cycle_time: None,
6122                        last_author: String::new(),
6123                    },
6124                )
6125            })
6126            .collect::<BTreeMap<_, _>>();
6127
6128        let mut commit_index = BTreeMap::<String, Vec<String>>::new();
6129        let mut method_distribution = BTreeMap::<String, usize>::new();
6130        let workspace_aliases = build_workspace_id_aliases(&self.analyzer.issues);
6131
6132        correlate_histories_with_git_aliases(
6133            &repo_root,
6134            &commits,
6135            &mut histories,
6136            &mut commit_index,
6137            &mut method_distribution,
6138            &workspace_aliases,
6139        );
6140
6141        finalize_history_entries(&mut histories);
6142
6143        let mut commit_bead_confidence = BTreeMap::<String, Vec<(String, f64)>>::new();
6144        for history in histories.values() {
6145            for commit in history.commits.as_deref().unwrap_or_default() {
6146                commit_bead_confidence
6147                    .entry(commit.sha.clone())
6148                    .or_default()
6149                    .push((history.bead_id.clone(), commit.confidence));
6150            }
6151        }
6152        for pairs in commit_bead_confidence.values_mut() {
6153            pairs.sort_by(|left, right| left.0.cmp(&right.0));
6154        }
6155
6156        self.history_git_cache = Some(HistoryGitCache {
6157            commits,
6158            histories,
6159            commit_bead_confidence,
6160        });
6161    }
6162
6163    fn history_git_visible_commit_indices(&self) -> Vec<usize> {
6164        let Some(cache) = &self.history_git_cache else {
6165            return Vec::new();
6166        };
6167
6168        let min_confidence = self.history_min_confidence();
6169        let query = self.history_search_query.trim().to_ascii_lowercase();
6170
6171        cache
6172            .commits
6173            .iter()
6174            .enumerate()
6175            .filter_map(|(index, commit)| {
6176                let related = self.history_git_related_beads_for_commit(&commit.sha);
6177                if related.is_empty() {
6178                    return None;
6179                }
6180                if !commit
6181                    .files
6182                    .iter()
6183                    .any(|file| self.history_path_matches_file_filter(&file.path))
6184                {
6185                    return None;
6186                }
6187
6188                if query.is_empty() {
6189                    return Some(index);
6190                }
6191
6192                let matches = match self.history_search_mode {
6193                    HistorySearchMode::Sha => {
6194                        commit.sha.to_ascii_lowercase().starts_with(&query)
6195                            || commit.short_sha.to_ascii_lowercase().starts_with(&query)
6196                    }
6197                    HistorySearchMode::Commit => {
6198                        commit.message.to_ascii_lowercase().contains(&query)
6199                    }
6200                    HistorySearchMode::Author => {
6201                        commit.author.to_ascii_lowercase().contains(&query)
6202                            || commit.author_email.to_ascii_lowercase().contains(&query)
6203                    }
6204                    HistorySearchMode::Bead => related
6205                        .iter()
6206                        .any(|id| id.to_ascii_lowercase().contains(&query)),
6207                    HistorySearchMode::All => {
6208                        let sha = commit.sha.to_ascii_lowercase();
6209                        let short_sha = commit.short_sha.to_ascii_lowercase();
6210                        let message = commit.message.to_ascii_lowercase();
6211                        let author = commit.author.to_ascii_lowercase();
6212                        let author_email = commit.author_email.to_ascii_lowercase();
6213                        let related_match = related
6214                            .iter()
6215                            .any(|id| id.to_ascii_lowercase().contains(&query));
6216                        sha.contains(&query)
6217                            || short_sha.contains(&query)
6218                            || message.contains(&query)
6219                            || author.contains(&query)
6220                            || author_email.contains(&query)
6221                            || commit.timestamp.to_ascii_lowercase().contains(&query)
6222                            || related_match
6223                    }
6224                };
6225
6226                matches.then_some(index)
6227            })
6228            .filter(|index| {
6229                let commit = cache.commits.get(*index);
6230                commit.is_some_and(|commit| {
6231                    self.history_git_related_beads_for_commit(&commit.sha)
6232                        .iter()
6233                        .any(|bead_id| {
6234                            cache.histories.get(bead_id).is_some_and(|history| {
6235                                history
6236                                    .commits
6237                                    .as_deref()
6238                                    .unwrap_or_default()
6239                                    .iter()
6240                                    .any(|entry| {
6241                                        entry.sha == commit.sha
6242                                            && entry.confidence >= min_confidence
6243                                    })
6244                            })
6245                        })
6246                })
6247            })
6248            .collect()
6249    }
6250
6251    fn selected_history_git_commit(&self) -> Option<&GitCommitRecord> {
6252        let Some(cache) = &self.history_git_cache else {
6253            return None;
6254        };
6255
6256        let visible = self.history_git_visible_commit_indices();
6257        if visible.is_empty() {
6258            return None;
6259        }
6260
6261        let slot = self
6262            .history_event_cursor
6263            .min(visible.len().saturating_sub(1));
6264        let index = visible[slot];
6265        cache.commits.get(index)
6266    }
6267
6268    fn history_git_related_beads_for_commit(&self, sha: &str) -> Vec<String> {
6269        let Some(cache) = &self.history_git_cache else {
6270            return Vec::new();
6271        };
6272
6273        let min_confidence = self.history_min_confidence();
6274        cache
6275            .commit_bead_confidence
6276            .get(sha)
6277            .into_iter()
6278            .flatten()
6279            .filter(|(_, confidence)| *confidence >= min_confidence)
6280            .map(|(bead_id, _)| bead_id.clone())
6281            .collect()
6282    }
6283
6284    fn selected_history_git_related_bead_id(&self) -> Option<String> {
6285        let commit = self.selected_history_git_commit()?;
6286        let related = self.history_git_related_beads_for_commit(&commit.sha);
6287        if related.is_empty() {
6288            return None;
6289        }
6290
6291        let slot = self
6292            .history_related_bead_cursor
6293            .min(related.len().saturating_sub(1));
6294        related.get(slot).cloned()
6295    }
6296
6297    fn move_history_cursor_relative(&mut self, delta: isize) {
6298        let commits_len = self.history_git_visible_commit_indices().len();
6299        if commits_len == 0 {
6300            self.history_event_cursor = 0;
6301            return;
6302        }
6303
6304        let max_slot = commits_len.saturating_sub(1);
6305        let next_slot = if delta >= 0 {
6306            self.history_event_cursor
6307                .saturating_add(delta.unsigned_abs())
6308                .min(max_slot)
6309        } else {
6310            self.history_event_cursor
6311                .saturating_sub(delta.unsigned_abs())
6312        };
6313        self.history_event_cursor = next_slot;
6314        self.history_related_bead_cursor = 0;
6315    }
6316
6317    fn select_last_history_event(&mut self) {
6318        let commits_len = self.history_git_visible_commit_indices().len();
6319        self.history_event_cursor = commits_len.saturating_sub(1);
6320        self.history_related_bead_cursor = 0;
6321    }
6322
6323    fn move_history_related_bead_relative(&mut self, delta: isize) {
6324        if delta == 0 {
6325            return;
6326        }
6327
6328        if self.focus == FocusPane::List {
6329            self.move_history_cursor_relative(delta);
6330            return;
6331        }
6332
6333        let Some(commit) = self.selected_history_git_commit() else {
6334            self.history_related_bead_cursor = 0;
6335            return;
6336        };
6337        let related_len = self.history_git_related_beads_for_commit(&commit.sha).len();
6338        if related_len == 0 {
6339            self.history_related_bead_cursor = 0;
6340            return;
6341        }
6342
6343        let max_slot = related_len.saturating_sub(1);
6344        let next_slot = if delta >= 0 {
6345            self.history_related_bead_cursor
6346                .saturating_add(delta.unsigned_abs())
6347                .min(max_slot)
6348        } else {
6349            self.history_related_bead_cursor
6350                .saturating_sub(delta.unsigned_abs())
6351        };
6352        self.history_related_bead_cursor = next_slot;
6353    }
6354
6355    fn move_history_bead_commit_relative(&mut self, delta: isize) {
6356        if delta == 0 {
6357            return;
6358        }
6359
6360        if self.focus == FocusPane::List {
6361            self.move_selection_relative(delta);
6362            return;
6363        }
6364
6365        let Some(issue_id) = self.selected_issue().map(|issue| issue.id.clone()) else {
6366            self.history_bead_commit_cursor = 0;
6367            return;
6368        };
6369
6370        self.ensure_git_history_loaded();
6371
6372        let commits_len = self.history_filtered_bead_commits(&issue_id).len();
6373
6374        if commits_len == 0 {
6375            self.history_bead_commit_cursor = 0;
6376            return;
6377        }
6378
6379        let max_slot = commits_len.saturating_sub(1);
6380        let next_slot = if delta >= 0 {
6381            self.history_bead_commit_cursor
6382                .saturating_add(delta.unsigned_abs())
6383                .min(max_slot)
6384        } else {
6385            self.history_bead_commit_cursor
6386                .saturating_sub(delta.unsigned_abs())
6387        };
6388        self.history_bead_commit_cursor = next_slot;
6389    }
6390
6391    // ── Detail dependency navigation (board/graph/insights) ─────
6392
6393    fn detail_dep_list(&self) -> Vec<String> {
6394        let Some(issue) = self.selected_issue() else {
6395            return Vec::new();
6396        };
6397        let mut deps = self.analyzer.graph.blockers(&issue.id);
6398        deps.extend(self.analyzer.graph.dependents(&issue.id));
6399        deps
6400    }
6401
6402    fn move_detail_dep_relative(&mut self, delta: isize) {
6403        if delta == 0 {
6404            return;
6405        }
6406        if self.focus == FocusPane::List {
6407            self.move_selection_relative(delta);
6408            return;
6409        }
6410        let dep_len = self.detail_dep_list().len();
6411        if dep_len == 0 {
6412            self.detail_dep_cursor = 0;
6413            return;
6414        }
6415        let max_slot = dep_len.saturating_sub(1);
6416        let next_slot = if delta >= 0 {
6417            self.detail_dep_cursor
6418                .saturating_add(delta.unsigned_abs())
6419                .min(max_slot)
6420        } else {
6421            self.detail_dep_cursor.saturating_sub(delta.unsigned_abs())
6422        };
6423        self.detail_dep_cursor = next_slot;
6424    }
6425
6426    fn history_timeline_events(&self) -> Vec<HistoryTimelineEvent> {
6427        let mut events = self
6428            .analyzer
6429            .history(None, 0)
6430            .into_iter()
6431            .flat_map(|history| {
6432                history
6433                    .events
6434                    .into_iter()
6435                    .map(move |event| HistoryTimelineEvent {
6436                        issue_id: history.id.clone(),
6437                        issue_title: history.title.clone(),
6438                        issue_status: history.status.clone(),
6439                        event_kind: event.kind,
6440                        event_timestamp: event.timestamp,
6441                        event_details: event.details,
6442                    })
6443            })
6444            .collect::<Vec<_>>();
6445
6446        events.sort_by(|left, right| {
6447            cmp_opt_datetime(left.event_timestamp, right.event_timestamp, true)
6448                .then_with(|| left.issue_id.cmp(&right.issue_id))
6449                .then_with(|| left.event_kind.cmp(&right.event_kind))
6450        });
6451
6452        events
6453    }
6454
6455    fn history_timeline_events_filtered(&self) -> Vec<HistoryTimelineEvent> {
6456        let query = self.history_search_query.trim().to_ascii_lowercase();
6457        if query.is_empty() {
6458            return self.history_timeline_events();
6459        }
6460
6461        self.history_timeline_events()
6462            .into_iter()
6463            .filter(|event| {
6464                event.issue_id.to_ascii_lowercase().contains(&query)
6465                    || event.issue_title.to_ascii_lowercase().contains(&query)
6466                    || event.issue_status.to_ascii_lowercase().contains(&query)
6467                    || event.event_kind.to_ascii_lowercase().contains(&query)
6468                    || event.event_details.to_ascii_lowercase().contains(&query)
6469                    || event
6470                        .event_timestamp
6471                        .map(|dt| {
6472                            dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6473                                .to_ascii_lowercase()
6474                        })
6475                        .is_some_and(|timestamp| timestamp.contains(&query))
6476            })
6477            .collect()
6478    }
6479
6480    fn selected_history_event(&self) -> Option<HistoryTimelineEvent> {
6481        let events = self.history_timeline_events_filtered();
6482        if events.is_empty() {
6483            return None;
6484        }
6485
6486        let slot = self
6487            .history_event_cursor
6488            .min(events.len().saturating_sub(1));
6489        events.get(slot).cloned()
6490    }
6491
6492    fn selected_history_bead_commit(&self) -> Option<HistoryCommitCompat> {
6493        let issue = self.selected_issue()?;
6494        let commits = self.history_filtered_bead_commits(&issue.id);
6495        if commits.is_empty() {
6496            return None;
6497        }
6498
6499        let slot = self
6500            .history_bead_commit_cursor
6501            .min(commits.len().saturating_sub(1));
6502        commits.get(slot).map(|commit| (*commit).clone())
6503    }
6504
6505    fn issue_matches_filter(&self, issue: &Issue) -> bool {
6506        let base = match self.list_filter {
6507            ListFilter::All => true,
6508            ListFilter::Open => issue.is_open_like(),
6509            ListFilter::InProgress => issue.status.eq_ignore_ascii_case("in_progress"),
6510            ListFilter::Blocked => issue.status.eq_ignore_ascii_case("blocked"),
6511            ListFilter::Closed => issue.is_closed_like(),
6512            ListFilter::Ready => {
6513                issue.is_open_like() && self.analyzer.graph.open_blockers(&issue.id).is_empty()
6514            }
6515        };
6516        if !base {
6517            return false;
6518        }
6519        if let Some(ref label) = self.modal_label_filter {
6520            if !issue
6521                .labels
6522                .iter()
6523                .any(|candidate| candidate.eq_ignore_ascii_case(label))
6524            {
6525                return false;
6526            }
6527        }
6528        if let Some(ref repo) = self.modal_repo_filter {
6529            if issue.source_repo != *repo {
6530                return false;
6531            }
6532        }
6533        true
6534    }
6535
6536    fn visible_issue_indices(&self) -> Vec<usize> {
6537        let mut visible = self
6538            .analyzer
6539            .issues
6540            .iter()
6541            .enumerate()
6542            .filter_map(|(index, issue)| self.issue_matches_filter(issue).then_some(index))
6543            .collect::<Vec<_>>();
6544
6545        {
6546            visible.sort_by(|left_index, right_index| {
6547                let left_issue = &self.analyzer.issues[*left_index];
6548                let right_issue = &self.analyzer.issues[*right_index];
6549
6550                match self.list_sort {
6551                    ListSort::Default => {
6552                        let l_open = left_issue.is_open_like();
6553                        let r_open = right_issue.is_open_like();
6554                        r_open
6555                            .cmp(&l_open)
6556                            .then_with(|| left_issue.priority.cmp(&right_issue.priority))
6557                            .then_with(|| left_issue.id.cmp(&right_issue.id))
6558                    }
6559                    ListSort::CreatedAsc => {
6560                        cmp_opt_datetime(left_issue.created_at, right_issue.created_at, false)
6561                            .then_with(|| left_issue.id.cmp(&right_issue.id))
6562                    }
6563                    ListSort::CreatedDesc => {
6564                        cmp_opt_datetime(left_issue.created_at, right_issue.created_at, true)
6565                            .then_with(|| left_issue.id.cmp(&right_issue.id))
6566                    }
6567                    ListSort::Priority => left_issue
6568                        .priority
6569                        .cmp(&right_issue.priority)
6570                        .then_with(|| left_issue.id.cmp(&right_issue.id)),
6571                    ListSort::Updated => cmp_opt_datetime(
6572                        left_issue.updated_at.or(left_issue.created_at),
6573                        right_issue.updated_at.or(right_issue.created_at),
6574                        true,
6575                    )
6576                    .then_with(|| left_issue.id.cmp(&right_issue.id)),
6577                    ListSort::PageRank => {
6578                        let l = self
6579                            .analyzer
6580                            .metrics
6581                            .pagerank
6582                            .get(&left_issue.id)
6583                            .copied()
6584                            .unwrap_or_default();
6585                        let r = self
6586                            .analyzer
6587                            .metrics
6588                            .pagerank
6589                            .get(&right_issue.id)
6590                            .copied()
6591                            .unwrap_or_default();
6592                        r.total_cmp(&l)
6593                            .then_with(|| left_issue.id.cmp(&right_issue.id))
6594                    }
6595                    ListSort::Blockers => {
6596                        let l = self
6597                            .analyzer
6598                            .metrics
6599                            .blocks_count
6600                            .get(&left_issue.id)
6601                            .copied()
6602                            .unwrap_or_default();
6603                        let r = self
6604                            .analyzer
6605                            .metrics
6606                            .blocks_count
6607                            .get(&right_issue.id)
6608                            .copied()
6609                            .unwrap_or_default();
6610                        r.cmp(&l).then_with(|| left_issue.id.cmp(&right_issue.id))
6611                    }
6612                }
6613            });
6614        }
6615
6616        visible
6617    }
6618
6619    fn history_visible_issue_indices(&self) -> Vec<usize> {
6620        let visible = self.visible_issue_indices();
6621        if !matches!(self.mode, ViewMode::History)
6622            || !matches!(self.history_view_mode, HistoryViewMode::Bead)
6623        {
6624            return visible;
6625        }
6626
6627        let query = self.history_search_query.trim().to_ascii_lowercase();
6628        if query.is_empty()
6629            && self.history_file_tree_filter.is_none()
6630            && self.history_min_confidence() == 0.0
6631        {
6632            return visible;
6633        }
6634
6635        let cache = self.history_git_cache.as_ref();
6636        visible
6637            .into_iter()
6638            .filter(|index| {
6639                self.analyzer.issues.get(*index).is_some_and(|issue| {
6640                    let filtered_commits = if cache.is_some() {
6641                        self.history_filtered_bead_commits(&issue.id)
6642                    } else {
6643                        Vec::new()
6644                    };
6645                    if (self.history_file_tree_filter.is_some()
6646                        || self.history_min_confidence() > 0.0)
6647                        && filtered_commits.is_empty()
6648                    {
6649                        return false;
6650                    }
6651
6652                    match self.history_search_mode {
6653                        HistorySearchMode::Bead => {
6654                            issue.id.to_ascii_lowercase().contains(&query)
6655                                || issue.title.to_ascii_lowercase().contains(&query)
6656                        }
6657                        HistorySearchMode::Sha => filtered_commits.iter().any(|commit| {
6658                            commit.sha.to_ascii_lowercase().starts_with(&query)
6659                                || commit.short_sha.to_ascii_lowercase().starts_with(&query)
6660                        }),
6661                        HistorySearchMode::Commit => filtered_commits
6662                            .iter()
6663                            .any(|commit| commit.message.to_ascii_lowercase().contains(&query)),
6664                        HistorySearchMode::Author => cache.is_some_and(|c| {
6665                            c.histories.get(&issue.id).is_some_and(|history| {
6666                                history.last_author.to_ascii_lowercase().contains(&query)
6667                                    || filtered_commits.iter().any(|commit| {
6668                                        commit.author.to_ascii_lowercase().contains(&query)
6669                                            || commit
6670                                                .author_email
6671                                                .to_ascii_lowercase()
6672                                                .contains(&query)
6673                                    })
6674                            })
6675                        }),
6676                        HistorySearchMode::All => {
6677                            issue.id.to_ascii_lowercase().contains(&query)
6678                                || issue.title.to_ascii_lowercase().contains(&query)
6679                                || issue.status.to_ascii_lowercase().contains(&query)
6680                                || issue.issue_type.to_ascii_lowercase().contains(&query)
6681                                || issue
6682                                    .labels
6683                                    .iter()
6684                                    .any(|label| label.to_ascii_lowercase().contains(&query))
6685                        }
6686                    }
6687                })
6688            })
6689            .collect()
6690    }
6691
6692    fn visible_issue_indices_for_list_nav(&self) -> Vec<usize> {
6693        if matches!(self.mode, ViewMode::History)
6694            && matches!(self.history_view_mode, HistoryViewMode::Bead)
6695        {
6696            return self.history_visible_issue_indices();
6697        }
6698        if matches!(self.mode, ViewMode::Graph) {
6699            return self.graph_visible_issue_indices();
6700        }
6701        if matches!(self.mode, ViewMode::Insights) {
6702            return self.insights_visible_issue_indices_for_list_nav();
6703        }
6704
6705        self.visible_issue_indices()
6706    }
6707
6708    fn selected_visible_slot(&self, visible: &[usize]) -> Option<usize> {
6709        visible.iter().position(|index| *index == self.selected)
6710    }
6711
6712    fn preserve_off_queue_ranked_context(&self) -> bool {
6713        match self.mode {
6714            ViewMode::Insights => {
6715                self.insights_heatmap.is_none()
6716                    && self.insights_search_query.trim().is_empty()
6717                    && !self
6718                        .insights_visible_issue_indices_for_list_nav()
6719                        .contains(&self.selected)
6720            }
6721            ViewMode::Graph => {
6722                self.graph_search_query.trim().is_empty()
6723                    && !self.graph_visible_issue_indices().contains(&self.selected)
6724            }
6725            _ => false,
6726        }
6727    }
6728
6729    fn ensure_selected_visible(&mut self) {
6730        let visible = self.visible_issue_indices_for_list_nav();
6731        if visible.is_empty() {
6732            self.set_selected_index(0);
6733            return;
6734        }
6735        if !visible.contains(&self.selected) {
6736            self.set_selected_index(visible[0]);
6737        }
6738    }
6739
6740    fn sync_ranked_list_context(&mut self) {
6741        self.ensure_selected_visible();
6742        self.sync_insights_heatmap_selection();
6743    }
6744
6745    fn reselect_insights_panel_context(&mut self) {
6746        if !self.insights_search_query.trim().is_empty() {
6747            self.select_current_insights_search_match();
6748            self.sync_insights_heatmap_selection();
6749            return;
6750        }
6751
6752        let visible = self.insights_visible_issue_indices_for_list_nav();
6753        if visible.contains(&self.selected) {
6754            self.select_first_visible();
6755        } else {
6756            self.sync_insights_heatmap_selection();
6757        }
6758    }
6759
6760    fn reselect_ranked_list_context(&mut self) {
6761        match self.mode {
6762            ViewMode::Graph if !self.graph_search_query.trim().is_empty() => {
6763                self.select_current_graph_search_match();
6764            }
6765            ViewMode::Insights if !self.insights_search_query.trim().is_empty() => {
6766                self.select_current_insights_search_match();
6767            }
6768            _ => self.select_first_visible(),
6769        }
6770        self.sync_insights_heatmap_selection();
6771    }
6772
6773    fn move_selection_relative(&mut self, delta: isize) {
6774        let visible = self.visible_issue_indices_for_list_nav();
6775        if visible.is_empty() {
6776            return;
6777        }
6778
6779        let current_slot = self.selected_visible_slot(&visible).unwrap_or(0);
6780        let max_slot = visible.len().saturating_sub(1);
6781        let next_slot = if delta >= 0 {
6782            current_slot
6783                .saturating_add(delta.unsigned_abs())
6784                .min(max_slot)
6785        } else {
6786            current_slot.saturating_sub(delta.unsigned_abs())
6787        };
6788        self.set_selected_index(visible[next_slot]);
6789    }
6790
6791    fn list_page_step(&self) -> usize {
6792        let body_rows = usize::from(cached_view_height().saturating_sub(3));
6793        if matches!(self.mode, ViewMode::Main) {
6794            body_rows
6795                .saturating_sub(self.main_search_banner_lines().len())
6796                .max(5)
6797        } else {
6798            body_rows.saturating_sub(2).max(5)
6799        }
6800    }
6801
6802    fn select_first_visible(&mut self) {
6803        if let Some(index) = self.visible_issue_indices_for_list_nav().first().copied() {
6804            self.set_selected_index(index);
6805            self.list_scroll_offset.set(0);
6806        }
6807    }
6808
6809    fn select_last_visible(&mut self) {
6810        if let Some(index) = self.visible_issue_indices_for_list_nav().last().copied() {
6811            self.set_selected_index(index);
6812        }
6813    }
6814
6815    fn has_active_filter(&self) -> bool {
6816        self.list_filter != ListFilter::All
6817            || self.modal_label_filter.is_some()
6818            || self.modal_repo_filter.is_some()
6819    }
6820
6821    fn should_clear_filter_with_all_shortcut(&self) -> bool {
6822        self.has_active_filter() && !matches!(self.mode, ViewMode::Actionable)
6823    }
6824
6825    fn set_list_filter(&mut self, list_filter: ListFilter) {
6826        self.list_filter = list_filter;
6827        if matches!(list_filter, ListFilter::All) {
6828            self.modal_label_filter = None;
6829            self.modal_repo_filter = None;
6830        }
6831        self.list_scroll_offset.set(0);
6832        self.ensure_selected_visible();
6833        self.sync_insights_heatmap_selection();
6834        self.focus = FocusPane::List;
6835        self.rebuild_tree_if_active();
6836    }
6837
6838    /// Rebuild tree flat nodes when in Tree view so filter changes
6839    /// take effect immediately.  Clamps the cursor afterward.
6840    fn rebuild_tree_if_active(&mut self) {
6841        if matches!(self.mode, ViewMode::Tree) {
6842            self.build_tree_flat_nodes();
6843            if self.tree_cursor >= self.tree_flat_nodes.len() {
6844                self.tree_cursor = self.tree_flat_nodes.len().saturating_sub(1);
6845            }
6846        }
6847    }
6848
6849    fn cycle_list_sort(&mut self) {
6850        self.list_sort = self.list_sort.next();
6851        self.ensure_selected_visible();
6852        self.sync_insights_heatmap_selection();
6853        self.focus = FocusPane::List;
6854    }
6855
6856    fn cycle_board_grouping(&mut self) {
6857        self.board_grouping = self.board_grouping.next();
6858        self.ensure_selected_visible();
6859        self.focus = FocusPane::List;
6860    }
6861
6862    fn toggle_board_empty_visibility(&mut self) {
6863        self.board_empty_visibility = self.board_empty_visibility.next();
6864        self.ensure_selected_visible();
6865        self.focus = FocusPane::List;
6866    }
6867
6868    fn scroll_board_detail(&mut self, delta: isize) {
6869        if delta == 0 || !matches!(self.mode, ViewMode::Board) || self.focus != FocusPane::Detail {
6870            return;
6871        }
6872
6873        if delta > 0 {
6874            self.board_detail_scroll_offset = self
6875                .board_detail_scroll_offset
6876                .saturating_add(delta.unsigned_abs());
6877        } else {
6878            self.board_detail_scroll_offset = self
6879                .board_detail_scroll_offset
6880                .saturating_sub(delta.unsigned_abs());
6881        }
6882    }
6883
6884    /// Universal detail pane scroll — works in any mode when focus is Detail.
6885    fn scroll_detail(&mut self, delta: isize) {
6886        if delta == 0 || self.focus != FocusPane::Detail {
6887            return;
6888        }
6889
6890        if delta > 0 {
6891            self.detail_scroll_offset = self
6892                .detail_scroll_offset
6893                .saturating_add(delta.unsigned_abs());
6894        } else {
6895            self.detail_scroll_offset = self
6896                .detail_scroll_offset
6897                .saturating_sub(delta.unsigned_abs());
6898        }
6899    }
6900
6901    fn set_selected_index(&mut self, index: usize) {
6902        let changed = self.selected != index;
6903        self.selected = index;
6904        if changed {
6905            self.detail_dep_cursor = 0;
6906            self.detail_scroll_offset = 0;
6907            if matches!(self.mode, ViewMode::Board) {
6908                self.board_detail_scroll_offset = 0;
6909            }
6910        }
6911    }
6912
6913    fn toggle_insights_explanations(&mut self) {
6914        self.insights_show_explanations = !self.insights_show_explanations;
6915        self.focus = FocusPane::List;
6916    }
6917
6918    fn toggle_insights_calc_proof(&mut self) {
6919        self.insights_show_calc_proof = !self.insights_show_calc_proof;
6920        self.focus = FocusPane::List;
6921    }
6922
6923    fn board_lane_indices(&self) -> Vec<(String, Vec<usize>)> {
6924        let visible = self.visible_issue_indices();
6925
6926        let mut lanes = match self.board_grouping {
6927            BoardGrouping::Status => {
6928                let mut open = Vec::<usize>::new();
6929                let mut in_progress = Vec::<usize>::new();
6930                let mut blocked = Vec::<usize>::new();
6931                let mut closed = Vec::<usize>::new();
6932                let mut other = Vec::<usize>::new();
6933
6934                for index in visible {
6935                    let issue = &self.analyzer.issues[index];
6936                    if issue.is_closed_like() {
6937                        closed.push(index);
6938                    } else if issue.status.eq_ignore_ascii_case("blocked") {
6939                        blocked.push(index);
6940                    } else if issue.status.eq_ignore_ascii_case("in_progress") {
6941                        in_progress.push(index);
6942                    } else if issue.status.eq_ignore_ascii_case("open") {
6943                        open.push(index);
6944                    } else {
6945                        other.push(index);
6946                    }
6947                }
6948
6949                vec![
6950                    ("open".to_string(), open),
6951                    ("in_progress".to_string(), in_progress),
6952                    ("blocked".to_string(), blocked),
6953                    ("closed".to_string(), closed),
6954                    ("other".to_string(), other),
6955                ]
6956            }
6957            BoardGrouping::Priority => {
6958                let mut p0 = Vec::<usize>::new();
6959                let mut p1 = Vec::<usize>::new();
6960                let mut p2 = Vec::<usize>::new();
6961                let mut p3_plus = Vec::<usize>::new();
6962
6963                for index in visible {
6964                    let issue = &self.analyzer.issues[index];
6965                    match issue.priority {
6966                        0 => p0.push(index),
6967                        1 => p1.push(index),
6968                        2 => p2.push(index),
6969                        _ => p3_plus.push(index),
6970                    }
6971                }
6972
6973                vec![
6974                    ("p0".to_string(), p0),
6975                    ("p1".to_string(), p1),
6976                    ("p2".to_string(), p2),
6977                    ("p3+".to_string(), p3_plus),
6978                ]
6979            }
6980            BoardGrouping::Type => {
6981                let mut by_type = std::collections::BTreeMap::<String, Vec<usize>>::new();
6982                for index in visible {
6983                    let issue = &self.analyzer.issues[index];
6984                    let key = if issue.issue_type.trim().is_empty() {
6985                        "unknown".to_string()
6986                    } else {
6987                        issue.issue_type.to_lowercase()
6988                    };
6989                    by_type.entry(key).or_default().push(index);
6990                }
6991                by_type.into_iter().collect()
6992            }
6993        };
6994
6995        if !self
6996            .board_empty_visibility
6997            .should_show_empty(self.board_grouping)
6998        {
6999            lanes.retain(|(_, indices)| !indices.is_empty());
7000        }
7001
7002        lanes
7003    }
7004
7005    fn select_first_in_board_lane(&mut self, lane_position: usize) {
7006        if !matches!(self.mode, ViewMode::Board) || lane_position == 0 {
7007            return;
7008        }
7009
7010        if let Some((_, indices)) = self.board_lane_indices().get(lane_position - 1)
7011            && let Some(index) = indices.first().copied()
7012        {
7013            self.set_selected_index(index);
7014        }
7015    }
7016
7017    fn current_board_lane_slot(&self) -> Option<usize> {
7018        let lanes = self.board_lane_indices();
7019        lanes
7020            .iter()
7021            .position(|(_, indices)| indices.contains(&self.selected))
7022            .or_else(|| lanes.iter().position(|(_, indices)| !indices.is_empty()))
7023    }
7024
7025    fn select_first_in_non_empty_board_lane(&mut self) {
7026        if !matches!(self.mode, ViewMode::Board) {
7027            return;
7028        }
7029
7030        if let Some((_, indices)) = self
7031            .board_lane_indices()
7032            .into_iter()
7033            .find(|(_, indices)| !indices.is_empty())
7034            && let Some(index) = indices.first().copied()
7035        {
7036            self.set_selected_index(index);
7037        }
7038    }
7039
7040    fn select_last_in_non_empty_board_lane(&mut self) {
7041        if !matches!(self.mode, ViewMode::Board) {
7042            return;
7043        }
7044
7045        if let Some((_, indices)) = self
7046            .board_lane_indices()
7047            .into_iter()
7048            .rev()
7049            .find(|(_, indices)| !indices.is_empty())
7050            && let Some(index) = indices.last().copied()
7051        {
7052            self.set_selected_index(index);
7053        }
7054    }
7055
7056    fn move_board_lane_relative(&mut self, delta: isize) {
7057        if !matches!(self.mode, ViewMode::Board)
7058            || !matches!(self.focus, FocusPane::List | FocusPane::Detail)
7059            || delta == 0
7060        {
7061            return;
7062        }
7063
7064        let lanes = self.board_lane_indices();
7065        if lanes.is_empty() {
7066            return;
7067        }
7068
7069        let Some(current_lane_slot) = self.current_board_lane_slot() else {
7070            return;
7071        };
7072
7073        let current_row = lanes
7074            .get(current_lane_slot)
7075            .and_then(|(_, indices)| indices.iter().position(|index| *index == self.selected))
7076            .unwrap_or(0);
7077
7078        let lane_count = isize::try_from(lanes.len()).unwrap_or(0);
7079        let mut target_lane_slot = isize::try_from(current_lane_slot).unwrap_or(0) + delta.signum();
7080
7081        while target_lane_slot >= 0 && target_lane_slot < lane_count {
7082            let slot = usize::try_from(target_lane_slot).unwrap_or(0);
7083            if let Some((_, indices)) = lanes.get(slot)
7084                && !indices.is_empty()
7085            {
7086                let target_row = current_row.min(indices.len().saturating_sub(1));
7087                self.set_selected_index(indices[target_row]);
7088                return;
7089            }
7090            target_lane_slot += delta.signum();
7091        }
7092    }
7093
7094    fn move_board_row_relative(&mut self, delta: isize) {
7095        if !matches!(self.mode, ViewMode::Board)
7096            || !matches!(self.focus, FocusPane::List | FocusPane::Detail)
7097            || delta == 0
7098        {
7099            return;
7100        }
7101
7102        if self.focus == FocusPane::Detail && !self.detail_dep_list().is_empty() {
7103            self.move_detail_dep_relative(delta);
7104            return;
7105        }
7106
7107        let lanes = self.board_lane_indices();
7108        let Some(lane_slot) = self.current_board_lane_slot() else {
7109            return;
7110        };
7111        let Some((_, indices)) = lanes.get(lane_slot) else {
7112            return;
7113        };
7114        if indices.is_empty() {
7115            return;
7116        }
7117
7118        let current_row = indices
7119            .iter()
7120            .position(|index| *index == self.selected)
7121            .unwrap_or(0);
7122        let max_row = indices.len().saturating_sub(1);
7123        let next_row = if delta >= 0 {
7124            current_row
7125                .saturating_add(delta.unsigned_abs())
7126                .min(max_row)
7127        } else {
7128            current_row.saturating_sub(delta.unsigned_abs())
7129        };
7130
7131        self.set_selected_index(indices[next_row]);
7132    }
7133
7134    fn start_board_search(&mut self) {
7135        if !matches!(self.mode, ViewMode::Board)
7136            || !matches!(self.focus, FocusPane::List | FocusPane::Detail)
7137        {
7138            return;
7139        }
7140
7141        self.board_search_active = true;
7142        self.board_search_query.clear();
7143        self.board_search_match_cursor = 0;
7144    }
7145
7146    fn finish_board_search(&mut self) {
7147        self.board_search_active = false;
7148    }
7149
7150    fn cancel_board_search(&mut self) {
7151        self.board_search_active = false;
7152        self.board_search_query.clear();
7153        self.board_search_match_cursor = 0;
7154    }
7155
7156    fn board_search_matches(&self) -> Vec<usize> {
7157        let query = self.board_search_query.trim().to_ascii_lowercase();
7158        if query.is_empty() {
7159            return Vec::new();
7160        }
7161
7162        self.board_visible_issue_indices_in_display_order()
7163            .into_iter()
7164            .filter(|index| {
7165                self.analyzer.issues.get(*index).is_some_and(|issue| {
7166                    issue.id.to_ascii_lowercase().contains(&query)
7167                        || issue.title.to_ascii_lowercase().contains(&query)
7168                        || issue.status.to_ascii_lowercase().contains(&query)
7169                        || issue.issue_type.to_ascii_lowercase().contains(&query)
7170                        || issue
7171                            .labels
7172                            .iter()
7173                            .any(|label| label.to_ascii_lowercase().contains(&query))
7174                })
7175            })
7176            .collect()
7177    }
7178
7179    fn select_current_board_search_match(&mut self) {
7180        let matches = self.board_search_matches();
7181        if matches.is_empty() {
7182            return;
7183        }
7184
7185        self.board_search_match_cursor = self
7186            .board_search_match_cursor
7187            .min(matches.len().saturating_sub(1));
7188        self.set_selected_index(matches[self.board_search_match_cursor]);
7189    }
7190
7191    fn move_board_search_match_relative(&mut self, delta: isize) {
7192        let matches = self.board_search_matches();
7193        if matches.is_empty() || delta == 0 {
7194            return;
7195        }
7196
7197        let len = matches.len();
7198        let current = self.board_search_match_cursor.min(len.saturating_sub(1));
7199        let step = delta.unsigned_abs() % len;
7200        let next = if delta > 0 {
7201            (current + step) % len
7202        } else {
7203            (current + len - step) % len
7204        };
7205
7206        self.board_search_match_cursor = next;
7207        self.set_selected_index(matches[next]);
7208    }
7209
7210    // ── Graph search ──────────────────────────────────────────
7211
7212    fn start_graph_search(&mut self) {
7213        if !matches!(self.mode, ViewMode::Graph)
7214            || !matches!(self.focus, FocusPane::List | FocusPane::Detail)
7215        {
7216            return;
7217        }
7218
7219        self.focus = FocusPane::List;
7220        self.graph_search_active = true;
7221        self.graph_search_query.clear();
7222        self.graph_search_match_cursor = 0;
7223    }
7224
7225    fn finish_graph_search(&mut self) {
7226        self.graph_search_active = false;
7227    }
7228
7229    fn cancel_graph_search(&mut self) {
7230        self.graph_search_active = false;
7231        self.graph_search_query.clear();
7232        self.graph_search_match_cursor = 0;
7233    }
7234
7235    fn graph_search_matches(&self) -> Vec<usize> {
7236        let query = self.graph_search_query.trim().to_ascii_lowercase();
7237        if query.is_empty() {
7238            return Vec::new();
7239        }
7240        self.graph_visible_issue_indices()
7241            .into_iter()
7242            .filter(|&index| {
7243                let issue = &self.analyzer.issues[index];
7244                issue.id.to_ascii_lowercase().contains(&query)
7245                    || issue.title.to_ascii_lowercase().contains(&query)
7246            })
7247            .collect()
7248    }
7249
7250    fn select_current_graph_search_match(&mut self) {
7251        let matches = self.graph_search_matches();
7252        if matches.is_empty() {
7253            return;
7254        }
7255
7256        if let Some(current) = matches.iter().position(|&index| index == self.selected) {
7257            self.graph_search_match_cursor = current;
7258            self.set_selected_index(matches[current]);
7259            return;
7260        }
7261
7262        self.graph_search_match_cursor = self
7263            .graph_search_match_cursor
7264            .min(matches.len().saturating_sub(1));
7265        self.set_selected_index(matches[self.graph_search_match_cursor]);
7266    }
7267
7268    fn move_graph_search_match_relative(&mut self, delta: isize) {
7269        let matches = self.graph_search_matches();
7270        if matches.is_empty() || delta == 0 {
7271            return;
7272        }
7273
7274        let len = matches.len();
7275        let current = self.graph_search_match_cursor.min(len.saturating_sub(1));
7276        let step = delta.unsigned_abs() % len;
7277        let next = if delta > 0 {
7278            (current + step) % len
7279        } else {
7280            (current + len - step) % len
7281        };
7282
7283        self.graph_search_match_cursor = next;
7284        self.set_selected_index(matches[next]);
7285    }
7286
7287    fn issue_index_for_id(&self, issue_id: &str) -> Option<usize> {
7288        self.analyzer
7289            .issues
7290            .iter()
7291            .position(|issue| issue.id == issue_id)
7292    }
7293
7294    fn insights_visible_issue_indices_for_list_nav(&self) -> Vec<usize> {
7295        if let Some(state) = self.insights_heatmap.as_ref() {
7296            let data = self.insights_heatmap_data();
7297            let row = state
7298                .row
7299                .min(INSIGHTS_HEATMAP_DEPTH_LABELS.len().saturating_sub(1));
7300            let col = state
7301                .col
7302                .min(INSIGHTS_HEATMAP_SCORE_LABELS.len().saturating_sub(1));
7303            return data.issue_ids[row][col]
7304                .iter()
7305                .filter_map(|issue_id| self.issue_index_for_id(issue_id))
7306                .collect();
7307        }
7308
7309        let insights = self.analyzer.insights();
7310        let ids = match self.insights_panel {
7311            InsightsPanel::Bottlenecks => insights
7312                .bottlenecks
7313                .iter()
7314                .map(|item| item.id.clone())
7315                .collect::<Vec<_>>(),
7316            InsightsPanel::Keystones => {
7317                let mut keystones = self
7318                    .analyzer
7319                    .issues
7320                    .iter()
7321                    .filter(|issue| issue.is_open_like())
7322                    .filter_map(|issue| {
7323                        self.analyzer
7324                            .metrics
7325                            .critical_depth
7326                            .get(&issue.id)
7327                            .copied()
7328                            .map(|depth| (issue.id.as_str(), depth))
7329                    })
7330                    .filter(|(_, depth)| *depth > 0)
7331                    .collect::<Vec<_>>();
7332                keystones
7333                    .sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(right.0)));
7334                keystones
7335                    .into_iter()
7336                    .map(|(id, _)| id.to_string())
7337                    .collect::<Vec<_>>()
7338            }
7339            InsightsPanel::CriticalPath => insights.critical_path.clone(),
7340            InsightsPanel::Influencers => insights
7341                .influencers
7342                .iter()
7343                .map(|item| item.id.clone())
7344                .collect::<Vec<_>>(),
7345            InsightsPanel::Betweenness => insights
7346                .betweenness
7347                .iter()
7348                .map(|item| item.id.clone())
7349                .collect::<Vec<_>>(),
7350            InsightsPanel::Hubs => insights
7351                .hubs
7352                .iter()
7353                .map(|item| item.id.clone())
7354                .collect::<Vec<_>>(),
7355            InsightsPanel::Authorities => insights
7356                .authorities
7357                .iter()
7358                .map(|item| item.id.clone())
7359                .collect::<Vec<_>>(),
7360            InsightsPanel::Cores => insights
7361                .cores
7362                .iter()
7363                .map(|item| item.id.clone())
7364                .collect::<Vec<_>>(),
7365            InsightsPanel::CutPoints => insights.articulation_points.clone(),
7366            InsightsPanel::Slack => insights.slack.clone(),
7367            InsightsPanel::Priority => self
7368                .analyzer
7369                .priority(0.0, 15, None, None)
7370                .into_iter()
7371                .map(|item| item.id)
7372                .collect::<Vec<_>>(),
7373            InsightsPanel::Cycles => Vec::new(),
7374        };
7375
7376        let indices = ids
7377            .iter()
7378            .filter_map(|issue_id| self.issue_index_for_id(issue_id))
7379            .collect::<Vec<_>>();
7380        if indices.is_empty() {
7381            self.visible_issue_indices()
7382        } else {
7383            indices
7384        }
7385    }
7386
7387    // ── Insights search ──────────────────────────────────────────
7388
7389    fn start_insights_search(&mut self) {
7390        if !matches!(self.mode, ViewMode::Insights)
7391            || !matches!(self.focus, FocusPane::List | FocusPane::Detail)
7392        {
7393            return;
7394        }
7395
7396        self.focus = FocusPane::List;
7397        self.insights_search_active = true;
7398        self.insights_search_query.clear();
7399        self.insights_search_match_cursor = 0;
7400    }
7401
7402    fn finish_insights_search(&mut self) {
7403        self.insights_search_active = false;
7404    }
7405
7406    fn cancel_insights_search(&mut self) {
7407        self.insights_search_active = false;
7408        self.insights_search_query.clear();
7409        self.insights_search_match_cursor = 0;
7410    }
7411
7412    fn insights_search_matches(&self) -> Vec<usize> {
7413        let query = self.insights_search_query.trim().to_ascii_lowercase();
7414        if query.is_empty() {
7415            return Vec::new();
7416        }
7417        self.insights_visible_issue_indices_for_list_nav()
7418            .into_iter()
7419            .filter(|&index| {
7420                let issue = &self.analyzer.issues[index];
7421                issue.id.to_ascii_lowercase().contains(&query)
7422                    || issue.title.to_ascii_lowercase().contains(&query)
7423            })
7424            .collect()
7425    }
7426
7427    fn select_current_insights_search_match(&mut self) {
7428        let matches = self.insights_search_matches();
7429        if matches.is_empty() {
7430            return;
7431        }
7432
7433        if let Some(current) = matches.iter().position(|&index| index == self.selected) {
7434            self.insights_search_match_cursor = current;
7435            self.set_selected_index(matches[current]);
7436            return;
7437        }
7438
7439        self.insights_search_match_cursor = self
7440            .insights_search_match_cursor
7441            .min(matches.len().saturating_sub(1));
7442        self.set_selected_index(matches[self.insights_search_match_cursor]);
7443    }
7444
7445    fn move_insights_search_match_relative(&mut self, delta: isize) {
7446        let matches = self.insights_search_matches();
7447        if matches.is_empty() || delta == 0 {
7448            return;
7449        }
7450
7451        let len = matches.len();
7452        let current = self.insights_search_match_cursor.min(len.saturating_sub(1));
7453        let step = delta.unsigned_abs() % len;
7454        let next = if delta > 0 {
7455            (current + step) % len
7456        } else {
7457            (current + len - step) % len
7458        };
7459
7460        self.insights_search_match_cursor = next;
7461        self.set_selected_index(matches[next]);
7462    }
7463
7464    // ── Main (issues list) search ──────────────────────────────
7465
7466    fn start_main_search(&mut self) {
7467        if !matches!(self.mode, ViewMode::Main) || self.focus != FocusPane::List {
7468            return;
7469        }
7470
7471        self.main_search_active = true;
7472        self.main_search_query.clear();
7473        self.main_search_match_cursor = 0;
7474    }
7475
7476    fn finish_main_search(&mut self) {
7477        self.main_search_active = false;
7478    }
7479
7480    fn cancel_main_search(&mut self) {
7481        self.main_search_active = false;
7482        self.main_search_query.clear();
7483        self.main_search_match_cursor = 0;
7484    }
7485
7486    fn main_search_matches(&self) -> Vec<usize> {
7487        let query = self.main_search_query.trim().to_ascii_lowercase();
7488        if query.is_empty() {
7489            return Vec::new();
7490        }
7491        self.visible_issue_indices()
7492            .into_iter()
7493            .filter(|&index| {
7494                let issue = &self.analyzer.issues[index];
7495                issue.id.to_ascii_lowercase().contains(&query)
7496                    || issue.title.to_ascii_lowercase().contains(&query)
7497                    || issue.status.to_ascii_lowercase().contains(&query)
7498                    || issue.issue_type.to_ascii_lowercase().contains(&query)
7499                    || issue.description.to_ascii_lowercase().contains(&query)
7500                    || issue.notes.to_ascii_lowercase().contains(&query)
7501                    || issue.design.to_ascii_lowercase().contains(&query)
7502                    || issue
7503                        .acceptance_criteria
7504                        .to_ascii_lowercase()
7505                        .contains(&query)
7506                    || issue.assignee.to_ascii_lowercase().contains(&query)
7507                    || issue
7508                        .labels
7509                        .iter()
7510                        .any(|label| label.to_ascii_lowercase().contains(&query))
7511            })
7512            .collect()
7513    }
7514
7515    fn select_current_main_search_match(&mut self) {
7516        let matches = self.main_search_matches();
7517        if matches.is_empty() {
7518            return;
7519        }
7520
7521        self.main_search_match_cursor = self
7522            .main_search_match_cursor
7523            .min(matches.len().saturating_sub(1));
7524        self.set_selected_index(matches[self.main_search_match_cursor]);
7525    }
7526
7527    fn move_main_search_match_relative(&mut self, delta: isize) {
7528        let matches = self.main_search_matches();
7529        if matches.is_empty() || delta == 0 {
7530            return;
7531        }
7532
7533        let len = matches.len();
7534        let current = self.main_search_match_cursor.min(len.saturating_sub(1));
7535        let step = delta.unsigned_abs() % len;
7536        let next = if delta > 0 {
7537            (current + step) % len
7538        } else {
7539            (current + len - step) % len
7540        };
7541
7542        self.main_search_match_cursor = next;
7543        self.set_selected_index(matches[next]);
7544    }
7545
7546    fn select_edge_in_current_board_lane(&mut self, select_last: bool) {
7547        if !matches!(self.mode, ViewMode::Board) {
7548            return;
7549        }
7550
7551        let lanes = self.board_lane_indices();
7552        let Some(lane_slot) = self.current_board_lane_slot() else {
7553            return;
7554        };
7555
7556        let Some((_, indices)) = lanes.get(lane_slot) else {
7557            return;
7558        };
7559
7560        let candidate = if select_last {
7561            indices.last().copied()
7562        } else {
7563            indices.first().copied()
7564        };
7565
7566        if let Some(index) = candidate {
7567            self.set_selected_index(index);
7568        }
7569    }
7570
7571    fn board_visible_issue_indices_in_display_order(&self) -> Vec<usize> {
7572        self.board_lane_indices()
7573            .into_iter()
7574            .flat_map(|(_, indices)| indices)
7575            .collect()
7576    }
7577
7578    fn issue_diff_tag(&self, issue_id: &str) -> Option<DiffTag> {
7579        let diff = self.time_travel_diff.as_ref()?;
7580        if diff
7581            .new_issues
7582            .as_ref()
7583            .is_some_and(|v| v.iter().any(|d| d.id == issue_id))
7584        {
7585            return Some(DiffTag::New);
7586        }
7587        if diff
7588            .reopened_issues
7589            .as_ref()
7590            .is_some_and(|v| v.iter().any(|d| d.id == issue_id))
7591        {
7592            return Some(DiffTag::Reopened);
7593        }
7594        if diff
7595            .modified_issues
7596            .as_ref()
7597            .is_some_and(|v| v.iter().any(|m| m.issue_id == issue_id))
7598        {
7599            return Some(DiffTag::Modified);
7600        }
7601        if diff
7602            .closed_issues
7603            .as_ref()
7604            .is_some_and(|v| v.iter().any(|d| d.id == issue_id))
7605        {
7606            return Some(DiffTag::Closed);
7607        }
7608        None
7609    }
7610
7611    fn selected_issue(&self) -> Option<&Issue> {
7612        let visible = self.visible_issue_indices_for_list_nav();
7613        if visible.is_empty() {
7614            return None;
7615        }
7616        let index = self
7617            .selected_visible_slot(&visible)
7618            .map_or(visible[0], |_| self.selected);
7619        self.analyzer.issues.get(index)
7620    }
7621
7622    fn selected_issue_external_ref_url(&self) -> Option<&str> {
7623        self.selected_issue()
7624            .and_then(|issue| issue.external_ref.as_deref())
7625            .filter(|url| is_http_url(url))
7626    }
7627
7628    fn main_footer_command_hints(&self) -> Vec<CommandHint<'static>> {
7629        let mut hints = vec![
7630            CommandHint {
7631                key: "b/i/g/h",
7632                desc: "modes",
7633            },
7634            CommandHint {
7635                key: "/",
7636                desc: "search",
7637            },
7638            CommandHint {
7639                key: "s",
7640                desc: self.list_sort.label(),
7641            },
7642            CommandHint {
7643                key: "p",
7644                desc: "hints",
7645            },
7646            CommandHint {
7647                key: "C",
7648                desc: "copy",
7649            },
7650        ];
7651        if matches!(self.focus, FocusPane::Detail) {
7652            if self.selected_issue_external_ref_url().is_some() {
7653                hints.push(CommandHint {
7654                    key: "o",
7655                    desc: "open link",
7656                });
7657                hints.push(CommandHint {
7658                    key: "y",
7659                    desc: "copy link",
7660                });
7661            }
7662            hints.push(CommandHint {
7663                key: "^j/k",
7664                desc: "scroll",
7665            });
7666        }
7667        hints.extend([
7668            CommandHint {
7669                key: "x",
7670                desc: "export",
7671            },
7672            CommandHint {
7673                key: "O",
7674                desc: "edit",
7675            },
7676            CommandHint {
7677                key: "^←/→",
7678                desc: "resize",
7679            },
7680            CommandHint {
7681                key: "^0",
7682                desc: "reset split",
7683            },
7684        ]);
7685        hints
7686    }
7687
7688    fn graph_footer_command_hints(&self) -> Vec<CommandHint<'static>> {
7689        let mut hints = match self.focus {
7690            FocusPane::List => vec![
7691                CommandHint {
7692                    key: "h/l",
7693                    desc: "nodes",
7694                },
7695                CommandHint {
7696                    key: "j/k",
7697                    desc: "nodes",
7698                },
7699                CommandHint {
7700                    key: "H/L",
7701                    desc: "jump",
7702                },
7703                CommandHint {
7704                    key: "Tab",
7705                    desc: "detail",
7706                },
7707                CommandHint {
7708                    key: "/",
7709                    desc: "search",
7710                },
7711                CommandHint {
7712                    key: "Enter",
7713                    desc: "open details",
7714                },
7715            ],
7716            FocusPane::Detail | FocusPane::Middle => {
7717                let mut hints = vec![
7718                    CommandHint {
7719                        key: "h/Tab",
7720                        desc: "list",
7721                    },
7722                    CommandHint {
7723                        key: "Enter",
7724                        desc: "open details",
7725                    },
7726                ];
7727                if !self.detail_dep_list().is_empty() {
7728                    hints.push(CommandHint {
7729                        key: "j/k",
7730                        desc: "deps",
7731                    });
7732                }
7733                hints.push(CommandHint {
7734                    key: "^j/k",
7735                    desc: "scroll",
7736                });
7737                hints
7738            }
7739        };
7740        if self.selected_issue_external_ref_url().is_some()
7741            && matches!(self.focus, FocusPane::Detail)
7742        {
7743            hints.push(CommandHint {
7744                key: "o",
7745                desc: "open link",
7746            });
7747            hints.push(CommandHint {
7748                key: "y",
7749                desc: "copy link",
7750            });
7751        }
7752        hints.push(CommandHint {
7753            key: "g/Esc",
7754            desc: "back",
7755        });
7756        hints.push(CommandHint {
7757            key: "^←/→",
7758            desc: "resize",
7759        });
7760        hints.push(CommandHint {
7761            key: "^0",
7762            desc: "reset split",
7763        });
7764        hints
7765    }
7766
7767    fn should_open_selected_issue_external_ref(&self) -> bool {
7768        matches!(
7769            self.mode,
7770            ViewMode::Main | ViewMode::Board | ViewMode::Insights | ViewMode::Graph
7771        ) && matches!(self.focus, FocusPane::Detail)
7772            && self.selected_issue_external_ref_url().is_some()
7773    }
7774
7775    fn should_copy_selected_issue_external_ref(&self) -> bool {
7776        matches!(
7777            self.mode,
7778            ViewMode::Main | ViewMode::Board | ViewMode::Insights | ViewMode::Graph
7779        ) && matches!(self.focus, FocusPane::Detail)
7780            && self.selected_issue_external_ref_url().is_some()
7781    }
7782
7783    fn open_selected_issue_external_ref(&mut self) {
7784        let Some(url) = self.selected_issue_external_ref_url().map(str::to_string) else {
7785            self.status_msg = "No external issue reference".into();
7786            return;
7787        };
7788
7789        if open_url_in_browser(&url) {
7790            self.status_msg = "Opened external issue reference".into();
7791        } else {
7792            self.status_msg = "Could not open browser".into();
7793        }
7794    }
7795
7796    fn copy_selected_issue_external_ref(&mut self) {
7797        let Some(url) = self.selected_issue_external_ref_url().map(str::to_string) else {
7798            self.status_msg = "No external issue reference".into();
7799            return;
7800        };
7801
7802        if copy_text_to_clipboard(&url) {
7803            self.status_msg = "Copied external issue reference to clipboard".into();
7804        } else {
7805            self.status_msg = "Clipboard not available".into();
7806        }
7807    }
7808
7809    fn current_detail_link_row_area(&self) -> Option<Rect> {
7810        let area = cached_detail_content_area();
7811        if area.width == 0 || area.height == 0 {
7812            return None;
7813        }
7814
7815        let (detail_text, line_index, scroll_offset) = match self.mode {
7816            ViewMode::Main => {
7817                let detail_text = self.issue_detail_render_text();
7818                let line_index = detail_text.lines().iter().position(|line| {
7819                    ftui::text::Line::spans(line)
7820                        .iter()
7821                        .any(|span| span.link.is_some())
7822                })?;
7823                (
7824                    detail_text,
7825                    line_index,
7826                    usize::from(saturating_scroll_offset(self.detail_scroll_offset)),
7827                )
7828            }
7829            ViewMode::Board => {
7830                let detail_text = self.board_detail_render_text();
7831                let line_index = detail_text.lines().iter().position(|line| {
7832                    ftui::text::Line::spans(line)
7833                        .iter()
7834                        .any(|span| span.link.is_some())
7835                })?;
7836                (detail_text, line_index, self.board_detail_scroll_offset)
7837            }
7838            ViewMode::Insights => {
7839                let detail_text = self.insights_detail_render_text();
7840                let line_index = detail_text.lines().iter().position(|line| {
7841                    ftui::text::Line::spans(line)
7842                        .iter()
7843                        .any(|span| span.link.is_some())
7844                })?;
7845                (
7846                    detail_text,
7847                    line_index,
7848                    usize::from(saturating_scroll_offset(self.detail_scroll_offset)),
7849                )
7850            }
7851            ViewMode::Graph => {
7852                let detail_text = self.graph_detail_render_text();
7853                let line_index = detail_text.lines().iter().position(|line| {
7854                    ftui::text::Line::spans(line)
7855                        .iter()
7856                        .any(|span| span.link.is_some())
7857                })?;
7858                (
7859                    detail_text,
7860                    line_index,
7861                    usize::from(saturating_scroll_offset(self.detail_scroll_offset)),
7862                )
7863            }
7864            ViewMode::History => {
7865                self.history_selected_commit_url()?;
7866                let detail_text = self.history_detail_render_text();
7867                let line_index = self.history_detail_text().lines().count().saturating_add(1);
7868                (detail_text, line_index, 0)
7869            }
7870            _ => return None,
7871        };
7872        let line = detail_text.lines().get(line_index)?;
7873        let line_width = display_width(&line.to_plain_text());
7874        if line_width == 0 {
7875            return None;
7876        }
7877        if line_index < scroll_offset {
7878            return None;
7879        }
7880
7881        let width = u16::try_from(line_width)
7882            .unwrap_or(u16::MAX)
7883            .min(area.width);
7884        let visible_line_index = line_index.saturating_sub(scroll_offset);
7885        let y = area
7886            .y
7887            .saturating_add(saturating_scroll_offset(visible_line_index));
7888        if width == 0 || y >= area.y.saturating_add(area.height) {
7889            return None;
7890        }
7891
7892        Some(Rect::new(area.x, y, width, 1))
7893    }
7894
7895    fn detail_link_hit(&self, x: u16, y: u16) -> bool {
7896        if !matches!(self.focus, FocusPane::Detail) {
7897            return false;
7898        }
7899
7900        let Some(link_area) = self.current_detail_link_row_area() else {
7901            return false;
7902        };
7903        if !rect_contains(link_area, x, y) {
7904            return false;
7905        }
7906
7907        match self.mode {
7908            ViewMode::Main | ViewMode::Board | ViewMode::Insights | ViewMode::Graph => {
7909                self.selected_issue_external_ref_url().is_some()
7910            }
7911            ViewMode::History => self.history_selected_commit_url().is_some(),
7912            _ => false,
7913        }
7914    }
7915
7916    fn mouse_open_detail_link(&mut self, x: u16, y: u16) -> bool {
7917        if !self.detail_link_hit(x, y) {
7918            return false;
7919        }
7920
7921        match self.mode {
7922            ViewMode::Main | ViewMode::Board | ViewMode::Insights | ViewMode::Graph => {
7923                self.open_selected_issue_external_ref();
7924            }
7925            ViewMode::History => self.history_open_in_browser(),
7926            _ => return false,
7927        }
7928
7929        true
7930    }
7931
7932    fn mouse_copy_detail_link(&mut self, x: u16, y: u16) -> bool {
7933        if !self.detail_link_hit(x, y) {
7934            return false;
7935        }
7936
7937        match self.mode {
7938            ViewMode::Main | ViewMode::Board | ViewMode::Insights | ViewMode::Graph => {
7939                self.copy_selected_issue_external_ref();
7940                true
7941            }
7942            ViewMode::History => {
7943                self.history_copy_commit_url();
7944                true
7945            }
7946            _ => false,
7947        }
7948    }
7949
7950    fn issue_by_id(&self, issue_id: &str) -> Option<&Issue> {
7951        self.analyzer
7952            .issues
7953            .iter()
7954            .find(|issue| issue.id == issue_id)
7955    }
7956
7957    fn select_issue_by_id(&mut self, issue_id: &str) {
7958        if let Some(index) = self
7959            .analyzer
7960            .issues
7961            .iter()
7962            .position(|issue| issue.id == issue_id)
7963        {
7964            self.set_selected_index(index);
7965            self.ensure_selected_visible();
7966        }
7967    }
7968
7969    fn no_filtered_issues_text(&self, context: &str) -> String {
7970        format!(
7971            "No issues match the active filter ({}) in {context}.",
7972            self.list_filter.label()
7973        )
7974    }
7975
7976    fn handle_pages_wizard_key(&mut self, code: KeyCode, mut wiz: PagesWizardState) -> Cmd<Msg> {
7977        match code {
7978            KeyCode::Escape => {
7979                self.modal_overlay = None;
7980                return Cmd::None;
7981            }
7982            KeyCode::Backspace if wiz.step == 0 && !wiz.export_dir.is_empty() => {
7983                wiz.export_dir.pop();
7984            }
7985            KeyCode::Backspace if wiz.step == 1 && !wiz.title.is_empty() => {
7986                wiz.title.pop();
7987            }
7988            KeyCode::Backspace if wiz.step > 0 => {
7989                wiz.step -= 1;
7990            }
7991            KeyCode::Char('c') if wiz.step == 2 => {
7992                wiz.include_closed = !wiz.include_closed;
7993            }
7994            KeyCode::Char('h') if wiz.step == 2 => {
7995                wiz.include_history = !wiz.include_history;
7996            }
7997            KeyCode::Char(ch) if wiz.step == 0 => {
7998                wiz.export_dir.push(ch);
7999            }
8000            KeyCode::Char(ch) if wiz.step == 1 => {
8001                wiz.title.push(ch);
8002            }
8003            KeyCode::Enter => {
8004                if wiz.step >= PagesWizardState::step_count() - 1 {
8005                    self.open_confirm_with_resume(
8006                        "Export Pages?",
8007                        "Finalize the current pages export settings?\n\nThis uses the reusable confirmation modal without changing the quit flow.",
8008                        Some(ModalOverlay::PagesWizard(wiz)),
8009                    );
8010                    return Cmd::None;
8011                }
8012                wiz.step += 1;
8013            }
8014            _ => {}
8015        }
8016        self.modal_overlay = Some(ModalOverlay::PagesWizard(wiz));
8017        Cmd::None
8018    }
8019
8020    fn pages_wizard_text(wiz: &PagesWizardState) -> String {
8021        match wiz.step {
8022            0 => format!(
8023                "Export directory: {}\n\n\
8024                 Type a path and press Enter to continue.\n\
8025                 (Default: ./bv-pages)",
8026                wiz.export_dir
8027            ),
8028            1 => format!(
8029                "Page title: {}\n\n\
8030                 Type a custom title and press Enter to continue.\n\
8031                 (Leave blank for default: \"Project Issues\")",
8032                if wiz.title.is_empty() {
8033                    "(default)"
8034                } else {
8035                    &wiz.title
8036                }
8037            ),
8038            2 => format!(
8039                "Options:\n\n\
8040                 [{}] Include closed issues     (toggle: c)\n\
8041                 [{}] Include history payload    (toggle: h)\n\n\
8042                 Press Enter to continue.",
8043                if wiz.include_closed { "x" } else { " " },
8044                if wiz.include_history { "x" } else { " " },
8045            ),
8046            3 => {
8047                let title_display = if wiz.title.is_empty() {
8048                    "Project Issues"
8049                } else {
8050                    &wiz.title
8051                };
8052                format!(
8053                    "Review:\n\n\
8054                     Directory:       {}\n\
8055                     Title:           {title_display}\n\
8056                     Include closed:  {}\n\
8057                     Include history: {}\n\n\
8058                     Press Enter to export, Esc to cancel.",
8059                    wiz.export_dir,
8060                    if wiz.include_closed { "yes" } else { "no" },
8061                    if wiz.include_history { "yes" } else { "no" },
8062                )
8063            }
8064            _ => String::new(),
8065        }
8066    }
8067
8068    fn help_overlay_text(&self, width: usize) -> String {
8069        // Define keybinding sections.
8070        struct Section {
8071            title: &'static str,
8072            bindings: Vec<(&'static str, &'static str)>,
8073        }
8074
8075        let sections = vec![
8076            Section {
8077                title: "Navigation",
8078                bindings: vec![
8079                    ("j/k", "Move selection up/down"),
8080                    ("arrows", "Move selection up/down"),
8081                    ("h/l", "Lateral nav (lanes, peers)"),
8082                    ("Ctrl+d/u", "Jump down/up by 10"),
8083                    ("Ctrl+j/k", "Scroll detail pane"),
8084                    ("Ctrl+←/→", "Resize active pane split"),
8085                    ("Ctrl+0", "Reset pane splits"),
8086                    ("PgUp/PgDn", "Jump by 10"),
8087                    ("Home/End", "Jump to top/bottom"),
8088                    ("gg", "Jump to top (any view)"),
8089                    ("G", "Jump to bottom"),
8090                    ("Ctrl+f/b", "Full page down/up"),
8091                    ("Tab / Shift+Tab", "Toggle focus forward/back"),
8092                    ("J/K", "Navigate deps in detail"),
8093                    ("Enter", "Return to main / drill"),
8094                    ("scroll", "Mouse wheel scrolls list"),
8095                    ("splitter click/scroll", "Mouse-resize active divider"),
8096                ],
8097            },
8098            Section {
8099                title: "Views",
8100                bindings: vec![
8101                    ("a", "Toggle actionable mode"),
8102                    ("b", "Toggle board mode"),
8103                    ("i", "Toggle insights mode"),
8104                    ("g", "Toggle graph mode"),
8105                    ("h", "Toggle history mode"),
8106                    ("!", "Toggle attention mode"),
8107                    ("T", "Toggle tree view"),
8108                    ("[", "Toggle label dashboard"),
8109                    ("]", "Toggle flow matrix"),
8110                    ("v", "History: bead/git toggle"),
8111                ],
8112            },
8113            Section {
8114                title: "Filters",
8115                bindings: vec![
8116                    ("o", "Filter: open only"),
8117                    ("c", "Filter: closed only"),
8118                    ("r", "Filter: ready only"),
8119                    ("s", "Cycle sort/grouping/panel"),
8120                ],
8121            },
8122            Section {
8123                title: "Search",
8124                bindings: vec![
8125                    ("/", "Start search"),
8126                    ("n/N", "Next/prev search match"),
8127                    ("Tab", "Cycle search mode (in /)"),
8128                    ("Esc", "Cancel search"),
8129                    ("Enter", "Confirm search"),
8130                ],
8131            },
8132            Section {
8133                title: "Actions",
8134                bindings: vec![
8135                    ("p", "Toggle priority hints"),
8136                    ("P", "Pages export wizard"),
8137                    ("C", "Copy issue ID"),
8138                    ("x", "Export issue markdown"),
8139                    ("O", "Open in editor"),
8140                    ("Ctrl+R/F5", "Refresh from disk"),
8141                ],
8142            },
8143            Section {
8144                title: "History",
8145                bindings: vec![
8146                    ("c", "Cycle confidence filter"),
8147                    ("y", "Copy SHA/ID"),
8148                    ("o", "Open commit in browser"),
8149                    ("f", "Toggle file tree"),
8150                ],
8151            },
8152            Section {
8153                title: "Board",
8154                bindings: vec![
8155                    ("1-4", "Jump to lane"),
8156                    ("H/L", "First/last lane"),
8157                    ("0/$", "First/last in lane"),
8158                    ("e", "Toggle empty lanes"),
8159                ],
8160            },
8161            Section {
8162                title: "Tree",
8163                bindings: vec![
8164                    ("Enter/za", "Toggle fold"),
8165                    ("zo/zc", "Open/close fold"),
8166                    ("zR/zM", "Open/close all folds"),
8167                    ("zz", "Recenter cursor"),
8168                ],
8169            },
8170            Section {
8171                title: "Insights",
8172                bindings: vec![
8173                    ("s/S", "Cycle panel fwd/back"),
8174                    ("m", "Toggle heatmap"),
8175                    ("e", "Toggle explanations"),
8176                    ("x", "Toggle calc-proof"),
8177                ],
8178            },
8179            Section {
8180                title: "Global",
8181                bindings: vec![
8182                    ("?/F1", "Toggle this help"),
8183                    ("Esc", "Back / clear / quit"),
8184                    ("q", "Quit / back to main"),
8185                    ("Ctrl+T", "Tutorial"),
8186                    ("Ctrl+C", "Quit immediately"),
8187                ],
8188            },
8189        ];
8190
8191        // Render each section as a block of lines.
8192        let render_section = |sec: &Section| -> Vec<String> {
8193            let mut block = vec![format!("[{}]", sec.title)];
8194            for (key, desc) in &sec.bindings {
8195                block.push(format!("  {:<12} {}", key, desc));
8196            }
8197            block
8198        };
8199
8200        let rendered: Vec<Vec<String>> = sections.iter().map(render_section).collect();
8201
8202        // Determine column count based on width.
8203        let col_width = 36;
8204        let num_cols = if width >= col_width * 3 + 4 {
8205            3
8206        } else if width >= col_width * 2 + 2 {
8207            2
8208        } else {
8209            1
8210        };
8211
8212        if num_cols == 1 {
8213            // Single column: just concatenate all sections.
8214            let mut out = Vec::new();
8215            for block in &rendered {
8216                if !out.is_empty() {
8217                    out.push(String::new());
8218                }
8219                out.extend(block.iter().cloned());
8220            }
8221            return out.join("\n");
8222        }
8223
8224        // Multi-column: distribute sections across columns to balance height.
8225        let total_lines: usize = rendered.iter().map(|b| b.len() + 1).sum::<usize>(); // +1 for gap
8226        let target_per_col = (total_lines + num_cols - 1) / num_cols;
8227
8228        let mut columns: Vec<Vec<String>> = vec![Vec::new(); num_cols];
8229        let mut col = 0;
8230        let mut col_lines = 0;
8231
8232        for block in &rendered {
8233            let block_lines = block.len() + 1; // +1 for gap before next section
8234            if col_lines > 0 && col_lines + block_lines > target_per_col && col + 1 < num_cols {
8235                col += 1;
8236                col_lines = 0;
8237            }
8238            if !columns[col].is_empty() {
8239                columns[col].push(String::new());
8240            }
8241            columns[col].extend(block.iter().cloned());
8242            col_lines += block_lines;
8243        }
8244
8245        // Merge columns side by side.
8246        let max_rows = columns.iter().map(|c| c.len()).max().unwrap_or(0);
8247        let actual_col_width = width
8248            .saturating_sub(num_cols.saturating_sub(1))
8249            .checked_div(num_cols)
8250            .unwrap_or(width);
8251
8252        let mut output = Vec::with_capacity(max_rows);
8253        for row in 0..max_rows {
8254            let mut line = String::new();
8255            for (ci, col_data) in columns.iter().enumerate() {
8256                let cell = col_data.get(row).map(|s| s.as_str()).unwrap_or("");
8257                if ci > 0 {
8258                    line.push_str(" | ");
8259                }
8260                let cell_trunc = truncate_display(cell, actual_col_width);
8261                line.push_str(&cell_trunc);
8262                // Pad to column width for alignment (except last column).
8263                if ci + 1 < columns.len() {
8264                    let padding = actual_col_width.saturating_sub(display_width(&cell_trunc));
8265                    for _ in 0..padding {
8266                        line.push(' ');
8267                    }
8268                }
8269            }
8270            output.push(line);
8271        }
8272
8273        output.join("\n")
8274    }
8275
8276    fn list_panel_text(&self) -> String {
8277        if self.analyzer.issues.is_empty() {
8278            return "(no issues loaded)".to_string();
8279        }
8280
8281        match self.mode {
8282            ViewMode::Board => self.board_list_text(),
8283            ViewMode::Insights => self.insights_list_text(),
8284            ViewMode::Graph => self.graph_list_text(),
8285            ViewMode::History => self.history_list_text(),
8286            ViewMode::Actionable => self.actionable_list_text(),
8287            ViewMode::Attention => self.attention_list_text(),
8288            ViewMode::Tree => self.tree_list_text(),
8289            ViewMode::LabelDashboard => self.label_dashboard_list_text(),
8290            ViewMode::FlowMatrix => self.flow_matrix_list_text(),
8291            ViewMode::TimeTravelDiff => self.time_travel_list_text(),
8292            ViewMode::Sprint => self.sprint_list_text(),
8293            ViewMode::Main => self.main_list_text(),
8294        }
8295    }
8296
8297    fn list_panel_render_text(&self, width: u16) -> RichText {
8298        match self.mode {
8299            ViewMode::Main => self.main_list_render_text(width),
8300            ViewMode::Graph => self.graph_list_render_text(width),
8301            ViewMode::Tree => self.tree_list_render_text(width),
8302            _ => RichText::raw(self.list_panel_text()),
8303        }
8304    }
8305
8306    fn main_list_text(&self) -> String {
8307        self.main_list_render_text(80).to_plain_text()
8308    }
8309
8310    fn main_list_empty_state_lines(&self) -> Vec<RichLine> {
8311        let mut lines = vec![RichLine::from_spans([RichSpan::styled(
8312            "No issues in the current triage slice",
8313            tokens::panel_title(),
8314        )])];
8315
8316        let mut scope = vec![format!("filter={}", self.list_filter.label())];
8317        if let Some(label) = self.modal_label_filter.as_deref() {
8318            scope.push(format!("label={label}"));
8319        }
8320        if let Some(repo) = self.modal_repo_filter.as_deref() {
8321            scope.push(format!("repo={repo}"));
8322        }
8323        if !self.main_search_query.is_empty() {
8324            scope.push(format!("search=/{}", self.main_search_query));
8325        }
8326        lines.push(RichLine::raw(format!("Scope: {}", scope.join(" | "))));
8327
8328        let recovery = if !self.main_search_query.is_empty() {
8329            "Recover: Esc keeps context | / edits search | n/N cycle hits | o/c/r/B/I switch filters"
8330        } else {
8331            "Recover: a all | o open | I in-progress | B blocked | c closed | r ready"
8332        };
8333        lines.push(RichLine::raw(recovery));
8334        lines
8335    }
8336
8337    fn main_focus_banner_line(&self) -> RichLine {
8338        let mut line = RichLine::new();
8339        push_chip(
8340            &mut line,
8341            if matches!(self.focus, FocusPane::List) {
8342                "Focus: list owns selection, / search, o/c/r/B/I filters, L label, w repo, Tab detail, Shift+Tab reverse"
8343            } else {
8344                "Focus: detail owns J/K deps, ^j/k scroll, o/y link actions, Tab returns to list, Shift+Tab reverse"
8345            },
8346            if matches!(self.focus, FocusPane::List) {
8347                SemanticTone::Accent
8348            } else {
8349                SemanticTone::Warning
8350            },
8351        );
8352        line
8353    }
8354
8355    fn main_scope_banner_line(&self) -> RichLine {
8356        let selected = self
8357            .selected_issue()
8358            .map_or_else(|| "none".to_string(), |issue| issue.id.clone());
8359        let visible = self.visible_issue_indices_for_list_nav();
8360        let position = self.selected_visible_slot(&visible).map_or_else(
8361            || "0/0".to_string(),
8362            |slot| format!("{}/{}", slot + 1, visible.len()),
8363        );
8364        let mut line = RichLine::new();
8365        push_metric_chip(
8366            &mut line,
8367            "scope",
8368            self.list_filter.label(),
8369            SemanticTone::Muted,
8370        );
8371        line.push_span(RichSpan::styled(" | ", tokens::dim()));
8372        push_metric_chip(
8373            &mut line,
8374            "label",
8375            self.modal_label_filter.as_deref().unwrap_or("any"),
8376            SemanticTone::Neutral,
8377        );
8378        line.push_span(RichSpan::styled(" | ", tokens::dim()));
8379        push_metric_chip(
8380            &mut line,
8381            "repo",
8382            self.modal_repo_filter.as_deref().unwrap_or("any"),
8383            SemanticTone::Neutral,
8384        );
8385        line.push_span(RichSpan::styled(" | ", tokens::dim()));
8386        push_metric_chip(&mut line, "pos", &position, SemanticTone::Muted);
8387        line.push_span(RichSpan::styled(" | ", tokens::dim()));
8388        push_metric_chip(
8389            &mut line,
8390            "search",
8391            if self.main_search_query.is_empty() {
8392                "off"
8393            } else {
8394                self.main_search_query.as_str()
8395            },
8396            if self.main_search_query.is_empty() {
8397                SemanticTone::Muted
8398            } else {
8399                SemanticTone::Accent
8400            },
8401        );
8402        line.push_span(RichSpan::styled(" | ", tokens::dim()));
8403        push_metric_chip(&mut line, "selected", &selected, SemanticTone::Warning);
8404        line
8405    }
8406
8407    fn main_search_banner_lines(&self) -> Vec<RichLine> {
8408        let mut lines = vec![self.main_focus_banner_line(), self.main_scope_banner_line()];
8409        if self.main_search_active {
8410            lines.push(RichLine::raw(format!(
8411                "Search (active): /{}",
8412                self.main_search_query
8413            )));
8414        } else if !self.main_search_query.is_empty() {
8415            lines.push(RichLine::raw(format!(
8416                "Search: /{} (n/N cycles)",
8417                self.main_search_query
8418            )));
8419        }
8420        let visible = self.visible_issue_indices();
8421        if !self.main_search_query.is_empty() {
8422            let matches = self.main_search_matches();
8423            if matches.is_empty() {
8424                lines.push(RichLine::raw("Matches: none in visible issues"));
8425                lines.push(RichLine::raw(
8426                    "Hint: keep scanning rows, refine /query, or clear repo/label filters",
8427                ));
8428            } else {
8429                let position = self
8430                    .main_search_match_cursor
8431                    .min(matches.len().saturating_sub(1))
8432                    + 1;
8433                lines.push(RichLine::raw(format!(
8434                    "Matches: {position}/{}",
8435                    matches.len()
8436                )));
8437                lines.push(RichLine::raw(
8438                    "Guide: n/N cycle hits | Enter keeps query | Esc clears | Tab keeps context",
8439                ));
8440            }
8441        } else {
8442            lines.push(RichLine::raw(
8443                "Guide: / search-as-you-type | o/c/r/B/I quick filters | Esc unwinds state | Tab/Shift+Tab focus",
8444            ));
8445        }
8446        lines.push(RichLine::raw(""));
8447        if visible.is_empty() {
8448            lines.extend(self.main_list_empty_state_lines());
8449        }
8450        lines
8451    }
8452
8453    fn main_list_render_text(&self, width: u16) -> RichText {
8454        let visible = self.visible_issue_indices();
8455        let mut lines = self.main_search_banner_lines();
8456        if visible.is_empty() {
8457            return RichText::from_lines(lines);
8458        }
8459
8460        let line_width = usize::from(width.saturating_sub(2)).max(24);
8461        let search_matches = self.main_search_matches();
8462        let search_positions = search_matches
8463            .iter()
8464            .enumerate()
8465            .map(|(slot, index)| (*index, slot + 1))
8466            .collect::<BTreeMap<usize, usize>>();
8467        for (slot, (index, issue)) in visible
8468            .into_iter()
8469            .filter_map(|index| self.analyzer.issues.get(index).map(|issue| (index, issue)))
8470            .enumerate()
8471        {
8472            let open_blockers = self.analyzer.graph.open_blockers(&issue.id).len();
8473            let blocks_count = self
8474                .analyzer
8475                .metrics
8476                .blocks_count
8477                .get(&issue.id)
8478                .copied()
8479                .unwrap_or_default();
8480            let pagerank_rank = metric_rank(&self.analyzer.metrics.pagerank, &issue.id);
8481            let graph_score = self
8482                .analyzer
8483                .metrics
8484                .pagerank
8485                .get(&issue.id)
8486                .copied()
8487                .unwrap_or_default();
8488            let critical_depth = self
8489                .analyzer
8490                .metrics
8491                .critical_depth
8492                .get(&issue.id)
8493                .copied()
8494                .unwrap_or_default();
8495            lines.push(issue_scan_line(
8496                issue,
8497                index == self.selected,
8498                ScanLineContext {
8499                    open_blockers,
8500                    blocks_count,
8501                    triage_rank: slot + 1,
8502                    pagerank_rank,
8503                    critical_depth,
8504                    graph_score,
8505                    search_match_position: search_positions.get(&index).copied(),
8506                    total_search_matches: search_matches.len(),
8507                    diff_tag: self.issue_diff_tag(&issue.id),
8508                    available_width: line_width,
8509                },
8510            ));
8511        }
8512
8513        RichText::from_lines(lines)
8514    }
8515
8516    fn board_list_text(&self) -> String {
8517        let lanes = self.board_lane_indices();
8518        let mut out = Vec::<String>::new();
8519        out.push(format!(
8520            "Grouping: {} (s cycles) | Empty: {} (e)",
8521            self.board_grouping.label(),
8522            self.board_empty_visibility.label(),
8523        ));
8524        if self.board_search_active {
8525            out.push(format!("Search (active): /{}", self.board_search_query));
8526        } else if !self.board_search_query.is_empty() {
8527            out.push(format!("Search: /{} (n/N cycles)", self.board_search_query));
8528        }
8529        if !self.board_search_query.is_empty() {
8530            let matches = self.board_search_matches();
8531            if matches.is_empty() {
8532                out.push("Matches: none".to_string());
8533            } else {
8534                let position = self
8535                    .board_search_match_cursor
8536                    .min(matches.len().saturating_sub(1))
8537                    + 1;
8538                out.push(format!("Matches: {position}/{}", matches.len()));
8539            }
8540        }
8541
8542        // Find which lane the selected issue belongs to
8543        let sel_id = self.selected_issue().map(|i| i.id.clone());
8544        let sel_index = self.selected;
8545
8546        out.push(String::new());
8547        let total: usize = lanes.iter().map(|(_, v)| v.len()).sum();
8548        out.push(format!("Lanes ({}) | {} issues total", lanes.len(), total));
8549        out.push(String::new());
8550
8551        for (lane, lane_indices) in &lanes {
8552            let count = lane_indices.len();
8553            // Mark current lane
8554            let is_current_lane = sel_id.as_ref().is_some_and(|sid| {
8555                lane_indices
8556                    .iter()
8557                    .any(|&i| self.analyzer.issues[i].id == *sid)
8558            });
8559            let marker = if is_current_lane { "▸" } else { " " };
8560
8561            // Lane health signals
8562            let blocked_count = lane_indices
8563                .iter()
8564                .filter(|&&i| {
8565                    self.analyzer.issues[i]
8566                        .normalized_status()
8567                        .eq_ignore_ascii_case("blocked")
8568                })
8569                .count();
8570            let health = if count == 0 {
8571                "empty".to_string()
8572            } else if blocked_count > 0 {
8573                format!("{blocked_count} blocked")
8574            } else {
8575                "clear".to_string()
8576            };
8577
8578            // Lane header with box-drawing border
8579            let bar_len = count.min(20);
8580            let bar: String = std::iter::repeat_n('\u{2588}', bar_len).collect();
8581            out.push(format!(
8582                "{marker} \u{250c}\u{2500} {lane} [{count}] {bar}  {health}"
8583            ));
8584
8585            // Show card previews with box-drawing borders
8586            let preview_limit = 8;
8587            for &idx in lane_indices.iter().take(preview_limit) {
8588                let issue = &self.analyzer.issues[idx];
8589                let is_sel = idx == sel_index;
8590                let s_icon = status_icon(&issue.status);
8591                let t_icon = type_icon(&issue.issue_type);
8592                let open_bl = self.analyzer.graph.open_blockers(&issue.id).len();
8593                let blocks = self
8594                    .analyzer
8595                    .metrics
8596                    .blocks_count
8597                    .get(&issue.id)
8598                    .copied()
8599                    .unwrap_or(0);
8600                let dep_tag = if open_bl > 0 {
8601                    format!("\u{2298}{open_bl}")
8602                } else if blocks > 0 {
8603                    format!("\u{2193}{blocks}")
8604                } else {
8605                    String::new()
8606                };
8607                let assignee = if issue.assignee.is_empty() {
8608                    String::new()
8609                } else {
8610                    format!(" @{}", truncate_str(&issue.assignee, 8))
8611                };
8612
8613                // Card with box border
8614                let sel_char = if is_sel { "\u{25b6}" } else { "\u{2502}" };
8615                out.push(format!(
8616                    "  {sel_char} {s_icon}{t_icon} P{} {} {}{dep_tag}{assignee}",
8617                    issue.priority.clamp(0, 4),
8618                    truncate_str(&issue.id, 10),
8619                    truncate_str(&issue.title, 18),
8620                ));
8621            }
8622            if lane_indices.len() > preview_limit {
8623                out.push(format!(
8624                    "  \u{2502} ... +{} more",
8625                    lane_indices.len() - preview_limit
8626                ));
8627            }
8628            if lane_indices.is_empty() {
8629                out.push("  \u{2502} (empty)".to_string());
8630            }
8631            out.push(format!(
8632                "  \u{2514}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
8633            ));
8634            out.push(String::new());
8635        }
8636
8637        out.join("\n")
8638    }
8639
8640    fn insights_list_text(&self) -> String {
8641        if let Some(state) = self.insights_heatmap.as_ref() {
8642            let data = self.insights_heatmap_data();
8643            let row = state
8644                .row
8645                .min(INSIGHTS_HEATMAP_DEPTH_LABELS.len().saturating_sub(1));
8646            let col = state
8647                .col
8648                .min(INSIGHTS_HEATMAP_SCORE_LABELS.len().saturating_sub(1));
8649            let cell_issue_ids = data.issue_ids[row][col].clone();
8650
8651            if state.drill_active {
8652                let mut lines = vec![
8653                    "Priority heatmap drill | m toggle | j/k issue | Tab detail | Esc back"
8654                        .to_string(),
8655                    format!(
8656                        "Cell: {} x {} ({} issue(s))",
8657                        INSIGHTS_HEATMAP_DEPTH_LABELS[row],
8658                        INSIGHTS_HEATMAP_SCORE_LABELS[col],
8659                        cell_issue_ids.len()
8660                    ),
8661                    String::new(),
8662                ];
8663
8664                if cell_issue_ids.is_empty() {
8665                    lines.push("  (no issues in selected cell)".to_string());
8666                    return lines.join("\n");
8667                }
8668
8669                let max_visible = 10usize;
8670                let cursor = state
8671                    .drill_cursor
8672                    .min(cell_issue_ids.len().saturating_sub(1));
8673                let start = cursor.saturating_sub(max_visible.saturating_sub(1));
8674                let end = (start + max_visible).min(cell_issue_ids.len());
8675
8676                for (idx, issue_id) in cell_issue_ids[start..end].iter().enumerate() {
8677                    let actual = start + idx;
8678                    let marker = if actual == cursor { '>' } else { ' ' };
8679                    let (priority, title) = self
8680                        .analyzer
8681                        .issues
8682                        .iter()
8683                        .find(|issue| issue.id == *issue_id)
8684                        .map_or((9, String::new()), |issue| {
8685                            (issue.priority, truncate_str(&issue.title, 28))
8686                        });
8687                    lines.push(format!("{marker} {issue_id:<12} p{priority} {title}"));
8688                }
8689
8690                if cell_issue_ids.len() > max_visible {
8691                    lines.push(String::new());
8692                    lines.push(format!(
8693                        "Drill cursor: {}/{}",
8694                        cursor + 1,
8695                        cell_issue_ids.len()
8696                    ));
8697                }
8698
8699                return lines.join("\n");
8700            }
8701
8702            let mut lines = vec![
8703                "Priority heatmap | m toggle | h/l/j/k cell | Enter drill | Tab detail".to_string(),
8704                String::new(),
8705            ];
8706
8707            if data
8708                .counts
8709                .iter()
8710                .all(|row_counts| row_counts.iter().all(|count| *count == 0))
8711            {
8712                lines.push("  (no open, filter-matching issues to chart)".to_string());
8713                return lines.join("\n");
8714            }
8715
8716            let col_totals = (0..INSIGHTS_HEATMAP_SCORE_LABELS.len())
8717                .map(|score_col| {
8718                    data.counts
8719                        .iter()
8720                        .map(|row_counts| row_counts[score_col])
8721                        .sum()
8722                })
8723                .collect::<Vec<usize>>();
8724
8725            lines.push(format!(
8726                "{:<8} | {:>4} {:>4} {:>4} {:>4} {:>4} | {:>4}",
8727                "Depth",
8728                INSIGHTS_HEATMAP_SCORE_LABELS[0],
8729                INSIGHTS_HEATMAP_SCORE_LABELS[1],
8730                INSIGHTS_HEATMAP_SCORE_LABELS[2],
8731                INSIGHTS_HEATMAP_SCORE_LABELS[3],
8732                INSIGHTS_HEATMAP_SCORE_LABELS[4],
8733                "Tot"
8734            ));
8735            lines.push("-".repeat(46));
8736
8737            for (depth_row, label) in INSIGHTS_HEATMAP_DEPTH_LABELS.iter().enumerate() {
8738                let row_total = data.counts[depth_row].iter().sum::<usize>();
8739                let mut cell_chunks = Vec::with_capacity(INSIGHTS_HEATMAP_SCORE_LABELS.len());
8740                for score_col in 0..INSIGHTS_HEATMAP_SCORE_LABELS.len() {
8741                    let count = data.counts[depth_row][score_col];
8742                    let cell = if depth_row == row && score_col == col {
8743                        if count == 0 {
8744                            "[ .]".to_string()
8745                        } else {
8746                            format!("[{count:>2}]")
8747                        }
8748                    } else if count == 0 {
8749                        "  . ".to_string()
8750                    } else {
8751                        format!(" {count:>2} ")
8752                    };
8753                    cell_chunks.push(cell);
8754                }
8755                lines.push(format!(
8756                    "{label:<8} | {} | {row_total:>4}",
8757                    cell_chunks.join(" ")
8758                ));
8759            }
8760
8761            lines.push("-".repeat(46));
8762            lines.push(format!(
8763                "{:<8} | {:>4} {:>4} {:>4} {:>4} {:>4} | {:>4}",
8764                "Total",
8765                col_totals[0],
8766                col_totals[1],
8767                col_totals[2],
8768                col_totals[3],
8769                col_totals[4],
8770                col_totals.iter().sum::<usize>()
8771            ));
8772            lines.push(String::new());
8773            lines.push(format!(
8774                "Selected: {} x {} ({} issue(s))",
8775                INSIGHTS_HEATMAP_DEPTH_LABELS[row],
8776                INSIGHTS_HEATMAP_SCORE_LABELS[col],
8777                cell_issue_ids.len()
8778            ));
8779            if let Some(issue_id) = cell_issue_ids.first() {
8780                let issue_title = self
8781                    .analyzer
8782                    .issues
8783                    .iter()
8784                    .find(|issue| issue.id == *issue_id)
8785                    .map_or("", |issue| issue.title.as_str());
8786                lines.push(format!(
8787                    "Lead issue: {issue_id} {}",
8788                    truncate_str(issue_title, 34)
8789                ));
8790            } else {
8791                lines.push("Lead issue: none in selected cell".to_string());
8792            }
8793
8794            return lines.join("\n");
8795        }
8796
8797        let insights = self.analyzer.insights();
8798
8799        let mut lines = vec![format!(
8800            "[{}] s/S cycles panel | e explanations | x calc-proof | / search | m heatmap",
8801            self.insights_panel.label()
8802        )];
8803        if self.insights_search_active {
8804            lines.push(format!("Search (active): /{}", self.insights_search_query));
8805        } else if !self.insights_search_query.is_empty() {
8806            lines.push(format!(
8807                "Search: /{} (n/N cycles)",
8808                self.insights_search_query
8809            ));
8810        }
8811        if !self.insights_search_query.is_empty() {
8812            let matches = self.insights_search_matches();
8813            if matches.is_empty() {
8814                lines.push("Matches: none".to_string());
8815            } else {
8816                let position = self
8817                    .insights_search_match_cursor
8818                    .min(matches.len().saturating_sub(1))
8819                    + 1;
8820                lines.push(format!("Matches: {position}/{}", matches.len()));
8821            }
8822        }
8823        lines.push(String::new());
8824        let search_matches = self.insights_search_matches();
8825        let search_positions = search_matches
8826            .iter()
8827            .enumerate()
8828            .map(|(slot, index)| (*index, slot + 1))
8829            .collect::<BTreeMap<usize, usize>>();
8830        lines.extend(self.insights_signal_tiles());
8831        lines.push(String::new());
8832        lines.extend(self.insights_outlier_radar());
8833        lines.push(String::new());
8834        lines.push(format!(
8835            "Panel Focus | {} | {}",
8836            self.insights_panel.label(),
8837            self.insights_panel_focus_hint()
8838        ));
8839        lines.push(String::new());
8840
8841        match self.insights_panel {
8842            InsightsPanel::Bottlenecks => {
8843                if insights.bottlenecks.is_empty() {
8844                    lines.push("  (no open issues to rank)".to_string());
8845                } else {
8846                    lines.extend(insights.bottlenecks.iter().take(15).enumerate().map(
8847                        |(index, item)| {
8848                            let hit_suffix = self.insights_search_hit_suffix(
8849                                &item.id,
8850                                &search_positions,
8851                                search_matches.len(),
8852                            );
8853                            format!(
8854                                " {}. {:<12} score={:.3} blocks={}{}",
8855                                index + 1,
8856                                item.id,
8857                                item.score,
8858                                item.blocks_count,
8859                                hit_suffix
8860                            )
8861                        },
8862                    ));
8863                }
8864            }
8865            InsightsPanel::Keystones => {
8866                let mut keystones = self
8867                    .analyzer
8868                    .issues
8869                    .iter()
8870                    .filter(|issue| issue.is_open_like())
8871                    .filter_map(|issue| {
8872                        self.analyzer
8873                            .metrics
8874                            .critical_depth
8875                            .get(&issue.id)
8876                            .copied()
8877                            .map(|depth| (issue.id.as_str(), depth))
8878                    })
8879                    .filter(|(_, depth)| *depth > 0)
8880                    .collect::<Vec<_>>();
8881
8882                keystones
8883                    .sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(right.0)));
8884
8885                if keystones.is_empty() {
8886                    lines.push("  (no foundational chain detected)".to_string());
8887                } else {
8888                    lines.extend(keystones.iter().take(15).enumerate().map(
8889                        |(index, (id, depth))| {
8890                            let unblocks = self
8891                                .analyzer
8892                                .metrics
8893                                .blocks_count
8894                                .get(*id)
8895                                .copied()
8896                                .unwrap_or_default();
8897                            let hit_suffix = self.insights_search_hit_suffix(
8898                                id,
8899                                &search_positions,
8900                                search_matches.len(),
8901                            );
8902                            format!(
8903                                " {}. {:<12} depth={} unblocks={}{}",
8904                                index + 1,
8905                                id,
8906                                depth,
8907                                unblocks,
8908                                hit_suffix
8909                            )
8910                        },
8911                    ));
8912                }
8913            }
8914            InsightsPanel::CriticalPath => {
8915                if insights.critical_path.is_empty() {
8916                    lines.push("  (no critical path detected)".to_string());
8917                } else {
8918                    lines.extend(
8919                        insights
8920                            .critical_path
8921                            .iter()
8922                            .enumerate()
8923                            .map(|(index, id)| {
8924                                let depth = self
8925                                    .analyzer
8926                                    .metrics
8927                                    .critical_depth
8928                                    .get(id)
8929                                    .copied()
8930                                    .unwrap_or_default();
8931                                let hit_suffix = self.insights_search_hit_suffix(
8932                                    id,
8933                                    &search_positions,
8934                                    search_matches.len(),
8935                                );
8936                                format!(" {}. {:<12} depth={}{}", index + 1, id, depth, hit_suffix)
8937                            }),
8938                    );
8939                }
8940            }
8941            InsightsPanel::Influencers => {
8942                self.append_metric_items(
8943                    &mut lines,
8944                    &insights.influencers,
8945                    "influencer",
8946                    &search_positions,
8947                    search_matches.len(),
8948                );
8949            }
8950            InsightsPanel::Betweenness => {
8951                self.append_metric_items(
8952                    &mut lines,
8953                    &insights.betweenness,
8954                    "betweenness",
8955                    &search_positions,
8956                    search_matches.len(),
8957                );
8958            }
8959            InsightsPanel::Hubs => {
8960                self.append_metric_items(
8961                    &mut lines,
8962                    &insights.hubs,
8963                    "hub-score",
8964                    &search_positions,
8965                    search_matches.len(),
8966                );
8967            }
8968            InsightsPanel::Authorities => {
8969                self.append_metric_items(
8970                    &mut lines,
8971                    &insights.authorities,
8972                    "authority",
8973                    &search_positions,
8974                    search_matches.len(),
8975                );
8976            }
8977            InsightsPanel::Cores => {
8978                if insights.cores.is_empty() {
8979                    lines.push("  (no k-core data)".to_string());
8980                } else {
8981                    lines.extend(insights.cores.iter().take(15).enumerate().map(
8982                        |(index, item)| {
8983                            let hit_suffix = self.insights_search_hit_suffix(
8984                                &item.id,
8985                                &search_positions,
8986                                search_matches.len(),
8987                            );
8988                            format!(
8989                                " {}. {:<12} k={}{}",
8990                                index + 1,
8991                                item.id,
8992                                item.value,
8993                                hit_suffix
8994                            )
8995                        },
8996                    ));
8997                }
8998            }
8999            InsightsPanel::CutPoints => {
9000                if insights.articulation_points.is_empty() {
9001                    lines.push("  (no cut points -- graph is well-connected)".to_string());
9002                } else {
9003                    lines.extend(insights.articulation_points.iter().enumerate().map(
9004                        |(index, id)| {
9005                            let hit_suffix = self.insights_search_hit_suffix(
9006                                id,
9007                                &search_positions,
9008                                search_matches.len(),
9009                            );
9010                            format!(" {}. {}{}", index + 1, id, hit_suffix)
9011                        },
9012                    ));
9013                }
9014            }
9015            InsightsPanel::Slack => {
9016                if insights.slack.is_empty() {
9017                    lines
9018                        .push("  (no zero-slack issues -- all have scheduling buffer)".to_string());
9019                } else {
9020                    lines.extend(insights.slack.iter().enumerate().map(|(index, id)| {
9021                        let hit_suffix = self.insights_search_hit_suffix(
9022                            id,
9023                            &search_positions,
9024                            search_matches.len(),
9025                        );
9026                        format!(" {}. {}{}", index + 1, id, hit_suffix)
9027                    }));
9028                }
9029            }
9030            InsightsPanel::Cycles => {
9031                if insights.cycles.is_empty() {
9032                    lines.push("  No cycles detected".to_string());
9033                } else {
9034                    lines.extend(
9035                        insights.cycles.iter().enumerate().map(|(index, cycle)| {
9036                            format!(" {}. {}", index + 1, cycle.join(" -> "))
9037                        }),
9038                    );
9039                }
9040            }
9041            InsightsPanel::Priority => {
9042                let recommendations = self.analyzer.priority(0.0, 15, None, None);
9043                if recommendations.is_empty() {
9044                    lines.push("  (no priority recommendations available)".to_string());
9045                } else {
9046                    lines.extend(recommendations.iter().enumerate().map(|(index, item)| {
9047                        let hit_suffix = self.insights_search_hit_suffix(
9048                            &item.id,
9049                            &search_positions,
9050                            search_matches.len(),
9051                        );
9052                        format!(
9053                            " {}. {:<12} score={:.3} unblocks={} p{}{}",
9054                            index + 1,
9055                            item.id,
9056                            item.score,
9057                            item.unblocks,
9058                            item.priority,
9059                            hit_suffix
9060                        )
9061                    }));
9062                }
9063            }
9064        }
9065
9066        lines.join("\n")
9067    }
9068
9069    fn insights_signal_tiles(&self) -> Vec<String> {
9070        let insights = self.analyzer.insights();
9071        let open_issues = self
9072            .analyzer
9073            .issues
9074            .iter()
9075            .filter(|issue| issue.is_open_like())
9076            .count();
9077        let blocked_open = self
9078            .analyzer
9079            .issues
9080            .iter()
9081            .filter(|issue| {
9082                issue.is_open_like() && !self.analyzer.graph.open_blockers(&issue.id).is_empty()
9083            })
9084            .count();
9085        let zero_slack = insights.slack.len();
9086        let max_k_core = insights
9087            .cores
9088            .iter()
9089            .map(|item| item.value)
9090            .max()
9091            .unwrap_or(0);
9092
9093        vec![
9094            "Signal Tiles".to_string(),
9095            format!(
9096                "[Flow ] open={open_issues} blocked={blocked_open} crit-path={} cycles={}",
9097                insights.critical_path.len(),
9098                insights.cycles.len()
9099            ),
9100            format!(
9101                "[Risk ] bottlenecks={} cut-points={} zero-slack={} max-k={max_k_core}",
9102                insights.bottlenecks.len(),
9103                insights.articulation_points.len(),
9104                zero_slack
9105            ),
9106        ]
9107    }
9108
9109    fn insights_outlier_radar(&self) -> Vec<String> {
9110        let insights = self.analyzer.insights();
9111        let priority = self.analyzer.priority(0.0, 1, None, None);
9112        let top_bottleneck = insights.bottlenecks.first().map_or_else(
9113            || "none".to_string(),
9114            |item| {
9115                format!(
9116                    "{} score={:.3} blocks={}",
9117                    item.id, item.score, item.blocks_count
9118                )
9119            },
9120        );
9121        let top_influencer = insights.influencers.first().map_or_else(
9122            || "none".to_string(),
9123            |item| format!("{} pr={:.4}", item.id, item.value),
9124        );
9125        let top_priority = priority.first().map_or_else(
9126            || "none".to_string(),
9127            |item| format!("{} score={:.3} p{}", item.id, item.score, item.priority),
9128        );
9129
9130        vec![
9131            "Outlier Radar".to_string(),
9132            format!("[Lead ] bottleneck={top_bottleneck}"),
9133            format!("[Rank ] influencer={top_influencer}"),
9134            format!("[Act  ] next-priority={top_priority}"),
9135        ]
9136    }
9137
9138    fn insights_panel_focus_hint(&self) -> &'static str {
9139        match self.insights_panel {
9140            InsightsPanel::Bottlenecks => "blocking pressure and downstream drag",
9141            InsightsPanel::Keystones => "foundational chains that unlock follow-on work",
9142            InsightsPanel::CriticalPath => "deep dependency rails with little slack",
9143            InsightsPanel::Influencers => "highest graph influence by PageRank",
9144            InsightsPanel::Betweenness => "bridge nodes that route dependency flow",
9145            InsightsPanel::Hubs => "strong outbound influence in the graph",
9146            InsightsPanel::Authorities => "strong inbound authority in the graph",
9147            InsightsPanel::Cores => "densest cohesion clusters",
9148            InsightsPanel::CutPoints => "single-node fragility in connectivity",
9149            InsightsPanel::Slack => "zero-buffer scheduling hotspots",
9150            InsightsPanel::Cycles => "circular dependency traps",
9151            InsightsPanel::Priority => "graph-informed reprioritization candidates",
9152        }
9153    }
9154
9155    fn insights_search_hit_suffix(
9156        &self,
9157        issue_id: &str,
9158        search_positions: &BTreeMap<usize, usize>,
9159        total_search_matches: usize,
9160    ) -> String {
9161        self.issue_index_for_id(issue_id)
9162            .and_then(|issue_index| search_positions.get(&issue_index).copied())
9163            .map_or_else(String::new, |position| {
9164                format!(" hit {position}/{total_search_matches}")
9165            })
9166    }
9167
9168    fn append_metric_items(
9169        &self,
9170        lines: &mut Vec<String>,
9171        items: &[crate::analysis::MetricItem],
9172        label: &str,
9173        search_positions: &BTreeMap<usize, usize>,
9174        total_search_matches: usize,
9175    ) {
9176        if items.is_empty() {
9177            lines.push(format!("  (no {label} data)"));
9178        } else {
9179            lines.extend(items.iter().take(15).enumerate().map(|(index, item)| {
9180                let scaled = (item.value * 20.0).clamp(0.0, 20.0);
9181                let bar_len = (1_u32..=20_u32)
9182                    .take_while(|threshold| scaled >= f64::from(*threshold))
9183                    .count();
9184                let bar = format!(
9185                    "{}{}",
9186                    "#".repeat(bar_len),
9187                    ".".repeat(20_usize.saturating_sub(bar_len))
9188                );
9189                let hit_suffix = self.insights_search_hit_suffix(
9190                    &item.id,
9191                    search_positions,
9192                    total_search_matches,
9193                );
9194                format!(
9195                    " {}. {:<12} [{bar}] {:.4}{hit_suffix}",
9196                    index + 1,
9197                    item.id,
9198                    item.value
9199                )
9200            }));
9201        }
9202    }
9203
9204    fn graph_node_score(&self, id: &str) -> f64 {
9205        let depth = self
9206            .analyzer
9207            .metrics
9208            .critical_depth
9209            .get(id)
9210            .copied()
9211            .unwrap_or_default() as f64;
9212        let pagerank = self
9213            .analyzer
9214            .metrics
9215            .pagerank
9216            .get(id)
9217            .copied()
9218            .unwrap_or_default();
9219        depth + pagerank
9220    }
9221
9222    fn graph_list_text(&self) -> String {
9223        let visible = self.graph_visible_issue_indices();
9224        if visible.is_empty() {
9225            return format!("(no issues match filter: {})", self.list_filter.label());
9226        }
9227
9228        let total = visible.len();
9229        let mut lines = vec![format!(
9230            "Nodes ({total}) by critical-path score | h/l nav | / search | Tab focus"
9231        )];
9232        if self.graph_search_active {
9233            lines.push(format!("Search (active): /{}", self.graph_search_query));
9234        } else if !self.graph_search_query.is_empty() {
9235            lines.push(format!("Search: /{} (n/N cycles)", self.graph_search_query));
9236        }
9237        if !self.graph_search_query.is_empty() {
9238            let matches = self.graph_search_matches();
9239            if matches.is_empty() {
9240                lines.push("Matches: none".to_string());
9241            } else {
9242                let position = self
9243                    .graph_search_match_cursor
9244                    .min(matches.len().saturating_sub(1))
9245                    + 1;
9246                lines.push(format!("Matches: {position}/{}", matches.len()));
9247            }
9248        }
9249        lines.push(String::new());
9250        let search_matches = self.graph_search_matches();
9251        let search_positions = search_matches
9252            .iter()
9253            .enumerate()
9254            .map(|(slot, index)| (*index, slot + 1))
9255            .collect::<BTreeMap<usize, usize>>();
9256
9257        lines.extend(
9258            visible
9259                .into_iter()
9260                .filter_map(|index| self.analyzer.issues.get(index).map(|issue| (index, issue)))
9261                .map(|(index, issue)| {
9262                    let marker = if index == self.selected { '>' } else { ' ' };
9263                    let si = status_icon(&issue.status);
9264                    let blocks = self
9265                        .analyzer
9266                        .metrics
9267                        .blocks_count
9268                        .get(&issue.id)
9269                        .copied()
9270                        .unwrap_or_default();
9271                    let blocked_by = self
9272                        .analyzer
9273                        .metrics
9274                        .blocked_by_count
9275                        .get(&issue.id)
9276                        .copied()
9277                        .unwrap_or_default();
9278                    let pagerank = self
9279                        .analyzer
9280                        .metrics
9281                        .pagerank
9282                        .get(&issue.id)
9283                        .copied()
9284                        .unwrap_or_default();
9285                    let hit_suffix = search_positions
9286                        .get(&index)
9287                        .map_or_else(String::new, |position| {
9288                            format!(" hit {position}/{}", search_matches.len())
9289                        });
9290                    format!(
9291                        "{marker} {si} {:<12} in:{:>2} out:{:>2} pr:{:.3}{hit_suffix}",
9292                        issue.id, blocked_by, blocks, pagerank
9293                    )
9294                }),
9295        );
9296        lines.join("\n")
9297    }
9298
9299    fn graph_list_render_text(&self, width: u16) -> RichText {
9300        let visible = self.graph_visible_issue_indices();
9301        if visible.is_empty() {
9302            return RichText::raw(format!(
9303                "(no issues match filter: {})",
9304                self.list_filter.label()
9305            ));
9306        }
9307
9308        let total = visible.len();
9309        let mut lines = vec![panel_header(
9310            "Nodes",
9311            Some(&format!(
9312                "{total} by critical-path score | h/l nav | / search | Tab focus"
9313            )),
9314        )];
9315        if self.graph_search_active {
9316            lines.push(RichLine::raw(format!(
9317                "Search (active): /{}",
9318                self.graph_search_query
9319            )));
9320        } else if !self.graph_search_query.is_empty() {
9321            lines.push(RichLine::raw(format!(
9322                "Search: /{} (n/N cycles)",
9323                self.graph_search_query
9324            )));
9325        }
9326        if !self.graph_search_query.is_empty() {
9327            let matches = self.graph_search_matches();
9328            if matches.is_empty() {
9329                lines.push(RichLine::raw("Matches: none"));
9330            } else {
9331                let position = self
9332                    .graph_search_match_cursor
9333                    .min(matches.len().saturating_sub(1))
9334                    + 1;
9335                lines.push(RichLine::raw(format!(
9336                    "Matches: {position}/{}",
9337                    matches.len()
9338                )));
9339            }
9340        }
9341        lines.push(section_separator(
9342            usize::from(width.saturating_sub(2)).max(24),
9343        ));
9344
9345        let pr_max = max_metric_value(&self.analyzer.metrics.pagerank);
9346        let line_width = usize::from(width.saturating_sub(2)).max(24);
9347        let search_matches = self.graph_search_matches();
9348        let search_positions = search_matches
9349            .iter()
9350            .enumerate()
9351            .map(|(slot, index)| (*index, slot + 1))
9352            .collect::<BTreeMap<usize, usize>>();
9353        for index in visible {
9354            let Some(issue) = self.analyzer.issues.get(index) else {
9355                continue;
9356            };
9357            let blocked_by = self.analyzer.graph.open_blockers(&issue.id).len();
9358            let blocks = self
9359                .analyzer
9360                .metrics
9361                .blocks_count
9362                .get(&issue.id)
9363                .copied()
9364                .unwrap_or_default();
9365            let pagerank = self
9366                .analyzer
9367                .metrics
9368                .pagerank
9369                .get(&issue.id)
9370                .copied()
9371                .unwrap_or_default();
9372            let mut line = RichLine::new();
9373            let marker_style = if index == self.selected {
9374                tokens::selected()
9375            } else {
9376                tokens::dim()
9377            };
9378            line.push_span(RichSpan::styled(
9379                if index == self.selected { "▸" } else { " " },
9380                marker_style,
9381            ));
9382            line.push_span(RichSpan::raw(" "));
9383            line.push_span(RichSpan::styled(
9384                truncate_display(&issue.id, 12),
9385                tokens::panel_title(),
9386            ));
9387            line.push_span(RichSpan::raw(" "));
9388            for span in metric_strip("PR", pagerank, pr_max) {
9389                line.push_span(span);
9390            }
9391            // Neighborhood: blocker/dependent indicators
9392            let bl = blocker_indicator(blocked_by, blocks);
9393            if !bl.is_empty() {
9394                line.push_span(RichSpan::raw(" "));
9395                for s in bl {
9396                    line.push_span(s);
9397                }
9398            }
9399            // Cycle membership
9400            if self
9401                .analyzer
9402                .metrics
9403                .cycles
9404                .iter()
9405                .any(|c| c.contains(&issue.id))
9406            {
9407                line.push_span(RichSpan::styled(
9408                    " \u{27f3}",
9409                    tokens::status_style("blocked"),
9410                ));
9411            }
9412            // Articulation point
9413            if self
9414                .analyzer
9415                .metrics
9416                .articulation_points
9417                .contains(&issue.id)
9418            {
9419                line.push_span(RichSpan::styled(
9420                    " \u{25c6}",
9421                    tokens::status_style("in_progress"),
9422                ));
9423            }
9424            line.push_span(RichSpan::raw(" "));
9425            let title_width = line_width.saturating_sub(42);
9426            line.push_span(RichSpan::styled(
9427                truncate_display(&issue.title, title_width.max(8)),
9428                tokens::help_desc(),
9429            ));
9430            if let Some(position) = search_positions.get(&index) {
9431                line.push_span(RichSpan::raw(" "));
9432                line.push_span(RichSpan::styled(
9433                    format!("hit {position}/{}", search_matches.len()),
9434                    tokens::status_style("selected"),
9435                ));
9436            }
9437            lines.push(line);
9438        }
9439
9440        RichText::from_lines(lines)
9441    }
9442
9443    fn graph_visible_issue_indices(&self) -> Vec<usize> {
9444        let mut visible = self.visible_issue_indices();
9445        visible.sort_by(|&left_idx, &right_idx| {
9446            let left = &self.analyzer.issues[left_idx];
9447            let right = &self.analyzer.issues[right_idx];
9448            let left_score = self.graph_node_score(&left.id);
9449            let right_score = self.graph_node_score(&right.id);
9450            right_score
9451                .total_cmp(&left_score)
9452                .then_with(|| left.id.cmp(&right.id))
9453        });
9454        visible
9455    }
9456
9457    fn history_list_text(&self) -> String {
9458        if matches!(self.history_view_mode, HistoryViewMode::Git) {
9459            let query = self.history_search_query.trim();
9460            let visible = self.history_git_visible_commit_indices();
9461            let cache = self.history_git_cache.as_ref();
9462
9463            if visible.is_empty() {
9464                if query.is_empty() {
9465                    return "No git commits correlated with beads.\n\
9466                            (ensure repo has commits referencing bead IDs)"
9467                        .to_string();
9468                }
9469                return format!("(no commits match search: /{query})");
9470            }
9471
9472            let cursor = self
9473                .history_event_cursor
9474                .min(visible.len().saturating_sub(1));
9475
9476            let total_commits = cache.map_or(0, |c| c.commits.len());
9477            let mut lines = Vec::<String>::new();
9478            if query.is_empty() {
9479                lines.push(format!(
9480                    "Git commits ({}/{} correlated) | v bead list | / search | c confidence",
9481                    visible.len(),
9482                    total_commits
9483                ));
9484            } else {
9485                lines.push(format!(
9486                    "Git commits (matches: {}/{}) | v bead list | / search",
9487                    visible.len(),
9488                    total_commits
9489                ));
9490            }
9491            lines.push(format!(
9492                "Min confidence: >= {:.0}%",
9493                self.history_min_confidence() * 100.0
9494            ));
9495
9496            if self.history_search_active {
9497                lines.push(format!(
9498                    "Search [{}] (Tab cycles): /{}",
9499                    self.history_search_mode.label(),
9500                    self.history_search_query
9501                ));
9502            } else if !query.is_empty() {
9503                let matches = self.history_search_matches();
9504                let mc = matches.len();
9505                lines.push(format!(
9506                    "Search [{}]: /{} ({mc} matches, n/N cycles)",
9507                    self.history_search_mode.label(),
9508                    self.history_search_query
9509                ));
9510            }
9511
9512            lines.push(String::new());
9513
9514            if let Some(cache) = cache {
9515                for (display_idx, &commit_idx) in visible.iter().enumerate() {
9516                    let marker = if display_idx == cursor { '>' } else { ' ' };
9517                    if let Some(commit) = cache.commits.get(commit_idx) {
9518                        let related = self.history_git_related_beads_for_commit(&commit.sha);
9519                        let beads_str = if related.len() <= 2 {
9520                            related.join(",")
9521                        } else {
9522                            format!("{}+{}", related[..2].join(","), related.len() - 2)
9523                        };
9524                        let type_icon = commit_type_icon(&commit.message);
9525                        let msg = truncate_str(&commit.message, 28);
9526                        let ts = compact_history_duration_label(&commit.timestamp);
9527                        lines.push(format!(
9528                            "{marker} {type_icon} {} {:<8} {} {ts}",
9529                            commit.short_sha, beads_str, msg
9530                        ));
9531                    }
9532                }
9533            }
9534            return lines.join("\n");
9535        }
9536
9537        let histories = self.analyzer.history(None, 0);
9538        let query = self.history_search_query.trim();
9539        let all_visible = self.visible_issue_indices();
9540        let visible = self.history_visible_issue_indices();
9541        if visible.is_empty() {
9542            if all_visible.is_empty() {
9543                return format!("(no issues match filter: {})", self.list_filter.label());
9544            }
9545            if query.is_empty() {
9546                return "(no issues available)".to_string();
9547            }
9548            return format!("(no issues match history search: /{query})");
9549        }
9550
9551        let mut lines = Vec::<String>::new();
9552        if query.is_empty() {
9553            lines.push(format!(
9554                "Bead history list ({} beads) | v toggles to git timeline | / search",
9555                visible.len()
9556            ));
9557        } else {
9558            lines.push(format!(
9559                "Bead history list (matches: {}/{}) | v toggles to git timeline | / search",
9560                visible.len(),
9561                all_visible.len()
9562            ));
9563        }
9564
9565        if self.history_search_active {
9566            lines.push(format!(
9567                "Search [{}] (Tab cycles): /{}",
9568                self.history_search_mode.label(),
9569                self.history_search_query
9570            ));
9571        } else if !query.is_empty() {
9572            let mc = visible.len();
9573            lines.push(format!(
9574                "Search [{}]: /{} ({mc} matches, n/N cycles)",
9575                self.history_search_mode.label(),
9576                self.history_search_query
9577            ));
9578        }
9579
9580        lines.push(String::new());
9581        lines.extend(
9582            visible
9583                .into_iter()
9584                .filter_map(|index| self.analyzer.issues.get(index).map(|issue| (index, issue)))
9585                .map(|(index, issue)| {
9586                    let marker = if index == self.selected {
9587                        "\u{25b8}"
9588                    } else {
9589                        " "
9590                    };
9591                    let event_count = histories
9592                        .iter()
9593                        .find(|entry| entry.id == issue.id)
9594                        .map_or(0, |entry| entry.events.len());
9595                    let si = status_icon(&issue.status);
9596                    let ti = type_icon(&issue.issue_type);
9597                    format!(
9598                        "{marker} {si}{ti} {:<12} {event_count:>2}\u{25aa} {:<11}",
9599                        issue.id, issue.status
9600                    )
9601                }),
9602        );
9603        lines.join("\n")
9604    }
9605
9606    fn history_middle_text(&self, width: u16, height: u16) -> String {
9607        let inner_width = usize::from(width.saturating_sub(4)).max(12);
9608        let visible_rows = usize::from(height.saturating_sub(4)).max(1);
9609
9610        if matches!(self.history_view_mode, HistoryViewMode::Git) {
9611            let Some(commit) = self.selected_history_git_commit() else {
9612                return "Select a commit to view related beads.".to_string();
9613            };
9614
9615            let related = self.history_git_related_beads_for_commit(&commit.sha);
9616            if related.is_empty() {
9617                return format!("No beads correlated with {}.", commit.short_sha);
9618            }
9619
9620            let slot = self
9621                .history_related_bead_cursor
9622                .min(related.len().saturating_sub(1));
9623            let start = slot.saturating_sub(visible_rows.saturating_sub(1));
9624            let end = (start + visible_rows).min(related.len());
9625            let mut lines = vec![
9626                format!("{} related bead(s) for {}", related.len(), commit.short_sha),
9627                String::new(),
9628            ];
9629
9630            for (idx, bead_id) in related.iter().enumerate().skip(start).take(end - start) {
9631                let marker = if idx == slot && matches!(self.focus, FocusPane::Middle) {
9632                    '>'
9633                } else {
9634                    ' '
9635                };
9636                let issue = self.issue_by_id(bead_id);
9637                let status = issue.map_or("?", |issue| status_icon(&issue.status));
9638                let title = issue
9639                    .map(|issue| truncate_str(&issue.title, inner_width.saturating_sub(18)))
9640                    .unwrap_or_else(|| bead_id.clone());
9641                lines.push(format!("{marker} [{status}] {bead_id:<8} {title}"));
9642            }
9643
9644            if end < related.len() {
9645                lines.push(format!("+{} more", related.len() - end));
9646            }
9647
9648            return lines.join("\n");
9649        }
9650
9651        let Some(issue) = self.selected_issue() else {
9652            return "Select a bead to view correlated commits.".to_string();
9653        };
9654        let commits = self.history_filtered_bead_commits(&issue.id);
9655        if commits.is_empty() {
9656            return format!("No commits correlated with {}.", issue.id);
9657        }
9658
9659        let slot = self
9660            .history_bead_commit_cursor
9661            .min(commits.len().saturating_sub(1));
9662        let start = slot.saturating_sub(visible_rows.saturating_sub(1));
9663        let end = (start + visible_rows).min(commits.len());
9664        let mut lines = vec![
9665            format!("{} commit(s) for {}", commits.len(), issue.id),
9666            String::new(),
9667        ];
9668
9669        for (idx, commit) in commits.iter().enumerate().skip(start).take(end - start) {
9670            let marker = if idx == slot && matches!(self.focus, FocusPane::Middle) {
9671                '>'
9672            } else {
9673                ' '
9674            };
9675            let summary = truncate_str(&commit.message, inner_width.saturating_sub(18));
9676            lines.push(format!(
9677                "{marker} {} {:>3.0}% {}",
9678                commit.short_sha,
9679                commit.confidence * 100.0,
9680                summary
9681            ));
9682        }
9683
9684        if end < commits.len() {
9685            lines.push(format!("+{} more", commits.len() - end));
9686        }
9687
9688        lines.join("\n")
9689    }
9690
9691    fn history_timeline_text(&self, width: u16, height: u16) -> String {
9692        let Some(issue) = self.selected_issue() else {
9693            return "Select a bead to view its timeline.".to_string();
9694        };
9695        let inner_width = usize::from(width.saturating_sub(4)).max(12);
9696        let visible_rows = usize::from(height.saturating_sub(4)).max(1);
9697        let compat_history = self
9698            .history_git_cache
9699            .as_ref()
9700            .and_then(|cache| cache.histories.get(&issue.id));
9701        let filtered_commits = self.history_filtered_bead_commits(&issue.id);
9702
9703        if let Some(compat_history) = compat_history {
9704            let mut lines = vec![format!("Timeline: {}", issue.id), String::new()];
9705            lines.push(self.history_compact_timeline_text(compat_history, inner_width));
9706            if let Some(cycle) = compat_history
9707                .cycle_time
9708                .as_ref()
9709                .and_then(|cycle| cycle.create_to_close.as_deref())
9710            {
9711                lines.push(format!("Cycle: {}", compact_history_duration_label(cycle)));
9712            }
9713            if !filtered_commits.is_empty() {
9714                let avg_confidence = filtered_commits
9715                    .iter()
9716                    .map(|commit| commit.confidence)
9717                    .sum::<f64>()
9718                    / filtered_commits.len() as f64;
9719                lines.push(format!(
9720                    "Commits: {} | Avg confidence: {:.0}%",
9721                    filtered_commits.len(),
9722                    avg_confidence * 100.0
9723                ));
9724            }
9725            lines.push(String::new());
9726
9727            let used_rows = lines.len();
9728            let max_timeline_rows = visible_rows.saturating_sub(used_rows).max(1);
9729            lines.extend(render_legacy_timeline_lines(
9730                compat_history,
9731                &filtered_commits,
9732                inner_width,
9733                max_timeline_rows,
9734            ));
9735            return lines.join("\n");
9736        }
9737
9738        let selected_history = self.analyzer.history(Some(&issue.id), 1).into_iter().next();
9739
9740        let mut entries = Vec::new();
9741        if let Some(history) = selected_history {
9742            for event in history.events {
9743                let ts = event
9744                    .timestamp
9745                    .map(|dt| format_compact_timestamp(Some(dt)))
9746                    .unwrap_or_else(|| "n/a".to_string());
9747                let detail = truncate_str(&event.details, inner_width.saturating_sub(16));
9748                entries.push(format!("{} {ts} {}", lifecycle_icon(&event.kind), detail));
9749            }
9750        }
9751
9752        for commit in self
9753            .history_filtered_bead_commits(&issue.id)
9754            .into_iter()
9755            .take(visible_rows)
9756        {
9757            let summary = truncate_str(&commit.message, inner_width.saturating_sub(14));
9758            entries.push(format!("• {} {}", commit.short_sha, summary));
9759        }
9760
9761        if entries.is_empty() {
9762            return format!("No timeline data for {}.", issue.id);
9763        }
9764
9765        let hidden = entries.len().saturating_sub(visible_rows);
9766        let mut lines = vec![format!("Cycle view for {}", issue.id), String::new()];
9767        lines.extend(entries.into_iter().take(visible_rows));
9768        if hidden > 0 {
9769            lines.push(format!("+{hidden} more"));
9770        }
9771        lines.join("\n")
9772    }
9773
9774    fn history_compact_timeline_text(
9775        &self,
9776        history: &HistoryBeadCompat,
9777        max_width: usize,
9778    ) -> String {
9779        let mut markers = Vec::<&str>::new();
9780        let mut start_ts = None::<&str>;
9781        let mut end_ts = None::<&str>;
9782
9783        if let Some(ref event) = history.milestones.created {
9784            markers.push("○");
9785            start_ts = Some(event.timestamp.as_str());
9786        }
9787        if let Some(ref event) = history.milestones.claimed {
9788            markers.push("●");
9789            start_ts = start_ts.or(Some(event.timestamp.as_str()));
9790        }
9791
9792        let commit_count = history.commits.as_ref().map_or(0, Vec::len);
9793        if commit_count > 5 {
9794            markers.extend(["├", "├", "├", "├", "…"]);
9795        } else {
9796            for _ in 0..commit_count {
9797                markers.push("├");
9798            }
9799        }
9800
9801        if let Some(ref event) = history.milestones.closed {
9802            markers.push("✓");
9803            end_ts = Some(event.timestamp.as_str());
9804        }
9805
9806        if markers.is_empty() {
9807            return "(no timeline data)".to_string();
9808        }
9809
9810        let mut summary = Vec::<String>::new();
9811        if let Some(ref cycle) = history.cycle_time {
9812            if let Some(ref create_to_close) = cycle.create_to_close {
9813                summary.push(format!(
9814                    "{} cycle",
9815                    compact_history_duration_label(create_to_close)
9816                ));
9817            }
9818        }
9819        if commit_count > 0 {
9820            summary.push(if commit_count == 1 {
9821                "1 commit".to_string()
9822            } else {
9823                format!("{commit_count} commits")
9824            });
9825        }
9826
9827        let mut result = markers.join("──");
9828        if !summary.is_empty() {
9829            result.push_str("  ");
9830            result.push_str(&summary.join(", "));
9831        }
9832
9833        if let (Some(start), Some(end)) = (start_ts, end_ts) {
9834            if let (Some(start), Some(end)) = (
9835                compact_history_month_day(start),
9836                compact_history_month_day(end),
9837            ) {
9838                let date_range = format!("{start} ─ {end}");
9839                if result.chars().count() + date_range.chars().count() + 4 < max_width {
9840                    result.push('\n');
9841                    result.push_str(&date_range);
9842                }
9843            }
9844        }
9845
9846        result
9847            .lines()
9848            .map(|line| truncate_display(line, max_width))
9849            .collect::<Vec<_>>()
9850            .join("\n")
9851    }
9852
9853    // -- Actionable view --------------------------------------------------
9854
9855    fn compute_actionable_plan(&mut self) {
9856        let triage = self
9857            .analyzer
9858            .triage(crate::analysis::triage::TriageOptions::default());
9859        let plan = self.analyzer.plan(&triage.score_by_id);
9860        self.actionable_track_cursor = 0;
9861        self.actionable_item_cursor = 0;
9862        self.actionable_plan = Some(plan);
9863    }
9864
9865    fn move_actionable_cursor(&mut self, delta: isize) {
9866        let Some(plan) = self.actionable_plan.as_ref() else {
9867            return;
9868        };
9869
9870        let previous_track = self.actionable_track_cursor;
9871        let previous_item = self.actionable_item_cursor;
9872
9873        if matches!(self.focus, FocusPane::List) {
9874            // Navigate between tracks.
9875            let max = plan.tracks.len().saturating_sub(1);
9876            let new_pos = (self.actionable_track_cursor as isize + delta).clamp(0, max as isize);
9877            self.actionable_track_cursor = new_pos as usize;
9878            self.actionable_item_cursor = 0;
9879        } else {
9880            // Navigate between items within current track.
9881            if let Some(track) = plan.tracks.get(self.actionable_track_cursor) {
9882                let max = track.items.len().saturating_sub(1);
9883                let new_pos = (self.actionable_item_cursor as isize + delta).clamp(0, max as isize);
9884                self.actionable_item_cursor = new_pos as usize;
9885            }
9886        }
9887
9888        if self.actionable_track_cursor != previous_track
9889            || self.actionable_item_cursor != previous_item
9890        {
9891            self.detail_scroll_offset = 0;
9892        }
9893    }
9894
9895    fn actionable_list_text(&self) -> String {
9896        let Some(plan) = self.actionable_plan.as_ref() else {
9897            return "(no execution plan computed)".to_string();
9898        };
9899
9900        if plan.tracks.is_empty() {
9901            return "⚡ ACTIONABLE ITEMS\n\n✓ No actionable items. All tasks are either blocked or completed."
9902                .to_string();
9903        }
9904
9905        let mut lines = Vec::new();
9906        lines.push(format!(
9907            "⚡ ACTIONABLE ITEMS | {} items in {} tracks",
9908            plan.summary.actionable_count,
9909            plan.tracks.len()
9910        ));
9911        if let (Some(highest), Some(reason)) = (
9912            plan.summary.highest_impact.as_deref(),
9913            plan.summary.impact_reason.as_deref(),
9914        ) {
9915            lines.push(format!("RECOMMENDED: Start with {highest} -> {reason}"));
9916        }
9917        lines.push(String::new());
9918
9919        for (track_idx, track) in plan.tracks.iter().enumerate() {
9920            let track_marker = if track_idx == self.actionable_track_cursor
9921                && matches!(self.focus, FocusPane::List)
9922            {
9923                "▸"
9924            } else {
9925                " "
9926            };
9927            let track_label = track.id.strip_prefix("track-").unwrap_or(&track.id);
9928            lines.push(format!(
9929                "{track_marker} TRACK {track_label} | {}",
9930                track.reason
9931            ));
9932            lines.push(format!(
9933                "  {} item{}",
9934                track.items.len(),
9935                if track.items.len() == 1 { "" } else { "s" }
9936            ));
9937            for (item_idx, item) in track.items.iter().enumerate() {
9938                let item_marker = if track_idx == self.actionable_track_cursor
9939                    && item_idx == self.actionable_item_cursor
9940                    && matches!(self.focus, FocusPane::Detail)
9941                {
9942                    "  ▸"
9943                } else {
9944                    "   "
9945                };
9946                let tree = if item_idx + 1 < track.items.len() {
9947                    "├─"
9948                } else {
9949                    "└─"
9950                };
9951                let title = truncate_str(&item.title, 42);
9952                let unblocks_str = if item.unblocks.is_empty() {
9953                    String::new()
9954                } else if item.unblocks.len() <= 2 {
9955                    format!(" \u{2192} {}", item.unblocks.join(", "))
9956                } else {
9957                    format!(
9958                        " \u{2192} {}, +{}",
9959                        item.unblocks[..2].join(", "),
9960                        item.unblocks.len() - 2
9961                    )
9962                };
9963                lines.push(format!(
9964                    "{item_marker}{tree} P{} {:<12} {:>5.2}  {}{unblocks_str}",
9965                    item.priority.clamp(0, 4),
9966                    item.id,
9967                    item.score,
9968                    title
9969                ));
9970            }
9971            lines.push(String::new());
9972        }
9973
9974        // On wide terminals (>=120), show a parallel summary of all tracks
9975        let width = usize::from(cached_view_width());
9976        if width >= 120 && plan.tracks.len() >= 2 {
9977            lines.push(String::new());
9978            lines.push("═══ Parallel Track Overview ═══".to_string());
9979            let col_width = (width.saturating_sub(4)) / plan.tracks.len().min(4);
9980            for chunk in plan.tracks.chunks(4) {
9981                // Header row
9982                let headers = chunk.iter().fold(String::new(), |mut acc, track| {
9983                    let label = track.id.strip_prefix("track-").unwrap_or(&track.id);
9984                    let title = format!("Track {label} ({})", track.items.len());
9985                    let _ = write!(acc, "{title:<w$}", w = col_width);
9986                    acc
9987                });
9988                lines.push(headers);
9989                // Item rows (show up to 5 per track)
9990                let max_items = chunk
9991                    .iter()
9992                    .map(|t| t.items.len().min(5))
9993                    .max()
9994                    .unwrap_or(0);
9995                for row in 0..max_items {
9996                    let row_text: String = chunk
9997                        .iter()
9998                        .map(|t| {
9999                            if let Some(item) = t.items.get(row) {
10000                                format!(
10001                                    "{:<w$}",
10002                                    format!("  {} {:.2}", truncate_str(&item.id, 10), item.score),
10003                                    w = col_width
10004                                )
10005                            } else {
10006                                " ".repeat(col_width)
10007                            }
10008                        })
10009                        .collect();
10010                    lines.push(row_text);
10011                }
10012                lines.push(String::new());
10013            }
10014        }
10015
10016        lines.join("\n").trim_end().to_string()
10017    }
10018
10019    fn actionable_detail_text(&self) -> String {
10020        let Some(plan) = self.actionable_plan.as_ref() else {
10021            return "(no plan)".to_string();
10022        };
10023
10024        let Some(track) = plan.tracks.get(self.actionable_track_cursor) else {
10025            return "(no track selected)".to_string();
10026        };
10027
10028        let track_label = track.id.strip_prefix("track-").unwrap_or(&track.id);
10029
10030        let mut lines = Vec::new();
10031        lines.push(format!("TRACK {track_label}"));
10032        lines.push(track.reason.clone());
10033        lines.push(format!(
10034            "{} actionable item{}",
10035            track.items.len(),
10036            if track.items.len() == 1 { "" } else { "s" }
10037        ));
10038        lines.push(String::new());
10039
10040        for (idx, item) in track.items.iter().enumerate() {
10041            let marker = if idx == self.actionable_item_cursor {
10042                "▸"
10043            } else {
10044                " "
10045            };
10046            lines.push(format!("{marker} {}  score {:.3}", item.id, item.score));
10047            lines.push(format!("  {}", item.title));
10048            if !item.unblocks.is_empty() {
10049                lines.push(format!("  Unblocks: {}", item.unblocks.join(", ")));
10050            }
10051            lines.push(format!("  Claim: {}", item.claim_command));
10052            lines.push(String::new());
10053        }
10054
10055        if let (Some(highest), Some(reason)) = (
10056            plan.summary.highest_impact.as_deref(),
10057            plan.summary.impact_reason.as_deref(),
10058        ) {
10059            lines.push(format!("Highest impact: {highest}"));
10060            lines.push(format!("Impact detail: {reason}"));
10061        }
10062
10063        lines.join("\n").trim_end().to_string()
10064    }
10065
10066    // -- end Actionable view -----------------------------------------------
10067
10068    // -- Attention view ----------------------------------------------------
10069
10070    fn compute_attention(&mut self) {
10071        let result = crate::analysis::label_intel::compute_label_attention(
10072            &self.analyzer.issues,
10073            &self.analyzer.metrics,
10074            0, // no limit — show all
10075        );
10076        self.attention_result = Some(result);
10077        self.attention_cursor = 0;
10078    }
10079
10080    fn copy_selected_issue_id(&mut self) {
10081        if let Some(issue) = self.selected_issue() {
10082            let id = issue.id.clone();
10083            if copy_text_to_clipboard(&id) {
10084                self.status_msg = format!("Copied {id} to clipboard");
10085            } else {
10086                self.status_msg = "Clipboard not available".into();
10087            }
10088        }
10089    }
10090
10091    fn export_selected_issue_markdown(&mut self) {
10092        let Some(issue) = self.selected_issue() else {
10093            return;
10094        };
10095
10096        let mut md = String::new();
10097        md.push_str(&format!("# {} — {}\n\n", issue.id, issue.title));
10098        md.push_str(&format!(
10099            "**Status:** {} | **Priority:** p{} | **Type:** {}\n\n",
10100            issue.status, issue.priority, issue.issue_type
10101        ));
10102        if !issue.assignee.is_empty() {
10103            md.push_str(&format!("**Assignee:** {}\n\n", issue.assignee));
10104        }
10105        if !issue.description.is_empty() {
10106            md.push_str(&format!("## Description\n\n{}\n\n", issue.description));
10107        }
10108        if !issue.notes.is_empty() {
10109            md.push_str(&format!("## Notes\n\n{}\n\n", issue.notes));
10110        }
10111        if !issue.labels.is_empty() {
10112            md.push_str(&format!("**Labels:** {}\n", issue.labels.join(", ")));
10113        }
10114
10115        let path = std::env::temp_dir().join(format!("{}.md", issue.id));
10116        match std::fs::write(&path, &md) {
10117            Ok(()) => {
10118                self.status_msg = format!("Exported to {}", path.display());
10119            }
10120            Err(err) => {
10121                self.status_msg = format!("Export failed: {err}");
10122            }
10123        }
10124    }
10125
10126    fn open_selected_in_editor(&mut self) {
10127        let Some(issue) = self.selected_issue() else {
10128            return;
10129        };
10130
10131        let yaml = format!(
10132            "id: {}\ntitle: {}\nstatus: {}\npriority: {}\ntype: {}\nassignee: {}\nlabels: [{}]\n\ndescription: |\n  {}\n\nnotes: |\n  {}\n",
10133            issue.id,
10134            issue.title,
10135            issue.status,
10136            issue.priority,
10137            issue.issue_type,
10138            issue.assignee,
10139            issue.labels.join(", "),
10140            issue.description.replace('\n', "\n  "),
10141            issue.notes.replace('\n', "\n  "),
10142        );
10143
10144        let path = std::env::temp_dir().join(format!("{}.yaml", issue.id));
10145        if std::fs::write(&path, &yaml).is_err() {
10146            self.status_msg = "Failed to write temp file".into();
10147            return;
10148        }
10149
10150        let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
10151        if run_command(&editor, &[&path.to_string_lossy()]) {
10152            self.status_msg = format!("Opened in {editor}");
10153        } else {
10154            self.status_msg = format!("Failed to open {editor}");
10155        }
10156    }
10157
10158    fn refresh_from_disk(&mut self) {
10159        let repo_path = self.repo_root.as_deref();
10160        let issues = match loader::load_issues(repo_path) {
10161            Ok(issues) => issues,
10162            Err(_) => return, // silently ignore — data unchanged
10163        };
10164
10165        // Preserve selection by ID when possible.
10166        let selected_id = self
10167            .analyzer
10168            .issues
10169            .get(self.selected)
10170            .map(|i| i.id.clone());
10171
10172        let use_two_phase =
10173            issues.len() > crate::analysis::graph::AnalysisConfig::background_threshold();
10174        if use_two_phase {
10175            self.analyzer = Analyzer::new_fast(issues);
10176            #[cfg(not(test))]
10177            {
10178                self.slow_metrics_rx = Some(self.analyzer.spawn_slow_computation());
10179            }
10180            self.slow_metrics_pending = true;
10181        } else {
10182            self.analyzer = Analyzer::new(issues);
10183            self.slow_metrics_pending = false;
10184            #[cfg(not(test))]
10185            {
10186                self.slow_metrics_rx = None;
10187            }
10188        }
10189
10190        // Restore selection.
10191        if let Some(ref id) = selected_id {
10192            if let Some(pos) = self.analyzer.issues.iter().position(|i| i.id == *id) {
10193                self.selected = pos;
10194            } else {
10195                self.selected = 0;
10196            }
10197        } else {
10198            self.selected = 0;
10199        }
10200
10201        // Reset computed views.
10202        self.actionable_plan = None;
10203        self.attention_result = None;
10204
10205        // Recompute if in a derived view.
10206        match self.mode {
10207            ViewMode::Actionable => self.compute_actionable_plan(),
10208            ViewMode::Attention => self.compute_attention(),
10209            _ => {}
10210        }
10211        // Rebuild tree so stale issue_index values don't cause
10212        // out-of-bounds panics with the new issue list.
10213        self.rebuild_tree_if_active();
10214    }
10215
10216    fn move_attention_cursor(&mut self, delta: i32) {
10217        let count = self.attention_result.as_ref().map_or(0, |r| r.labels.len());
10218        if count == 0 {
10219            return;
10220        }
10221        let cur = self.attention_cursor as i32 + delta;
10222        self.attention_cursor = cur.clamp(0, count as i32 - 1) as usize;
10223    }
10224
10225    fn attention_list_text(&self) -> String {
10226        let Some(result) = self.attention_result.as_ref() else {
10227            return "(computing attention scores…)".to_string();
10228        };
10229        if result.labels.is_empty() {
10230            return "(no labels found)".to_string();
10231        }
10232
10233        let mut lines = Vec::new();
10234        lines.push(format!(
10235            "{:>4}  {:<20} {:>6}  {}",
10236            "Rank", "Label", "Score", "Reason"
10237        ));
10238        lines.push(format!("{}", "─".repeat(72)));
10239
10240        for (idx, label) in result.labels.iter().enumerate() {
10241            let marker = if idx == self.attention_cursor {
10242                "▸"
10243            } else {
10244                " "
10245            };
10246            lines.push(format!(
10247                "{marker}{:>3}  {:<20} {:>5.1}  {}",
10248                label.rank,
10249                truncate_str(&label.label, 20),
10250                label.attention_score,
10251                truncate_str(&label.reason, 30),
10252            ));
10253        }
10254
10255        lines.join("\n")
10256    }
10257
10258    fn attention_detail_text(&self) -> String {
10259        let Some(result) = self.attention_result.as_ref() else {
10260            return "(no attention data)".to_string();
10261        };
10262        let Some(label) = result.labels.get(self.attention_cursor) else {
10263            return "(no label selected)".to_string();
10264        };
10265
10266        let mut lines = Vec::new();
10267        lines.push(format!("Label: {}", label.label));
10268        lines.push(format!("Rank: #{}", label.rank));
10269        lines.push(format!("Attention Score: {:.3}", label.attention_score));
10270        lines.push(format!("Normalized: {:.3}", label.normalized_score));
10271        lines.push(String::new());
10272
10273        lines.push("Breakdown:".to_string());
10274        lines.push(format!("  Open:     {}", label.open_count));
10275        lines.push(format!("  Blocked:  {}", label.blocked_count));
10276        lines.push(format!("  Stale:    {}", label.stale_count));
10277        lines.push(String::new());
10278
10279        lines.push("Factors:".to_string());
10280        lines.push(format!("  PageRank sum:     {:.4}", label.pagerank_sum));
10281        lines.push(format!("  Staleness factor: {:.2}", label.staleness_factor));
10282        lines.push(format!("  Block impact:     {:.1}", label.block_impact));
10283        lines.push(format!("  Velocity factor:  {:.2}", label.velocity_factor));
10284        lines.push(String::new());
10285
10286        lines.push(format!("Reason: {}", label.reason));
10287
10288        // Show affected issues from the analyzer
10289        let issues_with_label: Vec<&str> = self
10290            .analyzer
10291            .issues
10292            .iter()
10293            .filter(|i| {
10294                i.is_open_like()
10295                    && i.labels
10296                        .iter()
10297                        .any(|candidate| candidate.eq_ignore_ascii_case(&label.label))
10298            })
10299            .map(|i| i.id.as_str())
10300            .collect();
10301        if !issues_with_label.is_empty() {
10302            lines.push(String::new());
10303            lines.push(format!("Open issues ({}):", issues_with_label.len()));
10304            for id in &issues_with_label {
10305                lines.push(format!("  {id}"));
10306            }
10307        }
10308
10309        lines.join("\n")
10310    }
10311
10312    // -- end Attention view ------------------------------------------------
10313
10314    // -- Tree view --------------------------------------------------------
10315
10316    fn build_tree_flat_nodes(&mut self) {
10317        let issues = &self.analyzer.issues;
10318
10319        // Precompute which issues pass the active filter (list status,
10320        // label, and/or repo) so the tree only shows matching issues
10321        // (and their structural parents).
10322        let filter_active = self.has_active_filter();
10323        let passes: Vec<bool> = issues
10324            .iter()
10325            .map(|issue| self.issue_matches_filter(issue))
10326            .collect();
10327
10328        // Build a map from issue ID to index.
10329        let id_to_index: std::collections::HashMap<&str, usize> = issues
10330            .iter()
10331            .enumerate()
10332            .map(|(i, issue)| (issue.id.as_str(), i))
10333            .collect();
10334
10335        // Build parent-child hierarchy from parent-child dependencies.
10336        // A child issue has a dep with dep_type="parent-child" and
10337        // depends_on_id pointing to its parent.
10338        let mut children_of: std::collections::HashMap<usize, Vec<usize>> =
10339            std::collections::HashMap::new();
10340        let mut has_parent: std::collections::HashSet<usize> = std::collections::HashSet::new();
10341
10342        for (i, issue) in issues.iter().enumerate() {
10343            for dep in &issue.dependencies {
10344                if dep.is_parent_child() && !dep.depends_on_id.trim().is_empty() {
10345                    if let Some(&parent_idx) = id_to_index.get(dep.depends_on_id.as_str()) {
10346                        children_of.entry(parent_idx).or_default().push(i);
10347                        has_parent.insert(i);
10348                    }
10349                }
10350            }
10351        }
10352
10353        // Deduplicate and sort children lists.
10354        for children in children_of.values_mut() {
10355            children.sort_by(|&a, &b| issues[a].id.cmp(&issues[b].id));
10356            children.dedup();
10357        }
10358
10359        // Roots: issues with no parent-child dependency pointing to a
10360        // known parent. Fall back to blocking-based roots if no
10361        // parent-child deps exist at all.
10362        let mut roots: Vec<usize> = (0..issues.len())
10363            .filter(|i| !has_parent.contains(i))
10364            .collect();
10365
10366        if roots.len() == issues.len() && !issues.is_empty() {
10367            // No parent-child deps at all — fall back to blocking graph
10368            // so the tree still shows something useful.
10369            let graph = &self.analyzer.graph;
10370            let mut blocking_children_of: std::collections::HashMap<usize, Vec<usize>> =
10371                std::collections::HashMap::new();
10372            let mut has_blocker: std::collections::HashSet<usize> =
10373                std::collections::HashSet::new();
10374
10375            for (i, issue) in issues.iter().enumerate() {
10376                let blockers = graph.blockers(&issue.id);
10377                if !blockers.is_empty() {
10378                    has_blocker.insert(i);
10379                    for blocker_id in &blockers {
10380                        if let Some(&blocker_idx) = id_to_index.get(blocker_id.as_str()) {
10381                            blocking_children_of.entry(blocker_idx).or_default().push(i);
10382                        }
10383                    }
10384                }
10385            }
10386            for children in blocking_children_of.values_mut() {
10387                children.sort_by(|&a, &b| issues[a].id.cmp(&issues[b].id));
10388                children.dedup();
10389            }
10390
10391            roots = (0..issues.len())
10392                .filter(|i| !has_blocker.contains(i))
10393                .collect();
10394
10395            children_of = blocking_children_of;
10396        }
10397
10398        roots.sort_by(|&a, &b| issues[a].id.cmp(&issues[b].id));
10399
10400        // When a list filter is active, compute which nodes are "visible":
10401        // an issue that matches the filter, plus any ancestor whose subtree
10402        // contains a matching descendant (so the tree structure stays intact).
10403        let visible: Option<std::collections::HashSet<usize>> = if filter_active {
10404            let mut vis: std::collections::HashSet<usize> = std::collections::HashSet::new();
10405            // Mark matching leaves and propagate upward.
10406            fn mark_visible(
10407                idx: usize,
10408                children_of: &std::collections::HashMap<usize, Vec<usize>>,
10409                passes: &[bool],
10410                vis: &mut std::collections::HashSet<usize>,
10411                visited: &mut std::collections::HashSet<usize>,
10412            ) -> bool {
10413                if !visited.insert(idx) {
10414                    return vis.contains(&idx);
10415                }
10416                let mut dominated = passes[idx];
10417                if let Some(children) = children_of.get(&idx) {
10418                    for &child in children {
10419                        if mark_visible(child, children_of, passes, vis, visited) {
10420                            dominated = true;
10421                        }
10422                    }
10423                }
10424                if dominated {
10425                    vis.insert(idx);
10426                }
10427                dominated
10428            }
10429            let mut visited_mark: std::collections::HashSet<usize> =
10430                std::collections::HashSet::new();
10431            for &root in &roots {
10432                mark_visible(root, &children_of, &passes, &mut vis, &mut visited_mark);
10433            }
10434            Some(vis)
10435        } else {
10436            None
10437        };
10438
10439        // Filter roots to only visible ones when a filter is active.
10440        if let Some(ref vis) = visible {
10441            roots.retain(|idx| vis.contains(idx));
10442        }
10443
10444        // Collect issue IDs for collapse state lookup (avoids borrow conflict).
10445        let issue_ids: Vec<String> = issues.iter().map(|i| i.id.clone()).collect();
10446
10447        // DFS to build flat node list.
10448        let mut flat_nodes = Vec::new();
10449        // (issue_index, depth, is_last_sibling, ancestry_last)
10450        let mut stack: Vec<(usize, usize, bool, Vec<bool>)> = Vec::new();
10451
10452        for (ri, &root_idx) in roots.iter().enumerate().rev() {
10453            let is_last = ri + 1 == roots.len();
10454            stack.push((root_idx, 0, is_last, Vec::new()));
10455        }
10456
10457        let mut visited: std::collections::HashSet<usize> = std::collections::HashSet::new();
10458
10459        while let Some((issue_idx, depth, is_last, ancestry)) = stack.pop() {
10460            if !visited.insert(issue_idx) {
10461                continue; // Avoid cycles.
10462            }
10463
10464            let issue_id = &issue_ids[issue_idx];
10465            let children: Vec<usize> = children_of
10466                .get(&issue_idx)
10467                .map(|c| {
10468                    c.iter()
10469                        .copied()
10470                        .filter(|idx| {
10471                            !visited.contains(idx)
10472                                && visible.as_ref().map_or(true, |v| v.contains(idx))
10473                        })
10474                        .collect()
10475                })
10476                .unwrap_or_default();
10477
10478            let is_collapsed = self.tree_collapsed.contains(issue_id);
10479            let has_children = !children.is_empty();
10480
10481            flat_nodes.push(TreeFlatNode {
10482                issue_index: issue_idx,
10483                depth,
10484                has_children,
10485                is_collapsed,
10486                is_last_sibling: is_last,
10487                ancestry_last: ancestry.clone(),
10488            });
10489
10490            if !is_collapsed {
10491                for (ci, &child_idx) in children.iter().enumerate().rev() {
10492                    let child_is_last = ci + 1 == children.len();
10493                    let mut child_ancestry = ancestry.clone();
10494                    child_ancestry.push(is_last);
10495                    stack.push((child_idx, depth + 1, child_is_last, child_ancestry));
10496                }
10497            }
10498        }
10499
10500        self.tree_flat_nodes = flat_nodes;
10501    }
10502
10503    fn toggle_tree_mode(&mut self) {
10504        if matches!(self.mode, ViewMode::Tree) {
10505            self.mode = ViewMode::Main;
10506        } else {
10507            self.mode = ViewMode::Tree;
10508            self.tree_cursor = 0;
10509            self.build_tree_flat_nodes();
10510        }
10511    }
10512
10513    fn tree_toggle_collapse(&mut self) {
10514        if let Some(node) = self.tree_flat_nodes.get(self.tree_cursor) {
10515            if node.has_children {
10516                let issue_id = self.analyzer.issues[node.issue_index].id.clone();
10517                if self.tree_collapsed.contains(&issue_id) {
10518                    self.tree_collapsed.remove(&issue_id);
10519                } else {
10520                    self.tree_collapsed.insert(issue_id);
10521                }
10522                self.build_tree_flat_nodes();
10523                // Clamp cursor.
10524                if self.tree_cursor >= self.tree_flat_nodes.len() {
10525                    self.tree_cursor = self.tree_flat_nodes.len().saturating_sub(1);
10526                }
10527            }
10528        }
10529    }
10530
10531    /// `zo` — expand the fold under the cursor (no-op if already open or leaf).
10532    fn tree_expand_current(&mut self) {
10533        if let Some(node) = self.tree_flat_nodes.get(self.tree_cursor) {
10534            if node.has_children {
10535                let issue_id = self.analyzer.issues[node.issue_index].id.clone();
10536                if self.tree_collapsed.remove(&issue_id) {
10537                    self.build_tree_flat_nodes();
10538                }
10539            }
10540        }
10541    }
10542
10543    /// `zc` — close the fold under the cursor.
10544    ///
10545    /// Vim semantics: if the cursor is on a foldable node, close it.
10546    /// If the cursor is on a leaf (or an already-collapsed node whose
10547    /// parent fold should close next), walk up to the nearest ancestor
10548    /// with children and collapse *that* fold, then move the cursor to
10549    /// the newly-closed parent so repeat `zc` keeps walking up.
10550    ///
10551    /// Uses the flat-node depth column: the parent of the node at
10552    /// flat index `i` is the nearest preceding node with
10553    /// `depth < nodes[i].depth`. This matches the DFS invariant in
10554    /// `build_tree_flat_nodes` and avoids rebuilding the parent map.
10555    fn tree_collapse_current(&mut self) {
10556        let Some(node) = self.tree_flat_nodes.get(self.tree_cursor) else {
10557            return;
10558        };
10559        let cursor_has_children = node.has_children;
10560        let cursor_issue_index = node.issue_index;
10561        let cursor_depth = node.depth;
10562        let cursor_issue_id = self.analyzer.issues[cursor_issue_index].id.clone();
10563
10564        // Case 1: cursor is on a foldable, currently-open node — collapse it in place.
10565        if cursor_has_children && !self.tree_collapsed.contains(&cursor_issue_id) {
10566            if self.tree_collapsed.insert(cursor_issue_id) {
10567                self.build_tree_flat_nodes();
10568                if self.tree_cursor >= self.tree_flat_nodes.len() {
10569                    self.tree_cursor = self.tree_flat_nodes.len().saturating_sub(1);
10570                }
10571            }
10572            return;
10573        }
10574
10575        // Case 2: cursor is on a leaf or an already-collapsed node.
10576        // Walk up the flat list to find the nearest enclosing ancestor
10577        // with children. The flat list is a DFS order, so the parent of
10578        // the node at index `i` is the nearest preceding node with
10579        // `depth < nodes[i].depth`.
10580        let mut ancestor_cursor: Option<usize> = None;
10581        let mut search_depth = cursor_depth;
10582        let mut i = self.tree_cursor;
10583        while i > 0 {
10584            i -= 1;
10585            let n = &self.tree_flat_nodes[i];
10586            if n.depth < search_depth {
10587                if n.has_children {
10588                    ancestor_cursor = Some(i);
10589                    break;
10590                }
10591                // Keep climbing past depth boundaries (defensive; the DFS
10592                // invariant guarantees any depth-decreasing parent has children).
10593                search_depth = n.depth;
10594            }
10595        }
10596
10597        let Some(ancestor_idx) = ancestor_cursor else {
10598            // Orphan leaf at root — nothing to collapse. No-op gracefully.
10599            return;
10600        };
10601
10602        let ancestor_issue_index = self.tree_flat_nodes[ancestor_idx].issue_index;
10603        let ancestor_issue_id = self.analyzer.issues[ancestor_issue_index].id.clone();
10604        let inserted = self.tree_collapsed.insert(ancestor_issue_id.clone());
10605        if inserted {
10606            self.build_tree_flat_nodes();
10607        }
10608        // Move cursor to the newly-closed ancestor (by issue id, since
10609        // the flat list was rebuilt and descendant rows vanished).
10610        if let Some(new_cursor) = self
10611            .tree_flat_nodes
10612            .iter()
10613            .position(|n| self.analyzer.issues[n.issue_index].id == ancestor_issue_id)
10614        {
10615            self.tree_cursor = new_cursor;
10616        } else if self.tree_cursor >= self.tree_flat_nodes.len() {
10617            self.tree_cursor = self.tree_flat_nodes.len().saturating_sub(1);
10618        }
10619    }
10620
10621    /// `zR` — expand every fold in the tree.
10622    fn tree_expand_all(&mut self) {
10623        if !self.tree_collapsed.is_empty() {
10624            self.tree_collapsed.clear();
10625            self.build_tree_flat_nodes();
10626        }
10627    }
10628
10629    /// `zM` — collapse every fold in the tree.
10630    ///
10631    /// Temporarily expands all to discover every node (including deeply nested
10632    /// ones hidden inside collapsed parents), then collapses everything.
10633    fn tree_collapse_all(&mut self) {
10634        self.tree_collapsed.clear();
10635        self.build_tree_flat_nodes();
10636        let ids: Vec<String> = self
10637            .tree_flat_nodes
10638            .iter()
10639            .filter(|n| n.has_children)
10640            .map(|n| self.analyzer.issues[n.issue_index].id.clone())
10641            .collect();
10642        for id in ids {
10643            self.tree_collapsed.insert(id);
10644        }
10645        self.build_tree_flat_nodes();
10646        if self.tree_cursor >= self.tree_flat_nodes.len() {
10647            self.tree_cursor = self.tree_flat_nodes.len().saturating_sub(1);
10648        }
10649    }
10650
10651    /// `zz` — recenter the viewport so the cursor is roughly centred.
10652    fn tree_recenter_cursor(&self) {
10653        // The rendered tree text has header lines before the first node:
10654        // panel_header (1) + optional search banner (0-1) + separator (1).
10655        let header_lines = if self.tree_search_active || !self.tree_search_query.is_empty() {
10656            3
10657        } else {
10658            2
10659        };
10660        let cursor_line = self.tree_cursor + header_lines;
10661        let half = self.list_page_step() / 2;
10662        self.list_scroll_offset
10663            .set(cursor_line.saturating_sub(half));
10664    }
10665
10666    // -- Vim gg jump-to-top ---------------------------------------------------
10667
10668    fn vim_jump_to_top(&mut self) {
10669        if self.tree_shortcut_focus() {
10670            self.tree_cursor = 0;
10671        } else if self.board_shortcut_focus() {
10672            self.select_edge_in_current_board_lane(false);
10673        } else if matches!(self.mode, ViewMode::History) && self.focus == FocusPane::List {
10674            self.history_event_cursor = 0;
10675            self.history_related_bead_cursor = 0;
10676            self.history_bead_commit_cursor = 0;
10677        } else if self.focus == FocusPane::List {
10678            self.select_first_visible();
10679        }
10680    }
10681
10682    // -- Tree search ----------------------------------------------------------
10683
10684    fn start_tree_search(&mut self) {
10685        if !matches!(self.mode, ViewMode::Tree) || self.focus != FocusPane::List {
10686            return;
10687        }
10688        self.tree_search_active = true;
10689        self.tree_search_query.clear();
10690        self.tree_search_match_cursor = 0;
10691    }
10692
10693    fn finish_tree_search(&mut self) {
10694        self.tree_search_active = false;
10695    }
10696
10697    fn cancel_tree_search(&mut self) {
10698        self.tree_search_active = false;
10699        self.tree_search_query.clear();
10700        self.tree_search_match_cursor = 0;
10701    }
10702
10703    fn tree_search_matches(&self) -> Vec<usize> {
10704        let query = self.tree_search_query.trim().to_ascii_lowercase();
10705        if query.is_empty() {
10706            return Vec::new();
10707        }
10708        self.tree_flat_nodes
10709            .iter()
10710            .enumerate()
10711            .filter(|(_, node)| {
10712                let issue = &self.analyzer.issues[node.issue_index];
10713                issue.id.to_ascii_lowercase().contains(&query)
10714                    || issue.title.to_ascii_lowercase().contains(&query)
10715                    || issue.status.to_ascii_lowercase().contains(&query)
10716            })
10717            .map(|(i, _)| i)
10718            .collect()
10719    }
10720
10721    fn select_current_tree_search_match(&mut self) {
10722        let matches = self.tree_search_matches();
10723        if matches.is_empty() {
10724            return;
10725        }
10726        self.tree_search_match_cursor = self
10727            .tree_search_match_cursor
10728            .min(matches.len().saturating_sub(1));
10729        self.tree_cursor = matches[self.tree_search_match_cursor];
10730    }
10731
10732    fn move_tree_search_match_relative(&mut self, delta: isize) {
10733        let matches = self.tree_search_matches();
10734        if matches.is_empty() || delta == 0 {
10735            return;
10736        }
10737        let len = matches.len();
10738        let current = self.tree_search_match_cursor.min(len.saturating_sub(1));
10739        let step = delta.unsigned_abs() % len;
10740        let next = if delta > 0 {
10741            (current + step) % len
10742        } else {
10743            (current + len - step) % len
10744        };
10745        self.tree_search_match_cursor = next;
10746        self.tree_cursor = matches[next];
10747    }
10748
10749    fn tree_list_text(&self) -> String {
10750        self.tree_list_render_text(80).to_plain_text()
10751    }
10752
10753    fn tree_list_render_text(&self, width: u16) -> RichText {
10754        if self.tree_flat_nodes.is_empty() {
10755            return RichText::raw("(no dependency tree — all issues are independent)");
10756        }
10757
10758        let line_width = usize::from(width.saturating_sub(2)).max(24);
10759        let mut lines = Vec::new();
10760
10761        lines.push(panel_header(
10762            "Dependency tree",
10763            Some(&format!(
10764                "{} nodes | Enter/za toggle | zo/zc open/close | zR/zM all | T/Esc back",
10765                self.tree_flat_nodes.len()
10766            )),
10767        ));
10768        // Compute search matches once for both the banner and hit indicators.
10769        let tree_search_match_indices = self.tree_search_matches();
10770        let tree_search_hits: BTreeMap<usize, usize> = tree_search_match_indices
10771            .iter()
10772            .enumerate()
10773            .map(|(slot, &idx)| (idx, slot + 1))
10774            .collect();
10775
10776        if self.tree_search_active {
10777            lines.push(RichLine::raw(format!(
10778                "Search (active): /{}",
10779                self.tree_search_query
10780            )));
10781        } else if !self.tree_search_query.is_empty() {
10782            let pos = if tree_search_match_indices.is_empty() {
10783                "none".to_string()
10784            } else {
10785                format!(
10786                    "{}/{}",
10787                    self.tree_search_match_cursor
10788                        .min(tree_search_match_indices.len().saturating_sub(1))
10789                        + 1,
10790                    tree_search_match_indices.len()
10791                )
10792            };
10793            lines.push(RichLine::raw(format!(
10794                "Search: /{} (n/N cycles, matches: {})",
10795                self.tree_search_query, pos
10796            )));
10797        }
10798        lines.push(section_separator(line_width));
10799
10800        for (i, node) in self.tree_flat_nodes.iter().enumerate() {
10801            let is_selected = i == self.tree_cursor;
10802            let issue = &self.analyzer.issues[node.issue_index];
10803            let mut line = RichLine::new();
10804
10805            // Cursor marker
10806            let marker_style = if is_selected {
10807                tokens::selected()
10808            } else {
10809                tokens::dim()
10810            };
10811            line.push_span(RichSpan::styled(
10812                if is_selected { "▸" } else { " " },
10813                marker_style,
10814            ));
10815            line.push_span(RichSpan::raw(" "));
10816
10817            // Tree prefix (box-drawing characters)
10818            let mut prefix = String::new();
10819            for &parent_was_last in &node.ancestry_last {
10820                if parent_was_last {
10821                    prefix.push_str("    ");
10822                } else {
10823                    prefix.push_str("│   ");
10824                }
10825            }
10826            if node.depth > 0 {
10827                if node.is_last_sibling {
10828                    prefix.push_str("└── ");
10829                } else {
10830                    prefix.push_str("├── ");
10831                }
10832            }
10833            if !prefix.is_empty() {
10834                line.push_span(RichSpan::styled(prefix, tokens::dim()));
10835            }
10836
10837            // Collapse indicator
10838            if node.has_children {
10839                let indicator = if node.is_collapsed { "[+] " } else { "[-] " };
10840                line.push_span(RichSpan::styled(indicator, tokens::panel_title()));
10841            } else {
10842                line.push_span(RichSpan::raw("    "));
10843            }
10844
10845            // Status icon with status-specific color
10846            let si = status_icon(&issue.status);
10847            line.push_span(RichSpan::styled(si, tokens::status_style(&issue.status)));
10848            line.push_span(RichSpan::raw(" "));
10849
10850            // Priority badge
10851            line.push_span(priority_badge(issue.priority));
10852            line.push_span(RichSpan::raw(" "));
10853
10854            // Issue ID
10855            line.push_span(RichSpan::styled(
10856                truncate_display(&issue.id, 12),
10857                tokens::dim(),
10858            ));
10859            line.push_span(RichSpan::raw(" "));
10860
10861            // Title
10862            let title_width = line_width.saturating_sub(30 + node.depth * 4);
10863            line.push_span(RichSpan::styled(
10864                truncate_display(&issue.title, title_width.max(8)),
10865                tokens::help_desc(),
10866            ));
10867
10868            // Dependency indicators
10869            let blocks = self
10870                .analyzer
10871                .metrics
10872                .blocks_count
10873                .get(&issue.id)
10874                .copied()
10875                .unwrap_or(0);
10876            let open_bl = self.analyzer.graph.open_blockers(&issue.id).len();
10877            let bl = blocker_indicator(open_bl, blocks);
10878            if !bl.is_empty() {
10879                line.push_span(RichSpan::raw(" "));
10880                for s in bl {
10881                    line.push_span(s);
10882                }
10883            }
10884
10885            // Cycle indicator
10886            if self
10887                .analyzer
10888                .metrics
10889                .cycles
10890                .iter()
10891                .any(|c| c.contains(&issue.id))
10892            {
10893                line.push_span(RichSpan::styled(
10894                    " \u{27f3}",
10895                    tokens::status_style("blocked"),
10896                ));
10897            }
10898
10899            // Search hit indicator
10900            if let Some(&pos) = tree_search_hits.get(&i) {
10901                line.push_span(RichSpan::styled(
10902                    format!(" hit {pos}/{}", tree_search_hits.len()),
10903                    tokens::panel_title(),
10904                ));
10905            }
10906
10907            lines.push(line);
10908        }
10909
10910        RichText::from_lines(lines)
10911    }
10912
10913    fn tree_detail_text(&self) -> String {
10914        let Some(node) = self.tree_flat_nodes.get(self.tree_cursor) else {
10915            return "(no node selected)".to_string();
10916        };
10917
10918        let issue = &self.analyzer.issues[node.issue_index];
10919        let blockers = self.analyzer.graph.blockers(&issue.id);
10920        let dependents = self.analyzer.graph.dependents(&issue.id);
10921
10922        let mut lines = Vec::new();
10923        lines.push(format!("ID: {}", issue.id));
10924        lines.push(format!("Title: {}", issue.title));
10925        lines.push(format!("Status: {}", issue.status));
10926        lines.push(format!("Type: {}", issue.issue_type));
10927        lines.push(format!("Depth: {}", node.depth));
10928
10929        if !issue.labels.is_empty() {
10930            lines.push(format!("Labels: {}", issue.labels.join(", ")));
10931        }
10932
10933        if !blockers.is_empty() {
10934            lines.push(String::new());
10935            lines.push(format!("Blocked by ({}):", blockers.len()));
10936            for b in &blockers {
10937                let title = self
10938                    .analyzer
10939                    .issues
10940                    .iter()
10941                    .find(|i| i.id == *b)
10942                    .map(|i| i.title.as_str())
10943                    .unwrap_or("?");
10944                lines.push(format!("  {b} - {title}"));
10945            }
10946        }
10947
10948        if !dependents.is_empty() {
10949            lines.push(String::new());
10950            lines.push(format!("Dependents ({}):", dependents.len()));
10951            for d in &dependents {
10952                let title = self
10953                    .analyzer
10954                    .issues
10955                    .iter()
10956                    .find(|i| i.id == *d)
10957                    .map(|i| i.title.as_str())
10958                    .unwrap_or("?");
10959                lines.push(format!("  {d} - {title}"));
10960            }
10961        }
10962
10963        if !issue.description.is_empty() {
10964            lines.push(String::new());
10965            lines.push("Description:".to_string());
10966            lines.push(issue.description.clone());
10967        }
10968
10969        lines.join("\n")
10970    }
10971
10972    // -- end Tree view ----------------------------------------------------
10973
10974    // -- LabelDashboard view ----------------------------------------------
10975
10976    fn toggle_label_dashboard(&mut self) {
10977        if matches!(self.mode, ViewMode::LabelDashboard) {
10978            self.mode = ViewMode::Main;
10979        } else {
10980            self.mode = ViewMode::LabelDashboard;
10981            self.label_dashboard_cursor = 0;
10982            self.compute_label_dashboard();
10983        }
10984    }
10985
10986    fn compute_label_dashboard(&mut self) {
10987        use crate::analysis::label_intel::compute_all_label_health;
10988        let metrics = self.analyzer.graph.compute_metrics();
10989        let result =
10990            compute_all_label_health(&self.analyzer.issues, &self.analyzer.graph, &metrics);
10991        self.label_dashboard = Some(result);
10992    }
10993
10994    fn label_dashboard_list_text(&self) -> String {
10995        let Some(result) = &self.label_dashboard else {
10996            return "(computing label health...)".to_string();
10997        };
10998
10999        if result.labels.is_empty() {
11000            return "(no labels found in issues)".to_string();
11001        }
11002
11003        let mut lines = Vec::new();
11004        lines.push(format!(
11005            "Label health ({} labels) | healthy:{} warn:{} critical:{}",
11006            result.total_labels, result.healthy_count, result.warning_count, result.critical_count
11007        ));
11008        lines.push(String::new());
11009
11010        for (i, label) in result.labels.iter().enumerate() {
11011            let marker = if i == self.label_dashboard_cursor {
11012                '>'
11013            } else {
11014                ' '
11015            };
11016
11017            // Health bar visualization (10 chars wide).
11018            let bar_filled = (label.health as usize).min(100) / 10;
11019            let bar: String = (0..10)
11020                .map(|j| if j < bar_filled { '#' } else { '.' })
11021                .collect();
11022
11023            let level_marker = match label.health_level.as_str() {
11024                "critical" => "!!",
11025                "warning" => "! ",
11026                _ => "  ",
11027            };
11028
11029            // Velocity sparkline (uses Unicode block characters for trend)
11030            let velocity = label.velocity.velocity_score;
11031            let spark = if velocity > 70 {
11032                "\u{2593}\u{2593}\u{2593}" // ▓▓▓ high velocity
11033            } else if velocity > 30 {
11034                "\u{2592}\u{2592}\u{2591}" // ▒▒░ medium
11035            } else if velocity > 0 {
11036                "\u{2591}\u{2591}\u{2591}" // ░░░ low
11037            } else {
11038                "\u{2581}\u{2581}\u{2581}" // ▁▁▁ stale
11039            };
11040
11041            let stale_tag = if label.freshness.stale_count > 0 {
11042                " STALE"
11043            } else {
11044                ""
11045            };
11046
11047            lines.push(format!(
11048                "{marker} {level_marker} [{bar}] {:>3}  {spark} {:<14} ({} open, {} blk){stale_tag}",
11049                label.health,
11050                truncate_str(&label.label, 14),
11051                label.open_count,
11052                label.blocked_count,
11053            ));
11054        }
11055
11056        lines.join("\n")
11057    }
11058
11059    fn label_dashboard_detail_text(&self) -> String {
11060        let Some(result) = &self.label_dashboard else {
11061            return "(no data)".to_string();
11062        };
11063
11064        let Some(label) = result.labels.get(self.label_dashboard_cursor) else {
11065            return "(no label selected)".to_string();
11066        };
11067
11068        let mut lines = Vec::new();
11069        lines.push(format!("Label: {}", label.label));
11070        lines.push(format!(
11071            "Health: {}/100 ({})",
11072            label.health, label.health_level
11073        ));
11074        lines.push(format!(
11075            "Issues: {} total ({} open, {} closed, {} blocked)",
11076            label.issue_count, label.open_count, label.closed_count, label.blocked_count
11077        ));
11078
11079        // Velocity
11080        lines.push(String::new());
11081        lines.push(format!(
11082            "Velocity (score: {}/100):",
11083            label.velocity.velocity_score
11084        ));
11085        lines.push(format!(
11086            "  Closed 7d: {} | 30d: {}",
11087            label.velocity.closed_last_7_days, label.velocity.closed_last_30_days
11088        ));
11089        if label.velocity.avg_days_to_close > 0.0 {
11090            lines.push(format!(
11091                "  Avg days to close: {:.1}",
11092                label.velocity.avg_days_to_close
11093            ));
11094        }
11095        lines.push(format!(
11096            "  Trend: {} ({:+.0}%)",
11097            label.velocity.trend_direction, label.velocity.trend_percent
11098        ));
11099
11100        // Freshness
11101        lines.push(String::new());
11102        lines.push(format!(
11103            "Freshness (score: {}/100):",
11104            label.freshness.freshness_score
11105        ));
11106        lines.push(format!(
11107            "  Avg days since update: {:.1}",
11108            label.freshness.avg_days_since_update
11109        ));
11110        lines.push(format!(
11111            "  Stale: {} (threshold: {}d)",
11112            label.freshness.stale_count, label.freshness.stale_threshold_days
11113        ));
11114
11115        // Flow
11116        lines.push(String::new());
11117        lines.push(format!("Flow (score: {}/100):", label.flow.flow_score));
11118        lines.push(format!(
11119            "  Deps in: {} | out: {}",
11120            label.flow.incoming_deps, label.flow.outgoing_deps
11121        ));
11122        lines.push(format!(
11123            "  Blocked by external: {} | Blocking external: {}",
11124            label.flow.blocked_by_external, label.flow.blocking_external
11125        ));
11126
11127        // Criticality
11128        lines.push(String::new());
11129        lines.push(format!(
11130            "Criticality (score: {}/100):",
11131            label.criticality.criticality_score
11132        ));
11133        lines.push(format!(
11134            "  Avg PageRank: {:.4} | Avg betweenness: {:.4}",
11135            label.criticality.avg_pagerank, label.criticality.avg_betweenness
11136        ));
11137        lines.push(format!(
11138            "  Critical paths: {} | Bottlenecks: {}",
11139            label.criticality.critical_path_count, label.criticality.bottleneck_count
11140        ));
11141
11142        // Issues list
11143        if !label.issues.is_empty() {
11144            lines.push(String::new());
11145            lines.push(format!("Issues ({}):", label.issues.len()));
11146            for id in &label.issues {
11147                let title = self
11148                    .analyzer
11149                    .issues
11150                    .iter()
11151                    .find(|i| i.id == *id)
11152                    .map(|i| i.title.as_str())
11153                    .unwrap_or("?");
11154                lines.push(format!("  {id} - {title}"));
11155            }
11156        }
11157
11158        lines.join("\n")
11159    }
11160
11161    // -- end LabelDashboard view ------------------------------------------
11162
11163    // -- FlowMatrix view ---------------------------------------------------
11164
11165    fn toggle_flow_matrix(&mut self) {
11166        if matches!(self.mode, ViewMode::FlowMatrix) {
11167            self.mode = ViewMode::Main;
11168        } else {
11169            self.mode = ViewMode::FlowMatrix;
11170            self.flow_matrix_row_cursor = 0;
11171            self.flow_matrix_col_cursor = 0;
11172            self.compute_flow_matrix();
11173        }
11174    }
11175
11176    fn compute_flow_matrix(&mut self) {
11177        use crate::analysis::label_intel::compute_cross_label_flow;
11178        let result = compute_cross_label_flow(&self.analyzer.issues);
11179        self.flow_matrix = Some(result);
11180    }
11181
11182    fn flow_matrix_list_text(&self) -> String {
11183        let Some(flow) = &self.flow_matrix else {
11184            return "(computing flow matrix...)".to_string();
11185        };
11186
11187        if flow.labels.is_empty() {
11188            return "(no labels found — flow matrix empty)".to_string();
11189        }
11190
11191        let labels = &flow.labels;
11192        let matrix = &flow.flow_matrix;
11193        let mut lines = Vec::new();
11194
11195        // Header: summary
11196        lines.push(format!(
11197            "Cross-label flow ({} labels, {} deps) | bottlenecks: {}",
11198            labels.len(),
11199            flow.total_cross_label_deps,
11200            if flow.bottleneck_labels.is_empty() {
11201                "none".to_string()
11202            } else {
11203                flow.bottleneck_labels.join(", ")
11204            }
11205        ));
11206        lines.push(String::new());
11207
11208        // Compute column width (label name + padding)
11209        let max_label_width = labels.iter().map(|l| display_width(l)).max().unwrap_or(4);
11210        let col_w = max_label_width.max(4);
11211
11212        // Column headers
11213        let row_label_w = col_w + 2;
11214        let mut header = " ".repeat(row_label_w);
11215        for (ci, label) in labels.iter().enumerate() {
11216            let marker = if ci == self.flow_matrix_col_cursor {
11217                "v"
11218            } else {
11219                " "
11220            };
11221            header.push(' ');
11222            header.push_str(marker);
11223            header.push_str(&fit_display(label, col_w));
11224        }
11225        lines.push(header);
11226
11227        // Separator
11228        let sep_len = row_label_w + labels.len() * (col_w + 2);
11229        lines.push("-".repeat(sep_len));
11230
11231        // Rows
11232        for (ri, label) in labels.iter().enumerate() {
11233            let cursor = if ri == self.flow_matrix_row_cursor {
11234                ">"
11235            } else {
11236                " "
11237            };
11238            let mut row = format!("{cursor} {}", fit_display(label, col_w));
11239            for (ci, val) in matrix[ri].iter().enumerate() {
11240                let cell = if ri == ci {
11241                    " .".to_string()
11242                } else if *val == 0 {
11243                    " -".to_string()
11244                } else {
11245                    format!(" {val}")
11246                };
11247                let highlight = ri == self.flow_matrix_row_cursor
11248                    && ci == self.flow_matrix_col_cursor
11249                    && ri != ci;
11250                if highlight {
11251                    row.push('[');
11252                    row.push_str(&fit_display(cell.trim(), col_w - 1));
11253                    row.push(']');
11254                } else {
11255                    row.push(' ');
11256                    row.push_str(&fit_display(cell.trim(), col_w));
11257                }
11258            }
11259            lines.push(row);
11260        }
11261
11262        lines.push(String::new());
11263        lines.push("j/k rows | h/l cols | Tab focus | ] or Esc back".to_string());
11264
11265        lines.join("\n")
11266    }
11267
11268    fn flow_matrix_detail_text(&self) -> String {
11269        let Some(flow) = &self.flow_matrix else {
11270            return "(no flow data)".to_string();
11271        };
11272
11273        if flow.labels.is_empty() {
11274            return "(no labels)".to_string();
11275        }
11276
11277        let labels = &flow.labels;
11278        let row = self
11279            .flow_matrix_row_cursor
11280            .min(labels.len().saturating_sub(1));
11281        let col = self
11282            .flow_matrix_col_cursor
11283            .min(labels.len().saturating_sub(1));
11284        let from_label = &labels[row];
11285        let to_label = &labels[col];
11286
11287        let mut lines = Vec::new();
11288
11289        if row == col {
11290            lines.push(format!("Label: {from_label}"));
11291            lines.push(String::new());
11292            // Show issues with this label
11293            let issue_ids: Vec<_> = self
11294                .analyzer
11295                .issues
11296                .iter()
11297                .filter(|i| {
11298                    i.labels
11299                        .iter()
11300                        .any(|candidate| candidate.eq_ignore_ascii_case(from_label))
11301                })
11302                .collect();
11303            lines.push(format!("Issues with this label: {}", issue_ids.len()));
11304            for issue in &issue_ids {
11305                lines.push(format!("  {} - {}", issue.id, issue.title));
11306            }
11307        } else {
11308            let flow_val = flow.flow_matrix[row][col];
11309            lines.push(format!("{from_label} -> {to_label}: {flow_val} deps"));
11310            lines.push(String::new());
11311
11312            // Find matching dependency entries
11313            let matching: Vec<_> = flow
11314                .dependencies
11315                .iter()
11316                .filter(|d| d.from_label == *from_label && d.to_label == *to_label)
11317                .collect();
11318
11319            if matching.is_empty() && flow_val == 0 {
11320                lines.push("No cross-label dependencies in this direction.".to_string());
11321            } else {
11322                for dep in &matching {
11323                    lines.push(format!(
11324                        "{} -> {} ({} issues)",
11325                        dep.from_label, dep.to_label, dep.issue_count
11326                    ));
11327                    for id in &dep.issue_ids {
11328                        let title = self
11329                            .analyzer
11330                            .issues
11331                            .iter()
11332                            .find(|i| i.id == *id)
11333                            .map(|i| i.title.as_str())
11334                            .unwrap_or("?");
11335                        lines.push(format!("  {id} - {title}"));
11336                    }
11337                }
11338            }
11339        }
11340
11341        // Bottleneck info
11342        if !flow.bottleneck_labels.is_empty() {
11343            lines.push(String::new());
11344            lines.push(format!(
11345                "Bottleneck labels: {}",
11346                flow.bottleneck_labels.join(", ")
11347            ));
11348        }
11349
11350        lines.join("\n")
11351    }
11352
11353    // -- end FlowMatrix view -----------------------------------------------
11354
11355    // -- TimeTravelDiff view -------------------------------------------------
11356
11357    fn toggle_time_travel_mode(&mut self) {
11358        if matches!(self.mode, ViewMode::TimeTravelDiff) {
11359            self.mode = ViewMode::Main;
11360            self.focus = FocusPane::List;
11361        } else {
11362            self.mode = ViewMode::TimeTravelDiff;
11363            self.focus = FocusPane::List;
11364            if self.time_travel_diff.is_none() {
11365                // If no diff loaded yet, prompt for a ref
11366                self.time_travel_input_active = true;
11367                self.time_travel_ref_input.clear();
11368            }
11369        }
11370    }
11371
11372    fn execute_time_travel(&mut self) {
11373        let reference = self.time_travel_ref_input.trim().to_string();
11374        if reference.is_empty() {
11375            self.status_msg = "Time-travel: empty ref, cancelled".into();
11376            self.time_travel_input_active = false;
11377            if self.time_travel_diff.is_none() {
11378                self.mode = ViewMode::Main;
11379                self.focus = FocusPane::List;
11380            }
11381            return;
11382        }
11383
11384        self.time_travel_input_active = false;
11385        self.time_travel_last_ref = Some(reference.clone());
11386
11387        // Try to load historical snapshot
11388        match self.load_time_travel_diff(&reference) {
11389            Ok(diff) => {
11390                self.time_travel_diff = Some(diff);
11391                self.time_travel_category_cursor = 0;
11392                self.time_travel_issue_cursor = 0;
11393                self.status_msg = format!("Time-travel: loaded diff from {reference}");
11394            }
11395            Err(err) => {
11396                self.status_msg = format!("Time-travel: {err}");
11397            }
11398        }
11399    }
11400
11401    fn load_time_travel_diff(
11402        &self,
11403        reference: &str,
11404    ) -> std::result::Result<crate::analysis::diff::SnapshotDiff, String> {
11405        // Try file path first
11406        let path = std::path::Path::new(reference);
11407        if path.is_file() {
11408            let before = crate::loader::load_issues_from_file(path)
11409                .map_err(|e| format!("load file: {e}"))?;
11410            return Ok(crate::analysis::diff::compare_snapshots(
11411                &before,
11412                &self.analyzer.issues,
11413            ));
11414        }
11415
11416        // Try repo-relative path
11417        if let Some(ref root) = self.repo_root {
11418            let rooted = root.join(reference);
11419            if rooted.is_file() {
11420                let before = crate::loader::load_issues_from_file(&rooted)
11421                    .map_err(|e| format!("load file: {e}"))?;
11422                return Ok(crate::analysis::diff::compare_snapshots(
11423                    &before,
11424                    &self.analyzer.issues,
11425                ));
11426            }
11427        }
11428
11429        // Try git ref
11430        let repo_root = self
11431            .repo_root
11432            .as_deref()
11433            .unwrap_or_else(|| std::path::Path::new("."));
11434        let repo_root_str = repo_root.to_string_lossy();
11435
11436        // Find beads file candidates
11437        let candidates = [".beads/issues.jsonl", ".beads/beads.jsonl"];
11438        for candidate in &candidates {
11439            let output = std::process::Command::new("git")
11440                .args([
11441                    "-C",
11442                    &repo_root_str,
11443                    "show",
11444                    &format!("{reference}:{candidate}"),
11445                ])
11446                .output()
11447                .map_err(|e| format!("git show: {e}"))?;
11448
11449            if output.status.success() {
11450                let content = String::from_utf8_lossy(&output.stdout);
11451                let before = crate::loader::parse_issues_from_text(&content)
11452                    .map_err(|e| format!("parse: {e}"))?;
11453                return Ok(crate::analysis::diff::compare_snapshots(
11454                    &before,
11455                    &self.analyzer.issues,
11456                ));
11457            }
11458        }
11459
11460        Err(format!(
11461            "could not resolve '{reference}' as file or git ref"
11462        ))
11463    }
11464
11465    fn time_travel_categories(&self) -> Vec<(&str, usize)> {
11466        let Some(ref diff) = self.time_travel_diff else {
11467            return Vec::new();
11468        };
11469        let mut cats = Vec::new();
11470        let new_count = diff.new_issues.as_ref().map_or(0, |v| v.len());
11471        let closed_count = diff.closed_issues.as_ref().map_or(0, |v| v.len());
11472        let removed_count = diff.removed_issues.as_ref().map_or(0, |v| v.len());
11473        let reopened_count = diff.reopened_issues.as_ref().map_or(0, |v| v.len());
11474        let modified_count = diff.modified_issues.as_ref().map_or(0, |v| v.len());
11475        let new_cycles = diff.new_cycles.as_ref().map_or(0, |v| v.len());
11476        let resolved_cycles = diff.resolved_cycles.as_ref().map_or(0, |v| v.len());
11477
11478        if new_count > 0 {
11479            cats.push(("New issues", new_count));
11480        }
11481        if closed_count > 0 {
11482            cats.push(("Closed issues", closed_count));
11483        }
11484        if removed_count > 0 {
11485            cats.push(("Removed issues", removed_count));
11486        }
11487        if reopened_count > 0 {
11488            cats.push(("Reopened issues", reopened_count));
11489        }
11490        if modified_count > 0 {
11491            cats.push(("Modified issues", modified_count));
11492        }
11493        if new_cycles > 0 {
11494            cats.push(("New cycles", new_cycles));
11495        }
11496        if resolved_cycles > 0 {
11497            cats.push(("Resolved cycles", resolved_cycles));
11498        }
11499        cats
11500    }
11501
11502    fn time_travel_list_text(&self) -> String {
11503        let Some(ref diff) = self.time_travel_diff else {
11504            if self.time_travel_input_active {
11505                return format!(
11506                    " Enter git ref or file path:\n > {}_",
11507                    self.time_travel_ref_input
11508                );
11509            }
11510            return " No diff loaded. Press t to enter a ref.".to_string();
11511        };
11512
11513        let mut lines = Vec::new();
11514        let ref_label = self.time_travel_last_ref.as_deref().unwrap_or("unknown");
11515        lines.push(format!(" Diff from: {ref_label}"));
11516        lines.push(format!(" Summary: {} changes", diff.summary.total_changes));
11517        lines.push(String::new());
11518
11519        let cats = self.time_travel_categories();
11520        if cats.is_empty() {
11521            lines.push(" (no changes detected)".to_string());
11522        } else {
11523            for (i, (label, count)) in cats.iter().enumerate() {
11524                let marker = if i == self.time_travel_category_cursor {
11525                    ">"
11526                } else {
11527                    " "
11528                };
11529                lines.push(format!(" {marker} {label} ({count})"));
11530            }
11531        }
11532
11533        // Metric deltas summary
11534        lines.push(String::new());
11535        lines.push(" METRIC DELTAS".to_string());
11536        let md = &diff.metric_deltas;
11537        if md.total_issues != 0 {
11538            lines.push(format!("   Total issues: {:+}", md.total_issues));
11539        }
11540        if md.open_issues != 0 {
11541            lines.push(format!("   Open issues:  {:+}", md.open_issues));
11542        }
11543        if md.blocked_issues != 0 {
11544            lines.push(format!("   Blocked:      {:+}", md.blocked_issues));
11545        }
11546        if md.total_edges != 0 {
11547            lines.push(format!("   Edges:        {:+}", md.total_edges));
11548        }
11549        if md.cycle_count != 0 {
11550            lines.push(format!("   Cycles:       {:+}", md.cycle_count));
11551        }
11552
11553        lines.join("\n")
11554    }
11555
11556    fn time_travel_detail_text(&self) -> String {
11557        let Some(ref diff) = self.time_travel_diff else {
11558            return String::new();
11559        };
11560
11561        let cats = self.time_travel_categories();
11562        if cats.is_empty() {
11563            return " No changes in this diff.".to_string();
11564        }
11565
11566        let cat_idx = self
11567            .time_travel_category_cursor
11568            .min(cats.len().saturating_sub(1));
11569        let (label, _) = cats[cat_idx];
11570
11571        let mut lines = Vec::new();
11572        lines.push(format!(" {label}"));
11573        lines.push(String::new());
11574
11575        match label {
11576            "New issues" => {
11577                if let Some(ref issues) = diff.new_issues {
11578                    for (i, di) in issues.iter().enumerate() {
11579                        let marker = if i == self.time_travel_issue_cursor {
11580                            ">"
11581                        } else {
11582                            " "
11583                        };
11584                        lines.push(format!(" {marker} {} [{}] {}", di.id, di.status, di.title));
11585                    }
11586                }
11587            }
11588            "Closed issues" => {
11589                if let Some(ref issues) = diff.closed_issues {
11590                    for (i, di) in issues.iter().enumerate() {
11591                        let marker = if i == self.time_travel_issue_cursor {
11592                            ">"
11593                        } else {
11594                            " "
11595                        };
11596                        lines.push(format!(" {marker} {} [{}] {}", di.id, di.status, di.title));
11597                    }
11598                }
11599            }
11600            "Removed issues" => {
11601                if let Some(ref issues) = diff.removed_issues {
11602                    for (i, di) in issues.iter().enumerate() {
11603                        let marker = if i == self.time_travel_issue_cursor {
11604                            ">"
11605                        } else {
11606                            " "
11607                        };
11608                        lines.push(format!(" {marker} {} [{}] {}", di.id, di.status, di.title));
11609                    }
11610                }
11611            }
11612            "Reopened issues" => {
11613                if let Some(ref issues) = diff.reopened_issues {
11614                    for (i, di) in issues.iter().enumerate() {
11615                        let marker = if i == self.time_travel_issue_cursor {
11616                            ">"
11617                        } else {
11618                            " "
11619                        };
11620                        lines.push(format!(" {marker} {} [{}] {}", di.id, di.status, di.title));
11621                    }
11622                }
11623            }
11624            "Modified issues" => {
11625                if let Some(ref issues) = diff.modified_issues {
11626                    for (i, mi) in issues.iter().enumerate() {
11627                        let marker = if i == self.time_travel_issue_cursor {
11628                            ">"
11629                        } else {
11630                            " "
11631                        };
11632                        let field_changes: Vec<&str> =
11633                            mi.changes.iter().map(|c| c.field.as_str()).collect();
11634                        lines.push(format!(
11635                            " {marker} {} ({} fields: {})",
11636                            mi.issue_id,
11637                            mi.changes.len(),
11638                            field_changes.join(", ")
11639                        ));
11640                    }
11641                }
11642            }
11643            "New cycles" => {
11644                if let Some(ref cycles) = diff.new_cycles {
11645                    for (i, cycle) in cycles.iter().enumerate() {
11646                        let marker = if i == self.time_travel_issue_cursor {
11647                            ">"
11648                        } else {
11649                            " "
11650                        };
11651                        lines.push(format!(" {marker} [{}]", cycle.join(" -> ")));
11652                    }
11653                }
11654            }
11655            "Resolved cycles" => {
11656                if let Some(ref cycles) = diff.resolved_cycles {
11657                    for (i, cycle) in cycles.iter().enumerate() {
11658                        let marker = if i == self.time_travel_issue_cursor {
11659                            ">"
11660                        } else {
11661                            " "
11662                        };
11663                        lines.push(format!(" {marker} [{}]", cycle.join(" -> ")));
11664                    }
11665                }
11666            }
11667            _ => {}
11668        }
11669
11670        lines.join("\n")
11671    }
11672
11673    // -- end TimeTravelDiff view ---------------------------------------------
11674
11675    // -- Sprint view ---------------------------------------------------------
11676
11677    fn toggle_sprint_mode(&mut self) {
11678        if matches!(self.mode, ViewMode::Sprint) {
11679            self.mode = ViewMode::Main;
11680        } else {
11681            self.load_sprint_data();
11682            self.mode = ViewMode::Sprint;
11683            self.sprint_cursor = 0;
11684            self.sprint_issue_cursor = 0;
11685        }
11686    }
11687
11688    fn load_sprint_data(&mut self) {
11689        self.sprint_data = loader::load_sprints(self.repo_root.as_deref()).unwrap_or_default();
11690    }
11691
11692    fn sprint_visible_issues(&self) -> Vec<(usize, &Issue)> {
11693        let sprint = match self.sprint_data.get(self.sprint_cursor) {
11694            Some(s) => s,
11695            None => return Vec::new(),
11696        };
11697        sprint
11698            .bead_ids
11699            .iter()
11700            .filter_map(|bead_id| {
11701                self.analyzer
11702                    .issues
11703                    .iter()
11704                    .enumerate()
11705                    .find(|(_, issue)| issue.id == *bead_id)
11706            })
11707            .collect()
11708    }
11709
11710    fn sprint_list_text(&self) -> String {
11711        if self.sprint_data.is_empty() {
11712            return " No sprints found.\n\n \
11713                    Sprints are defined in .beads/sprints.jsonl\n \
11714                    Each line: {\"id\":\"sprint-1\",\"name\":\"Sprint Alpha\",\n \
11715                    \"start_date\":\"...\",\"end_date\":\"...\",\"bead_ids\":[...]}"
11716                .to_string();
11717        }
11718
11719        let now = sprint_reference_now();
11720        let mut lines = Vec::new();
11721        lines.push(format!(" {} sprint(s)", self.sprint_data.len()));
11722        lines.push(String::new());
11723
11724        for (i, sprint) in self.sprint_data.iter().enumerate() {
11725            let marker = if i == self.sprint_cursor { "▸" } else { " " };
11726            let active = if sprint.is_active_at(now) {
11727                " [ACTIVE]"
11728            } else {
11729                ""
11730            };
11731            let issue_count = sprint.bead_ids.len();
11732            let dates = match (&sprint.start_date, &sprint.end_date) {
11733                (Some(start), Some(end)) => {
11734                    format!("{} → {}", start.format("%Y-%m-%d"), end.format("%Y-%m-%d"))
11735                }
11736                _ => "no dates".to_string(),
11737            };
11738            lines.push(format!(
11739                " {marker} {} | {} issues | {dates}{active}",
11740                sprint.name, issue_count
11741            ));
11742
11743            // Show mini-progress
11744            let matched_issues = sprint
11745                .bead_ids
11746                .iter()
11747                .filter(|id| self.analyzer.issues.iter().any(|issue| issue.id == **id))
11748                .count();
11749            let closed = sprint
11750                .bead_ids
11751                .iter()
11752                .filter(|id| {
11753                    self.analyzer
11754                        .issues
11755                        .iter()
11756                        .any(|issue| issue.id == **id && issue.is_closed_like())
11757                })
11758                .count();
11759            if matched_issues > 0 {
11760                let pct = (closed as f64 / matched_issues as f64 * 100.0) as u32;
11761                lines.push(format!("   {closed}/{matched_issues} done ({pct}%)"));
11762            }
11763        }
11764
11765        lines.join("\n")
11766    }
11767
11768    fn sprint_detail_text(&self) -> String {
11769        let sprint = match self.sprint_data.get(self.sprint_cursor) {
11770            Some(s) => s,
11771            None => return " Select a sprint from the list.".to_string(),
11772        };
11773
11774        let now = sprint_reference_now();
11775        let mut lines = Vec::new();
11776
11777        // Sprint header
11778        lines.push(format!(" SPRINT: {}", sprint.name));
11779        if sprint.is_active_at(now) {
11780            lines.push(" Status: ACTIVE".to_string());
11781        } else if sprint.end_date.is_some_and(|end| end < now) {
11782            lines.push(" Status: Completed".to_string());
11783        } else {
11784            lines.push(" Status: Upcoming".to_string());
11785        }
11786
11787        if let (Some(start), Some(end)) = (&sprint.start_date, &sprint.end_date) {
11788            lines.push(format!(
11789                " Dates: {} → {}",
11790                start.format("%Y-%m-%d"),
11791                end.format("%Y-%m-%d")
11792            ));
11793            let total_days = (*end - *start).num_days();
11794            let elapsed = (now - *start).num_days().max(0).min(total_days);
11795            let remaining = total_days - elapsed;
11796            lines.push(format!(
11797                " Days: {total_days} total, {elapsed} elapsed, {remaining} remaining"
11798            ));
11799        }
11800        lines.push(String::new());
11801
11802        // Issue list
11803        let visible = self.sprint_visible_issues();
11804        if visible.is_empty() {
11805            lines.push(format!(
11806                " {} bead(s) assigned, none matched loaded issues.",
11807                sprint.bead_ids.len()
11808            ));
11809        } else {
11810            let total = visible.len();
11811            let closed = visible
11812                .iter()
11813                .filter(|(_, issue)| issue.is_closed_like())
11814                .count();
11815            let open = total - closed;
11816            lines.push(format!(
11817                " Issues: {total} total, {open} open, {closed} closed"
11818            ));
11819            lines.push(String::new());
11820
11821            // Sorted: open first (by priority), then closed
11822            let mut sorted: Vec<_> = visible.clone();
11823            sorted.sort_by(|(_, a), (_, b)| {
11824                let a_closed = a.is_closed_like();
11825                let b_closed = b.is_closed_like();
11826                a_closed.cmp(&b_closed).then(a.priority.cmp(&b.priority))
11827            });
11828
11829            for (i, (_, issue)) in sorted.iter().enumerate() {
11830                let marker = if i == self.sprint_issue_cursor {
11831                    "▸"
11832                } else {
11833                    " "
11834                };
11835                let status_icon = if issue.is_closed_like() {
11836                    "✓"
11837                } else if issue.status == "in_progress" {
11838                    "●"
11839                } else if issue.status == "blocked" {
11840                    "✗"
11841                } else {
11842                    "○"
11843                };
11844                lines.push(format!(
11845                    " {marker} {status_icon} {} [P{}] {}",
11846                    issue.id, issue.priority, issue.title
11847                ));
11848            }
11849
11850            // Burndown summary (ASCII bar)
11851            lines.push(String::new());
11852            if total > 0 {
11853                let pct = (closed as f64 / total as f64 * 100.0) as usize;
11854                let filled = pct / 5;
11855                let empty = 20 - filled;
11856                let bar = format!("[{}{}] {}%", "█".repeat(filled), "░".repeat(empty), pct);
11857                lines.push(format!(" Progress: {bar}"));
11858            }
11859        }
11860
11861        // Sprint action commands
11862        lines.push(String::new());
11863        lines.push(" ─── Sprint Actions ───".to_string());
11864        lines.push(format!(" Claim next:  br update <id> --status=in_progress"));
11865        lines.push(format!(" Close issue: br close <id> --reason \"done\""));
11866        if !sprint.bead_ids.is_empty() {
11867            let first_open = self
11868                .sprint_visible_issues()
11869                .iter()
11870                .find(|(_, i)| i.is_open_like())
11871                .map(|(_, i)| i.id.clone());
11872            if let Some(next_id) = first_open {
11873                lines.push(format!(
11874                    " Suggested:   br update {next_id} --status=in_progress"
11875                ));
11876            }
11877        }
11878
11879        lines.join("\n")
11880    }
11881
11882    // -- end Sprint view -----------------------------------------------------
11883
11884    // -- Modal pickers -------------------------------------------------------
11885
11886    fn open_recipe_picker(&mut self) {
11887        let recipes = crate::analysis::recipe::list_recipes();
11888        let items: Vec<(String, String)> = recipes
11889            .into_iter()
11890            .map(|r| (r.name, r.description))
11891            .collect();
11892        self.modal_overlay = Some(ModalOverlay::RecipePicker { items, cursor: 0 });
11893    }
11894
11895    fn open_label_picker(&mut self) {
11896        let mut label_counts = BTreeMap::<String, usize>::new();
11897        for issue in &self.analyzer.issues {
11898            for label in &issue.labels {
11899                *label_counts.entry(label.clone()).or_insert(0) += 1;
11900            }
11901        }
11902        let items: Vec<(String, usize)> = label_counts.into_iter().collect();
11903        self.modal_overlay = Some(ModalOverlay::LabelPicker {
11904            items,
11905            cursor: 0,
11906            filter: String::new(),
11907        });
11908    }
11909
11910    fn open_repo_picker(&mut self) {
11911        let mut repos = std::collections::HashSet::<String>::new();
11912        for issue in &self.analyzer.issues {
11913            if !issue.source_repo.is_empty() {
11914                repos.insert(issue.source_repo.clone());
11915            }
11916        }
11917        let mut items: Vec<String> = repos.into_iter().collect();
11918        items.sort();
11919        if items.is_empty() {
11920            self.status_msg = "No workspace repos loaded".into();
11921            return;
11922        }
11923        self.modal_overlay = Some(ModalOverlay::RepoPicker {
11924            items,
11925            cursor: 0,
11926            filter: String::new(),
11927        });
11928    }
11929
11930    fn set_label_filter(&mut self, label: &str) {
11931        if self
11932            .modal_label_filter
11933            .as_deref()
11934            .is_some_and(|current| current.eq_ignore_ascii_case(label))
11935        {
11936            self.modal_label_filter = None;
11937            self.status_msg = "Label filter cleared".into();
11938        } else {
11939            self.modal_label_filter = Some(label.to_string());
11940            self.status_msg = format!("Filtering by label: {label}");
11941        }
11942        self.list_scroll_offset.set(0);
11943        self.ensure_selected_visible();
11944        self.sync_insights_heatmap_selection();
11945        self.focus = FocusPane::List;
11946        self.rebuild_tree_if_active();
11947    }
11948
11949    fn set_repo_filter(&mut self, repo: &str) {
11950        if self
11951            .modal_repo_filter
11952            .as_deref()
11953            .is_some_and(|current| current == repo)
11954        {
11955            self.modal_repo_filter = None;
11956            self.status_msg = "Repo filter cleared".into();
11957        } else {
11958            self.modal_repo_filter = Some(repo.to_string());
11959            self.status_msg = format!("Filtering by repo: {repo}");
11960        }
11961        self.list_scroll_offset.set(0);
11962        self.ensure_selected_visible();
11963        self.sync_insights_heatmap_selection();
11964        self.focus = FocusPane::List;
11965        self.rebuild_tree_if_active();
11966    }
11967
11968    // -- end Modal pickers ---------------------------------------------------
11969
11970    fn detail_panel_text(&self) -> String {
11971        match self.mode {
11972            ViewMode::Board => self.board_detail_text(),
11973            ViewMode::Insights => self.insights_detail_text(),
11974            ViewMode::Graph => self.graph_detail_text(),
11975            ViewMode::History => self.history_detail_text(),
11976            ViewMode::Actionable => self.actionable_detail_text(),
11977            ViewMode::Attention => self.attention_detail_text(),
11978            ViewMode::Tree => self.tree_detail_text(),
11979            ViewMode::LabelDashboard => self.label_dashboard_detail_text(),
11980            ViewMode::FlowMatrix => self.flow_matrix_detail_text(),
11981            ViewMode::TimeTravelDiff => self.time_travel_detail_text(),
11982            ViewMode::Sprint => self.sprint_detail_text(),
11983            ViewMode::Main => self.issue_detail_text(),
11984        }
11985    }
11986
11987    fn detail_panel_render_text(&self) -> RichText {
11988        match self.mode {
11989            ViewMode::Main => self.issue_detail_render_text(),
11990            ViewMode::Insights => self.insights_detail_render_text(),
11991            ViewMode::Graph => self.graph_detail_render_text(),
11992            ViewMode::History => self.history_detail_render_text(),
11993            _ => RichText::raw(self.detail_panel_text()),
11994        }
11995    }
11996
11997    fn issue_detail_render_text(&self) -> RichText {
11998        let Some(issue) = self.selected_issue() else {
11999            return RichText::raw(self.issue_detail_text());
12000        };
12001
12002        let blockers = self.analyzer.graph.blockers(&issue.id);
12003        let open_blockers = self.analyzer.graph.open_blockers(&issue.id);
12004        let dependents = self.analyzer.graph.dependents(&issue.id);
12005        let pagerank = self
12006            .analyzer
12007            .metrics
12008            .pagerank
12009            .get(&issue.id)
12010            .copied()
12011            .unwrap_or_default();
12012        let betweenness = self
12013            .analyzer
12014            .metrics
12015            .betweenness
12016            .get(&issue.id)
12017            .copied()
12018            .unwrap_or_default();
12019        let eigenvector = self
12020            .analyzer
12021            .metrics
12022            .eigenvector
12023            .get(&issue.id)
12024            .copied()
12025            .unwrap_or_default();
12026        let k_core = self
12027            .analyzer
12028            .metrics
12029            .k_core
12030            .get(&issue.id)
12031            .copied()
12032            .unwrap_or_default();
12033        let slack = self
12034            .analyzer
12035            .metrics
12036            .slack
12037            .get(&issue.id)
12038            .copied()
12039            .unwrap_or_default();
12040        let depth = self
12041            .analyzer
12042            .metrics
12043            .critical_depth
12044            .get(&issue.id)
12045            .copied()
12046            .unwrap_or_default();
12047        let articulation = self
12048            .analyzer
12049            .metrics
12050            .articulation_points
12051            .contains(&issue.id);
12052        let pr_max = max_metric_value(&self.analyzer.metrics.pagerank);
12053        let bw_max = max_metric_value(&self.analyzer.metrics.betweenness);
12054        let ev_max = max_metric_value(&self.analyzer.metrics.eigenvector);
12055        let history = self.analyzer.history(Some(&issue.id), 1).into_iter().next();
12056        let action_state = if issue.is_closed_like() {
12057            "closed"
12058        } else if open_blockers.is_empty() {
12059            "ready"
12060        } else {
12061            "blocked"
12062        };
12063        let action_subtitle = match action_state {
12064            "closed" => "reference state",
12065            "ready" => "ready to execute",
12066            _ => "waiting on blockers",
12067        };
12068        let action_line = if issue.is_closed_like() {
12069            format!(
12070                "Action: Closed work item | Downstream still watching: {}",
12071                dependents.len()
12072            )
12073        } else if open_blockers.is_empty() {
12074            format!(
12075                "Action: Pull now | Downstream impact: {} | Critical depth: {}",
12076                dependents.len(),
12077                depth
12078            )
12079        } else {
12080            format!(
12081                "Action: Unblock first via {} | Open blockers: {} | Downstream impact: {}",
12082                join_display_values(&open_blockers, 3),
12083                open_blockers.len(),
12084                dependents.len()
12085            )
12086        };
12087        let status_line = format!(
12088            "Status: {} | Priority: p{} | Type: {} | State: {}",
12089            issue.status,
12090            issue.priority,
12091            display_or_fallback(&issue.issue_type, "unknown"),
12092            action_state
12093        );
12094        let context_line = format!(
12095            "Assignee: {} | Repo: {} | Estimate: {}",
12096            display_or_fallback(&issue.assignee, "unassigned"),
12097            display_or_fallback(&issue.source_repo, "local"),
12098            issue
12099                .estimated_minutes
12100                .map_or_else(|| "n/a".to_string(), |minutes| format!("{minutes}m"))
12101        );
12102        let closed_display = if issue.is_closed_like() {
12103            format_compact_timestamp(issue.closed_at.or(issue.updated_at))
12104        } else {
12105            "n/a".to_string()
12106        };
12107        let timeline_line = format!(
12108            "Created: {} | Updated: {} | Due: {}",
12109            format_compact_timestamp(issue.created_at),
12110            format_compact_timestamp(issue.updated_at),
12111            format_compact_timestamp(issue.due_date)
12112        );
12113        let signal_summary = format!(
12114            "Depth {depth} | k-core {k_core} | slack {slack:.4} | cut-point {}",
12115            if articulation { "YES" } else { "no" }
12116        );
12117        let external_ref = self.selected_issue_external_ref_url();
12118
12119        let mut lines = Vec::new();
12120        let push_module_header = |lines: &mut Vec<RichLine>, title: &str, subtitle: &str| {
12121            if !lines.is_empty() {
12122                lines.push(RichLine::raw(""));
12123            }
12124            lines.push(section_separator(48));
12125            lines.push(panel_header(title, Some(subtitle)));
12126        };
12127
12128        push_module_header(&mut lines, "Summary", action_subtitle);
12129        lines.push(RichLine::from_spans([
12130            RichSpan::raw(format!(
12131                "{} {}  {}",
12132                type_icon(&issue.issue_type),
12133                issue.id,
12134                issue.title
12135            )),
12136            RichSpan::styled("  ", tokens::dim()),
12137            RichSpan::styled("(C copy id)", tokens::dim()),
12138        ]));
12139        if let Some(styled_line) = styled_detail_summary_line(&status_line) {
12140            lines.push(styled_line);
12141        }
12142        lines.push(RichLine::from_spans([RichSpan::styled(
12143            action_line,
12144            tokens::panel_title_focused(),
12145        )]));
12146        lines.push(RichLine::from_spans([
12147            RichSpan::raw(&context_line),
12148            RichSpan::styled("  ", tokens::dim()),
12149            RichSpan::styled("(w repo filter)", tokens::dim()),
12150        ]));
12151
12152        let mut labels_line = RichLine::new();
12153        labels_line.push_span(RichSpan::raw(format!(
12154            "Closed: {closed_display} | Labels: "
12155        )));
12156        if issue.labels.is_empty() {
12157            labels_line.push_span(RichSpan::styled("none", tokens::dim()));
12158        } else {
12159            for span in label_chips(&issue.labels) {
12160                labels_line.push_span(span);
12161            }
12162        }
12163        labels_line.push_span(RichSpan::styled("  ", tokens::dim()));
12164        labels_line.push_span(RichSpan::styled("(L label filter)", tokens::dim()));
12165        lines.push(labels_line);
12166        lines.push(RichLine::from_spans([
12167            RichSpan::raw(&timeline_line),
12168            RichSpan::styled("  ", tokens::dim()),
12169            RichSpan::styled("(t time-travel)", tokens::dim()),
12170        ]));
12171        if let Some(url) = external_ref {
12172            lines.push(RichLine::from_spans([
12173                RichSpan::raw("External: "),
12174                RichSpan::styled(url, tokens::panel_title_focused()).link(url),
12175                RichSpan::styled("  ", tokens::dim()),
12176                RichSpan::styled("(o open, y copy)", tokens::dim()),
12177            ]));
12178        }
12179
12180        push_module_header(&mut lines, "Signals", "rank and graph pressure");
12181        lines.push(RichLine::raw(signal_summary));
12182        let mut primary_metrics = RichLine::new();
12183        for span in metric_strip("PR", pagerank, pr_max) {
12184            primary_metrics.push_span(span);
12185        }
12186        primary_metrics.push_span(RichSpan::styled("  ", tokens::dim()));
12187        for span in metric_strip("BW", betweenness, bw_max) {
12188            primary_metrics.push_span(span);
12189        }
12190        lines.push(primary_metrics);
12191        let mut secondary_metrics = RichLine::new();
12192        for span in metric_strip("EV", eigenvector, ev_max) {
12193            secondary_metrics.push_span(span);
12194        }
12195        secondary_metrics.push_span(RichSpan::styled("  ", tokens::dim()));
12196        secondary_metrics.push_span(RichSpan::styled(
12197            format!(
12198                "blockers={} | unblocks={}",
12199                open_blockers.len(),
12200                dependents.len()
12201            ),
12202            tokens::dim(),
12203        ));
12204        lines.push(secondary_metrics);
12205
12206        push_module_header(&mut lines, "Dependencies", "upstream, gates, downstream");
12207        lines.push(RichLine::raw(format!(
12208            "Upstream: {}",
12209            join_display_values(&blockers, 4)
12210        )));
12211        lines.push(RichLine::raw(format!(
12212            "Open Gate: {}",
12213            join_display_values(&open_blockers, 4)
12214        )));
12215        lines.push(RichLine::raw(format!(
12216            "Downstream: {}",
12217            join_display_values(&dependents, 4)
12218        )));
12219
12220        if self.priority_hints_visible {
12221            push_module_header(&mut lines, "Priority Hints", "scoring rationale");
12222            let mut hint_lines = Vec::new();
12223            self.append_priority_hints(&mut hint_lines, issue);
12224            for line in hint_lines {
12225                if let Some(styled_line) = styled_detail_summary_line(&line) {
12226                    lines.push(styled_line);
12227                } else {
12228                    lines.push(RichLine::raw(line));
12229                }
12230            }
12231        }
12232
12233        for line in self.issue_detail_text().lines() {
12234            if matches!(
12235                line,
12236                "Triage Snapshot:" | "Graph Signals:" | "Dependency Map:"
12237            ) {
12238                continue;
12239            }
12240            if line.starts_with("  open blockers:")
12241                || line.starts_with("  unblocks:")
12242                || line.starts_with("  dependency pressure:")
12243                || line.starts_with("  cycle time:")
12244                || line.starts_with("  Critical depth:")
12245                || line.starts_with("  PageRank:")
12246                || line.starts_with("  Betweenness:")
12247                || line.starts_with("  Eigenvector:")
12248                || line.starts_with("  HITS:")
12249                || line.starts_with("  upstream:")
12250                || line.starts_with("  open gate:")
12251                || line.starts_with("  downstream:")
12252                || line == status_line
12253                || line == context_line
12254                || line == timeline_line
12255                || line.starts_with("Closed: ")
12256                || line.starts_with("External: ")
12257                || line
12258                    == format!(
12259                        "{} {}  {}",
12260                        type_icon(&issue.issue_type),
12261                        issue.id,
12262                        issue.title
12263                    )
12264            {
12265                continue;
12266            }
12267
12268            if line.ends_with(':') && !line.is_empty() {
12269                lines.push(RichLine::raw(""));
12270                lines.push(section_separator(48));
12271                lines.push(panel_header(line.trim_end_matches(':'), None));
12272            } else if let Some(styled_line) = styled_detail_summary_line(line) {
12273                lines.push(styled_line);
12274            } else {
12275                lines.push(RichLine::raw(line));
12276            }
12277        }
12278
12279        if let Some(history) = history.as_ref()
12280            && !history.events.is_empty()
12281            && !self.issue_detail_text().contains(&format!(
12282                "History Summary ({} events):",
12283                history.events.len()
12284            ))
12285        {
12286            lines.push(RichLine::raw(""));
12287            lines.push(section_separator(48));
12288            lines.push(panel_header("History Summary", Some("recent lifecycle")));
12289        }
12290
12291        RichText::from_lines(lines)
12292    }
12293
12294    fn graph_detail_render_text(&self) -> RichText {
12295        let external_ref = self.selected_issue_external_ref_url();
12296        let graph_link_insert_after = 4usize;
12297        let mut lines = Vec::new();
12298        for (index, line) in self.graph_detail_text().lines().enumerate() {
12299            if let Some(url) = external_ref
12300                && index == graph_link_insert_after
12301            {
12302                lines.push(RichLine::from_spans([
12303                    RichSpan::raw("External Link: "),
12304                    RichSpan::styled(url, tokens::panel_title_focused()).link(url),
12305                    RichSpan::styled("  ", tokens::dim()),
12306                    RichSpan::styled("(o open, y copy)", tokens::dim()),
12307                ]));
12308                lines.push(RichLine::raw(""));
12309            }
12310
12311            if let Some(url) = external_ref
12312                && line
12313                    .strip_prefix("External: ")
12314                    .is_some_and(|rendered| rendered == url || rendered.ends_with('…'))
12315            {
12316                continue;
12317            } else if let Some(styled_line) = styled_detail_summary_line(line) {
12318                lines.push(styled_line);
12319                continue;
12320            }
12321
12322            lines.push(RichLine::raw(line));
12323        }
12324        if let Some(url) = external_ref
12325            && self.graph_detail_text().lines().count() <= graph_link_insert_after
12326        {
12327            lines.push(RichLine::raw(""));
12328            lines.push(RichLine::from_spans([
12329                RichSpan::raw("External Link: "),
12330                RichSpan::styled(url, tokens::panel_title_focused()).link(url),
12331                RichSpan::styled("  ", tokens::dim()),
12332                RichSpan::styled("(o open, y copy)", tokens::dim()),
12333            ]));
12334        }
12335        RichText::from_lines(lines)
12336    }
12337
12338    fn board_detail_render_text(&self) -> RichText {
12339        let external_ref = self.selected_issue_external_ref_url();
12340        let mut lines = Vec::new();
12341        let mut inserted_link = false;
12342
12343        for line in self.board_detail_text().lines() {
12344            if let Some(url) = external_ref
12345                && !inserted_link
12346                && line.is_empty()
12347            {
12348                lines.push(RichLine::from_spans([
12349                    RichSpan::raw("External Link: "),
12350                    RichSpan::styled(url, tokens::panel_title_focused()).link(url),
12351                    RichSpan::styled("  ", tokens::dim()),
12352                    RichSpan::styled("(o open, y copy)", tokens::dim()),
12353                ]));
12354                lines.push(RichLine::raw(""));
12355                inserted_link = true;
12356            }
12357
12358            lines.push(RichLine::raw(line));
12359        }
12360
12361        if let Some(url) = external_ref
12362            && !inserted_link
12363        {
12364            lines.push(RichLine::raw(""));
12365            lines.push(RichLine::from_spans([
12366                RichSpan::raw("External Link: "),
12367                RichSpan::styled(url, tokens::panel_title_focused()).link(url),
12368                RichSpan::styled("  ", tokens::dim()),
12369                RichSpan::styled("(o open, y copy)", tokens::dim()),
12370            ]));
12371        }
12372
12373        RichText::from_lines(lines)
12374    }
12375
12376    #[cfg(test)]
12377    fn board_detail_render_state(&self, visible_height: usize) -> (String, usize, usize) {
12378        let full_text = self.board_detail_text();
12379        let total_lines = full_text.lines().count();
12380        if visible_height == 0 {
12381            return (String::new(), 0, total_lines);
12382        }
12383
12384        let max_offset = total_lines.saturating_sub(visible_height);
12385        let offset = self.board_detail_scroll_offset.min(max_offset);
12386        if offset == 0 {
12387            return (full_text, 0, total_lines);
12388        }
12389
12390        let visible = full_text
12391            .lines()
12392            .skip(offset)
12393            .collect::<Vec<_>>()
12394            .join("\n");
12395        (visible, offset, total_lines)
12396    }
12397
12398    fn issue_detail_text(&self) -> String {
12399        if self.analyzer.issues.is_empty() {
12400            return "No issues to display. Create or load a .beads/*.jsonl dataset.".to_string();
12401        }
12402
12403        let Some(issue) = self.selected_issue() else {
12404            return self.no_filtered_issues_text("main detail");
12405        };
12406        let blockers = self.analyzer.graph.blockers(&issue.id);
12407        let open_blockers = self.analyzer.graph.open_blockers(&issue.id);
12408        let dependents = self.analyzer.graph.dependents(&issue.id);
12409        let betweenness = self
12410            .analyzer
12411            .metrics
12412            .betweenness
12413            .get(&issue.id)
12414            .copied()
12415            .unwrap_or_default();
12416        let eigenvector = self
12417            .analyzer
12418            .metrics
12419            .eigenvector
12420            .get(&issue.id)
12421            .copied()
12422            .unwrap_or_default();
12423        let hubs = self
12424            .analyzer
12425            .metrics
12426            .hubs
12427            .get(&issue.id)
12428            .copied()
12429            .unwrap_or_default();
12430        let authorities = self
12431            .analyzer
12432            .metrics
12433            .authorities
12434            .get(&issue.id)
12435            .copied()
12436            .unwrap_or_default();
12437        let k_core = self
12438            .analyzer
12439            .metrics
12440            .k_core
12441            .get(&issue.id)
12442            .copied()
12443            .unwrap_or_default();
12444        let slack = self
12445            .analyzer
12446            .metrics
12447            .slack
12448            .get(&issue.id)
12449            .copied()
12450            .unwrap_or_default();
12451        let pagerank = self
12452            .analyzer
12453            .metrics
12454            .pagerank
12455            .get(&issue.id)
12456            .copied()
12457            .unwrap_or_default();
12458        let depth = self
12459            .analyzer
12460            .metrics
12461            .critical_depth
12462            .get(&issue.id)
12463            .copied()
12464            .unwrap_or_default();
12465        let articulation = self
12466            .analyzer
12467            .metrics
12468            .articulation_points
12469            .contains(&issue.id);
12470        let history = self.analyzer.history(Some(&issue.id), 1).into_iter().next();
12471
12472        let pr_rank = metric_rank(&self.analyzer.metrics.pagerank, &issue.id);
12473        let bw_rank = metric_rank(&self.analyzer.metrics.betweenness, &issue.id);
12474        let ev_rank = metric_rank(&self.analyzer.metrics.eigenvector, &issue.id);
12475        let hub_rank = metric_rank(&self.analyzer.metrics.hubs, &issue.id);
12476        let auth_rank = metric_rank(&self.analyzer.metrics.authorities, &issue.id);
12477        let pr_max = max_metric_value(&self.analyzer.metrics.pagerank);
12478        let bw_max = max_metric_value(&self.analyzer.metrics.betweenness);
12479        let ev_max = max_metric_value(&self.analyzer.metrics.eigenvector);
12480        let hub_max = max_metric_value(&self.analyzer.metrics.hubs);
12481        let auth_max = max_metric_value(&self.analyzer.metrics.authorities);
12482        let action_state = if issue.is_closed_like() {
12483            "closed"
12484        } else if open_blockers.is_empty() {
12485            "ready"
12486        } else {
12487            "blocked"
12488        };
12489        let closed_display = if issue.is_closed_like() {
12490            format_compact_timestamp(issue.closed_at.or(issue.updated_at))
12491        } else {
12492            "n/a".to_string()
12493        };
12494
12495        let mut lines = vec![
12496            format!(
12497                "{} {}  {}",
12498                type_icon(&issue.issue_type),
12499                issue.id,
12500                issue.title
12501            ),
12502            format!(
12503                "Status: {} | Priority: p{} | Type: {} | State: {}",
12504                issue.status,
12505                issue.priority,
12506                display_or_fallback(&issue.issue_type, "unknown"),
12507                action_state
12508            ),
12509            format!(
12510                "Assignee: {} | Repo: {} | Estimate: {}",
12511                display_or_fallback(&issue.assignee, "unassigned"),
12512                display_or_fallback(&issue.source_repo, "local"),
12513                issue
12514                    .estimated_minutes
12515                    .map_or_else(|| "n/a".to_string(), |minutes| format!("{minutes}m"))
12516            ),
12517            format!(
12518                "Created: {} | Updated: {} | Due: {}",
12519                format_compact_timestamp(issue.created_at),
12520                format_compact_timestamp(issue.updated_at),
12521                format_compact_timestamp(issue.due_date)
12522            ),
12523            format!(
12524                "Closed: {} | Labels: {}",
12525                closed_display,
12526                join_display_values(&issue.labels, 4)
12527            ),
12528        ];
12529        if let Some(ref ext_ref) = issue.external_ref {
12530            lines.push(format!("External: {ext_ref}"));
12531        }
12532        lines.extend([
12533            String::new(),
12534            "Triage Snapshot:".to_string(),
12535            format!(
12536                "  open blockers: {} ({})",
12537                open_blockers.len(),
12538                join_display_values(&open_blockers, 4)
12539            ),
12540            format!(
12541                "  unblocks: {} ({})",
12542                dependents.len(),
12543                join_display_values(&dependents, 4)
12544            ),
12545            format!(
12546                "  dependency pressure: {} upstream | {} downstream",
12547                blockers.len(),
12548                dependents.len()
12549            ),
12550        ]);
12551
12552        if let (Some(created), Some(closed)) = (issue.created_at, issue.closed_at) {
12553            let duration = closed - created;
12554            lines.push(format!(
12555                "  cycle time: {}d {}h",
12556                duration.num_days(),
12557                duration.num_hours() - duration.num_days() * 24
12558            ));
12559        }
12560
12561        lines.push(String::new());
12562        lines.push("Graph Signals:".to_string());
12563        lines.push(format!(
12564            "  Critical depth: {depth} | k-core: {k_core} | slack: {slack:.4} | cut-point: {}",
12565            if articulation { "YES" } else { "no" }
12566        ));
12567        lines.push(format!(
12568            "  PageRank:     {pagerank:>8.4}  {}  #{pr_rank}",
12569            mini_bar(pagerank, pr_max)
12570        ));
12571        lines.push(format!(
12572            "  Betweenness:  {betweenness:>8.4}  {}  #{bw_rank}",
12573            mini_bar(betweenness, bw_max)
12574        ));
12575        lines.push(format!(
12576            "  Eigenvector:  {eigenvector:>8.4}  {}  #{ev_rank}",
12577            mini_bar(eigenvector, ev_max)
12578        ));
12579        lines.push(format!(
12580            "  HITS: hub {hubs:.4} {} #{hub_rank} | auth {authorities:.4} {} #{auth_rank}",
12581            mini_bar(hubs, hub_max),
12582            mini_bar(authorities, auth_max)
12583        ));
12584
12585        push_text_section(&mut lines, "Description", &issue.description);
12586        push_text_section(&mut lines, "Design Notes", &issue.design);
12587        push_text_section(
12588            &mut lines,
12589            "Acceptance Criteria",
12590            &issue.acceptance_criteria,
12591        );
12592        push_text_section(&mut lines, "Notes", &issue.notes);
12593
12594        lines.push(String::new());
12595        lines.push("Dependency Map:".to_string());
12596        lines.push(format!("  upstream: {}", join_display_values(&blockers, 4)));
12597        lines.push(format!(
12598            "  open gate: {}",
12599            join_display_values(&open_blockers, 4)
12600        ));
12601        lines.push(format!(
12602            "  downstream: {}",
12603            join_display_values(&dependents, 4)
12604        ));
12605
12606        if self.priority_hints_visible {
12607            self.append_priority_hints(&mut lines, issue);
12608        }
12609
12610        push_comment_section(&mut lines, issue);
12611        push_history_section(&mut lines, history.as_ref());
12612        lines.join("\n")
12613    }
12614
12615    fn append_priority_hints(&self, lines: &mut Vec<String>, issue: &crate::model::Issue) {
12616        use crate::analysis::triage::{TriageOptions, TriageScoringOptions};
12617
12618        lines.push(String::new());
12619        lines.push("Priority Hints (p to hide):".to_string());
12620
12621        let triage = self.analyzer.triage(TriageOptions {
12622            max_recommendations: 200,
12623            scoring: TriageScoringOptions::default(),
12624            ..TriageOptions::default()
12625        });
12626
12627        if let Some(rec) = triage
12628            .result
12629            .recommendations
12630            .iter()
12631            .find(|r| r.id == issue.id)
12632        {
12633            lines.push(format!("  Triage Score:  {:.3}", rec.score));
12634            lines.push(format!("  Confidence:    {:.1}%", rec.confidence * 100.0));
12635            lines.push(format!("  Unblocks:      {}", rec.unblocks));
12636            if !rec.reasons.is_empty() {
12637                lines.push(format!("  Reasons:       {}", rec.reasons.join("; ")));
12638            }
12639
12640            if let Some(ref breakdown) = rec.breakdown {
12641                lines.push(String::new());
12642                lines.push("  Score Breakdown:".to_string());
12643                for component in breakdown {
12644                    let bar = mini_bar(component.weighted, 0.3);
12645                    lines.push(format!(
12646                        "    {:<14} {:>5.1}% × {:.3} = {:.4}  {bar}",
12647                        component.name,
12648                        component.weight * 100.0,
12649                        component.normalized,
12650                        component.weighted,
12651                    ));
12652                    if let Some(ref explanation) = component.explanation {
12653                        lines.push(format!("      └ {explanation}"));
12654                    }
12655                }
12656            }
12657
12658            lines.push(format!("  Claim: {}", rec.claim_command));
12659        } else {
12660            lines.push("  (not in triage — may be closed or blocked)".to_string());
12661        }
12662    }
12663
12664    fn board_detail_text(&self) -> String {
12665        if self.analyzer.issues.is_empty() {
12666            return "No issues to display in board mode.".to_string();
12667        }
12668
12669        let Some(issue) = self.selected_issue() else {
12670            return self.no_filtered_issues_text("board mode");
12671        };
12672
12673        let blockers = self.analyzer.graph.blockers(&issue.id);
12674        let dependents = self.analyzer.graph.dependents(&issue.id);
12675        let open_blockers = self.analyzer.graph.open_blockers(&issue.id);
12676        let icon = status_icon(&issue.status);
12677        let ti = type_icon(&issue.issue_type);
12678
12679        // Determine current lane label from grouping
12680        let lane_label = match self.board_grouping {
12681            BoardGrouping::Status => issue.status.clone(),
12682            BoardGrouping::Priority => format!("p{}", issue.priority),
12683            BoardGrouping::Type => {
12684                if issue.issue_type.trim().is_empty() {
12685                    "unknown".to_string()
12686                } else {
12687                    issue.issue_type.to_lowercase()
12688                }
12689            }
12690        };
12691
12692        let title_trunc = truncate_str(&issue.title, 34);
12693        let id_line = format!(" {} {} {} p{}", icon, ti, issue.id, issue.priority);
12694        let box_width = 40;
12695        let hrule: String = std::iter::repeat_n('\u{2500}', box_width).collect();
12696
12697        let mut out = Vec::<String>::new();
12698        out.push(format!("\u{250c}{hrule}\u{2510}"));
12699        out.push(format!(
12700            "\u{2502} {:<w$}\u{2502}",
12701            id_line,
12702            w = box_width - 1
12703        ));
12704        out.push(format!(
12705            "\u{2502} {:<w$}\u{2502}",
12706            title_trunc,
12707            w = box_width - 1
12708        ));
12709        out.push(format!("\u{251c}{hrule}\u{2524}"));
12710        out.push(format!(
12711            "\u{2502} Lane: {:<w$}\u{2502}",
12712            lane_label,
12713            w = box_width - 7
12714        ));
12715        out.push(format!(
12716            "\u{2502} Assignee: {:<w$}\u{2502}",
12717            issue.assignee,
12718            w = box_width - 11
12719        ));
12720        // Labels
12721        if !issue.labels.is_empty() {
12722            let labels_str = issue
12723                .labels
12724                .iter()
12725                .take(4)
12726                .cloned()
12727                .collect::<Vec<_>>()
12728                .join(", ");
12729            let labels_display = if issue.labels.len() > 4 {
12730                format!("{labels_str} +{}", issue.labels.len() - 4)
12731            } else {
12732                labels_str
12733            };
12734            out.push(format!(
12735                "\u{2502} Labels: {:<w$}\u{2502}",
12736                truncate_str(&labels_display, box_width - 9),
12737                w = box_width - 9
12738            ));
12739        }
12740        // Dates
12741        if let Some(created) = issue.created_at {
12742            let reference_now = ui_reference_now();
12743            let age = (reference_now - created).num_days();
12744            let updated_age = issue.updated_at.map_or_else(
12745                || "never".to_string(),
12746                |u| format!("{}d ago", (reference_now - u).num_days()),
12747            );
12748            out.push(format!(
12749                "\u{2502} Age: {age}d | Updated: {:<w$}\u{2502}",
12750                updated_age,
12751                w = box_width - 21
12752            ));
12753        }
12754        // Graph metrics
12755        let pr = self
12756            .analyzer
12757            .metrics
12758            .pagerank
12759            .get(&issue.id)
12760            .copied()
12761            .unwrap_or(0.0);
12762        let depth = self
12763            .analyzer
12764            .metrics
12765            .critical_depth
12766            .get(&issue.id)
12767            .copied()
12768            .unwrap_or(0);
12769        if pr > 0.0 || depth > 0 {
12770            out.push(format!(
12771                "\u{2502} PR:{:.3} Depth:{} {:<w$}\u{2502}",
12772                pr,
12773                depth,
12774                if self
12775                    .analyzer
12776                    .metrics
12777                    .articulation_points
12778                    .contains(&issue.id)
12779                {
12780                    "\u{25c6}cut"
12781                } else {
12782                    ""
12783                },
12784                w = box_width - 18
12785            ));
12786        }
12787        // Description
12788        if !issue.description.is_empty() {
12789            out.push(format!("\u{251c}{hrule}\u{2524}"));
12790            // Wrap description across multiple lines
12791            for line in issue.description.lines().take(3) {
12792                out.push(format!(
12793                    "\u{2502} {:<w$}\u{2502}",
12794                    truncate_str(line.trim(), box_width - 3),
12795                    w = box_width - 1
12796                ));
12797            }
12798            if issue.description.lines().count() > 3 {
12799                out.push(format!("\u{2502} {:<w$}\u{2502}", "...", w = box_width - 1));
12800            }
12801        }
12802        out.push(format!("\u{2514}{hrule}\u{2518}"));
12803
12804        out.push(String::new());
12805        // Dependency context with detail cursor
12806        let mut dep_index = 0usize;
12807        let show_cursor = self.focus == FocusPane::Detail;
12808        if !blockers.is_empty() {
12809            out.push(format!("Depends on ({})", blockers.len()));
12810            for bid in &blockers {
12811                let bstatus = self
12812                    .analyzer
12813                    .issues
12814                    .iter()
12815                    .find(|i| i.id == *bid)
12816                    .map_or("?", |i| status_icon(&i.status));
12817                let is_open = open_blockers.contains(bid);
12818                let marker = if is_open { "OPEN" } else { "ok" };
12819                let prefix = if show_cursor && dep_index == self.detail_dep_cursor {
12820                    ">"
12821                } else {
12822                    " "
12823                };
12824                out.push(format!("{prefix} {bstatus} {bid} [{marker}]"));
12825                dep_index += 1;
12826            }
12827        }
12828        if !dependents.is_empty() {
12829            out.push(format!("Unblocks ({})", dependents.len()));
12830            for did in &dependents {
12831                let dstatus = self
12832                    .analyzer
12833                    .issues
12834                    .iter()
12835                    .find(|i| i.id == *did)
12836                    .map_or("?", |i| status_icon(&i.status));
12837                let prefix = if show_cursor && dep_index == self.detail_dep_cursor {
12838                    ">"
12839                } else {
12840                    " "
12841                };
12842                out.push(format!("{prefix} {dstatus} {did}"));
12843                dep_index += 1;
12844            }
12845        }
12846
12847        out.push(String::new());
12848        if open_blockers.is_empty() {
12849            out.push("Ready to advance to next lane.".to_string());
12850        } else {
12851            out.push(format!("Blocked by {} open issue(s).", open_blockers.len()));
12852        }
12853
12854        out.join("\n")
12855    }
12856
12857    fn insights_detail_text(&self) -> String {
12858        let heatmap_context = self.insights_heatmap.as_ref().map(|state| {
12859            let data = self.insights_heatmap_data();
12860            let row = state
12861                .row
12862                .min(INSIGHTS_HEATMAP_DEPTH_LABELS.len().saturating_sub(1));
12863            let col = state
12864                .col
12865                .min(INSIGHTS_HEATMAP_SCORE_LABELS.len().saturating_sub(1));
12866            let cell_issue_ids = data.issue_ids[row][col].clone();
12867
12868            let mut context = vec![format!(
12869                "Heatmap: {} x {} ({} issue(s))",
12870                INSIGHTS_HEATMAP_DEPTH_LABELS[row],
12871                INSIGHTS_HEATMAP_SCORE_LABELS[col],
12872                cell_issue_ids.len()
12873            )];
12874            if state.drill_active {
12875                let position = if cell_issue_ids.is_empty() {
12876                    0
12877                } else {
12878                    state
12879                        .drill_cursor
12880                        .min(cell_issue_ids.len().saturating_sub(1))
12881                        + 1
12882                };
12883                context.push(format!(
12884                    "Drill selection: {position}/{}",
12885                    cell_issue_ids.len()
12886                ));
12887            }
12888
12889            (context, cell_issue_ids)
12890        });
12891
12892        if self.analyzer.issues.is_empty() {
12893            return "No insights available.".to_string();
12894        }
12895
12896        if let Some((mut context, cell_issue_ids)) = heatmap_context.clone()
12897            && cell_issue_ids.is_empty()
12898        {
12899            context.push(String::new());
12900            context.push("No issues in the selected heatmap cell.".to_string());
12901            return context.join("\n");
12902        }
12903
12904        let Some(issue) = self.selected_issue() else {
12905            if let Some((mut context, _)) = heatmap_context {
12906                context.push(String::new());
12907                context.push(self.no_filtered_issues_text("insights mode"));
12908                return context.join("\n");
12909            }
12910            return self.no_filtered_issues_text("insights mode");
12911        };
12912        let insights = self.analyzer.insights();
12913        let pagerank = self
12914            .analyzer
12915            .metrics
12916            .pagerank
12917            .get(&issue.id)
12918            .copied()
12919            .unwrap_or_default();
12920        let betweenness = self
12921            .analyzer
12922            .metrics
12923            .betweenness
12924            .get(&issue.id)
12925            .copied()
12926            .unwrap_or_default();
12927        let eigenvector = self
12928            .analyzer
12929            .metrics
12930            .eigenvector
12931            .get(&issue.id)
12932            .copied()
12933            .unwrap_or_default();
12934        let hubs = self
12935            .analyzer
12936            .metrics
12937            .hubs
12938            .get(&issue.id)
12939            .copied()
12940            .unwrap_or_default();
12941        let authorities = self
12942            .analyzer
12943            .metrics
12944            .authorities
12945            .get(&issue.id)
12946            .copied()
12947            .unwrap_or_default();
12948        let k_core = self
12949            .analyzer
12950            .metrics
12951            .k_core
12952            .get(&issue.id)
12953            .copied()
12954            .unwrap_or_default();
12955        let slack = self
12956            .analyzer
12957            .metrics
12958            .slack
12959            .get(&issue.id)
12960            .copied()
12961            .unwrap_or_default();
12962        let depth = self
12963            .analyzer
12964            .metrics
12965            .critical_depth
12966            .get(&issue.id)
12967            .copied()
12968            .unwrap_or_default();
12969        let blockers = self.analyzer.graph.blockers(&issue.id);
12970        let dependents = self.analyzer.graph.dependents(&issue.id);
12971        let articulation = self
12972            .analyzer
12973            .metrics
12974            .articulation_points
12975            .contains(&issue.id);
12976        let in_critical_path = insights.critical_path.iter().any(|id| id == &issue.id);
12977        let in_cycle = insights
12978            .cycles
12979            .iter()
12980            .any(|cycle| cycle.iter().any(|id| id == &issue.id));
12981        let top_bottleneck = insights
12982            .bottlenecks
12983            .first()
12984            .is_some_and(|item| item.id == issue.id);
12985
12986        let mut lines = vec![
12987            "Analytics Cockpit".to_string(),
12988            format!(
12989                "System Radar | bottlenecks={} crit-path={} cycles={} cut-pts={} k-core-max={}",
12990                insights.bottlenecks.len(),
12991                insights.critical_path.len(),
12992                insights.cycles.len(),
12993                insights.articulation_points.len(),
12994                insights.cores.first().map_or(0, |c| c.value)
12995            ),
12996            String::new(),
12997            format!("Focus: {} ({})", issue.id, issue.title),
12998            format!("Status: {} | Priority: p{}", issue.status, issue.priority),
12999            String::new(),
13000            "Metric Strip".to_string(),
13001            format!(
13002                "[Rank ] pagerank={pagerank:.4} betweenness={betweenness:.4} eigenvector={eigenvector:.4}"
13003            ),
13004            format!("[HITS ] hub={hubs:.4} authority={authorities:.4} k-core={k_core}"),
13005            format!(
13006                "[Risk ] crit-depth={depth} slack={slack:.4} cut-point={}",
13007                if articulation { "yes" } else { "no" }
13008            ),
13009            format!(
13010                "[Flow ] blockers={} dependents={} crit-path={} cycle={}",
13011                blockers.len(),
13012                dependents.len(),
13013                if in_critical_path { "yes" } else { "no" },
13014                if in_cycle { "yes" } else { "no" }
13015            ),
13016            format!(
13017                "[Lead ] top-bottleneck={}",
13018                if top_bottleneck { "yes" } else { "no" }
13019            ),
13020            String::new(),
13021            "All Metrics:".to_string(),
13022            format!("  PageRank:     {:.4}", pagerank),
13023            format!("  Betweenness:  {:.4}", betweenness),
13024            format!("  Eigenvector:  {:.4}", eigenvector),
13025            format!("  Hub (HITS):   {:.4}", hubs),
13026            format!("  Auth (HITS):  {:.4}", authorities),
13027            format!("  K-core:       {}", k_core),
13028            format!("  Crit depth:   {}", depth),
13029            format!("  Slack:        {:.4}", slack),
13030            format!(
13031                "  Cut point:    {}",
13032                if articulation { "YES" } else { "no" }
13033            ),
13034        ];
13035
13036        lines.push(String::new());
13037        if self.insights_show_explanations {
13038            lines.push("Critical Path Head:".to_string());
13039            if insights.critical_path.is_empty() {
13040                lines.push("  none".to_string());
13041            } else {
13042                lines.extend(
13043                    insights
13044                        .critical_path
13045                        .iter()
13046                        .take(6)
13047                        .map(|id| format!("  - {id}")),
13048                );
13049            }
13050
13051            lines.push(String::new());
13052            lines.push("Cycle Hotspots:".to_string());
13053            if insights.cycles.is_empty() {
13054                lines.push("  none".to_string());
13055            } else {
13056                lines.extend(
13057                    insights
13058                        .cycles
13059                        .iter()
13060                        .take(4)
13061                        .map(|cycle| format!("  - {}", cycle.join(" -> "))),
13062                );
13063            }
13064        } else {
13065            lines.push("Explanations hidden (press e to show).".to_string());
13066        }
13067
13068        if self.insights_show_calc_proof {
13069            let blocks_count = self
13070                .analyzer
13071                .metrics
13072                .blocks_count
13073                .get(&issue.id)
13074                .copied()
13075                .unwrap_or_default();
13076            let blocked_by_count = self
13077                .analyzer
13078                .metrics
13079                .blocked_by_count
13080                .get(&issue.id)
13081                .copied()
13082                .unwrap_or_default();
13083            lines.push(String::new());
13084            lines.push("Calculation Proof:".to_string());
13085            lines.push(format!(
13086                "  score inputs -> blocks={blocks_count} blocked_by={blocked_by_count} pagerank={pagerank:.4} betweenness={betweenness:.4} depth={depth}",
13087            ));
13088        }
13089
13090        // Dependency context with detail cursor
13091        if !blockers.is_empty() || !dependents.is_empty() {
13092            let show_cursor = self.focus == FocusPane::Detail;
13093            let mut dep_index = 0usize;
13094            lines.push(String::new());
13095            if !blockers.is_empty() {
13096                lines.push(format!("Depends on ({})", blockers.len()));
13097                for bid in &blockers {
13098                    let bsi = self
13099                        .analyzer
13100                        .issues
13101                        .iter()
13102                        .find(|i| i.id == *bid)
13103                        .map_or("?", |i| status_icon(&i.status));
13104                    let prefix = if show_cursor && dep_index == self.detail_dep_cursor {
13105                        ">"
13106                    } else {
13107                        " "
13108                    };
13109                    lines.push(format!("{prefix} {bsi} {bid}"));
13110                    dep_index += 1;
13111                }
13112            }
13113            if !dependents.is_empty() {
13114                lines.push(format!("Unblocks ({})", dependents.len()));
13115                for did in &dependents {
13116                    let dsi = self
13117                        .analyzer
13118                        .issues
13119                        .iter()
13120                        .find(|i| i.id == *did)
13121                        .map_or("?", |i| status_icon(&i.status));
13122                    let prefix = if show_cursor && dep_index == self.detail_dep_cursor {
13123                        ">"
13124                    } else {
13125                        " "
13126                    };
13127                    lines.push(format!("{prefix} {dsi} {did}"));
13128                    dep_index += 1;
13129                }
13130            }
13131        }
13132
13133        if let Some((mut context, _)) = heatmap_context {
13134            context.push(String::new());
13135            context.append(&mut lines);
13136            return context.join("\n");
13137        }
13138
13139        lines.join("\n")
13140    }
13141
13142    fn insights_detail_render_text(&self) -> RichText {
13143        let external_ref = self.selected_issue_external_ref_url();
13144        let insights_link_insert_after = 4usize;
13145        let mut lines = Vec::new();
13146        let mut inserted_link = false;
13147        for (index, line) in self.insights_detail_text().lines().enumerate() {
13148            if let Some(url) = external_ref
13149                && index == insights_link_insert_after
13150            {
13151                lines.push(RichLine::from_spans([
13152                    RichSpan::raw("External Link: "),
13153                    RichSpan::styled(url, tokens::panel_title_focused()).link(url),
13154                    RichSpan::styled("  ", tokens::dim()),
13155                    RichSpan::styled("(o open, y copy)", tokens::dim()),
13156                ]));
13157                lines.push(RichLine::raw(""));
13158                inserted_link = true;
13159            }
13160
13161            lines.push(RichLine::raw(line));
13162        }
13163
13164        if let Some(url) = external_ref
13165            && !inserted_link
13166        {
13167            lines.push(RichLine::raw(""));
13168            lines.push(RichLine::from_spans([
13169                RichSpan::raw("External Link: "),
13170                RichSpan::styled(url, tokens::panel_title_focused()).link(url),
13171                RichSpan::styled("  ", tokens::dim()),
13172                RichSpan::styled("(o open, y copy)", tokens::dim()),
13173            ]));
13174        }
13175
13176        RichText::from_lines(lines)
13177    }
13178
13179    fn graph_relationship_box_width(&self, width: usize, count: usize) -> usize {
13180        let count = count.clamp(1, 5);
13181        let spacing = count.saturating_sub(1);
13182        let max_fit = width.saturating_sub(spacing) / count;
13183        if max_fit >= 20 {
13184            20
13185        } else if max_fit >= 12 {
13186            max_fit
13187        } else {
13188            max_fit.max(8)
13189        }
13190    }
13191
13192    fn graph_relationship_box(
13193        &self,
13194        target_id: &str,
13195        box_width: usize,
13196        focused: bool,
13197    ) -> Vec<String> {
13198        let inner = box_width.saturating_sub(2).max(1);
13199        let (top_left, horizontal, top_right, vertical, bottom_left, bottom_right) = if focused {
13200            ('╔', '═', '╗', '║', '╚', '╝')
13201        } else {
13202            ('┌', '─', '┐', '│', '└', '┘')
13203        };
13204        let border = std::iter::repeat_n(horizontal, inner).collect::<String>();
13205
13206        let (header, title) = self.issue_by_id(target_id).map_or_else(
13207            || (format!("[?] {target_id}"), "(not in filter)".to_string()),
13208            |candidate| {
13209                let prefix = if focused { ">" } else { " " };
13210                (
13211                    format!(
13212                        "{prefix}[{}] {}",
13213                        status_icon(&candidate.status),
13214                        candidate.id
13215                    ),
13216                    display_or_fallback(&candidate.title, "(untitled)"),
13217                )
13218            },
13219        );
13220
13221        vec![
13222            format!("{top_left}{border}{top_right}"),
13223            format!("{vertical}{}{vertical}", fit_display(&header, inner)),
13224            format!(
13225                "{vertical}{}{vertical}",
13226                fit_display(&truncate_str(&title, inner), inner)
13227            ),
13228            format!("{bottom_left}{border}{bottom_right}"),
13229        ]
13230    }
13231
13232    fn graph_ego_box(&self, issue: &crate::model::Issue, width: usize) -> Vec<String> {
13233        let box_width = (width / 2)
13234            .clamp(22, 38)
13235            .min(width.saturating_sub(4).max(12));
13236        let inner = box_width.saturating_sub(2).max(1);
13237        let border = std::iter::repeat_n('═', inner).collect::<String>();
13238        let si = status_icon(&issue.status);
13239        let ti = type_icon(&issue.issue_type);
13240        let header = format!("[{si} {ti} p{}] {}", issue.priority, issue.id);
13241        let title = display_or_fallback(&issue.title, "(untitled)");
13242        let counts = format!(
13243            "up:{} down:{}",
13244            self.analyzer.graph.blockers(&issue.id).len(),
13245            self.analyzer.graph.dependents(&issue.id).len()
13246        );
13247
13248        vec![
13249            center_display(&format!("╔{border}╗"), width),
13250            center_display(&format!("║{}║", fit_display(&header, inner)), width),
13251            center_display(
13252                &format!("║{}║", fit_display(&truncate_str(&title, inner), inner)),
13253                width,
13254            ),
13255            center_display(&format!("║{}║", fit_display(&counts, inner)), width),
13256            center_display(&format!("╚{border}╝"), width),
13257        ]
13258    }
13259
13260    fn graph_connector_rows(&self, count: usize, width: usize) -> Vec<String> {
13261        let display_count = count.clamp(0, 5);
13262        if display_count == 0 {
13263            return Vec::new();
13264        }
13265        if display_count == 1 {
13266            return ["│", "│", "▼"]
13267                .into_iter()
13268                .map(|line| center_display(line, width))
13269                .collect();
13270        }
13271
13272        let mut fan = String::from("├");
13273        for idx in 0..display_count {
13274            if idx > 0 {
13275                fan.push('┼');
13276            }
13277            fan.push('─');
13278        }
13279        fan.push('┤');
13280
13281        ["│".to_string(), fan, "▼".to_string()]
13282            .into_iter()
13283            .map(|line| center_display(&line, width))
13284            .collect()
13285    }
13286
13287    fn graph_relationship_rows(
13288        &self,
13289        ids: &[String],
13290        width: usize,
13291        show_cursor: bool,
13292        dep_index: &mut usize,
13293    ) -> Vec<String> {
13294        if ids.is_empty() {
13295            return Vec::new();
13296        }
13297
13298        let display_count = ids.len().min(5);
13299        let box_width = self.graph_relationship_box_width(width, display_count);
13300        let boxes = ids
13301            .iter()
13302            .take(display_count)
13303            .map(|target_id| {
13304                let focused = show_cursor && *dep_index == self.detail_dep_cursor;
13305                *dep_index += 1;
13306                self.graph_relationship_box(target_id, box_width, focused)
13307            })
13308            .collect::<Vec<_>>();
13309
13310        let mut lines = center_box_rows(&boxes, width);
13311        if ids.len() > display_count {
13312            lines.push(center_display(
13313                &format!("+{} more", ids.len() - display_count),
13314                width,
13315            ));
13316        }
13317        lines
13318    }
13319
13320    fn graph_detail_text(&self) -> String {
13321        self.graph_detail_text_for_width(72)
13322    }
13323
13324    fn graph_detail_text_for_width(&self, width: usize) -> String {
13325        if self.analyzer.issues.is_empty() {
13326            return "No graph data available.".to_string();
13327        }
13328
13329        let Some(issue) = self.selected_issue() else {
13330            return self.no_filtered_issues_text("graph mode");
13331        };
13332        let render_width = width.max(24);
13333        let blockers = self.analyzer.graph.blockers(&issue.id);
13334        let dependents = self.analyzer.graph.dependents(&issue.id);
13335        let pagerank = self
13336            .analyzer
13337            .metrics
13338            .pagerank
13339            .get(&issue.id)
13340            .copied()
13341            .unwrap_or_default();
13342        let betweenness = self
13343            .analyzer
13344            .metrics
13345            .betweenness
13346            .get(&issue.id)
13347            .copied()
13348            .unwrap_or_default();
13349        let eigenvector = self
13350            .analyzer
13351            .metrics
13352            .eigenvector
13353            .get(&issue.id)
13354            .copied()
13355            .unwrap_or_default();
13356        let hubs = self
13357            .analyzer
13358            .metrics
13359            .hubs
13360            .get(&issue.id)
13361            .copied()
13362            .unwrap_or_default();
13363        let authorities = self
13364            .analyzer
13365            .metrics
13366            .authorities
13367            .get(&issue.id)
13368            .copied()
13369            .unwrap_or_default();
13370        let k_core = self
13371            .analyzer
13372            .metrics
13373            .k_core
13374            .get(&issue.id)
13375            .copied()
13376            .unwrap_or_default();
13377        let slack = self
13378            .analyzer
13379            .metrics
13380            .slack
13381            .get(&issue.id)
13382            .copied()
13383            .unwrap_or_default();
13384        let depth = self
13385            .analyzer
13386            .metrics
13387            .critical_depth
13388            .get(&issue.id)
13389            .copied()
13390            .unwrap_or_default();
13391        let articulation = self
13392            .analyzer
13393            .metrics
13394            .articulation_points
13395            .contains(&issue.id);
13396        let cycle_hits = self
13397            .analyzer
13398            .metrics
13399            .cycles
13400            .iter()
13401            .filter(|cycle| cycle.iter().any(|id| id == &issue.id))
13402            .cloned()
13403            .collect::<Vec<_>>();
13404
13405        let visible = self.graph_visible_issue_indices();
13406        let focus_position = visible
13407            .iter()
13408            .position(|&index| {
13409                self.analyzer
13410                    .issues
13411                    .get(index)
13412                    .is_some_and(|candidate| candidate.id == issue.id)
13413            })
13414            .map_or(1, |position| position + 1);
13415        let total_focusable = visible.len().max(1);
13416        let focus_summary = match self.focus {
13417            FocusPane::Detail => {
13418                let total_edges = blockers.len() + dependents.len();
13419                if total_edges == 0 {
13420                    "Focused edge: none (isolated node)".to_string()
13421                } else if self.detail_dep_cursor < blockers.len() {
13422                    let target = &blockers[self.detail_dep_cursor];
13423                    let title = self
13424                        .issue_by_id(target)
13425                        .map(|candidate| candidate.title.as_str())
13426                        .unwrap_or("?");
13427                    format!(
13428                        "Focused edge: depends on [{}/{}] {} -> {} ({})",
13429                        self.detail_dep_cursor + 1,
13430                        total_edges,
13431                        issue.id,
13432                        target,
13433                        title
13434                    )
13435                } else {
13436                    let dep_index = self.detail_dep_cursor - blockers.len();
13437                    let target = &dependents[dep_index];
13438                    let title = self
13439                        .issue_by_id(target)
13440                        .map(|candidate| candidate.title.as_str())
13441                        .unwrap_or("?");
13442                    format!(
13443                        "Focused edge: unblocks [{}/{}] {} -> {} ({})",
13444                        self.detail_dep_cursor + 1,
13445                        total_edges,
13446                        issue.id,
13447                        target,
13448                        title
13449                    )
13450                }
13451            }
13452            FocusPane::Middle => {
13453                "Focused edge: list focus (Tab to inspect relationships)".to_string()
13454            }
13455            FocusPane::List => {
13456                "Focused edge: list focus (Tab to inspect relationships)".to_string()
13457            }
13458        };
13459
13460        let total = self.analyzer.issues.len();
13461        let cp_rank = self
13462            .analyzer
13463            .metrics
13464            .critical_depth
13465            .values()
13466            .filter(|&&value| value > depth)
13467            .count()
13468            + 1;
13469        let pr_rank = metric_rank(&self.analyzer.metrics.pagerank, &issue.id);
13470        let bw_rank = metric_rank(&self.analyzer.metrics.betweenness, &issue.id);
13471        let ev_rank = metric_rank(&self.analyzer.metrics.eigenvector, &issue.id);
13472        let hub_rank = metric_rank(&self.analyzer.metrics.hubs, &issue.id);
13473        let auth_rank = metric_rank(&self.analyzer.metrics.authorities, &issue.id);
13474        let cp_max = self
13475            .analyzer
13476            .metrics
13477            .critical_depth
13478            .values()
13479            .copied()
13480            .max()
13481            .unwrap_or_default()
13482            .max(1) as f64;
13483        let pr_max = max_metric_value(&self.analyzer.metrics.pagerank);
13484        let bw_max = max_metric_value(&self.analyzer.metrics.betweenness);
13485        let ev_max = max_metric_value(&self.analyzer.metrics.eigenvector);
13486        let hub_max = max_metric_value(&self.analyzer.metrics.hubs);
13487        let auth_max = max_metric_value(&self.analyzer.metrics.authorities);
13488
13489        let show_cursor = self.focus == FocusPane::Detail;
13490        let mut dep_index = 0usize;
13491        let mut lines = vec![
13492            format!(
13493                "Graph: nodes={} edges={} cycles={} actionable={}",
13494                self.analyzer.graph.node_count(),
13495                self.analyzer.graph.edge_count(),
13496                self.analyzer.metrics.cycles.len(),
13497                self.analyzer.graph.actionable_ids().len()
13498            ),
13499            format!(
13500                "Focus: node {focus_position}/{total_focusable} -> {} ({})",
13501                issue.id, issue.title
13502            ),
13503            focus_summary,
13504            String::new(),
13505        ];
13506
13507        if !blockers.is_empty() {
13508            lines.push(center_display(
13509                "▲ BLOCKED BY (must complete first) ▲",
13510                render_width,
13511            ));
13512            lines.extend(self.graph_relationship_rows(
13513                &blockers,
13514                render_width,
13515                show_cursor,
13516                &mut dep_index,
13517            ));
13518            lines.extend(self.graph_connector_rows(blockers.len().min(5), render_width));
13519        }
13520
13521        lines.extend(self.graph_ego_box(issue, render_width));
13522
13523        if !dependents.is_empty() {
13524            lines.extend(self.graph_connector_rows(dependents.len().min(5), render_width));
13525            lines.push(center_display("▼ BLOCKS (waiting on this) ▼", render_width));
13526            lines.extend(self.graph_relationship_rows(
13527                &dependents,
13528                render_width,
13529                show_cursor,
13530                &mut dep_index,
13531            ));
13532        }
13533
13534        lines.push(String::new());
13535        lines.push("GRAPH METRICS".to_string());
13536        lines.push("Importance:".to_string());
13537        lines.push(format!(
13538            "  Critical Path  {:>8}  {}  #{}",
13539            depth,
13540            mini_bar(depth as f64, cp_max),
13541            cp_rank
13542        ));
13543        lines.push(format!(
13544            "  PageRank       {:>8.4}  {}  #{}",
13545            pagerank,
13546            mini_bar(pagerank, pr_max),
13547            pr_rank
13548        ));
13549        lines.push(format!(
13550            "  Eigenvector    {:>8.4}  {}  #{}",
13551            eigenvector,
13552            mini_bar(eigenvector, ev_max),
13553            ev_rank
13554        ));
13555        lines.push("Flow & Connectivity:".to_string());
13556        lines.push(format!(
13557            "  Betweenness    {:>8.4}  {}  #{}",
13558            betweenness,
13559            mini_bar(betweenness, bw_max),
13560            bw_rank
13561        ));
13562        lines.push(format!(
13563            "  Hub Score      {:>8.4}  {}  #{}",
13564            hubs,
13565            mini_bar(hubs, hub_max),
13566            hub_rank
13567        ));
13568        lines.push(format!(
13569            "  Authority      {:>8.4}  {}  #{}",
13570            authorities,
13571            mini_bar(authorities, auth_max),
13572            auth_rank
13573        ));
13574        lines.push("Connections:".to_string());
13575        lines.push(format!(
13576            "  In-Degree      {:>8}  {}",
13577            blockers.len(),
13578            mini_bar(blockers.len() as f64, total as f64)
13579        ));
13580        lines.push(format!(
13581            "  Out-Degree     {:>8}  {}",
13582            dependents.len(),
13583            mini_bar(dependents.len() as f64, total as f64)
13584        ));
13585        lines.push(format!(
13586            "  K-core: {k_core}  Slack: {slack:.4}  Cut: {}",
13587            if articulation { "YES" } else { "no" }
13588        ));
13589
13590        if cycle_hits.is_empty() {
13591            lines.push("  Cycles: none".to_string());
13592        } else {
13593            lines.push("  Cycles:".to_string());
13594            lines.extend(
13595                cycle_hits
13596                    .iter()
13597                    .take(4)
13598                    .map(|cycle| format!("    {}", cycle.join(" -> "))),
13599            );
13600        }
13601
13602        lines.push(String::new());
13603        lines.push("Top PageRank:".to_string());
13604        lines.extend(
13605            top_metric_entries(&self.analyzer.metrics.pagerank, 5)
13606                .into_iter()
13607                .map(|(id, value)| format!("  {id:<12} {value:.4}")),
13608        );
13609        lines.push(String::new());
13610        lines.push(format!(
13611            "Legend: █ relative score | #N rank of {total} issues"
13612        ));
13613        lines.push(
13614            "Nav: h/l nodes | j/k nodes or focused edges | Tab node/edge focus | Enter open details"
13615                .to_string(),
13616        );
13617
13618        lines
13619            .into_iter()
13620            .map(|line| truncate_display(&line, render_width))
13621            .collect::<Vec<_>>()
13622            .join("\n")
13623    }
13624
13625    fn history_detail_text(&self) -> String {
13626        if self.analyzer.issues.is_empty() {
13627            return "No history data available.".to_string();
13628        }
13629
13630        if matches!(self.history_view_mode, HistoryViewMode::Git) {
13631            let visible = self.history_git_visible_commit_indices();
13632            let Some(commit) = self.selected_history_git_commit() else {
13633                return "No correlated git commits available.".to_string();
13634            };
13635
13636            let cursor = self
13637                .history_event_cursor
13638                .min(visible.len().saturating_sub(1));
13639
13640            let related = self.history_git_related_beads_for_commit(&commit.sha);
13641
13642            let commit_icon = commit_type_icon(&commit.message);
13643            let initials = author_initials(&commit.author);
13644            let mut lines = vec![
13645                format!(
13646                    "Commit {}/{} | confidence >= {:.0}%",
13647                    cursor + 1,
13648                    visible.len(),
13649                    self.history_min_confidence() * 100.0
13650                ),
13651                String::new(),
13652                "COMMIT DETAILS:".to_string(),
13653                format!("  SHA: {}", commit.sha),
13654                format!("  [{initials}] {} <{}>", commit.author, commit.author_email),
13655                format!("  Date: {}", commit.timestamp),
13656                format!("  {commit_icon} {}", commit.message),
13657            ];
13658
13659            if !commit.files.is_empty() {
13660                let total_ins: i64 = commit.files.iter().map(|f| f.insertions).sum();
13661                let total_del: i64 = commit.files.iter().map(|f| f.deletions).sum();
13662                lines.push(format!(
13663                    "  Files: {} changed +{total_ins}/-{total_del}",
13664                    commit.files.len()
13665                ));
13666                for file in commit.files.iter().take(5) {
13667                    let action_icon = match file.action.as_str() {
13668                        "A" => "+",
13669                        "D" => "-",
13670                        "R" | "R100" => ">",
13671                        _ => "~",
13672                    };
13673                    if file.insertions > 0 || file.deletions > 0 {
13674                        lines.push(format!(
13675                            "    {action_icon} {} +{}/-{}",
13676                            file.path, file.insertions, file.deletions
13677                        ));
13678                    } else {
13679                        lines.push(format!("    {action_icon} {}", file.path));
13680                    }
13681                }
13682                if commit.files.len() > 5 {
13683                    lines.push(format!("    +{} more files...", commit.files.len() - 5));
13684                }
13685            }
13686
13687            if !related.is_empty() {
13688                lines.push(String::new());
13689                lines.push("RELATED BEADS:".to_string());
13690                for bead_id in &related {
13691                    let conf = self
13692                        .history_git_cache
13693                        .as_ref()
13694                        .and_then(|c| c.commit_bead_confidence.get(&commit.sha))
13695                        .and_then(|pairs| {
13696                            pairs.iter().find(|(id, _)| id == bead_id).map(|(_, c)| *c)
13697                        })
13698                        .unwrap_or(0.0);
13699                    let issue_status = self
13700                        .analyzer
13701                        .issues
13702                        .iter()
13703                        .find(|i| i.id == *bead_id)
13704                        .map_or("?", |i| status_icon(&i.status));
13705                    let title = self
13706                        .analyzer
13707                        .issues
13708                        .iter()
13709                        .find(|i| i.id == *bead_id)
13710                        .map(|i| truncate_str(&i.title, 30))
13711                        .unwrap_or_default();
13712                    lines.push(format!(
13713                        "  [{issue_status}] {bead_id} ({:.0}%) {}",
13714                        conf * 100.0,
13715                        title
13716                    ));
13717                }
13718            }
13719
13720            if let Some(bead_commit) = self.selected_history_git_bead_commit() {
13721                lines.push(String::new());
13722                lines.push(format!(
13723                    "SELECTED BEAD CHANGE ({}):",
13724                    self.selected_history_git_related_bead_id()
13725                        .unwrap_or_default()
13726                ));
13727                if bead_commit.field_changes.is_empty() {
13728                    lines.push("  (no field-level bead changes detected)".to_string());
13729                } else {
13730                    let fields = bead_commit
13731                        .field_changes
13732                        .iter()
13733                        .map(|change| change.field.as_str())
13734                        .collect::<Vec<_>>();
13735                    lines.push(format!("  Fields: {}", fields.join(", ")));
13736                }
13737                if !bead_commit.bead_diff_lines.is_empty() {
13738                    lines.push("  Diff:".to_string());
13739                    for line in bead_commit.bead_diff_lines.iter().take(8) {
13740                        lines.push(format!("    {line}"));
13741                    }
13742                    if bead_commit.bead_diff_lines.len() > 8 {
13743                        lines.push(format!(
13744                            "    +{} more diff lines...",
13745                            bead_commit.bead_diff_lines.len() - 8
13746                        ));
13747                    }
13748                }
13749            }
13750
13751            // Append file tree panel inline when toggled on
13752            if self.history_show_file_tree {
13753                lines.push(String::new());
13754                lines.push(self.file_tree_panel_text());
13755            }
13756
13757            let action_line = if self.history_selected_commit_url().is_some() {
13758                "y: copy SHA | o: open in browser | f: file tree"
13759            } else {
13760                "y: copy SHA | f: file tree"
13761            };
13762            lines.push(String::new());
13763            lines.push(
13764                "Enter: jump to related bead | J/K: cycle related beads | diff follows cursor"
13765                    .to_string(),
13766            );
13767            lines.push("v: switch to bead timeline | c: cycle confidence".to_string());
13768            lines.push(action_line.to_string());
13769            if !self.history_status_msg.is_empty() {
13770                lines.push(String::new());
13771                lines.push(self.history_status_msg.clone());
13772            }
13773            return lines.join("\n");
13774        }
13775
13776        let Some(issue) = self.selected_issue() else {
13777            return self.no_filtered_issues_text("history mode");
13778        };
13779        let selected_history = self.analyzer.history(Some(&issue.id), 1).into_iter().next();
13780        let compat_history = self
13781            .history_git_cache
13782            .as_ref()
13783            .and_then(|cache| cache.histories.get(&issue.id));
13784
13785        let all_histories = self.analyzer.history(None, 0);
13786        let closed_histories = all_histories
13787            .iter()
13788            .filter(|history| {
13789                history
13790                    .events
13791                    .iter()
13792                    .any(|event| event.kind.eq_ignore_ascii_case("closed"))
13793            })
13794            .count();
13795
13796        let mut lines = vec![
13797            format!(
13798                "History Summary: beads={} closed-like={} selected={}",
13799                all_histories.len(),
13800                closed_histories,
13801                issue.id
13802            ),
13803            String::new(),
13804            format!("Issue: {} ({})", issue.id, issue.title),
13805            format!("Status: {}", issue.status),
13806            format!(
13807                "Min confidence filter: >= {:.0}%",
13808                self.history_min_confidence() * 100.0
13809            ),
13810            format!(
13811                "Created/Updated/Closed: {} / {} / {}",
13812                format_compact_timestamp(issue.created_at),
13813                format_compact_timestamp(issue.updated_at),
13814                format_compact_timestamp(issue.closed_at)
13815            ),
13816        ];
13817
13818        if let (Some(created), Some(closed)) = (issue.created_at, issue.closed_at) {
13819            let duration = closed - created;
13820            lines.push(format!(
13821                "Create->Close cycle time: {}d {}h",
13822                duration.num_days(),
13823                duration.num_hours() - duration.num_days() * 24
13824            ));
13825        }
13826
13827        // Show milestones from git history correlation if available
13828        if let Some(compat_history) = compat_history {
13829            push_text_section(
13830                &mut lines,
13831                "Timeline",
13832                &self.history_compact_timeline_text(compat_history, 56),
13833            );
13834
13835            let ms = &compat_history.milestones;
13836            let has_milestones = ms.created.is_some()
13837                || ms.claimed.is_some()
13838                || ms.closed.is_some()
13839                || ms.reopened.is_some();
13840            if has_milestones {
13841                lines.push(String::new());
13842                lines.push("Milestones:".to_string());
13843                if let Some(ref event) = ms.created {
13844                    lines.push(format!(
13845                        "  Created:  {} by {}",
13846                        event.timestamp, event.author
13847                    ));
13848                }
13849                if let Some(ref event) = ms.claimed {
13850                    lines.push(format!(
13851                        "  Claimed:  {} by {}",
13852                        event.timestamp, event.author
13853                    ));
13854                }
13855                if let Some(ref event) = ms.closed {
13856                    lines.push(format!(
13857                        "  Closed:   {} by {}",
13858                        event.timestamp, event.author
13859                    ));
13860                }
13861                if let Some(ref event) = ms.reopened {
13862                    lines.push(format!(
13863                        "  Reopened: {} by {}",
13864                        event.timestamp, event.author
13865                    ));
13866                }
13867            }
13868
13869            if !compat_history.last_author.is_empty() {
13870                lines.push(format!("Last author: {}", compat_history.last_author));
13871            }
13872        }
13873
13874        lines.push(String::new());
13875        if let Some(compat_history) = compat_history {
13876            lines.extend(history_legacy_lifecycle_lines(compat_history, 5));
13877        } else if let Some(history) = selected_history.as_ref() {
13878            lines.push("LIFECYCLE:".to_string());
13879            if history.events.is_empty() {
13880                lines.push("  (no events)".to_string());
13881            } else {
13882                let event_count = history.events.len();
13883                for (idx, event) in history.events.iter().enumerate() {
13884                    let ts = event
13885                        .timestamp
13886                        .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
13887                        .unwrap_or_else(|| "n/a".to_string());
13888                    let icon = lifecycle_icon(&event.kind);
13889                    let connector = if idx + 1 < event_count {
13890                        "\u{2502}"
13891                    } else {
13892                        "\u{2514}"
13893                    };
13894                    lines.push(format!(
13895                        "  {connector} {icon} {:<10} {ts}  {}",
13896                        event.kind, event.details
13897                    ));
13898                }
13899            }
13900        } else {
13901            lines.push("LIFECYCLE:".to_string());
13902            lines.push("  (history unavailable for selected issue)".to_string());
13903        }
13904
13905        if let Some(commit) = self.selected_history_bead_commit() {
13906            let total = self.history_filtered_bead_commits(&issue.id).len();
13907            let slot = self.history_bead_commit_cursor.min(total.saturating_sub(1));
13908
13909            lines.push(String::new());
13910            lines.push(format!(
13911                "COMMIT DETAILS ({}/{}):",
13912                slot.saturating_add(1),
13913                total
13914            ));
13915            let commit_icon = commit_type_icon(&commit.message);
13916            lines.push(format!(
13917                "  {commit_icon} {}  {}",
13918                commit.short_sha, commit.timestamp
13919            ));
13920            let initials = author_initials(&commit.author);
13921            lines.push(format!(
13922                "  [{initials}] {} <{}>",
13923                commit.author, commit.author_email
13924            ));
13925            lines.push(format!("  {}", commit.message));
13926            lines.push(format!(
13927                "  {:.0}% confidence ({})",
13928                commit.confidence * 100.0,
13929                commit.method
13930            ));
13931            if !commit.reason.is_empty() {
13932                lines.push(format!("  Reason: {}", commit.reason));
13933            }
13934            if !commit.files.is_empty() {
13935                let total_ins: i64 = commit.files.iter().map(|f| f.insertions).sum();
13936                let total_del: i64 = commit.files.iter().map(|f| f.deletions).sum();
13937                lines.push(format!(
13938                    "  {} file(s) +{total_ins}/-{total_del}",
13939                    commit.files.len()
13940                ));
13941                for file in commit.files.iter().take(5) {
13942                    let action_icon = match file.action.as_str() {
13943                        "A" => "+",
13944                        "D" => "-",
13945                        "R" | "R100" => ">",
13946                        _ => "~",
13947                    };
13948                    if file.insertions > 0 || file.deletions > 0 {
13949                        lines.push(format!(
13950                            "    {action_icon} {} +{}/-{}",
13951                            file.path, file.insertions, file.deletions
13952                        ));
13953                    } else {
13954                        lines.push(format!("    {action_icon} {}", file.path));
13955                    }
13956                }
13957                if commit.files.len() > 5 {
13958                    lines.push(format!("    +{} more files...", commit.files.len() - 5));
13959                }
13960            }
13961            if !commit.field_changes.is_empty() {
13962                let fields = commit
13963                    .field_changes
13964                    .iter()
13965                    .map(|change| change.field.as_str())
13966                    .collect::<Vec<_>>();
13967                lines.push(format!("  Fields changed: {}", fields.join(", ")));
13968            }
13969            if !commit.bead_diff_lines.is_empty() {
13970                lines.push("  Bead diff:".to_string());
13971                for line in commit.bead_diff_lines.iter().take(8) {
13972                    lines.push(format!("    {line}"));
13973                }
13974                if commit.bead_diff_lines.len() > 8 {
13975                    lines.push(format!(
13976                        "    +{} more diff lines...",
13977                        commit.bead_diff_lines.len() - 8
13978                    ));
13979                }
13980            }
13981        }
13982
13983        let action_line = if self.history_selected_commit_url().is_some() {
13984            "y: copy bead ID | o: open commit | f: file tree"
13985        } else {
13986            "y: copy bead ID | f: file tree"
13987        };
13988        lines.push(String::new());
13989        lines.push(
13990            "Enter: backtrace selected commit | v: switch to git timeline | J/K: cycle commits"
13991                .to_string(),
13992        );
13993        lines.push(action_line.to_string());
13994        if !self.history_status_msg.is_empty() {
13995            lines.push(String::new());
13996            lines.push(self.history_status_msg.clone());
13997        }
13998
13999        lines.join("\n")
14000    }
14001
14002    fn history_detail_render_text(&self) -> RichText {
14003        let mut text = RichText::raw(self.history_detail_text());
14004        if let Some(url) = self.history_selected_commit_url() {
14005            text.push_line(RichLine::raw(""));
14006            text.push_line(RichLine::from_spans([
14007                RichSpan::raw("Browser Link: "),
14008                RichSpan::styled(
14009                    "open selected commit (o open, right-click copy link)",
14010                    tokens::panel_title_focused(),
14011                )
14012                .link(url),
14013            ]));
14014        }
14015        text
14016    }
14017
14018    /// Render the file tree panel text (when visible).
14019    fn file_tree_panel_text(&self) -> String {
14020        let flat = self.history_flat_file_list();
14021        if flat.is_empty() {
14022            return "No file data available.\n(git history may not be loaded)".to_string();
14023        }
14024
14025        let mut out = Vec::new();
14026        out.push(format!("File Tree ({} entries) | Esc close", flat.len()));
14027        if let Some(ref filter) = self.history_file_tree_filter {
14028            out.push(format!("Filter: {filter}"));
14029        }
14030        out.push(String::new());
14031
14032        for (idx, entry) in flat.iter().enumerate() {
14033            let marker = if self.history_file_tree_focus && idx == self.history_file_tree_cursor {
14034                '>'
14035            } else {
14036                ' '
14037            };
14038            let indent = "  ".repeat(entry.level);
14039            let icon = if entry.is_dir { "/" } else { "" };
14040            out.push(format!(
14041                "{marker} {indent}{}{icon} ({})",
14042                entry.name, entry.change_count
14043            ));
14044        }
14045
14046        out.join("\n")
14047    }
14048}
14049
14050#[must_use]
14051fn truncate_str(value: &str, max_len: usize) -> String {
14052    truncate_display(value, max_len)
14053}
14054
14055fn metric_rank(metrics: &std::collections::HashMap<String, f64>, target_id: &str) -> usize {
14056    let target_value = metrics.get(target_id).copied().unwrap_or_default();
14057    metrics
14058        .values()
14059        .filter(|&&value| value > target_value)
14060        .count()
14061        + 1
14062}
14063
14064fn max_metric_value(metrics: &std::collections::HashMap<String, f64>) -> f64 {
14065    metrics.values().copied().fold(0.0_f64, f64::max).max(1e-9)
14066}
14067
14068fn run_command_with_stdin(program: &str, args: &[&str], input: &str) -> bool {
14069    let Ok(mut child) = std::process::Command::new(program)
14070        .args(args)
14071        .stdin(std::process::Stdio::piped())
14072        .stdout(std::process::Stdio::null())
14073        .stderr(std::process::Stdio::null())
14074        .spawn()
14075    else {
14076        return false;
14077    };
14078
14079    if let Some(mut stdin) = child.stdin.take()
14080        && stdin.write_all(input.as_bytes()).is_err()
14081    {
14082        let _ = child.kill();
14083        let _ = child.wait();
14084        return false;
14085    }
14086
14087    child.wait().is_ok_and(|status| status.success())
14088}
14089
14090fn run_command(program: &str, args: &[&str]) -> bool {
14091    std::process::Command::new(program)
14092        .args(args)
14093        .stdin(std::process::Stdio::null())
14094        .stdout(std::process::Stdio::null())
14095        .stderr(std::process::Stdio::null())
14096        .status()
14097        .is_ok_and(|status| status.success())
14098}
14099
14100fn copy_text_to_clipboard(text: &str) -> bool {
14101    run_command_with_stdin("wl-copy", &[], text)
14102        || run_command_with_stdin("xclip", &["-selection", "clipboard"], text)
14103        || run_command_with_stdin("xsel", &["--clipboard", "--input"], text)
14104        || run_command_with_stdin("pbcopy", &[], text)
14105        || run_command_with_stdin("clip.exe", &[], text)
14106}
14107
14108fn open_url_in_browser(url: &str) -> bool {
14109    if cfg!(target_os = "windows") {
14110        run_command("cmd", &["/C", "start", "", url])
14111    } else if cfg!(target_os = "macos") {
14112        run_command("open", &[url])
14113    } else {
14114        run_command("xdg-open", &[url])
14115            || run_command("open", &[url])
14116            || run_command("gio", &["open", url])
14117    }
14118}
14119
14120fn mini_bar(value: f64, max: f64) -> String {
14121    let width: usize = 6;
14122    let ratio = if max > 0.0 { value / max } else { 0.0 };
14123    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
14124    let filled = ratio
14125        .mul_add(width as f64, 0.0)
14126        .round()
14127        .clamp(0.0, width as f64) as usize;
14128    let empty = width - filled;
14129    std::iter::repeat_n('\u{2588}', filled)
14130        .chain(std::iter::repeat_n('\u{2591}', empty))
14131        .collect()
14132}
14133
14134fn status_icon(status: &str) -> &'static str {
14135    match status.to_ascii_lowercase().as_str() {
14136        "open" => "o",
14137        "in_progress" => "*",
14138        "blocked" => "!",
14139        "closed" => "x",
14140        "deferred" | "draft" => "~",
14141        "review" => "r",
14142        "pinned" => "^",
14143        "hooked" => "&",
14144        "tombstone" => "#",
14145        _ => "?",
14146    }
14147}
14148
14149fn type_icon(issue_type: &str) -> &'static str {
14150    match issue_type.to_ascii_lowercase().as_str() {
14151        "bug" => "B",
14152        "feature" => "F",
14153        "task" => "T",
14154        "epic" => "E",
14155        "question" => "Q",
14156        "docs" => "D",
14157        "refactor" => "R",
14158        _ => "-",
14159    }
14160}
14161
14162fn lifecycle_icon(kind: &str) -> &'static str {
14163    match kind.to_ascii_lowercase().as_str() {
14164        "created" => "+",
14165        "claimed" | "assigned" => "@",
14166        "closed" => "x",
14167        "reopened" => "~",
14168        "modified" | "updated" => ".",
14169        _ => "-",
14170    }
14171}
14172
14173fn commit_type_icon(message: &str) -> &'static str {
14174    let lower = message.to_ascii_lowercase();
14175    if lower.starts_with("feat") {
14176        "F"
14177    } else if lower.starts_with("fix") {
14178        "B"
14179    } else if lower.starts_with("docs") {
14180        "D"
14181    } else if lower.starts_with("refactor") {
14182        "R"
14183    } else if lower.starts_with("test") {
14184        "T"
14185    } else if lower.starts_with("chore") {
14186        "C"
14187    } else if lower.starts_with("perf") {
14188        "P"
14189    } else if lower.starts_with("ci") {
14190        "I"
14191    } else if lower.starts_with("build") {
14192        "K"
14193    } else if lower.starts_with("style") {
14194        "S"
14195    } else if lower.starts_with("merge") || lower.starts_with("Merge") {
14196        "M"
14197    } else if lower.starts_with("revert") {
14198        "<"
14199    } else {
14200        "*"
14201    }
14202}
14203
14204fn author_initials(name: &str) -> String {
14205    name.split_whitespace()
14206        .filter_map(|word| word.chars().next())
14207        .take(2)
14208        .flat_map(char::to_uppercase)
14209        .collect::<String>()
14210}
14211
14212fn display_or_fallback(value: &str, fallback: &str) -> String {
14213    let trimmed = value.trim();
14214    if trimmed.is_empty() {
14215        fallback.to_string()
14216    } else {
14217        trimmed.to_string()
14218    }
14219}
14220
14221fn format_compact_timestamp(dt: Option<DateTime<Utc>>) -> String {
14222    dt.map_or_else(|| "n/a".to_string(), |ts| ts.format("%Y-%m-%d").to_string())
14223}
14224
14225fn compact_history_duration_label(raw: &str) -> String {
14226    raw.split_whitespace()
14227        .find_map(|token| {
14228            let digits = token
14229                .chars()
14230                .take_while(|ch| ch.is_ascii_digit() || *ch == '-')
14231                .collect::<String>();
14232            digits
14233                .parse::<i64>()
14234                .ok()
14235                .filter(|value| *value > 0)
14236                .map(|_| token.to_string())
14237        })
14238        .or_else(|| raw.split_whitespace().next().map(str::to_string))
14239        .unwrap_or_else(|| raw.to_string())
14240}
14241
14242fn compact_history_month_day(timestamp: &str) -> Option<String> {
14243    DateTime::parse_from_rfc3339(timestamp)
14244        .ok()
14245        .map(|dt| dt.format("%b %-d").to_string())
14246}
14247
14248#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
14249enum LegacyTimelineEntryType {
14250    Event,
14251    Commit,
14252}
14253
14254#[derive(Debug, Clone)]
14255struct LegacyTimelineEntry {
14256    timestamp: String,
14257    parsed_ts: Option<DateTime<Utc>>,
14258    entry_type: LegacyTimelineEntryType,
14259    label: String,
14260    detail: String,
14261    confidence: Option<f64>,
14262}
14263
14264fn legacy_history_author_initials(name: &str) -> String {
14265    let parts = name.split_whitespace().collect::<Vec<_>>();
14266    match parts.as_slice() {
14267        [] => "??".to_string(),
14268        [single] => {
14269            let mut chars = single.chars();
14270            match (chars.next(), chars.next()) {
14271                (Some(first), Some(second)) => [first, second]
14272                    .into_iter()
14273                    .flat_map(char::to_uppercase)
14274                    .collect(),
14275                (Some(first), None) => first.to_uppercase().collect(),
14276                (None, _) => "??".to_string(),
14277            }
14278        }
14279        [first, .., last] => [first.chars().next(), last.chars().next()]
14280            .into_iter()
14281            .flatten()
14282            .flat_map(char::to_uppercase)
14283            .collect(),
14284    }
14285}
14286
14287fn legacy_history_relative_time(timestamp: &str) -> Option<String> {
14288    let ts = DateTime::parse_from_rfc3339(timestamp).ok()?;
14289    let diff = Utc::now().signed_duration_since(ts.with_timezone(&Utc));
14290    if diff < chrono::Duration::zero() {
14291        return Some("in future".to_string());
14292    }
14293    if diff < chrono::Duration::minutes(1) {
14294        return Some("just now".to_string());
14295    }
14296    if diff < chrono::Duration::hours(1) {
14297        return Some(format!("{}m ago", diff.num_minutes()));
14298    }
14299    if diff < chrono::Duration::days(1) {
14300        return Some(format!("{}h ago", diff.num_hours()));
14301    }
14302    if diff < chrono::Duration::days(7) {
14303        return Some(format!("{}d ago", diff.num_days()));
14304    }
14305    if diff < chrono::Duration::days(30) {
14306        return Some(format!("{}w ago", diff.num_weeks()));
14307    }
14308    if diff < chrono::Duration::days(365) {
14309        return Some(format!("{}mo ago", diff.num_days() / 30));
14310    }
14311    Some(format!("{}y ago", diff.num_days() / 365))
14312}
14313
14314fn legacy_history_lifecycle_icon(kind: &str) -> &'static str {
14315    match kind.to_ascii_lowercase().as_str() {
14316        "created" => "🆕",
14317        "claimed" | "assigned" => "👤",
14318        "closed" => "✓",
14319        "reopened" => "↺",
14320        "modified" | "updated" => "✎",
14321        _ => "•",
14322    }
14323}
14324
14325fn history_legacy_lifecycle_lines(
14326    compat_history: &HistoryBeadCompat,
14327    max_lines: usize,
14328) -> Vec<String> {
14329    let mut events = if compat_history.events.is_empty() {
14330        let mut fallback = Vec::new();
14331        if let Some(event) = compat_history.milestones.created.clone() {
14332            fallback.push(event);
14333        }
14334        if let Some(event) = compat_history.milestones.claimed.clone() {
14335            fallback.push(event);
14336        }
14337        if let Some(event) = compat_history.milestones.closed.clone() {
14338            fallback.push(event);
14339        }
14340        if let Some(event) = compat_history.milestones.reopened.clone() {
14341            fallback.push(event);
14342        }
14343        fallback
14344    } else {
14345        compat_history.events.clone()
14346    };
14347
14348    if events.is_empty() {
14349        return vec!["LIFECYCLE:".to_string(), "  (no events)".to_string()];
14350    }
14351
14352    events.sort_by(|left, right| {
14353        left.timestamp
14354            .cmp(&right.timestamp)
14355            .then_with(|| left.event_type.cmp(&right.event_type))
14356    });
14357
14358    let mut lines = vec![format!("LIFECYCLE ({})", events.len())];
14359    let mut available_events = max_lines.saturating_sub(1);
14360    let needs_more_line = events.len() > available_events;
14361    if needs_more_line {
14362        available_events = available_events.saturating_sub(1);
14363    }
14364
14365    let mut displayed = 0usize;
14366    for index in (0..events.len()).rev() {
14367        if displayed >= available_events {
14368            break;
14369        }
14370        let event = &events[index];
14371        let connector = if index == 0 { "└" } else { "│" };
14372        let relative =
14373            legacy_history_relative_time(&event.timestamp).unwrap_or_else(|| "n/a".to_string());
14374        let initials = legacy_history_author_initials(&event.author);
14375        lines.push(format!(
14376            "  {connector} {} {:<7} {initials}",
14377            legacy_history_lifecycle_icon(&event.event_type),
14378            truncate_display(&relative, 7),
14379        ));
14380        displayed += 1;
14381    }
14382
14383    if needs_more_line {
14384        lines.push(format!("  +{} more", events.len() - displayed));
14385    }
14386
14387    lines
14388}
14389
14390fn legacy_timeline_timestamp(timestamp: &str) -> Option<String> {
14391    let ts = DateTime::parse_from_rfc3339(timestamp)
14392        .ok()?
14393        .with_timezone(&Utc);
14394    let diff = Utc::now().signed_duration_since(ts);
14395    Some(if diff < chrono::Duration::days(1) {
14396        ts.format("%-I:%M %p").to_string()
14397    } else if diff < chrono::Duration::days(7) {
14398        ts.format("%a %-I%p").to_string()
14399    } else if diff < chrono::Duration::days(365) {
14400        ts.format("%b %-d").to_string()
14401    } else {
14402        ts.format("%b '%y").to_string()
14403    })
14404}
14405
14406fn build_legacy_timeline_entries(
14407    compat_history: &HistoryBeadCompat,
14408    commits: &[&HistoryCommitCompat],
14409) -> Vec<LegacyTimelineEntry> {
14410    let mut entries = Vec::new();
14411
14412    if let Some(event) = compat_history.milestones.created.as_ref() {
14413        entries.push(LegacyTimelineEntry {
14414            timestamp: event.timestamp.clone(),
14415            parsed_ts: DateTime::parse_from_rfc3339(&event.timestamp)
14416                .ok()
14417                .map(|dt| dt.with_timezone(&Utc)),
14418            entry_type: LegacyTimelineEntryType::Event,
14419            label: "○ Created".to_string(),
14420            detail: compat_history.title.clone(),
14421            confidence: None,
14422        });
14423    }
14424    if let Some(event) = compat_history.milestones.claimed.as_ref() {
14425        entries.push(LegacyTimelineEntry {
14426            timestamp: event.timestamp.clone(),
14427            parsed_ts: DateTime::parse_from_rfc3339(&event.timestamp)
14428                .ok()
14429                .map(|dt| dt.with_timezone(&Utc)),
14430            entry_type: LegacyTimelineEntryType::Event,
14431            label: "● Claimed".to_string(),
14432            detail: format!("by {}", event.author),
14433            confidence: None,
14434        });
14435    }
14436    if let Some(event) = compat_history.milestones.reopened.as_ref() {
14437        entries.push(LegacyTimelineEntry {
14438            timestamp: event.timestamp.clone(),
14439            parsed_ts: DateTime::parse_from_rfc3339(&event.timestamp)
14440                .ok()
14441                .map(|dt| dt.with_timezone(&Utc)),
14442            entry_type: LegacyTimelineEntryType::Event,
14443            label: "↻ Reopened".to_string(),
14444            detail: String::new(),
14445            confidence: None,
14446        });
14447    }
14448    if let Some(event) = compat_history.milestones.closed.as_ref() {
14449        entries.push(LegacyTimelineEntry {
14450            timestamp: event.timestamp.clone(),
14451            parsed_ts: DateTime::parse_from_rfc3339(&event.timestamp)
14452                .ok()
14453                .map(|dt| dt.with_timezone(&Utc)),
14454            entry_type: LegacyTimelineEntryType::Event,
14455            label: "✓ Closed".to_string(),
14456            detail: String::new(),
14457            confidence: None,
14458        });
14459    }
14460
14461    for commit in commits {
14462        entries.push(LegacyTimelineEntry {
14463            timestamp: commit.timestamp.clone(),
14464            parsed_ts: DateTime::parse_from_rfc3339(&commit.timestamp)
14465                .ok()
14466                .map(|dt| dt.with_timezone(&Utc)),
14467            entry_type: LegacyTimelineEntryType::Commit,
14468            label: commit.short_sha.clone(),
14469            detail: commit.message.clone(),
14470            confidence: Some(commit.confidence),
14471        });
14472    }
14473
14474    entries.sort_by(|left, right| {
14475        left.parsed_ts
14476            .cmp(&right.parsed_ts)
14477            .then_with(|| left.entry_type.cmp(&right.entry_type))
14478            .then_with(|| left.label.cmp(&right.label))
14479    });
14480    entries
14481}
14482
14483fn render_legacy_timeline_lines(
14484    compat_history: &HistoryBeadCompat,
14485    commits: &[&HistoryCommitCompat],
14486    width: usize,
14487    max_visible: usize,
14488) -> Vec<String> {
14489    let entries = build_legacy_timeline_entries(compat_history, commits);
14490    if entries.is_empty() {
14491        return vec!["No events recorded".to_string()];
14492    }
14493
14494    let shown = entries.iter().take(max_visible).collect::<Vec<_>>();
14495    let mut lines = Vec::new();
14496    let text_width = width.saturating_sub(12).max(8);
14497
14498    for entry in &shown {
14499        let ts = legacy_timeline_timestamp(&entry.timestamp).unwrap_or_else(|| "n/a".to_string());
14500        let ts = format!("{ts:>8}");
14501        match entry.entry_type {
14502            LegacyTimelineEntryType::Event => {
14503                let mut line = format!("{ts} | {}", entry.label);
14504                if !entry.detail.is_empty() {
14505                    line.push(' ');
14506                    line.push_str(&truncate_display(
14507                        &entry.detail,
14508                        text_width.saturating_sub(2),
14509                    ));
14510                }
14511                lines.push(truncate_display(&line, width));
14512            }
14513            LegacyTimelineEntryType::Commit => {
14514                let confidence = entry.confidence.unwrap_or(0.0) * 100.0;
14515                lines.push(truncate_display(
14516                    &format!("{ts} | ├─ {} {:.0}%", entry.label, confidence),
14517                    width,
14518                ));
14519                if !entry.detail.is_empty() {
14520                    lines.push(truncate_display(
14521                        &format!(
14522                            "{:>8} |   {}",
14523                            "",
14524                            truncate_display(&entry.detail, text_width)
14525                        ),
14526                        width,
14527                    ));
14528                }
14529            }
14530        }
14531    }
14532
14533    if entries.len() > shown.len() {
14534        lines.push(truncate_display(
14535            &format!("{:>8} | ↕ 1-{} of {}", "", shown.len(), entries.len()),
14536            width,
14537        ));
14538    }
14539
14540    lines
14541}
14542
14543fn join_display_values(values: &[String], limit: usize) -> String {
14544    if values.is_empty() {
14545        return "none".to_string();
14546    }
14547
14548    let mut parts = values.iter().take(limit).cloned().collect::<Vec<_>>();
14549    if values.len() > limit {
14550        parts.push(format!("+{} more", values.len() - limit));
14551    }
14552    parts.join(", ")
14553}
14554
14555fn push_text_section(lines: &mut Vec<String>, title: &str, body: &str) {
14556    let trimmed = body.trim();
14557    if trimmed.is_empty() {
14558        return;
14559    }
14560
14561    lines.push(String::new());
14562    lines.push(format!("{title}:"));
14563    for line in trimmed.lines() {
14564        let content = line.trim_end();
14565        if content.is_empty() {
14566            lines.push("  ".to_string());
14567        } else {
14568            lines.push(format!("  {content}"));
14569        }
14570    }
14571}
14572
14573fn push_comment_section(lines: &mut Vec<String>, issue: &Issue) {
14574    if issue.comments.is_empty() {
14575        return;
14576    }
14577
14578    lines.push(String::new());
14579    lines.push(format!("Recent Comments ({}):", issue.comments.len()));
14580    for comment in issue.comments.iter().rev().take(3) {
14581        lines.push(format!(
14582            "  - {} @ {}",
14583            display_or_fallback(&comment.author, "unknown"),
14584            format_compact_timestamp(comment.created_at)
14585        ));
14586        for line in comment.text.lines().take(3) {
14587            lines.push(format!("      {}", line.trim_end()));
14588        }
14589        if comment.text.lines().count() > 3 {
14590            lines.push("      ...".to_string());
14591        }
14592    }
14593}
14594
14595fn push_history_section(lines: &mut Vec<String>, history: Option<&IssueHistory>) {
14596    let Some(history) = history else {
14597        return;
14598    };
14599    if history.events.is_empty() {
14600        return;
14601    }
14602
14603    lines.push(String::new());
14604    lines.push(format!(
14605        "History Summary ({} events):",
14606        history.events.len()
14607    ));
14608    let start = history.events.len().saturating_sub(4);
14609    for event in history.events.iter().skip(start) {
14610        lines.push(format!(
14611            "  {} {} {}",
14612            lifecycle_icon(&event.kind),
14613            format_compact_timestamp(event.timestamp),
14614            event.details
14615        ));
14616    }
14617}
14618
14619fn top_metric_entries(
14620    metrics: &std::collections::HashMap<String, f64>,
14621    limit: usize,
14622) -> Vec<(String, f64)> {
14623    let mut entries = metrics
14624        .iter()
14625        .map(|(id, value)| (id.clone(), *value))
14626        .collect::<Vec<_>>();
14627    entries.sort_by(|left, right| {
14628        right
14629            .1
14630            .total_cmp(&left.1)
14631            .then_with(|| left.0.cmp(&right.0))
14632    });
14633    entries.truncate(limit);
14634    entries
14635}
14636
14637fn truncate_display(value: &str, max_len: usize) -> String {
14638    if max_len == 0 {
14639        return String::new();
14640    }
14641
14642    if display_width(value) <= max_len {
14643        return value.to_string();
14644    }
14645    if max_len == 1 {
14646        return truncate_to_width(value, max_len);
14647    }
14648
14649    truncate_with_ellipsis(value, max_len, "…")
14650}
14651
14652fn tone_for_status(status: &str) -> SemanticTone {
14653    if status.eq_ignore_ascii_case("open") || status.eq_ignore_ascii_case("review") {
14654        SemanticTone::Accent
14655    } else if status.eq_ignore_ascii_case("in_progress") || status.eq_ignore_ascii_case("hooked") {
14656        SemanticTone::Warning
14657    } else if status.eq_ignore_ascii_case("blocked") || status.eq_ignore_ascii_case("tombstone") {
14658        SemanticTone::Danger
14659    } else if status.eq_ignore_ascii_case("closed") {
14660        SemanticTone::Success
14661    } else {
14662        SemanticTone::Muted
14663    }
14664}
14665
14666fn tone_for_state(state: &str) -> SemanticTone {
14667    if state.eq_ignore_ascii_case("ready") {
14668        SemanticTone::Success
14669    } else if state.eq_ignore_ascii_case("blocked") {
14670        SemanticTone::Danger
14671    } else if state.eq_ignore_ascii_case("closed") {
14672        SemanticTone::Muted
14673    } else {
14674        SemanticTone::Accent
14675    }
14676}
14677
14678fn tone_for_priority(priority: &str) -> SemanticTone {
14679    match priority.trim_start_matches(['p', 'P']) {
14680        "0" => SemanticTone::Danger,
14681        "1" => SemanticTone::Warning,
14682        "2" => SemanticTone::Accent,
14683        "3" | "4" => SemanticTone::Muted,
14684        _ => SemanticTone::Neutral,
14685    }
14686}
14687
14688fn summary_line_from_pairs(
14689    line: &str,
14690    tone_for_key: impl Fn(&str, &str) -> SemanticTone,
14691) -> Option<RichLine> {
14692    let mut out = RichLine::new();
14693    let mut wrote_any = false;
14694    for part in line.split(" | ") {
14695        let (label, value) = part.split_once(": ")?;
14696        if wrote_any {
14697            out.push_span(RichSpan::styled(" | ", tokens::dim()));
14698        }
14699        out.push_span(RichSpan::styled(format!("{label}:"), tokens::dim()));
14700        out.push_span(RichSpan::raw(" "));
14701        push_chip(&mut out, value, tone_for_key(label, value));
14702        wrote_any = true;
14703    }
14704    wrote_any.then_some(out)
14705}
14706
14707fn styled_detail_summary_line(line: &str) -> Option<RichLine> {
14708    if line.ends_with(':') && !line.starts_with("  ") {
14709        return Some(RichLine::from_spans([RichSpan::styled(
14710            line,
14711            tokens::chip_style(SemanticTone::Accent),
14712        )]));
14713    }
14714
14715    if line.starts_with("Status: ") {
14716        return summary_line_from_pairs(line, |label, value| match label {
14717            "Status" => tone_for_status(value),
14718            "Priority" => tone_for_priority(value),
14719            "State" => tone_for_state(value),
14720            "Type" => SemanticTone::Neutral,
14721            _ => SemanticTone::Muted,
14722        });
14723    }
14724
14725    None
14726}
14727
14728fn command_hint_width(hint: CommandHint<'_>) -> usize {
14729    display_width(hint.key) + 1 + display_width(hint.desc)
14730}
14731
14732fn command_hint_line(hints: &[CommandHint<'_>]) -> RichLine {
14733    let mut line = RichLine::new();
14734    for (index, hint) in hints.iter().enumerate() {
14735        if index > 0 {
14736            line.push_span(RichSpan::styled(" │ ", tokens::footer_sep()));
14737        }
14738        line.push_span(RichSpan::styled(hint.key, tokens::footer_key()));
14739        line.push_span(RichSpan::raw(" "));
14740        line.push_span(RichSpan::styled(hint.desc, tokens::footer_hint()));
14741    }
14742    line
14743}
14744
14745fn wrap_command_hints(hints: &[CommandHint<'_>], width: usize) -> RichText {
14746    if hints.is_empty() || width == 0 {
14747        return RichText::new();
14748    }
14749
14750    let mut lines = Vec::new();
14751    let mut line_start = 0usize;
14752    let mut line_width = 0usize;
14753
14754    for (index, hint) in hints.iter().copied().enumerate() {
14755        let hint_width = command_hint_width(hint);
14756        let separator_width = usize::from(index > line_start) * 3;
14757        if index > line_start && line_width + separator_width + hint_width > width {
14758            lines.push(command_hint_line(&hints[line_start..index]));
14759            line_start = index;
14760            line_width = hint_width;
14761        } else {
14762            line_width += separator_width + hint_width;
14763        }
14764    }
14765
14766    if line_start < hints.len() {
14767        lines.push(command_hint_line(&hints[line_start..]));
14768    }
14769
14770    RichText::from_lines(lines)
14771}
14772
14773/// Build a styled footer with a dim prefix label and styled command hints.
14774fn styled_mode_footer(prefix: &str, hints: &[CommandHint<'_>], width: u16) -> RichText {
14775    let mut first_line = RichLine::new();
14776    first_line.push_span(RichSpan::styled(prefix, tokens::footer_dim()));
14777    first_line.push_span(RichSpan::styled(" │ ", tokens::footer_sep()));
14778
14779    // Append the first line of hints inline after the prefix
14780    let avail = width.saturating_sub(1) as usize;
14781    let prefix_width = display_width(prefix) + 3; // " │ " = 3
14782    let hint_text = wrap_command_hints(hints, avail.saturating_sub(prefix_width));
14783
14784    if let Some(first_hint_line) = hint_text.lines().first() {
14785        for span in first_hint_line.spans() {
14786            first_line.push_span(span.clone());
14787        }
14788    }
14789
14790    let mut lines = vec![first_line];
14791    for extra in hint_text.lines().iter().skip(1) {
14792        lines.push(extra.clone());
14793    }
14794    RichText::from_lines(lines)
14795}
14796
14797fn fit_display(value: &str, width: usize) -> String {
14798    let mut out = truncate_display(value, width);
14799    let visible = display_width(&out);
14800    if visible < width {
14801        out.push_str(&" ".repeat(width - visible));
14802    }
14803    out
14804}
14805
14806fn center_display(value: &str, width: usize) -> String {
14807    let text = truncate_display(value, width);
14808    let visible = display_width(&text);
14809    if visible >= width {
14810        return text;
14811    }
14812
14813    let left = (width - visible) / 2;
14814    let right = width - visible - left;
14815    format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
14816}
14817
14818fn center_box_rows(boxes: &[Vec<String>], width: usize) -> Vec<String> {
14819    if boxes.is_empty() {
14820        return Vec::new();
14821    }
14822
14823    let height = boxes.iter().map(Vec::len).max().unwrap_or(0);
14824    (0..height)
14825        .map(|row| {
14826            let joined = boxes
14827                .iter()
14828                .map(|box_lines| {
14829                    if let Some(line) = box_lines.get(row) {
14830                        line.clone()
14831                    } else {
14832                        let fallback_width =
14833                            box_lines.first().map_or(0, |line| display_width(line));
14834                        " ".repeat(fallback_width)
14835                    }
14836                })
14837                .collect::<Vec<_>>()
14838                .join(" ");
14839            center_display(&joined, width)
14840        })
14841        .collect()
14842}
14843
14844fn cmp_opt_datetime(
14845    left: Option<DateTime<Utc>>,
14846    right: Option<DateTime<Utc>>,
14847    descending: bool,
14848) -> std::cmp::Ordering {
14849    match (left, right) {
14850        (Some(left), Some(right)) => {
14851            if descending {
14852                right.cmp(&left)
14853            } else {
14854                left.cmp(&right)
14855            }
14856        }
14857        (Some(_), None) => std::cmp::Ordering::Less,
14858        (None, Some(_)) => std::cmp::Ordering::Greater,
14859        (None, None) => std::cmp::Ordering::Equal,
14860    }
14861}
14862
14863/// Convert a git remote URL to a web commit URL.
14864fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
14865    // Handle ssh (git@github.com:owner/repo.git) and https
14866    let trimmed = remote.trim();
14867    let web_base = if let Some(rest) = trimmed.strip_prefix("git@") {
14868        // git@github.com:owner/repo.git → https://github.com/owner/repo
14869        let rest = rest.strip_suffix(".git").unwrap_or(rest);
14870        let rest = rest.replacen(':', "/", 1);
14871        format!("https://{rest}")
14872    } else if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
14873        let base = trimmed.strip_suffix(".git").unwrap_or(trimmed);
14874        base.to_string()
14875    } else {
14876        return None;
14877    };
14878
14879    Some(format!("{web_base}/commit/{sha}"))
14880}
14881
14882fn new_app(issues: Vec<Issue>, mode: ViewMode) -> BvrApp {
14883    new_app_with_background(issues, mode, None)
14884}
14885
14886fn new_app_with_background(
14887    issues: Vec<Issue>,
14888    mode: ViewMode,
14889    background_config: Option<BackgroundModeConfig>,
14890) -> BvrApp {
14891    #[cfg(not(test))]
14892    let initial_data_hash = compute_data_hash(&issues);
14893    #[cfg(not(test))]
14894    let background_runtime = background_config.map(|config| {
14895        let mut timeline = VecDeque::new();
14896        timeline.push_back(background_timeline_entry("background mode initialized"));
14897
14898        BackgroundRuntimeState {
14899            config: config.normalized(),
14900            in_flight: false,
14901            cancel_requested: Arc::new(AtomicBool::new(false)),
14902            last_data_hash: initial_data_hash,
14903            timeline,
14904        }
14905    });
14906    #[cfg(test)]
14907    let _ = background_config;
14908
14909    let repo_root = loader::get_beads_dir(None)
14910        .ok()
14911        .and_then(|beads_dir| beads_dir.parent().map(std::path::Path::to_path_buf));
14912
14913    let use_two_phase =
14914        issues.len() > crate::analysis::graph::AnalysisConfig::background_threshold();
14915    let analyzer = if use_two_phase {
14916        Analyzer::new_fast(issues)
14917    } else {
14918        Analyzer::new(issues)
14919    };
14920    #[cfg(not(test))]
14921    let slow_metrics_rx = if use_two_phase {
14922        Some(analyzer.spawn_slow_computation())
14923    } else {
14924        None
14925    };
14926    let slow_metrics_pending = use_two_phase;
14927
14928    BvrApp {
14929        analyzer,
14930        repo_root,
14931        selected: 0,
14932        list_filter: ListFilter::All,
14933        list_sort: ListSort::Default,
14934        board_grouping: BoardGrouping::Status,
14935        board_empty_visibility: EmptyLaneVisibility::Auto,
14936        mode,
14937        mode_before_history: ViewMode::Main,
14938        mode_back_stack: Vec::new(),
14939        focus: FocusPane::List,
14940        focus_before_help: FocusPane::List,
14941        show_help: false,
14942        help_scroll_offset: 0,
14943        show_quit_confirm: false,
14944        modal_overlay: None,
14945        modal_confirm_result: None,
14946        history_confidence_index: 0,
14947        history_view_mode: HistoryViewMode::Bead,
14948        history_event_cursor: 0,
14949        history_related_bead_cursor: 0,
14950        history_bead_commit_cursor: 0,
14951        history_git_cache: None,
14952        history_search_active: false,
14953        history_search_query: String::new(),
14954        history_search_match_cursor: 0,
14955        history_search_mode: HistorySearchMode::All,
14956        history_show_file_tree: false,
14957        history_file_tree_cursor: 0,
14958        history_file_tree_filter: None,
14959        history_file_tree_focus: false,
14960        history_status_msg: String::new(),
14961        board_search_active: false,
14962        board_search_query: String::new(),
14963        board_search_match_cursor: 0,
14964        board_detail_scroll_offset: 0,
14965        detail_scroll_offset: 0,
14966        main_search_active: false,
14967        main_search_query: String::new(),
14968        main_search_match_cursor: 0,
14969        list_scroll_offset: Cell::new(0),
14970        list_viewport_height: Cell::new(0),
14971        graph_search_active: false,
14972        graph_search_query: String::new(),
14973        graph_search_match_cursor: 0,
14974        insights_search_active: false,
14975        insights_search_query: String::new(),
14976        insights_search_match_cursor: 0,
14977        insights_panel: InsightsPanel::Bottlenecks,
14978        insights_heatmap: None,
14979        insights_show_explanations: true,
14980        insights_show_calc_proof: false,
14981        detail_dep_cursor: 0,
14982        actionable_plan: None,
14983        actionable_track_cursor: 0,
14984        actionable_item_cursor: 0,
14985        attention_result: None,
14986        attention_cursor: 0,
14987        tree_flat_nodes: Vec::new(),
14988        tree_cursor: 0,
14989        tree_collapsed: std::collections::HashSet::new(),
14990        tree_search_active: false,
14991        tree_search_query: String::new(),
14992        tree_search_match_cursor: 0,
14993        pending_g: false,
14994        g_pre_toggle_mode: None,
14995        pending_z: false,
14996        label_dashboard: None,
14997        label_dashboard_cursor: 0,
14998        flow_matrix: None,
14999        flow_matrix_row_cursor: 0,
15000        flow_matrix_col_cursor: 0,
15001        time_travel_ref_input: String::new(),
15002        time_travel_input_active: false,
15003        time_travel_diff: None,
15004        time_travel_category_cursor: 0,
15005        time_travel_issue_cursor: 0,
15006        time_travel_last_ref: None,
15007        sprint_data: Vec::new(),
15008        sprint_cursor: 0,
15009        sprint_issue_cursor: 0,
15010        modal_label_filter: None,
15011        modal_repo_filter: None,
15012        priority_hints_visible: false,
15013        status_msg: String::new(),
15014        slow_metrics_pending,
15015        #[cfg(not(test))]
15016        slow_metrics_rx,
15017        #[cfg(not(test))]
15018        background_runtime,
15019        #[cfg(test)]
15020        key_trace: Vec::new(),
15021    }
15022}
15023
15024pub fn run_tui(issues: Vec<Issue>) -> Result<()> {
15025    run_tui_with_background(issues, None, None, None)
15026}
15027
15028pub fn run_tui_with_background(
15029    issues: Vec<Issue>,
15030    background_config: Option<BackgroundModeConfig>,
15031    initial_view: Option<ViewMode>,
15032    initial_filter: Option<ListFilter>,
15033) -> Result<()> {
15034    let mode = initial_view.unwrap_or(ViewMode::Main);
15035    let mut model = new_app_with_background(issues, mode, background_config);
15036    if let Some(filter) = initial_filter {
15037        model.list_filter = filter;
15038    }
15039    // Computed views initialise their data lazily when toggled via
15040    // keybinding, but `--view` bypasses those toggle functions.
15041    // Trigger the same initialisation here so the view is populated
15042    // on the very first frame.
15043    match mode {
15044        ViewMode::Tree => model.build_tree_flat_nodes(),
15045        ViewMode::LabelDashboard => model.compute_label_dashboard(),
15046        ViewMode::FlowMatrix => model.compute_flow_matrix(),
15047        ViewMode::Sprint => model.load_sprint_data(),
15048        ViewMode::Actionable => model.compute_actionable_plan(),
15049        ViewMode::Attention => model.compute_attention(),
15050        _ => {}
15051    }
15052    App::new(model)
15053        .screen_mode(ScreenMode::AltScreen)
15054        .run()
15055        .map_err(|error| BvrError::Tui(error.to_string()))
15056}
15057
15058/// Render a named TUI view non-interactively at the given dimensions and
15059/// return the textual output. Supported view names: `insights`, `board`,
15060/// `history`, `main`, `graph`.
15061pub fn render_debug_view(
15062    issues: Vec<Issue>,
15063    view_name: &str,
15064    width: u16,
15065    height: u16,
15066) -> Result<String> {
15067    let (mode, kind) = parse_debug_render_target(view_name)?;
15068
15069    #[cfg(test)]
15070    set_pane_split_state(PaneSplitState::default());
15071
15072    let mut app = new_app(issues, mode);
15073    if matches!(mode, ViewMode::History) && matches!(kind, DebugRenderKind::Layout) {
15074        app.history_view_mode = HistoryViewMode::Bead;
15075    }
15076    let mut pool = ftui::GraphemePool::default();
15077    let mut frame = Frame::new(width, height, &mut pool);
15078    app.view(&mut frame);
15079    match kind {
15080        DebugRenderKind::View => Ok(buffer_to_text(&frame.buffer, &pool)),
15081        DebugRenderKind::Layout => Ok(render_layout_debug_report(&app, width, height)),
15082        DebugRenderKind::HitTest => Ok(render_hittest_debug_report(&app, width, height)),
15083        DebugRenderKind::Capture => Ok(render_capture_debug_report(
15084            &app,
15085            &buffer_to_text(&frame.buffer, &pool),
15086            width,
15087            height,
15088        )),
15089    }
15090}
15091
15092/// Convert a rendered buffer to a plain-text string (one line per row,
15093/// trailing whitespace trimmed).
15094fn buffer_to_text(buf: &ftui::Buffer, pool: &ftui::GraphemePool) -> String {
15095    let mut out = String::with_capacity((buf.width() as usize + 1) * buf.height() as usize);
15096    for y in 0..buf.height() {
15097        if y > 0 {
15098            out.push('\n');
15099        }
15100        let mut row = String::with_capacity(buf.width() as usize);
15101        for x in 0..buf.width() {
15102            if let Some(cell) = buf.get(x, y) {
15103                if cell.is_continuation() {
15104                    continue;
15105                }
15106                if cell.is_empty() {
15107                    row.push(' ');
15108                } else if let Some(c) = cell.content.as_char() {
15109                    row.push(c);
15110                } else if let Some(gid) = cell.content.grapheme_id() {
15111                    if let Some(text) = pool.get(gid) {
15112                        row.push_str(text);
15113                    } else {
15114                        row.push('?');
15115                    }
15116                } else {
15117                    row.push(' ');
15118                }
15119            } else {
15120                row.push(' ');
15121            }
15122        }
15123        let trimmed = row.trim_end();
15124        out.push_str(trimmed);
15125    }
15126    out
15127}
15128
15129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15130enum DebugRenderKind {
15131    View,
15132    Layout,
15133    HitTest,
15134    Capture,
15135}
15136
15137fn parse_debug_render_target(view_name: &str) -> Result<(ViewMode, DebugRenderKind)> {
15138    let (base_name, kind) = if let Some(base) = view_name.strip_suffix("-layout") {
15139        (base, DebugRenderKind::Layout)
15140    } else if let Some(base) = view_name.strip_suffix("-hittest") {
15141        (base, DebugRenderKind::HitTest)
15142    } else if let Some(base) = view_name.strip_suffix("-capture") {
15143        (base, DebugRenderKind::Capture)
15144    } else {
15145        (view_name, DebugRenderKind::View)
15146    };
15147
15148    let mode = match base_name {
15149        "insights" => ViewMode::Insights,
15150        "board" => ViewMode::Board,
15151        "history" => ViewMode::History,
15152        "main" => ViewMode::Main,
15153        "graph" => ViewMode::Graph,
15154        other => {
15155            return Err(BvrError::InvalidArgument(format!(
15156                "Unknown debug-render view '{other}'. Supported: insights, board, history, main, graph"
15157            )));
15158        }
15159    };
15160
15161    Ok((mode, kind))
15162}
15163
15164fn rect_debug_line(label: &str, area: Rect) -> String {
15165    format!(
15166        "{label:<14} x={} y={} w={} h={}",
15167        area.x, area.y, area.width, area.height
15168    )
15169}
15170
15171fn debug_layout_rects(app: &BvrApp, width: u16, height: u16) -> Vec<(&'static str, Rect)> {
15172    let full = Rect::from_size(width, height);
15173    let rows = Flex::vertical()
15174        .constraints([
15175            Constraint::Fixed(1),
15176            Constraint::Min(3),
15177            Constraint::Fixed(1),
15178        ])
15179        .split(full);
15180    let body = rows[1];
15181    let bp = Breakpoint::from_width(width);
15182    let split_state = pane_split_state();
15183    let graph_single_pane = matches!(app.mode, ViewMode::Graph) && matches!(bp, Breakpoint::Narrow);
15184    let history_layout = if matches!(app.mode, ViewMode::History) {
15185        HistoryLayout::from_width(body.width)
15186    } else {
15187        HistoryLayout::Narrow
15188    };
15189    let history_multi_pane =
15190        matches!(app.mode, ViewMode::History) && history_layout.has_middle_pane();
15191    let mut rects = vec![("header", rows[0]), ("body", body), ("footer", rows[2])];
15192
15193    if graph_single_pane {
15194        rects.push(("detail", body));
15195        return rects;
15196    }
15197
15198    if history_multi_pane {
15199        if matches!(history_layout, HistoryLayout::Wide)
15200            && matches!(app.history_view_mode, HistoryViewMode::Bead)
15201        {
15202            let PaneSplitPreset::Four(pcts) =
15203                split_state.history_pcts(history_layout, app.history_view_mode)
15204            else {
15205                unreachable!("wide bead history should use four-pane split");
15206            };
15207            let panes = Flex::horizontal()
15208                .constraints([
15209                    Constraint::Percentage(pcts[0]),
15210                    Constraint::Percentage(pcts[1]),
15211                    Constraint::Percentage(pcts[2]),
15212                    Constraint::Percentage(pcts[3]),
15213                ])
15214                .split(body);
15215            rects.push(("list", panes[0]));
15216            rects.push(("timeline", panes[1]));
15217            rects.push(("middle", panes[2]));
15218            rects.push(("detail", panes[3]));
15219        } else {
15220            let PaneSplitPreset::Three(pane_widths) =
15221                split_state.history_pcts(history_layout, app.history_view_mode)
15222            else {
15223                unreachable!("multi-pane history should use three-pane split");
15224            };
15225            let panes = Flex::horizontal()
15226                .constraints([
15227                    Constraint::Percentage(pane_widths[0]),
15228                    Constraint::Percentage(pane_widths[1]),
15229                    Constraint::Percentage(pane_widths[2]),
15230                ])
15231                .split(body);
15232            rects.push(("list", panes[0]));
15233            rects.push(("middle", panes[1]));
15234            rects.push(("detail", panes[2]));
15235        }
15236        return rects;
15237    }
15238
15239    let panes = Flex::horizontal()
15240        .constraints([
15241            Constraint::Percentage(split_state.two_pane_list_pct(bp)),
15242            Constraint::Percentage(split_state.two_pane_detail_pct(bp)),
15243        ])
15244        .split(body);
15245    rects.push(("list", panes[0]));
15246    rects.push(("detail", panes[1]));
15247    rects
15248}
15249
15250fn render_layout_debug_report(app: &BvrApp, width: u16, height: u16) -> String {
15251    let rects = debug_layout_rects(app, width, height);
15252    let mut lines = vec![
15253        format!(
15254            "Layout Debug | view={} | focus={}",
15255            app.mode.label(),
15256            app.focus.label()
15257        ),
15258        format!(
15259            "viewport       w={} h={} breakpoint={:?}",
15260            width,
15261            height,
15262            Breakpoint::from_width(width)
15263        ),
15264    ];
15265    lines.extend(
15266        rects
15267            .into_iter()
15268            .map(|(label, area)| rect_debug_line(label, area)),
15269    );
15270
15271    let detail_area = cached_detail_content_area();
15272    if detail_area.width > 0 && detail_area.height > 0 {
15273        lines.push(rect_debug_line("detail-content", detail_area));
15274    }
15275
15276    lines.join("\n")
15277}
15278
15279fn render_hittest_debug_report(app: &BvrApp, width: u16, height: u16) -> String {
15280    let mut lines = vec![
15281        format!(
15282            "HitTest Debug | view={} | focus={}",
15283            app.mode.label(),
15284            app.focus.label()
15285        ),
15286        format!("viewport       w={} h={}", width, height),
15287    ];
15288
15289    let detail_area = cached_detail_content_area();
15290    lines.push(rect_debug_line("detail-content", detail_area));
15291
15292    for tab in header_mode_tabs(app, width) {
15293        lines.push(rect_debug_line(
15294            &format!("tab-{}", tab.mode.label().to_ascii_lowercase()),
15295            tab.rect,
15296        ));
15297    }
15298
15299    if let Some(link_area) = app.current_detail_link_row_area() {
15300        lines.push(rect_debug_line("link-row", link_area));
15301        let center_x = link_area.x.saturating_add(link_area.width / 2);
15302        let center_y = link_area.y;
15303        lines.push(format!(
15304            "link-center    x={} y={} inside={}",
15305            center_x,
15306            center_y,
15307            rect_contains(link_area, center_x, center_y)
15308        ));
15309    } else {
15310        lines.push("link-row       none".to_string());
15311    }
15312
15313    for (index, hit_box) in splitter_hit_boxes(app, width, height)
15314        .into_iter()
15315        .enumerate()
15316    {
15317        lines.push(rect_debug_line(&format!("splitter-{index}"), hit_box.rect));
15318    }
15319
15320    lines.join("\n")
15321}
15322
15323fn render_capture_debug_report(app: &BvrApp, rendered: &str, width: u16, height: u16) -> String {
15324    format!(
15325        "Capture Debug | view={} | focus={} | selected={} | trace-len={}\nviewport       w={} h={}\n\n--- render ---\n{}\n\n--- layout ---\n{}\n\n--- hittest ---\n{}",
15326        app.mode.label(),
15327        app.focus.label(),
15328        app.selected,
15329        debug_trace_len(app),
15330        width,
15331        height,
15332        rendered,
15333        render_layout_debug_report(app, width, height),
15334        render_hittest_debug_report(app, width, height),
15335    )
15336}
15337
15338#[cfg(test)]
15339fn debug_trace_len(app: &BvrApp) -> usize {
15340    app.key_trace.len()
15341}
15342
15343#[cfg(not(test))]
15344fn debug_trace_len(_app: &BvrApp) -> usize {
15345    0
15346}
15347
15348#[cfg(test)]
15349mod tests {
15350    use super::{
15351        BackgroundTickDecision, BoardGrouping, BvrApp, CommandHint, EmptyLaneVisibility, FocusPane,
15352        GitCommitRecord, HistoryBeadCompat, HistoryCommitCompat, HistoryGitCache, HistoryLayout,
15353        HistoryMilestonesCompat, HistorySearchMode, HistoryViewMode, InsightsPanel, ListFilter,
15354        ListSort, ModalOverlay, MouseButton, MouseEvent, MouseEventKind, Msg, ScanLineContext,
15355        SemanticTone, ViewMode, background_warning_message, blocker_indicator, buffer_to_text,
15356        build_header_text, cached_detail_content_area, center_display, command_hint_width,
15357        compact_history_duration_label, decide_background_tick, display_width, fit_display,
15358        history_legacy_lifecycle_lines, issue_scan_line, label_chips,
15359        legacy_history_author_initials, metric_strip, panel_header, priority_badge,
15360        record_view_size, render_debug_view, saturating_scroll_offset, section_separator,
15361        should_apply_background_reload, sprint_reference_now, status_chip,
15362        styled_detail_summary_line, truncate_display, type_badge, wrap_command_hints,
15363    };
15364    use crate::analysis::Analyzer;
15365    use crate::analysis::diff::FieldChange;
15366    use crate::analysis::git_history::{
15367        HistoryCycleCompat, HistoryEventCompat, HistoryFileChangeCompat,
15368    };
15369    use crate::analysis::label_intel::CrossLabelFlow;
15370    use crate::model::{Comment, Dependency, Issue, Sprint, ts};
15371    use chrono::Utc;
15372    use ftui::core::event::{KeyCode, Modifiers};
15373    use ftui::runtime::{Cmd, Model};
15374    use ftui::text::{Line as RichLine, Span as RichSpan};
15375    use std::cell::Cell;
15376    use std::collections::BTreeMap;
15377    use std::fmt::Write as _;
15378
15379    #[derive(Debug, Clone)]
15380    struct DebugReplayCapture {
15381        step: String,
15382        mode: ViewMode,
15383        focus: FocusPane,
15384        selected: usize,
15385        width: u16,
15386        height: u16,
15387        trace_len: usize,
15388        rendered: String,
15389        layout: String,
15390        hittest: String,
15391    }
15392
15393    fn sample_issues() -> Vec<Issue> {
15394        vec![
15395            Issue {
15396                id: "A".to_string(),
15397                title: "Root".to_string(),
15398                status: "open".to_string(),
15399                issue_type: "task".to_string(),
15400                description: "Top-level issue that unblocks downstream work once the shared baseline is solid."
15401                    .to_string(),
15402                design: "Keep the main flow lean, surface the most important context first, and let supporting sections trail after the summary."
15403                    .to_string(),
15404                acceptance_criteria:
15405                    "- Detail summary shows triage and graph context.\n- Rich body sections stay readable across narrow and wide panes."
15406                        .to_string(),
15407                notes: "Fixture used to exercise the richer main detail pane.".to_string(),
15408                assignee: "alice".to_string(),
15409                estimated_minutes: Some(90),
15410                created_at: ts("2026-01-01T00:00:00Z"),
15411                updated_at: ts("2026-01-02T00:00:00Z"),
15412                labels: vec!["core".to_string(), "parity".to_string()],
15413                comments: vec![
15414                    Comment {
15415                        id: 1,
15416                        issue_id: "A".to_string(),
15417                        author: "alice".to_string(),
15418                        text: "Need this baseline before the dependent slice can land."
15419                            .to_string(),
15420                        created_at: ts("2026-01-01T08:00:00Z"),
15421                    },
15422                    Comment {
15423                        id: 2,
15424                        issue_id: "A".to_string(),
15425                        author: "bob".to_string(),
15426                        text: "Verify the detail pane still reads well at narrow widths."
15427                            .to_string(),
15428                        created_at: ts("2026-01-02T09:30:00Z"),
15429                    },
15430                ],
15431                source_repo: "viewer".to_string(),
15432                ..Issue::default()
15433            },
15434            Issue {
15435                id: "B".to_string(),
15436                title: "Dependent".to_string(),
15437                status: "open".to_string(),
15438                issue_type: "task".to_string(),
15439                description: "Depends on A and should report blocked state clearly.".to_string(),
15440                created_at: ts("2026-01-03T00:00:00Z"),
15441                updated_at: ts("2026-01-04T00:00:00Z"),
15442                dependencies: vec![Dependency {
15443                    issue_id: "B".to_string(),
15444                    depends_on_id: "A".to_string(),
15445                    dep_type: "blocks".to_string(),
15446                    ..Dependency::default()
15447                }],
15448                ..Issue::default()
15449            },
15450            Issue {
15451                id: "C".to_string(),
15452                title: "Closed".to_string(),
15453                status: "closed".to_string(),
15454                issue_type: "task".to_string(),
15455                created_at: ts("2026-01-01T00:00:00Z"),
15456                updated_at: ts("2026-01-06T00:00:00Z"),
15457                closed_at: ts("2026-01-06T00:00:00Z"),
15458                ..Issue::default()
15459            },
15460        ]
15461    }
15462
15463    fn lane_issues() -> Vec<Issue> {
15464        vec![
15465            Issue {
15466                id: "OPEN-1".to_string(),
15467                title: "Open".to_string(),
15468                status: "open".to_string(),
15469                issue_type: "task".to_string(),
15470                priority: 0,
15471                ..Issue::default()
15472            },
15473            Issue {
15474                id: "IP-1".to_string(),
15475                title: "In Progress".to_string(),
15476                status: "in_progress".to_string(),
15477                issue_type: "feature".to_string(),
15478                priority: 1,
15479                ..Issue::default()
15480            },
15481            Issue {
15482                id: "BLK-1".to_string(),
15483                title: "Blocked".to_string(),
15484                status: "blocked".to_string(),
15485                issue_type: "bug".to_string(),
15486                priority: 2,
15487                ..Issue::default()
15488            },
15489            Issue {
15490                id: "CLS-1".to_string(),
15491                title: "Closed".to_string(),
15492                status: "closed".to_string(),
15493                issue_type: "docs".to_string(),
15494                priority: 3,
15495                ..Issue::default()
15496            },
15497        ]
15498    }
15499
15500    fn board_nav_issues() -> Vec<Issue> {
15501        vec![
15502            Issue {
15503                id: "OPEN-1".to_string(),
15504                title: "Open Start".to_string(),
15505                status: "open".to_string(),
15506                issue_type: "task".to_string(),
15507                priority: 0,
15508                ..Issue::default()
15509            },
15510            Issue {
15511                id: "OPEN-2".to_string(),
15512                title: "Open End".to_string(),
15513                status: "open".to_string(),
15514                issue_type: "task".to_string(),
15515                priority: 1,
15516                ..Issue::default()
15517            },
15518            Issue {
15519                id: "IP-1".to_string(),
15520                title: "In Progress".to_string(),
15521                status: "in_progress".to_string(),
15522                issue_type: "feature".to_string(),
15523                priority: 1,
15524                ..Issue::default()
15525            },
15526            Issue {
15527                id: "CLS-1".to_string(),
15528                title: "Closed".to_string(),
15529                status: "closed".to_string(),
15530                issue_type: "docs".to_string(),
15531                priority: 3,
15532                ..Issue::default()
15533            },
15534        ]
15535    }
15536
15537    fn board_with_unknown_status_issues() -> Vec<Issue> {
15538        vec![
15539            Issue {
15540                id: "OPEN-1".to_string(),
15541                title: "Open".to_string(),
15542                status: "open".to_string(),
15543                issue_type: "task".to_string(),
15544                priority: 0,
15545                ..Issue::default()
15546            },
15547            Issue {
15548                id: "QUE-1".to_string(),
15549                title: "Queued".to_string(),
15550                status: "queued".to_string(),
15551                issue_type: "task".to_string(),
15552                priority: 1,
15553                ..Issue::default()
15554            },
15555        ]
15556    }
15557
15558    fn sortable_issues() -> Vec<Issue> {
15559        vec![
15560            Issue {
15561                id: "Z".to_string(),
15562                title: "Oldest".to_string(),
15563                status: "open".to_string(),
15564                issue_type: "task".to_string(),
15565                priority: 3,
15566                created_at: ts("2026-01-01T00:00:00Z"),
15567                updated_at: ts("2026-01-06T00:00:00Z"),
15568                ..Issue::default()
15569            },
15570            Issue {
15571                id: "A".to_string(),
15572                title: "Middle".to_string(),
15573                status: "open".to_string(),
15574                issue_type: "task".to_string(),
15575                priority: 2,
15576                created_at: ts("2026-01-02T00:00:00Z"),
15577                updated_at: ts("2026-01-05T00:00:00Z"),
15578                ..Issue::default()
15579            },
15580            Issue {
15581                id: "M".to_string(),
15582                title: "Newest".to_string(),
15583                status: "open".to_string(),
15584                issue_type: "task".to_string(),
15585                priority: 1,
15586                created_at: ts("2026-01-03T00:00:00Z"),
15587                updated_at: ts("2026-01-04T00:00:00Z"),
15588                ..Issue::default()
15589            },
15590        ]
15591    }
15592
15593    fn graph_many_blocker_issues() -> Vec<Issue> {
15594        let mut issues = vec![Issue {
15595            id: "MAIN".to_string(),
15596            title: "Main Issue".to_string(),
15597            status: "open".to_string(),
15598            issue_type: "task".to_string(),
15599            dependencies: (0..10)
15600                .map(|idx| Dependency {
15601                    issue_id: "MAIN".to_string(),
15602                    depends_on_id: format!("BLK-{idx:02}"),
15603                    dep_type: "blocks".to_string(),
15604                    ..Dependency::default()
15605                })
15606                .collect(),
15607            ..Issue::default()
15608        }];
15609
15610        issues.extend((0..10).map(|idx| Issue {
15611            id: format!("BLK-{idx:02}"),
15612            title: format!("Blocker {idx:02}"),
15613            status: "open".to_string(),
15614            issue_type: "task".to_string(),
15615            ..Issue::default()
15616        }));
15617
15618        issues
15619    }
15620
15621    fn graph_many_dependent_issues() -> Vec<Issue> {
15622        let mut issues = vec![Issue {
15623            id: "ROOT".to_string(),
15624            title: "Root Issue".to_string(),
15625            status: "open".to_string(),
15626            issue_type: "task".to_string(),
15627            ..Issue::default()
15628        }];
15629
15630        issues.extend((0..10).map(|idx| Issue {
15631            id: format!("DEP-{idx:02}"),
15632            title: format!("Dependent {idx:02}"),
15633            status: "open".to_string(),
15634            issue_type: "task".to_string(),
15635            dependencies: vec![Dependency {
15636                issue_id: format!("DEP-{idx:02}"),
15637                depends_on_id: "ROOT".to_string(),
15638                dep_type: "blocks".to_string(),
15639                ..Dependency::default()
15640            }],
15641            ..Issue::default()
15642        }));
15643
15644        issues
15645    }
15646
15647    fn new_app(mode: ViewMode, selected: usize) -> BvrApp {
15648        let mut app = BvrApp {
15649            analyzer: Analyzer::new(sample_issues()),
15650            repo_root: None,
15651            selected,
15652            list_filter: ListFilter::All,
15653            list_sort: ListSort::Default,
15654            board_grouping: BoardGrouping::Status,
15655            board_empty_visibility: EmptyLaneVisibility::Auto,
15656            mode,
15657            mode_before_history: ViewMode::Main,
15658            mode_back_stack: Vec::new(),
15659            focus: FocusPane::List,
15660            focus_before_help: FocusPane::List,
15661            show_help: false,
15662            help_scroll_offset: 0,
15663            show_quit_confirm: false,
15664            modal_overlay: None,
15665            modal_confirm_result: None,
15666            history_confidence_index: 0,
15667            history_view_mode: HistoryViewMode::Bead,
15668            history_event_cursor: 0,
15669            history_related_bead_cursor: 0,
15670            history_bead_commit_cursor: 0,
15671            history_git_cache: None,
15672            history_search_active: false,
15673            history_search_query: String::new(),
15674            history_search_match_cursor: 0,
15675            history_search_mode: HistorySearchMode::All,
15676            history_show_file_tree: false,
15677            history_file_tree_cursor: 0,
15678            history_file_tree_filter: None,
15679            history_file_tree_focus: false,
15680            history_status_msg: String::new(),
15681            board_search_active: false,
15682            board_search_query: String::new(),
15683            board_search_match_cursor: 0,
15684            board_detail_scroll_offset: 0,
15685            detail_scroll_offset: 0,
15686            main_search_active: false,
15687            main_search_query: String::new(),
15688            main_search_match_cursor: 0,
15689            list_scroll_offset: Cell::new(0),
15690            list_viewport_height: Cell::new(0),
15691            graph_search_active: false,
15692            graph_search_query: String::new(),
15693            graph_search_match_cursor: 0,
15694            insights_search_active: false,
15695            insights_search_query: String::new(),
15696            insights_search_match_cursor: 0,
15697            insights_panel: InsightsPanel::Bottlenecks,
15698            insights_heatmap: None,
15699            insights_show_explanations: true,
15700            insights_show_calc_proof: false,
15701            detail_dep_cursor: 0,
15702            actionable_plan: None,
15703            actionable_track_cursor: 0,
15704            actionable_item_cursor: 0,
15705            attention_result: None,
15706            attention_cursor: 0,
15707            tree_flat_nodes: Vec::new(),
15708            tree_cursor: 0,
15709            tree_collapsed: std::collections::HashSet::new(),
15710            tree_search_active: false,
15711            tree_search_query: String::new(),
15712            tree_search_match_cursor: 0,
15713            pending_g: false,
15714            g_pre_toggle_mode: None,
15715            pending_z: false,
15716            label_dashboard: None,
15717            label_dashboard_cursor: 0,
15718            flow_matrix: None,
15719            flow_matrix_row_cursor: 0,
15720            flow_matrix_col_cursor: 0,
15721            time_travel_ref_input: String::new(),
15722            time_travel_input_active: false,
15723            time_travel_diff: None,
15724            time_travel_category_cursor: 0,
15725            time_travel_issue_cursor: 0,
15726            time_travel_last_ref: None,
15727            sprint_data: Vec::new(),
15728            sprint_cursor: 0,
15729            sprint_issue_cursor: 0,
15730            modal_label_filter: None,
15731            modal_repo_filter: None,
15732            priority_hints_visible: false,
15733            status_msg: String::new(),
15734            slow_metrics_pending: false,
15735            #[cfg(test)]
15736            key_trace: Vec::new(),
15737        };
15738        app.reset_pane_split_state();
15739        app
15740    }
15741
15742    fn new_app_with_issues(mode: ViewMode, selected: usize, issues: Vec<Issue>) -> BvrApp {
15743        let mut app = new_app(mode, selected);
15744        app.analyzer = Analyzer::new(issues);
15745        app.selected = selected;
15746        app
15747    }
15748
15749    fn history_file_change(path: &str) -> HistoryFileChangeCompat {
15750        HistoryFileChangeCompat {
15751            path: path.to_string(),
15752            action: "M".to_string(),
15753            insertions: 1,
15754            deletions: 0,
15755        }
15756    }
15757
15758    fn history_commit(
15759        sha: &str,
15760        message: &str,
15761        confidence: f64,
15762        paths: &[&str],
15763    ) -> HistoryCommitCompat {
15764        HistoryCommitCompat {
15765            sha: sha.to_string(),
15766            short_sha: sha[..7.min(sha.len())].to_string(),
15767            message: message.to_string(),
15768            author: "Test Author".to_string(),
15769            author_email: "test@example.com".to_string(),
15770            timestamp: "2026-01-10T00:00:00Z".to_string(),
15771            files: paths.iter().map(|path| history_file_change(path)).collect(),
15772            method: "co_committed".to_string(),
15773            confidence,
15774            reason: "fixture".to_string(),
15775            field_changes: vec![],
15776            bead_diff_lines: vec![],
15777        }
15778    }
15779
15780    fn git_commit_record(sha: &str, message: &str, paths: &[&str]) -> GitCommitRecord {
15781        GitCommitRecord {
15782            sha: sha.to_string(),
15783            short_sha: sha[..7.min(sha.len())].to_string(),
15784            timestamp: "2026-01-10T00:00:00Z".to_string(),
15785            author: "Test Author".to_string(),
15786            author_email: "test@example.com".to_string(),
15787            message: message.to_string(),
15788            files: paths.iter().map(|path| history_file_change(path)).collect(),
15789            changed_beads: true,
15790            changed_non_beads: true,
15791        }
15792    }
15793
15794    fn history_app_with_git_cache(view_mode: HistoryViewMode, selected: usize) -> BvrApp {
15795        let ui_commit = history_commit(
15796            "aaaa1111",
15797            "feat: ui wiring",
15798            0.95,
15799            &["src/ui/app.rs", "src/ui/detail.rs"],
15800        );
15801        let core_commit =
15802            history_commit("bbbb2222", "feat: graph core", 0.80, &["src/core/graph.rs"]);
15803        let docs_commit = history_commit("cccc3333", "docs: readme", 0.90, &["README.md"]);
15804        let build_commit = history_commit("dddd4444", "chore: cargo polish", 0.85, &["Cargo.toml"]);
15805
15806        let mut histories = BTreeMap::new();
15807        histories.insert(
15808            "A".to_string(),
15809            HistoryBeadCompat {
15810                bead_id: "A".to_string(),
15811                title: "Root".to_string(),
15812                status: "open".to_string(),
15813                events: Vec::new(),
15814                milestones: HistoryMilestonesCompat::default(),
15815                commits: Some(vec![ui_commit.clone(), core_commit.clone()]),
15816                cycle_time: None,
15817                last_author: "Alice".to_string(),
15818            },
15819        );
15820        histories.insert(
15821            "B".to_string(),
15822            HistoryBeadCompat {
15823                bead_id: "B".to_string(),
15824                title: "Dependent".to_string(),
15825                status: "open".to_string(),
15826                events: Vec::new(),
15827                milestones: HistoryMilestonesCompat::default(),
15828                commits: Some(vec![docs_commit.clone(), build_commit.clone()]),
15829                cycle_time: None,
15830                last_author: "Bob".to_string(),
15831            },
15832        );
15833
15834        let mut commit_bead_confidence = BTreeMap::new();
15835        commit_bead_confidence.insert("aaaa1111".to_string(), vec![("A".to_string(), 0.95)]);
15836        commit_bead_confidence.insert("bbbb2222".to_string(), vec![("A".to_string(), 0.80)]);
15837        commit_bead_confidence.insert("cccc3333".to_string(), vec![("B".to_string(), 0.90)]);
15838        commit_bead_confidence.insert("dddd4444".to_string(), vec![("B".to_string(), 0.85)]);
15839
15840        let mut app = new_app(ViewMode::History, selected);
15841        app.history_view_mode = view_mode;
15842        app.history_git_cache = Some(HistoryGitCache {
15843            commits: vec![
15844                git_commit_record(
15845                    "aaaa1111",
15846                    "feat: ui wiring",
15847                    &["src/ui/app.rs", "src/ui/detail.rs"],
15848                ),
15849                git_commit_record("bbbb2222", "feat: graph core", &["src/core/graph.rs"]),
15850                git_commit_record("cccc3333", "docs: readme", &["README.md"]),
15851                git_commit_record("dddd4444", "chore: cargo polish", &["Cargo.toml"]),
15852            ],
15853            histories,
15854            commit_bead_confidence,
15855        });
15856        app
15857    }
15858
15859    /// Pre-populate `history_git_cache` with deterministic fixture data so
15860    /// tests that switch to git-history mode don't depend on real repo state.
15861    fn inject_deterministic_git_cache(app: &mut BvrApp) {
15862        let ui_commit = history_commit(
15863            "aaaa1111",
15864            "feat: ui wiring",
15865            0.95,
15866            &["src/ui/app.rs", "src/ui/detail.rs"],
15867        );
15868        let core_commit =
15869            history_commit("bbbb2222", "feat: graph core", 0.80, &["src/core/graph.rs"]);
15870
15871        let mut histories = BTreeMap::new();
15872        for issue in &app.analyzer.issues {
15873            histories.insert(
15874                issue.id.clone(),
15875                HistoryBeadCompat {
15876                    bead_id: issue.id.clone(),
15877                    title: issue.title.clone(),
15878                    status: issue.status.clone(),
15879                    events: Vec::new(),
15880                    milestones: HistoryMilestonesCompat::default(),
15881                    commits: None,
15882                    cycle_time: None,
15883                    last_author: String::new(),
15884                },
15885            );
15886        }
15887
15888        // Wire commits to issue "B" (Dependent) so git mode shows content.
15889        if let Some(history) = histories.get_mut("B") {
15890            history.commits = Some(vec![ui_commit.clone(), core_commit.clone()]);
15891            history.last_author = "Test Author".to_string();
15892        }
15893
15894        let mut commit_bead_confidence = BTreeMap::new();
15895        commit_bead_confidence.insert("aaaa1111".to_string(), vec![("B".to_string(), 0.95)]);
15896        commit_bead_confidence.insert("bbbb2222".to_string(), vec![("B".to_string(), 0.80)]);
15897
15898        app.history_git_cache = Some(HistoryGitCache {
15899            commits: vec![
15900                git_commit_record(
15901                    "aaaa1111",
15902                    "feat: ui wiring",
15903                    &["src/ui/app.rs", "src/ui/detail.rs"],
15904                ),
15905                git_commit_record("bbbb2222", "feat: graph core", &["src/core/graph.rs"]),
15906            ],
15907            histories,
15908            commit_bead_confidence,
15909        });
15910    }
15911
15912    fn key(code: KeyCode) -> Msg {
15913        Msg::KeyPress(code, Modifiers::empty())
15914    }
15915
15916    fn key_ctrl(code: KeyCode) -> Msg {
15917        Msg::KeyPress(code, Modifiers::CTRL)
15918    }
15919
15920    fn key_backtab() -> Msg {
15921        Msg::KeyPress(KeyCode::BackTab, Modifiers::SHIFT)
15922    }
15923
15924    fn mouse(kind: MouseEventKind, x: u16, y: u16) -> Msg {
15925        Msg::Mouse(MouseEvent::new(kind, x, y))
15926    }
15927
15928    fn selected_issue_id(app: &BvrApp) -> String {
15929        app.analyzer
15930            .issues
15931            .get(app.selected)
15932            .map(|issue| issue.id.clone())
15933            .unwrap_or_default()
15934    }
15935
15936    fn first_rendered_issue_id(app: &BvrApp) -> String {
15937        let indices = app.visible_issue_indices();
15938        indices
15939            .first()
15940            .and_then(|&idx| app.analyzer.issues.get(idx))
15941            .map(|issue| issue.id.clone())
15942            .unwrap_or_default()
15943    }
15944
15945    #[test]
15946    fn background_tick_decision_prioritizes_cancel_then_in_flight() {
15947        assert_eq!(
15948            decide_background_tick(true, false),
15949            BackgroundTickDecision::Stop
15950        );
15951        assert_eq!(
15952            decide_background_tick(true, true),
15953            BackgroundTickDecision::Stop
15954        );
15955        assert_eq!(
15956            decide_background_tick(false, true),
15957            BackgroundTickDecision::TickOnly
15958        );
15959        assert_eq!(
15960            decide_background_tick(false, false),
15961            BackgroundTickDecision::ReloadAndTick
15962        );
15963    }
15964
15965    #[test]
15966    fn background_reload_apply_requires_no_cancel_and_hash_change() {
15967        assert!(should_apply_background_reload(
15968            false, "new-hash", "old-hash"
15969        ));
15970        assert!(!should_apply_background_reload(
15971            false,
15972            "same-hash",
15973            "same-hash"
15974        ));
15975        assert!(!should_apply_background_reload(
15976            true, "new-hash", "old-hash"
15977        ));
15978    }
15979
15980    #[test]
15981    fn background_warning_message_suppresses_canceled_paths() {
15982        assert_eq!(background_warning_message(true, "boom"), None);
15983        assert_eq!(background_warning_message(false, "canceled"), None);
15984        assert_eq!(
15985            background_warning_message(false, "permission denied").as_deref(),
15986            Some("background reload warning: permission denied")
15987        );
15988    }
15989
15990    #[test]
15991    fn render_debug_view_supports_all_named_views() {
15992        for view in [
15993            "insights",
15994            "board",
15995            "history",
15996            "main",
15997            "graph",
15998            "main-layout",
15999            "history-layout",
16000            "graph-layout",
16001            "main-hittest",
16002            "graph-hittest",
16003            "main-capture",
16004        ] {
16005            let output =
16006                render_debug_view(sample_issues(), view, 80, 12).expect("debug render succeeds");
16007            if view.contains("-layout") || view.contains("-hittest") || view.contains("-capture") {
16008                assert!(
16009                    !output.is_empty(),
16010                    "diagnostic debug render should return content for view {view}"
16011                );
16012            } else {
16013                assert_eq!(
16014                    output.lines().count(),
16015                    12,
16016                    "expected one line per requested row for view {view}"
16017                );
16018            }
16019        }
16020    }
16021
16022    #[test]
16023    fn render_debug_view_respects_dimensions() {
16024        let width = 32_u16;
16025        let height = 7_u16;
16026        let output = render_debug_view(sample_issues(), "main", width, height)
16027            .expect("main debug render succeeds");
16028        let lines: Vec<&str> = output.lines().collect();
16029
16030        assert_eq!(lines.len(), usize::from(height));
16031        assert!(
16032            lines
16033                .iter()
16034                .all(|line| display_width(line) <= usize::from(width)),
16035            "every rendered line should fit within the requested width"
16036        );
16037    }
16038
16039    #[test]
16040    fn render_debug_view_rejects_unknown_view() {
16041        let error = render_debug_view(sample_issues(), "bogus", 80, 10)
16042            .expect_err("unknown view should fail");
16043        let message = error.to_string();
16044        assert!(message.contains("Unknown debug-render view 'bogus'"));
16045        assert!(message.contains("insights, board, history, main, graph"));
16046    }
16047
16048    #[test]
16049    fn render_debug_view_layout_reports_pane_rects() {
16050        let output =
16051            render_debug_view(sample_issues(), "main-layout", 100, 20).expect("layout debug");
16052        assert!(output.contains("Layout Debug | view=Main"));
16053        assert!(output.contains("header"));
16054        assert!(output.contains("list"));
16055        assert!(output.contains("detail"));
16056        assert!(output.contains("detail-content"));
16057    }
16058
16059    #[test]
16060    fn render_debug_view_hittest_reports_link_row() {
16061        let mut issues = sample_issues();
16062        issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
16063        let output = render_debug_view(issues, "main-hittest", 100, 20).expect("hittest debug");
16064        assert!(output.contains("HitTest Debug | view=Main"));
16065        assert!(output.contains("detail-content"));
16066        assert!(output.contains("tab-main"));
16067        assert!(output.contains("tab-board"));
16068        assert!(output.contains("link-row"));
16069        assert!(output.contains("link-center"));
16070        assert!(output.contains("splitter-0"));
16071    }
16072
16073    #[test]
16074    fn render_debug_view_history_layout_reports_timeline_rects() {
16075        let output =
16076            render_debug_view(sample_issues(), "history-layout", 160, 24).expect("layout debug");
16077        assert!(output.contains("Layout Debug | view=History"));
16078        assert!(output.contains("timeline"));
16079        assert!(output.contains("middle"));
16080        assert!(output.contains("detail"));
16081    }
16082
16083    #[test]
16084    fn render_debug_view_graph_hittest_reports_link_row() {
16085        let mut issues = sample_issues();
16086        issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
16087        let output =
16088            render_debug_view(issues, "graph-hittest", 100, 20).expect("graph hittest debug");
16089        assert!(output.contains("HitTest Debug | view=Graph"));
16090        assert!(output.contains("link-row"));
16091        assert!(output.contains("link-center"));
16092    }
16093
16094    #[test]
16095    fn render_debug_view_capture_includes_render_layout_and_hittest_sections() {
16096        let mut issues = sample_issues();
16097        issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
16098        let output = render_debug_view(issues, "main-capture", 100, 20).expect("capture debug");
16099        assert!(output.contains("Capture Debug | view=Main"));
16100        assert!(output.contains("--- render ---"));
16101        assert!(output.contains("--- layout ---"));
16102        assert!(output.contains("--- hittest ---"));
16103        assert!(output.contains("Layout Debug | view=Main"));
16104        assert!(output.contains("HitTest Debug | view=Main"));
16105    }
16106
16107    #[test]
16108    fn debug_replay_artifact_tracks_responsive_layout_and_trace_growth() {
16109        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
16110        let mut captures = Vec::new();
16111
16112        capture_debug_replay(&app, 100, 24, "history_standard", &mut captures);
16113        app.update(key(KeyCode::Char('j')));
16114        app.update(key(KeyCode::Tab));
16115        capture_debug_replay(&app, 160, 24, "history_wide_detail", &mut captures);
16116
16117        let artifact = debug_replay_artifact("history responsive replay", &captures);
16118        assert!(artifact.contains("=== Debug Replay: history responsive replay ==="));
16119        assert!(artifact.contains("trace-len=0"));
16120        assert!(artifact.contains("trace-len=2"));
16121        assert!(artifact.contains("size=100x24"));
16122        assert!(artifact.contains("size=160x24"));
16123        assert!(artifact.contains("breakpoint=Medium"));
16124        assert!(artifact.contains("breakpoint=Wide"));
16125        assert!(artifact.contains("timeline"));
16126        assert!(artifact.contains("HitTest Debug | view=History"));
16127    }
16128
16129    #[test]
16130    fn graph_mode_renders_metric_sections() {
16131        let mut app = new_app(ViewMode::Graph, 0);
16132        app.mode = ViewMode::Graph;
16133        let text = app.detail_panel_text();
16134        assert!(text.contains("Graph:"));
16135        assert!(text.contains("PageRank"));
16136        assert!(text.contains("GRAPH METRICS"));
16137        assert!(text.contains("Importance:"));
16138        assert!(text.contains("Betweenness"));
16139        assert!(text.contains("Top PageRank"));
16140    }
16141
16142    #[test]
16143    fn graph_mode_narrow_uses_single_detail_pane_layout() {
16144        let text = render_frame(ViewMode::Graph, 60, 30);
16145        assert!(text.contains("Graph View [focus]"));
16146        assert!(!text.contains("Graph Nodes"));
16147        assert!(text.contains("Focus: node 1/3 -> A (Root)"));
16148        assert!(text.contains("Focused edge: list focus"));
16149    }
16150
16151    #[test]
16152    fn board_mode_renders_lane_summary() {
16153        let mut app = new_app(ViewMode::Board, 1);
16154        app.mode = ViewMode::Board;
16155        let list = app.list_panel_text();
16156        let detail = app.detail_panel_text();
16157        assert!(list.contains("Lane"));
16158        assert!(detail.contains("Lane:"));
16159        assert!(detail.contains('B'));
16160    }
16161
16162    #[test]
16163    fn insights_mode_renders_rank_sections() {
16164        let mut app = new_app(ViewMode::Insights, 0);
16165        app.mode = ViewMode::Insights;
16166        let list = app.list_panel_text();
16167        let detail = app.detail_panel_text();
16168        assert!(list.contains("[Bottlenecks]"));
16169        assert!(list.contains("Signal Tiles"));
16170        assert!(list.contains("Outlier Radar"));
16171        assert!(detail.contains("Analytics Cockpit"));
16172        assert!(detail.contains("Critical Path Head"));
16173    }
16174
16175    #[test]
16176    fn insights_panel_s_cycles_through_all_panels() {
16177        let mut app = new_app(ViewMode::Insights, 0);
16178        app.mode = ViewMode::Insights;
16179        assert!(matches!(app.insights_panel, InsightsPanel::Bottlenecks));
16180
16181        app.update(key(KeyCode::Char('s')));
16182        assert!(matches!(app.insights_panel, InsightsPanel::Keystones));
16183        let list = app.list_panel_text();
16184        assert!(list.contains("[Keystones]"));
16185
16186        app.update(key(KeyCode::Char('s')));
16187        assert!(matches!(app.insights_panel, InsightsPanel::CriticalPath));
16188        let list = app.list_panel_text();
16189        assert!(list.contains("[Critical Path]"));
16190
16191        app.update(key(KeyCode::Char('s')));
16192        assert!(matches!(app.insights_panel, InsightsPanel::Influencers));
16193
16194        app.update(key(KeyCode::Char('s')));
16195        assert!(matches!(app.insights_panel, InsightsPanel::Betweenness));
16196
16197        app.update(key(KeyCode::Char('s')));
16198        assert!(matches!(app.insights_panel, InsightsPanel::Hubs));
16199
16200        app.update(key(KeyCode::Char('s')));
16201        assert!(matches!(app.insights_panel, InsightsPanel::Authorities));
16202
16203        app.update(key(KeyCode::Char('s')));
16204        assert!(matches!(app.insights_panel, InsightsPanel::Cores));
16205
16206        app.update(key(KeyCode::Char('s')));
16207        assert!(matches!(app.insights_panel, InsightsPanel::CutPoints));
16208
16209        app.update(key(KeyCode::Char('s')));
16210        assert!(matches!(app.insights_panel, InsightsPanel::Slack));
16211
16212        app.update(key(KeyCode::Char('s')));
16213        assert!(matches!(app.insights_panel, InsightsPanel::Cycles));
16214
16215        app.update(key(KeyCode::Char('s')));
16216        assert!(matches!(app.insights_panel, InsightsPanel::Priority));
16217        let list = app.list_panel_text();
16218        assert!(list.contains("[Priority]"));
16219
16220        // Full cycle wraps back
16221        app.update(key(KeyCode::Char('s')));
16222        assert!(matches!(app.insights_panel, InsightsPanel::Bottlenecks));
16223
16224        // S (shift) goes backwards
16225        app.update(key(KeyCode::Char('S')));
16226        assert!(matches!(app.insights_panel, InsightsPanel::Priority));
16227    }
16228
16229    #[test]
16230    fn insights_keystones_and_priority_panels_render_legacy_style_rows() {
16231        let mut app = new_app(ViewMode::Insights, 0);
16232        app.mode = ViewMode::Insights;
16233
16234        app.update(key(KeyCode::Char('s')));
16235        let keystones = app.list_panel_text();
16236        assert!(keystones.contains("[Keystones]"));
16237        assert!(keystones.contains("depth="));
16238        assert!(keystones.contains("unblocks="));
16239        assert!(keystones.contains("A"));
16240
16241        for _ in 0..10 {
16242            app.update(key(KeyCode::Char('s')));
16243        }
16244        let priority = app.list_panel_text();
16245        assert!(priority.contains("[Priority]"));
16246        assert!(priority.contains("score="));
16247        assert!(priority.contains("unblocks="));
16248        assert!(priority.contains("p"));
16249    }
16250
16251    #[test]
16252    fn insights_detail_shows_all_metrics_for_focused_issue() {
16253        let mut app = new_app(ViewMode::Insights, 0);
16254        app.mode = ViewMode::Insights;
16255        let detail = app.detail_panel_text();
16256        assert!(detail.contains("Metric Strip"));
16257        assert!(detail.contains("[Rank ]"));
16258        assert!(detail.contains("[Flow ]"));
16259        assert!(detail.contains("PageRank:"));
16260        assert!(detail.contains("Betweenness:"));
16261        assert!(detail.contains("Eigenvector:"));
16262        assert!(detail.contains("Hub (HITS):"));
16263        assert!(detail.contains("Auth (HITS):"));
16264        assert!(detail.contains("K-core:"));
16265        assert!(detail.contains("Crit depth:"));
16266        assert!(detail.contains("Slack:"));
16267        assert!(detail.contains("Cut point:"));
16268    }
16269
16270    #[test]
16271    fn insights_mode_e_and_x_toggle_explanations_and_calc_proof() {
16272        let mut app = new_app(ViewMode::Insights, 0);
16273        app.mode = ViewMode::Insights;
16274
16275        let initial = app.detail_panel_text();
16276        assert!(initial.contains("Critical Path Head"));
16277        assert!(!initial.contains("Calculation Proof:"));
16278
16279        app.update(key(KeyCode::Char('e')));
16280        assert!(!app.insights_show_explanations);
16281        let without_explanations = app.detail_panel_text();
16282        assert!(without_explanations.contains("Explanations hidden"));
16283        assert!(!without_explanations.contains("Critical Path Head"));
16284
16285        app.update(key(KeyCode::Char('x')));
16286        assert!(app.insights_show_calc_proof);
16287        let with_proof = app.detail_panel_text();
16288        assert!(with_proof.contains("Calculation Proof:"));
16289
16290        app.update(key(KeyCode::Char('e')));
16291        assert!(app.insights_show_explanations);
16292        let restored = app.detail_panel_text();
16293        assert!(restored.contains("Critical Path Head"));
16294    }
16295
16296    #[test]
16297    fn history_mode_renders_timeline_sections() {
16298        let mut app = new_app(ViewMode::History, 2);
16299        app.mode = ViewMode::History;
16300        let text = app.detail_panel_text();
16301        assert!(text.contains("History Summary"));
16302        assert!(text.contains("LIFECYCLE:"));
16303        assert!(text.contains("switch to git timeline"));
16304        assert!(text.contains("Min confidence filter"));
16305    }
16306
16307    #[test]
16308    fn graph_mode_snapshot_like_output_is_stable() {
16309        let app = new_app(ViewMode::Graph, 0);
16310        let text = app.detail_panel_text();
16311        let lines = text.lines().collect::<Vec<_>>();
16312        assert!(lines.first().is_some_and(|line| line.starts_with("Graph:")));
16313        assert!(lines.iter().any(|line| line.contains('A')));
16314        assert!(lines.iter().any(|line| line.contains("Top PageRank:")));
16315    }
16316
16317    #[test]
16318    fn graph_detail_text_uses_legacy_relationship_headers_and_legend() {
16319        let mut blocker_view = new_app(ViewMode::Graph, 1);
16320        blocker_view.mode = ViewMode::Graph;
16321        let blocker_text = blocker_view.detail_panel_text();
16322        assert!(blocker_text.contains("▲ BLOCKED BY (must complete first) ▲"));
16323        assert!(blocker_text.contains("┌"));
16324        assert!(blocker_text.contains("[o] A"));
16325        assert!(blocker_text.contains("Root"));
16326
16327        let mut dependent_view = new_app(ViewMode::Graph, 0);
16328        dependent_view.mode = ViewMode::Graph;
16329        let dependent_text = dependent_view.detail_panel_text();
16330        assert!(dependent_text.contains("▼ BLOCKS (waiting on this) ▼"));
16331        assert!(dependent_text.contains("┌"));
16332        assert!(dependent_text.contains("[o] B"));
16333        assert!(dependent_text.contains("Dependent"));
16334        assert!(dependent_text.contains("Legend: █ relative score | #N rank of 3 issues"));
16335        assert!(dependent_text.contains("Nav: h/l nodes | j/k nodes or focused edges"));
16336    }
16337
16338    #[test]
16339    fn graph_detail_text_renders_visual_graph_content() {
16340        let mut app = new_app(ViewMode::Graph, 0);
16341        app.mode = ViewMode::Graph;
16342
16343        let text = app.graph_detail_text_for_width(58);
16344        assert!(text.contains("╔"));
16345        assert!(text.contains("▼ BLOCKS (waiting on this) ▼"));
16346        assert!(text.contains("[o] B"));
16347        assert!(text.contains("Dependent"));
16348        assert!(text.lines().all(|line| display_width(line) <= 58));
16349    }
16350
16351    #[test]
16352    fn graph_detail_text_many_blockers_shows_overflow_summary() {
16353        let mut app = new_app_with_issues(ViewMode::Graph, 0, graph_many_blocker_issues());
16354        app.mode = ViewMode::Graph;
16355        app.select_issue_by_id("MAIN");
16356
16357        let text = app.graph_detail_text_for_width(120);
16358        assert!(text.contains("▲ BLOCKED BY (must complete first) ▲"));
16359        assert!(text.contains("[o] BLK-00"));
16360        assert!(text.contains("[o] BLK-04"));
16361        assert!(text.contains("Blocker 00"));
16362        assert!(text.contains("Blocker 04"));
16363        assert!(text.contains("+5 more"));
16364        assert!(!text.contains("BLK-05"));
16365        assert!(!text.contains("Blocker 05"));
16366        assert!(text.lines().all(|line| display_width(line) <= 120));
16367    }
16368
16369    #[test]
16370    fn graph_detail_text_many_dependents_shows_overflow_summary() {
16371        let mut app = new_app_with_issues(ViewMode::Graph, 0, graph_many_dependent_issues());
16372        app.mode = ViewMode::Graph;
16373        app.select_issue_by_id("ROOT");
16374
16375        let text = app.graph_detail_text_for_width(120);
16376        assert!(text.contains("▼ BLOCKS (waiting on this) ▼"));
16377        assert!(text.contains("[o] DEP-00"));
16378        assert!(text.contains("[o] DEP-04"));
16379        assert!(text.contains("Dependent 00"));
16380        assert!(text.contains("Dependent 04"));
16381        assert!(text.contains("+5 more"));
16382        assert!(!text.contains("DEP-05"));
16383        assert!(!text.contains("Dependent 05"));
16384        assert!(text.lines().all(|line| display_width(line) <= 120));
16385    }
16386
16387    #[test]
16388    fn history_mode_snapshot_like_output_is_stable() {
16389        let app = new_app(ViewMode::History, 2);
16390        let text = app.detail_panel_text();
16391        let lines = text.lines().collect::<Vec<_>>();
16392        assert!(
16393            lines
16394                .first()
16395                .is_some_and(|line| line.starts_with("History Summary:"))
16396        );
16397        assert!(lines.iter().any(|line| line.contains("Issue: C (Closed)")));
16398        assert!(
16399            lines
16400                .iter()
16401                .any(|line| line.contains("Create->Close cycle time:"))
16402        );
16403        assert!(lines.iter().any(|line| line.contains("LIFECYCLE:")));
16404    }
16405
16406    #[test]
16407    fn help_tab_focus_and_quit_confirm_match_legacy_behavior() {
16408        let mut app = new_app(ViewMode::Main, 0);
16409
16410        let cmd = app.update(key(KeyCode::Char('?')));
16411        assert!(matches!(cmd, Cmd::None));
16412        assert!(app.show_help);
16413        assert_eq!(app.focus, FocusPane::List);
16414
16415        // 'x' no longer closes help (only ? or Esc does)
16416        let cmd = app.update(key(KeyCode::Char('x')));
16417        assert!(matches!(cmd, Cmd::None));
16418        assert!(app.show_help);
16419
16420        // Esc closes help and restores focus
16421        let cmd = app.update(key(KeyCode::Escape));
16422        assert!(matches!(cmd, Cmd::None));
16423        assert!(!app.show_help);
16424        assert_eq!(app.focus, FocusPane::List);
16425
16426        app.update(key(KeyCode::Tab));
16427        assert_eq!(app.focus, FocusPane::Detail);
16428        app.update(key(KeyCode::Tab));
16429        assert_eq!(app.focus, FocusPane::List);
16430
16431        let cmd = app.update(key(KeyCode::Escape));
16432        assert!(matches!(cmd, Cmd::None));
16433        assert!(app.show_quit_confirm);
16434
16435        let quit_cmd = app.update(key(KeyCode::Char('y')));
16436        assert!(matches!(quit_cmd, Cmd::Quit));
16437    }
16438
16439    #[test]
16440    fn escape_from_non_main_modes_returns_to_main() {
16441        for mode in [ViewMode::Board, ViewMode::Insights, ViewMode::Graph] {
16442            let mut app = new_app(mode, 0);
16443            let cmd = app.update(key(KeyCode::Escape));
16444            assert!(matches!(cmd, Cmd::None));
16445            assert!(matches!(app.mode, ViewMode::Main));
16446            assert!(!app.show_quit_confirm);
16447        }
16448    }
16449
16450    #[test]
16451    fn q_from_non_main_modes_returns_to_main_instead_of_quit() {
16452        for mode in [ViewMode::Board, ViewMode::Insights, ViewMode::Graph] {
16453            let mut app = new_app(mode, 0);
16454            let cmd = app.update(key(KeyCode::Char('q')));
16455            assert!(matches!(cmd, Cmd::None));
16456            assert!(matches!(app.mode, ViewMode::Main));
16457        }
16458    }
16459
16460    #[test]
16461    fn view_hotkeys_toggle_modes_back_to_main() {
16462        let mut app = new_app(ViewMode::Main, 0);
16463
16464        app.update(key(KeyCode::Char('b')));
16465        assert!(matches!(app.mode, ViewMode::Board));
16466        app.update(key(KeyCode::Char('b')));
16467        assert!(matches!(app.mode, ViewMode::Main));
16468
16469        app.update(key(KeyCode::Char('i')));
16470        assert!(matches!(app.mode, ViewMode::Insights));
16471        app.update(key(KeyCode::Char('i')));
16472        assert!(matches!(app.mode, ViewMode::Main));
16473
16474        app.update(key(KeyCode::Char('g')));
16475        assert!(matches!(app.mode, ViewMode::Graph));
16476        app.update(key(KeyCode::Char('g')));
16477        assert!(matches!(app.mode, ViewMode::Main));
16478    }
16479
16480    #[test]
16481    fn main_hotkey_from_graph_or_insights_resets_focus_to_list() {
16482        for mode in [ViewMode::Graph, ViewMode::Insights] {
16483            let mut app = new_app(mode, 1);
16484            app.focus = FocusPane::Detail;
16485
16486            app.update(key(KeyCode::Char('1')));
16487            assert!(matches!(app.mode, ViewMode::Main));
16488            assert_eq!(app.focus, FocusPane::List);
16489            assert_eq!(selected_issue_id(&app), "B");
16490        }
16491    }
16492
16493    #[test]
16494    fn history_toggle_and_escape_match_legacy_behavior() {
16495        let mut app = new_app(ViewMode::Main, 0);
16496
16497        app.update(key(KeyCode::Char('h')));
16498        assert!(matches!(app.mode, ViewMode::History));
16499        assert_eq!(app.focus, FocusPane::List);
16500
16501        app.update(key(KeyCode::Char('h')));
16502        assert!(matches!(app.mode, ViewMode::Main));
16503        assert_eq!(app.focus, FocusPane::List);
16504
16505        app.update(key(KeyCode::Char('h')));
16506        assert!(matches!(app.mode, ViewMode::History));
16507        app.update(key(KeyCode::Escape));
16508        assert!(matches!(app.mode, ViewMode::Main));
16509        assert!(!app.show_quit_confirm);
16510    }
16511
16512    #[test]
16513    fn insights_mode_h_l_switch_focus_panes() {
16514        let mut app = new_app(ViewMode::Insights, 0);
16515
16516        assert_eq!(app.focus, FocusPane::List);
16517        app.update(key(KeyCode::Char('l')));
16518        assert_eq!(app.focus, FocusPane::Detail);
16519        assert!(matches!(app.mode, ViewMode::Insights));
16520
16521        app.update(key(KeyCode::Char('h')));
16522        assert_eq!(app.focus, FocusPane::List);
16523        assert!(matches!(app.mode, ViewMode::Insights));
16524    }
16525
16526    #[test]
16527    fn insights_heatmap_toggle_and_escape_drill_preserve_mode() {
16528        let mut app = new_app(ViewMode::Insights, 0);
16529
16530        assert!(app.insights_heatmap.is_none());
16531        app.update(key(KeyCode::Char('m')));
16532        assert!(app.insights_heatmap.is_some());
16533        assert!(app.list_panel_text().contains("Priority heatmap |"));
16534
16535        app.update(key(KeyCode::Enter));
16536        assert!(
16537            app.insights_heatmap
16538                .as_ref()
16539                .is_some_and(|state| state.drill_active)
16540        );
16541        assert!(app.list_panel_text().contains("Priority heatmap drill"));
16542
16543        app.update(key(KeyCode::Escape));
16544        assert!(
16545            app.insights_heatmap
16546                .as_ref()
16547                .is_some_and(|state| !state.drill_active)
16548        );
16549        assert!(matches!(app.mode, ViewMode::Insights));
16550    }
16551
16552    #[test]
16553    fn insights_heatmap_empty_grid_renders_without_panic() {
16554        let issues = vec![Issue {
16555            id: "CLS-1".to_string(),
16556            title: "Closed".to_string(),
16557            status: "closed".to_string(),
16558            issue_type: "task".to_string(),
16559            ..Issue::default()
16560        }];
16561        let mut app = new_app_with_issues(ViewMode::Insights, 0, issues);
16562
16563        app.update(key(KeyCode::Char('m')));
16564        assert!(
16565            app.list_panel_text()
16566                .contains("no open, filter-matching issues")
16567        );
16568        assert!(
16569            app.detail_panel_text()
16570                .contains("No issues in the selected heatmap cell.")
16571        );
16572    }
16573
16574    #[test]
16575    fn insights_heatmap_drill_selection_syncs_detail_context() {
16576        let mut app = new_app(ViewMode::Insights, 0);
16577
16578        app.update(key(KeyCode::Char('m')));
16579        let selected = selected_issue_id(&app);
16580        let detail = app.detail_panel_text();
16581        assert!(detail.contains("Heatmap:"));
16582        assert!(detail.contains(&format!("Focus: {selected}")));
16583
16584        app.update(key(KeyCode::Enter));
16585        let drilled = app.detail_panel_text();
16586        assert!(drilled.contains("Drill selection:"));
16587        assert!(drilled.contains(&format!("Focus: {selected}")));
16588    }
16589
16590    #[test]
16591    fn insights_heatmap_list_keys_stay_in_list_focus() {
16592        let mut app = new_app(ViewMode::Insights, 0);
16593
16594        app.update(key(KeyCode::Char('m')));
16595        assert_eq!(app.focus, FocusPane::List);
16596
16597        app.update(key(KeyCode::Char('l')));
16598        assert_eq!(app.focus, FocusPane::List);
16599
16600        app.update(key(KeyCode::Char('j')));
16601        assert_eq!(app.focus, FocusPane::List);
16602        assert!(app.list_panel_text().contains("Priority heatmap"));
16603    }
16604
16605    #[test]
16606    fn graph_mode_h_l_and_ctrl_paging_move_selection() {
16607        let mut app = new_app(ViewMode::Graph, 0);
16608
16609        assert_eq!(selected_issue_id(&app), "A");
16610        app.update(key(KeyCode::Char('l')));
16611        assert_eq!(selected_issue_id(&app), "B");
16612        assert!(matches!(app.mode, ViewMode::Graph));
16613
16614        app.update(key(KeyCode::Char('h')));
16615        assert_eq!(selected_issue_id(&app), "A");
16616
16617        app.update(key_ctrl(KeyCode::Char('d')));
16618        assert_eq!(selected_issue_id(&app), "C");
16619
16620        app.update(key_ctrl(KeyCode::Char('u')));
16621        assert_eq!(selected_issue_id(&app), "A");
16622    }
16623
16624    #[test]
16625    fn graph_mode_shift_h_l_jump_by_page_window() {
16626        let mut app = new_app(ViewMode::Graph, 0);
16627
16628        assert_eq!(selected_issue_id(&app), "A");
16629        app.update(key(KeyCode::Char('L')));
16630        assert_eq!(selected_issue_id(&app), "C");
16631
16632        app.update(key(KeyCode::Char('H')));
16633        assert_eq!(selected_issue_id(&app), "A");
16634    }
16635
16636    #[test]
16637    fn graph_mode_h_from_detail_returns_to_list_focus() {
16638        let mut app = new_app(ViewMode::Graph, 0);
16639        assert_eq!(app.focus, FocusPane::List);
16640
16641        // Tab to detail
16642        app.update(key(KeyCode::Tab));
16643        assert_eq!(app.focus, FocusPane::Detail);
16644
16645        // h from detail should switch back to list focus
16646        app.update(key(KeyCode::Char('h')));
16647        assert_eq!(app.focus, FocusPane::List);
16648        assert!(matches!(app.mode, ViewMode::Graph));
16649
16650        // h from list should navigate (move selection)
16651        app.update(key(KeyCode::Char('l')));
16652        assert_eq!(selected_issue_id(&app), "B");
16653        app.update(key(KeyCode::Char('h')));
16654        assert_eq!(selected_issue_id(&app), "A");
16655    }
16656
16657    #[test]
16658    fn main_mode_search_query_and_match_cycling_work() {
16659        let mut app = new_app(ViewMode::Main, 0);
16660        assert!(!app.main_search_active);
16661
16662        app.update(key(KeyCode::Char('/')));
16663        assert!(app.main_search_active);
16664        assert!(app.main_search_query.is_empty());
16665
16666        app.update(key(KeyCode::Char('d')));
16667        assert_eq!(app.main_search_query, "d");
16668        assert_eq!(selected_issue_id(&app), "A");
16669        assert!(app.list_panel_text().contains("hit 1/3"));
16670
16671        app.update(key(KeyCode::Enter));
16672        assert!(!app.main_search_active);
16673        assert_eq!(app.main_search_query, "d");
16674        assert!(app.list_panel_text().contains("Matches: 1/3"));
16675
16676        app.update(key(KeyCode::Char('n')));
16677        assert_eq!(selected_issue_id(&app), "B");
16678        assert!(app.list_panel_text().contains("Matches: 2/3"));
16679        assert!(app.list_panel_text().contains("hit 2/3"));
16680
16681        app.update(key(KeyCode::Char('N')));
16682        assert_eq!(selected_issue_id(&app), "A");
16683        assert!(app.list_panel_text().contains("Matches: 1/3"));
16684
16685        app.update(key(KeyCode::Char('/')));
16686        app.update(key(KeyCode::Char('z')));
16687        assert_eq!(app.main_search_query, "z");
16688        app.update(key(KeyCode::Escape));
16689        assert!(!app.main_search_active);
16690        assert!(app.main_search_query.is_empty());
16691    }
16692
16693    #[test]
16694    fn main_mode_search_no_match_message_is_explicit() {
16695        let mut app = new_app(ViewMode::Main, 0);
16696
16697        app.update(key(KeyCode::Char('/')));
16698        app.update(key(KeyCode::Char('z')));
16699
16700        let active = app.list_panel_text();
16701        assert!(active.contains("Search (active): /z"));
16702        assert!(active.contains("Matches: none"));
16703
16704        app.update(key(KeyCode::Enter));
16705        let finished = app.list_panel_text();
16706        assert!(finished.contains("Search: /z (n/N cycles)"));
16707        assert!(finished.contains("Matches: none"));
16708        assert_eq!(selected_issue_id(&app), "A");
16709    }
16710
16711    #[test]
16712    fn main_mode_search_requires_list_focus() {
16713        let mut app = new_app(ViewMode::Main, 0);
16714        app.focus = FocusPane::Detail;
16715
16716        app.update(key(KeyCode::Char('/')));
16717        assert!(!app.main_search_active);
16718
16719        app.update(key(KeyCode::Tab));
16720        assert_eq!(app.focus, FocusPane::List);
16721        app.update(key(KeyCode::Char('/')));
16722        assert!(app.main_search_active);
16723    }
16724
16725    #[test]
16726    fn keyflow_main_escape_unwinds_focus_search_and_filter() {
16727        let mut app = new_app(ViewMode::Main, 0);
16728
16729        app.update(key(KeyCode::Tab));
16730        assert_eq!(app.focus, FocusPane::Detail);
16731        app.update(key(KeyCode::Escape));
16732        assert_eq!(app.focus, FocusPane::List);
16733        assert_eq!(app.status_msg, "Focus returned to list");
16734
16735        app.update(key(KeyCode::Char('/')));
16736        app.update(key(KeyCode::Char('d')));
16737        app.update(key(KeyCode::Enter));
16738        assert_eq!(app.main_search_query, "d");
16739        app.update(key(KeyCode::Escape));
16740        assert!(app.main_search_query.is_empty());
16741        assert_eq!(app.status_msg, "Main search cleared");
16742
16743        app.update(key(KeyCode::Char('o')));
16744        assert_eq!(app.list_filter, ListFilter::Open);
16745        app.update(key(KeyCode::Escape));
16746        assert_eq!(app.list_filter, ListFilter::All);
16747    }
16748
16749    #[test]
16750    fn backtab_reverses_main_focus_cycle() {
16751        let mut app = new_app(ViewMode::Main, 0);
16752        assert_eq!(app.focus, FocusPane::List);
16753
16754        app.update(key_backtab());
16755        assert_eq!(app.focus, FocusPane::Detail);
16756
16757        app.update(key_backtab());
16758        assert_eq!(app.focus, FocusPane::List);
16759    }
16760
16761    #[test]
16762    fn backtab_reverses_history_focus_cycle_with_file_tree() {
16763        let mut app = new_app(ViewMode::History, 0);
16764        app.history_show_file_tree = true;
16765
16766        app.update(key_backtab());
16767        assert!(app.history_file_tree_focus);
16768        assert_eq!(app.focus, FocusPane::List);
16769
16770        app.update(key_backtab());
16771        assert!(!app.history_file_tree_focus);
16772        assert_eq!(app.focus, FocusPane::Detail);
16773
16774        app.update(key_backtab());
16775        assert_eq!(app.focus, FocusPane::List);
16776    }
16777
16778    #[test]
16779    fn main_list_focus_banner_tracks_active_pane() {
16780        let mut app = new_app(ViewMode::Main, 0);
16781        let list_focus = app.main_list_render_text(90).to_plain_text();
16782        assert!(list_focus.contains("Focus: list owns selection"));
16783        assert!(list_focus.contains("scope=all"));
16784
16785        app.focus = FocusPane::Detail;
16786        let detail_focus = app.main_list_render_text(90).to_plain_text();
16787        assert!(detail_focus.contains("Focus: detail owns J/K deps"));
16788        assert!(detail_focus.contains("selected=A"));
16789    }
16790
16791    #[test]
16792    fn main_list_scope_banner_surfaces_label_repo_and_search_state() {
16793        let mut app = new_app(ViewMode::Main, 0);
16794        app.modal_label_filter = Some("core".to_string());
16795        app.modal_repo_filter = Some("viewer".to_string());
16796        app.main_search_query = "root".to_string();
16797
16798        let text = app.main_list_render_text(100).to_plain_text();
16799        assert!(text.contains("label=core"), "label scope missing: {text}");
16800        assert!(text.contains("pos=1/1"), "position scope missing: {text}");
16801        assert!(text.contains("repo=viewer"), "repo scope missing: {text}");
16802        assert!(text.contains("search=root"), "search scope missing: {text}");
16803    }
16804
16805    #[test]
16806    fn page_down_uses_viewport_aware_step_in_main_mode() {
16807        let issues = (0..20)
16808            .map(|idx| Issue {
16809                id: format!("ISSUE-{idx:02}"),
16810                title: format!("Issue {idx:02}"),
16811                status: "open".to_string(),
16812                issue_type: "task".to_string(),
16813                priority: idx % 4,
16814                ..Issue::default()
16815            })
16816            .collect();
16817        let mut app = new_app_with_issues(ViewMode::Main, 0, issues);
16818        let _ = render_app(&app, 120, 16);
16819        let visible = app.visible_issue_indices_for_list_nav();
16820        let page_step = app.list_page_step();
16821        let expected_down = visible[page_step.min(visible.len().saturating_sub(1))];
16822
16823        app.update(key(KeyCode::PageDown));
16824        assert_eq!(app.selected, expected_down);
16825
16826        app.update(key(KeyCode::PageUp));
16827        assert_eq!(selected_issue_id(&app), "ISSUE-00");
16828    }
16829
16830    #[test]
16831    fn graph_mode_list_header_shows_keybinding_hints() {
16832        let app = new_app(ViewMode::Graph, 0);
16833        let list_text = app.list_panel_text();
16834        assert!(list_text.contains("h/l nav"));
16835        assert!(list_text.contains("Tab focus"));
16836        assert!(list_text.contains("/ search"));
16837    }
16838
16839    #[test]
16840    fn graph_mode_search_query_and_match_cycling_work() {
16841        let issues = vec![
16842            Issue {
16843                id: "B".to_string(),
16844                title: "Alpha dependent".to_string(),
16845                status: "open".to_string(),
16846                issue_type: "task".to_string(),
16847                dependencies: vec![Dependency {
16848                    issue_id: "B".to_string(),
16849                    depends_on_id: "A".to_string(),
16850                    dep_type: "blocks".to_string(),
16851                    ..Dependency::default()
16852                }],
16853                ..Issue::default()
16854            },
16855            Issue {
16856                id: "A".to_string(),
16857                title: "Alpha root".to_string(),
16858                status: "open".to_string(),
16859                issue_type: "task".to_string(),
16860                ..Issue::default()
16861            },
16862        ];
16863        let mut app = new_app_with_issues(ViewMode::Graph, 0, issues);
16864        assert!(!app.graph_search_active);
16865
16866        app.update(key(KeyCode::Char('/')));
16867        assert!(app.graph_search_active);
16868        assert!(app.graph_search_query.is_empty());
16869
16870        // Type a search query that matches issue "A"
16871        app.update(key(KeyCode::Char('a')));
16872        assert_eq!(app.graph_search_query, "a");
16873        assert_eq!(selected_issue_id(&app), "A");
16874        assert!(app.list_panel_text().contains("hit 1/2"));
16875
16876        // Enter finishes search but keeps query
16877        app.update(key(KeyCode::Enter));
16878        assert!(!app.graph_search_active);
16879        assert_eq!(app.graph_search_query, "a");
16880        assert!(app.list_panel_text().contains("Matches: 1/2"));
16881        assert!(app.list_panel_text().contains("hit 1/2"));
16882
16883        // n/N should cycle matches
16884        app.update(key(KeyCode::Char('n')));
16885        assert_eq!(selected_issue_id(&app), "B");
16886        assert!(app.list_panel_text().contains("Matches: 2/2"));
16887        assert!(app.list_panel_text().contains("hit 2/2"));
16888
16889        app.update(key(KeyCode::Char('N')));
16890        assert_eq!(selected_issue_id(&app), "A");
16891        assert!(app.list_panel_text().contains("Matches: 1/2"));
16892
16893        // Escape from new search clears query
16894        app.update(key(KeyCode::Char('/')));
16895        app.update(key(KeyCode::Char('x')));
16896        assert_eq!(app.graph_search_query, "x");
16897        app.update(key(KeyCode::Escape));
16898        assert!(!app.graph_search_active);
16899        assert!(app.graph_search_query.is_empty());
16900    }
16901
16902    #[test]
16903    fn graph_mode_search_starts_from_detail_focus_and_returns_to_list() {
16904        let mut app = new_app(ViewMode::Graph, 0);
16905        app.focus = FocusPane::Detail;
16906
16907        app.update(key(KeyCode::Char('/')));
16908
16909        assert!(app.graph_search_active);
16910        assert_eq!(app.focus, FocusPane::List);
16911        assert!(app.graph_search_query.is_empty());
16912    }
16913
16914    #[test]
16915    fn graph_mode_search_prefers_rendered_order_over_storage_order() {
16916        let issues = vec![
16917            Issue {
16918                id: "B".to_string(),
16919                title: "Alpha dependent".to_string(),
16920                status: "open".to_string(),
16921                issue_type: "task".to_string(),
16922                dependencies: vec![Dependency {
16923                    issue_id: "B".to_string(),
16924                    depends_on_id: "A".to_string(),
16925                    dep_type: "blocks".to_string(),
16926                    ..Dependency::default()
16927                }],
16928                ..Issue::default()
16929            },
16930            Issue {
16931                id: "A".to_string(),
16932                title: "Alpha root".to_string(),
16933                status: "open".to_string(),
16934                issue_type: "task".to_string(),
16935                ..Issue::default()
16936            },
16937        ];
16938        let mut app = new_app_with_issues(ViewMode::Graph, 0, issues);
16939
16940        let rendered_ids = app
16941            .graph_visible_issue_indices()
16942            .into_iter()
16943            .map(|index| app.analyzer.issues[index].id.clone())
16944            .collect::<Vec<_>>();
16945        assert_eq!(rendered_ids, vec!["A", "B"]);
16946
16947        app.update(key(KeyCode::Char('/')));
16948        app.update(key(KeyCode::Char('a')));
16949
16950        assert_eq!(selected_issue_id(&app), "A");
16951        assert!(
16952            app.list_panel_text()
16953                .lines()
16954                .any(|line| line.starts_with('>') && line.contains(" A"))
16955        );
16956    }
16957
16958    #[test]
16959    fn graph_mode_navigation_uses_graph_ranked_order() {
16960        let issues = vec![
16961            Issue {
16962                id: "B".to_string(),
16963                title: "Dependent".to_string(),
16964                status: "open".to_string(),
16965                issue_type: "task".to_string(),
16966                dependencies: vec![Dependency {
16967                    issue_id: "B".to_string(),
16968                    depends_on_id: "A".to_string(),
16969                    dep_type: "blocks".to_string(),
16970                    ..Dependency::default()
16971                }],
16972                ..Issue::default()
16973            },
16974            Issue {
16975                id: "A".to_string(),
16976                title: "Root".to_string(),
16977                status: "open".to_string(),
16978                issue_type: "task".to_string(),
16979                ..Issue::default()
16980            },
16981        ];
16982        let mut app = new_app_with_issues(ViewMode::Graph, 0, issues);
16983        let order = app
16984            .graph_visible_issue_indices()
16985            .into_iter()
16986            .map(|index| app.analyzer.issues[index].id.clone())
16987            .collect::<Vec<_>>();
16988        assert_eq!(order.len(), 2);
16989
16990        assert_eq!(selected_issue_id(&app), order[0]);
16991        app.update(key(KeyCode::Char('j')));
16992        assert_eq!(selected_issue_id(&app), order[1]);
16993        app.update(key(KeyCode::Char('k')));
16994        assert_eq!(selected_issue_id(&app), order[0]);
16995    }
16996
16997    #[test]
16998    fn graph_mode_toggle_reselects_first_graph_ranked_issue() {
16999        let issues = vec![
17000            Issue {
17001                id: "B".to_string(),
17002                title: "Dependent".to_string(),
17003                status: "open".to_string(),
17004                issue_type: "task".to_string(),
17005                dependencies: vec![Dependency {
17006                    issue_id: "B".to_string(),
17007                    depends_on_id: "A".to_string(),
17008                    dep_type: "blocks".to_string(),
17009                    ..Dependency::default()
17010                }],
17011                ..Issue::default()
17012            },
17013            Issue {
17014                id: "A".to_string(),
17015                title: "Root".to_string(),
17016                status: "open".to_string(),
17017                issue_type: "task".to_string(),
17018                ..Issue::default()
17019            },
17020        ];
17021        let mut app = new_app_with_issues(ViewMode::Main, 0, issues);
17022        let graph_order = app.graph_visible_issue_indices();
17023        assert_eq!(graph_order.len(), 2);
17024        app.set_selected_index(graph_order[1]);
17025        let expected = app.analyzer.issues[graph_order[0]].id.clone();
17026        app.update(key(KeyCode::Char('g')));
17027        assert_eq!(app.mode, ViewMode::Graph);
17028        assert_eq!(selected_issue_id(&app), expected);
17029    }
17030
17031    #[test]
17032    fn graph_mode_toggle_preserves_search_match_selection() {
17033        let issues = vec![
17034            Issue {
17035                id: "B".to_string(),
17036                title: "Alpha dependent".to_string(),
17037                status: "open".to_string(),
17038                issue_type: "task".to_string(),
17039                dependencies: vec![Dependency {
17040                    issue_id: "B".to_string(),
17041                    depends_on_id: "A".to_string(),
17042                    dep_type: "blocks".to_string(),
17043                    ..Dependency::default()
17044                }],
17045                ..Issue::default()
17046            },
17047            Issue {
17048                id: "A".to_string(),
17049                title: "Root".to_string(),
17050                status: "open".to_string(),
17051                issue_type: "task".to_string(),
17052                ..Issue::default()
17053            },
17054        ];
17055        let mut app = new_app_with_issues(ViewMode::Graph, 0, issues);
17056        app.set_selected_index(app.issue_index_for_id("B").expect("issue B should exist"));
17057
17058        app.update(key(KeyCode::Char('/')));
17059        app.update(key(KeyCode::Char('a')));
17060        app.update(key(KeyCode::Enter));
17061        assert_eq!(selected_issue_id(&app), "B");
17062
17063        app.update(key(KeyCode::Char('g')));
17064        assert_eq!(app.mode, ViewMode::Main);
17065        // Clear pending_g latch (any non-g key) before re-toggling
17066        app.update(key(KeyCode::F(20)));
17067        app.update(key(KeyCode::Char('g')));
17068        assert_eq!(app.mode, ViewMode::Graph);
17069        assert_eq!(selected_issue_id(&app), "B");
17070    }
17071
17072    #[test]
17073    fn insights_mode_search_query_and_match_cycling_work() {
17074        let issues = vec![
17075            Issue {
17076                id: "B".to_string(),
17077                title: "Alpha dependent".to_string(),
17078                status: "open".to_string(),
17079                issue_type: "task".to_string(),
17080                dependencies: vec![Dependency {
17081                    issue_id: "B".to_string(),
17082                    depends_on_id: "A".to_string(),
17083                    dep_type: "blocks".to_string(),
17084                    ..Dependency::default()
17085                }],
17086                ..Issue::default()
17087            },
17088            Issue {
17089                id: "A".to_string(),
17090                title: "Alpha root".to_string(),
17091                status: "open".to_string(),
17092                issue_type: "task".to_string(),
17093                ..Issue::default()
17094            },
17095        ];
17096        let mut app = new_app_with_issues(ViewMode::Main, 0, issues);
17097        app.update(key(KeyCode::Char('i')));
17098        assert!(matches!(app.mode, ViewMode::Insights));
17099        assert!(!app.insights_search_active);
17100
17101        app.update(key(KeyCode::Char('/')));
17102        assert!(app.insights_search_active);
17103        assert!(app.insights_search_query.is_empty());
17104
17105        // Type a search query
17106        app.update(key(KeyCode::Char('a')));
17107        assert_eq!(app.insights_search_query, "a");
17108        assert_eq!(selected_issue_id(&app), "A");
17109        assert!(app.list_panel_text().contains("hit 1/2"));
17110
17111        // Enter finishes search but keeps query
17112        app.update(key(KeyCode::Enter));
17113        assert!(!app.insights_search_active);
17114        assert_eq!(app.insights_search_query, "a");
17115        assert!(app.list_panel_text().contains("Matches: 1/2"));
17116        assert!(app.list_panel_text().contains("hit 1/2"));
17117
17118        app.update(key(KeyCode::Char('n')));
17119        assert_eq!(selected_issue_id(&app), "B");
17120        assert!(app.list_panel_text().contains("Matches: 2/2"));
17121        assert!(app.list_panel_text().contains("hit 2/2"));
17122
17123        app.update(key(KeyCode::Char('N')));
17124        assert_eq!(selected_issue_id(&app), "A");
17125        assert!(app.list_panel_text().contains("Matches: 1/2"));
17126
17127        // Escape from new search clears query
17128        app.update(key(KeyCode::Char('/')));
17129        app.update(key(KeyCode::Char('z')));
17130        assert_eq!(app.insights_search_query, "z");
17131        app.update(key(KeyCode::Escape));
17132        assert!(!app.insights_search_active);
17133        assert!(app.insights_search_query.is_empty());
17134    }
17135
17136    #[test]
17137    fn insights_mode_search_starts_from_detail_focus_and_returns_to_list() {
17138        let mut app = new_app(ViewMode::Insights, 0);
17139        app.focus = FocusPane::Detail;
17140
17141        app.update(key(KeyCode::Char('/')));
17142
17143        assert!(app.insights_search_active);
17144        assert_eq!(app.focus, FocusPane::List);
17145        assert!(app.insights_search_query.is_empty());
17146    }
17147
17148    #[test]
17149    fn insights_mode_selection_tracks_bottleneck_order_not_storage_order() {
17150        let issues = vec![
17151            Issue {
17152                id: "C".to_string(),
17153                title: "Closed unrelated".to_string(),
17154                status: "closed".to_string(),
17155                issue_type: "task".to_string(),
17156                ..Issue::default()
17157            },
17158            Issue {
17159                id: "B".to_string(),
17160                title: "Dependent".to_string(),
17161                status: "open".to_string(),
17162                issue_type: "task".to_string(),
17163                dependencies: vec![Dependency {
17164                    issue_id: "B".to_string(),
17165                    depends_on_id: "A".to_string(),
17166                    dep_type: "blocks".to_string(),
17167                    ..Dependency::default()
17168                }],
17169                ..Issue::default()
17170            },
17171            Issue {
17172                id: "A".to_string(),
17173                title: "Root bottleneck".to_string(),
17174                status: "open".to_string(),
17175                issue_type: "task".to_string(),
17176                ..Issue::default()
17177            },
17178        ];
17179        let mut app = new_app_with_issues(ViewMode::Insights, 0, issues);
17180
17181        let expected = app
17182            .analyzer
17183            .insights()
17184            .bottlenecks
17185            .first()
17186            .map(|item| item.id.clone())
17187            .expect("bottleneck ranking");
17188        assert_eq!(selected_issue_id(&app), expected);
17189
17190        let order = app
17191            .insights_visible_issue_indices_for_list_nav()
17192            .into_iter()
17193            .map(|index| app.analyzer.issues[index].id.clone())
17194            .collect::<Vec<_>>();
17195        assert!(!order.is_empty());
17196
17197        app.update(key(KeyCode::Char('j')));
17198        let next_expected = order.get(1).cloned().unwrap_or_else(|| order[0].clone());
17199        assert_eq!(selected_issue_id(&app), next_expected);
17200    }
17201
17202    #[test]
17203    fn insights_mode_toggle_and_panel_cycle_sync_ranked_selection() {
17204        let issues = vec![
17205            Issue {
17206                id: "C".to_string(),
17207                title: "Closed unrelated".to_string(),
17208                status: "closed".to_string(),
17209                issue_type: "task".to_string(),
17210                ..Issue::default()
17211            },
17212            Issue {
17213                id: "B".to_string(),
17214                title: "Dependent".to_string(),
17215                status: "open".to_string(),
17216                issue_type: "task".to_string(),
17217                dependencies: vec![Dependency {
17218                    issue_id: "B".to_string(),
17219                    depends_on_id: "A".to_string(),
17220                    dep_type: "blocks".to_string(),
17221                    ..Dependency::default()
17222                }],
17223                ..Issue::default()
17224            },
17225            Issue {
17226                id: "A".to_string(),
17227                title: "Root bottleneck".to_string(),
17228                status: "open".to_string(),
17229                issue_type: "task".to_string(),
17230                ..Issue::default()
17231            },
17232        ];
17233        let mut app = new_app_with_issues(ViewMode::Main, 0, issues);
17234        app.set_selected_index(0);
17235
17236        app.update(key(KeyCode::Char('i')));
17237        assert_eq!(app.mode, ViewMode::Insights);
17238        let bottleneck_expected = app
17239            .insights_visible_issue_indices_for_list_nav()
17240            .first()
17241            .map(|index| app.analyzer.issues[*index].id.clone())
17242            .expect("bottleneck ranked issue");
17243        assert_eq!(selected_issue_id(&app), bottleneck_expected);
17244
17245        let bottleneck_order = app.insights_visible_issue_indices_for_list_nav();
17246        if let Some(second_index) = bottleneck_order.get(1).copied() {
17247            app.set_selected_index(second_index);
17248        }
17249
17250        app.update(key(KeyCode::Char('s')));
17251        assert_eq!(app.insights_panel, InsightsPanel::Keystones);
17252        let keystone_expected = app
17253            .insights_visible_issue_indices_for_list_nav()
17254            .first()
17255            .map(|index| app.analyzer.issues[*index].id.clone())
17256            .expect("keystone ranked issue");
17257        assert_eq!(selected_issue_id(&app), keystone_expected);
17258    }
17259
17260    #[test]
17261    fn insights_panel_cycle_preserves_search_match_selection() {
17262        let issues = vec![
17263            Issue {
17264                id: "C".to_string(),
17265                title: "Closed unrelated".to_string(),
17266                status: "closed".to_string(),
17267                issue_type: "task".to_string(),
17268                ..Issue::default()
17269            },
17270            Issue {
17271                id: "B".to_string(),
17272                title: "Alpha dependent".to_string(),
17273                status: "open".to_string(),
17274                issue_type: "task".to_string(),
17275                dependencies: vec![Dependency {
17276                    issue_id: "B".to_string(),
17277                    depends_on_id: "A".to_string(),
17278                    dep_type: "blocks".to_string(),
17279                    ..Dependency::default()
17280                }],
17281                ..Issue::default()
17282            },
17283            Issue {
17284                id: "A".to_string(),
17285                title: "Root bottleneck".to_string(),
17286                status: "open".to_string(),
17287                issue_type: "task".to_string(),
17288                ..Issue::default()
17289            },
17290        ];
17291        let mut app = new_app_with_issues(ViewMode::Insights, 0, issues);
17292        app.set_selected_index(app.issue_index_for_id("B").expect("issue B should exist"));
17293
17294        app.update(key(KeyCode::Char('/')));
17295        app.update(key(KeyCode::Char('a')));
17296        app.update(key(KeyCode::Enter));
17297        assert_eq!(selected_issue_id(&app), "B");
17298
17299        app.update(key(KeyCode::Char('s')));
17300        assert_eq!(app.insights_panel, InsightsPanel::Keystones);
17301        assert_eq!(selected_issue_id(&app), "B");
17302    }
17303
17304    #[test]
17305    fn insights_entry_from_graph_preserves_external_context_issue() {
17306        let issues = vec![
17307            Issue {
17308                id: "C".to_string(),
17309                title: "Closed unrelated".to_string(),
17310                status: "closed".to_string(),
17311                issue_type: "task".to_string(),
17312                ..Issue::default()
17313            },
17314            Issue {
17315                id: "B".to_string(),
17316                title: "Dependent".to_string(),
17317                status: "open".to_string(),
17318                issue_type: "task".to_string(),
17319                dependencies: vec![Dependency {
17320                    issue_id: "B".to_string(),
17321                    depends_on_id: "A".to_string(),
17322                    dep_type: "blocks".to_string(),
17323                    ..Dependency::default()
17324                }],
17325                ..Issue::default()
17326            },
17327            Issue {
17328                id: "A".to_string(),
17329                title: "Root bottleneck".to_string(),
17330                status: "open".to_string(),
17331                issue_type: "task".to_string(),
17332                ..Issue::default()
17333            },
17334        ];
17335        let mut app = new_app_with_issues(ViewMode::Board, 0, issues);
17336        app.set_selected_index(app.issue_index_for_id("C").expect("issue C should exist"));
17337
17338        app.update(key(KeyCode::Char('g')));
17339        assert_eq!(app.mode, ViewMode::Graph);
17340        assert_eq!(selected_issue_id(&app), "C");
17341
17342        app.update(key(KeyCode::Char('i')));
17343        assert_eq!(app.mode, ViewMode::Insights);
17344        assert_eq!(selected_issue_id(&app), "C");
17345
17346        app.update(key(KeyCode::Char('s')));
17347        assert_eq!(app.insights_panel, InsightsPanel::Keystones);
17348        assert_eq!(selected_issue_id(&app), "C");
17349
17350        app.update(key(KeyCode::Escape));
17351        assert_eq!(app.mode, ViewMode::Main);
17352        assert_eq!(selected_issue_id(&app), "C");
17353    }
17354
17355    #[test]
17356    fn graph_entry_from_insights_preserves_external_context_issue() {
17357        let issues = vec![
17358            Issue {
17359                id: "C".to_string(),
17360                title: "Closed unrelated".to_string(),
17361                status: "closed".to_string(),
17362                issue_type: "task".to_string(),
17363                ..Issue::default()
17364            },
17365            Issue {
17366                id: "B".to_string(),
17367                title: "Dependent".to_string(),
17368                status: "open".to_string(),
17369                issue_type: "task".to_string(),
17370                dependencies: vec![Dependency {
17371                    issue_id: "B".to_string(),
17372                    depends_on_id: "A".to_string(),
17373                    dep_type: "blocks".to_string(),
17374                    ..Dependency::default()
17375                }],
17376                ..Issue::default()
17377            },
17378            Issue {
17379                id: "A".to_string(),
17380                title: "Root bottleneck".to_string(),
17381                status: "open".to_string(),
17382                issue_type: "task".to_string(),
17383                ..Issue::default()
17384            },
17385        ];
17386        let mut app = new_app_with_issues(ViewMode::Board, 0, issues);
17387        app.set_selected_index(app.issue_index_for_id("C").expect("issue C should exist"));
17388
17389        app.update(key(KeyCode::Char('i')));
17390        assert_eq!(app.mode, ViewMode::Insights);
17391        assert_eq!(selected_issue_id(&app), "C");
17392
17393        app.update(key(KeyCode::Char('g')));
17394        assert_eq!(app.mode, ViewMode::Graph);
17395        assert_eq!(selected_issue_id(&app), "C");
17396
17397        app.update(key(KeyCode::Escape));
17398        assert_eq!(app.mode, ViewMode::Main);
17399        assert_eq!(selected_issue_id(&app), "C");
17400    }
17401
17402    #[test]
17403    fn insights_list_header_shows_search_hint() {
17404        let mut app = new_app(ViewMode::Main, 0);
17405        app.update(key(KeyCode::Char('i')));
17406        let list_text = app.list_panel_text();
17407        assert!(list_text.contains("/ search"));
17408    }
17409
17410    #[test]
17411    fn history_confidence_cycles_on_c_key() {
17412        let mut app = new_app(ViewMode::Main, 0);
17413        app.update(key(KeyCode::Char('h')));
17414
17415        let initial_index = app.history_confidence_index;
17416        app.update(key(KeyCode::Char('c')));
17417        assert_ne!(app.history_confidence_index, initial_index);
17418    }
17419
17420    #[test]
17421    fn history_git_mode_c_cycles_confidence_and_clamps_cursor() {
17422        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
17423        app.mode = ViewMode::History;
17424        app.history_view_mode = HistoryViewMode::Git;
17425        app.history_event_cursor = 3;
17426        app.history_file_tree_cursor = 99;
17427
17428        app.update(key(KeyCode::Char('c')));
17429        app.update(key(KeyCode::Char('c')));
17430        app.update(key(KeyCode::Char('c')));
17431
17432        assert_eq!(app.history_confidence_index, 3);
17433        assert_eq!(app.history_git_visible_commit_indices(), vec![0, 2]);
17434        assert_eq!(app.history_event_cursor, 1);
17435        assert!(app.history_flat_file_list().len() < 99);
17436        assert_eq!(app.history_file_tree_cursor, 4);
17437    }
17438
17439    #[test]
17440    fn history_v_toggles_git_mode_and_enter_jumps_to_related_issue() {
17441        let mut app = new_app(ViewMode::Main, 0);
17442        app.update(key(KeyCode::Char('h')));
17443        assert!(matches!(app.history_view_mode, HistoryViewMode::Bead));
17444
17445        app.update(key(KeyCode::Char('v')));
17446        assert!(matches!(app.history_view_mode, HistoryViewMode::Git));
17447        let git_list = app.list_panel_text();
17448        assert!(git_list.contains("Git commits") || git_list.contains("No git commits correlated"));
17449
17450        assert!(
17451            app.selected_history_event().is_some(),
17452            "git timeline should contain at least one event"
17453        );
17454
17455        app.update(key(KeyCode::Char('j')));
17456        app.update(key(KeyCode::Char('k')));
17457        assert!(matches!(app.history_view_mode, HistoryViewMode::Git));
17458
17459        app.update(key(KeyCode::Char('c')));
17460        assert_eq!(app.history_confidence_index, 1);
17461
17462        // Capture expected issue_id after confidence change (which may filter events)
17463        let expected_issue_id = app
17464            .selected_history_git_related_bead_id()
17465            .or_else(|| app.selected_history_event().map(|event| event.issue_id))
17466            .expect("should have a related bead after confidence change");
17467
17468        let cmd = app.update(key(KeyCode::Enter));
17469        assert!(matches!(cmd, Cmd::None));
17470        assert!(matches!(app.mode, ViewMode::Main));
17471        assert_eq!(app.focus, FocusPane::Detail);
17472        assert_eq!(selected_issue_id(&app), expected_issue_id);
17473    }
17474
17475    #[test]
17476    fn history_reentry_resets_search_and_file_tree_state() {
17477        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
17478        app.mode = ViewMode::History;
17479        app.mode_before_history = ViewMode::Main;
17480        app.history_view_mode = HistoryViewMode::Git;
17481        app.history_search_active = false;
17482        app.history_search_query = "graph".to_string();
17483        app.history_search_match_cursor = 2;
17484        app.history_search_mode = HistorySearchMode::Author;
17485        app.history_show_file_tree = true;
17486        app.history_file_tree_cursor = 3;
17487        app.history_file_tree_filter = Some("src/ui".to_string());
17488        app.history_file_tree_focus = true;
17489        app.history_status_msg = "Filtered to: src/ui".to_string();
17490        app.focus = FocusPane::Detail;
17491
17492        app.update(key(KeyCode::Char('q')));
17493        assert!(matches!(app.mode, ViewMode::Main));
17494
17495        app.update(key(KeyCode::Char('h')));
17496        assert!(matches!(app.mode, ViewMode::History));
17497        assert!(matches!(app.history_view_mode, HistoryViewMode::Bead));
17498        assert!(!app.history_search_active);
17499        assert!(app.history_search_query.is_empty());
17500        assert_eq!(app.history_search_match_cursor, 0);
17501        assert_eq!(app.history_search_mode, HistorySearchMode::All);
17502        assert!(!app.history_show_file_tree);
17503        assert_eq!(app.history_file_tree_cursor, 0);
17504        assert!(app.history_file_tree_filter.is_none());
17505        assert!(!app.history_file_tree_focus);
17506        assert!(app.history_status_msg.is_empty());
17507        assert_eq!(app.focus, FocusPane::List);
17508    }
17509
17510    #[test]
17511    fn history_git_mode_shift_j_k_perform_secondary_navigation() {
17512        let mut app = new_app(ViewMode::Main, 0);
17513        app.update(key(KeyCode::Char('h')));
17514        app.update(key(KeyCode::Char('v')));
17515        assert!(matches!(app.history_view_mode, HistoryViewMode::Git));
17516        assert_eq!(app.history_related_bead_cursor, 0);
17517        assert_eq!(app.history_event_cursor, 0);
17518
17519        // J/K navigation is safe even with no git history data (test fixtures
17520        // have no real git repo, so event/commit lists are empty).
17521        app.update(key(KeyCode::Char('J')));
17522        app.update(key(KeyCode::Char('K')));
17523
17524        // Cursors remain at zero since no events exist to navigate.
17525        assert_eq!(app.history_related_bead_cursor, 0);
17526        assert_eq!(app.history_event_cursor, 0);
17527    }
17528
17529    #[test]
17530    fn history_mode_search_filters_git_timeline_and_intercepts_hotkeys() {
17531        let mut app = new_app(ViewMode::Main, 0);
17532        app.update(key(KeyCode::Char('h')));
17533        app.update(key(KeyCode::Char('v')));
17534        assert!(matches!(app.mode, ViewMode::History));
17535        assert!(matches!(app.history_view_mode, HistoryViewMode::Git));
17536
17537        app.update(key(KeyCode::Char('/')));
17538        assert!(app.history_search_active);
17539        assert!(app.history_search_query.is_empty());
17540
17541        app.update(key(KeyCode::Char('o')));
17542        assert_eq!(app.history_search_query, "o");
17543        assert_eq!(app.list_filter, ListFilter::All);
17544
17545        app.update(key(KeyCode::Backspace));
17546        assert!(app.history_search_query.is_empty());
17547
17548        for ch in "dependent".chars() {
17549            app.update(key(KeyCode::Char(ch)));
17550        }
17551        assert_eq!(app.history_search_query, "dependent");
17552        let event = app
17553            .selected_history_event()
17554            .expect("history git mode should have timeline events");
17555        assert_eq!(event.issue_id, "B");
17556
17557        app.update(key(KeyCode::Enter));
17558        assert!(!app.history_search_active);
17559        assert_eq!(app.history_search_query, "dependent");
17560
17561        app.update(key(KeyCode::Char('/')));
17562        app.update(key(KeyCode::Char('x')));
17563        assert_eq!(app.history_search_query, "x");
17564        app.update(key(KeyCode::Escape));
17565        assert!(matches!(app.mode, ViewMode::History));
17566        assert!(!app.history_search_active);
17567        assert!(app.history_search_query.is_empty());
17568    }
17569
17570    #[test]
17571    fn history_mode_search_filters_bead_list_and_escape_clears_query() {
17572        let mut app = new_app(ViewMode::Main, 0);
17573        app.update(key(KeyCode::Char('h')));
17574        assert!(matches!(app.mode, ViewMode::History));
17575        assert!(matches!(app.history_view_mode, HistoryViewMode::Bead));
17576
17577        app.update(key(KeyCode::Char('/')));
17578        for ch in "closed".chars() {
17579            app.update(key(KeyCode::Char(ch)));
17580        }
17581        assert_eq!(app.history_search_query, "closed");
17582        assert_eq!(selected_issue_id(&app), "C");
17583
17584        app.update(key(KeyCode::Enter));
17585        assert!(!app.history_search_active);
17586        assert_eq!(app.history_search_query, "closed");
17587
17588        app.update(key(KeyCode::Char('j')));
17589        assert_eq!(selected_issue_id(&app), "C");
17590
17591        app.update(key(KeyCode::Char('/')));
17592        app.update(key(KeyCode::Escape));
17593        assert!(!app.history_search_active);
17594        assert!(app.history_search_query.is_empty());
17595
17596        app.update(key(KeyCode::Home));
17597        assert_eq!(selected_issue_id(&app), "A");
17598        app.update(key(KeyCode::Char('j')));
17599        assert_eq!(selected_issue_id(&app), "B");
17600    }
17601
17602    #[test]
17603    fn history_mode_search_zero_results_show_explicit_message() {
17604        let mut app = new_app(ViewMode::Main, 0);
17605        app.update(key(KeyCode::Char('h')));
17606        assert!(matches!(app.mode, ViewMode::History));
17607        assert!(matches!(app.history_view_mode, HistoryViewMode::Bead));
17608
17609        app.update(key(KeyCode::Char('/')));
17610        for ch in "zzzz".chars() {
17611            app.update(key(KeyCode::Char(ch)));
17612        }
17613
17614        assert!(app.history_visible_issue_indices().is_empty());
17615        let text = app.history_list_text();
17616        assert!(text.contains("(no issues match history search: /zzzz)"));
17617
17618        app.update(key(KeyCode::Enter));
17619        assert!(!app.history_search_active);
17620        assert_eq!(app.history_search_query, "zzzz");
17621        assert!(
17622            app.history_list_text()
17623                .contains("(no issues match history search: /zzzz)")
17624        );
17625    }
17626
17627    #[test]
17628    fn history_git_search_zero_results_show_explicit_message() {
17629        let mut app = new_app(ViewMode::Main, 0);
17630        app.update(key(KeyCode::Char('h')));
17631        app.update(key(KeyCode::Char('v')));
17632        assert!(matches!(app.mode, ViewMode::History));
17633        assert!(matches!(app.history_view_mode, HistoryViewMode::Git));
17634
17635        app.update(key(KeyCode::Char('/')));
17636        for ch in "zzzz".chars() {
17637            app.update(key(KeyCode::Char(ch)));
17638        }
17639
17640        assert!(app.history_search_matches().is_empty());
17641        let text = app.history_list_text();
17642        assert!(text.contains("(no commits match search: /zzzz)"));
17643
17644        app.update(key(KeyCode::Enter));
17645        assert!(!app.history_search_active);
17646        assert_eq!(app.history_search_query, "zzzz");
17647        assert!(
17648            app.history_list_text()
17649                .contains("(no commits match search: /zzzz)")
17650        );
17651    }
17652
17653    #[test]
17654    fn history_git_mode_g_switches_to_graph_and_selects_issue_from_event() {
17655        let mut app = new_app(ViewMode::Main, 0);
17656        app.update(key(KeyCode::Char('h')));
17657        app.update(key(KeyCode::Char('v')));
17658        assert!(matches!(app.mode, ViewMode::History));
17659        assert!(matches!(app.history_view_mode, HistoryViewMode::Git));
17660
17661        let event_issue_id = app
17662            .selected_history_event()
17663            .expect("git timeline should have events")
17664            .issue_id;
17665
17666        app.update(key(KeyCode::Char('g')));
17667        assert!(matches!(app.mode, ViewMode::Graph));
17668        assert_eq!(selected_issue_id(&app), event_issue_id);
17669    }
17670
17671    #[test]
17672    fn enter_from_specialized_modes_returns_to_main_detail() {
17673        for mode in [
17674            ViewMode::Board,
17675            ViewMode::Insights,
17676            ViewMode::Graph,
17677            ViewMode::History,
17678        ] {
17679            let mut app = new_app(mode, 0);
17680            let cmd = app.update(key(KeyCode::Enter));
17681            assert!(matches!(cmd, Cmd::None));
17682            assert!(matches!(app.mode, ViewMode::Main));
17683            assert_eq!(app.focus, FocusPane::Detail);
17684        }
17685    }
17686
17687    #[test]
17688    fn filter_hotkeys_apply_and_escape_clears_before_quit_confirm() {
17689        let mut app = new_app(ViewMode::Main, 0);
17690
17691        app.update(key(KeyCode::Char('c')));
17692        assert_eq!(app.list_filter, ListFilter::Closed);
17693        assert_eq!(selected_issue_id(&app), "C");
17694
17695        let cmd = app.update(key(KeyCode::Escape));
17696        assert!(matches!(cmd, Cmd::None));
17697        assert_eq!(app.list_filter, ListFilter::All);
17698        assert!(!app.show_quit_confirm);
17699
17700        let cmd = app.update(key(KeyCode::Escape));
17701        assert!(matches!(cmd, Cmd::None));
17702        assert!(app.show_quit_confirm);
17703    }
17704
17705    #[test]
17706    fn filter_hotkeys_include_blocked_and_in_progress_slices() {
17707        let mut app = new_app_with_issues(ViewMode::Main, 0, lane_issues());
17708
17709        app.update(key(KeyCode::Char('I')));
17710        assert_eq!(app.list_filter, ListFilter::InProgress);
17711        assert_eq!(selected_issue_id(&app), "IP-1");
17712        assert_eq!(app.visible_issue_indices().len(), 1);
17713
17714        app.update(key(KeyCode::Char('B')));
17715        assert_eq!(app.list_filter, ListFilter::Blocked);
17716        assert_eq!(selected_issue_id(&app), "BLK-1");
17717        assert_eq!(app.visible_issue_indices().len(), 1);
17718
17719        app.update(key(KeyCode::Escape));
17720        assert_eq!(app.list_filter, ListFilter::All);
17721        assert!(!app.show_quit_confirm);
17722    }
17723
17724    #[test]
17725    fn list_navigation_respects_active_filter() {
17726        let mut app = new_app(ViewMode::Main, 0);
17727        app.update(key(KeyCode::Char('o')));
17728        assert_eq!(app.list_filter, ListFilter::Open);
17729        assert_eq!(selected_issue_id(&app), "A");
17730
17731        app.update(key(KeyCode::Char('j')));
17732        assert_eq!(selected_issue_id(&app), "B");
17733
17734        app.update(key(KeyCode::Char('j')));
17735        assert_eq!(selected_issue_id(&app), "B");
17736    }
17737
17738    #[test]
17739    fn board_mode_number_keys_jump_to_expected_lane_selection() {
17740        let mut app = BvrApp {
17741            analyzer: Analyzer::new(lane_issues()),
17742            repo_root: None,
17743            selected: 0,
17744            list_filter: ListFilter::All,
17745            list_sort: ListSort::Default,
17746            board_grouping: BoardGrouping::Status,
17747            board_empty_visibility: EmptyLaneVisibility::Auto,
17748            mode: ViewMode::Board,
17749            mode_before_history: ViewMode::Main,
17750            mode_back_stack: Vec::new(),
17751            focus: FocusPane::List,
17752            focus_before_help: FocusPane::List,
17753            show_help: false,
17754            help_scroll_offset: 0,
17755            show_quit_confirm: false,
17756            modal_overlay: None,
17757            modal_confirm_result: None,
17758            history_confidence_index: 0,
17759            history_view_mode: HistoryViewMode::Bead,
17760            history_event_cursor: 0,
17761            history_related_bead_cursor: 0,
17762            history_bead_commit_cursor: 0,
17763            history_git_cache: None,
17764            history_search_active: false,
17765            history_search_query: String::new(),
17766            history_search_match_cursor: 0,
17767            history_search_mode: HistorySearchMode::All,
17768            history_show_file_tree: false,
17769            history_file_tree_cursor: 0,
17770            history_file_tree_filter: None,
17771            history_file_tree_focus: false,
17772            history_status_msg: String::new(),
17773            board_search_active: false,
17774            board_search_query: String::new(),
17775            board_search_match_cursor: 0,
17776            board_detail_scroll_offset: 0,
17777            detail_scroll_offset: 0,
17778            main_search_active: false,
17779            main_search_query: String::new(),
17780            main_search_match_cursor: 0,
17781            list_scroll_offset: Cell::new(0),
17782            list_viewport_height: Cell::new(0),
17783            graph_search_active: false,
17784            graph_search_query: String::new(),
17785            graph_search_match_cursor: 0,
17786            insights_search_active: false,
17787            insights_search_query: String::new(),
17788            insights_search_match_cursor: 0,
17789            insights_panel: InsightsPanel::Bottlenecks,
17790            insights_heatmap: None,
17791            insights_show_explanations: true,
17792            insights_show_calc_proof: false,
17793            detail_dep_cursor: 0,
17794            actionable_plan: None,
17795            actionable_track_cursor: 0,
17796            actionable_item_cursor: 0,
17797            attention_result: None,
17798            attention_cursor: 0,
17799            tree_flat_nodes: Vec::new(),
17800            tree_cursor: 0,
17801            tree_collapsed: std::collections::HashSet::new(),
17802            tree_search_active: false,
17803            tree_search_query: String::new(),
17804            tree_search_match_cursor: 0,
17805            pending_g: false,
17806            g_pre_toggle_mode: None,
17807            pending_z: false,
17808            label_dashboard: None,
17809            label_dashboard_cursor: 0,
17810            flow_matrix: None,
17811            flow_matrix_row_cursor: 0,
17812            flow_matrix_col_cursor: 0,
17813            time_travel_ref_input: String::new(),
17814            time_travel_input_active: false,
17815            time_travel_diff: None,
17816            time_travel_category_cursor: 0,
17817            time_travel_issue_cursor: 0,
17818            time_travel_last_ref: None,
17819            sprint_data: Vec::new(),
17820            sprint_cursor: 0,
17821            sprint_issue_cursor: 0,
17822            modal_label_filter: None,
17823            modal_repo_filter: None,
17824            priority_hints_visible: false,
17825            status_msg: String::new(),
17826            slow_metrics_pending: false,
17827            #[cfg(test)]
17828            key_trace: Vec::new(),
17829        };
17830
17831        app.update(key(KeyCode::Char('2')));
17832        assert_eq!(selected_issue_id(&app), "IP-1");
17833        assert!(matches!(app.mode, ViewMode::Board));
17834
17835        app.update(key(KeyCode::Char('3')));
17836        assert_eq!(selected_issue_id(&app), "BLK-1");
17837
17838        app.update(key(KeyCode::Char('4')));
17839        assert_eq!(selected_issue_id(&app), "CLS-1");
17840
17841        app.update(key(KeyCode::Char('1')));
17842        app.select_issue_by_id("OPEN-1");
17843        app.select_issue_by_id("OPEN-1");
17844        assert_eq!(selected_issue_id(&app), "OPEN-1");
17845        assert!(matches!(app.mode, ViewMode::Board));
17846    }
17847
17848    #[test]
17849    fn board_grouping_cycles_and_lane_jumps_follow_grouping() {
17850        let mut app = BvrApp {
17851            analyzer: Analyzer::new(lane_issues()),
17852            repo_root: None,
17853            selected: 0,
17854            list_filter: ListFilter::All,
17855            list_sort: ListSort::Default,
17856            board_grouping: BoardGrouping::Status,
17857            board_empty_visibility: EmptyLaneVisibility::Auto,
17858            mode: ViewMode::Board,
17859            mode_before_history: ViewMode::Main,
17860            mode_back_stack: Vec::new(),
17861            focus: FocusPane::List,
17862            focus_before_help: FocusPane::List,
17863            show_help: false,
17864            help_scroll_offset: 0,
17865            show_quit_confirm: false,
17866            modal_overlay: None,
17867            modal_confirm_result: None,
17868            history_confidence_index: 0,
17869            history_view_mode: HistoryViewMode::Bead,
17870            history_event_cursor: 0,
17871            history_related_bead_cursor: 0,
17872            history_bead_commit_cursor: 0,
17873            history_git_cache: None,
17874            history_search_active: false,
17875            history_search_query: String::new(),
17876            history_search_match_cursor: 0,
17877            history_search_mode: HistorySearchMode::All,
17878            history_show_file_tree: false,
17879            history_file_tree_cursor: 0,
17880            history_file_tree_filter: None,
17881            history_file_tree_focus: false,
17882            history_status_msg: String::new(),
17883            board_search_active: false,
17884            board_search_query: String::new(),
17885            board_search_match_cursor: 0,
17886            board_detail_scroll_offset: 0,
17887            detail_scroll_offset: 0,
17888            main_search_active: false,
17889            main_search_query: String::new(),
17890            main_search_match_cursor: 0,
17891            list_scroll_offset: Cell::new(0),
17892            list_viewport_height: Cell::new(0),
17893            graph_search_active: false,
17894            graph_search_query: String::new(),
17895            graph_search_match_cursor: 0,
17896            insights_search_active: false,
17897            insights_search_query: String::new(),
17898            insights_search_match_cursor: 0,
17899            insights_panel: InsightsPanel::Bottlenecks,
17900            insights_heatmap: None,
17901            insights_show_explanations: true,
17902            insights_show_calc_proof: false,
17903            detail_dep_cursor: 0,
17904            actionable_plan: None,
17905            actionable_track_cursor: 0,
17906            actionable_item_cursor: 0,
17907            attention_result: None,
17908            attention_cursor: 0,
17909            tree_flat_nodes: Vec::new(),
17910            tree_cursor: 0,
17911            tree_collapsed: std::collections::HashSet::new(),
17912            tree_search_active: false,
17913            tree_search_query: String::new(),
17914            tree_search_match_cursor: 0,
17915            pending_g: false,
17916            g_pre_toggle_mode: None,
17917            pending_z: false,
17918            label_dashboard: None,
17919            label_dashboard_cursor: 0,
17920            flow_matrix: None,
17921            flow_matrix_row_cursor: 0,
17922            flow_matrix_col_cursor: 0,
17923            time_travel_ref_input: String::new(),
17924            time_travel_input_active: false,
17925            time_travel_diff: None,
17926            time_travel_category_cursor: 0,
17927            time_travel_issue_cursor: 0,
17928            time_travel_last_ref: None,
17929            sprint_data: Vec::new(),
17930            sprint_cursor: 0,
17931            sprint_issue_cursor: 0,
17932            modal_label_filter: None,
17933            modal_repo_filter: None,
17934            priority_hints_visible: false,
17935            status_msg: String::new(),
17936            slow_metrics_pending: false,
17937            #[cfg(test)]
17938            key_trace: Vec::new(),
17939        };
17940
17941        app.update(key(KeyCode::Char('s')));
17942        assert_eq!(app.board_grouping, BoardGrouping::Priority);
17943        assert!(app.list_panel_text().contains("Grouping: priority"));
17944        app.update(key(KeyCode::Char('3')));
17945        assert_eq!(selected_issue_id(&app), "BLK-1");
17946
17947        app.update(key(KeyCode::Char('s')));
17948        assert_eq!(app.board_grouping, BoardGrouping::Type);
17949        assert!(app.list_panel_text().contains("Grouping: type"));
17950    }
17951
17952    #[test]
17953    fn board_mode_advanced_navigation_and_empty_lane_toggle_work() {
17954        let mut app = BvrApp {
17955            analyzer: Analyzer::new(board_nav_issues()),
17956            repo_root: None,
17957            selected: 0,
17958            list_filter: ListFilter::All,
17959            list_sort: ListSort::Default,
17960            board_grouping: BoardGrouping::Status,
17961            board_empty_visibility: EmptyLaneVisibility::Auto,
17962            mode: ViewMode::Board,
17963            mode_before_history: ViewMode::Main,
17964            mode_back_stack: Vec::new(),
17965            focus: FocusPane::List,
17966            focus_before_help: FocusPane::List,
17967            show_help: false,
17968            help_scroll_offset: 0,
17969            show_quit_confirm: false,
17970            modal_overlay: None,
17971            modal_confirm_result: None,
17972            history_confidence_index: 0,
17973            history_view_mode: HistoryViewMode::Bead,
17974            history_event_cursor: 0,
17975            history_related_bead_cursor: 0,
17976            history_bead_commit_cursor: 0,
17977            history_git_cache: None,
17978            history_search_active: false,
17979            history_search_query: String::new(),
17980            history_search_match_cursor: 0,
17981            history_search_mode: HistorySearchMode::All,
17982            history_show_file_tree: false,
17983            history_file_tree_cursor: 0,
17984            history_file_tree_filter: None,
17985            history_file_tree_focus: false,
17986            history_status_msg: String::new(),
17987            board_search_active: false,
17988            board_search_query: String::new(),
17989            board_search_match_cursor: 0,
17990            board_detail_scroll_offset: 0,
17991            detail_scroll_offset: 0,
17992            main_search_active: false,
17993            main_search_query: String::new(),
17994            main_search_match_cursor: 0,
17995            list_scroll_offset: Cell::new(0),
17996            list_viewport_height: Cell::new(0),
17997            graph_search_active: false,
17998            graph_search_query: String::new(),
17999            graph_search_match_cursor: 0,
18000            insights_search_active: false,
18001            insights_search_query: String::new(),
18002            insights_search_match_cursor: 0,
18003            insights_panel: InsightsPanel::Bottlenecks,
18004            insights_heatmap: None,
18005            insights_show_explanations: true,
18006            insights_show_calc_proof: false,
18007            detail_dep_cursor: 0,
18008            actionable_plan: None,
18009            actionable_track_cursor: 0,
18010            actionable_item_cursor: 0,
18011            attention_result: None,
18012            attention_cursor: 0,
18013            tree_flat_nodes: Vec::new(),
18014            tree_cursor: 0,
18015            tree_collapsed: std::collections::HashSet::new(),
18016            tree_search_active: false,
18017            tree_search_query: String::new(),
18018            tree_search_match_cursor: 0,
18019            pending_g: false,
18020            g_pre_toggle_mode: None,
18021            pending_z: false,
18022            label_dashboard: None,
18023            label_dashboard_cursor: 0,
18024            flow_matrix: None,
18025            flow_matrix_row_cursor: 0,
18026            flow_matrix_col_cursor: 0,
18027            time_travel_ref_input: String::new(),
18028            time_travel_input_active: false,
18029            time_travel_diff: None,
18030            time_travel_category_cursor: 0,
18031            time_travel_issue_cursor: 0,
18032            time_travel_last_ref: None,
18033            sprint_data: Vec::new(),
18034            sprint_cursor: 0,
18035            sprint_issue_cursor: 0,
18036            modal_label_filter: None,
18037            modal_repo_filter: None,
18038            priority_hints_visible: false,
18039            status_msg: String::new(),
18040            slow_metrics_pending: false,
18041            #[cfg(test)]
18042            key_trace: Vec::new(),
18043        };
18044
18045        app.select_issue_by_id("OPEN-1");
18046        app.update(key(KeyCode::Char('$')));
18047        assert_eq!(selected_issue_id(&app), "OPEN-2");
18048        app.update(key(KeyCode::Char('0')));
18049        assert_eq!(selected_issue_id(&app), "OPEN-1");
18050
18051        app.update(key(KeyCode::Char('L')));
18052        assert_eq!(selected_issue_id(&app), "CLS-1");
18053        app.update(key(KeyCode::Char('H')));
18054        assert_eq!(selected_issue_id(&app), "OPEN-1");
18055
18056        app.update(key(KeyCode::Char('c')));
18057        let with_empty_lanes = app.list_panel_text();
18058        assert!(with_empty_lanes.contains("open"));
18059        assert!(with_empty_lanes.contains("in_progress"));
18060        assert!(with_empty_lanes.contains("blocked"));
18061
18062        // 3-state cycle: Auto → ShowAll → HideEmpty
18063        app.update(key(KeyCode::Char('e')));
18064        assert_eq!(app.board_empty_visibility, EmptyLaneVisibility::ShowAll);
18065        app.update(key(KeyCode::Char('e')));
18066        assert_eq!(app.board_empty_visibility, EmptyLaneVisibility::HideEmpty);
18067        let without_empty_lanes = app.list_panel_text();
18068        assert!(!without_empty_lanes.contains("open"));
18069        assert!(!without_empty_lanes.contains("in_progress"));
18070        assert!(!without_empty_lanes.contains("blocked"));
18071        assert!(without_empty_lanes.contains("closed"));
18072    }
18073
18074    #[test]
18075    fn board_mode_home_and_end_stay_within_current_lane() {
18076        let mut app = BvrApp {
18077            analyzer: Analyzer::new(board_nav_issues()),
18078            repo_root: None,
18079            selected: 0,
18080            list_filter: ListFilter::All,
18081            list_sort: ListSort::Default,
18082            board_grouping: BoardGrouping::Status,
18083            board_empty_visibility: EmptyLaneVisibility::Auto,
18084            mode: ViewMode::Board,
18085            mode_before_history: ViewMode::Main,
18086            mode_back_stack: Vec::new(),
18087            focus: FocusPane::List,
18088            focus_before_help: FocusPane::List,
18089            show_help: false,
18090            help_scroll_offset: 0,
18091            show_quit_confirm: false,
18092            modal_overlay: None,
18093            modal_confirm_result: None,
18094            history_confidence_index: 0,
18095            history_view_mode: HistoryViewMode::Bead,
18096            history_event_cursor: 0,
18097            history_related_bead_cursor: 0,
18098            history_bead_commit_cursor: 0,
18099            history_git_cache: None,
18100            history_search_active: false,
18101            history_search_query: String::new(),
18102            history_search_match_cursor: 0,
18103            history_search_mode: HistorySearchMode::All,
18104            history_show_file_tree: false,
18105            history_file_tree_cursor: 0,
18106            history_file_tree_filter: None,
18107            history_file_tree_focus: false,
18108            history_status_msg: String::new(),
18109            board_search_active: false,
18110            board_search_query: String::new(),
18111            board_search_match_cursor: 0,
18112            board_detail_scroll_offset: 0,
18113            detail_scroll_offset: 0,
18114            main_search_active: false,
18115            main_search_query: String::new(),
18116            main_search_match_cursor: 0,
18117            list_scroll_offset: Cell::new(0),
18118            list_viewport_height: Cell::new(0),
18119            graph_search_active: false,
18120            graph_search_query: String::new(),
18121            graph_search_match_cursor: 0,
18122            insights_search_active: false,
18123            insights_search_query: String::new(),
18124            insights_search_match_cursor: 0,
18125            insights_panel: InsightsPanel::Bottlenecks,
18126            insights_heatmap: None,
18127            insights_show_explanations: true,
18128            insights_show_calc_proof: false,
18129            detail_dep_cursor: 0,
18130            actionable_plan: None,
18131            actionable_track_cursor: 0,
18132            actionable_item_cursor: 0,
18133            attention_result: None,
18134            attention_cursor: 0,
18135            tree_flat_nodes: Vec::new(),
18136            tree_cursor: 0,
18137            tree_collapsed: std::collections::HashSet::new(),
18138            tree_search_active: false,
18139            tree_search_query: String::new(),
18140            tree_search_match_cursor: 0,
18141            pending_g: false,
18142            g_pre_toggle_mode: None,
18143            pending_z: false,
18144            label_dashboard: None,
18145            label_dashboard_cursor: 0,
18146            flow_matrix: None,
18147            flow_matrix_row_cursor: 0,
18148            flow_matrix_col_cursor: 0,
18149            time_travel_ref_input: String::new(),
18150            time_travel_input_active: false,
18151            time_travel_diff: None,
18152            time_travel_category_cursor: 0,
18153            time_travel_issue_cursor: 0,
18154            time_travel_last_ref: None,
18155            sprint_data: Vec::new(),
18156            sprint_cursor: 0,
18157            sprint_issue_cursor: 0,
18158            modal_label_filter: None,
18159            modal_repo_filter: None,
18160            priority_hints_visible: false,
18161            status_msg: String::new(),
18162            slow_metrics_pending: false,
18163            #[cfg(test)]
18164            key_trace: Vec::new(),
18165        };
18166
18167        app.select_issue_by_id("OPEN-1");
18168        app.update(key(KeyCode::End));
18169        assert_eq!(selected_issue_id(&app), "OPEN-2");
18170
18171        app.update(key(KeyCode::Home));
18172        assert_eq!(selected_issue_id(&app), "OPEN-1");
18173
18174        app.update(key(KeyCode::Char('l')));
18175        assert_eq!(selected_issue_id(&app), "IP-1");
18176        app.update(key(KeyCode::End));
18177        assert_eq!(selected_issue_id(&app), "IP-1");
18178    }
18179
18180    #[test]
18181    fn board_mode_h_l_move_between_lanes_without_entering_history() {
18182        let mut app = BvrApp {
18183            analyzer: Analyzer::new(board_nav_issues()),
18184            repo_root: None,
18185            selected: 0,
18186            list_filter: ListFilter::All,
18187            list_sort: ListSort::Default,
18188            board_grouping: BoardGrouping::Status,
18189            board_empty_visibility: EmptyLaneVisibility::Auto,
18190            mode: ViewMode::Board,
18191            mode_before_history: ViewMode::Main,
18192            mode_back_stack: Vec::new(),
18193            focus: FocusPane::List,
18194            focus_before_help: FocusPane::List,
18195            show_help: false,
18196            help_scroll_offset: 0,
18197            show_quit_confirm: false,
18198            modal_overlay: None,
18199            modal_confirm_result: None,
18200            history_confidence_index: 0,
18201            history_view_mode: HistoryViewMode::Bead,
18202            history_event_cursor: 0,
18203            history_related_bead_cursor: 0,
18204            history_bead_commit_cursor: 0,
18205            history_git_cache: None,
18206            history_search_active: false,
18207            history_search_query: String::new(),
18208            history_search_match_cursor: 0,
18209            history_search_mode: HistorySearchMode::All,
18210            history_show_file_tree: false,
18211            history_file_tree_cursor: 0,
18212            history_file_tree_filter: None,
18213            history_file_tree_focus: false,
18214            history_status_msg: String::new(),
18215            board_search_active: false,
18216            board_search_query: String::new(),
18217            board_search_match_cursor: 0,
18218            board_detail_scroll_offset: 0,
18219            detail_scroll_offset: 0,
18220            main_search_active: false,
18221            main_search_query: String::new(),
18222            main_search_match_cursor: 0,
18223            list_scroll_offset: Cell::new(0),
18224            list_viewport_height: Cell::new(0),
18225            graph_search_active: false,
18226            graph_search_query: String::new(),
18227            graph_search_match_cursor: 0,
18228            insights_search_active: false,
18229            insights_search_query: String::new(),
18230            insights_search_match_cursor: 0,
18231            insights_panel: InsightsPanel::Bottlenecks,
18232            insights_heatmap: None,
18233            insights_show_explanations: true,
18234            insights_show_calc_proof: false,
18235            detail_dep_cursor: 0,
18236            actionable_plan: None,
18237            actionable_track_cursor: 0,
18238            actionable_item_cursor: 0,
18239            attention_result: None,
18240            attention_cursor: 0,
18241            tree_flat_nodes: Vec::new(),
18242            tree_cursor: 0,
18243            tree_collapsed: std::collections::HashSet::new(),
18244            tree_search_active: false,
18245            tree_search_query: String::new(),
18246            tree_search_match_cursor: 0,
18247            pending_g: false,
18248            g_pre_toggle_mode: None,
18249            pending_z: false,
18250            label_dashboard: None,
18251            label_dashboard_cursor: 0,
18252            flow_matrix: None,
18253            flow_matrix_row_cursor: 0,
18254            flow_matrix_col_cursor: 0,
18255            time_travel_ref_input: String::new(),
18256            time_travel_input_active: false,
18257            time_travel_diff: None,
18258            time_travel_category_cursor: 0,
18259            time_travel_issue_cursor: 0,
18260            time_travel_last_ref: None,
18261            sprint_data: Vec::new(),
18262            sprint_cursor: 0,
18263            sprint_issue_cursor: 0,
18264            modal_label_filter: None,
18265            modal_repo_filter: None,
18266            priority_hints_visible: false,
18267            status_msg: String::new(),
18268            slow_metrics_pending: false,
18269            #[cfg(test)]
18270            key_trace: Vec::new(),
18271        };
18272
18273        app.select_issue_by_id("OPEN-1");
18274        app.update(key(KeyCode::Char('l')));
18275        assert_eq!(selected_issue_id(&app), "IP-1");
18276        assert!(matches!(app.mode, ViewMode::Board));
18277
18278        app.update(key(KeyCode::Char('l')));
18279        assert_eq!(selected_issue_id(&app), "CLS-1");
18280        assert!(matches!(app.mode, ViewMode::Board));
18281
18282        app.update(key(KeyCode::Char('h')));
18283        assert_eq!(selected_issue_id(&app), "IP-1");
18284        assert!(matches!(app.mode, ViewMode::Board));
18285    }
18286
18287    #[test]
18288    fn board_mode_j_k_stay_within_current_lane() {
18289        let mut app = BvrApp {
18290            analyzer: Analyzer::new(board_nav_issues()),
18291            repo_root: None,
18292            selected: 0,
18293            list_filter: ListFilter::All,
18294            list_sort: ListSort::Default,
18295            board_grouping: BoardGrouping::Status,
18296            board_empty_visibility: EmptyLaneVisibility::Auto,
18297            mode: ViewMode::Board,
18298            mode_before_history: ViewMode::Main,
18299            mode_back_stack: Vec::new(),
18300            focus: FocusPane::List,
18301            focus_before_help: FocusPane::List,
18302            show_help: false,
18303            help_scroll_offset: 0,
18304            show_quit_confirm: false,
18305            modal_overlay: None,
18306            modal_confirm_result: None,
18307            history_confidence_index: 0,
18308            history_view_mode: HistoryViewMode::Bead,
18309            history_event_cursor: 0,
18310            history_related_bead_cursor: 0,
18311            history_bead_commit_cursor: 0,
18312            history_git_cache: None,
18313            history_search_active: false,
18314            history_search_query: String::new(),
18315            history_search_match_cursor: 0,
18316            history_search_mode: HistorySearchMode::All,
18317            history_show_file_tree: false,
18318            history_file_tree_cursor: 0,
18319            history_file_tree_filter: None,
18320            history_file_tree_focus: false,
18321            history_status_msg: String::new(),
18322            board_search_active: false,
18323            board_search_query: String::new(),
18324            board_search_match_cursor: 0,
18325            board_detail_scroll_offset: 0,
18326            detail_scroll_offset: 0,
18327            main_search_active: false,
18328            main_search_query: String::new(),
18329            main_search_match_cursor: 0,
18330            list_scroll_offset: Cell::new(0),
18331            list_viewport_height: Cell::new(0),
18332            graph_search_active: false,
18333            graph_search_query: String::new(),
18334            graph_search_match_cursor: 0,
18335            insights_search_active: false,
18336            insights_search_query: String::new(),
18337            insights_search_match_cursor: 0,
18338            insights_panel: InsightsPanel::Bottlenecks,
18339            insights_heatmap: None,
18340            insights_show_explanations: true,
18341            insights_show_calc_proof: false,
18342            detail_dep_cursor: 0,
18343            actionable_plan: None,
18344            actionable_track_cursor: 0,
18345            actionable_item_cursor: 0,
18346            attention_result: None,
18347            attention_cursor: 0,
18348            tree_flat_nodes: Vec::new(),
18349            tree_cursor: 0,
18350            tree_collapsed: std::collections::HashSet::new(),
18351            tree_search_active: false,
18352            tree_search_query: String::new(),
18353            tree_search_match_cursor: 0,
18354            pending_g: false,
18355            g_pre_toggle_mode: None,
18356            pending_z: false,
18357            label_dashboard: None,
18358            label_dashboard_cursor: 0,
18359            flow_matrix: None,
18360            flow_matrix_row_cursor: 0,
18361            flow_matrix_col_cursor: 0,
18362            time_travel_ref_input: String::new(),
18363            time_travel_input_active: false,
18364            time_travel_diff: None,
18365            time_travel_category_cursor: 0,
18366            time_travel_issue_cursor: 0,
18367            time_travel_last_ref: None,
18368            sprint_data: Vec::new(),
18369            sprint_cursor: 0,
18370            sprint_issue_cursor: 0,
18371            modal_label_filter: None,
18372            modal_repo_filter: None,
18373            priority_hints_visible: false,
18374            status_msg: String::new(),
18375            slow_metrics_pending: false,
18376            #[cfg(test)]
18377            key_trace: Vec::new(),
18378        };
18379
18380        app.select_issue_by_id("OPEN-1");
18381        app.update(key(KeyCode::Char('j')));
18382        assert_eq!(selected_issue_id(&app), "OPEN-2");
18383
18384        app.update(key(KeyCode::Char('j')));
18385        assert_eq!(selected_issue_id(&app), "OPEN-2");
18386
18387        app.update(key(KeyCode::Char('k')));
18388        assert_eq!(selected_issue_id(&app), "OPEN-1");
18389
18390        app.update(key(KeyCode::Char('k')));
18391        assert_eq!(selected_issue_id(&app), "OPEN-1");
18392        assert!(matches!(app.mode, ViewMode::Board));
18393    }
18394
18395    #[test]
18396    fn board_mode_ctrl_d_u_page_within_current_lane() {
18397        let mut app = BvrApp {
18398            analyzer: Analyzer::new(board_nav_issues()),
18399            repo_root: None,
18400            selected: 0,
18401            list_filter: ListFilter::All,
18402            list_sort: ListSort::Default,
18403            board_grouping: BoardGrouping::Status,
18404            board_empty_visibility: EmptyLaneVisibility::Auto,
18405            mode: ViewMode::Board,
18406            mode_before_history: ViewMode::Main,
18407            mode_back_stack: Vec::new(),
18408            focus: FocusPane::List,
18409            focus_before_help: FocusPane::List,
18410            show_help: false,
18411            help_scroll_offset: 0,
18412            show_quit_confirm: false,
18413            modal_overlay: None,
18414            modal_confirm_result: None,
18415            history_confidence_index: 0,
18416            history_view_mode: HistoryViewMode::Bead,
18417            history_event_cursor: 0,
18418            history_related_bead_cursor: 0,
18419            history_bead_commit_cursor: 0,
18420            history_git_cache: None,
18421            history_search_active: false,
18422            history_search_query: String::new(),
18423            history_search_match_cursor: 0,
18424            history_search_mode: HistorySearchMode::All,
18425            history_show_file_tree: false,
18426            history_file_tree_cursor: 0,
18427            history_file_tree_filter: None,
18428            history_file_tree_focus: false,
18429            history_status_msg: String::new(),
18430            board_search_active: false,
18431            board_search_query: String::new(),
18432            board_search_match_cursor: 0,
18433            board_detail_scroll_offset: 0,
18434            detail_scroll_offset: 0,
18435            main_search_active: false,
18436            main_search_query: String::new(),
18437            main_search_match_cursor: 0,
18438            list_scroll_offset: Cell::new(0),
18439            list_viewport_height: Cell::new(0),
18440            graph_search_active: false,
18441            graph_search_query: String::new(),
18442            graph_search_match_cursor: 0,
18443            insights_search_active: false,
18444            insights_search_query: String::new(),
18445            insights_search_match_cursor: 0,
18446            insights_panel: InsightsPanel::Bottlenecks,
18447            insights_heatmap: None,
18448            insights_show_explanations: true,
18449            insights_show_calc_proof: false,
18450            detail_dep_cursor: 0,
18451            actionable_plan: None,
18452            actionable_track_cursor: 0,
18453            actionable_item_cursor: 0,
18454            attention_result: None,
18455            attention_cursor: 0,
18456            tree_flat_nodes: Vec::new(),
18457            tree_cursor: 0,
18458            tree_collapsed: std::collections::HashSet::new(),
18459            tree_search_active: false,
18460            tree_search_query: String::new(),
18461            tree_search_match_cursor: 0,
18462            pending_g: false,
18463            g_pre_toggle_mode: None,
18464            pending_z: false,
18465            label_dashboard: None,
18466            label_dashboard_cursor: 0,
18467            flow_matrix: None,
18468            flow_matrix_row_cursor: 0,
18469            flow_matrix_col_cursor: 0,
18470            time_travel_ref_input: String::new(),
18471            time_travel_input_active: false,
18472            time_travel_diff: None,
18473            time_travel_category_cursor: 0,
18474            time_travel_issue_cursor: 0,
18475            time_travel_last_ref: None,
18476            sprint_data: Vec::new(),
18477            sprint_cursor: 0,
18478            sprint_issue_cursor: 0,
18479            modal_label_filter: None,
18480            modal_repo_filter: None,
18481            priority_hints_visible: false,
18482            status_msg: String::new(),
18483            slow_metrics_pending: false,
18484            #[cfg(test)]
18485            key_trace: Vec::new(),
18486        };
18487
18488        app.select_issue_by_id("OPEN-1");
18489        assert_eq!(selected_issue_id(&app), "OPEN-1");
18490        app.update(key_ctrl(KeyCode::Char('d')));
18491        assert_eq!(selected_issue_id(&app), "OPEN-2");
18492
18493        app.update(key_ctrl(KeyCode::Char('u')));
18494        assert_eq!(selected_issue_id(&app), "OPEN-1");
18495        assert!(matches!(app.mode, ViewMode::Board));
18496    }
18497
18498    #[test]
18499    fn board_mode_search_query_and_match_cycling_work() {
18500        let mut app = BvrApp {
18501            analyzer: Analyzer::new(board_nav_issues()),
18502            repo_root: None,
18503            selected: 0,
18504            list_filter: ListFilter::All,
18505            list_sort: ListSort::Default,
18506            board_grouping: BoardGrouping::Status,
18507            board_empty_visibility: EmptyLaneVisibility::Auto,
18508            mode: ViewMode::Board,
18509            mode_before_history: ViewMode::Main,
18510            mode_back_stack: Vec::new(),
18511            focus: FocusPane::List,
18512            focus_before_help: FocusPane::List,
18513            show_help: false,
18514            help_scroll_offset: 0,
18515            show_quit_confirm: false,
18516            modal_overlay: None,
18517            modal_confirm_result: None,
18518            history_confidence_index: 0,
18519            history_view_mode: HistoryViewMode::Bead,
18520            history_event_cursor: 0,
18521            history_related_bead_cursor: 0,
18522            history_bead_commit_cursor: 0,
18523            history_git_cache: None,
18524            history_search_active: false,
18525            history_search_query: String::new(),
18526            history_search_match_cursor: 0,
18527            history_search_mode: HistorySearchMode::All,
18528            history_show_file_tree: false,
18529            history_file_tree_cursor: 0,
18530            history_file_tree_filter: None,
18531            history_file_tree_focus: false,
18532            history_status_msg: String::new(),
18533            board_search_active: false,
18534            board_search_query: String::new(),
18535            board_search_match_cursor: 0,
18536            board_detail_scroll_offset: 0,
18537            detail_scroll_offset: 0,
18538            main_search_active: false,
18539            main_search_query: String::new(),
18540            main_search_match_cursor: 0,
18541            list_scroll_offset: Cell::new(0),
18542            list_viewport_height: Cell::new(0),
18543            graph_search_active: false,
18544            graph_search_query: String::new(),
18545            graph_search_match_cursor: 0,
18546            insights_search_active: false,
18547            insights_search_query: String::new(),
18548            insights_search_match_cursor: 0,
18549            insights_panel: InsightsPanel::Bottlenecks,
18550            insights_heatmap: None,
18551            insights_show_explanations: true,
18552            insights_show_calc_proof: false,
18553            detail_dep_cursor: 0,
18554            actionable_plan: None,
18555            actionable_track_cursor: 0,
18556            actionable_item_cursor: 0,
18557            attention_result: None,
18558            attention_cursor: 0,
18559            tree_flat_nodes: Vec::new(),
18560            tree_cursor: 0,
18561            tree_collapsed: std::collections::HashSet::new(),
18562            tree_search_active: false,
18563            tree_search_query: String::new(),
18564            tree_search_match_cursor: 0,
18565            pending_g: false,
18566            g_pre_toggle_mode: None,
18567            pending_z: false,
18568            label_dashboard: None,
18569            label_dashboard_cursor: 0,
18570            flow_matrix: None,
18571            flow_matrix_row_cursor: 0,
18572            flow_matrix_col_cursor: 0,
18573            time_travel_ref_input: String::new(),
18574            time_travel_input_active: false,
18575            time_travel_diff: None,
18576            time_travel_category_cursor: 0,
18577            time_travel_issue_cursor: 0,
18578            time_travel_last_ref: None,
18579            sprint_data: Vec::new(),
18580            sprint_cursor: 0,
18581            sprint_issue_cursor: 0,
18582            modal_label_filter: None,
18583            modal_repo_filter: None,
18584            priority_hints_visible: false,
18585            status_msg: String::new(),
18586            slow_metrics_pending: false,
18587            #[cfg(test)]
18588            key_trace: Vec::new(),
18589        };
18590
18591        app.update(key(KeyCode::Char('/')));
18592        assert!(app.board_search_active);
18593        assert!(app.board_search_query.is_empty());
18594
18595        for ch in ['o', 'p', 'e'] {
18596            app.update(key(KeyCode::Char(ch)));
18597        }
18598
18599        assert_eq!(app.board_search_query, "ope");
18600        assert_eq!(selected_issue_id(&app), "OPEN-1");
18601
18602        app.update(key(KeyCode::Char('n')));
18603        assert_eq!(selected_issue_id(&app), "OPEN-2");
18604
18605        app.update(key(KeyCode::Char('N')));
18606        assert_eq!(selected_issue_id(&app), "OPEN-1");
18607
18608        app.update(key(KeyCode::Enter));
18609        assert!(!app.board_search_active);
18610        assert_eq!(app.board_search_query, "ope");
18611
18612        app.update(key(KeyCode::Char('n')));
18613        assert_eq!(selected_issue_id(&app), "OPEN-2");
18614    }
18615
18616    #[test]
18617    fn board_mode_search_escape_clears_query_and_blocks_filter_hotkeys() {
18618        let mut app = BvrApp {
18619            analyzer: Analyzer::new(board_nav_issues()),
18620            repo_root: None,
18621            selected: 0,
18622            list_filter: ListFilter::All,
18623            list_sort: ListSort::Default,
18624            board_grouping: BoardGrouping::Status,
18625            board_empty_visibility: EmptyLaneVisibility::Auto,
18626            mode: ViewMode::Board,
18627            mode_before_history: ViewMode::Main,
18628            mode_back_stack: Vec::new(),
18629            focus: FocusPane::List,
18630            focus_before_help: FocusPane::List,
18631            show_help: false,
18632            help_scroll_offset: 0,
18633            show_quit_confirm: false,
18634            modal_overlay: None,
18635            modal_confirm_result: None,
18636            history_confidence_index: 0,
18637            history_view_mode: HistoryViewMode::Bead,
18638            history_event_cursor: 0,
18639            history_related_bead_cursor: 0,
18640            history_bead_commit_cursor: 0,
18641            history_git_cache: None,
18642            history_search_active: false,
18643            history_search_query: String::new(),
18644            history_search_match_cursor: 0,
18645            history_search_mode: HistorySearchMode::All,
18646            history_show_file_tree: false,
18647            history_file_tree_cursor: 0,
18648            history_file_tree_filter: None,
18649            history_file_tree_focus: false,
18650            history_status_msg: String::new(),
18651            board_search_active: false,
18652            board_search_query: String::new(),
18653            board_search_match_cursor: 0,
18654            board_detail_scroll_offset: 0,
18655            detail_scroll_offset: 0,
18656            main_search_active: false,
18657            main_search_query: String::new(),
18658            main_search_match_cursor: 0,
18659            list_scroll_offset: Cell::new(0),
18660            list_viewport_height: Cell::new(0),
18661            graph_search_active: false,
18662            graph_search_query: String::new(),
18663            graph_search_match_cursor: 0,
18664            insights_search_active: false,
18665            insights_search_query: String::new(),
18666            insights_search_match_cursor: 0,
18667            insights_panel: InsightsPanel::Bottlenecks,
18668            insights_heatmap: None,
18669            insights_show_explanations: true,
18670            insights_show_calc_proof: false,
18671            detail_dep_cursor: 0,
18672            actionable_plan: None,
18673            actionable_track_cursor: 0,
18674            actionable_item_cursor: 0,
18675            attention_result: None,
18676            attention_cursor: 0,
18677            tree_flat_nodes: Vec::new(),
18678            tree_cursor: 0,
18679            tree_collapsed: std::collections::HashSet::new(),
18680            tree_search_active: false,
18681            tree_search_query: String::new(),
18682            tree_search_match_cursor: 0,
18683            pending_g: false,
18684            g_pre_toggle_mode: None,
18685            pending_z: false,
18686            label_dashboard: None,
18687            label_dashboard_cursor: 0,
18688            flow_matrix: None,
18689            flow_matrix_row_cursor: 0,
18690            flow_matrix_col_cursor: 0,
18691            time_travel_ref_input: String::new(),
18692            time_travel_input_active: false,
18693            time_travel_diff: None,
18694            time_travel_category_cursor: 0,
18695            time_travel_issue_cursor: 0,
18696            time_travel_last_ref: None,
18697            sprint_data: Vec::new(),
18698            sprint_cursor: 0,
18699            sprint_issue_cursor: 0,
18700            modal_label_filter: None,
18701            modal_repo_filter: None,
18702            priority_hints_visible: false,
18703            status_msg: String::new(),
18704            slow_metrics_pending: false,
18705            #[cfg(test)]
18706            key_trace: Vec::new(),
18707        };
18708
18709        app.update(key(KeyCode::Char('/')));
18710        app.update(key(KeyCode::Char('c')));
18711        assert!(app.board_search_active);
18712        assert_eq!(app.board_search_query, "c");
18713        assert_eq!(app.list_filter, ListFilter::All);
18714
18715        app.update(key(KeyCode::Escape));
18716        assert!(!app.board_search_active);
18717        assert!(app.board_search_query.is_empty());
18718    }
18719
18720    #[test]
18721    fn board_mode_search_prefers_rendered_lane_order_over_storage_order() {
18722        let issues = vec![
18723            Issue {
18724                id: "BLK-1".to_string(),
18725                title: "Alpha blocked".to_string(),
18726                status: "blocked".to_string(),
18727                issue_type: "task".to_string(),
18728                ..Issue::default()
18729            },
18730            Issue {
18731                id: "OPEN-1".to_string(),
18732                title: "Alpha open".to_string(),
18733                status: "open".to_string(),
18734                issue_type: "task".to_string(),
18735                ..Issue::default()
18736            },
18737        ];
18738        let mut app = new_app_with_issues(ViewMode::Board, 0, issues);
18739
18740        let rendered_ids = app
18741            .board_visible_issue_indices_in_display_order()
18742            .into_iter()
18743            .map(|index| app.analyzer.issues[index].id.clone())
18744            .collect::<Vec<_>>();
18745        assert_eq!(rendered_ids, vec!["OPEN-1", "BLK-1"]);
18746
18747        app.update(key(KeyCode::Char('/')));
18748        app.update(key(KeyCode::Char('a')));
18749
18750        assert_eq!(selected_issue_id(&app), "OPEN-1");
18751        assert!(
18752            app.list_panel_text()
18753                .lines()
18754                .any(|line| line.contains('▶') && line.contains("OPEN-1"))
18755        );
18756    }
18757
18758    #[test]
18759    fn board_mode_detail_focus_shortcuts_drive_navigation_and_search() {
18760        let mut app = BvrApp {
18761            analyzer: Analyzer::new(board_nav_issues()),
18762            repo_root: None,
18763            selected: 0,
18764            list_filter: ListFilter::All,
18765            list_sort: ListSort::Default,
18766            board_grouping: BoardGrouping::Status,
18767            board_empty_visibility: EmptyLaneVisibility::Auto,
18768            mode: ViewMode::Board,
18769            mode_before_history: ViewMode::Main,
18770            mode_back_stack: Vec::new(),
18771            focus: FocusPane::List,
18772            focus_before_help: FocusPane::List,
18773            show_help: false,
18774            help_scroll_offset: 0,
18775            show_quit_confirm: false,
18776            modal_overlay: None,
18777            modal_confirm_result: None,
18778            history_confidence_index: 0,
18779            history_view_mode: HistoryViewMode::Bead,
18780            history_event_cursor: 0,
18781            history_related_bead_cursor: 0,
18782            history_bead_commit_cursor: 0,
18783            history_git_cache: None,
18784            history_search_active: false,
18785            history_search_query: String::new(),
18786            history_search_match_cursor: 0,
18787            history_search_mode: HistorySearchMode::All,
18788            history_show_file_tree: false,
18789            history_file_tree_cursor: 0,
18790            history_file_tree_filter: None,
18791            history_file_tree_focus: false,
18792            history_status_msg: String::new(),
18793            board_search_active: false,
18794            board_search_query: String::new(),
18795            board_search_match_cursor: 0,
18796            board_detail_scroll_offset: 0,
18797            detail_scroll_offset: 0,
18798            main_search_active: false,
18799            main_search_query: String::new(),
18800            main_search_match_cursor: 0,
18801            list_scroll_offset: Cell::new(0),
18802            list_viewport_height: Cell::new(0),
18803            graph_search_active: false,
18804            graph_search_query: String::new(),
18805            graph_search_match_cursor: 0,
18806            insights_search_active: false,
18807            insights_search_query: String::new(),
18808            insights_search_match_cursor: 0,
18809            insights_panel: InsightsPanel::Bottlenecks,
18810            insights_heatmap: None,
18811            insights_show_explanations: true,
18812            insights_show_calc_proof: false,
18813            detail_dep_cursor: 0,
18814            actionable_plan: None,
18815            actionable_track_cursor: 0,
18816            actionable_item_cursor: 0,
18817            attention_result: None,
18818            attention_cursor: 0,
18819            tree_flat_nodes: Vec::new(),
18820            tree_cursor: 0,
18821            tree_collapsed: std::collections::HashSet::new(),
18822            tree_search_active: false,
18823            tree_search_query: String::new(),
18824            tree_search_match_cursor: 0,
18825            pending_g: false,
18826            g_pre_toggle_mode: None,
18827            pending_z: false,
18828            label_dashboard: None,
18829            label_dashboard_cursor: 0,
18830            flow_matrix: None,
18831            flow_matrix_row_cursor: 0,
18832            flow_matrix_col_cursor: 0,
18833            time_travel_ref_input: String::new(),
18834            time_travel_input_active: false,
18835            time_travel_diff: None,
18836            time_travel_category_cursor: 0,
18837            time_travel_issue_cursor: 0,
18838            time_travel_last_ref: None,
18839            sprint_data: Vec::new(),
18840            sprint_cursor: 0,
18841            sprint_issue_cursor: 0,
18842            modal_label_filter: None,
18843            modal_repo_filter: None,
18844            priority_hints_visible: false,
18845            status_msg: String::new(),
18846            slow_metrics_pending: false,
18847            #[cfg(test)]
18848            key_trace: Vec::new(),
18849        };
18850
18851        app.select_issue_by_id("OPEN-1");
18852        app.update(key(KeyCode::Tab));
18853        assert_eq!(app.focus, FocusPane::Detail);
18854
18855        app.update(key(KeyCode::Char('j')));
18856        assert_eq!(selected_issue_id(&app), "OPEN-2");
18857        assert_eq!(app.focus, FocusPane::Detail);
18858
18859        app.update(key(KeyCode::Char('l')));
18860        assert_eq!(selected_issue_id(&app), "IP-1");
18861
18862        app.update(key(KeyCode::Char('L')));
18863        assert_eq!(selected_issue_id(&app), "CLS-1");
18864
18865        app.update(key(KeyCode::Char('H')));
18866        assert_eq!(selected_issue_id(&app), "OPEN-1");
18867
18868        app.update(key(KeyCode::Char('/')));
18869        assert!(app.board_search_active);
18870        app.update(key(KeyCode::Char('o')));
18871        app.update(key(KeyCode::Char('p')));
18872        app.update(key(KeyCode::Char('e')));
18873        assert_eq!(app.board_search_query, "ope");
18874        assert_eq!(selected_issue_id(&app), "OPEN-1");
18875
18876        app.update(key(KeyCode::Enter));
18877        assert!(!app.board_search_active);
18878
18879        app.update(key(KeyCode::Char('n')));
18880        assert_eq!(selected_issue_id(&app), "OPEN-2");
18881        assert_eq!(app.focus, FocusPane::Detail);
18882    }
18883
18884    #[test]
18885    fn board_detail_scroll_shortcuts_reset_when_selection_changes() {
18886        let mut app = new_app(ViewMode::Board, 0);
18887        app.focus = FocusPane::Detail;
18888
18889        app.update(key_ctrl(KeyCode::Char('j')));
18890        assert_eq!(app.board_detail_scroll_offset, 3);
18891
18892        app.update(key_ctrl(KeyCode::Char('d')));
18893        assert_eq!(app.board_detail_scroll_offset, 13);
18894
18895        app.focus = FocusPane::List;
18896        app.update(key(KeyCode::Char('j')));
18897        assert_eq!(selected_issue_id(&app), "B");
18898        assert_eq!(app.board_detail_scroll_offset, 0);
18899    }
18900
18901    #[test]
18902    fn board_detail_render_state_applies_scroll_offset() {
18903        let mut app = new_app(ViewMode::Board, 1);
18904        app.board_detail_scroll_offset = 4;
18905
18906        let full = app.board_detail_text();
18907        let total_lines = full.lines().count();
18908        let visible_height = 6;
18909        let expected_offset = 4.min(total_lines.saturating_sub(visible_height));
18910        let expected = if expected_offset == 0 {
18911            full
18912        } else {
18913            full.lines()
18914                .skip(expected_offset)
18915                .collect::<Vec<_>>()
18916                .join("\n")
18917        };
18918
18919        let (visible, offset, reported_total) = app.board_detail_render_state(visible_height);
18920        assert_eq!(reported_total, total_lines);
18921        assert_eq!(offset, expected_offset);
18922        assert_eq!(visible, expected);
18923    }
18924
18925    #[test]
18926    fn main_detail_scroll_shortcuts_move_offset() {
18927        let mut app = new_app(ViewMode::Main, 0);
18928        app.focus = FocusPane::Detail;
18929
18930        app.update(key_ctrl(KeyCode::Char('j')));
18931        assert_eq!(app.detail_scroll_offset, 3);
18932
18933        app.update(key_ctrl(KeyCode::Char('d')));
18934        assert_eq!(app.detail_scroll_offset, 13);
18935
18936        app.update(key_ctrl(KeyCode::Char('k')));
18937        assert_eq!(app.detail_scroll_offset, 10);
18938
18939        app.update(key_ctrl(KeyCode::Char('u')));
18940        assert_eq!(app.detail_scroll_offset, 0);
18941    }
18942
18943    #[test]
18944    fn main_detail_scroll_resets_when_selection_changes() {
18945        let mut app = new_app(ViewMode::Main, 0);
18946        app.focus = FocusPane::Detail;
18947
18948        app.update(key_ctrl(KeyCode::Char('j')));
18949        assert_eq!(app.detail_scroll_offset, 3);
18950
18951        app.focus = FocusPane::List;
18952        app.update(key(KeyCode::Char('j')));
18953        assert_eq!(selected_issue_id(&app), "B");
18954        assert_eq!(app.detail_scroll_offset, 0);
18955    }
18956
18957    #[test]
18958    fn main_detail_scroll_ignored_without_detail_focus() {
18959        let mut app = new_app(ViewMode::Main, 0);
18960        assert_eq!(app.focus, FocusPane::List);
18961
18962        app.update(key_ctrl(KeyCode::Char('j')));
18963        assert_eq!(app.detail_scroll_offset, 0);
18964    }
18965
18966    #[test]
18967    fn detail_scroll_works_in_all_modes() {
18968        // Universal detail scroll works in every mode, not just Main
18969        for mode in [
18970            ViewMode::Main,
18971            ViewMode::Graph,
18972            ViewMode::Insights,
18973            ViewMode::History,
18974        ] {
18975            let mut app = new_app(mode, 0);
18976            app.focus = FocusPane::Detail;
18977            app.scroll_detail(5);
18978            assert_eq!(
18979                app.detail_scroll_offset, 5,
18980                "scroll_detail should work in {mode:?}"
18981            );
18982        }
18983    }
18984
18985    #[test]
18986    fn main_footer_shows_scroll_hint_when_detail_focused() {
18987        let mut app = new_app(ViewMode::Main, 0);
18988        app.focus = FocusPane::Detail;
18989
18990        let rendered = render_app(&app, 120, 40);
18991        assert!(
18992            rendered.contains("^j/k"),
18993            "expected scroll hint in footer, got:\n{rendered}"
18994        );
18995    }
18996
18997    #[test]
18998    fn board_mode_g_switches_to_graph_view() {
18999        let mut app = new_app(ViewMode::Board, 0);
19000        app.update(key(KeyCode::Char('g')));
19001        assert!(matches!(app.mode, ViewMode::Graph));
19002        assert_eq!(app.focus, FocusPane::List);
19003    }
19004
19005    #[test]
19006    fn board_status_grouping_places_unknown_status_in_other_lane() {
19007        let mut app = BvrApp {
19008            analyzer: Analyzer::new(board_with_unknown_status_issues()),
19009            repo_root: None,
19010            selected: 0,
19011            list_filter: ListFilter::All,
19012            list_sort: ListSort::Default,
19013            board_grouping: BoardGrouping::Status,
19014            board_empty_visibility: EmptyLaneVisibility::Auto,
19015            mode: ViewMode::Board,
19016            mode_before_history: ViewMode::Main,
19017            mode_back_stack: Vec::new(),
19018            focus: FocusPane::List,
19019            focus_before_help: FocusPane::List,
19020            show_help: false,
19021            help_scroll_offset: 0,
19022            show_quit_confirm: false,
19023            modal_overlay: None,
19024            modal_confirm_result: None,
19025            history_confidence_index: 0,
19026            history_view_mode: HistoryViewMode::Bead,
19027            history_event_cursor: 0,
19028            history_related_bead_cursor: 0,
19029            history_bead_commit_cursor: 0,
19030            history_git_cache: None,
19031            history_search_active: false,
19032            history_search_query: String::new(),
19033            history_search_match_cursor: 0,
19034            history_search_mode: HistorySearchMode::All,
19035            history_show_file_tree: false,
19036            history_file_tree_cursor: 0,
19037            history_file_tree_filter: None,
19038            history_file_tree_focus: false,
19039            history_status_msg: String::new(),
19040            board_search_active: false,
19041            board_search_query: String::new(),
19042            board_search_match_cursor: 0,
19043            board_detail_scroll_offset: 0,
19044            detail_scroll_offset: 0,
19045            main_search_active: false,
19046            main_search_query: String::new(),
19047            main_search_match_cursor: 0,
19048            list_scroll_offset: Cell::new(0),
19049            list_viewport_height: Cell::new(0),
19050            graph_search_active: false,
19051            graph_search_query: String::new(),
19052            graph_search_match_cursor: 0,
19053            insights_search_active: false,
19054            insights_search_query: String::new(),
19055            insights_search_match_cursor: 0,
19056            insights_panel: InsightsPanel::Bottlenecks,
19057            insights_heatmap: None,
19058            insights_show_explanations: true,
19059            insights_show_calc_proof: false,
19060            detail_dep_cursor: 0,
19061            actionable_plan: None,
19062            actionable_track_cursor: 0,
19063            actionable_item_cursor: 0,
19064            attention_result: None,
19065            attention_cursor: 0,
19066            tree_flat_nodes: Vec::new(),
19067            tree_cursor: 0,
19068            tree_collapsed: std::collections::HashSet::new(),
19069            tree_search_active: false,
19070            tree_search_query: String::new(),
19071            tree_search_match_cursor: 0,
19072            pending_g: false,
19073            g_pre_toggle_mode: None,
19074            pending_z: false,
19075            label_dashboard: None,
19076            label_dashboard_cursor: 0,
19077            flow_matrix: None,
19078            flow_matrix_row_cursor: 0,
19079            flow_matrix_col_cursor: 0,
19080            time_travel_ref_input: String::new(),
19081            time_travel_input_active: false,
19082            time_travel_diff: None,
19083            time_travel_category_cursor: 0,
19084            time_travel_issue_cursor: 0,
19085            time_travel_last_ref: None,
19086            sprint_data: Vec::new(),
19087            sprint_cursor: 0,
19088            sprint_issue_cursor: 0,
19089            modal_label_filter: None,
19090            modal_repo_filter: None,
19091            priority_hints_visible: false,
19092            status_msg: String::new(),
19093            slow_metrics_pending: false,
19094            #[cfg(test)]
19095            key_trace: Vec::new(),
19096        };
19097
19098        let list = app.list_panel_text();
19099        assert!(list.contains("other"));
19100        assert!(list.contains("QUE-1"));
19101
19102        // 3-state cycle: Auto → ShowAll → HideEmpty
19103        app.update(key(KeyCode::Char('e')));
19104        assert_eq!(app.board_empty_visibility, EmptyLaneVisibility::ShowAll);
19105        app.update(key(KeyCode::Char('e')));
19106        assert_eq!(app.board_empty_visibility, EmptyLaneVisibility::HideEmpty);
19107        let hidden_empty = app.list_panel_text();
19108        assert!(hidden_empty.contains("open"));
19109        assert!(!hidden_empty.contains("in_progress"));
19110        assert!(!hidden_empty.contains("blocked"));
19111        assert!(!hidden_empty.contains("closed"));
19112        assert!(hidden_empty.contains("other"));
19113    }
19114
19115    #[test]
19116    fn sort_key_cycles_main_order_modes() {
19117        let mut app = BvrApp {
19118            analyzer: Analyzer::new(sortable_issues()),
19119            repo_root: None,
19120            selected: 0,
19121            list_filter: ListFilter::All,
19122            list_sort: ListSort::Default,
19123            board_grouping: BoardGrouping::Status,
19124            board_empty_visibility: EmptyLaneVisibility::Auto,
19125            mode: ViewMode::Main,
19126            mode_before_history: ViewMode::Main,
19127            mode_back_stack: Vec::new(),
19128            focus: FocusPane::List,
19129            focus_before_help: FocusPane::List,
19130            show_help: false,
19131            help_scroll_offset: 0,
19132            show_quit_confirm: false,
19133            modal_overlay: None,
19134            modal_confirm_result: None,
19135            history_confidence_index: 0,
19136            history_view_mode: HistoryViewMode::Bead,
19137            history_event_cursor: 0,
19138            history_related_bead_cursor: 0,
19139            history_bead_commit_cursor: 0,
19140            history_git_cache: None,
19141            history_search_active: false,
19142            history_search_query: String::new(),
19143            history_search_match_cursor: 0,
19144            history_search_mode: HistorySearchMode::All,
19145            history_show_file_tree: false,
19146            history_file_tree_cursor: 0,
19147            history_file_tree_filter: None,
19148            history_file_tree_focus: false,
19149            history_status_msg: String::new(),
19150            board_search_active: false,
19151            board_search_query: String::new(),
19152            board_search_match_cursor: 0,
19153            board_detail_scroll_offset: 0,
19154            detail_scroll_offset: 0,
19155            main_search_active: false,
19156            main_search_query: String::new(),
19157            main_search_match_cursor: 0,
19158            list_scroll_offset: Cell::new(0),
19159            list_viewport_height: Cell::new(0),
19160            graph_search_active: false,
19161            graph_search_query: String::new(),
19162            graph_search_match_cursor: 0,
19163            insights_search_active: false,
19164            insights_search_query: String::new(),
19165            insights_search_match_cursor: 0,
19166            insights_panel: InsightsPanel::Bottlenecks,
19167            insights_heatmap: None,
19168            insights_show_explanations: true,
19169            insights_show_calc_proof: false,
19170            detail_dep_cursor: 0,
19171            actionable_plan: None,
19172            actionable_track_cursor: 0,
19173            actionable_item_cursor: 0,
19174            attention_result: None,
19175            attention_cursor: 0,
19176            tree_flat_nodes: Vec::new(),
19177            tree_cursor: 0,
19178            tree_collapsed: std::collections::HashSet::new(),
19179            tree_search_active: false,
19180            tree_search_query: String::new(),
19181            tree_search_match_cursor: 0,
19182            pending_g: false,
19183            g_pre_toggle_mode: None,
19184            pending_z: false,
19185            label_dashboard: None,
19186            label_dashboard_cursor: 0,
19187            flow_matrix: None,
19188            flow_matrix_row_cursor: 0,
19189            flow_matrix_col_cursor: 0,
19190            time_travel_ref_input: String::new(),
19191            time_travel_input_active: false,
19192            time_travel_diff: None,
19193            time_travel_category_cursor: 0,
19194            time_travel_issue_cursor: 0,
19195            time_travel_last_ref: None,
19196            sprint_data: Vec::new(),
19197            sprint_cursor: 0,
19198            sprint_issue_cursor: 0,
19199            modal_label_filter: None,
19200            modal_repo_filter: None,
19201            priority_hints_visible: false,
19202            status_msg: String::new(),
19203            slow_metrics_pending: false,
19204            #[cfg(test)]
19205            key_trace: Vec::new(),
19206        };
19207
19208        // Default: open-first, then priority asc, then id asc → M(p1), A(p2), Z(p3)
19209        assert_eq!(first_rendered_issue_id(&app), "M");
19210        assert_eq!(app.list_sort, ListSort::Default);
19211
19212        app.update(key(KeyCode::Char('s')));
19213        assert_eq!(app.list_sort, ListSort::CreatedAsc);
19214        assert_eq!(first_rendered_issue_id(&app), "Z");
19215
19216        app.update(key(KeyCode::Char('s')));
19217        assert_eq!(app.list_sort, ListSort::CreatedDesc);
19218        assert_eq!(first_rendered_issue_id(&app), "M");
19219
19220        app.update(key(KeyCode::Char('s')));
19221        assert_eq!(app.list_sort, ListSort::Priority);
19222        assert_eq!(first_rendered_issue_id(&app), "M");
19223
19224        app.update(key(KeyCode::Char('s')));
19225        assert_eq!(app.list_sort, ListSort::Updated);
19226        assert_eq!(first_rendered_issue_id(&app), "Z");
19227
19228        app.update(key(KeyCode::Char('s')));
19229        assert_eq!(app.list_sort, ListSort::PageRank);
19230
19231        app.update(key(KeyCode::Char('s')));
19232        assert_eq!(app.list_sort, ListSort::Blockers);
19233
19234        app.update(key(KeyCode::Char('s')));
19235        assert_eq!(app.list_sort, ListSort::Default);
19236        assert_eq!(first_rendered_issue_id(&app), "M");
19237    }
19238
19239    #[test]
19240    fn default_sort_treats_tombstone_as_closed_like() {
19241        let mut issues = sortable_issues();
19242        issues.push(Issue {
19243            id: "T".to_string(),
19244            title: "Tombstone".to_string(),
19245            status: "tombstone".to_string(),
19246            issue_type: "task".to_string(),
19247            priority: 0,
19248            ..Issue::default()
19249        });
19250
19251        let mut app = new_app(ViewMode::Main, 0);
19252        app.analyzer = Analyzer::new(issues);
19253        app.list_filter = ListFilter::All;
19254        app.list_sort = ListSort::Default;
19255
19256        let visible = app.visible_issue_indices();
19257        assert!(!visible.is_empty());
19258
19259        let first = &app.analyzer.issues[visible[0]].id;
19260        let last = &app.analyzer.issues[*visible.last().unwrap_or(&0)].id;
19261        assert_ne!(first, "T", "tombstone issue should not be sorted as open");
19262        assert_eq!(
19263            last, "T",
19264            "tombstone issue should sort with closed-like items"
19265        );
19266    }
19267
19268    #[test]
19269    fn board_detail_j_k_navigate_deps_when_detail_focused() {
19270        // Issue A (index 0) has no blockers but B depends on it (dependents=["B"]).
19271        let mut app = new_app(ViewMode::Board, 0);
19272        app.mode = ViewMode::Board;
19273        assert_eq!(selected_issue_id(&app), "A");
19274
19275        // Tab to detail focus
19276        app.update(key(KeyCode::Tab));
19277        assert_eq!(app.focus, FocusPane::Detail);
19278        assert_eq!(app.detail_dep_cursor, 0);
19279
19280        // dep list for A = dependents=["B"] => length 1
19281        let deps = app.detail_dep_list();
19282        assert_eq!(deps, vec!["B".to_string()]);
19283
19284        // J should not move past the end
19285        app.update(key(KeyCode::Char('J')));
19286        assert_eq!(app.detail_dep_cursor, 0);
19287
19288        // Detail text should show cursor marker
19289        let detail = app.detail_panel_text();
19290        assert!(detail.contains('>'), "detail should show cursor marker");
19291    }
19292
19293    #[test]
19294    fn graph_detail_j_k_navigate_deps_when_detail_focused() {
19295        // Issue B (index 1) depends on A (blockers=["A"]), no dependents.
19296        let mut app = new_app(ViewMode::Graph, 1);
19297        app.mode = ViewMode::Graph;
19298        assert_eq!(selected_issue_id(&app), "B");
19299
19300        app.update(key(KeyCode::Tab));
19301        assert_eq!(app.focus, FocusPane::Detail);
19302
19303        let deps = app.detail_dep_list();
19304        assert_eq!(deps, vec!["A".to_string()]);
19305
19306        // j in detail focus navigates deps
19307        app.update(key(KeyCode::Char('j')));
19308        assert_eq!(app.detail_dep_cursor, 0);
19309
19310        // Detail text should show cursor
19311        let detail = app.detail_panel_text();
19312        assert!(
19313            detail.contains('>'),
19314            "graph detail should show cursor marker"
19315        );
19316    }
19317
19318    #[test]
19319    fn graph_detail_text_surfaces_focus_context_for_node_and_edge() {
19320        let mut app = new_app(ViewMode::Graph, 1);
19321        app.mode = ViewMode::Graph;
19322
19323        let list_focus = app.detail_panel_text();
19324        assert!(list_focus.contains("Focus: node"));
19325        assert!(list_focus.contains("Focused edge: list focus"));
19326
19327        app.update(key(KeyCode::Tab));
19328        let detail_focus = app.detail_panel_text();
19329        assert!(detail_focus.contains("Focused edge: depends on"));
19330        assert!(detail_focus.contains("B -> A"));
19331    }
19332
19333    #[test]
19334    fn insights_detail_shows_deps_with_cursor_and_j_k_works() {
19335        // Issue B (index 1) depends on A (blockers=["A"]).
19336        let mut app = new_app(ViewMode::Insights, 1);
19337        app.mode = ViewMode::Insights;
19338        assert_eq!(selected_issue_id(&app), "B");
19339
19340        app.update(key(KeyCode::Tab));
19341        assert_eq!(app.focus, FocusPane::Detail);
19342
19343        let detail = app.detail_panel_text();
19344        assert!(
19345            detail.contains("Depends on"),
19346            "insights detail should show dependency section"
19347        );
19348        assert!(detail.contains('>'), "insights detail should show cursor");
19349    }
19350
19351    #[test]
19352    fn detail_dep_cursor_resets_on_selection_change() {
19353        let mut app = new_app(ViewMode::Graph, 0);
19354        app.mode = ViewMode::Graph;
19355        app.detail_dep_cursor = 5;
19356
19357        // Moving selection should reset cursor
19358        app.update(key(KeyCode::Char('j')));
19359        assert_eq!(app.detail_dep_cursor, 0);
19360    }
19361
19362    // -- Breakpoint tests ----------------------------------------------------
19363
19364    #[test]
19365    fn breakpoint_narrow_below_80() {
19366        assert_eq!(Breakpoint::from_width(40), Breakpoint::Narrow);
19367        assert_eq!(Breakpoint::from_width(79), Breakpoint::Narrow);
19368    }
19369
19370    #[test]
19371    fn breakpoint_medium_80_to_119() {
19372        assert_eq!(Breakpoint::from_width(80), Breakpoint::Medium);
19373        assert_eq!(Breakpoint::from_width(100), Breakpoint::Medium);
19374        assert_eq!(Breakpoint::from_width(119), Breakpoint::Medium);
19375    }
19376
19377    #[test]
19378    fn breakpoint_wide_120_plus() {
19379        assert_eq!(Breakpoint::from_width(120), Breakpoint::Wide);
19380        assert_eq!(Breakpoint::from_width(200), Breakpoint::Wide);
19381    }
19382
19383    #[test]
19384    fn breakpoint_list_detail_pct_sums_to_100() {
19385        for bp in [Breakpoint::Narrow, Breakpoint::Medium, Breakpoint::Wide] {
19386            let sum = bp.list_pct() + bp.detail_pct();
19387            assert!(
19388                (sum - 100.0).abs() < f32::EPSILON,
19389                "{bp:?} pcts sum to {sum}"
19390            );
19391        }
19392    }
19393
19394    #[test]
19395    fn breakpoint_narrow_gives_smaller_list() {
19396        assert!(Breakpoint::Narrow.list_pct() < Breakpoint::Medium.list_pct());
19397    }
19398
19399    #[test]
19400    fn breakpoint_wide_gives_larger_detail() {
19401        assert!(Breakpoint::Wide.detail_pct() > Breakpoint::Medium.detail_pct());
19402    }
19403
19404    #[test]
19405    fn history_layout_breakpoints_match_legacy() {
19406        assert_eq!(HistoryLayout::from_width(99), HistoryLayout::Narrow);
19407        assert_eq!(HistoryLayout::from_width(100), HistoryLayout::Standard);
19408        assert_eq!(HistoryLayout::from_width(149), HistoryLayout::Standard);
19409        assert_eq!(HistoryLayout::from_width(150), HistoryLayout::Wide);
19410    }
19411
19412    #[test]
19413    fn pane_split_two_pane_adjustment_persists_and_clamps() {
19414        let mut app = new_app(ViewMode::Main, 0);
19415        let _ = render_app(&app, 100, 24);
19416        assert_eq!(
19417            super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19418            42.0
19419        );
19420
19421        app.update(Msg::KeyPress(KeyCode::Right, Modifiers::CTRL));
19422        assert_eq!(
19423            super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19424            46.0
19425        );
19426
19427        app.mode = ViewMode::Board;
19428        let _ = render_app(&app, 100, 24);
19429        assert_eq!(
19430            super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19431            46.0
19432        );
19433
19434        for _ in 0..20 {
19435            app.update(Msg::KeyPress(KeyCode::Left, Modifiers::CTRL));
19436        }
19437        assert_eq!(
19438            super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19439            25.0
19440        );
19441    }
19442
19443    #[test]
19444    fn pane_split_history_wide_bead_adjustment_preserves_minimums() {
19445        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
19446        app.focus = FocusPane::Middle;
19447        let _ = render_app(&app, 160, 24);
19448
19449        app.update(Msg::KeyPress(KeyCode::Right, Modifiers::CTRL));
19450        let split = super::pane_split_state();
19451        assert_eq!(split.history_wide_bead[1], 22.0);
19452        assert_eq!(split.history_wide_bead[2], 29.0);
19453        assert_eq!(split.history_wide_bead[3], 29.0);
19454
19455        for _ in 0..20 {
19456            app.update(Msg::KeyPress(KeyCode::Right, Modifiers::CTRL));
19457        }
19458        let clamped = super::pane_split_state().history_wide_bead;
19459        assert!(clamped[2] >= 15.0);
19460        assert!(clamped[3] >= 15.0);
19461        assert!((clamped.iter().sum::<f32>() - 100.0).abs() < f32::EPSILON);
19462    }
19463
19464    #[test]
19465    fn pane_split_reset_shortcut_restores_defaults() {
19466        let mut app = new_app(ViewMode::Main, 0);
19467        app.reset_pane_split_state();
19468        let _ = render_app(&app, 100, 24);
19469        app.update(Msg::KeyPress(KeyCode::Right, Modifiers::CTRL));
19470        assert_eq!(
19471            super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19472            46.0
19473        );
19474
19475        app.update(Msg::KeyPress(KeyCode::Char('0'), Modifiers::CTRL));
19476        assert_eq!(
19477            super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19478            42.0
19479        );
19480        assert_eq!(app.status_msg, "Pane splits reset");
19481    }
19482
19483    #[test]
19484    fn mouse_scroll_over_splitter_adjusts_two_pane_ratio() {
19485        let mut app = new_app(ViewMode::Main, 0);
19486        app.reset_pane_split_state();
19487        let _ = render_app(&app, 100, 24);
19488        let first_hit_box = super::splitter_hit_boxes(&app, 100, 24)
19489            .into_iter()
19490            .next()
19491            .expect("main view should expose a two-pane splitter");
19492
19493        app.update(mouse(
19494            MouseEventKind::ScrollUp,
19495            first_hit_box.rect.x,
19496            first_hit_box.rect.y.saturating_add(1),
19497        ));
19498        assert_eq!(
19499            super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19500            46.0
19501        );
19502
19503        let second_hit_box = super::splitter_hit_boxes(&app, 100, 24)
19504            .into_iter()
19505            .next()
19506            .expect("main view should still expose a splitter after resize");
19507
19508        app.update(mouse(
19509            MouseEventKind::ScrollDown,
19510            second_hit_box.rect.x,
19511            second_hit_box.rect.y.saturating_add(1),
19512        ));
19513        assert_eq!(
19514            super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19515            42.0
19516        );
19517    }
19518
19519    #[test]
19520    fn mouse_click_on_splitter_nudges_ratio_and_updates_focus() {
19521        let mut app = new_app(ViewMode::Main, 0);
19522        app.reset_pane_split_state();
19523        let _ = render_app(&app, 100, 24);
19524        let hit_box = super::splitter_hit_boxes(&app, 100, 24)
19525            .into_iter()
19526            .next()
19527            .expect("main view should expose a two-pane splitter");
19528        let right_edge = hit_box
19529            .rect
19530            .x
19531            .saturating_add(hit_box.rect.width.saturating_sub(1));
19532
19533        app.update(mouse(
19534            MouseEventKind::Down(MouseButton::Left),
19535            right_edge,
19536            hit_box.rect.y.saturating_add(1),
19537        ));
19538        assert_eq!(app.focus, FocusPane::Detail);
19539        assert_eq!(
19540            super::pane_split_state().two_pane_list_pct(Breakpoint::Medium),
19541            38.0
19542        );
19543    }
19544
19545    // -- Visual token tests --------------------------------------------------
19546
19547    #[test]
19548    fn token_status_colours_are_distinct() {
19549        let open = tokens::status_fg("open");
19550        let prog = tokens::status_fg("in_progress");
19551        let blk = tokens::status_fg("blocked");
19552        let cls = tokens::status_fg("closed");
19553        assert_ne!(open, prog);
19554        assert_ne!(open, blk);
19555        assert_ne!(open, cls);
19556        assert_ne!(prog, blk);
19557        assert_ne!(prog, cls);
19558        assert_ne!(blk, cls);
19559    }
19560
19561    #[test]
19562    fn token_priority_colours_descend_urgency() {
19563        // P0 (error red) must be different from P3/P4 (dim/muted)
19564        assert_ne!(tokens::priority_fg(0), tokens::priority_fg(3));
19565        assert_ne!(tokens::priority_fg(0), tokens::priority_fg(4));
19566    }
19567
19568    #[test]
19569    fn token_status_style_returns_correct_fg() {
19570        let open_style = tokens::status_style("open");
19571        assert_eq!(open_style.fg, Some(tokens::status_fg("open")));
19572
19573        let closed_style = tokens::status_style("closed");
19574        assert_eq!(closed_style.fg, Some(tokens::status_fg("closed")));
19575
19576        let unknown = tokens::status_style("whatever");
19577        assert_eq!(unknown.fg, Some(tokens::FG_DIM));
19578    }
19579
19580    #[test]
19581    fn token_header_is_bold() {
19582        let h = tokens::header();
19583        assert!(h.attrs.is_some_and(|a| a.contains(ftui::StyleFlags::BOLD)));
19584    }
19585
19586    #[test]
19587    fn token_selected_has_highlight_bg() {
19588        let s = tokens::selected();
19589        assert_eq!(s.bg, Some(tokens::BG_HIGHLIGHT));
19590    }
19591
19592    #[test]
19593    fn token_chip_style_has_semantic_background() {
19594        let style = tokens::chip_style(SemanticTone::Warning);
19595        assert_eq!(style.fg, Some(tokens::FG_WARNING));
19596        assert_eq!(style.bg, Some(tokens::BG_SURFACE_WARNING));
19597    }
19598
19599    #[test]
19600    fn token_focused_border_differs_from_unfocused() {
19601        let focused = tokens::panel_border_for(SemanticTone::Accent, true);
19602        let unfocused = tokens::panel_border_for(SemanticTone::Accent, false);
19603        assert_ne!(focused.fg, unfocused.fg);
19604    }
19605
19606    // -- Snapshot structural tests -------------------------------------------
19607    // These validate that the text content and panel structure are correct at
19608    // each breakpoint, using the text-generation methods directly. This avoids
19609    // needing full ftui Frame construction in tests.
19610
19611    use super::Breakpoint;
19612    use super::tokens;
19613
19614    /// Build the header string the same way `view()` does for a given width.
19615    fn header_for_width(app: &BvrApp, width: u16) -> String {
19616        build_header_text(app, width).lines()[0].to_plain_text()
19617    }
19618
19619    #[test]
19620    fn snapshot_narrow_header_is_compact() {
19621        let app = new_app(ViewMode::Main, 0);
19622        let h = header_for_width(&app, 60);
19623        assert!(h.contains("bvr"), "header should contain 'bvr'");
19624        assert!(
19625            h.contains("1 Main"),
19626            "narrow header should show the main tab"
19627        );
19628        assert!(
19629            !h.contains("Esc back/quit"),
19630            "narrow header should remain compact"
19631        );
19632    }
19633
19634    #[test]
19635    fn snapshot_medium_header_is_full() {
19636        let app = new_app(ViewMode::Main, 0);
19637        let h = header_for_width(&app, 100);
19638        assert!(h.contains("b Board"), "medium header should show board tab");
19639        assert!(
19640            h.contains("i Insights"),
19641            "medium header should show insights tab"
19642        );
19643        assert!(h.contains("mode=Main"), "medium header should show mode=");
19644        assert!(h.contains("focus=list"), "medium header should show focus=");
19645        assert!(
19646            h.contains("issues=3/3"),
19647            "medium header should show issues metric"
19648        );
19649    }
19650
19651    #[test]
19652    fn snapshot_wide_header_is_full() {
19653        let app = new_app(ViewMode::Main, 0);
19654        let h = header_for_width(&app, 140);
19655        assert!(
19656            h.contains("[ Labels"),
19657            "wide header should expose secondary tabs"
19658        );
19659        assert!(h.contains("] Flow"), "wide header should expose flow tab");
19660        assert!(
19661            h.contains("sort=default"),
19662            "wide header should show sort metric"
19663        );
19664        assert!(
19665            h.ends_with(" |"),
19666            "wide header should preserve trailing delimiter"
19667        );
19668    }
19669
19670    #[test]
19671    fn snapshot_narrow_header_keeps_active_non_primary_mode_visible() {
19672        let app = new_app(ViewMode::Sprint, 0);
19673        let h = header_for_width(&app, 60);
19674        assert!(
19675            h.contains("S Sprint"),
19676            "narrow header should keep active tab visible"
19677        );
19678    }
19679
19680    #[test]
19681    fn help_overlay_mentions_splitter_resize_controls() {
19682        let app = new_app(ViewMode::Main, 0);
19683        let help = app.help_overlay_text(120);
19684        assert!(help.contains("Ctrl+\u{2190}/\u{2192}"));
19685        assert!(help.contains("Ctrl+0"));
19686        assert!(help.contains("splitter click/scroll"));
19687    }
19688
19689    #[test]
19690    fn header_shows_metrics_pending_chip() {
19691        let mut app = new_app(ViewMode::Main, 0);
19692        app.slow_metrics_pending = true;
19693        let h = header_for_width(&app, 120);
19694        assert!(
19695            h.contains("metrics: computing..."),
19696            "header should surface pending metrics chip: {h}"
19697        );
19698    }
19699
19700    #[test]
19701    fn snapshot_list_panel_content_consistent_across_breakpoints() {
19702        let app = new_app(ViewMode::Main, 0);
19703        // list_panel_text() is breakpoint-independent (content stays same)
19704        let text = app.list_panel_text();
19705        assert!(text.contains("Root"), "list should contain issue title");
19706        assert!(text.contains('A'), "list should contain issue ID");
19707    }
19708
19709    #[test]
19710    fn main_list_render_text_uses_rich_issue_scan_rows() {
19711        let app = new_app(ViewMode::Main, 1);
19712        let text = app.main_list_render_text(120).to_plain_text();
19713        assert!(text.contains('▸'), "selected row marker missing: {text}");
19714        assert!(text.contains("P0"), "priority badge text missing: {text}");
19715        assert!(text.contains("#01"), "triage rank missing: {text}");
19716        assert!(text.contains("OPEN"), "status badge text missing: {text}");
19717        assert!(text.contains("⊘1"), "blocker indicator missing: {text}");
19718        assert!(text.contains("B"), "selected issue id missing: {text}");
19719        assert!(
19720            text.contains("pr#2"),
19721            "pagerank rank signal missing: {text}"
19722        );
19723    }
19724
19725    #[test]
19726    fn main_list_render_text_adapts_rows_to_narrow_width() {
19727        let app = new_app(ViewMode::Main, 1);
19728        let text = app.main_list_render_text(48).to_plain_text();
19729        assert!(text.contains("▸"), "selected row marker missing: {text}");
19730        assert!(text.contains("blocked"), "state chip missing: {text}");
19731        assert!(text.contains("Dependent"), "title missing: {text}");
19732        assert!(
19733            !text.contains("repo:"),
19734            "narrow variant should drop repo metadata: {text}"
19735        );
19736    }
19737
19738    #[test]
19739    fn main_list_empty_state_is_recovery_oriented() {
19740        let mut app = new_app(ViewMode::Main, 0);
19741        app.modal_repo_filter = Some("missing".to_string());
19742
19743        let text = app.main_list_render_text(90).to_plain_text();
19744        assert!(text.contains("No issues in the current triage slice"));
19745        assert!(
19746            text.contains("repo=missing"),
19747            "scope should mention repo: {text}"
19748        );
19749        assert!(text.contains("Recover:"), "recovery hint missing: {text}");
19750    }
19751
19752    #[test]
19753    fn main_list_search_no_hits_keeps_guidance_visible() {
19754        let mut app = new_app(ViewMode::Main, 0);
19755        app.main_search_query = "zzz".to_string();
19756
19757        let text = app.main_list_render_text(90).to_plain_text();
19758        assert!(text.contains("Matches: none in visible issues"));
19759        assert!(
19760            text.contains("refine /query"),
19761            "search guidance missing: {text}"
19762        );
19763    }
19764
19765    #[test]
19766    fn graph_list_render_text_uses_metric_strips_and_header() {
19767        let app = new_app(ViewMode::Graph, 0);
19768        let text = app.graph_list_render_text(90).to_plain_text();
19769        assert!(text.contains("Nodes"), "graph header missing: {text}");
19770        assert!(text.contains("PR "), "metric strip label missing: {text}");
19771        assert!(
19772            text.contains("↓") || text.contains("⊘"),
19773            "blocker indicators missing: {text}"
19774        );
19775    }
19776
19777    #[test]
19778    fn snapshot_detail_panel_content_consistent_across_breakpoints() {
19779        let app = new_app(ViewMode::Main, 0);
19780        let text = app.detail_panel_text();
19781        assert!(!text.is_empty(), "detail should have content");
19782    }
19783
19784    #[test]
19785    fn main_detail_includes_rich_sections() {
19786        let app = new_app(ViewMode::Main, 0);
19787        let text = app.detail_panel_text();
19788
19789        assert!(text.contains("Triage Snapshot:"));
19790        assert!(text.contains("Graph Signals:"));
19791        assert!(text.contains("Design Notes:"));
19792        assert!(text.contains("Recent Comments (2):"));
19793        assert!(text.contains("History Summary"));
19794    }
19795
19796    #[test]
19797    fn main_detail_render_text_uses_label_chips() {
19798        let app = new_app(ViewMode::Main, 0);
19799        let text = app.issue_detail_render_text().to_plain_text();
19800        assert!(text.contains("[core]"), "label chip missing: {text}");
19801        assert!(
19802            text.contains("[parity]"),
19803            "second label chip missing: {text}"
19804        );
19805    }
19806
19807    #[test]
19808    fn main_detail_render_text_uses_modular_cockpit_sections() {
19809        let app = new_app(ViewMode::Main, 0);
19810        let text = app.issue_detail_render_text().to_plain_text();
19811        assert!(text.contains("Summary"), "summary module missing: {text}");
19812        assert!(text.contains("Signals"), "signals module missing: {text}");
19813        assert!(
19814            text.contains("Dependencies"),
19815            "dependencies module missing: {text}"
19816        );
19817        assert!(
19818            text.contains("Action:"),
19819            "action-first summary line missing: {text}"
19820        );
19821    }
19822
19823    #[test]
19824    fn main_detail_marks_blocked_issue_and_dependency_map() {
19825        let app = new_app(ViewMode::Main, 1);
19826        let text = app.detail_panel_text();
19827
19828        assert!(text.contains("State: blocked"));
19829        assert!(text.contains("Dependency Map:"));
19830        assert!(text.contains("upstream: A"));
19831        assert!(text.contains("open gate: A"));
19832    }
19833
19834    #[test]
19835    fn snapshot_board_mode_list_mentions_grouping() {
19836        let app = new_app(ViewMode::Board, 0);
19837        let text = app.list_panel_text();
19838        assert!(
19839            text.contains("Grouping"),
19840            "board list should mention grouping"
19841        );
19842    }
19843
19844    #[test]
19845    fn snapshot_each_view_mode_has_content() {
19846        for mode in [
19847            ViewMode::Main,
19848            ViewMode::Board,
19849            ViewMode::Insights,
19850            ViewMode::Graph,
19851            ViewMode::History,
19852        ] {
19853            let app = new_app(mode, 0);
19854            let list = app.list_panel_text();
19855            let detail = app.detail_panel_text();
19856            // Neither should be empty
19857            assert!(!list.is_empty(), "{mode:?} list panel should not be empty");
19858            assert!(
19859                !detail.is_empty(),
19860                "{mode:?} detail panel should not be empty"
19861            );
19862        }
19863    }
19864
19865    #[test]
19866    fn snapshot_deterministic_text_across_calls() {
19867        let app = new_app(ViewMode::Main, 0);
19868        let list1 = app.list_panel_text();
19869        let list2 = app.list_panel_text();
19870        assert_eq!(list1, list2, "list text should be deterministic");
19871        let detail1 = app.detail_panel_text();
19872        let detail2 = app.detail_panel_text();
19873        assert_eq!(detail1, detail2, "detail text should be deterministic");
19874    }
19875
19876    // -- Help overlay key parity tests ---------------------------------------
19877
19878    #[test]
19879    fn help_g_scrolls_to_top() {
19880        let mut app = new_app(ViewMode::Main, 0);
19881        app.show_help = true;
19882        app.help_scroll_offset = 50;
19883
19884        app.update(key(KeyCode::Char('g')));
19885        assert_eq!(app.help_scroll_offset, 0);
19886        assert!(app.show_help, "g should scroll not close");
19887    }
19888
19889    #[test]
19890    fn help_big_g_scrolls_to_bottom() {
19891        let mut app = new_app(ViewMode::Main, 0);
19892        app.show_help = true;
19893        app.help_scroll_offset = 0;
19894
19895        app.update(key(KeyCode::Char('G')));
19896        assert_eq!(app.help_scroll_offset, 999);
19897        assert!(app.show_help, "G should scroll not close");
19898    }
19899
19900    #[test]
19901    fn help_home_scrolls_to_top() {
19902        let mut app = new_app(ViewMode::Main, 0);
19903        app.show_help = true;
19904        app.help_scroll_offset = 30;
19905
19906        app.update(key(KeyCode::Home));
19907        assert_eq!(app.help_scroll_offset, 0);
19908        assert!(app.show_help);
19909    }
19910
19911    #[test]
19912    fn help_end_scrolls_to_bottom() {
19913        let mut app = new_app(ViewMode::Main, 0);
19914        app.show_help = true;
19915        app.help_scroll_offset = 0;
19916
19917        app.update(key(KeyCode::End));
19918        assert_eq!(app.help_scroll_offset, 999);
19919        assert!(app.show_help);
19920    }
19921
19922    #[test]
19923    fn help_q_closes_help() {
19924        let mut app = new_app(ViewMode::Main, 0);
19925        app.show_help = true;
19926        app.help_scroll_offset = 10;
19927        app.focus_before_help = FocusPane::List;
19928
19929        app.update(key(KeyCode::Char('q')));
19930        assert!(!app.show_help, "q should close help");
19931        assert_eq!(app.help_scroll_offset, 0, "scroll should reset on close");
19932    }
19933
19934    #[test]
19935    fn help_f1_closes_help() {
19936        let mut app = new_app(ViewMode::Main, 0);
19937        app.show_help = true;
19938
19939        app.update(key(KeyCode::F(1)));
19940        assert!(!app.show_help, "F1 should close help");
19941    }
19942
19943    // -- Key trace tests -----------------------------------------------------
19944
19945    #[test]
19946    fn key_trace_records_state_transitions() {
19947        let mut app = new_app(ViewMode::Main, 0);
19948        assert!(app.key_trace.is_empty());
19949
19950        app.update(key(KeyCode::Char('j')));
19951        app.update(key(KeyCode::Char('b')));
19952        app.update(key(KeyCode::Char('j')));
19953
19954        assert_eq!(app.key_trace.len(), 3);
19955
19956        // First key: j in Main mode, moves selection to 1
19957        assert_eq!(app.key_trace[0].key, "Char('j')");
19958        assert_eq!(app.key_trace[0].mode, ViewMode::Main);
19959
19960        // Second key: b switches to Board mode
19961        assert_eq!(app.key_trace[1].mode, ViewMode::Board);
19962
19963        // Third key: j in Board mode
19964        assert_eq!(app.key_trace[2].mode, ViewMode::Board);
19965    }
19966
19967    #[test]
19968    fn key_trace_captures_filter_changes() {
19969        let mut app = new_app(ViewMode::Main, 0);
19970
19971        app.update(key(KeyCode::Char('o')));
19972        assert_eq!(app.key_trace.last().unwrap().filter, ListFilter::Open);
19973
19974        app.update(key(KeyCode::Char('a')));
19975        assert_eq!(app.key_trace.last().unwrap().filter, ListFilter::All);
19976    }
19977
19978    #[test]
19979    fn actionable_text_uses_legacy_summary_shape() {
19980        let mut app = new_app(ViewMode::Main, 0);
19981        app.update(key(KeyCode::Char('a')));
19982
19983        let list = app.list_panel_text();
19984        assert!(list.contains("ACTIONABLE ITEMS"));
19985        assert!(list.contains("TRACK 1"));
19986        assert!(list.contains("RECOMMENDED:"));
19987
19988        app.update(key(KeyCode::Tab));
19989        let detail = app.detail_panel_text();
19990        assert!(detail.contains("TRACK 1"));
19991        assert!(detail.contains("Claim:"));
19992        assert!(detail.contains("Highest impact:"));
19993    }
19994
19995    #[test]
19996    fn empty_lane_visibility_3state_cycle() {
19997        let v = EmptyLaneVisibility::Auto;
19998        assert_eq!(v.next(), EmptyLaneVisibility::ShowAll);
19999        assert_eq!(v.next().next(), EmptyLaneVisibility::HideEmpty);
20000        assert_eq!(v.next().next().next(), EmptyLaneVisibility::Auto);
20001    }
20002
20003    #[test]
20004    fn empty_lane_auto_shows_for_status_hides_for_others() {
20005        let auto = EmptyLaneVisibility::Auto;
20006        assert!(auto.should_show_empty(BoardGrouping::Status));
20007        assert!(!auto.should_show_empty(BoardGrouping::Priority));
20008        assert!(!auto.should_show_empty(BoardGrouping::Type));
20009    }
20010
20011    #[test]
20012    fn help_overlay_text_switches_column_layout_at_width_thresholds() {
20013        let app = new_app(ViewMode::Main, 0);
20014
20015        let narrow = app.help_overlay_text(70);
20016        let medium = app.help_overlay_text(80);
20017        let wide = app.help_overlay_text(120);
20018
20019        assert!(!narrow.lines().any(|line| line.contains(" | ")));
20020        assert!(medium.lines().any(|line| line.matches(" | ").count() == 1));
20021        assert!(wide.lines().any(|line| line.matches(" | ").count() >= 2));
20022
20023        for section in [
20024            "[Navigation]",
20025            "[Views]",
20026            "[Filters]",
20027            "[Search]",
20028            "[Actions]",
20029            "[History]",
20030            "[Board]",
20031            "[Insights]",
20032            "[Global]",
20033        ] {
20034            assert!(wide.contains(section), "wide help should include {section}");
20035        }
20036    }
20037
20038    #[test]
20039    fn help_overlay_text_keeps_critical_shortcuts_visible_in_compact_mode() {
20040        let app = new_app(ViewMode::Main, 0);
20041        let text = app.help_overlay_text(70);
20042
20043        for snippet in [
20044            "Toggle actionable mode",
20045            "Filter: open only",
20046            "Toggle file tree",
20047            "Ctrl+R/F5",
20048            "Quit immediately",
20049        ] {
20050            assert!(
20051                text.contains(snippet),
20052                "compact help should include {snippet}"
20053            );
20054        }
20055    }
20056
20057    // -- History parity tests ------------------------------------------------
20058
20059    #[test]
20060    fn history_file_tree_builds_nested_directories_and_root_files() {
20061        let app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20062        let nodes = app.history_file_tree_nodes();
20063
20064        assert_eq!(nodes.first().map(|node| node.name.as_str()), Some("src"));
20065        assert_eq!(
20066            nodes
20067                .iter()
20068                .filter(|node| !node.is_dir)
20069                .map(|node| node.name.as_str())
20070                .collect::<Vec<_>>(),
20071            vec!["Cargo.toml", "README.md"]
20072        );
20073
20074        let src = nodes
20075            .iter()
20076            .find(|node| node.path == "src")
20077            .expect("src root node should exist");
20078        assert_eq!(src.change_count, 3);
20079        assert_eq!(
20080            src.children
20081                .iter()
20082                .map(|node| node.name.as_str())
20083                .collect::<Vec<_>>(),
20084            vec!["core", "ui"]
20085        );
20086
20087        let ui = src
20088            .children
20089            .iter()
20090            .find(|node| node.path == "src/ui")
20091            .expect("src/ui directory should exist");
20092        assert_eq!(ui.change_count, 2);
20093        assert_eq!(
20094            ui.children
20095                .iter()
20096                .map(|node| node.name.as_str())
20097                .collect::<Vec<_>>(),
20098            vec!["app.rs", "detail.rs"]
20099        );
20100    }
20101
20102    #[test]
20103    fn history_file_tree_filter_applies_to_git_view_and_panel_text() {
20104        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20105        app.history_show_file_tree = true;
20106        app.history_file_tree_focus = true;
20107        app.history_file_tree_cursor = app
20108            .history_flat_file_list()
20109            .iter()
20110            .position(|entry| entry.path == "src/ui")
20111            .expect("src/ui entry should exist");
20112
20113        app.file_tree_toggle_or_filter();
20114
20115        assert_eq!(app.history_file_tree_filter.as_deref(), Some("src/ui"));
20116        assert_eq!(app.history_git_visible_commit_indices(), vec![0]);
20117
20118        let panel = app.file_tree_panel_text();
20119        assert!(panel.contains("Filter: src/ui"));
20120        assert!(panel.contains("src/"));
20121        assert!(panel.contains("ui/"));
20122        assert!(panel.contains("app.rs"));
20123    }
20124
20125    #[test]
20126    fn history_file_tree_filter_repositions_hidden_bead_selection() {
20127        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 1);
20128        app.history_show_file_tree = true;
20129        app.history_file_tree_cursor = app
20130            .history_flat_file_list()
20131            .iter()
20132            .position(|entry| entry.path == "src/ui")
20133            .expect("src/ui entry should exist");
20134
20135        app.file_tree_toggle_or_filter();
20136
20137        assert_eq!(selected_issue_id(&app), "A");
20138        assert_eq!(app.history_visible_issue_indices(), vec![0]);
20139        assert_eq!(
20140            app.selected_history_bead_commit()
20141                .map(|commit| commit.short_sha),
20142            Some("aaaa111".to_string())
20143        );
20144    }
20145
20146    #[test]
20147    fn history_file_tree_respects_confidence_threshold() {
20148        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20149        app.history_confidence_index = 3;
20150
20151        let paths = app
20152            .history_flat_file_list()
20153            .into_iter()
20154            .filter(|entry| !entry.is_dir)
20155            .map(|entry| entry.path)
20156            .collect::<Vec<_>>();
20157
20158        assert!(paths.contains(&"src/ui/app.rs".to_string()));
20159        assert!(paths.contains(&"src/ui/detail.rs".to_string()));
20160        assert!(paths.contains(&"README.md".to_string()));
20161        assert!(!paths.contains(&"src/core/graph.rs".to_string()));
20162        assert!(!paths.contains(&"Cargo.toml".to_string()));
20163    }
20164
20165    #[test]
20166    fn history_escape_closes_file_tree_before_leaving_history() {
20167        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20168        app.mode = ViewMode::History;
20169        app.mode_before_history = ViewMode::Main;
20170        app.history_show_file_tree = true;
20171        app.history_file_tree_focus = true;
20172
20173        app.update(key(KeyCode::Escape));
20174        assert!(matches!(app.mode, ViewMode::History));
20175        assert!(!app.history_show_file_tree);
20176        assert!(!app.history_file_tree_focus);
20177
20178        app.update(key(KeyCode::Escape));
20179        assert!(matches!(app.mode, ViewMode::Main));
20180    }
20181
20182    #[test]
20183    fn history_f_toggles_file_tree() {
20184        let mut app = new_app(ViewMode::History, 0);
20185        app.mode = ViewMode::History;
20186        assert!(!app.history_show_file_tree);
20187
20188        app.update(key(KeyCode::Char('f')));
20189        assert!(app.history_show_file_tree);
20190        assert!(!app.history_status_msg.is_empty());
20191
20192        app.update(key(KeyCode::Char('f')));
20193        assert!(!app.history_show_file_tree);
20194    }
20195
20196    #[test]
20197    fn history_tab_cycles_file_tree_focus() {
20198        let mut app = new_app(ViewMode::History, 0);
20199        app.mode = ViewMode::History;
20200        app.history_show_file_tree = true;
20201        app.focus = FocusPane::List;
20202
20203        // List → FileTree (no middle pane at default width)
20204        app.update(key(KeyCode::Tab));
20205        assert!(app.history_file_tree_focus);
20206        assert_eq!(app.focus, FocusPane::List);
20207
20208        // FileTree → List
20209        app.update(key(KeyCode::Tab));
20210        assert!(!app.history_file_tree_focus);
20211        assert_eq!(app.focus, FocusPane::List);
20212    }
20213
20214    #[test]
20215    fn history_standard_tab_cycles_list_middle_detail() {
20216        let mut app = new_app(ViewMode::History, 0);
20217        app.mode = ViewMode::History;
20218        record_view_size(120, 30);
20219
20220        assert_eq!(app.focus, FocusPane::List);
20221        app.update(key(KeyCode::Tab));
20222        assert_eq!(app.focus, FocusPane::Middle);
20223        app.update(key(KeyCode::Tab));
20224        assert_eq!(app.focus, FocusPane::Detail);
20225        app.update(key(KeyCode::Tab));
20226        assert_eq!(app.focus, FocusPane::List);
20227    }
20228
20229    #[test]
20230    fn history_standard_file_tree_cycle_includes_middle_before_detail() {
20231        let mut app = new_app(ViewMode::History, 0);
20232        app.mode = ViewMode::History;
20233        record_view_size(120, 30);
20234        app.history_show_file_tree = true;
20235        app.focus = FocusPane::List;
20236
20237        app.update(key(KeyCode::Tab));
20238        assert_eq!(app.focus, FocusPane::Middle);
20239        assert!(!app.history_file_tree_focus);
20240
20241        app.update(key(KeyCode::Tab));
20242        assert_eq!(app.focus, FocusPane::Detail);
20243        assert!(!app.history_file_tree_focus);
20244
20245        app.update(key(KeyCode::Tab));
20246        assert!(app.history_file_tree_focus);
20247        assert_eq!(app.focus, FocusPane::Detail);
20248
20249        app.update(key(KeyCode::Tab));
20250        assert!(!app.history_file_tree_focus);
20251        assert_eq!(app.focus, FocusPane::List);
20252    }
20253
20254    #[test]
20255    fn history_middle_navigation_moves_bead_commit_cursor() {
20256        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20257        app.mode = ViewMode::History;
20258        record_view_size(120, 30);
20259        app.focus = FocusPane::Middle;
20260
20261        app.update(key(KeyCode::Char('j')));
20262        assert_eq!(app.history_bead_commit_cursor, 1);
20263        app.update(key(KeyCode::Char('k')));
20264        assert_eq!(app.history_bead_commit_cursor, 0);
20265    }
20266
20267    #[test]
20268    fn history_middle_navigation_moves_git_related_bead_cursor() {
20269        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20270        app.mode = ViewMode::History;
20271        record_view_size(120, 30);
20272        app.focus = FocusPane::Middle;
20273        if let Some(cache) = app.history_git_cache.as_mut() {
20274            cache.commit_bead_confidence.insert(
20275                "aaaa1111".to_string(),
20276                vec![("A".to_string(), 0.95), ("B".to_string(), 0.91)],
20277            );
20278        }
20279
20280        app.update(key(KeyCode::Char('j')));
20281        assert_eq!(app.history_related_bead_cursor, 1);
20282        app.update(key(KeyCode::Char('k')));
20283        assert_eq!(app.history_related_bead_cursor, 0);
20284    }
20285
20286    #[test]
20287    fn history_standard_bead_renders_three_legacy_panes() {
20288        let app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20289        let text = render_app(&app, 120, 30);
20290        assert!(text.contains("Beads With History [focus]"));
20291        assert!(text.contains("Commits"));
20292        assert!(text.contains("Commit Details"));
20293    }
20294
20295    #[test]
20296    fn history_standard_git_renders_three_legacy_panes() {
20297        let app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20298        let text = render_app(&app, 120, 30);
20299        assert!(text.contains("Commits [focus]"));
20300        assert!(text.contains("Related Beads"));
20301        assert!(text.contains("Commit Details"));
20302    }
20303
20304    #[test]
20305    fn history_wide_bead_renders_timeline_pane() {
20306        let app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20307        let text = render_app(&app, 160, 30);
20308        assert!(text.contains("Beads With History [focus]"));
20309        assert!(text.contains("Timeline: A"));
20310        assert!(text.contains("Commits"));
20311        assert!(text.contains("Commit Details"));
20312    }
20313
20314    #[test]
20315    fn history_timeline_text_uses_legacy_summary_when_git_history_exists() {
20316        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20317        {
20318            let history = app
20319                .history_git_cache
20320                .as_mut()
20321                .and_then(|cache| cache.histories.get_mut("A"))
20322                .expect("history A present");
20323            history.milestones.created = Some(HistoryEventCompat {
20324                bead_id: "A".to_string(),
20325                event_type: "created".to_string(),
20326                timestamp: "2026-01-01T00:00:00Z".to_string(),
20327                commit_sha: String::new(),
20328                commit_message: String::new(),
20329                author: "Alice".to_string(),
20330                author_email: "alice@example.com".to_string(),
20331            });
20332            history.milestones.closed = Some(HistoryEventCompat {
20333                bead_id: "A".to_string(),
20334                event_type: "closed".to_string(),
20335                timestamp: "2026-01-04T00:00:00Z".to_string(),
20336                commit_sha: String::new(),
20337                commit_message: String::new(),
20338                author: "Alice".to_string(),
20339                author_email: "alice@example.com".to_string(),
20340            });
20341            history.cycle_time = Some(HistoryCycleCompat {
20342                claim_to_close: Some("2d 0h 0m".to_string()),
20343                create_to_close: Some("3d 0h 0m".to_string()),
20344                create_to_claim: Some("1d 0h 0m".to_string()),
20345            });
20346        }
20347
20348        let text = app.history_timeline_text(48, 12);
20349        assert!(text.contains("Timeline: A"));
20350        assert!(text.contains("3d cycle"));
20351        assert!(text.contains("Cycle: 3d"));
20352        assert!(text.contains("Avg confidence"));
20353    }
20354
20355    #[test]
20356    fn history_timeline_text_renders_legacy_event_and_commit_rows() {
20357        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20358        {
20359            let history = app
20360                .history_git_cache
20361                .as_mut()
20362                .and_then(|cache| cache.histories.get_mut("A"))
20363                .expect("history A present");
20364            history.milestones.created = Some(HistoryEventCompat {
20365                bead_id: "A".to_string(),
20366                event_type: "created".to_string(),
20367                timestamp: "2026-01-01T00:00:00Z".to_string(),
20368                commit_sha: String::new(),
20369                commit_message: String::new(),
20370                author: "Alice".to_string(),
20371                author_email: "alice@example.com".to_string(),
20372            });
20373            history.milestones.claimed = Some(HistoryEventCompat {
20374                bead_id: "A".to_string(),
20375                event_type: "claimed".to_string(),
20376                timestamp: "2026-01-02T00:00:00Z".to_string(),
20377                commit_sha: String::new(),
20378                commit_message: String::new(),
20379                author: "Bob Builder".to_string(),
20380                author_email: "bob@example.com".to_string(),
20381            });
20382            history.milestones.closed = Some(HistoryEventCompat {
20383                bead_id: "A".to_string(),
20384                event_type: "closed".to_string(),
20385                timestamp: "2026-01-04T00:00:00Z".to_string(),
20386                commit_sha: String::new(),
20387                commit_message: String::new(),
20388                author: "Carol".to_string(),
20389                author_email: "carol@example.com".to_string(),
20390            });
20391            history.commits = Some(vec![
20392                HistoryCommitCompat {
20393                    timestamp: "2026-01-03T00:00:00Z".to_string(),
20394                    ..history_commit("aaaa1111", "feat: ui wiring", 0.95, &["src/ui/app.rs"])
20395                },
20396                HistoryCommitCompat {
20397                    timestamp: "2026-01-03T12:00:00Z".to_string(),
20398                    ..history_commit("bbbb2222", "feat: graph core", 0.80, &["src/core/graph.rs"])
20399                },
20400            ]);
20401        }
20402
20403        let text = app.history_timeline_text(60, 14);
20404        assert!(text.contains("○ Created"));
20405        assert!(text.contains("● Claimed"));
20406        assert!(text.contains("✓ Closed"));
20407        assert!(text.contains("├─ aaaa111"));
20408        assert!(text.contains("feat: ui wiring"));
20409    }
20410
20411    #[test]
20412    fn history_detail_shows_yof_hints() {
20413        let app = new_app(ViewMode::History, 0);
20414        let detail = app.detail_panel_text();
20415        assert!(
20416            detail.contains("y: copy") || detail.contains("y:"),
20417            "history detail should mention y key"
20418        );
20419        assert!(
20420            detail.contains("f: file") || detail.contains("f:"),
20421            "history detail should mention f key"
20422        );
20423    }
20424
20425    #[test]
20426    fn history_footer_shows_key_hints() {
20427        // Verify the footer format string contains expected keys
20428        let app = new_app(ViewMode::History, 0);
20429        // The footer text is rendered in view(), but we can check the mode label
20430        assert_eq!(app.history_view_mode.label(), "bead");
20431    }
20432
20433    #[test]
20434    fn history_search_mode_defaults_to_all() {
20435        let app = new_app(ViewMode::History, 0);
20436        assert_eq!(app.history_search_mode, HistorySearchMode::All);
20437    }
20438
20439    #[test]
20440    fn history_search_mode_cycles_with_tab() {
20441        let mut app = new_app(ViewMode::History, 0);
20442        // Start search
20443        app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20444        assert!(app.history_search_active);
20445        assert_eq!(app.history_search_mode, HistorySearchMode::All);
20446
20447        // Tab cycles: All -> Commit
20448        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20449        assert_eq!(app.history_search_mode, HistorySearchMode::Commit);
20450
20451        // Commit -> Sha
20452        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20453        assert_eq!(app.history_search_mode, HistorySearchMode::Sha);
20454
20455        // Sha -> Bead
20456        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20457        assert_eq!(app.history_search_mode, HistorySearchMode::Bead);
20458
20459        // Bead -> Author
20460        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20461        assert_eq!(app.history_search_mode, HistorySearchMode::Author);
20462
20463        // Author -> All (wraps)
20464        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20465        assert_eq!(app.history_search_mode, HistorySearchMode::All);
20466    }
20467
20468    #[test]
20469    fn history_search_mode_resets_on_new_search() {
20470        let mut app = new_app(ViewMode::History, 0);
20471        // Start search and change mode
20472        app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20473        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20474        assert_eq!(app.history_search_mode, HistorySearchMode::Commit);
20475
20476        // Confirm search
20477        app.handle_key(KeyCode::Enter, Modifiers::NONE);
20478        assert!(!app.history_search_active);
20479        // Mode is preserved after confirm
20480        assert_eq!(app.history_search_mode, HistorySearchMode::Commit);
20481
20482        // Starting a new search resets to All
20483        app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20484        assert_eq!(app.history_search_mode, HistorySearchMode::All);
20485    }
20486
20487    #[test]
20488    fn history_search_mode_label_shown_in_list_text() {
20489        let mut app = new_app(ViewMode::History, 0);
20490        app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20491        app.handle_key(KeyCode::Char('t'), Modifiers::NONE);
20492
20493        let text = app.history_list_text();
20494        assert!(
20495            text.contains("[all]"),
20496            "list text should show search mode label, got: {text}"
20497        );
20498
20499        // Switch to bead mode
20500        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20501        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20502        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20503        let text = app.history_list_text();
20504        assert!(
20505            text.contains("[bead]"),
20506            "list text should show bead mode label, got: {text}"
20507        );
20508    }
20509
20510    #[test]
20511    fn history_search_mode_bead_filters_by_id_and_title_only() {
20512        let mut app = new_app(ViewMode::History, 0);
20513        // Start search in bead mode
20514        app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20515        // Cycle to Bead mode: All -> Commit -> Sha -> Bead
20516        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20517        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20518        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20519        assert_eq!(app.history_search_mode, HistorySearchMode::Bead);
20520
20521        // Search for "Root" — sample issue A has title "Root"
20522        for ch in "root".chars() {
20523            app.handle_key(KeyCode::Char(ch), Modifiers::NONE);
20524        }
20525
20526        let visible = app.history_visible_issue_indices();
20527        // Only issue A (title "Root") should match in bead mode
20528        assert!(
20529            !visible.is_empty(),
20530            "bead mode search for 'root' should match issue A by title"
20531        );
20532
20533        // Searching for "open" (status) should NOT match in bead mode
20534        app.handle_key(KeyCode::Escape, Modifiers::NONE);
20535        app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20536        // Cycle back to Bead mode
20537        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20538        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20539        app.handle_key(KeyCode::Tab, Modifiers::NONE);
20540        for ch in "open".chars() {
20541            app.handle_key(KeyCode::Char(ch), Modifiers::NONE);
20542        }
20543        let visible_status = app.history_visible_issue_indices();
20544
20545        // Now switch to All mode and search "open" — should match
20546        app.handle_key(KeyCode::Escape, Modifiers::NONE);
20547        app.handle_key(KeyCode::Char('/'), Modifiers::NONE);
20548        // All mode is default after starting new search
20549        assert_eq!(app.history_search_mode, HistorySearchMode::All);
20550        for ch in "open".chars() {
20551            app.handle_key(KeyCode::Char(ch), Modifiers::NONE);
20552        }
20553        let visible_all = app.history_visible_issue_indices();
20554
20555        // In All mode, "open" matches status so should find more results than Bead mode
20556        assert!(
20557            visible_all.len() >= visible_status.len(),
20558            "All mode should match at least as many as Bead mode"
20559        );
20560    }
20561
20562    #[test]
20563    fn history_git_search_modes_filter_commit_sha_and_author_fields() {
20564        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20565        if let Some(cache) = app.history_git_cache.as_mut() {
20566            cache.commits[0].author = "Alice Example".to_string();
20567            cache.commits[0].author_email = "alice@example.com".to_string();
20568            cache.commits[1].author = "Carol Example".to_string();
20569            cache.commits[1].author_email = "carol@example.com".to_string();
20570            cache.commits[2].author = "Bob Example".to_string();
20571            cache.commits[2].author_email = "bob@example.com".to_string();
20572            cache.commits[3].author = "Bob Example".to_string();
20573            cache.commits[3].author_email = "bob@example.com".to_string();
20574        }
20575
20576        app.history_search_mode = HistorySearchMode::Commit;
20577        app.history_search_query = "graph".to_string();
20578        assert_eq!(app.history_search_matches(), vec![1]);
20579
20580        app.history_search_mode = HistorySearchMode::Sha;
20581        app.history_search_query = "cccc".to_string();
20582        assert_eq!(app.history_search_matches(), vec![2]);
20583
20584        app.history_search_mode = HistorySearchMode::Author;
20585        app.history_search_query = "bob".to_string();
20586        assert_eq!(app.history_search_matches(), vec![2, 3]);
20587    }
20588
20589    #[test]
20590    fn history_git_search_matches_respect_file_tree_filter() {
20591        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20592        app.history_search_mode = HistorySearchMode::Commit;
20593        app.history_search_query = "feat".to_string();
20594
20595        assert_eq!(app.history_search_matches(), vec![0, 1]);
20596
20597        app.history_file_tree_filter = Some("src/ui".to_string());
20598        assert_eq!(app.history_search_matches(), vec![0]);
20599    }
20600
20601    #[test]
20602    fn history_search_mode_enum_coverage() {
20603        // Verify all mode labels
20604        assert_eq!(HistorySearchMode::All.label(), "all");
20605        assert_eq!(HistorySearchMode::Commit.label(), "msg");
20606        assert_eq!(HistorySearchMode::Sha.label(), "sha");
20607        assert_eq!(HistorySearchMode::Bead.label(), "bead");
20608        assert_eq!(HistorySearchMode::Author.label(), "author");
20609
20610        // Verify cycle is complete (5 steps back to start)
20611        let mut mode = HistorySearchMode::All;
20612        for _ in 0..5 {
20613            mode = mode.cycle();
20614        }
20615        assert_eq!(mode, HistorySearchMode::All);
20616    }
20617
20618    #[test]
20619    fn history_view_mode_indicator_matches_legacy_icons() {
20620        assert_eq!(HistoryViewMode::Bead.indicator(), "◈ Beads");
20621        assert_eq!(HistoryViewMode::Git.indicator(), "◉ Git");
20622    }
20623
20624    #[test]
20625    fn history_status_line_shows_legacy_mode_indicator() {
20626        let bead_view =
20627            render_debug_view(sample_issues(), "history", 100, 30).expect("history view renders");
20628        assert!(bead_view.contains("mode=History ◈ Beads"));
20629
20630        let mut app = new_app(ViewMode::History, 0);
20631        app.handle_key(KeyCode::Char('v'), Modifiers::NONE);
20632        let mut pool = ftui::GraphemePool::default();
20633        let mut frame = ftui::render::frame::Frame::new(100, 30, &mut pool);
20634        app.view(&mut frame);
20635        let git_view = buffer_to_text(&frame.buffer, &pool);
20636        assert!(git_view.contains("mode=History ◉ Git"));
20637    }
20638
20639    #[test]
20640    fn compact_history_duration_label_prefers_first_nonzero_unit() {
20641        assert_eq!(compact_history_duration_label("3d 0h 0m"), "3d");
20642        assert_eq!(compact_history_duration_label("0d 5h 0m"), "5h");
20643        assert_eq!(compact_history_duration_label("0d 0h 15m"), "15m");
20644    }
20645
20646    #[test]
20647    fn legacy_history_author_initials_match_go_contract() {
20648        assert_eq!(legacy_history_author_initials(""), "??");
20649        assert_eq!(legacy_history_author_initials("alice"), "AL");
20650        assert_eq!(legacy_history_author_initials("Alice Baker"), "AB");
20651        assert_eq!(legacy_history_author_initials("Alice Beth Carter"), "AC");
20652    }
20653
20654    #[test]
20655    fn history_compact_timeline_matches_legacy_marker_contract() {
20656        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20657        let history = {
20658            let history = app
20659                .history_git_cache
20660                .as_mut()
20661                .and_then(|cache| cache.histories.get_mut("A"))
20662                .expect("history A present");
20663
20664            history.milestones.created = Some(HistoryEventCompat {
20665                bead_id: "A".to_string(),
20666                event_type: "created".to_string(),
20667                timestamp: "2026-01-01T00:00:00Z".to_string(),
20668                commit_sha: String::new(),
20669                commit_message: String::new(),
20670                author: "Alice".to_string(),
20671                author_email: "alice@example.com".to_string(),
20672            });
20673            history.milestones.claimed = Some(HistoryEventCompat {
20674                bead_id: "A".to_string(),
20675                event_type: "claimed".to_string(),
20676                timestamp: "2026-01-02T00:00:00Z".to_string(),
20677                commit_sha: String::new(),
20678                commit_message: String::new(),
20679                author: "Alice".to_string(),
20680                author_email: "alice@example.com".to_string(),
20681            });
20682            history.milestones.closed = Some(HistoryEventCompat {
20683                bead_id: "A".to_string(),
20684                event_type: "closed".to_string(),
20685                timestamp: "2026-01-04T00:00:00Z".to_string(),
20686                commit_sha: String::new(),
20687                commit_message: String::new(),
20688                author: "Alice".to_string(),
20689                author_email: "alice@example.com".to_string(),
20690            });
20691            history.cycle_time = Some(HistoryCycleCompat {
20692                claim_to_close: Some("2d 0h 0m".to_string()),
20693                create_to_close: Some("3d 0h 0m".to_string()),
20694                create_to_claim: Some("1d 0h 0m".to_string()),
20695            });
20696            history.clone()
20697        };
20698
20699        let text = app.history_compact_timeline_text(&history, 80);
20700        assert!(text.contains("○"));
20701        assert!(text.contains("●"));
20702        assert!(text.contains("├"));
20703        assert!(text.contains("✓"));
20704        assert!(text.contains("3d cycle"));
20705        assert!(text.contains("2 commits"));
20706    }
20707
20708    #[test]
20709    fn history_compact_timeline_collapses_many_commit_markers() {
20710        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20711        let history = {
20712            let history = app
20713                .history_git_cache
20714                .as_mut()
20715                .and_then(|cache| cache.histories.get_mut("A"))
20716                .expect("history A present");
20717
20718            history.milestones.created = Some(HistoryEventCompat {
20719                bead_id: "A".to_string(),
20720                event_type: "created".to_string(),
20721                timestamp: "2026-01-01T00:00:00Z".to_string(),
20722                commit_sha: String::new(),
20723                commit_message: String::new(),
20724                author: "Alice".to_string(),
20725                author_email: "alice@example.com".to_string(),
20726            });
20727            history.commits = Some(vec![
20728                history_commit("a1", "feat: one", 0.90, &["src/a.rs"]),
20729                history_commit("a2", "feat: two", 0.90, &["src/b.rs"]),
20730                history_commit("a3", "feat: three", 0.90, &["src/c.rs"]),
20731                history_commit("a4", "feat: four", 0.90, &["src/d.rs"]),
20732                history_commit("a5", "feat: five", 0.90, &["src/e.rs"]),
20733                history_commit("a6", "feat: six", 0.90, &["src/f.rs"]),
20734                history_commit("a7", "feat: seven", 0.90, &["src/g.rs"]),
20735            ]);
20736            history.clone()
20737        };
20738
20739        let text = app.history_compact_timeline_text(&history, 80);
20740        assert!(text.contains("…"));
20741        assert!(text.contains("7 commits"));
20742    }
20743
20744    #[test]
20745    fn history_detail_surfaces_compact_timeline_when_git_history_exists() {
20746        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
20747        {
20748            let history = app
20749                .history_git_cache
20750                .as_mut()
20751                .and_then(|cache| cache.histories.get_mut("A"))
20752                .expect("history A present");
20753
20754            history.milestones.created = Some(HistoryEventCompat {
20755                bead_id: "A".to_string(),
20756                event_type: "created".to_string(),
20757                timestamp: "2026-01-01T00:00:00Z".to_string(),
20758                commit_sha: String::new(),
20759                commit_message: String::new(),
20760                author: "Alice".to_string(),
20761                author_email: "alice@example.com".to_string(),
20762            });
20763            history.milestones.closed = Some(HistoryEventCompat {
20764                bead_id: "A".to_string(),
20765                event_type: "closed".to_string(),
20766                timestamp: "2026-01-04T00:00:00Z".to_string(),
20767                commit_sha: String::new(),
20768                commit_message: String::new(),
20769                author: "Alice".to_string(),
20770                author_email: "alice@example.com".to_string(),
20771            });
20772            history.cycle_time = Some(HistoryCycleCompat {
20773                claim_to_close: Some("2d 0h 0m".to_string()),
20774                create_to_close: Some("3d 0h 0m".to_string()),
20775                create_to_claim: Some("1d 0h 0m".to_string()),
20776            });
20777        }
20778
20779        let text = app.history_detail_text();
20780        assert!(text.contains("Timeline:"));
20781        assert!(text.contains("3d cycle"));
20782    }
20783
20784    #[test]
20785    fn truncate_display_preserves_grapheme_clusters_and_cell_width() {
20786        let text = "Ame\u{301}lie 👩‍💻";
20787        let truncated = truncate_display(text, 6);
20788        assert_eq!(display_width(&truncated), 6);
20789        assert!(truncated.ends_with('…'));
20790        assert!(
20791            truncated.contains("e\u{301}"),
20792            "combining grapheme should stay intact"
20793        );
20794    }
20795
20796    #[test]
20797    fn fit_display_pads_to_visual_width_for_wide_graphemes() {
20798        let fitted = fit_display("界", 4);
20799        assert_eq!(display_width(&fitted), 4);
20800        assert_eq!(fitted, "界  ");
20801    }
20802
20803    #[test]
20804    fn truncate_display_handles_cjk_double_width() {
20805        // CJK characters are 2 cells wide
20806        let text = "日本語テスト";
20807        assert_eq!(display_width(text), 12);
20808
20809        let truncated = truncate_display(text, 7);
20810        // Should truncate to fit within 7 cells (3 CJK chars = 6 + ellipsis = 7)
20811        assert!(display_width(&truncated) <= 7);
20812        assert!(truncated.ends_with('…'));
20813    }
20814
20815    #[test]
20816    fn truncate_display_handles_emoji_sequences() {
20817        // Family emoji (ZWJ sequence) should be treated as single grapheme
20818        let text = "👨‍👩‍👧‍👦 family";
20819        let truncated = truncate_display(text, 5);
20820        assert!(display_width(&truncated) <= 5);
20821    }
20822
20823    #[test]
20824    fn truncate_display_ascii_within_limit_is_unchanged() {
20825        let text = "hello";
20826        assert_eq!(truncate_display(text, 10), "hello");
20827        assert_eq!(truncate_display(text, 5), "hello");
20828    }
20829
20830    #[test]
20831    fn truncate_display_empty_string() {
20832        assert_eq!(truncate_display("", 10), "");
20833        assert_eq!(truncate_display("", 0), "");
20834    }
20835
20836    #[test]
20837    fn truncate_display_zero_width() {
20838        assert_eq!(truncate_display("hello", 0), "");
20839    }
20840
20841    #[test]
20842    fn truncate_display_width_one() {
20843        let truncated = truncate_display("hello", 1);
20844        assert_eq!(display_width(&truncated), 1);
20845    }
20846
20847    #[test]
20848    fn fit_display_cjk_truncation_and_padding() {
20849        // "世界" is 4 cells wide; fitting to 6 should pad 2 spaces
20850        let fitted = fit_display("世界", 6);
20851        assert_eq!(display_width(&fitted), 6);
20852        assert!(fitted.starts_with("世界"));
20853
20854        // Fitting to 3 should truncate (can't fit 2-wide char + ellipsis in 3)
20855        let fitted_narrow = fit_display("世界你好", 5);
20856        assert_eq!(display_width(&fitted_narrow), 5);
20857    }
20858
20859    #[test]
20860    fn center_display_with_cjk() {
20861        let centered = center_display("界", 6);
20862        assert_eq!(display_width(&centered), 6);
20863        // "界" is 2 cells, so 4 cells of padding: 2 left + 2 right
20864        assert!(centered.contains("界"));
20865    }
20866
20867    #[test]
20868    fn command_hint_width_with_unicode_keys() {
20869        let hint = CommandHint {
20870            key: "⌘",
20871            desc: "cmd",
20872        };
20873        let width = command_hint_width(hint);
20874        assert_eq!(width, display_width("⌘") + 1 + display_width("cmd"));
20875    }
20876
20877    #[test]
20878    fn wrap_command_hints_preserves_groups_and_styles() {
20879        let hints = [
20880            CommandHint {
20881                key: "Tab",
20882                desc: "mode",
20883            },
20884            CommandHint {
20885                key: "/",
20886                desc: "search",
20887            },
20888            CommandHint {
20889                key: "O",
20890                desc: "edit",
20891            },
20892        ];
20893
20894        let wrapped = wrap_command_hints(&hints, 18);
20895        assert_eq!(wrapped.lines().len(), 2);
20896        assert_eq!(wrapped.lines()[0].to_plain_text(), "Tab mode");
20897        assert_eq!(wrapped.lines()[1].to_plain_text(), "/ search │ O edit");
20898
20899        let first_line = wrapped.lines()[0].spans();
20900        let second_line = wrapped.lines()[1].spans();
20901        assert_eq!(first_line[0].style, Some(tokens::footer_key()));
20902        assert_eq!(first_line[2].style, Some(tokens::footer_hint()));
20903        assert_eq!(second_line[3].style, Some(tokens::footer_sep()));
20904    }
20905
20906    #[test]
20907    fn main_footer_command_hints_wrap_across_multiple_lines() {
20908        let hints = [
20909            CommandHint {
20910                key: "b/i/g/h",
20911                desc: "modes",
20912            },
20913            CommandHint {
20914                key: "/",
20915                desc: "search",
20916            },
20917            CommandHint {
20918                key: "s",
20919                desc: "sort",
20920            },
20921            CommandHint {
20922                key: "p",
20923                desc: "hints",
20924            },
20925            CommandHint {
20926                key: "C",
20927                desc: "copy",
20928            },
20929            CommandHint {
20930                key: "x",
20931                desc: "export",
20932            },
20933            CommandHint {
20934                key: "O",
20935                desc: "edit",
20936            },
20937        ];
20938
20939        let wrapped = wrap_command_hints(&hints, 20);
20940        let plain_lines = wrapped
20941            .lines()
20942            .iter()
20943            .map(ftui::text::Line::to_plain_text)
20944            .collect::<Vec<_>>();
20945
20946        assert_eq!(
20947            plain_lines,
20948            vec![
20949                "b/i/g/h modes".to_string(),
20950                "/ search │ s sort".to_string(),
20951                "p hints │ C copy".to_string(),
20952                "x export │ O edit".to_string(),
20953            ]
20954        );
20955    }
20956
20957    #[test]
20958    fn styled_detail_summary_line_turns_status_into_chips() {
20959        let line =
20960            styled_detail_summary_line("Status: open | Priority: p1 | Type: bug | State: ready")
20961                .expect("styled summary line");
20962        assert_eq!(
20963            line.to_plain_text(),
20964            "Status: open | Priority: p1 | Type: bug | State: ready"
20965        );
20966        let spans = line.spans();
20967        assert_eq!(
20968            spans[2].style,
20969            Some(tokens::chip_style(SemanticTone::Accent))
20970        );
20971        assert_eq!(
20972            spans[6].style,
20973            Some(tokens::chip_style(SemanticTone::Warning))
20974        );
20975        assert_eq!(
20976            spans[14].style,
20977            Some(tokens::chip_style(SemanticTone::Success))
20978        );
20979    }
20980
20981    #[test]
20982    fn history_detail_renders_hyperlink_for_selected_commit() {
20983        let repo = init_temp_repo_with_remote("git@github.com:owner/repo.git");
20984        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
20985        app.repo_root = Some(repo.path().to_path_buf());
20986
20987        let urls = rendered_link_urls(&app, 120, 40);
20988        assert!(
20989            urls.iter()
20990                .any(|url| url == "https://github.com/owner/repo/commit/aaaa1111"),
20991            "expected commit hyperlink to be rendered, got {urls:?}"
20992        );
20993
20994        let rendered = app.history_detail_render_text().to_plain_text();
20995        assert!(
20996            rendered.contains("open selected commit (o open, right-click copy link)"),
20997            "expected inline open hint for commit hyperlink, got:\n{rendered}"
20998        );
20999    }
21000
21001    #[test]
21002    fn history_footer_shows_commit_actions_when_selected_commit_url_is_available() {
21003        let repo = init_temp_repo_with_remote("git@github.com:owner/repo.git");
21004        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21005        app.repo_root = Some(repo.path().to_path_buf());
21006
21007        let rendered = render_app(&app, 120, 40);
21008        assert!(
21009            rendered.contains("y copy"),
21010            "expected history footer to advertise copy action, got:\n{rendered}"
21011        );
21012        assert!(
21013            rendered.contains("o open commit"),
21014            "expected history footer to advertise open action when a commit URL exists, got:\n{rendered}"
21015        );
21016    }
21017
21018    #[test]
21019    fn history_footer_hides_open_commit_hint_without_selected_commit_url() {
21020        // Point repo_root at a non-git directory so history_selected_commit_url()
21021        // returns None (otherwise the test process's cwd falls back to the real
21022        // project git repo and a URL is always available).
21023        let no_git_dir = tempfile::tempdir().expect("tempdir");
21024        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21025        app.repo_root = Some(no_git_dir.path().to_path_buf());
21026
21027        let rendered = render_app(&app, 120, 40);
21028        assert!(
21029            rendered.contains("y copy"),
21030            "expected history footer to keep the copy action, got:\n{rendered}"
21031        );
21032        assert!(
21033            !rendered.contains("o open commit"),
21034            "expected history footer to hide open action without a commit URL, got:\n{rendered}"
21035        );
21036    }
21037
21038    #[test]
21039    fn history_footer_switches_to_file_tree_controls_when_tree_has_focus() {
21040        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21041        app.history_show_file_tree = true;
21042        app.history_file_tree_focus = true;
21043
21044        let rendered = render_app(&app, 120, 40);
21045        assert!(
21046            rendered.contains("j/k tree"),
21047            "expected history footer to advertise file-tree navigation, got:\n{rendered}"
21048        );
21049        assert!(
21050            rendered.contains("Enter filter"),
21051            "expected history footer to advertise file-tree filtering, got:\n{rendered}"
21052        );
21053        assert!(
21054            rendered.contains("Esc close tree"),
21055            "expected history footer to advertise closing the file tree, got:\n{rendered}"
21056        );
21057        assert!(
21058            !rendered.contains("o open commit"),
21059            "expected generic commit action hints to be hidden while file tree owns focus, got:\n{rendered}"
21060        );
21061    }
21062
21063    #[test]
21064    fn history_file_tree_focus_blocks_view_toggle_shortcuts() {
21065        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21066        app.history_show_file_tree = true;
21067        app.history_file_tree_focus = true;
21068
21069        app.update(key(KeyCode::Char('v')));
21070        assert_eq!(app.history_view_mode, HistoryViewMode::Git);
21071
21072        app.update(key(KeyCode::Char('h')));
21073        assert_eq!(app.mode, ViewMode::History);
21074        assert!(app.history_file_tree_focus);
21075    }
21076
21077    #[test]
21078    fn history_file_tree_focus_blocks_copy_and_open_shortcuts() {
21079        let repo = init_temp_repo_with_remote("git@github.com:owner/repo.git");
21080        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21081        app.repo_root = Some(repo.path().to_path_buf());
21082        app.history_show_file_tree = true;
21083        app.history_file_tree_focus = true;
21084        app.history_status_msg = "File tree: j/k navigate, Enter filter, Esc close".into();
21085
21086        app.update(key(KeyCode::Char('y')));
21087        assert_eq!(
21088            app.history_status_msg,
21089            "File tree: j/k navigate, Enter filter, Esc close"
21090        );
21091
21092        app.update(key(KeyCode::Char('o')));
21093        assert_eq!(
21094            app.history_status_msg,
21095            "File tree: j/k navigate, Enter filter, Esc close"
21096        );
21097    }
21098
21099    #[test]
21100    fn history_detail_hides_open_hint_without_selected_commit_url() {
21101        let no_git_dir = tempfile::tempdir().expect("tempdir");
21102        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21103        app.repo_root = Some(no_git_dir.path().to_path_buf());
21104
21105        let rendered = app.history_detail_render_text().to_plain_text();
21106        // When there is no commit URL, the detail should NOT contain the browser
21107        // link affordance (which is only added when a URL is available).
21108        assert!(
21109            !rendered.contains("open selected commit (o open, right-click copy link)"),
21110            "expected history detail to omit the browser link affordance without a commit URL, got:\n{rendered}"
21111        );
21112        assert!(
21113            !rendered.contains("Browser Link:"),
21114            "expected no browser link line without a commit URL, got:\n{rendered}"
21115        );
21116    }
21117
21118    #[test]
21119    fn history_selected_commit_url_tracks_selected_bead_commit_cursor() {
21120        let repo = init_temp_repo_with_remote("git@github.com:owner/repo.git");
21121        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
21122        app.repo_root = Some(repo.path().to_path_buf());
21123        app.history_bead_commit_cursor = 1;
21124
21125        let url = app
21126            .history_selected_commit_url()
21127            .expect("selected bead commit URL");
21128        assert!(
21129            url.ends_with("/bbbb2222"),
21130            "expected selected cursor to drive bead commit URL, got {url}"
21131        );
21132    }
21133
21134    #[test]
21135    fn history_bead_middle_enter_backtraces_to_git_commit() {
21136        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
21137        app.mode = ViewMode::History;
21138        app.focus = FocusPane::Middle;
21139        app.history_bead_commit_cursor = 1;
21140
21141        let cmd = app.update(key(KeyCode::Enter));
21142
21143        assert!(matches!(cmd, Cmd::None));
21144        assert_eq!(app.mode, ViewMode::History);
21145        assert_eq!(app.history_view_mode, HistoryViewMode::Git);
21146        assert_eq!(app.focus, FocusPane::List);
21147        assert_eq!(app.history_event_cursor, 1);
21148        assert!(
21149            app.history_status_msg
21150                .contains("Backtraced to commit bbbb222")
21151        );
21152    }
21153
21154    #[test]
21155    fn history_bead_detail_shows_field_level_diff_lines() {
21156        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
21157        app.mode = ViewMode::History;
21158        if let Some(cache) = app.history_git_cache.as_mut()
21159            && let Some(history) = cache.histories.get_mut("A")
21160            && let Some(commit) = history
21161                .commits
21162                .as_mut()
21163                .and_then(|commits| commits.get_mut(0))
21164        {
21165            commit.field_changes = vec![FieldChange {
21166                field: "status".to_string(),
21167                old_value: "open".to_string(),
21168                new_value: "blocked".to_string(),
21169            }];
21170            commit.bead_diff_lines = vec![
21171                "- status: open".to_string(),
21172                "+ status: blocked".to_string(),
21173            ];
21174        }
21175
21176        let detail = app.history_detail_text();
21177        assert!(detail.contains("Fields changed: status"), "got:\n{detail}");
21178        assert!(detail.contains("- status: open"), "got:\n{detail}");
21179        assert!(detail.contains("+ status: blocked"), "got:\n{detail}");
21180    }
21181
21182    #[test]
21183    fn history_git_detail_shows_selected_related_bead_change_summary() {
21184        let mut app = history_app_with_git_cache(HistoryViewMode::Git, 0);
21185        app.mode = ViewMode::History;
21186        if let Some(cache) = app.history_git_cache.as_mut()
21187            && let Some(history) = cache.histories.get_mut("A")
21188            && let Some(commit) = history
21189                .commits
21190                .as_mut()
21191                .and_then(|commits| commits.get_mut(0))
21192        {
21193            commit.field_changes = vec![FieldChange {
21194                field: "labels".to_string(),
21195                old_value: "backend".to_string(),
21196                new_value: "backend,urgent".to_string(),
21197            }];
21198            commit.bead_diff_lines = vec![
21199                "- labels: backend".to_string(),
21200                "+ labels: backend,urgent".to_string(),
21201            ];
21202        }
21203
21204        let detail = app.history_detail_text();
21205        assert!(
21206            detail.contains("SELECTED BEAD CHANGE (A):"),
21207            "got:\n{detail}"
21208        );
21209        assert!(detail.contains("Fields: labels"), "got:\n{detail}");
21210        assert!(
21211            detail.contains("+ labels: backend,urgent"),
21212            "got:\n{detail}"
21213        );
21214    }
21215
21216    #[test]
21217    fn main_detail_renders_hyperlink_for_external_issue_reference() {
21218        let mut app = new_app(ViewMode::Main, 0);
21219        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21220
21221        let urls = rendered_link_urls(&app, 120, 40);
21222        assert!(
21223            urls.iter()
21224                .any(|url| url == "https://github.com/org/repo/issues/42"),
21225            "expected external issue hyperlink to be rendered, got {urls:?}"
21226        );
21227
21228        let rendered = render_app(&app, 120, 40);
21229        assert!(
21230            rendered.contains("(o open, y copy)"),
21231            "expected inline external-ref action hint, got:\n{rendered}"
21232        );
21233        assert!(
21234            rendered.contains("(C copy id)"),
21235            "expected issue-id action hint, got:\n{rendered}"
21236        );
21237        assert!(
21238            rendered.contains("(w repo filter)"),
21239            "expected repo-filter action hint, got:\n{rendered}"
21240        );
21241        assert!(
21242            rendered.contains("(L label filter)"),
21243            "expected label-filter action hint, got:\n{rendered}"
21244        );
21245        assert!(
21246            rendered.contains("(t time-travel)"),
21247            "expected time-travel action hint, got:\n{rendered}"
21248        );
21249    }
21250
21251    #[test]
21252    fn graph_detail_renders_hyperlink_for_external_issue_reference() {
21253        let mut app = new_app(ViewMode::Graph, 0);
21254        app.focus = FocusPane::Detail;
21255        for issue in &mut app.analyzer.issues {
21256            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21257        }
21258
21259        let detail = app.graph_detail_render_text();
21260        let urls = detail
21261            .lines()
21262            .iter()
21263            .flat_map(ftui::text::Line::spans)
21264            .filter_map(|span| span.link.as_deref())
21265            .collect::<Vec<_>>();
21266        assert!(
21267            urls.iter()
21268                .any(|url| *url == "https://github.com/org/repo/issues/42"),
21269            "expected graph detail hyperlink span to be rendered, got {urls:?}"
21270        );
21271
21272        let rendered = detail.to_plain_text();
21273        assert!(
21274            rendered.contains("(o open, y copy)"),
21275            "expected graph detail inline external-ref action hint, got:\n{rendered}"
21276        );
21277    }
21278
21279    #[test]
21280    fn main_detail_open_shortcut_only_activates_for_detail_focus_with_http_ref() {
21281        let mut app = new_app(ViewMode::Main, 0);
21282        assert!(!app.should_open_selected_issue_external_ref());
21283
21284        app.focus = FocusPane::Detail;
21285        assert!(!app.should_open_selected_issue_external_ref());
21286
21287        app.analyzer.issues[0].external_ref = Some("mailto:test@example.com".into());
21288        assert!(!app.should_open_selected_issue_external_ref());
21289
21290        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21291        assert!(app.should_open_selected_issue_external_ref());
21292    }
21293
21294    #[test]
21295    fn graph_detail_open_shortcut_only_activates_for_detail_focus_with_http_ref() {
21296        let mut app = new_app(ViewMode::Graph, 0);
21297        assert!(!app.should_open_selected_issue_external_ref());
21298
21299        app.focus = FocusPane::Detail;
21300        assert!(!app.should_open_selected_issue_external_ref());
21301
21302        for issue in &mut app.analyzer.issues {
21303            issue.external_ref = Some("mailto:test@example.com".into());
21304        }
21305        assert!(!app.should_open_selected_issue_external_ref());
21306
21307        for issue in &mut app.analyzer.issues {
21308            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21309        }
21310        assert!(app.should_open_selected_issue_external_ref());
21311    }
21312
21313    #[test]
21314    fn board_detail_open_shortcut_only_activates_for_detail_focus_with_http_ref() {
21315        let mut app = new_app(ViewMode::Board, 0);
21316        assert!(!app.should_open_selected_issue_external_ref());
21317
21318        app.focus = FocusPane::Detail;
21319        assert!(!app.should_open_selected_issue_external_ref());
21320
21321        app.analyzer.issues[0].external_ref = Some("mailto:test@example.com".into());
21322        assert!(!app.should_open_selected_issue_external_ref());
21323
21324        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21325        assert!(app.should_open_selected_issue_external_ref());
21326    }
21327
21328    #[test]
21329    fn main_detail_copy_shortcut_only_activates_for_detail_focus_with_http_ref() {
21330        let mut app = new_app(ViewMode::Main, 0);
21331        assert!(!app.should_copy_selected_issue_external_ref());
21332
21333        app.focus = FocusPane::Detail;
21334        assert!(!app.should_copy_selected_issue_external_ref());
21335
21336        app.analyzer.issues[0].external_ref = Some("mailto:test@example.com".into());
21337        assert!(!app.should_copy_selected_issue_external_ref());
21338
21339        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21340        assert!(app.should_copy_selected_issue_external_ref());
21341    }
21342
21343    #[test]
21344    fn graph_detail_copy_shortcut_only_activates_for_detail_focus_with_http_ref() {
21345        let mut app = new_app(ViewMode::Graph, 0);
21346        assert!(!app.should_copy_selected_issue_external_ref());
21347
21348        app.focus = FocusPane::Detail;
21349        assert!(!app.should_copy_selected_issue_external_ref());
21350
21351        for issue in &mut app.analyzer.issues {
21352            issue.external_ref = Some("mailto:test@example.com".into());
21353        }
21354        assert!(!app.should_copy_selected_issue_external_ref());
21355
21356        for issue in &mut app.analyzer.issues {
21357            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21358        }
21359        assert!(app.should_copy_selected_issue_external_ref());
21360    }
21361
21362    #[test]
21363    fn board_detail_copy_shortcut_only_activates_for_detail_focus_with_http_ref() {
21364        let mut app = new_app(ViewMode::Board, 0);
21365        assert!(!app.should_copy_selected_issue_external_ref());
21366
21367        app.focus = FocusPane::Detail;
21368        assert!(!app.should_copy_selected_issue_external_ref());
21369
21370        app.analyzer.issues[0].external_ref = Some("mailto:test@example.com".into());
21371        assert!(!app.should_copy_selected_issue_external_ref());
21372
21373        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21374        assert!(app.should_copy_selected_issue_external_ref());
21375    }
21376
21377    #[test]
21378    fn insights_detail_open_shortcut_only_activates_for_detail_focus_with_http_ref() {
21379        let mut app = new_app(ViewMode::Insights, 0);
21380        assert!(!app.should_open_selected_issue_external_ref());
21381
21382        app.focus = FocusPane::Detail;
21383        assert!(!app.should_open_selected_issue_external_ref());
21384
21385        for issue in &mut app.analyzer.issues {
21386            issue.external_ref = Some("mailto:test@example.com".into());
21387        }
21388        assert!(!app.should_open_selected_issue_external_ref());
21389
21390        for issue in &mut app.analyzer.issues {
21391            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21392        }
21393        assert!(app.should_open_selected_issue_external_ref());
21394    }
21395
21396    #[test]
21397    fn insights_detail_copy_shortcut_only_activates_for_detail_focus_with_http_ref() {
21398        let mut app = new_app(ViewMode::Insights, 0);
21399        assert!(!app.should_copy_selected_issue_external_ref());
21400
21401        app.focus = FocusPane::Detail;
21402        assert!(!app.should_copy_selected_issue_external_ref());
21403
21404        for issue in &mut app.analyzer.issues {
21405            issue.external_ref = Some("mailto:test@example.com".into());
21406        }
21407        assert!(!app.should_copy_selected_issue_external_ref());
21408
21409        for issue in &mut app.analyzer.issues {
21410            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21411        }
21412        assert!(app.should_copy_selected_issue_external_ref());
21413    }
21414
21415    #[test]
21416    fn main_mode_o_keeps_open_filter_shortcut_without_external_ref() {
21417        let mut app = new_app(ViewMode::Main, 0);
21418        app.focus = FocusPane::Detail;
21419        app.update(key(KeyCode::Char('o')));
21420        assert_eq!(app.list_filter, ListFilter::Open);
21421    }
21422
21423    #[test]
21424    fn main_mode_y_without_external_ref_does_not_set_status_message() {
21425        let mut app = new_app(ViewMode::Main, 0);
21426        app.focus = FocusPane::Detail;
21427        app.update(key(KeyCode::Char('y')));
21428        assert!(app.status_msg.is_empty());
21429    }
21430
21431    #[test]
21432    fn main_footer_shows_external_ref_commands_when_detail_link_is_available() {
21433        let mut app = new_app(ViewMode::Main, 0);
21434        app.focus = FocusPane::Detail;
21435        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21436
21437        let rendered = render_app(&app, 120, 40);
21438        assert!(
21439            rendered.contains("o open link"),
21440            "expected open-link hint, got:\n{rendered}"
21441        );
21442        assert!(
21443            rendered.contains("y copy link"),
21444            "expected copy-link hint, got:\n{rendered}"
21445        );
21446    }
21447
21448    #[test]
21449    fn main_footer_hides_external_ref_commands_without_detail_link() {
21450        let rendered = render_frame(ViewMode::Main, 120, 40);
21451        assert!(
21452            !rendered.contains("o open link"),
21453            "unexpected open-link hint in:\n{rendered}"
21454        );
21455        assert!(
21456            !rendered.contains("y copy link"),
21457            "unexpected copy-link hint in:\n{rendered}"
21458        );
21459    }
21460
21461    #[test]
21462    fn graph_footer_shows_external_ref_commands_when_detail_link_is_available() {
21463        let mut app = new_app(ViewMode::Graph, 0);
21464        app.focus = FocusPane::Detail;
21465        for issue in &mut app.analyzer.issues {
21466            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21467        }
21468
21469        let rendered = render_app(&app, 120, 40);
21470        assert!(
21471            rendered.contains("o open link"),
21472            "expected graph footer to advertise open-link hint, got:\n{rendered}"
21473        );
21474        assert!(
21475            rendered.contains("y copy link"),
21476            "expected graph footer to advertise copy-link hint, got:\n{rendered}"
21477        );
21478    }
21479
21480    #[test]
21481    fn board_detail_render_shows_external_ref_link_actions() {
21482        let mut app = new_app(ViewMode::Board, 0);
21483        app.focus = FocusPane::Detail;
21484        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21485
21486        let detail = app.board_detail_render_text();
21487        let urls = detail
21488            .lines()
21489            .iter()
21490            .flat_map(ftui::text::Line::spans)
21491            .filter_map(|span| span.link.as_deref())
21492            .collect::<Vec<_>>();
21493        assert!(
21494            urls.iter()
21495                .any(|url| *url == "https://github.com/org/repo/issues/42"),
21496            "expected board detail hyperlink span to be rendered, got {urls:?}"
21497        );
21498
21499        let rendered = detail.to_plain_text();
21500        assert!(
21501            rendered.contains("(o open, y copy)"),
21502            "expected board detail inline external-ref action hint, got:\n{rendered}"
21503        );
21504    }
21505
21506    #[test]
21507    fn board_footer_shows_external_ref_commands_when_detail_link_is_available() {
21508        let mut app = new_app(ViewMode::Board, 0);
21509        app.focus = FocusPane::Detail;
21510        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21511
21512        // Use a wide terminal (240 cols) so the footer text is not truncated
21513        // before the link hints. The footer string exceeds 200 chars.
21514        let rendered = render_app(&app, 240, 40);
21515        assert!(
21516            rendered.contains("o open link"),
21517            "expected board footer to advertise open-link hint, got:\n{rendered}"
21518        );
21519        assert!(
21520            rendered.contains("y copy link"),
21521            "expected board footer to advertise copy-link hint, got:\n{rendered}"
21522        );
21523    }
21524
21525    #[test]
21526    fn board_footer_shows_status_message_when_present() {
21527        let mut app = new_app(ViewMode::Board, 0);
21528        app.status_msg = "Copied external issue reference to clipboard".into();
21529
21530        let rendered = render_app(&app, 120, 40);
21531        assert!(
21532            rendered.contains("Copied external issue reference to clipboard"),
21533            "expected board footer to surface status message, got:\n{rendered}"
21534        );
21535    }
21536
21537    #[test]
21538    fn board_footer_advertises_focus_and_search_controls() {
21539        let app = new_app(ViewMode::Board, 0);
21540
21541        let rendered = render_app(&app, 240, 40);
21542        assert!(
21543            rendered.contains("Tab focus"),
21544            "expected board footer to advertise focus switching, got:\n{rendered}"
21545        );
21546        assert!(
21547            rendered.contains("/ search"),
21548            "expected board footer to advertise search, got:\n{rendered}"
21549        );
21550    }
21551
21552    #[test]
21553    fn insights_detail_render_shows_external_ref_link_actions() {
21554        let mut app = new_app(ViewMode::Insights, 0);
21555        app.focus = FocusPane::Detail;
21556        for issue in &mut app.analyzer.issues {
21557            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21558        }
21559
21560        let detail = app.insights_detail_render_text();
21561        let urls = detail
21562            .lines()
21563            .iter()
21564            .flat_map(ftui::text::Line::spans)
21565            .filter_map(|span| span.link.as_deref())
21566            .collect::<Vec<_>>();
21567        assert!(
21568            urls.iter()
21569                .any(|url| *url == "https://github.com/org/repo/issues/42"),
21570            "expected insights detail hyperlink span to be rendered, got {urls:?}"
21571        );
21572
21573        let rendered = detail.to_plain_text();
21574        assert!(
21575            rendered.contains("(o open, y copy)"),
21576            "expected insights detail inline external-ref action hint, got:\n{rendered}"
21577        );
21578    }
21579
21580    #[test]
21581    fn insights_footer_shows_external_ref_commands_when_detail_link_is_available() {
21582        let mut app = new_app(ViewMode::Insights, 0);
21583        app.focus = FocusPane::Detail;
21584        for issue in &mut app.analyzer.issues {
21585            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21586        }
21587
21588        let rendered = render_app(&app, 120, 40);
21589        assert!(
21590            rendered.contains("o open link"),
21591            "expected insights footer to advertise open-link hint, got:\n{rendered}"
21592        );
21593        assert!(
21594            rendered.contains("y copy link"),
21595            "expected insights footer to advertise copy-link hint, got:\n{rendered}"
21596        );
21597    }
21598
21599    #[test]
21600    fn insights_footer_advertises_focus_and_search_controls() {
21601        let app = new_app(ViewMode::Insights, 0);
21602
21603        let rendered = render_app(&app, 120, 40);
21604        assert!(
21605            rendered.contains("Tab focus"),
21606            "expected insights footer to advertise focus switching, got:\n{rendered}"
21607        );
21608        assert!(
21609            rendered.contains("/ search"),
21610            "expected insights footer to advertise search, got:\n{rendered}"
21611        );
21612    }
21613
21614    #[test]
21615    fn insights_footer_shows_status_message_when_present() {
21616        let mut app = new_app(ViewMode::Insights, 0);
21617        app.status_msg = "Copied external issue reference to clipboard".into();
21618
21619        let rendered = render_app(&app, 120, 40);
21620        assert!(
21621            rendered.contains("Copied external issue reference to clipboard"),
21622            "expected insights footer to surface status message, got:\n{rendered}"
21623        );
21624    }
21625
21626    #[test]
21627    fn graph_footer_hides_external_ref_commands_without_detail_link() {
21628        let rendered = render_frame(ViewMode::Graph, 120, 40);
21629        assert!(
21630            !rendered.contains("o open link"),
21631            "unexpected graph open-link hint in:\n{rendered}"
21632        );
21633        assert!(
21634            !rendered.contains("y copy link"),
21635            "unexpected graph copy-link hint in:\n{rendered}"
21636        );
21637    }
21638
21639    #[test]
21640    fn graph_footer_shows_status_message_when_present() {
21641        let mut app = new_app(ViewMode::Graph, 0);
21642        app.status_msg = "Copied external issue reference to clipboard".into();
21643
21644        let rendered = render_app(&app, 120, 40);
21645        assert!(
21646            rendered.contains("Copied external issue reference to clipboard"),
21647            "expected graph footer to surface status message, got:\n{rendered}"
21648        );
21649    }
21650
21651    #[test]
21652    fn graph_footer_keeps_open_details_wording() {
21653        let rendered = render_frame(ViewMode::Graph, 120, 40);
21654        assert!(
21655            rendered.contains("Enter open details"),
21656            "expected graph footer to describe Enter accurately, got:\n{rendered}"
21657        );
21658    }
21659
21660    #[test]
21661    fn graph_footer_keeps_link_actions_visible_on_narrow_detail_layout() {
21662        let mut app = new_app(ViewMode::Graph, 0);
21663        app.focus = FocusPane::Detail;
21664        for issue in &mut app.analyzer.issues {
21665            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
21666        }
21667
21668        let rendered = render_app(&app, 80, 40);
21669        assert!(
21670            rendered.contains("o open link"),
21671            "expected narrow graph footer to keep open-link hint visible, got:\n{rendered}"
21672        );
21673        // At width=80, "y copy link" wraps to a second footer line that is
21674        // clipped by the 1-row footer constraint, so we only verify "o open link".
21675    }
21676
21677    #[test]
21678    fn graph_footer_shows_scroll_hint_when_detail_focused() {
21679        let mut app = new_app(ViewMode::Graph, 0);
21680        app.focus = FocusPane::Detail;
21681
21682        let rendered = render_app(&app, 120, 40);
21683        assert!(
21684            rendered.contains("^j/k scroll"),
21685            "expected graph footer to advertise detail scrolling, got:\n{rendered}"
21686        );
21687    }
21688
21689    #[test]
21690    fn history_legacy_lifecycle_lines_match_go_shape() {
21691        let now = Utc::now();
21692        let history = HistoryBeadCompat {
21693            bead_id: "A".to_string(),
21694            title: "Root".to_string(),
21695            status: "open".to_string(),
21696            events: vec![
21697                HistoryEventCompat {
21698                    bead_id: "A".to_string(),
21699                    event_type: "created".to_string(),
21700                    timestamp: (now - chrono::Duration::hours(3)).to_rfc3339(),
21701                    commit_sha: String::new(),
21702                    commit_message: String::new(),
21703                    author: "Alice".to_string(),
21704                    author_email: "alice@example.com".to_string(),
21705                },
21706                HistoryEventCompat {
21707                    bead_id: "A".to_string(),
21708                    event_type: "claimed".to_string(),
21709                    timestamp: (now - chrono::Duration::hours(2)).to_rfc3339(),
21710                    commit_sha: String::new(),
21711                    commit_message: String::new(),
21712                    author: "Bob Builder".to_string(),
21713                    author_email: "bob@example.com".to_string(),
21714                },
21715                HistoryEventCompat {
21716                    bead_id: "A".to_string(),
21717                    event_type: "closed".to_string(),
21718                    timestamp: (now - chrono::Duration::minutes(30)).to_rfc3339(),
21719                    commit_sha: String::new(),
21720                    commit_message: String::new(),
21721                    author: "Carol".to_string(),
21722                    author_email: "carol@example.com".to_string(),
21723                },
21724            ],
21725            milestones: HistoryMilestonesCompat::default(),
21726            commits: None,
21727            cycle_time: None,
21728            last_author: String::new(),
21729        };
21730
21731        let lines = history_legacy_lifecycle_lines(&history, 5);
21732        let text = lines.join("\n");
21733        assert!(text.contains("LIFECYCLE (3)"));
21734        assert!(text.contains("✓"));
21735        assert!(text.contains("👤"));
21736        assert!(text.contains("🆕"));
21737        assert!(text.contains("CA"));
21738        assert!(text.contains("BB"));
21739        assert!(text.contains("AL"));
21740    }
21741
21742    #[test]
21743    fn history_legacy_lifecycle_lines_show_overflow_summary() {
21744        let now = Utc::now();
21745        let events = (0..5)
21746            .map(|idx| HistoryEventCompat {
21747                bead_id: "A".to_string(),
21748                event_type: if idx == 0 {
21749                    "created".to_string()
21750                } else {
21751                    "updated".to_string()
21752                },
21753                timestamp: (now - chrono::Duration::hours(i64::from(5 - idx))).to_rfc3339(),
21754                commit_sha: String::new(),
21755                commit_message: String::new(),
21756                author: format!("Agent {idx}"),
21757                author_email: format!("agent{idx}@example.com"),
21758            })
21759            .collect::<Vec<_>>();
21760        let history = HistoryBeadCompat {
21761            bead_id: "A".to_string(),
21762            title: "Root".to_string(),
21763            status: "open".to_string(),
21764            events,
21765            milestones: HistoryMilestonesCompat::default(),
21766            commits: None,
21767            cycle_time: None,
21768            last_author: String::new(),
21769        };
21770
21771        let lines = history_legacy_lifecycle_lines(&history, 5);
21772        let text = lines.join("\n");
21773        assert_eq!(lines.len(), 5);
21774        assert!(text.contains("LIFECYCLE (5)"));
21775        assert!(text.contains("+2 more"));
21776    }
21777
21778    #[test]
21779    fn history_detail_prefers_legacy_lifecycle_summary_when_git_events_exist() {
21780        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
21781        let now = Utc::now();
21782        {
21783            let history = app
21784                .history_git_cache
21785                .as_mut()
21786                .and_then(|cache| cache.histories.get_mut("A"))
21787                .expect("history A present");
21788            history.events = vec![
21789                HistoryEventCompat {
21790                    bead_id: "A".to_string(),
21791                    event_type: "created".to_string(),
21792                    timestamp: (now - chrono::Duration::hours(3)).to_rfc3339(),
21793                    commit_sha: String::new(),
21794                    commit_message: String::new(),
21795                    author: "Alice".to_string(),
21796                    author_email: "alice@example.com".to_string(),
21797                },
21798                HistoryEventCompat {
21799                    bead_id: "A".to_string(),
21800                    event_type: "claimed".to_string(),
21801                    timestamp: (now - chrono::Duration::hours(2)).to_rfc3339(),
21802                    commit_sha: String::new(),
21803                    commit_message: String::new(),
21804                    author: "Bob Builder".to_string(),
21805                    author_email: "bob@example.com".to_string(),
21806                },
21807                HistoryEventCompat {
21808                    bead_id: "A".to_string(),
21809                    event_type: "closed".to_string(),
21810                    timestamp: (now - chrono::Duration::minutes(30)).to_rfc3339(),
21811                    commit_sha: String::new(),
21812                    commit_message: String::new(),
21813                    author: "Carol".to_string(),
21814                    author_email: "carol@example.com".to_string(),
21815                },
21816            ];
21817        }
21818
21819        let text = app.history_detail_text();
21820        assert!(text.contains("LIFECYCLE (3)"));
21821        assert!(text.contains("🆕"));
21822        assert!(text.contains("👤"));
21823        assert!(text.contains("✓"));
21824        assert!(text.contains("CA"));
21825        assert!(!text.contains("  │ created"));
21826    }
21827
21828    #[test]
21829    fn mouse_scroll_down_moves_selection() {
21830        let mut app = new_app(ViewMode::Main, 0);
21831        assert_eq!(app.selected, 0);
21832        app.handle_mouse(MouseEvent::new(MouseEventKind::ScrollDown, 0, 0));
21833        assert_eq!(app.selected, 1);
21834        app.handle_mouse(MouseEvent::new(MouseEventKind::ScrollDown, 0, 0));
21835        assert_eq!(app.selected, 2);
21836    }
21837
21838    #[test]
21839    fn mouse_scroll_up_moves_selection() {
21840        let mut app = new_app(ViewMode::Main, 2);
21841        assert_eq!(app.selected, 2);
21842        app.handle_mouse(MouseEvent::new(MouseEventKind::ScrollUp, 0, 0));
21843        assert_eq!(app.selected, 1);
21844        app.handle_mouse(MouseEvent::new(MouseEventKind::ScrollUp, 0, 0));
21845        assert_eq!(app.selected, 0);
21846    }
21847
21848    #[test]
21849    fn mouse_scroll_works_in_board_mode() {
21850        let mut app = new_app(ViewMode::Board, 0);
21851        app.handle_mouse(MouseEvent::new(MouseEventKind::ScrollDown, 0, 0));
21852        // Should not panic, and should move selection
21853        assert!(app.selected <= 1);
21854    }
21855
21856    #[test]
21857    fn mouse_other_events_are_noop() {
21858        let mut app = new_app(ViewMode::Main, 0);
21859        app.handle_mouse(MouseEvent::new(MouseEventKind::Moved, 0, 0));
21860        assert_eq!(app.selected, 0);
21861    }
21862
21863    #[test]
21864    fn mouse_left_click_opens_main_detail_external_link() {
21865        let mut app = new_app(ViewMode::Main, 0);
21866        app.focus = FocusPane::Detail;
21867        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21868        let (x, y) = detail_link_click_point(&app, 120, 40).expect("detail link point");
21869
21870        app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
21871
21872        assert!(
21873            !app.status_msg.is_empty(),
21874            "expected click to trigger open-link status"
21875        );
21876        assert_ne!(app.status_msg, "No external issue reference");
21877    }
21878
21879    #[test]
21880    fn mouse_left_click_opens_board_detail_external_link() {
21881        let mut app = new_app(ViewMode::Board, 0);
21882        app.focus = FocusPane::Detail;
21883        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21884        let (x, y) = detail_link_click_point(&app, 120, 40).expect("detail link point");
21885
21886        app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
21887
21888        assert!(
21889            !app.status_msg.is_empty(),
21890            "expected click to trigger open-link status"
21891        );
21892        assert_ne!(app.status_msg, "No external issue reference");
21893    }
21894
21895    #[test]
21896    fn mouse_click_outside_main_detail_link_row_is_noop() {
21897        let mut app = new_app(ViewMode::Main, 0);
21898        app.focus = FocusPane::Detail;
21899        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21900        let (x, y) = detail_non_link_click_point(&app, 120, 40).expect("non-link detail point");
21901
21902        app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
21903
21904        assert!(
21905            app.status_msg.is_empty(),
21906            "expected non-link click to stay inert, got {:?}",
21907            app.status_msg
21908        );
21909    }
21910
21911    #[test]
21912    fn mouse_left_click_on_header_mode_tab_switches_mode() {
21913        let mut app = new_app(ViewMode::Main, 0);
21914        let (x, y) =
21915            header_tab_click_point(&app, 120, 24, ViewMode::Graph).expect("graph header tab point");
21916
21917        app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
21918
21919        assert_eq!(app.mode, ViewMode::Graph);
21920        assert_eq!(app.focus, FocusPane::List);
21921        assert_eq!(app.status_msg, "Switched to Graph");
21922    }
21923
21924    #[test]
21925    fn current_detail_link_row_area_tracks_main_detail_scroll_offset() {
21926        let mut app = new_app(ViewMode::Main, 0);
21927        app.focus = FocusPane::Detail;
21928        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21929
21930        let _ = render_app(&app, 120, 40);
21931        let initial = app
21932            .current_detail_link_row_area()
21933            .expect("initial main detail link row area");
21934
21935        app.detail_scroll_offset = 1;
21936        let _ = render_app(&app, 120, 40);
21937        let scrolled = app
21938            .current_detail_link_row_area()
21939            .expect("scrolled main detail link row area");
21940
21941        assert_eq!(scrolled.y, initial.y.saturating_sub(1));
21942        assert_eq!(scrolled.x, initial.x);
21943        assert_eq!(scrolled.width, initial.width);
21944    }
21945
21946    #[test]
21947    fn current_detail_link_row_area_matches_board_hyperlink_row() {
21948        let mut app = new_app(ViewMode::Board, 0);
21949        app.focus = FocusPane::Detail;
21950        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21951
21952        let _ = render_app(&app, 120, 40);
21953        let link_area = app
21954            .current_detail_link_row_area()
21955            .expect("board detail link row area");
21956        let detail_area = cached_detail_content_area();
21957        let detail = app.board_detail_render_text();
21958        let expected_row = detail
21959            .lines()
21960            .iter()
21961            .position(|line| {
21962                ftui::text::Line::spans(line)
21963                    .iter()
21964                    .any(|span| span.link.is_some())
21965            })
21966            .expect("board detail hyperlink row");
21967
21968        assert_eq!(
21969            link_area.y,
21970            detail_area
21971                .y
21972                .saturating_add(saturating_scroll_offset(expected_row)),
21973        );
21974    }
21975
21976    #[test]
21977    fn current_detail_link_row_area_tracks_board_detail_scroll_offset() {
21978        let mut app = new_app(ViewMode::Board, 0);
21979        app.focus = FocusPane::Detail;
21980        app.analyzer.issues[0].external_ref = Some("https://github.com/org/repo/issues/42".into());
21981
21982        let _ = render_app(&app, 120, 40);
21983        let initial = app
21984            .current_detail_link_row_area()
21985            .expect("initial board detail link row area");
21986
21987        app.board_detail_scroll_offset = 1;
21988        let _ = render_app(&app, 120, 40);
21989        let scrolled = app
21990            .current_detail_link_row_area()
21991            .expect("scrolled board detail link row area");
21992
21993        assert_eq!(scrolled.y, initial.y.saturating_sub(1));
21994        assert_eq!(scrolled.height, initial.height);
21995        assert_eq!(scrolled.width, initial.width);
21996    }
21997
21998    #[test]
21999    fn current_detail_link_row_area_matches_graph_hyperlink_row() {
22000        let mut app = new_app(ViewMode::Graph, 0);
22001        app.focus = FocusPane::Detail;
22002        for issue in &mut app.analyzer.issues {
22003            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
22004        }
22005
22006        let _ = render_app(&app, 120, 40);
22007        let link_area = app
22008            .current_detail_link_row_area()
22009            .expect("graph detail link row area");
22010        let detail_area = cached_detail_content_area();
22011        let detail = app.graph_detail_render_text();
22012        let expected_row = detail
22013            .lines()
22014            .iter()
22015            .position(|line| {
22016                ftui::text::Line::spans(line)
22017                    .iter()
22018                    .any(|span| span.link.is_some())
22019            })
22020            .expect("graph detail hyperlink row");
22021
22022        assert_eq!(
22023            link_area.y,
22024            detail_area
22025                .y
22026                .saturating_add(saturating_scroll_offset(expected_row)),
22027        );
22028    }
22029
22030    #[test]
22031    fn current_detail_link_row_area_tracks_graph_detail_scroll_offset() {
22032        let mut app = new_app(ViewMode::Graph, 0);
22033        app.focus = FocusPane::Detail;
22034        for issue in &mut app.analyzer.issues {
22035            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
22036        }
22037
22038        let _ = render_app(&app, 120, 40);
22039        let initial = app
22040            .current_detail_link_row_area()
22041            .expect("initial graph detail link row area");
22042
22043        app.detail_scroll_offset = 1;
22044        let _ = render_app(&app, 120, 40);
22045        let scrolled = app
22046            .current_detail_link_row_area()
22047            .expect("scrolled graph detail link row area");
22048
22049        assert_eq!(scrolled.y, initial.y.saturating_sub(1));
22050        assert_eq!(scrolled.x, initial.x);
22051        assert_eq!(scrolled.width, initial.width);
22052    }
22053
22054    #[test]
22055    fn current_detail_link_row_area_matches_insights_hyperlink_row() {
22056        let mut app = new_app(ViewMode::Insights, 0);
22057        app.focus = FocusPane::Detail;
22058        for issue in &mut app.analyzer.issues {
22059            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
22060        }
22061
22062        let _ = render_app(&app, 120, 40);
22063        let link_area = app
22064            .current_detail_link_row_area()
22065            .expect("insights detail link row area");
22066        let detail_area = cached_detail_content_area();
22067        let detail = app.insights_detail_render_text();
22068        let expected_row = detail
22069            .lines()
22070            .iter()
22071            .position(|line| {
22072                ftui::text::Line::spans(line)
22073                    .iter()
22074                    .any(|span| span.link.is_some())
22075            })
22076            .expect("insights detail hyperlink row");
22077
22078        assert_eq!(
22079            link_area.y,
22080            detail_area
22081                .y
22082                .saturating_add(saturating_scroll_offset(expected_row)),
22083        );
22084    }
22085
22086    #[test]
22087    fn current_detail_link_row_area_tracks_insights_detail_scroll_offset() {
22088        let mut app = new_app(ViewMode::Insights, 0);
22089        app.focus = FocusPane::Detail;
22090        for issue in &mut app.analyzer.issues {
22091            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
22092        }
22093
22094        let _ = render_app(&app, 120, 40);
22095        let initial = app
22096            .current_detail_link_row_area()
22097            .expect("initial insights detail link row area");
22098
22099        app.detail_scroll_offset = 1;
22100        let _ = render_app(&app, 120, 40);
22101        let scrolled = app
22102            .current_detail_link_row_area()
22103            .expect("scrolled insights detail link row area");
22104
22105        assert_eq!(scrolled.y, initial.y.saturating_sub(1));
22106        assert_eq!(scrolled.height, initial.height);
22107        assert_eq!(scrolled.width, initial.width);
22108    }
22109
22110    #[test]
22111    fn mouse_right_click_copies_graph_detail_external_link() {
22112        let mut app = new_app(ViewMode::Graph, 0);
22113        app.focus = FocusPane::Detail;
22114        for issue in &mut app.analyzer.issues {
22115            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
22116        }
22117        let (x, y) = detail_link_click_point(&app, 120, 40).expect("detail link point");
22118
22119        app.update(mouse(MouseEventKind::Down(MouseButton::Right), x, y));
22120
22121        assert!(
22122            !app.status_msg.is_empty(),
22123            "expected click to trigger copy-link status"
22124        );
22125        assert_ne!(app.status_msg, "No external issue reference");
22126    }
22127
22128    #[test]
22129    fn mouse_left_click_opens_insights_detail_external_link() {
22130        let mut app = new_app(ViewMode::Insights, 0);
22131        app.focus = FocusPane::Detail;
22132        for issue in &mut app.analyzer.issues {
22133            issue.external_ref = Some("https://github.com/org/repo/issues/42".into());
22134        }
22135        let (x, y) = detail_link_click_point(&app, 120, 40).expect("detail link point");
22136
22137        app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
22138
22139        assert!(
22140            !app.status_msg.is_empty(),
22141            "expected click to trigger open-link status"
22142        );
22143        assert_ne!(app.status_msg, "No external issue reference");
22144    }
22145
22146    #[test]
22147    fn mouse_left_click_opens_history_commit_link() {
22148        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
22149        app.mode = ViewMode::History;
22150        app.focus = FocusPane::Detail;
22151        let temp = tempfile::tempdir().expect("temp git dir");
22152        std::process::Command::new("git")
22153            .args(["init"])
22154            .current_dir(temp.path())
22155            .output()
22156            .expect("init git repo");
22157        std::process::Command::new("git")
22158            .args(["remote", "add", "origin", "https://github.com/org/repo.git"])
22159            .current_dir(temp.path())
22160            .output()
22161            .expect("add git remote");
22162        app.repo_root = Some(temp.path().to_path_buf());
22163        let (x, y) = detail_link_click_point(&app, 120, 40).expect("history link point");
22164
22165        app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
22166
22167        assert!(
22168            !app.history_status_msg.is_empty(),
22169            "expected click to trigger history open status"
22170        );
22171        assert_ne!(app.history_status_msg, "No commit selected");
22172    }
22173
22174    #[test]
22175    fn mouse_right_click_copies_history_commit_link() {
22176        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
22177        app.mode = ViewMode::History;
22178        app.focus = FocusPane::Detail;
22179        let temp = tempfile::tempdir().expect("temp git dir");
22180        std::process::Command::new("git")
22181            .args(["init"])
22182            .current_dir(temp.path())
22183            .output()
22184            .expect("init git repo");
22185        std::process::Command::new("git")
22186            .args(["remote", "add", "origin", "https://github.com/org/repo.git"])
22187            .current_dir(temp.path())
22188            .output()
22189            .expect("add git remote");
22190        app.repo_root = Some(temp.path().to_path_buf());
22191        let (x, y) = detail_link_click_point(&app, 120, 40).expect("history link point");
22192
22193        app.update(mouse(MouseEventKind::Down(MouseButton::Right), x, y));
22194
22195        assert!(
22196            !app.history_status_msg.is_empty(),
22197            "expected right-click to trigger history copy status"
22198        );
22199        assert_ne!(app.history_status_msg, "No commit selected");
22200    }
22201
22202    #[test]
22203    fn mouse_click_outside_history_detail_link_row_is_noop() {
22204        let mut app = history_app_with_git_cache(HistoryViewMode::Bead, 0);
22205        app.mode = ViewMode::History;
22206        app.focus = FocusPane::Detail;
22207        let temp = tempfile::tempdir().expect("temp git dir");
22208        std::process::Command::new("git")
22209            .args(["init"])
22210            .current_dir(temp.path())
22211            .output()
22212            .expect("init git repo");
22213        std::process::Command::new("git")
22214            .args(["remote", "add", "origin", "https://github.com/org/repo.git"])
22215            .current_dir(temp.path())
22216            .output()
22217            .expect("add git remote");
22218        app.repo_root = Some(temp.path().to_path_buf());
22219        let (x, y) = detail_non_link_click_point(&app, 120, 40).expect("non-link history point");
22220
22221        app.update(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
22222
22223        assert!(
22224            app.history_status_msg.is_empty(),
22225            "expected non-link click to stay inert, got {:?}",
22226            app.history_status_msg
22227        );
22228    }
22229
22230    #[test]
22231    fn tree_view_toggle() {
22232        let mut app = new_app(ViewMode::Main, 0);
22233        assert!(matches!(app.mode, ViewMode::Main));
22234
22235        // T toggles to Tree
22236        app.handle_key(KeyCode::Char('T'), Modifiers::NONE);
22237        assert!(matches!(app.mode, ViewMode::Tree));
22238        assert!(!app.tree_flat_nodes.is_empty(), "tree should build nodes");
22239
22240        // T toggles back to Main
22241        app.handle_key(KeyCode::Char('T'), Modifiers::NONE);
22242        assert!(matches!(app.mode, ViewMode::Main));
22243    }
22244
22245    #[test]
22246    fn tree_view_navigation() {
22247        let mut app = new_app(ViewMode::Main, 0);
22248        app.handle_key(KeyCode::Char('T'), Modifiers::NONE);
22249        assert!(matches!(app.mode, ViewMode::Tree));
22250        assert_eq!(app.tree_cursor, 0);
22251
22252        if app.tree_flat_nodes.len() > 1 {
22253            app.handle_key(KeyCode::Char('j'), Modifiers::NONE);
22254            assert_eq!(app.tree_cursor, 1);
22255            app.handle_key(KeyCode::Char('k'), Modifiers::NONE);
22256            assert_eq!(app.tree_cursor, 0);
22257        }
22258    }
22259
22260    #[test]
22261    fn tree_view_renders_list_and_detail() {
22262        let mut app = new_app(ViewMode::Main, 0);
22263        app.handle_key(KeyCode::Char('T'), Modifiers::NONE);
22264
22265        let list = app.tree_list_text();
22266        assert!(
22267            list.contains("Dependency tree"),
22268            "list should show tree header, got: {list}"
22269        );
22270
22271        let detail = app.tree_detail_text();
22272        assert!(
22273            detail.contains("ID:"),
22274            "detail should show issue ID, got: {detail}"
22275        );
22276    }
22277
22278    #[test]
22279    fn label_dashboard_toggle() {
22280        let mut app = new_app(ViewMode::Main, 0);
22281        assert!(matches!(app.mode, ViewMode::Main));
22282
22283        // [ toggles to LabelDashboard
22284        app.handle_key(KeyCode::Char('['), Modifiers::NONE);
22285        assert!(matches!(app.mode, ViewMode::LabelDashboard));
22286        assert!(app.label_dashboard.is_some());
22287
22288        // [ toggles back
22289        app.handle_key(KeyCode::Char('['), Modifiers::NONE);
22290        assert!(matches!(app.mode, ViewMode::Main));
22291    }
22292
22293    #[test]
22294    fn label_dashboard_navigation() {
22295        let mut app = new_app(ViewMode::Main, 0);
22296        app.handle_key(KeyCode::Char('['), Modifiers::NONE);
22297        assert_eq!(app.label_dashboard_cursor, 0);
22298
22299        let count = app.label_dashboard.as_ref().map_or(0, |r| r.labels.len());
22300        if count > 1 {
22301            app.handle_key(KeyCode::Char('j'), Modifiers::NONE);
22302            assert_eq!(app.label_dashboard_cursor, 1);
22303            app.handle_key(KeyCode::Char('k'), Modifiers::NONE);
22304            assert_eq!(app.label_dashboard_cursor, 0);
22305        }
22306    }
22307
22308    #[test]
22309    fn label_dashboard_renders_list_and_detail() {
22310        let mut app = new_app(ViewMode::Main, 0);
22311        app.handle_key(KeyCode::Char('['), Modifiers::NONE);
22312
22313        let list = app.label_dashboard_list_text();
22314        assert!(
22315            list.contains("Label health") || list.contains("no labels"),
22316            "list should show header, got: {list}"
22317        );
22318
22319        let detail = app.label_dashboard_detail_text();
22320        // If there are labels, detail should show label info
22321        if app
22322            .label_dashboard
22323            .as_ref()
22324            .is_some_and(|r| !r.labels.is_empty())
22325        {
22326            assert!(
22327                detail.contains("Label:"),
22328                "detail should show label name, got: {detail}"
22329            );
22330        }
22331    }
22332
22333    #[test]
22334    fn tree_view_expand_collapse() {
22335        let mut app = new_app(ViewMode::Main, 0);
22336        app.handle_key(KeyCode::Char('T'), Modifiers::NONE);
22337
22338        // Find a node with children for the collapse test
22339        let has_children_node = app.tree_flat_nodes.iter().position(|n| n.has_children);
22340        if let Some(idx) = has_children_node {
22341            app.tree_cursor = idx;
22342            let initial_count = app.tree_flat_nodes.len();
22343
22344            // Enter collapses children
22345            app.handle_key(KeyCode::Enter, Modifiers::NONE);
22346            assert!(
22347                app.tree_flat_nodes.len() < initial_count,
22348                "collapsing should reduce node count"
22349            );
22350
22351            // Enter again expands
22352            app.handle_key(KeyCode::Enter, Modifiers::NONE);
22353            assert_eq!(
22354                app.tree_flat_nodes.len(),
22355                initial_count,
22356                "expanding should restore node count"
22357            );
22358        }
22359    }
22360
22361    #[test]
22362    fn flow_matrix_toggle() {
22363        let mut app = new_app(ViewMode::Main, 0);
22364        assert!(matches!(app.mode, ViewMode::Main));
22365
22366        // ] toggles to FlowMatrix
22367        app.handle_key(KeyCode::Char(']'), Modifiers::NONE);
22368        assert!(matches!(app.mode, ViewMode::FlowMatrix));
22369        assert!(app.flow_matrix.is_some());
22370
22371        // ] toggles back
22372        app.handle_key(KeyCode::Char(']'), Modifiers::NONE);
22373        assert!(matches!(app.mode, ViewMode::Main));
22374    }
22375
22376    #[test]
22377    fn flow_matrix_navigation() {
22378        let mut app = new_app(ViewMode::Main, 0);
22379        app.handle_key(KeyCode::Char(']'), Modifiers::NONE);
22380        assert_eq!(app.flow_matrix_row_cursor, 0);
22381        assert_eq!(app.flow_matrix_col_cursor, 0);
22382
22383        let count = app.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
22384        if count > 1 {
22385            // j/k for rows
22386            app.handle_key(KeyCode::Char('j'), Modifiers::NONE);
22387            assert_eq!(app.flow_matrix_row_cursor, 1);
22388            app.handle_key(KeyCode::Char('k'), Modifiers::NONE);
22389            assert_eq!(app.flow_matrix_row_cursor, 0);
22390
22391            // h/l for columns
22392            app.handle_key(KeyCode::Char('l'), Modifiers::NONE);
22393            assert_eq!(app.flow_matrix_col_cursor, 1);
22394            app.handle_key(KeyCode::Char('h'), Modifiers::NONE);
22395            assert_eq!(app.flow_matrix_col_cursor, 0);
22396        }
22397    }
22398
22399    #[test]
22400    fn flow_matrix_renders_list_and_detail() {
22401        let mut app = new_app(ViewMode::Main, 0);
22402        app.handle_key(KeyCode::Char(']'), Modifiers::NONE);
22403
22404        let list = app.flow_matrix_list_text();
22405        assert!(
22406            list.contains("Cross-label flow") || list.contains("no labels"),
22407            "list should show header or empty, got: {list}"
22408        );
22409
22410        let detail = app.flow_matrix_detail_text();
22411        // Detail should have some content
22412        assert!(!detail.is_empty(), "detail should not be empty");
22413    }
22414
22415    #[test]
22416    fn flow_matrix_list_handles_wide_unicode_labels_without_overflow() {
22417        let mut app = new_app(ViewMode::FlowMatrix, 0);
22418        app.mode = ViewMode::FlowMatrix;
22419        app.flow_matrix = Some(CrossLabelFlow {
22420            labels: vec!["界面".to_string(), "🚀-launch".to_string()],
22421            flow_matrix: vec![vec![0, 3], vec![1, 0]],
22422            dependencies: Vec::new(),
22423            bottleneck_labels: vec!["界面".to_string()],
22424            total_cross_label_deps: 4,
22425        });
22426        app.flow_matrix_row_cursor = 0;
22427        app.flow_matrix_col_cursor = 1;
22428
22429        let list = app.flow_matrix_list_text();
22430        assert!(list.contains("界面"));
22431        assert!(list.contains("🚀-launch"));
22432        assert!(
22433            list.lines().all(|line| display_width(line) <= 80),
22434            "every flow-matrix line should remain width-safe: {list}"
22435        );
22436    }
22437
22438    #[test]
22439    fn remote_to_commit_url_ssh() {
22440        let url = super::remote_to_commit_url("git@github.com:owner/repo.git", "abc123");
22441        assert_eq!(
22442            url,
22443            Some("https://github.com/owner/repo/commit/abc123".into())
22444        );
22445    }
22446
22447    #[test]
22448    fn remote_to_commit_url_https() {
22449        let url = super::remote_to_commit_url("https://github.com/owner/repo.git", "def456");
22450        assert_eq!(
22451            url,
22452            Some("https://github.com/owner/repo/commit/def456".into())
22453        );
22454    }
22455
22456    #[test]
22457    fn remote_to_commit_url_no_git_suffix() {
22458        let url = super::remote_to_commit_url("https://github.com/owner/repo", "sha789");
22459        assert_eq!(
22460            url,
22461            Some("https://github.com/owner/repo/commit/sha789".into())
22462        );
22463    }
22464
22465    #[test]
22466    fn file_tree_node_flatten_visible() {
22467        let node = super::FileTreeNode {
22468            name: "src".into(),
22469            path: "src".into(),
22470            is_dir: true,
22471            change_count: 3,
22472            expanded: true,
22473            level: 0,
22474            children: vec![
22475                super::FileTreeNode {
22476                    name: "main.rs".into(),
22477                    path: "src/main.rs".into(),
22478                    is_dir: false,
22479                    change_count: 2,
22480                    expanded: false,
22481                    level: 1,
22482                    children: vec![],
22483                },
22484                super::FileTreeNode {
22485                    name: "lib.rs".into(),
22486                    path: "src/lib.rs".into(),
22487                    is_dir: false,
22488                    change_count: 1,
22489                    expanded: false,
22490                    level: 1,
22491                    children: vec![],
22492                },
22493            ],
22494        };
22495
22496        let flat = node.flatten_visible();
22497        assert_eq!(flat.len(), 3);
22498        assert_eq!(flat[0].name, "src");
22499        assert!(flat[0].is_dir);
22500        assert_eq!(flat[1].name, "main.rs");
22501        assert_eq!(flat[2].name, "lib.rs");
22502    }
22503
22504    // ── Modal overlay tests ─────────────────────────────────────────
22505
22506    #[test]
22507    fn modal_tutorial_dismisses_on_any_key() {
22508        let mut app = new_app(ViewMode::Main, 0);
22509        app.open_tutorial();
22510        assert!(app.modal_overlay.is_some());
22511        assert!(matches!(app.modal_overlay, Some(ModalOverlay::Tutorial)));
22512
22513        app.update(key(KeyCode::Char('x')));
22514        assert!(app.modal_overlay.is_none());
22515    }
22516
22517    #[test]
22518    fn ctrl_t_opens_and_dismisses_tutorial_modal() {
22519        let mut app = new_app(ViewMode::Main, 0);
22520
22521        app.update(key_ctrl(KeyCode::Char('t')));
22522        assert!(matches!(app.modal_overlay, Some(ModalOverlay::Tutorial)));
22523
22524        app.update(key(KeyCode::Char('x')));
22525        assert!(app.modal_overlay.is_none());
22526    }
22527
22528    #[test]
22529    fn modal_confirm_accepts_on_y_rejects_on_n() {
22530        let mut app = new_app(ViewMode::Main, 0);
22531        app.open_confirm_with_resume("Test", "Do you confirm?", None);
22532        assert!(app.modal_overlay.is_some());
22533        assert!(app.modal_confirm_result.is_none());
22534
22535        app.update(key(KeyCode::Char('y')));
22536        assert!(app.modal_overlay.is_none());
22537        assert_eq!(app.modal_confirm_result, Some(true));
22538
22539        app.open_confirm_with_resume("Test", "Another question?", None);
22540        app.update(key(KeyCode::Char('n')));
22541        assert!(app.modal_overlay.is_none());
22542        assert_eq!(app.modal_confirm_result, Some(false));
22543
22544        app.open_confirm_with_resume("Test", "Third question?", None);
22545        app.update(key(KeyCode::Escape));
22546        assert!(app.modal_overlay.is_none());
22547        assert_eq!(app.modal_confirm_result, Some(false));
22548    }
22549
22550    #[test]
22551    fn modal_confirm_ignores_unrelated_keys() {
22552        let mut app = new_app(ViewMode::Main, 0);
22553        app.open_confirm_with_resume("Test", "Do you confirm?", None);
22554
22555        app.update(key(KeyCode::Char('x')));
22556        assert!(app.modal_overlay.is_some());
22557        assert!(app.modal_confirm_result.is_none());
22558    }
22559
22560    #[test]
22561    fn modal_pages_wizard_step_navigation() {
22562        let mut app = new_app(ViewMode::Main, 0);
22563        app.open_pages_wizard();
22564
22565        match &app.modal_overlay {
22566            Some(ModalOverlay::PagesWizard(wiz)) => {
22567                assert_eq!(wiz.step, 0);
22568                assert_eq!(wiz.export_dir, "./bv-pages");
22569            }
22570            other => panic!("Expected PagesWizard, got {other:?}"),
22571        }
22572
22573        app.update(key(KeyCode::Char('x')));
22574        match &app.modal_overlay {
22575            Some(ModalOverlay::PagesWizard(wiz)) => {
22576                assert_eq!(wiz.export_dir, "./bv-pagesx");
22577            }
22578            _ => panic!("lost wizard"),
22579        }
22580
22581        app.update(key(KeyCode::Backspace));
22582        match &app.modal_overlay {
22583            Some(ModalOverlay::PagesWizard(wiz)) => {
22584                assert_eq!(wiz.export_dir, "./bv-pages");
22585            }
22586            _ => panic!("lost wizard"),
22587        }
22588
22589        app.update(key(KeyCode::Enter));
22590        match &app.modal_overlay {
22591            Some(ModalOverlay::PagesWizard(wiz)) => {
22592                assert_eq!(wiz.step, 1);
22593                assert_eq!(wiz.step_label(), "Page Title");
22594            }
22595            _ => panic!("lost wizard"),
22596        }
22597
22598        app.update(key(KeyCode::Enter));
22599        match &app.modal_overlay {
22600            Some(ModalOverlay::PagesWizard(wiz)) => {
22601                assert_eq!(wiz.step, 2);
22602                assert!(wiz.include_closed);
22603                assert!(wiz.include_history);
22604            }
22605            _ => panic!("lost wizard"),
22606        }
22607
22608        app.update(key(KeyCode::Char('c')));
22609        match &app.modal_overlay {
22610            Some(ModalOverlay::PagesWizard(wiz)) => assert!(!wiz.include_closed),
22611            _ => panic!("lost wizard"),
22612        }
22613        app.update(key(KeyCode::Char('h')));
22614        match &app.modal_overlay {
22615            Some(ModalOverlay::PagesWizard(wiz)) => assert!(!wiz.include_history),
22616            _ => panic!("lost wizard"),
22617        }
22618
22619        app.update(key(KeyCode::Enter));
22620        match &app.modal_overlay {
22621            Some(ModalOverlay::PagesWizard(wiz)) => {
22622                assert_eq!(wiz.step, 3);
22623            }
22624            _ => panic!("lost wizard"),
22625        }
22626
22627        app.update(key(KeyCode::Enter));
22628        assert!(matches!(
22629            app.modal_overlay,
22630            Some(ModalOverlay::Confirm { .. })
22631        ));
22632        assert!(app.modal_confirm_result.is_none());
22633
22634        app.update(key(KeyCode::Char('y')));
22635        assert!(app.modal_overlay.is_none());
22636        assert_eq!(app.modal_confirm_result, Some(true));
22637    }
22638
22639    #[test]
22640    fn uppercase_p_opens_pages_wizard_modal() {
22641        let mut app = new_app(ViewMode::Main, 0);
22642
22643        app.update(key(KeyCode::Char('P')));
22644
22645        assert!(matches!(
22646            app.modal_overlay,
22647            Some(ModalOverlay::PagesWizard(_))
22648        ));
22649    }
22650
22651    #[test]
22652    fn modal_pages_wizard_escape_cancels() {
22653        let mut app = new_app(ViewMode::Main, 0);
22654        app.open_pages_wizard();
22655        app.update(key(KeyCode::Enter));
22656        app.update(key(KeyCode::Escape));
22657        assert!(app.modal_overlay.is_none());
22658    }
22659
22660    #[test]
22661    fn modal_pages_wizard_confirm_cancel_restores_wizard() {
22662        let mut app = new_app(ViewMode::Main, 0);
22663        app.open_pages_wizard();
22664
22665        app.update(key(KeyCode::Enter));
22666        app.update(key(KeyCode::Enter));
22667        app.update(key(KeyCode::Enter));
22668        app.update(key(KeyCode::Enter));
22669
22670        assert!(matches!(
22671            app.modal_overlay,
22672            Some(ModalOverlay::Confirm { .. })
22673        ));
22674
22675        app.update(key(KeyCode::Escape));
22676        match &app.modal_overlay {
22677            Some(ModalOverlay::PagesWizard(wiz)) => {
22678                assert_eq!(wiz.step, 3);
22679                assert_eq!(wiz.export_dir, "./bv-pages");
22680            }
22681            other => panic!("expected PagesWizard to resume after cancel, got {other:?}"),
22682        }
22683        assert_eq!(app.modal_confirm_result, Some(false));
22684    }
22685
22686    #[test]
22687    fn modal_pages_wizard_backspace_goes_back() {
22688        let mut app = new_app(ViewMode::Main, 0);
22689        app.open_pages_wizard();
22690        app.update(key(KeyCode::Enter));
22691        app.update(key(KeyCode::Enter));
22692        match &app.modal_overlay {
22693            Some(ModalOverlay::PagesWizard(wiz)) => assert_eq!(wiz.step, 2),
22694            _ => panic!("lost wizard"),
22695        }
22696        app.update(key(KeyCode::Backspace));
22697        match &app.modal_overlay {
22698            Some(ModalOverlay::PagesWizard(wiz)) => assert_eq!(wiz.step, 1),
22699            _ => panic!("lost wizard"),
22700        }
22701    }
22702
22703    #[test]
22704    fn modal_state_transitions_help_to_quit_cycle() {
22705        let mut app = new_app(ViewMode::Main, 0);
22706
22707        app.update(key(KeyCode::Char('?')));
22708        assert!(app.show_help);
22709        assert!(app.modal_overlay.is_none());
22710
22711        app.update(key(KeyCode::Escape));
22712        assert!(!app.show_help);
22713
22714        app.update(key(KeyCode::Escape));
22715        assert!(app.show_quit_confirm);
22716
22717        app.update(key(KeyCode::Char('x')));
22718        assert!(!app.show_quit_confirm);
22719
22720        app.open_tutorial();
22721        assert!(app.modal_overlay.is_some());
22722        assert!(!app.show_help);
22723
22724        app.update(key(KeyCode::Enter));
22725        assert!(app.modal_overlay.is_none());
22726    }
22727
22728    #[test]
22729    fn modal_overlay_blocks_normal_key_handling() {
22730        let mut app = new_app(ViewMode::Main, 0);
22731        let initial_mode = app.mode;
22732
22733        app.open_confirm_with_resume("Test", "Question", None);
22734        app.update(key(KeyCode::Char('b')));
22735        assert_eq!(app.mode, initial_mode);
22736        assert!(app.modal_overlay.is_some());
22737
22738        app.update(key(KeyCode::Char('y')));
22739        assert!(app.modal_overlay.is_none());
22740    }
22741
22742    // =====================================================================
22743    // Regression Harness: File-based Snapshots
22744    // =====================================================================
22745    //
22746    // These tests capture full rendered frames (via buffer_to_text) and
22747    // compare against stored baselines using `insta`.  Any rendering
22748    // change produces a diff that `cargo insta review` can approve.
22749
22750    /// Render a full frame for the given mode/width/height and return text.
22751    fn render_frame(mode: ViewMode, width: u16, height: u16) -> String {
22752        let app = new_app(mode, 0);
22753        render_app(&app, width, height)
22754    }
22755
22756    /// Redact non-deterministic git data from snapshot text.
22757    /// Handles: commit counts, 7-char SHAs, ISO dates, and author info.
22758    #[allow(dead_code)]
22759    fn redact_git_volatile(text: &str) -> String {
22760        let mut result = String::with_capacity(text.len());
22761
22762        for line in text.lines() {
22763            let mut redacted = line.to_string();
22764
22765            // Redact "N/M correla" patterns (commit counts)
22766            if let Some(pos) = redacted.find(" correla") {
22767                let prefix = &redacted[..pos];
22768                if let Some(slash) = prefix.rfind('/') {
22769                    let num_start = prefix[..slash]
22770                        .rfind(|ch: char| !ch.is_ascii_digit())
22771                        .map_or(0, |i| i + 1);
22772                    let after_slash = &prefix[slash + 1..];
22773                    if !after_slash.is_empty()
22774                        && after_slash.chars().all(|ch| ch.is_ascii_digit())
22775                        && prefix[num_start..slash]
22776                            .chars()
22777                            .all(|ch| ch.is_ascii_digit())
22778                        && !prefix[num_start..slash].is_empty()
22779                    {
22780                        redacted = format!("{}N/N{}", &redacted[..num_start], &redacted[pos..]);
22781                    }
22782                }
22783            }
22784
22785            // Redact "SHA: <full-hex>" lines by replacing the hex after "SHA: "
22786            if redacted.contains("SHA:") {
22787                if let Some(pos) = redacted.find("SHA: ") {
22788                    let sha_start = pos + 5;
22789                    let sha_end = redacted[sha_start..]
22790                        .find(|c: char| !c.is_ascii_hexdigit())
22791                        .map_or(redacted.len(), |i| sha_start + i);
22792                    if sha_end > sha_start {
22793                        redacted =
22794                            format!("{}AAAAAAA{}", &redacted[..sha_start], &redacted[sha_end..]);
22795                    }
22796                }
22797            }
22798
22799            // Redact 7-char hex SHAs (e.g., "d50d5c0" → "AAAAAAA")
22800            // Only in lines that look like git commit references
22801            if redacted.contains("for ") || redacted.contains("> F ") {
22802                let chars: Vec<char> = redacted.chars().collect();
22803                let mut new_line = String::with_capacity(redacted.len());
22804                let mut i = 0;
22805                while i < chars.len() {
22806                    if i + 7 <= chars.len()
22807                        && chars[i..i + 7].iter().all(|c| c.is_ascii_hexdigit())
22808                        && (i == 0 || !chars[i - 1].is_ascii_alphanumeric())
22809                        && (i + 7 >= chars.len() || !chars[i + 7].is_ascii_alphanumeric())
22810                    {
22811                        // Check it's not all digits (could be a date fragment)
22812                        if chars[i..i + 7].iter().any(|c| c.is_ascii_alphabetic()) {
22813                            new_line.push_str("AAAAAAA");
22814                            i += 7;
22815                            continue;
22816                        }
22817                    }
22818                    new_line.push(chars[i]);
22819                    i += 1;
22820                }
22821                redacted = new_line;
22822            }
22823
22824            // Redact ISO dates like "2026-03-23T06:14:45Z" → "YYYY-MM-DDTHH:MM:SSZ"
22825            if redacted.contains("Date:") {
22826                if let Some(pos) = redacted.find("20") {
22827                    let rest = &redacted[pos..];
22828                    if rest.len() >= 20 && rest.as_bytes().get(4) == Some(&b'-') {
22829                        redacted = format!(
22830                            "{}YYYY-MM-DDTHH:MM:SSZ{}",
22831                            &redacted[..pos],
22832                            &redacted[pos + 20..]
22833                        );
22834                    }
22835                }
22836            }
22837
22838            result.push_str(&redacted);
22839            result.push('\n');
22840        }
22841
22842        // Remove trailing newline to match input format
22843        if result.ends_with('\n') && !text.ends_with('\n') {
22844            result.pop();
22845        }
22846
22847        result
22848    }
22849
22850    fn render_app(app: &BvrApp, width: u16, height: u16) -> String {
22851        let mut pool = ftui::GraphemePool::default();
22852        let mut frame = ftui::render::frame::Frame::new(width, height, &mut pool);
22853        app.view(&mut frame);
22854        super::buffer_to_text(&frame.buffer, &pool)
22855    }
22856
22857    fn capture_debug_replay(
22858        app: &BvrApp,
22859        width: u16,
22860        height: u16,
22861        step: &str,
22862        captures: &mut Vec<DebugReplayCapture>,
22863    ) -> String {
22864        let rendered = render_app(app, width, height);
22865        captures.push(DebugReplayCapture {
22866            step: step.to_string(),
22867            mode: app.mode,
22868            focus: app.focus,
22869            selected: app.selected,
22870            width,
22871            height,
22872            trace_len: app.key_trace.len(),
22873            rendered: rendered.clone(),
22874            layout: super::render_layout_debug_report(app, width, height),
22875            hittest: super::render_hittest_debug_report(app, width, height),
22876        });
22877        rendered
22878    }
22879
22880    fn debug_replay_artifact(journey_name: &str, captures: &[DebugReplayCapture]) -> String {
22881        let mut out = String::new();
22882        let _ = writeln!(out, "=== Debug Replay: {journey_name} ===");
22883        let _ = writeln!(out);
22884        for (idx, capture) in captures.iter().enumerate() {
22885            let _ = writeln!(
22886                out,
22887                "--- Step {}: {} | view={} | focus={} | selected={} | size={}x{} | trace-len={} ---",
22888                idx + 1,
22889                capture.step,
22890                capture.mode.label(),
22891                capture.focus.label(),
22892                capture.selected,
22893                capture.width,
22894                capture.height,
22895                capture.trace_len
22896            );
22897            let _ = writeln!(out, "{}", capture.rendered);
22898            let _ = writeln!(out);
22899            let _ = writeln!(out, "{}", capture.layout);
22900            let _ = writeln!(out);
22901            let _ = writeln!(out, "{}", capture.hittest);
22902            let _ = writeln!(out);
22903        }
22904        out
22905    }
22906
22907    fn detail_link_click_point(app: &BvrApp, width: u16, height: u16) -> Option<(u16, u16)> {
22908        let _ = render_app(app, width, height);
22909        let area = app.current_detail_link_row_area()?;
22910        if area.width == 0 || area.height == 0 {
22911            return None;
22912        }
22913        Some((area.x, area.y))
22914    }
22915
22916    fn detail_non_link_click_point(app: &BvrApp, width: u16, height: u16) -> Option<(u16, u16)> {
22917        let _ = render_app(app, width, height);
22918        let detail_area = cached_detail_content_area();
22919        let link_area = app.current_detail_link_row_area()?;
22920        if detail_area.width == 0 || detail_area.height == 0 {
22921            return None;
22922        }
22923
22924        if link_area.y > detail_area.y {
22925            return Some((detail_area.x, detail_area.y));
22926        }
22927
22928        let next_y = link_area.y.saturating_add(1);
22929        if next_y < detail_area.y.saturating_add(detail_area.height) {
22930            return Some((detail_area.x, next_y));
22931        }
22932
22933        None
22934    }
22935
22936    fn header_tab_click_point(
22937        app: &BvrApp,
22938        width: u16,
22939        height: u16,
22940        mode: ViewMode,
22941    ) -> Option<(u16, u16)> {
22942        let _ = render_app(app, width, height);
22943        let tab = super::header_mode_tabs(app, width)
22944            .into_iter()
22945            .find(|tab| tab.mode == mode)?;
22946        Some((tab.rect.x, tab.rect.y))
22947    }
22948
22949    #[test]
22950    fn saturating_scroll_offset_clamps_large_values() {
22951        assert_eq!(saturating_scroll_offset(0), 0);
22952        assert_eq!(saturating_scroll_offset(42), 42);
22953        assert_eq!(
22954            saturating_scroll_offset(usize::from(u16::MAX) + 1),
22955            u16::MAX
22956        );
22957    }
22958
22959    fn rendered_link_urls(app: &BvrApp, width: u16, height: u16) -> Vec<String> {
22960        let mut pool = ftui::GraphemePool::default();
22961        let mut links = ftui::LinkRegistry::new();
22962        let mut frame =
22963            ftui::render::frame::Frame::with_links(width, height, &mut pool, &mut links);
22964        app.view(&mut frame);
22965        let mut link_ids = Vec::new();
22966        for y in 0..height {
22967            for x in 0..width {
22968                if let Some(cell) = frame.buffer.get(x, y) {
22969                    let link_id = cell.attrs.link_id();
22970                    if link_id != 0 && !link_ids.contains(&link_id) {
22971                        link_ids.push(link_id);
22972                    }
22973                }
22974            }
22975        }
22976        drop(frame);
22977        link_ids
22978            .into_iter()
22979            .filter_map(|link_id| links.get(link_id).map(ToString::to_string))
22980            .collect()
22981    }
22982
22983    fn init_temp_repo_with_remote(remote: &str) -> tempfile::TempDir {
22984        let dir = tempfile::tempdir().expect("tempdir");
22985        let status = std::process::Command::new("git")
22986            .args(["init", "-q"])
22987            .current_dir(dir.path())
22988            .status()
22989            .expect("git init");
22990        assert!(status.success(), "git init should succeed");
22991
22992        let status = std::process::Command::new("git")
22993            .args(["remote", "add", "origin", remote])
22994            .current_dir(dir.path())
22995            .status()
22996            .expect("git remote add origin");
22997        assert!(status.success(), "git remote add origin should succeed");
22998        dir
22999    }
23000
23001    macro_rules! snapshot_test {
23002        ($name:ident, $mode:expr, $width:literal, $height:literal) => {
23003            #[test]
23004            fn $name() {
23005                let text = render_frame($mode, $width, $height);
23006                insta::assert_snapshot!(text);
23007            }
23008        };
23009    }
23010
23011    // Main mode at 3 breakpoints
23012    snapshot_test!(snap_main_narrow, ViewMode::Main, 60, 30);
23013    snapshot_test!(snap_main_medium, ViewMode::Main, 100, 30);
23014    snapshot_test!(snap_main_wide, ViewMode::Main, 140, 30);
23015
23016    // Board mode at 3 breakpoints
23017    snapshot_test!(snap_board_narrow, ViewMode::Board, 60, 30);
23018    snapshot_test!(snap_board_medium, ViewMode::Board, 100, 30);
23019    snapshot_test!(snap_board_wide, ViewMode::Board, 140, 30);
23020
23021    // Insights mode at 3 breakpoints
23022    snapshot_test!(snap_insights_narrow, ViewMode::Insights, 60, 30);
23023    snapshot_test!(snap_insights_medium, ViewMode::Insights, 100, 30);
23024    snapshot_test!(snap_insights_wide, ViewMode::Insights, 140, 30);
23025
23026    // Graph mode at 3 breakpoints
23027    snapshot_test!(snap_graph_narrow, ViewMode::Graph, 60, 30);
23028    snapshot_test!(snap_graph_medium, ViewMode::Graph, 100, 30);
23029    snapshot_test!(snap_graph_wide, ViewMode::Graph, 140, 30);
23030
23031    // History mode at 3 breakpoints
23032    snapshot_test!(snap_history_narrow, ViewMode::History, 60, 30);
23033    snapshot_test!(snap_history_medium, ViewMode::History, 100, 30);
23034    snapshot_test!(snap_history_wide, ViewMode::History, 140, 30);
23035
23036    // =====================================================================
23037    // Regression Harness: Keyflow Journey Tests
23038    // =====================================================================
23039    //
23040    // Each test replays a complete keyboard journey that a user would
23041    // perform, asserting state at each step.  The key_trace vector
23042    // provides a full audit log for triage.
23043
23044    #[test]
23045    fn keyflow_main_to_board_navigate_return() {
23046        let mut app = new_app(ViewMode::Main, 0);
23047
23048        // Enter board
23049        app.update(key(KeyCode::Char('b')));
23050        assert_eq!(app.mode, ViewMode::Board);
23051
23052        // Navigate lanes
23053        app.update(key(KeyCode::Char('l')));
23054        // Move down within lane
23055        app.update(key(KeyCode::Char('j')));
23056        app.update(key(KeyCode::Char('j')));
23057
23058        // Toggle grouping
23059        app.update(key(KeyCode::Char('s')));
23060        assert_eq!(app.board_grouping, BoardGrouping::Priority);
23061
23062        // Return to main
23063        app.update(key(KeyCode::Char('q')));
23064        assert_eq!(app.mode, ViewMode::Main);
23065
23066        // Verify trace has all steps
23067        assert_eq!(app.key_trace.len(), 6);
23068    }
23069
23070    #[test]
23071    fn keyflow_board_search_with_cycling() {
23072        let mut app = new_app(ViewMode::Board, 0);
23073
23074        // Start search
23075        app.update(key(KeyCode::Char('/')));
23076        assert!(app.board_search_active);
23077
23078        // Type query
23079        app.update(key(KeyCode::Char('R')));
23080        app.update(key(KeyCode::Char('o')));
23081        assert_eq!(app.board_search_query, "Ro");
23082
23083        // Accept search
23084        app.update(key(KeyCode::Enter));
23085        assert!(!app.board_search_active);
23086
23087        // Cycle matches
23088        app.update(key(KeyCode::Char('n')));
23089        app.update(key(KeyCode::Char('N')));
23090
23091        // Clear with Esc
23092        app.update(key(KeyCode::Escape));
23093        assert_eq!(app.mode, ViewMode::Main);
23094    }
23095
23096    #[test]
23097    fn keyflow_main_to_insights_explore_return() {
23098        let mut app = new_app(ViewMode::Main, 0);
23099
23100        // Enter insights
23101        app.update(key(KeyCode::Char('i')));
23102        assert_eq!(app.mode, ViewMode::Insights);
23103
23104        // Cycle panels
23105        app.update(key(KeyCode::Char('s')));
23106        assert_ne!(app.insights_panel, InsightsPanel::Bottlenecks);
23107
23108        // Toggle explanations
23109        app.update(key(KeyCode::Char('e')));
23110        assert!(!app.insights_show_explanations);
23111
23112        // Toggle calc proof
23113        app.update(key(KeyCode::Char('x')));
23114        assert!(app.insights_show_calc_proof);
23115
23116        // Switch focus
23117        app.update(key(KeyCode::Char('l')));
23118        assert_eq!(app.focus, FocusPane::Detail);
23119
23120        // Return
23121        app.update(key(KeyCode::Char('q')));
23122        assert_eq!(app.mode, ViewMode::Main);
23123    }
23124
23125    #[test]
23126    fn keyflow_main_to_graph_search_return() {
23127        let mut app = new_app(ViewMode::Main, 0);
23128
23129        // Enter graph
23130        app.update(key(KeyCode::Char('g')));
23131        assert_eq!(app.mode, ViewMode::Graph);
23132
23133        // Navigate
23134        app.update(key(KeyCode::Char('j')));
23135        app.update(key(KeyCode::Char('j')));
23136        assert!(app.selected >= 2);
23137
23138        // Search
23139        app.update(key(KeyCode::Char('/')));
23140        assert!(app.graph_search_active);
23141        app.update(key(KeyCode::Char('B')));
23142        app.update(key(KeyCode::Enter));
23143        assert!(!app.graph_search_active);
23144
23145        // n/N cycling
23146        app.update(key(KeyCode::Char('n')));
23147
23148        // Escape returns to main
23149        app.update(key(KeyCode::Escape));
23150        assert_eq!(app.mode, ViewMode::Main);
23151    }
23152
23153    #[test]
23154    fn keyflow_history_bead_to_git_search_graph_jump() {
23155        let mut app = new_app(ViewMode::Main, 0);
23156
23157        // Enter history
23158        app.update(key(KeyCode::Char('h')));
23159        assert_eq!(app.mode, ViewMode::History);
23160
23161        // Toggle to git mode
23162        app.update(key(KeyCode::Char('v')));
23163        assert_eq!(app.history_view_mode, HistoryViewMode::Git);
23164
23165        // Back to bead mode
23166        app.update(key(KeyCode::Char('v')));
23167        assert_eq!(app.history_view_mode, HistoryViewMode::Bead);
23168
23169        // Search in bead mode
23170        app.update(key(KeyCode::Char('/')));
23171        assert!(app.history_search_active);
23172        app.update(key(KeyCode::Char('C')));
23173        app.update(key(KeyCode::Enter));
23174        assert!(!app.history_search_active);
23175
23176        // n/N cycling
23177        app.update(key(KeyCode::Char('n')));
23178
23179        // Confidence cycling
23180        app.update(key(KeyCode::Char('c')));
23181        assert_ne!(app.history_confidence_index, 0);
23182
23183        // Jump to graph
23184        app.update(key(KeyCode::Char('g')));
23185        assert_eq!(app.mode, ViewMode::Graph);
23186
23187        // Return to main
23188        app.update(key(KeyCode::Char('q')));
23189        assert_eq!(app.mode, ViewMode::Main);
23190    }
23191
23192    #[test]
23193    fn keyflow_filter_navigation_preserves_selection() {
23194        let mut app = new_app(ViewMode::Main, 0);
23195
23196        // Apply open filter
23197        app.update(key(KeyCode::Char('o')));
23198        assert_eq!(app.list_filter, ListFilter::Open);
23199        let open_count = app.visible_issue_indices().len();
23200
23201        // Navigate within filtered list
23202        for _ in 0..3 {
23203            app.update(key(KeyCode::Char('j')));
23204        }
23205
23206        // Switch to closed filter
23207        app.update(key(KeyCode::Char('c')));
23208        assert_eq!(app.list_filter, ListFilter::Closed);
23209        let closed_count = app.visible_issue_indices().len();
23210        assert_ne!(open_count, closed_count);
23211
23212        // Clear to all
23213        app.update(key(KeyCode::Char('a')));
23214        assert_eq!(app.list_filter, ListFilter::All);
23215    }
23216
23217    #[test]
23218    fn keyflow_main_to_actionable_return() {
23219        let mut app = new_app(ViewMode::Main, 0);
23220
23221        app.update(key(KeyCode::Char('a')));
23222        assert_eq!(app.mode, ViewMode::Actionable);
23223        assert!(app.actionable_plan.is_some());
23224        assert_eq!(app.focus, FocusPane::List);
23225
23226        app.update(key(KeyCode::Tab));
23227        assert_eq!(app.focus, FocusPane::Detail);
23228        assert!(app.detail_panel_text().contains("Claim:"));
23229
23230        app.update(key(KeyCode::Char('j')));
23231        app.update(key(KeyCode::Char('a')));
23232        assert_eq!(app.mode, ViewMode::Main);
23233        assert_eq!(app.focus, FocusPane::List);
23234    }
23235
23236    #[test]
23237    fn keyflow_main_search_reacts_to_filter_changes() {
23238        let mut app = new_app(ViewMode::Main, 0);
23239
23240        app.update(key(KeyCode::Char('o')));
23241        assert_eq!(app.list_filter, ListFilter::Open);
23242
23243        app.update(key(KeyCode::Char('/')));
23244        app.update(key(KeyCode::Char('d')));
23245        // "d" matches A (via description) and B (via title "Dependent"); cursor=0 selects A
23246        assert_eq!(selected_issue_id(&app), "A");
23247        app.update(key(KeyCode::Enter));
23248
23249        let open_only = app.list_panel_text();
23250        assert!(open_only.contains("Search: /d (n/N cycles)"));
23251        assert!(open_only.contains("Matches: 1/2"));
23252
23253        app.update(key(KeyCode::Char('a')));
23254        assert_eq!(app.mode, ViewMode::Main);
23255        assert_eq!(app.list_filter, ListFilter::All);
23256
23257        let all_issues = app.list_panel_text();
23258        assert!(all_issues.contains("Search: /d (n/N cycles)"));
23259        // All filter: A (desc), B (title), C (title "Closed" ends with 'd') = 3 matches
23260        assert!(all_issues.contains("Matches: 1/3"));
23261
23262        app.update(key(KeyCode::Char('n')));
23263        assert_eq!(selected_issue_id(&app), "B");
23264        assert!(app.list_panel_text().contains("Matches: 2/3"));
23265    }
23266
23267    #[test]
23268    fn actionable_all_shortcut_clears_filter_before_toggling_view() {
23269        let mut app = new_app(ViewMode::Main, 0);
23270
23271        app.update(key(KeyCode::Char('o')));
23272        assert_eq!(app.list_filter, ListFilter::Open);
23273
23274        app.update(key(KeyCode::Char('a')));
23275        assert_eq!(app.mode, ViewMode::Main);
23276        assert_eq!(app.list_filter, ListFilter::All);
23277
23278        app.update(key(KeyCode::Char('a')));
23279        assert_eq!(app.mode, ViewMode::Actionable);
23280        assert!(app.actionable_plan.is_some());
23281    }
23282
23283    #[test]
23284    fn keyflow_help_then_modal_then_quit() {
23285        let mut app = new_app(ViewMode::Main, 0);
23286
23287        // Open help
23288        app.update(key(KeyCode::Char('?')));
23289        assert!(app.show_help);
23290
23291        // Scroll help
23292        app.update(key(KeyCode::Char('j')));
23293        app.update(key(KeyCode::Char('j')));
23294
23295        // Close help
23296        app.update(key(KeyCode::Escape));
23297        assert!(!app.show_help);
23298
23299        // Open tutorial
23300        app.open_tutorial();
23301        assert!(matches!(app.modal_overlay, Some(ModalOverlay::Tutorial)));
23302
23303        // Dismiss
23304        app.update(key(KeyCode::Enter));
23305        assert!(app.modal_overlay.is_none());
23306
23307        // Open confirm
23308        app.open_confirm_with_resume("Delete?", "Are you sure?", None);
23309        assert!(matches!(
23310            app.modal_overlay,
23311            Some(ModalOverlay::Confirm { .. })
23312        ));
23313
23314        // Reject
23315        app.update(key(KeyCode::Char('n')));
23316        assert!(app.modal_overlay.is_none());
23317        assert_eq!(app.modal_confirm_result, Some(false));
23318
23319        // Quit confirm flow
23320        app.update(key(KeyCode::Escape));
23321        assert!(app.show_quit_confirm);
23322        let cmd = app.update(key(KeyCode::Char('y')));
23323        assert!(matches!(cmd, Cmd::Quit));
23324    }
23325
23326    #[test]
23327    fn keyflow_sort_cycle_persists_across_modes() {
23328        let mut app = new_app(ViewMode::Main, 0);
23329
23330        // Cycle sort
23331        app.update(key(KeyCode::Char('s')));
23332        let sort_after = app.list_sort;
23333        assert_ne!(sort_after, ListSort::Default);
23334
23335        // Enter board and return
23336        app.update(key(KeyCode::Char('b')));
23337        app.update(key(KeyCode::Char('q')));
23338        assert_eq!(app.mode, ViewMode::Main);
23339        assert_eq!(app.list_sort, sort_after, "sort should persist");
23340    }
23341
23342    #[test]
23343    fn keyflow_pages_wizard_full_flow() {
23344        let mut app = new_app(ViewMode::Main, 0);
23345
23346        app.open_pages_wizard();
23347        assert!(matches!(
23348            app.modal_overlay,
23349            Some(ModalOverlay::PagesWizard(_))
23350        ));
23351
23352        // Step 0 → 1 (export dir)
23353        app.update(key(KeyCode::Enter));
23354        // Step 1 → 2 (title)
23355        app.update(key(KeyCode::Enter));
23356        // Step 2: toggle include_closed (true→false)
23357        app.update(key(KeyCode::Char('c')));
23358        // Step 2: toggle include_history (true→false)
23359        app.update(key(KeyCode::Char('h')));
23360        // Step 2 → 3 (review)
23361        app.update(key(KeyCode::Enter));
23362
23363        // Verify we're at step 3 with toggled options
23364        match &app.modal_overlay {
23365            Some(ModalOverlay::PagesWizard(wiz)) => {
23366                assert_eq!(wiz.step, 3);
23367                assert!(!wiz.include_closed, "should have toggled off");
23368                assert!(!wiz.include_history, "should have toggled off");
23369            }
23370            _ => panic!("expected PagesWizard at step 3"),
23371        }
23372
23373        // Go back
23374        app.update(key(KeyCode::Backspace));
23375        match &app.modal_overlay {
23376            Some(ModalOverlay::PagesWizard(wiz)) => assert_eq!(wiz.step, 2),
23377            _ => panic!("expected step 2"),
23378        }
23379
23380        // Forward again and finish
23381        app.update(key(KeyCode::Enter));
23382        app.update(key(KeyCode::Enter));
23383        assert!(matches!(
23384            app.modal_overlay,
23385            Some(ModalOverlay::Confirm { .. })
23386        ));
23387        app.update(key(KeyCode::Char('y')));
23388        assert!(app.modal_overlay.is_none());
23389    }
23390
23391    #[test]
23392    fn keyflow_full_mode_tour() {
23393        // Journey: Main → Board → Main → Insights → Main → Graph → Main → Actionable → Main → History → Main
23394        let mut app = new_app(ViewMode::Main, 0);
23395
23396        for (toggle_key, expected_mode) in [
23397            ('b', ViewMode::Board),
23398            ('b', ViewMode::Main), // toggle back
23399            ('i', ViewMode::Insights),
23400            ('i', ViewMode::Main), // toggle back
23401            ('g', ViewMode::Graph),
23402            ('g', ViewMode::Main), // toggle back
23403            ('a', ViewMode::Actionable),
23404            ('a', ViewMode::Main), // toggle back
23405            ('h', ViewMode::History),
23406        ] {
23407            app.update(key(KeyCode::Char(toggle_key)));
23408            assert_eq!(
23409                app.mode, expected_mode,
23410                "after pressing '{toggle_key}' expected {expected_mode:?}"
23411            );
23412        }
23413
23414        // History returns via Escape
23415        app.update(key(KeyCode::Escape));
23416        assert_eq!(app.mode, ViewMode::Main);
23417
23418        // Verify complete trace
23419        assert_eq!(app.key_trace.len(), 10);
23420    }
23421
23422    #[test]
23423    fn keyflow_detail_dep_navigation_journey() {
23424        let mut app = new_app(ViewMode::Main, 0);
23425
23426        // Select issue B (index 1 in sample_issues) which has a dependency on A
23427        app.update(key(KeyCode::Char('j'))); // move to B
23428        assert_eq!(selected_issue_id(&app), "B");
23429
23430        // Enter graph mode (deps visible)
23431        app.update(key(KeyCode::Char('g')));
23432        assert_eq!(app.mode, ViewMode::Graph);
23433
23434        // Focus on detail
23435        app.update(key(KeyCode::Tab));
23436        assert_eq!(app.focus, FocusPane::Detail);
23437
23438        // J/K dep navigation
23439        app.update(key(KeyCode::Char('J')));
23440        app.update(key(KeyCode::Char('K')));
23441
23442        // Return to list focus
23443        app.update(key(KeyCode::Tab));
23444        assert_eq!(app.focus, FocusPane::List);
23445
23446        app.update(key(KeyCode::Char('q')));
23447        assert_eq!(app.mode, ViewMode::Main);
23448    }
23449
23450    // =====================================================================
23451    // Regression Harness: Snapshot + Keyflow Combined
23452    // =====================================================================
23453    //
23454    // These tests replay a keyflow and then snapshot the rendered output,
23455    // catching both behavioral and visual regressions.
23456
23457    #[test]
23458    fn snap_after_board_search() {
23459        let mut app = new_app(ViewMode::Board, 0);
23460        app.update(key(KeyCode::Char('/')));
23461        app.update(key(KeyCode::Char('B')));
23462        app.update(key(KeyCode::Enter));
23463
23464        let mut pool = ftui::GraphemePool::default();
23465        let mut frame = ftui::render::frame::Frame::new(100, 30, &mut pool);
23466        app.view(&mut frame);
23467        let text = super::buffer_to_text(&frame.buffer, &pool);
23468        insta::assert_snapshot!(text);
23469    }
23470
23471    #[test]
23472    fn snap_after_insights_panel_cycle() {
23473        let mut app = new_app(ViewMode::Insights, 0);
23474        // Cycle through 3 panels
23475        app.update(key(KeyCode::Char('s')));
23476        app.update(key(KeyCode::Char('s')));
23477        app.update(key(KeyCode::Char('s')));
23478
23479        let mut pool = ftui::GraphemePool::default();
23480        let mut frame = ftui::render::frame::Frame::new(100, 30, &mut pool);
23481        app.view(&mut frame);
23482        let text = super::buffer_to_text(&frame.buffer, &pool);
23483        insta::assert_snapshot!(text);
23484    }
23485
23486    #[test]
23487    fn snap_help_overlay() {
23488        let mut app = new_app(ViewMode::Main, 0);
23489        app.update(key(KeyCode::Char('?')));
23490
23491        let mut pool = ftui::GraphemePool::default();
23492        let mut frame = ftui::render::frame::Frame::new(100, 30, &mut pool);
23493        app.view(&mut frame);
23494        let text = super::buffer_to_text(&frame.buffer, &pool);
23495        insta::assert_snapshot!(text);
23496    }
23497
23498    #[test]
23499    fn snap_history_git_mode() {
23500        let app = history_app_with_git_cache(HistoryViewMode::Git, 0);
23501
23502        let text = render_app(&app, 100, 30);
23503        insta::assert_snapshot!(text);
23504    }
23505
23506    #[test]
23507    fn snap_quit_confirm_modal() {
23508        let mut app = new_app(ViewMode::Main, 0);
23509        app.update(key(KeyCode::Escape));
23510        assert!(app.show_quit_confirm);
23511
23512        let mut pool = ftui::GraphemePool::default();
23513        let mut frame = ftui::render::frame::Frame::new(100, 30, &mut pool);
23514        app.view(&mut frame);
23515        let text = super::buffer_to_text(&frame.buffer, &pool);
23516        insta::assert_snapshot!(text);
23517    }
23518
23519    #[test]
23520    fn snap_confirm_modal() {
23521        let mut app = new_app(ViewMode::Main, 0);
23522        app.open_confirm_with_resume(
23523            "Export Pages?",
23524            "Finalize the current pages export settings?",
23525            None,
23526        );
23527
23528        let text = render_app(&app, 100, 30);
23529        insta::assert_snapshot!(text);
23530    }
23531
23532    #[test]
23533    fn snap_filter_applied() {
23534        let mut app = new_app(ViewMode::Main, 0);
23535        app.update(key(KeyCode::Char('o'))); // open filter
23536
23537        let text = render_app(&app, 100, 30);
23538        insta::assert_snapshot!(text);
23539    }
23540
23541    #[test]
23542    fn snap_main_with_priority_hints() {
23543        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23544        app.update(key(KeyCode::Char('p')));
23545
23546        let text = render_app(&app, 100, 30);
23547        insta::assert_snapshot!(text);
23548    }
23549
23550    #[test]
23551    fn snap_board_detail_focus() {
23552        let mut app = new_app(ViewMode::Board, 1);
23553        app.update(key(KeyCode::Tab));
23554
23555        let text = render_app(&app, 100, 30);
23556        insta::assert_snapshot!(text);
23557    }
23558
23559    #[test]
23560    fn snap_insights_heatmap_drill() {
23561        let mut app = new_app(ViewMode::Insights, 0);
23562        app.update(key(KeyCode::Char('m')));
23563        app.update(key(KeyCode::Enter));
23564
23565        let text = render_app(&app, 100, 30);
23566        insta::assert_snapshot!(text);
23567    }
23568
23569    #[test]
23570    fn snap_graph_detail_focus() {
23571        let mut app = new_app(ViewMode::Graph, 1);
23572        app.update(key(KeyCode::Tab));
23573
23574        let text = render_app(&app, 100, 30);
23575        insta::assert_snapshot!(text);
23576    }
23577
23578    #[test]
23579    fn snap_actionable_detail_focus() {
23580        let mut app = new_app(ViewMode::Main, 0);
23581        app.update(key(KeyCode::Char('a')));
23582        app.update(key(KeyCode::Tab));
23583
23584        let text = render_app(&app, 100, 30);
23585        insta::assert_snapshot!(text);
23586    }
23587
23588    // -- Attention view tests ------------------------------------------------
23589
23590    fn labeled_issues() -> Vec<Issue> {
23591        vec![
23592            Issue {
23593                id: "A".to_string(),
23594                title: "Feature work".to_string(),
23595                status: "open".to_string(),
23596                issue_type: "feature".to_string(),
23597                priority: 1,
23598                labels: vec!["backend".to_string(), "urgent".to_string()],
23599                ..Issue::default()
23600            },
23601            Issue {
23602                id: "B".to_string(),
23603                title: "Bug fix".to_string(),
23604                status: "open".to_string(),
23605                issue_type: "bug".to_string(),
23606                priority: 2,
23607                labels: vec!["backend".to_string()],
23608                ..Issue::default()
23609            },
23610            Issue {
23611                id: "C".to_string(),
23612                title: "UI polish".to_string(),
23613                status: "open".to_string(),
23614                issue_type: "task".to_string(),
23615                priority: 3,
23616                labels: vec!["frontend".to_string()],
23617                ..Issue::default()
23618            },
23619        ]
23620    }
23621
23622    #[test]
23623    fn attention_view_toggle_and_state() {
23624        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23625
23626        // Press ! to enter Attention mode
23627        app.update(key(KeyCode::Char('!')));
23628        assert!(matches!(app.mode, ViewMode::Attention));
23629        assert!(app.attention_result.is_some());
23630        assert_eq!(app.attention_cursor, 0);
23631
23632        // Press ! again to return to Main
23633        app.update(key(KeyCode::Char('!')));
23634        assert!(matches!(app.mode, ViewMode::Main));
23635    }
23636
23637    #[test]
23638    fn attention_view_navigation() {
23639        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23640        app.update(key(KeyCode::Char('!')));
23641        assert!(matches!(app.mode, ViewMode::Attention));
23642
23643        let label_count = app.attention_result.as_ref().unwrap().labels.len();
23644        assert!(label_count >= 2, "should have at least 2 labels");
23645
23646        // Navigate down
23647        app.update(key(KeyCode::Char('j')));
23648        assert_eq!(app.attention_cursor, 1);
23649
23650        // Navigate up
23651        app.update(key(KeyCode::Char('k')));
23652        assert_eq!(app.attention_cursor, 0);
23653
23654        // Can't go above 0
23655        app.update(key(KeyCode::Char('k')));
23656        assert_eq!(app.attention_cursor, 0);
23657    }
23658
23659    #[test]
23660    fn attention_view_renders_list_and_detail() {
23661        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23662        app.update(key(KeyCode::Char('!')));
23663
23664        let list = app.list_panel_text();
23665        assert!(list.contains("Rank"));
23666        assert!(list.contains("Label"));
23667        assert!(list.contains("Score"));
23668
23669        let detail = app.detail_panel_text();
23670        assert!(detail.contains("Label:"));
23671        assert!(detail.contains("Attention Score:"));
23672        assert!(detail.contains("Breakdown:"));
23673    }
23674
23675    #[test]
23676    fn attention_view_empty_issues_no_panic() {
23677        let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
23678        app.update(key(KeyCode::Char('!')));
23679        assert!(matches!(app.mode, ViewMode::Attention));
23680
23681        let list = app.list_panel_text();
23682        // Empty issues triggers early return before mode dispatch
23683        assert!(list.contains("no issues loaded"));
23684
23685        // Navigation on empty should not panic
23686        app.update(key(KeyCode::Char('j')));
23687        app.update(key(KeyCode::Char('k')));
23688    }
23689
23690    // -- Refresh tests -------------------------------------------------------
23691
23692    #[test]
23693    fn refresh_keybinding_does_not_panic() {
23694        let mut app = new_app(ViewMode::Main, 0);
23695
23696        // Ctrl+R — silently fails (no disk data) but doesn't panic
23697        app.update(Msg::KeyPress(KeyCode::Char('r'), Modifiers::CTRL));
23698        assert!(matches!(app.mode, ViewMode::Main));
23699
23700        // F5 — same behavior
23701        app.update(key(KeyCode::F(5)));
23702        assert!(matches!(app.mode, ViewMode::Main));
23703    }
23704
23705    #[test]
23706    fn refresh_preserves_mode() {
23707        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23708        app.update(key(KeyCode::Char('!')));
23709        assert!(matches!(app.mode, ViewMode::Attention));
23710
23711        // Refresh in Attention mode — fails silently but stays in Attention
23712        app.update(Msg::KeyPress(KeyCode::Char('r'), Modifiers::CTRL));
23713        assert!(matches!(app.mode, ViewMode::Attention));
23714    }
23715
23716    // -- Priority hints tests ------------------------------------------------
23717
23718    #[test]
23719    fn priority_hints_toggle() {
23720        let mut app = new_app(ViewMode::Main, 0);
23721        assert!(!app.priority_hints_visible);
23722
23723        app.update(key(KeyCode::Char('p')));
23724        assert!(app.priority_hints_visible);
23725
23726        app.update(key(KeyCode::Char('p')));
23727        assert!(!app.priority_hints_visible);
23728    }
23729
23730    #[test]
23731    fn priority_hints_show_breakdown_in_detail() {
23732        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23733        app.update(key(KeyCode::Char('p')));
23734        assert!(app.priority_hints_visible);
23735
23736        let detail = app.detail_panel_text();
23737        assert!(detail.contains("Priority Hints"));
23738        assert!(detail.contains("Triage Score:") || detail.contains("not in triage"));
23739    }
23740
23741    #[test]
23742    fn priority_hints_only_in_main_mode() {
23743        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23744
23745        // Switch to Board mode — p should NOT toggle hints
23746        app.update(key(KeyCode::Char('b')));
23747        assert!(matches!(app.mode, ViewMode::Board));
23748        app.update(key(KeyCode::Char('p')));
23749        assert!(
23750            !app.priority_hints_visible,
23751            "p should not toggle hints in Board mode"
23752        );
23753    }
23754
23755    // -- Export/clipboard/editor tests ---------------------------------------
23756
23757    #[test]
23758    fn copy_issue_id_sets_status_msg() {
23759        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23760
23761        // C key should attempt clipboard (may fail in test env, but shouldn't panic)
23762        app.update(key(KeyCode::Char('C')));
23763        assert!(
23764            app.status_msg.contains("Copied") || app.status_msg.contains("Clipboard"),
23765            "status_msg should indicate clipboard result: {}",
23766            app.status_msg
23767        );
23768    }
23769
23770    #[test]
23771    fn export_markdown_creates_temp_file() {
23772        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23773        app.update(key(KeyCode::Char('x')));
23774        assert!(
23775            app.status_msg.contains("Exported"),
23776            "should confirm export: {}",
23777            app.status_msg
23778        );
23779    }
23780
23781    #[test]
23782    fn status_msg_cleared_on_next_key() {
23783        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
23784        app.status_msg = "Test message".to_string();
23785
23786        // Any key press should clear the status msg
23787        app.update(key(KeyCode::Char('j')));
23788        assert!(app.status_msg.is_empty());
23789    }
23790
23791    // -- TimeTravelDiff mode tests -------------------------------------------
23792
23793    #[test]
23794    fn t_key_enters_time_travel_mode_with_input_prompt() {
23795        let mut app = new_app(ViewMode::Main, 0);
23796        app.update(key(KeyCode::Char('t')));
23797        assert_eq!(app.mode, ViewMode::TimeTravelDiff);
23798        assert!(app.time_travel_input_active);
23799        assert!(app.time_travel_ref_input.is_empty());
23800    }
23801
23802    #[test]
23803    fn time_travel_escape_from_empty_input_returns_to_main() {
23804        let mut app = new_app(ViewMode::Main, 0);
23805        app.update(key(KeyCode::Char('t')));
23806        assert_eq!(app.mode, ViewMode::TimeTravelDiff);
23807        app.update(key(KeyCode::Escape));
23808        assert_eq!(app.mode, ViewMode::Main);
23809        assert!(!app.time_travel_input_active);
23810    }
23811
23812    #[test]
23813    fn time_travel_input_accepts_characters() {
23814        let mut app = new_app(ViewMode::Main, 0);
23815        app.update(key(KeyCode::Char('t')));
23816        app.update(key(KeyCode::Char('H')));
23817        app.update(key(KeyCode::Char('E')));
23818        app.update(key(KeyCode::Char('A')));
23819        app.update(key(KeyCode::Char('D')));
23820        assert_eq!(app.time_travel_ref_input, "HEAD");
23821    }
23822
23823    #[test]
23824    fn time_travel_backspace_removes_char() {
23825        let mut app = new_app(ViewMode::Main, 0);
23826        app.update(key(KeyCode::Char('t')));
23827        app.update(key(KeyCode::Char('a')));
23828        app.update(key(KeyCode::Char('b')));
23829        app.update(key(KeyCode::Backspace));
23830        assert_eq!(app.time_travel_ref_input, "a");
23831    }
23832
23833    #[test]
23834    fn time_travel_enter_with_empty_ref_returns_to_main() {
23835        let mut app = new_app(ViewMode::Main, 0);
23836        app.update(key(KeyCode::Char('t')));
23837        app.update(key(KeyCode::Enter));
23838        // Empty ref cancels
23839        assert_eq!(app.mode, ViewMode::Main);
23840        assert!(!app.time_travel_input_active);
23841    }
23842
23843    #[test]
23844    fn time_travel_empty_ref_with_existing_diff_stays_in_mode() {
23845        let mut app = new_app(ViewMode::Main, 0);
23846        app.mode = ViewMode::TimeTravelDiff;
23847        app.time_travel_input_active = true;
23848        app.time_travel_last_ref = Some("HEAD~1".to_string());
23849        app.time_travel_diff = Some(crate::analysis::diff::compare_snapshots(
23850            &[],
23851            &app.analyzer.issues,
23852        ));
23853
23854        app.update(key(KeyCode::Enter));
23855
23856        assert_eq!(app.mode, ViewMode::TimeTravelDiff);
23857        assert!(!app.time_travel_input_active);
23858        assert!(app.time_travel_diff.is_some());
23859        assert_eq!(app.time_travel_last_ref.as_deref(), Some("HEAD~1"));
23860        assert_eq!(app.status_msg, "Time-travel: empty ref, cancelled");
23861    }
23862
23863    #[test]
23864    fn time_travel_invalid_ref_sets_error_status_and_keeps_mode() {
23865        let mut app = new_app(ViewMode::Main, 0);
23866        app.update(key(KeyCode::Char('t')));
23867        for ch in "__definitely_not_a_real_ref__".chars() {
23868            app.update(key(KeyCode::Char(ch)));
23869        }
23870
23871        app.update(key(KeyCode::Enter));
23872
23873        assert_eq!(app.mode, ViewMode::TimeTravelDiff);
23874        assert!(!app.time_travel_input_active);
23875        assert!(app.time_travel_diff.is_none());
23876        assert_eq!(
23877            app.time_travel_last_ref.as_deref(),
23878            Some("__definitely_not_a_real_ref__")
23879        );
23880        assert!(
23881            app.status_msg
23882                .contains("could not resolve '__definitely_not_a_real_ref__'"),
23883            "status should explain invalid ref: {}",
23884            app.status_msg
23885        );
23886    }
23887
23888    #[test]
23889    fn time_travel_invalid_ref_preserves_existing_diff() {
23890        let mut app = new_app(ViewMode::Main, 0);
23891        let existing = crate::analysis::diff::compare_snapshots(&[], &app.analyzer.issues);
23892        let existing_new_count = existing.new_issues.as_ref().map_or(0, Vec::len);
23893        app.mode = ViewMode::TimeTravelDiff;
23894        app.time_travel_input_active = true;
23895        app.time_travel_last_ref = Some("HEAD~1".to_string());
23896        app.time_travel_diff = Some(existing);
23897        app.time_travel_ref_input = "__still_not_a_real_ref__".to_string();
23898
23899        app.update(key(KeyCode::Enter));
23900
23901        assert_eq!(app.mode, ViewMode::TimeTravelDiff);
23902        assert!(!app.time_travel_input_active);
23903        let retained_new_count = app
23904            .time_travel_diff
23905            .as_ref()
23906            .and_then(|diff| diff.new_issues.as_ref())
23907            .map_or(0, Vec::len);
23908        assert_eq!(retained_new_count, existing_new_count);
23909        assert_eq!(
23910            app.time_travel_last_ref.as_deref(),
23911            Some("__still_not_a_real_ref__")
23912        );
23913        assert!(
23914            app.status_msg
23915                .contains("could not resolve '__still_not_a_real_ref__'"),
23916            "status should explain invalid ref: {}",
23917            app.status_msg
23918        );
23919    }
23920
23921    #[test]
23922    fn time_travel_toggle_off() {
23923        let mut app = new_app(ViewMode::Main, 0);
23924        // Enter time-travel, then provide a diff manually
23925        app.mode = ViewMode::TimeTravelDiff;
23926        app.time_travel_input_active = false;
23927        app.time_travel_diff = Some(crate::analysis::diff::compare_snapshots(
23928            &app.analyzer.issues.clone(),
23929            &app.analyzer.issues,
23930        ));
23931        // Press t again to toggle off
23932        app.update(key(KeyCode::Char('t')));
23933        assert_eq!(app.mode, ViewMode::Main);
23934    }
23935
23936    #[test]
23937    fn time_travel_jk_navigates_categories() {
23938        let mut app = new_app(ViewMode::Main, 0);
23939        app.mode = ViewMode::TimeTravelDiff;
23940        app.time_travel_input_active = false;
23941        // Provide a self-diff (will have 0 categories, but navigation shouldn't panic)
23942        app.time_travel_diff = Some(crate::analysis::diff::compare_snapshots(
23943            &[],
23944            &app.analyzer.issues,
23945        ));
23946        app.update(key(KeyCode::Char('j')));
23947        app.update(key(KeyCode::Char('k')));
23948        // Should not panic
23949    }
23950
23951    #[test]
23952    fn time_travel_list_text_shows_prompt_when_input_active() {
23953        let mut app = new_app(ViewMode::Main, 0);
23954        app.mode = ViewMode::TimeTravelDiff;
23955        app.time_travel_input_active = true;
23956        app.time_travel_ref_input = "HEAD~3".to_string();
23957        let text = app.time_travel_list_text();
23958        assert!(text.contains("HEAD~3"), "should show input: {text}");
23959    }
23960
23961    #[test]
23962    fn time_travel_list_text_shows_no_diff_when_empty() {
23963        let mut app = new_app(ViewMode::Main, 0);
23964        app.mode = ViewMode::TimeTravelDiff;
23965        app.time_travel_input_active = false;
23966        let text = app.time_travel_list_text();
23967        assert!(
23968            text.contains("No diff loaded"),
23969            "should show no-diff message: {text}"
23970        );
23971    }
23972
23973    #[test]
23974    fn time_travel_with_diff_shows_summary() {
23975        let mut app = new_app(ViewMode::Main, 0);
23976        app.mode = ViewMode::TimeTravelDiff;
23977        app.time_travel_input_active = false;
23978        app.time_travel_last_ref = Some("HEAD~1".to_string());
23979        // Diff from empty to current issues shows new_issues
23980        app.time_travel_diff = Some(crate::analysis::diff::compare_snapshots(
23981            &[],
23982            &app.analyzer.issues,
23983        ));
23984        let text = app.time_travel_list_text();
23985        assert!(text.contains("HEAD~1"), "should show ref: {text}");
23986        assert!(
23987            text.contains("New issues"),
23988            "should show new issues category: {text}"
23989        );
23990    }
23991
23992    // -- Sprint view tests ---------------------------------------------------
23993
23994    fn make_sprint(id: &str, name: &str, bead_ids: Vec<&str>) -> Sprint {
23995        let now = sprint_reference_now();
23996        Sprint {
23997            id: id.to_string(),
23998            name: name.to_string(),
23999            start_date: Some(now - chrono::Duration::days(7)),
24000            end_date: Some(now + chrono::Duration::days(7)),
24001            bead_ids: bead_ids.into_iter().map(String::from).collect(),
24002        }
24003    }
24004
24005    #[test]
24006    fn s_key_toggles_sprint_mode() {
24007        let mut app = new_app(ViewMode::Main, 0);
24008        app.update(key(KeyCode::Char('S')));
24009        assert_eq!(app.mode, ViewMode::Sprint, "S should enter Sprint mode");
24010        app.update(key(KeyCode::Char('S')));
24011        assert_eq!(app.mode, ViewMode::Main, "S again should return to Main");
24012    }
24013
24014    #[test]
24015    fn sprint_list_shows_no_sprints_message() {
24016        let mut app = new_app(ViewMode::Main, 0);
24017        app.mode = ViewMode::Sprint;
24018        app.sprint_data = Vec::new();
24019        let text = app.sprint_list_text();
24020        assert!(
24021            text.contains("No sprints found"),
24022            "should show no-sprints message: {text}"
24023        );
24024    }
24025
24026    #[test]
24027    fn sprint_list_shows_sprint_summary() {
24028        let mut app = new_app(ViewMode::Main, 0);
24029        app.mode = ViewMode::Sprint;
24030        // Create a sprint referencing sample issues (A, B, C from sample_issues())
24031        app.sprint_data = vec![make_sprint("s1", "Sprint Alpha", vec!["A", "B", "C"])];
24032        let text = app.sprint_list_text();
24033        assert!(
24034            text.contains("Sprint Alpha"),
24035            "should show sprint name: {text}"
24036        );
24037        assert!(text.contains("3 issues"), "should show issue count: {text}");
24038        assert!(text.contains("ACTIVE"), "should show active status: {text}");
24039    }
24040
24041    #[test]
24042    fn sprint_detail_shows_issue_list() {
24043        let mut app = new_app(ViewMode::Main, 0);
24044        app.mode = ViewMode::Sprint;
24045        app.sprint_data = vec![make_sprint("s1", "Sprint Alpha", vec!["A", "B", "C"])];
24046        app.sprint_cursor = 0;
24047        let text = app.sprint_detail_text();
24048        assert!(
24049            text.contains("SPRINT: Sprint Alpha"),
24050            "should show sprint name: {text}"
24051        );
24052        assert!(text.contains("ACTIVE"), "should show active status: {text}");
24053        // Should list at least some issues
24054        assert!(
24055            text.contains("Issues:") || text.contains("bead(s)"),
24056            "should show issue summary: {text}"
24057        );
24058    }
24059
24060    #[test]
24061    fn sprint_jk_navigates_sprints() {
24062        let mut app = new_app(ViewMode::Main, 0);
24063        app.mode = ViewMode::Sprint;
24064        app.sprint_data = vec![
24065            make_sprint("s1", "Sprint 1", vec!["A"]),
24066            make_sprint("s2", "Sprint 2", vec!["B"]),
24067        ];
24068        app.focus = FocusPane::List;
24069        assert_eq!(app.sprint_cursor, 0);
24070        app.update(key(KeyCode::Char('j')));
24071        assert_eq!(app.sprint_cursor, 1, "j should move to next sprint");
24072        app.update(key(KeyCode::Char('k')));
24073        assert_eq!(app.sprint_cursor, 0, "k should move back");
24074    }
24075
24076    #[test]
24077    fn sprint_jk_navigates_issues_in_detail() {
24078        let mut app = new_app(ViewMode::Main, 0);
24079        app.mode = ViewMode::Sprint;
24080        app.sprint_data = vec![make_sprint("s1", "Sprint 1", vec!["A", "B", "C"])];
24081        app.focus = FocusPane::Detail;
24082        assert_eq!(app.sprint_issue_cursor, 0);
24083        app.update(key(KeyCode::Char('j')));
24084        assert_eq!(
24085            app.sprint_issue_cursor, 1,
24086            "j in detail should move issue cursor"
24087        );
24088        app.update(key(KeyCode::Char('k')));
24089        assert_eq!(app.sprint_issue_cursor, 0, "k should move back");
24090    }
24091
24092    #[test]
24093    fn sprint_escape_returns_to_main() {
24094        let mut app = new_app(ViewMode::Sprint, 0);
24095        app.sprint_data = vec![make_sprint("s1", "Sprint 1", vec!["A"])];
24096        app.update(key(KeyCode::Char('S')));
24097        assert_eq!(
24098            app.mode,
24099            ViewMode::Main,
24100            "S in Sprint mode should return to Main"
24101        );
24102    }
24103
24104    #[test]
24105    fn sprint_detail_shows_progress_bar() {
24106        let mut app = new_app(ViewMode::Main, 0);
24107        app.mode = ViewMode::Sprint;
24108        // C is closed in sample_issues()
24109        app.sprint_data = vec![make_sprint("s1", "Sprint Alpha", vec!["A", "B", "C"])];
24110        app.sprint_cursor = 0;
24111        let text = app.sprint_detail_text();
24112        assert!(
24113            text.contains("Progress:"),
24114            "should show progress bar: {text}"
24115        );
24116        assert!(text.contains('%'), "should show percentage: {text}");
24117    }
24118
24119    #[test]
24120    fn sprint_cursor_reset_on_sprint_change() {
24121        let mut app = new_app(ViewMode::Main, 0);
24122        app.mode = ViewMode::Sprint;
24123        app.sprint_data = vec![
24124            make_sprint("s1", "Sprint 1", vec!["A", "B"]),
24125            make_sprint("s2", "Sprint 2", vec!["C"]),
24126        ];
24127        app.focus = FocusPane::List;
24128        app.sprint_issue_cursor = 1;
24129        // Navigate to next sprint
24130        app.update(key(KeyCode::Char('j')));
24131        assert_eq!(
24132            app.sprint_issue_cursor, 0,
24133            "issue cursor should reset on sprint change"
24134        );
24135    }
24136
24137    // -- Modal picker tests ---------------------------------------------------
24138
24139    #[test]
24140    fn quote_key_opens_recipe_picker() {
24141        let mut app = new_app(ViewMode::Main, 0);
24142        app.update(key(KeyCode::Char('\'')));
24143        assert!(
24144            matches!(app.modal_overlay, Some(ModalOverlay::RecipePicker { .. })),
24145            "' should open recipe picker"
24146        );
24147    }
24148
24149    #[test]
24150    fn recipe_picker_jk_navigates() {
24151        let mut app = new_app(ViewMode::Main, 0);
24152        app.update(key(KeyCode::Char('\'')));
24153        if let Some(ModalOverlay::RecipePicker { cursor, .. }) = &app.modal_overlay {
24154            assert_eq!(*cursor, 0);
24155        }
24156        app.update(key(KeyCode::Char('j')));
24157        if let Some(ModalOverlay::RecipePicker { cursor, .. }) = &app.modal_overlay {
24158            assert_eq!(*cursor, 1, "j should advance cursor");
24159        }
24160        app.update(key(KeyCode::Char('k')));
24161        if let Some(ModalOverlay::RecipePicker { cursor, .. }) = &app.modal_overlay {
24162            assert_eq!(*cursor, 0, "k should go back");
24163        }
24164    }
24165
24166    #[test]
24167    fn recipe_picker_escape_closes() {
24168        let mut app = new_app(ViewMode::Main, 0);
24169        app.update(key(KeyCode::Char('\'')));
24170        assert!(app.modal_overlay.is_some());
24171        app.update(key(KeyCode::Escape));
24172        assert!(
24173            app.modal_overlay.is_none(),
24174            "Esc should close recipe picker"
24175        );
24176    }
24177
24178    #[test]
24179    fn recipe_picker_enter_selects_and_closes() {
24180        let mut app = new_app(ViewMode::Main, 0);
24181        app.update(key(KeyCode::Char('\'')));
24182        app.update(key(KeyCode::Enter));
24183        assert!(
24184            app.modal_overlay.is_none(),
24185            "Enter should close recipe picker"
24186        );
24187        assert!(
24188            app.status_msg.contains("Recipe:"),
24189            "should show recipe name in status: {}",
24190            app.status_msg
24191        );
24192    }
24193
24194    #[test]
24195    fn capital_l_opens_label_picker() {
24196        let mut app = new_app(ViewMode::Main, 0);
24197        app.update(key(KeyCode::Char('L')));
24198        assert!(
24199            matches!(app.modal_overlay, Some(ModalOverlay::LabelPicker { .. })),
24200            "L should open label picker"
24201        );
24202        // sample_issues have "core" and "parity" labels on issue A
24203        if let Some(ModalOverlay::LabelPicker { items, .. }) = &app.modal_overlay {
24204            assert!(!items.is_empty(), "should have labels from issues");
24205        }
24206    }
24207
24208    #[test]
24209    fn label_picker_enter_filters_by_label() {
24210        let mut app = new_app(ViewMode::Main, 0);
24211        app.update(key(KeyCode::Char('L')));
24212        app.update(key(KeyCode::Enter));
24213        assert!(
24214            app.modal_label_filter.is_some(),
24215            "Enter in label picker should set label filter"
24216        );
24217        assert!(
24218            app.status_msg.contains("Filtering by label"),
24219            "should show filter status: {}",
24220            app.status_msg
24221        );
24222    }
24223
24224    #[test]
24225    fn label_picker_filter_updates_selection_before_next_key() {
24226        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24227        app.selected = 1;
24228
24229        app.update(key(KeyCode::Char('L')));
24230        app.update(key(KeyCode::Down));
24231        app.update(key(KeyCode::Enter));
24232
24233        assert_eq!(app.modal_label_filter.as_deref(), Some("frontend"));
24234        assert_eq!(selected_issue_id(&app), "C");
24235        assert_eq!(
24236            app.selected_issue().map(|issue| issue.id.as_str()),
24237            Some("C")
24238        );
24239        assert!(
24240            app.list_panel_text()
24241                .lines()
24242                .any(|line| line.contains("▸") && line.contains(" C "))
24243        );
24244    }
24245
24246    #[test]
24247    fn label_filter_clears_on_all() {
24248        let mut app = new_app(ViewMode::Main, 0);
24249        app.modal_label_filter = Some("core".to_string());
24250        assert!(app.has_active_filter());
24251        app.set_list_filter(ListFilter::All);
24252        assert!(
24253            app.modal_label_filter.is_none(),
24254            "All filter should clear label filter"
24255        );
24256    }
24257
24258    #[test]
24259    fn w_key_opens_repo_picker_or_status_msg() {
24260        let mut app = new_app(ViewMode::Main, 0);
24261        app.update(key(KeyCode::Char('w')));
24262        // sample_issues have source_repo="viewer" on issue A, "" on others
24263        // So either we get a picker with repos, or a status message
24264        if app.modal_overlay.is_some() {
24265            assert!(
24266                matches!(app.modal_overlay, Some(ModalOverlay::RepoPicker { .. })),
24267                "w should open repo picker when repos exist"
24268            );
24269        } else {
24270            // No repos -> status message
24271            assert!(
24272                app.status_msg.contains("repo") || app.status_msg.contains("workspace"),
24273                "should indicate no repos: {}",
24274                app.status_msg
24275            );
24276        }
24277    }
24278
24279    #[test]
24280    fn repo_picker_filter_updates_selection_before_next_key() {
24281        let mut app = new_app(ViewMode::Main, 2);
24282
24283        app.update(key(KeyCode::Char('w')));
24284        app.update(key(KeyCode::Enter));
24285
24286        assert_eq!(app.modal_repo_filter.as_deref(), Some("viewer"));
24287        assert_eq!(selected_issue_id(&app), "A");
24288        assert_eq!(
24289            app.selected_issue().map(|issue| issue.id.as_str()),
24290            Some("A")
24291        );
24292        assert!(
24293            app.list_panel_text()
24294                .lines()
24295                .any(|line| line.contains("▸") && line.contains(" A "))
24296        );
24297    }
24298
24299    #[test]
24300    fn sprint_with_no_matching_issues_shows_message() {
24301        let mut app = new_app(ViewMode::Main, 0);
24302        app.mode = ViewMode::Sprint;
24303        app.sprint_data = vec![make_sprint(
24304            "s1",
24305            "Sprint Ghost",
24306            vec!["NONEXISTENT-1", "NONEXISTENT-2"],
24307        )];
24308        app.sprint_cursor = 0;
24309        let text = app.sprint_detail_text();
24310        assert!(
24311            text.contains("none matched"),
24312            "should say no issues matched: {text}"
24313        );
24314    }
24315
24316    // =====================================================================
24317    // bd-2ec: Expanded TUI snapshot + keyflow + edge-case coverage
24318    // =====================================================================
24319
24320    // -- Snapshots for newer view modes ------------------------------------
24321
24322    snapshot_test!(snap_actionable_narrow, ViewMode::Actionable, 60, 30);
24323    snapshot_test!(snap_actionable_medium, ViewMode::Actionable, 100, 30);
24324    snapshot_test!(snap_actionable_wide, ViewMode::Actionable, 140, 30);
24325
24326    snapshot_test!(snap_tree_narrow, ViewMode::Tree, 60, 30);
24327    snapshot_test!(snap_tree_medium, ViewMode::Tree, 100, 30);
24328    snapshot_test!(snap_tree_wide, ViewMode::Tree, 140, 30);
24329
24330    snapshot_test!(
24331        snap_label_dashboard_narrow,
24332        ViewMode::LabelDashboard,
24333        60,
24334        30
24335    );
24336    snapshot_test!(
24337        snap_label_dashboard_medium,
24338        ViewMode::LabelDashboard,
24339        100,
24340        30
24341    );
24342    snapshot_test!(snap_label_dashboard_wide, ViewMode::LabelDashboard, 140, 30);
24343
24344    snapshot_test!(snap_flow_matrix_narrow, ViewMode::FlowMatrix, 60, 30);
24345    snapshot_test!(snap_flow_matrix_medium, ViewMode::FlowMatrix, 100, 30);
24346    snapshot_test!(snap_flow_matrix_wide, ViewMode::FlowMatrix, 140, 30);
24347
24348    snapshot_test!(snap_sprint_narrow, ViewMode::Sprint, 60, 30);
24349    snapshot_test!(snap_sprint_medium, ViewMode::Sprint, 100, 30);
24350    snapshot_test!(snap_sprint_wide, ViewMode::Sprint, 140, 30);
24351
24352    snapshot_test!(snap_time_travel_narrow, ViewMode::TimeTravelDiff, 60, 30);
24353    snapshot_test!(snap_time_travel_medium, ViewMode::TimeTravelDiff, 100, 30);
24354    snapshot_test!(snap_time_travel_wide, ViewMode::TimeTravelDiff, 140, 30);
24355
24356    // -- Interactive snapshots for newer view modes -------------------------
24357
24358    #[test]
24359    fn snap_attention_detail_focus() {
24360        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24361        app.update(key(KeyCode::Char('!')));
24362        app.update(key(KeyCode::Tab));
24363        let text = render_app(&app, 100, 30);
24364        insta::assert_snapshot!(text);
24365    }
24366
24367    #[test]
24368    fn snap_tree_detail_focus() {
24369        let mut app = new_app(ViewMode::Main, 0);
24370        app.update(key(KeyCode::Char('T')));
24371        app.update(key(KeyCode::Tab));
24372        let text = render_app(&app, 100, 30);
24373        insta::assert_snapshot!(text);
24374    }
24375
24376    #[test]
24377    fn snap_label_dashboard_detail_focus() {
24378        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24379        app.update(key(KeyCode::Char('[')));
24380        app.update(key(KeyCode::Tab));
24381        let text = render_app(&app, 100, 30);
24382        insta::assert_snapshot!(text);
24383    }
24384
24385    #[test]
24386    fn snap_flow_matrix_detail_focus() {
24387        let mut app = new_app(ViewMode::Main, 0);
24388        app.update(key(KeyCode::Char(']')));
24389        app.update(key(KeyCode::Tab));
24390        let text = render_app(&app, 100, 30);
24391        insta::assert_snapshot!(text);
24392    }
24393
24394    #[test]
24395    fn snap_sprint_detail_focus() {
24396        let mut app = new_app(ViewMode::Main, 0);
24397        app.mode = ViewMode::Sprint;
24398        app.sprint_data = vec![make_sprint("s1", "Sprint Alpha", vec!["A", "B", "C"])];
24399        app.focus = FocusPane::Detail;
24400        let text = render_app(&app, 100, 30);
24401        insta::assert_snapshot!(text);
24402    }
24403
24404    #[test]
24405    fn snap_time_travel_input_prompt() {
24406        let mut app = new_app(ViewMode::Main, 0);
24407        app.update(key(KeyCode::Char('t')));
24408        // Now in time-travel input mode
24409        app.update(key(KeyCode::Char('H')));
24410        app.update(key(KeyCode::Char('E')));
24411        app.update(key(KeyCode::Char('A')));
24412        app.update(key(KeyCode::Char('D')));
24413        let text = render_app(&app, 100, 30);
24414        insta::assert_snapshot!(text);
24415    }
24416
24417    #[test]
24418    fn snap_recipe_picker_overlay() {
24419        let mut app = new_app(ViewMode::Main, 0);
24420        app.update(key(KeyCode::Char('\'')));
24421        assert!(app.modal_overlay.is_some());
24422        let text = render_app(&app, 100, 30);
24423        insta::assert_snapshot!(text);
24424    }
24425
24426    #[test]
24427    fn snap_label_picker_overlay() {
24428        let mut app = new_app(ViewMode::Main, 0);
24429        app.update(key(KeyCode::Char('L')));
24430        assert!(app.modal_overlay.is_some());
24431        let text = render_app(&app, 100, 30);
24432        insta::assert_snapshot!(text);
24433    }
24434
24435    #[test]
24436    fn snap_time_travel_with_diff() {
24437        let mut app = new_app(ViewMode::Main, 0);
24438        app.mode = ViewMode::TimeTravelDiff;
24439        app.time_travel_input_active = false;
24440        app.time_travel_last_ref = Some("HEAD~1".to_string());
24441        app.time_travel_diff = Some(crate::analysis::diff::compare_snapshots(
24442            &[],
24443            &app.analyzer.issues,
24444        ));
24445        let text = render_app(&app, 100, 30);
24446        insta::assert_snapshot!(text);
24447    }
24448
24449    // -- Empty data / edge-case no-panic tests -----------------------------
24450
24451    #[test]
24452    fn tree_view_empty_issues_no_panic() {
24453        let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
24454        app.update(key(KeyCode::Char('T')));
24455        let list = app.list_panel_text();
24456        assert!(!list.is_empty());
24457        app.update(key(KeyCode::Char('j')));
24458        app.update(key(KeyCode::Char('k')));
24459        app.update(key(KeyCode::Tab));
24460        let detail = app.detail_panel_text();
24461        assert!(!detail.is_empty());
24462    }
24463
24464    #[test]
24465    fn label_dashboard_empty_issues_no_panic() {
24466        let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
24467        app.update(key(KeyCode::Char('[')));
24468        let list = app.list_panel_text();
24469        assert!(!list.is_empty());
24470        app.update(key(KeyCode::Char('j')));
24471        app.update(key(KeyCode::Char('k')));
24472        app.update(key(KeyCode::Tab));
24473        let detail = app.detail_panel_text();
24474        assert!(!detail.is_empty());
24475    }
24476
24477    #[test]
24478    fn flow_matrix_empty_issues_no_panic() {
24479        let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
24480        app.update(key(KeyCode::Char(']')));
24481        let list = app.list_panel_text();
24482        assert!(!list.is_empty());
24483        app.update(key(KeyCode::Char('j')));
24484        app.update(key(KeyCode::Char('k')));
24485        app.update(key(KeyCode::Tab));
24486        let detail = app.detail_panel_text();
24487        assert!(!detail.is_empty());
24488    }
24489
24490    #[test]
24491    fn sprint_empty_issues_no_panic() {
24492        let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
24493        app.mode = ViewMode::Sprint;
24494        app.sprint_data = vec![make_sprint("s1", "Sprint Empty", vec!["X"])];
24495        let list = app.list_panel_text();
24496        assert!(!list.is_empty());
24497        app.update(key(KeyCode::Char('j')));
24498        app.update(key(KeyCode::Char('k')));
24499        let detail = app.detail_panel_text();
24500        assert!(!detail.is_empty());
24501    }
24502
24503    #[test]
24504    fn time_travel_render_all_states_no_panic() {
24505        let mut app = new_app(ViewMode::Main, 0);
24506        // Input active state
24507        app.mode = ViewMode::TimeTravelDiff;
24508        app.time_travel_input_active = true;
24509        let _ = render_app(&app, 100, 30);
24510
24511        // No diff state
24512        app.time_travel_input_active = false;
24513        let _ = render_app(&app, 100, 30);
24514
24515        // With diff state
24516        app.time_travel_diff = Some(crate::analysis::diff::compare_snapshots(
24517            &[],
24518            &app.analyzer.issues,
24519        ));
24520        let _ = render_app(&app, 100, 30);
24521    }
24522
24523    #[test]
24524    fn actionable_empty_issues_no_panic() {
24525        let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
24526        app.update(key(KeyCode::Char('a')));
24527        let list = app.list_panel_text();
24528        assert!(!list.is_empty());
24529        app.update(key(KeyCode::Char('j')));
24530        app.update(key(KeyCode::Char('k')));
24531        let _ = render_app(&app, 100, 30);
24532    }
24533
24534    #[test]
24535    fn all_modes_render_at_narrow_width_no_panic() {
24536        for mode in [
24537            ViewMode::Main,
24538            ViewMode::Board,
24539            ViewMode::Insights,
24540            ViewMode::Graph,
24541            ViewMode::History,
24542            ViewMode::Actionable,
24543            ViewMode::Attention,
24544            ViewMode::Tree,
24545            ViewMode::LabelDashboard,
24546            ViewMode::FlowMatrix,
24547            ViewMode::Sprint,
24548            ViewMode::TimeTravelDiff,
24549        ] {
24550            let _ = render_frame(mode, 40, 10);
24551        }
24552    }
24553
24554    #[test]
24555    fn all_modes_render_with_single_issue_no_panic() {
24556        let single = vec![Issue {
24557            id: "X".to_string(),
24558            title: "Solo".to_string(),
24559            status: "open".to_string(),
24560            issue_type: "task".to_string(),
24561            ..Issue::default()
24562        }];
24563        for mode in [
24564            ViewMode::Main,
24565            ViewMode::Board,
24566            ViewMode::Insights,
24567            ViewMode::Graph,
24568            ViewMode::Actionable,
24569            ViewMode::Attention,
24570            ViewMode::Tree,
24571            ViewMode::LabelDashboard,
24572            ViewMode::FlowMatrix,
24573        ] {
24574            let app = new_app_with_issues(mode, 0, single.clone());
24575            let _ = render_app(&app, 100, 30);
24576        }
24577    }
24578
24579    #[test]
24580    fn all_modes_render_with_all_closed_issues_no_panic() {
24581        let closed = vec![
24582            Issue {
24583                id: "X".to_string(),
24584                title: "Done A".to_string(),
24585                status: "closed".to_string(),
24586                ..Issue::default()
24587            },
24588            Issue {
24589                id: "Y".to_string(),
24590                title: "Done B".to_string(),
24591                status: "closed".to_string(),
24592                ..Issue::default()
24593            },
24594        ];
24595        for mode in [
24596            ViewMode::Main,
24597            ViewMode::Board,
24598            ViewMode::Insights,
24599            ViewMode::Graph,
24600            ViewMode::Actionable,
24601            ViewMode::Attention,
24602            ViewMode::Tree,
24603            ViewMode::LabelDashboard,
24604            ViewMode::FlowMatrix,
24605        ] {
24606            let app = new_app_with_issues(mode, 0, closed.clone());
24607            let _ = render_app(&app, 100, 30);
24608        }
24609    }
24610
24611    // -- Keyflow journeys through newer modes ------------------------------
24612
24613    #[test]
24614    fn keyflow_full_newer_mode_tour() {
24615        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24616        assert_eq!(app.mode, ViewMode::Main);
24617
24618        // Actionable
24619        app.update(key(KeyCode::Char('a')));
24620        assert_eq!(app.mode, ViewMode::Actionable);
24621        app.update(key(KeyCode::Char('j')));
24622        app.update(key(KeyCode::Tab));
24623        assert_eq!(app.focus, FocusPane::Detail);
24624        app.update(key(KeyCode::Char('a')));
24625        assert_eq!(app.mode, ViewMode::Main);
24626
24627        // Attention
24628        app.update(key(KeyCode::Char('!')));
24629        assert_eq!(app.mode, ViewMode::Attention);
24630        app.update(key(KeyCode::Char('j')));
24631        app.update(key(KeyCode::Char('!')));
24632        assert_eq!(app.mode, ViewMode::Main);
24633
24634        // Tree
24635        app.update(key(KeyCode::Char('T')));
24636        assert_eq!(app.mode, ViewMode::Tree);
24637        app.update(key(KeyCode::Char('j')));
24638        app.update(key(KeyCode::Tab));
24639        app.update(key(KeyCode::Char('q')));
24640        assert_eq!(app.mode, ViewMode::Main);
24641
24642        // LabelDashboard
24643        app.update(key(KeyCode::Char('[')));
24644        assert_eq!(app.mode, ViewMode::LabelDashboard);
24645        app.update(key(KeyCode::Char('j')));
24646        app.update(key(KeyCode::Char('q')));
24647        assert_eq!(app.mode, ViewMode::Main);
24648
24649        // FlowMatrix
24650        app.update(key(KeyCode::Char(']')));
24651        assert_eq!(app.mode, ViewMode::FlowMatrix);
24652        app.update(key(KeyCode::Char('j')));
24653        app.update(key(KeyCode::Char('q')));
24654        assert_eq!(app.mode, ViewMode::Main);
24655
24656        // TimeTravelDiff (enter then cancel)
24657        app.update(key(KeyCode::Char('t')));
24658        assert_eq!(app.mode, ViewMode::TimeTravelDiff);
24659        app.update(key(KeyCode::Escape));
24660        assert_eq!(app.mode, ViewMode::Main);
24661
24662        // Verify key trace captured all transitions
24663        assert!(
24664            app.key_trace.len() >= 15,
24665            "should have many trace entries: {}",
24666            app.key_trace.len()
24667        );
24668    }
24669
24670    #[test]
24671    fn keyflow_sprint_full_journey() {
24672        let mut app = new_app(ViewMode::Main, 0);
24673
24674        // Enter sprint mode (load_sprint_data called internally, returns empty w/o disk)
24675        app.update(key(KeyCode::Char('S')));
24676        assert_eq!(app.mode, ViewMode::Sprint);
24677
24678        // Inject sprint data after entering mode (simulating disk load)
24679        app.sprint_data = vec![
24680            make_sprint("s1", "Sprint 1", vec!["A", "B"]),
24681            make_sprint("s2", "Sprint 2", vec!["C"]),
24682        ];
24683
24684        // Navigate sprints
24685        app.update(key(KeyCode::Char('j')));
24686        assert_eq!(app.sprint_cursor, 1);
24687
24688        // Switch to detail focus
24689        app.update(key(KeyCode::Tab));
24690        assert_eq!(app.focus, FocusPane::Detail);
24691
24692        // Navigate back to list
24693        app.update(key(KeyCode::Tab));
24694        assert_eq!(app.focus, FocusPane::List);
24695
24696        // Return to main
24697        app.update(key(KeyCode::Char('S')));
24698        assert_eq!(app.mode, ViewMode::Main);
24699    }
24700
24701    #[test]
24702    fn keyflow_modal_chain() {
24703        let mut app = new_app(ViewMode::Main, 0);
24704
24705        // Open recipe picker, navigate, close
24706        app.update(key(KeyCode::Char('\'')));
24707        assert!(matches!(
24708            app.modal_overlay,
24709            Some(ModalOverlay::RecipePicker { .. })
24710        ));
24711        app.update(key(KeyCode::Char('j')));
24712        app.update(key(KeyCode::Escape));
24713        assert!(app.modal_overlay.is_none());
24714
24715        // Open label picker, select
24716        app.update(key(KeyCode::Char('L')));
24717        assert!(matches!(
24718            app.modal_overlay,
24719            Some(ModalOverlay::LabelPicker { .. })
24720        ));
24721        app.update(key(KeyCode::Enter));
24722        assert!(app.modal_overlay.is_none());
24723        assert!(app.modal_label_filter.is_some());
24724
24725        // Clear filter
24726        app.set_list_filter(ListFilter::All);
24727        assert!(app.modal_label_filter.is_none());
24728    }
24729
24730    #[test]
24731    fn keyflow_rapid_mode_switching_no_panic() {
24732        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24733        // Rapid switching between modes — should never panic
24734        let keys = [
24735            KeyCode::Char('b'), // Board
24736            KeyCode::Char('q'), // Back
24737            KeyCode::Char('i'), // Insights
24738            KeyCode::Char('q'), // Back
24739            KeyCode::Char('g'), // Graph
24740            KeyCode::Char('q'), // Back
24741            KeyCode::Char('a'), // Actionable
24742            KeyCode::Char('a'), // Back
24743            KeyCode::Char('!'), // Attention
24744            KeyCode::Char('!'), // Back
24745            KeyCode::Char('T'), // Tree
24746            KeyCode::Char('q'), // Back
24747            KeyCode::Char('['), // LabelDashboard
24748            KeyCode::Char('q'), // Back
24749            KeyCode::Char(']'), // FlowMatrix
24750            KeyCode::Char('q'), // Back
24751            KeyCode::Char('t'), // TimeTravelDiff
24752            KeyCode::Escape,    // Cancel
24753            KeyCode::Char('b'), // Board again
24754            KeyCode::Char('g'), // Graph from board?
24755            KeyCode::Char('q'), // Back
24756        ];
24757        for k in keys {
24758            app.update(key(k));
24759        }
24760        // Should still be in a valid state
24761        assert!(!matches!(app.mode, ViewMode::TimeTravelDiff));
24762    }
24763
24764    #[test]
24765    fn keyflow_navigation_in_all_newer_modes() {
24766        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24767        // Test j/k/Tab/Esc in each newer mode
24768        for mode_key in [
24769            KeyCode::Char('a'), // Actionable
24770            KeyCode::Char('!'), // Attention
24771            KeyCode::Char('T'), // Tree
24772            KeyCode::Char('['), // LabelDashboard
24773            KeyCode::Char(']'), // FlowMatrix
24774        ] {
24775            app.update(key(mode_key));
24776            app.update(key(KeyCode::Char('j')));
24777            app.update(key(KeyCode::Char('j')));
24778            app.update(key(KeyCode::Char('k')));
24779            app.update(key(KeyCode::Tab));
24780            app.update(key(KeyCode::Char('j')));
24781            app.update(key(KeyCode::Tab));
24782            // Return to main
24783            let exit_key = match mode_key {
24784                KeyCode::Char('a') => KeyCode::Char('a'),
24785                KeyCode::Char('!') => KeyCode::Char('!'),
24786                _ => KeyCode::Char('q'),
24787            };
24788            app.update(key(exit_key));
24789            assert_eq!(
24790                app.mode,
24791                ViewMode::Main,
24792                "should return to Main from mode entered via {:?}",
24793                mode_key
24794            );
24795        }
24796    }
24797
24798    #[test]
24799    fn keyflow_help_from_newer_modes() {
24800        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24801        // Help should work from any mode
24802        for mode_key in [
24803            KeyCode::Char('a'),
24804            KeyCode::Char('T'),
24805            KeyCode::Char('['),
24806            KeyCode::Char(']'),
24807        ] {
24808            app.update(key(mode_key));
24809            app.update(key(KeyCode::Char('?')));
24810            assert!(app.show_help, "help should open in mode {:?}", mode_key);
24811            app.update(key(KeyCode::Char('?')));
24812            assert!(!app.show_help);
24813            // Return to main via q (or a for actionable)
24814            let exit = match mode_key {
24815                KeyCode::Char('a') => KeyCode::Char('a'),
24816                _ => KeyCode::Char('q'),
24817            };
24818            app.update(key(exit));
24819        }
24820    }
24821
24822    #[test]
24823    fn keyflow_filter_then_mode_switch_preserves_filter() {
24824        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24825        // Set open filter
24826        app.update(key(KeyCode::Char('o')));
24827        assert_eq!(app.list_filter, ListFilter::Open);
24828
24829        // Switch to Tree mode and back — filter should persist
24830        app.update(key(KeyCode::Char('T')));
24831        assert_eq!(app.mode, ViewMode::Tree);
24832        app.update(key(KeyCode::Char('q')));
24833        assert_eq!(app.mode, ViewMode::Main);
24834        assert_eq!(
24835            app.list_filter,
24836            ListFilter::Open,
24837            "filter should persist across mode transitions"
24838        );
24839    }
24840
24841    // -- Additional edge-case coverage for existing features ---------------
24842
24843    #[test]
24844    fn graph_with_cycle_issues_no_panic() {
24845        let issues = vec![
24846            Issue {
24847                id: "X".to_string(),
24848                title: "Cyclic A".to_string(),
24849                status: "open".to_string(),
24850                issue_type: "task".to_string(),
24851                dependencies: vec![Dependency {
24852                    issue_id: "X".to_string(),
24853                    depends_on_id: "Y".to_string(),
24854                    dep_type: "blocks".to_string(),
24855                    ..Dependency::default()
24856                }],
24857                ..Issue::default()
24858            },
24859            Issue {
24860                id: "Y".to_string(),
24861                title: "Cyclic B".to_string(),
24862                status: "open".to_string(),
24863                issue_type: "task".to_string(),
24864                dependencies: vec![Dependency {
24865                    issue_id: "Y".to_string(),
24866                    depends_on_id: "X".to_string(),
24867                    dep_type: "blocks".to_string(),
24868                    ..Dependency::default()
24869                }],
24870                ..Issue::default()
24871            },
24872        ];
24873        for mode in [
24874            ViewMode::Main,
24875            ViewMode::Graph,
24876            ViewMode::Insights,
24877            ViewMode::Actionable,
24878        ] {
24879            let app = new_app_with_issues(mode, 0, issues.clone());
24880            let _ = render_app(&app, 100, 30);
24881        }
24882    }
24883
24884    #[test]
24885    fn attention_detail_shows_correct_label_on_navigation() {
24886        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24887        app.update(key(KeyCode::Char('!')));
24888        let labels_count = app.attention_result.as_ref().unwrap().labels.len();
24889        if labels_count >= 2 {
24890            let detail_0 = app.detail_panel_text();
24891            app.update(key(KeyCode::Char('j')));
24892            let detail_1 = app.detail_panel_text();
24893            // Different label should produce different detail content
24894            assert_ne!(
24895                detail_0, detail_1,
24896                "navigating to next label should change detail"
24897            );
24898        }
24899    }
24900
24901    #[test]
24902    fn sprint_tab_switches_focus_and_navigation_context() {
24903        let mut app = new_app(ViewMode::Main, 0);
24904        app.mode = ViewMode::Sprint;
24905        app.sprint_data = vec![
24906            make_sprint("s1", "Sprint 1", vec!["A", "B"]),
24907            make_sprint("s2", "Sprint 2", vec!["C"]),
24908        ];
24909
24910        // List focus: j/k moves sprint_cursor
24911        app.focus = FocusPane::List;
24912        app.update(key(KeyCode::Char('j')));
24913        assert_eq!(app.sprint_cursor, 1);
24914
24915        // Switch to detail
24916        app.update(key(KeyCode::Tab));
24917        assert_eq!(app.focus, FocusPane::Detail);
24918
24919        // Detail focus: j/k moves sprint_issue_cursor (sprint 2 has 1 issue)
24920        app.update(key(KeyCode::Char('j')));
24921        // sprint_issue_cursor can't go past issue count - verify no panic
24922
24923        // Back to list
24924        app.update(key(KeyCode::Tab));
24925        assert_eq!(app.focus, FocusPane::List);
24926    }
24927
24928    #[test]
24929    fn modal_overlay_blocks_mode_switch_keys() {
24930        let mut app = new_app(ViewMode::Main, 0);
24931        app.update(key(KeyCode::Char('\'')));
24932        assert!(app.modal_overlay.is_some());
24933
24934        // Mode keys should NOT switch modes while modal is open
24935        app.update(key(KeyCode::Char('b')));
24936        assert_eq!(
24937            app.mode,
24938            ViewMode::Main,
24939            "b should not switch mode during modal"
24940        );
24941        assert!(app.modal_overlay.is_some(), "modal should still be open");
24942
24943        app.update(key(KeyCode::Char('g')));
24944        assert_eq!(app.mode, ViewMode::Main);
24945
24946        // Close modal
24947        app.update(key(KeyCode::Escape));
24948        assert!(app.modal_overlay.is_none());
24949
24950        // Now mode switch should work
24951        app.update(key(KeyCode::Char('b')));
24952        assert_eq!(app.mode, ViewMode::Board);
24953    }
24954
24955    #[test]
24956    fn label_filter_affects_visible_issues() {
24957        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24958        let total = app.visible_issue_indices().len();
24959        assert!(total >= 3);
24960
24961        // Set label filter to "frontend" — only issue C has it
24962        app.modal_label_filter = Some("frontend".to_string());
24963        let filtered = app.visible_issue_indices().len();
24964        assert!(
24965            filtered < total,
24966            "label filter should reduce visible issues: {filtered} < {total}"
24967        );
24968    }
24969
24970    #[test]
24971    fn label_filter_matches_case_insensitively() {
24972        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
24973        app.modal_label_filter = Some("FRONTEND".to_string());
24974
24975        let visible_ids = app
24976            .visible_issue_indices()
24977            .into_iter()
24978            .map(|index| app.analyzer.issues[index].id.as_str())
24979            .collect::<Vec<_>>();
24980
24981        assert_eq!(visible_ids, vec!["C"]);
24982    }
24983
24984    #[test]
24985    fn set_label_filter_toggles_case_insensitively() {
24986        let mut app = new_app(ViewMode::Main, 0);
24987        app.modal_label_filter = Some("backend".to_string());
24988
24989        app.set_label_filter("BACKEND");
24990
24991        assert!(app.modal_label_filter.is_none());
24992        assert_eq!(app.status_msg, "Label filter cleared");
24993    }
24994
24995    #[test]
24996    fn repo_filter_affects_visible_issues() {
24997        let mut app = new_app(ViewMode::Main, 0);
24998        // sample_issues: A has source_repo="viewer", B and C have ""
24999        app.modal_repo_filter = Some("viewer".to_string());
25000        let filtered = app.visible_issue_indices().len();
25001        assert!(filtered >= 1, "repo filter should show at least one issue");
25002    }
25003
25004    #[test]
25005    fn header_shows_combined_filters() {
25006        let mut app = new_app(ViewMode::Main, 0);
25007        app.modal_label_filter = Some("core".to_string());
25008        app.modal_repo_filter = Some("viewer".to_string());
25009        let text = render_app(&app, 100, 3);
25010        assert!(
25011            text.contains("label:core") || text.contains("core"),
25012            "header should mention label filter: {text}"
25013        );
25014    }
25015
25016    // -- Two-phase (fast/slow) metric TUI tests ------------------------------
25017
25018    #[test]
25019    fn slow_metrics_pending_flag_default_false() {
25020        let app = new_app(ViewMode::Main, 0);
25021        assert!(
25022            !app.slow_metrics_pending,
25023            "small graph should not have pending slow metrics"
25024        );
25025    }
25026
25027    #[test]
25028    fn slow_metrics_pending_shows_in_header() {
25029        let mut app = new_app(ViewMode::Main, 0);
25030        app.slow_metrics_pending = true;
25031        let text = render_app(&app, 120, 3);
25032        assert!(
25033            text.contains("computing"),
25034            "header should show metrics computing indicator: {text}"
25035        );
25036    }
25037
25038    #[test]
25039    fn slow_metrics_pending_clears_after_apply() {
25040        let mut app = new_app(ViewMode::Main, 0);
25041        app.slow_metrics_pending = true;
25042        let slow = app
25043            .analyzer
25044            .graph
25045            .compute_metrics_with_config(&crate::analysis::graph::AnalysisConfig::slow_phase());
25046        app.analyzer.apply_slow_metrics(slow);
25047        app.slow_metrics_pending = false;
25048        let text = render_app(&app, 120, 3);
25049        assert!(
25050            !text.contains("computing"),
25051            "header should not show computing after metrics applied: {text}"
25052        );
25053    }
25054
25055    // -- Tree expanded coverage (bd-7oo.4.5) ---------------------------------
25056
25057    #[test]
25058    fn keyflow_tree_full_journey() {
25059        let mut app = new_app(ViewMode::Main, 0);
25060        assert_eq!(app.mode, ViewMode::Main);
25061
25062        // Enter Tree mode
25063        app.update(key(KeyCode::Char('T')));
25064        assert_eq!(app.mode, ViewMode::Tree);
25065        assert!(!app.tree_flat_nodes.is_empty(), "tree should build nodes");
25066        assert_eq!(app.focus, FocusPane::List);
25067
25068        // Verify list shows dependency tree header
25069        let list = app.list_panel_text();
25070        assert!(
25071            list.contains("Dependency tree") || list.contains("no dependency tree"),
25072            "list should show tree header: {list}"
25073        );
25074
25075        // Navigate down/up
25076        let node_count = app.tree_flat_nodes.len();
25077        if node_count > 1 {
25078            app.update(key(KeyCode::Char('j')));
25079            assert_eq!(app.tree_cursor, 1);
25080            app.update(key(KeyCode::Char('k')));
25081            assert_eq!(app.tree_cursor, 0);
25082        }
25083
25084        // Switch to detail pane
25085        app.update(key(KeyCode::Tab));
25086        assert_eq!(app.focus, FocusPane::Detail);
25087        let detail = app.detail_panel_text();
25088        assert!(
25089            detail.contains("ID:"),
25090            "detail should show issue ID: {detail}"
25091        );
25092
25093        // Switch back to list
25094        app.update(key(KeyCode::Tab));
25095        assert_eq!(app.focus, FocusPane::List);
25096
25097        // Help overlay
25098        app.update(key(KeyCode::Char('?')));
25099        assert!(app.show_help);
25100        let help_text = render_app(&app, 100, 30);
25101        assert!(
25102            help_text.contains("Help") || help_text.contains("help"),
25103            "help overlay should render: {help_text}"
25104        );
25105        app.update(key(KeyCode::Char('?')));
25106        assert!(!app.show_help);
25107
25108        // Exit back to Main
25109        app.update(key(KeyCode::Char('T')));
25110        assert_eq!(app.mode, ViewMode::Main);
25111    }
25112
25113    #[test]
25114    fn tree_narrow_width_rendering_no_panic() {
25115        let mut app = new_app(ViewMode::Main, 0);
25116        app.update(key(KeyCode::Char('T')));
25117        assert_eq!(app.mode, ViewMode::Tree);
25118
25119        // 40x20 — narrow but usable
25120        let text_40 = render_app(&app, 40, 20);
25121        assert!(!text_40.is_empty(), "40x20 render should produce output");
25122
25123        // 20x10 — extremely narrow
25124        let text_20 = render_app(&app, 20, 10);
25125        assert!(!text_20.is_empty(), "20x10 render should produce output");
25126
25127        // Navigate at narrow width — should not panic
25128        app.update(key(KeyCode::Char('j')));
25129        app.update(key(KeyCode::Tab));
25130        let text_detail = render_app(&app, 40, 20);
25131        assert!(
25132            !text_detail.is_empty(),
25133            "detail at 40x20 should produce output"
25134        );
25135    }
25136
25137    #[test]
25138    fn tree_cursor_clamp_at_boundary() {
25139        let mut app = new_app(ViewMode::Main, 0);
25140        app.update(key(KeyCode::Char('T')));
25141        assert_eq!(app.mode, ViewMode::Tree);
25142
25143        // k at top should clamp to 0
25144        app.update(key(KeyCode::Char('k')));
25145        assert_eq!(app.tree_cursor, 0, "cursor should clamp at top");
25146
25147        // Navigate to bottom and try to go past
25148        let node_count = app.tree_flat_nodes.len();
25149        for _ in 0..node_count + 5 {
25150            app.update(key(KeyCode::Char('j')));
25151        }
25152        assert_eq!(
25153            app.tree_cursor,
25154            node_count.saturating_sub(1),
25155            "cursor should clamp at bottom"
25156        );
25157
25158        // One more j should stay clamped
25159        app.update(key(KeyCode::Char('j')));
25160        assert_eq!(
25161            app.tree_cursor,
25162            node_count.saturating_sub(1),
25163            "cursor should stay clamped at bottom"
25164        );
25165    }
25166
25167    // -- LabelDashboard expanded coverage (bd-7oo.4.5) ----------------------
25168
25169    #[test]
25170    fn keyflow_label_dashboard_full_journey() {
25171        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25172        assert_eq!(app.mode, ViewMode::Main);
25173
25174        // Enter LabelDashboard
25175        app.update(key(KeyCode::Char('[')));
25176        assert_eq!(app.mode, ViewMode::LabelDashboard);
25177        assert!(app.label_dashboard.is_some());
25178        assert_eq!(app.focus, FocusPane::List);
25179
25180        // Verify list shows health header
25181        let list = app.list_panel_text();
25182        assert!(
25183            list.contains("Label health") || list.contains("no labels"),
25184            "list should show label health header: {list}"
25185        );
25186
25187        // Navigate labels
25188        let count = app.label_dashboard.as_ref().map_or(0, |r| r.labels.len());
25189        if count > 1 {
25190            app.update(key(KeyCode::Char('j')));
25191            assert_eq!(app.label_dashboard_cursor, 1);
25192        }
25193
25194        // Switch to detail focus
25195        app.update(key(KeyCode::Tab));
25196        assert_eq!(app.focus, FocusPane::Detail);
25197
25198        // Detail should show label info
25199        let detail = app.detail_panel_text();
25200        if count > 0 {
25201            assert!(
25202                detail.contains("Label:") || detail.contains("Health:"),
25203                "detail should show label health info: {detail}"
25204            );
25205        }
25206
25207        // Navigate back
25208        app.update(key(KeyCode::Char('k')));
25209        if count > 1 {
25210            assert_eq!(app.label_dashboard_cursor, 0);
25211        }
25212
25213        // Switch back to list
25214        app.update(key(KeyCode::Tab));
25215        assert_eq!(app.focus, FocusPane::List);
25216
25217        // Open help overlay
25218        app.update(key(KeyCode::Char('?')));
25219        assert!(app.show_help);
25220        app.update(key(KeyCode::Char('?')));
25221        assert!(!app.show_help);
25222
25223        // Return to Main via [
25224        app.update(key(KeyCode::Char('[')));
25225        assert_eq!(app.mode, ViewMode::Main);
25226    }
25227
25228    #[test]
25229    fn label_dashboard_narrow_width_rendering_no_panic() {
25230        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25231        app.update(key(KeyCode::Char('[')));
25232        assert_eq!(app.mode, ViewMode::LabelDashboard);
25233
25234        let text = render_app(&app, 40, 20);
25235        assert!(
25236            !text.is_empty(),
25237            "narrow label dashboard should produce output"
25238        );
25239
25240        let text_tiny = render_app(&app, 20, 10);
25241        assert!(
25242            !text_tiny.is_empty(),
25243            "very narrow label dashboard should produce output"
25244        );
25245    }
25246
25247    #[test]
25248    fn label_dashboard_detail_changes_on_navigation() {
25249        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25250        app.update(key(KeyCode::Char('[')));
25251
25252        let count = app.label_dashboard.as_ref().map_or(0, |r| r.labels.len());
25253        if count >= 2 {
25254            let detail_0 = app.detail_panel_text();
25255            app.update(key(KeyCode::Char('j')));
25256            let detail_1 = app.detail_panel_text();
25257            assert_ne!(
25258                detail_0, detail_1,
25259                "navigating labels should change detail content"
25260            );
25261        }
25262    }
25263
25264    // -- Sprint expanded coverage (bd-7oo.4.5) -----------------------------
25265
25266    #[test]
25267    fn sprint_narrow_width_rendering_no_panic() {
25268        let mut app = new_app(ViewMode::Main, 0);
25269        app.mode = ViewMode::Sprint;
25270        app.sprint_data = vec![
25271            make_sprint("s1", "Sprint Alpha", vec!["A", "B"]),
25272            make_sprint("s2", "Sprint Beta", vec!["C"]),
25273        ];
25274
25275        let text = render_app(&app, 40, 20);
25276        assert!(
25277            !text.is_empty(),
25278            "narrow sprint render should produce output"
25279        );
25280
25281        let text_tiny = render_app(&app, 20, 10);
25282        assert!(
25283            !text_tiny.is_empty(),
25284            "very narrow sprint render should produce output"
25285        );
25286    }
25287
25288    #[test]
25289    fn sprint_detail_focus_navigation_no_panic() {
25290        let mut app = new_app(ViewMode::Main, 0);
25291        app.mode = ViewMode::Sprint;
25292        app.sprint_data = vec![
25293            make_sprint("s1", "Sprint Alpha", vec!["A", "B", "C"]),
25294            make_sprint("s2", "Sprint Beta", vec!["D"]),
25295        ];
25296
25297        // Navigate sprints in list focus
25298        app.update(key(KeyCode::Char('j')));
25299        assert_eq!(app.sprint_cursor, 1);
25300
25301        // Switch to detail and navigate issues
25302        app.update(key(KeyCode::Tab));
25303        assert_eq!(app.focus, FocusPane::Detail);
25304        app.update(key(KeyCode::Char('j')));
25305        app.update(key(KeyCode::Char('k')));
25306
25307        // Switch back and navigate to first sprint
25308        app.update(key(KeyCode::Tab));
25309        app.update(key(KeyCode::Char('k')));
25310        assert_eq!(app.sprint_cursor, 0);
25311
25312        // Render at multiple widths should not panic
25313        for width in [40, 80, 120] {
25314            let _ = render_app(&app, width, 30);
25315        }
25316    }
25317
25318    // -- Actionable expanded coverage (bd-7oo.4.5) ---------------------------
25319
25320    #[test]
25321    fn keyflow_actionable_full_journey() {
25322        let mut app = new_app(ViewMode::Main, 0);
25323        assert_eq!(app.mode, ViewMode::Main);
25324
25325        // Enter Actionable mode
25326        app.update(key(KeyCode::Char('a')));
25327        assert_eq!(app.mode, ViewMode::Actionable);
25328        assert!(app.actionable_plan.is_some());
25329        assert_eq!(app.focus, FocusPane::List);
25330
25331        // Verify list shows actionable header and recommended start
25332        let list = app.list_panel_text();
25333        assert!(
25334            list.contains("ACTIONABLE ITEMS"),
25335            "list should show actionable header: {list}"
25336        );
25337        assert!(
25338            list.contains("TRACK"),
25339            "list should show track info: {list}"
25340        );
25341
25342        // Navigate tracks
25343        let track_count = app.actionable_plan.as_ref().map_or(0, |p| p.tracks.len());
25344        if track_count > 1 {
25345            app.update(key(KeyCode::Char('j')));
25346            assert_eq!(app.actionable_track_cursor, 1);
25347            app.update(key(KeyCode::Char('k')));
25348            assert_eq!(app.actionable_track_cursor, 0);
25349        }
25350
25351        // Switch to detail focus — navigates items within track
25352        app.update(key(KeyCode::Tab));
25353        assert_eq!(app.focus, FocusPane::Detail);
25354        let detail = app.detail_panel_text();
25355        assert!(
25356            detail.contains("TRACK"),
25357            "detail should show track info: {detail}"
25358        );
25359
25360        // Item navigation in detail
25361        let item_count = app
25362            .actionable_plan
25363            .as_ref()
25364            .and_then(|p| p.tracks.first())
25365            .map_or(0, |t| t.items.len());
25366        if item_count > 1 {
25367            app.update(key(KeyCode::Char('j')));
25368            assert_eq!(app.actionable_item_cursor, 1);
25369            app.update(key(KeyCode::Char('k')));
25370            assert_eq!(app.actionable_item_cursor, 0);
25371        }
25372
25373        // Help overlay
25374        app.update(key(KeyCode::Char('?')));
25375        assert!(app.show_help);
25376        let help_text = render_app(&app, 100, 30);
25377        assert!(
25378            help_text.contains("Help") || help_text.contains("help"),
25379            "help overlay should render"
25380        );
25381        app.update(key(KeyCode::Char('?')));
25382        assert!(!app.show_help);
25383
25384        // Switch back to list and exit
25385        app.update(key(KeyCode::Tab));
25386        assert_eq!(app.focus, FocusPane::List);
25387        app.update(key(KeyCode::Char('a')));
25388        assert_eq!(app.mode, ViewMode::Main);
25389    }
25390
25391    #[test]
25392    fn actionable_narrow_width_rendering_no_panic() {
25393        let mut app = new_app(ViewMode::Main, 0);
25394        app.update(key(KeyCode::Char('a')));
25395        assert_eq!(app.mode, ViewMode::Actionable);
25396
25397        // 40x20 — narrow but usable
25398        let text_40 = render_app(&app, 40, 20);
25399        assert!(!text_40.is_empty(), "40x20 render should produce output");
25400
25401        // 20x10 — extremely narrow
25402        let text_20 = render_app(&app, 20, 10);
25403        assert!(!text_20.is_empty(), "20x10 render should produce output");
25404
25405        // Navigate and render at narrow width
25406        app.update(key(KeyCode::Char('j')));
25407        app.update(key(KeyCode::Tab));
25408        let text_detail = render_app(&app, 40, 20);
25409        assert!(
25410            !text_detail.is_empty(),
25411            "detail at 40x20 should produce output"
25412        );
25413    }
25414
25415    #[test]
25416    fn actionable_track_cursor_clamp_at_boundary() {
25417        let mut app = new_app(ViewMode::Main, 0);
25418        app.update(key(KeyCode::Char('a')));
25419        assert_eq!(app.mode, ViewMode::Actionable);
25420
25421        // k at top should stay at 0
25422        app.update(key(KeyCode::Char('k')));
25423        assert_eq!(
25424            app.actionable_track_cursor, 0,
25425            "track cursor should clamp at top"
25426        );
25427
25428        // Navigate past bottom
25429        let track_count = app.actionable_plan.as_ref().map_or(0, |p| p.tracks.len());
25430        for _ in 0..track_count + 5 {
25431            app.update(key(KeyCode::Char('j')));
25432        }
25433        assert_eq!(
25434            app.actionable_track_cursor,
25435            track_count.saturating_sub(1),
25436            "track cursor should clamp at bottom"
25437        );
25438    }
25439
25440    #[test]
25441    fn actionable_detail_changes_on_track_navigation() {
25442        let mut app = new_app(ViewMode::Main, 0);
25443        app.update(key(KeyCode::Char('a')));
25444        assert_eq!(app.mode, ViewMode::Actionable);
25445
25446        let track_count = app.actionable_plan.as_ref().map_or(0, |p| p.tracks.len());
25447        if track_count > 1 {
25448            let detail_first = app.detail_panel_text();
25449
25450            app.update(key(KeyCode::Char('j')));
25451            let detail_second = app.detail_panel_text();
25452
25453            assert_ne!(
25454                detail_first, detail_second,
25455                "detail should change when navigating to a different track"
25456            );
25457        }
25458    }
25459
25460    #[test]
25461    fn actionable_item_cursor_resets_on_track_change() {
25462        let mut app = new_app(ViewMode::Main, 0);
25463        app.update(key(KeyCode::Char('a')));
25464
25465        // Navigate to detail and move item cursor
25466        app.update(key(KeyCode::Tab));
25467        app.update(key(KeyCode::Char('j')));
25468        let item_pos = app.actionable_item_cursor;
25469
25470        // Switch back to list and change track
25471        app.update(key(KeyCode::Tab));
25472        let track_count = app.actionable_plan.as_ref().map_or(0, |p| p.tracks.len());
25473        if track_count > 1 && item_pos > 0 {
25474            app.update(key(KeyCode::Char('j')));
25475            assert_eq!(
25476                app.actionable_item_cursor, 0,
25477                "item cursor should reset when changing track"
25478            );
25479        }
25480    }
25481
25482    #[test]
25483    fn actionable_detail_scroll_resets_on_track_navigation() {
25484        let mut app = new_app(ViewMode::Actionable, 0);
25485        app.mode = ViewMode::Actionable;
25486        app.focus = FocusPane::List;
25487        app.actionable_plan = Some(crate::analysis::plan::ExecutionPlan {
25488            total_actionable: 2,
25489            total_blocked: 0,
25490            tracks: vec![
25491                crate::analysis::plan::ExecutionTrack {
25492                    id: "track-1".to_string(),
25493                    reason: "first".to_string(),
25494                    items: vec![crate::analysis::plan::ExecutionItem {
25495                        id: "A".to_string(),
25496                        title: "Alpha".to_string(),
25497                        status: "open".to_string(),
25498                        priority: 1,
25499                        score: 5.0,
25500                        unblocks: Vec::new(),
25501                        claim_command: "br update A --status=in_progress".to_string(),
25502                        show_command: "br show A".to_string(),
25503                    }],
25504                },
25505                crate::analysis::plan::ExecutionTrack {
25506                    id: "track-2".to_string(),
25507                    reason: "second".to_string(),
25508                    items: vec![crate::analysis::plan::ExecutionItem {
25509                        id: "B".to_string(),
25510                        title: "Beta".to_string(),
25511                        status: "open".to_string(),
25512                        priority: 1,
25513                        score: 4.0,
25514                        unblocks: Vec::new(),
25515                        claim_command: "br update B --status=in_progress".to_string(),
25516                        show_command: "br show B".to_string(),
25517                    }],
25518                },
25519            ],
25520            summary: crate::analysis::plan::PlanSummary {
25521                track_count: 2,
25522                actionable_count: 2,
25523                unblocks_count: Some(0),
25524                highest_impact: Some("A".to_string()),
25525                impact_reason: Some("highest impact: A (score 5.00)".to_string()),
25526            },
25527        });
25528        app.detail_scroll_offset = 7;
25529
25530        app.move_actionable_cursor(1);
25531
25532        assert_eq!(app.actionable_track_cursor, 1);
25533        assert_eq!(app.actionable_item_cursor, 0);
25534        assert_eq!(app.detail_scroll_offset, 0);
25535    }
25536
25537    #[test]
25538    fn actionable_detail_scroll_resets_on_item_navigation() {
25539        let mut app = new_app(ViewMode::Actionable, 0);
25540        app.mode = ViewMode::Actionable;
25541        app.focus = FocusPane::Detail;
25542        app.actionable_plan = Some(crate::analysis::plan::ExecutionPlan {
25543            total_actionable: 2,
25544            total_blocked: 0,
25545            tracks: vec![crate::analysis::plan::ExecutionTrack {
25546                id: "track-1".to_string(),
25547                reason: "first".to_string(),
25548                items: vec![
25549                    crate::analysis::plan::ExecutionItem {
25550                        id: "A".to_string(),
25551                        title: "Alpha".to_string(),
25552                        status: "open".to_string(),
25553                        priority: 1,
25554                        score: 5.0,
25555                        unblocks: Vec::new(),
25556                        claim_command: "br update A --status=in_progress".to_string(),
25557                        show_command: "br show A".to_string(),
25558                    },
25559                    crate::analysis::plan::ExecutionItem {
25560                        id: "B".to_string(),
25561                        title: "Beta".to_string(),
25562                        status: "open".to_string(),
25563                        priority: 1,
25564                        score: 4.0,
25565                        unblocks: Vec::new(),
25566                        claim_command: "br update B --status=in_progress".to_string(),
25567                        show_command: "br show B".to_string(),
25568                    },
25569                ],
25570            }],
25571            summary: crate::analysis::plan::PlanSummary {
25572                track_count: 1,
25573                actionable_count: 2,
25574                unblocks_count: Some(0),
25575                highest_impact: Some("A".to_string()),
25576                impact_reason: Some("highest impact: A (score 5.00)".to_string()),
25577            },
25578        });
25579        app.detail_scroll_offset = 6;
25580
25581        app.move_actionable_cursor(1);
25582
25583        assert_eq!(app.actionable_item_cursor, 1);
25584        assert_eq!(app.detail_scroll_offset, 0);
25585    }
25586
25587    #[test]
25588    fn actionable_mode_entry_resets_detail_scroll_offset() {
25589        let mut app = new_app(ViewMode::Main, 0);
25590        app.detail_scroll_offset = 5;
25591
25592        app.update(key(KeyCode::Char('a')));
25593
25594        assert_eq!(app.mode, ViewMode::Actionable);
25595        assert_eq!(app.detail_scroll_offset, 0);
25596    }
25597
25598    #[test]
25599    fn actionable_mode_exit_resets_detail_scroll_offset() {
25600        let mut app = new_app(ViewMode::Main, 0);
25601        app.update(key(KeyCode::Char('a')));
25602        app.detail_scroll_offset = 5;
25603
25604        app.update(key(KeyCode::Char('a')));
25605
25606        assert_eq!(app.mode, ViewMode::Main);
25607        assert_eq!(app.detail_scroll_offset, 0);
25608    }
25609
25610    // -- Attention mode expanded coverage (bd-7oo.4.5) ----------------------
25611
25612    #[test]
25613    fn keyflow_attention_full_journey() {
25614        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25615        assert_eq!(app.mode, ViewMode::Main);
25616
25617        // Enter Attention mode
25618        app.update(key(KeyCode::Char('!')));
25619        assert_eq!(app.mode, ViewMode::Attention);
25620        assert!(app.attention_result.is_some());
25621        assert_eq!(app.focus, FocusPane::List);
25622
25623        // Verify list has ranked labels
25624        let list = app.list_panel_text();
25625        assert!(list.contains("Rank"), "list should show rank header");
25626        assert!(list.contains("Score"), "list should show score header");
25627
25628        // Navigate down through labels
25629        let label_count = app.attention_result.as_ref().unwrap().labels.len();
25630        assert!(label_count >= 2);
25631        app.update(key(KeyCode::Char('j')));
25632        assert_eq!(app.attention_cursor, 1);
25633
25634        // Switch to detail focus
25635        app.update(key(KeyCode::Tab));
25636        assert_eq!(app.focus, FocusPane::Detail);
25637
25638        // Detail should show breakdown for the navigated-to label
25639        let detail = app.detail_panel_text();
25640        assert!(detail.contains("Attention Score:"));
25641        assert!(detail.contains("Breakdown:"));
25642        assert!(detail.contains("Factors:"));
25643
25644        // Navigate back up in detail focus
25645        app.update(key(KeyCode::Char('k')));
25646        assert_eq!(app.attention_cursor, 0);
25647
25648        // Switch back to list
25649        app.update(key(KeyCode::Tab));
25650        assert_eq!(app.focus, FocusPane::List);
25651
25652        // Open help from Attention mode
25653        app.update(key(KeyCode::Char('?')));
25654        assert!(app.show_help);
25655        app.update(key(KeyCode::Char('?')));
25656        assert!(!app.show_help);
25657
25658        // Return to Main
25659        app.update(key(KeyCode::Char('!')));
25660        assert_eq!(app.mode, ViewMode::Main);
25661    }
25662
25663    #[test]
25664    fn attention_narrow_width_rendering_no_panic() {
25665        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25666        app.update(key(KeyCode::Char('!')));
25667        assert_eq!(app.mode, ViewMode::Attention);
25668
25669        // Render at narrow width — should not panic
25670        let text = render_app(&app, 40, 20);
25671        assert!(
25672            !text.is_empty(),
25673            "narrow attention render should produce output"
25674        );
25675
25676        // Also at very narrow width
25677        let text_tiny = render_app(&app, 20, 10);
25678        assert!(
25679            !text_tiny.is_empty(),
25680            "very narrow attention render should produce output"
25681        );
25682    }
25683
25684    #[test]
25685    fn attention_tab_focus_updates_detail_context() {
25686        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25687        app.update(key(KeyCode::Char('!')));
25688
25689        // In list focus, get detail for cursor 0
25690        let detail_at_0 = app.detail_panel_text();
25691
25692        // Navigate to cursor 1 and switch to detail focus
25693        app.update(key(KeyCode::Char('j')));
25694        app.update(key(KeyCode::Tab));
25695        assert_eq!(app.focus, FocusPane::Detail);
25696        let detail_at_1 = app.detail_panel_text();
25697
25698        // Detail content should differ between labels
25699        assert_ne!(
25700            detail_at_0, detail_at_1,
25701            "detail pane should reflect cursor position change"
25702        );
25703
25704        // Verify detail shows open issues for the focused label
25705        assert!(
25706            detail_at_1.contains("Open issues") || detail_at_1.contains("Label:"),
25707            "detail should show label info: {detail_at_1}"
25708        );
25709    }
25710
25711    #[test]
25712    fn attention_cursor_clamp_at_boundary() {
25713        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25714        app.update(key(KeyCode::Char('!')));
25715
25716        let label_count = app.attention_result.as_ref().unwrap().labels.len();
25717
25718        // Navigate past the end — should clamp
25719        for _ in 0..label_count + 5 {
25720            app.update(key(KeyCode::Char('j')));
25721        }
25722        assert!(
25723            app.attention_cursor < label_count,
25724            "cursor should be clamped: {} < {}",
25725            app.attention_cursor,
25726            label_count
25727        );
25728
25729        // Navigate back past the start — should clamp to 0
25730        for _ in 0..label_count + 5 {
25731            app.update(key(KeyCode::Char('k')));
25732        }
25733        assert_eq!(app.attention_cursor, 0, "cursor should clamp to 0");
25734    }
25735
25736    #[test]
25737    fn snap_attention_list_overview() {
25738        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25739        app.update(key(KeyCode::Char('!')));
25740        // List focus, cursor at 0
25741        let text = render_app(&app, 100, 30);
25742        insta::assert_snapshot!(text);
25743    }
25744
25745    // -- Flow Matrix mode expanded coverage (bd-7oo.4.5) --------------------
25746
25747    #[test]
25748    fn keyflow_flow_matrix_full_journey() {
25749        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25750        assert_eq!(app.mode, ViewMode::Main);
25751
25752        // Enter FlowMatrix mode
25753        app.update(key(KeyCode::Char(']')));
25754        assert_eq!(app.mode, ViewMode::FlowMatrix);
25755        assert!(app.flow_matrix.is_some());
25756        assert_eq!(app.focus, FocusPane::List);
25757
25758        // Verify list has flow header
25759        let list = app.list_panel_text();
25760        assert!(
25761            list.contains("Cross-label flow") || list.contains("no labels"),
25762            "list should show flow header: {list}"
25763        );
25764
25765        // Navigate rows with j/k
25766        let label_count = app.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
25767        if label_count > 1 {
25768            app.update(key(KeyCode::Char('j')));
25769            assert_eq!(app.flow_matrix_row_cursor, 1);
25770
25771            // Navigate columns with h/l
25772            app.update(key(KeyCode::Char('l')));
25773            assert_eq!(app.flow_matrix_col_cursor, 1);
25774
25775            // Switch to detail focus
25776            app.update(key(KeyCode::Tab));
25777            assert_eq!(app.focus, FocusPane::Detail);
25778
25779            // Detail should show cross-label info for selected cell
25780            let detail = app.detail_panel_text();
25781            assert!(
25782                !detail.is_empty(),
25783                "detail should have content for selected cell"
25784            );
25785
25786            // Navigate column back
25787            app.update(key(KeyCode::Char('h')));
25788            assert_eq!(app.flow_matrix_col_cursor, 0);
25789
25790            // Switch back to list
25791            app.update(key(KeyCode::Tab));
25792            assert_eq!(app.focus, FocusPane::List);
25793        }
25794
25795        // Open help from FlowMatrix mode
25796        app.update(key(KeyCode::Char('?')));
25797        assert!(app.show_help);
25798        app.update(key(KeyCode::Char('?')));
25799        assert!(!app.show_help);
25800
25801        // Return to Main
25802        app.update(key(KeyCode::Char(']')));
25803        assert_eq!(app.mode, ViewMode::Main);
25804    }
25805
25806    #[test]
25807    fn flow_matrix_narrow_width_rendering_no_panic() {
25808        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25809        app.update(key(KeyCode::Char(']')));
25810        assert_eq!(app.mode, ViewMode::FlowMatrix);
25811
25812        // Render at narrow width — should not panic
25813        let text = render_app(&app, 40, 20);
25814        assert!(
25815            !text.is_empty(),
25816            "narrow flow matrix render should produce output"
25817        );
25818
25819        // Also at very narrow width
25820        let text_tiny = render_app(&app, 20, 10);
25821        assert!(
25822            !text_tiny.is_empty(),
25823            "very narrow flow matrix render should produce output"
25824        );
25825    }
25826
25827    #[test]
25828    fn flow_matrix_detail_changes_on_cell_navigation() {
25829        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25830        app.update(key(KeyCode::Char(']')));
25831
25832        let label_count = app.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
25833        if label_count >= 2 {
25834            // Detail at (0,0) — diagonal, shows label self-info
25835            let detail_diag = app.detail_panel_text();
25836
25837            // Move to (0,1) — off-diagonal, shows cross-label flow
25838            app.update(key(KeyCode::Char('l')));
25839            let detail_off = app.detail_panel_text();
25840
25841            assert_ne!(
25842                detail_diag, detail_off,
25843                "diagonal and off-diagonal cells should show different detail"
25844            );
25845
25846            // Move row down to (1,1) — back on diagonal for different label
25847            app.update(key(KeyCode::Char('j')));
25848            let detail_diag_2 = app.detail_panel_text();
25849
25850            assert_ne!(
25851                detail_diag, detail_diag_2,
25852                "different diagonal cells should show different labels"
25853            );
25854        }
25855    }
25856
25857    #[test]
25858    fn flow_matrix_cursor_clamp_at_boundary() {
25859        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25860        app.update(key(KeyCode::Char(']')));
25861
25862        let label_count = app.flow_matrix.as_ref().map_or(0, |f| f.labels.len());
25863        if label_count > 0 {
25864            // Navigate past row end — should clamp
25865            for _ in 0..label_count + 5 {
25866                app.update(key(KeyCode::Char('j')));
25867            }
25868            assert!(
25869                app.flow_matrix_row_cursor < label_count,
25870                "row cursor should clamp: {} < {}",
25871                app.flow_matrix_row_cursor,
25872                label_count
25873            );
25874
25875            // Navigate past column end — should clamp
25876            for _ in 0..label_count + 5 {
25877                app.update(key(KeyCode::Char('l')));
25878            }
25879            assert!(
25880                app.flow_matrix_col_cursor < label_count,
25881                "col cursor should clamp: {} < {}",
25882                app.flow_matrix_col_cursor,
25883                label_count
25884            );
25885
25886            // Navigate back past start — should clamp to 0
25887            for _ in 0..label_count + 5 {
25888                app.update(key(KeyCode::Char('k')));
25889            }
25890            assert_eq!(app.flow_matrix_row_cursor, 0);
25891            for _ in 0..label_count + 5 {
25892                app.update(key(KeyCode::Char('h')));
25893            }
25894            assert_eq!(app.flow_matrix_col_cursor, 0);
25895        }
25896    }
25897
25898    #[test]
25899    fn snap_flow_matrix_list_overview() {
25900        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
25901        app.update(key(KeyCode::Char(']')));
25902        // List focus, cursors at (0,0)
25903        let text = render_app(&app, 100, 30);
25904        insta::assert_snapshot!(text);
25905    }
25906
25907    #[test]
25908    fn all_modes_work_with_fast_only_metrics() {
25909        // Create analyzer with fast-only metrics
25910        let mut app = new_app(ViewMode::Main, 0);
25911        let issues = app.analyzer.issues.clone();
25912        app.analyzer = Analyzer::new_fast(issues);
25913        app.slow_metrics_pending = true;
25914
25915        // Verify all view modes render without panic
25916        for mode in [
25917            ViewMode::Main,
25918            ViewMode::Board,
25919            ViewMode::Graph,
25920            ViewMode::Insights,
25921            ViewMode::Actionable,
25922        ] {
25923            app.mode = mode;
25924            let _ = render_app(&app, 100, 30);
25925        }
25926    }
25927
25928    // =========================================================================
25929    // E2E TUI JOURNEYS (bd-7oo.4.6)
25930    //
25931    // Multi-mode investigative flows with screen captures at each transition.
25932    // These exercise the complete user experience across mode boundaries.
25933    //
25934    // Run: cargo test --lib e2e_journey_
25935    // =========================================================================
25936
25937    /// Helper: capture a labelled screen dump for an e2e journey step.
25938    /// Returns the rendered text for assertion or snapshot use.
25939    fn journey_capture(
25940        app: &BvrApp,
25941        width: u16,
25942        height: u16,
25943        step: &str,
25944        captures: &mut Vec<(String, String)>,
25945    ) -> String {
25946        let text = render_app(app, width, height);
25947        captures.push((step.to_string(), text.clone()));
25948        text
25949    }
25950
25951    /// Format all captured journey steps into a single diagnostic artifact.
25952    fn journey_artifact(
25953        journey_name: &str,
25954        width: u16,
25955        height: u16,
25956        captures: &[(String, String)],
25957    ) -> String {
25958        let mut out = String::new();
25959        out.push_str(&format!(
25960            "=== E2E Journey: {journey_name} | {width}x{height} ===\n\n"
25961        ));
25962        for (i, (step, text)) in captures.iter().enumerate() {
25963            out.push_str(&format!("--- Step {}: {} ---\n{}\n\n", i + 1, step, text));
25964        }
25965        out
25966    }
25967
25968    #[test]
25969    fn e2e_journey_main_board_insights_graph_investigation() {
25970        let mut app = new_app(ViewMode::Main, 0);
25971        let (w, h) = (120, 35);
25972        let mut caps: Vec<(String, String)> = Vec::new();
25973
25974        // Step 1: Start in Main — verify issue list
25975        let text = journey_capture(&app, w, h, "main_list_start", &mut caps);
25976        assert!(
25977            text.contains("mode=Main") || text.contains("Issues"),
25978            "main should show issue list: {text}"
25979        );
25980
25981        // Step 2: Select an issue and inspect detail
25982        app.update(key(KeyCode::Char('j')));
25983        app.update(key(KeyCode::Tab));
25984        let text = journey_capture(&app, w, h, "main_detail_focus", &mut caps);
25985        assert!(
25986            text.contains("ID:") || text.contains("Status:"),
25987            "detail should show issue: {text}"
25988        );
25989
25990        // Step 3: Enter Board mode
25991        app.update(key(KeyCode::Char('b')));
25992        assert_eq!(app.mode, ViewMode::Board);
25993        let text = journey_capture(&app, w, h, "board_entry", &mut caps);
25994        assert!(
25995            text.contains("open") || text.contains("Board"),
25996            "board should show lane content: {text}"
25997        );
25998
25999        // Step 4: Navigate board lanes
26000        app.update(key(KeyCode::Char('l')));
26001        app.update(key(KeyCode::Char('j')));
26002        let text = journey_capture(&app, w, h, "board_navigate", &mut caps);
26003        assert!(!text.is_empty());
26004
26005        // Step 5: Jump to Graph from board
26006        app.update(key(KeyCode::Char('g')));
26007        assert_eq!(app.mode, ViewMode::Graph);
26008        let text = journey_capture(&app, w, h, "graph_entry_from_board", &mut caps);
26009        assert!(
26010            text.contains("Graph") || text.contains("graph") || text.contains("Dep"),
26011            "graph should show graph content: {text}"
26012        );
26013
26014        // Step 6: Inspect graph detail
26015        app.update(key(KeyCode::Tab));
26016        let text = journey_capture(&app, w, h, "graph_detail_focus", &mut caps);
26017        assert!(!text.is_empty());
26018
26019        // Step 7: Switch to Insights
26020        app.update(key(KeyCode::Char('i')));
26021        assert_eq!(app.mode, ViewMode::Insights);
26022        let text = journey_capture(&app, w, h, "insights_entry", &mut caps);
26023        assert!(
26024            text.contains("Bottleneck") || text.contains("bottleneck") || text.contains("Insight"),
26025            "insights should show analysis: {text}"
26026        );
26027
26028        // Step 8: Cycle insights panels
26029        app.update(key(KeyCode::Char('s')));
26030        let text = journey_capture(&app, w, h, "insights_panel_cycle", &mut caps);
26031        assert!(!text.is_empty());
26032
26033        // Step 9: Return to Main
26034        app.update(key(KeyCode::Escape));
26035        assert_eq!(app.mode, ViewMode::Main);
26036        let text = journey_capture(&app, w, h, "main_return", &mut caps);
26037        assert!(text.contains("mode=Main") || text.contains("Issues"));
26038
26039        // Snapshot the full journey artifact
26040        let artifact = journey_artifact("main→board→graph→insights→main", w, h, &caps);
26041        insta::assert_snapshot!(artifact);
26042    }
26043
26044    #[test]
26045    fn e2e_journey_actionable_tree_attention_flow() {
26046        let mut app = new_app_with_issues(ViewMode::Main, 0, labeled_issues());
26047        let (w, h) = (120, 35);
26048        let mut caps: Vec<(String, String)> = Vec::new();
26049
26050        // Step 1: Start in Main
26051        journey_capture(&app, w, h, "main_start", &mut caps);
26052
26053        // Step 2: Enter Actionable — check execution tracks
26054        app.update(key(KeyCode::Char('a')));
26055        assert_eq!(app.mode, ViewMode::Actionable);
26056        let text = journey_capture(&app, w, h, "actionable_entry", &mut caps);
26057        assert!(
26058            text.contains("ACTIONABLE") || text.contains("actionable"),
26059            "should show actionable items: {text}"
26060        );
26061
26062        // Step 3: Navigate tracks and detail
26063        app.update(key(KeyCode::Tab));
26064        app.update(key(KeyCode::Char('j')));
26065        journey_capture(&app, w, h, "actionable_detail_nav", &mut caps);
26066
26067        // Step 4: Return to Main, enter Tree
26068        app.update(key(KeyCode::Char('a')));
26069        assert_eq!(app.mode, ViewMode::Main);
26070        app.update(key(KeyCode::Char('T')));
26071        assert_eq!(app.mode, ViewMode::Tree);
26072        let text = journey_capture(&app, w, h, "tree_entry", &mut caps);
26073        assert!(
26074            text.contains("Dependency tree") || text.contains("no dependency tree"),
26075            "tree should show structure: {text}"
26076        );
26077
26078        // Step 5: Navigate tree and expand/collapse
26079        let has_children = app.tree_flat_nodes.iter().any(|n| n.has_children);
26080        if has_children {
26081            let idx = app
26082                .tree_flat_nodes
26083                .iter()
26084                .position(|n| n.has_children)
26085                .unwrap();
26086            app.tree_cursor = idx;
26087            app.update(key(KeyCode::Enter)); // collapse
26088            journey_capture(&app, w, h, "tree_collapsed", &mut caps);
26089            app.update(key(KeyCode::Enter)); // expand
26090        }
26091        journey_capture(&app, w, h, "tree_navigate", &mut caps);
26092
26093        // Step 6: Return to Main, enter Attention
26094        app.update(key(KeyCode::Char('T')));
26095        assert_eq!(app.mode, ViewMode::Main);
26096        app.update(key(KeyCode::Char('!')));
26097        assert_eq!(app.mode, ViewMode::Attention);
26098        let text = journey_capture(&app, w, h, "attention_entry", &mut caps);
26099        assert!(
26100            text.contains("Rank") || text.contains("Score") || text.contains("Attention"),
26101            "attention should show ranked labels: {text}"
26102        );
26103
26104        // Step 7: Navigate attention labels and detail
26105        app.update(key(KeyCode::Char('j')));
26106        app.update(key(KeyCode::Tab));
26107        journey_capture(&app, w, h, "attention_detail", &mut caps);
26108
26109        // Step 8: Return to Main
26110        app.update(key(KeyCode::Char('!')));
26111        assert_eq!(app.mode, ViewMode::Main);
26112        journey_capture(&app, w, h, "main_return", &mut caps);
26113
26114        let artifact = journey_artifact("actionable→tree→attention", w, h, &caps);
26115        insta::assert_snapshot!(artifact);
26116    }
26117
26118    #[test]
26119    fn e2e_journey_main_search_focus_recovery() {
26120        let mut app = new_app(ViewMode::Main, 0);
26121        let (w, h) = (120, 35);
26122        let mut caps: Vec<(String, String)> = Vec::new();
26123
26124        let text = journey_capture(&app, w, h, "main_start", &mut caps);
26125        assert!(text.contains("Focus: list owns selection"));
26126
26127        app.update(key(KeyCode::Char('/')));
26128        app.update(key(KeyCode::Char('d')));
26129        let text = journey_capture(&app, w, h, "search_active", &mut caps);
26130        assert!(text.contains("Search (active): /d"));
26131
26132        app.update(key(KeyCode::Enter));
26133        let text = journey_capture(&app, w, h, "search_committed", &mut caps);
26134        assert!(text.contains("Matches: 1/3"));
26135        assert!(text.contains("hit 1/3"), "search committed frame: {text}");
26136
26137        app.update(key(KeyCode::Char('n')));
26138        let text = journey_capture(&app, w, h, "search_cycle_second_hit", &mut caps);
26139        assert!(text.contains("Matches: 2/3"));
26140        assert!(text.contains("hit 2/3"), "search cycled frame: {text}");
26141
26142        app.update(key(KeyCode::Tab));
26143        let text = journey_capture(&app, w, h, "detail_focus", &mut caps);
26144        assert!(text.contains("Focus: detail owns J/K deps"));
26145
26146        app.update(key(KeyCode::Escape));
26147        let text = journey_capture(&app, w, h, "focus_recovered", &mut caps);
26148        assert!(text.contains("Focus returned to list"));
26149
26150        app.update(key(KeyCode::Char('/')));
26151        app.update(key(KeyCode::Char('z')));
26152        app.update(key(KeyCode::Enter));
26153        let text = journey_capture(&app, w, h, "search_no_hit", &mut caps);
26154        assert!(text.contains("Matches: none in visible issues"));
26155
26156        app.update(key(KeyCode::Escape));
26157        let text = journey_capture(&app, w, h, "search_cleared", &mut caps);
26158        assert!(text.contains("Main search cleared"));
26159
26160        app.update(key(KeyCode::Char('o')));
26161        let text = journey_capture(&app, w, h, "open_filter", &mut caps);
26162        assert!(text.contains("scope=open"));
26163
26164        app.update(key(KeyCode::Escape));
26165        let text = journey_capture(&app, w, h, "filter_cleared", &mut caps);
26166        assert!(text.contains("scope=all"));
26167    }
26168
26169    #[test]
26170    fn e2e_journey_narrow_geometry_stress() {
26171        // Exercises the same multi-mode flow at narrow terminal width
26172        let mut app = new_app(ViewMode::Main, 0);
26173        let (w, h) = (40, 15);
26174        let mut caps: Vec<(String, String)> = Vec::new();
26175
26176        // Main at narrow
26177        journey_capture(&app, w, h, "narrow_main", &mut caps);
26178
26179        // Board at narrow
26180        app.update(key(KeyCode::Char('b')));
26181        journey_capture(&app, w, h, "narrow_board", &mut caps);
26182        app.update(key(KeyCode::Char('j')));
26183        app.update(key(KeyCode::Char('l')));
26184        journey_capture(&app, w, h, "narrow_board_nav", &mut caps);
26185
26186        // Graph at narrow
26187        app.update(key(KeyCode::Char('g')));
26188        journey_capture(&app, w, h, "narrow_graph", &mut caps);
26189
26190        // Insights at narrow
26191        app.update(key(KeyCode::Char('i')));
26192        journey_capture(&app, w, h, "narrow_insights", &mut caps);
26193        app.update(key(KeyCode::Char('s')));
26194        journey_capture(&app, w, h, "narrow_insights_cycle", &mut caps);
26195
26196        // Actionable at narrow
26197        app.update(key(KeyCode::Escape));
26198        app.update(key(KeyCode::Char('a')));
26199        journey_capture(&app, w, h, "narrow_actionable", &mut caps);
26200
26201        // Tree at narrow
26202        app.update(key(KeyCode::Char('a')));
26203        app.update(key(KeyCode::Char('T')));
26204        journey_capture(&app, w, h, "narrow_tree", &mut caps);
26205
26206        // Return to Main
26207        app.update(key(KeyCode::Char('T')));
26208        journey_capture(&app, w, h, "narrow_main_return", &mut caps);
26209
26210        let artifact = journey_artifact("narrow-geometry-stress", w, h, &caps);
26211        insta::assert_snapshot!(artifact);
26212    }
26213
26214    #[test]
26215    fn e2e_journey_empty_data_edge_case() {
26216        // Multi-mode flow with zero issues — proves no panics and useful messaging
26217        let mut app = new_app_with_issues(ViewMode::Main, 0, vec![]);
26218        let (w, h) = (100, 30);
26219        let mut caps: Vec<(String, String)> = Vec::new();
26220
26221        // Main with no issues
26222        let text = journey_capture(&app, w, h, "empty_main", &mut caps);
26223        assert!(
26224            text.contains("issues=0") || text.contains("No issues") || text.contains("mode=Main"),
26225            "empty main should render: {text}"
26226        );
26227
26228        // Board with no issues
26229        app.update(key(KeyCode::Char('b')));
26230        let text = journey_capture(&app, w, h, "empty_board", &mut caps);
26231        assert!(!text.is_empty(), "empty board should render something");
26232
26233        // Graph with no issues
26234        app.update(key(KeyCode::Char('g')));
26235        journey_capture(&app, w, h, "empty_graph", &mut caps);
26236
26237        // Insights with no issues
26238        app.update(key(KeyCode::Char('i')));
26239        journey_capture(&app, w, h, "empty_insights", &mut caps);
26240
26241        // Actionable with no issues
26242        app.update(key(KeyCode::Escape));
26243        app.update(key(KeyCode::Char('a')));
26244        let text = journey_capture(&app, w, h, "empty_actionable", &mut caps);
26245        assert!(
26246            text.contains("Actionable")
26247                || text.contains("Execution Tracks")
26248                || text.contains("ACTIONABLE"),
26249            "empty actionable should render: {text}"
26250        );
26251
26252        // Tree with no issues
26253        app.update(key(KeyCode::Char('a')));
26254        app.update(key(KeyCode::Char('T')));
26255        journey_capture(&app, w, h, "empty_tree", &mut caps);
26256
26257        // Return to Main
26258        app.update(key(KeyCode::Char('T')));
26259        journey_capture(&app, w, h, "empty_main_return", &mut caps);
26260
26261        let artifact = journey_artifact("empty-data-edge-case", w, h, &caps);
26262        insta::assert_snapshot!(artifact);
26263    }
26264
26265    #[test]
26266    fn e2e_journey_history_deep_dive() {
26267        let mut app = new_app(ViewMode::Main, 0);
26268        inject_deterministic_git_cache(&mut app);
26269        let (w, h) = (120, 35);
26270        let mut caps: Vec<(String, String)> = Vec::new();
26271
26272        // Enter History from Main
26273        app.update(key(KeyCode::Char('h')));
26274        assert_eq!(app.mode, ViewMode::History);
26275        let text = journey_capture(&app, w, h, "history_entry", &mut caps);
26276        assert!(!text.is_empty());
26277
26278        // Navigate bead history
26279        app.update(key(KeyCode::Char('j')));
26280        journey_capture(&app, w, h, "history_bead_nav", &mut caps);
26281
26282        // Switch to git mode
26283        app.update(key(KeyCode::Char('v')));
26284        journey_capture(&app, w, h, "history_git_mode", &mut caps);
26285
26286        // Search in history
26287        app.update(key(KeyCode::Char('/')));
26288        for ch in "test".chars() {
26289            app.update(key(KeyCode::Char(ch)));
26290        }
26291        app.update(key(KeyCode::Enter));
26292        journey_capture(&app, w, h, "history_search", &mut caps);
26293
26294        // Switch back to bead mode
26295        app.update(key(KeyCode::Char('v')));
26296        journey_capture(&app, w, h, "history_bead_return", &mut caps);
26297
26298        // Navigate focus panes
26299        app.update(key(KeyCode::Tab));
26300        journey_capture(&app, w, h, "history_middle_focus", &mut caps);
26301        app.update(key(KeyCode::Tab));
26302        journey_capture(&app, w, h, "history_detail_focus", &mut caps);
26303
26304        // Return to Main
26305        app.update(key(KeyCode::Escape));
26306        assert_eq!(app.mode, ViewMode::Main);
26307        journey_capture(&app, w, h, "main_after_history", &mut caps);
26308
26309        let artifact = journey_artifact("history-deep-dive", w, h, &caps);
26310        insta::assert_snapshot!(artifact);
26311    }
26312
26313    // -- Visual primitive tests ------------------------------------------------
26314
26315    /// Convert a slice of `RichSpan` into plain text for test assertions.
26316    fn spans_text(spans: &[RichSpan<'_>]) -> String {
26317        RichLine::from_spans(spans.to_vec()).to_plain_text()
26318    }
26319
26320    /// Convert a single `RichSpan` into plain text for test assertions.
26321    fn span_text(span: &RichSpan<'_>) -> String {
26322        RichLine::from_spans([span.clone()]).to_plain_text()
26323    }
26324
26325    #[test]
26326    fn status_chip_covers_all_known_statuses() {
26327        for status in &[
26328            "open",
26329            "in_progress",
26330            "blocked",
26331            "closed",
26332            "deferred",
26333            "review",
26334            "pinned",
26335            "tombstone",
26336            "hooked",
26337        ] {
26338            let spans = status_chip(status);
26339            assert_eq!(
26340                spans.len(),
26341                2,
26342                "status_chip({status}) should return 2 spans"
26343            );
26344            let text = spans_text(&spans);
26345            assert!(!text.contains('?'), "known status {status} got '?' icon");
26346        }
26347    }
26348
26349    #[test]
26350    fn status_chip_unknown_shows_question() {
26351        let text = spans_text(&status_chip("nonexistent"));
26352        assert!(text.contains('?'));
26353        assert!(text.contains("unkn"));
26354    }
26355
26356    #[test]
26357    fn priority_badge_clamps_range() {
26358        assert_eq!(span_text(&priority_badge(0)), "P0");
26359        assert_eq!(span_text(&priority_badge(4)), "P4");
26360        assert_eq!(span_text(&priority_badge(-5)), "P0");
26361        assert_eq!(span_text(&priority_badge(99)), "P4");
26362    }
26363
26364    #[test]
26365    fn type_badge_maps_types() {
26366        assert_eq!(span_text(&type_badge("task")), "T");
26367        assert_eq!(span_text(&type_badge("bug")), "B");
26368        assert_eq!(span_text(&type_badge("epic")), "E");
26369    }
26370
26371    #[test]
26372    fn blocker_indicator_states() {
26373        assert!(blocker_indicator(0, 0).is_empty());
26374        assert!(spans_text(&blocker_indicator(3, 0)).contains('3'));
26375        assert!(spans_text(&blocker_indicator(0, 5)).contains('5'));
26376    }
26377
26378    #[test]
26379    fn metric_strip_content() {
26380        let text = spans_text(&metric_strip("PR", 0.42, 1.0));
26381        assert!(text.contains("PR"));
26382        assert!(text.contains("0.42"));
26383    }
26384
26385    #[test]
26386    fn section_separator_caps_width() {
26387        assert!(display_width(&section_separator(200).to_plain_text()) <= 120);
26388    }
26389
26390    #[test]
26391    fn panel_header_content() {
26392        let h = panel_header("Issues", Some("3 open")).to_plain_text();
26393        assert!(h.contains("Issues"));
26394        assert!(h.contains("3 open"));
26395        assert_eq!(panel_header("Graph", None).to_plain_text(), "Graph");
26396    }
26397
26398    #[test]
26399    fn label_chips_content() {
26400        let text = spans_text(&label_chips(&["backend".into(), "urgent".into()]));
26401        assert!(text.contains("[backend]"));
26402        assert!(text.contains("[urgent]"));
26403        assert!(label_chips(&[]).is_empty());
26404    }
26405
26406    #[test]
26407    fn issue_scan_line_fields() {
26408        let issue = Issue {
26409            id: "BD-42".into(),
26410            title: "Fix widget".into(),
26411            status: "open".into(),
26412            priority: 1,
26413            issue_type: "bug".into(),
26414            ..Default::default()
26415        };
26416        let text = issue_scan_line(
26417            &issue,
26418            false,
26419            ScanLineContext {
26420                open_blockers: 0,
26421                blocks_count: 0,
26422                triage_rank: 3,
26423                pagerank_rank: 2,
26424                critical_depth: 1,
26425                graph_score: 0.0,
26426                search_match_position: None,
26427                total_search_matches: 0,
26428                diff_tag: None,
26429                available_width: 80,
26430            },
26431        )
26432        .to_plain_text();
26433        assert!(text.contains("BD-42"), "id missing: {text}");
26434        assert!(text.contains("Fix widget"), "title missing: {text}");
26435        assert!(text.contains("P1"), "priority missing: {text}");
26436        assert!(text.contains("#03"), "triage rank missing: {text}");
26437    }
26438
26439    #[test]
26440    fn issue_scan_line_selected_marker() {
26441        let issue = Issue {
26442            id: "A".into(),
26443            title: "Test".into(),
26444            status: "open".into(),
26445            issue_type: "task".into(),
26446            ..Default::default()
26447        };
26448        assert!(
26449            issue_scan_line(
26450                &issue,
26451                true,
26452                ScanLineContext {
26453                    open_blockers: 0,
26454                    blocks_count: 0,
26455                    triage_rank: 1,
26456                    pagerank_rank: 1,
26457                    critical_depth: 0,
26458                    graph_score: 0.0,
26459                    search_match_position: None,
26460                    total_search_matches: 0,
26461                    diff_tag: None,
26462                    available_width: 60,
26463                },
26464            )
26465            .to_plain_text()
26466            .starts_with('▸')
26467        );
26468    }
26469
26470    #[test]
26471    fn issue_scan_line_blocker() {
26472        let issue = Issue {
26473            id: "A".into(),
26474            title: "Blocked".into(),
26475            status: "blocked".into(),
26476            issue_type: "task".into(),
26477            ..Default::default()
26478        };
26479        let text = issue_scan_line(
26480            &issue,
26481            false,
26482            ScanLineContext {
26483                open_blockers: 2,
26484                blocks_count: 1,
26485                triage_rank: 4,
26486                pagerank_rank: 3,
26487                critical_depth: 2,
26488                graph_score: 0.0,
26489                search_match_position: None,
26490                total_search_matches: 0,
26491                diff_tag: None,
26492                available_width: 80,
26493            },
26494        )
26495        .to_plain_text();
26496        assert!(text.contains("⊘2"), "blocker missing: {text}");
26497        assert!(text.contains("↓1"), "downstream count missing: {text}");
26498    }
26499
26500    // ---------- tree fold (zc / zo) tests ----------
26501
26502    /// Build a three-level parent-child tree:
26503    ///     P
26504    ///     ├── C1   (leaf)
26505    ///     ├── C2
26506    ///     │   └── G (leaf grandchild of P, child of C2)
26507    ///     └── C3   (leaf)
26508    fn tree_fold_fixture_issues() -> Vec<Issue> {
26509        let p = Issue {
26510            id: "P".to_string(),
26511            title: "Parent".to_string(),
26512            status: "open".to_string(),
26513            issue_type: "task".to_string(),
26514            ..Issue::default()
26515        };
26516        let mk_child = |id: &str, parent: &str| Issue {
26517            id: id.to_string(),
26518            title: id.to_string(),
26519            status: "open".to_string(),
26520            issue_type: "task".to_string(),
26521            dependencies: vec![Dependency {
26522                issue_id: id.to_string(),
26523                depends_on_id: parent.to_string(),
26524                dep_type: "parent-child".to_string(),
26525                ..Dependency::default()
26526            }],
26527            ..Issue::default()
26528        };
26529        vec![
26530            p,
26531            mk_child("C1", "P"),
26532            mk_child("C2", "P"),
26533            mk_child("G", "C2"),
26534            mk_child("C3", "P"),
26535        ]
26536    }
26537
26538    fn tree_app_with(issues: Vec<Issue>) -> BvrApp {
26539        let mut app = new_app(ViewMode::Tree, 0);
26540        app.analyzer = Analyzer::new(issues);
26541        app.tree_cursor = 0;
26542        app.tree_collapsed = std::collections::HashSet::new();
26543        app.build_tree_flat_nodes();
26544        app
26545    }
26546
26547    fn tree_row_ids(app: &BvrApp) -> Vec<String> {
26548        app.tree_flat_nodes
26549            .iter()
26550            .map(|n| app.analyzer.issues[n.issue_index].id.clone())
26551            .collect()
26552    }
26553
26554    fn tree_cursor_id(app: &BvrApp) -> String {
26555        app.tree_flat_nodes
26556            .get(app.tree_cursor)
26557            .map(|n| app.analyzer.issues[n.issue_index].id.clone())
26558            .unwrap_or_default()
26559    }
26560
26561    #[test]
26562    fn tree_zc_on_leaf_closes_enclosing_parent_and_moves_cursor() {
26563        let mut app = tree_app_with(tree_fold_fixture_issues());
26564        let rows = tree_row_ids(&app);
26565        // Expected DFS order: P, C1, C2, G, C3.
26566        assert_eq!(rows, vec!["P", "C1", "C2", "G", "C3"]);
26567
26568        // Place cursor on leaf C1, press zc — should collapse P, cursor moves to P.
26569        let c1_idx = rows.iter().position(|id| id == "C1").unwrap();
26570        app.tree_cursor = c1_idx;
26571        app.tree_collapse_current();
26572        assert_eq!(
26573            tree_row_ids(&app),
26574            vec!["P"],
26575            "all children should fold into P"
26576        );
26577        assert_eq!(
26578            tree_cursor_id(&app),
26579            "P",
26580            "cursor should land on the newly closed parent"
26581        );
26582    }
26583
26584    #[test]
26585    fn tree_zc_on_deep_leaf_closes_nearest_enclosing_ancestor() {
26586        let mut app = tree_app_with(tree_fold_fixture_issues());
26587        let rows = tree_row_ids(&app);
26588
26589        // Cursor on grandchild G — should close C2 (nearest foldable ancestor),
26590        // NOT jump straight to P.
26591        let g_idx = rows.iter().position(|id| id == "G").unwrap();
26592        app.tree_cursor = g_idx;
26593        app.tree_collapse_current();
26594
26595        let rows_after = tree_row_ids(&app);
26596        assert_eq!(rows_after, vec!["P", "C1", "C2", "C3"]);
26597        assert_eq!(tree_cursor_id(&app), "C2");
26598
26599        // Pressing zc again from C2 (now a collapsed foldable) should walk up to P.
26600        app.tree_collapse_current();
26601        assert_eq!(tree_row_ids(&app), vec!["P"]);
26602        assert_eq!(tree_cursor_id(&app), "P");
26603    }
26604
26605    #[test]
26606    fn tree_zc_on_open_parent_still_collapses_in_place() {
26607        // Regression guard: the original behavior — zc on a foldable, open node
26608        // collapses that node — must still work.
26609        let mut app = tree_app_with(tree_fold_fixture_issues());
26610        // Cursor on P (root, foldable).
26611        app.tree_cursor = 0;
26612        assert_eq!(tree_cursor_id(&app), "P");
26613        app.tree_collapse_current();
26614        assert_eq!(tree_row_ids(&app), vec!["P"]);
26615        assert_eq!(tree_cursor_id(&app), "P");
26616    }
26617
26618    #[test]
26619    fn tree_zc_on_orphan_root_leaf_is_noop() {
26620        // A single issue with no children and no parent: zc must not panic or move cursor.
26621        let issues = vec![Issue {
26622            id: "SOLO".to_string(),
26623            title: "Solo".to_string(),
26624            status: "open".to_string(),
26625            issue_type: "task".to_string(),
26626            ..Issue::default()
26627        }];
26628        let mut app = tree_app_with(issues);
26629        assert_eq!(tree_row_ids(&app), vec!["SOLO"]);
26630        app.tree_cursor = 0;
26631        app.tree_collapse_current();
26632        assert_eq!(tree_row_ids(&app), vec!["SOLO"]);
26633        assert_eq!(tree_cursor_id(&app), "SOLO");
26634    }
26635}