Files
gitlore/plans/tui-prd.md
Taylor Eernisse 1161edb212 docs: add TUI PRD v2 (FrankenTUI) with 9 plan-refine iterations
Comprehensive product requirements document for the gitlore TUI built on
FrankenTUI's Elm architecture (Msg -> update -> view). The PRD (7800+
lines) covers:

Architecture: Separate binary crate (lore-tui) with runtime delegation,
Elm-style Model/Cmd/Msg, DbManager with closure-based read pool + WAL,
TaskSupervisor for dedup/cancellation, EntityKey system for type-safe
entity references, CommandRegistry as single source of truth for
keybindings/palette/help.

Screens: Dashboard, IssueList, IssueDetail, MrList, MrDetail, Search
(lexical/hybrid/semantic with facets), Timeline (5-stage pipeline),
Who (expert/workload/reviews/active/overlap), Sync (live progress),
CommandPalette, Help overlay.

Infrastructure: InputMode state machine, Clock trait for deterministic
rendering, crash_context ring buffer with redaction, instance lock,
progressive hydration, session restore, grapheme-safe text truncation
(unicode-width + unicode-segmentation), terminal sanitization (ANSI/bidi/
C1 controls), entity LRU cache.

Testing: Snapshot tests via insta, event-fuzz, CLI/TUI parity, tiered
benchmark fixtures (S/M/L), query-plan CI enforcement, Phase 2.5
vertical slice gate.

9 plan-refine iterations (ChatGPT review -> Claude integration):
  Iter 1-3: Connection pool, debounce, EntityKey, TaskSupervisor,
    keyset pagination, capability-adaptive rendering
  Iter 4-6: Separate binary crate, ANSI hardening, session restore,
    read tx isolation, progressive hydration, unicode-width
  Iter 7-9: Per-screen LoadState, CommandRegistry, InputMode, Clock,
    log redaction, entity cache, search cancel SLO, crash diagnostics

Also includes the original tui-prd.md (ratatui-based, superseded by v2).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 08:11:26 -05:00

85 KiB

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


Table of Contents

  1. Executive Summary
  2. Framework Evaluation
  3. Recommendation
  4. Architecture
  5. Screen Taxonomy
  6. User Flows — Every Command
  7. Widget Inventory
  8. Keybinding Reference
  9. Implementation Plan
  10. Code Changes Required
  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 at a glance
  • 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

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


2. Framework Evaluation

2.1 Candidates

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 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

2.2 FrankenTUI — Deep Analysis

Strengths:

  • Inline mode is genuinely innovative. Scrollback preservation while maintaining stable UI chrome is a killer feature for tools that mix streaming logs with interactive controls. This maps perfectly to lore sync where you want progress bars + log output.
  • Elm architecture is clean. The Model trait with update()/view() and Cmd<Msg> for side effects is well-proven (Elm, Bubbletea, Iced). This is the right pattern for TUI state management.
  • Widget catalog is exceptionally rich for a v0.1. CommandPalette, Tree, LogViewer, Modal, VirtualizedList, JsonView, Forms with validation — these are exactly what gitlore needs.
  • Responsive layout with breakpoints handles the 80-col vs 200-col terminal reality gracefully.
  • WCAG contrast validation is unique among Rust TUI frameworks.
  • Feature-gated crates keep binary size controllable.
  • Snapshot testing harness enables deterministic regression testing of TUI output.

Risks (Critical):

  • 6 days old. The API will change. There is no migration guide, no stability guarantee, no semver promise. Building on v0.1.1 means accepting frequent breakage.
  • 10 downloads. Zero production validation. Unknown edge cases in terminal compatibility, Unicode handling, resize behavior, SSH forwarding, tmux/screen, Windows Terminal, etc.
  • Requires Rust nightly. Gitlore currently builds on stable Rust (edition 2024). Switching to nightly introduces compiler instability risk and makes CI more complex.
  • Solo maintainer. Bus factor of 1. If Jeffrey Emanuel moves on, the ecosystem is orphaned.
  • No community. Zero Stack Overflow answers, zero blog posts, zero third-party tutorials. Every problem is novel.
  • Partial crates.io publishing. Not all crates are published yet — means depending on git source.

2.3 Ratatui — Deep Analysis

Strengths:

  • Battle-tested at scale. 16.9M downloads, used by Netflix/OpenAI/AWS. Terminal compatibility is proven across macOS, Linux, Windows, tmux, SSH, Mosh, etc.
  • Stable Rust. No toolchain change required for gitlore.
  • Massive ecosystem. tui-widgets (33K downloads), tui-textarea, tui-tree-widget, ratatui-image, community templates, and 2,200+ dependent crates provide answers to almost any widget need.
  • Excellent documentation. Official book, tutorials, examples repo with dozens of reference apps.
  • Constraint-based layout is proven and well-understood.
  • Active development. v0.30.0 with regular releases, multiple maintainers, responsive issue tracker.
  • Compatible with crossterm (which gitlore already indirectly depends on via console/dialoguer).

Limitations:

  • No native inline mode. Can be implemented app-side (alternating between raw/cooked mode) but it's not a first-class feature.
  • Immediate-mode rendering means the app rebuilds the full widget tree every frame. For gitlore's data sizes this is fine (sub-millisecond), but it's a different mental model than Elm.
  • No built-in snapshot testing. Would need to build or adopt insta snapshots.
  • Theming is manual. No semantic token system or WCAG validation — you manage styles yourself.

2.4 Charm (bubbletea/lipgloss/bubbles) — Deep Analysis

Eliminated. Charm is Go-only. Gitlore is Rust. FFI bridging would be absurd. Including for completeness only because the user asked.

However: Charm's design philosophy heavily influenced FrankenTUI's architecture. The Elm pattern (Model + Update + View) and the inline-mode scrollback approach both originated in Charm's ecosystem. If gitlore were a Go project, Charm would be the obvious choice.


3. Recommendation

Primary: Ratatui

Use ratatui as the TUI framework.

Rationale:

  1. Stability over novelty. Gitlore is a production tool with real users (Taylor's workflow). Building on a 6-day-old framework with 10 downloads introduces unacceptable risk for a core user interface.
  2. No toolchain change. Staying on stable Rust avoids nightly churn.
  3. Community safety net. 2,200+ dependent crates means problems are solvable. Every terminal quirk has been encountered.
  4. Good enough widgets. Between ratatui core, tui-widgets, and custom implementations, every gitlore screen can be built. The CommandPalette and Tree can be built in ~200 lines each.
  5. Proven at scale. Netflix and OpenAI trust it. The terminal compatibility matrix is validated.

Adopt FrankenTUI Ideas

While not using FrankenTUI as a dependency, we should steal its best ideas:

  • Elm-style state management: Implement Model + Message + update() + view() as our own thin layer on ratatui. This is ~100 lines of code and gives us the clean architecture.
  • Inline mode for sync: Use ratatui's Terminal::insert_before() (available since v0.28) to achieve scrollback-preserving inline mode during lore sync.
  • Semantic theming: Build a Theme struct with named color slots that resolves against terminal capabilities.
  • Command palette: Build a fuzzy-filter overlay that maps to lore commands.

Revisit FrankenTUI in 6 Months

If FrankenTUI reaches v0.5+ with 1,000+ downloads, stable Rust support, and multiple production users, it would become a strong candidate for a v2 rewrite. The widget catalog and inline mode are genuinely superior.


4. Architecture

4.1 Module Structure

src/
  tui/                          # NEW: TUI module
    mod.rs                      # Public API: launch_tui()
    app.rs                      # App struct, event loop, Elm-style dispatch
    event.rs                    # Event polling (crossterm → Message translation)
    theme.rs                    # Semantic color tokens, light/dark detection
    message.rs                  # Message enum (all user actions + async results)
    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 state
      command_palette.rs        # Fuzzy command palette state
    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                   # Progress bars + log stream
      command_palette.rs        # Overlay fuzzy palette
      common/
        mod.rs                  # Shared widget helpers
        filter_bar.rs           # Reusable filter input row
        entity_table.rs         # Generic sortable/filterable table
        discussion_tree.rs      # Threaded discussion renderer
        status_bar.rs           # Bottom status + keybinding hints
        breadcrumb.rs           # Navigation breadcrumb trail
    action.rs                   # Async action runners (DB queries, GitLab calls)
    navigation.rs               # Screen stack + back/forward history

4.2 Elm-Style Event Loop

graph TD
    A[Terminal Events] -->|crossterm poll| B[Event Handler]
    B -->|translate| C[Message]
    C --> D[update fn]
    D -->|new state| E[AppState]
    D -->|side effects| F[Action Queue]
    F -->|spawn tokio task| G[Async Action]
    G -->|result| C
    E --> H[view fn]
    H -->|widget tree| I[ratatui Frame]
    I -->|diff + render| J[Terminal Output]

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

Core types:

// src/tui/message.rs

pub enum Message {
    // Navigation
    NavigateTo(Screen),
    GoBack,

    // Input
    Key(KeyEvent),
    Mouse(MouseEvent),
    Resize(u16, u16),
    Tick,

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

    // Issue list
    IssueListLoaded(Vec<IssueRow>),
    IssueListFilterChanged(IssueFilter),
    IssueListSortChanged(SortField, SortOrder),
    IssueSelected(i64),  // gitlab iid

    // MR list
    MrListLoaded(Vec<MrRow>),
    MrListFilterChanged(MrFilter),
    MrSelected(i64),

    // Detail views
    IssueDetailLoaded(IssueDetail),
    MrDetailLoaded(MrDetail),
    DiscussionsLoaded(Vec<Discussion>),

    // Search
    SearchQueryChanged(String),
    SearchExecuted(SearchResults),
    SearchResultSelected(usize),

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

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

    // Sync
    SyncStarted,
    SyncProgress(ProgressEvent),
    SyncLogLine(String),
    SyncCompleted(SyncResult),
    SyncFailed(String),

    // Dashboard
    DashboardLoaded(DashboardData),

    // System
    Error(String),
    Quit,
}

pub enum Screen {
    Dashboard,
    IssueList,
    IssueDetail(i64),  // project_id, iid
    MrList,
    MrDetail(i64),
    Search,
    Timeline,
    Who,
    Sync,
    Stats,
    Doctor,
}
// src/tui/app.rs

pub struct App {
    state: AppState,
    navigation: NavigationStack,
    action_tx: mpsc::UnboundedSender<Message>,
    db: Connection,
    config: Config,
    theme: Theme,
}

impl App {
    pub fn update(&mut self, msg: Message) -> Vec<Action> {
        match msg {
            Message::NavigateTo(screen) => {
                self.navigation.push(screen.clone());
                vec![Action::LoadScreen(screen)]
            }
            Message::GoBack => {
                if let Some(screen) = self.navigation.pop() {
                    vec![Action::LoadScreen(screen)]
                } else {
                    vec![]
                }
            }
            Message::Key(key) => self.handle_key(key),
            // ... delegate to sub-state handlers
            _ => self.delegate_to_active_screen(msg),
        }
    }

    pub fn view(&self, frame: &mut Frame) {
        let [header, body, footer] = Layout::vertical([
            Constraint::Length(1),   // breadcrumb
            Constraint::Min(0),      // main content
            Constraint::Length(1),   // status bar
        ]).areas(frame.area());

        // Breadcrumb
        view::common::breadcrumb::render(
            &self.navigation, frame, header
        );

        // Active screen
        match self.navigation.current() {
            Screen::Dashboard => view::dashboard::render(&self.state.dashboard, frame, body),
            Screen::IssueList => view::issue_list::render(&self.state.issue_list, frame, body),
            Screen::IssueDetail(_) => view::issue_detail::render(&self.state.issue_detail, frame, body),
            Screen::MrList => view::mr_list::render(&self.state.mr_list, frame, body),
            Screen::MrDetail(_) => view::mr_detail::render(&self.state.mr_detail, frame, body),
            Screen::Search => view::search::render(&self.state.search, frame, body),
            Screen::Timeline => view::timeline::render(&self.state.timeline, frame, body),
            Screen::Who => view::who::render(&self.state.who, frame, body),
            Screen::Sync => view::sync::render(&self.state.sync, frame, body),
            _ => {}
        }

        // Status bar with context-sensitive keybindings
        view::common::status_bar::render(
            &self.navigation, &self.theme, frame, footer
        );

        // Command palette overlay (if open)
        if self.state.command_palette.is_open {
            view::command_palette::render(
                &self.state.command_palette, frame, frame.area()
            );
        }
    }
}

4.3 Async Action System

graph LR
    subgraph "UI Thread (main)"
        A[App::update] -->|returns Actions| B[Action Dispatcher]
        E[Message Channel] --> A
    end

    subgraph "Tokio Runtime"
        B -->|spawn| C[DB Query]
        B -->|spawn| D[GitLab API Call]
        B -->|spawn| F[Embed Pipeline]
        C -->|msg_tx.send| E
        D -->|msg_tx.send| E
        F -->|msg_tx.send| 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
// src/tui/action.rs

pub enum Action {
    LoadScreen(Screen),

    // Database reads (run on blocking thread pool)
    FetchIssues(IssueFilter),
    FetchIssueDetail(i64),
    FetchMrs(MrFilter),
    FetchMrDetail(i64),
    FetchDiscussions(EntityType, i64),
    ExecuteSearch(SearchQuery),
    FetchTimeline(TimelineQuery),
    FetchWho(WhoQuery),
    FetchDashboard,
    FetchStats,

    // Network operations
    RunSync(SyncOptions),
    RunDoctor,

    // Browser
    OpenInBrowser(String),
}

4.4 Navigation Architecture

stateDiagram-v2
    [*] --> Dashboard
    Dashboard --> IssueList: i
    Dashboard --> MrList: m
    Dashboard --> Search: /
    Dashboard --> Timeline: t
    Dashboard --> Who: w
    Dashboard --> Sync: s

    IssueList --> IssueDetail: Enter
    IssueDetail --> IssueList: Esc/Backspace
    IssueDetail --> MrDetail: cross-ref link
    IssueDetail --> Timeline: t (scoped)

    MrList --> MrDetail: Enter
    MrDetail --> MrList: Esc/Backspace
    MrDetail --> IssueDetail: cross-ref link

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

    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: Ctrl+P opens Command Palette from anywhere
    note right of Dashboard: Esc/Backspace always goes back
    note right of Dashboard: q quits from any screen

4.5 Data Flow — Read Path

sequenceDiagram
    participant U as User
    participant TUI as TUI Event Loop
    participant S as State
    participant A as Action Runner
    participant DB as SQLite

    U->>TUI: Press 'i' (go to issues)
    TUI->>S: Message::NavigateTo(IssueList)
    S->>S: Push to navigation stack
    S-->>A: Action::FetchIssues(default_filter)
    A->>DB: SELECT from issues WHERE...
    DB-->>A: Vec<IssueRow>
    A-->>TUI: Message::IssueListLoaded(rows)
    TUI->>S: Update issue_list.items
    TUI->>TUI: view() → render table
    TUI-->>U: Rendered issue list

    U->>TUI: Type in filter bar "bug"
    TUI->>S: Message::IssueListFilterChanged(label="bug")
    S-->>A: Action::FetchIssues(filter{label:"bug"})
    A->>DB: SELECT ... WHERE label = 'bug'
    DB-->>A: Filtered rows
    A-->>TUI: Message::IssueListLoaded(filtered)
    TUI-->>U: Updated 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 │
├────────────────────────────┴───────────────────────────────────┤
│ [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 Update frequency: On entry + every 60s tick Interaction: Single-key navigation to all major screens

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 field:value syntax Pagination: Virtual scrolling for large result sets

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: lore issues <iid> + discussions + cross-references Sections: Collapsible (description, discussions, cross-refs) Discussion threads: Expand/collapse with arrow keys 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

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

5.5 MR Detail

Similar to Issue Detail, with additional sections:

┌─ 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:

  • File changes list with diffstat (+/- indicators)
  • Diff discussions shown with file:line context
  • Separate sections for diff discussions vs general discussions
┌─ 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 Filters: Type (issue/mr/discussion), project, author, label, since Live preview: Selected result shows snippet + metadata in right 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

┌─ 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│
└────────────────────────────────────────────────────────────────┘

Layout: Progress bars (top) + scrolling log (bottom) Progress source: ProgressCallback / ProgressEvent from ingestion orchestrator Log source: Captured tracing subscriber output Controls:

  • f — toggle full sync (reset cursors)
  • e — toggle embed after sync
  • d — dry-run mode
  • l — cycle log verbosity
  • Esc — cancel running sync (graceful shutdown via signal)

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)

Simple read-only views rendering the output of lore doctor and lore stats as styled text blocks. No complex interaction needed — just scrollable content with Esc to go back.


6. User Flows

6.1 Flow: "Find who knows about auth code"

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/EnterEnter↓↓↓Entero 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 458lore mrs 458 --open CLI keystrokes: 6 separate commands, ~200 characters of typing.

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

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 → EnterEsc → 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"

graph TD
    A[Any Screen] -->|/| 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 check results"

graph TD
    A[Dashboard] -->|s| B[Sync Screen]
    B -->|Enter to start| C[Progress Bars + Log Stream]
    C -->|watch progress| D[Sync Completes]
    D -->|i| E[Issue List shows new items]
    E -->|sort by updated| F[See freshly synced issues]

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

6.5 Flow: "Review someone's open work"

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

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

7. Widget Inventory

7.1 Ratatui Built-in Widgets Used

Widget Usage Screen(s)
Table Issue/MR lists, expert rankings, file changes IssueList, MrList, Who, MrDetail
Paragraph Descriptions, discussion content, log output IssueDetail, MrDetail, Sync
Block Section borders with titles All
Gauge Sync progress bars Sync
List Search results, timeline events, command palette Search, Timeline, Palette
Sparkline Activity trends on dashboard Dashboard
Tabs Mode switching (Who modes, Search modes) Who, Search
Scrollbar Long content scrolling Detail views, Timeline
BarChart Code area distribution Who (expert detail)

7.2 Custom Widgets (Build Ourselves)

Widget Purpose Complexity
FilterBar Interactive field:value filter input Medium
DiscussionTree Threaded discussion with collapse/expand High
CommandPalette Fuzzy-filtered command overlay Medium
Breadcrumb Navigation trail showing current path Low
StatusBar Context-sensitive keybinding hints Low
EntityPreview Right-pane preview of selected entity Medium
ProgressPanel Multi-project sync progress with log tail Medium
TimelineStream Color-coded chronological event list Medium
CrossRefLink Clickable entity references Low

7.3 Third-Party Widget Crates

Crate Widget Version Purpose
tui-textarea TextArea latest Multi-line search/filter input
tui-tree-widget Tree latest Discussion thread hierarchy
tui-scrollview ScrollView latest Smooth scrolling for detail views

8. Keybinding Reference

8.1 Global (Available Everywhere)

Key Action
q Quit TUI
Esc Go back / close overlay
Backspace Go back (when not in text input)
Ctrl+P Open command palette
/ Focus search (or navigate to Search from Dashboard)
? Show help overlay
o Open current entity in browser
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
G Jump to bottom
g Jump to top
Tab Cycle sort column
f Focus filter bar
r Refresh data
[ / ] Previous/next page

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

9. Implementation Plan

9.1 Dependency Additions

# Cargo.toml additions

[dependencies]
# TUI framework
ratatui = { version = "0.30", features = ["crossterm"] }
crossterm = "0.28"

# TUI widgets
tui-textarea = "0.7"
tui-tree-widget = "0.22"
tui-scrollview = "0.5"

# Fuzzy matching for command palette
nucleo-matcher = "0.3"

9.2 Phases

gantt
    title TUI Implementation Phases
    dateFormat  YYYY-MM-DD
    axisFormat  %b %d

    section Phase 1 — Foundation
    App skeleton + event loop       :p1a, 2026-02-15, 3d
    Theme system                    :p1b, after p1a, 1d
    Navigation stack + breadcrumb   :p1c, after p1a, 2d
    Status bar + keybinding hints   :p1d, after p1c, 1d

    section Phase 2 — Core Screens
    Dashboard                       :p2a, after p1d, 2d
    Issue List + filter bar         :p2b, after p2a, 3d
    Issue Detail + discussions      :p2c, after p2b, 4d
    MR List                         :p2d, after p2b, 1d
    MR Detail + diff discussions    :p2e, after p2d, 3d

    section Phase 3 — Power Features
    Search (hybrid + preview)       :p3a, after p2e, 3d
    Timeline viewer                 :p3b, after p3a, 3d
    Who (all 5 modes)              :p3c, after p3b, 4d
    Command Palette                 :p3d, after p2a, 2d

    section Phase 4 — Operations
    Sync screen + progress          :p4a, after p3c, 3d
    Doctor + Stats views            :p4b, after p4a, 1d
    CLI integration (lore tui)      :p4c, after p4b, 1d

    section Phase 5 — Polish
    Responsive breakpoints          :p5a, after p4c, 2d
    Snapshot tests                  :p5b, after p5a, 2d
    Documentation                   :p5c, after p5b, 1d

Total estimated scope: ~40 implementation days across 5 phases.

9.3 Phase 1 Detail — Foundation

Goal: Render a blank TUI with working navigation, theme, and event loop.

Deliverables:

  1. src/tui/mod.rspub fn launch_tui(config: Config, db_path: &Path) -> Result<()>
  2. src/tui/app.rsApp struct with Elm-style update() + view()
  3. src/tui/event.rs — Crossterm event polling on background thread → Message channel
  4. src/tui/message.rs — Full Message enum
  5. src/tui/navigation.rsNavigationStack with push/pop/current
  6. src/tui/theme.rs — Semantic theme tokens (detect dark/light terminal)
  7. src/tui/view/common/breadcrumb.rs — Navigation breadcrumb renderer
  8. src/tui/view/common/status_bar.rs — Keybinding hint bar
  9. src/main.rs — Add lore tui subcommand

10. Code Changes Required

10.1 New CLI Subcommand

// src/cli/mod.rs — add to Commands enum

#[derive(Subcommand)]
pub enum Commands {
    // ... existing commands ...

    /// Launch interactive TUI
    Tui,
}
// src/main.rs — add match arm

Commands::Tui => {
    let config = load_config(&cli)?;
    let db_path = config.storage.db_path();
    crate::tui::launch_tui(config, &db_path)?;
}

10.2 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:

// 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

10.3 Core App Structure

// src/tui/mod.rs

mod app;
mod event;
mod message;
mod navigation;
mod theme;
mod action;
mod state;
mod view;

use std::path::Path;
use crate::core::config::Config;
use crate::core::error::LoreError;

pub fn launch_tui(config: Config, db_path: &Path) -> Result<(), LoreError> {
    // Enable raw mode
    crossterm::terminal::enable_raw_mode()
        .map_err(|e| LoreError::Io(e.into()))?;

    // Enter alternate screen
    let mut stdout = std::io::stdout();
    crossterm::execute!(
        stdout,
        crossterm::terminal::EnterAlternateScreen,
        crossterm::event::EnableMouseCapture,
    ).map_err(|e| LoreError::Io(e.into()))?;

    let backend = ratatui::backend::CrosstermBackend::new(stdout);
    let mut terminal = ratatui::Terminal::new(backend)
        .map_err(|e| LoreError::Io(e.into()))?;

    // Build runtime
    let rt = tokio::runtime::Runtime::new()
        .map_err(|e| LoreError::Io(e.into()))?;

    // Run app
    let result = rt.block_on(async {
        let db = crate::core::db::create_connection(db_path)?;
        let mut app = app::App::new(config, db);
        app.run(&mut terminal).await
    });

    // Restore terminal (RAII-safe)
    crossterm::terminal::disable_raw_mode()
        .map_err(|e| LoreError::Io(e.into()))?;
    crossterm::execute!(
        terminal.backend_mut(),
        crossterm::terminal::LeaveAlternateScreen,
        crossterm::event::DisableMouseCapture,
    ).map_err(|e| LoreError::Io(e.into()))?;
    terminal.show_cursor()
        .map_err(|e| LoreError::Io(e.into()))?;

    result
}
// src/tui/event.rs

use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyModifiers};
use tokio::sync::mpsc;
use std::time::Duration;

pub struct EventHandler {
    rx: mpsc::UnboundedReceiver<CrosstermEvent>,
}

impl EventHandler {
    pub fn new() -> (Self, tokio::task::JoinHandle<()>) {
        let (tx, rx) = mpsc::unbounded_channel();
        let handle = tokio::task::spawn_blocking(move || {
            loop {
                if event::poll(Duration::from_millis(50)).unwrap_or(false) {
                    if let Ok(evt) = event::read() {
                        if tx.send(evt).is_err() {
                            break;
                        }
                    }
                }
            }
        });
        (Self { rx }, handle)
    }

    pub async fn next(&mut self) -> Option<CrosstermEvent> {
        self.rx.recv().await
    }
}
// src/tui/theme.rs

use ratatui::style::{Color, Modifier, Style};

pub struct Theme {
    // Semantic tokens
    pub fg: Color,
    pub fg_dim: Color,
    pub fg_emphasis: Color,
    pub bg: Color,
    pub bg_surface: Color,
    pub bg_highlight: Color,

    // Entity colors
    pub issue_opened: Color,
    pub issue_closed: Color,
    pub mr_opened: Color,
    pub mr_merged: Color,
    pub mr_closed: Color,
    pub mr_draft: Color,

    // Event colors (timeline)
    pub event_created: Color,
    pub event_state: Color,
    pub event_label: Color,
    pub event_note: Color,
    pub event_xref: Color,
    pub event_merged: Color,

    // UI chrome
    pub border: Color,
    pub border_focus: Color,
    pub selection_bg: Color,
    pub selection_fg: Color,
    pub search_match: Color,
    pub error: Color,
    pub warning: Color,
    pub success: Color,

    // Progress
    pub progress_fill: Color,
    pub progress_empty: Color,
}

impl Theme {
    pub fn dark() -> Self {
        Self {
            fg: Color::Rgb(220, 220, 220),
            fg_dim: Color::Rgb(128, 128, 128),
            fg_emphasis: Color::White,
            bg: Color::Reset,  // Use terminal default
            bg_surface: Color::Rgb(30, 30, 30),
            bg_highlight: Color::Rgb(45, 45, 45),

            issue_opened: Color::Green,
            issue_closed: Color::Red,
            mr_opened: Color::Green,
            mr_merged: Color::Rgb(130, 80, 220),
            mr_closed: Color::Red,
            mr_draft: Color::Yellow,

            event_created: Color::Green,
            event_state: Color::Yellow,
            event_label: Color::Cyan,
            event_note: Color::White,
            event_xref: Color::Magenta,
            event_merged: Color::Green,

            border: Color::Rgb(60, 60, 60),
            border_focus: Color::Rgb(100, 150, 255),
            selection_bg: Color::Rgb(50, 80, 140),
            selection_fg: Color::White,
            search_match: Color::Rgb(255, 200, 50),
            error: Color::Red,
            warning: Color::Yellow,
            success: Color::Green,

            progress_fill: Color::Rgb(100, 150, 255),
            progress_empty: Color::Rgb(40, 40, 40),
        }
    }

    pub fn detect() -> Self {
        // Future: detect light/dark from terminal
        Self::dark()
    }

    // Convenience style builders
    pub fn title(&self) -> Style {
        Style::default().fg(self.fg_emphasis).add_modifier(Modifier::BOLD)
    }

    pub fn dim(&self) -> Style {
        Style::default().fg(self.fg_dim)
    }

    pub fn selected(&self) -> Style {
        Style::default().fg(self.selection_fg).bg(self.selection_bg)
    }

    pub fn state_color(&self, state: &str) -> Color {
        match state {
            "opened" => self.issue_opened,
            "closed" => self.issue_closed,
            "merged" => self.mr_merged,
            "locked" => self.warning,
            _ => self.fg,
        }
    }
}
// src/tui/navigation.rs

use super::message::Screen;

pub struct NavigationStack {
    stack: Vec<Screen>,
}

impl NavigationStack {
    pub fn new() -> Self {
        Self {
            stack: vec![Screen::Dashboard],
        }
    }

    pub fn current(&self) -> &Screen {
        self.stack.last().expect("navigation stack is never empty")
    }

    pub fn push(&mut self, screen: Screen) {
        self.stack.push(screen);
    }

    pub fn pop(&mut self) -> Option<Screen> {
        if self.stack.len() > 1 {
            self.stack.pop()
        } else {
            None // Can't pop the last screen
        }
    }

    pub fn breadcrumbs(&self) -> Vec<&str> {
        self.stack.iter().map(|s| s.label()).collect()
    }

    pub fn depth(&self) -> usize {
        self.stack.len()
    }

    pub fn reset_to(&mut self, screen: Screen) {
        self.stack.clear();
        self.stack.push(screen);
    }
}

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",
        }
    }
}

10.4 Sample View Implementation — Dashboard

// src/tui/view/dashboard.rs

use ratatui::{
    Frame,
    layout::{Constraint, Layout, Rect},
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Sparkline},
};
use crate::tui::state::dashboard::DashboardState;
use crate::tui::theme::Theme;

pub fn render(state: &DashboardState, theme: &Theme, frame: &mut Frame, area: Rect) {
    let [left, right] = Layout::horizontal([
        Constraint::Percentage(40),
        Constraint::Percentage(60),
    ]).areas(area);

    let [projects, sync_info] = Layout::vertical([
        Constraint::Percentage(50),
        Constraint::Percentage(50),
    ]).areas(left);

    let [stats, recent] = Layout::vertical([
        Constraint::Length(10),
        Constraint::Min(0),
    ]).areas(right);

    // Projects panel
    render_projects(state, theme, frame, projects);

    // Sync info panel
    render_sync_info(state, theme, frame, sync_info);

    // Quick stats panel
    render_stats(state, theme, frame, stats);

    // Recent activity panel
    render_recent(state, theme, frame, recent);
}

fn render_projects(state: &DashboardState, theme: &Theme, frame: &mut Frame, area: Rect) {
    let block = Block::default()
        .title(" Projects ")
        .borders(Borders::ALL)
        .border_style(Style::default().fg(theme.border));

    let items: Vec<ListItem> = state.projects.iter().map(|p| {
        let status_icon = if p.minutes_since_sync < 10 {
            Span::styled(" ✓ ", Style::default().fg(theme.success))
        } else if p.minutes_since_sync < 120 {
            Span::styled(" ⚠ ", Style::default().fg(theme.warning))
        } else {
            Span::styled(" ✗ ", Style::default().fg(theme.error))
        };

        let age = format_relative_time(p.minutes_since_sync);

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

    let list = List::new(items).block(block);
    frame.render_widget(list, area);
}

fn render_stats(state: &DashboardState, theme: &Theme, frame: &mut Frame, area: Rect) {
    let block = Block::default()
        .title(" Quick Stats ")
        .borders(Borders::ALL)
        .border_style(Style::default().fg(theme.border));

    let text = vec![
        Line::from(vec![
            Span::styled("  Issues:    ", theme.dim()),
            Span::styled(
                format!("{} open", state.counts.issues_open),
                Style::default().fg(theme.issue_opened),
            ),
            Span::styled(
                format!(" / {} total", state.counts.issues_total),
                theme.dim(),
            ),
        ]),
        Line::from(vec![
            Span::styled("  MRs:       ", theme.dim()),
            Span::styled(
                format!("{} open", state.counts.mrs_open),
                Style::default().fg(theme.mr_opened),
            ),
            Span::styled(
                format!(" / {} total", state.counts.mrs_total),
                theme.dim(),
            ),
        ]),
        Line::from(vec![
            Span::styled("  Discuss:   ", theme.dim()),
            Span::styled(
                format!("{} threads", state.counts.discussions),
                Style::default().fg(theme.fg),
            ),
        ]),
        Line::from(vec![
            Span::styled("  Notes:     ", theme.dim()),
            Span::styled(
                format!("{}", state.counts.notes_total),
                Style::default().fg(theme.fg),
            ),
            Span::styled(
                format!(" ({}% system)", state.counts.notes_system_pct),
                theme.dim(),
            ),
        ]),
        Line::from(vec![
            Span::styled("  Documents: ", theme.dim()),
            Span::styled(
                format!("{} indexed", state.counts.documents),
                Style::default().fg(theme.fg),
            ),
        ]),
        Line::from(vec![
            Span::styled("  Embeddings:", theme.dim()),
            Span::styled(
                format!("{} vectors", state.counts.embeddings),
                Style::default().fg(theme.fg),
            ),
        ]),
    ];

    let paragraph = Paragraph::new(text).block(block);
    frame.render_widget(paragraph, area);
}

fn render_recent(state: &DashboardState, theme: &Theme, frame: &mut Frame, area: Rect) {
    let block = Block::default()
        .title(" Recent Activity ")
        .borders(Borders::ALL)
        .border_style(Style::default().fg(theme.border));

    let items: Vec<ListItem> = state.recent.iter().map(|item| {
        let prefix = match item.entity_type.as_str() {
            "issue" => Span::styled(
                format!("  #{:<5}", item.iid),
                Style::default().fg(theme.issue_opened),
            ),
            "mr" => Span::styled(
                format!("  !{:<5}", item.iid),
                Style::default().fg(theme.mr_opened),
            ),
            _ => Span::raw("       "),
        };

        let title = Span::styled(
            truncate_str(&item.title, 40),
            Style::default().fg(theme.fg),
        );

        let age = Span::styled(
            format!("  {}", format_relative_time(item.minutes_ago)),
            theme.dim(),
        );

        ListItem::new(Line::from(vec![prefix, title, age]))
    }).collect();

    let list = List::new(items).block(block);
    frame.render_widget(list, area);
}

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)
    }
}

fn truncate_str(s: &str, max: usize) -> String {
    if s.len() > max {
        format!("{}..", &s[..s.floor_char_boundary(max - 2)])
    } else {
        s.to_string()
    }
}

10.5 Command Palette Implementation

// src/tui/state/command_palette.rs

use nucleo_matcher::{Matcher, pattern::Pattern};

pub struct CommandPaletteState {
    pub is_open: bool,
    pub input: String,
    pub items: Vec<PaletteItem>,
    pub filtered: Vec<usize>,  // Indices into items
    pub selected: usize,
    matcher: Matcher,
}

pub struct PaletteItem {
    pub label: String,
    pub description: String,
    pub action: PaletteAction,
}

pub enum PaletteAction {
    Navigate(super::super::message::Screen),
    RunSync { full: bool, embed: bool },
    RunDoctor,
    ShowStats,
    OpenBrowser,
    Quit,
}

impl CommandPaletteState {
    pub fn new() -> Self {
        let items = vec![
            PaletteItem {
                label: "Issues".into(),
                description: "Browse all issues".into(),
                action: PaletteAction::Navigate(Screen::IssueList),
            },
            PaletteItem {
                label: "Issues (opened)".into(),
                description: "Issues filtered to opened state".into(),
                action: PaletteAction::Navigate(Screen::IssueList),
            },
            PaletteItem {
                label: "Merge Requests".into(),
                description: "Browse all merge requests".into(),
                action: PaletteAction::Navigate(Screen::MrList),
            },
            PaletteItem {
                label: "MRs (draft)".into(),
                description: "Draft/WIP merge requests".into(),
                action: PaletteAction::Navigate(Screen::MrList),
            },
            PaletteItem {
                label: "Search".into(),
                description: "Hybrid full-text + semantic search".into(),
                action: PaletteAction::Navigate(Screen::Search),
            },
            PaletteItem {
                label: "Timeline".into(),
                description: "Chronological event reconstruction".into(),
                action: PaletteAction::Navigate(Screen::Timeline),
            },
            PaletteItem {
                label: "Who (expert)".into(),
                description: "Find experts for a code path".into(),
                action: PaletteAction::Navigate(Screen::Who),
            },
            PaletteItem {
                label: "Who (workload)".into(),
                description: "View someone's assigned work".into(),
                action: PaletteAction::Navigate(Screen::Who),
            },
            PaletteItem {
                label: "Who (active)".into(),
                description: "Unresolved discussions, last 7 days".into(),
                action: PaletteAction::Navigate(Screen::Who),
            },
            PaletteItem {
                label: "Sync".into(),
                description: "Incremental sync from GitLab".into(),
                action: PaletteAction::RunSync { full: false, embed: true },
            },
            PaletteItem {
                label: "Sync (full)".into(),
                description: "Full sync with cursor reset".into(),
                action: PaletteAction::RunSync { full: true, embed: true },
            },
            PaletteItem {
                label: "Sync (no embed)".into(),
                description: "Sync without vector embedding".into(),
                action: PaletteAction::RunSync { full: false, embed: false },
            },
            PaletteItem {
                label: "Doctor".into(),
                description: "Check environment health".into(),
                action: PaletteAction::RunDoctor,
            },
            PaletteItem {
                label: "Stats".into(),
                description: "Document and index statistics".into(),
                action: PaletteAction::ShowStats,
            },
            PaletteItem {
                label: "Open in browser".into(),
                description: "Open current entity in GitLab".into(),
                action: PaletteAction::OpenBrowser,
            },
            PaletteItem {
                label: "Quit".into(),
                description: "Exit the TUI".into(),
                action: PaletteAction::Quit,
            },
        ];

        let filtered = (0..items.len()).collect();

        Self {
            is_open: false,
            input: String::new(),
            items,
            filtered,
            selected: 0,
            matcher: Matcher::new(nucleo_matcher::Config::DEFAULT),
        }
    }

    pub fn open(&mut self) {
        self.is_open = true;
        self.input.clear();
        self.selected = 0;
        self.update_filter();
    }

    pub fn close(&mut self) {
        self.is_open = false;
    }

    pub fn update_filter(&mut self) {
        if self.input.is_empty() {
            self.filtered = (0..self.items.len()).collect();
        } else {
            // Fuzzy match using nucleo
            let pattern = Pattern::parse(
                &self.input,
                nucleo_matcher::pattern::CaseMatching::Ignore,
                nucleo_matcher::pattern::Normalization::Smart,
            );

            let mut scored: Vec<(usize, u32)> = self.items.iter()
                .enumerate()
                .filter_map(|(i, item)| {
                    let mut buf = Vec::new();
                    let haystack = nucleo_matcher::Utf32Str::new(
                        &item.label, &mut buf
                    );
                    pattern.score(haystack, &mut self.matcher)
                        .map(|score| (i, score))
                })
                .collect();

            scored.sort_by(|a, b| b.1.cmp(&a.1));
            self.filtered = scored.into_iter().map(|(i, _)| i).collect();
        }

        self.selected = 0;
    }

    pub fn select_current(&self) -> Option<&PaletteAction> {
        self.filtered.get(self.selected)
            .map(|&i| &self.items[i].action)
    }
}

Appendix A: Full Architecture Diagram

graph TB
    subgraph "Terminal Layer"
        CT[crossterm]
        RT[ratatui]
    end

    subgraph "TUI Module (new)"
        direction TB
        EH[Event Handler] -->|Message| AL[App Loop]
        AL -->|update| ST[State Tree]
        AL -->|view| VW[View Router]
        AL -->|actions| 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[Command Palette]
        end

        subgraph "Views"
            VW --> DV[Dashboard View]
            VW --> IV[Issue Views]
            VW --> MV[MR Views]
            VW --> SV[Search View]
            VW --> TV[Timeline View]
            VW --> WV[Who View]
            VW --> YV[Sync View]
            VW --> PV[Palette Overlay]
        end
    end

    subgraph "Existing Modules (unchanged)"
        direction TB
        DB[(SQLite DB)]
        GL[GitLab Client]
        ING[Ingestion]
        DOC[Documents]
        EMB[Embedding]
        SCH[Search]
        TIM[Timeline]
        WHO2[Who Queries]
    end

    CT -->|events| EH
    VW -->|widgets| RT
    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 AL 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

Appendix B: State Machine — Filter Bar

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: Check frame.area().width in each view function and adjust Constraint arrays accordingly.


Appendix D: Discussion Thread Rendering Algorithm

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 will stabilize within 6 months. If it doesn't reach v0.5+ with broader adoption, we never revisit. If it does, we evaluate a migration.
  2. Ratatui 0.30 API is stable. We assume no breaking changes in the 0.30.x line during implementation.
  3. crossterm works in all target terminals. macOS Terminal.app, iTerm2, Alacritty, Kitty, tmux, SSH sessions. This is well-validated by ratatui's adoption.
  4. Nightly Rust is unacceptable for gitlore. This eliminates FrankenTUI as a direct dependency.

Data & Performance

  1. SQLite queries are fast enough for interactive use. Issue lists with 10,000+ items should render filtered results in <50ms. The existing index design supports this.
  2. Virtual scrolling handles large datasets. We don't load all 10K issues into memory — we page from SQLite.
  3. Sync progress can be captured as events. The existing ProgressCallback / ProgressEvent system is sufficient for the TUI progress display.
  4. The database is never locked during TUI use. Only lore sync acquires the app lock. The TUI reads with WAL mode, which allows concurrent reads.

User Experience

  1. Vim-style keybindings are acceptable. j/k navigation is standard for developer TUIs. We also support arrow keys for accessibility.
  2. Mouse support is optional but nice. Click-to-select in lists, scrollbar dragging. Not required for core functionality.
  3. The TUI does not replace the CLI. All commands continue to work via lore issues, lore search, etc. The TUI is invoked explicitly via lore tui.
  4. No TUI-specific configuration is needed initially. Theme auto-detects dark/light. Keybindings are fixed (no customization in v1).
  5. Users have 80+ column terminals. The minimum usable width is ~60 columns. Below that, we show a "terminal too narrow" message.

Implementation

  1. The query layer can be cleanly extracted. Some commands may have coupled query+display logic that needs refactoring. This is estimated at ~2 days of work.
  2. tokio is already in the dependency tree. No new async runtime needed — we use the existing tokio runtime for async actions.
  3. No new SQLite tables are needed. The TUI is purely a view layer over existing data.
  4. The TUI module is feature-gated. cargo build --features tui to avoid adding ratatui/crossterm to the default binary size for robot-mode-only deployments.

Scope Boundaries

  1. No write operations in v1. The TUI is read-only + sync. No creating issues, editing labels, or posting comments from the TUI. This is a significant simplification.
  2. No init wizard in the TUI. Configuration remains CLI-only (lore init).
  3. No embedding visualization. The TUI shows embedding counts but doesn't visualize vector space.
  4. No concurrent TUI sessions. Only one lore tui instance at a time (enforced by terminal ownership, not app locks).

Sources