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

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

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

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

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

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

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

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

12 KiB

  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.
--- 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
  1. 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.
--- 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
+        );
  1. 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.
--- a/PRD.md
+++ b/PRD.md
@@ 4.5.1 Task Supervisor (Dedup + Cancellation + Priority)
-pub struct TaskSupervisor {
-    active: HashMap<TaskKey, Arc<CancelToken>>,
-    generation: AtomicU64,
-}
+pub struct TaskSupervisor {
+    active: HashMap<TaskKey, Arc<CancelToken>>,
+    generation: AtomicU64,
+    queue: BinaryHeap<ScheduledTask>,
+    inflight: HashMap<TaskPriority, usize>,
+    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)
+}
  1. 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.
--- 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=<last_run>`
+- `m` navigates to MR List pre-filtered to `sync_run_id=<last_run>`
+- 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.
  1. 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.
--- 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
  1. 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.
--- 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 <db-path>`)
+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.
  1. 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.
--- 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)
  1. 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.
--- 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
  1. 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.
--- 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() { ... }
  1. 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.
--- 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.