---
plan: true
title: "Gitlore TUI PRD v2 - FrankenTUI"
status: iterating
iteration: 11
target_iterations: 10
beads_revision: 0
related_plans: []
created: 2026-02-11
updated: 2026-02-12
---

# Gitlore TUI — Product Requirements Document

**Author:** Work Ghost
**Date:** 2026-02-11
**Status:** DRAFT — Pending Approval
**Scope:** Interactive terminal UI for the `lore` CLI tool
**Framework:** FrankenTUI v0.1.1+

---

## Table of Contents

1. [Executive Summary](#1-executive-summary)
2. [Framework Selection](#2-framework-selection)
3. [Risk Mitigation](#3-risk-mitigation)
4. [Architecture](#4-architecture)
5. [Screen Taxonomy](#5-screen-taxonomy)
6. [User Flows — Every Command](#6-user-flows)
7. [Widget Inventory](#7-widget-inventory)
8. [Keybinding Reference](#8-keybinding-reference)
9. [Implementation Plan](#9-implementation-plan)
10. [Code Changes Required](#10-code-changes-required)
11. [Assumptions](#11-assumptions)

---

## 1. Executive Summary

Gitlore (`lore`) is a 32,844-line Rust CLI for local GitLab data management with semantic search, people intelligence, and temporal analysis. It currently offers 21 commands via clap with two output modes: human-readable tables (`comfy-table`, `indicatif`, `console`) and machine-readable JSON (`--robot`).

**The problem:** Power users must memorize 21 commands, ~80 flags, and their interaction semantics. Discovering data relationships (who touched which files → which MRs → which discussions) requires sequential command invocations. There is no way to browse, filter, and drill into data interactively.

**The solution:** A full-screen interactive TUI that provides:

- **Dashboard** — sync status, project health, counts, and ranked actionable insights
- **Browsable entity lists** — issues, MRs, discussions with live filtering
- **Drill-down detail views** — threaded discussions, diff notes, cross-references
- **Integrated search** — hybrid FTS/semantic search with inline results
- **People explorer** — expert maps, workload snapshots, review patterns
- **Timeline viewer** — chronological event reconstruction with graph navigation
- **Sync orchestration** — progress visualization with real-time log streaming

- **Session restore** — resume last screen, filters, and selection on startup (`--fresh` to skip)

The TUI augments but does not replace the existing CLI. All commands continue to work as-is.

---

## 2. Framework Selection

### 2.1 Candidates Evaluated

| Dimension | FrankenTUI | Ratatui | Charm (bubbletea) |
|-----------|-----------|---------|-------------------|
| **Language** | Rust (nightly) | Rust (stable) | Go |
| **Age** | 6 days (Feb 2026) | 3+ years (fork of tui-rs) | 4+ years |
| **Downloads** | ~10 (crates.io) | 16.9M (crates.io) | N/A (Go module) |
| **Stars** | 125 | 18,200+ | 28,000+ |
| **Dependent crates** | 0 | 2,200+ | N/A |
| **Production users** | None known | Netflix, OpenAI, AWS, Vercel | Numerous Go tools |
| **Architecture** | Elm/Bubbletea | Immediate-mode | Elm/Bubbletea |
| **Inline mode** | First-class kernel feature | App-specific hack | First-class (Bubble Tea) |
| **Layout** | Flex + Grid + Responsive breakpoints | Constraint-based Flex | Lipgloss (CSS-like) |
| **Widgets** | Rich (Table, Tree, CommandPalette, Modal, LogViewer, FilePicker, Charts) | Core set + community ecosystem | Bubbles (spinner, text input, list, paginator, table, viewport) |
| **Snapshot testing** | Built-in harness | None built-in | None built-in |
| **RAII teardown** | Kernel-enforced | App-specific | Deferred |
| **Theming** | WCAG-compliant, light/dark, semantic tokens | Manual style application | Lipgloss (adaptive colors) |
| **Accessibility** | WCAG contrast validation | None | None |
| **Toolchain req.** | Rust nightly | Rust stable | Go 1.18+ |
| **Documentation** | 76-94% (docs.rs) | Extensive tutorials + book | Excellent |

**Charm is eliminated.** It's Go-only; gitlore is Rust. FFI bridging would be absurd. However, Charm's Elm architecture and inline-mode approach directly influenced FrankenTUI's design.

### 2.2 Why FrankenTUI

**1. Architectural fit is exceptional.** FrankenTUI's `Model` trait with `update()/view()` + `Cmd<Msg>` is the Elm architecture we need for clean TUI state management — but as the native runtime rather than a hand-rolled abstraction layer. Zero impedance mismatch.

**2. The widget catalog eliminates ~80% of custom widget work.** We need 10 widget types for gitlore's screens. FrankenTUI provides 9 built-in:

| Required Widget | FrankenTUI Solution |
|----------------|---------------------|
| Command Palette | `ftui_widgets::command_palette::CommandPalette` |
| Discussion Tree | `ftui_widgets::tree::Tree` |
| Filter Bar | `ftui_extras::forms` + `ftui_widgets::input::TextInput` |
| Progress Panel | `ftui_widgets::progress::ProgressBar` + `ftui_widgets::log_viewer::LogViewer` |
| Entity Preview | `ftui_widgets::panel::Panel` + `ftui_widgets::paragraph::Paragraph` |
| Timeline Stream | `ftui_widgets::virtualized::VirtualizedList` |
| Breadcrumb | Trivial with `ftui_widgets::badge::Badge` chain |
| Status Bar | `ftui_widgets::status_line::StatusLine` |
| Cross-Ref Link | Custom styled `Span` (~30 lines) |

Only `CrossRefLink` requires custom implementation — everything else maps to a built-in.

**3. Inline mode is perfect for sync.** The `ScreenMode::Inline { ui_height }` keeps progress bars stable at the top while log output scrolls naturally in terminal scrollback. After sync completes and the TUI exits, the full log history remains visible in the terminal scrollback.

**4. Responsive breakpoints are first-class.** The `Breakpoints` / `Breakpoint` / `Responsive<T>` / `ResponsiveLayout` types let us define different layouts per terminal width declaratively.

**5. WCAG contrast validation prevents accessibility bugs.** The `best_text_color()`, `meets_wcag_aa()`, and `contrast_ratio()` functions in `ftui-style` ensure theme choices are readable.

**6. Snapshot testing harness enables TUI regression tests.** The built-in `ftui-harness` crate provides deterministic frame capture and comparison.

### 2.3 What We're Betting On

We are making a deliberate bet that FrankenTUI's technical superiority justifies its immaturity. This is a calculated risk, not a blind one. See Section 3 for the full mitigation strategy.

---

## 3. Risk Mitigation

### 3.1 Risk Matrix

| Risk | Severity | Likelihood | Mitigation |
|------|----------|------------|------------|
| API breaking changes | High | High (v0.x) | Pin exact git commit; vendor source if needed |
| Bidi/invisible Unicode spoofing in rendered text | High | Medium | Strip bidi overrides (RLO/LRO/PDF/RLI/LRI/FSI/PDI) and zero-width/invisible controls in untrusted text |
| Terminal compatibility bugs | High | Medium | Test matrix: iTerm2, Terminal.app, Alacritty, tmux, SSH |
| Nightly Rust instability | Medium | Low | Pin nightly date in `rust-toolchain.toml` (TUI crate only); CI tests on pinned nightly |
| Maintainer abandonment | High | Low-Medium | Fork-ready; MIT license; 12-crate workspace is navigable |
| Missing documentation | Medium | High | Read source directly; contribute docs upstream |
| Build failures from nightly breakage | Medium | Medium | Pin channel date, update monthly with test gate |
| Query latency regressions on large datasets | Medium | Medium | Performance SLOs enforced by benchmark fixtures in CI (see 9.3) |
| DB contention during sync | Medium | High | DbManager with read pool + dedicated writer; WAL + busy_timeout |
| Runtime panic leaves user blocked | High | Medium | Panic hook captures crash context (last 2000 events ring buffer + screen/nav/task/build/db snapshot), restores terminal, offers fallback CLI command. Retention: latest 20 crash files, oldest auto-pruned. |
| Hard-to-reproduce input race bugs | Medium | Medium | Crash context ring buffer includes last 2000 normalized events + current screen + in-flight task keys/generations + build version + DB fingerprint for post-mortem replay |
| Interrupted sync loses partial progress | Medium | Medium | Per-project fault isolation; failed lanes marked degraded while others continue. Resumable checkpoints planned for post-v1 (requires `sync_checkpoints` table). |
| Malicious URL in entity data opened in browser | Medium | Low | Validate scheme+host+port AND path pattern allowlist (`/-/issues/`, `/-/merge_requests/`, project issue/MR routes) before `open`/`xdg-open`. Unknown same-host paths require explicit confirm modal. |
| Terminal escape/control-sequence injection via issue/note text | High | Medium | Strip ANSI/OSC/control chars + C1 controls (U+0080..U+009F) + bidi overrides + directional marks (LRM/RLM/ALM) via `sanitize_for_terminal()` before render; origin-normalized URL validation before open; disable raw HTML in markdown rendering |

### 3.2 Nightly Rust Strategy

FrankenTUI requires nightly for features like `#![feature(...)]` in its workspace. We handle this with **complete toolchain isolation via a separate binary crate**:

```toml
# Workspace root Cargo.toml — lore-tui is EXCLUDED, not a member.
# This prevents `cargo check --workspace` from pulling nightly.
[workspace]
members = ["."]
exclude = ["crates/lore-tui"]

# crates/lore-tui/rust-toolchain.toml
[toolchain]
channel = "nightly-2026-02-08"  # Pin to known-good date
components = ["rustfmt", "clippy"]
```

**Key decisions:**
- Core `lore` stays on **stable Rust**. Only `crates/lore-tui` uses pinned nightly.
- **`crates/lore-tui` is excluded from the root workspace**, not listed as a member. This structurally prevents `cargo check --workspace`, `cargo clippy --workspace`, and `cargo test --workspace` from ever touching nightly code. To build the TUI: `cargo +nightly-2026-02-08 check --manifest-path crates/lore-tui/Cargo.toml`. Core repo remains: `cargo +stable check --workspace`.
- We pin to a specific nightly date (not floating `nightly`). Updates are deliberate, tested, and committed.
- CI runs both `cargo check` and `cargo test` on the pinned nightly for `lore-tui`, and stable for everything else. CI scripts must use explicit `--manifest-path` invocations for the TUI crate.
- If a nightly update breaks us, we stay on the previous pin until it's fixed — this is a solved workflow.
- **`lore-tui` is a separate binary crate (nightly-only).** The root `lore` crate has **NO compile-time dependency** on `lore-tui`. The `lore tui` subcommand delegates by resolving and spawning the `lore-tui` binary at runtime. This eliminates the Cargo dependency cycle risk that would exist with `lore -> lore-tui (optional)` + `lore-tui -> lore` under a feature gate.
- **Compatibility contract:** Before spawning `lore-tui`, the `lore tui` subcommand validates:
  1. Binary compat version (`lore-tui --compat-version`) — integer check against a minimum supported version constant. On mismatch: `"lore-tui is too old (compat v1, need v2). Rebuild with: cargo install --path crates/lore-tui"`.
  2. DB schema version (`lore-tui --check-schema <db-path>`) — validates the database schema version is within the supported range for this TUI build. On mismatch: `"Database schema too old (v12, need v14+). Run: lore migrate"`.

  This catches version drift between the two binaries — the #1 support failure mode for runtime delegation architectures. The contract is intentionally minimal: integer versions, not JSON schemas, because it only needs to detect "too old to work" vs "compatible."
- If `lore-tui` is not installed, `lore tui` prints an actionable error: `"lore-tui binary not found. Build with: cargo install --path crates/lore-tui"`.
- Non-TUI users, CI pipelines, and robot-mode agents never touch nightly.

**Why separate binary over feature-gated library dep:** A feature-gated `lore-tui` library dependency creates a hidden Cargo package cycle: the root `lore` depends on `lore-tui` (optional), but `lore-tui` depends on `lore` for core types. While Cargo can sometimes resolve optional dependencies without cycles, this is fragile and becomes a real blocker when both crates need to reference shared types. A separate binary eliminates this risk entirely — `lore-tui` depends on `lore` as a library, and `lore` has zero knowledge of `lore-tui` at compile time. The runtime delegation cost is negligible (one `Command::new` + `exec`).

**Why workspace isolation over whole-project nightly:** Nightly brings risk (compiler regressions, clippy changes, feature instability). The TUI is an optional enhancement — the core CLI, sync pipeline, search, and robot mode must stay rock-solid on stable. Split toolchains add minor CI complexity but dramatically reduce blast radius of nightly breakage.

### 3.3 Dependency Pinning Strategy

```toml
# crates/lore-tui/Cargo.toml — pin to exact git revision
[dependencies.ftui]
git = "https://github.com/Dicklesworthstone/frankentui.git"
rev = "abc123def"  # Pin to tested commit
```

**If crates.io publishes all workspace crates** before we ship, switch to version pins:
```toml
ftui = "=0.1.1"
```

**If the project is abandoned**, we fork to `gitlore-org/frankentui` and maintain the subset we use. The 12-crate workspace is well-structured for selective maintenance.

### 3.4 Terminal Compatibility Testing

Before shipping, validate on:

| Terminal | Platform | Priority |
|----------|----------|----------|
| iTerm2 | macOS | Must pass |
| Terminal.app | macOS | Must pass |
| Alacritty | macOS/Linux | Must pass |
| Kitty | macOS/Linux | Should pass |
| tmux (nested) | Any | Must pass |
| SSH (mosh) | Remote | Should pass |
| VS Code terminal | Any | Should pass |
| Windows Terminal | Windows | Nice to have |

FrankenTUI's `TerminalCapabilities` detection module handles many of these automatically, but we validate end-to-end.

#### 3.4.1 Capability-Adaptive Rendering

Runtime rendering adapts to detected terminal capabilities:

| Capability | Full Mode | Minimal Mode |
|-----------|-----------|-------------|
| **Color** | Truecolor (24-bit) | 256-color or 16-color fallback |
| **Glyphs** | Unicode box-drawing, progress bars | ASCII-safe equivalents (`|`, `-`, `#`) |
| **Alt screen** | Enabled (fullscreen TUI) | Disabled (`--no-alt-screen` for tmux/SSH) |

CLI flags for explicit override:
```
lore tui --render-mode=auto|full|minimal
lore tui --ascii          # Force ASCII-safe glyphs
lore tui --no-alt-screen  # Stay in normal terminal (useful for tmux/SSH)
```

Default is `auto` — detect capabilities and choose the best mode. Explicit flags override detection for environments where auto-detection is unreliable (e.g., some SSH + tmux combinations).

### 3.5 Escape Hatch

If FrankenTUI proves unworkable after Phase 1 (foundation), the migration cost to ratatui is ~3-5 days:
- The `state/` module is framework-agnostic (pure data).
- The `action.rs` query layer is framework-agnostic (sync SQLite queries).
- The `navigation.rs` stack is framework-agnostic.
- Only `view/` files and `app.rs` need rewriting — they're the rendering layer.

The Elm architecture we use (Message → update → view) works identically in both frameworks. This is the key architectural decision that makes the framework swappable.

**Validation gate:** In Phase 1, build one non-trivial screen (Dashboard) end-to-end with FrankenTUI. If the widget API friction is high or rendering quality is poor, pause and prototype the same screen with ratatui before committing to Phases 2-5. This validates the escape hatch cost estimate early rather than discovering it's optimistic after investing 20+ days.

---

## 4. Architecture

### 4.1 Module Structure

```
crates/lore-tui/src/
    mod.rs                        # Public API: launch_tui()
    app.rs                        # LoreApp impl Model (FrankenTUI native)
    message.rs                    # Msg enum (all user actions + async results)
    commands.rs                   # CommandRegistry: single source of truth for actions, keybindings, CLI equivalents, palette entries, help text, and status hints. All keybinding/help/status/palette definitions generated from this registry — no hardcoded duplicate maps in view/state modules.
    clock.rs                      # Clock trait: SystemClock (production) / FakeClock (tests). All relative-time rendering (e.g., "3h ago" labels) uses injected Clock, ensuring deterministic snapshot tests and consistent timestamps within a single frame.
    instance_lock.rs              # Single-instance advisory lock file with stale-lock recovery. Prevents state/session corruption from accidental double-launch.
    state/
      mod.rs                      # AppState (top-level composition)
      dashboard.rs                # Dashboard state
      issue_list.rs               # Issue list + filter state
      issue_detail.rs             # Single issue view state
      mr_list.rs                  # MR list + filter state
      mr_detail.rs                # Single MR view state
      search.rs                   # Search query + results state
      timeline.rs                 # Timeline view state
      who.rs                      # People explorer state
      sync.rs                     # Sync progress + post-sync summary state
      command_palette.rs          # Command palette state (uses ftui CommandPalette)
    view/
      mod.rs                      # Screen router (match state → render fn)
      dashboard.rs                # Dashboard layout + widgets
      issue_list.rs               # Issue table + filter bar
      issue_detail.rs             # Issue detail + threaded discussions
      mr_list.rs                  # MR table + filter bar
      mr_detail.rs                # MR detail + diff notes
      search.rs                   # Search input + results + preview
      timeline.rs                 # Event stream + entity graph
      who.rs                      # Expert map / workload / reviews
      sync.rs                     # Inline-mode progress + log stream + summary
      command_palette.rs          # Modal overlay
      common/
        mod.rs                    # Shared helpers
        filter_bar.rs             # Reusable filter input (ftui Forms)
        entity_table.rs           # Generic sortable/filterable table
        discussion_tree.rs        # ftui Tree wrapper for discussions
        cross_ref.rs              # Clickable entity references
    action.rs                     # Async action runners (DB queries, GitLab calls)
    navigation.rs                 # Screen stack + back/forward history
    theme.rs                      # ftui Theme + AdaptiveColor configuration
    safety.rs                     # sanitize_for_terminal(), safe_url_policy()
    redact.rs                     # redact_sensitive(): strip tokens, Authorization headers, and credential patterns from logs and crash reports before persisting to disk
    session.rs                    # Versioned session state persistence + corruption quarantine
    scope.rs                      # Global project scope context: all-projects or pinned project set. Applied to dashboard/list/search/timeline/who queries. Persisted in session state.
    entity_cache.rs               # Bounded LRU cache for detail payloads (IssueDetail, MrDetail). Keyed by EntityKey. Selective invalidation by changed EntityKey set on sync completion (not blanket invalidate_all). Optional post-sync prewarm of top changed entities for immediate triage. Enables near-instant reopen during Enter/Esc drill-in/out workflows without re-querying.
    render_cache.rs               # Width/theme/content-hash keyed cache for expensive render artifacts (markdown → styled text, discussion tree shaping). Invalidation triggers: content hash change, terminal width change, theme change. Prevents per-frame recomputation of markdown parsing and tree layout.
    crash_context.rs              # Ring buffer of last 2000 normalized events + current screen/task snapshot for crash diagnostics. Captured by panic hook for post-mortem debugging.
```

### 4.2 Native Elm Architecture

The key architectural advantage: FrankenTUI's runtime *is* the Elm loop. We implement the `Model` trait and the framework handles everything else.

```mermaid
graph TD
    A[Terminal Events] -->|ftui-core| B[ftui-runtime Event Loop]
    B -->|Event → Msg| C[LoreApp::update]
    C -->|new state| D[AppState]
    C -->|Cmd side effects| E[Cmd Queue]
    E -->|async execution| F[Action Runners]
    F -->|result Msg| B
    D --> G[LoreApp::view]
    G -->|widget tree| H[ftui-render Frame]
    H -->|buffer diff| I[ftui-render Presenter]
    I -->|minimal ANSI| J[Terminal Output]

    style B fill:#2d2d2d,stroke:#666,color:#fff
    style C fill:#ff6b6b,stroke:#333,color:#fff
    style D fill:#51cf66,stroke:#333,color:#fff
    style G fill:#ffd43b,stroke:#333,color:#000
    style I fill:#4a9eff,stroke:#333,color:#fff
```

**What the framework gives us for free:**
- Event polling thread management
- Buffer double-buffering and cell-level diffing
- ANSI escape sequence optimization
- Terminal state RAII (TerminalSession)
- Resize coalescing (BOCPD)
- Subscription management (timers, async streams)
- Panic recovery (terminal restored even on crash)

### 4.3 Core Types

```rust
// crates/lore-tui/src/message.rs

use ftui_core::event::{Event, KeyEvent, KeyCode, KeyEventKind, Modifiers};

/// Every action the TUI can take, triggered by user input or async completion.
///
/// IMPORTANT: FrankenTUI's Model trait requires `type Message: From<Event>`.
/// We implement `From<Event>` to convert raw terminal events into Msg variants,
/// then dispatch domain logic in `update()`.
#[derive(Debug, Clone)]
pub enum Msg {
    // Framework events (converted via From<Event>)
    RawEvent(Event),
    Tick,
    Resize { width: u16, height: u16 },

    // Navigation
    NavigateTo(Screen),
    GoBack,
    GoForward,
    GoHome,
    JumpBack,
    JumpForward,

    // Command palette
    OpenCommandPalette,
    CloseCommandPalette,
    CommandPaletteInput(String),
    CommandPaletteSelect(usize),

    // Issue list
    /// Generation-guarded: stale results from superseded filter/nav are dropped.
    IssueListLoaded { generation: u64, rows: Vec<IssueRow> },
    IssueListFilterChanged(IssueFilter),
    IssueListSortChanged(SortField, SortOrder),
    IssueSelected(EntityKey),

    // MR list
    /// Generation-guarded: stale results from superseded filter/nav are dropped.
    MrListLoaded { generation: u64, rows: Vec<MrRow> },
    MrListFilterChanged(MrFilter),
    MrSelected(EntityKey),

    // Detail views
    /// Generation-guarded: prevents stale detail overwrites after rapid navigation.
    IssueDetailLoaded { generation: u64, key: EntityKey, detail: IssueDetail },
    /// Generation-guarded: prevents stale detail overwrites after rapid navigation.
    MrDetailLoaded { generation: u64, key: EntityKey, detail: MrDetail },
    DiscussionsLoaded { generation: u64, discussions: Vec<Discussion> },

    // Search
    SearchQueryChanged(String),
    SearchRequestStarted { generation: u64, query: String },
    SearchExecuted { generation: u64, results: SearchResults },
    SearchResultSelected(usize),
    SearchModeChanged(SearchMode),
    /// Loaded on startup and after each sync. Determines which search modes
    /// are available based on FTS index presence and embedding coverage.
    SearchCapabilitiesLoaded(SearchCapabilities),

    // Timeline
    TimelineLoaded(Vec<TimelineEvent>),
    TimelineEntitySelected(EntityRef),

    // Who
    WhoResultLoaded(WhoResult),
    WhoModeChanged(WhoMode),

    // Sync
    SyncStarted,
    SyncProgress(ProgressEvent),
    /// Coalesced batch of progress events (one per lane key).
    /// Reduces render pressure by batching at <=30Hz per lane.
    SyncProgressBatch(Vec<ProgressEvent>),
    SyncLogLine(String),
    SyncBackpressureDrop,
    SyncCompleted(SyncResult),
    SyncCancelled,
    SyncFailed(String),
    /// Lightweight stream telemetry: dropped progress events and max queue depth.
    /// Rendered in sync running-mode footer for operator visibility under backpressure.
    SyncStreamStats { dropped_progress: u64, max_queue_depth: usize },

    // Search debounce (timer-driven, replaces thread::sleep)
    /// Arm debounce: runtime timer will fire SearchDebounceFired after 200ms.
    /// If a newer keystroke arrives before the timer fires, the generation mismatch
    /// causes the fired event to be silently dropped.
    SearchDebounceArmed { generation: u64, query: String },
    /// Timer fired: if generation is still current, execute the search query.
    SearchDebounceFired { generation: u64 },

    // Dashboard
    DashboardLoaded(DashboardData),

    // System
    Error(AppError),
    ShowHelp,
    ShowCliEquivalent,
    OpenInBrowser,
    BlurTextInput,
    ScrollToTopCurrentScreen,
    Quit,
}

/// Convert raw terminal events into our domain Msg type.
/// This is required by FrankenTUI's `Model` trait: `type Message: From<Event>`.
///
/// DESIGN: We do NOT parse keybindings here. Raw events arrive as `Msg::RawEvent(event)`,
/// and `update()` dispatches them through `interpret_key()` which has access to full
/// app state (current screen, text focus, input_mode, etc.) to produce the correct Msg.
impl From<Event> for Msg {
    fn from(event: Event) -> Self {
        match event {
            Event::Resize { width, height } => Msg::Resize { width, height },
            Event::Tick => Msg::Tick,
            other => Msg::RawEvent(other),
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub enum Screen {
    Dashboard,
    IssueList,
    IssueDetail(EntityKey),
    MrList,
    MrDetail(EntityKey),
    Search,
    Timeline,
    Who,
    Sync,
    Stats,
    Doctor,
    Bootstrap,
}

/// Composite key for entity identity across multi-project datasets.
/// Using bare `iid` is unsafe when multiple projects have overlapping IIDs.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EntityKey {
    pub project_id: i64,
    pub iid: i64,
    pub kind: EntityKind,
}

impl EntityKey {
    /// Construct an issue key. Preferred over raw struct construction
    /// to enforce consistent entity identity at all call sites.
    pub fn issue(project_id: i64, iid: i64) -> Self {
        Self { project_id, iid, kind: EntityKind::Issue }
    }

    /// Construct a merge request key.
    pub fn mr(project_id: i64, iid: i64) -> Self {
        Self { project_id, iid, kind: EntityKind::MergeRequest }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EntityKind {
    Issue,
    MergeRequest,
}

/// Typed error categories for structured error handling in the TUI.
/// Enables context-aware error display (retry prompts, actionable suggestions)
/// rather than opaque string toasts.
#[derive(Debug, Clone)]
pub enum AppError {
    /// SQLite busy/locked — suggest retry or check for running sync
    DbBusy,
    /// Database corruption detected
    DbCorruption(String),
    /// GitLab rate limited — show backoff timer
    NetworkRateLimited { retry_after_secs: Option<u64> },
    /// Network unreachable
    NetworkUnavailable,
    /// GitLab auth failed — suggest token check
    AuthFailed,
    /// Data parse/transform error
    ParseError(String),
    /// Catch-all for unexpected errors
    Internal(String),
}

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::DbBusy => write!(f, "Database busy — another operation may be running"),
            Self::DbCorruption(msg) => write!(f, "Database error: {msg}"),
            Self::NetworkRateLimited { retry_after_secs: Some(s) } =>
                write!(f, "Rate limited — retry in {s}s"),
            Self::NetworkRateLimited { retry_after_secs: None } =>
                write!(f, "Rate limited — please wait"),
            Self::NetworkUnavailable => write!(f, "Network unavailable"),
            Self::AuthFailed => write!(f, "Authentication failed — check GITLAB_TOKEN"),
            Self::ParseError(msg) => write!(f, "Parse error: {msg}"),
            Self::Internal(msg) => write!(f, "Error: {msg}"),
        }
    }
}
```

### 4.3.1 Input Mode State Machine

```rust
/// Typed input mode — replaces ad-hoc boolean flags with explicit states.
/// Ensures deterministic key dispatch and clean timeout enforcement.
#[derive(Debug, Clone)]
pub enum InputMode {
    /// Normal navigation mode — keys are dispatched to global/screen handlers.
    Normal,
    /// Text input is focused (filter bar, search box). Alphanumeric keys go to
    /// the active text widget. Only Esc and Ctrl+P break out.
    Text,
    /// Command palette is open. All keys routed to palette widget.
    Palette,
    /// The `g` prefix key was pressed. Next key determines destination.
    /// Timeout enforced by comparing `started_at` against `clock.now()` on each tick.
    GoPrefix { started_at: std::time::Instant },
}

impl Default for InputMode {
    fn default() -> Self { Self::Normal }
}
```

### 4.4 App — Implementing the Model Trait

```rust
// crates/lore-tui/src/app.rs

use ftui_runtime::program::{Model, Cmd, TaskSpec};
use ftui_runtime::subscription::{Subscription, Every, After};
use ftui_core::event::{Event, KeyEvent, KeyCode, KeyEventKind, Modifiers};
use ftui_render::frame::Frame;
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
use std::time::Duration;

use crate::core::config::Config;
use crate::tui::message::{Msg, Screen};
use crate::tui::navigation::NavigationStack;
use crate::tui::state::AppState;

pub struct LoreApp {
    pub state: AppState,
    pub navigation: NavigationStack,
    pub config: Config,
    /// Connection manager with read pool + dedicated writer.
    /// Reads use pooled connections (no contention during sync).
    /// Writes (sync) use a dedicated connection with WAL + busy_timeout.
    pub db: Arc<DbManager>,
    /// Injected clock for deterministic time. All relative-time rendering
    /// ("3h ago") and go-prefix timeout enforcement uses this instead of
    /// direct SystemTime::now() calls. FakeClock in tests ensures stable
    /// snapshot output and consistent timestamps within a single frame.
    pub clock: Arc<dyn Clock>,
    /// Single source of truth for all actions, keybindings, CLI equivalents,
    /// palette entries, help text, and status bar hints. All consumers
    /// (execute_palette_action, ShowCliEquivalent, help overlay, status bar)
    /// read from this registry — no hardcoded duplicate maps.
    commands: CommandRegistry,
    /// Explicit input mode state machine. Replaces ad-hoc boolean flags
    /// (`pending_go`, `go_prefix_instant`) with a typed enum for deterministic
    /// key dispatch and clean timeout enforcement.
    ///
    /// Transitions:
    ///   Normal → GoPrefix { started_at } on bare `g` press
    ///   GoPrefix → Normal on valid continuation key, 500ms timeout, or cancel
    ///   Normal → Text when filter bar / search input gains focus
    ///   Text → Normal on Esc or blur
    ///   Normal → Palette on Ctrl+P
    ///   Palette → Normal on selection or Esc
    input_mode: InputMode,
    /// Task supervisor: the SOLE path for launching background async work.
    /// All deduplication, cancellation, and stale-response detection is
    /// centralized here. No direct `Cmd::task` from screen handlers.
    /// Replaces the former `search_request_id` field — generation IDs
    /// from the supervisor subsume per-screen dedup counters.
    task_supervisor: TaskSupervisor,
    /// Bounded LRU cache for entity detail payloads (IssueDetail, MrDetail).
    /// Keyed by EntityKey. Enables near-instant reopen during Enter/Esc
    /// drill-in/out workflows without re-querying the database.
    /// Invalidated on sync completion to ensure fresh data.
    entity_cache: EntityCache,
    /// Ring buffer of last 2000 normalized events + current screen/task snapshot.
    /// Captured by panic hook for post-mortem crash diagnostics.
    crash_context: Arc<Mutex<CrashContext>>,
}

/// Manages separate read and write database connections.
/// Prevents TUI read queries from blocking on sync writes (and vice versa).
///
/// SQLite WAL mode allows concurrent readers with a single writer.
/// Read connections are opened with `PRAGMA query_only = ON` for safety.
pub struct DbManager {
    /// Pool of read-only connections for TUI queries.
    /// Using a simple Vec<Mutex<Connection>> with round-robin selection.
    readers: Vec<Mutex<Connection>>,
    /// Dedicated writer connection for sync operations.
    writer: Mutex<Connection>,
    /// Round-robin index for reader selection.
    next_reader: AtomicUsize,
}

/// A task-scoped reader lease that owns an interrupt handle for safe cancellation.
/// Unlike interrupting a shared pooled connection (which can cancel unrelated work),
/// each dispatched query receives its own ReaderLease. The InterruptHandle stored in
/// TaskHandle targets only this lease's connection, preventing cross-task cancellation bleed.
pub struct ReaderLease<'a> {
    conn: std::sync::MutexGuard<'a, Connection>,
    /// Owned interrupt handle — safe to fire without affecting other tasks.
    pub interrupt: rusqlite::InterruptHandle,
}

impl<'a> ReaderLease<'a> {
    pub fn conn(&self) -> &Connection { &self.conn }
}

impl DbManager {
    pub fn new(db_path: &Path, reader_count: usize) -> Result<Self, LoreError> {
        let mut readers = Vec::with_capacity(reader_count);
        for _ in 0..reader_count {
            let conn = crate::core::db::create_connection(db_path)?;
            conn.execute_batch("PRAGMA query_only = ON; PRAGMA busy_timeout = 5000;")?;
            readers.push(Mutex::new(conn));
        }
        let writer = crate::core::db::create_connection(db_path)?;
        writer.execute_batch("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 10000;")?;
        Ok(Self {
            readers,
            writer: Mutex::new(writer),
            next_reader: AtomicUsize::new(0),
        })
    }

    /// Execute a closure with a read-only connection (round-robin from pool).
    /// Closure-based access prevents accidental lock scope expansion and
    /// eliminates `expect("lock poisoned")` panics by converting to Result.
    /// All query hot paths should use `prepare_cached()` within the closure
    /// to reduce parse overhead on repeated queries.
    pub fn with_reader<T>(&self, f: impl FnOnce(&Connection) -> Result<T, LoreError>) -> Result<T, LoreError> {
        let idx = self.next_reader.fetch_add(1, Ordering::Relaxed) % self.readers.len();
        let conn = self.readers[idx].lock()
            .map_err(|e| LoreError::Internal(format!("reader lock poisoned: {e}")))?;
        f(&conn)
    }

    /// Execute a closure with the writer connection (for sync operations).
    /// Same closure-based pattern as `with_reader` — lock scope is bounded
    /// to the closure, preventing accidental long-held guards.
    pub fn with_writer<T>(&self, f: impl FnOnce(&Connection) -> Result<T, LoreError>) -> Result<T, LoreError> {
        let conn = self.writer.lock()
            .map_err(|e| LoreError::Internal(format!("writer lock poisoned: {e}")))?;
        f(&conn)
    }

    /// Lease a reader connection with a task-owned interrupt handle.
    /// The returned `ReaderLease` holds the mutex guard and provides
    /// an `InterruptHandle` that can be stored in `TaskHandle` for
    /// safe per-task cancellation. This prevents cross-task interrupt bleed
    /// that would occur with shared-connection `sqlite3_interrupt()`.
    pub fn lease_reader(&self) -> Result<ReaderLease<'_>, LoreError> {
        let idx = self.next_reader.fetch_add(1, Ordering::Relaxed) % self.readers.len();
        let conn = self.readers[idx].lock()
            .map_err(|e| LoreError::Internal(format!("reader lock poisoned: {e}")))?;
        let interrupt = conn.get_interrupt_handle();
        Ok(ReaderLease { conn, interrupt })
    }
}

impl LoreApp {
    pub fn new(config: Config, db: DbManager) -> Self {
        Self {
            state: AppState::default(),
            navigation: NavigationStack::new(),
            config,
            db: Arc::new(db),
            input_mode: InputMode::Normal,
            task_supervisor: TaskSupervisor::new(),
            entity_cache: EntityCache::new(128), // Bounded LRU: 128 detail payloads
            crash_context: Arc::new(Mutex::new(CrashContext::new(2000))),
        }
    }

    /// Interpret a raw key event in the context of current app state.
    /// Returns Some(Msg) if the key is handled, None to ignore.
    ///
    /// KEY DISPATCH ORDER:
    ///   1. Pending `g` prefix completion
    ///   2. Command palette intercept (Ctrl+P, Esc when open)
    ///   3. Text input delegation (when filter bar / search has focus)
    ///   4. Global shortcuts (q, H, Esc, g prefix start)
    ///   5. Screen-local shortcuts (dashboard letters, list j/k, etc.)
    fn interpret_key(&mut self, key: &KeyEvent) -> Option<Msg> {
        // Only handle Press events (ignore Repeat/Release)
        if key.kind != KeyEventKind::Press {
            return None;
        }

        // 1. GoPrefix state — next key determines destination
        if let InputMode::GoPrefix { .. } = &self.input_mode {
            self.input_mode = InputMode::Normal;
            return Some(match key.code {
                KeyCode::Char('i') => Msg::NavigateTo(Screen::IssueList),
                KeyCode::Char('m') => Msg::NavigateTo(Screen::MrList),
                KeyCode::Char('/') => Msg::NavigateTo(Screen::Search),
                KeyCode::Char('t') => Msg::NavigateTo(Screen::Timeline),
                KeyCode::Char('w') => Msg::NavigateTo(Screen::Who),
                KeyCode::Char('s') => Msg::NavigateTo(Screen::Sync),
                KeyCode::Char('d') => Msg::NavigateTo(Screen::Doctor),
                KeyCode::Char('g') => Msg::ScrollToTopCurrentScreen, // gg = jump to top of current list
                _ => return None, // Unknown — cancel prefix silently
            });
        }

        // 2. Command palette intercept
        if key.code == KeyCode::Char('p') && key.modifiers.contains(Modifiers::CTRL) {
            return Some(Msg::OpenCommandPalette);
        }
        if self.state.command_palette.is_visible() {
            // Delegate all keys to palette when open
            return None; // Palette's handle_event() is called separately
        }

        // 3. Text input delegation — when filter bar or search input has focus
        if self.state.has_text_focus() {
            // Text-focused widgets handle their own input.
            // Only Esc and Ctrl+P break out of text focus.
            if key.code == KeyCode::Escape {
                return Some(Msg::BlurTextInput);
            }
            return None; // Let widget handle_event() process it
        }

        // 4. Global shortcuts (available when no text focus)
        match (key.code, key.modifiers) {
            (KeyCode::Char('q'), Modifiers::NONE) => return Some(Msg::Quit),
            (KeyCode::Char('H'), Modifiers::NONE) => return Some(Msg::GoHome),
            (KeyCode::Char('?'), Modifiers::NONE) => return Some(Msg::ShowHelp),
            (KeyCode::Char('o'), Modifiers::NONE) => return Some(Msg::OpenInBrowser),
            (KeyCode::Char('c'), m) if m.contains(Modifiers::CTRL) => {
                return Some(Msg::Quit);
            }
            (KeyCode::Char('o'), m) if m.contains(Modifiers::CTRL) => {
                return Some(Msg::JumpBack);
            }
            // NOTE: Ctrl+I collides with Tab in many terminals — use Alt+o instead.
            (KeyCode::Char('o'), m) if m.contains(Modifiers::ALT) => {
                return Some(Msg::JumpForward);
            }
            (KeyCode::Backspace, Modifiers::NONE) => {
                return Some(Msg::GoBack);
            }
            (KeyCode::Char(':'), Modifiers::NONE) => {
                return Some(Msg::ShowCliEquivalent);
            }
            (KeyCode::Escape, Modifiers::NONE) => {
                return Some(Msg::GoBack);
            }
            _ => {}
        }

        // g prefix start (not on Dashboard — Dashboard uses single-key shortcuts)
        if key.code == KeyCode::Char('g')
            && key.modifiers == Modifiers::NONE
            && !matches!(self.navigation.current(), Screen::Dashboard)
        {
            self.input_mode = InputMode::GoPrefix {
                started_at: self.clock.now_instant(),
            };
            return Some(Msg::Tick); // Render go-prefix indicator
        }

        // 5. Screen-local shortcuts (delegated to per-screen handler)
        self.state.interpret_screen_key(self.navigation.current(), key)
    }

    /// Dispatch a background query for the given screen.
    /// Uses Cmd::task (blocking thread pool) since rusqlite is sync.
    /// All read queries use db.with_reader() (closure-based pooled access).
    fn load_screen(&self, screen: &Screen) -> Cmd<Msg> {
        let db = Arc::clone(&self.db);
        match screen {
            Screen::Dashboard => Cmd::task(move || {
                match db.with_reader(|conn| crate::tui::action::fetch_dashboard(conn)) {
                    Ok(d) => Msg::DashboardLoaded(d),
                    Err(e) => Msg::Error(AppError::Internal(e.to_string())),
                }
            }),
            Screen::IssueList => {
                let filter = self.state.issue_list.current_filter();
                let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::IssueList));
                let generation = handle.generation;
                Cmd::task(move || {
                    match db.with_reader(|conn| crate::tui::action::fetch_issues(conn, &filter)) {
                        Ok(rows) => Msg::IssueListLoaded { generation, rows },
                        Err(e) => Msg::Error(AppError::Internal(e.to_string())),
                    }
                })
            }
            Screen::IssueDetail(key) => {
                // Check entity cache first — enables near-instant reopen
                // during Enter/Esc drill-in/out workflows.
                if let Some(cached) = self.entity_cache.get_issue(key) {
                    let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::IssueDetail(key.clone())));
                    return Cmd::msg(Msg::IssueDetailLoaded { generation: handle.generation, key: key.clone(), detail: cached.clone() });
                }
                let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::IssueDetail(key.clone())));
                let generation = handle.generation;
                let key = key.clone();
                Cmd::task(move || {
                    match db.with_reader(|conn| crate::tui::action::fetch_issue_detail(conn, &key)) {
                        Ok(detail) => Msg::IssueDetailLoaded { generation, key, detail },
                        Err(e) => Msg::Error(AppError::Internal(e.to_string())),
                    }
                })
            }
            Screen::MrList => {
                let filter = self.state.mr_list.current_filter();
                let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::MrList));
                let generation = handle.generation;
                Cmd::task(move || {
                    match db.with_reader(|conn| crate::tui::action::fetch_mrs(conn, &filter)) {
                        Ok(rows) => Msg::MrListLoaded { generation, rows },
                        Err(e) => Msg::Error(AppError::Internal(e.to_string())),
                    }
                })
            }
            Screen::MrDetail(key) => {
                // Check entity cache first
                if let Some(cached) = self.entity_cache.get_mr(key) {
                    let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::MrDetail(key.clone())));
                    return Cmd::msg(Msg::MrDetailLoaded { generation: handle.generation, key: key.clone(), detail: cached.clone() });
                }
                let handle = self.task_supervisor.submit(TaskKey::LoadScreen(Screen::MrDetail(key.clone())));
                let generation = handle.generation;
                let key = key.clone();
                Cmd::task(move || {
                    match db.with_reader(|conn| crate::tui::action::fetch_mr_detail(conn, &key)) {
                        Ok(detail) => Msg::MrDetailLoaded { generation, key, detail },
                        Err(e) => Msg::Error(AppError::Internal(e.to_string())),
                    }
                })
            }
            Screen::Search => Cmd::none(), // Search loads on query, not on navigate
            Screen::Timeline => {
                let params = self.state.timeline.current_params();
                Cmd::task(move || {
                    match db.with_reader(|conn| crate::tui::action::fetch_timeline(conn, &params)) {
                        Ok(result) => Msg::TimelineLoaded(result),
                        Err(e) => Msg::Error(AppError::Internal(e.to_string())),
                    }
                })
            }
            Screen::Who => {
                let params = self.state.who.current_params();
                Cmd::task(move || {
                    match db.with_reader(|conn| crate::tui::action::fetch_who(conn, &params)) {
                        Ok(result) => Msg::WhoResultLoaded(result),
                        Err(e) => Msg::Error(AppError::Internal(e.to_string())),
                    }
                })
            }
            Screen::Sync => Cmd::none(), // Sync starts on user action, not on navigate
            _ => Cmd::none(),
        }
    }

    /// Map a command palette action ID to a navigation or system Msg.
    /// Uses `CommandRegistry` as the single source of truth — the registry
    /// resolves action_id to Msg based on the current screen context.
    /// This eliminates the duplicated action mapping that previously existed
    /// across execute_palette_action, ShowCliEquivalent, help overlay, and
    /// status bar hints. All those consumers now read from commands.rs.
    fn execute_palette_action(&self, action_id: &str) -> Cmd<Msg> {
        if let Some(spec) = self.commands.get(action_id) {
            return self.update(spec.to_msg(self.navigation.current()));
        }
        Cmd::none()
    }

    /// If the current screen has a text input that triggers a debounced query
    /// (search bar, filter bar), schedule a background fetch after a short delay.
    /// Returns Cmd::none() if no debounced query is needed.
    ///
    /// DEBOUNCE STRATEGY: Timer-driven debounce via runtime subscription messages.
    /// Each keystroke arms a SearchDebounceArmed with the current generation.
    /// The subscriptions() method registers a 200ms timer that fires SearchDebounceFired.
    /// If a newer keystroke arrives before the timer fires, the generation mismatch
    /// causes the fired event to be silently dropped — no wasted thread pool threads.
    /// This replaces the previous thread::sleep(200ms) approach which wasted pool
    /// threads under fast typing and reduced responsiveness under contention.
    ///
    /// Filter requery (IssueList/MrList) uses direct dispatch — no debounce needed
    /// because filter changes are discrete user actions, not rapid keystrokes.
    fn maybe_debounced_query(&mut self) -> Cmd<Msg> {
        match self.navigation.current() {
            Screen::Search => {
                let query = self.state.search.query_text().to_string();
                if query.len() < 2 {
                    return Cmd::none(); // Don't search for 1-char queries
                }
                // Arm debounce only — runtime timer will fire SearchDebounceFired.
                // No thread pool thread is consumed during the debounce window.
                let generation = self.task_supervisor.submit(TaskKey::Search).generation;
                Cmd::msg(Msg::SearchDebounceArmed { generation, query })
            }
            Screen::IssueList => {
                let filter = self.state.issue_list.current_filter();
                let db = Arc::clone(&self.db);
                let handle = self.task_supervisor.submit(TaskKey::FilterRequery(Screen::IssueList));
                let generation = handle.generation;
                Cmd::task(move || {
                    match db.with_reader(|conn| crate::tui::action::fetch_issues(conn, &filter)) {
                        Ok(rows) => Msg::IssueListLoaded { generation, rows },
                        Err(e) => Msg::Error(AppError::Internal(e.to_string())),
                    }
                })
            }
            Screen::MrList => {
                let filter = self.state.mr_list.current_filter();
                let db = Arc::clone(&self.db);
                let handle = self.task_supervisor.submit(TaskKey::FilterRequery(Screen::MrList));
                let generation = handle.generation;
                Cmd::task(move || {
                    match db.with_reader(|conn| crate::tui::action::fetch_mrs(conn, &filter)) {
                        Ok(rows) => Msg::MrListLoaded { generation, rows },
                        Err(e) => Msg::Error(AppError::Internal(e.to_string())),
                    }
                })
            }
            _ => Cmd::none(),
        }
    }

    /// Launch a full sync pipeline as a background task.
    /// Progress events stream back as SyncProgress/SyncLogLine messages via channel.
    ///
    /// STREAMING DESIGN: Uses an mpsc channel to send progress events back to
    /// the runtime as individual Msgs. The sync thread sends SyncProgress and
    /// SyncLogLine events as they occur. The runtime's subscription mechanism
    /// drains the channel each tick and feeds events into update().
    ///
    /// CANCELLATION: A cooperative cancel token (AtomicBool) is checked at page
    /// boundaries, batch upsert boundaries, and before each network request —
    /// not just between sync phases. UX target: cancel acknowledged <250ms,
    /// sync stop p95 <2s after Esc. Pressing Esc during sync sets the token, and the next phase
    /// boundary returns early with a SyncCancelled message.
    fn start_sync_task(&self) -> Cmd<Msg> {
        let config = self.config.clone();
        let db = Arc::clone(&self.db);
        let cancel_token = Arc::clone(&self.state.sync.cancel_token);

        // Use Cmd::stream if available (FrankenTUI v0.2+), otherwise fall back
        // to Cmd::task with channel-based progress relay.
        // Bounded channel with backpressure. The rx end is registered as a
        // Subscription via Subscription::from_receiver("sync-stream", rx),
        // which drains events into update() each tick.
        let (tx, rx) = std::sync::mpsc::sync_channel::<SyncUiEvent>(2048);
        // Register rx as a live subscription via Subscription::from_receiver("sync-stream", rx).
        // Progress and logs MUST render in real time — no batch-at-end fallback.
        // Bounded ring buffer (N=5000 lines). Progress events coalesced to max 30Hz
        // per lane to reduce render pressure. Terminal events (SyncUiEvent::StreamClosed)
        // are NEVER dropped — they are sent with blocking try_send retries to guarantee
        // the UI always observes sync completion regardless of backpressure state.

        Cmd::task(move || {
            // Stream stats for operator visibility under backpressure.
            let mut dropped_count: u64 = 0;
            let mut max_queue_depth: usize = 0;

            let result = db.with_writer(|conn| {
                crate::ingestion::orchestrator::run_sync(
                    &config,
                    conn,
                    |event| {
                        // Check cancel token at each progress callback
                        if cancel_token.load(std::sync::atomic::Ordering::Relaxed) {
                            return; // Early exit — orchestrator handles partial state
                        }
                        // Coalesce progress events by lane key at <=30Hz to reduce
                        // render pressure. Each lane (project x resource_type) keeps
                        // only its latest progress snapshot. The coalescer flushes
                        // a batch when 33ms have elapsed since last flush.
                        coalescer.update(event.clone());
                        if let Some(batch) = coalescer.flush_ready() {
                            if tx.try_send(Msg::SyncProgressBatch(batch)).is_err() {
                                // Channel full — drop this batch rather than
                                // blocking the sync thread. Track for stats.
                                dropped_count += 1;
                                let _ = tx.try_send(Msg::SyncBackpressureDrop);
                            }
                        }
                        let _ = tx.try_send(Msg::SyncLogLine(format!("{event:?}")));
                    },
                )
            });

            // Emit stream stats for UI rendering
            let _ = tx.try_send(Msg::SyncStreamStats { dropped_progress: dropped_count, max_queue_depth });

            if cancel_token.load(std::sync::atomic::Ordering::Relaxed) {
                return Msg::SyncCancelled;
            }

            match result {
                Ok(summary) => Msg::SyncCompleted(crate::tui::state::SyncSummary {
                    log_lines: Vec::new(), // Streamed individually
                    errors: 0,
                    duration_ms: summary.elapsed_ms,
                }),
                Err(e) => Msg::SyncFailed(e.to_string()),
            }
        })
    }

    /// Open the currently-focused entity in the system browser.
    fn open_current_in_browser(&self) {
        let url = match self.navigation.current() {
            Screen::IssueDetail(iid) => {
                self.state.issue_detail.detail.as_ref()
                    .map(|d| d.web_url.clone())
            }
            Screen::MrDetail(iid) => {
                self.state.mr_detail.detail.as_ref()
                    .map(|d| d.web_url.clone())
            }
            Screen::IssueList => {
                self.state.issue_list.selected_item()
                    .map(|item| item.web_url.clone())
            }
            Screen::MrList => {
                self.state.mr_list.selected_item()
                    .map(|item| item.web_url.clone())
            }
            _ => None,
        };

        if let Some(url) = url {
            // Validate URL host against configured GitLab instance(s) before opening.
            // This prevents navigation to arbitrary URLs that might be injected via
            // issue/MR descriptions or discussion bodies stored in the database.
            if !is_allowed_gitlab_url(&url, &self.config) {
                self.state.set_error("Blocked: URL does not match configured GitLab host".into());
                return;
            }
            // Fire-and-forget: open in background, ignore errors
            #[cfg(target_os = "macos")]
            let _ = std::process::Command::new("open").arg(&url).spawn();
            #[cfg(target_os = "linux")]
            let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
        }
    }
}

impl Model for LoreApp {
    type Message = Msg;

    fn init(&mut self) -> Cmd<Msg> {
        // Load dashboard data on startup
        self.load_screen(&Screen::Dashboard)
    }

    fn update(&mut self, msg: Msg) -> Cmd<Msg> {
        match msg {
            // --- Raw terminal events: interpret keys ---
            Msg::RawEvent(Event::Key(key)) => {
                // First: let command palette handle its own events when open
                if self.state.command_palette.is_visible() {
                    if let Some(action) = self.state.command_palette.handle_event(
                        &Event::Key(key)
                    ) {
                        return match action {
                            PaletteAction::Execute(id) => {
                                self.state.command_palette.close();
                                self.execute_palette_action(&id)
                            }
                            PaletteAction::Dismiss => {
                                self.state.command_palette.close();
                                Cmd::none()
                            }
                        };
                    }
                    return Cmd::none();
                }

                // Then: let text input widgets handle their own events
                if self.state.has_text_focus() {
                    let handled = self.state.delegate_text_event(
                        self.navigation.current(), &Event::Key(key),
                    );
                    if handled {
                        return self.maybe_debounced_query();
                    }
                }

                // Finally: interpret as domain action
                match self.interpret_key(&key) {
                    Some(msg) => self.update(msg), // Re-enter update with domain Msg
                    None => Cmd::none(),
                }
            }
            Msg::RawEvent(_) => Cmd::none(), // Mouse, Paste, Focus — ignored for now

            // --- Navigation ---
            Msg::NavigateTo(screen) => {
                self.state.set_loading(true);
                self.navigation.push(screen.clone());
                self.load_screen(&screen)
            }
            Msg::GoBack => {
                if let Some(_prev) = self.navigation.pop() {
                    // State is preserved — no reload needed
                    Cmd::none()
                } else {
                    Cmd::none()
                }
            }
            Msg::GoForward => {
                if let Some(_next) = self.navigation.go_forward() {
                    Cmd::none()
                } else {
                    Cmd::none()
                }
            }
            Msg::JumpBack => {
                if let Some(screen) = self.navigation.jump_back().cloned() {
                    self.navigation.reset_to(screen.clone());
                    self.load_screen(&screen)
                } else {
                    Cmd::none()
                }
            }
            Msg::JumpForward => {
                if let Some(screen) = self.navigation.jump_forward().cloned() {
                    self.navigation.reset_to(screen.clone());
                    self.load_screen(&screen)
                } else {
                    Cmd::none()
                }
            }
            Msg::GoHome => {
                self.navigation.reset_to(Screen::Dashboard);
                self.load_screen(&Screen::Dashboard)
            }
            Msg::Quit => Cmd::quit(),

            // --- Command Palette ---
            Msg::OpenCommandPalette => {
                self.state.command_palette.open();
                Cmd::none()
            }

            // --- Text Input ---
            Msg::BlurTextInput => {
                self.state.blur_text_focus();
                Cmd::none()
            }

            // --- Data Loaded ---
            Msg::DashboardLoaded(data) => {
                self.state.set_loading(false);
                self.state.dashboard.update(data);
                Cmd::none()
            }
            Msg::IssueListLoaded { generation, rows } => {
                if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation) {
                    return Cmd::none(); // Stale — superseded by newer nav/filter
                }
                self.state.set_loading(false);
                self.state.issue_list.set_result(rows);
                Cmd::none()
            }
            Msg::IssueDetailLoaded { generation, key, detail } => {
                if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::IssueDetail(key.clone())), generation) {
                    return Cmd::none(); // Stale — user navigated away
                }
                self.state.set_loading(false);
                self.entity_cache.put_issue(key, detail.clone());
                self.state.issue_detail.set(detail);
                Cmd::none()
            }
            Msg::MrListLoaded { generation, rows } => {
                if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::MrList), generation) {
                    return Cmd::none(); // Stale — superseded by newer nav/filter
                }
                self.state.set_loading(false);
                self.state.mr_list.set_result(rows);
                Cmd::none()
            }
            Msg::MrDetailLoaded { generation, key, detail } => {
                if !self.task_supervisor.is_current(&TaskKey::LoadScreen(Screen::MrDetail(key.clone())), generation) {
                    return Cmd::none(); // Stale — user navigated away
                }
                self.state.set_loading(false);
                self.entity_cache.put_mr(key, detail.clone());
                self.state.mr_detail.set(detail);
                Cmd::none()
            }
            Msg::SearchDebounceArmed { generation, query } => {
                // Store debounce state; subscriptions() will register a 200ms timer.
                self.state.search.arm_debounce(generation, query);
                Cmd::none()
            }
            Msg::SearchDebounceFired { generation } => {
                // Timer fired — check generation is still current before executing.
                if !self.task_supervisor.is_current(&TaskKey::Search, generation) {
                    return Cmd::none(); // Superseded by newer keystroke
                }
                let query = self.state.search.query_text().to_string();
                let filters = self.state.search.current_filters();
                let mode = self.state.search.mode;
                let db = Arc::clone(&self.db);
                self.state.search.clear_debounce();
                Cmd::task(move || {
                    match db.with_reader(|conn| {
                        crate::tui::action::execute_search(conn, &query, &filters, mode)
                    }) {
                        Ok(results) => Msg::SearchExecuted { generation, results },
                        Err(e) => Msg::Error(AppError::Internal(e.to_string())),
                    }
                })
            }
            Msg::SearchExecuted { generation, results } => {
                // Drop stale results from superseded queries.
                // Generation check via TaskSupervisor is the sole dedup mechanism.
                if !self.task_supervisor.is_current(&TaskKey::Search, generation) {
                    return Cmd::none(); // Stale — ignore silently
                }
                self.state.set_loading(false);
                self.state.search.set_results(results);
                Cmd::none()
            }
            Msg::TimelineLoaded(result) => {
                self.state.set_loading(false);
                self.state.timeline.set_result(result);
                Cmd::none()
            }
            Msg::WhoResultLoaded(result) => {
                self.state.set_loading(false);
                self.state.who.set_result(result);
                Cmd::none()
            }

            // --- Sync ---
            Msg::SyncStarted => {
                self.state.sync.start();
                self.start_sync_task()
            }
            Msg::SyncProgress(event) => {
                self.state.sync.update_progress(event);
                Cmd::none()
            }
            Msg::SyncProgressBatch(events) => {
                for event in events {
                    self.state.sync.update_progress(event);
                }
                Cmd::none()
            }
            Msg::SyncLogLine(line) => {
                self.state.sync.push_log(line);
                Cmd::none()
            }
            Msg::SyncBackpressureDrop => {
                // A progress event was dropped due to channel backpressure.
                // This is expected under heavy sync load — the TUI will still
                // receive the final completion message. No action needed.
                Cmd::none()
            }
            Msg::SyncStreamStats { dropped_progress, max_queue_depth } => {
                self.state.sync.update_stream_stats(dropped_progress, max_queue_depth);
                Cmd::none()
            }
            Msg::SyncCompleted(result) => {
                self.state.sync.complete(&result);
                // Selective invalidation: evict only changed entities from sync delta.
                self.entity_cache.invalidate_keys(&result.changed_entity_keys);
                // Prewarm top N changed/new entities for immediate post-sync triage.
                // This is lazy — enqueues Cmd::task fetches, doesn't block the event loop.
                let prewarm_cmds = self.enqueue_cache_prewarm(&result.changed_entity_keys);
                // Notify list screens that new data is available (snapshot fence refresh badge).
                self.state.notify_data_changed();
                prewarm_cmds
            }
            Msg::SyncFailed(err) => {
                self.state.sync.fail(err);
                Cmd::none()
            }

            // --- System ---
            Msg::Error(e) => {
                self.state.set_loading(false);
                self.state.set_error(e.to_string());
                Cmd::none()
            }
            Msg::ShowHelp => {
                self.state.show_help = !self.state.show_help;
                Cmd::none()
            }
            Msg::ShowCliEquivalent => {
                // Show a toast with the equivalent CLI command for the current
                // screen/action. Helps users learn the CLI and provides a fallback
                // when the TUI is unavailable.
                // CLI equivalents are stored in CommandRegistry — look up by
                // current screen context to avoid hardcoded map duplication.
                let cmd = self.commands.cli_equivalent_for(self.navigation.current());
                self.state.set_error(format!("CLI: {cmd}"));
                Cmd::none()
            }
            Msg::OpenInBrowser => {
                self.open_current_in_browser();
                Cmd::none()
            }
            Msg::Resize { width, height } => {
                self.state.terminal_size = (width, height);
                Cmd::none()
            }

            // Screen-specific Msg variants (list scrolling, filter changes, etc.)
            // are delegated to the relevant sub-state handler.
            // State handlers are PURE (return ScreenIntent, not Cmd).
            // All async work is launched here through the TaskSupervisor.
            other => {
                let intent = self.state.handle_screen_msg(
                    self.navigation.current(),
                    other,
                );
                match intent {
                    ScreenIntent::None => Cmd::none(),
                    ScreenIntent::Navigate(screen) => self.update(Msg::NavigateTo(screen)),
                    ScreenIntent::RequeryNeeded(screen) => {
                        // Route re-query through TaskSupervisor for dedup + generation tracking
                        self.load_screen(&screen)
                    }
                }
            }
        }
    }

    fn view(&self, frame: &mut Frame) {
        let area = frame.buffer.area();

        // Three-row layout: breadcrumb | content | status bar
        let layout = Flex::vertical()
            .constraints([
                Constraint::Fixed(1),   // breadcrumb
                Constraint::Fill,       // main content
                Constraint::Fixed(1),   // status bar
            ])
            .split(area);

        let header = layout[0];
        let body = layout[1];
        let footer = layout[2];

        // Breadcrumb bar (shows full navigation path)
        crate::tui::view::common::render_breadcrumb(
            &self.navigation, frame, header,
        );

        // Always render active screen content — stale data stays visible during refresh.
        // Per-screen LoadState enables stale-while-revalidate: screens keep showing
        // their last data during background refresh with a lightweight spinner overlay.
        {
            match self.navigation.current() {
                Screen::Dashboard => {
                    crate::tui::view::dashboard::render(
                        &self.state.dashboard, frame, body,
                    );
                }
                Screen::IssueList => {
                    crate::tui::view::issue_list::render(
                        &self.state.issue_list, frame, body,
                    );
                }
                Screen::IssueDetail(_) => {
                    crate::tui::view::issue_detail::render(
                        &self.state.issue_detail, frame, body,
                    );
                }
                Screen::MrList => {
                    crate::tui::view::mr_list::render(
                        &self.state.mr_list, frame, body,
                    );
                }
                Screen::MrDetail(_) => {
                    crate::tui::view::mr_detail::render(
                        &self.state.mr_detail, frame, body,
                    );
                }
                Screen::Search => {
                    crate::tui::view::search::render(
                        &self.state.search, frame, body,
                    );
                }
                Screen::Timeline => {
                    crate::tui::view::timeline::render(
                        &self.state.timeline, frame, body,
                    );
                }
                Screen::Who => {
                    crate::tui::view::who::render(
                        &self.state.who, frame, body,
                    );
                }
                Screen::Sync => {
                    crate::tui::view::sync::render(
                        &self.state.sync, frame, body,
                    );
                }
                _ => {}
            }
        }

        // Lightweight refresh indicator overlay (non-blocking).
        // Shows a small spinner/badge when current screen's LoadState is Refreshing,
        // without obscuring the stale data underneath.
        crate::tui::view::common::render_refresh_indicator(
            self.navigation.current(), &self.state.load_state, frame, body,
        );

        // Status bar with context-sensitive keybinding hints
        crate::tui::view::common::render_status_bar(
            self.navigation.current(), &self.input_mode, frame, footer,
        );

        // Command palette modal overlay (renders on top of everything)
        if self.state.command_palette.is_visible() {
            self.state.command_palette.render(area, frame);
        }

        // Error toast overlay
        if let Some(error) = &self.state.error_toast {
            crate::tui::view::common::render_error_toast(error, frame, area);
        }

        // Help overlay
        if self.state.show_help {
            crate::tui::view::common::render_help_overlay(
                self.navigation.current(), frame, area,
            );
        }
    }

    fn subscriptions(&self) -> Vec<Box<dyn Subscription<Msg>>> {
        let mut subs: Vec<Box<dyn Subscription<Msg>>> = Vec::new();

        // Dashboard auto-refresh: tick every 60s when on Dashboard
        if matches!(self.navigation.current(), Screen::Dashboard) {
            subs.push(Box::new(
                Every::new(Duration::from_secs(60), || Msg::NavigateTo(Screen::Dashboard))
            ));
        }

        // Spinner animation: tick every 100ms when any screen is loading
        if self.state.load_state.any_loading() {
            subs.push(Box::new(
                Every::with_id(1, Duration::from_millis(100), || Msg::Tick)
            ));
        }

        // Go-prefix timeout: one-shot After(500ms) tied to the prefix start.
        // Uses After (one-shot) instead of Every (periodic) — the prefix
        // either completes with a valid key or times out exactly once.
        if matches!(self.input_mode, InputMode::GoPrefix { .. }) {
            subs.push(Box::new(
                After::with_id(2, Duration::from_millis(500), || Msg::Tick)
            ));
        }

        // Search debounce timer: one-shot fires SearchDebounceFired after 200ms.
        // Only active when a debounce is pending (armed by keystroke).
        // Uses After (one-shot) instead of Every (periodic) to avoid repeated
        // firings from a periodic timer — one debounce = one fire.
        if self.state.search.debounce_pending() {
            let generation = self.state.search.debounce_generation();
            subs.push(Box::new(
                After::with_id(3, Duration::from_millis(200), move || {
                    Msg::SearchDebounceFired { generation }
                })
            ));
        }

        subs
    }
}
```

### 4.5 Async Action System

```mermaid
graph LR
    subgraph "ftui-runtime Event Loop"
        A[Model::update] -->|returns Cmd| B[Cmd Executor]
        E[Msg Channel] --> A
    end

    subgraph "Background Tasks (spawned by Cmd::task)"
        B -->|Cmd::task| C[DB Query]
        B -->|Cmd::task| D[GitLab Sync]
        B -->|Cmd::task| F[Embed Pipeline]
        C -->|returns Msg| E
        D -->|returns Msg| E
        F -->|returns Msg| E
    end

    style A fill:#ff6b6b,stroke:#333,color:#fff
    style E fill:#4a9eff,stroke:#333,color:#fff
    style C fill:#51cf66,stroke:#333,color:#fff
    style D fill:#51cf66,stroke:#333,color:#fff
    style F fill:#51cf66,stroke:#333,color:#fff
```

The `Cmd::task(|| { ... })` pattern runs a blocking closure on a background thread pool. The runtime feeds the returned `Msg` back into `update()`. This is a **blocking** model, not async — which is perfect for us because `rusqlite::Connection` is sync.

**Snapshot consistency for detail fetches:** All detail fetches (`issue_detail`, `mr_detail`, timeline expansion) run inside a single read transaction to guarantee snapshot consistency across subqueries. Without this, concurrent sync writes can cause mixed-state reads where header metadata comes from one point in time but discussion counts come from another.

```rust
/// Wrap multiple queries in a single read transaction for snapshot consistency.
/// Detail screens combine 3-5 subqueries (metadata, discussions, cross-refs,
/// events) that must observe the same database state.
pub fn with_read_snapshot<T>(
    conn: &Connection,
    f: impl FnOnce(&rusqlite::Transaction<'_>) -> Result<T, LoreError>,
) -> Result<T, LoreError> {
    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
    let result = f(&tx)?;
    tx.commit()?;
    Ok(result)
}
```

**Query interruption:** Long-running queries use task-owned `ReaderLease` interrupt handles (from `DbManager::lease_reader()`) to avoid >1s uninterruptible stalls during rapid navigation/filtering. When the user navigates away from a detail screen before queries complete, the `TaskHandle`'s owned `InterruptHandle` fires `sqlite3_interrupt()` on that specific leased connection — never on a shared pool connection. This prevents cross-task cancellation bleed where interrupting one query accidentally cancels an unrelated query on the same pooled connection.

#### 4.5.1 Task Supervisor (Dedup + Cancellation + Priority)

Background tasks are managed by a `TaskSupervisor` that prevents redundant work, enables cooperative cancellation, and uses generation IDs for late-result detection across all task types (not just search):

```rust
// crates/lore-tui/src/task_supervisor.rs

use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;

/// Keys for task deduplication. Only one task per key can be active at a time.
/// Submitting a new task with an existing key cancels the previous one.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TaskKey {
    /// Screen data load — deduplicated per screen.
    LoadScreen(Screen),
    /// Search query — deduplicated as a single active task. Generation ID
    /// for stale result detection is tracked separately via TaskSupervisor::next_generation(),
    /// NOT embedded in the key. Embedding generation in the key defeats dedup by making
    /// every submission unique.
    Search,
    /// Sync pipeline — only one sync at a time.
    SyncStream,
    /// Filter requery — deduplicated per screen.
    FilterRequery(Screen),
}

/// Priority lanes for task scheduling. Higher priority tasks are dispatched
/// first when the thread pool is contended.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TaskPriority {
    /// User-initiated input response (filter changes, navigation).
    Input = 0,
    /// Screen navigation loads.
    Navigation = 1,
    /// Background work (sync, embed, dashboard refresh).
    Background = 2,
}

/// Cooperative cancellation token. Checked by long-running tasks between
/// phases to allow early exit without killing threads.
pub struct CancelToken {
    cancelled: AtomicBool,
}

impl CancelToken {
    pub fn new() -> Arc<Self> {
        Arc::new(Self { cancelled: AtomicBool::new(false) })
    }
    pub fn cancel(&self) { self.cancelled.store(true, Ordering::Relaxed); }
    pub fn is_cancelled(&self) -> bool { self.cancelled.load(Ordering::Relaxed) }
}

/// Returned by `TaskSupervisor::submit()` — a unified handle containing the
/// task's identity, generation ID for stale-result detection, and cancel token.
/// Callers use `handle.generation` for result freshness checks and
/// `handle.cancel` for cooperative cancellation.
pub struct TaskHandle {
    pub key: TaskKey,
    pub generation: u64,
    pub cancel: Arc<CancelToken>,
    /// Per-task SQLite interrupt handle. When set, cancellation fires
    /// this handle instead of interrupting shared pool connections.
    /// Prevents cross-task cancellation bleed.
    pub interrupt: Option<rusqlite::InterruptHandle>,
}

/// The TaskSupervisor manages active tasks, deduplicates by key, and tracks
/// generation IDs for late-arriving result detection. The unified `submit()`
/// API returns a `TaskHandle` combining key, generation, and cancel token —
/// eliminating the previous API inconsistency between `register()`/`next_generation()`.
pub struct TaskSupervisor {
    /// Active task handles keyed by TaskKey. When a new task supersedes
    /// an existing one, the old token is cancelled before the new handle is stored.
    active: HashMap<TaskKey, TaskHandle>,
    /// Monotonic generation counter for stale result detection.
    generation: AtomicU64,
}

impl TaskSupervisor {
    pub fn new() -> Self {
        Self {
            active: HashMap::new(),
            generation: AtomicU64::new(0),
        }
    }

    /// Submit a new task. Cancels any existing task with the same key.
    /// Returns a TaskHandle with key, generation, and cancel token.
    /// This is the SOLE entry point for task registration — no separate
    /// register()/next_generation() calls needed.
    pub fn submit(&mut self, key: TaskKey) -> TaskHandle {
        if let Some(old_handle) = self.active.get(&key) {
            old_handle.cancel.cancel();
        }
        let generation = self.generation.fetch_add(1, Ordering::Relaxed);
        let cancel = CancelToken::new();
        let handle = TaskHandle {
            key: key.clone(),
            generation,
            cancel: Arc::clone(&cancel),
        };
        self.active.insert(key, TaskHandle {
            key: handle.key.clone(),
            generation,
            cancel,
        });
        handle
    }

    /// Check if a generation is still current for a given key.
    /// Used by update() to drop stale results from superseded tasks.
    pub fn is_current(&self, key: &TaskKey, generation: u64) -> bool {
        self.active.get(key)
            .map_or(false, |h| h.generation == generation)
    }

    /// Remove a completed task's handle.
    pub fn complete(&mut self, key: &TaskKey, generation: u64) {
        if self.is_current(key, generation) {
            self.active.remove(key);
        }
    }
}
```

The supervisor is owned by `LoreApp` and is the **ONLY allowed path for background work**, enforced by architecture:

- **State handlers (`AppState::handle_screen_msg`) return `ScreenIntent`, not `Cmd<Msg>`.** They have no access to `DbManager` or `Cmd::task`. This makes it structurally impossible for screen state code to bypass the supervisor.
- **`LoreApp::update()` converts `ScreenIntent` into supervised `Cmd::task` calls** via `TaskSupervisor::register(key)`, which enforces dedup, cancellation tokening, and generation checks.
- **Direct `Cmd::task` calls are confined to `LoreApp` methods** (`load_screen`, `start_sync_task`, `maybe_debounced_query`). The supervisor is the single chokepoint for all background work.

The `search_request_id` field in `LoreApp` is superseded by `TaskSupervisor::submit().generation` — all task types (search, filter requery, screen load) benefit from the same generation-based staleness detection.

**Key implication:** We use `DbManager` with a pool of read-only connections and a dedicated writer. Closure-based access (`db.with_reader()`, `db.with_writer()`) prevents accidental lock scope expansion and eliminates `expect("lock poisoned")` panics. Query hot paths use `prepare_cached()` within closures to reduce parse overhead. All connections use `std::sync::Mutex` (not `tokio::sync::Mutex`). SQLite WAL mode allows concurrent readers alongside a single writer.

```rust
// crates/lore-tui/src/action.rs

use rusqlite::Connection;
use crate::tui::state::dashboard::DashboardData;
use crate::core::error::LoreError;

/// Pure query functions — no I/O, no framework dependency.
/// These run on the Cmd::task blocking thread pool.

pub fn fetch_dashboard(conn: &Connection) -> Result<DashboardData, LoreError> {
    let issues_open = count_by_state(conn, "issues", "opened")?;
    let issues_total = count_total(conn, "issues")?;
    let mrs_open = count_by_state(conn, "merge_requests", "opened")?;
    let mrs_total = count_total(conn, "merge_requests")?;
    let discussions = count_total(conn, "discussions")?;
    let notes = count_total(conn, "notes")?;
    let documents = count_total(conn, "documents")?;
    let embeddings = count_total(conn, "embeddings")?;
    let recent = fetch_recent_activity(conn, 10)?;
    let projects = fetch_project_sync_status(conn)?;

    Ok(DashboardData {
        counts: EntityCounts {
            issues_open,
            issues_total,
            mrs_open,
            mrs_total,
            discussions,
            notes_total: notes,
            notes_system_pct: 0, // computed separately
            documents,
            embeddings,
        },
        recent,
        projects,
    })
}

pub fn fetch_issues(
    conn: &Connection,
    filter: &IssueFilter,
) -> Result<Vec<IssueRow>, LoreError> {
    // Reuse existing query logic from cli/commands/list.rs
    crate::cli::commands::list::query_issues(conn, filter)
}

pub fn fetch_issue_detail(
    conn: &Connection,
    key: &EntityKey,
) -> Result<IssueDetail, LoreError> {
    crate::cli::commands::show::query_issue_detail(conn, key.project_id, key.iid)
}

// ... same pattern for all screens
```

### 4.6 Navigation Architecture

Navigation uses a **stack with global shortcuts**, designed around five principles:

1. **Spatial consistency** — the same key always means the same thing in the same context
2. **Minimal keystrokes** — the most common operations require the fewest keys
3. **State preservation** — returning to a screen restores cursor position, filter state, and scroll offset exactly
4. **No dead ends** — every screen has a clear way out (Esc, H, or `g` prefix)
5. **Progressive disclosure** — new users find features via status bar hints and command palette; power users learn `g` prefix shortcuts

```mermaid
stateDiagram-v2
    [*] --> Dashboard

    Dashboard --> IssueList: i
    Dashboard --> MrList: m
    Dashboard --> Search: /
    Dashboard --> Timeline: t
    Dashboard --> Who: w
    Dashboard --> Sync: s
    Dashboard --> Doctor: d

    IssueList --> IssueDetail: Enter
    IssueDetail --> IssueList: Esc
    IssueDetail --> MrDetail: cross-ref Enter
    IssueDetail --> Timeline: t (scoped to entity)

    MrList --> MrDetail: Enter
    MrDetail --> MrList: Esc
    MrDetail --> IssueDetail: cross-ref Enter

    Search --> IssueDetail: Enter (issue hit)
    Search --> MrDetail: Enter (MR hit)

    Timeline --> IssueDetail: Enter (issue event)
    Timeline --> MrDetail: Enter (MR event)

    Who --> IssueList: Enter (person's issues)
    Who --> MrList: Enter (person's MRs)

    note right of Dashboard
        Global shortcuts from ANY screen:
        H = Home (Dashboard, resets stack)
        gi = Issues, gm = MRs
        g/ = Search, gt = Timeline
        gw = Who, gs = Sync
        Ctrl+P = Command Palette
        q = Quit
    end note

    note left of IssueDetail
        Esc always goes back one level.
        State is preserved on return.
    end note
```

**How `g` prefix works:** Press `g`, then the destination key within 500ms. A brief `g--` indicator appears in the status bar. If no valid key follows, the prefix cancels silently. This mirrors vim's `g` prefix behavior.

**How `H` works:** Clears the entire navigation stack and resets to Dashboard. This is the "escape hatch" when you're deep in cross-reference chains and want a clean slate.

**State preservation guarantee:** When navigating back via Esc, the screen's full state is preserved — cursor position, filter text, selected item, scroll offset. The state tree is never cleared on pop; it only refreshes on explicit re-entry via NavigateTo.

### 4.7 Navigation Stack Implementation

```rust
// crates/lore-tui/src/navigation.rs

use super::message::Screen;

pub struct NavigationStack {
    back_stack: Vec<Screen>,
    current: Screen,
    forward_stack: Vec<Screen>,
    /// Jump list: records entity/detail navigation hops for Ctrl+O/Ctrl+I.
    /// Unlike back/forward which track ALL navigation, the jump list only
    /// records "significant" hops (detail views, cross-references, search results).
    /// This mirrors vim's jump list behavior.
    jump_list: Vec<Screen>,
    jump_index: usize,
    /// Browse snapshot token: each list/search screen carries a per-screen
    /// `BrowseSnapshot` that preserves stable ordering until explicit refresh
    /// or screen re-entry. This works with the snapshot fence to ensure
    /// deterministic pagination during concurrent sync writes.
    browse_snapshots: HashMap<ScreenKind, BrowseSnapshot>,
}

impl NavigationStack {
    pub fn new() -> Self {
        Self {
            back_stack: Vec::new(),
            current: Screen::Dashboard,
            forward_stack: Vec::new(),
            jump_list: vec![Screen::Dashboard],
            jump_index: 0,
        }
    }

    pub fn current(&self) -> &Screen {
        &self.current
    }

    pub fn is_at(&self, screen: &Screen) -> bool {
        &self.current == screen
    }

    pub fn push(&mut self, screen: Screen) {
        self.back_stack.push(self.current.clone());
        self.forward_stack.clear(); // Forward history lost on new navigation
        self.current = screen.clone();

        // Record significant hops in jump list
        if screen.is_detail_or_entity() {
            self.jump_list.truncate(self.jump_index + 1);
            self.jump_list.push(screen);
            self.jump_index = self.jump_list.len() - 1;
        }
    }

    pub fn pop(&mut self) -> Option<Screen> {
        if let Some(prev) = self.back_stack.pop() {
            self.forward_stack.push(self.current.clone());
            let old = self.current.clone();
            self.current = prev;
            Some(old)
        } else {
            None // Can't pop past the first screen
        }
    }

    /// Go forward (redo) in navigation history (browser-like).
    pub fn go_forward(&mut self) -> Option<Screen> {
        if let Some(next) = self.forward_stack.pop() {
            self.back_stack.push(self.current.clone());
            self.current = next.clone();
            Some(next)
        } else {
            None
        }
    }

    /// Jump backward in the jump list (Ctrl+O, like vim).
    /// Only moves through significant entity hops.
    pub fn jump_back(&mut self) -> Option<&Screen> {
        if self.jump_index > 0 {
            self.jump_index -= 1;
            Some(&self.jump_list[self.jump_index])
        } else {
            None
        }
    }

    /// Jump forward in the jump list (Ctrl+I, like vim).
    pub fn jump_forward(&mut self) -> Option<&Screen> {
        if self.jump_index + 1 < self.jump_list.len() {
            self.jump_index += 1;
            Some(&self.jump_list[self.jump_index])
        } else {
            None
        }
    }

    pub fn reset_to(&mut self, screen: Screen) {
        self.back_stack.clear();
        self.forward_stack.clear();
        self.current = screen;
    }

    pub fn breadcrumbs(&self) -> Vec<&str> {
        let mut crumbs: Vec<&str> = self.back_stack.iter().map(|s| s.label()).collect();
        crumbs.push(self.current.label());
        crumbs
    }

    pub fn depth(&self) -> usize {
        self.back_stack.len() + 1
    }
}

impl Screen {
    pub fn label(&self) -> &str {
        match self {
            Screen::Dashboard => "Dashboard",
            Screen::IssueList => "Issues",
            Screen::IssueDetail(_) => "Issue",
            Screen::MrList => "MRs",
            Screen::MrDetail(_) => "MR",
            Screen::Search => "Search",
            Screen::Timeline => "Timeline",
            Screen::Who => "Who",
            Screen::Sync => "Sync",
            Screen::Stats => "Stats",
            Screen::Doctor => "Doctor",
        }
    }

    /// Returns true for screens that represent "significant" navigation hops
    /// worth recording in the jump list. Detail views and search/timeline
    /// are significant; list views and dashboard are not.
    pub fn is_detail_or_entity(&self) -> bool {
        matches!(self,
            Screen::IssueDetail(_)
            | Screen::MrDetail(_)
            | Screen::Search
            | Screen::Timeline
        )
    }
}
```

### 4.8 Data Flow

```mermaid
sequenceDiagram
    participant U as User
    participant RT as ftui-runtime
    participant M as LoreApp (Model)
    participant CMD as Cmd::task
    participant DB as SQLite

    U->>RT: Press 'i'
    RT->>M: Event::Key('i')
    M->>M: translate_event → NavigateTo(IssueList)
    M->>M: update() → push nav stack
    M-->>CMD: Cmd::task(fetch_issues)
    CMD->>DB: SELECT from issues WHERE...
    DB-->>CMD: Vec<IssueRow>
    CMD-->>RT: Msg::IssueListLoaded(rows)
    RT->>M: update(IssueListLoaded)
    M->>M: state.issue_list.set_items(rows)
    RT->>M: view(frame)
    M-->>U: Rendered issue table
```

---

## 5. Screen Taxonomy

### 5.1 Dashboard (Home Screen)

```
┌─ lore ─────────────────────────────────────────────────────────┐
│ Dashboard                                          Ctrl+P Help │
├────────────────────────────┬───────────────────────────────────┤
│  Projects (3)              │  Quick Stats                      │
│  ├─ vs/platform     ✓ 2m  │  Issues:  1,247 open / 3,891 tot │
│  ├─ vs/mobile-app   ✓ 5m  │  MRs:      89 open /   412 tot  │
│  └─ vs/infra        ⚠ 2h  │  Discuss: 14,293 threads         │
│                            │  Notes:   52,841 (12% system)    │
├────────────────────────────┤  Docs:     4,132 indexed         │
│  Last Sync                 │  Embeds:   3,891 vectors         │
│  Started: 2h 14m ago       ├───────────────────────────────────┤
│  Duration: 3m 42s          │  Recent Activity                  │
│  Issues: +12 new, 5 upd    │  ┌─ #1247 Fix auth timeout  12m │
│  MRs: +3 new, 8 upd       │  ├─ !456  Add caching layer 45m │
│  Discussions: +89 new      │  ├─ #1245 DB migration fail  1h │
│  Events: +234              │  ├─ !453  Refactor search    2h │
│  Errors: 0                 │  └─ #1244 Update deps        3h │
├────────────────────────────┴───────────────────────────────────┤
│  Actionable Insights                                           │
│  1) 7 opened P1 issues >14d without update                    │
│  2) 3 MRs blocked by unresolved discussions                   │
│  3) auth/ has +42% note velocity this week                    │
│  Enter on insight → jump to pre-filtered list/detail           │
├────────────────────────────────────────────────────────────────┤
│ [i]ssues [m]rs [/]search [t]imeline [w]ho [s]ync [d]octor  [q]│
└────────────────────────────────────────────────────────────────┘
```

**Data source:** `lore count` + `lore sync-status` + recent issues/MRs query + insight queries
**Update frequency:** On entry + every 60s tick
**Interaction:** Single-key navigation to all major screens; Enter on insight rows jumps to pre-filtered view

**Actionable Insights panel:** Ranked heuristic-driven signals that surface what needs attention:
- Stale P1/P2 issues (opened > N days without update)
- MRs blocked by unresolved discussion threads
- Directories/labels with unusual activity velocity (spikes or drops)
- Issues approaching due date with open blockers

Insights are computed from local data during dashboard load. Each insight row is selectable — pressing Enter navigates to the relevant list screen with filters pre-applied (e.g., stale P1s → Issue List filtered to `state:opened label:P1 since:14d sort:updated`).

### 5.2 Issue List

```
┌─ lore > Issues ────────────────────────────────────────────────┐
│ Filter: state:opened  author:_  label:_  since:_     [Tab]edit │
├──────┬──────────────────────────────────┬────────┬─────────────┤
│  IID │ Title                            │ State  │ Updated     │
├──────┼──────────────────────────────────┼────────┼─────────────┤
│▶1247 │ Fix authentication timeout       │ opened │ 12 min ago  │
│ 1245 │ Database migration failure on ..  │ opened │ 1 hour ago  │
│ 1244 │ Update third-party dependencies  │ opened │ 3 hours ago │
│ 1243 │ Add rate limiting to public API  │ opened │ 5 hours ago │
│ 1241 │ Memory leak in worker process    │ opened │ 1 day ago   │
│ 1239 │ Dark mode color contrast issues  │ opened │ 2 days ago  │
│ 1237 │ Refactor notification system     │ opened │ 3 days ago  │
│ 1235 │ Add telemetry dashboard          │ opened │ 4 days ago  │
│ 1233 │ Fix flaky test in auth suite     │ closed │ 5 days ago  │
│ 1231 │ Support SAML SSO integration     │ opened │ 1 week ago  │
│  ... │                                  │        │             │
├──────┴──────────────────────────────────┴────────┴─────────────┤
│ 1/50  ↑↓ navigate  Enter detail  / filter  o open  Tab sort   │
└────────────────────────────────────────────────────────────────┘
```

**Data source:** `lore issues` query against SQLite
**Columns:** Configurable — iid, title, state, author, labels, milestone, updated_at
**Sorting:** Click column header or Tab to cycle (iid, updated, created)
**Filtering:** Interactive filter bar with typed DSL parser. Grammar (v1):
- `term := [ "-" ] (field ":" value | quoted_text | bare_text)`
- `value := quoted | unquoted`
- Examples: `state:opened label:"P1 blocker" -author:bot since:14d`
- Negation prefix (`-`) excludes matches for that term
- Quoted values allow spaces in filter values
- Parser surfaces inline diagnostics with cursor position for parse errors — never silently drops unknown fields
**Pagination:** Windowed keyset pagination with explicit cursor state. The list state maintains `window` (current visible rows), `next_cursor` / `prev_cursor` (keyset boundary values for forward/back navigation), `prefetching` flag (background fetch of next window in progress), and a fixed `window_size` (default 200 rows). First paint uses current window only; no full-result materialization. Virtual scrolling within the window for smooth UX. When the user scrolls past ~80% of the window, the next window is prefetched in the background.

**Snapshot fence:** On list entry, capture `snapshot_upper_updated_at` (current max `updated_at` in the result set) and pin all list-page queries to `updated_at <= snapshot_upper_updated_at`. This guarantees no duplicate or skipped rows during scrolling even if sync writes occur concurrently. A "new data available" badge appears when a newer sync completes; `r` refreshes the fence and re-queries from the top.

**Quick Peek (`Space`):** Toggle a right-side preview pane showing the selected item's metadata, first discussion snippet, and cross-references without entering the full detail view. This enables rapid triage scanning — the user can evaluate issues at a glance without the Enter/Esc cycle. The peek pane uses the same progressive hydration as detail views (metadata first, discussions lazy). The pane width adapts to terminal breakpoints (hidden at Xs/Sm, 40% width at Md+).

**Cursor determinism:** Keyset pagination uses deterministic tuple ordering: `ORDER BY <primary_sort>, project_id, iid`. The cursor struct includes the current `sort_field`, `sort_order`, `project_id` (tie-breaker for multi-project datasets where rows share timestamps), and a `filter_hash: u64` (hash of the active filter state). On cursor resume, the cursor is rejected if `filter_hash` or sort tuple mismatches the current query — this prevents stale cursors from producing duplicate/skipped rows after the user changes sort mode or filters mid-browse.

### 5.3 Issue Detail

```
┌─ lore > Issues > #1247 ───────────────────────────────────────┐
│ Fix authentication timeout                    state: opened    │
│ Author: @asmith  Assignee: @bjones  Labels: bug, auth, P1     │
│ Milestone: v2.3  Created: 2026-02-08  Due: 2026-02-15         │
│ Project: vs/platform                                           │
├────────────────────────────────────────────────────────────────┤
│ ┌─ Description ──────────────────────────────────────────────┐ │
│ │ Users are experiencing authentication timeouts when the    │ │
│ │ session token refresh happens during a concurrent API      │ │
│ │ call. The race condition causes the refresh to fail and    │ │
│ │ the user gets logged out.                                  │ │
│ │                                                            │ │
│ │ Steps to reproduce:                                        │ │
│ │ 1. Login and wait for token to near expiry                 │ │
│ │ 2. Trigger multiple API calls simultaneously               │ │
│ │ 3. Observe 401 errors and forced logout                    │ │
│ └────────────────────────────────────────────────────────────┘ │
│                                                                │
│ ┌─ Discussions (4 threads) ──────────────────────────────────┐ │
│ │ ▼ Thread 1 — @asmith (3 replies)                    2d ago │ │
│ │   I've narrowed this down to the TokenRefreshService.      │ │
│ │   The mutex isn't being held across the full refresh cy..  │ │
│ │   ├─ @bjones: Can you check if the retry logic in...  1d  │ │
│ │   ├─ @asmith: Yes, confirmed. The retry doesn't ch... 1d  │ │
│ │   └─ @clee: I have a fix in !458, PTAL             12h    │ │
│ │                                                            │ │
│ │ ▶ Thread 2 — @dkim (1 reply)                        1d ago │ │
│ │ ▶ Thread 3 — @system (label added: P1)             23h ago │ │
│ │ ▶ Thread 4 — @asmith (0 replies)                   12h ago │ │
│ └────────────────────────────────────────────────────────────┘ │
│                                                                │
│ ┌─ Cross-References ─────────────────────────────────────────┐ │
│ │ Closes: !458 (Fix token refresh race condition)   opened   │ │
│ │ Related: #1198 (Session management rework)        closed   │ │
│ └────────────────────────────────────────────────────────────┘ │
├────────────────────────────────────────────────────────────────┤
│ Esc back  o open in browser  t timeline  Enter cross-ref      │
└────────────────────────────────────────────────────────────────┘
```

**Data source (progressive hydration):**
1. **Phase 1 (first paint):** metadata/header — issue title, state, labels, assignees, milestones, due date. Renders immediately from the issues table.
2. **Phase 2:** discussions summary + cross-references. Loaded asynchronously; skeleton placeholders shown until ready.
3. **Phase 3:** full thread bodies loaded on demand when the user expands a collapsed discussion thread.

This 3-phase approach keeps first-paint fast (p95 < 75ms on M-tier fixtures) even for issues with hundreds of discussion notes.

**Sections:** Collapsible (description, discussions, cross-refs)
**Discussion threads:** Expand/collapse with arrow keys; full body loaded lazily on first expand
**Cross-reference navigation:** Enter on a ref → navigate to that entity
**Scrolling:** Vim-style (j/k) or arrow keys through content

### 5.4 MR List

Identical structure to Issue List with MR-specific columns:

| Column | Description |
|--------|-------------|
| IID | Merge request number |
| Title | MR title (draft prefix if WIP) |
| State | opened/merged/closed/locked |
| Draft | Draft indicator |
| Source | Source branch |
| Target | Target branch |
| Author | MR author |
| Updated | Relative time |

**Pagination:** Same windowed keyset pagination strategy as Issue List (window=200, background prefetch, deterministic cursor with `project_id` tie-breaker and `filter_hash` invalidation). Same snapshot fence (`updated_at <= snapshot_upper_updated_at`) for deterministic cross-page traversal under concurrent sync writes.

**Quick Peek (`Space`):** Same as Issue List — toggle right preview pane showing MR metadata, first discussion snippet, and cross-references for rapid triage without entering detail view.

**Additional filters:** `--draft`, `--no-draft`, `--target-branch`, `--source-branch`, `--reviewer`

### 5.5 MR Detail

```
┌─ lore > MRs > !458 ──────────────────────────────────────────┐
│ Fix token refresh race condition              state: opened    │
│ Author: @clee  Reviewers: @asmith, @bjones  Labels: fix, auth │
│ Source: fix/token-refresh → Target: main                       │
│ Pipeline: ✓ passed  Conflicts: none                            │
├────────────────────────────────────────────────────────────────┤
│ ┌─ Description ──────────────────────────────────────────────┐ │
│ │ Fixes #1247. Holds the refresh mutex across the full       │ │
│ │ token lifecycle including retry.                            │ │
│ └────────────────────────────────────────────────────────────┘ │
│                                                                │
│ ┌─ File Changes (3 files) ───────────────────────────────────┐ │
│ │  M src/auth/token_service.rs        +12 -3                 │ │
│ │  M src/auth/middleware.rs            +5  -2                 │ │
│ │  A tests/auth/token_refresh_test.rs +45                    │ │
│ └────────────────────────────────────────────────────────────┘ │
│                                                                │
│ ┌─ Diff Discussions (2 threads) ─────────────────────────────┐ │
│ │ ▼ @asmith on src/auth/token_service.rs:45          12h ago │ │
│ │   "Should we add a timeout to the mutex lock here?"        │ │
│ │   └─ @clee: Good point, added in latest push          6h  │ │
│ │                                                            │ │
│ │ ▶ @bjones on src/auth/middleware.rs:23              10h ago │ │
│ └────────────────────────────────────────────────────────────┘ │
│                                                                │
│ ┌─ General Discussions (1 thread) ───────────────────────────┐ │
│ │ ▶ Thread 1 — @asmith: LGTM, approved               6h ago │ │
│ └────────────────────────────────────────────────────────────┘ │
├────────────────────────────────────────────────────────────────┤
│ Esc back  o open in browser  Enter cross-ref  f file changes  │
└────────────────────────────────────────────────────────────────┘
```

**Unique features (progressive hydration):**
- **File change summary** in first paint (filename + diffstat from MR metadata)
- **Diff discussion bodies** loaded lazily per expanded thread
- **Separate sections** for diff discussions vs general discussions
- First paint shows file changes list and discussion count; full thread content loads on expand

### 5.6 Search

```
┌─ lore > Search ───────────────────────────────────────────────┐
│ Query: authentication timeout_                                 │
│ Mode: [hybrid]  Type: [all]  Since: [any]  Project: [all]     │
├───────────────────────────────────┬────────────────────────────┤
│  Results (24 matches, 42ms)       │  Preview                   │
│  ┌────────────────────────────┐   │  ┌──────────────────────┐  │
│  │▶ #1247 Fix auth timeout   │   │  │ Fix authentication    │  │
│  │  issue · vs/platform      │   │  │ timeout               │  │
│  │  Score: 0.94 (BM25+vec)   │   │  │                       │  │
│  │                            │   │  │ Users are experienc.. │  │
│  │  !458 Fix token refresh   │   │  │ ...session token       │  │
│  │  mr · vs/platform         │   │  │ refresh happens        │  │
│  │  Score: 0.87              │   │  │ during a concurrent    │  │
│  │                            │   │  │ API call...            │  │
│  │  #1198 Session mgmt       │   │  │                       │  │
│  │  issue · vs/platform      │   │  │ Labels: bug, auth, P1 │  │
│  │  Score: 0.72              │   │  │ Author: @asmith        │  │
│  │                            │   │  │ Updated: 12 min ago   │  │
│  │  Discussion on !412       │   │  │                       │  │
│  │  discussion · vs/platform │   │  │ ── Snippet ──          │  │
│  │  Score: 0.68              │   │  │ "...the [auth]         │  │
│  │                            │   │  │ [timeout] occurs       │  │
│  │  ...                       │   │  │ when the token..."     │  │
│  └────────────────────────────┘   │  └──────────────────────┘  │
├───────────────────────────────────┴────────────────────────────┤
│ ↑↓ select  Enter open  Tab switch mode  / refine  Esc back    │
└────────────────────────────────────────────────────────────────┘
```

**Layout:** Split pane — results list (left) + preview (right)
**Search modes:** Toggle between lexical / hybrid / semantic
**Capability-aware mode availability:** On startup and after each sync, the TUI probes search capabilities (FTS index presence, embedding coverage). If embeddings are unavailable or stale (coverage < 50%), semantic mode is disabled with an inline reason badge: "semantic unavailable: no embeddings". Hybrid mode auto-falls back to lexical-only and shows a status badge: "hybrid (semantic unavailable)". This prevents silent degradation that causes "why are results weird?" confusion and builds trust in the search system.
**Filters:** Type (issue/mr/discussion), project, author, label, since
**Live preview:** Selected result shows snippet + metadata in right pane
**Explain score:** Optional breakdown (lexical weight, semantic similarity, recency boost, field boosts) toggled with `e` key. Helps users understand why results ranked as they did and builds trust in hybrid search. Displayed as a small footer section in the preview pane.
**Debounced input:** 200ms debounce before executing search

### 5.7 Timeline

```
┌─ lore > Timeline ─────────────────────────────────────────────┐
│ Query: token refresh_                        Depth: 1  Since: 30d│
├────────────────────────────────────────────────────────────────┤
│  Seeds: #1247, !458, #1198                  Events: 47         │
├────────────────────────────────────────────────────────────────┤
│  Timeline                                                      │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ 2026-02-08 09:14  CREATED  #1247 Fix auth timeout       │  │
│  │                   by @asmith in vs/platform              │  │
│  │                                                          │  │
│  │ 2026-02-08 09:30  LABEL    #1247 +bug +auth             │  │
│  │                   by @asmith                             │  │
│  │                                                          │  │
│  │ 2026-02-08 14:22  NOTE     #1247 "I've narrowed this    │  │
│  │                   down to the TokenRefreshService..."    │  │
│  │                   by @asmith                             │  │
│  │                                                          │  │
│  │ 2026-02-09 10:00  CREATED  !458 Fix token refresh race  │  │
│  │                   by @clee (closes #1247)                │  │
│  │                                                          │  │
│  │ 2026-02-09 10:05  XREF     !458 → #1247 (closes)       │  │
│  │                                                          │  │
│  │ 2026-02-09 16:30  LABEL    #1247 +P1                    │  │
│  │                   by @lead                               │  │
│  │                                                          │  │
│  │ 2026-02-10 08:00  NOTE     !458 "@asmith PTAL"          │  │
│  │                   by @clee [src/auth/token_service.rs]   │  │
│  │                                                          │  │
│  │ 2026-02-10 14:00  STATE    !458 approved                │  │
│  │                   by @asmith                             │  │
│  └──────────────────────────────────────────────────────────┘  │
├────────────────────────────────────────────────────────────────┤
│ ↑↓ scroll  Enter entity  d depth  s since  / search  Esc back │
└────────────────────────────────────────────────────────────────┘
```

**Data source:** Timeline pipeline (SEED → HYDRATE → EXPAND → COLLECT → RENDER)
**Event types:** Color-coded by type (CREATED=green, STATE=yellow, LABEL=cyan, NOTE=white, XREF=magenta, MERGED=green+bold)
**Interaction:** Enter on any event → navigate to the parent entity
**Controls:** Adjust depth (BFS expansion), since window, max seeds

### 5.8 People Explorer (Who)

```
┌─ lore > Who ──────────────────────────────────────────────────┐
│ Mode: [Expert]  Path: src/auth/_              Since: 6m       │
├────────────────────────────────────────────────────────────────┤
│  Expert Rankings for src/auth/                                 │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │  Rank │ Person   │ Score │ Authored │ Reviewed │ Notes   │  │
│  │ ─────┼──────────┼───────┼──────────┼──────────┼──────── │  │
│  │▶  1  │ @asmith  │   142 │    8 MRs │    5 MRs │  23     │  │
│  │   2  │ @bjones  │    89 │    3 MRs │   12 MRs │  15     │  │
│  │   3  │ @clee    │    67 │    5 MRs │    2 MRs │   8     │  │
│  │   4  │ @dkim    │    34 │    1 MR  │    4 MRs │   5     │  │
│  │   5  │ @epark   │    21 │    2 MRs │    1 MR  │   3     │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                │
│  @asmith — Detail                                              │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ Top code areas:                                          │  │
│  │   src/auth/token_service.rs    ████████████░  45%        │  │
│  │   src/auth/middleware.rs       ██████░░░░░░░  22%        │  │
│  │   src/auth/session.rs          ████░░░░░░░░░  15%        │  │
│  │   src/auth/oauth.rs            ██░░░░░░░░░░░   8%        │  │
│  │   other                        ██░░░░░░░░░░░  10%        │  │
│  └──────────────────────────────────────────────────────────┘  │
├────────────────────────────────────────────────────────────────┤
│ Tab mode  Enter person MRs  / path  r reviews  a active       │
└────────────────────────────────────────────────────────────────┘
```

**Modes (Tab to cycle):**

| Mode | Description | Data Source |
|------|-------------|-------------|
| Expert | Path-based expert ranking | `lore who <path>` |
| Workload | Person's assigned work | `lore who @username` |
| Reviews | DiffNote review patterns | `lore who @username --reviews` |
| Active | Unresolved discussions | `lore who --active` |
| Overlap | Who else touches files | `lore who --overlap <path>` |

### 5.9 Sync

The Sync screen has two modes: **running** (progress + log) and **summary** (post-sync change report).

**Running mode:**

```
┌─ lore > Sync ─────────────────────────────────────────────────┐
│ Status: Syncing...  Elapsed: 1m 23s                            │
├────────────────────────────────────────────────────────────────┤
│  vs/platform                                                   │
│  ├─ Issues     ████████████████████████████████░░ 89%  +12    │
│  ├─ MRs        ████████████████████████████████████ 100% +3   │
│  ├─ Discussions ██████████████░░░░░░░░░░░░░░░░░░ 42%  +89    │
│  └─ Events     ████████████████████░░░░░░░░░░░░ 58%  +234    │
│                                                                │
│  vs/mobile-app                                                 │
│  ├─ Issues     ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0%   queued │
│  ├─ MRs        ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0%   queued │
│  ├─ Discussions ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0%   queued │
│  └─ Events     ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0%   queued │
│                                                                │
│  vs/infra                                                      │
│  └─ (queued)                                                   │
├────────────────────────────────────────────────────────────────┤
│  Log ──────────────────────────────────────────────────────────│
│  │ [12:01:23] Fetching issues page 4/5 for vs/platform        │
│  │ [12:01:24] Upserting 20 issues (12 new, 8 updated)         │
│  │ [12:01:25] Fetching discussions for 12 issues              │
│  │ [12:01:26] Rate limit: 28/30 req/s, backing off 200ms     │
│  │ [12:01:27] Fetching resource events page 1/? for vs/plat  │
│  │                                                             │
├────────────────────────────────────────────────────────────────┤
│ Esc cancel  f full sync  e embed after  d dry-run  l log level  stats:drop=0 qmax=0 │
└────────────────────────────────────────────────────────────────┘
```

**Summary mode (after sync completes):**

```
┌─ lore > Sync ─────────────────────────────────────────────────┐
│ Status: Complete  Duration: 3m 42s                             │
├────────────────────────────────────────────────────────────────┤
│  What Changed                                                  │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │  +12 new issues       5 updated       (i to browse)      │  │
│  │   +3 new MRs          8 updated       (m to browse)      │  │
│  │  +89 new discussions                                      │  │
│  │ +234 new events                                           │  │
│  │   0 errors                                                │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                │
│  Log ──────────────────────────────────────────────────────────│
│  │ [12:05:05] Sync complete: 3m 42s total                    │  │
│  │ [12:05:04] Embedded 89 new documents (1.2s)               │  │
│  │ [12:05:02] Processed 234 resource events                  │  │
│  │ [12:05:00] Fetched 89 discussions (3 projects)            │  │
│  │                                                            │
├────────────────────────────────────────────────────────────────┤
│ i new issues  m new MRs  r restart  f full sync  Esc back     │
└────────────────────────────────────────────────────────────────┘
```

**Running mode:**
- Progress bars per project per resource
- **Per-project fault isolation:** If one project/resource lane fails, it is marked degraded while other lanes continue syncing. Failed lanes are retried on next sync or via explicit `[R]esume`.
- Log viewer with streaming output and explicit stream-finalization state (FrankenTUI `ScreenMode::Inline` preserves scrollback)
- `f` = toggle full sync, `e` = toggle embed after, `d` = dry-run, `l` = log level, `Esc` = cancel

**Summary mode:**
- Shows delta counts (new, updated) for each entity type
- `i` navigates to Issue List filtered by exact issue IDs changed in this sync run (from in-memory `SyncDeltaLedger`). Falls back to timestamp filter via `sync_status.last_completed_at` only if run delta is not available (e.g., after app restart).
- `m` navigates to MR List filtered by exact MR IDs changed in this sync run (from in-memory `SyncDeltaLedger`). Falls back to timestamp filter only if run delta is not available.
- `r` restarts sync

### 5.10 Command Palette (Overlay)

```
┌─────────────────────────────────────────────────────┐
│ > sync fu_                                           │
├─────────────────────────────────────────────────────┤
│ ▶ Sync (full)          Reset cursors and re-sync    │
│   Sync                 Incremental sync             │
│   Sync (no embed)      Skip embedding step          │
│   Search "fu"          Search for "fu" in docs      │
└─────────────────────────────────────────────────────┘
```

**Trigger:** `Ctrl+P` from any screen
**Behavior:** Fuzzy match against all commands + recent entities
**Commands mapped:**

| Palette Entry | Action |
|---------------|--------|
| Issues | Navigate to Issue List |
| Issues (opened) | Issue List pre-filtered |
| Issues (closed) | Issue List pre-filtered |
| Merge Requests | Navigate to MR List |
| MRs (opened) | MR List pre-filtered |
| MRs (merged) | MR List pre-filtered |
| MRs (draft) | MR List pre-filtered |
| Search | Navigate to Search |
| Timeline | Navigate to Timeline |
| Who (expert) | Who → Expert mode |
| Who (workload) | Who → Workload mode |
| Who (active) | Who → Active mode |
| Sync | Start incremental sync |
| Sync (full) | Full sync with cursor reset |
| Sync (no embed) | Sync without embedding |
| Doctor | Run health check |
| Stats | Show index statistics |
| Open in browser | Open current entity in GitLab |
| Quit | Exit TUI |

### 5.11 Doctor / Stats (Info Screens)

**Stats:** Simple read-only view rendering the output of `lore stats` as styled text blocks. No complex interaction — just scrollable content with Esc to go back.

**Doctor:** Interactive health check view with actionable suggestions:
- Shows health checks with severity indicators (pass/warn/fail)
- Exposes the `actions` array from robot-mode `lore --robot doctor` errors as selectable suggestion rows
- Each suggestion shows the remediation command (e.g., `lore init`, `lore migrate`, `lore sync --full`)
- Enter on a suggestion copies the command to clipboard (or displays it in a toast for manual execution)
- Does NOT auto-execute commands — the user always runs them manually for safety
- Scrollable with j/k, Esc to go back

### 5.12 Bootstrap (Data Readiness)

Shown automatically when the TUI detects no synced projects/documents or required indexes are missing. This is a read-only screen — it never auto-executes commands.

Displays concise readiness checks with pass/fail indicators:
- Synced projects present?
- Issues/MRs populated?
- FTS index built?
- Embedding index built? (optional — warns but doesn't block)
- Required migration version met?

For each failing check, shows the exact CLI command to recover (e.g., `lore sync`, `lore migrate`, `lore --robot doctor`). The user exits the TUI and runs the commands manually.

This prevents the "blank screen" first-run experience where a user launches `lore tui` before syncing data and sees an empty dashboard with no indication of what to do next.

---

## 6. User Flows

### 6.1 Flow: "Find who knows about auth code"

```mermaid
graph TD
    A[Dashboard] -->|w| B[Who Screen]
    B -->|type 'src/auth/'| C[Expert Mode Results]
    C -->|↓ select @asmith| D[Detail Panel Shows Code Areas]
    D -->|Enter| E[Issue List filtered by @asmith]
    E -->|↓ select #1247| F[Issue Detail]
    F -->|scroll to discussions| G[Read discussion threads]
    G -->|Enter on cross-ref !458| H[MR Detail]
    H -->|o| I[Opens in browser]

    style A fill:#2d2d2d,stroke:#666,color:#fff
    style C fill:#4a9eff,stroke:#333,color:#fff
    style F fill:#51cf66,stroke:#333,color:#fff
    style H fill:#ffd43b,stroke:#333,color:#000
```

**Keystrokes:** `w` → type `src/auth/` → `↓` → `Enter` → `↓` → `Enter` → `↓↓↓` → `Enter` → `o`
**Total:** 8 actions to go from "who knows about auth" to opening the relevant MR in browser.
**CLI equivalent:** `lore who src/auth/` → read output → `lore who @asmith` → read → `lore issues 1247` → read → `lore mrs 458` → `lore mrs 458 --open` — 6 separate commands, ~200 characters of typing.

### 6.2 Flow: "What happened with the auth timeout this week?"

```mermaid
graph TD
    A[Dashboard] -->|t| B[Timeline Screen]
    B -->|type 'auth timeout'| C[Timeline Seeds: #1247, !458]
    C -->|scroll through events| D[See chronological story]
    D -->|Enter on CREATED !458| E[MR Detail]
    E -->|Esc| D
    D -->|Enter on NOTE evidence| F[Issue Detail at discussion]

    style A fill:#2d2d2d,stroke:#666,color:#fff
    style C fill:#4a9eff,stroke:#333,color:#fff
    style D fill:#51cf66,stroke:#333,color:#fff
```

**Keystrokes:** `t` → type query → `Enter` → scroll → `Enter` → `Esc` → scroll
**Value:** The timeline view reconstructs the full narrative that would require 5+ separate CLI commands.

### 6.3 Flow: "Quick search for something I vaguely remember"

```mermaid
graph TD
    A[Any Screen] -->|Ctrl+P or /| B[Search Screen]
    B -->|type query| C[Live Results + Preview]
    C -->|Tab| D[Switch to semantic mode]
    D -->|↑↓ browse| E[Preview updates in real-time]
    E -->|Enter| F[Entity Detail View]

    style B fill:#4a9eff,stroke:#333,color:#fff
    style C fill:#51cf66,stroke:#333,color:#fff
    style F fill:#ffd43b,stroke:#333,color:#000
```

### 6.4 Flow: "Sync and see what changed"

```mermaid
graph TD
    A[Dashboard] -->|s| B[Sync Screen]
    B -->|Enter to start| C[Progress Bars + Log Stream]
    C -->|sync completes| D[Summary: +12 issues, +3 MRs, ...]
    D -->|i| E[Issue List filtered to new since sync]
    D -->|m| F[MR List filtered to new since sync]
    E -->|↓ select| G[Issue Detail of new item]

    style B fill:#4a9eff,stroke:#333,color:#fff
    style C fill:#ff6b6b,stroke:#333,color:#fff
    style D fill:#51cf66,stroke:#333,color:#fff
    style E fill:#ffd43b,stroke:#333,color:#000
```

**Key improvement:** After sync completes, the screen transitions to a change summary showing exactly what's new. Pressing `i` or `m` navigates to filtered lists showing only items from this sync, bridging the gap between "syncing data" and "consuming it."

### 6.5 Flow: "Review someone's open work"

```mermaid
graph TD
    A[Dashboard] -->|w| B[Who Screen]
    B -->|Tab to Workload| C[Workload Mode]
    C -->|type '@bjones'| D[Workload Summary]
    D -->|see assigned issues| E[Issue section]
    D -->|see authored MRs| F[MR section]
    D -->|see review queue| G[Review section]
    E -->|Enter| H[Issue Detail]
    F -->|Enter| I[MR Detail]

    style D fill:#4a9eff,stroke:#333,color:#fff
    style E fill:#51cf66,stroke:#333,color:#fff
    style F fill:#51cf66,stroke:#333,color:#fff
    style G fill:#51cf66,stroke:#333,color:#fff
```

### 6.6 Flow: Command Palette Power Usage

```mermaid
graph TD
    A[Any Screen] -->|Ctrl+P| B[Command Palette Opens]
    B -->|type 'mrs draft'| C[Fuzzy matches 'MRs draft']
    C -->|Enter| D[MR List pre-filtered: draft=true]
    D -->|Ctrl+P again| E[Palette Opens]
    E -->|type 'sync'| F[Fuzzy matches Sync commands]
    F -->|↓ select 'Sync full'| G[Sync Screen starts full sync]

    style B fill:#ffd43b,stroke:#333,color:#000
    style C fill:#4a9eff,stroke:#333,color:#fff
    style E fill:#ffd43b,stroke:#333,color:#000
```

### 6.7 Flow: "Morning triage scan"

```mermaid
graph TD
    A[Dashboard] -->|i| B[Issue List auto: opened, sort by updated]
    B -->|j/k scan titles| C[Cursor on #1247]
    C -->|Enter| D[Issue Detail #1247]
    D -->|Esc| C
    C -->|j| E[Cursor on #1245]
    E -->|Enter| F[Issue Detail #1245]
    F -->|Esc| E
    E -->|j j j| G[Continue scanning...]

    style A fill:#2d2d2d,stroke:#666,color:#fff
    style B fill:#4a9eff,stroke:#333,color:#fff
    style D fill:#51cf66,stroke:#333,color:#fff
    style F fill:#51cf66,stroke:#333,color:#fff
```

**Keystrokes:** `i` → `j/k` to scan → `Space` to Quick Peek (or `Enter` for full detail) → `Esc` to return → continue scanning
**State preservation:** After pressing Esc from Issue Detail, the cursor returns to exactly the same row in the list. Filter state and scroll offset are preserved. This tight Enter/Esc loop is the most common daily workflow. Quick Peek (`Space`) makes triage even faster — preview metadata and first discussion snippet without leaving the list.

### 6.8 Flow: "Jump between screens without returning to Dashboard"

```mermaid
graph TD
    A[Issue Detail #1247] -->|gt| B[Timeline scoped to #1247]
    B -->|gw| C[Who Screen]
    C -->|type 'src/auth/'| D[Expert Rankings]
    D -->|gi| E[Issue List]
    E -->|H| F[Dashboard clean slate]

    style A fill:#51cf66,stroke:#333,color:#fff
    style B fill:#4a9eff,stroke:#333,color:#fff
    style C fill:#ffd43b,stroke:#333,color:#000
    style E fill:#ff6b6b,stroke:#333,color:#fff
    style F fill:#2d2d2d,stroke:#666,color:#fff
```

**Demonstrates:** The `g` prefix allows direct screen-to-screen navigation without returning to Dashboard first. Each `g` shortcut pushes onto the stack, so Esc traces back through the chain. When the stack gets deep, `H` resets to Dashboard for a clean slate.

### 6.9 Flow: "Risk-first morning sweep"

```mermaid
graph TD
    A[Dashboard] -->|scroll to Insights| B[Actionable Insights panel]
    B -->|Enter on 'P1 issues >14d'| C[Issue List pre-filtered: P1 + stale]
    C -->|j/k scan| D[Review stale issues]
    D -->|Esc back to Dashboard| E[Dashboard]
    E -->|Enter on 'MRs blocked'| F[MR List pre-filtered: unresolved threads]
    F -->|Enter on !458| G[MR Detail: see blocking discussion]

    style A fill:#2d2d2d,stroke:#666,color:#fff
    style B fill:#ffd43b,stroke:#333,color:#000
    style C fill:#4a9eff,stroke:#333,color:#fff
    style F fill:#ff6b6b,stroke:#333,color:#fff
```

**Demonstrates:** Dashboard insights as a decision surface. Instead of browsing all issues, the user starts from ranked signals — stale P1s, blocked MRs, activity spikes — and jumps directly to pre-filtered views. This turns the dashboard from a metrics screen into a triage launcher.

---

## 7. Widget Inventory

### 7.1 FrankenTUI Built-in Widgets Used

| Widget | Module | Usage | Screen(s) |
|--------|--------|-------|-----------|
| `Table` | `ftui_widgets::table` | Issue/MR lists, expert rankings, file changes | IssueList, MrList, Who, MrDetail |
| `Paragraph` | `ftui_widgets::paragraph` | Descriptions, discussion bodies, log output | IssueDetail, MrDetail, Sync |
| `Block` | `ftui_widgets::block` | Section borders with titles | All |
| `Panel` | `ftui_widgets::panel` | Border + title + padding (richer than Block) | Detail views |
| `ProgressBar` | `ftui_widgets::progress` | Sync progress per resource | Sync |
| `List` | `ftui_widgets::list` | Search results, timeline events | Search, Timeline |
| `VirtualizedList` | `ftui_widgets::virtualized` | Large dataset scrolling | IssueList, Timeline |
| `Tree` | `ftui_widgets::tree` | Discussion thread hierarchy | IssueDetail, MrDetail |
| `CommandPalette` | `ftui_widgets::command_palette` | Fuzzy command overlay | Global overlay |
| `Modal` | `ftui_widgets::modal` | Help overlay, confirmation dialogs | Global |
| `LogViewer` | `ftui_widgets::log_viewer` | Streaming log during sync | Sync |
| `TextInput` | `ftui_widgets::input` | Filter bar, search query input | Lists, Search |
| `Scrollbar` | `ftui_widgets::scrollbar` | Long content navigation | Detail views |
| `Spinner` | `ftui_widgets::spinner` | Loading indicators | All (during data fetch) |
| `Toast` | `ftui_widgets::toast` | Error/success notifications | Global |
| `Badge` | `ftui_widgets::badge` | State pills, label tags, breadcrumb segments | Detail views, nav |
| `StatusLine` | `ftui_widgets::status_line` | Keybinding hints | Footer |
| `JsonView` | (extras) | Raw JSON display for doctor/stats | Doctor, Stats |

### 7.2 FrankenTUI Extras Used

| Feature | Module | Usage |
|---------|--------|-------|
| `charts` | `ftui_extras::charts` | Code area distribution bar chart | Who (expert detail) |
| `markdown` | `ftui_extras::markdown` | Render issue/MR descriptions | Detail views |
| `forms` | `ftui_extras::forms` | Filter bar with structured fields | Lists, Search |
| ~~`validation`~~ | ~~`ftui_extras::validation`~~ | **NOT YET IMPLEMENTED** — declared but empty upstream | ~~Lists~~ |
| ~~`help`~~ | ~~`ftui_extras::help`~~ | **NOT YET IMPLEMENTED** — declared but empty upstream | ~~Global~~ |
| `theme` | `ftui_extras::theme` | Color themes with presets | Global |
| `logging` | `ftui_extras::logging` | TUI-aware tracing subscriber | Sync |

### 7.3 Custom Widgets (Only 1 Needed)

| Widget | Purpose | Complexity |
|--------|---------|------------|
| `CrossRefLink` | Styled entity reference with navigation | Low (~30 lines) |

---

## 8. Keybinding Reference

> **Implementation note:** All keybinding, help overlay, status bar hint, and command palette definitions are generated from `commands.rs` (the `CommandRegistry`). No hardcoded duplicate maps exist in view or state modules. To add or change a binding, update the registry — all consumers reflect the change automatically.

### 8.1 Global (Available Everywhere)

| Key | Action |
|-----|--------|
| `q` | Quit TUI |
| `Esc` | Go back / close overlay |
| `Backspace` | Go back (when not in text input) |
| `H` | Home — jump to Dashboard (resets nav stack) |
| `g` + key | Go-prefix: `gi` Issues, `gm` MRs, `g/` Search, `gt` Timeline, `gw` Who, `gs` Sync |
| `Ctrl+P` | Open command palette |
| `?` | Show help overlay |
| `o` | Open current entity in browser |
| `:` | Show equivalent CLI command for current screen/action |
| `Ctrl+O` | Jump backward in jump list (entity hops) |
| `Alt+o` | Jump forward in jump list (entity hops) |
| `Ctrl+R` | Reset session state for current screen (clear filters, scroll to top) |
| `P` | Open project scope picker / toggle global scope pin. When a scope is pinned, all list/search/timeline/who queries are filtered to that project set. A visible `[scope: project/path]` indicator appears in the status bar. |
| `Ctrl+C` | Quit (force) |

### 8.2 List Screens (Issues, MRs, Search Results)

| Key | Action |
|-----|--------|
| `j` / `↓` | Move selection down |
| `k` / `↑` | Move selection up |
| `Enter` | Open selected item |
| `Space` | Toggle Quick Peek panel for selected row |
| `G` | Jump to bottom |
| `g` `g` | Jump to top |
| `Tab` / `f` | Focus filter bar |
| `/` | Focus filter bar (alias) |
| `S` | Cycle sort column |
| `r` | Refresh data |
| `[` / `]` | Previous/next page |

**Keybinding precedence (deterministic dispatch order):**
1. Modal/palette — when command palette is open, it consumes all input
2. Focused text input — filter bar / search box consume alphanumeric keys; only `Esc` and `Ctrl+P` break out
3. Global shortcuts — `q`, `H`, `?`, `o`, `Ctrl+C`, `Ctrl+P`, `Esc`, `g` prefix
4. Screen-local shortcuts — per-screen key handlers (the table above)

**Go-prefix timeout:** 500ms from first `g` press, enforced by a one-shot `After(500ms)` subscription tied to the prefix generation. If no valid continuation key arrives within 500ms, the timer fires a single `Msg::Tick` which checks `InputMode::GoPrefix { started_at }` via `clock.now_instant()` and cancels the prefix. A brief "g--" flash clears from the status bar. Using `After` (one-shot) instead of `Every` (periodic) avoids unnecessary repeated ticks. Using `InputMode` instead of ad-hoc boolean flags makes the state machine explicit and deterministic. Feedback is immediate — the status bar shows "g--" within the same frame as the keypress.

**Terminal keybinding safety notes:**
- `Ctrl+I` is NOT used — it is indistinguishable from `Tab` in most terminals (both send `\x09`). Jump-forward uses `Alt+o` instead.
- `Ctrl+H` is NOT used — it collides with `Backspace` in many terminals (both send `\x08`).
- `Ctrl+M` is NOT used — it collides with `Enter` in most terminals (both send `\x0D`).

**Key normalization:** The `interpret_key()` function applies lightweight normalization before dispatch to handle common terminal encoding differences:
- Backspace variants (`^H` / `\x7F` / `DEL`) normalized to a single `KeyCode::Backspace`
- Alt/Meta prefix encoding differences (ESC+key vs 8th-bit) normalized to `Modifiers::ALT`
- Shift+Tab normalized to `KeyCode::BackTab` regardless of terminal encoding

This prevents "works in iTerm, broken in tmux" bugs. FrankenTUI's `ftui-core` event parser handles much of this, but we add a thin normalization pass for the remaining edge cases not covered upstream.

### 8.3 Detail Screens (Issue Detail, MR Detail)

| Key | Action |
|-----|--------|
| `j` / `↓` | Scroll down |
| `k` / `↑` | Scroll up |
| `Space` | Page down |
| `b` | Page up |
| `d` | Toggle discussion collapse/expand |
| `Enter` | Follow cross-reference link |
| `t` | Open timeline scoped to this entity |

### 8.4 Dashboard

| Key | Action |
|-----|--------|
| `i` | Go to Issues |
| `m` | Go to MRs |
| `/` | Go to Search |
| `t` | Go to Timeline |
| `w` | Go to Who |
| `s` | Go to Sync |
| `d` | Run Doctor |
| `r` | Refresh dashboard |

### 8.5 Who Screen

| Key | Action |
|-----|--------|
| `Tab` | Cycle mode (Expert → Workload → Reviews → Active → Overlap) |
| `Enter` | Open person's issues/MRs |
| `r` | Switch to Reviews sub-view |
| `a` | Switch to Active sub-view |

### 8.6 Sync Screen

| Key | Action (running) | Action (summary) |
|-----|------------------|-------------------|
| `Enter` | Start sync (if not started) | — |
| `Esc` | Cancel running sync | Go back to previous screen |
| `f` | Toggle full sync mode | Restart as full sync |
| `e` | Toggle embed after sync | — |
| `d` | Toggle dry-run | — |
| `l` | Cycle log verbosity | — |
| `i` | — | Browse new issues (filtered) |
| `m` | — | Browse new MRs (filtered) |
| `r` | — | Restart sync |

---

## 9. Implementation Plan

### 9.1 Dependency Changes

```toml
# Root Cargo.toml changes
[workspace]
members = ["."]
exclude = ["crates/lore-tui"]

# Keep lore-tui EXCLUDED from root workspace (nightly isolation boundary).
# NO feature gate for TUI — lore has zero compile-time dependency on lore-tui.
# The `lore tui` subcommand resolves and spawns the lore-tui binary at runtime.

# KEEP (existing, used by CLI):
console = "0.16"        # Still used by non-TUI CLI output
indicatif = "0.18"      # Still used by non-TUI CLI progress bars
comfy-table = "7"       # Still used by non-TUI CLI tables
dialoguer = "0.12"      # Still used by lore init

# crates/lore-tui/Cargo.toml
[package]
name = "lore-tui"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "lore-tui"
path = "src/main.rs"

[dependencies]
lore = { path = "../.." }  # Core lore library (as library, not binary)
ftui = { git = "https://github.com/Dicklesworthstone/frankentui.git", rev = "TBD" }
ftui-extras = { git = "https://github.com/Dicklesworthstone/frankentui.git", rev = "TBD", features = [
    "charts",
    "forms",
    "theme",
    "markdown",
    "logging",
] }
rusqlite = { version = "0.31", features = ["bundled"] }
unicode-segmentation = "1"  # Grapheme cluster segmentation for safe text truncation
clap = { version = "4", features = ["derive"] }  # CLI arg parsing for lore-tui binary

# NOTE: ftui-extras "validation" and "help" features are declared in Cargo.toml
# but the modules are NOT YET IMPLEMENTED upstream. Omit until available.
# We build our own help overlay and validate filter input with simple match logic.
```

### 9.2 Phases

```mermaid
gantt
    title TUI Implementation Phases (FrankenTUI)
    dateFormat  YYYY-MM-DD
    axisFormat  %b %d

    section Phase 0 — Toolchain Gate
    Add rust-toolchain.toml (nightly) :p0a, 2026-02-15, 1d
    Add ftui dependency, verify build  :p0b, after p0a, 1d
    Terminal compat smoke test          :p0c, after p0b, 1d

    section Phase 1 — Foundation
    LoreApp impl Model + event loop    :p1a, after p0c, 2d
    Theme (ftui ThemeBuilder)           :p1b, after p1a, 1d
    Navigation stack + breadcrumb      :p1c, after p1a, 1d
    Status bar (ftui StatusLine)       :p1d, after p1c, 1d

    section Phase 2 — Core Screens
    Dashboard                          :p2a, after p1d, 2d
    Issue List + filter (ftui Forms)   :p2b, after p2a, 2d
    Issue Detail + tree discussions    :p2c, after p2b, 3d
    MR List                            :p2d, after p2b, 1d
    MR Detail + diff discussions       :p2e, after p2d, 2d

    section Phase 2.5 — Vertical Slice Gate
    Dashboard+IssueList+IssueDetail+Sync integrated :p25a, after p2c, 3d
    Gate: p95 nav <75ms M-tier; 0 stuck-input; cancel p95 <2s :p25b, after p25a, 1d

    section Phase 3 — Power Features
    Search (hybrid + preview)          :p3a, after p25b, 2d
    Timeline (VirtualizedList)         :p3b, after p3a, 2d
    Who (all 5 modes + charts)        :p3c, after p3b, 3d
    Command Palette (ftui built-in)    :p3d, after p2a, 1d

    section Phase 4 — Operations
    Sync (Inline mode + LogViewer)     :p4a, after p3c, 2d
    Doctor + Stats (JsonView)          :p4b, after p4a, 1d
    CLI integration (lore tui)         :p4c, after p4b, 1d

    section Phase 5 — Polish
    Responsive breakpoints (native)    :p5a, after p4c, 1d
    Snapshot tests (ftui-harness)      :p5b, after p5a, 2d
    Terminal compat test matrix        :p5c, after p5b, 2d

    section Phase 5.5 — Reliability Test Pack
    Stale response drop tests          :p55a, after p5c, 1d
    Sync cancel/resume tests           :p55b, after p55a, 1d
    SQLITE_BUSY retry tests            :p55c, after p55a, 0.5d
    Resize storm + rapid keypress      :p55d, after p55b, 0.5d
    Property tests for nav invariants  :p55e, after p55c, 1d
    Performance benchmark fixtures     :p55f, after p55d, 1d
    Event fuzz tests (key/resize/paste, deterministic seed replay):p55g, after p55e, 1d
    Deterministic clock/render tests:p55i, after p55g, 0.5d
    30-minute soak test (no panic/leak):p55h, after p55i, 1d
    Concurrent pagination/write race tests :p55j, after p55h, 1d
    Query cancellation race tests    :p55k, after p55j, 0.5d

    section Phase 5.6 — CLI/TUI Parity Pack
    Dashboard count parity tests       :p56a, after p55h, 0.5d
    List/detail parity tests           :p56b, after p56a, 1d
    Search result identity parity      :p56c, after p56b, 0.5d
    Terminal safety sanitization tests :p56d, after p56a, 0.5d
```

**Phase 5.6 — CLI/TUI Parity Pack:**

Ensures the TUI displays the same data as the CLI robot mode, preventing drift between interfaces:

- **Dashboard count parity:** TUI dashboard entity counts match `lore --robot count issues` and `lore --robot count mrs` on the same fixture.
- **List/detail parity:** Sampled issues and MRs from TUI list views contain identical fields to `lore --robot issues <iid>` and `lore --robot mrs <iid>`.
- **Search result identity parity:** Top-N result IDs from TUI lexical search match `lore --robot search "<query>"` on the same fixture.
- **Terminal safety sanitization:** `sanitize_for_terminal()` test suite covers ANSI CSI, OSC, C0/C1 control chars, directional marks (LRM/RLM/ALM), bidi overrides, multi-byte edge cases, and Unicode preservation.

**Success criterion:** Parity suite passes on CI fixtures (S and M tiers). Parity is asserted by field-level comparison, not string formatting comparison — the TUI and CLI may format differently but must present the same underlying data.

**Total estimated scope:** ~51 implementation days across 9 phases (increased from ~49 to account for snapshot fences, sync delta ledger, bootstrap screen, global scope pinning, concurrent pagination/write race tests, and cancellation race tests).

### 9.3 Phase 0 — Toolchain Gate

This is a hard gate. If Phase 0 fails, we evaluate alternatives before proceeding.

**Success criteria:**
1. `cargo +stable check --workspace --all-targets` passes for root workspace
2. `cargo +nightly-2026-02-08 check --manifest-path crates/lore-tui/Cargo.toml --all-targets` passes
3. `cargo clippy --all-targets -- -D warnings` passes (both stable workspace and nightly TUI crate)
4. `cargo test` passes (all existing tests)
5. `cargo run --release -- --robot health` returns exit 0
6. ftui demo-showcase renders correctly in iTerm2 and tmux
7. Binary size increase is < 5MB
8. **Tiered performance fixtures** validate latency at three scales:

   | Tier | Issues | MRs | Notes | Docs |
   |------|--------|-----|-------|------|
   | **S** (small) | 10k | 5k | 50k | 10k |
   | **M** (medium) | 100k | 50k | 500k | 50k |
   | **L** (large) | 250k | 100k | 1M | 100k |

   **SLO targets per tier:**
   - p95 first-paint latency: < 50ms (S), < 75ms (M), < 150ms (L)
   - p95 next-window fetch (keyset pagination): < 50ms (S), < 75ms (M), < 100ms (L)
   - p95 search latency (lexical mode): < 100ms (S), < 200ms (M), < 400ms (L)
   - **Memory ceiling: < 150MB RSS at S tier, < 250MB RSS at M tier** (measured after full dashboard + list load)
   - S and M tiers are CI-enforced gates; L tier is advisory (run on-demand, not blocking)

9. EXPLAIN QUERY PLAN must show index usage for top 10 TUI queries (no full table scans under default filters)
10. No full table scan on issues/MRs/discussions under default filters — verified by query plan CI check
11. p95 search latency validated in both S and M tiers, lexical and hybrid modes. Hybrid mode allowed 2x lexical latency budget.
12. Real-time sync stream verified: progress updates visible during run, not only at completion. Channel-based subscription drains events each tick.
13. Event-fuzz suite passes with zero invariant violations across 10k randomized key/resize/paste traces.
14. 30-minute soak test: no panic, no deadlock, memory growth < 5%.
15. Search mode parity: lexical, hybrid, and semantic each return mode-consistent top-N IDs on the fixture dataset. Switching modes in the TUI actually changes query behavior (no hardcoded defaults).
16. Detail first-paint p95 < 75ms on M-tier fixtures (metadata-only phase, before discussion/thread hydration).
17. Deterministic clock/render: snapshot tests using `FakeClock` produce identical output across runs regardless of wall-clock time.
18. Schema preflight test: incompatible DB schema yields actionable error and non-zero exit before entering TUI loop.
19. Unicode rendering tests pass for CJK, emoji ZWJ sequences, combining marks, skin tone modifiers, and flag sequences. Grapheme cluster boundaries are never split during truncation.
20. Redaction tests confirm tokens, Authorization headers, and credential patterns never appear in persisted crash reports or log artifacts.
21. Search mode capability detection validated: lexical/hybrid/semantic correctly enabled/disabled based on fixture capabilities (FTS index presence, embedding coverage threshold).
22. Cancel latency test: sync cancel acknowledged p95 < 250ms, sync stop p95 < 2s under M-tier fixtures.
23. Single-instance lock enforced: second TUI launch attempt yields clear error message and non-zero exit.
24. Sync stream stats are emitted and rendered; terminal events (completed/failed/cancelled) delivery is 100% under induced backpressure.
25. Entity cache provides near-instant reopen for Issue/MR detail views during Enter/Esc drill-in/out workflows; cache invalidated on sync completion.
26. Concurrent pagination/write race test proves no duplicate or skipped rows within a pinned browse snapshot fence under concurrent sync writes.
27. Cancellation race test proves no cross-task interrupt bleed and no stuck loading state after rapid cancel-then-resubmit sequences.

**Performance SLO rationale:** Interactive TUI responsiveness requires sub-100ms for list operations and sub-250ms for search. Tiered fixtures catch scaling regressions at different data magnitudes — a query that's fast at 10k rows may degrade at 100k without proper indexing or pagination. Memory ceilings prevent unbounded growth from large in-memory result sets. These targets are validated with synthetic SQLite fixtures during Phase 0 and enforced as CI benchmark gates thereafter. Required indexes are documented and migration-backed before TUI GA.

#### 9.3.1 Required Indexes (GA Blocker)

The following covering indexes must be present before TUI ships. They are added via migration and verified by EXPLAIN QUERY PLAN CI checks:

```sql
-- Keyset pagination for issue list (default sort: updated_at DESC)
CREATE INDEX IF NOT EXISTS idx_issues_list_default
    ON issues(project_id, state, updated_at DESC, iid DESC);

-- Keyset pagination for MR list (default sort: updated_at DESC)
CREATE INDEX IF NOT EXISTS idx_mrs_list_default
    ON merge_requests(project_id, state, updated_at DESC, iid DESC);

-- Discussion list for entity detail views
CREATE INDEX IF NOT EXISTS idx_discussions_entity
    ON discussions(project_id, entity_type, entity_iid, created_at DESC);

-- Notes within a discussion (chronological)
CREATE INDEX IF NOT EXISTS idx_notes_discussion
    ON notes(discussion_id, created_at ASC);

-- Filter-path indexes: cover common TUI filter combinations beyond default sort.
-- These prevent full scans when users filter by author, assignee, reviewer, etc.
-- Exact column names TBD pending schema audit — the principle is that each
-- user-facing filter dimension used in list screens must have an index that
-- avoids SCAN TABLE under M-tier data volumes. At minimum:
--   issues: author + state, assignee + state
--   merge_requests: author + state, reviewer + state, target_branch + state
-- Index definitions will be finalized during Phase 2 (Core Screens) when
-- the actual query patterns are built, and validated by EXPLAIN QUERY PLAN CI.
```

**CI enforcement:** A dedicated test runs EXPLAIN QUERY PLAN against the top 10 TUI queries on a synthetic fixture and parses the query plan rows structurally rather than relying on brittle `SCAN TABLE` string matching (SQLite planner text varies by version). The test asserts:
- Top-level loop uses expected index families for each query
- No full scan on primary entity tables (issues, merge_requests, discussions, notes) under default filters AND the top 8 user-facing filter combinations (state, author, assignee, reviewer, label, target_branch, milestone, since)
- Join order remains bounded (no accidental cartesian expansions)
This prevents accidental index regressions while being robust across SQLite versions.

If any criterion fails, we stop and evaluate whether the issue is fixable or fundamental.

---

## 10. Code Changes Required

### 10.1 New Files

```
# Workspace infrastructure
crates/lore-tui/Cargo.toml            # TUI binary crate with FrankenTUI deps (nightly)
crates/lore-tui/rust-toolchain.toml    # Nightly pin (isolated to TUI crate)
crates/lore-tui/src/main.rs            # Binary entry point: CLI arg parsing, delegates to lib
crates/lore-tui/src/lib.rs             # Public API: launch_tui(), launch_sync_tui()

# TUI module (inside crates/lore-tui/src/)
crates/lore-tui/src/app.rs             # LoreApp impl Model
crates/lore-tui/src/message.rs         # Msg enum + EntityKey + AppError
crates/lore-tui/src/navigation.rs      # NavigationStack
crates/lore-tui/src/theme.rs           # ftui Theme config
crates/lore-tui/src/action.rs          # Query bridge functions (uses lore core)
crates/lore-tui/src/db_manager.rs      # DbManager: closure-based read pool (with_reader) + dedicated writer (with_writer). Prevents lock-poison panics and accidental long-held guards.
crates/lore-tui/src/task_supervisor.rs # TaskSupervisor: unified submit() → TaskHandle API with dedup, cancellation, generation IDs, and priority lanes
crates/lore-tui/src/entity_cache.rs    # Bounded LRU cache for IssueDetail/MrDetail payloads. Keyed by EntityKey. Selective invalidation by changed EntityKey set (not blanket invalidate_all). Optional post-sync prewarm of top changed entities. Enables near-instant reopen during Enter/Esc drill-in/out workflows.
crates/lore-tui/src/render_cache.rs    # Width/theme/content-hash keyed cache for expensive render artifacts (markdown → styled text, discussion tree shaping). Prevents per-frame recomputation.
crates/lore-tui/src/filter_dsl.rs      # Typed filter bar DSL parser: quoted values, negation prefix, field:value syntax, inline diagnostics with cursor position. Replaces brittle split_whitespace() parsing.
crates/lore-tui/src/progress_coalescer.rs # Per-lane progress event coalescer. Batches progress updates at <=30Hz per lane key (project x resource_type) to reduce render pressure during sync.
crates/lore-tui/src/sync_delta_ledger.rs # In-memory per-run exact changed/new entity IDs (issues, MRs, discussions). Populated from SyncCompleted result. Used by Sync Summary mode for exact "what changed" navigation without new DB tables. Cleared on next sync run start.
crates/lore-tui/src/scope.rs           # Global project scope context (AllProjects or pinned project set). Flows through all query bridge functions. Persisted in session state. `P` keybinding opens scope picker overlay.
crates/lore-tui/src/crash_context.rs   # Ring buffer of last 2000 normalized events + current screen/task/build snapshot. Captured by panic hook for post-mortem crash diagnostics with retention policy (latest 20 files).
crates/lore-tui/src/safety.rs          # sanitize_for_terminal(), safe_url_policy()
crates/lore-tui/src/redact.rs          # redact_sensitive(): strip tokens, Authorization headers, and credential patterns from logs and crash reports before persisting
crates/lore-tui/src/commands.rs        # CommandRegistry: single source of truth for all actions, keybindings, CLI equivalents, palette entries, help text, and status hints
crates/lore-tui/src/clock.rs           # Clock trait: SystemClock (production) / FakeClock (tests) for deterministic timestamps
crates/lore-tui/src/instance_lock.rs   # Single-instance advisory lock file with stale-lock recovery and clear error on collision
crates/lore-tui/src/session.rs         # Versioned session state persistence + atomic write (tmp->fsync->rename) + CRC32 checksum + max-size guard (1MB) + corruption quarantine
crates/lore-tui/src/text_width.rs      # Grapheme-safe display width: truncate_display_width(), pad_display_width(), measure_display_width() using unicode-width + unicode-segmentation crates. All column fitting, table alignment, and text truncation uses terminal display width (not bytes or chars) with grapheme cluster boundaries. Handles CJK (2-cell), emoji ZWJ sequences, skin tone modifiers, flag sequences, combining marks, and RTL text correctly. Never splits a grapheme cluster mid-sequence.
crates/lore-tui/src/state/mod.rs       # AppState
crates/lore-tui/src/state/dashboard.rs # Dashboard data
crates/lore-tui/src/state/issue_list.rs # Issue list state
crates/lore-tui/src/state/issue_detail.rs # Issue detail state
crates/lore-tui/src/state/mr_list.rs   # MR list state
crates/lore-tui/src/state/mr_detail.rs # MR detail state
crates/lore-tui/src/state/search.rs    # Search state
crates/lore-tui/src/state/timeline.rs  # Timeline state
crates/lore-tui/src/state/who.rs       # Who state
crates/lore-tui/src/state/sync.rs      # Sync state (running + summary modes)
crates/lore-tui/src/state/command_palette.rs # Palette state
crates/lore-tui/src/view/mod.rs        # View router
crates/lore-tui/src/view/dashboard.rs  # Dashboard render
crates/lore-tui/src/view/issue_list.rs # Issue list render
crates/lore-tui/src/view/issue_detail.rs # Issue detail render
crates/lore-tui/src/view/mr_list.rs    # MR list render
crates/lore-tui/src/view/mr_detail.rs  # MR detail render
crates/lore-tui/src/view/search.rs     # Search render
crates/lore-tui/src/view/timeline.rs   # Timeline render
crates/lore-tui/src/view/who.rs        # Who render
crates/lore-tui/src/view/sync.rs       # Sync render (inline mode + summary)
crates/lore-tui/src/view/command_palette.rs # Palette overlay
crates/lore-tui/src/view/common/mod.rs # Shared helpers
crates/lore-tui/src/view/common/filter_bar.rs # Filter input
crates/lore-tui/src/view/common/entity_table.rs # Sortable table
crates/lore-tui/src/view/common/discussion_tree.rs # Tree wrapper
crates/lore-tui/src/view/common/cross_ref.rs # Entity link
```

### 10.2 Modified Files

```
Cargo.toml                             # Keep lore-tui EXCLUDED from workspace (no dep, no feature)
src/cli/mod.rs                         # Add Tui command variant + resolve_tui_binary()
src/main.rs                            # Add Tui match arm (runtime delegation, no feature gate)
src/cli/commands/list.rs               # Extract query_issues(), query_mrs() as pub fns
src/cli/commands/show.rs               # Extract query_issue_detail(), query_mr_detail() as pub fns
src/cli/commands/who.rs                # Extract query_experts(), etc. as pub fns
src/cli/commands/search.rs             # Extract run_search_query() as pub fn
```

**Query extraction strategy:** The TUI's `action.rs` bridges to existing CLI query functions via the `lore` core library. Rather than creating a separate `src/domain/query/` directory (premature at this stage), we make the existing `query_*` functions in `cli/commands/*.rs` public and call them from `action.rs`. This avoids code duplication while keeping the refactoring scope minimal. If both CLI and TUI diverge significantly in their query needs, a shared domain query layer can be extracted later — but we don't pay that cost until it's needed.

### 10.3 Entry Point

The `lore-tui` crate has both a `lib.rs` (public API) and a `main.rs` (binary entry point). The `lore` CLI spawns the `lore-tui` binary; the binary parses its own CLI args and calls the library API.

```rust
// crates/lore-tui/src/main.rs — binary entry point

use clap::Parser;
use lore::core::config::Config;
use lore::core::db;

#[derive(Parser)]
#[command(name = "lore-tui", about = "Interactive TUI for gitlore")]
struct TuiCli {
    /// Path to config file
    #[arg(long)]
    config: Option<String>,

    /// Start directly on sync screen (inline mode)
    #[arg(long)]
    sync: bool,

    /// Start without restoring prior session state
    #[arg(long)]
    fresh: bool,

    /// Rendering mode: auto, full, minimal
    #[arg(long, default_value = "auto")]
    render_mode: String,

    /// Force ASCII-safe glyphs
    #[arg(long)]
    ascii: bool,

    /// Stay in normal terminal (no alternate screen)
    #[arg(long)]
    no_alt_screen: bool,
}

fn main() -> anyhow::Result<()> {
    let cli = TuiCli::parse();
    let config = Config::load(cli.config.as_deref())?;
    let db_path = db::resolve_db_path(&config)?;

    let opts = lore_tui::LaunchOptions {
        sync_mode: cli.sync,
        fresh: cli.fresh,
        render_mode: cli.render_mode,
        ascii: cli.ascii,
        no_alt_screen: cli.no_alt_screen,
    };

    if cli.sync {
        lore_tui::launch_sync_tui(config, &db_path)?;
    } else {
        lore_tui::launch_tui(config, &db_path, &opts)?;
    }

    Ok(())
}
```

```rust
// crates/lore-tui/src/lib.rs — library API

mod app;
mod message;
mod navigation;
mod theme;
mod action;
mod db_manager;
mod task_supervisor;
mod safety;
mod session;
mod state;
mod view;

use std::path::Path;
use ftui_runtime::program::App;
use lore::core::config::Config;
use lore::core::error::LoreError;
use db_manager::DbManager;

/// Default read pool size for TUI queries.
const DEFAULT_READER_COUNT: usize = 3;

/// Options for launching the TUI (parsed from CLI args by the binary).
pub struct LaunchOptions {
    pub sync_mode: bool,
    pub fresh: bool,
    pub render_mode: String,
    pub ascii: bool,
    pub no_alt_screen: bool,
}

pub fn launch_tui(config: Config, db_path: &Path, opts: &LaunchOptions) -> Result<(), LoreError> {
    // Install a panic hook that restores the terminal before printing the
    // crash report. Without this, a panic leaves the terminal in raw mode
    // with no cursor, making the user's shell unusable. The hook writes
    // a crash dump to ~/.local/share/lore/crash-<timestamp>.log containing:
    //   - Panic message and backtrace
    //   - Last 2000 normalized input events (ring buffer snapshot)
    //   - Current screen and navigation stack
    //   - In-flight task keys and their generations
    // This event trace enables deterministic crash reproduction without
    // requiring the user to remember what they were doing. The hook also
    // prints a fallback CLI command suggestion so the user isn't blocked.
    install_panic_hook_for_tui();

    let db = DbManager::new(db_path, DEFAULT_READER_COUNT)?;
    let lore_app = app::LoreApp::new(config, db);

    // FrankenTUI handles ALL terminal lifecycle:
    // - raw mode enable/disable
    // - alternate screen enter/leave (AltScreen mode)
    // - panic recovery (RAII via TerminalSession)
    // - event polling thread
    // - buffer double-buffering and cell-level diffing
    App::fullscreen(lore_app)
        .run()
        .map_err(|e| LoreError::Io(std::io::Error::new(
            std::io::ErrorKind::Other,
            e.to_string(),
        )))
}

/// Launch sync in inline mode (preserves terminal scrollback).
/// Used by `lore sync --tui` to show progress without alternate screen.
pub fn launch_sync_tui(config: Config, db_path: &Path) -> Result<(), LoreError> {
    let db = DbManager::new(db_path, DEFAULT_READER_COUNT)?;
    let mut lore_app = app::LoreApp::new(config, db);
    lore_app.navigation.reset_to(message::Screen::Sync);

    // Inline mode: UI occupies 12 rows at bottom, terminal scrollback preserved.
    // After TUI exits, sync log remains visible in scrollback.
    App::inline(lore_app, 12)
        .run()
        .map_err(|e| LoreError::Io(std::io::Error::new(
            std::io::ErrorKind::Other,
            e.to_string(),
        )))
}

/// Install a panic hook that restores the terminal and writes a crash report.
/// This is critical for TUI applications — without it, a panic leaves the
/// terminal in raw mode with the alternate screen active.
fn install_panic_hook_for_tui() {
    let default_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |panic_info| {
        // 1. Restore terminal state (best-effort)
        let _ = crossterm::terminal::disable_raw_mode();
        let _ = crossterm::execute!(
            std::io::stderr(),
            crossterm::terminal::LeaveAlternateScreen,
            crossterm::cursor::Show,
        );

        // 2. Capture crash context snapshot (ring buffer + state)
        let ctx = crate::crash_context::snapshot();
        let crash_dir = dirs::data_local_dir()
            .unwrap_or_else(|| std::path::PathBuf::from("."))
            .join("lore");
        let _ = std::fs::create_dir_all(&crash_dir);
        let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
        let crash_path = crash_dir.join(format!("crash-{timestamp}.log"));
        // Redact sensitive values (tokens, Authorization headers, credentials)
        // before persisting crash report to disk. Prevents accidental credential
        // leakage in reports shared for debugging.
        let report = crate::redact::redact_sensitive(&format!(
            "{panic_info:#?}\n\n--- Crash Context ---\n\
             Build: {}\n\
             Screen: {:?}\n\
             Nav stack: {:?}\n\
             Active tasks: {:?}\n\
             DB fingerprint: {:?}\n\n\
             --- Last {} events ---\n{:#?}",
            env!("CARGO_PKG_VERSION"),
            ctx.current_screen,
            ctx.nav_stack,
            ctx.task_handles,
            ctx.db_fingerprint,
            ctx.events.len(),
            ctx.events,
        ));
        let _ = std::fs::write(&crash_path, report);

        // 3. Retention: keep latest 20 crash files, delete oldest
        if let Ok(entries) = std::fs::read_dir(&crash_dir) {
            let mut crash_files: Vec<_> = entries
                .filter_map(|e| e.ok())
                .filter(|e| e.file_name().to_string_lossy().starts_with("crash-"))
                .collect();
            crash_files.sort_by_key(|e| e.file_name());
            while crash_files.len() > 20 {
                if let Some(oldest) = crash_files.first() {
                    let _ = std::fs::remove_file(oldest.path());
                }
                crash_files.remove(0);
            }
        }

        // 4. Print user-friendly message with fallback
        eprintln!("\nlore TUI crashed. Terminal has been restored.");
        eprintln!("Crash report: {}", crash_path.display());
        eprintln!("\nFallback: use `lore --robot <command>` for CLI access.");

        // 5. Call default hook (prints backtrace if RUST_BACKTRACE=1)
        default_hook(panic_info);
    }));
}
```

### 10.4 Theme Configuration

```rust
// crates/lore-tui/src/theme.rs

use ftui_style::{
    AdaptiveColor, Color, Rgb, Theme, ThemeBuilder,
    contrast_ratio, best_text_color, meets_wcag_aa,
};

/// Build the gitlore TUI theme using FrankenTUI's semantic color slots.
///
/// The Theme struct has 19 semantic slots. We set each with AdaptiveColor
/// (light/dark variants). FrankenTUI's runtime detects terminal background
/// brightness and resolves the correct variant.
pub fn build_theme() -> Theme {
    Theme::builder()
        .primary(AdaptiveColor::adaptive(
            Color::rgb(0, 100, 200),       // light: blue accent
            Color::rgb(100, 150, 255),     // dark: brighter blue
        ))
        .secondary(AdaptiveColor::adaptive(
            Color::rgb(80, 80, 80),
            Color::rgb(180, 180, 180),
        ))
        .accent(AdaptiveColor::adaptive(
            Color::rgb(0, 130, 200),
            Color::rgb(80, 180, 255),
        ))
        .background(AdaptiveColor::adaptive(
            Color::rgb(255, 255, 255),
            Color::rgb(20, 20, 20),        // near-black for dark mode
        ))
        .surface(AdaptiveColor::adaptive(
            Color::rgb(245, 245, 245),
            Color::rgb(30, 30, 30),
        ))
        .overlay(AdaptiveColor::adaptive(
            Color::rgb(0, 0, 0),           // dark overlay on light
            Color::rgb(0, 0, 0),           // dark overlay on dark
        ))
        .text(AdaptiveColor::adaptive(
            Color::rgb(30, 30, 30),
            Color::rgb(220, 220, 220),
        ))
        .text_muted(AdaptiveColor::adaptive(
            Color::rgb(100, 100, 100),
            Color::rgb(128, 128, 128),
        ))
        .text_subtle(AdaptiveColor::adaptive(
            Color::rgb(160, 160, 160),
            Color::rgb(80, 80, 80),
        ))
        .success(AdaptiveColor::fixed(Color::rgb(50, 180, 80)))
        .warning(AdaptiveColor::fixed(Color::rgb(220, 180, 50)))
        .error(AdaptiveColor::fixed(Color::rgb(220, 50, 50)))
        .info(AdaptiveColor::fixed(Color::rgb(80, 160, 220)))
        .border(AdaptiveColor::adaptive(
            Color::rgb(200, 200, 200),
            Color::rgb(60, 60, 60),
        ))
        .border_focused(AdaptiveColor::adaptive(
            Color::rgb(0, 100, 200),
            Color::rgb(100, 150, 255),
        ))
        .selection_bg(AdaptiveColor::adaptive(
            Color::rgb(200, 220, 255),
            Color::rgb(50, 80, 140),
        ))
        .selection_fg(AdaptiveColor::adaptive(
            Color::rgb(0, 0, 0),
            Color::rgb(255, 255, 255),
        ))
        .build()
}

/// GitLab entity state → color mapping.
/// Returns Rgb for use with WCAG contrast checks.
pub fn state_color(state: &str) -> Rgb {
    match state {
        "opened" => Rgb::new(50, 180, 80),    // green
        "closed" => Rgb::new(220, 50, 50),    // red
        "merged" => Rgb::new(130, 80, 220),   // purple
        "locked" => Rgb::new(220, 180, 50),   // amber
        _ => Rgb::new(128, 128, 128),         // gray
    }
}

/// Timeline event type → color mapping.
pub fn event_type_color(event_type: &str) -> Rgb {
    match event_type {
        "created" | "Created" => Rgb::new(50, 180, 80),    // green
        "state_changed" => Rgb::new(220, 180, 50),         // amber
        "label_added" | "label_removed" => Rgb::new(80, 180, 220), // cyan
        "note_evidence" => Rgb::new(200, 200, 200),        // light gray
        "cross_referenced" => Rgb::new(180, 80, 220),      // purple
        "merged" | "Merged" => Rgb::new(50, 220, 80),      // bright green
        _ => Rgb::new(128, 128, 128),
    }
}

/// Resolve a GitLab hex label color (e.g., "#428BCA") to a Style with
/// WCAG-compliant foreground text.
pub fn label_style(hex_color: &str) -> Style {
    let bg = parse_hex_to_rgb(hex_color);
    let fg = best_text_color(bg, &[Rgb::new(0, 0, 0), Rgb::new(255, 255, 255)]);
    Style::new().fg(fg).bg(bg)
}

fn parse_hex_to_rgb(hex: &str) -> Rgb {
    let hex = hex.trim_start_matches('#');
    if hex.len() != 6 {
        return Rgb::new(128, 128, 128); // fallback gray
    }
    let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(128);
    let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(128);
    let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(128);
    Rgb::new(r, g, b)
}
```

### 10.4.1 Terminal Safety — Untrusted Text Sanitization

Issue/MR bodies, discussion notes, and log output are **untrusted text** rendered in a terminal context. Without sanitization, embedded ANSI escape sequences, OSC commands, or control characters could spoof TUI elements, trigger unintended terminal behavior, or exfiltrate data via OSC title-set sequences.

```rust
// crates/lore-tui/src/safety.rs

/// Strip ANSI escape sequences, OSC commands, control characters,
/// and bidi override/spoof controls from untrusted text before rendering in the TUI.
///
/// Preserves legitimate Unicode rendering characters:
/// - ZWJ (U+200D) — required for emoji sequences (family, skin tone, flags)
/// - ZWNJ (U+200C) — required for correct rendering in some scripts (Arabic, Indic)
/// - Combining marks and variation selectors — preserved for correct grapheme display
///
/// This is a critical security boundary: all user-sourced text from
/// GitLab (issue bodies, note content, MR descriptions, discussion text)
/// MUST pass through this function before widget rendering.
///
/// Bidi controls can spoof displayed content by reordering rendered text,
/// making "safe-looking" strings actually contain reversed malicious content.
pub fn sanitize_for_terminal(input: &str) -> String {
    let mut output = String::with_capacity(input.len());
    let mut chars = input.chars().peekable();

    while let Some(c) = chars.next() {
        match c {
            // Strip ESC sequences (ANSI CSI, OSC, etc.)
            '\x1b' => {
                // Consume the entire escape sequence
                if let Some(&next) = chars.peek() {
                    match next {
                        '[' => {
                            // CSI sequence: ESC [ ... final_byte
                            chars.next();
                            while let Some(&c) = chars.peek() {
                                chars.next();
                                if c.is_ascii_alphabetic() || c == '@' { break; }
                            }
                        }
                        ']' => {
                            // OSC sequence: ESC ] ... ST (BEL or ESC \)
                            chars.next();
                            while let Some(&c) = chars.peek() {
                                chars.next();
                                if c == '\x07' { break; }  // BEL terminates
                                if c == '\x1b' {           // ESC \ terminates
                                    if chars.peek() == Some(&'\\') { chars.next(); }
                                    break;
                                }
                            }
                        }
                        _ => { chars.next(); } // Other ESC sequences
                    }
                }
            }
            // Strip bidi override/embedding controls (Unicode spoofing vectors)
            '\u{202A}' // LRE  Left-to-Right Embedding
            | '\u{202B}' // RLE  Right-to-Left Embedding
            | '\u{202C}' // PDF  Pop Directional Formatting
            | '\u{202D}' // LRO  Left-to-Right Override
            | '\u{202E}' // RLO  Right-to-Left Override
            | '\u{2066}' // LRI  Left-to-Right Isolate
            | '\u{2067}' // RLI  Right-to-Left Isolate
            | '\u{2068}' // FSI  First Strong Isolate
            | '\u{2069}' // PDI  Pop Directional Isolate
            => {}
            // Strip C1 control block (U+0080..U+009F) — terminal control codes
            // that can cause rendering corruption or be used for injection.
            c if ('\u{0080}'..='\u{009F}').contains(&c) => {}
            // Strip directional marks (can spoof displayed content direction)
            '\u{200E}' // LRM  Left-to-Right Mark
            | '\u{200F}' // RLM  Right-to-Left Mark
            | '\u{061C}' // ALM  Arabic Letter Mark
            => {}
            // Strip invisible controls that serve no rendering purpose.
            // NOTE: ZWJ (U+200D) and ZWNJ (U+200C) are intentionally PRESERVED —
            // they are required for correct emoji and script rendering.
            '\u{200B}' // Zero Width Space (no rendering purpose in terminal)
            | '\u{FEFF}' // BOM / Zero Width No-Break Space
            | '\u{00AD}' // Soft Hyphen
            => {}
            // Strip other control characters (except newline, tab)
            c if c.is_control() && c != '\n' && c != '\t' => {}
            // Allow everything else (including ZWJ, ZWNJ, combining marks, emoji)
            c => output.push(c),
        }
    }

    output
}

/// Classify a URL's safety level against the configured GitLab origin(s) and
/// known entity path patterns before opening in browser.
/// Returns tri-state: AllowedEntityPath (open immediately), AllowedButUnrecognizedPath
/// (prompt user to confirm), or Blocked (refuse to open).
pub fn classify_safe_url(url: &str, policy: &UrlPolicy) -> UrlSafety {
    let Ok(parsed) = url::Url::parse(url) else { return UrlSafety::Blocked };

    // Only allow HTTPS
    if parsed.scheme() != "https" { return UrlSafety::Blocked; }

    // Normalize host (lowercase, IDNA-compatible) and match scheme+host+port
    let Some(host) = parsed.host_str() else { return UrlSafety::Blocked; };
    let host_lower = host.to_ascii_lowercase();
    let port = parsed.port_or_known_default();

    let origin_match = policy.allowed_origins.iter().any(|origin| {
        origin.scheme == "https"
            && origin.host == host_lower
            && origin.port == port
    });

    if !origin_match {
        return UrlSafety::Blocked;
    }

    // Check path against known GitLab entity patterns
    let path = parsed.path();
    if policy.entity_path_patterns.iter().any(|pat| pat.matches(path)) {
        UrlSafety::AllowedEntityPath
    } else {
        UrlSafety::AllowedButUnrecognizedPath
    }
}

/// Tri-state URL safety classification.
#[derive(Debug, Clone, PartialEq)]
pub enum UrlSafety {
    /// Known GitLab entity path — open immediately without prompt.
    AllowedEntityPath,
    /// Same host but unrecognized path — show confirmation modal before opening.
    AllowedButUnrecognizedPath,
    /// Different host, wrong scheme, or parse failure — refuse to open.
    Blocked,
}

/// URL validation policy: allowed origins + known GitLab entity path patterns.
pub struct UrlPolicy {
    pub allowed_origins: Vec<AllowedOrigin>,
    /// Path patterns for known GitLab entity routes (e.g., `/-/issues/`, `/-/merge_requests/`).
    pub entity_path_patterns: Vec<PathPattern>,
}

/// Typed origin for URL validation (scheme + normalized host + port).
pub struct AllowedOrigin {
    pub scheme: String,
    pub host: String,     // lowercase, IDNA-normalized
    pub port: Option<u16>,
}

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

    #[test]
    fn strips_ansi_color_codes() {
        assert_eq!(
            sanitize_for_terminal("\x1b[31mred text\x1b[0m"),
            "red text",
        );
    }

    #[test]
    fn strips_osc_title_set() {
        assert_eq!(
            sanitize_for_terminal("\x1b]0;malicious title\x07normal text"),
            "normal text",
        );
    }

    #[test]
    fn preserves_newlines_and_tabs() {
        assert_eq!(
            sanitize_for_terminal("line1\n\tindented"),
            "line1\n\tindented",
        );
    }

    #[test]
    fn strips_control_chars() {
        assert_eq!(
            sanitize_for_terminal("hello\x00\x01\x02world"),
            "helloworld",
        );
    }

    #[test]
    fn strips_c1_controls() {
        // C1 control block U+0080..U+009F — terminal control codes
        assert_eq!(
            sanitize_for_terminal("before\u{0085}\u{008D}\u{009F}after"),
            "beforeafter",
        );
    }

    #[test]
    fn strips_lrm_rlm_alm() {
        // Directional marks that can spoof displayed content direction
        assert_eq!(
            sanitize_for_terminal("hello\u{200E}\u{200F}\u{061C}world"),
            "helloworld",
        );
    }
}
```

**Enforcement rule:** All user-sourced text MUST pass through `sanitize_for_terminal()` before being rendered by any widget. This includes:
- Issue titles and bodies
- MR titles and descriptions
- Discussion note content
- Timeline event descriptions
- Search result snippets
- Author names and labels
- Markdown link destinations (scheme + origin validation via `safe_url_policy()` before render/open) (could contain injected escape sequences in GitLab)

The sanitization is applied in `action.rs` at the query boundary (not in view code) to prevent accidental bypass.

### 10.5 Dashboard View (FrankenTUI Native)

```rust
// crates/lore-tui/src/view/dashboard.rs

use ftui::prelude::*;
use ftui::layout::{Flex, Grid, Constraint, Responsive, Breakpoint, Breakpoints};
use ftui::widgets::{Badge, List, Paragraph, Panel};
use ftui::text::{Line, Span};
use crate::tui::state::dashboard::DashboardState;
use crate::tui::theme;

/// Project-wide breakpoint thresholds (reused across all views).
pub const LORE_BREAKPOINTS: Breakpoints = Breakpoints::new(60, 90, 120);

pub fn render(state: &DashboardState, frame: &mut Frame, area: Rect) {
    let bp = LORE_BREAKPOINTS.classify_width(area.width);

    match bp {
        Breakpoint::Xs | Breakpoint::Sm => render_single_column(state, frame, area),
        Breakpoint::Md => render_two_column(state, frame, area),
        Breakpoint::Lg | Breakpoint::Xl => render_three_column(state, frame, area),
    }
}

fn render_single_column(state: &DashboardState, frame: &mut Frame, area: Rect) {
    // Narrow: stack all panels vertically
    let layout = Flex::vertical()
        .constraints([
            Constraint::Percentage(25.0),  // projects
            Constraint::Percentage(35.0),  // stats
            Constraint::Percentage(40.0),  // recent + sync
        ])
        .split(area);

    render_projects(state, frame, layout[0]);
    render_stats(state, frame, layout[1]);
    render_recent(state, frame, layout[2]);
}

fn render_two_column(state: &DashboardState, frame: &mut Frame, area: Rect) {
    let grid = Grid::new()
        .rows([Constraint::Percentage(50.0), Constraint::Percentage(50.0)])
        .columns([Constraint::Percentage(40.0), Constraint::Percentage(60.0)]);
    let gl = grid.split(area);

    render_projects(state, frame, gl.cell(0, 0));
    render_stats(state, frame, gl.cell(0, 1));
    render_recent(state, frame, gl.cell(1, 0));
    render_sync_info(state, frame, gl.cell(1, 1));
}

fn render_three_column(state: &DashboardState, frame: &mut Frame, area: Rect) {
    let grid = Grid::new()
        .rows([Constraint::Percentage(50.0), Constraint::Percentage(50.0)])
        .columns([
            Constraint::Percentage(30.0),
            Constraint::Percentage(35.0),
            Constraint::Percentage(35.0),
        ]);
    let gl = grid.split(area);

    render_projects(state, frame, gl.cell(0, 0));
    render_stats(state, frame, gl.cell(0, 1));
    render_recent(state, frame, gl.cell(0, 2));
    render_sync_info(state, frame, gl.span(1, 0, 1, 3)); // full bottom row
}

fn render_projects(state: &DashboardState, frame: &mut Frame, area: Rect) {
    let items: Vec<Line> = state.projects.iter().map(|p| {
        let (badge_text, badge_color) = if p.minutes_since_sync < 10 {
            ("OK", theme::state_color("opened"))
        } else if p.minutes_since_sync < 120 {
            ("STALE", theme::state_color("locked"))
        } else {
            ("OLD", theme::state_color("closed"))
        };

        let age = format_relative_time(p.minutes_since_sync);

        Line::from(vec![
            Span::raw("  "),
            Span::styled(badge_text, Style::default().fg(badge_color)),
            Span::raw(" "),
            Span::raw(&p.path),
            Span::raw("  "),
            Span::styled(age, dim()),
        ])
    }).collect();

    let list = List::new(items);
    let panel = Panel::new(list)
        .title(format!(" Projects ({}) ", state.projects.len()));
    frame.render_widget(panel, area);
}

fn render_stats(state: &DashboardState, frame: &mut Frame, area: Rect) {
    let c = &state.counts;
    let lines = vec![
        stat_line("Issues", c.issues_open, "open", c.issues_total, "total", "opened"),
        stat_line("MRs", c.mrs_open, "open", c.mrs_total, "total", "opened"),
        Line::from(vec![
            Span::styled("  Discussions: ", dim()),
            Span::styled(format!("{} threads", c.discussions), normal()),
        ]),
        Line::from(vec![
            Span::styled("  Notes:       ", dim()),
            Span::styled(format!("{}", c.notes_total), normal()),
            Span::styled(format!(" ({}% system)", c.notes_system_pct), dim()),
        ]),
        Line::from(vec![
            Span::styled("  Documents:   ", dim()),
            Span::styled(format!("{} indexed", c.documents), normal()),
        ]),
        Line::from(vec![
            Span::styled("  Embeddings:  ", dim()),
            Span::styled(format!("{} vectors", c.embeddings), normal()),
        ]),
    ];

    let para = Paragraph::new(lines);
    let panel = Panel::new(para).title(" Quick Stats ");
    frame.render_widget(panel, area);
}

fn render_recent(state: &DashboardState, frame: &mut Frame, area: Rect) {
    let items: Vec<Line> = state.recent.iter().map(|item| {
        let prefix = match item.entity_type.as_str() {
            "issue" => "#",
            "mr" => "!",
            _ => "",
        };
        Line::from(vec![
            Span::styled(
                format!("  {}{:<5} ", prefix, item.iid),
                Style::default().fg(theme::state_color(&item.state)),
            ),
            Span::raw(truncate(&item.title, 30)),
            Span::styled(
                format!("  {}", format_relative_time(item.minutes_ago)),
                dim(),
            ),
        ])
    }).collect();

    let list = List::new(items);
    let panel = Panel::new(list).title(" Recent Activity ");
    frame.render_widget(panel, area);
}

fn render_sync_info(state: &DashboardState, frame: &mut Frame, area: Rect) {
    let sync = &state.last_sync;
    let lines = vec![
        Line::from(vec![
            Span::styled("  Last Sync:  ", dim()),
            Span::styled(
                format_relative_time(sync.minutes_ago),
                normal(),
            ),
        ]),
        Line::from(vec![
            Span::styled("  Duration:   ", dim()),
            Span::styled(&sync.duration_display, normal()),
        ]),
        Line::from(vec![
            Span::styled("  Issues:     ", dim()),
            Span::styled(
                format!("+{} new, {} updated", sync.new_issues, sync.updated_issues),
                normal(),
            ),
        ]),
        Line::from(vec![
            Span::styled("  MRs:        ", dim()),
            Span::styled(
                format!("+{} new, {} updated", sync.new_mrs, sync.updated_mrs),
                normal(),
            ),
        ]),
        Line::from(vec![
            Span::styled("  Errors:     ", dim()),
            Span::styled(
                format!("{}", sync.errors),
                if sync.errors > 0 {
                    Style::default().fg(Color::rgb(220, 50, 50))
                } else { normal() },
            ),
        ]),
    ];

    let para = Paragraph::new(lines);
    let panel = Panel::new(para).title(" Last Sync ");
    frame.render_widget(panel, area);
}

fn stat_line(
    label: &str, active: u64, active_label: &str,
    total: u64, total_label: &str, color_key: &str,
) -> Line<'static> {
    Line::from(vec![
        Span::styled(format!("  {:<12}", format!("{}:", label)), dim()),
        Span::styled(
            format!("{} {}", active, active_label),
            Style::default().fg(theme::state_color(color_key)),
        ),
        Span::styled(format!(" / {} {}", total, total_label), dim()),
    ])
}

/// All truncation uses `crate::text_width::truncate_display_width()` — terminal
/// display width (via unicode-width), not byte/char length. Handles CJK, emoji,
/// combining marks, and zero-width joiners correctly for column alignment.
fn truncate(s: &str, max: usize) -> String {
    crate::text_width::truncate_display_width(s, max)
}

fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) }
fn normal() -> Style { Style::default().fg(Color::rgb(220, 220, 220)) }

fn format_relative_time(minutes: u64) -> String {
    if minutes < 60 { format!("{}m ago", minutes) }
    else if minutes < 1440 { format!("{}h ago", minutes / 60) }
    else { format!("{}d ago", minutes / 1440) }
}
```

### 10.6 Sync View — Inline Mode with Post-Sync Summary

```rust
// crates/lore-tui/src/view/sync.rs

use ftui::prelude::*;
use ftui::layout::{Flex, Constraint};
use ftui::widgets::{LogViewer, LogViewerState, Panel, Paragraph, ProgressBar};
use ftui::text::{Line, Span};
use crate::tui::state::sync::{SyncState, SyncPhase};

pub fn render(state: &mut SyncState, frame: &mut Frame, area: Rect) {
    match state.phase {
        SyncPhase::Idle | SyncPhase::Running => render_running(state, frame, area),
        SyncPhase::Summary => render_summary(state, frame, area),
    }
}

fn render_running(state: &mut SyncState, frame: &mut Frame, area: Rect) {
    let layout = Flex::vertical()
        .constraints([
            Constraint::Min(0),            // progress section (grows)
            Constraint::Fixed(1),          // separator
            Constraint::Percentage(40.0),  // log viewer
        ])
        .split(area);

    render_progress(state, frame, layout[0]);
    render_log(state, frame, layout[2]);
}

fn render_summary(state: &mut SyncState, frame: &mut Frame, area: Rect) {
    let layout = Flex::vertical()
        .constraints([
            Constraint::Fixed(9),   // change summary
            Constraint::Fixed(1),   // separator
            Constraint::Min(0),     // log viewer
        ])
        .split(area);

    // Change summary panel
    let summary = &state.summary;
    let lines = vec![
        Line::raw(""),
        Line::from(vec![
            Span::styled(format!("  +{} new issues", summary.new_issues), normal()),
            Span::styled(format!("       {} updated", summary.updated_issues), dim()),
            Span::styled("       (i to browse)", dim()),
        ]),
        Line::from(vec![
            Span::styled(format!("  +{} new MRs", summary.new_mrs), normal()),
            Span::styled(format!("          {} updated", summary.updated_mrs), dim()),
            Span::styled("       (m to browse)", dim()),
        ]),
        Line::from(vec![
            Span::styled(format!("  +{} new discussions", summary.new_discussions), dim()),
        ]),
        Line::from(vec![
            Span::styled(format!("  +{} new events", summary.new_events), dim()),
        ]),
        Line::from(vec![
            Span::styled(format!("    {} errors", summary.errors), if summary.errors > 0 {
                Style::default().fg(Color::rgb(220, 50, 50))
            } else {
                dim()
            }),
        ]),
        Line::raw(""),
    ];

    let para = Paragraph::new(lines);
    let panel = Panel::new(para)
        .title(format!(" Sync Complete -- {} ", state.elapsed_display()));
    frame.render_widget(panel, layout[0]);

    render_log(state, frame, layout[2]);
}

fn render_progress(state: &SyncState, frame: &mut Frame, area: Rect) {
    let mut lines = Vec::new();

    for project in &state.projects {
        lines.push(Line::from(Span::styled(
            format!("  {}", project.path),
            Style::default().add_modifier(Modifier::BOLD),
        )));

        for resource in &project.resources {
            lines.push(Line::from(Span::raw(format!(
                "    {:<14} {} {}",
                resource.kind_label(),
                progress_bar_ascii(resource.progress_pct(), 30),
                resource.delta_label(),
            ))));
        }
        lines.push(Line::raw(""));
    }

    let para = Paragraph::new(lines);
    let panel = Panel::new(para)
        .title(format!(
            " Sync -- {} -- {} ",
            state.status_label(),
            state.elapsed_display(),
        ));
    frame.render_widget(panel, area);
}

fn render_log(state: &mut SyncState, frame: &mut Frame, area: Rect) {
    // LogViewer is a stateful widget with separate LogViewerState.
    // SyncState owns both the LogViewer and its rendering state.
    //
    // Panel wraps the log viewer for a border + title. We compute the
    // inner area first, render the Panel chrome, then render the LogViewer
    // as a stateful widget into the inner region.
    let border_block = Panel::new(Paragraph::new("")).title(" Log ");
    let inner = border_block.inner(area);
    frame.render_widget(border_block, area);

    // LogViewer auto-scrolls to bottom by default. The user can scroll up
    // with j/k and auto-scroll resumes when they hit the bottom.
    frame.render_stateful_widget(
        &state.log_viewer,
        inner,
        &mut state.log_viewer_state,
    );
}

fn progress_bar_ascii(pct: u8, width: usize) -> String {
    let filled = (usize::from(pct) * width) / 100;
    let empty = width - filled;
    format!("{}{} {}%",
        "\u{2588}".repeat(filled),
        "\u{2591}".repeat(empty),
        pct,
    )
}

fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) }
fn normal() -> Style { Style::default().fg(Color::rgb(220, 220, 220)) }
```

### 10.7 Command Palette — Using Built-in Widget

```rust
// crates/lore-tui/src/state/command_palette.rs
//
// The CommandPalette widget is STATEFUL — it owns its own query, action list,
// selected index, and fuzzy matching. We wrap it and register all lore commands.

use ftui::widgets::command_palette::{CommandPalette, PaletteAction};
use ftui::widgets::modal::{Modal, ModalPosition, ModalState, ModalSizeConstraints, BackdropConfig};
use ftui::core::event::Event;

pub struct CommandPaletteState {
    pub palette: CommandPalette,
    pub modal_state: ModalState,
}

impl CommandPaletteState {
    pub fn new() -> Self {
        let mut palette = CommandPalette::new();

        // Register all lore commands with fuzzy-searchable metadata.
        // id is auto-generated from title (lowercase + underscores).
        palette.register("Issues", Some("Browse all issues"), &["list", "browse"]);
        palette.register("Issues (opened)", Some("Open issues only"), &["list", "filter"]);
        palette.register("Issues (closed)", Some("Closed issues only"), &["list", "filter"]);
        palette.register("Merge Requests", Some("Browse all MRs"), &["list", "mr"]);
        palette.register("MRs (opened)", Some("Open MRs only"), &["list", "mr", "filter"]);
        palette.register("MRs (merged)", Some("Merged MRs only"), &["list", "mr", "filter"]);
        palette.register("MRs (draft)", Some("Draft/WIP MRs"), &["list", "mr", "filter"]);
        palette.register("Search", Some("Full-text + semantic search"), &["find", "query"]);
        palette.register("Timeline", Some("Chronological event view"), &["history", "events"]);
        palette.register("Who (expert)", Some("Path-based expert ranking"), &["people", "expert"]);
        palette.register("Who (workload)", Some("Person workload summary"), &["people"]);
        palette.register("Who (active)", Some("Active unresolved discussions"), &["people"]);
        palette.register("Sync", Some("Incremental sync from GitLab"), &["fetch", "update"]);
        palette.register("Sync (full)", Some("Full sync with cursor reset"), &["fetch", "reset"]);
        palette.register("Doctor", Some("Health check"), &["diagnostics"]);
        palette.register("Stats", Some("Index statistics"), &["info"]);
        palette.register("Open in browser", Some("Open current entity in GitLab"), &["web"]);
        palette.register("Quit", Some("Exit TUI"), &["exit", "close"]);

        Self {
            palette,
            modal_state: ModalState::default(),
        }
    }

    pub fn is_visible(&self) -> bool {
        self.modal_state.is_open()
    }

    pub fn open(&mut self) {
        self.palette.open();
        self.modal_state.open();
    }

    pub fn close(&mut self) {
        self.palette.close();
        self.modal_state.close();
    }

    /// Delegate a terminal event to the palette.
    /// Returns Some(PaletteAction) if the user selected or dismissed.
    pub fn handle_event(&mut self, event: &Event) -> Option<PaletteAction> {
        self.palette.handle_event(event)
    }
}

// --- Rendering (in crates/lore-tui/src/view/command_palette.rs) ---

use ftui::prelude::*;

pub fn render(state: &CommandPaletteState, frame: &mut Frame, area: Rect) {
    if !state.is_visible() {
        return;
    }

    // Wrap palette in modal overlay with dimmed backdrop
    let size = ModalSizeConstraints::new()
        .min_width(40)
        .max_width((area.width as f32 * 0.6) as u16)
        .min_height(4)
        .max_height(15);

    let modal = Modal::new(&state.palette)
        .position(ModalPosition::TopCenter { margin: 3 })
        .backdrop(BackdropConfig::new(Color::rgb(0, 0, 0), 0.5))
        .size(size);

    frame.render_widget(modal, area);
}
```

### 10.8 Issue Detail — Using Built-in Tree for Discussions

```rust
// crates/lore-tui/src/view/issue_detail.rs

use ftui::prelude::*;
use ftui::layout::{Flex, Constraint};
use ftui::widgets::{Panel, Paragraph, Scrollbar, Tree, TreeNode};
use ftui::text::{Line, Span};
use ftui_extras::markdown::MarkdownRenderer;
use crate::tui::state::issue_detail::IssueDetailState;
use crate::tui::theme;

pub fn render(state: &IssueDetailState, frame: &mut Frame, area: Rect) {
    let Some(detail) = &state.detail else {
        frame.render_widget(Paragraph::new("Loading..."), area);
        return;
    };

    let layout = Flex::vertical()
        .constraints([
            Constraint::Fixed(4),          // header (title + metadata)
            Constraint::Percentage(30.0),  // description (markdown)
            Constraint::Min(0),            // discussions (tree)
            Constraint::Fixed(3),          // cross-references
        ])
        .split(area);

    render_header(detail, frame, layout[0]);
    render_description(detail, frame, layout[1]);
    render_discussions(state, frame, layout[2]);
    render_cross_refs(detail, frame, layout[3]);
}

fn render_header(detail: &IssueDetail, frame: &mut Frame, area: Rect) {
    let state_color = theme::state_color(&detail.state);
    let lines = vec![
        Line::from(vec![
            Span::styled(
                format!("#{} ", detail.iid),
                Style::default().fg(state_color).add_modifier(Modifier::BOLD),
            ),
            Span::styled(&detail.title, Style::default().add_modifier(Modifier::BOLD)),
            Span::styled(
                format!("  {}", detail.state),
                Style::default().fg(state_color),
            ),
        ]),
        Line::from(vec![
            Span::styled("Author: ", dim()),
            Span::raw(format!("@{}", detail.author)),
            Span::raw("  "),
            Span::styled("Assignees: ", dim()),
            Span::raw(detail.assignees.join(", ")),
        ]),
        Line::from(vec![
            Span::styled("Labels: ", dim()),
            // Labels rendered inline — full WCAG-aware styling in production
            Span::raw(detail.labels.join(", ")),
            Span::raw("  "),
            Span::styled("Milestone: ", dim()),
            Span::raw(detail.milestone.as_deref().unwrap_or("none")),
        ]),
        Line::from(vec![
            Span::styled("Project: ", dim()),
            Span::raw(&detail.project_path),
            Span::raw("  "),
            Span::styled("Created: ", dim()),
            Span::raw(&detail.created_at_display),
        ]),
    ];
    frame.render_widget(Paragraph::new(lines), area);
}

fn render_description(detail: &IssueDetail, frame: &mut Frame, area: Rect) {
    // Render markdown description using ftui-extras markdown renderer.
    // Falls back to plain text if markdown parsing fails.
    let body = detail.description.as_deref().unwrap_or("(no description)");
    let md = MarkdownRenderer::new(theme::build_theme());
    let rendered = md.render(body);

    let panel = Panel::new(rendered).title(" Description ");
    frame.render_widget(panel, area);
}

fn render_discussions(state: &IssueDetailState, frame: &mut Frame, area: Rect) {
    let Some(detail) = &state.detail else { return };

    // Tree::new takes a SINGLE TreeNode root. We create a virtual root
    // with each discussion thread as a child, then hide the root label.
    let mut root = TreeNode::new(format!(
        "Discussions ({} threads)",
        state.discussions.len(),
    ));

    for disc in &state.discussions {
        let header = format!(
            "{} -- {} ({} replies)  {}",
            disc.author,
            truncate(&disc.preview, 50),
            disc.reply_count,
            disc.relative_age,
        );

        let mut thread_node = TreeNode::new(header)
            .with_expanded(disc.expanded);

        if disc.expanded {
            // Root note body as first child
            thread_node = thread_node.child(
                TreeNode::new(truncate(&disc.root_note_body, 200))
            );

            // Replies as subsequent children
            for reply in &disc.replies {
                thread_node = thread_node.child(
                    TreeNode::new(format!(
                        "@{}: {}  {}",
                        reply.author,
                        truncate(&reply.body, 60),
                        reply.relative_age,
                    ))
                );
            }
        }

        root = root.child(thread_node);
    }

    let tree = Tree::new(root)
        .with_show_root(true)  // Show "Discussions (N threads)" as header
        .with_guides(ftui::widgets::tree::TreeGuides::Rounded);

    let panel = Panel::new(tree);
    frame.render_widget(panel, area);
}

fn render_cross_refs(detail: &IssueDetail, frame: &mut Frame, area: Rect) {
    let mut lines: Vec<Line> = Vec::new();

    for xref in &detail.closing_merge_requests {
        lines.push(Line::from(vec![
            Span::styled("  Closes: ", dim()),
            Span::styled(
                format!("!{}", xref.iid),
                Style::default().fg(theme::state_color("merged"))
                    .add_modifier(Modifier::UNDERLINED),
            ),
            Span::raw(format!(" ({})", truncate(&xref.title, 40))),
            Span::styled(
                format!("  {}", xref.state),
                Style::default().fg(theme::state_color(&xref.state)),
            ),
        ]));
    }

    if lines.is_empty() {
        lines.push(Line::styled("  (no cross-references)", dim()));
    }

    let para = Paragraph::new(lines);
    let panel = Panel::new(para).title(" Cross-References ");
    frame.render_widget(panel, area);
}

/// All truncation uses `crate::text_width::truncate_display_width()` — terminal
/// display width (via unicode-width), not byte/char length. Handles CJK, emoji,
/// combining marks, and zero-width joiners correctly for column alignment.
fn truncate(s: &str, max: usize) -> String {
    crate::text_width::truncate_display_width(s, max)
}

fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) }
```

### 10.9 Snapshot Test Example

```rust
// tests/tui_snapshot.rs

use ftui_harness::TestHarness;
use lore::tui::app::LoreApp;

#[test]
fn dashboard_renders_correctly() {
    let harness = TestHarness::new(80, 24); // 80x24 terminal
    let app = LoreApp::new_for_test(/* mock config + db */);

    // Inject dashboard data
    app.state.dashboard.update(mock_dashboard_data());

    // Capture frame
    let frame = harness.render(&app);

    // Snapshot comparison (deterministic)
    insta::assert_snapshot!(frame.to_string());
}

#[test]
fn issue_list_with_filter() {
    let harness = TestHarness::new(120, 40);
    let mut app = LoreApp::new_for_test(/* mock */);

    app.state.issue_list.set_items(mock_issues());
    app.state.issue_list.set_filter("state:opened");

    let frame = harness.render(&app);
    insta::assert_snapshot!(frame.to_string());
}
```

### 10.9.1 Non-Snapshot Tests (Phase 5.5 Reliability Pack)

Beyond visual snapshot testing, the TUI requires correctness tests for:

**Deterministic test infrastructure:**
- All time-based rendering (relative timestamps, "3h ago" labels) uses an injected `Clock` trait (`SystemClock` in production, `FakeClock` in tests) so snapshot tests produce stable output regardless of wall-clock time.
- Fuzz tests (key/resize/paste) use deterministic seeds. On failure, the seed is printed for exact replay: `FUZZ_SEED=<seed> cargo test tui_fuzz`.
- CI runs fuzz tests with a fixed set of seeds plus one random seed per run for discovery.

```rust
// tests/tui_reliability.rs

/// Property test: navigation stack invariants.
/// The stack is never empty, pop preserves at least Dashboard,
/// and push/pop are inverse operations for state preservation.
#[test]
fn navigation_stack_never_empty() {
    let mut nav = NavigationStack::new();
    assert_eq!(nav.depth(), 1); // Always starts with Dashboard
    assert!(nav.pop().is_none()); // Can't pop last screen
    assert_eq!(nav.depth(), 1);
}

/// Stale search response is silently dropped.
/// Uses TaskSupervisor generation check — the supervisor's generation counter
/// has advanced past the stale result's generation.
#[test]
fn stale_search_response_dropped() {
    let mut app = LoreApp::new_for_test(/* mock */);
    // Advance supervisor generation to 5 (simulates 5 tasks dispatched)
    for _ in 0..5 { app.task_supervisor.next_generation(); }
    // Simulate result from generation 3 (stale — current is 5)
    let cmd = app.update(Msg::SearchExecuted {
        generation: 3,
        results: mock_search_results(),
    });
    assert!(app.state.search.results.is_empty()); // Not applied
}

/// DbManager concurrent reader access doesn't deadlock.
#[test]
fn db_manager_concurrent_reads() {
    let db = DbManager::new(Path::new(":memory:"), 4).unwrap();
    let handles: Vec<_> = (0..8).map(|_| {
        let db = Arc::clone(&db);
        std::thread::spawn(move || {
            let conn = db.reader();
            conn.query_row("SELECT 1", [], |r| r.get::<_, i64>(0)).unwrap();
        })
    }).collect();
    for h in handles { h.join().unwrap(); }
}

/// Sync cancellation token is respected.
#[test]
fn sync_cancel_stops_early() {
    let mut state = SyncState::new();
    state.cancel_token.store(true, Ordering::Relaxed);
    // Verify cancel is checked in progress callback
    assert!(state.cancel_token.load(Ordering::Relaxed));
}

/// Rapid resize events don't crash or leave inconsistent state.
#[test]
fn resize_storm_stability() {
    let mut app = LoreApp::new_for_test(/* mock */);
    for w in (40..200).step_by(3) {
        app.update(Msg::Resize { width: w, height: 24 });
    }
    assert!(app.state.terminal_size.0 > 0);
}
```

**Performance benchmark tests** (run in CI, gated on SLO thresholds):

```rust
// benches/tui_queries.rs (criterion)

fn bench_issue_list_query(c: &mut Criterion) {
    let db = setup_synthetic_db(10_000, 5_000); // 10k issues, 5k MRs
    let filter = IssueFilter::default();
    c.bench_function("issue_list_10k", |b| {
        b.iter(|| fetch_issues(&db, &filter))
    });
}

fn bench_search_query(c: &mut Criterion) {
    let db = setup_synthetic_db_with_docs(50_000); // 50k documents
    c.bench_function("search_50k_lexical", |b| {
        b.iter(|| execute_search(&db, "authentication timeout", &SearchCliFilters::default()))
    });
}
```

### 10.10 State Module — Complete

The state tree is a composition of per-screen state structs. Each screen owns its data, filter state, cursor position, and any widget state (e.g., `TableState`, `TreePersistState`). This is the **single source of truth** for what the TUI displays.

```rust
// crates/lore-tui/src/state/mod.rs

pub mod dashboard;
pub mod issue_list;
pub mod issue_detail;
pub mod mr_list;
pub mod mr_detail;
pub mod search;
pub mod timeline;
pub mod who;
pub mod sync;

use crate::tui::state::command_palette::CommandPaletteState;

/// Top-level state composition. Each field corresponds to one screen.
/// State is preserved when navigating away — screens are never cleared on pop.
#[derive(Default)]
pub struct AppState {
    pub dashboard: dashboard::DashboardState,
    pub issue_list: issue_list::IssueListState,
    pub issue_detail: issue_detail::IssueDetailState,
    pub mr_list: mr_list::MrListState,
    pub mr_detail: mr_detail::MrDetailState,
    pub search: search::SearchState,
    pub timeline: timeline::TimelineState,
    pub who: who::WhoState,
    pub sync: sync::SyncState,
    pub command_palette: CommandPaletteState,

    // Cross-cutting state
    pub global_scope: ScopeContext,   // Applies to dashboard/list/search/timeline/who queries. Default: AllProjects.
    pub load_state: ScreenLoadStateMap,
    pub error_toast: Option<String>,
    pub show_help: bool,
    pub terminal_size: (u16, u16),
}

/// Per-screen loading state. Enables stale-while-revalidate: screens keep
/// showing their last data during background refresh, with a lightweight
/// spinner indicator rather than a full-screen loading overlay.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadState {
    /// No load in progress, data is current.
    Idle,
    /// Initial load — no data to show yet, render skeleton/spinner.
    LoadingInitial,
    /// Refreshing — stale data remains visible, show unobtrusive refresh indicator.
    Refreshing,
    /// Load failed — show error context with retry suggestion.
    Error(String),
}

impl Default for LoadState {
    fn default() -> Self { Self::Idle }
}

/// Map of per-screen load states. Defaults to Idle for untracked screens.
#[derive(Default)]
pub struct ScreenLoadStateMap {
    states: HashMap<Screen, LoadState>,
}

impl ScreenLoadStateMap {
    pub fn get(&self, screen: &Screen) -> &LoadState {
        self.states.get(screen).unwrap_or(&LoadState::Idle)
    }

    pub fn set(&mut self, screen: Screen, state: LoadState) {
        if matches!(state, LoadState::Idle) {
            self.states.remove(&screen);
        } else {
            self.states.insert(screen, state);
        }
    }

    /// Returns true if any screen is in a loading state (for spinner subscription).
    pub fn any_loading(&self) -> bool {
        self.states.values().any(|s| matches!(s, LoadState::LoadingInitial | LoadState::Refreshing))
    }
}

impl AppState {
    pub fn set_loading(&mut self, screen: Screen, state: LoadState) {
        self.load_state.set(screen, state);
    }

    pub fn set_error(&mut self, msg: String) {
        self.error_toast = Some(msg);
    }

    pub fn clear_error(&mut self) {
        self.error_toast = None;
    }

    /// Returns true if any screen has an active text input (filter bar, search box).
    pub fn has_text_focus(&self) -> bool {
        self.issue_list.filter_focused
            || self.mr_list.filter_focused
            || self.search.query_focused
            || self.who.path_focused
            || self.timeline.query_focused
    }

    /// Blur all text inputs across all screens.
    pub fn blur_text_focus(&mut self) {
        self.issue_list.filter_focused = false;
        self.mr_list.filter_focused = false;
        self.search.query_focused = false;
        self.who.path_focused = false;
        self.timeline.query_focused = false;
    }

    /// Delegate a key event to the text input widget that currently has focus.
    /// Returns true if the event was consumed.
    pub fn delegate_text_event(
        &mut self, screen: &Screen, event: &Event,
    ) -> bool {
        match screen {
            Screen::IssueList => self.issue_list.filter_input.handle_event(event),
            Screen::MrList => self.mr_list.filter_input.handle_event(event),
            Screen::Search => self.search.query_input.handle_event(event),
            Screen::Who => self.who.path_input.handle_event(event),
            Screen::Timeline => self.timeline.query_input.handle_event(event),
            _ => false,
        }
    }

    /// Dispatch screen-local keybindings (j/k scroll, Tab sort, etc.).
    /// Called from interpret_key() stage 5.
    pub fn interpret_screen_key(
        &mut self, screen: &Screen, key: &KeyEvent,
    ) -> Option<Msg> {
        match screen {
            Screen::Dashboard => match key.code {
                KeyCode::Char('i') => Some(Msg::NavigateTo(Screen::IssueList)),
                KeyCode::Char('m') => Some(Msg::NavigateTo(Screen::MrList)),
                KeyCode::Char('/') => Some(Msg::NavigateTo(Screen::Search)),
                KeyCode::Char('t') => Some(Msg::NavigateTo(Screen::Timeline)),
                KeyCode::Char('w') => Some(Msg::NavigateTo(Screen::Who)),
                KeyCode::Char('s') => Some(Msg::NavigateTo(Screen::Sync)),
                KeyCode::Char('d') => Some(Msg::NavigateTo(Screen::Doctor)),
                KeyCode::Char('r') => Some(Msg::NavigateTo(Screen::Dashboard)), // refresh
                _ => None,
            },
            Screen::IssueList => self.issue_list.handle_key(key),
            Screen::IssueDetail(_) => self.issue_detail.handle_key(key),
            Screen::MrList => self.mr_list.handle_key(key),
            Screen::MrDetail(_) => self.mr_detail.handle_key(key),
            Screen::Search => self.search.handle_key(key),
            Screen::Timeline => self.timeline.handle_key(key),
            Screen::Who => self.who.handle_key(key),
            Screen::Sync => self.sync.handle_key(key),
            _ => None,
        }
    }

    /// Handle Msg variants that are specific to a single screen's sub-state.
    /// Called from update() for messages not handled at the top level.
    ///
    /// IMPORTANT: This function is PURE — it updates state and returns
    /// `ScreenIntent` signals, NEVER `Cmd::task` directly. All async work
    /// (DB queries, re-queries after filter changes) is launched by
    /// `LoreApp::update()` through the `TaskSupervisor`, which enforces
    /// dedup, cancellation, and generation-based stale result detection.
    ///
    /// This prevents the bug where filter re-queries bypass the supervisor
    /// and race with screen-load queries, causing stale data display.
    pub fn handle_screen_msg(
        &mut self, screen: &Screen, msg: Msg,
    ) -> ScreenIntent {
        match msg {
            // Issue list scrolling, filter, sort
            Msg::IssueSelected(entity_key) => {
                ScreenIntent::Navigate(Screen::IssueDetail(entity_key))
            }
            Msg::IssueListFilterChanged(filter) => {
                self.issue_list.apply_filter(filter);
                // Signal that a re-query is needed — LoreApp::update() dispatches
                // via TaskSupervisor with TaskKey::FilterRequery(Screen::IssueList)
                ScreenIntent::RequeryNeeded(Screen::IssueList)
            }
            Msg::IssueListSortChanged(field, order) => {
                self.issue_list.set_sort(field, order);
                ScreenIntent::None // Re-sort locally, no re-query needed
            }
            Msg::ScrollToTopCurrentScreen => {
                // Generic scroll-to-top for whichever list screen is active
                match screen {
                    Screen::IssueList => self.issue_list.scroll_to_top(),
                    Screen::MrList => {
                        self.mr_list.selected_index = 0;
                        self.mr_list.scroll_offset = 0;
                    }
                    _ => {}
                }
                ScreenIntent::None
            }

            // MR list
            Msg::MrSelected(entity_key) => {
                ScreenIntent::Navigate(Screen::MrDetail(entity_key))
            }
            Msg::MrListFilterChanged(filter) => {
                self.mr_list.apply_filter(filter);
                ScreenIntent::RequeryNeeded(Screen::MrList)
            }

            // Search
            Msg::SearchQueryChanged(query) => {
                self.search.set_query(query.clone());
                if query.len() < 2 { return ScreenIntent::None; }
                ScreenIntent::RequeryNeeded(Screen::Search)
            }
            Msg::SearchResultSelected(idx) => {
                self.search.select(idx);
                ScreenIntent::None
            }

            // Timeline — EntityRef carries full EntityKey for safe cross-project navigation
            Msg::TimelineEntitySelected(entity_ref) => {
                match entity_ref {
                    EntityRef::Issue(key) => {
                        ScreenIntent::Navigate(Screen::IssueDetail(key))
                    }
                    EntityRef::MergeRequest(key) => {
                        ScreenIntent::Navigate(Screen::MrDetail(key))
                    }
                }
            }

            // Who
            Msg::WhoModeChanged(mode) => {
                self.who.set_mode(mode);
                ScreenIntent::None
            }

            _ => ScreenIntent::None,
        }
    }
}

/// Signals from screen state handlers to LoreApp::update().
/// State handlers are PURE — they never launch async tasks directly.
/// LoreApp::update() converts these intents into supervised Cmd::task calls.
#[derive(Debug)]
pub enum ScreenIntent {
    /// No side effect needed.
    None,
    /// Navigate to a new screen (will trigger screen load via supervisor).
    Navigate(Screen),
    /// The screen's filter/query changed and needs a re-query.
    /// LoreApp::update() dispatches via TaskSupervisor with the appropriate TaskKey.
    RequeryNeeded(Screen),
}
```

#### Per-Screen State Structs

```rust
// crates/lore-tui/src/state/dashboard.rs

pub struct DashboardState {
    pub counts: EntityCounts,
    pub projects: Vec<ProjectSyncInfo>,
    pub recent: Vec<RecentActivityItem>,
    pub last_sync: LastSyncInfo,
}

pub struct EntityCounts {
    pub issues_open: u64,
    pub issues_total: u64,
    pub mrs_open: u64,
    pub mrs_total: u64,
    pub discussions: u64,
    pub notes_total: u64,
    pub notes_system_pct: u8,
    pub documents: u64,
    pub embeddings: u64,
}

pub struct ProjectSyncInfo {
    pub path: String,
    pub minutes_since_sync: u64,
}

pub struct RecentActivityItem {
    pub entity_type: String,  // "issue" | "mr"
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub minutes_ago: u64,
}

pub struct LastSyncInfo {
    pub minutes_ago: u64,
    pub duration_display: String,
    pub new_issues: usize,
    pub updated_issues: usize,
    pub new_mrs: usize,
    pub updated_mrs: usize,
    pub errors: usize,
}

/// Wire type: returned by fetch_dashboard(), consumed by DashboardState::update().
pub struct DashboardData {
    pub counts: EntityCounts,
    pub projects: Vec<ProjectSyncInfo>,
    pub recent: Vec<RecentActivityItem>,
    pub last_sync: LastSyncInfo,
}

impl DashboardState {
    pub fn update(&mut self, data: DashboardData) {
        self.counts = data.counts;
        self.projects = data.projects;
        self.recent = data.recent;
        self.last_sync = data.last_sync;
    }
}
```

```rust
// crates/lore-tui/src/state/issue_list.rs

use ftui::widgets::input::TextInput;

/// Keyset cursor for pagination — encodes the boundary values needed to
/// fetch the next/previous window without OFFSET (which degrades at scale).
#[derive(Clone)]
pub struct IssueCursor {
    pub updated_at: i64,
    pub iid: i64,
}

pub struct IssueListState {
    /// Current visible window of rows (not the full result set).
    pub window: Vec<IssueListRow>,
    pub total_count: usize,
    pub selected_index: usize,
    pub scroll_offset: usize,
    /// Keyset cursor for fetching the next page forward.
    pub next_cursor: Option<IssueCursor>,
    /// Keyset cursor for fetching the previous page backward.
    pub prev_cursor: Option<IssueCursor>,
    /// True while a background prefetch of the next window is in progress.
    pub prefetching: bool,
    /// Number of rows per window (default 200).
    pub window_size: usize,
    pub sort_field: SortField,
    pub sort_order: SortOrder,
    pub filter_input: TextInput,
    pub filter_focused: bool,
    pub active_filter: IssueFilter,
}

#[derive(Clone)]
pub struct IssueFilter {
    pub state: Option<String>,
    pub author: Option<String>,
    pub assignee: Option<String>,
    pub labels: Vec<String>,
    pub milestone: Option<String>,
    pub since: Option<String>,
    pub project: Option<String>,
    pub limit: usize,
}

impl Default for IssueFilter {
    fn default() -> Self {
        Self {
            state: Some("opened".into()),
            author: None, assignee: None,
            labels: Vec::new(), milestone: None,
            since: None, project: None,
            limit: 50,
        }
    }
}

#[derive(Clone, Copy)]
pub enum SortField { Iid, Updated, Created }

#[derive(Clone, Copy)]
pub enum SortOrder { Asc, Desc }

impl IssueListState {
    pub fn set_result(
        &mut self,
        items: Vec<IssueListRow>,
        next_cursor: Option<IssueCursor>,
        prev_cursor: Option<IssueCursor>,
    ) {
        self.total_count = items.len();
        self.window = items;
        self.next_cursor = next_cursor;
        self.prev_cursor = prev_cursor;
        self.prefetching = false;
        // Preserve selected_index if in range
        if self.selected_index >= self.window.len() {
            self.selected_index = self.window.len().saturating_sub(1);
        }
    }

    pub fn current_filter(&self) -> IssueFilter {
        self.active_filter.clone()
    }

    pub fn apply_filter(&mut self, filter: IssueFilter) {
        self.active_filter = filter;
        self.selected_index = 0;
        self.scroll_offset = 0;
    }

    pub fn set_sort(&mut self, field: SortField, order: SortOrder) {
        self.sort_field = field;
        self.sort_order = order;
        self.sort_items_locally();
    }

    pub fn scroll_to_top(&mut self) {
        self.selected_index = 0;
        self.scroll_offset = 0;
    }

    /// Get the currently selected item (for browser-open, etc.).
    pub fn selected_item(&self) -> Option<&IssueListRow> {
        self.window.get(self.selected_index)
    }

    pub fn handle_key(&mut self, key: &KeyEvent) -> Option<Msg> {
        match key.code {
            KeyCode::Char('j') | KeyCode::Down => {
                if self.selected_index < self.window.len().saturating_sub(1) {
                    self.selected_index += 1;
                }
                Some(Msg::Tick) // trigger re-render
            }
            KeyCode::Char('k') | KeyCode::Up => {
                self.selected_index = self.selected_index.saturating_sub(1);
                Some(Msg::Tick)
            }
            KeyCode::Enter => {
                self.window.get(self.selected_index)
                    .map(|item| Msg::IssueSelected(
                        EntityKey::issue(item.project_id, item.iid)
                    ))
            }
            KeyCode::Char('G') => {
                self.selected_index = self.window.len().saturating_sub(1);
                Some(Msg::Tick)
            }
            KeyCode::Char('f') | KeyCode::Tab => {
                self.filter_focused = true;
                Some(Msg::Tick)
            }
            KeyCode::Char('r') => Some(Msg::NavigateTo(Screen::IssueList)), // refresh
            _ => None,
        }
    }

    fn sort_items_locally(&mut self) {
        let field = self.sort_field;
        let desc = matches!(self.sort_order, SortOrder::Desc);
        self.items.sort_by(|a, b| {
            let cmp = match field {
                SortField::Iid => a.iid.cmp(&b.iid),
                SortField::Updated => a.updated_at.cmp(&b.updated_at),
                SortField::Created => a.created_at.cmp(&b.created_at),
            };
            if desc { cmp.reverse() } else { cmp }
        });
    }
}
```

```rust
// crates/lore-tui/src/state/issue_detail.rs

pub struct IssueDetailState {
    pub detail: Option<IssueDetail>,
    pub discussions: Vec<DiscussionViewState>,
    pub scroll_offset: usize,
    pub selected_section: DetailSection,
}

pub struct DiscussionViewState {
    pub author: String,
    pub preview: String,
    pub root_note_body: String,
    pub reply_count: usize,
    pub relative_age: String,
    pub expanded: bool,
    pub replies: Vec<ReplyViewState>,
}

pub struct ReplyViewState {
    pub author: String,
    pub body: String,
    pub relative_age: String,
}

#[derive(Default)]
pub enum DetailSection { #[default] Description, Discussions, CrossRefs }

impl IssueDetailState {
    pub fn set(&mut self, detail: IssueDetail) {
        self.discussions = detail.discussions.iter().map(|d| {
            // Transform DiscussionDetail → DiscussionViewState
            // First note is root, rest are replies
            let root = d.notes.first();
            DiscussionViewState {
                author: root.map_or_else(String::new, |n| format!("@{}", n.author_username)),
                preview: root.map_or_else(String::new, |n| truncate(&n.body, 60)),
                root_note_body: root.map_or_else(String::new, |n| n.body.clone()),
                reply_count: d.notes.len().saturating_sub(1),
                relative_age: root.map_or_else(String::new, |n| format_relative_ms(n.created_at)),
                expanded: false, // collapsed by default
                replies: d.notes.iter().skip(1).map(|n| ReplyViewState {
                    author: format!("@{}", n.author_username),
                    body: n.body.clone(),
                    relative_age: format_relative_ms(n.created_at),
                }).collect(),
            }
        }).collect();
        self.detail = Some(detail);
        self.scroll_offset = 0;
    }

    pub fn handle_key(&mut self, key: &KeyEvent) -> Option<Msg> {
        match key.code {
            KeyCode::Char('j') | KeyCode::Down => {
                self.scroll_offset += 1;
                Some(Msg::Tick)
            }
            KeyCode::Char('k') | KeyCode::Up => {
                self.scroll_offset = self.scroll_offset.saturating_sub(1);
                Some(Msg::Tick)
            }
            KeyCode::Char('d') => {
                // Toggle expand/collapse of selected discussion
                self.toggle_discussion();
                Some(Msg::Tick)
            }
            KeyCode::Char('t') => {
                // Open timeline scoped to this entity
                self.detail.as_ref().map(|d| Msg::NavigateTo(Screen::Timeline))
            }
            KeyCode::Char(' ') => {
                self.scroll_offset += 10; // page down
                Some(Msg::Tick)
            }
            KeyCode::Char('b') => {
                self.scroll_offset = self.scroll_offset.saturating_sub(10);
                Some(Msg::Tick)
            }
            _ => None,
        }
    }

    fn toggle_discussion(&mut self) {
        // Toggle the first collapsed/expanded discussion near current scroll
        // (simplified — real impl would track focused discussion index)
        if let Some(disc) = self.discussions.first_mut() {
            disc.expanded = !disc.expanded;
        }
    }
}
```

```rust
// crates/lore-tui/src/state/mr_list.rs — mirrors IssueListState with MR-specific filter fields

/// Keyset cursor for MR pagination (mirrors IssueCursor pattern).
#[derive(Clone)]
pub struct MrCursor {
    pub updated_at: i64,
    pub iid: i64,
}

pub struct MrListState {
    /// Current visible window of rows (not the full result set).
    pub window: Vec<MrListRow>,
    pub total_count: usize,
    pub selected_index: usize,
    pub scroll_offset: usize,
    /// Keyset cursor for fetching the next page forward.
    pub next_cursor: Option<MrCursor>,
    /// Keyset cursor for fetching the previous page backward.
    pub prev_cursor: Option<MrCursor>,
    /// True while a background prefetch of the next window is in progress.
    pub prefetching: bool,
    /// Number of rows per window (default 200).
    pub window_size: usize,
    pub sort_field: SortField,
    pub sort_order: SortOrder,
    pub filter_input: TextInput,
    pub filter_focused: bool,
    pub active_filter: MrFilter,
}

#[derive(Clone)]
pub struct MrFilter {
    pub state: Option<String>,
    pub author: Option<String>,
    pub assignee: Option<String>,
    pub reviewer: Option<String>,
    pub draft: Option<bool>,
    pub labels: Vec<String>,
    pub target_branch: Option<String>,
    pub source_branch: Option<String>,
    pub since: Option<String>,
    pub project: Option<String>,
    pub limit: usize,
}

impl MrListState {
    pub fn set_result(
        &mut self,
        items: Vec<MrListRow>,
        next_cursor: Option<MrCursor>,
        prev_cursor: Option<MrCursor>,
    ) {
        self.total_count = items.len();
        self.window = items;
        self.next_cursor = next_cursor;
        self.prev_cursor = prev_cursor;
        self.prefetching = false;
        if self.selected_index >= self.window.len() {
            self.selected_index = self.window.len().saturating_sub(1);
        }
    }

    pub fn current_filter(&self) -> MrFilter {
        self.active_filter.clone()
    }

    pub fn apply_filter(&mut self, filter: MrFilter) {
        self.active_filter = filter;
        self.selected_index = 0;
        self.scroll_offset = 0;
    }

    pub fn selected_item(&self) -> Option<&MrListRow> {
        self.window.get(self.selected_index)
    }

    pub fn handle_key(&mut self, key: &KeyEvent) -> Option<Msg> {
        // Identical to IssueListState::handle_key but produces MR-specific Msgs
        match key.code {
            KeyCode::Char('j') | KeyCode::Down => {
                if self.selected_index < self.window.len().saturating_sub(1) {
                    self.selected_index += 1;
                }
                Some(Msg::Tick)
            }
            KeyCode::Char('k') | KeyCode::Up => {
                self.selected_index = self.selected_index.saturating_sub(1);
                Some(Msg::Tick)
            }
            KeyCode::Enter => {
                self.window.get(self.selected_index)
                    .map(|item| Msg::MrSelected(
                        EntityKey::mr(item.project_id, item.iid)
                    ))
            }
            KeyCode::Char('G') => {
                self.selected_index = self.window.len().saturating_sub(1);
                Some(Msg::Tick)
            }
            KeyCode::Char('f') | KeyCode::Tab => {
                self.filter_focused = true;
                Some(Msg::Tick)
            }
            KeyCode::Char('r') => Some(Msg::NavigateTo(Screen::MrList)),
            _ => None,
        }
    }
}
```

```rust
// crates/lore-tui/src/state/mr_detail.rs — mirrors IssueDetailState with diff discussion sections

pub struct MrDetailState {
    pub detail: Option<MrDetail>,
    pub general_discussions: Vec<DiscussionViewState>,
    pub diff_discussions: Vec<DiffDiscussionViewState>,
    pub file_changes: Vec<FileChangeView>,
    pub scroll_offset: usize,
    pub active_tab: MrDetailTab,
}

pub struct DiffDiscussionViewState {
    pub file_path: String,
    pub line: Option<i64>,
    pub discussion: DiscussionViewState,
}

pub struct FileChangeView {
    pub path: String,
    pub change_type: String,  // "M", "A", "D", "R"
    pub additions: i64,
    pub deletions: i64,
}

#[derive(Default)]
pub enum MrDetailTab { #[default] Overview, Files, Discussions }
```

```rust
// crates/lore-tui/src/state/search.rs

pub struct SearchState {
    pub query_input: TextInput,
    pub query_focused: bool,
    pub mode: SearchMode,
    pub source_type: Option<String>,
    pub results: Vec<SearchResultDisplay>,
    pub selected_index: usize,
    pub total_results: usize,
    pub last_query_ms: u64,
}

#[derive(Default, Clone, Copy)]
pub enum SearchMode { #[default] Hybrid, Lexical, Semantic }

impl SearchState {
    pub fn query_text(&self) -> &str {
        self.query_input.value()
    }

    pub fn set_query(&mut self, query: String) {
        self.query_input.set_value(&query);
    }

    pub fn set_results(&mut self, response: SearchResponse) {
        self.results = response.results;
        self.total_results = response.total_results;
        self.selected_index = 0;
    }

    pub fn select(&mut self, idx: usize) {
        if idx < self.results.len() {
            self.selected_index = idx;
        }
    }

    pub fn current_filters(&self) -> SearchCliFilters {
        SearchCliFilters {
            source_type: self.source_type.clone(),
            author: None,
            project: None,
            labels: Vec::new(),
            path: None,
            since: None,
            updated_since: None,
            limit: 50,
        }
    }

    pub fn handle_key(&mut self, key: &KeyEvent) -> Option<Msg> {
        match key.code {
            KeyCode::Char('j') | KeyCode::Down => {
                if self.selected_index < self.results.len().saturating_sub(1) {
                    self.selected_index += 1;
                }
                Some(Msg::Tick)
            }
            KeyCode::Char('k') | KeyCode::Up => {
                self.selected_index = self.selected_index.saturating_sub(1);
                Some(Msg::Tick)
            }
            KeyCode::Tab => {
                self.mode = match self.mode {
                    SearchMode::Hybrid => SearchMode::Lexical,
                    SearchMode::Lexical => SearchMode::Semantic,
                    SearchMode::Semantic => SearchMode::Hybrid,
                };
                Some(Msg::Tick)
            }
            KeyCode::Char('/') => {
                self.query_focused = true;
                Some(Msg::Tick)
            }
            KeyCode::Enter => {
                // Navigate to selected result using EntityKey for safe cross-project identity
                self.results.get(self.selected_index).and_then(|r| {
                    r.entity_key.as_ref().and_then(|key| {
                        match key.kind {
                            EntityKind::Issue => Some(Msg::NavigateTo(
                                Screen::IssueDetail(key.clone()),
                            )),
                            EntityKind::MergeRequest => Some(Msg::NavigateTo(
                                Screen::MrDetail(key.clone()),
                            )),
                        }
                    })
                })
            }
            _ => None,
        }
    }
}
```

```rust
// crates/lore-tui/src/state/timeline.rs

/// Parameters for a timeline query.
pub struct TimelineParams {
    pub query: String,
    pub depth: u8,
    pub since_days: u32,
}

/// A single event in the timeline stream.
/// Wraps the existing timeline pipeline's output for TUI rendering.
#[derive(Clone)]
pub struct TimelineEvent {
    pub timestamp_ms: i64,
    pub event_type: String,       // "state_change", "label_change", "note", "commit", etc.
    pub entity_type: String,      // "issue", "merge_request"
    pub entity_iid: i64,
    pub entity_title: String,
    pub description: String,      // Human-readable event description
    pub author: Option<String>,
    pub entity_ref: Option<EntityRef>,
}

/// A reference to a GitLab entity for cross-navigation.
/// Uses EntityKey (project_id + iid) instead of bare iid to prevent
/// misrouting across projects with overlapping IID ranges.
#[derive(Clone)]
pub enum EntityRef {
    Issue(EntityKey),
    MergeRequest(EntityKey),
}

pub struct TimelineState {
    pub query_input: TextInput,
    pub query_focused: bool,
    pub events: Vec<TimelineEvent>,
    pub seeds: Vec<String>,  // seed entity labels
    pub selected_index: usize,
    pub scroll_offset: usize,
    pub depth: u8,
    pub since_days: u32,
}

impl TimelineState {
    pub fn set_result(&mut self, result: Vec<TimelineEvent>) {
        self.events = result;
        self.selected_index = 0;
        self.scroll_offset = 0;
    }

    pub fn current_params(&self) -> TimelineParams {
        TimelineParams {
            query: self.query_input.value().to_string(),
            depth: self.depth,
            since_days: self.since_days,
        }
    }

    pub fn handle_key(&mut self, key: &KeyEvent) -> Option<Msg> {
        match key.code {
            KeyCode::Char('j') | KeyCode::Down => {
                if self.selected_index < self.events.len().saturating_sub(1) {
                    self.selected_index += 1;
                }
                Some(Msg::Tick)
            }
            KeyCode::Char('k') | KeyCode::Up => {
                self.selected_index = self.selected_index.saturating_sub(1);
                Some(Msg::Tick)
            }
            KeyCode::Char('d') => {
                self.depth = (self.depth + 1).min(3);
                Some(Msg::Tick) // re-query happens on next update
            }
            KeyCode::Char('s') => {
                self.since_days = match self.since_days {
                    7 => 30, 30 => 90, 90 => 365, _ => 7,
                };
                Some(Msg::Tick)
            }
            KeyCode::Char('/') => {
                self.query_focused = true;
                Some(Msg::Tick)
            }
            KeyCode::Enter => {
                self.events.get(self.selected_index).and_then(|e| {
                    e.entity_ref.as_ref().map(|er| Msg::TimelineEntitySelected(er.clone()))
                })
            }
            _ => None,
        }
    }
}
```

```rust
// crates/lore-tui/src/state/who.rs

/// Parameters for a who query.
pub struct WhoParams {
    pub mode: WhoMode,
    pub path: String,
    pub username: String,
}

pub struct WhoState {
    pub mode: WhoMode,
    pub path_input: TextInput,
    pub path_focused: bool,
    pub username_input: TextInput,
    pub result: Option<WhoResult>,
    pub selected_index: usize,
}

#[derive(Default, Clone, Copy)]
pub enum WhoMode { #[default] Expert, Workload, Reviews, Active, Overlap }

impl WhoState {
    pub fn set_mode(&mut self, mode: WhoMode) {
        self.mode = mode;
        self.selected_index = 0;
    }

    pub fn set_result(&mut self, result: WhoResult) {
        self.result = Some(result);
        self.selected_index = 0;
    }

    pub fn current_params(&self) -> WhoParams {
        WhoParams {
            mode: self.mode,
            path: self.path_input.value().to_string(),
            username: self.username_input.value().to_string(),
        }
    }

    pub fn handle_key(&mut self, key: &KeyEvent) -> Option<Msg> {
        match key.code {
            KeyCode::Tab => {
                self.mode = match self.mode {
                    WhoMode::Expert => WhoMode::Workload,
                    WhoMode::Workload => WhoMode::Reviews,
                    WhoMode::Reviews => WhoMode::Active,
                    WhoMode::Active => WhoMode::Overlap,
                    WhoMode::Overlap => WhoMode::Expert,
                };
                Some(Msg::WhoModeChanged(self.mode))
            }
            KeyCode::Char('j') | KeyCode::Down => {
                self.selected_index += 1;
                Some(Msg::Tick)
            }
            KeyCode::Char('k') | KeyCode::Up => {
                self.selected_index = self.selected_index.saturating_sub(1);
                Some(Msg::Tick)
            }
            KeyCode::Char('/') => {
                self.path_focused = true;
                Some(Msg::Tick)
            }
            _ => None,
        }
    }
}
```

```rust
// crates/lore-tui/src/state/sync.rs

use ftui::widgets::log_viewer::{LogViewer, LogViewerState};
use std::sync::atomic::{AtomicBool, Ordering};

pub struct SyncState {
    pub phase: SyncPhase,
    pub projects: Vec<SyncProjectProgress>,
    pub summary: SyncSummary,
    pub log_viewer: LogViewer,
    pub log_viewer_state: LogViewerState,
    pub start_time: Option<std::time::Instant>,
    pub options: SyncOptions,
    /// Cooperative cancel token — set to true when user presses Esc during sync.
    /// The sync thread checks this at phase boundaries for early exit.
    pub cancel_token: Arc<AtomicBool>,
}

#[derive(Default, Clone, Copy)]
pub enum SyncPhase { #[default] Idle, Running, Summary }

pub struct SyncProjectProgress {
    pub path: String,
    pub resources: Vec<SyncResourceProgress>,
}

pub struct SyncResourceProgress {
    pub kind: String,  // "Issues", "MRs", "Discussions", "Events"
    pub fetched: usize,
    pub total: Option<usize>,
    pub delta: i64,
}

impl SyncResourceProgress {
    pub fn progress_pct(&self) -> u8 {
        match self.total {
            Some(t) if t > 0 => ((self.fetched * 100) / t).min(100) as u8,
            _ => 0,
        }
    }
    pub fn kind_label(&self) -> &str { &self.kind }
    pub fn delta_label(&self) -> String {
        if self.delta > 0 { format!("+{}", self.delta) }
        else { String::new() }
    }
}

#[derive(Default)]
pub struct SyncSummary {
    pub new_issues: usize,
    pub updated_issues: usize,
    pub new_mrs: usize,
    pub updated_mrs: usize,
    pub new_discussions: usize,
    pub new_events: usize,
    pub errors: usize,
}

#[derive(Default)]
pub struct SyncOptions {
    pub full: bool,
    pub embed_after: bool,
    pub dry_run: bool,
}

impl SyncState {
    pub fn new() -> Self {
        Self {
            phase: SyncPhase::Idle,
            projects: Vec::new(),
            summary: SyncSummary::default(),
            log_viewer: LogViewer::new(10_000),
            log_viewer_state: LogViewerState::default(),
            start_time: None,
            options: SyncOptions::default(),
            cancel_token: Arc::new(AtomicBool::new(false)),
        }
    }

    pub fn start(&mut self) {
        self.phase = SyncPhase::Running;
        self.cancel_token.store(false, Ordering::Relaxed); // Reset cancel on new sync
        self.start_time = Some(std::time::Instant::now());
    }

    pub fn update_progress(&mut self, event: ProgressEvent) {
        // Map ProgressEvent to SyncProjectProgress updates
        // (project routing done by matching event variants)
    }

    pub fn push_log(&mut self, line: String) {
        self.log_viewer.push(&line);
    }

    pub fn complete(&mut self, result: SyncResult) {
        self.phase = SyncPhase::Summary;
        // Populate summary from result
    }

    pub fn fail(&mut self, err: String) {
        self.phase = SyncPhase::Summary;
        self.summary.errors += 1;
        self.log_viewer.push(&format!("ERROR: {}", err));
    }

    pub fn elapsed_display(&self) -> String {
        match self.start_time {
            Some(t) => {
                let secs = t.elapsed().as_secs();
                format!("{}m {}s", secs / 60, secs % 60)
            }
            None => "0s".into(),
        }
    }

    pub fn status_label(&self) -> &str {
        match self.phase {
            SyncPhase::Idle => "Ready",
            SyncPhase::Running => "Syncing...",
            SyncPhase::Summary => "Complete",
        }
    }

    pub fn handle_key(&mut self, key: &KeyEvent) -> Option<Msg> {
        match self.phase {
            SyncPhase::Idle => match key.code {
                KeyCode::Enter => Some(Msg::SyncStarted),
                KeyCode::Char('f') => { self.options.full = !self.options.full; Some(Msg::Tick) }
                KeyCode::Char('e') => { self.options.embed_after = !self.options.embed_after; Some(Msg::Tick) }
                KeyCode::Char('d') => { self.options.dry_run = !self.options.dry_run; Some(Msg::Tick) }
                _ => None,
            },
            SyncPhase::Running => match key.code {
                KeyCode::Escape => {
                    self.cancel_token.store(true, Ordering::Relaxed);
                    self.log_viewer.push("Cancelling sync...");
                    None // Sync thread will detect token and return SyncCancelled
                }
                _ => None,
            },
            SyncPhase::Summary => match key.code {
                KeyCode::Char('i') => Some(Msg::NavigateTo(Screen::IssueList)),
                KeyCode::Char('m') => Some(Msg::NavigateTo(Screen::MrList)),
                KeyCode::Char('r') => Some(Msg::SyncStarted),
                _ => None,
            },
        }
    }
}
```

### 10.11 Action Module — Query Bridge

The action module provides pure query functions that bridge the TUI state to existing CLI query logic. These run inside `Cmd::task` closures on a thread pool.

```rust
// crates/lore-tui/src/action.rs
//
// All functions are pure data queries: &Connection in, Result<T> out.
// No I/O, no framework dependency, no terminal access.

use rusqlite::Connection;
use crate::core::error::LoreError;
use crate::tui::state::dashboard::*;
use crate::tui::state::issue_list::IssueFilter;
use crate::tui::state::mr_list::MrFilter;
use crate::tui::state::search::SearchMode;
use crate::cli::commands::list::{
    IssueListRow, ListFilters, ListResult,
    MrListRow, MrListFilters, MrListResult,
};
use crate::cli::commands::show::{IssueDetail, MrDetail};
use crate::cli::commands::search::{SearchResponse, SearchCliFilters, SearchResultDisplay};
use crate::cli::commands::who::{WhoResult, WhoArgs};
use crate::search::FtsQueryMode;

/// Fetch dashboard aggregate data.
pub fn fetch_dashboard(conn: &Connection) -> Result<DashboardData, LoreError> {
    // Count queries — direct SQL for efficiency
    let issues_open = count_by_state(conn, "issues", "opened")?;
    let issues_total = count_total(conn, "issues")?;
    let mrs_open = count_by_state(conn, "merge_requests", "opened")?;
    let mrs_total = count_total(conn, "merge_requests")?;
    let discussions = count_total(conn, "discussions")?;
    let notes = count_total(conn, "notes")?;
    let documents = count_total(conn, "documents")?;
    let embeddings = count_total(conn, "embeddings")?;

    // System note percentage
    let system_notes: u64 = conn.query_row(
        "SELECT COUNT(*) FROM notes WHERE is_system = 1", [], |r| r.get(0),
    ).unwrap_or(0);
    let notes_system_pct = if notes > 0 {
        ((system_notes * 100) / notes) as u8
    } else { 0 };

    // Recent activity — last 10 updated issues/MRs
    let recent = fetch_recent_activity(conn, 10)?;

    // Project sync status
    let projects = fetch_project_sync_status(conn)?;

    // Last sync info
    let last_sync = fetch_last_sync_info(conn)?;

    Ok(DashboardData {
        counts: EntityCounts {
            issues_open, issues_total, mrs_open, mrs_total,
            discussions, notes_total: notes, notes_system_pct,
            documents, embeddings,
        },
        recent,
        projects,
        last_sync,
    })
}

/// Fetch issues, converting TUI IssueFilter → CLI ListFilters.
/// The `scope` parameter applies global project pinning — when a scope is active,
/// it overrides any per-filter project selection, ensuring cross-screen consistency.
pub fn fetch_issues(
    conn: &Connection,
    scope: &ScopeContext,
    filter: &IssueFilter,
) -> Result<Vec<IssueListRow>, LoreError> {
    // Convert TUI filter to CLI filter format.
    // The CLI already has query_issues() — we just need to bridge the types.
    // Global scope overrides per-filter project when active.
    let effective_project = scope.effective_project(filter.project.as_deref());
    let cli_filter = ListFilters {
        limit: filter.limit,
        project: effective_project.as_deref(),
        state: filter.state.as_deref(),
        author: filter.author.as_deref(),
        assignee: filter.assignee.as_deref(),
        labels: filter.labels.as_slice().into(),
        milestone: filter.milestone.as_deref(),
        since: filter.since.as_deref(),
        due_before: None,
        has_due_date: false,
        sort: "updated_at",
        order: "desc",
    };
    let result = crate::cli::commands::list::query_issues(conn, &cli_filter)?;
    Ok(result.issues)
}

/// Fetch issue detail with discussions and cross-references.
/// Uses EntityKey (project_id + iid) for safe multi-project identity.
pub fn fetch_issue_detail(
    conn: &Connection,
    key: &EntityKey,
) -> Result<IssueDetail, LoreError> {
    crate::cli::commands::show::query_issue_detail(conn, key.iid, Some(key.project_id))
}

/// Fetch MRs, converting TUI MrFilter → CLI MrListFilters.
pub fn fetch_mrs(
    conn: &Connection,
    filter: &MrFilter,
) -> Result<Vec<MrListRow>, LoreError> {
    let cli_filter = MrListFilters {
        limit: filter.limit,
        project: filter.project.as_deref(),
        state: filter.state.as_deref(),
        author: filter.author.as_deref(),
        assignee: filter.assignee.as_deref(),
        reviewer: filter.reviewer.as_deref(),
        labels: filter.labels.as_slice().into(),
        since: filter.since.as_deref(),
        draft: filter.draft.unwrap_or(false),
        no_draft: filter.draft.map_or(false, |d| !d),
        target_branch: filter.target_branch.as_deref(),
        source_branch: filter.source_branch.as_deref(),
        sort: "updated_at",
        order: "desc",
    };
    let result = crate::cli::commands::list::query_mrs(conn, &cli_filter)?;
    Ok(result.mrs)
}

/// Fetch MR detail with discussions and file changes.
/// Uses EntityKey (project_id + iid) for safe multi-project identity.
pub fn fetch_mr_detail(
    conn: &Connection,
    key: &EntityKey,
) -> Result<MrDetail, LoreError> {
    crate::cli::commands::show::query_mr_detail(conn, key.iid, Some(key.project_id))
}

/// Execute search query bridging TUI search mode to FTS query mode.
/// The `mode` parameter is a first-class query input sourced from the TUI
/// mode selector (hybrid / lexical / semantic), ensuring the UI toggle
/// actually changes query behavior.
pub fn execute_search(
    conn: &Connection,
    query: &str,
    filters: &SearchCliFilters,
    mode: SearchMode,
) -> Result<SearchResponse, LoreError> {
    let mode_str = match mode {
        SearchMode::Hybrid => "hybrid",
        SearchMode::Lexical => "lexical",
        SearchMode::Semantic => "semantic",
    };
    let fts_mode = FtsQueryMode::Phrase; // default for TUI
    crate::cli::commands::search::run_search_query(
        conn, query, filters.clone(), fts_mode, mode_str, false,
    )
}

/// Fetch timeline events.
pub fn fetch_timeline(
    conn: &Connection,
    params: &TimelineParams,
) -> Result<Vec<TimelineEvent>, LoreError> {
    // Use the existing 5-stage pipeline: SEED → HYDRATE → EXPAND → COLLECT → RENDER
    crate::core::timeline_seed::seed_from_query(conn, &params.query, params.depth, params.since_days)
}

/// Fetch who results.
pub fn fetch_who(
    conn: &Connection,
    params: &WhoParams,
) -> Result<WhoResult, LoreError> {
    // Bridge WhoParams to the existing WhoArgs structure
    crate::cli::commands::who::query_who(conn, params)
}

// ── Helper SQL queries ──────────────────────────────────────────────

fn count_total(conn: &Connection, table: &str) -> Result<u64, LoreError> {
    // SAFETY: table name is from our own code, not user input
    let sql = format!("SELECT COUNT(*) FROM {}", table);
    conn.query_row(&sql, [], |r| r.get(0))
        .map_err(|e| LoreError::Database(e.to_string()))
}

fn count_by_state(conn: &Connection, table: &str, state: &str) -> Result<u64, LoreError> {
    let sql = format!("SELECT COUNT(*) FROM {} WHERE state = ?1", table);
    conn.query_row(&sql, [state], |r| r.get(0))
        .map_err(|e| LoreError::Database(e.to_string()))
}

/// All action/query functions receive `now_ms` computed once per frame from the
/// injected Clock rather than calling SystemTime::now() internally. This ensures:
/// 1. Consistent relative-time labels within a single frame
/// 2. Deterministic output under FakeClock in snapshot tests
/// 3. Lower per-frame overhead (one syscall per frame, not per row)
fn fetch_recent_activity(
    conn: &Connection,
    limit: usize,
    now_ms: i64,
) -> Result<Vec<RecentActivityItem>, LoreError> {
    // Union of recently updated issues and MRs, sorted by updated_at desc
    let sql = r#"
        SELECT 'issue' AS entity_type, iid, title, state, updated_at
        FROM issues
        UNION ALL
        SELECT 'mr' AS entity_type, iid, title, state, updated_at
        FROM merge_requests
        ORDER BY updated_at DESC
        LIMIT ?1
    "#;
    let mut stmt = conn.prepare(sql)?;
    let rows = stmt.query_map([limit as i64], |row| {
        let updated_at: i64 = row.get(4)?;
        // now_ms is passed in from the injected Clock (computed once per frame)
        Ok(RecentActivityItem {
            entity_type: row.get(0)?,
            iid: row.get(1)?,
            title: row.get(2)?,
            state: row.get(3)?,
            minutes_ago: ((now_ms - updated_at) / 60_000).max(0) as u64,
        })
    })?;
    rows.collect::<Result<Vec<_>, _>>()
        .map_err(|e| LoreError::Database(e.to_string()))
}

fn fetch_project_sync_status(
    conn: &Connection,
    now_ms: i64,
) -> Result<Vec<ProjectSyncInfo>, LoreError> {
    let sql = r#"
        SELECT p.path_with_namespace,
               COALESCE(MAX(sl.started_at), 0) as last_sync
        FROM projects p
        LEFT JOIN sync_locks sl ON sl.project_id = p.id
        GROUP BY p.id
        ORDER BY p.path_with_namespace
    "#;
    let mut stmt = conn.prepare(sql)?;
    // now_ms is passed in from the injected Clock (computed once per frame)
    let rows = stmt.query_map([], |row| {
        let last_sync: i64 = row.get(1)?;
        Ok(ProjectSyncInfo {
            path: row.get(0)?,
            minutes_since_sync: if last_sync > 0 {
                ((now_ms - last_sync) / 60_000).max(0) as u64
            } else { u64::MAX },
        })
    })?;
    rows.collect::<Result<Vec<_>, _>>()
        .map_err(|e| LoreError::Database(e.to_string()))
}

fn fetch_last_sync_info(conn: &Connection, now_ms: i64) -> Result<LastSyncInfo, LoreError> {
    // Query the most recent sync lock for timing info
    let sql = r#"
        SELECT started_at, completed_at
        FROM sync_locks
        WHERE completed_at IS NOT NULL
        ORDER BY completed_at DESC
        LIMIT 1
    "#;
    let result = conn.query_row(sql, [], |row| {
        let started: i64 = row.get(0)?;
        let completed: i64 = row.get(1)?;
        // now_ms is passed in from the injected Clock (computed once per frame)
        let duration_secs = (completed - started) / 1000;
        Ok(LastSyncInfo {
            minutes_ago: ((now_ms - completed) / 60_000).max(0) as u64,
            duration_display: format!("{}m {}s", duration_secs / 60, duration_secs % 60),
            new_issues: 0, updated_issues: 0, // populated from sync result if available
            new_mrs: 0, updated_mrs: 0,
            errors: 0,
        })
    });
    result.or_else(|_| Ok(LastSyncInfo {
        minutes_ago: u64::MAX,
        duration_display: "never".into(),
        new_issues: 0, updated_issues: 0, new_mrs: 0, updated_mrs: 0, errors: 0,
    }))
}
```

### 10.12 Common Widgets — Shared Rendering Helpers

```rust
// crates/lore-tui/src/view/common/mod.rs

pub mod filter_bar;
pub mod entity_table;
pub mod discussion_tree;
pub mod cross_ref;

use ftui::prelude::*;
use ftui::layout::{Flex, Constraint};
use ftui::widgets::{Panel, Paragraph, StatusLine, StatusItem, Badge, Modal};
use ftui::widgets::modal::{ModalPosition, ModalSizeConstraints, BackdropConfig};
use ftui::text::{Line, Span};
use crate::tui::navigation::NavigationStack;
use crate::tui::message::Screen;

/// Render breadcrumb bar showing the full navigation path.
pub fn render_breadcrumb(nav: &NavigationStack, frame: &mut Frame, area: Rect) {
    let crumbs = nav.breadcrumbs();
    let spans: Vec<Span> = crumbs.iter().enumerate().flat_map(|(i, label)| {
        let mut v = Vec::new();
        if i > 0 {
            v.push(Span::styled(" > ", Style::default().fg(Color::rgb(80, 80, 80))));
        }
        if i == crumbs.len() - 1 {
            // Active screen — bold
            v.push(Span::styled(
                *label,
                Style::default().add_modifier(Modifier::BOLD),
            ));
        } else {
            v.push(Span::styled(
                *label,
                Style::default().fg(Color::rgb(128, 128, 128)),
            ));
        }
        v
    }).collect();

    let line = Line::from(vec![
        Span::styled(" lore ", Style::default().add_modifier(Modifier::BOLD)),
    ].into_iter().chain(spans).collect::<Vec<_>>());

    frame.render_widget(Paragraph::new(line), area);
}

/// Render the status bar with context-sensitive keybinding hints.
pub fn render_status_bar(
    screen: &Screen, input_mode: &InputMode, frame: &mut Frame, area: Rect,
) {
    let mut status = StatusLine::new()
        .style(Style::default().fg(Color::rgb(180, 180, 180)).bg(Color::rgb(30, 30, 30)));

    // Go-prefix indicator (driven by InputMode state machine)
    if matches!(input_mode, InputMode::GoPrefix { .. }) {
        status = status.left(StatusItem::text("g--"));
    }

    // Context-sensitive hints based on current screen
    match screen {
        Screen::Dashboard => {
            status = status
                .left(StatusItem::key_hint("i", "issues"))
                .left(StatusItem::key_hint("m", "mrs"))
                .left(StatusItem::key_hint("/", "search"))
                .left(StatusItem::key_hint("s", "sync"))
                .right(StatusItem::key_hint("?", "help"))
                .right(StatusItem::key_hint("q", "quit"));
        }
        Screen::IssueList | Screen::MrList => {
            status = status
                .left(StatusItem::key_hint("j/k", "navigate"))
                .left(StatusItem::key_hint("Enter", "open"))
                .left(StatusItem::key_hint("f", "filter"))
                .left(StatusItem::key_hint("Tab", "sort"))
                .right(StatusItem::key_hint("Esc", "back"))
                .right(StatusItem::key_hint("?", "help"));
        }
        Screen::IssueDetail(_) | Screen::MrDetail(_) => {
            status = status
                .left(StatusItem::key_hint("j/k", "scroll"))
                .left(StatusItem::key_hint("d", "discussions"))
                .left(StatusItem::key_hint("o", "browser"))
                .left(StatusItem::key_hint("t", "timeline"))
                .right(StatusItem::key_hint("Esc", "back"));
        }
        Screen::Search => {
            status = status
                .left(StatusItem::key_hint("/", "query"))
                .left(StatusItem::key_hint("Tab", "mode"))
                .left(StatusItem::key_hint("Enter", "open"))
                .right(StatusItem::key_hint("Esc", "back"));
        }
        Screen::Timeline => {
            status = status
                .left(StatusItem::key_hint("j/k", "scroll"))
                .left(StatusItem::key_hint("Enter", "entity"))
                .left(StatusItem::key_hint("d", "depth"))
                .left(StatusItem::key_hint("s", "since"))
                .right(StatusItem::key_hint("Esc", "back"));
        }
        Screen::Who => {
            status = status
                .left(StatusItem::key_hint("Tab", "mode"))
                .left(StatusItem::key_hint("/", "path"))
                .left(StatusItem::key_hint("Enter", "open"))
                .right(StatusItem::key_hint("Esc", "back"));
        }
        Screen::Sync => {
            status = status
                .left(StatusItem::key_hint("Enter", "start"))
                .left(StatusItem::key_hint("f", "full"))
                .left(StatusItem::key_hint("e", "embed"))
                .right(StatusItem::key_hint("Esc", "back"));
        }
        _ => {
            status = status
                .right(StatusItem::key_hint("Esc", "back"))
                .right(StatusItem::key_hint("q", "quit"));
        }
    }

    frame.render_widget(status, area);
}

/// Render a loading spinner overlay centered in the given area.
pub fn render_loading(frame: &mut Frame, area: Rect) {
    let text = Paragraph::new(" Loading... ")
        .alignment(ftui::layout::Alignment::Center);
    // Center vertically
    let y = area.y + area.height / 2;
    let centered = Rect::new(area.x, y, area.width, 1);
    frame.render_widget(text, centered);
}

/// Render an error toast overlay at the bottom of the screen.
pub fn render_error_toast(msg: &str, frame: &mut Frame, area: Rect) {
    let toast_area = Rect::new(
        area.x + 2,
        area.y + area.height.saturating_sub(3),
        area.width.saturating_sub(4),
        1,
    );
    let para = Paragraph::new(
        Line::from(vec![
            Span::styled(" ERROR ", Style::default()
                .fg(Color::rgb(255, 255, 255))
                .bg(Color::rgb(220, 50, 50))
                .add_modifier(Modifier::BOLD)),
            Span::raw(" "),
            Span::raw(msg),
        ]),
    );
    frame.render_widget(para, toast_area);
}

/// Render a help overlay showing all keybindings for the current screen.
pub fn render_help_overlay(
    screen: &Screen, frame: &mut Frame, area: Rect,
) {
    let bindings = help_text_for_screen(screen);
    let lines: Vec<Line> = bindings.iter().map(|(key, desc)| {
        Line::from(vec![
            Span::styled(
                format!("  {:<12} ", key),
                Style::default().add_modifier(Modifier::BOLD),
            ),
            Span::raw(*desc),
        ])
    }).collect();

    let para = Paragraph::new(lines);
    let size = ModalSizeConstraints::new()
        .min_width(40).max_width(60)
        .min_height(10).max_height(25);
    let modal = Modal::new(para)
        .position(ModalPosition::Center)
        .backdrop(BackdropConfig::new(Color::rgb(0, 0, 0), 0.6))
        .size(size);
    frame.render_widget(modal, area);
}

fn help_text_for_screen(screen: &Screen) -> Vec<(&'static str, &'static str)> {
    let mut bindings = vec![
        ("q", "Quit"),
        ("Esc", "Go back"),
        ("H", "Home (Dashboard)"),
        ("g + key", "Go-prefix navigation"),
        ("Ctrl+P", "Command palette"),
        ("?", "Toggle this help"),
        ("o", "Open in browser"),
    ];

    match screen {
        Screen::IssueList | Screen::MrList => {
            bindings.extend([
                ("j / Down", "Move selection down"),
                ("k / Up", "Move selection up"),
                ("Enter", "Open selected item"),
                ("G", "Jump to bottom"),
                ("gg", "Jump to top"),
                ("f / Tab", "Focus filter bar"),
                ("r", "Refresh"),
            ]);
        }
        Screen::IssueDetail(_) | Screen::MrDetail(_) => {
            bindings.extend([
                ("j / Down", "Scroll down"),
                ("k / Up", "Scroll up"),
                ("Space", "Page down"),
                ("b", "Page up"),
                ("d", "Toggle discussion expand"),
                ("t", "Timeline for this entity"),
                ("Enter", "Follow cross-reference"),
            ]);
        }
        Screen::Search => {
            bindings.extend([
                ("/", "Focus search input"),
                ("Tab", "Cycle search mode"),
                ("j / k", "Navigate results"),
                ("Enter", "Open result"),
            ]);
        }
        _ => {}
    }

    bindings
}
```

#### 10.12.1 Filter Bar Widget

The filter bar provides inline text-based filtering for list views (issues, MRs). It uses FrankenTUI's `TextInput` widget and interprets structured filter syntax (`state:opened author:jane`).

```rust
// crates/lore-tui/src/view/common/filter_bar.rs

use ftui::prelude::*;
use ftui::widgets::input::TextInput;
use ftui::text::{Line, Span};

/// Shared filter bar state. Owned by the parent screen's state (e.g. IssueListState).
pub struct FilterBarState {
    pub input: TextInput,
    pub focused: bool,
    /// Parsed filter tokens extracted from the input text.
    pub tokens: Vec<FilterToken>,
    /// Parse errors from unknown or invalid filter fields.
    /// Displayed inline below the filter bar in error color.
    pub errors: Vec<String>,
    /// Non-fatal parse warnings (e.g., value coercions).
    pub warnings: Vec<String>,
}

#[derive(Debug, Clone)]
pub struct FilterToken {
    pub field: String,   // e.g. "state", "author", "label"
    pub value: String,   // e.g. "opened", "jane", "bug"
}

impl FilterBarState {
    pub fn new() -> Self {
        Self {
            input: TextInput::new()
                .with_placeholder("Filter: state:opened author:... label:... (f to focus)")
                .with_style(Style::default().fg(Color::rgb(180, 180, 180))),
            focused: false,
            tokens: Vec::new(),
            errors: Vec::new(),
            warnings: Vec::new(),
        }
    }

    pub fn focus(&mut self) {
        self.focused = true;
        self.input.set_focused(true);
    }

    pub fn blur(&mut self) {
        self.focused = false;
        self.input.set_focused(false);
    }

    /// Handle a key event when focused. Returns true if consumed.
    pub fn handle_event(&mut self, event: &Event) -> bool {
        if !self.focused { return false; }
        match event {
            Event::Key(key) if key.code == KeyCode::Escape => {
                self.blur();
                true
            }
            Event::Key(key) if key.code == KeyCode::Enter => {
                self.parse_tokens();
                self.blur();
                true
            }
            _ => self.input.handle_event(event),
        }
    }

    /// Parse the input text into structured filter tokens.
    /// Supports "field:value" pairs and bare text (interpreted as title search).
    fn parse_tokens(&mut self) {
        let text = self.input.value().to_string();
        self.tokens = text.split_whitespace().map(|chunk| {
            if let Some((field, value)) = chunk.split_once(':') {
                FilterToken {
                    field: field.to_lowercase(),
                    value: value.to_string(),
                }
            } else {
                FilterToken {
                    field: "title".to_string(),
                    value: chunk.to_string(),
                }
            }
        }).collect();
    }

    /// Apply parsed tokens to an IssueFilter.
    /// Unknown fields are collected into `self.errors` for inline display.
    pub fn apply_to_issue_filter(&mut self, base: &mut crate::tui::state::IssueFilter) {
        self.errors.clear();
        self.warnings.clear();
        for token in &self.tokens {
            match token.field.as_str() {
                "state" | "s"     => base.state = Some(token.value.clone()),
                "author" | "a"    => base.author = Some(token.value.clone()),
                "assignee"        => base.assignee = Some(token.value.clone()),
                "label" | "l"     => base.labels.push(token.value.clone()),
                "milestone" | "m" => base.milestone = Some(token.value.clone()),
                "since"           => base.since = Some(token.value.clone()),
                "project" | "p"   => base.project = Some(token.value.clone()),
                "title"           => {} // bare text interpreted as title search — no error
                _ => self.errors.push(format!("Unknown filter field: '{}'", token.field)),
            }
        }
    }

    /// Apply parsed tokens to an MrFilter (same pattern, MR-specific fields).
    /// Unknown fields are collected into `self.errors` for inline display.
    pub fn apply_to_mr_filter(&mut self, base: &mut crate::tui::state::MrFilter) {
        self.errors.clear();
        self.warnings.clear();
        for token in &self.tokens {
            match token.field.as_str() {
                "state" | "s"      => base.state = Some(token.value.clone()),
                "author" | "a"     => base.author = Some(token.value.clone()),
                "reviewer" | "r"   => base.reviewer = Some(token.value.clone()),
                "assignee"         => base.assignee = Some(token.value.clone()),
                "label" | "l"      => base.labels.push(token.value.clone()),
                "draft"            => base.draft = Some(token.value == "true" || token.value == "yes"),
                "target" | "tb"    => base.target_branch = Some(token.value.clone()),
                "source" | "sb"    => base.source_branch = Some(token.value.clone()),
                "since"            => base.since = Some(token.value.clone()),
                "project" | "p"    => base.project = Some(token.value.clone()),
                "title"            => {} // bare text — no error
                _ => self.errors.push(format!("Unknown filter field: '{}'", token.field)),
            }
        }
    }
}

/// Render the filter bar as a single-line widget.
pub fn render(state: &FilterBarState, frame: &mut Frame, area: Rect) {
    if state.focused {
        // When focused, show the full TextInput with cursor
        frame.render_widget(&state.input, area);
    } else {
        // When unfocused, show a summary of active filters
        let mut spans = vec![
            Span::styled("Filter: ", Style::default().fg(Color::rgb(100, 100, 100))),
        ];
        if state.tokens.is_empty() {
            spans.push(Span::styled(
                "[press f to filter]",
                Style::default().fg(Color::rgb(80, 80, 80)),
            ));
        } else {
            for (i, token) in state.tokens.iter().enumerate() {
                if i > 0 { spans.push(Span::raw(" ")); }
                spans.push(Span::styled(
                    format!("{}:", token.field),
                    Style::default().fg(Color::rgb(100, 180, 255)),
                ));
                spans.push(Span::raw(&token.value));
            }
        }
        frame.render_widget(Paragraph::new(Line::from(spans)), area);
    }
}
```

#### 10.12.2 Entity Table Widget

Generic sortable table used by both issue and MR list views to avoid duplicating column/row rendering logic.

```rust
// crates/lore-tui/src/view/common/entity_table.rs

use ftui::prelude::*;
use ftui::widgets::table::{Table, TableState};
use ftui::text::Span;

/// Column definition for the entity table.
pub struct ColumnDef {
    pub header: &'static str,
    pub constraint: Constraint,
}

/// Render a table with the given columns, rows, and selected index.
/// The `rows` parameter is pre-built by the caller (issue_list or mr_list)
/// since column extraction is entity-specific.
pub fn render_entity_table(
    columns: &[ColumnDef],
    rows: Vec<Vec<Span>>,
    selected: usize,
    frame: &mut Frame,
    area: Rect,
) {
    let widths: Vec<Constraint> = columns.iter().map(|c| c.constraint).collect();
    let header_spans: Vec<Span> = columns.iter().map(|c| {
        Span::styled(c.header, Style::default().add_modifier(Modifier::BOLD))
    }).collect();

    let table = Table::new(rows, widths)
        .header(header_spans)
        .highlight_style(Style::default().bg(Color::rgb(50, 80, 140)));

    let mut ts = TableState::default();
    ts.select(Some(selected));
    frame.render_stateful_widget(table, area, &mut ts);
}

/// Build column definitions for issue list, adapting to terminal width.
pub fn issue_columns(bp: ftui::layout::Breakpoint) -> Vec<ColumnDef> {
    use ftui::layout::{Breakpoint, Constraint};
    let mut cols = vec![
        ColumnDef { header: "IID",     constraint: Constraint::Fixed(6) },
        ColumnDef { header: "Title",   constraint: Constraint::Min(20) },
        ColumnDef { header: "State",   constraint: Constraint::Fixed(8) },
        ColumnDef { header: "Updated", constraint: Constraint::Fixed(10) },
    ];
    if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) {
        cols.push(ColumnDef { header: "Author",   constraint: Constraint::Fixed(12) });
        cols.push(ColumnDef { header: "Assignee", constraint: Constraint::Fixed(12) });
    }
    if matches!(bp, Breakpoint::Lg | Breakpoint::Xl) {
        cols.push(ColumnDef { header: "Labels",   constraint: Constraint::Min(15) });
    }
    cols
}

/// Build column definitions for MR list, adapting to terminal width.
pub fn mr_columns(bp: ftui::layout::Breakpoint) -> Vec<ColumnDef> {
    use ftui::layout::{Breakpoint, Constraint};
    let mut cols = vec![
        ColumnDef { header: "IID",     constraint: Constraint::Fixed(6) },
        ColumnDef { header: "Title",   constraint: Constraint::Min(20) },
        ColumnDef { header: "State",   constraint: Constraint::Fixed(8) },
        ColumnDef { header: "Updated", constraint: Constraint::Fixed(10) },
    ];
    if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) {
        cols.push(ColumnDef { header: "Draft",  constraint: Constraint::Fixed(5) });
        cols.push(ColumnDef { header: "Author", constraint: Constraint::Fixed(12) });
    }
    if matches!(bp, Breakpoint::Lg | Breakpoint::Xl) {
        cols.push(ColumnDef { header: "Source", constraint: Constraint::Fixed(15) });
        cols.push(ColumnDef { header: "Target", constraint: Constraint::Fixed(15) });
    }
    cols
}
```

#### 10.12.3 Discussion Tree Widget

Wraps FrankenTUI's `Tree` widget to render threaded discussions uniformly across Issue Detail and MR Detail views.

```rust
// crates/lore-tui/src/view/common/discussion_tree.rs

use ftui::prelude::*;
use ftui::widgets::tree::{Tree, TreeNode, TreeGuides};
use ftui::widgets::Panel;
use ftui::text::Span;
use crate::tui::state::issue_detail::{DiscussionViewState, ReplyViewState};

/// Build and render a discussion tree from DiscussionViewState items.
/// Used by both issue_detail and mr_detail views.
pub fn render_discussion_tree(
    discussions: &[DiscussionViewState],
    title: &str,
    frame: &mut Frame,
    area: Rect,
) {
    let mut root = TreeNode::new(format!("{} ({} threads)", title, discussions.len()));

    for disc in discussions {
        let header = format!(
            "{}  {} replies  {}",
            disc.author, disc.reply_count, disc.relative_age,
        );
        let mut thread = TreeNode::new(header).with_expanded(disc.expanded);

        // Root note body (first ~200 chars)
        thread = thread.child(
            TreeNode::new(truncate(&disc.root_note_body, 200))
        );

        // Reply notes
        for reply in &disc.replies {
            thread = thread.child(
                TreeNode::new(format!(
                    "{}: {}  {}",
                    reply.author,
                    truncate(&reply.body, 120),
                    reply.relative_age,
                ))
            );
        }

        root = root.child(thread);
    }

    let tree = Tree::new(root)
        .with_show_root(true)
        .with_guides(TreeGuides::Rounded);
    let panel = Panel::new(tree).title(title);
    frame.render_widget(panel, area);
}

/// All truncation uses `crate::text_width::truncate_display_width()` — terminal
/// display width (via unicode-width), not byte/char length. Handles CJK, emoji,
/// combining marks, and zero-width joiners correctly for column alignment.
fn truncate(s: &str, max: usize) -> String {
    crate::text_width::truncate_display_width(s, max)
}
```

#### 10.12.4 Cross-Reference Widget

Renders clickable-style entity references (issue `#123`, MR `!456`) with color coding. These are navigation targets — pressing Enter on a focused cross-ref navigates to the referenced entity.

```rust
// crates/lore-tui/src/view/common/cross_ref.rs

use ftui::prelude::*;
use ftui::text::{Line, Span};
use crate::tui::message::{Msg, Screen};

/// A parsed cross-reference to a GitLab entity.
#[derive(Debug, Clone)]
pub struct CrossRef {
    pub kind: CrossRefKind,
    pub project_path: String,
    pub iid: i64,
    pub label: String,       // Display string: "group/project#123"
}

#[derive(Debug, Clone, PartialEq)]
pub enum CrossRefKind {
    Issue,
    MergeRequest,
}

impl CrossRef {
    /// Parse a reference string like "group/project#123" or "group/project!456"
    pub fn parse(s: &str) -> Option<Self> {
        let (prefix, sigil, kind) = if let Some(pos) = s.rfind('#') {
            (s[..pos].to_string(), pos, CrossRefKind::Issue)
        } else if let Some(pos) = s.rfind('!') {
            (s[..pos].to_string(), pos, CrossRefKind::MergeRequest)
        } else {
            return None;
        };
        let iid_str = &s[sigil + 1..];
        let iid = iid_str.parse::<i64>().ok()?;
        Some(Self {
            kind,
            project_path: prefix,
            iid,
            label: s.to_string(),
        })
    }

    /// Convert this cross-ref into a navigation message.
    /// Resolves project_path → project_id via DB lookup before navigating.
    /// This ensures correct entity resolution in multi-project datasets
    /// where bare iid is ambiguous.
    pub fn to_nav_msg(&self, project_id: i64) -> Msg {
        let key = EntityKey {
            project_id,
            iid: self.iid,
            kind: match self.kind {
                CrossRefKind::Issue => EntityKind::Issue,
                CrossRefKind::MergeRequest => EntityKind::MergeRequest,
            },
        };
        match self.kind {
            CrossRefKind::Issue => Msg::NavigateTo(Screen::IssueDetail(key)),
            CrossRefKind::MergeRequest => Msg::NavigateTo(Screen::MrDetail(key)),
        }
    }

    /// Render as a styled Span (issue = green sigil, MR = blue sigil).
    pub fn to_span(&self) -> Span {
        let color = match self.kind {
            CrossRefKind::Issue => Color::rgb(80, 200, 120),
            CrossRefKind::MergeRequest => Color::rgb(100, 160, 255),
        };
        Span::styled(
            &self.label,
            Style::default().fg(color).add_modifier(Modifier::UNDERLINED),
        )
    }
}

/// Render a list of cross-references as a horizontal flow within the given area.
pub fn render_cross_refs(refs: &[CrossRef], frame: &mut Frame, area: Rect) {
    let spans: Vec<Span> = refs.iter().flat_map(|r| {
        vec![r.to_span(), Span::raw("  ")]
    }).collect();

    if spans.is_empty() {
        frame.render_widget(
            Paragraph::new(Span::styled("(none)", Style::default().fg(Color::rgb(80, 80, 80)))),
            area,
        );
    } else {
        frame.render_widget(Paragraph::new(Line::from(spans)), area);
    }
}
```

### 10.13 Issue List View

```rust
// crates/lore-tui/src/view/issue_list.rs

use ftui::prelude::*;
use ftui::layout::{Flex, Constraint, Breakpoint};
use ftui::widgets::{Panel, Paragraph, Table, TableState};
use ftui::text::{Line, Span};
use crate::tui::state::issue_list::IssueListState;
use crate::tui::theme;
use crate::tui::view::dashboard::LORE_BREAKPOINTS;

pub fn render(state: &IssueListState, frame: &mut Frame, area: Rect) {
    let layout = Flex::vertical()
        .constraints([
            Constraint::Fixed(1),   // filter bar
            Constraint::Min(0),     // table
        ])
        .split(area);

    render_filter_bar(state, frame, layout[0]);
    render_table(state, frame, layout[1]);
}

fn render_filter_bar(state: &IssueListState, frame: &mut Frame, area: Rect) {
    let filter = &state.active_filter;
    let parts = vec![
        format!("state:{}", filter.state.as_deref().unwrap_or("all")),
        filter.author.as_ref().map_or(String::new(), |a| format!("author:{}", a)),
        filter.assignee.as_ref().map_or(String::new(), |a| format!("assignee:{}", a)),
        if filter.labels.is_empty() { String::new() }
        else { format!("labels:{}", filter.labels.join(",")) },
    ].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join("  ");

    let style = if state.filter_focused {
        Style::default().fg(Color::rgb(100, 150, 255))
    } else {
        Style::default().fg(Color::rgb(128, 128, 128))
    };

    let line = Line::from(vec![
        Span::styled(" Filter: ", style.add_modifier(Modifier::BOLD)),
        Span::styled(&parts, style),
        if state.filter_focused {
            Span::styled("  [Tab]apply [Esc]close", Style::default().fg(Color::rgb(80, 80, 80)))
        } else {
            Span::styled("  [f]edit", Style::default().fg(Color::rgb(80, 80, 80)))
        },
    ]);
    frame.render_widget(Paragraph::new(line), area);
}

fn render_table(state: &IssueListState, frame: &mut Frame, area: Rect) {
    let bp = LORE_BREAKPOINTS.classify_width(area.width);

    // Column widths adapt to terminal width
    let (widths, show_labels, show_author) = match bp {
        Breakpoint::Xs | Breakpoint::Sm => (
            vec![Constraint::Fixed(6), Constraint::Min(20), Constraint::Fixed(8), Constraint::Fixed(10)],
            false, false,
        ),
        Breakpoint::Md => (
            vec![
                Constraint::Fixed(6), Constraint::Min(30), Constraint::Fixed(8),
                Constraint::Fixed(10), Constraint::Fixed(12),
            ],
            false, true,
        ),
        Breakpoint::Lg | Breakpoint::Xl => (
            vec![
                Constraint::Fixed(6), Constraint::Min(30), Constraint::Fixed(8),
                Constraint::Fixed(12), Constraint::Fixed(20), Constraint::Fixed(12),
            ],
            true, true,
        ),
    };

    // Header row
    let mut header = vec!["IID", "Title", "State", "Updated"];
    if show_author { header.push("Author"); }
    if show_labels { header.push("Labels"); }

    // Data rows
    let rows: Vec<Vec<Span>> = state.items.iter().enumerate().map(|(i, issue)| {
        let selected = i == state.selected_index;
        let state_color = theme::state_color(&issue.state);

        let mut cols = vec![
            Span::raw(format!("{}", issue.iid)),
            Span::raw(truncate(&issue.title, 40)),
            Span::styled(&issue.state, Style::default().fg(state_color)),
            Span::raw(format_relative_ms(issue.updated_at)),
        ];
        if show_author {
            cols.push(Span::raw(format!("@{}", issue.author_username)));
        }
        if show_labels {
            cols.push(Span::raw(issue.labels.join(", ")));
        }
        cols
    }).collect();

    // Table widget (StatefulWidget pattern)
    let table = Table::new(rows, widths)
        .header(header)
        .highlight_style(
            Style::default().bg(Color::rgb(50, 80, 140)).fg(Color::rgb(255, 255, 255)),
        );

    let mut table_state = TableState::default();
    table_state.select(Some(state.selected_index));

    frame.render_stateful_widget(table, area, &mut table_state);

    // Row counter
    let counter = format!(" {}/{} ", state.selected_index + 1, state.items.len());
    let counter_area = Rect::new(
        area.x + area.width - counter.len() as u16 - 1,
        area.y + area.height - 1,
        counter.len() as u16,
        1,
    );
    frame.render_widget(
        Paragraph::new(counter).style(Style::default().fg(Color::rgb(128, 128, 128))),
        counter_area,
    );
}

/// All truncation uses `crate::text_width::truncate_display_width()` — terminal
/// display width (via unicode-width), not byte/char length. Handles CJK, emoji,
/// combining marks, and zero-width joiners correctly for column alignment.
fn truncate(s: &str, max: usize) -> String {
    crate::text_width::truncate_display_width(s, max)
}

/// Accepts `now_ms` computed once per frame from the injected Clock.
/// Never calls SystemTime::now() directly — ensures deterministic
/// snapshot tests under FakeClock and consistent labels within one frame.
fn format_relative_ms(ms: i64, now_ms: i64) -> String {
    let minutes = ((now_ms - ms) / 60_000).max(0) as u64;
    if minutes < 60 { format!("{}m ago", minutes) }
    else if minutes < 1440 { format!("{}h ago", minutes / 60) }
    else { format!("{}d ago", minutes / 1440) }
}
```

### 10.14 Search View

```rust
// crates/lore-tui/src/view/search.rs

use ftui::prelude::*;
use ftui::layout::{Flex, Constraint, Breakpoint};
use ftui::widgets::{Panel, Paragraph, List};
use ftui::text::{Line, Span};
use ftui_extras::markdown::MarkdownRenderer;
use crate::tui::state::search::{SearchState, SearchMode};
use crate::tui::theme;
use crate::tui::view::dashboard::LORE_BREAKPOINTS;

pub fn render(state: &SearchState, frame: &mut Frame, area: Rect) {
    let layout = Flex::vertical()
        .constraints([
            Constraint::Fixed(2),   // query bar + mode selector
            Constraint::Min(0),     // results + preview
        ])
        .split(area);

    render_query_bar(state, frame, layout[0]);

    let bp = LORE_BREAKPOINTS.classify_width(area.width);
    let show_preview = !matches!(bp, Breakpoint::Xs | Breakpoint::Sm);

    if show_preview && !state.results.is_empty() {
        // Split pane: results left, preview right
        let split = Flex::horizontal()
            .constraints([
                Constraint::Percentage(45.0),
                Constraint::Percentage(55.0),
            ])
            .split(layout[1]);

        render_results(state, frame, split[0]);
        render_preview(state, frame, split[1]);
    } else {
        // Narrow: results only
        render_results(state, frame, layout[1]);
    }
}

fn render_query_bar(state: &SearchState, frame: &mut Frame, area: Rect) {
    let mode_label = match state.mode {
        SearchMode::Hybrid => "hybrid",
        SearchMode::Lexical => "lexical",
        SearchMode::Semantic => "semantic",
    };

    let lines = vec![
        Line::from(vec![
            Span::styled(" Query: ", Style::default().add_modifier(Modifier::BOLD)),
            Span::raw(state.query_input.value()),
            if state.query_focused {
                Span::styled("_", Style::default().add_modifier(Modifier::SLOW_BLINK))
            } else { Span::raw("") },
        ]),
        Line::from(vec![
            Span::styled(" Mode: ", Style::default().fg(Color::rgb(128, 128, 128))),
            Span::styled(
                format!("[{}]", mode_label),
                Style::default().fg(Color::rgb(100, 150, 255)),
            ),
            Span::styled("  Type: ", Style::default().fg(Color::rgb(128, 128, 128))),
            Span::styled(
                format!("[{}]", state.source_type.as_deref().unwrap_or("all")),
                Style::default().fg(Color::rgb(100, 150, 255)),
            ),
        ]),
    ];
    frame.render_widget(Paragraph::new(lines), area);
}

fn render_results(state: &SearchState, frame: &mut Frame, area: Rect) {
    let header = format!(
        " Results ({} matches) ",
        state.total_results,
    );

    let items: Vec<Line> = state.results.iter().enumerate().map(|(i, result)| {
        let selected = i == state.selected_index;
        let prefix = match result.source_type.as_str() {
            "issue" => "#",
            "merge_request" => "!",
            "discussion" => "@",
            _ => "",
        };
        let style = if selected {
            Style::default().bg(Color::rgb(50, 80, 140)).fg(Color::rgb(255, 255, 255))
        } else {
            Style::default()
        };

        Line::from(vec![
            Span::styled(format!("{} {}{} ", if selected { ">" } else { " " }, prefix, result.title), style),
        ])
    }).collect();

    let list = List::new(items);
    let panel = Panel::new(list).title(header);
    frame.render_widget(panel, area);
}

fn render_preview(state: &SearchState, frame: &mut Frame, area: Rect) {
    let Some(result) = state.results.get(state.selected_index) else {
        frame.render_widget(Paragraph::new("(no selection)"), area);
        return;
    };

    let mut lines = vec![
        Line::from(Span::styled(
            &result.title,
            Style::default().add_modifier(Modifier::BOLD),
        )),
        Line::raw(""),
        Line::from(vec![
            Span::styled("Type: ", dim()),
            Span::raw(&result.source_type),
            Span::raw("  "),
            Span::styled("Score: ", dim()),
            Span::raw(format!("{:.2}", result.score)),
        ]),
        Line::from(vec![
            Span::styled("Project: ", dim()),
            Span::raw(&result.project_path),
        ]),
    ];

    if let Some(author) = &result.author {
        lines.push(Line::from(vec![
            Span::styled("Author: ", dim()),
            Span::raw(format!("@{}", author)),
        ]));
    }

    if !result.labels.is_empty() {
        lines.push(Line::from(vec![
            Span::styled("Labels: ", dim()),
            Span::raw(result.labels.join(", ")),
        ]));
    }

    lines.push(Line::raw(""));
    lines.push(Line::styled("-- Snippet --", dim()));

    // Snippet text — wrap to available width
    for snippet_line in result.snippet.lines() {
        lines.push(Line::raw(snippet_line.to_string()));
    }

    let para = Paragraph::new(lines).wrap(ftui::widgets::paragraph::Wrap { trim: false });
    let panel = Panel::new(para).title(" Preview ");
    frame.render_widget(panel, area);
}

fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) }
```

### 10.15 Timeline View

```rust
// crates/lore-tui/src/view/timeline.rs

use ftui::prelude::*;
use ftui::layout::{Flex, Constraint};
use ftui::widgets::{Panel, Paragraph, List};
use ftui::text::{Line, Span};
use crate::tui::state::timeline::TimelineState;
use crate::tui::theme;

pub fn render(state: &TimelineState, frame: &mut Frame, area: Rect) {
    let layout = Flex::vertical()
        .constraints([
            Constraint::Fixed(2),   // query + controls
            Constraint::Fixed(1),   // seed summary
            Constraint::Min(0),     // event stream
        ])
        .split(area);

    // Query bar + controls
    let controls = Line::from(vec![
        Span::styled(" Query: ", Style::default().add_modifier(Modifier::BOLD)),
        Span::raw(state.query_input.value()),
        Span::raw("  "),
        Span::styled("Depth: ", dim()),
        Span::styled(format!("{}", state.depth), normal()),
        Span::styled("  Since: ", dim()),
        Span::styled(format!("{}d", state.since_days), normal()),
    ]);
    frame.render_widget(Paragraph::new(controls), layout[0]);

    // Seed summary
    let seed_line = Line::from(vec![
        Span::styled(" Seeds: ", dim()),
        Span::raw(state.seeds.join(", ")),
        Span::styled(format!("  Events: {}", state.events.len()), dim()),
    ]);
    frame.render_widget(Paragraph::new(seed_line), layout[1]);

    // Event stream — virtualized for large timelines
    let items: Vec<Line> = state.events.iter().enumerate().map(|(i, event)| {
        let selected = i == state.selected_index;
        let type_color = theme::event_type_color(&event.event_type);

        let prefix = if selected { ">" } else { " " };

        Line::from(vec![
            Span::raw(format!("{} ", prefix)),
            Span::styled(
                format!("{:<16}", event.timestamp_display),
                Style::default().fg(Color::rgb(100, 100, 100)),
            ),
            Span::styled(
                format!("{:<10}", event.event_type.to_uppercase()),
                Style::default().fg(type_color).add_modifier(Modifier::BOLD),
            ),
            Span::raw(format!(" {} ", event.entity_label)),
            Span::styled(
                truncate(&event.summary, 50),
                if selected {
                    Style::default().bg(Color::rgb(50, 80, 140)).fg(Color::rgb(255, 255, 255))
                } else {
                    Style::default()
                },
            ),
        ])
    }).collect();

    let list = List::new(items);
    let panel = Panel::new(list).title(" Timeline ");
    frame.render_widget(panel, layout[2]);
}

/// All truncation uses `crate::text_width::truncate_display_width()` — terminal
/// display width (via unicode-width), not byte/char length. Handles CJK, emoji,
/// combining marks, and zero-width joiners correctly for column alignment.
fn truncate(s: &str, max: usize) -> String {
    crate::text_width::truncate_display_width(s, max)
}

fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) }
fn normal() -> Style { Style::default().fg(Color::rgb(220, 220, 220)) }
```

### 10.16 Who View — Multi-Mode People Explorer

```rust
// crates/lore-tui/src/view/who.rs

use ftui::prelude::*;
use ftui::layout::{Flex, Constraint};
use ftui::widgets::{Panel, Paragraph, Table, TableState};
use ftui::text::{Line, Span};
use ftui_extras::charts::BarChart;
use crate::tui::state::who::{WhoState, WhoMode};
use crate::cli::commands::who::{WhoResult, Expert, WorkloadResult};
use crate::tui::theme;

pub fn render(state: &WhoState, frame: &mut Frame, area: Rect) {
    let layout = Flex::vertical()
        .constraints([
            Constraint::Fixed(1),   // mode selector + path input
            Constraint::Min(0),     // results
        ])
        .split(area);

    render_mode_bar(state, frame, layout[0]);

    match (&state.mode, &state.result) {
        (WhoMode::Expert, Some(WhoResult::Expert(r))) => render_expert(r, state, frame, layout[1]),
        (WhoMode::Workload, Some(WhoResult::Workload(r))) => render_workload(r, state, frame, layout[1]),
        (WhoMode::Reviews, Some(WhoResult::Reviews(r))) => render_reviews(r, state, frame, layout[1]),
        (WhoMode::Active, Some(WhoResult::Active(r))) => render_active(r, state, frame, layout[1]),
        (WhoMode::Overlap, Some(WhoResult::Overlap(r))) => render_overlap(r, state, frame, layout[1]),
        _ => {
            frame.render_widget(
                Paragraph::new(" Enter a path or username to search..."),
                layout[1],
            );
        }
    }
}

fn render_mode_bar(state: &WhoState, frame: &mut Frame, area: Rect) {
    let modes = ["Expert", "Workload", "Reviews", "Active", "Overlap"];
    let current = match state.mode {
        WhoMode::Expert => 0,
        WhoMode::Workload => 1,
        WhoMode::Reviews => 2,
        WhoMode::Active => 3,
        WhoMode::Overlap => 4,
    };

    let spans: Vec<Span> = modes.iter().enumerate().flat_map(|(i, label)| {
        let mut v = Vec::new();
        if i > 0 { v.push(Span::raw(" | ")); }
        if i == current {
            v.push(Span::styled(
                format!("[{}]", label),
                Style::default().add_modifier(Modifier::BOLD).fg(Color::rgb(100, 150, 255)),
            ));
        } else {
            v.push(Span::styled(*label, Style::default().fg(Color::rgb(128, 128, 128))));
        }
        v
    }).collect();

    let mut line_spans = vec![Span::styled(" Mode: ", dim())];
    line_spans.extend(spans);

    // Add path/username input based on mode
    match state.mode {
        WhoMode::Expert | WhoMode::Overlap => {
            line_spans.push(Span::raw("  Path: "));
            line_spans.push(Span::raw(state.path_input.value()));
        }
        WhoMode::Workload | WhoMode::Reviews => {
            line_spans.push(Span::raw("  User: "));
            line_spans.push(Span::raw(state.username_input.value()));
        }
        _ => {}
    }

    frame.render_widget(Paragraph::new(Line::from(line_spans)), area);
}

fn render_expert(
    result: &ExpertResult, state: &WhoState, frame: &mut Frame, area: Rect,
) {
    let split = Flex::horizontal()
        .constraints([Constraint::Percentage(60.0), Constraint::Percentage(40.0)])
        .split(area);

    // Expert table
    let widths = vec![
        Constraint::Fixed(4), Constraint::Fixed(12), Constraint::Fixed(6),
        Constraint::Fixed(8), Constraint::Fixed(8), Constraint::Fixed(6),
    ];
    let header = vec!["Rank", "Person", "Score", "Authored", "Reviewed", "Notes"];
    let rows: Vec<Vec<Span>> = result.experts.iter().enumerate().map(|(i, e)| {
        vec![
            Span::raw(format!("{}", i + 1)),
            Span::styled(format!("@{}", e.username), Style::default().add_modifier(Modifier::BOLD)),
            Span::raw(format!("{}", e.score)),
            Span::raw(format!("{} MRs", e.author_mr_count)),
            Span::raw(format!("{} MRs", e.review_mr_count)),
            Span::raw(format!("{}", e.review_note_count)),
        ]
    }).collect();

    let table = Table::new(rows, widths)
        .header(header)
        .highlight_style(Style::default().bg(Color::rgb(50, 80, 140)));

    let mut ts = TableState::default();
    ts.select(Some(state.selected_index));

    let panel_left = Panel::new(table)
        .title(format!(" Expert Rankings for {} ", result.path_query));
    frame.render_stateful_widget(panel_left, split[0], &mut ts);

    // Detail panel for selected expert (code area bar chart)
    if let Some(expert) = result.experts.get(state.selected_index) {
        render_expert_detail(expert, frame, split[1]);
    }
}

fn render_expert_detail(expert: &Expert, frame: &mut Frame, area: Rect) {
    // Show expert's MR references as a simple list
    let mut lines: Vec<Line> = vec![
        Line::from(Span::styled(
            format!("@{} -- Detail", expert.username),
            Style::default().add_modifier(Modifier::BOLD),
        )),
        Line::raw(""),
    ];

    if let Some(details) = &expert.details {
        for d in details.iter().take(10) {
            lines.push(Line::from(vec![
                Span::styled(format!("  {} ", d.mr_ref), Style::default().fg(Color::rgb(100, 150, 255))),
                Span::raw(truncate(&d.title, 30)),
                Span::styled(format!("  [{}]", d.role), dim()),
            ]));
        }
    }

    let para = Paragraph::new(lines);
    let panel = Panel::new(para).title(format!(" @{} ", expert.username));
    frame.render_widget(panel, area);
}

fn render_workload(
    result: &WorkloadResult, state: &WhoState, frame: &mut Frame, area: Rect,
) {
    let layout = Flex::vertical()
        .constraints([
            Constraint::Percentage(33.0),  // assigned issues
            Constraint::Percentage(33.0),  // authored MRs
            Constraint::Percentage(34.0),  // reviewing MRs
        ])
        .split(area);

    // Assigned issues section
    let issue_lines: Vec<Line> = result.assigned_issues.iter().map(|i| {
        Line::from(vec![
            Span::raw(format!("  {} ", i.ref_)),
            Span::raw(truncate(&i.title, 40)),
        ])
    }).collect();
    let panel = Panel::new(Paragraph::new(issue_lines))
        .title(format!(" Assigned Issues ({}) ", result.assigned_issues.len()));
    frame.render_widget(panel, layout[0]);

    // Authored MRs section
    let mr_lines: Vec<Line> = result.authored_mrs.iter().map(|m| {
        Line::from(vec![
            Span::raw(format!("  {} ", m.ref_)),
            if m.draft { Span::styled("[DRAFT] ", Style::default().fg(Color::rgb(220, 180, 50))) }
            else { Span::raw("") },
            Span::raw(truncate(&m.title, 40)),
        ])
    }).collect();
    let panel = Panel::new(Paragraph::new(mr_lines))
        .title(format!(" Authored MRs ({}) ", result.authored_mrs.len()));
    frame.render_widget(panel, layout[1]);

    // Reviewing MRs section
    let review_lines: Vec<Line> = result.reviewing_mrs.iter().map(|m| {
        Line::from(vec![
            Span::raw(format!("  {} ", m.ref_)),
            Span::raw(truncate(&m.title, 40)),
        ])
    }).collect();
    let panel = Panel::new(Paragraph::new(review_lines))
        .title(format!(" Reviewing ({}) ", result.reviewing_mrs.len()));
    frame.render_widget(panel, layout[2]);
}

fn render_reviews(result: &ReviewsResult, state: &WhoState, frame: &mut Frame, area: Rect) {
    let layout = Flex::vertical()
        .constraints([
            Constraint::Fixed(3),   // summary header
            Constraint::Min(0),     // category breakdown table
        ])
        .split(area);

    // Summary header
    let summary = Paragraph::new(vec![
        Line::from(vec![
            Span::styled("Reviewer: ", dim()),
            Span::styled(
                format!("@{}", result.username),
                Style::default().add_modifier(Modifier::BOLD),
            ),
        ]),
        Line::from(vec![
            Span::styled("Total diff notes: ", dim()),
            Span::raw(format!("{}", result.total_diffnotes)),
            Span::raw("  "),
            Span::styled("MRs reviewed: ", dim()),
            Span::raw(format!("{}", result.mrs_reviewed)),
            Span::raw("  "),
            Span::styled("Categorized: ", dim()),
            Span::raw(format!("{}", result.categorized_count)),
        ]),
    ]);
    frame.render_widget(summary, layout[0]);

    // Category breakdown as a table
    let widths = [
        Constraint::Min(20),     // Category name
        Constraint::Fixed(8),    // Count
        Constraint::Fixed(8),    // Percentage
        Constraint::Min(20),     // Visual bar
    ];
    let rows: Vec<Vec<Span>> = result.categories.iter().map(|cat| {
        let bar_len = (cat.percentage * 0.3) as usize; // 30-char max bar
        vec![
            Span::raw(&cat.name),
            Span::raw(format!("{:>5}", cat.count)),
            Span::raw(format!("{:>5.1}%", cat.percentage)),
            Span::styled(
                "\u{2588}".repeat(bar_len),
                Style::default().fg(Color::rgb(100, 180, 255)),
            ),
        ]
    }).collect();

    let table = Table::new(rows, widths)
        .header(["Category", "Count", "%", ""].map(|h|
            Span::styled(h, Style::default().add_modifier(Modifier::BOLD))
        ));
    let panel = Panel::new(table).title(" Review Category Breakdown ");
    frame.render_widget(panel, layout[1]);
}

fn render_active(result: &ActiveResult, state: &WhoState, frame: &mut Frame, area: Rect) {
    let layout = Flex::vertical()
        .constraints([
            Constraint::Fixed(2),   // summary
            Constraint::Min(0),     // discussion list
        ])
        .split(area);

    // Summary header
    let truncated_indicator = if result.truncated { " (truncated)" } else { "" };
    let summary = Paragraph::new(vec![
        Line::from(vec![
            Span::styled("Active discussions: ", dim()),
            Span::raw(format!("{}", result.discussions.len())),
            Span::raw(truncated_indicator),
            Span::raw("  "),
            Span::styled("Unresolved in window: ", dim()),
            Span::styled(
                format!("{}", result.total_unresolved_in_window),
                Style::default().fg(Color::rgb(220, 100, 100)),
            ),
        ]),
    ]);
    frame.render_widget(summary, layout[0]);

    // Discussion list with detail
    let widths = [
        Constraint::Fixed(8),    // Entity ref (type + iid)
        Constraint::Min(25),     // Entity title
        Constraint::Fixed(12),   // Project
        Constraint::Fixed(6),    // Notes
        Constraint::Fixed(6),    // Participants
        Constraint::Fixed(10),   // Last activity
    ];
    let rows: Vec<Vec<Span>> = result.discussions.iter().map(|d| {
        let entity_sigil = if d.entity_type == "issue" { "#" } else { "!" };
        let participants_label = if d.participants_truncated {
            format!("{}+", d.participants_total)
        } else {
            format!("{}", d.participants.len())
        };
        vec![
            Span::raw(format!("{}{}", entity_sigil, d.entity_iid)),
            Span::raw(truncate(&d.entity_title, 35)),
            Span::styled(truncate(&d.project_path, 11), dim()),
            Span::raw(format!("{:>3}", d.note_count)),
            Span::raw(format!("{:>3}", participants_label)),
            Span::raw(format_relative_ms(d.last_note_at)),
        ]
    }).collect();

    let table = Table::new(rows, widths)
        .header(["Ref", "Title", "Project", "Notes", "People", "Last"].map(|h|
            Span::styled(h, Style::default().add_modifier(Modifier::BOLD))
        ))
        .highlight_style(Style::default().bg(Color::rgb(50, 80, 140)));

    // Panel for border + title, then render stateful table into inner area
    let border = Panel::new(Paragraph::new("")).title(" Active Discussions ");
    let inner = border.inner(layout[1]);
    frame.render_widget(border, layout[1]);

    let mut ts = TableState::default();
    ts.select(Some(state.selected_index));
    frame.render_stateful_widget(table, inner, &mut ts);
}

fn render_overlap(result: &OverlapResult, state: &WhoState, frame: &mut Frame, area: Rect) {
    let layout = Flex::vertical()
        .constraints([
            Constraint::Fixed(2),   // path query header
            Constraint::Min(0),     // user overlap table
        ])
        .split(area);

    // Path query header
    let truncated_indicator = if result.truncated { " (truncated)" } else { "" };
    let header = Paragraph::new(vec![
        Line::from(vec![
            Span::styled("Path: ", dim()),
            Span::styled(
                &result.path_query,
                Style::default().add_modifier(Modifier::BOLD),
            ),
            Span::styled(format!("  ({}){}", result.path_match, truncated_indicator), dim()),
        ]),
    ]);
    frame.render_widget(header, layout[0]);

    // User overlap table — shows who has touched this file path
    let widths = [
        Constraint::Fixed(16),   // Username
        Constraint::Fixed(8),    // Total touches
        Constraint::Fixed(8),    // Author touches
        Constraint::Fixed(8),    // Review touches
        Constraint::Fixed(10),   // Last seen
        Constraint::Min(20),     // MR refs
    ];
    let rows: Vec<Vec<Span>> = result.users.iter().map(|u| {
        let mr_summary = if u.mr_refs_truncated {
            format!("{} (+{})", u.mr_refs.first().map_or("", String::as_str), u.mr_refs_total - 1)
        } else {
            u.mr_refs.join(", ")
        };
        vec![
            Span::styled(
                format!("@{}", u.username),
                Style::default().add_modifier(Modifier::BOLD),
            ),
            Span::raw(format!("{:>5}", u.touch_count)),
            Span::raw(format!("{:>5}", u.author_touch_count)),
            Span::raw(format!("{:>5}", u.review_touch_count)),
            Span::raw(format_relative_ms(u.last_seen_at)),
            Span::styled(truncate(&mr_summary, 30), dim()),
        ]
    }).collect();

    let table = Table::new(rows, widths)
        .header(["User", "Total", "Author", "Review", "Last Seen", "MR Refs"].map(|h|
            Span::styled(h, Style::default().add_modifier(Modifier::BOLD))
        ))
        .highlight_style(Style::default().bg(Color::rgb(50, 80, 140)));

    let mut ts = TableState::default();
    let panel = Panel::new(table).title(" File Overlap ");
    frame.render_widget(panel, layout[1]);
}

/// All truncation uses `crate::text_width::truncate_display_width()` — terminal
/// display width (via unicode-width), not byte/char length. Handles CJK, emoji,
/// combining marks, and zero-width joiners correctly for column alignment.
fn truncate(s: &str, max: usize) -> String {
    crate::text_width::truncate_display_width(s, max)
}

fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) }
```

### 10.17 MR List View

Structurally identical to Issue List (Section 10.13) with MR-specific columns:

```rust
// crates/lore-tui/src/view/mr_list.rs — same pattern as issue_list.rs

// Key differences from issue_list:
// 1. Columns: IID, Title, State, Draft, Source, Target, Author, Updated
// 2. Draft column shows badge when true
// 3. State includes "merged" and "locked" variants
// 4. Filter bar shows MR-specific fields (reviewer, draft, target-branch)

pub fn render(state: &MrListState, frame: &mut Frame, area: Rect) {
    // Same 2-row layout: filter bar + table
    let layout = Flex::vertical()
        .constraints([Constraint::Fixed(1), Constraint::Min(0)])
        .split(area);

    render_filter_bar(state, frame, layout[0]);
    render_table(state, frame, layout[1]);
}

fn render_table(state: &MrListState, frame: &mut Frame, area: Rect) {
    let bp = LORE_BREAKPOINTS.classify_width(area.width);

    // Narrow: IID, Title, State, Updated
    // Medium: + Draft, Author
    // Wide: + Source, Target
    let widths = match bp {
        Breakpoint::Xs | Breakpoint::Sm => vec![
            Constraint::Fixed(6), Constraint::Min(20),
            Constraint::Fixed(8), Constraint::Fixed(10),
        ],
        Breakpoint::Md => vec![
            Constraint::Fixed(6), Constraint::Min(25), Constraint::Fixed(8),
            Constraint::Fixed(5), Constraint::Fixed(10), Constraint::Fixed(12),
        ],
        Breakpoint::Lg | Breakpoint::Xl => vec![
            Constraint::Fixed(6), Constraint::Min(25), Constraint::Fixed(8),
            Constraint::Fixed(5), Constraint::Fixed(15), Constraint::Fixed(15),
            Constraint::Fixed(12), Constraint::Fixed(10),
        ],
    };

    // Build rows from state.items (Vec<MrListRow>)
    let rows: Vec<Vec<Span>> = state.items.iter().map(|mr| {
        let mut cols = vec![
            Span::raw(format!("{}", mr.iid)),
            Span::raw(truncate(&mr.title, 35)),
            Span::styled(&mr.state, Style::default().fg(theme::state_color(&mr.state))),
        ];
        if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) {
            cols.push(if mr.draft {
                Span::styled("WIP", Style::default().fg(Color::rgb(220, 180, 50)))
            } else { Span::raw("") });
            cols.push(Span::raw(format_relative_ms(mr.updated_at)));
            cols.push(Span::raw(format!("@{}", mr.author_username)));
        }
        if matches!(bp, Breakpoint::Lg | Breakpoint::Xl) {
            cols.push(Span::raw(truncate(&mr.source_branch, 14)));
            cols.push(Span::raw(truncate(&mr.target_branch, 14)));
        }
        cols
    }).collect();

    let table = Table::new(rows, widths)
        .highlight_style(Style::default().bg(Color::rgb(50, 80, 140)));

    let mut ts = TableState::default();
    ts.select(Some(state.selected_index));
    frame.render_stateful_widget(table, area, &mut ts);
}
```

### 10.18 MR Detail View

```rust
// crates/lore-tui/src/view/mr_detail.rs — extends issue_detail pattern with file changes and diff discussions

pub fn render(state: &MrDetailState, frame: &mut Frame, area: Rect) {
    let Some(detail) = &state.detail else {
        frame.render_widget(Paragraph::new("Loading..."), area);
        return;
    };

    let layout = Flex::vertical()
        .constraints([
            Constraint::Fixed(5),          // header (title + branches + pipeline)
            Constraint::Percentage(20.0),  // description
            Constraint::Fixed(6),          // file changes
            Constraint::Min(0),            // discussions (diff + general)
        ])
        .split(area);

    render_header(detail, frame, layout[0]);
    render_description(detail, frame, layout[1]);
    render_file_changes(state, frame, layout[2]);
    render_discussions(state, frame, layout[3]);
}

fn render_header(detail: &MrDetail, frame: &mut Frame, area: Rect) {
    let state_color = theme::state_color(&detail.state);
    let lines = vec![
        Line::from(vec![
            Span::styled(
                format!("!{} ", detail.iid),
                Style::default().fg(state_color).add_modifier(Modifier::BOLD),
            ),
            Span::styled(&detail.title, Style::default().add_modifier(Modifier::BOLD)),
            if detail.draft {
                Span::styled(" [DRAFT]", Style::default().fg(Color::rgb(220, 180, 50)))
            } else { Span::raw("") },
        ]),
        Line::from(vec![
            Span::styled("Author: ", dim()),
            Span::raw(format!("@{}", detail.author_username)),
            Span::raw("  "),
            Span::styled("Reviewers: ", dim()),
            Span::raw(detail.reviewers.join(", ")),
        ]),
        Line::from(vec![
            Span::styled("Source: ", dim()),
            Span::raw(&detail.source_branch),
            Span::styled(" -> Target: ", dim()),
            Span::raw(&detail.target_branch),
        ]),
        Line::from(vec![
            Span::styled("Labels: ", dim()),
            Span::raw(detail.labels.join(", ")),
        ]),
        Line::from(vec![
            Span::styled("State: ", dim()),
            Span::styled(&detail.state, Style::default().fg(state_color)),
            if let Some(merged_at) = detail.merged_at {
                Span::styled(format!("  Merged: {}", format_relative_ms(merged_at)), dim())
            } else { Span::raw("") },
        ]),
    ];
    frame.render_widget(Paragraph::new(lines), area);
}

fn render_file_changes(state: &MrDetailState, frame: &mut Frame, area: Rect) {
    let lines: Vec<Line> = state.file_changes.iter().map(|f| {
        let indicator = match f.change_type.as_str() {
            "A" => Span::styled("A", Style::default().fg(Color::rgb(50, 180, 80))),
            "D" => Span::styled("D", Style::default().fg(Color::rgb(220, 50, 50))),
            "M" => Span::styled("M", Style::default().fg(Color::rgb(220, 180, 50))),
            "R" => Span::styled("R", Style::default().fg(Color::rgb(130, 80, 220))),
            _ => Span::raw(&f.change_type),
        };
        Line::from(vec![
            Span::raw("  "),
            indicator,
            Span::raw(format!(" {}", f.path)),
            Span::styled(
                format!("  +{} -{}", f.additions, f.deletions),
                Style::default().fg(Color::rgb(128, 128, 128)),
            ),
        ])
    }).collect();

    let panel = Panel::new(Paragraph::new(lines))
        .title(format!(" File Changes ({}) ", state.file_changes.len()));
    frame.render_widget(panel, area);
}

fn render_discussions(state: &MrDetailState, frame: &mut Frame, area: Rect) {
    // Split discussions into diff discussions (with file context) and general
    let split = Flex::vertical()
        .constraints([Constraint::Percentage(60.0), Constraint::Percentage(40.0)])
        .split(area);

    // Diff discussions — grouped by file, using Tree widget
    let mut root = TreeNode::new(format!(
        "Diff Discussions ({})",
        state.diff_discussions.len(),
    ));

    for dd in &state.diff_discussions {
        let header = format!(
            "@{} on {}:{}  {}",
            dd.discussion.author,
            dd.file_path,
            dd.line.map_or("?".to_string(), |l| l.to_string()),
            dd.discussion.relative_age,
        );
        root = root.child(TreeNode::new(header).with_expanded(dd.discussion.expanded));
    }

    let tree = Tree::new(root).with_show_root(true)
        .with_guides(ftui::widgets::tree::TreeGuides::Rounded);
    let panel = Panel::new(tree);
    frame.render_widget(panel, split[0]);

    // General discussions — same Tree pattern as issue_detail
    let mut gen_root = TreeNode::new(format!(
        "General Discussions ({})",
        state.general_discussions.len(),
    ));
    for disc in &state.general_discussions {
        let node = TreeNode::new(format!(
            "{} ({} replies)  {}",
            disc.author, disc.reply_count, disc.relative_age,
        )).with_expanded(disc.expanded);
        gen_root = gen_root.child(node);
    }

    let tree = Tree::new(gen_root).with_show_root(true)
        .with_guides(ftui::widgets::tree::TreeGuides::Rounded);
    let panel = Panel::new(tree);
    frame.render_widget(panel, split[1]);
}

fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) }
```

### 10.19 CLI Integration — Adding `lore tui` (Runtime Binary Delegation)

The `lore` binary has **zero compile-time dependency** on `lore-tui`. Instead, `lore tui` resolves and spawns the `lore-tui` binary at runtime. This keeps the root crate on stable Rust and eliminates the Cargo dependency cycle.

```rust
// Changes to existing files to wire up the TUI command.

// ── src/cli/mod.rs ──────────────────────────────────────────────────

// Add to the Commands enum:
#[derive(Debug, Subcommand)]
pub enum Commands {
    // ... existing variants ...

    /// Launch interactive TUI
    Tui {
        /// Start directly on sync screen (inline mode)
        #[arg(long)]
        sync: bool,

        /// Start without restoring prior session state
        #[arg(long)]
        fresh: bool,

        /// Rendering mode (auto-detects by default)
        #[arg(long, default_value = "auto")]
        render_mode: String,

        /// Force ASCII-safe glyphs
        #[arg(long)]
        ascii: bool,

        /// Stay in normal terminal (no alternate screen)
        #[arg(long)]
        no_alt_screen: bool,
    },
}

// ── src/main.rs ─────────────────────────────────────────────────────

// NO `mod tui;` — lore has no compile-time knowledge of lore-tui.

// Add match arm in main dispatch:
fn main() -> Result<()> {
    let cli = Cli::parse();

    match &cli.command {
        // ... existing arms ...

        Commands::Tui { sync, fresh, render_mode, ascii, no_alt_screen } => {
            // Resolve lore-tui binary: check PATH, then adjacent to current exe.
            let tui_bin = resolve_tui_binary()?;

            // Compatibility contract handshake: validate lore-tui version before launch.
            // This catches version drift between the two binaries, which is the #1
            // support failure mode for runtime delegation architectures.
            validate_tui_compat(&tui_bin)?;

            let mut cmd = std::process::Command::new(&tui_bin);
            // Pass through config path if specified
            if let Some(config_path) = &cli.config {
                cmd.arg("--config").arg(config_path);
            }
            // Pass through TUI-specific flags
            if *sync { cmd.arg("--sync"); }
            if *fresh { cmd.arg("--fresh"); }
            if render_mode != "auto" {
                cmd.arg("--render-mode").arg(render_mode);
            }
            if *ascii { cmd.arg("--ascii"); }
            if *no_alt_screen { cmd.arg("--no-alt-screen"); }

            let status = cmd.status().map_err(|e| {
                anyhow::anyhow!("Failed to launch lore-tui: {e}")
            })?;

            if !status.success() {
                std::process::exit(status.code().unwrap_or(1));
            }
        }
    }

    Ok(())
}

/// Resolve the lore-tui binary location.
/// Search order: 1) PATH, 2) adjacent to current executable.
fn resolve_tui_binary() -> Result<std::path::PathBuf> {
    // Check PATH first
    if let Ok(path) = which::which("lore-tui") {
        return Ok(path);
    }
    // Check adjacent to current exe (common for cargo install)
    if let Ok(exe) = std::env::current_exe() {
        let adjacent = exe.with_file_name("lore-tui");
        if adjacent.exists() {
            return Ok(adjacent);
        }
    }
    Err(anyhow::anyhow!(
        "lore-tui binary not found.\n\
         Build with: cargo install --path crates/lore-tui\n\
         Or ensure lore-tui is in your PATH."
    ))
}

/// Minimum compat version this `lore` binary requires from `lore-tui`.
const MIN_TUI_COMPAT_VERSION: u32 = 1;

/// Validate compatibility contract with lore-tui before launch.
/// Runs `lore-tui --compat-version` and checks the returned integer
/// against MIN_TUI_COMPAT_VERSION.
fn validate_tui_compat(tui_bin: &std::path::Path) -> Result<()> {
    let output = std::process::Command::new(tui_bin)
        .arg("--compat-version")
        .output()
        .map_err(|e| anyhow::anyhow!("Failed to check lore-tui compatibility: {e}"))?;

    if !output.status.success() {
        // Old lore-tui that doesn't support --compat-version: warn but allow
        eprintln!("warning: lore-tui does not support --compat-version; skipping compat check");
        return Ok(());
    }

    let version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let version: u32 = version_str.parse().map_err(|_| {
        anyhow::anyhow!("lore-tui returned invalid compat version: {version_str}")
    })?;

    if version < MIN_TUI_COMPAT_VERSION {
        Err(anyhow::anyhow!(
            "lore-tui is too old (compat v{version}, need v{MIN_TUI_COMPAT_VERSION}).\n\
             Rebuild with: cargo install --path crates/lore-tui"
        ))
    } else {
        Ok(())
    }
}
```

**Also add `lore sync --tui` convenience flag:**

```rust
// In src/cli/mod.rs, modify existing Sync command:
#[derive(Debug, Args)]
pub struct SyncArgs {
    // ... existing fields ...

    /// Show sync progress in interactive TUI (inline mode)
    #[arg(long)]
    pub tui: bool,
}

// In src/cli/commands/sync.rs, handle the flag:
pub fn run_sync(config: &Config, args: &SyncArgs) -> Result<()> {
    if args.tui {
        // Delegate to lore-tui binary with --sync flag
        let tui_bin = resolve_tui_binary()?;
        let status = std::process::Command::new(&tui_bin)
            .arg("--sync")
            .status()?;
        if !status.success() {
            std::process::exit(status.code().unwrap_or(1));
        }
        return Ok(());
    }
    // ... existing sync logic ...
}
```

### 10.20 Refactor: Extract Query Functions

Currently, some commands execute queries and format output in the same function. For the TUI, we need the query layer separated from the display layer.

**Pattern to apply across all commands:**

```rust
// BEFORE (coupled)
pub fn run_list_issues(config: &Config, args: &IssueArgs) -> Result<()> {
    let conn = open_db(&config)?;
    let rows = query_issues(&conn, &args.filter)?;
    print_issue_table(&rows, args.robot);
    Ok(())
}

// AFTER (decoupled)
pub fn query_issues(conn: &Connection, filter: &IssueFilter) -> Result<Vec<IssueRow>> {
    // Pure data query, no I/O
}

pub fn run_list_issues(config: &Config, args: &IssueArgs) -> Result<()> {
    let conn = open_db(&config)?;
    let rows = query_issues(&conn, &args.filter)?;
    print_issue_table(&rows, args.robot);
    Ok(())
}
```

**Files requiring this refactor:**

| File | Extract | Used By |
|------|---------|---------|
| `src/cli/commands/list.rs` | `query_issues()`, `query_mrs()` | IssueList, MrList |
| `src/cli/commands/show.rs` | `query_issue_detail()`, `query_mr_detail()`, `query_discussions()` | IssueDetail, MrDetail |
| `src/cli/commands/search.rs` (if exists) | `execute_search()` | Search |
| `src/cli/commands/who.rs` | `query_experts()`, `query_workload()`, `query_reviews()` | Who |
| Timeline modules | Already pipeline-based (good) | Timeline |
| Ingestion orchestrator | Already has `ProgressCallback` (good) | Sync |

---

## Appendix A: Full Architecture Diagram

```mermaid
graph TB
    subgraph "FrankenTUI Kernel"
        FC[ftui-core<br/>Events + Terminal]
        FR[ftui-render<br/>Buffer + Diff + Presenter]
        FRT[ftui-runtime<br/>Elm Event Loop]
        FL[ftui-layout<br/>Flex + Grid + Responsive]
        FS[ftui-style<br/>Theme + WCAG]
        FW[ftui-widgets<br/>Table, Tree, etc.]
        FE[ftui-extras<br/>Charts, Forms, etc.]
        FH[ftui-harness<br/>Snapshot Tests]
    end

    subgraph "Gitlore TUI Module (new)"
        direction TB
        APP[LoreApp<br/>impl Model]
        APP -->|update| ST[State Tree]
        APP -->|view| VW[View Router]
        APP -->|Cmd::task| AR[Action Runner]

        subgraph "State"
            ST --> DS[Dashboard]
            ST --> IL[Issue List]
            ST --> ID[Issue Detail]
            ST --> ML[MR List]
            ST --> MD[MR Detail]
            ST --> SS[Search]
            ST --> TL[Timeline]
            ST --> WH[Who]
            ST --> SY[Sync]
            ST --> CP[Cmd Palette]
        end

        subgraph "Views"
            VW --> DV[Dashboard]
            VW --> IV[Issues]
            VW --> MV[MRs]
            VW --> SV[Search]
            VW --> TV[Timeline]
            VW --> WV[Who]
            VW --> YV[Sync]
            VW --> PV[Palette]
        end
    end

    subgraph "Existing Modules (unchanged)"
        direction TB
        DB[(SQLite DB)]
        GL[GitLab Client]
        ING[Ingestion]
        DOC[Documents]
        EMB[Embedding]
        SCH[Search Engine]
        TIM[Timeline Pipeline]
        WHO2[Who Queries]
    end

    FC -->|events| FRT
    FRT -->|drives| APP
    VW -->|widgets| FW
    VW -->|extras| FE
    FW -->|render| FR
    FR -->|layout| FL
    FR -->|style| FS
    AR -->|queries| DB
    AR -->|sync| ING
    AR -->|search| SCH
    AR -->|timeline| TIM
    AR -->|who| WHO2
    ING -->|fetch| GL
    ING -->|store| DB
    DOC -->|index| DB
    EMB -->|vectors| DB

    style APP fill:#ff6b6b,stroke:#333,color:#fff
    style ST fill:#4a9eff,stroke:#333,color:#fff
    style VW fill:#51cf66,stroke:#333,color:#fff
    style DB fill:#ffd43b,stroke:#333,color:#000
    style FRT fill:#9775fa,stroke:#333,color:#fff
```

---

## Appendix B: State Machine — Filter Bar

```mermaid
stateDiagram-v2
    [*] --> Inactive

    Inactive --> Active: f (focus) or Tab
    Active --> FieldSelect: type ':'
    Active --> FreeText: type any char

    FieldSelect --> ValueInput: select field
    FreeText --> Inactive: Enter (apply) or Esc (cancel)

    ValueInput --> Inactive: Enter (apply filter)
    ValueInput --> Active: Backspace past ':'

    state Active {
        [*] --> Typing
        Typing --> Suggesting: pause 200ms
        Suggesting --> Typing: resume typing
    }

    state FieldSelect {
        [*] --> ShowFields
        ShowFields: state, author, assignee, label, milestone, since, project
    }

    state ValueInput {
        [*] --> ShowValues
        ShowValues: context-dependent completions
    }
```

---

## Appendix C: Responsive Layout Breakpoints

| Terminal Width | Layout Adaptation |
|---------------|-------------------|
| < 80 cols | Single-column, abbreviated headers, hide secondary columns |
| 80-120 cols | Standard layout (as shown in mockups) |
| 120-200 cols | Wider columns, show all fields, split pane previews |
| > 200 cols | Triple-column layout on search/who screens |

**Implementation:** FrankenTUI's native `Responsive<T>` with `Breakpoint` enum. Define project-wide breakpoints once:

```rust
use ftui::layout::{Breakpoints, Breakpoint, Responsive, Flex, Constraint};

// Define project-wide breakpoints once (used across all views).
// Breakpoints::new(sm, md, lg) — xl defaults to lg + 40.
// Xs is always < sm (i.e., < 60 cols).
pub const LORE_BREAKPOINTS: Breakpoints = Breakpoints::new(60, 90, 120);

// Classify terminal width into a breakpoint bucket:
let bp = LORE_BREAKPOINTS.classify_width(area.width);

// Responsive<T> provides breakpoint-aware values with inheritance.
// .new(base) sets the Xs value. .at(bp, val) sets overrides.
// .resolve(bp) walks downward until finding an explicit value.
let columns: u16 = Responsive::new(1_u16)     // Xs → 1 column
    .at(Breakpoint::Md, 2)                     // Md+ → 2 columns
    .at(Breakpoint::Lg, 3)                     // Lg+ → 3 columns
    .resolve_cloned(bp);

// Visibility control: hide secondary panels on narrow terminals.
let show_preview: bool = Responsive::new(false) // Xs/Sm → hidden
    .at(Breakpoint::Md, true)                   // Md+ → shown
    .resolve_cloned(bp);
```

---

## Appendix D: Discussion Thread Rendering Algorithm

```mermaid
graph TD
    A[Fetch discussions for entity] --> B{For each discussion}
    B --> C[Get root note]
    C --> D[Format thread header: author + age + reply count]
    D --> E{Expanded?}
    E -->|Yes| F[Render root note body]
    F --> G{For each reply}
    G --> H[Indent + render reply: author + body + age]
    H --> G
    E -->|No| I[Show collapsed indicator with preview]
    B --> B

    style A fill:#4a9eff,stroke:#333,color:#fff
    style F fill:#51cf66,stroke:#333,color:#fff
    style I fill:#ffd43b,stroke:#333,color:#000
```

**For diff discussions (MR-specific):**
1. Group by file path
2. Within each file, sort by line number
3. Show `[file.rs:45]` context prefix
4. Indent discussion tree under file header

---

## 11. Assumptions

### Framework Choice

1. **FrankenTUI's API will stabilize enough for our needs within 3 months.** We pin to a specific git revision and update deliberately. If the API churns faster than we can adapt, we exercise the escape hatch (Section 3.5).
2. **Nightly Rust is acceptable for the TUI crate only.** The core `lore` library stays on stable. The nightly pin is isolated to `crates/lore-tui` and updated monthly with CI gates. This is a deliberate tradeoff for FrankenTUI's architectural advantages without risking core stability.
3. **Terminal compatibility is adequate for our target set.** iTerm2, Terminal.app, Alacritty, tmux are validated in Phase 0. Edge cases in exotic terminals are accepted as non-blocking bugs.
4. **The escape hatch works.** The Elm architecture (Msg → update → view) is framework-agnostic. Only view/ files and the launch entry point need rewriting for a framework swap. Estimated cost: ~3-5 days. Validated by building Dashboard in Phase 1 before committing to subsequent phases.

### Data & Performance

5. **Performance budgets are enforced by tiered benchmark fixtures (S/M/L) and query-plan checks.** p95 list query < 75ms at M tier (100k issues); p95 search < 200ms at M tier (50k docs, lexical). Memory ceiling < 250MB RSS at M tier. S and M tiers are CI-enforced gates; L tier is advisory. Required indexes are documented and migration-backed before TUI GA.
6. Virtual scrolling handles large datasets (VirtualizedList).
7. Sync progress streamed via channel-based subscription (not batch-at-end).
8. **TUI reads never contend with sync writes.** DbManager provides pooled read-only connections alongside a dedicated writer. WAL mode + busy_timeout ensures SQLITE_BUSY is handled gracefully.

### User Experience

9. Vim-style keybindings acceptable (j/k + arrow keys).
10. Mouse support optional but nice.
11. TUI does not replace CLI.
12. Versioned TUI state file allowed for session restore only (`~/.local/share/lore/tui-session.json`). Fields include `schema_version`, `app_version`, `db_fingerprint` (hash of DB path + schema version), `saved_at`, `checksum` (CRC32 of serialized state), and `state` (last screen, active filters, scroll position). **Session writes are atomic:** write to tmp file, fsync, rename over target. On load: if `schema_version` is unsupported, `db_fingerprint` mismatches, CRC32 checksum fails, or file exceeds 1MB max-size guard, the file is quarantined to `.bad-<timestamp>` and the TUI starts fresh. This prevents random boot failures from partial writes, power loss, or corruption. No user-editable configuration beyond this. `lore tui --fresh` starts without restoring prior session state.
13. Users have 80+ column terminals (60 col minimum).

### Implementation

14. Query layer can be cleanly extracted (~2 days refactor). Existing `query_*` functions in `cli/commands/*.rs` are made public; `action.rs` bridges types.
15. FrankenTUI's `Cmd::task` runs blocking closures on a thread pool (not async) — `DbManager` uses `std::sync::Mutex` (not tokio). Tokio remains in the dep tree for GitLab HTTP client but is not used by the TUI event loop.
16. No new SQLite tables needed for v1 (but required indexes — including filter-path indexes — must be verified and migration-backed; see Performance SLOs and Section 9.3.1). Post-v1: `sync_checkpoints` table planned for resumable sync support; `sync_runs`/`sync_deltas` ledger planned for exact post-sync navigation.
17. TUI is isolated in a separate binary crate (`crates/lore-tui`) with zero compile-time dependency from the root `lore` crate. The `lore tui` subcommand delegates by spawning the `lore-tui` binary at runtime, eliminating Cargo dependency cycle risk and keeping stable/nightly toolchains fully isolated.

### Scope Boundaries

18. No write operations (read-only + sync).
19. No init wizard in TUI.
20. No embedding visualization.
21. Concurrent TUI sessions unsupported and actively prevented by instance lock (`instance_lock.rs`) with stale-lock recovery and clear error message on collision.

---

## Sources

- [FrankenTUI GitHub](https://github.com/Dicklesworthstone/frankentui)
- [FrankenTUI Website](https://www.frankentui.com/)
- [ftui-runtime docs](https://docs.rs/ftui-runtime/0.1.1/ftui_runtime/)
- [ftui-widgets docs](https://docs.rs/ftui-widgets/0.1.1/ftui_widgets/)
- [ftui-extras docs](https://docs.rs/ftui-extras/latest/aarch64-apple-darwin/ftui_extras/)
- [ftui-layout docs](https://docs.rs/ftui-layout/latest/ftui_layout/)
- [ftui-core docs](https://docs.rs/ftui-core/0.1.1/ftui_core/)
- [ftui-style docs](https://docs.rs/ftui-style/latest/ftui_style/)
- [ftui-demo-showcase on crates.io](https://crates.io/crates/ftui-demo-showcase)
- [FrankenTUI HN Discussion](https://news.ycombinator.com/item?id=46911912)

---

## Rejected Recommendations

Recommendations from external review (feedback-1) that were evaluated and declined:

- **UiRuntime + ScreenRenderer abstraction trait for framework portability** — rejected because it's over-engineering at this stage. The Elm architecture (Msg -> update -> view) is already framework-agnostic by nature. Only `view/` files and `app.rs` need rewriting for a framework swap. Adding a formal trait boundary before we've built a single screen creates an abstraction without proven need. The "3-day escape hatch" is validated by building Dashboard in Phase 1 — if that reveals the estimate is optimistic, we can add an abstraction then. YAGNI principle applies.

- **Saved Views / Watchlist as a v1 product feature** — rejected because it's feature bloat for the initial TUI PRD. Named persistent filters and delta-tracked watchlists are genuinely compelling for daily use, but they require: persistent configuration storage (new file format or SQLite table), a UI for CRUD operations on views, and dashboard integration for delta badges. This is a ~5-day feature that should be planned as a separate v2 enhancement after the core TUI ships and proves its value. The concept is captured here for future reference.

Recommendations from external review (feedback-2) that were evaluated and declined:

- **`lore-core` crate split to prevent cyclic Cargo dependencies** — partially superseded. The cycle concern was valid: feedback-4 (ChatGPT) correctly identified the dependency cycle risk with the original feature-gated library approach. We adopted a different solution — `lore-tui` as a separate binary crate with runtime delegation (Section 3.2) — which eliminates the cycle without requiring a `lore-core` split. The `lore-core` crate split itself remains rejected because it would add workspace complexity, require moving dozens of modules, and change import paths across the entire codebase to solve a problem now addressed by the binary separation.

- **Service/DTO/query layer in a separate `lore-core` crate with canonical query modules** — rejected because it's premature abstraction. The plan explicitly acknowledges this as a YAGNI tradeoff (Section 10.2) with a documented extraction path: make existing `query_*` functions in `cli/commands/*.rs` public, call them from `action.rs`. Creating a separate crate with `query/issues.rs`, `query/mrs.rs`, `query/show.rs`, `query/who.rs`, `query/search.rs`, and DTO types before building a single TUI screen is exactly the kind of speculative architecture that slows down iteration. The plan has a clear rule: "If both CLI and TUI diverge significantly in their query needs, a shared domain query layer can be extracted later — but we don't pay that cost until it's needed."

Recommendations from external review (feedback-3) that were evaluated and declined:

- **"My Workbench" screen for daily pull (assigned issues, review requests, mentions)** — rejected for the same reason as Saved Views: feature bloat for v1. This is a compelling daily-driver feature, but it requires: GitLab user identity resolution (current-user API call or config), new query patterns (assigned-to-me, reviewer-requested, mentioned-in), and a dedicated screen with its own state management. The core TUI must prove value with exploration and search first. A Workbench screen is an excellent v2 candidate once the navigation and data layer are stable.

- **Rollout, SLO telemetry, feature flags, and kill-switch plan** — rejected because this is a single-user local CLI tool, not a SaaS product. Feature flags, canary rollouts, panic-rate kill-switches, and metrics pipelines (startup_ms, frame_render_p95_ms, db_busy_rate, panic_free_sessions) are appropriate for multi-tenant services with thousands of users. Gitlore's TUI is compiled and run locally by one person. The user IS the rollout canary. Crashes are immediately visible. The existing Phase 0 toolchain gate, Phase 5 test suite, and success criteria already provide sufficient quality assurance for this deployment model.

Recommendations from external review (feedback-4, ChatGPT) that were evaluated and declined:

- **`core::query::*` internal module to decouple TUI from CLI presentation structs** — rejected for the same reason as the feedback-2 Service/DTO recommendation: premature abstraction. This is the third time this recommendation has been proposed (feedback-2 as `lore-core` crate, feedback-4 as `core::query::*` module). The answer remains the same: making existing `query_*` functions in `cli/commands/*.rs` public is sufficient for v1. The plan documents a clear extraction path if CLI and TUI query needs diverge. Creating new modules (`core/query/issues.rs`, `core/query/mrs.rs`, `core/query/detail.rs`, `core/query/search.rs`, `core/query/who.rs`) before building a single TUI screen is speculative architecture. YAGNI applies.

- **Move resumable sync checkpoints into v1 (lightweight cursors per project/resource lane)** — rejected because "lightweight" undersells the complexity. Resumable sync checkpoints require: a new `sync_checkpoints` table with migration, cursor management per resource lane (issues, MRs, discussions, notes, events), resume logic that handles partial-page state, and integration tests proving interrupt-and-resume reaches completion. This is 3-5 days of work with meaningful correctness risk (resuming mid-page with stale cursors). The existing per-project fault isolation already handles partial failures — failed lanes are marked degraded while others continue. Sync checkpoints remain a strong v2 candidate after the core sync pipeline has production mileage.

- **`ScreenIntent` intermediary type to enforce pure state handlers** — accepted in spirit but with a simpler approach than proposed. ChatGPT correctly identified that `handle_screen_msg` was bypassing the TaskSupervisor by calling `Cmd::task` directly. Rather than introducing a formal "intent-only" type hierarchy with DB-access prohibition, we made the minimal fix: `handle_screen_msg` now returns `ScreenIntent` (Navigate | RequeryNeeded | None) instead of `Cmd<Msg>`, and `LoreApp::update()` converts intents into supervised `Cmd::task` calls. This was accepted and integrated — listed here for audit trail only.

Recommendations from external review (feedback-5, ChatGPT) that were evaluated and declined:

- **`ui_adapter` seam to contain FrankenTUI API churn** — rejected as premature abstraction. The proposal adds a `ui_adapter/` module layer (runtime.rs, widgets.rs, input.rs) that wraps all FrankenTUI types so no screen module imports `ftui_*` directly. While the intent (reducing blast radius of upstream API changes) is sound, this is the same "portability abstraction" pattern rejected in feedback-1 wearing different clothes. The existing mitigation — pinning an exact git commit + the 3-day escape hatch validated in Phase 1 — already addresses the risk without adding an indirection layer. If FrankenTUI's API churn proves worse than expected during Phase 1, we can introduce targeted wrappers for the specific types that broke. Adding a full adapter module before building a single screen is speculative insulation against a risk we've already mitigated. YAGNI applies.

Recommendations from external review (feedback-6, ChatGPT) that were evaluated and declined:

- **QoS sync event bus with split ctrl/data channels and sequence IDs** — rejected as over-engineering for a local TUI. The proposal splits the sync channel into a never-drop ctrl channel (256 capacity) and a coalescible data channel (4096 capacity), adds sequence numbers to every event, and has the UI detect and display gaps ("Dropped N events due to backpressure"). The current design — a single bounded `sync_channel::<SyncUiEvent>(2048)` with `try_send` for progress (droppable) and blocking retries for terminal events (completion/failure) — already handles backpressure correctly. Progress drops are invisible to the user (the next progress update replaces the stale one). Terminal events are guaranteed delivered. Adding sequence IDs, gap detection, and split channels adds complexity with zero user-visible benefit for a single-user local tool. The backpressure drop counter (`SyncBackpressureDrop`) already exists for diagnostics if needed.

- **Dependency Lens panel for Issue/MR detail views** — rejected because the required data doesn't exist in the current schema. The proposal adds a "Blocked by / Blocks / Risk" panel to detail views, but gitlore does not sync GitLab's blocking issue relationships (`/issues/:id/links` API with `blocks`/`is_blocked_by` link types). The cross-references in the plan are discussion-level mentions (e.g., "mentioned in !458"), not structured dependency relationships. Implementing this feature requires: new API sync for issue links, a new `issue_links` table with migration, relationship resolution logic, and risk scoring heuristics. This is a compelling v2 feature — it would make the detail view significantly more useful for triage — but it requires schema additions that are out of scope for v1. The concept is captured here for future reference when the issue links API is synced.

Recommendations from external review (feedback-7, ChatGPT) that were evaluated and declined:

- **`TaskSupervisor` as a real scheduler with `BinaryHeap`, inflight counters, and `TaskLaneLimits`** — rejected because it solves a problem we don't have. The TUI dispatches at most 5-10 concurrent background tasks. A priority queue scheduler with per-lane inflight limits adds complexity (BinaryHeap ordering, lane accounting, dispatch-ready polling) without user-visible benefit. The current design — direct `Cmd::task` dispatch with dedup + cancellation via `TaskSupervisor::register()` — is adequate for TUI workloads. The debounce-via-`thread::sleep` concern is also overblown: the sleep runs in a `Cmd::task` pooled thread, not the event loop, so it doesn't block rendering or input. If task contention becomes measurable (not speculative), a scheduler can be added — but the monitoring to detect it must come first.

- **Sync run ledger (`sync_runs` + `sync_deltas` tables) for exact "since last sync" navigation** — rejected for v1 because it adds new schema (contradicting assumption #16) for marginal UX improvement. The "since last sync" filter works with the existing `sync_status.last_completed_at` timestamp comparison. The edge cases (partial failures, retries, clock drift) are real but rare in practice for a single-user local tool. The existing per-project fault isolation already handles partial sync failures. Adding `sync_runs` + `sync_deltas` with indexes, migration, and integration into the filter pipeline is 2-3 days of work for a v1 that hasn't shipped yet. Captured as a strong v2 candidate — once the TUI proves its value and users ask for exact delta navigation, we add the schema then.

- **Full `KeyNormalizer` struct with dedicated integration tests and terminal profile replay tests (2 days)** — rejected as too heavy. The underlying concern (terminal encoding differences for Backspace, Alt, Tab) is valid and accepted as a lightweight normalization pass in `interpret_key()` (see Section 8.2). But a separate struct with its own integration test suite and "terminal profile replay" fixtures turns a 20-line normalization function into a 2-day testing project. FrankenTUI's `ftui-core` event parser already handles most encoding differences; we add a thin layer for the remaining edge cases. If terminal compat bugs surface during Phase 5 testing, the normalization can be expanded then — with actual bug reports driving the test cases.

- **"Replay captured event trace" test (`replay_trace_artifact_is_stable`)** — rejected because deterministic event replay in a TUI with async task completion is non-trivial to implement correctly. The event trace in the panic crash dump (accepted — see Section 10.3) is valuable for human debugging. But building an automated test that replays captured events and asserts no panic requires mocking all async completion timing, which makes the test brittle and non-deterministic. The existing event-fuzz suite (criterion 13) with randomized seed replay already covers the "no panic under arbitrary input" property more robustly.

Recommendations from external review (feedback-8, ChatGPT) that were evaluated and declined:

- **"Hotspots" screen for risk/churn triage** — rejected as feature scope expansion for v1. The concept (file/path risk ranking by churn x unresolved discussion pressure x staleness) is compelling and uses existing data, but it is a net-new screen with its own state module, view module, query logic, keybinding (`gx`), and test coverage. Adding it to an already ambitious v1 scope dilutes focus on shipping the core exploration and search experience. The existing Timeline and Who screens already surface much of the same signal (recent activity, discussion hotspots) from different angles. Hotspots is captured as a strong v2 candidate — once the core TUI ships and proves its navigation model, a risk-ranking screen can layer on top without architectural changes.

- **Degraded startup mode (`--degraded` flag) when schema/compat checks fail** — rejected as over-engineering for the failure mode. The current behavior (actionable error message with specific recovery steps and non-zero exit) is the correct UX for a schema mismatch. A degraded mode that shells to `lore --robot` for read-only summary/doctor creates a second execution path that must be tested, documented, and maintained — and it papers over the root problem (wrong schema) rather than fixing it. Users who hit this error need to run `lore migrate` or update their binary, not navigate a limited TUI. The existing `lore --robot doctor` and `lore --robot status` commands already serve the "quick health check without full TUI" use case directly from the command line.

Recommendations from external review (feedback-9, ChatGPT) that were evaluated and declined:

- **Search Facets panel (entity type counts, top labels/projects/authors with one-key apply)** — rejected as feature scope expansion for v1. The concept (three-pane layout with facet counts and quick-apply shortcuts like `1/2/3` for type facets, `l` for label cycling) is compelling and would make search more actionable for triage workflows. However, it requires: new aggregate queries for facet counting that must perform well across all three data tiers, a third layout pane that breaks the current two-pane split design, new keybinding slots (`1/2/3/l`) that could conflict with future list navigation, and per-query facet recalculation that adds latency. The existing search with explicit field-based filters is sufficient for v1. Facets are a strong v2 candidate — once search has production mileage and users report wanting faster triage filtering, the aggregate query patterns and UI layout can be designed with real usage data.

Recommendations from external review (feedback-10, ChatGPT) that were evaluated and declined:

- **Structured compat handshake (`--compat-json` replacing `--compat-version` integer)** — rejected because the current two-step contract (integer compat version + separate schema version check) is intentionally minimal and robust. Adding JSON parsing (`{ "protocol": 1, "compat_version": 2, "min_schema": 14, "max_schema": 16, "build": "..." }`) to a preflight binary validation introduces a new failure mode (malformed JSON, missing fields, version parsing) for zero user-visible benefit. The integer check detects "too old to work" — the only case that matters before spawning the TUI. Schema range is already validated separately via `--check-schema`. Combining both into a single JSON response couples concerns that are better kept independent (binary compat vs schema compat). The current approach is more resilient: if `--compat-version` is missing (old binary), we warn and proceed; JSON parsing failure would be a hard error. KISS principle applies.

Recommendations from external review (feedback-11, ChatGPT) that were evaluated and declined:

- **Query budgets and soft deadlines (120ms/250ms hard deadlines with `QueryDegraded` truncation)** — rejected as over-engineering for a local SQLite tool. The proposal adds per-query-type latency budgets (list: 250ms, detail: 150ms, search: 250ms hard deadline) with SQLite progress handler interrupts and inline "results truncated" badges. This papers over slow queries with UX complexity rather than fixing the root cause. If a list query exceeds 250ms on a local SQLite database, the correct fix is adding an index or optimizing the query plan — not truncating results and showing a retry badge. The existing cancellation + stale-drop system already handles the interactive case (user navigates away before query completes). SQLite progress handlers are also tricky to implement correctly — they fire on every VM instruction, adding overhead to all queries, and the cancellation semantics interact poorly with SQLite's transaction semantics. The complexity-to-benefit ratio is wrong for a single-user local tool. If specific queries are slow, we fix them at the query/index level (Section 9.3.1 already documents required covering indexes).

- **Adaptive render governor (runtime frame-time monitoring with automatic profile downgrading)** — rejected for the same reason as feedback-3's SLO telemetry and runtime monitoring proposals. The proposal adds a frame-time p95 sliding window, stream pressure detection, automatic profile switching (quality/balanced/speed), hysteresis for recovery, and a `--render-profile` CLI flag. This is appropriate for a multi-user rendering engine or game, not a single-user TUI. The capability detection in Section 3.4.1 already handles the static case (detect terminal capabilities, choose appropriate rendering). If the TUI is slow in tmux or over SSH, the user can pass `--ascii` or reduce their terminal size. Adding a runtime monitoring system with automatic visual degradation introduces a state machine, requires frame-time measurement infrastructure, needs hysteresis tuning to avoid flapping, and must be tested across all the profiles it can switch between. This is significant complexity for an edge case that affects one user once and is solved by a flag. The `--render-profile` flag itself is a reasonable addition as a static override — but the dynamic adaptation runtime is rejected.
