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>
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
- Executive Summary
- Framework Evaluation
- Recommendation
- Architecture
- Screen Taxonomy
- User Flows — Every Command
- Widget Inventory
- Keybinding Reference
- Implementation Plan
- Code Changes Required
- 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 syncwhere you want progress bars + log output. - Elm architecture is clean. The
Modeltrait withupdate()/view()andCmd<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
instasnapshots. - 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:
- 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.
- No toolchain change. Staying on stable Rust avoids nightly churn.
- Community safety net. 2,200+ dependent crates means problems are solvable. Every terminal quirk has been encountered.
- 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.
- 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 duringlore sync. - Semantic theming: Build a
Themestruct 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
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 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 syncd— dry-run model— cycle log verbosityEsc— 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/ → ↓ → 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
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 → 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"
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:
src/tui/mod.rs—pub fn launch_tui(config: Config, db_path: &Path) -> Result<()>src/tui/app.rs—Appstruct with Elm-styleupdate()+view()src/tui/event.rs— Crossterm event polling on background thread →Messagechannelsrc/tui/message.rs— FullMessageenumsrc/tui/navigation.rs—NavigationStackwith push/pop/currentsrc/tui/theme.rs— Semantic theme tokens (detect dark/light terminal)src/tui/view/common/breadcrumb.rs— Navigation breadcrumb renderersrc/tui/view/common/status_bar.rs— Keybinding hint barsrc/main.rs— Addlore tuisubcommand
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):
- Group by file path
- Within each file, sort by line number
- Show
[file.rs:45]context prefix - Indent discussion tree under file header
11. Assumptions
Framework Choice
- 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.
- Ratatui 0.30 API is stable. We assume no breaking changes in the 0.30.x line during implementation.
- crossterm works in all target terminals. macOS Terminal.app, iTerm2, Alacritty, Kitty, tmux, SSH sessions. This is well-validated by ratatui's adoption.
- Nightly Rust is unacceptable for gitlore. This eliminates FrankenTUI as a direct dependency.
Data & Performance
- 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.
- Virtual scrolling handles large datasets. We don't load all 10K issues into memory — we page from SQLite.
- Sync progress can be captured as events. The existing
ProgressCallback/ProgressEventsystem is sufficient for the TUI progress display. - The database is never locked during TUI use. Only
lore syncacquires the app lock. The TUI reads with WAL mode, which allows concurrent reads.
User Experience
- Vim-style keybindings are acceptable.
j/knavigation is standard for developer TUIs. We also support arrow keys for accessibility. - Mouse support is optional but nice. Click-to-select in lists, scrollbar dragging. Not required for core functionality.
- The TUI does not replace the CLI. All commands continue to work via
lore issues,lore search, etc. The TUI is invoked explicitly vialore tui. - No TUI-specific configuration is needed initially. Theme auto-detects dark/light. Keybindings are fixed (no customization in v1).
- Users have 80+ column terminals. The minimum usable width is ~60 columns. Below that, we show a "terminal too narrow" message.
Implementation
- 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.
- tokio is already in the dependency tree. No new async runtime needed — we use the existing tokio runtime for async actions.
- No new SQLite tables are needed. The TUI is purely a view layer over existing data.
- The TUI module is feature-gated.
cargo build --features tuito avoid adding ratatui/crossterm to the default binary size for robot-mode-only deployments.
Scope Boundaries
- 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.
- No
initwizard in the TUI. Configuration remains CLI-only (lore init). - No embedding visualization. The TUI shows embedding counts but doesn't visualize vector space.
- No concurrent TUI sessions. Only one
lore tuiinstance at a time (enforced by terminal ownership, not app locks).
Sources
- FrankenTUI GitHub Repository
- FrankenTUI Website
- FrankenTUI on LinkedIn (Jeffrey Emanuel)
- Ratatui Official Site
- Ratatui crates.io
- FrankenTUI Hacker News Discussion
- ftui-extras docs.rs
- ftui-widgets docs.rs
- ftui-runtime docs.rs
- ftui-layout docs.rs
- ftui-core docs.rs
- ftui-style docs.rs
- ftui-demo-showcase on crates.io
- @doodlestein on X announcing FrankenTUI