From 1161edb21218ae66deceb3f7d627bd6e7a932ba0 Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Wed, 11 Feb 2026 08:11:26 -0500 Subject: [PATCH] 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 --- plans/tui-prd-v2-frankentui.feedback-1.md | 209 + plans/tui-prd-v2-frankentui.feedback-2.md | 203 + plans/tui-prd-v2-frankentui.feedback-3.md | 163 + plans/tui-prd-v2-frankentui.feedback-4.md | 157 + plans/tui-prd-v2-frankentui.feedback-5.md | 200 + plans/tui-prd-v2-frankentui.feedback-6.md | 150 + plans/tui-prd-v2-frankentui.feedback-7.md | 264 + plans/tui-prd-v2-frankentui.feedback-8.md | 211 + plans/tui-prd-v2-frankentui.feedback-9.md | 198 + plans/tui-prd-v2-frankentui.md | 7808 +++++++++++++++++++++ plans/tui-prd.md | 2075 ++++++ 11 files changed, 11638 insertions(+) create mode 100644 plans/tui-prd-v2-frankentui.feedback-1.md create mode 100644 plans/tui-prd-v2-frankentui.feedback-2.md create mode 100644 plans/tui-prd-v2-frankentui.feedback-3.md create mode 100644 plans/tui-prd-v2-frankentui.feedback-4.md create mode 100644 plans/tui-prd-v2-frankentui.feedback-5.md create mode 100644 plans/tui-prd-v2-frankentui.feedback-6.md create mode 100644 plans/tui-prd-v2-frankentui.feedback-7.md create mode 100644 plans/tui-prd-v2-frankentui.feedback-8.md create mode 100644 plans/tui-prd-v2-frankentui.feedback-9.md create mode 100644 plans/tui-prd-v2-frankentui.md create mode 100644 plans/tui-prd.md diff --git a/plans/tui-prd-v2-frankentui.feedback-1.md b/plans/tui-prd-v2-frankentui.feedback-1.md new file mode 100644 index 0000000..96e073d --- /dev/null +++ b/plans/tui-prd-v2-frankentui.feedback-1.md @@ -0,0 +1,209 @@ +No `## Rejected Recommendations` section was present, so these are all net-new improvements. + +1. Keep core `lore` stable; isolate nightly to a TUI crate +Rationale: the current plan says “whole project nightly” but later assumes TUI is feature-gated. Isolating nightly removes unnecessary risk from non-TUI users, CI, and release cadence. + +```diff +@@ 3.2 Nightly Rust Strategy +-- The entire gitlore project moves to pinned nightly, not just the TUI feature. ++- Keep core `lore` on stable Rust. ++- Add workspace member `lore-tui` pinned to nightly for FrankenTUI. ++- Ship `lore tui` only when `--features tui` (or separate `lore-tui` binary) is enabled. + +@@ 10.1 New Files ++- crates/lore-tui/Cargo.toml ++- crates/lore-tui/src/main.rs + +@@ 11. Assumptions +-17. TUI module is feature-gated. ++17. TUI is isolated in a workspace crate and feature-gated in root CLI integration. +``` + +2. Add a framework adapter boundary from day 1 +Rationale: the “3-day ratatui escape hatch” is optimistic without a strict interface. A tiny `UiRuntime` + screen renderer trait makes fallback real, not aspirational. + +```diff +@@ 4. Architecture ++### 4.9 UI Runtime Abstraction ++Introduce `UiRuntime` trait (`run`, `send`, `subscribe`) and `ScreenRenderer` trait. ++FrankenTUI implementation is default; ratatui adapter can be dropped in with no state/action rewrite. + +@@ 3.5 Escape Hatch +-- The migration cost to ratatui is ~3 days ++- Migration cost target is ~3-5 days, validated by one ratatui spike screen in Phase 1. +``` + +3. Stop using CLI command modules as the TUI query API +Rationale: coupling TUI to CLI output-era structs creates long-term friction and accidental regressions. Create a shared domain query layer used by both CLI and TUI. + +```diff +@@ 10.20 Refactor: Extract Query Functions +-- extract query_* from cli/commands/* ++- introduce `src/domain/query/*` as the canonical read model API. ++- CLI and TUI both depend on domain query layer. ++- CLI modules retain formatting/output only. + +@@ 10.2 Modified Files ++- src/domain/query/mod.rs ++- src/domain/query/issues.rs ++- src/domain/query/mrs.rs ++- src/domain/query/search.rs ++- src/domain/query/who.rs +``` + +4. Replace single `Arc>` with connection manager +Rationale: one locked connection serializes everything and hurts responsiveness, especially during sync. Use separate read pool + writer connection with WAL and busy timeout. + +```diff +@@ 4.4 App — Implementing the Model Trait +- pub db: Arc>, ++ pub db: Arc, // read pool + single writer coordination + +@@ 4.5 Async Action System +- Each Cmd::task closure locks the mutex, runs the query, and returns a Msg ++ Reads use pooled read-only connections. ++ Sync/write path uses dedicated writer connection. ++ Enforce WAL, busy_timeout, and retry policy for SQLITE_BUSY. +``` + +5. Make debouncing/cancellation explicit and correct +Rationale: “runtime coalesces rapid keypresses” is not a safe correctness guarantee. Add request IDs and stale-response dropping to prevent flicker and wrong data. + +```diff +@@ 4.3 Core Types (Msg) ++ SearchRequestStarted { request_id: u64, query: String } +- SearchExecuted(SearchResults), ++ SearchExecuted { request_id: u64, results: SearchResults }, + +@@ 4.4 maybe_debounced_query() +- runtime coalesces rapid keypresses ++ use explicit 200ms debounce timer + monotonic request_id ++ ignore results whose request_id != current_search_request_id +``` + +6. Implement true streaming sync, not batch-at-end pseudo-streaming +Rationale: the plan promises real-time logs/progress but code currently returns one completion message. This gap will disappoint users and complicate cancellation. + +```diff +@@ 4.4 start_sync_task() +- Pragmatic approach: run sync synchronously, collect all progress events, return summary. ++ Use event channel subscription for `SyncProgress`/`SyncLogLine` streaming. ++ Keep `SyncCompleted` only as terminal event. ++ Add cooperative cancel token mapped to `Esc` while running. + +@@ 5.9 Sync ++ Add "Resume from checkpoint" option for interrupted syncs. +``` + +7. Fix entity identity ambiguity across projects +Rationale: using `iid` alone is unsafe in multi-project datasets. Navigation and cross-refs should key by `(project_id, iid)` or global ID. + +```diff +@@ 4.3 Core Types +- IssueDetail(i64) +- MrDetail(i64) ++ IssueDetail(EntityKey) ++ MrDetail(EntityKey) + ++ pub struct EntityKey { pub project_id: i64, pub iid: i64, pub kind: EntityKind } + +@@ 10.12.4 Cross-Reference Widget +- parse "group/project#123" -> iid only ++ parse into `{project_path, iid, kind}` then resolve to `project_id` before navigation +``` + +8. Resolve keybinding conflicts and formalize keymap precedence +Rationale: current spec conflicts (`Tab` sort vs focus filter; `gg` vs go-prefix). A deterministic keymap contract prevents UX bugs. + +```diff +@@ 8.2 List Screens +- Tab | Cycle sort column +- f | Focus filter bar ++ Tab | Focus filter bar ++ S | Cycle sort column ++ / | Focus filter bar (alias) + +@@ 4.4 interpret_key() ++ Add explicit precedence table: ++ 1) modal/palette ++ 2) focused input ++ 3) global ++ 4) screen-local ++ Add configurable go-prefix timeout (default 500ms) with cancel feedback. +``` + +9. Add performance SLOs and DB/index plan +Rationale: “fast enough” is vague. Add measurable budgets, required indexes, and query-plan gates in CI for predictable performance. + +```diff +@@ 3.1 Risk Matrix ++ Add risk: "Query latency regressions on large datasets" + +@@ 9.3 Phase 0 — Toolchain Gate ++7. p95 list query latency < 75ms on 100k issues synthetic fixture ++8. p95 search latency < 200ms on 1M docs (lexical mode) + +@@ 11. Assumptions +-5. SQLite queries are fast enough for interactive use (<50ms for filtered results). ++5. Performance budgets are enforced by benchmark fixtures and query-plan checks. ++6. Required indexes documented and migration-backed before TUI GA. +``` + +10. Add reliability/observability model (error classes, retries, tracing) +Rationale: one string toast is not enough for production debugging. Add typed errors, retry policy, and an in-TUI diagnostics pane. + +```diff +@@ 4.3 Core Types (Msg) +- Error(String), ++ Error(AppError), + ++ pub enum AppError { ++ DbBusy, DbCorruption, NetworkRateLimited, NetworkUnavailable, ++ AuthFailed, ParseError, Internal(String) ++ } + +@@ 5.11 Doctor / Stats ++ Add "Diagnostics" tab: ++ - last 100 errors ++ - retry counts ++ - current sync/backoff state ++ - DB contention metrics +``` + +11. Add “Saved Views + Watchlist” as high-value product features +Rationale: this makes the TUI compelling daily, not just navigable. Users can persist filters and monitor critical slices (e.g., “P1 auth issues updated in last 24h”). + +```diff +@@ 1. Executive Summary ++ - Saved Views (named filters and layouts) ++ - Watchlist panel (tracked queries with delta badges) + +@@ 5. Screen Taxonomy ++### 5.12 Saved Views / Watchlist ++Persistent named filters for Issues/MRs/Search. ++Dashboard shows per-watchlist deltas since last session. + +@@ 6. User Flows ++### 6.9 Flow: "Run morning watchlist triage" ++Dashboard -> Watchlist -> filtered IssueList/MRList -> detail drilldown +``` + +12. Strengthen testing plan with deterministic behavior and chaos cases +Rationale: snapshot tests alone won’t catch race/staleness/cancellation issues. Add concurrency, cancellation, and flaky terminal behavior tests. + +```diff +@@ 9.2 Phases ++Phase 5.5 Reliability Test Pack (2d) ++ - stale response drop tests ++ - sync cancel/resume tests ++ - SQLITE_BUSY retry tests ++ - resize storm and rapid key-chord tests + +@@ 10.9 Snapshot Test Example ++ Add non-snapshot tests: ++ - property tests for navigation invariants ++ - integration tests for request ordering correctness ++ - benchmark tests for query budgets +``` + +If you want, I can produce a consolidated “PRD v2.1 patch” with all of the above merged into one coherent updated document structure. \ No newline at end of file diff --git a/plans/tui-prd-v2-frankentui.feedback-2.md b/plans/tui-prd-v2-frankentui.feedback-2.md new file mode 100644 index 0000000..0c7a36f --- /dev/null +++ b/plans/tui-prd-v2-frankentui.feedback-2.md @@ -0,0 +1,203 @@ +I excluded the two items in your `## Rejected Recommendations` and focused on net-new improvements. +These are the highest-impact revisions I’d make. + +### 1. Fix the package graph now (avoid a hard Cargo cycle) +Your current plan has `root -> optional lore-tui` and `lore-tui -> lore (root)`, which creates a cyclic dependency risk. Split shared logic into a dedicated core crate so CLI and TUI both depend downward. + +```diff +diff --git a/PRD.md b/PRD.md +@@ ## 9.1 Dependency Changes +-[workspace] +-members = [".", "crates/lore-tui"] ++[workspace] ++members = [".", "crates/lore-core", "crates/lore-tui"] + +@@ +-[dependencies] +-lore-tui = { path = "crates/lore-tui", optional = true } ++[dependencies] ++lore-core = { path = "crates/lore-core" } ++lore-tui = { path = "crates/lore-tui", optional = true } + +@@ # crates/lore-tui/Cargo.toml +-lore = { path = "../.." } # Core lore library ++lore-core = { path = "../lore-core" } # Shared domain/query crate (acyclic graph) +``` + +### 2. Stop coupling TUI to `cli/commands/*` internals +Calling CLI command modules from TUI is brittle and will drift. Introduce a shared query/service layer with DTOs owned by core. + +```diff +diff --git a/PRD.md b/PRD.md +@@ ## 4.1 Module Structure +- action.rs # Async action runners (DB queries, GitLab calls) ++ action.rs # Task dispatch only ++ service/ ++ mod.rs ++ query.rs # Shared read services (CLI + TUI) ++ sync.rs # Shared sync orchestration facade ++ dto.rs # UI-agnostic data contracts + +@@ ## 10.2 Modified Files +-src/cli/commands/list.rs # Extract query_issues(), query_mrs() as pub fns +-src/cli/commands/show.rs # Extract query_issue_detail(), query_mr_detail() as pub fns +-src/cli/commands/who.rs # Extract query_experts(), etc. as pub fns +-src/cli/commands/search.rs # Extract run_search_query() as pub fn ++crates/lore-core/src/query/issues.rs # Canonical issue queries ++crates/lore-core/src/query/mrs.rs # Canonical MR queries ++crates/lore-core/src/query/show.rs # Canonical detail queries ++crates/lore-core/src/query/who.rs # Canonical people queries ++crates/lore-core/src/query/search.rs # Canonical search queries ++src/cli/commands/*.rs # Consume lore-core query services ++crates/lore-tui/src/action.rs # Consume lore-core query services +``` + +### 3. Add a real task supervisor (dedupe + cancellation + priority) +Right now tasks are ad hoc and can overrun each other. Add a scheduler keyed by screen+intent. + +```diff +diff --git a/PRD.md b/PRD.md +@@ ## 4.5 Async Action System +-The `Cmd::task(|| { ... })` pattern runs a blocking closure on a background thread pool. ++The TUI uses a `TaskSupervisor`: ++- Keyed tasks (`TaskKey`) to dedupe redundant requests ++- Priority lanes (`Input`, `Navigation`, `Background`) ++- Cooperative cancellation tokens per task ++- Late-result drop via generation IDs (not just search) + +@@ ## 4.3 Core Types ++pub enum TaskKey { ++ LoadScreen(Screen), ++ Search { generation: u64 }, ++ SyncStream, ++} +``` + +### 4. Correct sync streaming architecture (current sketch loses streamed events) +The sample creates `tx/rx` then drops `rx`; events never reach update loop. Define an explicit stream subscription with bounded queue and backpressure policy. + +```diff +diff --git a/PRD.md b/PRD.md +@@ ## 4.4 App — Implementing the Model Trait +- let (tx, _rx) = std::sync::mpsc::channel::(); ++ let (tx, rx) = std::sync::mpsc::sync_channel::(1024); ++ // rx is registered via Subscription::from_receiver("sync-stream", rx) + +@@ +- let result = crate::ingestion::orchestrator::run_sync( ++ let result = crate::ingestion::orchestrator::run_sync( + &config, + &conn, + |event| { +@@ +- let _ = tx.send(Msg::SyncProgress(event.clone())); +- let _ = tx.send(Msg::SyncLogLine(format!("{event:?}"))); ++ if tx.try_send(Msg::SyncProgress(event.clone())).is_err() { ++ let _ = tx.try_send(Msg::SyncBackpressureDrop); ++ } ++ let _ = tx.try_send(Msg::SyncLogLine(format!("{event:?}"))); + }, + ); +``` + +### 5. Upgrade data-plane performance plan (keyset pagination + index contracts) +Virtualized list without keyset paging still forces expensive scans. Add explicit keyset pagination and query-plan CI checks. + +```diff +diff --git a/PRD.md b/PRD.md +@@ ## 9.3 Phase 0 — Toolchain Gate +-7. p95 list query latency < 75ms on synthetic fixture (10k issues, 5k MRs) ++7. p95 list page fetch latency < 75ms using keyset pagination (10k issues, 5k MRs) ++8. EXPLAIN QUERY PLAN must show index usage for top 10 TUI queries ++9. No full table scan on issues/MRs/discussions under default filters + +@@ +-8. p95 search latency < 200ms on synthetic fixture (50k documents, lexical mode) ++10. p95 search latency < 200ms on synthetic fixture (50k documents, lexical mode) + ++## 9.4 Required Indexes (GA blocker) ++- `issues(project_id, state, updated_at DESC, iid DESC)` ++- `merge_requests(project_id, state, updated_at DESC, iid DESC)` ++- `discussions(project_id, entity_type, entity_iid, created_at DESC)` ++- `notes(discussion_id, created_at ASC)` +``` + +### 6. Enforce `EntityKey` everywhere (remove bare IID paths) +You correctly identified multi-project IID collisions, but many message/state signatures still use `i64`. Make `EntityKey` mandatory in all navigation and detail loaders. + +```diff +diff --git a/PRD.md b/PRD.md +@@ ## 4.3 Core Types +- IssueSelected(i64), ++ IssueSelected(EntityKey), +@@ +- MrSelected(i64), ++ MrSelected(EntityKey), +@@ +- IssueDetailLoaded(IssueDetail), ++ IssueDetailLoaded { key: EntityKey, detail: IssueDetail }, +@@ +- MrDetailLoaded(MrDetail), ++ MrDetailLoaded { key: EntityKey, detail: MrDetail }, + +@@ ## 10.10 State Module — Complete +- Cmd::msg(Msg::NavigateTo(Screen::IssueDetail(iid))) ++ Cmd::msg(Msg::NavigateTo(Screen::IssueDetail(entity_key))) +``` + +### 7. Harden filter/search semantics (strict parser + inline diagnostics + explain scores) +Current filter parser silently ignores unknown fields; that causes hidden mistakes. Add strict parse diagnostics and search score explainability. + +```diff +diff --git a/PRD.md b/PRD.md +@@ ## 10.12.1 Filter Bar Widget +- _ => {} // Unknown fields silently ignored ++ _ => self.errors.push(format!("Unknown filter field: {}", token.field)) + ++ pub errors: Vec, // inline parse/validation errors ++ pub warnings: Vec, // non-fatal coercions + +@@ ## 5.6 Search +-- **Live preview:** Selected result shows snippet + metadata in right pane ++- **Live preview:** Selected result shows snippet + metadata in right pane ++- **Explain score:** Optional breakdown (lexical, semantic, recency, boosts) for trust/debug +``` + +### 8. Add operational resilience: safe mode + panic report + startup fallback +TUI failures should degrade gracefully, not block usage. + +```diff +diff --git a/PRD.md b/PRD.md +@@ ## 3.1 Risk Matrix ++| Runtime panic leaves user blocked | High | Medium | Panic hook writes crash report, restores terminal, offers fallback CLI command | + +@@ ## 10.3 Entry Point ++pub fn launch_tui(config: Config, db_path: &Path) -> Result<(), LoreError> { ++ install_panic_hook_for_tui(); // terminal restore + crash dump path ++ ... ++} + +@@ ## 8.1 Global (Available Everywhere) ++| `:` | Show fallback equivalent CLI command for current screen/action | +``` + +### 9. Add a “jump list” (forward/back navigation, not only stack pop) +Current model has only push/pop and reset. Add browser-like history for investigation workflows. + +```diff +diff --git a/PRD.md b/PRD.md +@@ ## 4.7 Navigation Stack Implementation + pub struct NavigationStack { +- stack: Vec, ++ back_stack: Vec, ++ current: Screen, ++ forward_stack: Vec, ++ jump_list: Vec, // recent entity/detail hops + } + +@@ ## 8.1 Global (Available Everywhere) ++| `Ctrl+o` | Jump backward in jump list | ++| `Ctrl+i` | Jump forward in jump list | +``` + +If you want, I can produce a single consolidated “PRD v2.1” patch that applies all nine revisions coherently section-by-section. \ No newline at end of file diff --git a/plans/tui-prd-v2-frankentui.feedback-3.md b/plans/tui-prd-v2-frankentui.feedback-3.md new file mode 100644 index 0000000..9d843d7 --- /dev/null +++ b/plans/tui-prd-v2-frankentui.feedback-3.md @@ -0,0 +1,163 @@ +I excluded everything already listed in `## Rejected Recommendations`. +These are the highest-impact net-new revisions I’d make. + +1. **Enforce Entity Identity Consistency End-to-End (P0)** +Analysis: The PRD defines `EntityKey`, but many code paths still pass bare `iid` (`IssueSelected(item.iid)`, timeline refs, search refs). In multi-project datasets this will cause wrong-entity navigation and subtle data corruption in cached state. Make `EntityKey` mandatory in every navigation message and add compile-time constructors. +```diff +@@ 4.3 Core Types + pub struct EntityKey { + pub project_id: i64, + pub iid: i64, + pub kind: EntityKind, + } ++impl EntityKey { ++ pub fn issue(project_id: i64, iid: i64) -> Self { Self { project_id, iid, kind: EntityKind::Issue } } ++ pub fn mr(project_id: i64, iid: i64) -> Self { Self { project_id, iid, kind: EntityKind::MergeRequest } } ++} + +@@ 10.10 state/issue_list.rs +- .map(|item| Msg::IssueSelected(item.iid)) ++ .map(|item| Msg::IssueSelected(EntityKey::issue(item.project_id, item.iid))) + +@@ 10.10 state/mr_list.rs +- .map(|item| Msg::MrSelected(item.iid)) ++ .map(|item| Msg::MrSelected(EntityKey::mr(item.project_id, item.iid))) +``` + +2. **Make TaskSupervisor Mandatory for All Background Work (P0)** +Analysis: The plan introduces `TaskSupervisor` but still dispatches many direct `Cmd::task` calls. That will reintroduce stale updates, duplicate queries, and priority inversion under rapid input. Centralize all background task creation through one spawn path that enforces dedupe, cancellation tokening, and generation checks. +```diff +@@ 4.5.1 Task Supervisor (Dedup + Cancellation + Priority) +-The supervisor is owned by `LoreApp` and consulted before dispatching any `Cmd::task`. ++The supervisor is owned by `LoreApp` and is the ONLY allowed path for background work. ++All task launches use `LoreApp::spawn_task(TaskKey, TaskPriority, closure)`. + +@@ 4.4 App — Implementing the Model Trait +- Cmd::task(move || { ... }) ++ self.spawn_task(TaskKey::LoadScreen(screen.clone()), TaskPriority::Navigation, move |token| { ... }) +``` + +3. **Remove the Sync Streaming TODO and Make Real-Time Streaming a GA Gate (P0)** +Analysis: Current text admits sync progress is buffered with a TODO. That undercuts one of the main value props. Make streaming progress/log delivery non-optional, with bounded buffers and dropped-line accounting. +```diff +@@ 4.4 start_sync_task() +- // TODO: Register rx as subscription when FrankenTUI supports it. +- // For now, the task returns the final Msg and progress is buffered. ++ // Register rx as a live subscription (`Subscription::from_receiver` adapter). ++ // Progress and logs must render in real time (no batch-at-end fallback). ++ // Keep a bounded ring buffer (N=5000) and surface `dropped_log_lines` in UI. + +@@ 9.3 Phase 0 — Toolchain Gate ++11. Real-time sync stream verified: progress updates visible during run, not only at completion. +``` + +4. **Upgrade List/Search Data Strategy to Windowed Keyset + Prefetch (P0)** +Analysis: “Virtualized list” alone does not solve query/transfer cost if full result sets are loaded. Move to fixed-size keyset windows with next-window prefetch and fast first paint; this keeps latency predictable on 100k+ records. +```diff +@@ 5.2 Issue List +- Pagination: Virtual scrolling for large result sets ++ Pagination: Windowed keyset pagination (window=200 rows) with background prefetch of next window. ++ First paint uses current window only; no full-result materialization. + +@@ 5.4 MR List ++ Same windowed keyset pagination strategy as Issue List. + +@@ 9.3 Success criteria +- 7. p95 list page fetch latency < 75ms using keyset pagination on synthetic fixture (10k issues, 5k MRs) ++ 7. p95 first-paint latency < 50ms and p95 next-window fetch < 75ms on synthetic fixture (100k issues, 50k MRs) +``` + +5. **Add Resumable Sync Checkpoints + Per-Project Fault Isolation (P1)** +Analysis: If sync is interrupted or one project fails, current design mostly falls back to cancel/fail. Add checkpoints so long runs can resume, and isolate failures to project/resource scope while continuing others. +```diff +@@ 3.1 Risk Matrix ++| Interrupted sync loses progress | High | Medium | Persist phase checkpoints and offer resume | + +@@ 5.9 Sync ++Running mode: failed project/resource lanes are marked degraded while other lanes continue. ++Summary mode: offer `[R]esume interrupted sync` from last checkpoint. + +@@ 11 Assumptions +-16. No new SQLite tables needed (but required indexes must be verified — see Performance SLOs). ++16. Add minimal internal tables for reliability: `sync_runs` and `sync_checkpoints` (append-only metadata). +``` + +6. **Add Capability-Adaptive Rendering Modes (P1)** +Analysis: Terminal compatibility is currently test-focused, but runtime adaptation is under-specified. Add explicit degradations for no-truecolor, no-unicode, slow SSH/tmux paths to reduce rendering artifacts and support incidents. +```diff +@@ 3.4 Terminal Compatibility Testing ++Add capability matrix validation: truecolor/256/16 color, unicode/ascii glyphs, alt-screen on/off. + +@@ 10.19 CLI Integration ++Tui { ++ #[arg(long, default_value="auto")] render_mode: String, // auto|full|minimal ++ #[arg(long)] ascii: bool, ++ #[arg(long)] no_alt_screen: bool, ++} +``` + +7. **Harden Browser/Open and Log Privacy (P1)** +Analysis: `open_current_in_browser` currently trusts stored URLs; sync logs may expose tokens/emails from upstream messages. Add host allowlisting and redaction pipeline by default. +```diff +@@ 4.4 open_current_in_browser() +- if let Some(url) = url { ... open ... } ++ if let Some(url) = url { ++ if !self.state.security.is_allowed_gitlab_url(&url) { ++ self.state.set_error("Blocked non-GitLab URL".into()); ++ return; ++ } ++ ... open ... ++ } + +@@ 5.9 Sync ++Log stream passes through redaction (tokens, auth headers, email local-parts) before render/storage. +``` + +8. **Add “My Workbench” Screen for Daily Pull (P1, new feature)** +Analysis: The PRD is strong on exploration, weaker on “what should I do now?”. Add a focused operator screen aggregating assigned issues, requested reviews, unresolved threads mentioning me, and stale approvals. This makes the TUI habit-forming. +```diff +@@ 5. Screen Taxonomy ++### 5.12 My Workbench ++Single-screen triage cockpit: ++- Assigned-to-me open issues/MRs ++- Review requests awaiting action ++- Threads mentioning me and unresolved ++- Recently stale approvals / blocked MRs + +@@ 8.1 Global ++| `gb` | Go to My Workbench | + +@@ 9.2 Phases ++section Phase 3.5 — Daily Workflow ++My Workbench screen + queries :p35a, after p3d, 2d +``` + +9. **Add Rollout, SLO Telemetry, and Kill-Switch Plan (P0)** +Analysis: You have implementation phases but no production rollout control. Add explicit experiment flags, health telemetry, and rollback criteria so risk is operationally bounded. +```diff +@@ Table of Contents +-11. [Assumptions](#11-assumptions) ++11. [Assumptions](#11-assumptions) ++12. [Rollout & Telemetry](#12-rollout--telemetry) + +@@ NEW SECTION 12 ++## 12. Rollout & Telemetry ++- Feature flags: `tui_experimental`, `tui_sync_streaming`, `tui_workbench` ++- Metrics: startup_ms, frame_render_p95_ms, db_busy_rate, panic_free_sessions, sync_drop_events ++- Kill-switch: disable `tui` feature path at runtime if panic rate > 0.5% sessions over 24h ++- Canary rollout: internal only -> opt-in beta -> default-on +``` + +10. **Strengthen Reliability Pack with Event-Fuzz + Soak Tests (P0)** +Analysis: Current tests are good but still light on prolonged event pressure. Add deterministic fuzzed key/resize/paste streams and a long soak to catch rare deadlocks/leaks and state corruption. +```diff +@@ 9.2 Phase 5.5 — Reliability Test Pack ++Event fuzz tests (key/resize/paste interleavings) :p55g, after p55e, 1d ++30-minute soak test (no panic, bounded memory) :p55h, after p55g, 1d + +@@ 9.3 Success criteria ++12. Event-fuzz suite passes with zero invariant violations across 10k randomized traces. ++13. 30-minute soak: no panic, no deadlock, memory growth < 5%. +``` + +If you want, I can produce a single consolidated unified diff of the full PRD text next (all edits merged, ready to apply as v3). \ No newline at end of file diff --git a/plans/tui-prd-v2-frankentui.feedback-4.md b/plans/tui-prd-v2-frankentui.feedback-4.md new file mode 100644 index 0000000..652192b --- /dev/null +++ b/plans/tui-prd-v2-frankentui.feedback-4.md @@ -0,0 +1,157 @@ +Below are my strongest revisions, focused on correctness, reliability, and long-term maintainability, while avoiding all items in your `## Rejected Recommendations`. + +1. **Fix the Cargo/toolchain architecture (current plan has a real dependency-cycle risk and shaky per-member toolchain behavior).** +Analysis: The current plan has `lore -> lore-tui (optional)` and `lore-tui -> lore`, which creates a package cycle when `tui` is enabled. Also, per-member `rust-toolchain.toml` in a workspace is easy to misapply in CI/dev workflows. The cleanest robust shape is: `lore-tui` is a separate binary crate (nightly), `lore` remains stable and delegates at runtime (`lore tui` shells out to `lore-tui`). +```diff +--- a/Gitlore_TUI_PRD_v2.md ++++ b/Gitlore_TUI_PRD_v2.md +@@ 3.2 Nightly Rust Strategy +-- The `lore` binary integrates TUI via `lore tui` subcommand. The `lore-tui` crate is a library dependency feature-gated in the root. ++- `lore-tui` is a separate binary crate built on pinned nightly. ++- `lore` (stable) does not compile-link `lore-tui`; `lore tui` delegates by spawning `lore-tui`. ++- This removes Cargo dependency-cycle risk and keeps stable builds nightly-free. +@@ 9.1 Dependency Changes +-[features] +-tui = ["dep:lore-tui"] +-[dependencies] +-lore-tui = { path = "crates/lore-tui", optional = true } ++[dependencies] ++# no compile-time dependency on lore-tui from lore ++# runtime delegation keeps toolchains isolated +@@ 10.19 CLI Integration +-Add Tui match arm that directly calls crate::tui::launch_tui(...) ++Add Tui match arm that resolves and spawns `lore-tui` with passthrough args. ++If missing, print actionable install/build command. +``` + +2. **Make `TaskSupervisor` the *actual* single async path (remove contradictory direct `Cmd::task` usage in state handlers).** +Analysis: You declare “direct `Cmd::task` is prohibited outside supervisor,” but later `handle_screen_msg` still launches tasks directly. That contradiction will reintroduce stale-result bugs and race conditions. Make state handlers pure (intent-only); all async launch/cancel/dedup goes through one supervised API. +```diff +--- a/Gitlore_TUI_PRD_v2.md ++++ b/Gitlore_TUI_PRD_v2.md +@@ 4.5.1 Task Supervisor +-The supervisor is the ONLY allowed path for background work. ++The supervisor is the ONLY allowed path for background work, enforced by architecture: ++`AppState` emits intents only; `LoreApp::update` launches tasks via `spawn_task(...)`. +@@ 10.10 State Module — Complete +-pub fn handle_screen_msg(..., db: &Arc>) -> Cmd ++pub fn handle_screen_msg(...) -> ScreenIntent ++// no DB access, no Cmd::task in state layer +``` + +3. **Enforce `EntityKey` everywhere (remove raw IID navigation paths).** +Analysis: Multi-project identity is one of your strongest ideas, but multiple snippets still navigate by bare IID (`document_id`, `EntityRef::Issue(i64)`). That can misroute across projects and create silent correctness bugs. Make all navigation-bearing results carry `EntityKey` end-to-end. +```diff +--- a/Gitlore_TUI_PRD_v2.md ++++ b/Gitlore_TUI_PRD_v2.md +@@ 4.3 Core Types +-pub enum EntityRef { Issue(i64), MergeRequest(i64) } ++pub enum EntityRef { Issue(EntityKey), MergeRequest(EntityKey) } +@@ 10.10 state/search.rs +-Some(Msg::NavigateTo(Screen::IssueDetail(r.document_id))) ++Some(Msg::NavigateTo(Screen::IssueDetail(r.entity_key.clone()))) +@@ 10.11 action.rs +-pub fn fetch_issue_detail(conn: &Connection, iid: i64) -> Result ++pub fn fetch_issue_detail(conn: &Connection, key: &EntityKey) -> Result +``` + +4. **Introduce a shared query boundary inside the existing crate (not a new crate) to decouple TUI from CLI presentation structs.** +Analysis: Reusing CLI command modules directly is fast initially, but it ties TUI to output-layer types and command concerns. A minimal internal `core::query::*` module gives a stable data contract used by both CLI and TUI without the overhead of a new crate split. +```diff +--- a/Gitlore_TUI_PRD_v2.md ++++ b/Gitlore_TUI_PRD_v2.md +@@ 10.2 Modified Files +-src/cli/commands/list.rs # extract query_issues/query_mrs as pub +-src/cli/commands/show.rs # extract query_issue_detail/query_mr_detail as pub ++src/core/query/mod.rs ++src/core/query/issues.rs ++src/core/query/mrs.rs ++src/core/query/detail.rs ++src/core/query/search.rs ++src/core/query/who.rs ++src/cli/commands/* now call core::query::* + format output ++TUI action.rs calls core::query::* directly +``` + +5. **Add terminal-safety sanitization for untrusted text (ANSI/OSC injection hardening).** +Analysis: Issue/MR bodies, notes, and logs are untrusted text in a terminal context. Without sanitization, terminal escape/control sequences can spoof UI or trigger unintended behavior. Add explicit sanitization and a strict URL policy before rendering/opening. +```diff +--- a/Gitlore_TUI_PRD_v2.md ++++ b/Gitlore_TUI_PRD_v2.md +@@ 3.1 Risk Matrix ++| Terminal escape/control-sequence injection via issue/note text | High | Medium | Strip ANSI/OSC/control chars before render; escape markdown output; allowlist URL scheme+host | +@@ 4.1 Module Structure ++ safety.rs # sanitize_for_terminal(), safe_url_policy() +@@ 10.5/10.8/10.14/10.16 ++All user-sourced text passes through `sanitize_for_terminal()` before widget rendering. ++Disable markdown raw HTML and clickable links unless URL policy passes. +``` + +6. **Move resumable sync checkpoints into v1 (lightweight version).** +Analysis: You already identify interruption risk as real. Deferring resumability to post-v1 leaves a major reliability gap in exactly the heaviest workflow. A lightweight checkpoint table (resource cursor + updated-at watermark) gives large reliability gain with modest complexity. +```diff +--- a/Gitlore_TUI_PRD_v2.md ++++ b/Gitlore_TUI_PRD_v2.md +@@ 3.1 Risk Matrix +-- Resumable checkpoints planned for post-v1 ++Resumable checkpoints included in v1 (lightweight cursors per project/resource lane) +@@ 9.3 Success Criteria ++14. Interrupt-and-resume test: sync resumes from checkpoint and reaches completion without full restart. +@@ 9.3.1 Required Indexes (GA Blocker) ++CREATE TABLE IF NOT EXISTS sync_checkpoints ( ++ project_id INTEGER NOT NULL, ++ lane TEXT NOT NULL, ++ cursor TEXT, ++ updated_at_ms INTEGER NOT NULL, ++ PRIMARY KEY (project_id, lane) ++); +``` + +7. **Strengthen performance gates with tiered fixtures and memory ceilings.** +Analysis: Current thresholds are good, but fixture sizes are too close to mid-scale only. Add S/M/L fixtures and memory budget checks so regressions appear before real-world datasets hit them. This gives much more confidence in long-term scalability. +```diff +--- a/Gitlore_TUI_PRD_v2.md ++++ b/Gitlore_TUI_PRD_v2.md +@@ 9.3 Phase 0 — Toolchain Gate +-7. p95 first-paint latency < 50ms ... (100k issues, 50k MRs) +-10. p95 search latency < 200ms ... (50k documents) ++7. Tiered fixtures: ++ S: 10k issues / 5k MRs / 50k notes ++ M: 100k issues / 50k MRs / 500k notes ++ L: 250k issues / 100k MRs / 1M notes ++ Enforce p95 targets per tier and memory ceiling (<250MB RSS in M tier). ++10. Search SLO validated in S and M tiers, lexical and hybrid modes. +``` + +8. **Add session restore (last screen + filters + selection), with explicit `--fresh` opt-out.** +Analysis: This is high-value daily UX with low complexity, and it makes the TUI feel materially more “compelling/useful” without feature bloat. It also reduces friction when recovering from crash/restart. +```diff +--- a/Gitlore_TUI_PRD_v2.md ++++ b/Gitlore_TUI_PRD_v2.md +@@ 1. Executive Summary ++- **Session restore** — resume last screen, filters, and selection on startup. +@@ 4.1 Module Structure ++ session.rs # persisted UI session state +@@ 8.1 Global ++| `Ctrl+R` | Reset session state for current screen | +@@ 10.19 CLI Integration ++`lore tui --fresh` starts without restoring prior session state. +@@ 11. Assumptions +-12. No TUI-specific configuration initially. ++12. Minimal TUI state file is allowed for session restore only. +``` + +9. **Add parity tests between TUI data panels and `--robot` outputs.** +Analysis: You already have `ShowCliEquivalent`; parity tests make that claim trustworthy and prevent drift between interfaces. This is a strong reliability multiplier and helps future refactors. +```diff +--- a/Gitlore_TUI_PRD_v2.md ++++ b/Gitlore_TUI_PRD_v2.md +@@ 9.2 Phases / 9.3 Success Criteria ++Phase 5.6 — CLI/TUI Parity Pack ++ - Dashboard count parity vs `lore --robot count/status` ++ - List/detail parity for issues/MRs on sampled entities ++ - Search result identity parity (top-N ids) for lexical mode ++Success criterion: parity suite passes on CI fixtures. +``` + +If you want, I can produce a single consolidated patch of the PRD text (one unified diff) so you can drop it directly into the next iteration. \ No newline at end of file diff --git a/plans/tui-prd-v2-frankentui.feedback-5.md b/plans/tui-prd-v2-frankentui.feedback-5.md new file mode 100644 index 0000000..c5ee47a --- /dev/null +++ b/plans/tui-prd-v2-frankentui.feedback-5.md @@ -0,0 +1,200 @@ +1. **Fix the structural inconsistency between `src/tui` and `crates/lore-tui/src`** +Analysis: The PRD currently defines two different code layouts for the same system. That will cause implementation drift, wrong imports, and duplicated modules. Locking to one canonical layout early prevents execution churn and makes every snippet/action item unambiguous. + +```diff +@@ 4.1 Module Structure @@ +-src/ +- tui/ ++crates/lore-tui/src/ + mod.rs + app.rs + message.rs +@@ +-### 10.5 Dashboard View (FrankenTUI Native) +-// src/tui/view/dashboard.rs ++### 10.5 Dashboard View (FrankenTUI Native) ++// crates/lore-tui/src/view/dashboard.rs +@@ +-### 10.6 Sync View +-// src/tui/view/sync.rs ++### 10.6 Sync View ++// crates/lore-tui/src/view/sync.rs +``` + +2. **Add a small `ui_adapter` seam to contain FrankenTUI API churn** +Analysis: You already identified high likelihood of upstream breakage. Pinning a commit helps, but if every screen imports raw `ftui_*` types directly, churn ripples through dozens of files. A thin adapter layer reduces upgrade cost without introducing the rejected “full portability abstraction”. + +```diff +@@ 3.1 Risk Matrix @@ + | API breaking changes | High | High (v0.x) | Pin exact git commit; vendor source if needed | ++| API breakage blast radius across app code | High | High | Constrain ftui usage behind `ui_adapter/*` wrappers | + +@@ 4.1 Module Structure @@ ++ ui_adapter/ ++ mod.rs # Re-export stable local UI primitives ++ runtime.rs # App launch/options wrappers ++ widgets.rs # Table/List/Modal wrapper constructors ++ input.rs # Text input + focus helpers + +@@ 9.3 Phase 0 — Toolchain Gate @@ ++14. `ui_adapter` compile-check: no screen module imports `ftui_*` directly (lint-enforced) +``` + +3. **Correct search mode behavior and replace sleep-based debounce with cancelable scheduling** +Analysis: Current plan hardcodes `"hybrid"` in `execute_search`, so mode switching is UI-only and incorrect. Also, spawning sleeping tasks per keypress is wasteful under fast typing. Make mode a first-class query parameter and debounce via one cancelable scheduled event per input domain. + +```diff +@@ 4.4 maybe_debounced_query @@ +-std::thread::sleep(std::time::Duration::from_millis(200)); +-match crate::tui::action::execute_search(&conn, &query, &filters) { ++// no thread sleep; schedule SearchRequestStarted after 200ms via debounce scheduler ++match crate::tui::action::execute_search(&conn, &query, &filters, mode) { + +@@ 10.11 Action Module — Query Bridge @@ +-pub fn execute_search(conn: &Connection, query: &str, filters: &SearchCliFilters) -> Result { +- let mode_str = "hybrid"; // default; TUI mode selector overrides ++pub fn execute_search( ++ conn: &Connection, ++ query: &str, ++ filters: &SearchCliFilters, ++ mode: SearchMode, ++) -> Result { ++ let mode_str = match mode { ++ SearchMode::Hybrid => "hybrid", ++ SearchMode::Lexical => "lexical", ++ SearchMode::Semantic => "semantic", ++ }; + +@@ 9.3 Phase 0 — Toolchain Gate @@ ++15. Search mode parity: lexical/hybrid/semantic each return mode-consistent top-N IDs on fixture +``` + +4. **Guarantee consistent multi-query reads and add query interruption for responsiveness** +Analysis: Detail screens combine multiple queries that can observe mixed states during sync writes. Wrap each detail fetch in a single read transaction for snapshot consistency. Add cancellation/interrupt checks for long-running queries so UI remains responsive under heavy datasets. + +```diff +@@ 4.5 Async Action System @@ ++All detail fetches (`issue_detail`, `mr_detail`, timeline expansion) run inside one read transaction ++to guarantee snapshot consistency across subqueries. + +@@ 10.11 Action Module — Query Bridge @@ ++pub fn with_read_snapshot( ++ conn: &Connection, ++ f: impl FnOnce(&rusqlite::Transaction<'_>) -> Result, ++) -> Result { ... } + ++// Long queries register interrupt checks tied to CancelToken ++// to avoid >1s uninterruptible stalls during rapid navigation/filtering. +``` + +5. **Formalize sync event streaming contract to prevent “stuck” states** +Analysis: Dropping events on backpressure is acceptable, but completion must never be dropped and event ordering must be explicit. Add a typed `SyncUiEvent` stream with guaranteed terminal sentinel and progress coalescing to reduce load while preserving correctness. + +```diff +@@ 4.4 start_sync_task @@ +-let (tx, rx) = std::sync::mpsc::sync_channel::(1024); ++let (tx, rx) = std::sync::mpsc::sync_channel::(2048); + +-// drop this progress update rather than blocking the sync thread ++// coalesce progress to max 30Hz per lane; never drop terminal events ++// always emit SyncUiEvent::StreamClosed { outcome } + +@@ 5.9 Sync @@ +-- Log viewer with streaming output ++- Log viewer with streaming output and explicit stream-finalization state ++- UI shows dropped/coalesced event counters for transparency +``` + +6. **Version and validate session restore payloads** +Analysis: A raw JSON session file without schema/version checks is fragile across releases and DB switches. Add schema version, DB fingerprint, and safe fallback rules so session restore never blocks startup or applies stale state incorrectly. + +```diff +@@ 11. Assumptions @@ +-12. Minimal TUI state file allowed for session restore only ... ++12. Versioned TUI state file allowed for session restore only: ++ fields include `schema_version`, `app_version`, `db_fingerprint`, `saved_at`, `state`. + +@@ 10.1 New Files @@ + crates/lore-tui/src/session.rs # Lightweight session state persistence ++ # + versioning, validation, corruption quarantine + +@@ 4.1 Module Structure @@ + session.rs # Lightweight session state persistence ++ # corrupted file -> `.bad-` and fresh start +``` + +7. **Harden terminal safety beyond ANSI stripping** +Analysis: ANSI stripping is necessary but not sufficient. Bidi controls and invisible Unicode controls can still spoof displayed content. URL checks should normalize host/port and disallow deceptive variants. This closes realistic terminal spoofing vectors. + +```diff +@@ 3.1 Risk Matrix @@ + | Terminal escape/control-sequence injection via issue/note text | High | Medium | Strip ANSI/OSC/control chars via sanitize_for_terminal() ... | ++| Bidi/invisible Unicode spoofing in rendered text | High | Medium | Strip bidi overrides + zero-width controls in untrusted text | + +@@ 10.4.1 Terminal Safety — Untrusted Text Sanitization @@ +-Strip ANSI escape sequences, OSC commands, and control characters ++Strip ANSI/OSC/control chars, bidi overrides (RLO/LRO/PDF/RLI/LRI/FSI/PDI), ++and zero-width/invisible controls from untrusted text + +-pub fn is_safe_url(url: &str, allowed_hosts: &[String]) -> bool { ++pub fn is_safe_url(url: &str, allowed_origins: &[Origin]) -> bool { ++ // normalize host (IDNA), enforce scheme+host+port match +``` + +8. **Use progressive hydration for detail screens** +Analysis: Issue/MR detail first-paint can become slow when discussions are large. Split fetch into phases: metadata first, then discussions/file changes, then deep thread content on expand. This improves perceived performance and keeps navigation snappy on large repos. + +```diff +@@ 5.3 Issue Detail @@ +-Data source: `lore issues ` + discussions + cross-references ++Data source (progressive): ++1) metadata/header (first paint) ++2) discussions summary + cross-refs ++3) full thread bodies loaded on demand when expanded + +@@ 5.5 MR Detail @@ +-Unique features: File changes list, Diff discussions ... ++Unique features (progressive hydration): ++- file change summary in first paint ++- diff discussion bodies loaded lazily per expanded thread + +@@ 9.3 Phase 0 — Toolchain Gate @@ ++16. Detail first-paint p95 < 75ms on M-tier fixtures (metadata-only phase) +``` + +9. **Make reliability tests reproducible with deterministic clocks/seeds** +Analysis: Relative-time rendering and fuzz tests are currently tied to wall clock/randomness, which makes CI flakes hard to diagnose. Introduce a `Clock` abstraction and deterministic fuzz seeds with failure replay output. + +```diff +@@ 10.9.1 Non-Snapshot Tests @@ ++/// All time-based rendering uses injected `Clock` in tests. ++/// Fuzz failures print deterministic seed for replay. + +@@ 9.2 Phase 5.5 — Reliability Test Pack @@ +-Event fuzz tests (key/resize/paste):p55g ++Event fuzz tests (key/resize/paste, deterministic seed replay):p55g ++Deterministic clock/render tests:p55i +``` + +10. **Add an “Actionable Insights” dashboard panel for stronger day-to-day utility** +Analysis: Current dashboard is informative, but not prioritizing. Adding ranked insights (stale P1s, blocked MRs, discussion hotspots) turns it into a decision surface, not just a metrics screen. This makes the TUI materially more compelling for triage workflows. + +```diff +@@ 1. Executive Summary @@ + - Dashboard — sync status, project health, counts at a glance ++- Dashboard — sync status, project health, counts, and ranked actionable insights + +@@ 5.1 Dashboard (Home Screen) @@ +-│ Recent Activity │ ++│ Recent Activity │ ++│ Actionable Insights │ ++│ 1) 7 opened P1 issues >14d │ ++│ 2) 3 MRs blocked by unresolved │ ++│ 3) auth/ has +42% note velocity │ + +@@ 6. User Flows @@ ++### 6.9 Flow: "Risk-first morning sweep" ++Dashboard -> select insight -> jump to pre-filtered list/detail +``` + +These 10 changes stay clear of your `Rejected Recommendations` list and materially improve correctness, operability, and product value without adding speculative architecture. \ No newline at end of file diff --git a/plans/tui-prd-v2-frankentui.feedback-6.md b/plans/tui-prd-v2-frankentui.feedback-6.md new file mode 100644 index 0000000..6240501 --- /dev/null +++ b/plans/tui-prd-v2-frankentui.feedback-6.md @@ -0,0 +1,150 @@ +Your plan is strong and unusually detailed. The biggest upgrades I’d make are around build isolation, async correctness, terminal correctness, and turning existing data into sharper triage workflows. + +## 1) Fix toolchain isolation so stable builds cannot accidentally pull nightly +Rationale: a `rust-toolchain.toml` inside `crates/lore-tui` is not a complete guard when running workspace commands from repo root. You should structurally prevent stable workflows from touching nightly-only code. + +```diff +@@ 3.2 Nightly Rust Strategy +-[workspace] +-members = [".", "crates/lore-tui"] ++[workspace] ++members = ["."] ++exclude = ["crates/lore-tui"] + ++`crates/lore-tui` is built as an isolated workspace/package with explicit toolchain invocation: ++ cargo +nightly-2026-02-08 check --manifest-path crates/lore-tui/Cargo.toml ++Core repo remains: ++ cargo +stable check --workspace +``` + +## 2) Add an explicit `lore` <-> `lore-tui` compatibility contract +Rationale: runtime delegation is correct, but version drift between binaries will become the #1 support failure mode. Add a handshake before launch. + +```diff +@@ 10.19 CLI Integration — Adding `lore tui` ++Before spawning `lore-tui`, `lore` runs: ++ lore-tui --print-contract-json ++and validates: ++ - minimum_core_version ++ - supported_db_schema_range ++ - contract_version ++On mismatch, print actionable remediation: ++ cargo install --path crates/lore-tui +``` + +## 3) Make TaskSupervisor truly authoritative (remove split async paths) +Rationale: the document says supervisor is the only path, but examples still use direct `Cmd::task` and `search_request_id`. Close that contradiction now to avoid stale-data races. + +```diff +@@ 4.4 App — Implementing the Model Trait +- search_request_id: u64, ++ task_supervisor: TaskSupervisor, + +@@ 4.5.1 Task Supervisor +-The `search_request_id` field in `LoreApp` is superseded... ++`search_request_id` is removed. All async work uses TaskSupervisor generations. ++No direct `Cmd::task` from screen handlers or ad-hoc helpers. +``` + +## 4) Resolve keybinding conflicts and implement real go-prefix timeout +Rationale: `Ctrl+I` collides with `Tab` in terminals. Also your 500ms go-prefix timeout is described but not enforced in code. + +```diff +@@ 8.1 Global (Available Everywhere) +-| `Ctrl+I` | Jump forward in jump list (entity hops) | ++| `Alt+o` | Jump forward in jump list (entity hops) | + +@@ 8.2 Keybinding precedence ++Go-prefix timeout is enforced by timestamped state + tick check. ++Backspace global-back behavior is implemented (currently documented but not wired). +``` + +## 5) Add a shared display-width text utility (Unicode-safe truncation and alignment) +Rationale: current `truncate()` implementations use byte/char length and will misalign CJK/emoji/full-width text in tables and trees. + +```diff +@@ 10.1 New Files ++crates/lore-tui/src/text_width.rs # grapheme-safe truncation + display width helpers + +@@ 10.5 Dashboard View / 10.13 Issue List / 10.16 Who View +-fn truncate(s: &str, max: usize) -> String { ... } ++use crate::text_width::truncate_display_width; ++// all column fitting/truncation uses terminal display width, not bytes/chars +``` + +## 6) Upgrade sync streaming to a QoS event bus with sequence IDs +Rationale: today progress/log events can be dropped under load with weak observability. Keep UI responsive while guaranteeing completion semantics and visible gap accounting. + +```diff +@@ 4.4 start_sync_task() +-let (tx, rx) = std::sync::mpsc::sync_channel::(2048); ++let (ctrl_tx, ctrl_rx) = std::sync::mpsc::sync_channel::(256); // never-drop ++let (data_tx, data_rx) = std::sync::mpsc::sync_channel::(4096); // coalescible + ++Every streamed event carries seq_no. ++UI detects gaps and renders: "Dropped N log/progress events due to backpressure." ++Terminal events (started/completed/failed/cancelled) remain lossless. +``` + +## 7) Make list pagination truly keyset-driven in state, not just in prose +Rationale: plan text promises windowed keyset paging, but state examples still keep a single list without cursor model. Encode pagination state explicitly. + +```diff +@@ 10.10 state/issue_list.rs +-pub items: Vec, ++pub window: Vec, ++pub next_cursor: Option, ++pub prev_cursor: Option, ++pub prefetch: Option>, ++pub window_size: usize, // default 200 + +@@ 5.2 Issue List +-Pagination: Windowed keyset pagination... ++Pagination: Keyset cursor model is first-class state with forward/back cursors and prefetch buffer. +``` + +## 8) Harden session restore with atomic persistence + integrity checksum +Rationale: versioning/quarantine is good, but you still need crash-safe write semantics and tamper/corruption detection to avoid random boot failures. + +```diff +@@ 10.1 New Files +-crates/lore-tui/src/session.rs # Versioned session state persistence + validation + corruption quarantine ++crates/lore-tui/src/session.rs # + atomic write (tmp->fsync->rename), checksum, max-size guard + +@@ 11. Assumptions ++Session writes are atomic and checksummed. ++Invalid checksum or oversized file triggers quarantine and fresh boot. +``` + +## 9) Evolve Doctor from read-only text into actionable remediation +Rationale: your CLI already returns machine-actionable `actions`. TUI should surface those as one-key fixes; this materially increases usefulness. + +```diff +@@ 5.11 Doctor / Stats (Info Screens) +-Simple read-only views rendering the output... ++Doctor is interactive: ++ - shows health checks + severity ++ - exposes suggested `actions` from robot-mode errors ++ - Enter runs selected action command (with confirmation modal) ++Stats remains read-only. +``` + +## 10) Add a Dependency Lens to Issue/MR detail (high-value triage feature) +Rationale: you already have cross-refs + discussions + timeline. A compact dependency panel (blocked-by / blocks / unresolved threads) makes this data operational for prioritization. + +```diff +@@ 5.3 Issue Detail +-│ ┌─ Cross-References ─────────────────────────────────────────┐ │ ++│ ┌─ Dependency Lens ──────────────────────────────────────────┐ │ ++│ │ Blocked by: #1198 (open, stale 9d) │ │ ++│ │ Blocks: !458 (opened, 2 unresolved threads) │ │ ++│ │ Risk: High (P1 + stale blocker + open MR discussion) │ │ ++│ └────────────────────────────────────────────────────────────┘ │ + +@@ 9.2 Phases ++Dependency Lens (issue/mr detail, computed risk score) :p3e, after p2e, 1d +``` + +--- + +If you want, I can next produce a consolidated **“v2.1 patch”** of the PRD with all these edits merged into one coherent updated document structure. \ No newline at end of file diff --git a/plans/tui-prd-v2-frankentui.feedback-7.md b/plans/tui-prd-v2-frankentui.feedback-7.md new file mode 100644 index 0000000..10c7a09 --- /dev/null +++ b/plans/tui-prd-v2-frankentui.feedback-7.md @@ -0,0 +1,264 @@ +1. **Fix a critical contradiction in workspace/toolchain isolation** +Rationale: Section `3.2` says `crates/lore-tui` is excluded from the root workspace, but Section `9.1` currently adds it as a member. That inconsistency will cause broken CI/tooling behavior and confusion about whether stable-only workflows remain safe. + +```diff +--- a/PRD.md ++++ b/PRD.md +@@ 9.1 Dependency Changes +-# Root Cargo.toml changes +-[workspace] +-members = [".", "crates/lore-tui"] ++# Root Cargo.toml changes ++[workspace] ++members = ["."] ++exclude = ["crates/lore-tui"] +@@ +-# Add workspace member (no lore-tui dep, no tui feature) ++# Keep lore-tui EXCLUDED from root workspace (nightly isolation boundary) +@@ 9.3 Phase 0 — Toolchain Gate +-1. `cargo check --all-targets` passes on pinned nightly (TUI crate) and stable (core) ++1. `cargo +stable check --workspace --all-targets` passes for root workspace ++2. `cargo +nightly-2026-02-08 check --manifest-path crates/lore-tui/Cargo.toml --all-targets` passes +``` + +2. **Replace global loading spinner with per-screen stale-while-revalidate** +Rationale: A single `is_loading` flag causes full-screen flicker and blocked context during quick refreshes. Per-screen load states keep existing data visible while background refresh runs, improving perceived performance and usability. + +```diff +--- a/PRD.md ++++ b/PRD.md +@@ 10.10 State Module — Complete +- pub is_loading: bool, ++ pub load_state: ScreenLoadStateMap, +@@ +- pub fn set_loading(&mut self, loading: bool) { +- self.is_loading = loading; +- } ++ pub fn set_loading(&mut self, screen: ScreenId, state: LoadState) { ++ self.load_state.insert(screen, state); ++ } ++ ++pub enum LoadState { ++ Idle, ++ LoadingInitial, ++ Refreshing, // stale data remains visible ++ Error(String), ++} +@@ 4.4 App — Implementing the Model Trait +- // Loading spinner overlay (while async data is fetching) +- if self.state.is_loading { +- crate::tui::view::common::render_loading(frame, body); +- } else { +- match self.navigation.current() { ... } +- } ++ // Always render screen; show lightweight refresh indicator when needed. ++ match self.navigation.current() { ... } ++ crate::tui::view::common::render_refresh_indicator_if_needed( ++ self.navigation.current(), &self.state.load_state, frame, body ++ ); +``` + +3. **Make `TaskSupervisor` a real scheduler (not just token registry)** +Rationale: Current design declares priority lanes but still dispatches directly with `Cmd::task`, and debounce uses `thread::sleep` per keystroke (wastes worker threads). A bounded scheduler with queued tasks and timer-driven debounce will reduce contention and tail latency. + +```diff +--- a/PRD.md ++++ b/PRD.md +@@ 4.5.1 Task Supervisor (Dedup + Cancellation + Priority) +-pub struct TaskSupervisor { +- active: HashMap>, +- generation: AtomicU64, +-} ++pub struct TaskSupervisor { ++ active: HashMap>, ++ generation: AtomicU64, ++ queue: BinaryHeap, ++ inflight: HashMap, ++ limits: TaskLaneLimits, // e.g. Input=4, Navigation=2, Background=1 ++} +@@ +-// 200ms debounce via cancelable scheduled event (not thread::sleep). +-Cmd::task(move || { +- std::thread::sleep(std::time::Duration::from_millis(200)); +- ... +-}) ++// Debounce via runtime timer message; no sleeping worker thread. ++self.state.search.debounce_deadline = Some(now + 200ms); ++Cmd::none() +@@ 4.4 update() ++Msg::Tick => { ++ if self.state.search.debounce_expired(now) { ++ return self.dispatch_supervised(TaskKey::Search, TaskPriority::Input, ...); ++ } ++ self.task_supervisor.dispatch_ready(now) ++} +``` + +4. **Add a sync run ledger for exact “new since sync” navigation** +Rationale: “Since last sync” based on timestamps is ambiguous with partial failures, retries, and clock drift. A lightweight `sync_runs` + `sync_deltas` ledger makes summary-mode drill-down exact and auditable without implementing full resumable checkpoints. + +```diff +--- a/PRD.md ++++ b/PRD.md +@@ 5.9 Sync +-- `i` navigates to Issue List pre-filtered to "since last sync" +-- `m` navigates to MR List pre-filtered to "since last sync" ++- `i` navigates to Issue List pre-filtered to `sync_run_id=` ++- `m` navigates to MR List pre-filtered to `sync_run_id=` ++- Filters are driven by persisted `sync_deltas` rows (exact entity keys changed in run) +@@ 10.1 New Files ++src/core/migrations/00xx_add_sync_run_ledger.sql +@@ New migration (appendix) ++CREATE TABLE sync_runs ( ++ id INTEGER PRIMARY KEY, ++ started_at_ms INTEGER NOT NULL, ++ completed_at_ms INTEGER, ++ status TEXT NOT NULL ++); ++CREATE TABLE sync_deltas ( ++ sync_run_id INTEGER NOT NULL, ++ entity_kind TEXT NOT NULL, ++ project_id INTEGER NOT NULL, ++ iid INTEGER NOT NULL, ++ change_kind TEXT NOT NULL ++); ++CREATE INDEX idx_sync_deltas_run_kind ON sync_deltas(sync_run_id, entity_kind); +@@ 11 Assumptions +-16. No new SQLite tables needed for v1 ++16. Two small v1 tables are added: `sync_runs` and `sync_deltas` for deterministic post-sync UX. +``` + +5. **Expand the GA index set to match actual filter surface** +Rationale: Current required indexes only cover default sort paths; they do not match common filters like `author`, `assignee`, `reviewer`, `target_branch`, label-based filtering. This will likely miss p95 SLOs at M tier. + +```diff +--- a/PRD.md ++++ b/PRD.md +@@ 9.3.1 Required Indexes (GA Blocker) + CREATE INDEX IF NOT EXISTS idx_issues_list_default + ON issues(project_id, state, updated_at DESC, iid DESC); ++CREATE INDEX IF NOT EXISTS idx_issues_author_updated ++ ON issues(project_id, state, author_username, updated_at DESC, iid DESC); ++CREATE INDEX IF NOT EXISTS idx_issues_assignee_updated ++ ON issues(project_id, state, assignee_username, updated_at DESC, iid DESC); +@@ + CREATE INDEX IF NOT EXISTS idx_mrs_list_default + ON merge_requests(project_id, state, updated_at DESC, iid DESC); ++CREATE INDEX IF NOT EXISTS idx_mrs_reviewer_updated ++ ON merge_requests(project_id, state, reviewer_username, updated_at DESC, iid DESC); ++CREATE INDEX IF NOT EXISTS idx_mrs_target_updated ++ ON merge_requests(project_id, state, target_branch, updated_at DESC, iid DESC); ++CREATE INDEX IF NOT EXISTS idx_mrs_source_updated ++ ON merge_requests(project_id, state, source_branch, updated_at DESC, iid DESC); +@@ ++-- If labels are normalized through join table: ++CREATE INDEX IF NOT EXISTS idx_issue_labels_label_issue ON issue_labels(label, issue_id); ++CREATE INDEX IF NOT EXISTS idx_mr_labels_label_mr ON mr_labels(label, mr_id); +@@ CI enforcement +-asserts that none show `SCAN TABLE` for the primary entity tables ++asserts that none show full scans for primary tables under default filters AND top 8 user-facing filter combinations +``` + +6. **Add DB schema compatibility preflight (separate from binary compat)** +Rationale: Binary compat (`--compat-version`) does not protect against schema mismatches. Add explicit schema version checks before booting the TUI to avoid runtime SQL errors deep in navigation paths. + +```diff +--- a/PRD.md ++++ b/PRD.md +@@ 3.2 Nightly Rust Strategy +-- **Compatibility contract:** Before spawning `lore-tui`, the `lore tui` subcommand runs `lore-tui --compat-version` ... ++- **Compatibility contract:** Before spawning `lore-tui`, `lore tui` validates: ++ 1) binary compat version (`lore-tui --compat-version`) ++ 2) DB schema range (`lore-tui --check-schema `) ++If schema is out-of-range, print remediation: `lore migrate`. +@@ 9.3 Phase 0 — Toolchain Gate ++17. Schema preflight test: incompatible DB schema yields actionable error and non-zero exit before entering TUI loop. +``` + +7. **Refine terminal sanitization to preserve legitimate Unicode while blocking control attacks** +Rationale: Current sanitizer strips zero-width joiners and similar characters, which breaks emoji/grapheme rendering and undermines your own `text_width` goals. Keep benign Unicode, remove only dangerous controls/bidi spoof vectors, and sanitize markdown link targets too. + +```diff +--- a/PRD.md ++++ b/PRD.md +@@ 10.4.1 Terminal Safety — Untrusted Text Sanitization +-- Strip bidi overrides ... and zero-width/invisible controls ... ++- Strip ANSI/OSC/control chars and bidi spoof controls. ++- Preserve legitimate grapheme-joining characters (ZWJ/ZWNJ/combining marks) for correct Unicode rendering. ++- Sanitize markdown link targets with strict URL allowlist before rendering clickable links. +@@ safety.rs +- // Strip zero-width and invisible controls +- '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{00AD}' => {} ++ // Preserve grapheme/emoji join behavior; remove only harmful controls. ++ // (ZWJ/ZWNJ/combining marks are retained) +@@ Enforcement rule + - Search result snippets + - Author names and labels ++- Markdown link destinations (scheme + origin validation before render/open) +``` + +8. **Add key normalization layer for terminal portability** +Rationale: Collision notes are good, but you still need a canonicalization layer because terminals emit different sequences for Alt/Meta/Backspace/Enter variants. This reduces “works in iTerm, broken in tmux/SSH” bugs. + +```diff +--- a/PRD.md ++++ b/PRD.md +@@ 8.2 List Screens + **Terminal keybinding safety notes:** +@@ + - `Ctrl+M` is NOT used — it collides with `Enter` ... ++ ++**Key normalization layer (new):** ++- Introduce `KeyNormalizer` before `interpret_key()`: ++ - normalize Backspace variants (`^H`, `DEL`) ++ - normalize Alt/Meta prefixes ++ - normalize Shift+Tab vs Tab where terminal supports it ++ - normalize kitty/CSI-u enhanced key protocols when present +@@ 9.2 Phases ++ Key normalization integration tests :p5d, after p5c, 1d ++ Terminal profile replay tests :p5e, after p5d, 1d +``` + +9. **Add deterministic event-trace capture for crash reproduction** +Rationale: Panic logs without recent event context are often insufficient for TUI race bugs. Persist last-N normalized events + active screen + task state snapshot on panic for one-command repro. + +```diff +--- a/PRD.md ++++ b/PRD.md +@@ 3.1 Risk Matrix + | Runtime panic leaves user blocked | High | Medium | Panic hook writes crash report, restores terminal, offers fallback CLI command | ++| Hard-to-reproduce input race bugs | Medium | Medium | Persist last 2k normalized events + state hash on panic for deterministic replay | +@@ 10.3 Entry Point / panic hook +- // 2. Write crash dump ++ // 2. Write crash dump + event trace snapshot ++ // Includes: last 2000 normalized events, current screen, in-flight task keys/generations +@@ 10.9.1 Non-Snapshot Tests ++/// Replay captured event trace from panic artifact and assert no panic. ++#[test] ++fn replay_trace_artifact_is_stable() { ... } +``` + +10. **Do a plan-wide consistency pass on pseudocode contracts** +Rationale: There are internal mismatches that will create implementation churn (`search_request_id` still referenced after replacement, `items` vs `window`, keybinding mismatch `Ctrl+I` vs `Alt+o`). Tightening these now saves real engineering time later. + +```diff +--- a/PRD.md ++++ b/PRD.md +@@ 4.4 LoreApp::new +- search_request_id: 0, ++ // dedup generation handled by TaskSupervisor +@@ 8.1 Global +-| `Ctrl+O` | Jump backward in jump list (entity hops) | +-| `Alt+o` | Jump forward in jump list (entity hops) | ++| `Ctrl+O` | Jump backward in jump list (entity hops) | ++| `Alt+o` | Jump forward in jump list (entity hops) | +@@ 10.10 IssueListState +- pub fn selected_item(&self) -> Option<&IssueListRow> { +- self.items.get(self.selected_index) +- } ++ pub fn selected_item(&self) -> Option<&IssueListRow> { ++ self.window.get(self.selected_index) ++ } +``` + +If you want, I can now produce a single consolidated unified diff patch of the full PRD with these revisions merged end-to-end. \ No newline at end of file diff --git a/plans/tui-prd-v2-frankentui.feedback-8.md b/plans/tui-prd-v2-frankentui.feedback-8.md new file mode 100644 index 0000000..fffcc83 --- /dev/null +++ b/plans/tui-prd-v2-frankentui.feedback-8.md @@ -0,0 +1,211 @@ +Below are the strongest revisions I’d make. I intentionally avoided anything in your `## Rejected Recommendations`. + +1. **Unify commands/keybindings/help/palette into one registry** +Rationale: your plan currently duplicates action definitions across `execute_palette_action`, `ShowCliEquivalent`, help overlay text, and status hints. That will drift quickly and create correctness bugs. A single `CommandRegistry` makes behavior consistent and testable. + +```diff +diff --git a/PRD.md b/PRD.md +@@ 4.1 Module Structure ++ commands.rs # Single source of truth for actions, keybindings, CLI equivalents + +@@ 4.4 App — Implementing the Model Trait +- fn execute_palette_action(&self, action_id: &str) -> Cmd { ... big match ... } ++ fn execute_palette_action(&self, action_id: &str) -> Cmd { ++ if let Some(spec) = self.commands.get(action_id) { ++ return self.update(spec.to_msg(self.navigation.current())); ++ } ++ Cmd::none() ++ } + +@@ 8. Keybinding Reference ++All keybinding/help/status/palette definitions are generated from `commands.rs`. ++No hardcoded duplicate maps in view/state modules. +``` + +2. **Replace ad-hoc key flags with explicit input state machine** +Rationale: `pending_go` + `go_prefix_instant` is fragile and already inconsistent with documented behavior. A typed `InputMode` removes edge-case bugs and makes prefix timeout deterministic. + +```diff +diff --git a/PRD.md b/PRD.md +@@ 4.4 LoreApp struct +- pending_go: bool, +- go_prefix_instant: Option, ++ input_mode: InputMode, // Normal | Text | Palette | GoPrefix { started_at } + +@@ 8.2 List Screens +-| `g` `g` | Jump to top | ++| `g` `g` | Jump to top (current list screen) | + +@@ 4.4 interpret_key +- KeyCode::Char('g') => Msg::IssueListScrollToTop ++ KeyCode::Char('g') => Msg::ScrollToTopCurrentScreen +``` + +3. **Fix TaskSupervisor contract and message schema drift** +Rationale: the plan mixes `request_id` and `generation`, and `TaskKey::Search { generation }` defeats dedup by making every key unique. This can silently reintroduce stale-result races. + +```diff +diff --git a/PRD.md b/PRD.md +@@ 4.3 Core Types (Msg) +- SearchRequestStarted { request_id: u64, query: String }, +- SearchExecuted { request_id: u64, results: SearchResults }, ++ SearchRequestStarted { generation: u64, query: String }, ++ SearchExecuted { generation: u64, results: SearchResults }, + +@@ 4.5.1 Task Supervisor +- Search { generation: u64 }, ++ Search, ++ struct TaskStamp { key: TaskKey, generation: u64 } + +@@ 10.9.1 Non-Snapshot Tests +- Msg::SearchExecuted { request_id: 3, ... } ++ Msg::SearchExecuted { generation: 3, ... } +``` + +4. **Add a `Clock` boundary everywhere time is computed** +Rationale: you call `SystemTime::now()` in many query/render paths, causing inconsistent relative-time labels inside one frame and flaky tests. Injected clock gives deterministic rendering and lower per-frame overhead. + +```diff +diff --git a/PRD.md b/PRD.md +@@ 4.1 Module Structure ++ clock.rs # Clock trait: SystemClock/FakeClock + +@@ 4.4 LoreApp struct ++ clock: Arc, + +@@ 10.11 action.rs +- let now_ms = std::time::SystemTime::now()... ++ let now_ms = clock.now_ms(); + +@@ 9.3 Phase 0 success criteria ++19. Relative-time rendering deterministic under FakeClock across snapshot runs. +``` + +5. **Upgrade text truncation to grapheme-safe width handling** +Rationale: `unicode-width` alone is not enough for safe truncation; it can split grapheme clusters (emoji ZWJ sequences, skin tones, flags). You need width + grapheme segmentation together. + +```diff +diff --git a/PRD.md b/PRD.md +@@ 10.1 New Files +-crates/lore-tui/src/text_width.rs # ... using unicode-width crate ++crates/lore-tui/src/text_width.rs # Grapheme-safe width/truncation using unicode-width + unicode-segmentation + +@@ 10.1 New Files ++Cargo.toml (lore-tui): unicode-segmentation = "1" + +@@ 9.3 Phase 0 success criteria ++20. Unicode rendering tests pass for CJK, emoji ZWJ, combining marks, RTL text. +``` + +6. **Redact sensitive values in logs and crash dumps** +Rationale: current crash/log strategy risks storing tokens/credentials in plain text. This is a serious operational/security gap for local tooling too. + +```diff +diff --git a/PRD.md b/PRD.md +@@ 4.1 Module Structure + safety.rs # sanitize_for_terminal(), safe_url_policy() ++ redact.rs # redact_sensitive() for logs/crash reports + +@@ 10.3 install_panic_hook_for_tui +- let _ = std::fs::write(&crash_path, format!("{panic_info:#?}")); ++ let report = redact_sensitive(format!("{panic_info:#?}")); ++ let _ = std::fs::write(&crash_path, report); + +@@ 9.3 Phase 0 success criteria ++21. Redaction tests confirm tokens/Authorization headers never appear in persisted crash/log artifacts. +``` + +7. **Add search capability detection and mode fallback UX** +Rationale: semantic/hybrid mode should not silently degrade when embeddings are absent/stale. Explicit capability state increases trust and avoids “why are results weird?” confusion. + +```diff +diff --git a/PRD.md b/PRD.md +@@ 5.6 Search ++Capability-aware modes: ++- If embeddings unavailable/stale, semantic mode is disabled with inline reason. ++- Hybrid mode auto-falls back to lexical and shows badge: "semantic unavailable". + +@@ 4.3 Core Types ++ SearchCapabilitiesLoaded(SearchCapabilities) + +@@ 9.3 Phase 0 success criteria ++22. Mode availability checks validated: lexical/hybrid/semantic correctly enabled/disabled by fixture capabilities. +``` + +8. **Define sync cancel latency SLO and enforce fine-grained checks** +Rationale: “check cancel between phases” is too coarse on big projects. Users need fast cancel acknowledgment and bounded stop time. + +```diff +diff --git a/PRD.md b/PRD.md +@@ 5.9 Sync +-CANCELLATION: checked between sync phases ++CANCELLATION: checked at page boundaries, batch upsert boundaries, and before each network request. ++UX target: cancel acknowledged <250ms, sync stop p95 <2s after Esc. + +@@ 9.3 Phase 0 success criteria ++23. Cancel latency test passes: p95 stop time <2s under M-tier fixtures. +``` + +9. **Add a “Hotspots” screen for risk/churn triage** +Rationale: this is high-value and uses existing data (events, unresolved discussions, stale items). It makes the TUI more compelling without needing new sync tables or rejected features. + +```diff +diff --git a/PRD.md b/PRD.md +@@ 1. Executive Summary ++- **Hotspots** — file/path risk ranking by churn × unresolved discussion pressure × staleness + +@@ 5. Screen Taxonomy ++### 5.12 Hotspots ++Shows top risky paths with drill-down to related issues/MRs/timeline. + +@@ 8.1 Global ++| `gx` | Go to Hotspots | + +@@ 10.1 New Files ++crates/lore-tui/src/state/hotspots.rs ++crates/lore-tui/src/view/hotspots.rs +``` + +10. **Add degraded startup mode when compat/schema checks fail** +Rationale: hard-exit on mismatch blocks users. A degraded mode that shells to `lore --robot` for read-only summary/doctor keeps the product usable and gives guided recovery. + +```diff +diff --git a/PRD.md b/PRD.md +@@ 3.2 Nightly Rust Strategy +- On mismatch: actionable error and exit ++ On mismatch: actionable error with `--degraded` option. ++ `--degraded` launches limited TUI (Dashboard/Doctor/Stats via `lore --robot` subprocess calls). + +@@ 10.3 TuiCli ++ /// Allow limited mode when schema/compat checks fail ++ #[arg(long)] ++ degraded: bool, +``` + +11. **Harden query-plan CI checks (don’t rely on `SCAN TABLE` string matching)** +Rationale: SQLite planner text varies by version. Parse opcode structure and assert index usage semantically; otherwise CI will be flaky or miss regressions. + +```diff +diff --git a/PRD.md b/PRD.md +@@ 9.3.1 Required Indexes (CI enforcement) +- asserts that none show `SCAN TABLE` ++ parses EXPLAIN QUERY PLAN rows and asserts: ++ - top-level loop uses expected index families ++ - no full scan on primary entity tables under default and top filter combos ++ - join order remains bounded (no accidental cartesian expansions) +``` + +12. **Enforce single-instance lock for session/state safety** +Rationale: assumption says no concurrent TUI sessions, but accidental double-launch will still happen. Locking prevents state corruption and confusing interleaved sync actions. + +```diff +diff --git a/PRD.md b/PRD.md +@@ 10.1 New Files ++crates/lore-tui/src/instance_lock.rs # lock file with stale-lock recovery + +@@ 11. Assumptions +-21. No concurrent TUI sessions. ++21. Concurrent sessions unsupported and actively prevented by instance lock (with clear error message). +``` + +If you want, I can turn this into a consolidated patched PRD (single unified diff) next. \ No newline at end of file diff --git a/plans/tui-prd-v2-frankentui.feedback-9.md b/plans/tui-prd-v2-frankentui.feedback-9.md new file mode 100644 index 0000000..f1a589b --- /dev/null +++ b/plans/tui-prd-v2-frankentui.feedback-9.md @@ -0,0 +1,198 @@ +I reviewed the full PRD end-to-end and avoided all items already listed in `## Rejected Recommendations`. +These are the highest-impact revisions I’d make. + +1. **Fix keybinding/state-machine correctness gaps (critical)** +The plan currently has an internal conflict: the doc says jump-forward is `Alt+o`, but code sample uses `Ctrl+i` (which collides with `Tab` in many terminals). Also, `g`-prefix timeout depends on `Tick`, but `Tick` isn’t guaranteed when idle, so prefix mode can get “stuck.” This is a correctness bug, not polish. + +```diff +@@ 8.1 Global (Available Everywhere) +-| `Ctrl+O` | Jump backward in jump list (entity hops) | +-| `Alt+o` | Jump forward in jump list (entity hops) | ++| `Ctrl+O` | Jump backward in jump list (entity hops) | ++| `Alt+o` | Jump forward in jump list (entity hops) | ++| `Backspace` | Go back (when no text input is focused) | + +@@ 4.4 LoreApp::interpret_key +- (KeyCode::Char('i'), m) if m.contains(Modifiers::CTRL) => { +- return Some(Msg::JumpForward); +- } ++ (KeyCode::Char('o'), m) if m.contains(Modifiers::ALT) => { ++ return Some(Msg::JumpForward); ++ } ++ (KeyCode::Backspace, Modifiers::NONE) => { ++ return Some(Msg::GoBack); ++ } + +@@ 4.4 Model::subscriptions ++ // Go-prefix timeout enforcement must tick even when nothing is loading. ++ if matches!(self.input_mode, InputMode::GoPrefix { .. }) { ++ subs.push(Box::new( ++ Every::with_id(2, Duration::from_millis(50), || Msg::Tick) ++ )); ++ } +``` + +2. **Make `TaskSupervisor` API internally consistent and enforceable** +The plan uses `submit()`/`is_current()` in one place and `register()`/`next_generation()` in another. That inconsistency will cause implementation drift and stale-result bugs. Use one coherent API with a returned handle containing `{key, generation, cancel_token}`. + +```diff +@@ 4.5.1 Task Supervisor (Dedup + Cancellation + Priority) +-pub struct TaskSupervisor { +- active: HashMap>, +- generation: AtomicU64, +-} ++pub struct TaskSupervisor { ++ active: HashMap, ++} ++ ++pub struct TaskHandle { ++ pub key: TaskKey, ++ pub generation: u64, ++ pub cancel: Arc, ++} + +- pub fn register(&mut self, key: TaskKey) -> Arc +- pub fn next_generation(&self) -> u64 ++ pub fn submit(&mut self, key: TaskKey) -> TaskHandle ++ pub fn is_current(&self, key: &TaskKey, generation: u64) -> bool ++ pub fn complete(&mut self, key: &TaskKey, generation: u64) +``` + +3. **Replace thread-sleep debounce with runtime timer messages** +`std::thread::sleep(200ms)` inside task closures wastes pool threads under fast typing and reduces responsiveness under contention. Use timer-driven debounce messages and only fire the latest generation. This improves latency stability on large datasets. + +```diff +@@ 4.3 Core Types (Msg enum) ++ SearchDebounceArmed { generation: u64, query: String }, ++ SearchDebounceFired { generation: u64 }, + +@@ 4.4 maybe_debounced_query +- Cmd::task(move || { +- std::thread::sleep(std::time::Duration::from_millis(200)); +- ... +- }) ++ // Arm debounce only; runtime timer emits SearchDebounceFired. ++ Cmd::msg(Msg::SearchDebounceArmed { generation, query }) + +@@ 4.4 subscriptions() ++ if self.state.search.debounce_pending() { ++ subs.push(Box::new( ++ Every::with_id(3, Duration::from_millis(200), || Msg::SearchDebounceFired { generation: ... }) ++ )); ++ } +``` + +4. **Harden `DbManager` API to avoid lock-poison panics and accidental long-held guards** +Returning raw `MutexGuard` invites accidental lock scope expansion and `expect("lock poisoned")` panics. Move to closure-based access (`with_reader`, `with_writer`) returning `Result`, and use cached statements. This reduces deadlock risk and tail latency. + +```diff +@@ 4.4 DbManager +- pub fn reader(&self) -> MutexGuard<'_, Connection> { ...expect("reader lock poisoned") } +- pub fn writer(&self) -> MutexGuard<'_, Connection> { ...expect("writer lock poisoned") } ++ pub fn with_reader(&self, f: impl FnOnce(&Connection) -> Result) -> Result ++ pub fn with_writer(&self, f: impl FnOnce(&Connection) -> Result) -> Result + +@@ 10.11 action.rs +- let conn = db.reader(); +- match fetch_issues(&conn, &filter) { ... } ++ match db.with_reader(|conn| fetch_issues(conn, &filter)) { ... } + ++ // Query hot paths use prepare_cached() to reduce parse overhead. +``` + +5. **Add read-path entity cache (LRU) for repeated drill-in/out workflows** +Your core daily flow is Enter/Esc bouncing between list/detail. Without caching, identical detail payloads are re-queried repeatedly. A bounded LRU by `EntityKey` with invalidation on sync completion gives near-instant reopen behavior and reduces DB pressure. + +```diff +@@ 4.1 Module Structure ++ entity_cache.rs # Bounded LRU cache for detail payloads + +@@ app.rs LoreApp fields ++ entity_cache: EntityCache, + +@@ load_screen(Screen::IssueDetail / MrDetail) ++ if let Some(cached) = self.entity_cache.get_issue(&key) { ++ return Cmd::msg(Msg::IssueDetailLoaded { key, detail: cached.clone() }); ++ } + +@@ Msg::IssueDetailLoaded / Msg::MrDetailLoaded handlers ++ self.entity_cache.put_issue(key.clone(), detail.clone()); + +@@ Msg::SyncCompleted ++ self.entity_cache.invalidate_all(); +``` + +6. **Tighten sync-stream observability and drop semantics without adding heavy architecture** +You already handle backpressure, but operators need visibility when it happens. Track dropped-progress count and max queue depth in state and surface it in running/summary views. This keeps the current simple design while making reliability measurable. + +```diff +@@ 4.3 Msg ++ SyncStreamStats { dropped_progress: u64, max_queue_depth: usize }, + +@@ 5.9 Sync (Running mode footer) +-| Esc cancel f full sync e embed after d dry-run l log level| ++| Esc cancel f full sync e embed after d dry-run l log level stats:drop=12 qmax=1847 | + +@@ 9.3 Success criteria ++24. Sync stream stats are emitted and rendered; terminal events (completed/failed/cancelled) delivery is 100% under induced backpressure. +``` + +7. **Make crash reporting match the promised diagnostic value** +The PRD promises event replay context, but sample hook writes only panic text. Add explicit crash context capture (`last events`, `current screen`, `task handles`, `build id`, `db fingerprint`) and retention policy. This materially improves post-mortem debugging. + +```diff +@@ 4.1 Module Structure ++ crash_context.rs # ring buffer of normalized events + task/screen snapshot + +@@ 10.3 install_panic_hook_for_tui() +- let report = crate::redact::redact_sensitive(&format!("{panic_info:#?}")); ++ let ctx = crate::crash_context::snapshot(); ++ let report = crate::redact::redact_sensitive(&format!("{panic_info:#?}\n{ctx:#?}")); + ++ // Retention: keep latest 20 crash files, delete oldest metadata entries only. +``` + +8. **Add Search Facets panel for faster triage (high-value feature, low risk)** +Search is central, but right now filtering requires manual field edits. Add facet counts (`issues`, `MRs`, `discussions`, top labels/projects/authors) with one-key apply. This makes search more compelling and actionable without introducing schema changes. + +```diff +@@ 5.6 Search +-- Layout: Split pane — results list (left) + preview (right) ++- Layout: Three-pane on wide terminals — results (left) + preview (center) + facets (right) + ++**Facets panel:** ++- Entity type counts (issue/MR/discussion) ++- Top labels/projects/authors for current query ++- `1/2/3` quick-apply type facet; `l` cycles top label facet + +@@ 8.2 List/Search keybindings ++| `1` `2` `3` | Apply facet: Issue / MR / Discussion | ++| `l` | Apply next top-label facet | +``` + +9. **Strengthen text sanitization for terminal edge cases** +Current sanitizer is strong, but still misses some control-space edge cases (C1 controls, directional marks beyond the listed bidi set). Add those and test them. This closes spoofing/render confusion gaps with minimal complexity. + +```diff +@@ 10.4.1 sanitize_for_terminal() ++ // Strip C1 control block (U+0080..U+009F) and additional directional marks ++ c if ('\u{0080}'..='\u{009F}').contains(&c) => {} ++ '\u{200E}' | '\u{200F}' | '\u{061C}' => {} // LRM, RLM, ALM + +@@ tests ++ #[test] fn strips_c1_controls() { ... } ++ #[test] fn strips_lrm_rlm_alm() { ... } +``` + +10. **Add an explicit vertical-slice gate before broad screen expansion** +The plan is comprehensive, but risk is still front-loaded on framework + runtime behavior. Insert a strict vertical slice gate (`Dashboard + IssueList + IssueDetail + Sync running`) with perf and stability thresholds before Phase 3 features. This reduces rework if foundational assumptions break. + +```diff +@@ 9.2 Phases ++section Phase 2.5 — Vertical Slice Gate ++Dashboard + IssueList + IssueDetail + Sync (running) integrated :p25a, after p2c, 3d ++Gate: p95 nav latency < 75ms on M tier; zero stuck-input-state bugs; cancel p95 < 2s :p25b, after p25a, 1d ++Only then proceed to Search/Timeline/Who/Palette expansion. +``` + +If you want, I can produce a full consolidated `diff` block against the entire PRD text (single patch), but the above is the set I’d prioritize first. \ No newline at end of file diff --git a/plans/tui-prd-v2-frankentui.md b/plans/tui-prd-v2-frankentui.md new file mode 100644 index 0000000..bded6e3 --- /dev/null +++ b/plans/tui-prd-v2-frankentui.md @@ -0,0 +1,7808 @@ +--- +plan: true +title: "Gitlore TUI PRD v2 - FrankenTUI" +status: iterating +iteration: 9 +target_iterations: 10 +beads_revision: 0 +related_plans: [] +created: 2026-02-11 +updated: 2026-02-11 +--- + +# Gitlore TUI — Product Requirements Document + +**Author:** Work Ghost +**Date:** 2026-02-11 +**Status:** DRAFT — Pending Approval +**Scope:** Interactive terminal UI for the `lore` CLI tool +**Framework:** FrankenTUI v0.1.1+ + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [Framework Selection](#2-framework-selection) +3. [Risk Mitigation](#3-risk-mitigation) +4. [Architecture](#4-architecture) +5. [Screen Taxonomy](#5-screen-taxonomy) +6. [User Flows — Every Command](#6-user-flows) +7. [Widget Inventory](#7-widget-inventory) +8. [Keybinding Reference](#8-keybinding-reference) +9. [Implementation Plan](#9-implementation-plan) +10. [Code Changes Required](#10-code-changes-required) +11. [Assumptions](#11-assumptions) + +--- + +## 1. Executive Summary + +Gitlore (`lore`) is a 32,844-line Rust CLI for local GitLab data management with semantic search, people intelligence, and temporal analysis. It currently offers 21 commands via clap with two output modes: human-readable tables (`comfy-table`, `indicatif`, `console`) and machine-readable JSON (`--robot`). + +**The problem:** Power users must memorize 21 commands, ~80 flags, and their interaction semantics. Discovering data relationships (who touched which files → which MRs → which discussions) requires sequential command invocations. There is no way to browse, filter, and drill into data interactively. + +**The solution:** A full-screen interactive TUI that provides: + +- **Dashboard** — sync status, project health, counts, and ranked actionable insights +- **Browsable entity lists** — issues, MRs, discussions with live filtering +- **Drill-down detail views** — threaded discussions, diff notes, cross-references +- **Integrated search** — hybrid FTS/semantic search with inline results +- **People explorer** — expert maps, workload snapshots, review patterns +- **Timeline viewer** — chronological event reconstruction with graph navigation +- **Sync orchestration** — progress visualization with real-time log streaming + +- **Session restore** — resume last screen, filters, and selection on startup (`--fresh` to skip) + +The TUI augments but does not replace the existing CLI. All commands continue to work as-is. + +--- + +## 2. Framework Selection + +### 2.1 Candidates Evaluated + +| Dimension | FrankenTUI | Ratatui | Charm (bubbletea) | +|-----------|-----------|---------|-------------------| +| **Language** | Rust (nightly) | Rust (stable) | Go | +| **Age** | 6 days (Feb 2026) | 3+ years (fork of tui-rs) | 4+ years | +| **Downloads** | ~10 (crates.io) | 16.9M (crates.io) | N/A (Go module) | +| **Stars** | 125 | 18,200+ | 28,000+ | +| **Dependent crates** | 0 | 2,200+ | N/A | +| **Production users** | None known | Netflix, OpenAI, AWS, Vercel | Numerous Go tools | +| **Architecture** | Elm/Bubbletea | Immediate-mode | Elm/Bubbletea | +| **Inline mode** | First-class kernel feature | App-specific hack | First-class (Bubble Tea) | +| **Layout** | Flex + Grid + Responsive breakpoints | Constraint-based Flex | Lipgloss (CSS-like) | +| **Widgets** | Rich (Table, Tree, CommandPalette, Modal, LogViewer, FilePicker, Charts) | Core set + community ecosystem | Bubbles (spinner, text input, list, paginator, table, viewport) | +| **Snapshot testing** | Built-in harness | None built-in | None built-in | +| **RAII teardown** | Kernel-enforced | App-specific | Deferred | +| **Theming** | WCAG-compliant, light/dark, semantic tokens | Manual style application | Lipgloss (adaptive colors) | +| **Accessibility** | WCAG contrast validation | None | None | +| **Toolchain req.** | Rust nightly | Rust stable | Go 1.18+ | +| **Documentation** | 76-94% (docs.rs) | Extensive tutorials + book | Excellent | + +**Charm is eliminated.** It's Go-only; gitlore is Rust. FFI bridging would be absurd. However, Charm's Elm architecture and inline-mode approach directly influenced FrankenTUI's design. + +### 2.2 Why FrankenTUI + +**1. Architectural fit is exceptional.** FrankenTUI's `Model` trait with `update()/view()` + `Cmd` is the Elm architecture we need for clean TUI state management — but as the native runtime rather than a hand-rolled abstraction layer. Zero impedance mismatch. + +**2. The widget catalog eliminates ~80% of custom widget work.** We need 10 widget types for gitlore's screens. FrankenTUI provides 9 built-in: + +| Required Widget | FrankenTUI Solution | +|----------------|---------------------| +| Command Palette | `ftui_widgets::command_palette::CommandPalette` | +| Discussion Tree | `ftui_widgets::tree::Tree` | +| Filter Bar | `ftui_extras::forms` + `ftui_widgets::input::TextInput` | +| Progress Panel | `ftui_widgets::progress::ProgressBar` + `ftui_widgets::log_viewer::LogViewer` | +| Entity Preview | `ftui_widgets::panel::Panel` + `ftui_widgets::paragraph::Paragraph` | +| Timeline Stream | `ftui_widgets::virtualized::VirtualizedList` | +| Breadcrumb | Trivial with `ftui_widgets::badge::Badge` chain | +| Status Bar | `ftui_widgets::status_line::StatusLine` | +| Cross-Ref Link | Custom styled `Span` (~30 lines) | + +Only `CrossRefLink` requires custom implementation — everything else maps to a built-in. + +**3. Inline mode is perfect for sync.** The `ScreenMode::Inline { ui_height }` keeps progress bars stable at the top while log output scrolls naturally in terminal scrollback. After sync completes and the TUI exits, the full log history remains visible in the terminal scrollback. + +**4. Responsive breakpoints are first-class.** The `Breakpoints` / `Breakpoint` / `Responsive` / `ResponsiveLayout` types let us define different layouts per terminal width declaratively. + +**5. WCAG contrast validation prevents accessibility bugs.** The `best_text_color()`, `meets_wcag_aa()`, and `contrast_ratio()` functions in `ftui-style` ensure theme choices are readable. + +**6. Snapshot testing harness enables TUI regression tests.** The built-in `ftui-harness` crate provides deterministic frame capture and comparison. + +### 2.3 What We're Betting On + +We are making a deliberate bet that FrankenTUI's technical superiority justifies its immaturity. This is a calculated risk, not a blind one. See Section 3 for the full mitigation strategy. + +--- + +## 3. Risk Mitigation + +### 3.1 Risk Matrix + +| Risk | Severity | Likelihood | Mitigation | +|------|----------|------------|------------| +| API breaking changes | High | High (v0.x) | Pin exact git commit; vendor source if needed | +| Bidi/invisible Unicode spoofing in rendered text | High | Medium | Strip bidi overrides (RLO/LRO/PDF/RLI/LRI/FSI/PDI) and zero-width/invisible controls in untrusted text | +| Terminal compatibility bugs | High | Medium | Test matrix: iTerm2, Terminal.app, Alacritty, tmux, SSH | +| Nightly Rust instability | Medium | Low | Pin nightly date in `rust-toolchain.toml` (TUI crate only); CI tests on pinned nightly | +| Maintainer abandonment | High | Low-Medium | Fork-ready; MIT license; 12-crate workspace is navigable | +| Missing documentation | Medium | High | Read source directly; contribute docs upstream | +| Build failures from nightly breakage | Medium | Medium | Pin channel date, update monthly with test gate | +| Query latency regressions on large datasets | Medium | Medium | Performance SLOs enforced by benchmark fixtures in CI (see 9.3) | +| DB contention during sync | Medium | High | DbManager with read pool + dedicated writer; WAL + busy_timeout | +| Runtime panic leaves user blocked | High | Medium | Panic hook captures crash context (last 2000 events ring buffer + screen/nav/task/build/db snapshot), restores terminal, offers fallback CLI command. Retention: latest 20 crash files, oldest auto-pruned. | +| Hard-to-reproduce input race bugs | Medium | Medium | Crash context ring buffer includes last 2000 normalized events + current screen + in-flight task keys/generations + build version + DB fingerprint for post-mortem replay | +| Interrupted sync loses partial progress | Medium | Medium | Per-project fault isolation; failed lanes marked degraded while others continue. Resumable checkpoints planned for post-v1 (requires `sync_checkpoints` table). | +| Malicious URL in entity data opened in browser | Medium | Low | URL host validated against configured GitLab instance before `open`/`xdg-open` | +| Terminal escape/control-sequence injection via issue/note text | High | Medium | Strip ANSI/OSC/control chars + C1 controls (U+0080..U+009F) + bidi overrides + directional marks (LRM/RLM/ALM) via `sanitize_for_terminal()` before render; origin-normalized URL validation before open; disable raw HTML in markdown rendering | + +### 3.2 Nightly Rust Strategy + +FrankenTUI requires nightly for features like `#![feature(...)]` in its workspace. We handle this with **complete toolchain isolation via a separate binary crate**: + +```toml +# Workspace root Cargo.toml — lore-tui is EXCLUDED, not a member. +# This prevents `cargo check --workspace` from pulling nightly. +[workspace] +members = ["."] +exclude = ["crates/lore-tui"] + +# crates/lore-tui/rust-toolchain.toml +[toolchain] +channel = "nightly-2026-02-08" # Pin to known-good date +components = ["rustfmt", "clippy"] +``` + +**Key decisions:** +- Core `lore` stays on **stable Rust**. Only `crates/lore-tui` uses pinned nightly. +- **`crates/lore-tui` is excluded from the root workspace**, not listed as a member. This structurally prevents `cargo check --workspace`, `cargo clippy --workspace`, and `cargo test --workspace` from ever touching nightly code. To build the TUI: `cargo +nightly-2026-02-08 check --manifest-path crates/lore-tui/Cargo.toml`. Core repo remains: `cargo +stable check --workspace`. +- We pin to a specific nightly date (not floating `nightly`). Updates are deliberate, tested, and committed. +- CI runs both `cargo check` and `cargo test` on the pinned nightly for `lore-tui`, and stable for everything else. CI scripts must use explicit `--manifest-path` invocations for the TUI crate. +- If a nightly update breaks us, we stay on the previous pin until it's fixed — this is a solved workflow. +- **`lore-tui` is a separate binary crate (nightly-only).** The root `lore` crate has **NO compile-time dependency** on `lore-tui`. The `lore tui` subcommand delegates by resolving and spawning the `lore-tui` binary at runtime. This eliminates the Cargo dependency cycle risk that would exist with `lore -> lore-tui (optional)` + `lore-tui -> lore` under a feature gate. +- **Compatibility contract:** Before spawning `lore-tui`, the `lore tui` subcommand validates: + 1. Binary compat version (`lore-tui --compat-version`) — integer check against a minimum supported version constant. On mismatch: `"lore-tui is too old (compat v1, need v2). Rebuild with: cargo install --path crates/lore-tui"`. + 2. DB schema version (`lore-tui --check-schema `) — validates the database schema version is within the supported range for this TUI build. On mismatch: `"Database schema too old (v12, need v14+). Run: lore migrate"`. + + This catches version drift between the two binaries — the #1 support failure mode for runtime delegation architectures. The contract is intentionally minimal: integer versions, not JSON schemas, because it only needs to detect "too old to work" vs "compatible." +- If `lore-tui` is not installed, `lore tui` prints an actionable error: `"lore-tui binary not found. Build with: cargo install --path crates/lore-tui"`. +- Non-TUI users, CI pipelines, and robot-mode agents never touch nightly. + +**Why separate binary over feature-gated library dep:** A feature-gated `lore-tui` library dependency creates a hidden Cargo package cycle: the root `lore` depends on `lore-tui` (optional), but `lore-tui` depends on `lore` for core types. While Cargo can sometimes resolve optional dependencies without cycles, this is fragile and becomes a real blocker when both crates need to reference shared types. A separate binary eliminates this risk entirely — `lore-tui` depends on `lore` as a library, and `lore` has zero knowledge of `lore-tui` at compile time. The runtime delegation cost is negligible (one `Command::new` + `exec`). + +**Why workspace isolation over whole-project nightly:** Nightly brings risk (compiler regressions, clippy changes, feature instability). The TUI is an optional enhancement — the core CLI, sync pipeline, search, and robot mode must stay rock-solid on stable. Split toolchains add minor CI complexity but dramatically reduce blast radius of nightly breakage. + +### 3.3 Dependency Pinning Strategy + +```toml +# crates/lore-tui/Cargo.toml — pin to exact git revision +[dependencies.ftui] +git = "https://github.com/Dicklesworthstone/frankentui.git" +rev = "abc123def" # Pin to tested commit +``` + +**If crates.io publishes all workspace crates** before we ship, switch to version pins: +```toml +ftui = "=0.1.1" +``` + +**If the project is abandoned**, we fork to `gitlore-org/frankentui` and maintain the subset we use. The 12-crate workspace is well-structured for selective maintenance. + +### 3.4 Terminal Compatibility Testing + +Before shipping, validate on: + +| Terminal | Platform | Priority | +|----------|----------|----------| +| iTerm2 | macOS | Must pass | +| Terminal.app | macOS | Must pass | +| Alacritty | macOS/Linux | Must pass | +| Kitty | macOS/Linux | Should pass | +| tmux (nested) | Any | Must pass | +| SSH (mosh) | Remote | Should pass | +| VS Code terminal | Any | Should pass | +| Windows Terminal | Windows | Nice to have | + +FrankenTUI's `TerminalCapabilities` detection module handles many of these automatically, but we validate end-to-end. + +#### 3.4.1 Capability-Adaptive Rendering + +Runtime rendering adapts to detected terminal capabilities: + +| Capability | Full Mode | Minimal Mode | +|-----------|-----------|-------------| +| **Color** | Truecolor (24-bit) | 256-color or 16-color fallback | +| **Glyphs** | Unicode box-drawing, progress bars | ASCII-safe equivalents (`|`, `-`, `#`) | +| **Alt screen** | Enabled (fullscreen TUI) | Disabled (`--no-alt-screen` for tmux/SSH) | + +CLI flags for explicit override: +``` +lore tui --render-mode=auto|full|minimal +lore tui --ascii # Force ASCII-safe glyphs +lore tui --no-alt-screen # Stay in normal terminal (useful for tmux/SSH) +``` + +Default is `auto` — detect capabilities and choose the best mode. Explicit flags override detection for environments where auto-detection is unreliable (e.g., some SSH + tmux combinations). + +### 3.5 Escape Hatch + +If FrankenTUI proves unworkable after Phase 1 (foundation), the migration cost to ratatui is ~3-5 days: +- The `state/` module is framework-agnostic (pure data). +- The `action.rs` query layer is framework-agnostic (sync SQLite queries). +- The `navigation.rs` stack is framework-agnostic. +- Only `view/` files and `app.rs` need rewriting — they're the rendering layer. + +The Elm architecture we use (Message → update → view) works identically in both frameworks. This is the key architectural decision that makes the framework swappable. + +**Validation gate:** In Phase 1, build one non-trivial screen (Dashboard) end-to-end with FrankenTUI. If the widget API friction is high or rendering quality is poor, pause and prototype the same screen with ratatui before committing to Phases 2-5. This validates the escape hatch cost estimate early rather than discovering it's optimistic after investing 20+ days. + +--- + +## 4. Architecture + +### 4.1 Module Structure + +``` +crates/lore-tui/src/ + mod.rs # Public API: launch_tui() + app.rs # LoreApp impl Model (FrankenTUI native) + message.rs # Msg enum (all user actions + async results) + commands.rs # CommandRegistry: single source of truth for actions, keybindings, CLI equivalents, palette entries, help text, and status hints. All keybinding/help/status/palette definitions generated from this registry — no hardcoded duplicate maps in view/state modules. + clock.rs # Clock trait: SystemClock (production) / FakeClock (tests). All relative-time rendering (e.g., "3h ago" labels) uses injected Clock, ensuring deterministic snapshot tests and consistent timestamps within a single frame. + instance_lock.rs # Single-instance advisory lock file with stale-lock recovery. Prevents state/session corruption from accidental double-launch. + state/ + mod.rs # AppState (top-level composition) + dashboard.rs # Dashboard state + issue_list.rs # Issue list + filter state + issue_detail.rs # Single issue view state + mr_list.rs # MR list + filter state + mr_detail.rs # Single MR view state + search.rs # Search query + results state + timeline.rs # Timeline view state + who.rs # People explorer state + sync.rs # Sync progress + post-sync summary state + command_palette.rs # Command palette state (uses ftui CommandPalette) + view/ + mod.rs # Screen router (match state → render fn) + dashboard.rs # Dashboard layout + widgets + issue_list.rs # Issue table + filter bar + issue_detail.rs # Issue detail + threaded discussions + mr_list.rs # MR table + filter bar + mr_detail.rs # MR detail + diff notes + search.rs # Search input + results + preview + timeline.rs # Event stream + entity graph + who.rs # Expert map / workload / reviews + sync.rs # Inline-mode progress + log stream + summary + command_palette.rs # Modal overlay + common/ + mod.rs # Shared helpers + filter_bar.rs # Reusable filter input (ftui Forms) + entity_table.rs # Generic sortable/filterable table + discussion_tree.rs # ftui Tree wrapper for discussions + cross_ref.rs # Clickable entity references + action.rs # Async action runners (DB queries, GitLab calls) + navigation.rs # Screen stack + back/forward history + theme.rs # ftui Theme + AdaptiveColor configuration + safety.rs # sanitize_for_terminal(), safe_url_policy() + redact.rs # redact_sensitive(): strip tokens, Authorization headers, and credential patterns from logs and crash reports before persisting to disk + session.rs # Versioned session state persistence + corruption quarantine + entity_cache.rs # Bounded LRU cache for detail payloads (IssueDetail, MrDetail). Keyed by EntityKey. Invalidated on sync completion. Enables near-instant reopen during Enter/Esc drill-in/out workflows without re-querying. + crash_context.rs # Ring buffer of last 2000 normalized events + current screen/task snapshot for crash diagnostics. Captured by panic hook for post-mortem debugging. +``` + +### 4.2 Native Elm Architecture + +The key architectural advantage: FrankenTUI's runtime *is* the Elm loop. We implement the `Model` trait and the framework handles everything else. + +```mermaid +graph TD + A[Terminal Events] -->|ftui-core| B[ftui-runtime Event Loop] + B -->|Event → Msg| C[LoreApp::update] + C -->|new state| D[AppState] + C -->|Cmd side effects| E[Cmd Queue] + E -->|async execution| F[Action Runners] + F -->|result Msg| B + D --> G[LoreApp::view] + G -->|widget tree| H[ftui-render Frame] + H -->|buffer diff| I[ftui-render Presenter] + I -->|minimal ANSI| J[Terminal Output] + + style B fill:#2d2d2d,stroke:#666,color:#fff + style C fill:#ff6b6b,stroke:#333,color:#fff + style D fill:#51cf66,stroke:#333,color:#fff + style G fill:#ffd43b,stroke:#333,color:#000 + style I fill:#4a9eff,stroke:#333,color:#fff +``` + +**What the framework gives us for free:** +- Event polling thread management +- Buffer double-buffering and cell-level diffing +- ANSI escape sequence optimization +- Terminal state RAII (TerminalSession) +- Resize coalescing (BOCPD) +- Subscription management (timers, async streams) +- Panic recovery (terminal restored even on crash) + +### 4.3 Core Types + +```rust +// crates/lore-tui/src/message.rs + +use ftui_core::event::{Event, KeyEvent, KeyCode, KeyEventKind, Modifiers}; + +/// Every action the TUI can take, triggered by user input or async completion. +/// +/// IMPORTANT: FrankenTUI's Model trait requires `type Message: From`. +/// We implement `From` to convert raw terminal events into Msg variants, +/// then dispatch domain logic in `update()`. +#[derive(Debug, Clone)] +pub enum Msg { + // Framework events (converted via From) + RawEvent(Event), + Tick, + Resize { width: u16, height: u16 }, + + // Navigation + NavigateTo(Screen), + GoBack, + GoForward, + GoHome, + JumpBack, + JumpForward, + + // Command palette + OpenCommandPalette, + CloseCommandPalette, + CommandPaletteInput(String), + CommandPaletteSelect(usize), + + // Issue list + IssueListLoaded(Vec), + IssueListFilterChanged(IssueFilter), + IssueListSortChanged(SortField, SortOrder), + IssueSelected(EntityKey), + + // MR list + MrListLoaded(Vec), + MrListFilterChanged(MrFilter), + MrSelected(EntityKey), + + // Detail views + IssueDetailLoaded { key: EntityKey, detail: IssueDetail }, + MrDetailLoaded { key: EntityKey, detail: MrDetail }, + DiscussionsLoaded(Vec), + + // Search + SearchQueryChanged(String), + SearchRequestStarted { generation: u64, query: String }, + SearchExecuted { generation: u64, results: SearchResults }, + SearchResultSelected(usize), + SearchModeChanged(SearchMode), + /// Loaded on startup and after each sync. Determines which search modes + /// are available based on FTS index presence and embedding coverage. + SearchCapabilitiesLoaded(SearchCapabilities), + + // Timeline + TimelineLoaded(Vec), + TimelineEntitySelected(EntityRef), + + // Who + WhoResultLoaded(WhoResult), + WhoModeChanged(WhoMode), + + // Sync + SyncStarted, + SyncProgress(ProgressEvent), + SyncLogLine(String), + SyncBackpressureDrop, + SyncCompleted(SyncResult), + SyncCancelled, + SyncFailed(String), + /// Lightweight stream telemetry: dropped progress events and max queue depth. + /// Rendered in sync running-mode footer for operator visibility under backpressure. + SyncStreamStats { dropped_progress: u64, max_queue_depth: usize }, + + // Search debounce (timer-driven, replaces thread::sleep) + /// Arm debounce: runtime timer will fire SearchDebounceFired after 200ms. + /// If a newer keystroke arrives before the timer fires, the generation mismatch + /// causes the fired event to be silently dropped. + SearchDebounceArmed { generation: u64, query: String }, + /// Timer fired: if generation is still current, execute the search query. + SearchDebounceFired { generation: u64 }, + + // Dashboard + DashboardLoaded(DashboardData), + + // System + Error(AppError), + ShowHelp, + ShowCliEquivalent, + OpenInBrowser, + BlurTextInput, + ScrollToTopCurrentScreen, + Quit, +} + +/// Convert raw terminal events into our domain Msg type. +/// This is required by FrankenTUI's `Model` trait: `type Message: From`. +/// +/// DESIGN: We do NOT parse keybindings here. Raw events arrive as `Msg::RawEvent(event)`, +/// and `update()` dispatches them through `interpret_key()` which has access to full +/// app state (current screen, text focus, input_mode, etc.) to produce the correct Msg. +impl From for Msg { + fn from(event: Event) -> Self { + match event { + Event::Resize { width, height } => Msg::Resize { width, height }, + Event::Tick => Msg::Tick, + other => Msg::RawEvent(other), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Screen { + Dashboard, + IssueList, + IssueDetail(EntityKey), + MrList, + MrDetail(EntityKey), + Search, + Timeline, + Who, + Sync, + Stats, + Doctor, +} + +/// Composite key for entity identity across multi-project datasets. +/// Using bare `iid` is unsafe when multiple projects have overlapping IIDs. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct EntityKey { + pub project_id: i64, + pub iid: i64, + pub kind: EntityKind, +} + +impl EntityKey { + /// Construct an issue key. Preferred over raw struct construction + /// to enforce consistent entity identity at all call sites. + pub fn issue(project_id: i64, iid: i64) -> Self { + Self { project_id, iid, kind: EntityKind::Issue } + } + + /// Construct a merge request key. + pub fn mr(project_id: i64, iid: i64) -> Self { + Self { project_id, iid, kind: EntityKind::MergeRequest } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EntityKind { + Issue, + MergeRequest, +} + +/// Typed error categories for structured error handling in the TUI. +/// Enables context-aware error display (retry prompts, actionable suggestions) +/// rather than opaque string toasts. +#[derive(Debug, Clone)] +pub enum AppError { + /// SQLite busy/locked — suggest retry or check for running sync + DbBusy, + /// Database corruption detected + DbCorruption(String), + /// GitLab rate limited — show backoff timer + NetworkRateLimited { retry_after_secs: Option }, + /// Network unreachable + NetworkUnavailable, + /// GitLab auth failed — suggest token check + AuthFailed, + /// Data parse/transform error + ParseError(String), + /// Catch-all for unexpected errors + Internal(String), +} + +impl std::fmt::Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DbBusy => write!(f, "Database busy — another operation may be running"), + Self::DbCorruption(msg) => write!(f, "Database error: {msg}"), + Self::NetworkRateLimited { retry_after_secs: Some(s) } => + write!(f, "Rate limited — retry in {s}s"), + Self::NetworkRateLimited { retry_after_secs: None } => + write!(f, "Rate limited — please wait"), + Self::NetworkUnavailable => write!(f, "Network unavailable"), + Self::AuthFailed => write!(f, "Authentication failed — check GITLAB_TOKEN"), + Self::ParseError(msg) => write!(f, "Parse error: {msg}"), + Self::Internal(msg) => write!(f, "Error: {msg}"), + } + } +} +``` + +### 4.3.1 Input Mode State Machine + +```rust +/// Typed input mode — replaces ad-hoc boolean flags with explicit states. +/// Ensures deterministic key dispatch and clean timeout enforcement. +#[derive(Debug, Clone)] +pub enum InputMode { + /// Normal navigation mode — keys are dispatched to global/screen handlers. + Normal, + /// Text input is focused (filter bar, search box). Alphanumeric keys go to + /// the active text widget. Only Esc and Ctrl+P break out. + Text, + /// Command palette is open. All keys routed to palette widget. + Palette, + /// The `g` prefix key was pressed. Next key determines destination. + /// Timeout enforced by comparing `started_at` against `clock.now()` on each tick. + GoPrefix { started_at: std::time::Instant }, +} + +impl Default for InputMode { + fn default() -> Self { Self::Normal } +} +``` + +### 4.4 App — Implementing the Model Trait + +```rust +// crates/lore-tui/src/app.rs + +use ftui_runtime::program::{Model, Cmd, TaskSpec}; +use ftui_runtime::subscription::{Subscription, Every}; +use ftui_core::event::{Event, KeyEvent, KeyCode, KeyEventKind, Modifiers}; +use ftui_render::frame::Frame; +use rusqlite::Connection; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::core::config::Config; +use crate::tui::message::{Msg, Screen}; +use crate::tui::navigation::NavigationStack; +use crate::tui::state::AppState; + +pub struct LoreApp { + pub state: AppState, + pub navigation: NavigationStack, + pub config: Config, + /// Connection manager with read pool + dedicated writer. + /// Reads use pooled connections (no contention during sync). + /// Writes (sync) use a dedicated connection with WAL + busy_timeout. + pub db: Arc, + /// Injected clock for deterministic time. All relative-time rendering + /// ("3h ago") and go-prefix timeout enforcement uses this instead of + /// direct SystemTime::now() calls. FakeClock in tests ensures stable + /// snapshot output and consistent timestamps within a single frame. + pub clock: Arc, + /// Single source of truth for all actions, keybindings, CLI equivalents, + /// palette entries, help text, and status bar hints. All consumers + /// (execute_palette_action, ShowCliEquivalent, help overlay, status bar) + /// read from this registry — no hardcoded duplicate maps. + commands: CommandRegistry, + /// Explicit input mode state machine. Replaces ad-hoc boolean flags + /// (`pending_go`, `go_prefix_instant`) with a typed enum for deterministic + /// key dispatch and clean timeout enforcement. + /// + /// Transitions: + /// Normal → GoPrefix { started_at } on bare `g` press + /// GoPrefix → Normal on valid continuation key, 500ms timeout, or cancel + /// Normal → Text when filter bar / search input gains focus + /// Text → Normal on Esc or blur + /// Normal → Palette on Ctrl+P + /// Palette → Normal on selection or Esc + input_mode: InputMode, + /// Task supervisor: the SOLE path for launching background async work. + /// All deduplication, cancellation, and stale-response detection is + /// centralized here. No direct `Cmd::task` from screen handlers. + /// Replaces the former `search_request_id` field — generation IDs + /// from the supervisor subsume per-screen dedup counters. + task_supervisor: TaskSupervisor, + /// Bounded LRU cache for entity detail payloads (IssueDetail, MrDetail). + /// Keyed by EntityKey. Enables near-instant reopen during Enter/Esc + /// drill-in/out workflows without re-querying the database. + /// Invalidated on sync completion to ensure fresh data. + entity_cache: EntityCache, + /// Ring buffer of last 2000 normalized events + current screen/task snapshot. + /// Captured by panic hook for post-mortem crash diagnostics. + crash_context: Arc>, +} + +/// Manages separate read and write database connections. +/// Prevents TUI read queries from blocking on sync writes (and vice versa). +/// +/// SQLite WAL mode allows concurrent readers with a single writer. +/// Read connections are opened with `PRAGMA query_only = ON` for safety. +pub struct DbManager { + /// Pool of read-only connections for TUI queries. + /// Using a simple Vec> with round-robin selection. + readers: Vec>, + /// Dedicated writer connection for sync operations. + writer: Mutex, + /// Round-robin index for reader selection. + next_reader: AtomicUsize, +} + +impl DbManager { + pub fn new(db_path: &Path, reader_count: usize) -> Result { + let mut readers = Vec::with_capacity(reader_count); + for _ in 0..reader_count { + let conn = crate::core::db::create_connection(db_path)?; + conn.execute_batch("PRAGMA query_only = ON; PRAGMA busy_timeout = 5000;")?; + readers.push(Mutex::new(conn)); + } + let writer = crate::core::db::create_connection(db_path)?; + writer.execute_batch("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 10000;")?; + Ok(Self { + readers, + writer: Mutex::new(writer), + next_reader: AtomicUsize::new(0), + }) + } + + /// Execute a closure with a read-only connection (round-robin from pool). + /// Closure-based access prevents accidental lock scope expansion and + /// eliminates `expect("lock poisoned")` panics by converting to Result. + /// All query hot paths should use `prepare_cached()` within the closure + /// to reduce parse overhead on repeated queries. + pub fn with_reader(&self, f: impl FnOnce(&Connection) -> Result) -> Result { + let idx = self.next_reader.fetch_add(1, Ordering::Relaxed) % self.readers.len(); + let conn = self.readers[idx].lock() + .map_err(|e| LoreError::Internal(format!("reader lock poisoned: {e}")))?; + f(&conn) + } + + /// Execute a closure with the writer connection (for sync operations). + /// Same closure-based pattern as `with_reader` — lock scope is bounded + /// to the closure, preventing accidental long-held guards. + pub fn with_writer(&self, f: impl FnOnce(&Connection) -> Result) -> Result { + let conn = self.writer.lock() + .map_err(|e| LoreError::Internal(format!("writer lock poisoned: {e}")))?; + f(&conn) + } +} + +impl LoreApp { + pub fn new(config: Config, db: DbManager) -> Self { + Self { + state: AppState::default(), + navigation: NavigationStack::new(), + config, + db: Arc::new(db), + input_mode: InputMode::Normal, + task_supervisor: TaskSupervisor::new(), + entity_cache: EntityCache::new(128), // Bounded LRU: 128 detail payloads + crash_context: Arc::new(Mutex::new(CrashContext::new(2000))), + } + } + + /// Interpret a raw key event in the context of current app state. + /// Returns Some(Msg) if the key is handled, None to ignore. + /// + /// KEY DISPATCH ORDER: + /// 1. Pending `g` prefix completion + /// 2. Command palette intercept (Ctrl+P, Esc when open) + /// 3. Text input delegation (when filter bar / search has focus) + /// 4. Global shortcuts (q, H, Esc, g prefix start) + /// 5. Screen-local shortcuts (dashboard letters, list j/k, etc.) + fn interpret_key(&mut self, key: &KeyEvent) -> Option { + // Only handle Press events (ignore Repeat/Release) + if key.kind != KeyEventKind::Press { + return None; + } + + // 1. GoPrefix state — next key determines destination + if let InputMode::GoPrefix { .. } = &self.input_mode { + self.input_mode = InputMode::Normal; + return Some(match key.code { + KeyCode::Char('i') => Msg::NavigateTo(Screen::IssueList), + KeyCode::Char('m') => Msg::NavigateTo(Screen::MrList), + KeyCode::Char('/') => Msg::NavigateTo(Screen::Search), + KeyCode::Char('t') => Msg::NavigateTo(Screen::Timeline), + KeyCode::Char('w') => Msg::NavigateTo(Screen::Who), + KeyCode::Char('s') => Msg::NavigateTo(Screen::Sync), + KeyCode::Char('d') => Msg::NavigateTo(Screen::Doctor), + KeyCode::Char('g') => Msg::ScrollToTopCurrentScreen, // gg = jump to top of current list + _ => return None, // Unknown — cancel prefix silently + }); + } + + // 2. Command palette intercept + if key.code == KeyCode::Char('p') && key.modifiers.contains(Modifiers::CTRL) { + return Some(Msg::OpenCommandPalette); + } + if self.state.command_palette.is_visible() { + // Delegate all keys to palette when open + return None; // Palette's handle_event() is called separately + } + + // 3. Text input delegation — when filter bar or search input has focus + if self.state.has_text_focus() { + // Text-focused widgets handle their own input. + // Only Esc and Ctrl+P break out of text focus. + if key.code == KeyCode::Escape { + return Some(Msg::BlurTextInput); + } + return None; // Let widget handle_event() process it + } + + // 4. Global shortcuts (available when no text focus) + match (key.code, key.modifiers) { + (KeyCode::Char('q'), Modifiers::NONE) => return Some(Msg::Quit), + (KeyCode::Char('H'), Modifiers::NONE) => return Some(Msg::GoHome), + (KeyCode::Char('?'), Modifiers::NONE) => return Some(Msg::ShowHelp), + (KeyCode::Char('o'), Modifiers::NONE) => return Some(Msg::OpenInBrowser), + (KeyCode::Char('c'), m) if m.contains(Modifiers::CTRL) => { + return Some(Msg::Quit); + } + (KeyCode::Char('o'), m) if m.contains(Modifiers::CTRL) => { + return Some(Msg::JumpBack); + } + // NOTE: Ctrl+I collides with Tab in many terminals — use Alt+o instead. + (KeyCode::Char('o'), m) if m.contains(Modifiers::ALT) => { + return Some(Msg::JumpForward); + } + (KeyCode::Backspace, Modifiers::NONE) => { + return Some(Msg::GoBack); + } + (KeyCode::Char(':'), Modifiers::NONE) => { + return Some(Msg::ShowCliEquivalent); + } + (KeyCode::Escape, Modifiers::NONE) => { + return Some(Msg::GoBack); + } + _ => {} + } + + // g prefix start (not on Dashboard — Dashboard uses single-key shortcuts) + if key.code == KeyCode::Char('g') + && key.modifiers == Modifiers::NONE + && !matches!(self.navigation.current(), Screen::Dashboard) + { + self.input_mode = InputMode::GoPrefix { + started_at: self.clock.now_instant(), + }; + return Some(Msg::Tick); // Render go-prefix indicator + } + + // 5. Screen-local shortcuts (delegated to per-screen handler) + self.state.interpret_screen_key(self.navigation.current(), key) + } + + /// Dispatch a background query for the given screen. + /// Uses Cmd::task (blocking thread pool) since rusqlite is sync. + /// All read queries use db.with_reader() (closure-based pooled access). + fn load_screen(&self, screen: &Screen) -> Cmd { + let db = Arc::clone(&self.db); + match screen { + Screen::Dashboard => Cmd::task(move || { + match db.with_reader(|conn| crate::tui::action::fetch_dashboard(conn)) { + Ok(d) => Msg::DashboardLoaded(d), + Err(e) => Msg::Error(AppError::Internal(e.to_string())), + } + }), + Screen::IssueList => { + let filter = self.state.issue_list.current_filter(); + Cmd::task(move || { + match db.with_reader(|conn| crate::tui::action::fetch_issues(conn, &filter)) { + Ok(result) => Msg::IssueListLoaded(result), + Err(e) => Msg::Error(AppError::Internal(e.to_string())), + } + }) + } + Screen::IssueDetail(key) => { + // Check entity cache first — enables near-instant reopen + // during Enter/Esc drill-in/out workflows. + if let Some(cached) = self.entity_cache.get_issue(key) { + return Cmd::msg(Msg::IssueDetailLoaded { key: key.clone(), detail: cached.clone() }); + } + let key = key.clone(); + Cmd::task(move || { + match db.with_reader(|conn| crate::tui::action::fetch_issue_detail(conn, &key)) { + Ok(detail) => Msg::IssueDetailLoaded { key, detail }, + Err(e) => Msg::Error(AppError::Internal(e.to_string())), + } + }) + } + Screen::MrList => { + let filter = self.state.mr_list.current_filter(); + Cmd::task(move || { + match db.with_reader(|conn| crate::tui::action::fetch_mrs(conn, &filter)) { + Ok(result) => Msg::MrListLoaded(result), + Err(e) => Msg::Error(AppError::Internal(e.to_string())), + } + }) + } + Screen::MrDetail(key) => { + // Check entity cache first + if let Some(cached) = self.entity_cache.get_mr(key) { + return Cmd::msg(Msg::MrDetailLoaded { key: key.clone(), detail: cached.clone() }); + } + let key = key.clone(); + Cmd::task(move || { + match db.with_reader(|conn| crate::tui::action::fetch_mr_detail(conn, &key)) { + Ok(detail) => Msg::MrDetailLoaded { key, detail }, + Err(e) => Msg::Error(AppError::Internal(e.to_string())), + } + }) + } + Screen::Search => Cmd::none(), // Search loads on query, not on navigate + Screen::Timeline => { + let params = self.state.timeline.current_params(); + Cmd::task(move || { + match db.with_reader(|conn| crate::tui::action::fetch_timeline(conn, ¶ms)) { + Ok(result) => Msg::TimelineLoaded(result), + Err(e) => Msg::Error(AppError::Internal(e.to_string())), + } + }) + } + Screen::Who => { + let params = self.state.who.current_params(); + Cmd::task(move || { + match db.with_reader(|conn| crate::tui::action::fetch_who(conn, ¶ms)) { + Ok(result) => Msg::WhoResultLoaded(result), + Err(e) => Msg::Error(AppError::Internal(e.to_string())), + } + }) + } + Screen::Sync => Cmd::none(), // Sync starts on user action, not on navigate + _ => Cmd::none(), + } + } + + /// Map a command palette action ID to a navigation or system Msg. + /// Uses `CommandRegistry` as the single source of truth — the registry + /// resolves action_id to Msg based on the current screen context. + /// This eliminates the duplicated action mapping that previously existed + /// across execute_palette_action, ShowCliEquivalent, help overlay, and + /// status bar hints. All those consumers now read from commands.rs. + fn execute_palette_action(&self, action_id: &str) -> Cmd { + if let Some(spec) = self.commands.get(action_id) { + return self.update(spec.to_msg(self.navigation.current())); + } + Cmd::none() + } + + /// If the current screen has a text input that triggers a debounced query + /// (search bar, filter bar), schedule a background fetch after a short delay. + /// Returns Cmd::none() if no debounced query is needed. + /// + /// DEBOUNCE STRATEGY: Timer-driven debounce via runtime subscription messages. + /// Each keystroke arms a SearchDebounceArmed with the current generation. + /// The subscriptions() method registers a 200ms timer that fires SearchDebounceFired. + /// If a newer keystroke arrives before the timer fires, the generation mismatch + /// causes the fired event to be silently dropped — no wasted thread pool threads. + /// This replaces the previous thread::sleep(200ms) approach which wasted pool + /// threads under fast typing and reduced responsiveness under contention. + /// + /// Filter requery (IssueList/MrList) uses direct dispatch — no debounce needed + /// because filter changes are discrete user actions, not rapid keystrokes. + fn maybe_debounced_query(&mut self) -> Cmd { + match self.navigation.current() { + Screen::Search => { + let query = self.state.search.query_text().to_string(); + if query.len() < 2 { + return Cmd::none(); // Don't search for 1-char queries + } + // Arm debounce only — runtime timer will fire SearchDebounceFired. + // No thread pool thread is consumed during the debounce window. + let generation = self.task_supervisor.submit(TaskKey::Search).generation; + Cmd::msg(Msg::SearchDebounceArmed { generation, query }) + } + Screen::IssueList => { + let filter = self.state.issue_list.current_filter(); + let db = Arc::clone(&self.db); + Cmd::task(move || { + match db.with_reader(|conn| crate::tui::action::fetch_issues(conn, &filter)) { + Ok(result) => Msg::IssueListLoaded(result), + Err(e) => Msg::Error(AppError::Internal(e.to_string())), + } + }) + } + Screen::MrList => { + let filter = self.state.mr_list.current_filter(); + let db = Arc::clone(&self.db); + Cmd::task(move || { + match db.with_reader(|conn| crate::tui::action::fetch_mrs(conn, &filter)) { + Ok(result) => Msg::MrListLoaded(result), + Err(e) => Msg::Error(AppError::Internal(e.to_string())), + } + }) + } + _ => Cmd::none(), + } + } + + /// Launch a full sync pipeline as a background task. + /// Progress events stream back as SyncProgress/SyncLogLine messages via channel. + /// + /// STREAMING DESIGN: Uses an mpsc channel to send progress events back to + /// the runtime as individual Msgs. The sync thread sends SyncProgress and + /// SyncLogLine events as they occur. The runtime's subscription mechanism + /// drains the channel each tick and feeds events into update(). + /// + /// CANCELLATION: A cooperative cancel token (AtomicBool) is checked at page + /// boundaries, batch upsert boundaries, and before each network request — + /// not just between sync phases. UX target: cancel acknowledged <250ms, + /// sync stop p95 <2s after Esc. Pressing Esc during sync sets the token, and the next phase + /// boundary returns early with a SyncCancelled message. + fn start_sync_task(&self) -> Cmd { + let config = self.config.clone(); + let db = Arc::clone(&self.db); + let cancel_token = Arc::clone(&self.state.sync.cancel_token); + + // Use Cmd::stream if available (FrankenTUI v0.2+), otherwise fall back + // to Cmd::task with channel-based progress relay. + // Bounded channel with backpressure. The rx end is registered as a + // Subscription via Subscription::from_receiver("sync-stream", rx), + // which drains events into update() each tick. + let (tx, rx) = std::sync::mpsc::sync_channel::(2048); + // Register rx as a live subscription via Subscription::from_receiver("sync-stream", rx). + // Progress and logs MUST render in real time — no batch-at-end fallback. + // Bounded ring buffer (N=5000 lines). Progress events coalesced to max 30Hz + // per lane to reduce render pressure. Terminal events (SyncUiEvent::StreamClosed) + // are NEVER dropped — they are sent with blocking try_send retries to guarantee + // the UI always observes sync completion regardless of backpressure state. + + Cmd::task(move || { + // Stream stats for operator visibility under backpressure. + let mut dropped_count: u64 = 0; + let mut max_queue_depth: usize = 0; + + let result = db.with_writer(|conn| { + crate::ingestion::orchestrator::run_sync( + &config, + conn, + |event| { + // Check cancel token at each progress callback + if cancel_token.load(std::sync::atomic::Ordering::Relaxed) { + return; // Early exit — orchestrator handles partial state + } + // Track queue depth for stream stats + let current_depth = 2048 - tx.try_send(Msg::SyncProgress(event.clone())) + .err().map_or(0, |_| 1); + max_queue_depth = max_queue_depth.max(current_depth); + if tx.try_send(Msg::SyncProgress(event.clone())).is_err() { + // Channel full — drop this progress update rather than + // blocking the sync thread. Track for stats. + dropped_count += 1; + let _ = tx.try_send(Msg::SyncBackpressureDrop); + } + let _ = tx.try_send(Msg::SyncLogLine(format!("{event:?}"))); + }, + ) + }); + + // Emit stream stats for UI rendering + let _ = tx.try_send(Msg::SyncStreamStats { dropped_progress: dropped_count, max_queue_depth }); + + if cancel_token.load(std::sync::atomic::Ordering::Relaxed) { + return Msg::SyncCancelled; + } + + match result { + Ok(summary) => Msg::SyncCompleted(crate::tui::state::SyncSummary { + log_lines: Vec::new(), // Streamed individually + errors: 0, + duration_ms: summary.elapsed_ms, + }), + Err(e) => Msg::SyncFailed(e.to_string()), + } + }) + } + + /// Open the currently-focused entity in the system browser. + fn open_current_in_browser(&self) { + let url = match self.navigation.current() { + Screen::IssueDetail(iid) => { + self.state.issue_detail.detail.as_ref() + .map(|d| d.web_url.clone()) + } + Screen::MrDetail(iid) => { + self.state.mr_detail.detail.as_ref() + .map(|d| d.web_url.clone()) + } + Screen::IssueList => { + self.state.issue_list.selected_item() + .map(|item| item.web_url.clone()) + } + Screen::MrList => { + self.state.mr_list.selected_item() + .map(|item| item.web_url.clone()) + } + _ => None, + }; + + if let Some(url) = url { + // Validate URL host against configured GitLab instance(s) before opening. + // This prevents navigation to arbitrary URLs that might be injected via + // issue/MR descriptions or discussion bodies stored in the database. + if !is_allowed_gitlab_url(&url, &self.config) { + self.state.set_error("Blocked: URL does not match configured GitLab host".into()); + return; + } + // Fire-and-forget: open in background, ignore errors + #[cfg(target_os = "macos")] + let _ = std::process::Command::new("open").arg(&url).spawn(); + #[cfg(target_os = "linux")] + let _ = std::process::Command::new("xdg-open").arg(&url).spawn(); + } + } +} + +impl Model for LoreApp { + type Message = Msg; + + fn init(&mut self) -> Cmd { + // Load dashboard data on startup + self.load_screen(&Screen::Dashboard) + } + + fn update(&mut self, msg: Msg) -> Cmd { + match msg { + // --- Raw terminal events: interpret keys --- + Msg::RawEvent(Event::Key(key)) => { + // First: let command palette handle its own events when open + if self.state.command_palette.is_visible() { + if let Some(action) = self.state.command_palette.handle_event( + &Event::Key(key) + ) { + return match action { + PaletteAction::Execute(id) => { + self.state.command_palette.close(); + self.execute_palette_action(&id) + } + PaletteAction::Dismiss => { + self.state.command_palette.close(); + Cmd::none() + } + }; + } + return Cmd::none(); + } + + // Then: let text input widgets handle their own events + if self.state.has_text_focus() { + let handled = self.state.delegate_text_event( + self.navigation.current(), &Event::Key(key), + ); + if handled { + return self.maybe_debounced_query(); + } + } + + // Finally: interpret as domain action + match self.interpret_key(&key) { + Some(msg) => self.update(msg), // Re-enter update with domain Msg + None => Cmd::none(), + } + } + Msg::RawEvent(_) => Cmd::none(), // Mouse, Paste, Focus — ignored for now + + // --- Navigation --- + Msg::NavigateTo(screen) => { + self.state.set_loading(true); + self.navigation.push(screen.clone()); + self.load_screen(&screen) + } + Msg::GoBack => { + if let Some(_prev) = self.navigation.pop() { + // State is preserved — no reload needed + Cmd::none() + } else { + Cmd::none() + } + } + Msg::GoForward => { + if let Some(_next) = self.navigation.go_forward() { + Cmd::none() + } else { + Cmd::none() + } + } + Msg::JumpBack => { + if let Some(screen) = self.navigation.jump_back().cloned() { + self.navigation.reset_to(screen.clone()); + self.load_screen(&screen) + } else { + Cmd::none() + } + } + Msg::JumpForward => { + if let Some(screen) = self.navigation.jump_forward().cloned() { + self.navigation.reset_to(screen.clone()); + self.load_screen(&screen) + } else { + Cmd::none() + } + } + Msg::GoHome => { + self.navigation.reset_to(Screen::Dashboard); + self.load_screen(&Screen::Dashboard) + } + Msg::Quit => Cmd::quit(), + + // --- Command Palette --- + Msg::OpenCommandPalette => { + self.state.command_palette.open(); + Cmd::none() + } + + // --- Text Input --- + Msg::BlurTextInput => { + self.state.blur_text_focus(); + Cmd::none() + } + + // --- Data Loaded --- + Msg::DashboardLoaded(data) => { + self.state.set_loading(false); + self.state.dashboard.update(data); + Cmd::none() + } + Msg::IssueListLoaded(result) => { + self.state.set_loading(false); + self.state.issue_list.set_result(result); + Cmd::none() + } + Msg::IssueDetailLoaded { key, detail } => { + self.state.set_loading(false); + self.entity_cache.put_issue(key, detail.clone()); + self.state.issue_detail.set(detail); + Cmd::none() + } + Msg::MrListLoaded(result) => { + self.state.set_loading(false); + self.state.mr_list.set_result(result); + Cmd::none() + } + Msg::MrDetailLoaded { key, detail } => { + self.state.set_loading(false); + self.entity_cache.put_mr(key, detail.clone()); + self.state.mr_detail.set(detail); + Cmd::none() + } + Msg::SearchDebounceArmed { generation, query } => { + // Store debounce state; subscriptions() will register a 200ms timer. + self.state.search.arm_debounce(generation, query); + Cmd::none() + } + Msg::SearchDebounceFired { generation } => { + // Timer fired — check generation is still current before executing. + if !self.task_supervisor.is_current(&TaskKey::Search, generation) { + return Cmd::none(); // Superseded by newer keystroke + } + let query = self.state.search.query_text().to_string(); + let filters = self.state.search.current_filters(); + let mode = self.state.search.mode; + let db = Arc::clone(&self.db); + self.state.search.clear_debounce(); + Cmd::task(move || { + match db.with_reader(|conn| { + crate::tui::action::execute_search(conn, &query, &filters, mode) + }) { + Ok(results) => Msg::SearchExecuted { generation, results }, + Err(e) => Msg::Error(AppError::Internal(e.to_string())), + } + }) + } + Msg::SearchExecuted { generation, results } => { + // Drop stale results from superseded queries. + // Generation check via TaskSupervisor is the sole dedup mechanism. + if !self.task_supervisor.is_current(&TaskKey::Search, generation) { + return Cmd::none(); // Stale — ignore silently + } + self.state.set_loading(false); + self.state.search.set_results(results); + Cmd::none() + } + Msg::TimelineLoaded(result) => { + self.state.set_loading(false); + self.state.timeline.set_result(result); + Cmd::none() + } + Msg::WhoResultLoaded(result) => { + self.state.set_loading(false); + self.state.who.set_result(result); + Cmd::none() + } + + // --- Sync --- + Msg::SyncStarted => { + self.state.sync.start(); + self.start_sync_task() + } + Msg::SyncProgress(event) => { + self.state.sync.update_progress(event); + Cmd::none() + } + Msg::SyncLogLine(line) => { + self.state.sync.push_log(line); + Cmd::none() + } + Msg::SyncBackpressureDrop => { + // A progress event was dropped due to channel backpressure. + // This is expected under heavy sync load — the TUI will still + // receive the final completion message. No action needed. + Cmd::none() + } + Msg::SyncStreamStats { dropped_progress, max_queue_depth } => { + self.state.sync.update_stream_stats(dropped_progress, max_queue_depth); + Cmd::none() + } + Msg::SyncCompleted(result) => { + self.state.sync.complete(result); + // Invalidate entity cache — synced data may have changed. + self.entity_cache.invalidate_all(); + Cmd::none() + } + Msg::SyncFailed(err) => { + self.state.sync.fail(err); + Cmd::none() + } + + // --- System --- + Msg::Error(e) => { + self.state.set_loading(false); + self.state.set_error(e.to_string()); + Cmd::none() + } + Msg::ShowHelp => { + self.state.show_help = !self.state.show_help; + Cmd::none() + } + Msg::ShowCliEquivalent => { + // Show a toast with the equivalent CLI command for the current + // screen/action. Helps users learn the CLI and provides a fallback + // when the TUI is unavailable. + // CLI equivalents are stored in CommandRegistry — look up by + // current screen context to avoid hardcoded map duplication. + let cmd = self.commands.cli_equivalent_for(self.navigation.current()); + self.state.set_error(format!("CLI: {cmd}")); + Cmd::none() + } + Msg::OpenInBrowser => { + self.open_current_in_browser(); + Cmd::none() + } + Msg::Resize { width, height } => { + self.state.terminal_size = (width, height); + Cmd::none() + } + + // Screen-specific Msg variants (list scrolling, filter changes, etc.) + // are delegated to the relevant sub-state handler. + // State handlers are PURE (return ScreenIntent, not Cmd). + // All async work is launched here through the TaskSupervisor. + other => { + let intent = self.state.handle_screen_msg( + self.navigation.current(), + other, + ); + match intent { + ScreenIntent::None => Cmd::none(), + ScreenIntent::Navigate(screen) => self.update(Msg::NavigateTo(screen)), + ScreenIntent::RequeryNeeded(screen) => { + // Route re-query through TaskSupervisor for dedup + generation tracking + self.load_screen(&screen) + } + } + } + } + } + + fn view(&self, frame: &mut Frame) { + let area = frame.buffer.area(); + + // Three-row layout: breadcrumb | content | status bar + let layout = Flex::vertical() + .constraints([ + Constraint::Fixed(1), // breadcrumb + Constraint::Fill, // main content + Constraint::Fixed(1), // status bar + ]) + .split(area); + + let header = layout[0]; + let body = layout[1]; + let footer = layout[2]; + + // Breadcrumb bar (shows full navigation path) + crate::tui::view::common::render_breadcrumb( + &self.navigation, frame, header, + ); + + // Always render active screen content — stale data stays visible during refresh. + // Per-screen LoadState enables stale-while-revalidate: screens keep showing + // their last data during background refresh with a lightweight spinner overlay. + { + match self.navigation.current() { + Screen::Dashboard => { + crate::tui::view::dashboard::render( + &self.state.dashboard, frame, body, + ); + } + Screen::IssueList => { + crate::tui::view::issue_list::render( + &self.state.issue_list, frame, body, + ); + } + Screen::IssueDetail(_) => { + crate::tui::view::issue_detail::render( + &self.state.issue_detail, frame, body, + ); + } + Screen::MrList => { + crate::tui::view::mr_list::render( + &self.state.mr_list, frame, body, + ); + } + Screen::MrDetail(_) => { + crate::tui::view::mr_detail::render( + &self.state.mr_detail, frame, body, + ); + } + Screen::Search => { + crate::tui::view::search::render( + &self.state.search, frame, body, + ); + } + Screen::Timeline => { + crate::tui::view::timeline::render( + &self.state.timeline, frame, body, + ); + } + Screen::Who => { + crate::tui::view::who::render( + &self.state.who, frame, body, + ); + } + Screen::Sync => { + crate::tui::view::sync::render( + &self.state.sync, frame, body, + ); + } + _ => {} + } + } + + // Lightweight refresh indicator overlay (non-blocking). + // Shows a small spinner/badge when current screen's LoadState is Refreshing, + // without obscuring the stale data underneath. + crate::tui::view::common::render_refresh_indicator( + self.navigation.current(), &self.state.load_state, frame, body, + ); + + // Status bar with context-sensitive keybinding hints + crate::tui::view::common::render_status_bar( + self.navigation.current(), &self.input_mode, frame, footer, + ); + + // Command palette modal overlay (renders on top of everything) + if self.state.command_palette.is_visible() { + self.state.command_palette.render(area, frame); + } + + // Error toast overlay + if let Some(error) = &self.state.error_toast { + crate::tui::view::common::render_error_toast(error, frame, area); + } + + // Help overlay + if self.state.show_help { + crate::tui::view::common::render_help_overlay( + self.navigation.current(), frame, area, + ); + } + } + + fn subscriptions(&self) -> Vec>> { + let mut subs: Vec>> = Vec::new(); + + // Dashboard auto-refresh: tick every 60s when on Dashboard + if matches!(self.navigation.current(), Screen::Dashboard) { + subs.push(Box::new( + Every::new(Duration::from_secs(60), || Msg::NavigateTo(Screen::Dashboard)) + )); + } + + // Spinner animation: tick every 100ms when any screen is loading + if self.state.load_state.any_loading() { + subs.push(Box::new( + Every::with_id(1, Duration::from_millis(100), || Msg::Tick) + )); + } + + // Go-prefix timeout enforcement: tick even when nothing is loading. + // Without this, GoPrefix mode can get "stuck" when idle (no other + // events to drive the Tick that checks the 500ms timeout). + if matches!(self.input_mode, InputMode::GoPrefix { .. }) { + subs.push(Box::new( + Every::with_id(2, Duration::from_millis(50), || Msg::Tick) + )); + } + + // Search debounce timer: fires SearchDebounceFired after 200ms. + // Only active when a debounce is pending (armed by keystroke). + if self.state.search.debounce_pending() { + let generation = self.state.search.debounce_generation(); + subs.push(Box::new( + Every::with_id(3, Duration::from_millis(200), move || { + Msg::SearchDebounceFired { generation } + }) + )); + } + + subs + } +} +``` + +### 4.5 Async Action System + +```mermaid +graph LR + subgraph "ftui-runtime Event Loop" + A[Model::update] -->|returns Cmd| B[Cmd Executor] + E[Msg Channel] --> A + end + + subgraph "Background Tasks (spawned by Cmd::task)" + B -->|Cmd::task| C[DB Query] + B -->|Cmd::task| D[GitLab Sync] + B -->|Cmd::task| F[Embed Pipeline] + C -->|returns Msg| E + D -->|returns Msg| E + F -->|returns Msg| E + end + + style A fill:#ff6b6b,stroke:#333,color:#fff + style E fill:#4a9eff,stroke:#333,color:#fff + style C fill:#51cf66,stroke:#333,color:#fff + style D fill:#51cf66,stroke:#333,color:#fff + style F fill:#51cf66,stroke:#333,color:#fff +``` + +The `Cmd::task(|| { ... })` pattern runs a blocking closure on a background thread pool. The runtime feeds the returned `Msg` back into `update()`. This is a **blocking** model, not async — which is perfect for us because `rusqlite::Connection` is sync. + +**Snapshot consistency for detail fetches:** All detail fetches (`issue_detail`, `mr_detail`, timeline expansion) run inside a single read transaction to guarantee snapshot consistency across subqueries. Without this, concurrent sync writes can cause mixed-state reads where header metadata comes from one point in time but discussion counts come from another. + +```rust +/// Wrap multiple queries in a single read transaction for snapshot consistency. +/// Detail screens combine 3-5 subqueries (metadata, discussions, cross-refs, +/// events) that must observe the same database state. +pub fn with_read_snapshot( + conn: &Connection, + f: impl FnOnce(&rusqlite::Transaction<'_>) -> Result, +) -> Result { + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?; + let result = f(&tx)?; + tx.commit()?; + Ok(result) +} +``` + +**Query interruption:** Long-running queries register interrupt checks tied to `CancelToken` to avoid >1s uninterruptible stalls during rapid navigation/filtering. When the user navigates away from a detail screen before queries complete, the cancel token fires `sqlite3_interrupt()` on the connection. + +#### 4.5.1 Task Supervisor (Dedup + Cancellation + Priority) + +Background tasks are managed by a `TaskSupervisor` that prevents redundant work, enables cooperative cancellation, and uses generation IDs for late-result detection across all task types (not just search): + +```rust +// crates/lore-tui/src/task_supervisor.rs + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +/// Keys for task deduplication. Only one task per key can be active at a time. +/// Submitting a new task with an existing key cancels the previous one. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TaskKey { + /// Screen data load — deduplicated per screen. + LoadScreen(Screen), + /// Search query — deduplicated as a single active task. Generation ID + /// for stale result detection is tracked separately via TaskSupervisor::next_generation(), + /// NOT embedded in the key. Embedding generation in the key defeats dedup by making + /// every submission unique. + Search, + /// Sync pipeline — only one sync at a time. + SyncStream, + /// Filter requery — deduplicated per screen. + FilterRequery(Screen), +} + +/// Priority lanes for task scheduling. Higher priority tasks are dispatched +/// first when the thread pool is contended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum TaskPriority { + /// User-initiated input response (filter changes, navigation). + Input = 0, + /// Screen navigation loads. + Navigation = 1, + /// Background work (sync, embed, dashboard refresh). + Background = 2, +} + +/// Cooperative cancellation token. Checked by long-running tasks between +/// phases to allow early exit without killing threads. +pub struct CancelToken { + cancelled: AtomicBool, +} + +impl CancelToken { + pub fn new() -> Arc { + Arc::new(Self { cancelled: AtomicBool::new(false) }) + } + pub fn cancel(&self) { self.cancelled.store(true, Ordering::Relaxed); } + pub fn is_cancelled(&self) -> bool { self.cancelled.load(Ordering::Relaxed) } +} + +/// Returned by `TaskSupervisor::submit()` — a unified handle containing the +/// task's identity, generation ID for stale-result detection, and cancel token. +/// Callers use `handle.generation` for result freshness checks and +/// `handle.cancel` for cooperative cancellation. +pub struct TaskHandle { + pub key: TaskKey, + pub generation: u64, + pub cancel: Arc, +} + +/// The TaskSupervisor manages active tasks, deduplicates by key, and tracks +/// generation IDs for late-arriving result detection. The unified `submit()` +/// API returns a `TaskHandle` combining key, generation, and cancel token — +/// eliminating the previous API inconsistency between `register()`/`next_generation()`. +pub struct TaskSupervisor { + /// Active task handles keyed by TaskKey. When a new task supersedes + /// an existing one, the old token is cancelled before the new handle is stored. + active: HashMap, + /// Monotonic generation counter for stale result detection. + generation: AtomicU64, +} + +impl TaskSupervisor { + pub fn new() -> Self { + Self { + active: HashMap::new(), + generation: AtomicU64::new(0), + } + } + + /// Submit a new task. Cancels any existing task with the same key. + /// Returns a TaskHandle with key, generation, and cancel token. + /// This is the SOLE entry point for task registration — no separate + /// register()/next_generation() calls needed. + pub fn submit(&mut self, key: TaskKey) -> TaskHandle { + if let Some(old_handle) = self.active.get(&key) { + old_handle.cancel.cancel(); + } + let generation = self.generation.fetch_add(1, Ordering::Relaxed); + let cancel = CancelToken::new(); + let handle = TaskHandle { + key: key.clone(), + generation, + cancel: Arc::clone(&cancel), + }; + self.active.insert(key, TaskHandle { + key: handle.key.clone(), + generation, + cancel, + }); + handle + } + + /// Check if a generation is still current for a given key. + /// Used by update() to drop stale results from superseded tasks. + pub fn is_current(&self, key: &TaskKey, generation: u64) -> bool { + self.active.get(key) + .map_or(false, |h| h.generation == generation) + } + + /// Remove a completed task's handle. + pub fn complete(&mut self, key: &TaskKey, generation: u64) { + if self.is_current(key, generation) { + self.active.remove(key); + } + } +} +``` + +The supervisor is owned by `LoreApp` and is the **ONLY allowed path for background work**, enforced by architecture: + +- **State handlers (`AppState::handle_screen_msg`) return `ScreenIntent`, not `Cmd`.** They have no access to `DbManager` or `Cmd::task`. This makes it structurally impossible for screen state code to bypass the supervisor. +- **`LoreApp::update()` converts `ScreenIntent` into supervised `Cmd::task` calls** via `TaskSupervisor::register(key)`, which enforces dedup, cancellation tokening, and generation checks. +- **Direct `Cmd::task` calls are confined to `LoreApp` methods** (`load_screen`, `start_sync_task`, `maybe_debounced_query`). The supervisor is the single chokepoint for all background work. + +The `search_request_id` field in `LoreApp` is superseded by `TaskSupervisor::submit().generation` — all task types (search, filter requery, screen load) benefit from the same generation-based staleness detection. + +**Key implication:** We use `DbManager` with a pool of read-only connections and a dedicated writer. Closure-based access (`db.with_reader()`, `db.with_writer()`) prevents accidental lock scope expansion and eliminates `expect("lock poisoned")` panics. Query hot paths use `prepare_cached()` within closures to reduce parse overhead. All connections use `std::sync::Mutex` (not `tokio::sync::Mutex`). SQLite WAL mode allows concurrent readers alongside a single writer. + +```rust +// crates/lore-tui/src/action.rs + +use rusqlite::Connection; +use crate::tui::state::dashboard::DashboardData; +use crate::core::error::LoreError; + +/// Pure query functions — no I/O, no framework dependency. +/// These run on the Cmd::task blocking thread pool. + +pub fn fetch_dashboard(conn: &Connection) -> Result { + let issues_open = count_by_state(conn, "issues", "opened")?; + let issues_total = count_total(conn, "issues")?; + let mrs_open = count_by_state(conn, "merge_requests", "opened")?; + let mrs_total = count_total(conn, "merge_requests")?; + let discussions = count_total(conn, "discussions")?; + let notes = count_total(conn, "notes")?; + let documents = count_total(conn, "documents")?; + let embeddings = count_total(conn, "embeddings")?; + let recent = fetch_recent_activity(conn, 10)?; + let projects = fetch_project_sync_status(conn)?; + + Ok(DashboardData { + counts: EntityCounts { + issues_open, + issues_total, + mrs_open, + mrs_total, + discussions, + notes_total: notes, + notes_system_pct: 0, // computed separately + documents, + embeddings, + }, + recent, + projects, + }) +} + +pub fn fetch_issues( + conn: &Connection, + filter: &IssueFilter, +) -> Result, LoreError> { + // Reuse existing query logic from cli/commands/list.rs + crate::cli::commands::list::query_issues(conn, filter) +} + +pub fn fetch_issue_detail( + conn: &Connection, + key: &EntityKey, +) -> Result { + crate::cli::commands::show::query_issue_detail(conn, key.project_id, key.iid) +} + +// ... same pattern for all screens +``` + +### 4.6 Navigation Architecture + +Navigation uses a **stack with global shortcuts**, designed around five principles: + +1. **Spatial consistency** — the same key always means the same thing in the same context +2. **Minimal keystrokes** — the most common operations require the fewest keys +3. **State preservation** — returning to a screen restores cursor position, filter state, and scroll offset exactly +4. **No dead ends** — every screen has a clear way out (Esc, H, or `g` prefix) +5. **Progressive disclosure** — new users find features via status bar hints and command palette; power users learn `g` prefix shortcuts + +```mermaid +stateDiagram-v2 + [*] --> Dashboard + + Dashboard --> IssueList: i + Dashboard --> MrList: m + Dashboard --> Search: / + Dashboard --> Timeline: t + Dashboard --> Who: w + Dashboard --> Sync: s + Dashboard --> Doctor: d + + IssueList --> IssueDetail: Enter + IssueDetail --> IssueList: Esc + IssueDetail --> MrDetail: cross-ref Enter + IssueDetail --> Timeline: t (scoped to entity) + + MrList --> MrDetail: Enter + MrDetail --> MrList: Esc + MrDetail --> IssueDetail: cross-ref Enter + + Search --> IssueDetail: Enter (issue hit) + Search --> MrDetail: Enter (MR hit) + + Timeline --> IssueDetail: Enter (issue event) + Timeline --> MrDetail: Enter (MR event) + + Who --> IssueList: Enter (person's issues) + Who --> MrList: Enter (person's MRs) + + note right of Dashboard + Global shortcuts from ANY screen: + H = Home (Dashboard, resets stack) + gi = Issues, gm = MRs + g/ = Search, gt = Timeline + gw = Who, gs = Sync + Ctrl+P = Command Palette + q = Quit + end note + + note left of IssueDetail + Esc always goes back one level. + State is preserved on return. + end note +``` + +**How `g` prefix works:** Press `g`, then the destination key within 500ms. A brief `g--` indicator appears in the status bar. If no valid key follows, the prefix cancels silently. This mirrors vim's `g` prefix behavior. + +**How `H` works:** Clears the entire navigation stack and resets to Dashboard. This is the "escape hatch" when you're deep in cross-reference chains and want a clean slate. + +**State preservation guarantee:** When navigating back via Esc, the screen's full state is preserved — cursor position, filter text, selected item, scroll offset. The state tree is never cleared on pop; it only refreshes on explicit re-entry via NavigateTo. + +### 4.7 Navigation Stack Implementation + +```rust +// crates/lore-tui/src/navigation.rs + +use super::message::Screen; + +pub struct NavigationStack { + back_stack: Vec, + current: Screen, + forward_stack: Vec, + /// Jump list: records entity/detail navigation hops for Ctrl+O/Ctrl+I. + /// Unlike back/forward which track ALL navigation, the jump list only + /// records "significant" hops (detail views, cross-references, search results). + /// This mirrors vim's jump list behavior. + jump_list: Vec, + jump_index: usize, +} + +impl NavigationStack { + pub fn new() -> Self { + Self { + back_stack: Vec::new(), + current: Screen::Dashboard, + forward_stack: Vec::new(), + jump_list: vec![Screen::Dashboard], + jump_index: 0, + } + } + + pub fn current(&self) -> &Screen { + &self.current + } + + pub fn is_at(&self, screen: &Screen) -> bool { + &self.current == screen + } + + pub fn push(&mut self, screen: Screen) { + self.back_stack.push(self.current.clone()); + self.forward_stack.clear(); // Forward history lost on new navigation + self.current = screen.clone(); + + // Record significant hops in jump list + if screen.is_detail_or_entity() { + self.jump_list.truncate(self.jump_index + 1); + self.jump_list.push(screen); + self.jump_index = self.jump_list.len() - 1; + } + } + + pub fn pop(&mut self) -> Option { + if let Some(prev) = self.back_stack.pop() { + self.forward_stack.push(self.current.clone()); + let old = self.current.clone(); + self.current = prev; + Some(old) + } else { + None // Can't pop past the first screen + } + } + + /// Go forward (redo) in navigation history (browser-like). + pub fn go_forward(&mut self) -> Option { + if let Some(next) = self.forward_stack.pop() { + self.back_stack.push(self.current.clone()); + self.current = next.clone(); + Some(next) + } else { + None + } + } + + /// Jump backward in the jump list (Ctrl+O, like vim). + /// Only moves through significant entity hops. + pub fn jump_back(&mut self) -> Option<&Screen> { + if self.jump_index > 0 { + self.jump_index -= 1; + Some(&self.jump_list[self.jump_index]) + } else { + None + } + } + + /// Jump forward in the jump list (Ctrl+I, like vim). + pub fn jump_forward(&mut self) -> Option<&Screen> { + if self.jump_index + 1 < self.jump_list.len() { + self.jump_index += 1; + Some(&self.jump_list[self.jump_index]) + } else { + None + } + } + + pub fn reset_to(&mut self, screen: Screen) { + self.back_stack.clear(); + self.forward_stack.clear(); + self.current = screen; + } + + pub fn breadcrumbs(&self) -> Vec<&str> { + let mut crumbs: Vec<&str> = self.back_stack.iter().map(|s| s.label()).collect(); + crumbs.push(self.current.label()); + crumbs + } + + pub fn depth(&self) -> usize { + self.back_stack.len() + 1 + } +} + +impl Screen { + pub fn label(&self) -> &str { + match self { + Screen::Dashboard => "Dashboard", + Screen::IssueList => "Issues", + Screen::IssueDetail(_) => "Issue", + Screen::MrList => "MRs", + Screen::MrDetail(_) => "MR", + Screen::Search => "Search", + Screen::Timeline => "Timeline", + Screen::Who => "Who", + Screen::Sync => "Sync", + Screen::Stats => "Stats", + Screen::Doctor => "Doctor", + } + } + + /// Returns true for screens that represent "significant" navigation hops + /// worth recording in the jump list. Detail views and search/timeline + /// are significant; list views and dashboard are not. + pub fn is_detail_or_entity(&self) -> bool { + matches!(self, + Screen::IssueDetail(_) + | Screen::MrDetail(_) + | Screen::Search + | Screen::Timeline + ) + } +} +``` + +### 4.8 Data Flow + +```mermaid +sequenceDiagram + participant U as User + participant RT as ftui-runtime + participant M as LoreApp (Model) + participant CMD as Cmd::task + participant DB as SQLite + + U->>RT: Press 'i' + RT->>M: Event::Key('i') + M->>M: translate_event → NavigateTo(IssueList) + M->>M: update() → push nav stack + M-->>CMD: Cmd::task(fetch_issues) + CMD->>DB: SELECT from issues WHERE... + DB-->>CMD: Vec + CMD-->>RT: Msg::IssueListLoaded(rows) + RT->>M: update(IssueListLoaded) + M->>M: state.issue_list.set_items(rows) + RT->>M: view(frame) + M-->>U: Rendered issue table +``` + +--- + +## 5. Screen Taxonomy + +### 5.1 Dashboard (Home Screen) + +``` +┌─ lore ─────────────────────────────────────────────────────────┐ +│ Dashboard Ctrl+P Help │ +├────────────────────────────┬───────────────────────────────────┤ +│ Projects (3) │ Quick Stats │ +│ ├─ vs/platform ✓ 2m │ Issues: 1,247 open / 3,891 tot │ +│ ├─ vs/mobile-app ✓ 5m │ MRs: 89 open / 412 tot │ +│ └─ vs/infra ⚠ 2h │ Discuss: 14,293 threads │ +│ │ Notes: 52,841 (12% system) │ +├────────────────────────────┤ Docs: 4,132 indexed │ +│ Last Sync │ Embeds: 3,891 vectors │ +│ Started: 2h 14m ago ├───────────────────────────────────┤ +│ Duration: 3m 42s │ Recent Activity │ +│ Issues: +12 new, 5 upd │ ┌─ #1247 Fix auth timeout 12m │ +│ MRs: +3 new, 8 upd │ ├─ !456 Add caching layer 45m │ +│ Discussions: +89 new │ ├─ #1245 DB migration fail 1h │ +│ Events: +234 │ ├─ !453 Refactor search 2h │ +│ Errors: 0 │ └─ #1244 Update deps 3h │ +├────────────────────────────┴───────────────────────────────────┤ +│ Actionable Insights │ +│ 1) 7 opened P1 issues >14d without update │ +│ 2) 3 MRs blocked by unresolved discussions │ +│ 3) auth/ has +42% note velocity this week │ +│ Enter on insight → jump to pre-filtered list/detail │ +├────────────────────────────────────────────────────────────────┤ +│ [i]ssues [m]rs [/]search [t]imeline [w]ho [s]ync [d]octor [q]│ +└────────────────────────────────────────────────────────────────┘ +``` + +**Data source:** `lore count` + `lore sync-status` + recent issues/MRs query + insight queries +**Update frequency:** On entry + every 60s tick +**Interaction:** Single-key navigation to all major screens; Enter on insight rows jumps to pre-filtered view + +**Actionable Insights panel:** Ranked heuristic-driven signals that surface what needs attention: +- Stale P1/P2 issues (opened > N days without update) +- MRs blocked by unresolved discussion threads +- Directories/labels with unusual activity velocity (spikes or drops) +- Issues approaching due date with open blockers + +Insights are computed from local data during dashboard load. Each insight row is selectable — pressing Enter navigates to the relevant list screen with filters pre-applied (e.g., stale P1s → Issue List filtered to `state:opened label:P1 since:14d sort:updated`). + +### 5.2 Issue List + +``` +┌─ lore > Issues ────────────────────────────────────────────────┐ +│ Filter: state:opened author:_ label:_ since:_ [Tab]edit │ +├──────┬──────────────────────────────────┬────────┬─────────────┤ +│ IID │ Title │ State │ Updated │ +├──────┼──────────────────────────────────┼────────┼─────────────┤ +│▶1247 │ Fix authentication timeout │ opened │ 12 min ago │ +│ 1245 │ Database migration failure on .. │ opened │ 1 hour ago │ +│ 1244 │ Update third-party dependencies │ opened │ 3 hours ago │ +│ 1243 │ Add rate limiting to public API │ opened │ 5 hours ago │ +│ 1241 │ Memory leak in worker process │ opened │ 1 day ago │ +│ 1239 │ Dark mode color contrast issues │ opened │ 2 days ago │ +│ 1237 │ Refactor notification system │ opened │ 3 days ago │ +│ 1235 │ Add telemetry dashboard │ opened │ 4 days ago │ +│ 1233 │ Fix flaky test in auth suite │ closed │ 5 days ago │ +│ 1231 │ Support SAML SSO integration │ opened │ 1 week ago │ +│ ... │ │ │ │ +├──────┴──────────────────────────────────┴────────┴─────────────┤ +│ 1/50 ↑↓ navigate Enter detail / filter o open Tab sort │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Data source:** `lore issues` query against SQLite +**Columns:** Configurable — iid, title, state, author, labels, milestone, updated_at +**Sorting:** Click column header or Tab to cycle (iid, updated, created) +**Filtering:** Interactive filter bar with field:value syntax +**Pagination:** Windowed keyset pagination with explicit cursor state. The list state maintains `window` (current visible rows), `next_cursor` / `prev_cursor` (keyset boundary values for forward/back navigation), `prefetching` flag (background fetch of next window in progress), and a fixed `window_size` (default 200 rows). First paint uses current window only; no full-result materialization. Virtual scrolling within the window for smooth UX. When the user scrolls past ~80% of the window, the next window is prefetched in the background. + +### 5.3 Issue Detail + +``` +┌─ lore > Issues > #1247 ───────────────────────────────────────┐ +│ Fix authentication timeout state: opened │ +│ Author: @asmith Assignee: @bjones Labels: bug, auth, P1 │ +│ Milestone: v2.3 Created: 2026-02-08 Due: 2026-02-15 │ +│ Project: vs/platform │ +├────────────────────────────────────────────────────────────────┤ +│ ┌─ Description ──────────────────────────────────────────────┐ │ +│ │ Users are experiencing authentication timeouts when the │ │ +│ │ session token refresh happens during a concurrent API │ │ +│ │ call. The race condition causes the refresh to fail and │ │ +│ │ the user gets logged out. │ │ +│ │ │ │ +│ │ Steps to reproduce: │ │ +│ │ 1. Login and wait for token to near expiry │ │ +│ │ 2. Trigger multiple API calls simultaneously │ │ +│ │ 3. Observe 401 errors and forced logout │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Discussions (4 threads) ──────────────────────────────────┐ │ +│ │ ▼ Thread 1 — @asmith (3 replies) 2d ago │ │ +│ │ I've narrowed this down to the TokenRefreshService. │ │ +│ │ The mutex isn't being held across the full refresh cy.. │ │ +│ │ ├─ @bjones: Can you check if the retry logic in... 1d │ │ +│ │ ├─ @asmith: Yes, confirmed. The retry doesn't ch... 1d │ │ +│ │ └─ @clee: I have a fix in !458, PTAL 12h │ │ +│ │ │ │ +│ │ ▶ Thread 2 — @dkim (1 reply) 1d ago │ │ +│ │ ▶ Thread 3 — @system (label added: P1) 23h ago │ │ +│ │ ▶ Thread 4 — @asmith (0 replies) 12h ago │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Cross-References ─────────────────────────────────────────┐ │ +│ │ Closes: !458 (Fix token refresh race condition) opened │ │ +│ │ Related: #1198 (Session management rework) closed │ │ +│ └────────────────────────────────────────────────────────────┘ │ +├────────────────────────────────────────────────────────────────┤ +│ Esc back o open in browser t timeline Enter cross-ref │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Data source (progressive hydration):** +1. **Phase 1 (first paint):** metadata/header — issue title, state, labels, assignees, milestones, due date. Renders immediately from the issues table. +2. **Phase 2:** discussions summary + cross-references. Loaded asynchronously; skeleton placeholders shown until ready. +3. **Phase 3:** full thread bodies loaded on demand when the user expands a collapsed discussion thread. + +This 3-phase approach keeps first-paint fast (p95 < 75ms on M-tier fixtures) even for issues with hundreds of discussion notes. + +**Sections:** Collapsible (description, discussions, cross-refs) +**Discussion threads:** Expand/collapse with arrow keys; full body loaded lazily on first expand +**Cross-reference navigation:** Enter on a ref → navigate to that entity +**Scrolling:** Vim-style (j/k) or arrow keys through content + +### 5.4 MR List + +Identical structure to Issue List with MR-specific columns: + +| Column | Description | +|--------|-------------| +| IID | Merge request number | +| Title | MR title (draft prefix if WIP) | +| State | opened/merged/closed/locked | +| Draft | Draft indicator | +| Source | Source branch | +| Target | Target branch | +| Author | MR author | +| Updated | Relative time | + +**Pagination:** Same windowed keyset pagination strategy as Issue List (window=200, background prefetch). + +**Additional filters:** `--draft`, `--no-draft`, `--target-branch`, `--source-branch`, `--reviewer` + +### 5.5 MR Detail + +``` +┌─ lore > MRs > !458 ──────────────────────────────────────────┐ +│ Fix token refresh race condition state: opened │ +│ Author: @clee Reviewers: @asmith, @bjones Labels: fix, auth │ +│ Source: fix/token-refresh → Target: main │ +│ Pipeline: ✓ passed Conflicts: none │ +├────────────────────────────────────────────────────────────────┤ +│ ┌─ Description ──────────────────────────────────────────────┐ │ +│ │ Fixes #1247. Holds the refresh mutex across the full │ │ +│ │ token lifecycle including retry. │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ File Changes (3 files) ───────────────────────────────────┐ │ +│ │ M src/auth/token_service.rs +12 -3 │ │ +│ │ M src/auth/middleware.rs +5 -2 │ │ +│ │ A tests/auth/token_refresh_test.rs +45 │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Diff Discussions (2 threads) ─────────────────────────────┐ │ +│ │ ▼ @asmith on src/auth/token_service.rs:45 12h ago │ │ +│ │ "Should we add a timeout to the mutex lock here?" │ │ +│ │ └─ @clee: Good point, added in latest push 6h │ │ +│ │ │ │ +│ │ ▶ @bjones on src/auth/middleware.rs:23 10h ago │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ General Discussions (1 thread) ───────────────────────────┐ │ +│ │ ▶ Thread 1 — @asmith: LGTM, approved 6h ago │ │ +│ └────────────────────────────────────────────────────────────┘ │ +├────────────────────────────────────────────────────────────────┤ +│ Esc back o open in browser Enter cross-ref f file changes │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Unique features (progressive hydration):** +- **File change summary** in first paint (filename + diffstat from MR metadata) +- **Diff discussion bodies** loaded lazily per expanded thread +- **Separate sections** for diff discussions vs general discussions +- First paint shows file changes list and discussion count; full thread content loads on expand + +### 5.6 Search + +``` +┌─ lore > Search ───────────────────────────────────────────────┐ +│ Query: authentication timeout_ │ +│ Mode: [hybrid] Type: [all] Since: [any] Project: [all] │ +├───────────────────────────────────┬────────────────────────────┤ +│ Results (24 matches, 42ms) │ Preview │ +│ ┌────────────────────────────┐ │ ┌──────────────────────┐ │ +│ │▶ #1247 Fix auth timeout │ │ │ Fix authentication │ │ +│ │ issue · vs/platform │ │ │ timeout │ │ +│ │ Score: 0.94 (BM25+vec) │ │ │ │ │ +│ │ │ │ │ Users are experienc.. │ │ +│ │ !458 Fix token refresh │ │ │ ...session token │ │ +│ │ mr · vs/platform │ │ │ refresh happens │ │ +│ │ Score: 0.87 │ │ │ during a concurrent │ │ +│ │ │ │ │ API call... │ │ +│ │ #1198 Session mgmt │ │ │ │ │ +│ │ issue · vs/platform │ │ │ Labels: bug, auth, P1 │ │ +│ │ Score: 0.72 │ │ │ Author: @asmith │ │ +│ │ │ │ │ Updated: 12 min ago │ │ +│ │ Discussion on !412 │ │ │ │ │ +│ │ discussion · vs/platform │ │ │ ── Snippet ── │ │ +│ │ Score: 0.68 │ │ │ "...the [auth] │ │ +│ │ │ │ │ [timeout] occurs │ │ +│ │ ... │ │ │ when the token..." │ │ +│ └────────────────────────────┘ │ └──────────────────────┘ │ +├───────────────────────────────────┴────────────────────────────┤ +│ ↑↓ select Enter open Tab switch mode / refine Esc back │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Layout:** Split pane — results list (left) + preview (right) +**Search modes:** Toggle between lexical / hybrid / semantic +**Capability-aware mode availability:** On startup and after each sync, the TUI probes search capabilities (FTS index presence, embedding coverage). If embeddings are unavailable or stale (coverage < 50%), semantic mode is disabled with an inline reason badge: "semantic unavailable: no embeddings". Hybrid mode auto-falls back to lexical-only and shows a status badge: "hybrid (semantic unavailable)". This prevents silent degradation that causes "why are results weird?" confusion and builds trust in the search system. +**Filters:** Type (issue/mr/discussion), project, author, label, since +**Live preview:** Selected result shows snippet + metadata in right pane +**Explain score:** Optional breakdown (lexical weight, semantic similarity, recency boost, field boosts) toggled with `e` key. Helps users understand why results ranked as they did and builds trust in hybrid search. Displayed as a small footer section in the preview pane. +**Debounced input:** 200ms debounce before executing search + +### 5.7 Timeline + +``` +┌─ lore > Timeline ─────────────────────────────────────────────┐ +│ Query: token refresh_ Depth: 1 Since: 30d│ +├────────────────────────────────────────────────────────────────┤ +│ Seeds: #1247, !458, #1198 Events: 47 │ +├────────────────────────────────────────────────────────────────┤ +│ Timeline │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 2026-02-08 09:14 CREATED #1247 Fix auth timeout │ │ +│ │ by @asmith in vs/platform │ │ +│ │ │ │ +│ │ 2026-02-08 09:30 LABEL #1247 +bug +auth │ │ +│ │ by @asmith │ │ +│ │ │ │ +│ │ 2026-02-08 14:22 NOTE #1247 "I've narrowed this │ │ +│ │ down to the TokenRefreshService..." │ │ +│ │ by @asmith │ │ +│ │ │ │ +│ │ 2026-02-09 10:00 CREATED !458 Fix token refresh race │ │ +│ │ by @clee (closes #1247) │ │ +│ │ │ │ +│ │ 2026-02-09 10:05 XREF !458 → #1247 (closes) │ │ +│ │ │ │ +│ │ 2026-02-09 16:30 LABEL #1247 +P1 │ │ +│ │ by @lead │ │ +│ │ │ │ +│ │ 2026-02-10 08:00 NOTE !458 "@asmith PTAL" │ │ +│ │ by @clee [src/auth/token_service.rs] │ │ +│ │ │ │ +│ │ 2026-02-10 14:00 STATE !458 approved │ │ +│ │ by @asmith │ │ +│ └──────────────────────────────────────────────────────────┘ │ +├────────────────────────────────────────────────────────────────┤ +│ ↑↓ scroll Enter entity d depth s since / search Esc back │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Data source:** Timeline pipeline (SEED → HYDRATE → EXPAND → COLLECT → RENDER) +**Event types:** Color-coded by type (CREATED=green, STATE=yellow, LABEL=cyan, NOTE=white, XREF=magenta, MERGED=green+bold) +**Interaction:** Enter on any event → navigate to the parent entity +**Controls:** Adjust depth (BFS expansion), since window, max seeds + +### 5.8 People Explorer (Who) + +``` +┌─ lore > Who ──────────────────────────────────────────────────┐ +│ Mode: [Expert] Path: src/auth/_ Since: 6m │ +├────────────────────────────────────────────────────────────────┤ +│ Expert Rankings for src/auth/ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Rank │ Person │ Score │ Authored │ Reviewed │ Notes │ │ +│ │ ─────┼──────────┼───────┼──────────┼──────────┼──────── │ │ +│ │▶ 1 │ @asmith │ 142 │ 8 MRs │ 5 MRs │ 23 │ │ +│ │ 2 │ @bjones │ 89 │ 3 MRs │ 12 MRs │ 15 │ │ +│ │ 3 │ @clee │ 67 │ 5 MRs │ 2 MRs │ 8 │ │ +│ │ 4 │ @dkim │ 34 │ 1 MR │ 4 MRs │ 5 │ │ +│ │ 5 │ @epark │ 21 │ 2 MRs │ 1 MR │ 3 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ @asmith — Detail │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Top code areas: │ │ +│ │ src/auth/token_service.rs ████████████░ 45% │ │ +│ │ src/auth/middleware.rs ██████░░░░░░░ 22% │ │ +│ │ src/auth/session.rs ████░░░░░░░░░ 15% │ │ +│ │ src/auth/oauth.rs ██░░░░░░░░░░░ 8% │ │ +│ │ other ██░░░░░░░░░░░ 10% │ │ +│ └──────────────────────────────────────────────────────────┘ │ +├────────────────────────────────────────────────────────────────┤ +│ Tab mode Enter person MRs / path r reviews a active │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Modes (Tab to cycle):** + +| Mode | Description | Data Source | +|------|-------------|-------------| +| Expert | Path-based expert ranking | `lore who ` | +| 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 ` | + +### 5.9 Sync + +The Sync screen has two modes: **running** (progress + log) and **summary** (post-sync change report). + +**Running mode:** + +``` +┌─ lore > Sync ─────────────────────────────────────────────────┐ +│ Status: Syncing... Elapsed: 1m 23s │ +├────────────────────────────────────────────────────────────────┤ +│ vs/platform │ +│ ├─ Issues ████████████████████████████████░░ 89% +12 │ +│ ├─ MRs ████████████████████████████████████ 100% +3 │ +│ ├─ Discussions ██████████████░░░░░░░░░░░░░░░░░░ 42% +89 │ +│ └─ Events ████████████████████░░░░░░░░░░░░ 58% +234 │ +│ │ +│ vs/mobile-app │ +│ ├─ Issues ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% queued │ +│ ├─ MRs ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% queued │ +│ ├─ Discussions ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% queued │ +│ └─ Events ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% queued │ +│ │ +│ vs/infra │ +│ └─ (queued) │ +├────────────────────────────────────────────────────────────────┤ +│ Log ──────────────────────────────────────────────────────────│ +│ │ [12:01:23] Fetching issues page 4/5 for vs/platform │ +│ │ [12:01:24] Upserting 20 issues (12 new, 8 updated) │ +│ │ [12:01:25] Fetching discussions for 12 issues │ +│ │ [12:01:26] Rate limit: 28/30 req/s, backing off 200ms │ +│ │ [12:01:27] Fetching resource events page 1/? for vs/plat │ +│ │ │ +├────────────────────────────────────────────────────────────────┤ +│ Esc cancel f full sync e embed after d dry-run l log level stats:drop=0 qmax=0 │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Summary mode (after sync completes):** + +``` +┌─ lore > Sync ─────────────────────────────────────────────────┐ +│ Status: Complete Duration: 3m 42s │ +├────────────────────────────────────────────────────────────────┤ +│ What Changed │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ +12 new issues 5 updated (i to browse) │ │ +│ │ +3 new MRs 8 updated (m to browse) │ │ +│ │ +89 new discussions │ │ +│ │ +234 new events │ │ +│ │ 0 errors │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ Log ──────────────────────────────────────────────────────────│ +│ │ [12:05:05] Sync complete: 3m 42s total │ │ +│ │ [12:05:04] Embedded 89 new documents (1.2s) │ │ +│ │ [12:05:02] Processed 234 resource events │ │ +│ │ [12:05:00] Fetched 89 discussions (3 projects) │ │ +│ │ │ +├────────────────────────────────────────────────────────────────┤ +│ i new issues m new MRs r restart f full sync Esc back │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Running mode:** +- Progress bars per project per resource +- **Per-project fault isolation:** If one project/resource lane fails, it is marked degraded while other lanes continue syncing. Failed lanes are retried on next sync or via explicit `[R]esume`. +- Log viewer with streaming output and explicit stream-finalization state (FrankenTUI `ScreenMode::Inline` preserves scrollback) +- `f` = toggle full sync, `e` = toggle embed after, `d` = dry-run, `l` = log level, `Esc` = cancel + +**Summary mode:** +- Shows delta counts (new, updated) for each entity type +- `i` navigates to Issue List pre-filtered to "since last sync" (using `sync_status.last_completed_at` timestamp comparison) +- `m` navigates to MR List pre-filtered to "since last sync" (using `sync_status.last_completed_at` timestamp comparison) +- `r` restarts sync + +### 5.10 Command Palette (Overlay) + +``` +┌─────────────────────────────────────────────────────┐ +│ > sync fu_ │ +├─────────────────────────────────────────────────────┤ +│ ▶ Sync (full) Reset cursors and re-sync │ +│ Sync Incremental sync │ +│ Sync (no embed) Skip embedding step │ +│ Search "fu" Search for "fu" in docs │ +└─────────────────────────────────────────────────────┘ +``` + +**Trigger:** `Ctrl+P` from any screen +**Behavior:** Fuzzy match against all commands + recent entities +**Commands mapped:** + +| Palette Entry | Action | +|---------------|--------| +| Issues | Navigate to Issue List | +| Issues (opened) | Issue List pre-filtered | +| Issues (closed) | Issue List pre-filtered | +| Merge Requests | Navigate to MR List | +| MRs (opened) | MR List pre-filtered | +| MRs (merged) | MR List pre-filtered | +| MRs (draft) | MR List pre-filtered | +| Search | Navigate to Search | +| Timeline | Navigate to Timeline | +| Who (expert) | Who → Expert mode | +| Who (workload) | Who → Workload mode | +| Who (active) | Who → Active mode | +| Sync | Start incremental sync | +| Sync (full) | Full sync with cursor reset | +| Sync (no embed) | Sync without embedding | +| Doctor | Run health check | +| Stats | Show index statistics | +| Open in browser | Open current entity in GitLab | +| Quit | Exit TUI | + +### 5.11 Doctor / Stats (Info Screens) + +**Stats:** Simple read-only view rendering the output of `lore stats` as styled text blocks. No complex interaction — just scrollable content with Esc to go back. + +**Doctor:** Interactive health check view with actionable suggestions: +- Shows health checks with severity indicators (pass/warn/fail) +- Exposes the `actions` array from robot-mode `lore --robot doctor` errors as selectable suggestion rows +- Each suggestion shows the remediation command (e.g., `lore init`, `lore migrate`, `lore sync --full`) +- Enter on a suggestion copies the command to clipboard (or displays it in a toast for manual execution) +- Does NOT auto-execute commands — the user always runs them manually for safety +- Scrollable with j/k, Esc to go back + +--- + +## 6. User Flows + +### 6.1 Flow: "Find who knows about auth code" + +```mermaid +graph TD + A[Dashboard] -->|w| B[Who Screen] + B -->|type 'src/auth/'| C[Expert Mode Results] + C -->|↓ select @asmith| D[Detail Panel Shows Code Areas] + D -->|Enter| E[Issue List filtered by @asmith] + E -->|↓ select #1247| F[Issue Detail] + F -->|scroll to discussions| G[Read discussion threads] + G -->|Enter on cross-ref !458| H[MR Detail] + H -->|o| I[Opens in browser] + + style A fill:#2d2d2d,stroke:#666,color:#fff + style C fill:#4a9eff,stroke:#333,color:#fff + style F fill:#51cf66,stroke:#333,color:#fff + style H fill:#ffd43b,stroke:#333,color:#000 +``` + +**Keystrokes:** `w` → type `src/auth/` → `↓` → `Enter` → `↓` → `Enter` → `↓↓↓` → `Enter` → `o` +**Total:** 8 actions to go from "who knows about auth" to opening the relevant MR in browser. +**CLI equivalent:** `lore who src/auth/` → read output → `lore who @asmith` → read → `lore issues 1247` → read → `lore mrs 458` → `lore mrs 458 --open` — 6 separate commands, ~200 characters of typing. + +### 6.2 Flow: "What happened with the auth timeout this week?" + +```mermaid +graph TD + A[Dashboard] -->|t| B[Timeline Screen] + B -->|type 'auth timeout'| C[Timeline Seeds: #1247, !458] + C -->|scroll through events| D[See chronological story] + D -->|Enter on CREATED !458| E[MR Detail] + E -->|Esc| D + D -->|Enter on NOTE evidence| F[Issue Detail at discussion] + + style A fill:#2d2d2d,stroke:#666,color:#fff + style C fill:#4a9eff,stroke:#333,color:#fff + style D fill:#51cf66,stroke:#333,color:#fff +``` + +**Keystrokes:** `t` → type query → `Enter` → scroll → `Enter` → `Esc` → scroll +**Value:** The timeline view reconstructs the full narrative that would require 5+ separate CLI commands. + +### 6.3 Flow: "Quick search for something I vaguely remember" + +```mermaid +graph TD + A[Any Screen] -->|Ctrl+P or /| B[Search Screen] + B -->|type query| C[Live Results + Preview] + C -->|Tab| D[Switch to semantic mode] + D -->|↑↓ browse| E[Preview updates in real-time] + E -->|Enter| F[Entity Detail View] + + style B fill:#4a9eff,stroke:#333,color:#fff + style C fill:#51cf66,stroke:#333,color:#fff + style F fill:#ffd43b,stroke:#333,color:#000 +``` + +### 6.4 Flow: "Sync and see what changed" + +```mermaid +graph TD + A[Dashboard] -->|s| B[Sync Screen] + B -->|Enter to start| C[Progress Bars + Log Stream] + C -->|sync completes| D[Summary: +12 issues, +3 MRs, ...] + D -->|i| E[Issue List filtered to new since sync] + D -->|m| F[MR List filtered to new since sync] + E -->|↓ select| G[Issue Detail of new item] + + style B fill:#4a9eff,stroke:#333,color:#fff + style C fill:#ff6b6b,stroke:#333,color:#fff + style D fill:#51cf66,stroke:#333,color:#fff + style E fill:#ffd43b,stroke:#333,color:#000 +``` + +**Key improvement:** After sync completes, the screen transitions to a change summary showing exactly what's new. Pressing `i` or `m` navigates to filtered lists showing only items from this sync, bridging the gap between "syncing data" and "consuming it." + +### 6.5 Flow: "Review someone's open work" + +```mermaid +graph TD + A[Dashboard] -->|w| B[Who Screen] + B -->|Tab to Workload| C[Workload Mode] + C -->|type '@bjones'| D[Workload Summary] + D -->|see assigned issues| E[Issue section] + D -->|see authored MRs| F[MR section] + D -->|see review queue| G[Review section] + E -->|Enter| H[Issue Detail] + F -->|Enter| I[MR Detail] + + style D fill:#4a9eff,stroke:#333,color:#fff + style E fill:#51cf66,stroke:#333,color:#fff + style F fill:#51cf66,stroke:#333,color:#fff + style G fill:#51cf66,stroke:#333,color:#fff +``` + +### 6.6 Flow: Command Palette Power Usage + +```mermaid +graph TD + A[Any Screen] -->|Ctrl+P| B[Command Palette Opens] + B -->|type 'mrs draft'| C[Fuzzy matches 'MRs draft'] + C -->|Enter| D[MR List pre-filtered: draft=true] + D -->|Ctrl+P again| E[Palette Opens] + E -->|type 'sync'| F[Fuzzy matches Sync commands] + F -->|↓ select 'Sync full'| G[Sync Screen starts full sync] + + style B fill:#ffd43b,stroke:#333,color:#000 + style C fill:#4a9eff,stroke:#333,color:#fff + style E fill:#ffd43b,stroke:#333,color:#000 +``` + +### 6.7 Flow: "Morning triage scan" + +```mermaid +graph TD + A[Dashboard] -->|i| B[Issue List auto: opened, sort by updated] + B -->|j/k scan titles| C[Cursor on #1247] + C -->|Enter| D[Issue Detail #1247] + D -->|Esc| C + C -->|j| E[Cursor on #1245] + E -->|Enter| F[Issue Detail #1245] + F -->|Esc| E + E -->|j j j| G[Continue scanning...] + + style A fill:#2d2d2d,stroke:#666,color:#fff + style B fill:#4a9eff,stroke:#333,color:#fff + style D fill:#51cf66,stroke:#333,color:#fff + style F fill:#51cf66,stroke:#333,color:#fff +``` + +**Keystrokes:** `i` → `j/k` to scan → `Enter` to peek → `Esc` to return → continue scanning +**State preservation:** After pressing Esc from Issue Detail, the cursor returns to exactly the same row in the list. Filter state and scroll offset are preserved. This tight Enter/Esc loop is the most common daily workflow. + +### 6.8 Flow: "Jump between screens without returning to Dashboard" + +```mermaid +graph TD + A[Issue Detail #1247] -->|gt| B[Timeline scoped to #1247] + B -->|gw| C[Who Screen] + C -->|type 'src/auth/'| D[Expert Rankings] + D -->|gi| E[Issue List] + E -->|H| F[Dashboard clean slate] + + style A fill:#51cf66,stroke:#333,color:#fff + style B fill:#4a9eff,stroke:#333,color:#fff + style C fill:#ffd43b,stroke:#333,color:#000 + style E fill:#ff6b6b,stroke:#333,color:#fff + style F fill:#2d2d2d,stroke:#666,color:#fff +``` + +**Demonstrates:** The `g` prefix allows direct screen-to-screen navigation without returning to Dashboard first. Each `g` shortcut pushes onto the stack, so Esc traces back through the chain. When the stack gets deep, `H` resets to Dashboard for a clean slate. + +### 6.9 Flow: "Risk-first morning sweep" + +```mermaid +graph TD + A[Dashboard] -->|scroll to Insights| B[Actionable Insights panel] + B -->|Enter on 'P1 issues >14d'| C[Issue List pre-filtered: P1 + stale] + C -->|j/k scan| D[Review stale issues] + D -->|Esc back to Dashboard| E[Dashboard] + E -->|Enter on 'MRs blocked'| F[MR List pre-filtered: unresolved threads] + F -->|Enter on !458| G[MR Detail: see blocking discussion] + + style A fill:#2d2d2d,stroke:#666,color:#fff + style B fill:#ffd43b,stroke:#333,color:#000 + style C fill:#4a9eff,stroke:#333,color:#fff + style F fill:#ff6b6b,stroke:#333,color:#fff +``` + +**Demonstrates:** Dashboard insights as a decision surface. Instead of browsing all issues, the user starts from ranked signals — stale P1s, blocked MRs, activity spikes — and jumps directly to pre-filtered views. This turns the dashboard from a metrics screen into a triage launcher. + +--- + +## 7. Widget Inventory + +### 7.1 FrankenTUI Built-in Widgets Used + +| Widget | Module | Usage | Screen(s) | +|--------|--------|-------|-----------| +| `Table` | `ftui_widgets::table` | Issue/MR lists, expert rankings, file changes | IssueList, MrList, Who, MrDetail | +| `Paragraph` | `ftui_widgets::paragraph` | Descriptions, discussion bodies, log output | IssueDetail, MrDetail, Sync | +| `Block` | `ftui_widgets::block` | Section borders with titles | All | +| `Panel` | `ftui_widgets::panel` | Border + title + padding (richer than Block) | Detail views | +| `ProgressBar` | `ftui_widgets::progress` | Sync progress per resource | Sync | +| `List` | `ftui_widgets::list` | Search results, timeline events | Search, Timeline | +| `VirtualizedList` | `ftui_widgets::virtualized` | Large dataset scrolling | IssueList, Timeline | +| `Tree` | `ftui_widgets::tree` | Discussion thread hierarchy | IssueDetail, MrDetail | +| `CommandPalette` | `ftui_widgets::command_palette` | Fuzzy command overlay | Global overlay | +| `Modal` | `ftui_widgets::modal` | Help overlay, confirmation dialogs | Global | +| `LogViewer` | `ftui_widgets::log_viewer` | Streaming log during sync | Sync | +| `TextInput` | `ftui_widgets::input` | Filter bar, search query input | Lists, Search | +| `Scrollbar` | `ftui_widgets::scrollbar` | Long content navigation | Detail views | +| `Spinner` | `ftui_widgets::spinner` | Loading indicators | All (during data fetch) | +| `Toast` | `ftui_widgets::toast` | Error/success notifications | Global | +| `Badge` | `ftui_widgets::badge` | State pills, label tags, breadcrumb segments | Detail views, nav | +| `StatusLine` | `ftui_widgets::status_line` | Keybinding hints | Footer | +| `JsonView` | (extras) | Raw JSON display for doctor/stats | Doctor, Stats | + +### 7.2 FrankenTUI Extras Used + +| Feature | Module | Usage | +|---------|--------|-------| +| `charts` | `ftui_extras::charts` | Code area distribution bar chart | Who (expert detail) | +| `markdown` | `ftui_extras::markdown` | Render issue/MR descriptions | Detail views | +| `forms` | `ftui_extras::forms` | Filter bar with structured fields | Lists, Search | +| ~~`validation`~~ | ~~`ftui_extras::validation`~~ | **NOT YET IMPLEMENTED** — declared but empty upstream | ~~Lists~~ | +| ~~`help`~~ | ~~`ftui_extras::help`~~ | **NOT YET IMPLEMENTED** — declared but empty upstream | ~~Global~~ | +| `theme` | `ftui_extras::theme` | Color themes with presets | Global | +| `logging` | `ftui_extras::logging` | TUI-aware tracing subscriber | Sync | + +### 7.3 Custom Widgets (Only 1 Needed) + +| Widget | Purpose | Complexity | +|--------|---------|------------| +| `CrossRefLink` | Styled entity reference with navigation | Low (~30 lines) | + +--- + +## 8. Keybinding Reference + +> **Implementation note:** All keybinding, help overlay, status bar hint, and command palette definitions are generated from `commands.rs` (the `CommandRegistry`). No hardcoded duplicate maps exist in view or state modules. To add or change a binding, update the registry — all consumers reflect the change automatically. + +### 8.1 Global (Available Everywhere) + +| Key | Action | +|-----|--------| +| `q` | Quit TUI | +| `Esc` | Go back / close overlay | +| `Backspace` | Go back (when not in text input) | +| `H` | Home — jump to Dashboard (resets nav stack) | +| `g` + key | Go-prefix: `gi` Issues, `gm` MRs, `g/` Search, `gt` Timeline, `gw` Who, `gs` Sync | +| `Ctrl+P` | Open command palette | +| `?` | Show help overlay | +| `o` | Open current entity in browser | +| `:` | Show equivalent CLI command for current screen/action | +| `Ctrl+O` | Jump backward in jump list (entity hops) | +| `Alt+o` | Jump forward in jump list (entity hops) | +| `Ctrl+R` | Reset session state for current screen (clear filters, scroll to top) | +| `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` `g` | Jump to top | +| `Tab` / `f` | Focus filter bar | +| `/` | Focus filter bar (alias) | +| `S` | Cycle sort column | +| `r` | Refresh data | +| `[` / `]` | Previous/next page | + +**Keybinding precedence (deterministic dispatch order):** +1. Modal/palette — when command palette is open, it consumes all input +2. Focused text input — filter bar / search box consume alphanumeric keys; only `Esc` and `Ctrl+P` break out +3. Global shortcuts — `q`, `H`, `?`, `o`, `Ctrl+C`, `Ctrl+P`, `Esc`, `g` prefix +4. Screen-local shortcuts — per-screen key handlers (the table above) + +**Go-prefix timeout:** 500ms from first `g` press, enforced by `InputMode::GoPrefix { started_at }` state checked on each tick via `clock.now_instant()`. If no valid continuation key arrives within 500ms, the prefix cancels and a brief "g--" flash clears from the status bar. The tick subscription compares the injected Clock's current instant against `started_at` — no separate timer task needed. Using `InputMode` instead of ad-hoc boolean flags makes the state machine explicit and deterministic. Feedback is immediate — the status bar shows "g--" within the same frame as the keypress. + +**Terminal keybinding safety notes:** +- `Ctrl+I` is NOT used — it is indistinguishable from `Tab` in most terminals (both send `\x09`). Jump-forward uses `Alt+o` instead. +- `Ctrl+H` is NOT used — it collides with `Backspace` in many terminals (both send `\x08`). +- `Ctrl+M` is NOT used — it collides with `Enter` in most terminals (both send `\x0D`). + +**Key normalization:** The `interpret_key()` function applies lightweight normalization before dispatch to handle common terminal encoding differences: +- Backspace variants (`^H` / `\x7F` / `DEL`) normalized to a single `KeyCode::Backspace` +- Alt/Meta prefix encoding differences (ESC+key vs 8th-bit) normalized to `Modifiers::ALT` +- Shift+Tab normalized to `KeyCode::BackTab` regardless of terminal encoding + +This prevents "works in iTerm, broken in tmux" bugs. FrankenTUI's `ftui-core` event parser handles much of this, but we add a thin normalization pass for the remaining edge cases not covered upstream. + +### 8.3 Detail Screens (Issue Detail, MR Detail) + +| Key | Action | +|-----|--------| +| `j` / `↓` | Scroll down | +| `k` / `↑` | Scroll up | +| `Space` | Page down | +| `b` | Page up | +| `d` | Toggle discussion collapse/expand | +| `Enter` | Follow cross-reference link | +| `t` | Open timeline scoped to this entity | + +### 8.4 Dashboard + +| Key | Action | +|-----|--------| +| `i` | Go to Issues | +| `m` | Go to MRs | +| `/` | Go to Search | +| `t` | Go to Timeline | +| `w` | Go to Who | +| `s` | Go to Sync | +| `d` | Run Doctor | +| `r` | Refresh dashboard | + +### 8.5 Who Screen + +| Key | Action | +|-----|--------| +| `Tab` | Cycle mode (Expert → Workload → Reviews → Active → Overlap) | +| `Enter` | Open person's issues/MRs | +| `r` | Switch to Reviews sub-view | +| `a` | Switch to Active sub-view | + +### 8.6 Sync Screen + +| Key | Action (running) | Action (summary) | +|-----|------------------|-------------------| +| `Enter` | Start sync (if not started) | — | +| `Esc` | Cancel running sync | Go back to previous screen | +| `f` | Toggle full sync mode | Restart as full sync | +| `e` | Toggle embed after sync | — | +| `d` | Toggle dry-run | — | +| `l` | Cycle log verbosity | — | +| `i` | — | Browse new issues (filtered) | +| `m` | — | Browse new MRs (filtered) | +| `r` | — | Restart sync | + +--- + +## 9. Implementation Plan + +### 9.1 Dependency Changes + +```toml +# Root Cargo.toml changes +[workspace] +members = ["."] +exclude = ["crates/lore-tui"] + +# Keep lore-tui EXCLUDED from root workspace (nightly isolation boundary). +# NO feature gate for TUI — lore has zero compile-time dependency on lore-tui. +# The `lore tui` subcommand resolves and spawns the lore-tui binary at runtime. + +# KEEP (existing, used by CLI): +console = "0.16" # Still used by non-TUI CLI output +indicatif = "0.18" # Still used by non-TUI CLI progress bars +comfy-table = "7" # Still used by non-TUI CLI tables +dialoguer = "0.12" # Still used by lore init + +# crates/lore-tui/Cargo.toml +[package] +name = "lore-tui" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "lore-tui" +path = "src/main.rs" + +[dependencies] +lore = { path = "../.." } # Core lore library (as library, not binary) +ftui = { git = "https://github.com/Dicklesworthstone/frankentui.git", rev = "TBD" } +ftui-extras = { git = "https://github.com/Dicklesworthstone/frankentui.git", rev = "TBD", features = [ + "charts", + "forms", + "theme", + "markdown", + "logging", +] } +rusqlite = { version = "0.31", features = ["bundled"] } +unicode-segmentation = "1" # Grapheme cluster segmentation for safe text truncation +clap = { version = "4", features = ["derive"] } # CLI arg parsing for lore-tui binary + +# NOTE: ftui-extras "validation" and "help" features are declared in Cargo.toml +# but the modules are NOT YET IMPLEMENTED upstream. Omit until available. +# We build our own help overlay and validate filter input with simple match logic. +``` + +### 9.2 Phases + +```mermaid +gantt + title TUI Implementation Phases (FrankenTUI) + dateFormat YYYY-MM-DD + axisFormat %b %d + + section Phase 0 — Toolchain Gate + Add rust-toolchain.toml (nightly) :p0a, 2026-02-15, 1d + Add ftui dependency, verify build :p0b, after p0a, 1d + Terminal compat smoke test :p0c, after p0b, 1d + + section Phase 1 — Foundation + LoreApp impl Model + event loop :p1a, after p0c, 2d + Theme (ftui ThemeBuilder) :p1b, after p1a, 1d + Navigation stack + breadcrumb :p1c, after p1a, 1d + Status bar (ftui StatusLine) :p1d, after p1c, 1d + + section Phase 2 — Core Screens + Dashboard :p2a, after p1d, 2d + Issue List + filter (ftui Forms) :p2b, after p2a, 2d + Issue Detail + tree discussions :p2c, after p2b, 3d + MR List :p2d, after p2b, 1d + MR Detail + diff discussions :p2e, after p2d, 2d + + section Phase 2.5 — Vertical Slice Gate + Dashboard+IssueList+IssueDetail+Sync integrated :p25a, after p2c, 3d + Gate: p95 nav <75ms M-tier; 0 stuck-input; cancel p95 <2s :p25b, after p25a, 1d + + section Phase 3 — Power Features + Search (hybrid + preview) :p3a, after p25b, 2d + Timeline (VirtualizedList) :p3b, after p3a, 2d + Who (all 5 modes + charts) :p3c, after p3b, 3d + Command Palette (ftui built-in) :p3d, after p2a, 1d + + section Phase 4 — Operations + Sync (Inline mode + LogViewer) :p4a, after p3c, 2d + Doctor + Stats (JsonView) :p4b, after p4a, 1d + CLI integration (lore tui) :p4c, after p4b, 1d + + section Phase 5 — Polish + Responsive breakpoints (native) :p5a, after p4c, 1d + Snapshot tests (ftui-harness) :p5b, after p5a, 2d + Terminal compat test matrix :p5c, after p5b, 2d + + section Phase 5.5 — Reliability Test Pack + Stale response drop tests :p55a, after p5c, 1d + Sync cancel/resume tests :p55b, after p55a, 1d + SQLITE_BUSY retry tests :p55c, after p55a, 0.5d + Resize storm + rapid keypress :p55d, after p55b, 0.5d + Property tests for nav invariants :p55e, after p55c, 1d + Performance benchmark fixtures :p55f, after p55d, 1d + Event fuzz tests (key/resize/paste, deterministic seed replay):p55g, after p55e, 1d + Deterministic clock/render tests:p55i, after p55g, 0.5d + 30-minute soak test (no panic/leak):p55h, after p55i, 1d + + section Phase 5.6 — CLI/TUI Parity Pack + Dashboard count parity tests :p56a, after p55h, 0.5d + List/detail parity tests :p56b, after p56a, 1d + Search result identity parity :p56c, after p56b, 0.5d + Terminal safety sanitization tests :p56d, after p56a, 0.5d +``` + +**Phase 5.6 — CLI/TUI Parity Pack:** + +Ensures the TUI displays the same data as the CLI robot mode, preventing drift between interfaces: + +- **Dashboard count parity:** TUI dashboard entity counts match `lore --robot count issues` and `lore --robot count mrs` on the same fixture. +- **List/detail parity:** Sampled issues and MRs from TUI list views contain identical fields to `lore --robot issues ` and `lore --robot mrs `. +- **Search result identity parity:** Top-N result IDs from TUI lexical search match `lore --robot search ""` on the same fixture. +- **Terminal safety sanitization:** `sanitize_for_terminal()` test suite covers ANSI CSI, OSC, C0/C1 control chars, directional marks (LRM/RLM/ALM), bidi overrides, multi-byte edge cases, and Unicode preservation. + +**Success criterion:** Parity suite passes on CI fixtures (S and M tiers). Parity is asserted by field-level comparison, not string formatting comparison — the TUI and CLI may format differently but must present the same underlying data. + +**Total estimated scope:** ~47 implementation days across 9 phases (increased from ~43 to account for Phase 2.5 vertical slice gate, entity cache, crash context ring buffer, timer-based debounce, and expanded success criteria 24-25). + +### 9.3 Phase 0 — Toolchain Gate + +This is a hard gate. If Phase 0 fails, we evaluate alternatives before proceeding. + +**Success criteria:** +1. `cargo +stable check --workspace --all-targets` passes for root workspace +2. `cargo +nightly-2026-02-08 check --manifest-path crates/lore-tui/Cargo.toml --all-targets` passes +3. `cargo clippy --all-targets -- -D warnings` passes (both stable workspace and nightly TUI crate) +4. `cargo test` passes (all existing tests) +5. `cargo run --release -- --robot health` returns exit 0 +6. ftui demo-showcase renders correctly in iTerm2 and tmux +7. Binary size increase is < 5MB +8. **Tiered performance fixtures** validate latency at three scales: + + | Tier | Issues | MRs | Notes | Docs | + |------|--------|-----|-------|------| + | **S** (small) | 10k | 5k | 50k | 10k | + | **M** (medium) | 100k | 50k | 500k | 50k | + | **L** (large) | 250k | 100k | 1M | 100k | + + **SLO targets per tier:** + - p95 first-paint latency: < 50ms (S), < 75ms (M), < 150ms (L) + - p95 next-window fetch (keyset pagination): < 50ms (S), < 75ms (M), < 100ms (L) + - p95 search latency (lexical mode): < 100ms (S), < 200ms (M), < 400ms (L) + - **Memory ceiling: < 150MB RSS at S tier, < 250MB RSS at M tier** (measured after full dashboard + list load) + - S and M tiers are CI-enforced gates; L tier is advisory (run on-demand, not blocking) + +9. EXPLAIN QUERY PLAN must show index usage for top 10 TUI queries (no full table scans under default filters) +10. No full table scan on issues/MRs/discussions under default filters — verified by query plan CI check +11. p95 search latency validated in both S and M tiers, lexical and hybrid modes. Hybrid mode allowed 2x lexical latency budget. +12. Real-time sync stream verified: progress updates visible during run, not only at completion. Channel-based subscription drains events each tick. +13. Event-fuzz suite passes with zero invariant violations across 10k randomized key/resize/paste traces. +14. 30-minute soak test: no panic, no deadlock, memory growth < 5%. +15. Search mode parity: lexical, hybrid, and semantic each return mode-consistent top-N IDs on the fixture dataset. Switching modes in the TUI actually changes query behavior (no hardcoded defaults). +16. Detail first-paint p95 < 75ms on M-tier fixtures (metadata-only phase, before discussion/thread hydration). +17. Deterministic clock/render: snapshot tests using `FakeClock` produce identical output across runs regardless of wall-clock time. +18. Schema preflight test: incompatible DB schema yields actionable error and non-zero exit before entering TUI loop. +19. Unicode rendering tests pass for CJK, emoji ZWJ sequences, combining marks, skin tone modifiers, and flag sequences. Grapheme cluster boundaries are never split during truncation. +20. Redaction tests confirm tokens, Authorization headers, and credential patterns never appear in persisted crash reports or log artifacts. +21. Search mode capability detection validated: lexical/hybrid/semantic correctly enabled/disabled based on fixture capabilities (FTS index presence, embedding coverage threshold). +22. Cancel latency test: sync cancel acknowledged p95 < 250ms, sync stop p95 < 2s under M-tier fixtures. +23. Single-instance lock enforced: second TUI launch attempt yields clear error message and non-zero exit. +24. Sync stream stats are emitted and rendered; terminal events (completed/failed/cancelled) delivery is 100% under induced backpressure. +25. Entity cache provides near-instant reopen for Issue/MR detail views during Enter/Esc drill-in/out workflows; cache invalidated on sync completion. + +**Performance SLO rationale:** Interactive TUI responsiveness requires sub-100ms for list operations and sub-250ms for search. Tiered fixtures catch scaling regressions at different data magnitudes — a query that's fast at 10k rows may degrade at 100k without proper indexing or pagination. Memory ceilings prevent unbounded growth from large in-memory result sets. These targets are validated with synthetic SQLite fixtures during Phase 0 and enforced as CI benchmark gates thereafter. Required indexes are documented and migration-backed before TUI GA. + +#### 9.3.1 Required Indexes (GA Blocker) + +The following covering indexes must be present before TUI ships. They are added via migration and verified by EXPLAIN QUERY PLAN CI checks: + +```sql +-- Keyset pagination for issue list (default sort: updated_at DESC) +CREATE INDEX IF NOT EXISTS idx_issues_list_default + ON issues(project_id, state, updated_at DESC, iid DESC); + +-- Keyset pagination for MR list (default sort: updated_at DESC) +CREATE INDEX IF NOT EXISTS idx_mrs_list_default + ON merge_requests(project_id, state, updated_at DESC, iid DESC); + +-- Discussion list for entity detail views +CREATE INDEX IF NOT EXISTS idx_discussions_entity + ON discussions(project_id, entity_type, entity_iid, created_at DESC); + +-- Notes within a discussion (chronological) +CREATE INDEX IF NOT EXISTS idx_notes_discussion + ON notes(discussion_id, created_at ASC); + +-- Filter-path indexes: cover common TUI filter combinations beyond default sort. +-- These prevent full scans when users filter by author, assignee, reviewer, etc. +-- Exact column names TBD pending schema audit — the principle is that each +-- user-facing filter dimension used in list screens must have an index that +-- avoids SCAN TABLE under M-tier data volumes. At minimum: +-- issues: author + state, assignee + state +-- merge_requests: author + state, reviewer + state, target_branch + state +-- Index definitions will be finalized during Phase 2 (Core Screens) when +-- the actual query patterns are built, and validated by EXPLAIN QUERY PLAN CI. +``` + +**CI enforcement:** A dedicated test runs EXPLAIN QUERY PLAN against the top 10 TUI queries on a synthetic fixture and parses the query plan rows structurally rather than relying on brittle `SCAN TABLE` string matching (SQLite planner text varies by version). The test asserts: +- Top-level loop uses expected index families for each query +- No full scan on primary entity tables (issues, merge_requests, discussions, notes) under default filters AND the top 8 user-facing filter combinations (state, author, assignee, reviewer, label, target_branch, milestone, since) +- Join order remains bounded (no accidental cartesian expansions) +This prevents accidental index regressions while being robust across SQLite versions. + +If any criterion fails, we stop and evaluate whether the issue is fixable or fundamental. + +--- + +## 10. Code Changes Required + +### 10.1 New Files + +``` +# Workspace infrastructure +crates/lore-tui/Cargo.toml # TUI binary crate with FrankenTUI deps (nightly) +crates/lore-tui/rust-toolchain.toml # Nightly pin (isolated to TUI crate) +crates/lore-tui/src/main.rs # Binary entry point: CLI arg parsing, delegates to lib +crates/lore-tui/src/lib.rs # Public API: launch_tui(), launch_sync_tui() + +# TUI module (inside crates/lore-tui/src/) +crates/lore-tui/src/app.rs # LoreApp impl Model +crates/lore-tui/src/message.rs # Msg enum + EntityKey + AppError +crates/lore-tui/src/navigation.rs # NavigationStack +crates/lore-tui/src/theme.rs # ftui Theme config +crates/lore-tui/src/action.rs # Query bridge functions (uses lore core) +crates/lore-tui/src/db_manager.rs # DbManager: closure-based read pool (with_reader) + dedicated writer (with_writer). Prevents lock-poison panics and accidental long-held guards. +crates/lore-tui/src/task_supervisor.rs # TaskSupervisor: unified submit() → TaskHandle API with dedup, cancellation, generation IDs, and priority lanes +crates/lore-tui/src/entity_cache.rs # Bounded LRU cache for IssueDetail/MrDetail payloads. Keyed by EntityKey. Invalidated on sync completion. Enables near-instant reopen during Enter/Esc drill-in/out workflows. +crates/lore-tui/src/crash_context.rs # Ring buffer of last 2000 normalized events + current screen/task/build snapshot. Captured by panic hook for post-mortem crash diagnostics with retention policy (latest 20 files). +crates/lore-tui/src/safety.rs # sanitize_for_terminal(), safe_url_policy() +crates/lore-tui/src/redact.rs # redact_sensitive(): strip tokens, Authorization headers, and credential patterns from logs and crash reports before persisting +crates/lore-tui/src/commands.rs # CommandRegistry: single source of truth for all actions, keybindings, CLI equivalents, palette entries, help text, and status hints +crates/lore-tui/src/clock.rs # Clock trait: SystemClock (production) / FakeClock (tests) for deterministic timestamps +crates/lore-tui/src/instance_lock.rs # Single-instance advisory lock file with stale-lock recovery and clear error on collision +crates/lore-tui/src/session.rs # Versioned session state persistence + atomic write (tmp->fsync->rename) + CRC32 checksum + max-size guard (1MB) + corruption quarantine +crates/lore-tui/src/text_width.rs # Grapheme-safe display width: truncate_display_width(), pad_display_width(), measure_display_width() using unicode-width + unicode-segmentation crates. All column fitting, table alignment, and text truncation uses terminal display width (not bytes or chars) with grapheme cluster boundaries. Handles CJK (2-cell), emoji ZWJ sequences, skin tone modifiers, flag sequences, combining marks, and RTL text correctly. Never splits a grapheme cluster mid-sequence. +crates/lore-tui/src/state/mod.rs # AppState +crates/lore-tui/src/state/dashboard.rs # Dashboard data +crates/lore-tui/src/state/issue_list.rs # Issue list state +crates/lore-tui/src/state/issue_detail.rs # Issue detail state +crates/lore-tui/src/state/mr_list.rs # MR list state +crates/lore-tui/src/state/mr_detail.rs # MR detail state +crates/lore-tui/src/state/search.rs # Search state +crates/lore-tui/src/state/timeline.rs # Timeline state +crates/lore-tui/src/state/who.rs # Who state +crates/lore-tui/src/state/sync.rs # Sync state (running + summary modes) +crates/lore-tui/src/state/command_palette.rs # Palette state +crates/lore-tui/src/view/mod.rs # View router +crates/lore-tui/src/view/dashboard.rs # Dashboard render +crates/lore-tui/src/view/issue_list.rs # Issue list render +crates/lore-tui/src/view/issue_detail.rs # Issue detail render +crates/lore-tui/src/view/mr_list.rs # MR list render +crates/lore-tui/src/view/mr_detail.rs # MR detail render +crates/lore-tui/src/view/search.rs # Search render +crates/lore-tui/src/view/timeline.rs # Timeline render +crates/lore-tui/src/view/who.rs # Who render +crates/lore-tui/src/view/sync.rs # Sync render (inline mode + summary) +crates/lore-tui/src/view/command_palette.rs # Palette overlay +crates/lore-tui/src/view/common/mod.rs # Shared helpers +crates/lore-tui/src/view/common/filter_bar.rs # Filter input +crates/lore-tui/src/view/common/entity_table.rs # Sortable table +crates/lore-tui/src/view/common/discussion_tree.rs # Tree wrapper +crates/lore-tui/src/view/common/cross_ref.rs # Entity link +``` + +### 10.2 Modified Files + +``` +Cargo.toml # Keep lore-tui EXCLUDED from workspace (no dep, no feature) +src/cli/mod.rs # Add Tui command variant + resolve_tui_binary() +src/main.rs # Add Tui match arm (runtime delegation, no feature gate) +src/cli/commands/list.rs # Extract query_issues(), query_mrs() as pub fns +src/cli/commands/show.rs # Extract query_issue_detail(), query_mr_detail() as pub fns +src/cli/commands/who.rs # Extract query_experts(), etc. as pub fns +src/cli/commands/search.rs # Extract run_search_query() as pub fn +``` + +**Query extraction strategy:** The TUI's `action.rs` bridges to existing CLI query functions via the `lore` core library. Rather than creating a separate `src/domain/query/` directory (premature at this stage), we make the existing `query_*` functions in `cli/commands/*.rs` public and call them from `action.rs`. This avoids code duplication while keeping the refactoring scope minimal. If both CLI and TUI diverge significantly in their query needs, a shared domain query layer can be extracted later — but we don't pay that cost until it's needed. + +### 10.3 Entry Point + +The `lore-tui` crate has both a `lib.rs` (public API) and a `main.rs` (binary entry point). The `lore` CLI spawns the `lore-tui` binary; the binary parses its own CLI args and calls the library API. + +```rust +// crates/lore-tui/src/main.rs — binary entry point + +use clap::Parser; +use lore::core::config::Config; +use lore::core::db; + +#[derive(Parser)] +#[command(name = "lore-tui", about = "Interactive TUI for gitlore")] +struct TuiCli { + /// Path to config file + #[arg(long)] + config: Option, + + /// Start directly on sync screen (inline mode) + #[arg(long)] + sync: bool, + + /// Start without restoring prior session state + #[arg(long)] + fresh: bool, + + /// Rendering mode: auto, full, minimal + #[arg(long, default_value = "auto")] + render_mode: String, + + /// Force ASCII-safe glyphs + #[arg(long)] + ascii: bool, + + /// Stay in normal terminal (no alternate screen) + #[arg(long)] + no_alt_screen: bool, +} + +fn main() -> anyhow::Result<()> { + let cli = TuiCli::parse(); + let config = Config::load(cli.config.as_deref())?; + let db_path = db::resolve_db_path(&config)?; + + let opts = lore_tui::LaunchOptions { + sync_mode: cli.sync, + fresh: cli.fresh, + render_mode: cli.render_mode, + ascii: cli.ascii, + no_alt_screen: cli.no_alt_screen, + }; + + if cli.sync { + lore_tui::launch_sync_tui(config, &db_path)?; + } else { + lore_tui::launch_tui(config, &db_path, &opts)?; + } + + Ok(()) +} +``` + +```rust +// crates/lore-tui/src/lib.rs — library API + +mod app; +mod message; +mod navigation; +mod theme; +mod action; +mod db_manager; +mod task_supervisor; +mod safety; +mod session; +mod state; +mod view; + +use std::path::Path; +use ftui_runtime::program::App; +use lore::core::config::Config; +use lore::core::error::LoreError; +use db_manager::DbManager; + +/// Default read pool size for TUI queries. +const DEFAULT_READER_COUNT: usize = 3; + +/// Options for launching the TUI (parsed from CLI args by the binary). +pub struct LaunchOptions { + pub sync_mode: bool, + pub fresh: bool, + pub render_mode: String, + pub ascii: bool, + pub no_alt_screen: bool, +} + +pub fn launch_tui(config: Config, db_path: &Path, opts: &LaunchOptions) -> Result<(), LoreError> { + // Install a panic hook that restores the terminal before printing the + // crash report. Without this, a panic leaves the terminal in raw mode + // with no cursor, making the user's shell unusable. The hook writes + // a crash dump to ~/.local/share/lore/crash-.log containing: + // - Panic message and backtrace + // - Last 2000 normalized input events (ring buffer snapshot) + // - Current screen and navigation stack + // - In-flight task keys and their generations + // This event trace enables deterministic crash reproduction without + // requiring the user to remember what they were doing. The hook also + // prints a fallback CLI command suggestion so the user isn't blocked. + install_panic_hook_for_tui(); + + let db = DbManager::new(db_path, DEFAULT_READER_COUNT)?; + let lore_app = app::LoreApp::new(config, db); + + // FrankenTUI handles ALL terminal lifecycle: + // - raw mode enable/disable + // - alternate screen enter/leave (AltScreen mode) + // - panic recovery (RAII via TerminalSession) + // - event polling thread + // - buffer double-buffering and cell-level diffing + App::fullscreen(lore_app) + .run() + .map_err(|e| LoreError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + ))) +} + +/// Launch sync in inline mode (preserves terminal scrollback). +/// Used by `lore sync --tui` to show progress without alternate screen. +pub fn launch_sync_tui(config: Config, db_path: &Path) -> Result<(), LoreError> { + let db = DbManager::new(db_path, DEFAULT_READER_COUNT)?; + let mut lore_app = app::LoreApp::new(config, db); + lore_app.navigation.reset_to(message::Screen::Sync); + + // Inline mode: UI occupies 12 rows at bottom, terminal scrollback preserved. + // After TUI exits, sync log remains visible in scrollback. + App::inline(lore_app, 12) + .run() + .map_err(|e| LoreError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + ))) +} + +/// Install a panic hook that restores the terminal and writes a crash report. +/// This is critical for TUI applications — without it, a panic leaves the +/// terminal in raw mode with the alternate screen active. +fn install_panic_hook_for_tui() { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + // 1. Restore terminal state (best-effort) + let _ = crossterm::terminal::disable_raw_mode(); + let _ = crossterm::execute!( + std::io::stderr(), + crossterm::terminal::LeaveAlternateScreen, + crossterm::cursor::Show, + ); + + // 2. Capture crash context snapshot (ring buffer + state) + let ctx = crate::crash_context::snapshot(); + let crash_dir = dirs::data_local_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("lore"); + let _ = std::fs::create_dir_all(&crash_dir); + let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S"); + let crash_path = crash_dir.join(format!("crash-{timestamp}.log")); + // Redact sensitive values (tokens, Authorization headers, credentials) + // before persisting crash report to disk. Prevents accidental credential + // leakage in reports shared for debugging. + let report = crate::redact::redact_sensitive(&format!( + "{panic_info:#?}\n\n--- Crash Context ---\n\ + Build: {}\n\ + Screen: {:?}\n\ + Nav stack: {:?}\n\ + Active tasks: {:?}\n\ + DB fingerprint: {:?}\n\n\ + --- Last {} events ---\n{:#?}", + env!("CARGO_PKG_VERSION"), + ctx.current_screen, + ctx.nav_stack, + ctx.task_handles, + ctx.db_fingerprint, + ctx.events.len(), + ctx.events, + )); + let _ = std::fs::write(&crash_path, report); + + // 3. Retention: keep latest 20 crash files, delete oldest + if let Ok(entries) = std::fs::read_dir(&crash_dir) { + let mut crash_files: Vec<_> = entries + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with("crash-")) + .collect(); + crash_files.sort_by_key(|e| e.file_name()); + while crash_files.len() > 20 { + if let Some(oldest) = crash_files.first() { + let _ = std::fs::remove_file(oldest.path()); + } + crash_files.remove(0); + } + } + + // 4. Print user-friendly message with fallback + eprintln!("\nlore TUI crashed. Terminal has been restored."); + eprintln!("Crash report: {}", crash_path.display()); + eprintln!("\nFallback: use `lore --robot ` for CLI access."); + + // 5. Call default hook (prints backtrace if RUST_BACKTRACE=1) + default_hook(panic_info); + })); +} +``` + +### 10.4 Theme Configuration + +```rust +// crates/lore-tui/src/theme.rs + +use ftui_style::{ + AdaptiveColor, Color, Rgb, Theme, ThemeBuilder, + contrast_ratio, best_text_color, meets_wcag_aa, +}; + +/// Build the gitlore TUI theme using FrankenTUI's semantic color slots. +/// +/// The Theme struct has 19 semantic slots. We set each with AdaptiveColor +/// (light/dark variants). FrankenTUI's runtime detects terminal background +/// brightness and resolves the correct variant. +pub fn build_theme() -> Theme { + Theme::builder() + .primary(AdaptiveColor::adaptive( + Color::rgb(0, 100, 200), // light: blue accent + Color::rgb(100, 150, 255), // dark: brighter blue + )) + .secondary(AdaptiveColor::adaptive( + Color::rgb(80, 80, 80), + Color::rgb(180, 180, 180), + )) + .accent(AdaptiveColor::adaptive( + Color::rgb(0, 130, 200), + Color::rgb(80, 180, 255), + )) + .background(AdaptiveColor::adaptive( + Color::rgb(255, 255, 255), + Color::rgb(20, 20, 20), // near-black for dark mode + )) + .surface(AdaptiveColor::adaptive( + Color::rgb(245, 245, 245), + Color::rgb(30, 30, 30), + )) + .overlay(AdaptiveColor::adaptive( + Color::rgb(0, 0, 0), // dark overlay on light + Color::rgb(0, 0, 0), // dark overlay on dark + )) + .text(AdaptiveColor::adaptive( + Color::rgb(30, 30, 30), + Color::rgb(220, 220, 220), + )) + .text_muted(AdaptiveColor::adaptive( + Color::rgb(100, 100, 100), + Color::rgb(128, 128, 128), + )) + .text_subtle(AdaptiveColor::adaptive( + Color::rgb(160, 160, 160), + Color::rgb(80, 80, 80), + )) + .success(AdaptiveColor::fixed(Color::rgb(50, 180, 80))) + .warning(AdaptiveColor::fixed(Color::rgb(220, 180, 50))) + .error(AdaptiveColor::fixed(Color::rgb(220, 50, 50))) + .info(AdaptiveColor::fixed(Color::rgb(80, 160, 220))) + .border(AdaptiveColor::adaptive( + Color::rgb(200, 200, 200), + Color::rgb(60, 60, 60), + )) + .border_focused(AdaptiveColor::adaptive( + Color::rgb(0, 100, 200), + Color::rgb(100, 150, 255), + )) + .selection_bg(AdaptiveColor::adaptive( + Color::rgb(200, 220, 255), + Color::rgb(50, 80, 140), + )) + .selection_fg(AdaptiveColor::adaptive( + Color::rgb(0, 0, 0), + Color::rgb(255, 255, 255), + )) + .build() +} + +/// GitLab entity state → color mapping. +/// Returns Rgb for use with WCAG contrast checks. +pub fn state_color(state: &str) -> Rgb { + match state { + "opened" => Rgb::new(50, 180, 80), // green + "closed" => Rgb::new(220, 50, 50), // red + "merged" => Rgb::new(130, 80, 220), // purple + "locked" => Rgb::new(220, 180, 50), // amber + _ => Rgb::new(128, 128, 128), // gray + } +} + +/// Timeline event type → color mapping. +pub fn event_type_color(event_type: &str) -> Rgb { + match event_type { + "created" | "Created" => Rgb::new(50, 180, 80), // green + "state_changed" => Rgb::new(220, 180, 50), // amber + "label_added" | "label_removed" => Rgb::new(80, 180, 220), // cyan + "note_evidence" => Rgb::new(200, 200, 200), // light gray + "cross_referenced" => Rgb::new(180, 80, 220), // purple + "merged" | "Merged" => Rgb::new(50, 220, 80), // bright green + _ => Rgb::new(128, 128, 128), + } +} + +/// Resolve a GitLab hex label color (e.g., "#428BCA") to a Style with +/// WCAG-compliant foreground text. +pub fn label_style(hex_color: &str) -> Style { + let bg = parse_hex_to_rgb(hex_color); + let fg = best_text_color(bg, &[Rgb::new(0, 0, 0), Rgb::new(255, 255, 255)]); + Style::new().fg(fg).bg(bg) +} + +fn parse_hex_to_rgb(hex: &str) -> Rgb { + let hex = hex.trim_start_matches('#'); + if hex.len() != 6 { + return Rgb::new(128, 128, 128); // fallback gray + } + let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(128); + let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(128); + let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(128); + Rgb::new(r, g, b) +} +``` + +### 10.4.1 Terminal Safety — Untrusted Text Sanitization + +Issue/MR bodies, discussion notes, and log output are **untrusted text** rendered in a terminal context. Without sanitization, embedded ANSI escape sequences, OSC commands, or control characters could spoof TUI elements, trigger unintended terminal behavior, or exfiltrate data via OSC title-set sequences. + +```rust +// crates/lore-tui/src/safety.rs + +/// Strip ANSI escape sequences, OSC commands, control characters, +/// and bidi override/spoof controls from untrusted text before rendering in the TUI. +/// +/// Preserves legitimate Unicode rendering characters: +/// - ZWJ (U+200D) — required for emoji sequences (family, skin tone, flags) +/// - ZWNJ (U+200C) — required for correct rendering in some scripts (Arabic, Indic) +/// - Combining marks and variation selectors — preserved for correct grapheme display +/// +/// This is a critical security boundary: all user-sourced text from +/// GitLab (issue bodies, note content, MR descriptions, discussion text) +/// MUST pass through this function before widget rendering. +/// +/// Bidi controls can spoof displayed content by reordering rendered text, +/// making "safe-looking" strings actually contain reversed malicious content. +pub fn sanitize_for_terminal(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + match c { + // Strip ESC sequences (ANSI CSI, OSC, etc.) + '\x1b' => { + // Consume the entire escape sequence + if let Some(&next) = chars.peek() { + match next { + '[' => { + // CSI sequence: ESC [ ... final_byte + chars.next(); + while let Some(&c) = chars.peek() { + chars.next(); + if c.is_ascii_alphabetic() || c == '@' { break; } + } + } + ']' => { + // OSC sequence: ESC ] ... ST (BEL or ESC \) + chars.next(); + while let Some(&c) = chars.peek() { + chars.next(); + if c == '\x07' { break; } // BEL terminates + if c == '\x1b' { // ESC \ terminates + if chars.peek() == Some(&'\\') { chars.next(); } + break; + } + } + } + _ => { chars.next(); } // Other ESC sequences + } + } + } + // Strip bidi override/embedding controls (Unicode spoofing vectors) + '\u{202A}' // LRE Left-to-Right Embedding + | '\u{202B}' // RLE Right-to-Left Embedding + | '\u{202C}' // PDF Pop Directional Formatting + | '\u{202D}' // LRO Left-to-Right Override + | '\u{202E}' // RLO Right-to-Left Override + | '\u{2066}' // LRI Left-to-Right Isolate + | '\u{2067}' // RLI Right-to-Left Isolate + | '\u{2068}' // FSI First Strong Isolate + | '\u{2069}' // PDI Pop Directional Isolate + => {} + // Strip C1 control block (U+0080..U+009F) — terminal control codes + // that can cause rendering corruption or be used for injection. + c if ('\u{0080}'..='\u{009F}').contains(&c) => {} + // Strip directional marks (can spoof displayed content direction) + '\u{200E}' // LRM Left-to-Right Mark + | '\u{200F}' // RLM Right-to-Left Mark + | '\u{061C}' // ALM Arabic Letter Mark + => {} + // Strip invisible controls that serve no rendering purpose. + // NOTE: ZWJ (U+200D) and ZWNJ (U+200C) are intentionally PRESERVED — + // they are required for correct emoji and script rendering. + '\u{200B}' // Zero Width Space (no rendering purpose in terminal) + | '\u{FEFF}' // BOM / Zero Width No-Break Space + | '\u{00AD}' // Soft Hyphen + => {} + // Strip other control characters (except newline, tab) + c if c.is_control() && c != '\n' && c != '\t' => {} + // Allow everything else (including ZWJ, ZWNJ, combining marks, emoji) + c => output.push(c), + } + } + + output +} + +/// Validate a URL against the configured GitLab origin(s) before opening. +/// Enforces scheme + normalized host + port match to prevent deceptive variants +/// (e.g., IDN homograph attacks, unexpected port redirects). +pub fn is_safe_url(url: &str, allowed_origins: &[AllowedOrigin]) -> bool { + let Ok(parsed) = url::Url::parse(url) else { return false }; + + // Only allow HTTPS + if parsed.scheme() != "https" { return false; } + + // Normalize host (lowercase, IDNA-compatible) and match scheme+host+port + let Some(host) = parsed.host_str() else { return false; }; + let host_lower = host.to_ascii_lowercase(); + let port = parsed.port_or_known_default(); + + allowed_origins.iter().any(|origin| { + origin.scheme == "https" + && origin.host == host_lower + && origin.port == port + }) +} + +/// Typed origin for URL validation (scheme + normalized host + port). +pub struct AllowedOrigin { + pub scheme: String, + pub host: String, // lowercase, IDNA-normalized + pub port: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_ansi_color_codes() { + assert_eq!( + sanitize_for_terminal("\x1b[31mred text\x1b[0m"), + "red text", + ); + } + + #[test] + fn strips_osc_title_set() { + assert_eq!( + sanitize_for_terminal("\x1b]0;malicious title\x07normal text"), + "normal text", + ); + } + + #[test] + fn preserves_newlines_and_tabs() { + assert_eq!( + sanitize_for_terminal("line1\n\tindented"), + "line1\n\tindented", + ); + } + + #[test] + fn strips_control_chars() { + assert_eq!( + sanitize_for_terminal("hello\x00\x01\x02world"), + "helloworld", + ); + } + + #[test] + fn strips_c1_controls() { + // C1 control block U+0080..U+009F — terminal control codes + assert_eq!( + sanitize_for_terminal("before\u{0085}\u{008D}\u{009F}after"), + "beforeafter", + ); + } + + #[test] + fn strips_lrm_rlm_alm() { + // Directional marks that can spoof displayed content direction + assert_eq!( + sanitize_for_terminal("hello\u{200E}\u{200F}\u{061C}world"), + "helloworld", + ); + } +} +``` + +**Enforcement rule:** All user-sourced text MUST pass through `sanitize_for_terminal()` before being rendered by any widget. This includes: +- Issue titles and bodies +- MR titles and descriptions +- Discussion note content +- Timeline event descriptions +- Search result snippets +- Author names and labels +- Markdown link destinations (scheme + origin validation via `safe_url_policy()` before render/open) (could contain injected escape sequences in GitLab) + +The sanitization is applied in `action.rs` at the query boundary (not in view code) to prevent accidental bypass. + +### 10.5 Dashboard View (FrankenTUI Native) + +```rust +// crates/lore-tui/src/view/dashboard.rs + +use ftui::prelude::*; +use ftui::layout::{Flex, Grid, Constraint, Responsive, Breakpoint, Breakpoints}; +use ftui::widgets::{Badge, List, Paragraph, Panel}; +use ftui::text::{Line, Span}; +use crate::tui::state::dashboard::DashboardState; +use crate::tui::theme; + +/// Project-wide breakpoint thresholds (reused across all views). +pub const LORE_BREAKPOINTS: Breakpoints = Breakpoints::new(60, 90, 120); + +pub fn render(state: &DashboardState, frame: &mut Frame, area: Rect) { + let bp = LORE_BREAKPOINTS.classify_width(area.width); + + match bp { + Breakpoint::Xs | Breakpoint::Sm => render_single_column(state, frame, area), + Breakpoint::Md => render_two_column(state, frame, area), + Breakpoint::Lg | Breakpoint::Xl => render_three_column(state, frame, area), + } +} + +fn render_single_column(state: &DashboardState, frame: &mut Frame, area: Rect) { + // Narrow: stack all panels vertically + let layout = Flex::vertical() + .constraints([ + Constraint::Percentage(25.0), // projects + Constraint::Percentage(35.0), // stats + Constraint::Percentage(40.0), // recent + sync + ]) + .split(area); + + render_projects(state, frame, layout[0]); + render_stats(state, frame, layout[1]); + render_recent(state, frame, layout[2]); +} + +fn render_two_column(state: &DashboardState, frame: &mut Frame, area: Rect) { + let grid = Grid::new() + .rows([Constraint::Percentage(50.0), Constraint::Percentage(50.0)]) + .columns([Constraint::Percentage(40.0), Constraint::Percentage(60.0)]); + let gl = grid.split(area); + + render_projects(state, frame, gl.cell(0, 0)); + render_stats(state, frame, gl.cell(0, 1)); + render_recent(state, frame, gl.cell(1, 0)); + render_sync_info(state, frame, gl.cell(1, 1)); +} + +fn render_three_column(state: &DashboardState, frame: &mut Frame, area: Rect) { + let grid = Grid::new() + .rows([Constraint::Percentage(50.0), Constraint::Percentage(50.0)]) + .columns([ + Constraint::Percentage(30.0), + Constraint::Percentage(35.0), + Constraint::Percentage(35.0), + ]); + let gl = grid.split(area); + + render_projects(state, frame, gl.cell(0, 0)); + render_stats(state, frame, gl.cell(0, 1)); + render_recent(state, frame, gl.cell(0, 2)); + render_sync_info(state, frame, gl.span(1, 0, 1, 3)); // full bottom row +} + +fn render_projects(state: &DashboardState, frame: &mut Frame, area: Rect) { + let items: Vec = state.projects.iter().map(|p| { + let (badge_text, badge_color) = if p.minutes_since_sync < 10 { + ("OK", theme::state_color("opened")) + } else if p.minutes_since_sync < 120 { + ("STALE", theme::state_color("locked")) + } else { + ("OLD", theme::state_color("closed")) + }; + + let age = format_relative_time(p.minutes_since_sync); + + Line::from(vec![ + Span::raw(" "), + Span::styled(badge_text, Style::default().fg(badge_color)), + Span::raw(" "), + Span::raw(&p.path), + Span::raw(" "), + Span::styled(age, dim()), + ]) + }).collect(); + + let list = List::new(items); + let panel = Panel::new(list) + .title(format!(" Projects ({}) ", state.projects.len())); + frame.render_widget(panel, area); +} + +fn render_stats(state: &DashboardState, frame: &mut Frame, area: Rect) { + let c = &state.counts; + let lines = vec![ + stat_line("Issues", c.issues_open, "open", c.issues_total, "total", "opened"), + stat_line("MRs", c.mrs_open, "open", c.mrs_total, "total", "opened"), + Line::from(vec![ + Span::styled(" Discussions: ", dim()), + Span::styled(format!("{} threads", c.discussions), normal()), + ]), + Line::from(vec![ + Span::styled(" Notes: ", dim()), + Span::styled(format!("{}", c.notes_total), normal()), + Span::styled(format!(" ({}% system)", c.notes_system_pct), dim()), + ]), + Line::from(vec![ + Span::styled(" Documents: ", dim()), + Span::styled(format!("{} indexed", c.documents), normal()), + ]), + Line::from(vec![ + Span::styled(" Embeddings: ", dim()), + Span::styled(format!("{} vectors", c.embeddings), normal()), + ]), + ]; + + let para = Paragraph::new(lines); + let panel = Panel::new(para).title(" Quick Stats "); + frame.render_widget(panel, area); +} + +fn render_recent(state: &DashboardState, frame: &mut Frame, area: Rect) { + let items: Vec = state.recent.iter().map(|item| { + let prefix = match item.entity_type.as_str() { + "issue" => "#", + "mr" => "!", + _ => "", + }; + Line::from(vec![ + Span::styled( + format!(" {}{:<5} ", prefix, item.iid), + Style::default().fg(theme::state_color(&item.state)), + ), + Span::raw(truncate(&item.title, 30)), + Span::styled( + format!(" {}", format_relative_time(item.minutes_ago)), + dim(), + ), + ]) + }).collect(); + + let list = List::new(items); + let panel = Panel::new(list).title(" Recent Activity "); + frame.render_widget(panel, area); +} + +fn render_sync_info(state: &DashboardState, frame: &mut Frame, area: Rect) { + let sync = &state.last_sync; + let lines = vec![ + Line::from(vec![ + Span::styled(" Last Sync: ", dim()), + Span::styled( + format_relative_time(sync.minutes_ago), + normal(), + ), + ]), + Line::from(vec![ + Span::styled(" Duration: ", dim()), + Span::styled(&sync.duration_display, normal()), + ]), + Line::from(vec![ + Span::styled(" Issues: ", dim()), + Span::styled( + format!("+{} new, {} updated", sync.new_issues, sync.updated_issues), + normal(), + ), + ]), + Line::from(vec![ + Span::styled(" MRs: ", dim()), + Span::styled( + format!("+{} new, {} updated", sync.new_mrs, sync.updated_mrs), + normal(), + ), + ]), + Line::from(vec![ + Span::styled(" Errors: ", dim()), + Span::styled( + format!("{}", sync.errors), + if sync.errors > 0 { + Style::default().fg(Color::rgb(220, 50, 50)) + } else { normal() }, + ), + ]), + ]; + + let para = Paragraph::new(lines); + let panel = Panel::new(para).title(" Last Sync "); + frame.render_widget(panel, area); +} + +fn stat_line( + label: &str, active: u64, active_label: &str, + total: u64, total_label: &str, color_key: &str, +) -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {:<12}", format!("{}:", label)), dim()), + Span::styled( + format!("{} {}", active, active_label), + Style::default().fg(theme::state_color(color_key)), + ), + Span::styled(format!(" / {} {}", total, total_label), dim()), + ]) +} + +/// All truncation uses `crate::text_width::truncate_display_width()` — terminal +/// display width (via unicode-width), not byte/char length. Handles CJK, emoji, +/// combining marks, and zero-width joiners correctly for column alignment. +fn truncate(s: &str, max: usize) -> String { + crate::text_width::truncate_display_width(s, max) +} + +fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) } +fn normal() -> Style { Style::default().fg(Color::rgb(220, 220, 220)) } + +fn format_relative_time(minutes: u64) -> String { + if minutes < 60 { format!("{}m ago", minutes) } + else if minutes < 1440 { format!("{}h ago", minutes / 60) } + else { format!("{}d ago", minutes / 1440) } +} +``` + +### 10.6 Sync View — Inline Mode with Post-Sync Summary + +```rust +// crates/lore-tui/src/view/sync.rs + +use ftui::prelude::*; +use ftui::layout::{Flex, Constraint}; +use ftui::widgets::{LogViewer, LogViewerState, Panel, Paragraph, ProgressBar}; +use ftui::text::{Line, Span}; +use crate::tui::state::sync::{SyncState, SyncPhase}; + +pub fn render(state: &mut SyncState, frame: &mut Frame, area: Rect) { + match state.phase { + SyncPhase::Idle | SyncPhase::Running => render_running(state, frame, area), + SyncPhase::Summary => render_summary(state, frame, area), + } +} + +fn render_running(state: &mut SyncState, frame: &mut Frame, area: Rect) { + let layout = Flex::vertical() + .constraints([ + Constraint::Min(0), // progress section (grows) + Constraint::Fixed(1), // separator + Constraint::Percentage(40.0), // log viewer + ]) + .split(area); + + render_progress(state, frame, layout[0]); + render_log(state, frame, layout[2]); +} + +fn render_summary(state: &mut SyncState, frame: &mut Frame, area: Rect) { + let layout = Flex::vertical() + .constraints([ + Constraint::Fixed(9), // change summary + Constraint::Fixed(1), // separator + Constraint::Min(0), // log viewer + ]) + .split(area); + + // Change summary panel + let summary = &state.summary; + let lines = vec![ + Line::raw(""), + Line::from(vec![ + Span::styled(format!(" +{} new issues", summary.new_issues), normal()), + Span::styled(format!(" {} updated", summary.updated_issues), dim()), + Span::styled(" (i to browse)", dim()), + ]), + Line::from(vec![ + Span::styled(format!(" +{} new MRs", summary.new_mrs), normal()), + Span::styled(format!(" {} updated", summary.updated_mrs), dim()), + Span::styled(" (m to browse)", dim()), + ]), + Line::from(vec![ + Span::styled(format!(" +{} new discussions", summary.new_discussions), dim()), + ]), + Line::from(vec![ + Span::styled(format!(" +{} new events", summary.new_events), dim()), + ]), + Line::from(vec![ + Span::styled(format!(" {} errors", summary.errors), if summary.errors > 0 { + Style::default().fg(Color::rgb(220, 50, 50)) + } else { + dim() + }), + ]), + Line::raw(""), + ]; + + let para = Paragraph::new(lines); + let panel = Panel::new(para) + .title(format!(" Sync Complete -- {} ", state.elapsed_display())); + frame.render_widget(panel, layout[0]); + + render_log(state, frame, layout[2]); +} + +fn render_progress(state: &SyncState, frame: &mut Frame, area: Rect) { + let mut lines = Vec::new(); + + for project in &state.projects { + lines.push(Line::from(Span::styled( + format!(" {}", project.path), + Style::default().add_modifier(Modifier::BOLD), + ))); + + for resource in &project.resources { + lines.push(Line::from(Span::raw(format!( + " {:<14} {} {}", + resource.kind_label(), + progress_bar_ascii(resource.progress_pct(), 30), + resource.delta_label(), + )))); + } + lines.push(Line::raw("")); + } + + let para = Paragraph::new(lines); + let panel = Panel::new(para) + .title(format!( + " Sync -- {} -- {} ", + state.status_label(), + state.elapsed_display(), + )); + frame.render_widget(panel, area); +} + +fn render_log(state: &mut SyncState, frame: &mut Frame, area: Rect) { + // LogViewer is a stateful widget with separate LogViewerState. + // SyncState owns both the LogViewer and its rendering state. + // + // Panel wraps the log viewer for a border + title. We compute the + // inner area first, render the Panel chrome, then render the LogViewer + // as a stateful widget into the inner region. + let border_block = Panel::new(Paragraph::new("")).title(" Log "); + let inner = border_block.inner(area); + frame.render_widget(border_block, area); + + // LogViewer auto-scrolls to bottom by default. The user can scroll up + // with j/k and auto-scroll resumes when they hit the bottom. + frame.render_stateful_widget( + &state.log_viewer, + inner, + &mut state.log_viewer_state, + ); +} + +fn progress_bar_ascii(pct: u8, width: usize) -> String { + let filled = (usize::from(pct) * width) / 100; + let empty = width - filled; + format!("{}{} {}%", + "\u{2588}".repeat(filled), + "\u{2591}".repeat(empty), + pct, + ) +} + +fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) } +fn normal() -> Style { Style::default().fg(Color::rgb(220, 220, 220)) } +``` + +### 10.7 Command Palette — Using Built-in Widget + +```rust +// crates/lore-tui/src/state/command_palette.rs +// +// The CommandPalette widget is STATEFUL — it owns its own query, action list, +// selected index, and fuzzy matching. We wrap it and register all lore commands. + +use ftui::widgets::command_palette::{CommandPalette, PaletteAction}; +use ftui::widgets::modal::{Modal, ModalPosition, ModalState, ModalSizeConstraints, BackdropConfig}; +use ftui::core::event::Event; + +pub struct CommandPaletteState { + pub palette: CommandPalette, + pub modal_state: ModalState, +} + +impl CommandPaletteState { + pub fn new() -> Self { + let mut palette = CommandPalette::new(); + + // Register all lore commands with fuzzy-searchable metadata. + // id is auto-generated from title (lowercase + underscores). + palette.register("Issues", Some("Browse all issues"), &["list", "browse"]); + palette.register("Issues (opened)", Some("Open issues only"), &["list", "filter"]); + palette.register("Issues (closed)", Some("Closed issues only"), &["list", "filter"]); + palette.register("Merge Requests", Some("Browse all MRs"), &["list", "mr"]); + palette.register("MRs (opened)", Some("Open MRs only"), &["list", "mr", "filter"]); + palette.register("MRs (merged)", Some("Merged MRs only"), &["list", "mr", "filter"]); + palette.register("MRs (draft)", Some("Draft/WIP MRs"), &["list", "mr", "filter"]); + palette.register("Search", Some("Full-text + semantic search"), &["find", "query"]); + palette.register("Timeline", Some("Chronological event view"), &["history", "events"]); + palette.register("Who (expert)", Some("Path-based expert ranking"), &["people", "expert"]); + palette.register("Who (workload)", Some("Person workload summary"), &["people"]); + palette.register("Who (active)", Some("Active unresolved discussions"), &["people"]); + palette.register("Sync", Some("Incremental sync from GitLab"), &["fetch", "update"]); + palette.register("Sync (full)", Some("Full sync with cursor reset"), &["fetch", "reset"]); + palette.register("Doctor", Some("Health check"), &["diagnostics"]); + palette.register("Stats", Some("Index statistics"), &["info"]); + palette.register("Open in browser", Some("Open current entity in GitLab"), &["web"]); + palette.register("Quit", Some("Exit TUI"), &["exit", "close"]); + + Self { + palette, + modal_state: ModalState::default(), + } + } + + pub fn is_visible(&self) -> bool { + self.modal_state.is_open() + } + + pub fn open(&mut self) { + self.palette.open(); + self.modal_state.open(); + } + + pub fn close(&mut self) { + self.palette.close(); + self.modal_state.close(); + } + + /// Delegate a terminal event to the palette. + /// Returns Some(PaletteAction) if the user selected or dismissed. + pub fn handle_event(&mut self, event: &Event) -> Option { + self.palette.handle_event(event) + } +} + +// --- Rendering (in crates/lore-tui/src/view/command_palette.rs) --- + +use ftui::prelude::*; + +pub fn render(state: &CommandPaletteState, frame: &mut Frame, area: Rect) { + if !state.is_visible() { + return; + } + + // Wrap palette in modal overlay with dimmed backdrop + let size = ModalSizeConstraints::new() + .min_width(40) + .max_width((area.width as f32 * 0.6) as u16) + .min_height(4) + .max_height(15); + + let modal = Modal::new(&state.palette) + .position(ModalPosition::TopCenter { margin: 3 }) + .backdrop(BackdropConfig::new(Color::rgb(0, 0, 0), 0.5)) + .size(size); + + frame.render_widget(modal, area); +} +``` + +### 10.8 Issue Detail — Using Built-in Tree for Discussions + +```rust +// crates/lore-tui/src/view/issue_detail.rs + +use ftui::prelude::*; +use ftui::layout::{Flex, Constraint}; +use ftui::widgets::{Panel, Paragraph, Scrollbar, Tree, TreeNode}; +use ftui::text::{Line, Span}; +use ftui_extras::markdown::MarkdownRenderer; +use crate::tui::state::issue_detail::IssueDetailState; +use crate::tui::theme; + +pub fn render(state: &IssueDetailState, frame: &mut Frame, area: Rect) { + let Some(detail) = &state.detail else { + frame.render_widget(Paragraph::new("Loading..."), area); + return; + }; + + let layout = Flex::vertical() + .constraints([ + Constraint::Fixed(4), // header (title + metadata) + Constraint::Percentage(30.0), // description (markdown) + Constraint::Min(0), // discussions (tree) + Constraint::Fixed(3), // cross-references + ]) + .split(area); + + render_header(detail, frame, layout[0]); + render_description(detail, frame, layout[1]); + render_discussions(state, frame, layout[2]); + render_cross_refs(detail, frame, layout[3]); +} + +fn render_header(detail: &IssueDetail, frame: &mut Frame, area: Rect) { + let state_color = theme::state_color(&detail.state); + let lines = vec![ + Line::from(vec![ + Span::styled( + format!("#{} ", detail.iid), + Style::default().fg(state_color).add_modifier(Modifier::BOLD), + ), + Span::styled(&detail.title, Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + format!(" {}", detail.state), + Style::default().fg(state_color), + ), + ]), + Line::from(vec![ + Span::styled("Author: ", dim()), + Span::raw(format!("@{}", detail.author)), + Span::raw(" "), + Span::styled("Assignees: ", dim()), + Span::raw(detail.assignees.join(", ")), + ]), + Line::from(vec![ + Span::styled("Labels: ", dim()), + // Labels rendered inline — full WCAG-aware styling in production + Span::raw(detail.labels.join(", ")), + Span::raw(" "), + Span::styled("Milestone: ", dim()), + Span::raw(detail.milestone.as_deref().unwrap_or("none")), + ]), + Line::from(vec![ + Span::styled("Project: ", dim()), + Span::raw(&detail.project_path), + Span::raw(" "), + Span::styled("Created: ", dim()), + Span::raw(&detail.created_at_display), + ]), + ]; + frame.render_widget(Paragraph::new(lines), area); +} + +fn render_description(detail: &IssueDetail, frame: &mut Frame, area: Rect) { + // Render markdown description using ftui-extras markdown renderer. + // Falls back to plain text if markdown parsing fails. + let body = detail.description.as_deref().unwrap_or("(no description)"); + let md = MarkdownRenderer::new(theme::build_theme()); + let rendered = md.render(body); + + let panel = Panel::new(rendered).title(" Description "); + frame.render_widget(panel, area); +} + +fn render_discussions(state: &IssueDetailState, frame: &mut Frame, area: Rect) { + let Some(detail) = &state.detail else { return }; + + // Tree::new takes a SINGLE TreeNode root. We create a virtual root + // with each discussion thread as a child, then hide the root label. + let mut root = TreeNode::new(format!( + "Discussions ({} threads)", + state.discussions.len(), + )); + + for disc in &state.discussions { + let header = format!( + "{} -- {} ({} replies) {}", + disc.author, + truncate(&disc.preview, 50), + disc.reply_count, + disc.relative_age, + ); + + let mut thread_node = TreeNode::new(header) + .with_expanded(disc.expanded); + + if disc.expanded { + // Root note body as first child + thread_node = thread_node.child( + TreeNode::new(truncate(&disc.root_note_body, 200)) + ); + + // Replies as subsequent children + for reply in &disc.replies { + thread_node = thread_node.child( + TreeNode::new(format!( + "@{}: {} {}", + reply.author, + truncate(&reply.body, 60), + reply.relative_age, + )) + ); + } + } + + root = root.child(thread_node); + } + + let tree = Tree::new(root) + .with_show_root(true) // Show "Discussions (N threads)" as header + .with_guides(ftui::widgets::tree::TreeGuides::Rounded); + + let panel = Panel::new(tree); + frame.render_widget(panel, area); +} + +fn render_cross_refs(detail: &IssueDetail, frame: &mut Frame, area: Rect) { + let mut lines: Vec = Vec::new(); + + for xref in &detail.closing_merge_requests { + lines.push(Line::from(vec![ + Span::styled(" Closes: ", dim()), + Span::styled( + format!("!{}", xref.iid), + Style::default().fg(theme::state_color("merged")) + .add_modifier(Modifier::UNDERLINED), + ), + Span::raw(format!(" ({})", truncate(&xref.title, 40))), + Span::styled( + format!(" {}", xref.state), + Style::default().fg(theme::state_color(&xref.state)), + ), + ])); + } + + if lines.is_empty() { + lines.push(Line::styled(" (no cross-references)", dim())); + } + + let para = Paragraph::new(lines); + let panel = Panel::new(para).title(" Cross-References "); + frame.render_widget(panel, area); +} + +/// All truncation uses `crate::text_width::truncate_display_width()` — terminal +/// display width (via unicode-width), not byte/char length. Handles CJK, emoji, +/// combining marks, and zero-width joiners correctly for column alignment. +fn truncate(s: &str, max: usize) -> String { + crate::text_width::truncate_display_width(s, max) +} + +fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) } +``` + +### 10.9 Snapshot Test Example + +```rust +// tests/tui_snapshot.rs + +use ftui_harness::TestHarness; +use lore::tui::app::LoreApp; + +#[test] +fn dashboard_renders_correctly() { + let harness = TestHarness::new(80, 24); // 80x24 terminal + let app = LoreApp::new_for_test(/* mock config + db */); + + // Inject dashboard data + app.state.dashboard.update(mock_dashboard_data()); + + // Capture frame + let frame = harness.render(&app); + + // Snapshot comparison (deterministic) + insta::assert_snapshot!(frame.to_string()); +} + +#[test] +fn issue_list_with_filter() { + let harness = TestHarness::new(120, 40); + let mut app = LoreApp::new_for_test(/* mock */); + + app.state.issue_list.set_items(mock_issues()); + app.state.issue_list.set_filter("state:opened"); + + let frame = harness.render(&app); + insta::assert_snapshot!(frame.to_string()); +} +``` + +### 10.9.1 Non-Snapshot Tests (Phase 5.5 Reliability Pack) + +Beyond visual snapshot testing, the TUI requires correctness tests for: + +**Deterministic test infrastructure:** +- All time-based rendering (relative timestamps, "3h ago" labels) uses an injected `Clock` trait (`SystemClock` in production, `FakeClock` in tests) so snapshot tests produce stable output regardless of wall-clock time. +- Fuzz tests (key/resize/paste) use deterministic seeds. On failure, the seed is printed for exact replay: `FUZZ_SEED= cargo test tui_fuzz`. +- CI runs fuzz tests with a fixed set of seeds plus one random seed per run for discovery. + +```rust +// tests/tui_reliability.rs + +/// Property test: navigation stack invariants. +/// The stack is never empty, pop preserves at least Dashboard, +/// and push/pop are inverse operations for state preservation. +#[test] +fn navigation_stack_never_empty() { + let mut nav = NavigationStack::new(); + assert_eq!(nav.depth(), 1); // Always starts with Dashboard + assert!(nav.pop().is_none()); // Can't pop last screen + assert_eq!(nav.depth(), 1); +} + +/// Stale search response is silently dropped. +/// Uses TaskSupervisor generation check — the supervisor's generation counter +/// has advanced past the stale result's generation. +#[test] +fn stale_search_response_dropped() { + let mut app = LoreApp::new_for_test(/* mock */); + // Advance supervisor generation to 5 (simulates 5 tasks dispatched) + for _ in 0..5 { app.task_supervisor.next_generation(); } + // Simulate result from generation 3 (stale — current is 5) + let cmd = app.update(Msg::SearchExecuted { + generation: 3, + results: mock_search_results(), + }); + assert!(app.state.search.results.is_empty()); // Not applied +} + +/// DbManager concurrent reader access doesn't deadlock. +#[test] +fn db_manager_concurrent_reads() { + let db = DbManager::new(Path::new(":memory:"), 4).unwrap(); + let handles: Vec<_> = (0..8).map(|_| { + let db = Arc::clone(&db); + std::thread::spawn(move || { + let conn = db.reader(); + conn.query_row("SELECT 1", [], |r| r.get::<_, i64>(0)).unwrap(); + }) + }).collect(); + for h in handles { h.join().unwrap(); } +} + +/// Sync cancellation token is respected. +#[test] +fn sync_cancel_stops_early() { + let mut state = SyncState::new(); + state.cancel_token.store(true, Ordering::Relaxed); + // Verify cancel is checked in progress callback + assert!(state.cancel_token.load(Ordering::Relaxed)); +} + +/// Rapid resize events don't crash or leave inconsistent state. +#[test] +fn resize_storm_stability() { + let mut app = LoreApp::new_for_test(/* mock */); + for w in (40..200).step_by(3) { + app.update(Msg::Resize { width: w, height: 24 }); + } + assert!(app.state.terminal_size.0 > 0); +} +``` + +**Performance benchmark tests** (run in CI, gated on SLO thresholds): + +```rust +// benches/tui_queries.rs (criterion) + +fn bench_issue_list_query(c: &mut Criterion) { + let db = setup_synthetic_db(10_000, 5_000); // 10k issues, 5k MRs + let filter = IssueFilter::default(); + c.bench_function("issue_list_10k", |b| { + b.iter(|| fetch_issues(&db, &filter)) + }); +} + +fn bench_search_query(c: &mut Criterion) { + let db = setup_synthetic_db_with_docs(50_000); // 50k documents + c.bench_function("search_50k_lexical", |b| { + b.iter(|| execute_search(&db, "authentication timeout", &SearchCliFilters::default())) + }); +} +``` + +### 10.10 State Module — Complete + +The state tree is a composition of per-screen state structs. Each screen owns its data, filter state, cursor position, and any widget state (e.g., `TableState`, `TreePersistState`). This is the **single source of truth** for what the TUI displays. + +```rust +// crates/lore-tui/src/state/mod.rs + +pub mod dashboard; +pub mod issue_list; +pub mod issue_detail; +pub mod mr_list; +pub mod mr_detail; +pub mod search; +pub mod timeline; +pub mod who; +pub mod sync; + +use crate::tui::state::command_palette::CommandPaletteState; + +/// Top-level state composition. Each field corresponds to one screen. +/// State is preserved when navigating away — screens are never cleared on pop. +#[derive(Default)] +pub struct AppState { + pub dashboard: dashboard::DashboardState, + pub issue_list: issue_list::IssueListState, + pub issue_detail: issue_detail::IssueDetailState, + pub mr_list: mr_list::MrListState, + pub mr_detail: mr_detail::MrDetailState, + pub search: search::SearchState, + pub timeline: timeline::TimelineState, + pub who: who::WhoState, + pub sync: sync::SyncState, + pub command_palette: CommandPaletteState, + + // Cross-cutting state + pub load_state: ScreenLoadStateMap, + pub error_toast: Option, + pub show_help: bool, + pub terminal_size: (u16, u16), +} + +/// Per-screen loading state. Enables stale-while-revalidate: screens keep +/// showing their last data during background refresh, with a lightweight +/// spinner indicator rather than a full-screen loading overlay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoadState { + /// No load in progress, data is current. + Idle, + /// Initial load — no data to show yet, render skeleton/spinner. + LoadingInitial, + /// Refreshing — stale data remains visible, show unobtrusive refresh indicator. + Refreshing, + /// Load failed — show error context with retry suggestion. + Error(String), +} + +impl Default for LoadState { + fn default() -> Self { Self::Idle } +} + +/// Map of per-screen load states. Defaults to Idle for untracked screens. +#[derive(Default)] +pub struct ScreenLoadStateMap { + states: HashMap, +} + +impl ScreenLoadStateMap { + pub fn get(&self, screen: &Screen) -> &LoadState { + self.states.get(screen).unwrap_or(&LoadState::Idle) + } + + pub fn set(&mut self, screen: Screen, state: LoadState) { + if matches!(state, LoadState::Idle) { + self.states.remove(&screen); + } else { + self.states.insert(screen, state); + } + } + + /// Returns true if any screen is in a loading state (for spinner subscription). + pub fn any_loading(&self) -> bool { + self.states.values().any(|s| matches!(s, LoadState::LoadingInitial | LoadState::Refreshing)) + } +} + +impl AppState { + pub fn set_loading(&mut self, screen: Screen, state: LoadState) { + self.load_state.set(screen, state); + } + + pub fn set_error(&mut self, msg: String) { + self.error_toast = Some(msg); + } + + pub fn clear_error(&mut self) { + self.error_toast = None; + } + + /// Returns true if any screen has an active text input (filter bar, search box). + pub fn has_text_focus(&self) -> bool { + self.issue_list.filter_focused + || self.mr_list.filter_focused + || self.search.query_focused + || self.who.path_focused + || self.timeline.query_focused + } + + /// Blur all text inputs across all screens. + pub fn blur_text_focus(&mut self) { + self.issue_list.filter_focused = false; + self.mr_list.filter_focused = false; + self.search.query_focused = false; + self.who.path_focused = false; + self.timeline.query_focused = false; + } + + /// Delegate a key event to the text input widget that currently has focus. + /// Returns true if the event was consumed. + pub fn delegate_text_event( + &mut self, screen: &Screen, event: &Event, + ) -> bool { + match screen { + Screen::IssueList => self.issue_list.filter_input.handle_event(event), + Screen::MrList => self.mr_list.filter_input.handle_event(event), + Screen::Search => self.search.query_input.handle_event(event), + Screen::Who => self.who.path_input.handle_event(event), + Screen::Timeline => self.timeline.query_input.handle_event(event), + _ => false, + } + } + + /// Dispatch screen-local keybindings (j/k scroll, Tab sort, etc.). + /// Called from interpret_key() stage 5. + pub fn interpret_screen_key( + &mut self, screen: &Screen, key: &KeyEvent, + ) -> Option { + match screen { + Screen::Dashboard => match key.code { + KeyCode::Char('i') => Some(Msg::NavigateTo(Screen::IssueList)), + KeyCode::Char('m') => Some(Msg::NavigateTo(Screen::MrList)), + KeyCode::Char('/') => Some(Msg::NavigateTo(Screen::Search)), + KeyCode::Char('t') => Some(Msg::NavigateTo(Screen::Timeline)), + KeyCode::Char('w') => Some(Msg::NavigateTo(Screen::Who)), + KeyCode::Char('s') => Some(Msg::NavigateTo(Screen::Sync)), + KeyCode::Char('d') => Some(Msg::NavigateTo(Screen::Doctor)), + KeyCode::Char('r') => Some(Msg::NavigateTo(Screen::Dashboard)), // refresh + _ => None, + }, + Screen::IssueList => self.issue_list.handle_key(key), + Screen::IssueDetail(_) => self.issue_detail.handle_key(key), + Screen::MrList => self.mr_list.handle_key(key), + Screen::MrDetail(_) => self.mr_detail.handle_key(key), + Screen::Search => self.search.handle_key(key), + Screen::Timeline => self.timeline.handle_key(key), + Screen::Who => self.who.handle_key(key), + Screen::Sync => self.sync.handle_key(key), + _ => None, + } + } + + /// Handle Msg variants that are specific to a single screen's sub-state. + /// Called from update() for messages not handled at the top level. + /// + /// IMPORTANT: This function is PURE — it updates state and returns + /// `ScreenIntent` signals, NEVER `Cmd::task` directly. All async work + /// (DB queries, re-queries after filter changes) is launched by + /// `LoreApp::update()` through the `TaskSupervisor`, which enforces + /// dedup, cancellation, and generation-based stale result detection. + /// + /// This prevents the bug where filter re-queries bypass the supervisor + /// and race with screen-load queries, causing stale data display. + pub fn handle_screen_msg( + &mut self, screen: &Screen, msg: Msg, + ) -> ScreenIntent { + match msg { + // Issue list scrolling, filter, sort + Msg::IssueSelected(entity_key) => { + ScreenIntent::Navigate(Screen::IssueDetail(entity_key)) + } + Msg::IssueListFilterChanged(filter) => { + self.issue_list.apply_filter(filter); + // Signal that a re-query is needed — LoreApp::update() dispatches + // via TaskSupervisor with TaskKey::FilterRequery(Screen::IssueList) + ScreenIntent::RequeryNeeded(Screen::IssueList) + } + Msg::IssueListSortChanged(field, order) => { + self.issue_list.set_sort(field, order); + ScreenIntent::None // Re-sort locally, no re-query needed + } + Msg::ScrollToTopCurrentScreen => { + // Generic scroll-to-top for whichever list screen is active + match screen { + Screen::IssueList => self.issue_list.scroll_to_top(), + Screen::MrList => { + self.mr_list.selected_index = 0; + self.mr_list.scroll_offset = 0; + } + _ => {} + } + ScreenIntent::None + } + + // MR list + Msg::MrSelected(entity_key) => { + ScreenIntent::Navigate(Screen::MrDetail(entity_key)) + } + Msg::MrListFilterChanged(filter) => { + self.mr_list.apply_filter(filter); + ScreenIntent::RequeryNeeded(Screen::MrList) + } + + // Search + Msg::SearchQueryChanged(query) => { + self.search.set_query(query.clone()); + if query.len() < 2 { return ScreenIntent::None; } + ScreenIntent::RequeryNeeded(Screen::Search) + } + Msg::SearchResultSelected(idx) => { + self.search.select(idx); + ScreenIntent::None + } + + // Timeline — EntityRef carries full EntityKey for safe cross-project navigation + Msg::TimelineEntitySelected(entity_ref) => { + match entity_ref { + EntityRef::Issue(key) => { + ScreenIntent::Navigate(Screen::IssueDetail(key)) + } + EntityRef::MergeRequest(key) => { + ScreenIntent::Navigate(Screen::MrDetail(key)) + } + } + } + + // Who + Msg::WhoModeChanged(mode) => { + self.who.set_mode(mode); + ScreenIntent::None + } + + _ => ScreenIntent::None, + } + } +} + +/// Signals from screen state handlers to LoreApp::update(). +/// State handlers are PURE — they never launch async tasks directly. +/// LoreApp::update() converts these intents into supervised Cmd::task calls. +#[derive(Debug)] +pub enum ScreenIntent { + /// No side effect needed. + None, + /// Navigate to a new screen (will trigger screen load via supervisor). + Navigate(Screen), + /// The screen's filter/query changed and needs a re-query. + /// LoreApp::update() dispatches via TaskSupervisor with the appropriate TaskKey. + RequeryNeeded(Screen), +} +``` + +#### Per-Screen State Structs + +```rust +// crates/lore-tui/src/state/dashboard.rs + +pub struct DashboardState { + pub counts: EntityCounts, + pub projects: Vec, + pub recent: Vec, + pub last_sync: LastSyncInfo, +} + +pub struct EntityCounts { + pub issues_open: u64, + pub issues_total: u64, + pub mrs_open: u64, + pub mrs_total: u64, + pub discussions: u64, + pub notes_total: u64, + pub notes_system_pct: u8, + pub documents: u64, + pub embeddings: u64, +} + +pub struct ProjectSyncInfo { + pub path: String, + pub minutes_since_sync: u64, +} + +pub struct RecentActivityItem { + pub entity_type: String, // "issue" | "mr" + pub iid: i64, + pub title: String, + pub state: String, + pub minutes_ago: u64, +} + +pub struct LastSyncInfo { + pub minutes_ago: u64, + pub duration_display: String, + pub new_issues: usize, + pub updated_issues: usize, + pub new_mrs: usize, + pub updated_mrs: usize, + pub errors: usize, +} + +/// Wire type: returned by fetch_dashboard(), consumed by DashboardState::update(). +pub struct DashboardData { + pub counts: EntityCounts, + pub projects: Vec, + pub recent: Vec, + pub last_sync: LastSyncInfo, +} + +impl DashboardState { + pub fn update(&mut self, data: DashboardData) { + self.counts = data.counts; + self.projects = data.projects; + self.recent = data.recent; + self.last_sync = data.last_sync; + } +} +``` + +```rust +// crates/lore-tui/src/state/issue_list.rs + +use ftui::widgets::input::TextInput; + +/// Keyset cursor for pagination — encodes the boundary values needed to +/// fetch the next/previous window without OFFSET (which degrades at scale). +#[derive(Clone)] +pub struct IssueCursor { + pub updated_at: i64, + pub iid: i64, +} + +pub struct IssueListState { + /// Current visible window of rows (not the full result set). + pub window: Vec, + pub total_count: usize, + pub selected_index: usize, + pub scroll_offset: usize, + /// Keyset cursor for fetching the next page forward. + pub next_cursor: Option, + /// Keyset cursor for fetching the previous page backward. + pub prev_cursor: Option, + /// True while a background prefetch of the next window is in progress. + pub prefetching: bool, + /// Number of rows per window (default 200). + pub window_size: usize, + pub sort_field: SortField, + pub sort_order: SortOrder, + pub filter_input: TextInput, + pub filter_focused: bool, + pub active_filter: IssueFilter, +} + +#[derive(Clone)] +pub struct IssueFilter { + pub state: Option, + pub author: Option, + pub assignee: Option, + pub labels: Vec, + pub milestone: Option, + pub since: Option, + pub project: Option, + pub limit: usize, +} + +impl Default for IssueFilter { + fn default() -> Self { + Self { + state: Some("opened".into()), + author: None, assignee: None, + labels: Vec::new(), milestone: None, + since: None, project: None, + limit: 50, + } + } +} + +#[derive(Clone, Copy)] +pub enum SortField { Iid, Updated, Created } + +#[derive(Clone, Copy)] +pub enum SortOrder { Asc, Desc } + +impl IssueListState { + pub fn set_result( + &mut self, + items: Vec, + next_cursor: Option, + prev_cursor: Option, + ) { + self.total_count = items.len(); + self.window = items; + self.next_cursor = next_cursor; + self.prev_cursor = prev_cursor; + self.prefetching = false; + // Preserve selected_index if in range + if self.selected_index >= self.window.len() { + self.selected_index = self.window.len().saturating_sub(1); + } + } + + pub fn current_filter(&self) -> IssueFilter { + self.active_filter.clone() + } + + pub fn apply_filter(&mut self, filter: IssueFilter) { + self.active_filter = filter; + self.selected_index = 0; + self.scroll_offset = 0; + } + + pub fn set_sort(&mut self, field: SortField, order: SortOrder) { + self.sort_field = field; + self.sort_order = order; + self.sort_items_locally(); + } + + pub fn scroll_to_top(&mut self) { + self.selected_index = 0; + self.scroll_offset = 0; + } + + /// Get the currently selected item (for browser-open, etc.). + pub fn selected_item(&self) -> Option<&IssueListRow> { + self.window.get(self.selected_index) + } + + pub fn handle_key(&mut self, key: &KeyEvent) -> Option { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if self.selected_index < self.window.len().saturating_sub(1) { + self.selected_index += 1; + } + Some(Msg::Tick) // trigger re-render + } + KeyCode::Char('k') | KeyCode::Up => { + self.selected_index = self.selected_index.saturating_sub(1); + Some(Msg::Tick) + } + KeyCode::Enter => { + self.window.get(self.selected_index) + .map(|item| Msg::IssueSelected( + EntityKey::issue(item.project_id, item.iid) + )) + } + KeyCode::Char('G') => { + self.selected_index = self.window.len().saturating_sub(1); + Some(Msg::Tick) + } + KeyCode::Char('f') | KeyCode::Tab => { + self.filter_focused = true; + Some(Msg::Tick) + } + KeyCode::Char('r') => Some(Msg::NavigateTo(Screen::IssueList)), // refresh + _ => None, + } + } + + fn sort_items_locally(&mut self) { + let field = self.sort_field; + let desc = matches!(self.sort_order, SortOrder::Desc); + self.items.sort_by(|a, b| { + let cmp = match field { + SortField::Iid => a.iid.cmp(&b.iid), + SortField::Updated => a.updated_at.cmp(&b.updated_at), + SortField::Created => a.created_at.cmp(&b.created_at), + }; + if desc { cmp.reverse() } else { cmp } + }); + } +} +``` + +```rust +// crates/lore-tui/src/state/issue_detail.rs + +pub struct IssueDetailState { + pub detail: Option, + pub discussions: Vec, + pub scroll_offset: usize, + pub selected_section: DetailSection, +} + +pub struct DiscussionViewState { + pub author: String, + pub preview: String, + pub root_note_body: String, + pub reply_count: usize, + pub relative_age: String, + pub expanded: bool, + pub replies: Vec, +} + +pub struct ReplyViewState { + pub author: String, + pub body: String, + pub relative_age: String, +} + +#[derive(Default)] +pub enum DetailSection { #[default] Description, Discussions, CrossRefs } + +impl IssueDetailState { + pub fn set(&mut self, detail: IssueDetail) { + self.discussions = detail.discussions.iter().map(|d| { + // Transform DiscussionDetail → DiscussionViewState + // First note is root, rest are replies + let root = d.notes.first(); + DiscussionViewState { + author: root.map_or_else(String::new, |n| format!("@{}", n.author_username)), + preview: root.map_or_else(String::new, |n| truncate(&n.body, 60)), + root_note_body: root.map_or_else(String::new, |n| n.body.clone()), + reply_count: d.notes.len().saturating_sub(1), + relative_age: root.map_or_else(String::new, |n| format_relative_ms(n.created_at)), + expanded: false, // collapsed by default + replies: d.notes.iter().skip(1).map(|n| ReplyViewState { + author: format!("@{}", n.author_username), + body: n.body.clone(), + relative_age: format_relative_ms(n.created_at), + }).collect(), + } + }).collect(); + self.detail = Some(detail); + self.scroll_offset = 0; + } + + pub fn handle_key(&mut self, key: &KeyEvent) -> Option { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + self.scroll_offset += 1; + Some(Msg::Tick) + } + KeyCode::Char('k') | KeyCode::Up => { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + Some(Msg::Tick) + } + KeyCode::Char('d') => { + // Toggle expand/collapse of selected discussion + self.toggle_discussion(); + Some(Msg::Tick) + } + KeyCode::Char('t') => { + // Open timeline scoped to this entity + self.detail.as_ref().map(|d| Msg::NavigateTo(Screen::Timeline)) + } + KeyCode::Char(' ') => { + self.scroll_offset += 10; // page down + Some(Msg::Tick) + } + KeyCode::Char('b') => { + self.scroll_offset = self.scroll_offset.saturating_sub(10); + Some(Msg::Tick) + } + _ => None, + } + } + + fn toggle_discussion(&mut self) { + // Toggle the first collapsed/expanded discussion near current scroll + // (simplified — real impl would track focused discussion index) + if let Some(disc) = self.discussions.first_mut() { + disc.expanded = !disc.expanded; + } + } +} +``` + +```rust +// crates/lore-tui/src/state/mr_list.rs — mirrors IssueListState with MR-specific filter fields + +/// Keyset cursor for MR pagination (mirrors IssueCursor pattern). +#[derive(Clone)] +pub struct MrCursor { + pub updated_at: i64, + pub iid: i64, +} + +pub struct MrListState { + /// Current visible window of rows (not the full result set). + pub window: Vec, + pub total_count: usize, + pub selected_index: usize, + pub scroll_offset: usize, + /// Keyset cursor for fetching the next page forward. + pub next_cursor: Option, + /// Keyset cursor for fetching the previous page backward. + pub prev_cursor: Option, + /// True while a background prefetch of the next window is in progress. + pub prefetching: bool, + /// Number of rows per window (default 200). + pub window_size: usize, + pub sort_field: SortField, + pub sort_order: SortOrder, + pub filter_input: TextInput, + pub filter_focused: bool, + pub active_filter: MrFilter, +} + +#[derive(Clone)] +pub struct MrFilter { + pub state: Option, + pub author: Option, + pub assignee: Option, + pub reviewer: Option, + pub draft: Option, + pub labels: Vec, + pub target_branch: Option, + pub source_branch: Option, + pub since: Option, + pub project: Option, + pub limit: usize, +} + +impl MrListState { + pub fn set_result( + &mut self, + items: Vec, + next_cursor: Option, + prev_cursor: Option, + ) { + self.total_count = items.len(); + self.window = items; + self.next_cursor = next_cursor; + self.prev_cursor = prev_cursor; + self.prefetching = false; + if self.selected_index >= self.window.len() { + self.selected_index = self.window.len().saturating_sub(1); + } + } + + pub fn current_filter(&self) -> MrFilter { + self.active_filter.clone() + } + + pub fn apply_filter(&mut self, filter: MrFilter) { + self.active_filter = filter; + self.selected_index = 0; + self.scroll_offset = 0; + } + + pub fn selected_item(&self) -> Option<&MrListRow> { + self.window.get(self.selected_index) + } + + pub fn handle_key(&mut self, key: &KeyEvent) -> Option { + // Identical to IssueListState::handle_key but produces MR-specific Msgs + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if self.selected_index < self.window.len().saturating_sub(1) { + self.selected_index += 1; + } + Some(Msg::Tick) + } + KeyCode::Char('k') | KeyCode::Up => { + self.selected_index = self.selected_index.saturating_sub(1); + Some(Msg::Tick) + } + KeyCode::Enter => { + self.window.get(self.selected_index) + .map(|item| Msg::MrSelected( + EntityKey::mr(item.project_id, item.iid) + )) + } + KeyCode::Char('G') => { + self.selected_index = self.window.len().saturating_sub(1); + Some(Msg::Tick) + } + KeyCode::Char('f') | KeyCode::Tab => { + self.filter_focused = true; + Some(Msg::Tick) + } + KeyCode::Char('r') => Some(Msg::NavigateTo(Screen::MrList)), + _ => None, + } + } +} +``` + +```rust +// crates/lore-tui/src/state/mr_detail.rs — mirrors IssueDetailState with diff discussion sections + +pub struct MrDetailState { + pub detail: Option, + pub general_discussions: Vec, + pub diff_discussions: Vec, + pub file_changes: Vec, + pub scroll_offset: usize, + pub active_tab: MrDetailTab, +} + +pub struct DiffDiscussionViewState { + pub file_path: String, + pub line: Option, + pub discussion: DiscussionViewState, +} + +pub struct FileChangeView { + pub path: String, + pub change_type: String, // "M", "A", "D", "R" + pub additions: i64, + pub deletions: i64, +} + +#[derive(Default)] +pub enum MrDetailTab { #[default] Overview, Files, Discussions } +``` + +```rust +// crates/lore-tui/src/state/search.rs + +pub struct SearchState { + pub query_input: TextInput, + pub query_focused: bool, + pub mode: SearchMode, + pub source_type: Option, + pub results: Vec, + pub selected_index: usize, + pub total_results: usize, + pub last_query_ms: u64, +} + +#[derive(Default, Clone, Copy)] +pub enum SearchMode { #[default] Hybrid, Lexical, Semantic } + +impl SearchState { + pub fn query_text(&self) -> &str { + self.query_input.value() + } + + pub fn set_query(&mut self, query: String) { + self.query_input.set_value(&query); + } + + pub fn set_results(&mut self, response: SearchResponse) { + self.results = response.results; + self.total_results = response.total_results; + self.selected_index = 0; + } + + pub fn select(&mut self, idx: usize) { + if idx < self.results.len() { + self.selected_index = idx; + } + } + + pub fn current_filters(&self) -> SearchCliFilters { + SearchCliFilters { + source_type: self.source_type.clone(), + author: None, + project: None, + labels: Vec::new(), + path: None, + since: None, + updated_since: None, + limit: 50, + } + } + + pub fn handle_key(&mut self, key: &KeyEvent) -> Option { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if self.selected_index < self.results.len().saturating_sub(1) { + self.selected_index += 1; + } + Some(Msg::Tick) + } + KeyCode::Char('k') | KeyCode::Up => { + self.selected_index = self.selected_index.saturating_sub(1); + Some(Msg::Tick) + } + KeyCode::Tab => { + self.mode = match self.mode { + SearchMode::Hybrid => SearchMode::Lexical, + SearchMode::Lexical => SearchMode::Semantic, + SearchMode::Semantic => SearchMode::Hybrid, + }; + Some(Msg::Tick) + } + KeyCode::Char('/') => { + self.query_focused = true; + Some(Msg::Tick) + } + KeyCode::Enter => { + // Navigate to selected result using EntityKey for safe cross-project identity + self.results.get(self.selected_index).and_then(|r| { + r.entity_key.as_ref().and_then(|key| { + match key.kind { + EntityKind::Issue => Some(Msg::NavigateTo( + Screen::IssueDetail(key.clone()), + )), + EntityKind::MergeRequest => Some(Msg::NavigateTo( + Screen::MrDetail(key.clone()), + )), + } + }) + }) + } + _ => None, + } + } +} +``` + +```rust +// crates/lore-tui/src/state/timeline.rs + +/// Parameters for a timeline query. +pub struct TimelineParams { + pub query: String, + pub depth: u8, + pub since_days: u32, +} + +/// A single event in the timeline stream. +/// Wraps the existing timeline pipeline's output for TUI rendering. +#[derive(Clone)] +pub struct TimelineEvent { + pub timestamp_ms: i64, + pub event_type: String, // "state_change", "label_change", "note", "commit", etc. + pub entity_type: String, // "issue", "merge_request" + pub entity_iid: i64, + pub entity_title: String, + pub description: String, // Human-readable event description + pub author: Option, + pub entity_ref: Option, +} + +/// A reference to a GitLab entity for cross-navigation. +/// Uses EntityKey (project_id + iid) instead of bare iid to prevent +/// misrouting across projects with overlapping IID ranges. +#[derive(Clone)] +pub enum EntityRef { + Issue(EntityKey), + MergeRequest(EntityKey), +} + +pub struct TimelineState { + pub query_input: TextInput, + pub query_focused: bool, + pub events: Vec, + pub seeds: Vec, // seed entity labels + pub selected_index: usize, + pub scroll_offset: usize, + pub depth: u8, + pub since_days: u32, +} + +impl TimelineState { + pub fn set_result(&mut self, result: Vec) { + self.events = result; + self.selected_index = 0; + self.scroll_offset = 0; + } + + pub fn current_params(&self) -> TimelineParams { + TimelineParams { + query: self.query_input.value().to_string(), + depth: self.depth, + since_days: self.since_days, + } + } + + pub fn handle_key(&mut self, key: &KeyEvent) -> Option { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if self.selected_index < self.events.len().saturating_sub(1) { + self.selected_index += 1; + } + Some(Msg::Tick) + } + KeyCode::Char('k') | KeyCode::Up => { + self.selected_index = self.selected_index.saturating_sub(1); + Some(Msg::Tick) + } + KeyCode::Char('d') => { + self.depth = (self.depth + 1).min(3); + Some(Msg::Tick) // re-query happens on next update + } + KeyCode::Char('s') => { + self.since_days = match self.since_days { + 7 => 30, 30 => 90, 90 => 365, _ => 7, + }; + Some(Msg::Tick) + } + KeyCode::Char('/') => { + self.query_focused = true; + Some(Msg::Tick) + } + KeyCode::Enter => { + self.events.get(self.selected_index).and_then(|e| { + e.entity_ref.as_ref().map(|er| Msg::TimelineEntitySelected(er.clone())) + }) + } + _ => None, + } + } +} +``` + +```rust +// crates/lore-tui/src/state/who.rs + +/// Parameters for a who query. +pub struct WhoParams { + pub mode: WhoMode, + pub path: String, + pub username: String, +} + +pub struct WhoState { + pub mode: WhoMode, + pub path_input: TextInput, + pub path_focused: bool, + pub username_input: TextInput, + pub result: Option, + pub selected_index: usize, +} + +#[derive(Default, Clone, Copy)] +pub enum WhoMode { #[default] Expert, Workload, Reviews, Active, Overlap } + +impl WhoState { + pub fn set_mode(&mut self, mode: WhoMode) { + self.mode = mode; + self.selected_index = 0; + } + + pub fn set_result(&mut self, result: WhoResult) { + self.result = Some(result); + self.selected_index = 0; + } + + pub fn current_params(&self) -> WhoParams { + WhoParams { + mode: self.mode, + path: self.path_input.value().to_string(), + username: self.username_input.value().to_string(), + } + } + + pub fn handle_key(&mut self, key: &KeyEvent) -> Option { + match key.code { + KeyCode::Tab => { + self.mode = match self.mode { + WhoMode::Expert => WhoMode::Workload, + WhoMode::Workload => WhoMode::Reviews, + WhoMode::Reviews => WhoMode::Active, + WhoMode::Active => WhoMode::Overlap, + WhoMode::Overlap => WhoMode::Expert, + }; + Some(Msg::WhoModeChanged(self.mode)) + } + KeyCode::Char('j') | KeyCode::Down => { + self.selected_index += 1; + Some(Msg::Tick) + } + KeyCode::Char('k') | KeyCode::Up => { + self.selected_index = self.selected_index.saturating_sub(1); + Some(Msg::Tick) + } + KeyCode::Char('/') => { + self.path_focused = true; + Some(Msg::Tick) + } + _ => None, + } + } +} +``` + +```rust +// crates/lore-tui/src/state/sync.rs + +use ftui::widgets::log_viewer::{LogViewer, LogViewerState}; +use std::sync::atomic::{AtomicBool, Ordering}; + +pub struct SyncState { + pub phase: SyncPhase, + pub projects: Vec, + pub summary: SyncSummary, + pub log_viewer: LogViewer, + pub log_viewer_state: LogViewerState, + pub start_time: Option, + pub options: SyncOptions, + /// Cooperative cancel token — set to true when user presses Esc during sync. + /// The sync thread checks this at phase boundaries for early exit. + pub cancel_token: Arc, +} + +#[derive(Default, Clone, Copy)] +pub enum SyncPhase { #[default] Idle, Running, Summary } + +pub struct SyncProjectProgress { + pub path: String, + pub resources: Vec, +} + +pub struct SyncResourceProgress { + pub kind: String, // "Issues", "MRs", "Discussions", "Events" + pub fetched: usize, + pub total: Option, + pub delta: i64, +} + +impl SyncResourceProgress { + pub fn progress_pct(&self) -> u8 { + match self.total { + Some(t) if t > 0 => ((self.fetched * 100) / t).min(100) as u8, + _ => 0, + } + } + pub fn kind_label(&self) -> &str { &self.kind } + pub fn delta_label(&self) -> String { + if self.delta > 0 { format!("+{}", self.delta) } + else { String::new() } + } +} + +#[derive(Default)] +pub struct SyncSummary { + pub new_issues: usize, + pub updated_issues: usize, + pub new_mrs: usize, + pub updated_mrs: usize, + pub new_discussions: usize, + pub new_events: usize, + pub errors: usize, +} + +#[derive(Default)] +pub struct SyncOptions { + pub full: bool, + pub embed_after: bool, + pub dry_run: bool, +} + +impl SyncState { + pub fn new() -> Self { + Self { + phase: SyncPhase::Idle, + projects: Vec::new(), + summary: SyncSummary::default(), + log_viewer: LogViewer::new(10_000), + log_viewer_state: LogViewerState::default(), + start_time: None, + options: SyncOptions::default(), + cancel_token: Arc::new(AtomicBool::new(false)), + } + } + + pub fn start(&mut self) { + self.phase = SyncPhase::Running; + self.cancel_token.store(false, Ordering::Relaxed); // Reset cancel on new sync + self.start_time = Some(std::time::Instant::now()); + } + + pub fn update_progress(&mut self, event: ProgressEvent) { + // Map ProgressEvent to SyncProjectProgress updates + // (project routing done by matching event variants) + } + + pub fn push_log(&mut self, line: String) { + self.log_viewer.push(&line); + } + + pub fn complete(&mut self, result: SyncResult) { + self.phase = SyncPhase::Summary; + // Populate summary from result + } + + pub fn fail(&mut self, err: String) { + self.phase = SyncPhase::Summary; + self.summary.errors += 1; + self.log_viewer.push(&format!("ERROR: {}", err)); + } + + pub fn elapsed_display(&self) -> String { + match self.start_time { + Some(t) => { + let secs = t.elapsed().as_secs(); + format!("{}m {}s", secs / 60, secs % 60) + } + None => "0s".into(), + } + } + + pub fn status_label(&self) -> &str { + match self.phase { + SyncPhase::Idle => "Ready", + SyncPhase::Running => "Syncing...", + SyncPhase::Summary => "Complete", + } + } + + pub fn handle_key(&mut self, key: &KeyEvent) -> Option { + match self.phase { + SyncPhase::Idle => match key.code { + KeyCode::Enter => Some(Msg::SyncStarted), + KeyCode::Char('f') => { self.options.full = !self.options.full; Some(Msg::Tick) } + KeyCode::Char('e') => { self.options.embed_after = !self.options.embed_after; Some(Msg::Tick) } + KeyCode::Char('d') => { self.options.dry_run = !self.options.dry_run; Some(Msg::Tick) } + _ => None, + }, + SyncPhase::Running => match key.code { + KeyCode::Escape => { + self.cancel_token.store(true, Ordering::Relaxed); + self.log_viewer.push("Cancelling sync..."); + None // Sync thread will detect token and return SyncCancelled + } + _ => None, + }, + SyncPhase::Summary => match key.code { + KeyCode::Char('i') => Some(Msg::NavigateTo(Screen::IssueList)), + KeyCode::Char('m') => Some(Msg::NavigateTo(Screen::MrList)), + KeyCode::Char('r') => Some(Msg::SyncStarted), + _ => None, + }, + } + } +} +``` + +### 10.11 Action Module — Query Bridge + +The action module provides pure query functions that bridge the TUI state to existing CLI query logic. These run inside `Cmd::task` closures on a thread pool. + +```rust +// crates/lore-tui/src/action.rs +// +// All functions are pure data queries: &Connection in, Result out. +// No I/O, no framework dependency, no terminal access. + +use rusqlite::Connection; +use crate::core::error::LoreError; +use crate::tui::state::dashboard::*; +use crate::tui::state::issue_list::IssueFilter; +use crate::tui::state::mr_list::MrFilter; +use crate::tui::state::search::SearchMode; +use crate::cli::commands::list::{ + IssueListRow, ListFilters, ListResult, + MrListRow, MrListFilters, MrListResult, +}; +use crate::cli::commands::show::{IssueDetail, MrDetail}; +use crate::cli::commands::search::{SearchResponse, SearchCliFilters, SearchResultDisplay}; +use crate::cli::commands::who::{WhoResult, WhoArgs}; +use crate::search::FtsQueryMode; + +/// Fetch dashboard aggregate data. +pub fn fetch_dashboard(conn: &Connection) -> Result { + // Count queries — direct SQL for efficiency + let issues_open = count_by_state(conn, "issues", "opened")?; + let issues_total = count_total(conn, "issues")?; + let mrs_open = count_by_state(conn, "merge_requests", "opened")?; + let mrs_total = count_total(conn, "merge_requests")?; + let discussions = count_total(conn, "discussions")?; + let notes = count_total(conn, "notes")?; + let documents = count_total(conn, "documents")?; + let embeddings = count_total(conn, "embeddings")?; + + // System note percentage + let system_notes: u64 = conn.query_row( + "SELECT COUNT(*) FROM notes WHERE is_system = 1", [], |r| r.get(0), + ).unwrap_or(0); + let notes_system_pct = if notes > 0 { + ((system_notes * 100) / notes) as u8 + } else { 0 }; + + // Recent activity — last 10 updated issues/MRs + let recent = fetch_recent_activity(conn, 10)?; + + // Project sync status + let projects = fetch_project_sync_status(conn)?; + + // Last sync info + let last_sync = fetch_last_sync_info(conn)?; + + Ok(DashboardData { + counts: EntityCounts { + issues_open, issues_total, mrs_open, mrs_total, + discussions, notes_total: notes, notes_system_pct, + documents, embeddings, + }, + recent, + projects, + last_sync, + }) +} + +/// Fetch issues, converting TUI IssueFilter → CLI ListFilters. +pub fn fetch_issues( + conn: &Connection, + filter: &IssueFilter, +) -> Result, LoreError> { + // Convert TUI filter to CLI filter format. + // The CLI already has query_issues() — we just need to bridge the types. + let cli_filter = ListFilters { + limit: filter.limit, + project: filter.project.as_deref(), + state: filter.state.as_deref(), + author: filter.author.as_deref(), + assignee: filter.assignee.as_deref(), + labels: filter.labels.as_slice().into(), + milestone: filter.milestone.as_deref(), + since: filter.since.as_deref(), + due_before: None, + has_due_date: false, + sort: "updated_at", + order: "desc", + }; + let result = crate::cli::commands::list::query_issues(conn, &cli_filter)?; + Ok(result.issues) +} + +/// Fetch issue detail with discussions and cross-references. +/// Uses EntityKey (project_id + iid) for safe multi-project identity. +pub fn fetch_issue_detail( + conn: &Connection, + key: &EntityKey, +) -> Result { + crate::cli::commands::show::query_issue_detail(conn, key.iid, Some(key.project_id)) +} + +/// Fetch MRs, converting TUI MrFilter → CLI MrListFilters. +pub fn fetch_mrs( + conn: &Connection, + filter: &MrFilter, +) -> Result, LoreError> { + let cli_filter = MrListFilters { + limit: filter.limit, + project: filter.project.as_deref(), + state: filter.state.as_deref(), + author: filter.author.as_deref(), + assignee: filter.assignee.as_deref(), + reviewer: filter.reviewer.as_deref(), + labels: filter.labels.as_slice().into(), + since: filter.since.as_deref(), + draft: filter.draft.unwrap_or(false), + no_draft: filter.draft.map_or(false, |d| !d), + target_branch: filter.target_branch.as_deref(), + source_branch: filter.source_branch.as_deref(), + sort: "updated_at", + order: "desc", + }; + let result = crate::cli::commands::list::query_mrs(conn, &cli_filter)?; + Ok(result.mrs) +} + +/// Fetch MR detail with discussions and file changes. +/// Uses EntityKey (project_id + iid) for safe multi-project identity. +pub fn fetch_mr_detail( + conn: &Connection, + key: &EntityKey, +) -> Result { + crate::cli::commands::show::query_mr_detail(conn, key.iid, Some(key.project_id)) +} + +/// Execute search query bridging TUI search mode to FTS query mode. +/// The `mode` parameter is a first-class query input sourced from the TUI +/// mode selector (hybrid / lexical / semantic), ensuring the UI toggle +/// actually changes query behavior. +pub fn execute_search( + conn: &Connection, + query: &str, + filters: &SearchCliFilters, + mode: SearchMode, +) -> Result { + let mode_str = match mode { + SearchMode::Hybrid => "hybrid", + SearchMode::Lexical => "lexical", + SearchMode::Semantic => "semantic", + }; + let fts_mode = FtsQueryMode::Phrase; // default for TUI + crate::cli::commands::search::run_search_query( + conn, query, filters.clone(), fts_mode, mode_str, false, + ) +} + +/// Fetch timeline events. +pub fn fetch_timeline( + conn: &Connection, + params: &TimelineParams, +) -> Result, LoreError> { + // Use the existing 5-stage pipeline: SEED → HYDRATE → EXPAND → COLLECT → RENDER + crate::core::timeline_seed::seed_from_query(conn, ¶ms.query, params.depth, params.since_days) +} + +/// Fetch who results. +pub fn fetch_who( + conn: &Connection, + params: &WhoParams, +) -> Result { + // Bridge WhoParams to the existing WhoArgs structure + crate::cli::commands::who::query_who(conn, params) +} + +// ── Helper SQL queries ────────────────────────────────────────────── + +fn count_total(conn: &Connection, table: &str) -> Result { + // SAFETY: table name is from our own code, not user input + let sql = format!("SELECT COUNT(*) FROM {}", table); + conn.query_row(&sql, [], |r| r.get(0)) + .map_err(|e| LoreError::Database(e.to_string())) +} + +fn count_by_state(conn: &Connection, table: &str, state: &str) -> Result { + let sql = format!("SELECT COUNT(*) FROM {} WHERE state = ?1", table); + conn.query_row(&sql, [state], |r| r.get(0)) + .map_err(|e| LoreError::Database(e.to_string())) +} + +/// All action/query functions receive `now_ms` computed once per frame from the +/// injected Clock rather than calling SystemTime::now() internally. This ensures: +/// 1. Consistent relative-time labels within a single frame +/// 2. Deterministic output under FakeClock in snapshot tests +/// 3. Lower per-frame overhead (one syscall per frame, not per row) +fn fetch_recent_activity( + conn: &Connection, + limit: usize, + now_ms: i64, +) -> Result, LoreError> { + // Union of recently updated issues and MRs, sorted by updated_at desc + let sql = r#" + SELECT 'issue' AS entity_type, iid, title, state, updated_at + FROM issues + UNION ALL + SELECT 'mr' AS entity_type, iid, title, state, updated_at + FROM merge_requests + ORDER BY updated_at DESC + LIMIT ?1 + "#; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([limit as i64], |row| { + let updated_at: i64 = row.get(4)?; + // now_ms is passed in from the injected Clock (computed once per frame) + Ok(RecentActivityItem { + entity_type: row.get(0)?, + iid: row.get(1)?, + title: row.get(2)?, + state: row.get(3)?, + minutes_ago: ((now_ms - updated_at) / 60_000).max(0) as u64, + }) + })?; + rows.collect::, _>>() + .map_err(|e| LoreError::Database(e.to_string())) +} + +fn fetch_project_sync_status( + conn: &Connection, + now_ms: i64, +) -> Result, LoreError> { + let sql = r#" + SELECT p.path_with_namespace, + COALESCE(MAX(sl.started_at), 0) as last_sync + FROM projects p + LEFT JOIN sync_locks sl ON sl.project_id = p.id + GROUP BY p.id + ORDER BY p.path_with_namespace + "#; + let mut stmt = conn.prepare(sql)?; + // now_ms is passed in from the injected Clock (computed once per frame) + let rows = stmt.query_map([], |row| { + let last_sync: i64 = row.get(1)?; + Ok(ProjectSyncInfo { + path: row.get(0)?, + minutes_since_sync: if last_sync > 0 { + ((now_ms - last_sync) / 60_000).max(0) as u64 + } else { u64::MAX }, + }) + })?; + rows.collect::, _>>() + .map_err(|e| LoreError::Database(e.to_string())) +} + +fn fetch_last_sync_info(conn: &Connection, now_ms: i64) -> Result { + // Query the most recent sync lock for timing info + let sql = r#" + SELECT started_at, completed_at + FROM sync_locks + WHERE completed_at IS NOT NULL + ORDER BY completed_at DESC + LIMIT 1 + "#; + let result = conn.query_row(sql, [], |row| { + let started: i64 = row.get(0)?; + let completed: i64 = row.get(1)?; + // now_ms is passed in from the injected Clock (computed once per frame) + let duration_secs = (completed - started) / 1000; + Ok(LastSyncInfo { + minutes_ago: ((now_ms - completed) / 60_000).max(0) as u64, + duration_display: format!("{}m {}s", duration_secs / 60, duration_secs % 60), + new_issues: 0, updated_issues: 0, // populated from sync result if available + new_mrs: 0, updated_mrs: 0, + errors: 0, + }) + }); + result.or_else(|_| Ok(LastSyncInfo { + minutes_ago: u64::MAX, + duration_display: "never".into(), + new_issues: 0, updated_issues: 0, new_mrs: 0, updated_mrs: 0, errors: 0, + })) +} +``` + +### 10.12 Common Widgets — Shared Rendering Helpers + +```rust +// crates/lore-tui/src/view/common/mod.rs + +pub mod filter_bar; +pub mod entity_table; +pub mod discussion_tree; +pub mod cross_ref; + +use ftui::prelude::*; +use ftui::layout::{Flex, Constraint}; +use ftui::widgets::{Panel, Paragraph, StatusLine, StatusItem, Badge, Modal}; +use ftui::widgets::modal::{ModalPosition, ModalSizeConstraints, BackdropConfig}; +use ftui::text::{Line, Span}; +use crate::tui::navigation::NavigationStack; +use crate::tui::message::Screen; + +/// Render breadcrumb bar showing the full navigation path. +pub fn render_breadcrumb(nav: &NavigationStack, frame: &mut Frame, area: Rect) { + let crumbs = nav.breadcrumbs(); + let spans: Vec = crumbs.iter().enumerate().flat_map(|(i, label)| { + let mut v = Vec::new(); + if i > 0 { + v.push(Span::styled(" > ", Style::default().fg(Color::rgb(80, 80, 80)))); + } + if i == crumbs.len() - 1 { + // Active screen — bold + v.push(Span::styled( + *label, + Style::default().add_modifier(Modifier::BOLD), + )); + } else { + v.push(Span::styled( + *label, + Style::default().fg(Color::rgb(128, 128, 128)), + )); + } + v + }).collect(); + + let line = Line::from(vec![ + Span::styled(" lore ", Style::default().add_modifier(Modifier::BOLD)), + ].into_iter().chain(spans).collect::>()); + + frame.render_widget(Paragraph::new(line), area); +} + +/// Render the status bar with context-sensitive keybinding hints. +pub fn render_status_bar( + screen: &Screen, input_mode: &InputMode, frame: &mut Frame, area: Rect, +) { + let mut status = StatusLine::new() + .style(Style::default().fg(Color::rgb(180, 180, 180)).bg(Color::rgb(30, 30, 30))); + + // Go-prefix indicator (driven by InputMode state machine) + if matches!(input_mode, InputMode::GoPrefix { .. }) { + status = status.left(StatusItem::text("g--")); + } + + // Context-sensitive hints based on current screen + match screen { + Screen::Dashboard => { + status = status + .left(StatusItem::key_hint("i", "issues")) + .left(StatusItem::key_hint("m", "mrs")) + .left(StatusItem::key_hint("/", "search")) + .left(StatusItem::key_hint("s", "sync")) + .right(StatusItem::key_hint("?", "help")) + .right(StatusItem::key_hint("q", "quit")); + } + Screen::IssueList | Screen::MrList => { + status = status + .left(StatusItem::key_hint("j/k", "navigate")) + .left(StatusItem::key_hint("Enter", "open")) + .left(StatusItem::key_hint("f", "filter")) + .left(StatusItem::key_hint("Tab", "sort")) + .right(StatusItem::key_hint("Esc", "back")) + .right(StatusItem::key_hint("?", "help")); + } + Screen::IssueDetail(_) | Screen::MrDetail(_) => { + status = status + .left(StatusItem::key_hint("j/k", "scroll")) + .left(StatusItem::key_hint("d", "discussions")) + .left(StatusItem::key_hint("o", "browser")) + .left(StatusItem::key_hint("t", "timeline")) + .right(StatusItem::key_hint("Esc", "back")); + } + Screen::Search => { + status = status + .left(StatusItem::key_hint("/", "query")) + .left(StatusItem::key_hint("Tab", "mode")) + .left(StatusItem::key_hint("Enter", "open")) + .right(StatusItem::key_hint("Esc", "back")); + } + Screen::Timeline => { + status = status + .left(StatusItem::key_hint("j/k", "scroll")) + .left(StatusItem::key_hint("Enter", "entity")) + .left(StatusItem::key_hint("d", "depth")) + .left(StatusItem::key_hint("s", "since")) + .right(StatusItem::key_hint("Esc", "back")); + } + Screen::Who => { + status = status + .left(StatusItem::key_hint("Tab", "mode")) + .left(StatusItem::key_hint("/", "path")) + .left(StatusItem::key_hint("Enter", "open")) + .right(StatusItem::key_hint("Esc", "back")); + } + Screen::Sync => { + status = status + .left(StatusItem::key_hint("Enter", "start")) + .left(StatusItem::key_hint("f", "full")) + .left(StatusItem::key_hint("e", "embed")) + .right(StatusItem::key_hint("Esc", "back")); + } + _ => { + status = status + .right(StatusItem::key_hint("Esc", "back")) + .right(StatusItem::key_hint("q", "quit")); + } + } + + frame.render_widget(status, area); +} + +/// Render a loading spinner overlay centered in the given area. +pub fn render_loading(frame: &mut Frame, area: Rect) { + let text = Paragraph::new(" Loading... ") + .alignment(ftui::layout::Alignment::Center); + // Center vertically + let y = area.y + area.height / 2; + let centered = Rect::new(area.x, y, area.width, 1); + frame.render_widget(text, centered); +} + +/// Render an error toast overlay at the bottom of the screen. +pub fn render_error_toast(msg: &str, frame: &mut Frame, area: Rect) { + let toast_area = Rect::new( + area.x + 2, + area.y + area.height.saturating_sub(3), + area.width.saturating_sub(4), + 1, + ); + let para = Paragraph::new( + Line::from(vec![ + Span::styled(" ERROR ", Style::default() + .fg(Color::rgb(255, 255, 255)) + .bg(Color::rgb(220, 50, 50)) + .add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::raw(msg), + ]), + ); + frame.render_widget(para, toast_area); +} + +/// Render a help overlay showing all keybindings for the current screen. +pub fn render_help_overlay( + screen: &Screen, frame: &mut Frame, area: Rect, +) { + let bindings = help_text_for_screen(screen); + let lines: Vec = bindings.iter().map(|(key, desc)| { + Line::from(vec![ + Span::styled( + format!(" {:<12} ", key), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(*desc), + ]) + }).collect(); + + let para = Paragraph::new(lines); + let size = ModalSizeConstraints::new() + .min_width(40).max_width(60) + .min_height(10).max_height(25); + let modal = Modal::new(para) + .position(ModalPosition::Center) + .backdrop(BackdropConfig::new(Color::rgb(0, 0, 0), 0.6)) + .size(size); + frame.render_widget(modal, area); +} + +fn help_text_for_screen(screen: &Screen) -> Vec<(&'static str, &'static str)> { + let mut bindings = vec![ + ("q", "Quit"), + ("Esc", "Go back"), + ("H", "Home (Dashboard)"), + ("g + key", "Go-prefix navigation"), + ("Ctrl+P", "Command palette"), + ("?", "Toggle this help"), + ("o", "Open in browser"), + ]; + + match screen { + Screen::IssueList | Screen::MrList => { + bindings.extend([ + ("j / Down", "Move selection down"), + ("k / Up", "Move selection up"), + ("Enter", "Open selected item"), + ("G", "Jump to bottom"), + ("gg", "Jump to top"), + ("f / Tab", "Focus filter bar"), + ("r", "Refresh"), + ]); + } + Screen::IssueDetail(_) | Screen::MrDetail(_) => { + bindings.extend([ + ("j / Down", "Scroll down"), + ("k / Up", "Scroll up"), + ("Space", "Page down"), + ("b", "Page up"), + ("d", "Toggle discussion expand"), + ("t", "Timeline for this entity"), + ("Enter", "Follow cross-reference"), + ]); + } + Screen::Search => { + bindings.extend([ + ("/", "Focus search input"), + ("Tab", "Cycle search mode"), + ("j / k", "Navigate results"), + ("Enter", "Open result"), + ]); + } + _ => {} + } + + bindings +} +``` + +#### 10.12.1 Filter Bar Widget + +The filter bar provides inline text-based filtering for list views (issues, MRs). It uses FrankenTUI's `TextInput` widget and interprets structured filter syntax (`state:opened author:jane`). + +```rust +// crates/lore-tui/src/view/common/filter_bar.rs + +use ftui::prelude::*; +use ftui::widgets::input::TextInput; +use ftui::text::{Line, Span}; + +/// Shared filter bar state. Owned by the parent screen's state (e.g. IssueListState). +pub struct FilterBarState { + pub input: TextInput, + pub focused: bool, + /// Parsed filter tokens extracted from the input text. + pub tokens: Vec, + /// Parse errors from unknown or invalid filter fields. + /// Displayed inline below the filter bar in error color. + pub errors: Vec, + /// Non-fatal parse warnings (e.g., value coercions). + pub warnings: Vec, +} + +#[derive(Debug, Clone)] +pub struct FilterToken { + pub field: String, // e.g. "state", "author", "label" + pub value: String, // e.g. "opened", "jane", "bug" +} + +impl FilterBarState { + pub fn new() -> Self { + Self { + input: TextInput::new() + .with_placeholder("Filter: state:opened author:... label:... (f to focus)") + .with_style(Style::default().fg(Color::rgb(180, 180, 180))), + focused: false, + tokens: Vec::new(), + errors: Vec::new(), + warnings: Vec::new(), + } + } + + pub fn focus(&mut self) { + self.focused = true; + self.input.set_focused(true); + } + + pub fn blur(&mut self) { + self.focused = false; + self.input.set_focused(false); + } + + /// Handle a key event when focused. Returns true if consumed. + pub fn handle_event(&mut self, event: &Event) -> bool { + if !self.focused { return false; } + match event { + Event::Key(key) if key.code == KeyCode::Escape => { + self.blur(); + true + } + Event::Key(key) if key.code == KeyCode::Enter => { + self.parse_tokens(); + self.blur(); + true + } + _ => self.input.handle_event(event), + } + } + + /// Parse the input text into structured filter tokens. + /// Supports "field:value" pairs and bare text (interpreted as title search). + fn parse_tokens(&mut self) { + let text = self.input.value().to_string(); + self.tokens = text.split_whitespace().map(|chunk| { + if let Some((field, value)) = chunk.split_once(':') { + FilterToken { + field: field.to_lowercase(), + value: value.to_string(), + } + } else { + FilterToken { + field: "title".to_string(), + value: chunk.to_string(), + } + } + }).collect(); + } + + /// Apply parsed tokens to an IssueFilter. + /// Unknown fields are collected into `self.errors` for inline display. + pub fn apply_to_issue_filter(&mut self, base: &mut crate::tui::state::IssueFilter) { + self.errors.clear(); + self.warnings.clear(); + for token in &self.tokens { + match token.field.as_str() { + "state" | "s" => base.state = Some(token.value.clone()), + "author" | "a" => base.author = Some(token.value.clone()), + "assignee" => base.assignee = Some(token.value.clone()), + "label" | "l" => base.labels.push(token.value.clone()), + "milestone" | "m" => base.milestone = Some(token.value.clone()), + "since" => base.since = Some(token.value.clone()), + "project" | "p" => base.project = Some(token.value.clone()), + "title" => {} // bare text interpreted as title search — no error + _ => self.errors.push(format!("Unknown filter field: '{}'", token.field)), + } + } + } + + /// Apply parsed tokens to an MrFilter (same pattern, MR-specific fields). + /// Unknown fields are collected into `self.errors` for inline display. + pub fn apply_to_mr_filter(&mut self, base: &mut crate::tui::state::MrFilter) { + self.errors.clear(); + self.warnings.clear(); + for token in &self.tokens { + match token.field.as_str() { + "state" | "s" => base.state = Some(token.value.clone()), + "author" | "a" => base.author = Some(token.value.clone()), + "reviewer" | "r" => base.reviewer = Some(token.value.clone()), + "assignee" => base.assignee = Some(token.value.clone()), + "label" | "l" => base.labels.push(token.value.clone()), + "draft" => base.draft = Some(token.value == "true" || token.value == "yes"), + "target" | "tb" => base.target_branch = Some(token.value.clone()), + "source" | "sb" => base.source_branch = Some(token.value.clone()), + "since" => base.since = Some(token.value.clone()), + "project" | "p" => base.project = Some(token.value.clone()), + "title" => {} // bare text — no error + _ => self.errors.push(format!("Unknown filter field: '{}'", token.field)), + } + } + } +} + +/// Render the filter bar as a single-line widget. +pub fn render(state: &FilterBarState, frame: &mut Frame, area: Rect) { + if state.focused { + // When focused, show the full TextInput with cursor + frame.render_widget(&state.input, area); + } else { + // When unfocused, show a summary of active filters + let mut spans = vec![ + Span::styled("Filter: ", Style::default().fg(Color::rgb(100, 100, 100))), + ]; + if state.tokens.is_empty() { + spans.push(Span::styled( + "[press f to filter]", + Style::default().fg(Color::rgb(80, 80, 80)), + )); + } else { + for (i, token) in state.tokens.iter().enumerate() { + if i > 0 { spans.push(Span::raw(" ")); } + spans.push(Span::styled( + format!("{}:", token.field), + Style::default().fg(Color::rgb(100, 180, 255)), + )); + spans.push(Span::raw(&token.value)); + } + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); + } +} +``` + +#### 10.12.2 Entity Table Widget + +Generic sortable table used by both issue and MR list views to avoid duplicating column/row rendering logic. + +```rust +// crates/lore-tui/src/view/common/entity_table.rs + +use ftui::prelude::*; +use ftui::widgets::table::{Table, TableState}; +use ftui::text::Span; + +/// Column definition for the entity table. +pub struct ColumnDef { + pub header: &'static str, + pub constraint: Constraint, +} + +/// Render a table with the given columns, rows, and selected index. +/// The `rows` parameter is pre-built by the caller (issue_list or mr_list) +/// since column extraction is entity-specific. +pub fn render_entity_table( + columns: &[ColumnDef], + rows: Vec>, + selected: usize, + frame: &mut Frame, + area: Rect, +) { + let widths: Vec = columns.iter().map(|c| c.constraint).collect(); + let header_spans: Vec = columns.iter().map(|c| { + Span::styled(c.header, Style::default().add_modifier(Modifier::BOLD)) + }).collect(); + + let table = Table::new(rows, widths) + .header(header_spans) + .highlight_style(Style::default().bg(Color::rgb(50, 80, 140))); + + let mut ts = TableState::default(); + ts.select(Some(selected)); + frame.render_stateful_widget(table, area, &mut ts); +} + +/// Build column definitions for issue list, adapting to terminal width. +pub fn issue_columns(bp: ftui::layout::Breakpoint) -> Vec { + use ftui::layout::{Breakpoint, Constraint}; + let mut cols = vec![ + ColumnDef { header: "IID", constraint: Constraint::Fixed(6) }, + ColumnDef { header: "Title", constraint: Constraint::Min(20) }, + ColumnDef { header: "State", constraint: Constraint::Fixed(8) }, + ColumnDef { header: "Updated", constraint: Constraint::Fixed(10) }, + ]; + if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) { + cols.push(ColumnDef { header: "Author", constraint: Constraint::Fixed(12) }); + cols.push(ColumnDef { header: "Assignee", constraint: Constraint::Fixed(12) }); + } + if matches!(bp, Breakpoint::Lg | Breakpoint::Xl) { + cols.push(ColumnDef { header: "Labels", constraint: Constraint::Min(15) }); + } + cols +} + +/// Build column definitions for MR list, adapting to terminal width. +pub fn mr_columns(bp: ftui::layout::Breakpoint) -> Vec { + use ftui::layout::{Breakpoint, Constraint}; + let mut cols = vec![ + ColumnDef { header: "IID", constraint: Constraint::Fixed(6) }, + ColumnDef { header: "Title", constraint: Constraint::Min(20) }, + ColumnDef { header: "State", constraint: Constraint::Fixed(8) }, + ColumnDef { header: "Updated", constraint: Constraint::Fixed(10) }, + ]; + if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) { + cols.push(ColumnDef { header: "Draft", constraint: Constraint::Fixed(5) }); + cols.push(ColumnDef { header: "Author", constraint: Constraint::Fixed(12) }); + } + if matches!(bp, Breakpoint::Lg | Breakpoint::Xl) { + cols.push(ColumnDef { header: "Source", constraint: Constraint::Fixed(15) }); + cols.push(ColumnDef { header: "Target", constraint: Constraint::Fixed(15) }); + } + cols +} +``` + +#### 10.12.3 Discussion Tree Widget + +Wraps FrankenTUI's `Tree` widget to render threaded discussions uniformly across Issue Detail and MR Detail views. + +```rust +// crates/lore-tui/src/view/common/discussion_tree.rs + +use ftui::prelude::*; +use ftui::widgets::tree::{Tree, TreeNode, TreeGuides}; +use ftui::widgets::Panel; +use ftui::text::Span; +use crate::tui::state::issue_detail::{DiscussionViewState, ReplyViewState}; + +/// Build and render a discussion tree from DiscussionViewState items. +/// Used by both issue_detail and mr_detail views. +pub fn render_discussion_tree( + discussions: &[DiscussionViewState], + title: &str, + frame: &mut Frame, + area: Rect, +) { + let mut root = TreeNode::new(format!("{} ({} threads)", title, discussions.len())); + + for disc in discussions { + let header = format!( + "{} {} replies {}", + disc.author, disc.reply_count, disc.relative_age, + ); + let mut thread = TreeNode::new(header).with_expanded(disc.expanded); + + // Root note body (first ~200 chars) + thread = thread.child( + TreeNode::new(truncate(&disc.root_note_body, 200)) + ); + + // Reply notes + for reply in &disc.replies { + thread = thread.child( + TreeNode::new(format!( + "{}: {} {}", + reply.author, + truncate(&reply.body, 120), + reply.relative_age, + )) + ); + } + + root = root.child(thread); + } + + let tree = Tree::new(root) + .with_show_root(true) + .with_guides(TreeGuides::Rounded); + let panel = Panel::new(tree).title(title); + frame.render_widget(panel, area); +} + +/// All truncation uses `crate::text_width::truncate_display_width()` — terminal +/// display width (via unicode-width), not byte/char length. Handles CJK, emoji, +/// combining marks, and zero-width joiners correctly for column alignment. +fn truncate(s: &str, max: usize) -> String { + crate::text_width::truncate_display_width(s, max) +} +``` + +#### 10.12.4 Cross-Reference Widget + +Renders clickable-style entity references (issue `#123`, MR `!456`) with color coding. These are navigation targets — pressing Enter on a focused cross-ref navigates to the referenced entity. + +```rust +// crates/lore-tui/src/view/common/cross_ref.rs + +use ftui::prelude::*; +use ftui::text::{Line, Span}; +use crate::tui::message::{Msg, Screen}; + +/// A parsed cross-reference to a GitLab entity. +#[derive(Debug, Clone)] +pub struct CrossRef { + pub kind: CrossRefKind, + pub project_path: String, + pub iid: i64, + pub label: String, // Display string: "group/project#123" +} + +#[derive(Debug, Clone, PartialEq)] +pub enum CrossRefKind { + Issue, + MergeRequest, +} + +impl CrossRef { + /// Parse a reference string like "group/project#123" or "group/project!456" + pub fn parse(s: &str) -> Option { + let (prefix, sigil, kind) = if let Some(pos) = s.rfind('#') { + (s[..pos].to_string(), pos, CrossRefKind::Issue) + } else if let Some(pos) = s.rfind('!') { + (s[..pos].to_string(), pos, CrossRefKind::MergeRequest) + } else { + return None; + }; + let iid_str = &s[sigil + 1..]; + let iid = iid_str.parse::().ok()?; + Some(Self { + kind, + project_path: prefix, + iid, + label: s.to_string(), + }) + } + + /// Convert this cross-ref into a navigation message. + /// Resolves project_path → project_id via DB lookup before navigating. + /// This ensures correct entity resolution in multi-project datasets + /// where bare iid is ambiguous. + pub fn to_nav_msg(&self, project_id: i64) -> Msg { + let key = EntityKey { + project_id, + iid: self.iid, + kind: match self.kind { + CrossRefKind::Issue => EntityKind::Issue, + CrossRefKind::MergeRequest => EntityKind::MergeRequest, + }, + }; + match self.kind { + CrossRefKind::Issue => Msg::NavigateTo(Screen::IssueDetail(key)), + CrossRefKind::MergeRequest => Msg::NavigateTo(Screen::MrDetail(key)), + } + } + + /// Render as a styled Span (issue = green sigil, MR = blue sigil). + pub fn to_span(&self) -> Span { + let color = match self.kind { + CrossRefKind::Issue => Color::rgb(80, 200, 120), + CrossRefKind::MergeRequest => Color::rgb(100, 160, 255), + }; + Span::styled( + &self.label, + Style::default().fg(color).add_modifier(Modifier::UNDERLINED), + ) + } +} + +/// Render a list of cross-references as a horizontal flow within the given area. +pub fn render_cross_refs(refs: &[CrossRef], frame: &mut Frame, area: Rect) { + let spans: Vec = refs.iter().flat_map(|r| { + vec![r.to_span(), Span::raw(" ")] + }).collect(); + + if spans.is_empty() { + frame.render_widget( + Paragraph::new(Span::styled("(none)", Style::default().fg(Color::rgb(80, 80, 80)))), + area, + ); + } else { + frame.render_widget(Paragraph::new(Line::from(spans)), area); + } +} +``` + +### 10.13 Issue List View + +```rust +// crates/lore-tui/src/view/issue_list.rs + +use ftui::prelude::*; +use ftui::layout::{Flex, Constraint, Breakpoint}; +use ftui::widgets::{Panel, Paragraph, Table, TableState}; +use ftui::text::{Line, Span}; +use crate::tui::state::issue_list::IssueListState; +use crate::tui::theme; +use crate::tui::view::dashboard::LORE_BREAKPOINTS; + +pub fn render(state: &IssueListState, frame: &mut Frame, area: Rect) { + let layout = Flex::vertical() + .constraints([ + Constraint::Fixed(1), // filter bar + Constraint::Min(0), // table + ]) + .split(area); + + render_filter_bar(state, frame, layout[0]); + render_table(state, frame, layout[1]); +} + +fn render_filter_bar(state: &IssueListState, frame: &mut Frame, area: Rect) { + let filter = &state.active_filter; + let parts = vec![ + format!("state:{}", filter.state.as_deref().unwrap_or("all")), + filter.author.as_ref().map_or(String::new(), |a| format!("author:{}", a)), + filter.assignee.as_ref().map_or(String::new(), |a| format!("assignee:{}", a)), + if filter.labels.is_empty() { String::new() } + else { format!("labels:{}", filter.labels.join(",")) }, + ].into_iter().filter(|s| !s.is_empty()).collect::>().join(" "); + + let style = if state.filter_focused { + Style::default().fg(Color::rgb(100, 150, 255)) + } else { + Style::default().fg(Color::rgb(128, 128, 128)) + }; + + let line = Line::from(vec![ + Span::styled(" Filter: ", style.add_modifier(Modifier::BOLD)), + Span::styled(&parts, style), + if state.filter_focused { + Span::styled(" [Tab]apply [Esc]close", Style::default().fg(Color::rgb(80, 80, 80))) + } else { + Span::styled(" [f]edit", Style::default().fg(Color::rgb(80, 80, 80))) + }, + ]); + frame.render_widget(Paragraph::new(line), area); +} + +fn render_table(state: &IssueListState, frame: &mut Frame, area: Rect) { + let bp = LORE_BREAKPOINTS.classify_width(area.width); + + // Column widths adapt to terminal width + let (widths, show_labels, show_author) = match bp { + Breakpoint::Xs | Breakpoint::Sm => ( + vec![Constraint::Fixed(6), Constraint::Min(20), Constraint::Fixed(8), Constraint::Fixed(10)], + false, false, + ), + Breakpoint::Md => ( + vec![ + Constraint::Fixed(6), Constraint::Min(30), Constraint::Fixed(8), + Constraint::Fixed(10), Constraint::Fixed(12), + ], + false, true, + ), + Breakpoint::Lg | Breakpoint::Xl => ( + vec![ + Constraint::Fixed(6), Constraint::Min(30), Constraint::Fixed(8), + Constraint::Fixed(12), Constraint::Fixed(20), Constraint::Fixed(12), + ], + true, true, + ), + }; + + // Header row + let mut header = vec!["IID", "Title", "State", "Updated"]; + if show_author { header.push("Author"); } + if show_labels { header.push("Labels"); } + + // Data rows + let rows: Vec> = state.items.iter().enumerate().map(|(i, issue)| { + let selected = i == state.selected_index; + let state_color = theme::state_color(&issue.state); + + let mut cols = vec![ + Span::raw(format!("{}", issue.iid)), + Span::raw(truncate(&issue.title, 40)), + Span::styled(&issue.state, Style::default().fg(state_color)), + Span::raw(format_relative_ms(issue.updated_at)), + ]; + if show_author { + cols.push(Span::raw(format!("@{}", issue.author_username))); + } + if show_labels { + cols.push(Span::raw(issue.labels.join(", "))); + } + cols + }).collect(); + + // Table widget (StatefulWidget pattern) + let table = Table::new(rows, widths) + .header(header) + .highlight_style( + Style::default().bg(Color::rgb(50, 80, 140)).fg(Color::rgb(255, 255, 255)), + ); + + let mut table_state = TableState::default(); + table_state.select(Some(state.selected_index)); + + frame.render_stateful_widget(table, area, &mut table_state); + + // Row counter + let counter = format!(" {}/{} ", state.selected_index + 1, state.items.len()); + let counter_area = Rect::new( + area.x + area.width - counter.len() as u16 - 1, + area.y + area.height - 1, + counter.len() as u16, + 1, + ); + frame.render_widget( + Paragraph::new(counter).style(Style::default().fg(Color::rgb(128, 128, 128))), + counter_area, + ); +} + +/// All truncation uses `crate::text_width::truncate_display_width()` — terminal +/// display width (via unicode-width), not byte/char length. Handles CJK, emoji, +/// combining marks, and zero-width joiners correctly for column alignment. +fn truncate(s: &str, max: usize) -> String { + crate::text_width::truncate_display_width(s, max) +} + +/// Accepts `now_ms` computed once per frame from the injected Clock. +/// Never calls SystemTime::now() directly — ensures deterministic +/// snapshot tests under FakeClock and consistent labels within one frame. +fn format_relative_ms(ms: i64, now_ms: i64) -> String { + let minutes = ((now_ms - ms) / 60_000).max(0) as u64; + if minutes < 60 { format!("{}m ago", minutes) } + else if minutes < 1440 { format!("{}h ago", minutes / 60) } + else { format!("{}d ago", minutes / 1440) } +} +``` + +### 10.14 Search View + +```rust +// crates/lore-tui/src/view/search.rs + +use ftui::prelude::*; +use ftui::layout::{Flex, Constraint, Breakpoint}; +use ftui::widgets::{Panel, Paragraph, List}; +use ftui::text::{Line, Span}; +use ftui_extras::markdown::MarkdownRenderer; +use crate::tui::state::search::{SearchState, SearchMode}; +use crate::tui::theme; +use crate::tui::view::dashboard::LORE_BREAKPOINTS; + +pub fn render(state: &SearchState, frame: &mut Frame, area: Rect) { + let layout = Flex::vertical() + .constraints([ + Constraint::Fixed(2), // query bar + mode selector + Constraint::Min(0), // results + preview + ]) + .split(area); + + render_query_bar(state, frame, layout[0]); + + let bp = LORE_BREAKPOINTS.classify_width(area.width); + let show_preview = !matches!(bp, Breakpoint::Xs | Breakpoint::Sm); + + if show_preview && !state.results.is_empty() { + // Split pane: results left, preview right + let split = Flex::horizontal() + .constraints([ + Constraint::Percentage(45.0), + Constraint::Percentage(55.0), + ]) + .split(layout[1]); + + render_results(state, frame, split[0]); + render_preview(state, frame, split[1]); + } else { + // Narrow: results only + render_results(state, frame, layout[1]); + } +} + +fn render_query_bar(state: &SearchState, frame: &mut Frame, area: Rect) { + let mode_label = match state.mode { + SearchMode::Hybrid => "hybrid", + SearchMode::Lexical => "lexical", + SearchMode::Semantic => "semantic", + }; + + let lines = vec![ + Line::from(vec![ + Span::styled(" Query: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(state.query_input.value()), + if state.query_focused { + Span::styled("_", Style::default().add_modifier(Modifier::SLOW_BLINK)) + } else { Span::raw("") }, + ]), + Line::from(vec![ + Span::styled(" Mode: ", Style::default().fg(Color::rgb(128, 128, 128))), + Span::styled( + format!("[{}]", mode_label), + Style::default().fg(Color::rgb(100, 150, 255)), + ), + Span::styled(" Type: ", Style::default().fg(Color::rgb(128, 128, 128))), + Span::styled( + format!("[{}]", state.source_type.as_deref().unwrap_or("all")), + Style::default().fg(Color::rgb(100, 150, 255)), + ), + ]), + ]; + frame.render_widget(Paragraph::new(lines), area); +} + +fn render_results(state: &SearchState, frame: &mut Frame, area: Rect) { + let header = format!( + " Results ({} matches) ", + state.total_results, + ); + + let items: Vec = state.results.iter().enumerate().map(|(i, result)| { + let selected = i == state.selected_index; + let prefix = match result.source_type.as_str() { + "issue" => "#", + "merge_request" => "!", + "discussion" => "@", + _ => "", + }; + let style = if selected { + Style::default().bg(Color::rgb(50, 80, 140)).fg(Color::rgb(255, 255, 255)) + } else { + Style::default() + }; + + Line::from(vec![ + Span::styled(format!("{} {}{} ", if selected { ">" } else { " " }, prefix, result.title), style), + ]) + }).collect(); + + let list = List::new(items); + let panel = Panel::new(list).title(header); + frame.render_widget(panel, area); +} + +fn render_preview(state: &SearchState, frame: &mut Frame, area: Rect) { + let Some(result) = state.results.get(state.selected_index) else { + frame.render_widget(Paragraph::new("(no selection)"), area); + return; + }; + + let mut lines = vec![ + Line::from(Span::styled( + &result.title, + Style::default().add_modifier(Modifier::BOLD), + )), + Line::raw(""), + Line::from(vec![ + Span::styled("Type: ", dim()), + Span::raw(&result.source_type), + Span::raw(" "), + Span::styled("Score: ", dim()), + Span::raw(format!("{:.2}", result.score)), + ]), + Line::from(vec![ + Span::styled("Project: ", dim()), + Span::raw(&result.project_path), + ]), + ]; + + if let Some(author) = &result.author { + lines.push(Line::from(vec![ + Span::styled("Author: ", dim()), + Span::raw(format!("@{}", author)), + ])); + } + + if !result.labels.is_empty() { + lines.push(Line::from(vec![ + Span::styled("Labels: ", dim()), + Span::raw(result.labels.join(", ")), + ])); + } + + lines.push(Line::raw("")); + lines.push(Line::styled("-- Snippet --", dim())); + + // Snippet text — wrap to available width + for snippet_line in result.snippet.lines() { + lines.push(Line::raw(snippet_line.to_string())); + } + + let para = Paragraph::new(lines).wrap(ftui::widgets::paragraph::Wrap { trim: false }); + let panel = Panel::new(para).title(" Preview "); + frame.render_widget(panel, area); +} + +fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) } +``` + +### 10.15 Timeline View + +```rust +// crates/lore-tui/src/view/timeline.rs + +use ftui::prelude::*; +use ftui::layout::{Flex, Constraint}; +use ftui::widgets::{Panel, Paragraph, List}; +use ftui::text::{Line, Span}; +use crate::tui::state::timeline::TimelineState; +use crate::tui::theme; + +pub fn render(state: &TimelineState, frame: &mut Frame, area: Rect) { + let layout = Flex::vertical() + .constraints([ + Constraint::Fixed(2), // query + controls + Constraint::Fixed(1), // seed summary + Constraint::Min(0), // event stream + ]) + .split(area); + + // Query bar + controls + let controls = Line::from(vec![ + Span::styled(" Query: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(state.query_input.value()), + Span::raw(" "), + Span::styled("Depth: ", dim()), + Span::styled(format!("{}", state.depth), normal()), + Span::styled(" Since: ", dim()), + Span::styled(format!("{}d", state.since_days), normal()), + ]); + frame.render_widget(Paragraph::new(controls), layout[0]); + + // Seed summary + let seed_line = Line::from(vec![ + Span::styled(" Seeds: ", dim()), + Span::raw(state.seeds.join(", ")), + Span::styled(format!(" Events: {}", state.events.len()), dim()), + ]); + frame.render_widget(Paragraph::new(seed_line), layout[1]); + + // Event stream — virtualized for large timelines + let items: Vec = state.events.iter().enumerate().map(|(i, event)| { + let selected = i == state.selected_index; + let type_color = theme::event_type_color(&event.event_type); + + let prefix = if selected { ">" } else { " " }; + + Line::from(vec![ + Span::raw(format!("{} ", prefix)), + Span::styled( + format!("{:<16}", event.timestamp_display), + Style::default().fg(Color::rgb(100, 100, 100)), + ), + Span::styled( + format!("{:<10}", event.event_type.to_uppercase()), + Style::default().fg(type_color).add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" {} ", event.entity_label)), + Span::styled( + truncate(&event.summary, 50), + if selected { + Style::default().bg(Color::rgb(50, 80, 140)).fg(Color::rgb(255, 255, 255)) + } else { + Style::default() + }, + ), + ]) + }).collect(); + + let list = List::new(items); + let panel = Panel::new(list).title(" Timeline "); + frame.render_widget(panel, layout[2]); +} + +/// All truncation uses `crate::text_width::truncate_display_width()` — terminal +/// display width (via unicode-width), not byte/char length. Handles CJK, emoji, +/// combining marks, and zero-width joiners correctly for column alignment. +fn truncate(s: &str, max: usize) -> String { + crate::text_width::truncate_display_width(s, max) +} + +fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) } +fn normal() -> Style { Style::default().fg(Color::rgb(220, 220, 220)) } +``` + +### 10.16 Who View — Multi-Mode People Explorer + +```rust +// crates/lore-tui/src/view/who.rs + +use ftui::prelude::*; +use ftui::layout::{Flex, Constraint}; +use ftui::widgets::{Panel, Paragraph, Table, TableState}; +use ftui::text::{Line, Span}; +use ftui_extras::charts::BarChart; +use crate::tui::state::who::{WhoState, WhoMode}; +use crate::cli::commands::who::{WhoResult, Expert, WorkloadResult}; +use crate::tui::theme; + +pub fn render(state: &WhoState, frame: &mut Frame, area: Rect) { + let layout = Flex::vertical() + .constraints([ + Constraint::Fixed(1), // mode selector + path input + Constraint::Min(0), // results + ]) + .split(area); + + render_mode_bar(state, frame, layout[0]); + + match (&state.mode, &state.result) { + (WhoMode::Expert, Some(WhoResult::Expert(r))) => render_expert(r, state, frame, layout[1]), + (WhoMode::Workload, Some(WhoResult::Workload(r))) => render_workload(r, state, frame, layout[1]), + (WhoMode::Reviews, Some(WhoResult::Reviews(r))) => render_reviews(r, state, frame, layout[1]), + (WhoMode::Active, Some(WhoResult::Active(r))) => render_active(r, state, frame, layout[1]), + (WhoMode::Overlap, Some(WhoResult::Overlap(r))) => render_overlap(r, state, frame, layout[1]), + _ => { + frame.render_widget( + Paragraph::new(" Enter a path or username to search..."), + layout[1], + ); + } + } +} + +fn render_mode_bar(state: &WhoState, frame: &mut Frame, area: Rect) { + let modes = ["Expert", "Workload", "Reviews", "Active", "Overlap"]; + let current = match state.mode { + WhoMode::Expert => 0, + WhoMode::Workload => 1, + WhoMode::Reviews => 2, + WhoMode::Active => 3, + WhoMode::Overlap => 4, + }; + + let spans: Vec = modes.iter().enumerate().flat_map(|(i, label)| { + let mut v = Vec::new(); + if i > 0 { v.push(Span::raw(" | ")); } + if i == current { + v.push(Span::styled( + format!("[{}]", label), + Style::default().add_modifier(Modifier::BOLD).fg(Color::rgb(100, 150, 255)), + )); + } else { + v.push(Span::styled(*label, Style::default().fg(Color::rgb(128, 128, 128)))); + } + v + }).collect(); + + let mut line_spans = vec![Span::styled(" Mode: ", dim())]; + line_spans.extend(spans); + + // Add path/username input based on mode + match state.mode { + WhoMode::Expert | WhoMode::Overlap => { + line_spans.push(Span::raw(" Path: ")); + line_spans.push(Span::raw(state.path_input.value())); + } + WhoMode::Workload | WhoMode::Reviews => { + line_spans.push(Span::raw(" User: ")); + line_spans.push(Span::raw(state.username_input.value())); + } + _ => {} + } + + frame.render_widget(Paragraph::new(Line::from(line_spans)), area); +} + +fn render_expert( + result: &ExpertResult, state: &WhoState, frame: &mut Frame, area: Rect, +) { + let split = Flex::horizontal() + .constraints([Constraint::Percentage(60.0), Constraint::Percentage(40.0)]) + .split(area); + + // Expert table + let widths = vec![ + Constraint::Fixed(4), Constraint::Fixed(12), Constraint::Fixed(6), + Constraint::Fixed(8), Constraint::Fixed(8), Constraint::Fixed(6), + ]; + let header = vec!["Rank", "Person", "Score", "Authored", "Reviewed", "Notes"]; + let rows: Vec> = result.experts.iter().enumerate().map(|(i, e)| { + vec![ + Span::raw(format!("{}", i + 1)), + Span::styled(format!("@{}", e.username), Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", e.score)), + Span::raw(format!("{} MRs", e.author_mr_count)), + Span::raw(format!("{} MRs", e.review_mr_count)), + Span::raw(format!("{}", e.review_note_count)), + ] + }).collect(); + + let table = Table::new(rows, widths) + .header(header) + .highlight_style(Style::default().bg(Color::rgb(50, 80, 140))); + + let mut ts = TableState::default(); + ts.select(Some(state.selected_index)); + + let panel_left = Panel::new(table) + .title(format!(" Expert Rankings for {} ", result.path_query)); + frame.render_stateful_widget(panel_left, split[0], &mut ts); + + // Detail panel for selected expert (code area bar chart) + if let Some(expert) = result.experts.get(state.selected_index) { + render_expert_detail(expert, frame, split[1]); + } +} + +fn render_expert_detail(expert: &Expert, frame: &mut Frame, area: Rect) { + // Show expert's MR references as a simple list + let mut lines: Vec = vec![ + Line::from(Span::styled( + format!("@{} -- Detail", expert.username), + Style::default().add_modifier(Modifier::BOLD), + )), + Line::raw(""), + ]; + + if let Some(details) = &expert.details { + for d in details.iter().take(10) { + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", d.mr_ref), Style::default().fg(Color::rgb(100, 150, 255))), + Span::raw(truncate(&d.title, 30)), + Span::styled(format!(" [{}]", d.role), dim()), + ])); + } + } + + let para = Paragraph::new(lines); + let panel = Panel::new(para).title(format!(" @{} ", expert.username)); + frame.render_widget(panel, area); +} + +fn render_workload( + result: &WorkloadResult, state: &WhoState, frame: &mut Frame, area: Rect, +) { + let layout = Flex::vertical() + .constraints([ + Constraint::Percentage(33.0), // assigned issues + Constraint::Percentage(33.0), // authored MRs + Constraint::Percentage(34.0), // reviewing MRs + ]) + .split(area); + + // Assigned issues section + let issue_lines: Vec = result.assigned_issues.iter().map(|i| { + Line::from(vec![ + Span::raw(format!(" {} ", i.ref_)), + Span::raw(truncate(&i.title, 40)), + ]) + }).collect(); + let panel = Panel::new(Paragraph::new(issue_lines)) + .title(format!(" Assigned Issues ({}) ", result.assigned_issues.len())); + frame.render_widget(panel, layout[0]); + + // Authored MRs section + let mr_lines: Vec = result.authored_mrs.iter().map(|m| { + Line::from(vec![ + Span::raw(format!(" {} ", m.ref_)), + if m.draft { Span::styled("[DRAFT] ", Style::default().fg(Color::rgb(220, 180, 50))) } + else { Span::raw("") }, + Span::raw(truncate(&m.title, 40)), + ]) + }).collect(); + let panel = Panel::new(Paragraph::new(mr_lines)) + .title(format!(" Authored MRs ({}) ", result.authored_mrs.len())); + frame.render_widget(panel, layout[1]); + + // Reviewing MRs section + let review_lines: Vec = result.reviewing_mrs.iter().map(|m| { + Line::from(vec![ + Span::raw(format!(" {} ", m.ref_)), + Span::raw(truncate(&m.title, 40)), + ]) + }).collect(); + let panel = Panel::new(Paragraph::new(review_lines)) + .title(format!(" Reviewing ({}) ", result.reviewing_mrs.len())); + frame.render_widget(panel, layout[2]); +} + +fn render_reviews(result: &ReviewsResult, state: &WhoState, frame: &mut Frame, area: Rect) { + let layout = Flex::vertical() + .constraints([ + Constraint::Fixed(3), // summary header + Constraint::Min(0), // category breakdown table + ]) + .split(area); + + // Summary header + let summary = Paragraph::new(vec![ + Line::from(vec![ + Span::styled("Reviewer: ", dim()), + Span::styled( + format!("@{}", result.username), + Style::default().add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled("Total diff notes: ", dim()), + Span::raw(format!("{}", result.total_diffnotes)), + Span::raw(" "), + Span::styled("MRs reviewed: ", dim()), + Span::raw(format!("{}", result.mrs_reviewed)), + Span::raw(" "), + Span::styled("Categorized: ", dim()), + Span::raw(format!("{}", result.categorized_count)), + ]), + ]); + frame.render_widget(summary, layout[0]); + + // Category breakdown as a table + let widths = [ + Constraint::Min(20), // Category name + Constraint::Fixed(8), // Count + Constraint::Fixed(8), // Percentage + Constraint::Min(20), // Visual bar + ]; + let rows: Vec> = result.categories.iter().map(|cat| { + let bar_len = (cat.percentage * 0.3) as usize; // 30-char max bar + vec![ + Span::raw(&cat.name), + Span::raw(format!("{:>5}", cat.count)), + Span::raw(format!("{:>5.1}%", cat.percentage)), + Span::styled( + "\u{2588}".repeat(bar_len), + Style::default().fg(Color::rgb(100, 180, 255)), + ), + ] + }).collect(); + + let table = Table::new(rows, widths) + .header(["Category", "Count", "%", ""].map(|h| + Span::styled(h, Style::default().add_modifier(Modifier::BOLD)) + )); + let panel = Panel::new(table).title(" Review Category Breakdown "); + frame.render_widget(panel, layout[1]); +} + +fn render_active(result: &ActiveResult, state: &WhoState, frame: &mut Frame, area: Rect) { + let layout = Flex::vertical() + .constraints([ + Constraint::Fixed(2), // summary + Constraint::Min(0), // discussion list + ]) + .split(area); + + // Summary header + let truncated_indicator = if result.truncated { " (truncated)" } else { "" }; + let summary = Paragraph::new(vec![ + Line::from(vec![ + Span::styled("Active discussions: ", dim()), + Span::raw(format!("{}", result.discussions.len())), + Span::raw(truncated_indicator), + Span::raw(" "), + Span::styled("Unresolved in window: ", dim()), + Span::styled( + format!("{}", result.total_unresolved_in_window), + Style::default().fg(Color::rgb(220, 100, 100)), + ), + ]), + ]); + frame.render_widget(summary, layout[0]); + + // Discussion list with detail + let widths = [ + Constraint::Fixed(8), // Entity ref (type + iid) + Constraint::Min(25), // Entity title + Constraint::Fixed(12), // Project + Constraint::Fixed(6), // Notes + Constraint::Fixed(6), // Participants + Constraint::Fixed(10), // Last activity + ]; + let rows: Vec> = result.discussions.iter().map(|d| { + let entity_sigil = if d.entity_type == "issue" { "#" } else { "!" }; + let participants_label = if d.participants_truncated { + format!("{}+", d.participants_total) + } else { + format!("{}", d.participants.len()) + }; + vec![ + Span::raw(format!("{}{}", entity_sigil, d.entity_iid)), + Span::raw(truncate(&d.entity_title, 35)), + Span::styled(truncate(&d.project_path, 11), dim()), + Span::raw(format!("{:>3}", d.note_count)), + Span::raw(format!("{:>3}", participants_label)), + Span::raw(format_relative_ms(d.last_note_at)), + ] + }).collect(); + + let table = Table::new(rows, widths) + .header(["Ref", "Title", "Project", "Notes", "People", "Last"].map(|h| + Span::styled(h, Style::default().add_modifier(Modifier::BOLD)) + )) + .highlight_style(Style::default().bg(Color::rgb(50, 80, 140))); + + // Panel for border + title, then render stateful table into inner area + let border = Panel::new(Paragraph::new("")).title(" Active Discussions "); + let inner = border.inner(layout[1]); + frame.render_widget(border, layout[1]); + + let mut ts = TableState::default(); + ts.select(Some(state.selected_index)); + frame.render_stateful_widget(table, inner, &mut ts); +} + +fn render_overlap(result: &OverlapResult, state: &WhoState, frame: &mut Frame, area: Rect) { + let layout = Flex::vertical() + .constraints([ + Constraint::Fixed(2), // path query header + Constraint::Min(0), // user overlap table + ]) + .split(area); + + // Path query header + let truncated_indicator = if result.truncated { " (truncated)" } else { "" }; + let header = Paragraph::new(vec![ + Line::from(vec![ + Span::styled("Path: ", dim()), + Span::styled( + &result.path_query, + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" ({}){}", result.path_match, truncated_indicator), dim()), + ]), + ]); + frame.render_widget(header, layout[0]); + + // User overlap table — shows who has touched this file path + let widths = [ + Constraint::Fixed(16), // Username + Constraint::Fixed(8), // Total touches + Constraint::Fixed(8), // Author touches + Constraint::Fixed(8), // Review touches + Constraint::Fixed(10), // Last seen + Constraint::Min(20), // MR refs + ]; + let rows: Vec> = result.users.iter().map(|u| { + let mr_summary = if u.mr_refs_truncated { + format!("{} (+{})", u.mr_refs.first().map_or("", String::as_str), u.mr_refs_total - 1) + } else { + u.mr_refs.join(", ") + }; + vec![ + Span::styled( + format!("@{}", u.username), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{:>5}", u.touch_count)), + Span::raw(format!("{:>5}", u.author_touch_count)), + Span::raw(format!("{:>5}", u.review_touch_count)), + Span::raw(format_relative_ms(u.last_seen_at)), + Span::styled(truncate(&mr_summary, 30), dim()), + ] + }).collect(); + + let table = Table::new(rows, widths) + .header(["User", "Total", "Author", "Review", "Last Seen", "MR Refs"].map(|h| + Span::styled(h, Style::default().add_modifier(Modifier::BOLD)) + )) + .highlight_style(Style::default().bg(Color::rgb(50, 80, 140))); + + let mut ts = TableState::default(); + let panel = Panel::new(table).title(" File Overlap "); + frame.render_widget(panel, layout[1]); +} + +/// All truncation uses `crate::text_width::truncate_display_width()` — terminal +/// display width (via unicode-width), not byte/char length. Handles CJK, emoji, +/// combining marks, and zero-width joiners correctly for column alignment. +fn truncate(s: &str, max: usize) -> String { + crate::text_width::truncate_display_width(s, max) +} + +fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) } +``` + +### 10.17 MR List View + +Structurally identical to Issue List (Section 10.13) with MR-specific columns: + +```rust +// crates/lore-tui/src/view/mr_list.rs — same pattern as issue_list.rs + +// Key differences from issue_list: +// 1. Columns: IID, Title, State, Draft, Source, Target, Author, Updated +// 2. Draft column shows badge when true +// 3. State includes "merged" and "locked" variants +// 4. Filter bar shows MR-specific fields (reviewer, draft, target-branch) + +pub fn render(state: &MrListState, frame: &mut Frame, area: Rect) { + // Same 2-row layout: filter bar + table + let layout = Flex::vertical() + .constraints([Constraint::Fixed(1), Constraint::Min(0)]) + .split(area); + + render_filter_bar(state, frame, layout[0]); + render_table(state, frame, layout[1]); +} + +fn render_table(state: &MrListState, frame: &mut Frame, area: Rect) { + let bp = LORE_BREAKPOINTS.classify_width(area.width); + + // Narrow: IID, Title, State, Updated + // Medium: + Draft, Author + // Wide: + Source, Target + let widths = match bp { + Breakpoint::Xs | Breakpoint::Sm => vec![ + Constraint::Fixed(6), Constraint::Min(20), + Constraint::Fixed(8), Constraint::Fixed(10), + ], + Breakpoint::Md => vec![ + Constraint::Fixed(6), Constraint::Min(25), Constraint::Fixed(8), + Constraint::Fixed(5), Constraint::Fixed(10), Constraint::Fixed(12), + ], + Breakpoint::Lg | Breakpoint::Xl => vec![ + Constraint::Fixed(6), Constraint::Min(25), Constraint::Fixed(8), + Constraint::Fixed(5), Constraint::Fixed(15), Constraint::Fixed(15), + Constraint::Fixed(12), Constraint::Fixed(10), + ], + }; + + // Build rows from state.items (Vec) + let rows: Vec> = state.items.iter().map(|mr| { + let mut cols = vec![ + Span::raw(format!("{}", mr.iid)), + Span::raw(truncate(&mr.title, 35)), + Span::styled(&mr.state, Style::default().fg(theme::state_color(&mr.state))), + ]; + if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) { + cols.push(if mr.draft { + Span::styled("WIP", Style::default().fg(Color::rgb(220, 180, 50))) + } else { Span::raw("") }); + cols.push(Span::raw(format_relative_ms(mr.updated_at))); + cols.push(Span::raw(format!("@{}", mr.author_username))); + } + if matches!(bp, Breakpoint::Lg | Breakpoint::Xl) { + cols.push(Span::raw(truncate(&mr.source_branch, 14))); + cols.push(Span::raw(truncate(&mr.target_branch, 14))); + } + cols + }).collect(); + + let table = Table::new(rows, widths) + .highlight_style(Style::default().bg(Color::rgb(50, 80, 140))); + + let mut ts = TableState::default(); + ts.select(Some(state.selected_index)); + frame.render_stateful_widget(table, area, &mut ts); +} +``` + +### 10.18 MR Detail View + +```rust +// crates/lore-tui/src/view/mr_detail.rs — extends issue_detail pattern with file changes and diff discussions + +pub fn render(state: &MrDetailState, frame: &mut Frame, area: Rect) { + let Some(detail) = &state.detail else { + frame.render_widget(Paragraph::new("Loading..."), area); + return; + }; + + let layout = Flex::vertical() + .constraints([ + Constraint::Fixed(5), // header (title + branches + pipeline) + Constraint::Percentage(20.0), // description + Constraint::Fixed(6), // file changes + Constraint::Min(0), // discussions (diff + general) + ]) + .split(area); + + render_header(detail, frame, layout[0]); + render_description(detail, frame, layout[1]); + render_file_changes(state, frame, layout[2]); + render_discussions(state, frame, layout[3]); +} + +fn render_header(detail: &MrDetail, frame: &mut Frame, area: Rect) { + let state_color = theme::state_color(&detail.state); + let lines = vec![ + Line::from(vec![ + Span::styled( + format!("!{} ", detail.iid), + Style::default().fg(state_color).add_modifier(Modifier::BOLD), + ), + Span::styled(&detail.title, Style::default().add_modifier(Modifier::BOLD)), + if detail.draft { + Span::styled(" [DRAFT]", Style::default().fg(Color::rgb(220, 180, 50))) + } else { Span::raw("") }, + ]), + Line::from(vec![ + Span::styled("Author: ", dim()), + Span::raw(format!("@{}", detail.author_username)), + Span::raw(" "), + Span::styled("Reviewers: ", dim()), + Span::raw(detail.reviewers.join(", ")), + ]), + Line::from(vec![ + Span::styled("Source: ", dim()), + Span::raw(&detail.source_branch), + Span::styled(" -> Target: ", dim()), + Span::raw(&detail.target_branch), + ]), + Line::from(vec![ + Span::styled("Labels: ", dim()), + Span::raw(detail.labels.join(", ")), + ]), + Line::from(vec![ + Span::styled("State: ", dim()), + Span::styled(&detail.state, Style::default().fg(state_color)), + if let Some(merged_at) = detail.merged_at { + Span::styled(format!(" Merged: {}", format_relative_ms(merged_at)), dim()) + } else { Span::raw("") }, + ]), + ]; + frame.render_widget(Paragraph::new(lines), area); +} + +fn render_file_changes(state: &MrDetailState, frame: &mut Frame, area: Rect) { + let lines: Vec = state.file_changes.iter().map(|f| { + let indicator = match f.change_type.as_str() { + "A" => Span::styled("A", Style::default().fg(Color::rgb(50, 180, 80))), + "D" => Span::styled("D", Style::default().fg(Color::rgb(220, 50, 50))), + "M" => Span::styled("M", Style::default().fg(Color::rgb(220, 180, 50))), + "R" => Span::styled("R", Style::default().fg(Color::rgb(130, 80, 220))), + _ => Span::raw(&f.change_type), + }; + Line::from(vec![ + Span::raw(" "), + indicator, + Span::raw(format!(" {}", f.path)), + Span::styled( + format!(" +{} -{}", f.additions, f.deletions), + Style::default().fg(Color::rgb(128, 128, 128)), + ), + ]) + }).collect(); + + let panel = Panel::new(Paragraph::new(lines)) + .title(format!(" File Changes ({}) ", state.file_changes.len())); + frame.render_widget(panel, area); +} + +fn render_discussions(state: &MrDetailState, frame: &mut Frame, area: Rect) { + // Split discussions into diff discussions (with file context) and general + let split = Flex::vertical() + .constraints([Constraint::Percentage(60.0), Constraint::Percentage(40.0)]) + .split(area); + + // Diff discussions — grouped by file, using Tree widget + let mut root = TreeNode::new(format!( + "Diff Discussions ({})", + state.diff_discussions.len(), + )); + + for dd in &state.diff_discussions { + let header = format!( + "@{} on {}:{} {}", + dd.discussion.author, + dd.file_path, + dd.line.map_or("?".to_string(), |l| l.to_string()), + dd.discussion.relative_age, + ); + root = root.child(TreeNode::new(header).with_expanded(dd.discussion.expanded)); + } + + let tree = Tree::new(root).with_show_root(true) + .with_guides(ftui::widgets::tree::TreeGuides::Rounded); + let panel = Panel::new(tree); + frame.render_widget(panel, split[0]); + + // General discussions — same Tree pattern as issue_detail + let mut gen_root = TreeNode::new(format!( + "General Discussions ({})", + state.general_discussions.len(), + )); + for disc in &state.general_discussions { + let node = TreeNode::new(format!( + "{} ({} replies) {}", + disc.author, disc.reply_count, disc.relative_age, + )).with_expanded(disc.expanded); + gen_root = gen_root.child(node); + } + + let tree = Tree::new(gen_root).with_show_root(true) + .with_guides(ftui::widgets::tree::TreeGuides::Rounded); + let panel = Panel::new(tree); + frame.render_widget(panel, split[1]); +} + +fn dim() -> Style { Style::default().fg(Color::rgb(128, 128, 128)) } +``` + +### 10.19 CLI Integration — Adding `lore tui` (Runtime Binary Delegation) + +The `lore` binary has **zero compile-time dependency** on `lore-tui`. Instead, `lore tui` resolves and spawns the `lore-tui` binary at runtime. This keeps the root crate on stable Rust and eliminates the Cargo dependency cycle. + +```rust +// Changes to existing files to wire up the TUI command. + +// ── src/cli/mod.rs ────────────────────────────────────────────────── + +// Add to the Commands enum: +#[derive(Debug, Subcommand)] +pub enum Commands { + // ... existing variants ... + + /// Launch interactive TUI + Tui { + /// Start directly on sync screen (inline mode) + #[arg(long)] + sync: bool, + + /// Start without restoring prior session state + #[arg(long)] + fresh: bool, + + /// Rendering mode (auto-detects by default) + #[arg(long, default_value = "auto")] + render_mode: String, + + /// Force ASCII-safe glyphs + #[arg(long)] + ascii: bool, + + /// Stay in normal terminal (no alternate screen) + #[arg(long)] + no_alt_screen: bool, + }, +} + +// ── src/main.rs ───────────────────────────────────────────────────── + +// NO `mod tui;` — lore has no compile-time knowledge of lore-tui. + +// Add match arm in main dispatch: +fn main() -> Result<()> { + let cli = Cli::parse(); + + match &cli.command { + // ... existing arms ... + + Commands::Tui { sync, fresh, render_mode, ascii, no_alt_screen } => { + // Resolve lore-tui binary: check PATH, then adjacent to current exe. + let tui_bin = resolve_tui_binary()?; + + // Compatibility contract handshake: validate lore-tui version before launch. + // This catches version drift between the two binaries, which is the #1 + // support failure mode for runtime delegation architectures. + validate_tui_compat(&tui_bin)?; + + let mut cmd = std::process::Command::new(&tui_bin); + // Pass through config path if specified + if let Some(config_path) = &cli.config { + cmd.arg("--config").arg(config_path); + } + // Pass through TUI-specific flags + if *sync { cmd.arg("--sync"); } + if *fresh { cmd.arg("--fresh"); } + if render_mode != "auto" { + cmd.arg("--render-mode").arg(render_mode); + } + if *ascii { cmd.arg("--ascii"); } + if *no_alt_screen { cmd.arg("--no-alt-screen"); } + + let status = cmd.status().map_err(|e| { + anyhow::anyhow!("Failed to launch lore-tui: {e}") + })?; + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + } + } + + Ok(()) +} + +/// Resolve the lore-tui binary location. +/// Search order: 1) PATH, 2) adjacent to current executable. +fn resolve_tui_binary() -> Result { + // Check PATH first + if let Ok(path) = which::which("lore-tui") { + return Ok(path); + } + // Check adjacent to current exe (common for cargo install) + if let Ok(exe) = std::env::current_exe() { + let adjacent = exe.with_file_name("lore-tui"); + if adjacent.exists() { + return Ok(adjacent); + } + } + Err(anyhow::anyhow!( + "lore-tui binary not found.\n\ + Build with: cargo install --path crates/lore-tui\n\ + Or ensure lore-tui is in your PATH." + )) +} + +/// Minimum compat version this `lore` binary requires from `lore-tui`. +const MIN_TUI_COMPAT_VERSION: u32 = 1; + +/// Validate compatibility contract with lore-tui before launch. +/// Runs `lore-tui --compat-version` and checks the returned integer +/// against MIN_TUI_COMPAT_VERSION. +fn validate_tui_compat(tui_bin: &std::path::Path) -> Result<()> { + let output = std::process::Command::new(tui_bin) + .arg("--compat-version") + .output() + .map_err(|e| anyhow::anyhow!("Failed to check lore-tui compatibility: {e}"))?; + + if !output.status.success() { + // Old lore-tui that doesn't support --compat-version: warn but allow + eprintln!("warning: lore-tui does not support --compat-version; skipping compat check"); + return Ok(()); + } + + let version_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let version: u32 = version_str.parse().map_err(|_| { + anyhow::anyhow!("lore-tui returned invalid compat version: {version_str}") + })?; + + if version < MIN_TUI_COMPAT_VERSION { + Err(anyhow::anyhow!( + "lore-tui is too old (compat v{version}, need v{MIN_TUI_COMPAT_VERSION}).\n\ + Rebuild with: cargo install --path crates/lore-tui" + )) + } else { + Ok(()) + } +} +``` + +**Also add `lore sync --tui` convenience flag:** + +```rust +// In src/cli/mod.rs, modify existing Sync command: +#[derive(Debug, Args)] +pub struct SyncArgs { + // ... existing fields ... + + /// Show sync progress in interactive TUI (inline mode) + #[arg(long)] + pub tui: bool, +} + +// In src/cli/commands/sync.rs, handle the flag: +pub fn run_sync(config: &Config, args: &SyncArgs) -> Result<()> { + if args.tui { + // Delegate to lore-tui binary with --sync flag + let tui_bin = resolve_tui_binary()?; + let status = std::process::Command::new(&tui_bin) + .arg("--sync") + .status()?; + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + return Ok(()); + } + // ... existing sync logic ... +} +``` + +### 10.20 Refactor: Extract Query Functions + +Currently, some commands execute queries and format output in the same function. For the TUI, we need the query layer separated from the display layer. + +**Pattern to apply across all commands:** + +```rust +// BEFORE (coupled) +pub fn run_list_issues(config: &Config, args: &IssueArgs) -> Result<()> { + let conn = open_db(&config)?; + let rows = query_issues(&conn, &args.filter)?; + print_issue_table(&rows, args.robot); + Ok(()) +} + +// AFTER (decoupled) +pub fn query_issues(conn: &Connection, filter: &IssueFilter) -> Result> { + // Pure data query, no I/O +} + +pub fn run_list_issues(config: &Config, args: &IssueArgs) -> Result<()> { + let conn = open_db(&config)?; + let rows = query_issues(&conn, &args.filter)?; + print_issue_table(&rows, args.robot); + Ok(()) +} +``` + +**Files requiring this refactor:** + +| File | Extract | Used By | +|------|---------|---------| +| `src/cli/commands/list.rs` | `query_issues()`, `query_mrs()` | IssueList, MrList | +| `src/cli/commands/show.rs` | `query_issue_detail()`, `query_mr_detail()`, `query_discussions()` | IssueDetail, MrDetail | +| `src/cli/commands/search.rs` (if exists) | `execute_search()` | Search | +| `src/cli/commands/who.rs` | `query_experts()`, `query_workload()`, `query_reviews()` | Who | +| Timeline modules | Already pipeline-based (good) | Timeline | +| Ingestion orchestrator | Already has `ProgressCallback` (good) | Sync | + +--- + +## Appendix A: Full Architecture Diagram + +```mermaid +graph TB + subgraph "FrankenTUI Kernel" + FC[ftui-core
Events + Terminal] + FR[ftui-render
Buffer + Diff + Presenter] + FRT[ftui-runtime
Elm Event Loop] + FL[ftui-layout
Flex + Grid + Responsive] + FS[ftui-style
Theme + WCAG] + FW[ftui-widgets
Table, Tree, etc.] + FE[ftui-extras
Charts, Forms, etc.] + FH[ftui-harness
Snapshot Tests] + end + + subgraph "Gitlore TUI Module (new)" + direction TB + APP[LoreApp
impl Model] + APP -->|update| ST[State Tree] + APP -->|view| VW[View Router] + APP -->|Cmd::task| AR[Action Runner] + + subgraph "State" + ST --> DS[Dashboard] + ST --> IL[Issue List] + ST --> ID[Issue Detail] + ST --> ML[MR List] + ST --> MD[MR Detail] + ST --> SS[Search] + ST --> TL[Timeline] + ST --> WH[Who] + ST --> SY[Sync] + ST --> CP[Cmd Palette] + end + + subgraph "Views" + VW --> DV[Dashboard] + VW --> IV[Issues] + VW --> MV[MRs] + VW --> SV[Search] + VW --> TV[Timeline] + VW --> WV[Who] + VW --> YV[Sync] + VW --> PV[Palette] + end + end + + subgraph "Existing Modules (unchanged)" + direction TB + DB[(SQLite DB)] + GL[GitLab Client] + ING[Ingestion] + DOC[Documents] + EMB[Embedding] + SCH[Search Engine] + TIM[Timeline Pipeline] + WHO2[Who Queries] + end + + FC -->|events| FRT + FRT -->|drives| APP + VW -->|widgets| FW + VW -->|extras| FE + FW -->|render| FR + FR -->|layout| FL + FR -->|style| FS + AR -->|queries| DB + AR -->|sync| ING + AR -->|search| SCH + AR -->|timeline| TIM + AR -->|who| WHO2 + ING -->|fetch| GL + ING -->|store| DB + DOC -->|index| DB + EMB -->|vectors| DB + + style APP fill:#ff6b6b,stroke:#333,color:#fff + style ST fill:#4a9eff,stroke:#333,color:#fff + style VW fill:#51cf66,stroke:#333,color:#fff + style DB fill:#ffd43b,stroke:#333,color:#000 + style FRT fill:#9775fa,stroke:#333,color:#fff +``` + +--- + +## Appendix B: State Machine — Filter Bar + +```mermaid +stateDiagram-v2 + [*] --> Inactive + + Inactive --> Active: f (focus) or Tab + Active --> FieldSelect: type ':' + Active --> FreeText: type any char + + FieldSelect --> ValueInput: select field + FreeText --> Inactive: Enter (apply) or Esc (cancel) + + ValueInput --> Inactive: Enter (apply filter) + ValueInput --> Active: Backspace past ':' + + state Active { + [*] --> Typing + Typing --> Suggesting: pause 200ms + Suggesting --> Typing: resume typing + } + + state FieldSelect { + [*] --> ShowFields + ShowFields: state, author, assignee, label, milestone, since, project + } + + state ValueInput { + [*] --> ShowValues + ShowValues: context-dependent completions + } +``` + +--- + +## Appendix C: Responsive Layout Breakpoints + +| Terminal Width | Layout Adaptation | +|---------------|-------------------| +| < 80 cols | Single-column, abbreviated headers, hide secondary columns | +| 80-120 cols | Standard layout (as shown in mockups) | +| 120-200 cols | Wider columns, show all fields, split pane previews | +| > 200 cols | Triple-column layout on search/who screens | + +**Implementation:** FrankenTUI's native `Responsive` with `Breakpoint` enum. Define project-wide breakpoints once: + +```rust +use ftui::layout::{Breakpoints, Breakpoint, Responsive, Flex, Constraint}; + +// Define project-wide breakpoints once (used across all views). +// Breakpoints::new(sm, md, lg) — xl defaults to lg + 40. +// Xs is always < sm (i.e., < 60 cols). +pub const LORE_BREAKPOINTS: Breakpoints = Breakpoints::new(60, 90, 120); + +// Classify terminal width into a breakpoint bucket: +let bp = LORE_BREAKPOINTS.classify_width(area.width); + +// Responsive provides breakpoint-aware values with inheritance. +// .new(base) sets the Xs value. .at(bp, val) sets overrides. +// .resolve(bp) walks downward until finding an explicit value. +let columns: u16 = Responsive::new(1_u16) // Xs → 1 column + .at(Breakpoint::Md, 2) // Md+ → 2 columns + .at(Breakpoint::Lg, 3) // Lg+ → 3 columns + .resolve_cloned(bp); + +// Visibility control: hide secondary panels on narrow terminals. +let show_preview: bool = Responsive::new(false) // Xs/Sm → hidden + .at(Breakpoint::Md, true) // Md+ → shown + .resolve_cloned(bp); +``` + +--- + +## Appendix D: Discussion Thread Rendering Algorithm + +```mermaid +graph TD + A[Fetch discussions for entity] --> B{For each discussion} + B --> C[Get root note] + C --> D[Format thread header: author + age + reply count] + D --> E{Expanded?} + E -->|Yes| F[Render root note body] + F --> G{For each reply} + G --> H[Indent + render reply: author + body + age] + H --> G + E -->|No| I[Show collapsed indicator with preview] + B --> B + + style A fill:#4a9eff,stroke:#333,color:#fff + style F fill:#51cf66,stroke:#333,color:#fff + style I fill:#ffd43b,stroke:#333,color:#000 +``` + +**For diff discussions (MR-specific):** +1. Group by file path +2. Within each file, sort by line number +3. Show `[file.rs:45]` context prefix +4. Indent discussion tree under file header + +--- + +## 11. Assumptions + +### Framework Choice + +1. **FrankenTUI's API will stabilize enough for our needs within 3 months.** We pin to a specific git revision and update deliberately. If the API churns faster than we can adapt, we exercise the escape hatch (Section 3.5). +2. **Nightly Rust is acceptable for the TUI crate only.** The core `lore` library stays on stable. The nightly pin is isolated to `crates/lore-tui` and updated monthly with CI gates. This is a deliberate tradeoff for FrankenTUI's architectural advantages without risking core stability. +3. **Terminal compatibility is adequate for our target set.** iTerm2, Terminal.app, Alacritty, tmux are validated in Phase 0. Edge cases in exotic terminals are accepted as non-blocking bugs. +4. **The escape hatch works.** The Elm architecture (Msg → update → view) is framework-agnostic. Only view/ files and the launch entry point need rewriting for a framework swap. Estimated cost: ~3-5 days. Validated by building Dashboard in Phase 1 before committing to subsequent phases. + +### Data & Performance + +5. **Performance budgets are enforced by tiered benchmark fixtures (S/M/L) and query-plan checks.** p95 list query < 75ms at M tier (100k issues); p95 search < 200ms at M tier (50k docs, lexical). Memory ceiling < 250MB RSS at M tier. S and M tiers are CI-enforced gates; L tier is advisory. Required indexes are documented and migration-backed before TUI GA. +6. Virtual scrolling handles large datasets (VirtualizedList). +7. Sync progress streamed via channel-based subscription (not batch-at-end). +8. **TUI reads never contend with sync writes.** DbManager provides pooled read-only connections alongside a dedicated writer. WAL mode + busy_timeout ensures SQLITE_BUSY is handled gracefully. + +### User Experience + +9. Vim-style keybindings acceptable (j/k + arrow keys). +10. Mouse support optional but nice. +11. TUI does not replace CLI. +12. Versioned TUI state file allowed for session restore only (`~/.local/share/lore/tui-session.json`). Fields include `schema_version`, `app_version`, `db_fingerprint` (hash of DB path + schema version), `saved_at`, `checksum` (CRC32 of serialized state), and `state` (last screen, active filters, scroll position). **Session writes are atomic:** write to tmp file, fsync, rename over target. On load: if `schema_version` is unsupported, `db_fingerprint` mismatches, CRC32 checksum fails, or file exceeds 1MB max-size guard, the file is quarantined to `.bad-` and the TUI starts fresh. This prevents random boot failures from partial writes, power loss, or corruption. No user-editable configuration beyond this. `lore tui --fresh` starts without restoring prior session state. +13. Users have 80+ column terminals (60 col minimum). + +### Implementation + +14. Query layer can be cleanly extracted (~2 days refactor). Existing `query_*` functions in `cli/commands/*.rs` are made public; `action.rs` bridges types. +15. FrankenTUI's `Cmd::task` runs blocking closures on a thread pool (not async) — `DbManager` uses `std::sync::Mutex` (not tokio). Tokio remains in the dep tree for GitLab HTTP client but is not used by the TUI event loop. +16. No new SQLite tables needed for v1 (but required indexes — including filter-path indexes — must be verified and migration-backed; see Performance SLOs and Section 9.3.1). Post-v1: `sync_checkpoints` table planned for resumable sync support; `sync_runs`/`sync_deltas` ledger planned for exact post-sync navigation. +17. TUI is isolated in a separate binary crate (`crates/lore-tui`) with zero compile-time dependency from the root `lore` crate. The `lore tui` subcommand delegates by spawning the `lore-tui` binary at runtime, eliminating Cargo dependency cycle risk and keeping stable/nightly toolchains fully isolated. + +### Scope Boundaries + +18. No write operations (read-only + sync). +19. No init wizard in TUI. +20. No embedding visualization. +21. Concurrent TUI sessions unsupported and actively prevented by instance lock (`instance_lock.rs`) with stale-lock recovery and clear error message on collision. + +--- + +## Sources + +- [FrankenTUI GitHub](https://github.com/Dicklesworthstone/frankentui) +- [FrankenTUI Website](https://www.frankentui.com/) +- [ftui-runtime docs](https://docs.rs/ftui-runtime/0.1.1/ftui_runtime/) +- [ftui-widgets docs](https://docs.rs/ftui-widgets/0.1.1/ftui_widgets/) +- [ftui-extras docs](https://docs.rs/ftui-extras/latest/aarch64-apple-darwin/ftui_extras/) +- [ftui-layout docs](https://docs.rs/ftui-layout/latest/ftui_layout/) +- [ftui-core docs](https://docs.rs/ftui-core/0.1.1/ftui_core/) +- [ftui-style docs](https://docs.rs/ftui-style/latest/ftui_style/) +- [ftui-demo-showcase on crates.io](https://crates.io/crates/ftui-demo-showcase) +- [FrankenTUI HN Discussion](https://news.ycombinator.com/item?id=46911912) + +--- + +## Rejected Recommendations + +Recommendations from external review (feedback-1) that were evaluated and declined: + +- **UiRuntime + ScreenRenderer abstraction trait for framework portability** — rejected because it's over-engineering at this stage. The Elm architecture (Msg -> update -> view) is already framework-agnostic by nature. Only `view/` files and `app.rs` need rewriting for a framework swap. Adding a formal trait boundary before we've built a single screen creates an abstraction without proven need. The "3-day escape hatch" is validated by building Dashboard in Phase 1 — if that reveals the estimate is optimistic, we can add an abstraction then. YAGNI principle applies. + +- **Saved Views / Watchlist as a v1 product feature** — rejected because it's feature bloat for the initial TUI PRD. Named persistent filters and delta-tracked watchlists are genuinely compelling for daily use, but they require: persistent configuration storage (new file format or SQLite table), a UI for CRUD operations on views, and dashboard integration for delta badges. This is a ~5-day feature that should be planned as a separate v2 enhancement after the core TUI ships and proves its value. The concept is captured here for future reference. + +Recommendations from external review (feedback-2) that were evaluated and declined: + +- **`lore-core` crate split to prevent cyclic Cargo dependencies** — partially superseded. The cycle concern was valid: feedback-4 (ChatGPT) correctly identified the dependency cycle risk with the original feature-gated library approach. We adopted a different solution — `lore-tui` as a separate binary crate with runtime delegation (Section 3.2) — which eliminates the cycle without requiring a `lore-core` split. The `lore-core` crate split itself remains rejected because it would add workspace complexity, require moving dozens of modules, and change import paths across the entire codebase to solve a problem now addressed by the binary separation. + +- **Service/DTO/query layer in a separate `lore-core` crate with canonical query modules** — rejected because it's premature abstraction. The plan explicitly acknowledges this as a YAGNI tradeoff (Section 10.2) with a documented extraction path: make existing `query_*` functions in `cli/commands/*.rs` public, call them from `action.rs`. Creating a separate crate with `query/issues.rs`, `query/mrs.rs`, `query/show.rs`, `query/who.rs`, `query/search.rs`, and DTO types before building a single TUI screen is exactly the kind of speculative architecture that slows down iteration. The plan has a clear rule: "If both CLI and TUI diverge significantly in their query needs, a shared domain query layer can be extracted later — but we don't pay that cost until it's needed." + +Recommendations from external review (feedback-3) that were evaluated and declined: + +- **"My Workbench" screen for daily pull (assigned issues, review requests, mentions)** — rejected for the same reason as Saved Views: feature bloat for v1. This is a compelling daily-driver feature, but it requires: GitLab user identity resolution (current-user API call or config), new query patterns (assigned-to-me, reviewer-requested, mentioned-in), and a dedicated screen with its own state management. The core TUI must prove value with exploration and search first. A Workbench screen is an excellent v2 candidate once the navigation and data layer are stable. + +- **Rollout, SLO telemetry, feature flags, and kill-switch plan** — rejected because this is a single-user local CLI tool, not a SaaS product. Feature flags, canary rollouts, panic-rate kill-switches, and metrics pipelines (startup_ms, frame_render_p95_ms, db_busy_rate, panic_free_sessions) are appropriate for multi-tenant services with thousands of users. Gitlore's TUI is compiled and run locally by one person. The user IS the rollout canary. Crashes are immediately visible. The existing Phase 0 toolchain gate, Phase 5 test suite, and success criteria already provide sufficient quality assurance for this deployment model. + +Recommendations from external review (feedback-4, ChatGPT) that were evaluated and declined: + +- **`core::query::*` internal module to decouple TUI from CLI presentation structs** — rejected for the same reason as the feedback-2 Service/DTO recommendation: premature abstraction. This is the third time this recommendation has been proposed (feedback-2 as `lore-core` crate, feedback-4 as `core::query::*` module). The answer remains the same: making existing `query_*` functions in `cli/commands/*.rs` public is sufficient for v1. The plan documents a clear extraction path if CLI and TUI query needs diverge. Creating new modules (`core/query/issues.rs`, `core/query/mrs.rs`, `core/query/detail.rs`, `core/query/search.rs`, `core/query/who.rs`) before building a single TUI screen is speculative architecture. YAGNI applies. + +- **Move resumable sync checkpoints into v1 (lightweight cursors per project/resource lane)** — rejected because "lightweight" undersells the complexity. Resumable sync checkpoints require: a new `sync_checkpoints` table with migration, cursor management per resource lane (issues, MRs, discussions, notes, events), resume logic that handles partial-page state, and integration tests proving interrupt-and-resume reaches completion. This is 3-5 days of work with meaningful correctness risk (resuming mid-page with stale cursors). The existing per-project fault isolation already handles partial failures — failed lanes are marked degraded while others continue. Sync checkpoints remain a strong v2 candidate after the core sync pipeline has production mileage. + +- **`ScreenIntent` intermediary type to enforce pure state handlers** — accepted in spirit but with a simpler approach than proposed. ChatGPT correctly identified that `handle_screen_msg` was bypassing the TaskSupervisor by calling `Cmd::task` directly. Rather than introducing a formal "intent-only" type hierarchy with DB-access prohibition, we made the minimal fix: `handle_screen_msg` now returns `ScreenIntent` (Navigate | RequeryNeeded | None) instead of `Cmd`, and `LoreApp::update()` converts intents into supervised `Cmd::task` calls. This was accepted and integrated — listed here for audit trail only. + +Recommendations from external review (feedback-5, ChatGPT) that were evaluated and declined: + +- **`ui_adapter` seam to contain FrankenTUI API churn** — rejected as premature abstraction. The proposal adds a `ui_adapter/` module layer (runtime.rs, widgets.rs, input.rs) that wraps all FrankenTUI types so no screen module imports `ftui_*` directly. While the intent (reducing blast radius of upstream API changes) is sound, this is the same "portability abstraction" pattern rejected in feedback-1 wearing different clothes. The existing mitigation — pinning an exact git commit + the 3-day escape hatch validated in Phase 1 — already addresses the risk without adding an indirection layer. If FrankenTUI's API churn proves worse than expected during Phase 1, we can introduce targeted wrappers for the specific types that broke. Adding a full adapter module before building a single screen is speculative insulation against a risk we've already mitigated. YAGNI applies. + +Recommendations from external review (feedback-6, ChatGPT) that were evaluated and declined: + +- **QoS sync event bus with split ctrl/data channels and sequence IDs** — rejected as over-engineering for a local TUI. The proposal splits the sync channel into a never-drop ctrl channel (256 capacity) and a coalescible data channel (4096 capacity), adds sequence numbers to every event, and has the UI detect and display gaps ("Dropped N events due to backpressure"). The current design — a single bounded `sync_channel::(2048)` with `try_send` for progress (droppable) and blocking retries for terminal events (completion/failure) — already handles backpressure correctly. Progress drops are invisible to the user (the next progress update replaces the stale one). Terminal events are guaranteed delivered. Adding sequence IDs, gap detection, and split channels adds complexity with zero user-visible benefit for a single-user local tool. The backpressure drop counter (`SyncBackpressureDrop`) already exists for diagnostics if needed. + +- **Dependency Lens panel for Issue/MR detail views** — rejected because the required data doesn't exist in the current schema. The proposal adds a "Blocked by / Blocks / Risk" panel to detail views, but gitlore does not sync GitLab's blocking issue relationships (`/issues/:id/links` API with `blocks`/`is_blocked_by` link types). The cross-references in the plan are discussion-level mentions (e.g., "mentioned in !458"), not structured dependency relationships. Implementing this feature requires: new API sync for issue links, a new `issue_links` table with migration, relationship resolution logic, and risk scoring heuristics. This is a compelling v2 feature — it would make the detail view significantly more useful for triage — but it requires schema additions that are out of scope for v1. The concept is captured here for future reference when the issue links API is synced. + +Recommendations from external review (feedback-7, ChatGPT) that were evaluated and declined: + +- **`TaskSupervisor` as a real scheduler with `BinaryHeap`, inflight counters, and `TaskLaneLimits`** — rejected because it solves a problem we don't have. The TUI dispatches at most 5-10 concurrent background tasks. A priority queue scheduler with per-lane inflight limits adds complexity (BinaryHeap ordering, lane accounting, dispatch-ready polling) without user-visible benefit. The current design — direct `Cmd::task` dispatch with dedup + cancellation via `TaskSupervisor::register()` — is adequate for TUI workloads. The debounce-via-`thread::sleep` concern is also overblown: the sleep runs in a `Cmd::task` pooled thread, not the event loop, so it doesn't block rendering or input. If task contention becomes measurable (not speculative), a scheduler can be added — but the monitoring to detect it must come first. + +- **Sync run ledger (`sync_runs` + `sync_deltas` tables) for exact "since last sync" navigation** — rejected for v1 because it adds new schema (contradicting assumption #16) for marginal UX improvement. The "since last sync" filter works with the existing `sync_status.last_completed_at` timestamp comparison. The edge cases (partial failures, retries, clock drift) are real but rare in practice for a single-user local tool. The existing per-project fault isolation already handles partial sync failures. Adding `sync_runs` + `sync_deltas` with indexes, migration, and integration into the filter pipeline is 2-3 days of work for a v1 that hasn't shipped yet. Captured as a strong v2 candidate — once the TUI proves its value and users ask for exact delta navigation, we add the schema then. + +- **Full `KeyNormalizer` struct with dedicated integration tests and terminal profile replay tests (2 days)** — rejected as too heavy. The underlying concern (terminal encoding differences for Backspace, Alt, Tab) is valid and accepted as a lightweight normalization pass in `interpret_key()` (see Section 8.2). But a separate struct with its own integration test suite and "terminal profile replay" fixtures turns a 20-line normalization function into a 2-day testing project. FrankenTUI's `ftui-core` event parser already handles most encoding differences; we add a thin layer for the remaining edge cases. If terminal compat bugs surface during Phase 5 testing, the normalization can be expanded then — with actual bug reports driving the test cases. + +- **"Replay captured event trace" test (`replay_trace_artifact_is_stable`)** — rejected because deterministic event replay in a TUI with async task completion is non-trivial to implement correctly. The event trace in the panic crash dump (accepted — see Section 10.3) is valuable for human debugging. But building an automated test that replays captured events and asserts no panic requires mocking all async completion timing, which makes the test brittle and non-deterministic. The existing event-fuzz suite (criterion 13) with randomized seed replay already covers the "no panic under arbitrary input" property more robustly. + +Recommendations from external review (feedback-8, ChatGPT) that were evaluated and declined: + +- **"Hotspots" screen for risk/churn triage** — rejected as feature scope expansion for v1. The concept (file/path risk ranking by churn x unresolved discussion pressure x staleness) is compelling and uses existing data, but it is a net-new screen with its own state module, view module, query logic, keybinding (`gx`), and test coverage. Adding it to an already ambitious v1 scope dilutes focus on shipping the core exploration and search experience. The existing Timeline and Who screens already surface much of the same signal (recent activity, discussion hotspots) from different angles. Hotspots is captured as a strong v2 candidate — once the core TUI ships and proves its navigation model, a risk-ranking screen can layer on top without architectural changes. + +- **Degraded startup mode (`--degraded` flag) when schema/compat checks fail** — rejected as over-engineering for the failure mode. The current behavior (actionable error message with specific recovery steps and non-zero exit) is the correct UX for a schema mismatch. A degraded mode that shells to `lore --robot` for read-only summary/doctor creates a second execution path that must be tested, documented, and maintained — and it papers over the root problem (wrong schema) rather than fixing it. Users who hit this error need to run `lore migrate` or update their binary, not navigate a limited TUI. The existing `lore --robot doctor` and `lore --robot status` commands already serve the "quick health check without full TUI" use case directly from the command line. + +Recommendations from external review (feedback-9, ChatGPT) that were evaluated and declined: + +- **Search Facets panel (entity type counts, top labels/projects/authors with one-key apply)** — rejected as feature scope expansion for v1. The concept (three-pane layout with facet counts and quick-apply shortcuts like `1/2/3` for type facets, `l` for label cycling) is compelling and would make search more actionable for triage workflows. However, it requires: new aggregate queries for facet counting that must perform well across all three data tiers, a third layout pane that breaks the current two-pane split design, new keybinding slots (`1/2/3/l`) that could conflict with future list navigation, and per-query facet recalculation that adds latency. The existing search with explicit field-based filters is sufficient for v1. Facets are a strong v2 candidate — once search has production mileage and users report wanting faster triage filtering, the aggregate query patterns and UI layout can be designed with real usage data. diff --git a/plans/tui-prd.md b/plans/tui-prd.md new file mode 100644 index 0000000..077ac7b --- /dev/null +++ b/plans/tui-prd.md @@ -0,0 +1,2075 @@ +# 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](#1-executive-summary) +2. [Framework Evaluation](#2-framework-evaluation) +3. [Recommendation](#3-recommendation) +4. [Architecture](#4-architecture) +5. [Screen Taxonomy](#5-screen-taxonomy) +6. [User Flows — Every Command](#6-user-flows) +7. [Widget Inventory](#7-widget-inventory) +8. [Keybinding Reference](#8-keybinding-reference) +9. [Implementation Plan](#9-implementation-plan) +10. [Code Changes Required](#10-code-changes-required) +11. [Assumptions](#11-assumptions) + +--- + +## 1. Executive Summary + +Gitlore (`lore`) is a 32,844-line Rust CLI for local GitLab data management with semantic search, people intelligence, and temporal analysis. It currently offers 21 commands via clap with two output modes: human-readable tables (`comfy-table`, `indicatif`, `console`) and machine-readable JSON (`--robot`). + +**The problem:** Power users must memorize 21 commands, ~80 flags, and their interaction semantics. Discovering data relationships (who touched which files → which MRs → which discussions) requires sequential command invocations. There is no way to browse, filter, and drill into data interactively. + +**The solution:** A full-screen interactive TUI that provides: + +- **Dashboard** — sync status, project health, counts 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` 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 + +```mermaid +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:** + +```rust +// 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), + IssueListFilterChanged(IssueFilter), + IssueListSortChanged(SortField, SortOrder), + IssueSelected(i64), // gitlab iid + + // MR list + MrListLoaded(Vec), + MrListFilterChanged(MrFilter), + MrSelected(i64), + + // Detail views + IssueDetailLoaded(IssueDetail), + MrDetailLoaded(MrDetail), + DiscussionsLoaded(Vec), + + // Search + SearchQueryChanged(String), + SearchExecuted(SearchResults), + SearchResultSelected(usize), + + // Timeline + TimelineLoaded(Vec), + 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, +} +``` + +```rust +// src/tui/app.rs + +pub struct App { + state: AppState, + navigation: NavigationStack, + action_tx: mpsc::UnboundedSender, + db: Connection, + config: Config, + theme: Theme, +} + +impl App { + pub fn update(&mut self, msg: Message) -> Vec { + 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 + +```mermaid +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 +``` + +```rust +// 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 + +```mermaid +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 + +```mermaid +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 + 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 ` + 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 ` | +| 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 ` | + +### 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" + +```mermaid +graph TD + A[Dashboard] -->|w| B[Who Screen] + B -->|type 'src/auth/'| C[Expert Mode Results] + C -->|↓ select @asmith| D[Detail Panel Shows Code Areas] + D -->|Enter| E[Issue List filtered by @asmith] + E -->|↓ select #1247| F[Issue Detail] + F -->|scroll to discussions| G[Read discussion threads] + G -->|Enter on cross-ref !458| H[MR Detail] + H -->|o| I[Opens in browser] + + style A fill:#2d2d2d,stroke:#666,color:#fff + style C fill:#4a9eff,stroke:#333,color:#fff + style F fill:#51cf66,stroke:#333,color:#fff + style H fill:#ffd43b,stroke:#333,color:#000 +``` + +**Keystrokes:** `w` → type `src/auth/` → `↓` → `Enter` → `↓` → `Enter` → `↓↓↓` → `Enter` → `o` +**Total:** 8 actions to go from "who knows about auth" to opening the relevant MR in browser. +**CLI equivalent:** `lore who src/auth/` → read output → `lore who @asmith` → read → `lore issues 1247` → read → `lore mrs 458` → `lore mrs 458 --open` +**CLI keystrokes:** 6 separate commands, ~200 characters of typing. + +### 6.2 Flow: "What happened with the auth timeout this week?" + +```mermaid +graph TD + A[Dashboard] -->|t| B[Timeline Screen] + B -->|type 'auth timeout'| C[Timeline Seeds: #1247, !458] + C -->|scroll through events| D[See chronological story] + D -->|Enter on CREATED !458| E[MR Detail] + E -->|Esc| D + D -->|Enter on NOTE evidence| F[Issue Detail at discussion] + + style A fill:#2d2d2d,stroke:#666,color:#fff + style C fill:#4a9eff,stroke:#333,color:#fff + style D fill:#51cf66,stroke:#333,color:#fff +``` + +**Keystrokes:** `t` → type query → `Enter` → scroll → `Enter` → `Esc` → scroll +**Value:** The timeline view reconstructs the full narrative that would require 5+ separate CLI commands. + +### 6.3 Flow: "Quick search for something I vaguely remember" + +```mermaid +graph TD + A[Any Screen] -->|/| 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" + +```mermaid +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" + +```mermaid +graph TD + A[Dashboard] -->|w| B[Who Screen] + B -->|Tab to Workload| C[Workload Mode] + C -->|type '@bjones'| D[Workload Summary] + D -->|see assigned issues| E[Issue section] + D -->|see authored MRs| F[MR section] + D -->|see review queue| G[Review section] + E -->|Enter| H[Issue Detail] + F -->|Enter| I[MR Detail] + + style D fill:#4a9eff,stroke:#333,color:#fff + style E fill:#51cf66,stroke:#333,color:#fff + style F fill:#51cf66,stroke:#333,color:#fff + style G fill:#51cf66,stroke:#333,color:#fff +``` + +### 6.6 Flow: Command Palette Power Usage + +```mermaid +graph TD + A[Any Screen] -->|Ctrl+P| B[Command Palette Opens] + B -->|type 'mrs draft'| C[Fuzzy matches 'MRs draft'] + C -->|Enter| D[MR List pre-filtered: draft=true] + D -->|Ctrl+P again| E[Palette Opens] + E -->|type 'sync'| F[Fuzzy matches Sync commands] + F -->|↓ select 'Sync full'| G[Sync Screen starts full sync] + + style B fill:#ffd43b,stroke:#333,color:#000 + style C fill:#4a9eff,stroke:#333,color:#fff + style E fill:#ffd43b,stroke:#333,color:#000 +``` + +--- + +## 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 + +```toml +# 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 + +```mermaid +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.rs` — `pub fn launch_tui(config: Config, db_path: &Path) -> Result<()>` +2. `src/tui/app.rs` — `App` 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.rs` — `NavigationStack` 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 + +```rust +// src/cli/mod.rs — add to Commands enum + +#[derive(Subcommand)] +pub enum Commands { + // ... existing commands ... + + /// Launch interactive TUI + Tui, +} +``` + +```rust +// 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:** + +```rust +// BEFORE (coupled) +pub fn run_list_issues(config: &Config, args: &IssueArgs) -> Result<()> { + let conn = open_db(&config)?; + let rows = query_issues(&conn, &args.filter)?; + print_issue_table(&rows, args.robot); + Ok(()) +} + +// AFTER (decoupled) +pub fn query_issues(conn: &Connection, filter: &IssueFilter) -> Result> { + // 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 + +```rust +// 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 +} +``` + +```rust +// 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, +} + +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 { + self.rx.recv().await + } +} +``` + +```rust +// 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, + } + } +} +``` + +```rust +// src/tui/navigation.rs + +use super::message::Screen; + +pub struct NavigationStack { + stack: Vec, +} + +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 { + 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 + +```rust +// 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 = 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 = 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 + +```rust +// 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, + pub filtered: Vec, // 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 + +```mermaid +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 + +```mermaid +stateDiagram-v2 + [*] --> Inactive + + Inactive --> Active: f (focus) or Tab + Active --> FieldSelect: type ':' + Active --> FreeText: type any char + + FieldSelect --> ValueInput: select field + FreeText --> Inactive: Enter (apply) or Esc (cancel) + + ValueInput --> Inactive: Enter (apply filter) + ValueInput --> Active: Backspace past ':' + + state Active { + [*] --> Typing + Typing --> Suggesting: pause 200ms + Suggesting --> Typing: resume typing + } + + state FieldSelect { + [*] --> ShowFields + ShowFields: state, author, assignee, label, milestone, since, project + } + + state ValueInput { + [*] --> ShowValues + ShowValues: context-dependent completions + } +``` + +--- + +## Appendix C: Responsive Layout Breakpoints + +| Terminal Width | Layout Adaptation | +|---------------|-------------------| +| < 80 cols | Single-column, abbreviated headers, hide secondary columns | +| 80-120 cols | Standard layout (as shown in mockups) | +| 120-200 cols | Wider columns, show all fields, split pane previews | +| > 200 cols | Triple-column layout on search/who screens | + +**Implementation:** Check `frame.area().width` in each view function and adjust `Constraint` arrays accordingly. + +--- + +## Appendix D: Discussion Thread Rendering Algorithm + +```mermaid +graph TD + A[Fetch discussions for entity] --> B{For each discussion} + B --> C[Get root note] + C --> D[Format thread header: author + age + reply count] + D --> E{Expanded?} + E -->|Yes| F[Render root note body] + F --> G{For each reply} + G --> H[Indent + render reply: author + body + age] + H --> G + E -->|No| I[Show collapsed indicator with preview] + B --> B + + style A fill:#4a9eff,stroke:#333,color:#fff + style F fill:#51cf66,stroke:#333,color:#fff + style I fill:#ffd43b,stroke:#333,color:#000 +``` + +**For diff discussions (MR-specific):** +1. Group by file path +2. Within each file, sort by line number +3. Show `[file.rs:45]` context prefix +4. Indent discussion tree under file header + +--- + +## 11. Assumptions + +### Framework Choice + +1. **FrankenTUI 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 + +5. **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. +6. **Virtual scrolling handles large datasets.** We don't load all 10K issues into memory — we page from SQLite. +7. **Sync progress can be captured as events.** The existing `ProgressCallback` / `ProgressEvent` system is sufficient for the TUI progress display. +8. **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 + +9. **Vim-style keybindings are acceptable.** `j/k` navigation is standard for developer TUIs. We also support arrow keys for accessibility. +10. **Mouse support is optional but nice.** Click-to-select in lists, scrollbar dragging. Not required for core functionality. +11. **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`. +12. **No TUI-specific configuration is needed initially.** Theme auto-detects dark/light. Keybindings are fixed (no customization in v1). +13. **Users have 80+ column terminals.** The minimum usable width is ~60 columns. Below that, we show a "terminal too narrow" message. + +### Implementation + +14. **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. +15. **tokio is already in the dependency tree.** No new async runtime needed — we use the existing tokio runtime for async actions. +16. **No new SQLite tables are needed.** The TUI is purely a view layer over existing data. +17. **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 + +18. **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. +19. **No `init` wizard in the TUI.** Configuration remains CLI-only (`lore init`). +20. **No embedding visualization.** The TUI shows embedding counts but doesn't visualize vector space. +21. **No concurrent TUI sessions.** Only one `lore tui` instance at a time (enforced by terminal ownership, not app locks). + +--- + +## Sources + +- [FrankenTUI GitHub Repository](https://github.com/Dicklesworthstone/frankentui) +- [FrankenTUI Website](https://www.frankentui.com/) +- [FrankenTUI on LinkedIn (Jeffrey Emanuel)](https://www.linkedin.com/posts/jeffreyemanuel_frankentui-the-monster-terminal-ui-kernel-activity-7426409327512514560-pNEx) +- [Ratatui Official Site](https://ratatui.rs/) +- [Ratatui crates.io](https://crates.io/crates/ratatui) +- [FrankenTUI Hacker News Discussion](https://news.ycombinator.com/item?id=46911912) +- [ftui-extras docs.rs](https://docs.rs/ftui-extras/latest/aarch64-apple-darwin/ftui_extras/) +- [ftui-widgets docs.rs](https://docs.rs/ftui-widgets/latest/ftui_widgets/) +- [ftui-runtime docs.rs](https://docs.rs/ftui-runtime/latest/ftui_runtime/) +- [ftui-layout docs.rs](https://docs.rs/ftui-layout/latest/ftui_layout/) +- [ftui-core docs.rs](https://docs.rs/ftui-core/latest/ftui_core/) +- [ftui-style docs.rs](https://docs.rs/ftui-style/latest/ftui_style/) +- [ftui-demo-showcase on crates.io](https://crates.io/crates/ftui-demo-showcase) +- [@doodlestein on X announcing FrankenTUI](https://x.com/doodlestein/status/2018848938141614302)