diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 874b6e9..a2ae9ca 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -9,6 +9,7 @@ {"id":"bd-140","title":"[CP1] Database migration 002_issues.sql","description":"Create migration file with tables for issues, labels, issue_labels, discussions, and notes.\n\nTables to create:\n- issues: gitlab_id, project_id, iid, title, description, state, author_username, timestamps, web_url, raw_payload_id\n- labels: gitlab_id, project_id, name, color, description (unique on project_id+name)\n- issue_labels: junction table\n- discussions: gitlab_discussion_id, project_id, issue_id, noteable_type, individual_note, timestamps, resolvable/resolved\n- notes: gitlab_id, discussion_id, project_id, type, is_system, author_username, body, timestamps, position, resolution fields, DiffNote position fields\n\nInclude appropriate indexes:\n- idx_issues_project_updated, idx_issues_author, uq_issues_project_iid\n- uq_labels_project_name, idx_labels_name\n- idx_issue_labels_label\n- uq_discussions_project_discussion_id, idx_discussions_issue/mr/last_note\n- idx_notes_discussion/author/system\n\nFiles: migrations/002_issues.sql\nDone when: Migration applies cleanly on top of 001_initial.sql","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:18:53.954039Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.154936Z","closed_at":"2026-01-25T15:21:35.154936Z","deleted_at":"2026-01-25T15:21:35.154934Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-14hv","title":"Implement soak test + concurrent pagination/write race tests","description":"## Background\nThe 30-minute soak test verifies no panic, deadlock, or memory leak under sustained use. Concurrent pagination/write race tests prove browse snapshot fences prevent duplicate or skipped rows during sync writes.\n\n## Approach\nSoak test:\n- Automated script that drives the TUI for 30 minutes: random navigation, filter changes, sync starts/cancels, search queries\n- Monitors: no panic (exit code), no deadlock (watchdog timer), memory growth < 5% (RSS sampling)\n- Uses FakeClock with accelerated time for time-dependent features\n\nConcurrent pagination/write race:\n- Thread A: paginating through Issue List (fetching pages via keyset cursor)\n- Thread B: writing new issues to DB (simulating sync)\n- Assert: no duplicate rows across pages, no skipped rows within a browse snapshot fence\n- BrowseSnapshot token ensures stable ordering until explicit refresh\n\n## Acceptance Criteria\n- [ ] 30-min soak: no panic\n- [ ] 30-min soak: no deadlock (watchdog detects)\n- [ ] 30-min soak: memory growth < 5%\n- [ ] Concurrent pagination: no duplicate rows across pages\n- [ ] Concurrent pagination: no skipped rows within snapshot fence\n- [ ] BrowseSnapshot invalidated on manual refresh, not on background writes\n\n## Files\n- CREATE: crates/lore-tui/tests/soak_test.rs\n- CREATE: crates/lore-tui/tests/pagination_race_test.rs\n\n## TDD Anchor\nRED: Write test_pagination_no_duplicates that runs paginator and writer concurrently for 1000 iterations, collects all returned row IDs, asserts no duplicates.\nGREEN: Implement browse snapshot fence in keyset pagination.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_pagination_no_duplicates\n\n## Edge Cases\n- Soak test needs headless mode (no real terminal) — use ftui test harness\n- Memory sampling on macOS: use mach_task_info or /proc equivalent\n- Writer must use WAL mode to not block readers\n- Snapshot fence: deferred read transaction holds snapshot until page sequence completes\n\n## Dependency Context\nUses DbManager from \"Implement DbManager\" task.\nUses BrowseSnapshot from \"Implement NavigationStack\" task.\nUses keyset pagination from \"Implement Issue List\" task.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T17:05:28.130516Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:38.546708Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-14hv","depends_on_id":"bd-1b6k","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-14hv","depends_on_id":"bd-wnuo","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-14q","title":"Epic: Gate 4 - File Decision History (lore file-history)","description":"## Background\n\nGate 4 implements `lore file-history` — answers \"Which MRs touched this file, and why?\" by linking files to MRs via a new mr_file_changes table and resolving rename chains.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Gate 4 (Sections 4.1-4.7).\n\n## Prerequisites\n\n- Gates 1-2 COMPLETE: entity_references populated, resource events fetched\n- Migration 015 exists on disk (commit SHAs + closes watermark) — registered by bd-1oo\n- pending_dependent_fetches has job_type='mr_diffs' in CHECK constraint (migration 011)\n\n## Architecture\n\n- **New table:** mr_file_changes (migration 016) stores file paths per MR\n- **New config:** fetchMrFileChanges (default true) gates the API calls\n- **API source:** GET /projects/:id/merge_requests/:iid/diffs — extract paths only, discard diff content\n- **Rename resolution:** BFS both directions on mr_file_changes WHERE change_type='renamed', bounded at 10 hops\n- **Query:** Join mr_file_changes -> merge_requests, optionally enrich with entity_references and discussions\n\n## Children (Execution Order)\n\n1. **bd-1oo** — Register migration 015 + create migration 016 (mr_file_changes table)\n2. **bd-jec** — Add fetchMrFileChanges config flag\n3. **bd-2yo** — Fetch MR diffs API and populate mr_file_changes\n4. **bd-1yx** — Implement rename chain resolution (BFS algorithm)\n5. **bd-z94** — Implement lore file-history CLI command (human + robot output)\n\n## Gate Completion Criteria\n\n- [ ] mr_file_changes table populated from GitLab diffs API\n- [ ] merge_commit_sha and squash_commit_sha captured in merge_requests (already done in code, needs migration 015 registered)\n- [ ] `lore file-history ` returns MRs ordered by merge/creation date\n- [ ] Output includes: MR title, state, author, change type, discussion count\n- [ ] --discussions shows inline discussion snippets from DiffNotes on the file\n- [ ] Rename chains resolved with bounded hop count (default 10) and cycle detection\n- [ ] --no-follow-renames disables chain resolution\n- [ ] Robot mode JSON includes rename_chain when renames detected\n- [ ] -p required when path in multiple projects (exit 18 Ambiguous)\n","status":"open","priority":1,"issue_type":"feature","created_at":"2026-02-02T21:31:01.094024Z","created_by":"tayloreernisse","updated_at":"2026-02-05T20:56:53.434796Z","compaction_level":0,"original_size":0,"labels":["epic","gate-4","phase-b"],"dependencies":[{"issue_id":"bd-14q","depends_on_id":"bd-1se","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-14q","depends_on_id":"bd-2zl","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} +{"id":"bd-14q8","title":"Split commands.rs into commands/ module (registry + defs)","description":"commands.rs is 807 lines. Split into crates/lore-tui/src/commands/mod.rs (re-exports), commands/registry.rs (CommandRegistry, lookup, status_hints, help_entries, palette_entries, build_registry), and commands/defs.rs (command definitions, KeyCombo, CommandDef struct). Keep public API identical via re-exports. All downstream imports should continue to work unchanged.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T21:24:11.259683Z","created_by":"tayloreernisse","updated_at":"2026-02-12T21:24:32.009880Z","compaction_level":0,"original_size":0,"labels":["TUI"]} {"id":"bd-157","title":"[CP1] Issue transformer with label extraction","description":"Transform GitLab issue payloads to normalized database schema.\n\n## Module\nsrc/gitlab/transformers/issue.rs\n\n## Structs\n\n### NormalizedIssue\n- gitlab_id: i64\n- project_id: i64 (local DB project ID)\n- iid: i64\n- title: String\n- description: Option\n- state: String\n- author_username: String\n- created_at, updated_at, last_seen_at: i64 (ms epoch)\n- web_url: String\n\n### NormalizedLabel (CP1: name-only)\n- project_id: i64\n- name: String\n\n## Functions\n\n### transform_issue(gitlab_issue: &GitLabIssue, local_project_id: i64) -> NormalizedIssue\n- Convert ISO timestamps to ms epoch using iso_to_ms()\n- Set last_seen_at to now_ms()\n- Clone string fields\n\n### extract_labels(gitlab_issue: &GitLabIssue, local_project_id: i64) -> Vec\n- Map labels vec to NormalizedLabel structs\n\nFiles: \n- src/gitlab/transformers/mod.rs\n- src/gitlab/transformers/issue.rs\nTests: tests/issue_transformer_tests.rs\nDone when: Unit tests pass for payload transformation and label extraction","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:42:47.719562Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.736142Z","closed_at":"2026-01-25T17:02:01.736142Z","deleted_at":"2026-01-25T17:02:01.736129Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-16m8","title":"OBSERV: Record item counts as span fields in sync stages","description":"## Background\nMetricsLayer (bd-34ek) captures span fields, but the stage functions must actually record item counts INTO their spans. This is the bridge between \"work happened\" and \"MetricsLayer knows about it.\"\n\n## Approach\nIn each stage function, after the work loop completes, record counts into the current span:\n\n### src/ingestion/orchestrator.rs - ingest_project_issues_with_progress() (~line 110)\nAfter issues are fetched and discussions synced:\n```rust\ntracing::Span::current().record(\"items_processed\", result.issues_upserted);\ntracing::Span::current().record(\"items_skipped\", result.issues_skipped);\ntracing::Span::current().record(\"errors\", result.errors);\n```\n\n### src/ingestion/orchestrator.rs - drain_resource_events() (~line 566)\nAfter the drain loop:\n```rust\ntracing::Span::current().record(\"items_processed\", result.fetched);\ntracing::Span::current().record(\"errors\", result.failed);\n```\n\n### src/documents/regenerator.rs - regenerate_dirty_documents() (~line 24)\nAfter the regeneration loop:\n```rust\ntracing::Span::current().record(\"items_processed\", result.regenerated);\ntracing::Span::current().record(\"items_skipped\", result.unchanged);\ntracing::Span::current().record(\"errors\", result.errored);\n```\n\n### src/embedding/pipeline.rs - embed_documents() (~line 36)\nAfter embedding completes:\n```rust\ntracing::Span::current().record(\"items_processed\", result.embedded);\ntracing::Span::current().record(\"items_skipped\", result.skipped);\ntracing::Span::current().record(\"errors\", result.failed);\n```\n\nIMPORTANT: These fields must be declared as tracing::field::Empty in the #[instrument] attribute (done in bd-24j1). You can only record() a field that was declared at span creation. Attempting to record an undeclared field silently does nothing.\n\n## Acceptance Criteria\n- [ ] MetricsLayer captures items_processed for each stage\n- [ ] MetricsLayer captures items_skipped and errors when non-zero\n- [ ] Fields match the span declarations from bd-24j1\n- [ ] extract_timings() returns correct counts in StageTiming\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/ingestion/orchestrator.rs (record counts in ingest + drain functions)\n- src/documents/regenerator.rs (record counts in regenerate)\n- src/embedding/pipeline.rs (record counts in embed)\n\n## TDD Loop\nRED: test_stage_fields_recorded (integration: run pipeline, extract timings, verify counts > 0)\nGREEN: Add Span::current().record() calls at end of each stage\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Span::current() returns a disabled span if no subscriber is registered (e.g., in tests without subscriber setup). record() on disabled span is a no-op. Tests need a subscriber.\n- Field names must exactly match the declaration: \"items_processed\" not \"itemsProcessed\"\n- Recording must happen BEFORE the span closes (before function returns). Place at end of function but before Ok(result).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:32.011236Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:27:38.620645Z","closed_at":"2026-02-04T17:27:38.620601Z","close_reason":"Added tracing::field::Empty declarations and Span::current().record() calls in 4 functions: ingest_project_issues, ingest_project_merge_requests, drain_resource_events, regenerate_dirty_documents, embed_documents","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-16m8","depends_on_id":"bd-24j1","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-16m8","depends_on_id":"bd-34ek","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-16m8","depends_on_id":"bd-3er","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-17n","title":"OBSERV: Add LoggingConfig to Config struct","description":"## Background\nLoggingConfig centralizes log file settings so users can customize retention and disable file logging. It follows the same #[serde(default)] pattern as SyncConfig (src/core/config.rs:32-78) so existing config.json files continue working with zero changes.\n\n## Approach\nAdd to src/core/config.rs, after the EmbeddingConfig struct (around line 120):\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\n#[serde(default)]\npub struct LoggingConfig {\n /// Directory for log files. Default: None (= XDG data dir + /logs/)\n pub log_dir: Option,\n\n /// Days to retain log files. Default: 30. Set to 0 to disable file logging.\n pub retention_days: u32,\n\n /// Enable JSON log files. Default: true.\n pub file_logging: bool,\n}\n\nimpl Default for LoggingConfig {\n fn default() -> Self {\n Self {\n log_dir: None,\n retention_days: 30,\n file_logging: true,\n }\n }\n}\n```\n\nAdd to the Config struct (src/core/config.rs:123-137), after the embedding field:\n\n```rust\n#[serde(default)]\npub logging: LoggingConfig,\n```\n\nNote: Using impl Default rather than default helper functions (default_retention_days, default_true) because #[serde(default)] on the struct applies Default::default() to the entire struct when the key is missing. This is the same pattern used by SyncConfig.\n\n## Acceptance Criteria\n- [ ] Deserializing {} as LoggingConfig yields retention_days=30, file_logging=true, log_dir=None\n- [ ] Deserializing {\"retention_days\": 7} preserves file_logging=true default\n- [ ] Existing config.json files (no \"logging\" key) deserialize without error\n- [ ] Config struct has .logging field accessible\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/config.rs (add LoggingConfig struct + Default impl, add field to Config)\n\n## TDD Loop\nRED: tests/config_tests.rs (or inline #[cfg(test)] mod):\n - test_logging_config_defaults\n - test_logging_config_partial\nGREEN: Add LoggingConfig struct, Default impl, field on Config\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- retention_days=0 means disable file logging entirely (not \"delete all files\") -- document this in the struct doc comment\n- log_dir with a relative path: should be resolved relative to CWD or treated as absolute? Decision: treat as absolute, document it\n- Missing \"logging\" key in JSON: #[serde(default)] handles this -- the entire LoggingConfig gets Default::default()","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.471193Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:10:22.751969Z","closed_at":"2026-02-04T17:10:22.751921Z","close_reason":"Added LoggingConfig struct with log_dir, retention_days, file_logging fields and serde defaults","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-17n","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} @@ -110,12 +111,14 @@ {"id":"bd-2711","title":"WHO: Reviews mode query (query_reviews)","description":"## Background\n\nReviews mode answers \"What review patterns does person X have?\" by analyzing the **prefix** convention in DiffNote bodies (e.g., **suggestion**: ..., **question**: ..., **nit**: ...). Only counts DiffNotes on MRs the user did NOT author (m.author_username != ?1).\n\n## Approach\n\n### Three queries:\n1. **Total DiffNotes**: COUNT(*) of DiffNotes by user on others' MRs\n2. **Distinct MRs reviewed**: COUNT(DISTINCT m.id) \n3. **Category extraction**: SQL-level prefix parsing + Rust normalization\n\n### Category extraction SQL:\n```sql\nSELECT\n SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix,\n COUNT(*) AS cnt\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nJOIN merge_requests m ON d.merge_request_id = m.id\nWHERE n.author_username = ?1\n AND n.note_type = 'DiffNote' AND n.is_system = 0\n AND m.author_username != ?1\n AND ltrim(n.body) LIKE '**%**%' -- only bodies with **prefix** pattern\n AND n.created_at >= ?2\n AND (?3 IS NULL OR n.project_id = ?3)\nGROUP BY raw_prefix ORDER BY cnt DESC\n```\n\nKey: `ltrim(n.body)` tolerates leading whitespace before **prefix** (common in practice).\n\n### normalize_review_prefix() in Rust:\n```rust\nfn normalize_review_prefix(raw: &str) -> String {\n let s = raw.trim().trim_end_matches(':').trim().to_lowercase();\n // Strip parentheticals like \"(non-blocking)\"\n let s = if let Some(idx) = s.find('(') { s[..idx].trim().to_string() } else { s };\n // Merge nit/nitpick variants\n match s.as_str() {\n \"nitpick\" | \"nit\" => \"nit\".to_string(),\n other => other.to_string(),\n }\n}\n```\n\n### HashMap merge for normalized categories, then sort by count DESC\n\n### ReviewsResult struct:\n```rust\npub struct ReviewsResult {\n pub username: String,\n pub total_diffnotes: u32,\n pub categorized_count: u32,\n pub mrs_reviewed: u32,\n pub categories: Vec,\n}\npub struct ReviewCategory { pub name: String, pub count: u32, pub percentage: f64 }\n```\n\nNo LIMIT needed — categories are naturally bounded (few distinct prefixes).\n\n## Files\n\n- `src/cli/commands/who.rs`\n\n## TDD Loop\n\nRED:\n```\ntest_reviews_query — insert 3 DiffNotes (2 with **prefix**, 1 without); verify total=3, categorized=2, categories.len()=2\ntest_normalize_review_prefix — \"suggestion\" \"Suggestion:\" \"suggestion (non-blocking):\" \"Nitpick:\" \"nit (non-blocking):\" \"question\" \"TODO:\"\n```\n\nGREEN: Implement query_reviews + normalize_review_prefix\nVERIFY: `cargo test -- reviews`\n\n## Acceptance Criteria\n\n- [ ] test_reviews_query passes (total=3, categorized=2)\n- [ ] test_normalize_review_prefix passes (nit/nitpick merge, parenthetical strip)\n- [ ] Only counts DiffNotes on MRs user did NOT author\n- [ ] Default since window: 6m\n\n## Edge Cases\n\n- Self-authored MRs excluded (m.author_username != ?1) — user's notes on own MRs are not \"reviews\"\n- ltrim() handles leading whitespace before **prefix**\n- Empty raw_prefix after normalization filtered out (!normalized.is_empty())\n- Percentage calculated from categorized_count (not total_diffnotes)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:40:53.350210Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.599252Z","closed_at":"2026-02-08T04:10:29.599217Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2711","depends_on_id":"bd-2ldg","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2711","depends_on_id":"bd-34rr","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-296a","title":"NOTE-1E: Composite query index and author_id column (migration 022)","description":"## Background\nThe notes table needs composite covering indexes for the new query_notes() function, plus the author_id column for immutable identity (NOTE-0D). Combined in a single migration to avoid an extra migration step. Migration slot 022 is available (021 = work_item_status, 023 = issue_detail_fields already exists).\n\n## Approach\nCreate migrations/022_notes_query_index.sql with:\n\n1. Composite index for author-scoped queries (most common pattern):\n CREATE INDEX IF NOT EXISTS idx_notes_user_created\n ON notes(project_id, author_username COLLATE NOCASE, created_at DESC, id DESC)\n WHERE is_system = 0;\n\n2. Composite index for project-scoped date-range queries:\n CREATE INDEX IF NOT EXISTS idx_notes_project_created\n ON notes(project_id, created_at DESC, id DESC)\n WHERE is_system = 0;\n\n3. Discussion JOIN indexes (check if they already exist first):\n CREATE INDEX IF NOT EXISTS idx_discussions_issue_id ON discussions(issue_id);\n CREATE INDEX IF NOT EXISTS idx_discussions_mr_id ON discussions(merge_request_id);\n\n4. Immutable author identity column (for NOTE-0D):\n ALTER TABLE notes ADD COLUMN author_id INTEGER;\n CREATE INDEX IF NOT EXISTS idx_notes_author_id ON notes(author_id) WHERE author_id IS NOT NULL;\n\nRegister in src/core/db.rs MIGRATIONS array as (\"022\", include_str!(\"../../migrations/022_notes_query_index.sql\")). Insert BEFORE the existing (\"023\", ...) entry. LATEST_SCHEMA_VERSION auto-increments via MIGRATIONS.len().\n\n## Files\n- CREATE: migrations/022_notes_query_index.sql\n- MODIFY: src/core/db.rs (add (\"022\", include_str!(...)) to MIGRATIONS array, insert at position before \"023\" entry around line 73)\n\n## TDD Anchor\nRED: test_migration_022_indexes_exist — run_migrations on in-memory DB, verify 4 new indexes exist in sqlite_master.\nGREEN: Create migration file with all CREATE INDEX statements.\nVERIFY: cargo test migration_022 -- --nocapture\n\n## Acceptance Criteria\n- [ ] Migration 022 creates idx_notes_user_created partial index\n- [ ] Migration 022 creates idx_notes_project_created partial index\n- [ ] Migration 022 creates idx_discussions_issue_id (or is no-op if exists)\n- [ ] Migration 022 creates idx_discussions_mr_id (or is no-op if exists)\n- [ ] Migration 022 adds author_id INTEGER column to notes\n- [ ] Migration 022 creates idx_notes_author_id partial index\n- [ ] MIGRATIONS array in db.rs includes (\"022\", ...) before (\"023\", ...)\n- [ ] Existing tests still pass with new migration\n- [ ] Test verifying all indexes exist passes\n\n## Edge Cases\n- Partial indexes exclude system notes (is_system = 0) — filters 30-50% of notes\n- COLLATE NOCASE on author_username matches the query's case-insensitive comparison\n- author_id is nullable (existing notes won't have it until re-synced)\n- IF NOT EXISTS on all CREATE INDEX statements makes migration idempotent","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:01:18.127989Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.435624Z","closed_at":"2026-02-12T18:13:15.435576Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"],"dependencies":[{"issue_id":"bd-296a","depends_on_id":"bd-jbfw","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-29qw","title":"Implement Timeline screen (state + action + view)","description":"## Background\nThe Timeline screen renders a chronological event stream from the 5-stage timeline pipeline (SEED -> HYDRATE -> EXPAND -> COLLECT -> RENDER). Events are color-coded by type and can be scoped to an entity, author, or time range.\n\n## Approach\nState (state/timeline.rs):\n- TimelineState: events (Vec), query (String), query_input (TextInput), query_focused (bool), selected_index (usize), scroll_offset (usize), scope (TimelineScope)\n- TimelineScope: All, Entity(EntityKey), Author(String), DateRange(DateTime, DateTime)\n\nAction (action.rs):\n- fetch_timeline(conn, scope, limit, clock) -> Vec: runs the timeline pipeline against DB\n\nView (view/timeline.rs):\n- Vertical event stream with timestamp gutter on the left\n- Color-coded event types: Created(green), Updated(yellow), Closed(red), Merged(purple), Commented(blue), Labeled(cyan), Milestoned(orange)\n- Each event: timestamp | entity ref | event description\n- Entity refs navigable via Enter\n- Query bar for filtering by text or entity\n- Keyboard: j/k scroll, Enter navigate to entity, / focus query, g+g top\n\n## Acceptance Criteria\n- [ ] Timeline renders chronological event stream\n- [ ] Events color-coded by type\n- [ ] Entity references navigable\n- [ ] Scope filters: all, per-entity, per-author, date range\n- [ ] Query bar filters events\n- [ ] Keyboard navigation works (j/k/Enter/Esc)\n- [ ] Timestamps use injected Clock\n\n## Files\n- MODIFY: crates/lore-tui/src/state/timeline.rs (expand from stub)\n- MODIFY: crates/lore-tui/src/action.rs (add fetch_timeline)\n- CREATE: crates/lore-tui/src/view/timeline.rs\n\n## TDD Anchor\nRED: Write test_fetch_timeline_scoped that creates issues with events, calls fetch_timeline with Entity scope, asserts only that entity's events returned.\nGREEN: Implement fetch_timeline with scope filtering.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_timeline\n\n## Edge Cases\n- Timeline pipeline may not be fully implemented in core yet — degrade gracefully if SEED/HYDRATE/EXPAND stages are not available, fall back to raw events\n- Very long timelines: VirtualizedList or lazy loading for performance\n- Events with identical timestamps: stable sort by entity type, then iid\n\n## Dependency Context\nUses timeline pipeline types from src/core/timeline.rs if available.\nUses Clock for timestamp rendering from \"Implement Clock trait\" task.\nUses EntityKey navigation from \"Implement core types\" task.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T17:01:05.605968Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:33.993830Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-29qw","depends_on_id":"bd-1zow","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-29qw","depends_on_id":"bd-nwux","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} +{"id":"bd-29wn","title":"Split app.rs into app/ module (model + key dispatch)","description":"app.rs is 712 lines and will grow as screens are added. Split into crates/lore-tui/src/app/mod.rs (LoreApp struct, new(), init()), app/update.rs (update() method, key dispatch, message handling), app/view.rs (view() delegation). Key dispatch is the largest section and the primary growth point. Keep public API identical via re-exports.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T21:24:16.854321Z","created_by":"tayloreernisse","updated_at":"2026-02-12T21:24:38.918911Z","compaction_level":0,"original_size":0,"labels":["TUI"]} {"id":"bd-2ac","title":"Create migration 009_embeddings.sql","description":"## Background\nMigration 009 creates the embedding storage layer for Gate B. It introduces a sqlite-vec vec0 virtual table for vector search and an embedding_metadata table for tracking provenance per chunk. Unlike migrations 007-008, this migration REQUIRES sqlite-vec to be loaded before it can be applied. The migration runner in db.rs must load the sqlite-vec extension first.\n\n## Approach\nCreate `migrations/009_embeddings.sql` per PRD Section 1.3.\n\n**Tables:**\n1. `embeddings` — vec0 virtual table with `embedding float[768]`\n2. `embedding_metadata` — tracks per-chunk provenance with composite PK (document_id, chunk_index)\n3. Orphan cleanup trigger: `documents_embeddings_ad` — deletes ALL chunk embeddings when a document is deleted using range deletion `[doc_id * 1000, (doc_id + 1) * 1000)`\n\n**Critical: sqlite-vec loading:**\nThe migration runner in `src/core/db.rs` must load sqlite-vec BEFORE applying any migrations. This means adding extension loading to the `create_connection()` or `run_migrations()` function. sqlite-vec is loaded via:\n```rust\nconn.load_extension_enable()?;\nconn.load_extension(\"vec0\", None)?; // or platform-specific path\nconn.load_extension_disable()?;\n```\n\nRegister migration 9 in `src/core/db.rs` MIGRATIONS array.\n\n## Acceptance Criteria\n- [ ] `migrations/009_embeddings.sql` file exists\n- [ ] `embeddings` vec0 virtual table created with `embedding float[768]`\n- [ ] `embedding_metadata` table has composite PK (document_id, chunk_index)\n- [ ] `embedding_metadata.document_id` has FK to documents(id) ON DELETE CASCADE\n- [ ] Error tracking fields: last_error, attempt_count, last_attempt_at\n- [ ] Orphan cleanup trigger: deletes embeddings WHERE rowid in [doc_id*1000, (doc_id+1)*1000)\n- [ ] Index on embedding_metadata(last_error) WHERE last_error IS NOT NULL\n- [ ] Index on embedding_metadata(document_id)\n- [ ] Schema version 9 recorded\n- [ ] Migration runner loads sqlite-vec before applying migrations\n- [ ] `cargo build` succeeds\n\n## Files\n- `migrations/009_embeddings.sql` — new file (copy exact SQL from PRD Section 1.3)\n- `src/core/db.rs` — add migration 9 to MIGRATIONS array; add sqlite-vec extension loading\n\n## TDD Loop\nRED: Register migration in db.rs, `cargo test migration_tests` fails\nGREEN: Create SQL file + add extension loading\nVERIFY: `cargo test migration_tests && cargo build`\n\n## Edge Cases\n- sqlite-vec not installed: migration fails with clear error (not a silent skip)\n- Migration applied without sqlite-vec loaded: `CREATE VIRTUAL TABLE` fails with \"no such module: vec0\"\n- Documents deleted before embeddings: trigger fires but vec0 DELETE on empty range is safe\n- vec0 doesn't support FK cascades: that's why we need the explicit trigger","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:33.958178Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:22:26.478290Z","closed_at":"2026-01-30T17:22:26.478229Z","close_reason":"Completed: migration 009_embeddings.sql with vec0 table, embedding_metadata with composite PK, orphan cleanup trigger, registered in db.rs","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2ac","depends_on_id":"bd-221","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2am8","title":"OBSERV: Enhance sync-status to show recent runs with metrics","description":"## Background\nsync_status currently queries sync_runs but always gets zero rows (nothing writes to the table). After bd-23a4 wires up SyncRunRecorder, rows will exist. This bead enhances the display to show recent runs with metrics.\n\n## Approach\n### src/cli/commands/sync_status.rs\n\n1. Change get_last_sync_run() (line ~66) to get_recent_sync_runs() returning last N:\n```rust\nfn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result> {\n let mut stmt = conn.prepare(\n \"SELECT id, started_at, finished_at, status, command, error,\n run_id, total_items_processed, total_errors, metrics_json\n FROM sync_runs\n ORDER BY started_at DESC\n LIMIT ?1\",\n )?;\n // ... map rows to SyncRunInfo\n}\n```\n\n2. Extend SyncRunInfo to include new fields:\n```rust\npub struct SyncRunInfo {\n pub id: i64,\n pub started_at: i64,\n pub finished_at: Option,\n pub status: String,\n pub command: String,\n pub error: Option,\n pub run_id: Option, // NEW\n pub total_items_processed: i64, // NEW\n pub total_errors: i64, // NEW\n pub stages: Option>, // NEW: parsed from metrics_json\n}\n```\n\n3. Parse metrics_json into Vec:\n```rust\nlet stages: Option> = row.get::<_, Option>(9)?\n .and_then(|json| serde_json::from_str(&json).ok());\n```\n\n4. Interactive output (new format):\n```\nRecent sync runs:\n Run a1b2c3 | 2026-02-04 14:32 | 45.2s | 235 items | 1 error\n Run d4e5f6 | 2026-02-03 14:30 | 38.1s | 220 items | 0 errors\n Run g7h8i9 | 2026-02-02 14:29 | 42.7s | 228 items | 0 errors\n```\n\n5. Robot JSON output: runs array with stages parsed from metrics_json:\n```json\n{\n \"ok\": true,\n \"data\": {\n \"runs\": [{ \"run_id\": \"...\", \"stages\": [...] }],\n \"cursors\": [...],\n \"summary\": {...}\n }\n}\n```\n\n6. Add --run flag to sync-status subcommand for single-run detail view (shows full stage breakdown).\n\n## Acceptance Criteria\n- [ ] lore sync-status shows last 10 runs (not just 1) with run_id, duration, items, errors\n- [ ] lore --robot sync-status JSON includes runs array with stages parsed from metrics_json\n- [ ] lore sync-status --run a1b2c3 shows single run detail with full stage breakdown\n- [ ] When no runs exist, shows appropriate \"No sync runs recorded\" message\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/commands/sync_status.rs (rewrite query, extend structs, update display)\n\n## TDD Loop\nRED:\n - test_sync_status_shows_runs: insert 3 sync_runs rows, call print function, assert all 3 shown\n - test_sync_status_json_includes_stages: insert row with metrics_json, verify robot JSON has stages\n - test_sync_status_empty: no rows, verify graceful message\nGREEN: Rewrite get_last_sync_run -> get_recent_sync_runs, extend SyncRunInfo, update output\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- metrics_json is NULL (old rows or failed runs): stages field is null/empty in output\n- metrics_json is malformed: serde_json::from_str fails silently (.ok()), stages is None\n- Duration calculation: finished_at - started_at in ms. If finished_at is NULL (running), show \"in progress\"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:51.467705Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:43:07.306504Z","closed_at":"2026-02-04T17:43:07.306425Z","close_reason":"Enhanced sync-status: shows last 10 runs with run_id, duration, items, errors, parsed stages; JSON includes full stages array","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-2am8","depends_on_id":"bd-23a4","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2am8","depends_on_id":"bd-3pz","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2ao4","title":"Add migration for dual-path and reviewer participation indexes","description":"## Background\nThe restructured expert SQL (bd-1hoq) uses UNION ALL + dedup to match both old_path and new_path columns. Without indexes on old_path columns, these branches would force table scans. The reviewer_participation CTE joins notes -> discussions and needs index coverage on discussion_id. Path resolution probes (build_path_query, suffix_probe) need their own old_path index optimized for existence checks.\n\n## Approach\nCreate migration file migrations/026_scoring_indexes.sql (latest is 025_note_dirty_backfill.sql). Add entry to MIGRATIONS array in db.rs. LATEST_SCHEMA_VERSION auto-increments via MIGRATIONS.len().\n\n### Migration SQL (5 indexes):\n```sql\n-- 1. Support old_path leg of matched_notes CTE (scoring queries)\nCREATE INDEX IF NOT EXISTS idx_notes_old_path_author\n ON notes(position_old_path, author_username, created_at)\n WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;\n\n-- 2. Support old_path leg of matched_file_changes CTE\nCREATE INDEX IF NOT EXISTS idx_mfc_old_path_project_mr\n ON mr_file_changes(old_path, project_id, merge_request_id)\n WHERE old_path IS NOT NULL;\n\n-- 3. Ensure new_path index parity for matched_file_changes CTE\nCREATE INDEX IF NOT EXISTS idx_mfc_new_path_project_mr\n ON mr_file_changes(new_path, project_id, merge_request_id);\n\n-- 4. Support reviewer_participation CTE: notes -> discussions join\nCREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author\n ON notes(discussion_id, author_username, created_at)\n WHERE note_type = 'DiffNote' AND is_system = 0;\n\n-- 5. Support path resolution probes on old_path (build_path_query + suffix_probe)\nCREATE INDEX IF NOT EXISTS idx_notes_old_path_project_created\n ON notes(position_old_path, project_id, created_at)\n WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;\n```\n\n### MIGRATIONS array addition (src/core/db.rs, after the (\"025\", ...) entry):\n```rust\n(\"026\", include_str!(\"../../migrations/026_scoring_indexes.sql\")),\n```\n\n## Acceptance Criteria\n- [ ] Migration file at migrations/026_scoring_indexes.sql with 5 CREATE INDEX statements\n- [ ] MIGRATIONS array has (\"026\", include_str!(\"../../migrations/026_scoring_indexes.sql\"))\n- [ ] LATEST_SCHEMA_VERSION auto-increments to 26 (MIGRATIONS.len())\n- [ ] cargo test (migration tests pass on both fresh and migrated :memory: DBs)\n- [ ] Existing indexes unaffected\n\n## Files\n- CREATE: migrations/026_scoring_indexes.sql\n- MODIFY: src/core/db.rs (MIGRATIONS array — add entry after (\"025\", ...) at end of array)\n\n## TDD Loop\nRED: cargo test should fail if migration not applied (schema version mismatch)\nGREEN: Add migration file + MIGRATIONS entry\nVERIFY: cargo test -p lore\n\n## Edge Cases\n- Use CREATE INDEX IF NOT EXISTS (idempotent)\n- Partial indexes with WHERE clauses keep size minimal\n- position_old_path and old_path can be NULL (handled by WHERE clause)\n- idx_notes_old_path_project_created vs idx_notes_old_path_author: former for probes (no author constraint), latter for scoring (author in covering index)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-09T16:59:30.746899Z","created_by":"tayloreernisse","updated_at":"2026-02-12T19:44:15.476719Z","compaction_level":0,"original_size":0,"labels":["db","scoring"]} {"id":"bd-2as","title":"[CP1] Epic: Issue Ingestion","description":"Ingest all issues, labels, and issue discussions from configured GitLab repositories with resumable cursor-based incremental sync. This establishes the core data ingestion pattern reused for MRs in CP2.\n\nSuccess Criteria:\n- gi ingest --type=issues fetches all issues (count matches GitLab UI)\n- Labels extracted from issue payloads\n- Issue discussions fetched per-issue\n- Cursor-based sync is resumable\n- Sync tracking records all runs\n- Single-flight lock prevents concurrent runs\n\nReference: docs/prd/checkpoint-1.md","status":"tombstone","priority":1,"issue_type":"task","created_at":"2026-01-25T15:18:44.062057Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.155746Z","closed_at":"2026-01-25T15:21:35.155746Z","deleted_at":"2026-01-25T15:21:35.155744Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-2b28","title":"NOTE-0C: Sweep safety guard for partial fetch protection","description":"## Background\nThe sweep pattern (delete notes where last_seen_at < run_seen_at) is correct only when a discussion's notes were fully fetched. If a page fails mid-fetch, the current logic would incorrectly delete valid notes that weren't seen during the incomplete fetch. Especially dangerous for long threads spanning multiple API pages.\n\n## Approach\nAdd a fetch_complete: bool parameter to discussion ingestion functions. Only run sweep when fetch completed successfully:\n\nif fetch_complete {\n sweep_stale_issue_notes(&tx, local_discussion_id, last_seen_at)?;\n} else {\n tracing::warn!(discussion_id = local_discussion_id, \"Skipping stale note sweep due to partial/incomplete fetch\");\n}\n\nDetermining fetch_complete: Look at the existing pagination_error pattern in src/ingestion/discussions.rs lines 148-154. When pagination_error is None (all pages fetched successfully), fetch_complete = true. When pagination_error is Some (network error, rate limit, interruption), fetch_complete = false. The MR path has a similar pattern in src/ingestion/mr_discussions.rs — search for where sweep_stale_discussions (line 539) and sweep_stale_notes (line 551) are called to find the equivalent guard.\n\nThe fetch_complete flag should be threaded from the outer discussion-fetch loop into the per-discussion upsert transaction, NOT as a parameter on sweep itself (sweep always sweeps — the caller decides whether to call it).\n\n## Files\n- MODIFY: src/ingestion/discussions.rs (guard sweep call with fetch_complete, lines 132-146)\n- MODIFY: src/ingestion/mr_discussions.rs (guard sweep call, near line 551 call site)\n\n## TDD Anchor\nRED: test_partial_fetch_does_not_sweep_notes — 5 notes in DB, partial fetch returns 2, assert all 5 still exist.\nGREEN: Add fetch_complete guard around sweep call.\nVERIFY: cargo test partial_fetch_does_not_sweep -- --nocapture\nTests: test_complete_fetch_runs_sweep_normally, test_partial_fetch_then_complete_fetch_cleans_up\n\n## Acceptance Criteria\n- [ ] Sweep only runs when fetch_complete = true\n- [ ] Partial fetch logs a warning (tracing::warn!) but preserves all notes\n- [ ] Second complete fetch correctly sweeps notes deleted on GitLab\n- [ ] Both issue and MR discussion paths support fetch_complete\n- [ ] All 3 tests pass\n\n## Dependency Context\n- Depends on NOTE-0A (bd-3bpk): modifies the sweep call site from NOTE-0A. The sweep functions must exist before this guard can wrap them.\n\n## Edge Cases\n- Rate limit mid-page: pagination_error triggers partial fetch — sweep must be skipped\n- Discussion with 1 page of notes: always fully fetched if no error, sweep runs normally\n- Empty discussion (0 notes returned): still counts as complete fetch — sweep is a no-op anyway","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:59:44.290790Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.172004Z","closed_at":"2026-02-12T18:13:15.171952Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"]} {"id":"bd-2bu","title":"[CP1] GitLab types for issues, discussions, notes","description":"Add Rust types to src/gitlab/types.rs for GitLab API responses.\n\n## Types to Add\n\n### GitLabIssue\n- id: i64 (GitLab global ID)\n- iid: i64 (project-scoped issue number)\n- project_id: i64\n- title: String\n- description: Option\n- state: String (\"opened\" | \"closed\")\n- created_at, updated_at: String (ISO 8601)\n- closed_at: Option\n- author: GitLabAuthor\n- labels: Vec (array of label names - CP1 canonical)\n- web_url: String\nNOTE: labels_details intentionally NOT modeled - varies across GitLab versions\n\n### GitLabAuthor\n- id: i64\n- username: String\n- name: String\n\n### GitLabDiscussion\n- id: String (like \"6a9c1750b37d...\")\n- individual_note: bool\n- notes: Vec\n\n### GitLabNote\n- id: i64\n- note_type: Option (\"DiscussionNote\" | \"DiffNote\" | null)\n- body: String\n- author: GitLabAuthor\n- created_at, updated_at: String (ISO 8601)\n- system: bool\n- resolvable: bool (default false)\n- resolved: bool (default false)\n- resolved_by: Option\n- resolved_at: Option\n- position: Option\n\n### GitLabNotePosition\n- old_path, new_path: Option\n- old_line, new_line: Option\n\nFiles: src/gitlab/types.rs\nTests: Test deserialization with fixtures\nDone when: Types compile and deserialize sample API responses correctly","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:42:46.922805Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.710057Z","closed_at":"2026-01-25T17:02:01.710057Z","deleted_at":"2026-01-25T17:02:01.710051Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} +{"id":"bd-2cbw","title":"Split who.rs into who/ module (queries + scoring + formatting)","description":"who.rs is 3742 lines — the largest file in the project. Split into src/cli/commands/who/mod.rs (re-exports, CLI arg handling), who/queries.rs (SQL queries, data retrieval), who/scoring.rs (half_life_decay, normalize_query_path, scoring aggregation), who/format.rs (human-readable + robot-mode output formatting). Keep public API identical via re-exports.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T21:24:22.040110Z","created_by":"tayloreernisse","updated_at":"2026-02-12T21:24:45.468981Z","compaction_level":0,"original_size":0,"labels":["CLI"]} {"id":"bd-2cu","title":"[CP1] Discussion ingestion module","description":"Fetch and store discussions/notes for each issue.\n\n## Module\nsrc/ingestion/discussions.rs\n\n## Key Structs\n\n### IngestDiscussionsResult\n- discussions_fetched: usize\n- discussions_upserted: usize\n- notes_upserted: usize\n- system_notes_count: usize\n\n## Main Function\npub async fn ingest_issue_discussions(\n conn, client, config,\n project_id, gitlab_project_id,\n issue_iid, local_issue_id, issue_updated_at\n) -> Result\n\n## Logic\n1. Paginate through all discussions for given issue\n2. For each discussion:\n - Begin transaction\n - Store raw payload (compressed)\n - Transform and upsert discussion record with correct issue FK\n - Get local discussion ID\n - Transform notes from discussion\n - For each note:\n - Store raw payload\n - Upsert note with discussion_id FK\n - Count system notes\n - Commit transaction\n3. After all discussions synced: mark_discussions_synced(conn, local_issue_id, issue_updated_at)\n - UPDATE issues SET discussions_synced_for_updated_at = ? WHERE id = ?\n\n## Invariant\nA rerun MUST NOT refetch discussions for issues whose updated_at has not advanced, even with cursor rewind. The discussions_synced_for_updated_at watermark ensures this.\n\n## Helper Functions\n- upsert_discussion(conn, discussion, payload_id)\n- get_local_discussion_id(conn, project_id, gitlab_id) -> i64\n- upsert_note(conn, discussion_id, note, payload_id)\n- mark_discussions_synced(conn, issue_id, issue_updated_at)\n\nFiles: src/ingestion/discussions.rs\nTests: tests/discussion_watermark_tests.rs\nDone when: Discussions and notes populated with correct FKs, watermark prevents redundant refetch","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T16:57:36.703237Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.827880Z","closed_at":"2026-01-25T17:02:01.827880Z","deleted_at":"2026-01-25T17:02:01.827876Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-2dk","title":"Implement project resolution for --project filter","description":"## Background\nThe --project filter on search (and other commands) accepts a string that must be resolved to a project_id. Users may type the full path, a partial path, or just the project name. The resolution logic provides cascading match with helpful error messages when ambiguous. This improves ergonomics for multi-project installations.\n\n## Approach\nImplement project resolution function (location TBD — likely `src/core/project.rs` or inline in search filters):\n\n```rust\n/// Resolve a project string to a project_id using cascading match:\n/// 1. Exact match on path_with_namespace\n/// 2. Case-insensitive exact match\n/// 3. Suffix match (only if unambiguous)\n/// 4. Error with available projects list\npub fn resolve_project(conn: &Connection, project_str: &str) -> Result\n```\n\n**SQL queries:**\n```sql\n-- Step 1: exact match\nSELECT id FROM projects WHERE path_with_namespace = ?\n\n-- Step 2: case-insensitive\nSELECT id FROM projects WHERE LOWER(path_with_namespace) = LOWER(?)\n\n-- Step 3: suffix match\nSELECT id, path_with_namespace FROM projects\nWHERE path_with_namespace LIKE '%/' || ?\n OR path_with_namespace = ?\n\n-- Step 4: list all for error message\nSELECT path_with_namespace FROM projects ORDER BY path_with_namespace\n```\n\n**Error format:**\n```\nError: Project 'auth-service' not found.\n\nAvailable projects:\n backend/auth-service\n frontend/auth-service-ui\n infra/auth-proxy\n\nHint: Use the full path, e.g., --project=backend/auth-service\n```\n\nUses `LoreError::Ambiguous` variant for multiple suffix matches.\n\n## Acceptance Criteria\n- [ ] Exact match: \"group/project\" resolves correctly\n- [ ] Case-insensitive: \"Group/Project\" resolves to \"group/project\"\n- [ ] Suffix match: \"project-name\" resolves when only one \"*/project-name\" exists\n- [ ] Ambiguous suffix: error lists matching projects with hint\n- [ ] No match: error lists all available projects with hint\n- [ ] Empty projects table: clear error message\n- [ ] `cargo test project_resolution` passes\n\n## Files\n- `src/core/project.rs` — new file (or add to existing module)\n- `src/core/mod.rs` — add `pub mod project;`\n\n## TDD Loop\nRED: Tests:\n- `test_exact_match` — full path resolves\n- `test_case_insensitive` — mixed case resolves\n- `test_suffix_unambiguous` — short name resolves when unique\n- `test_suffix_ambiguous` — error with list when multiple match\n- `test_no_match` — error with available projects\nGREEN: Implement resolve_project\nVERIFY: `cargo test project_resolution`\n\n## Edge Cases\n- Project path containing special LIKE characters (%, _): unlikely but escape for safety\n- Single project in DB: suffix always unambiguous\n- Project path with multiple slashes: \"org/group/project\" — suffix match on \"project\" works","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:26:13.076571Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:39:17.197735Z","closed_at":"2026-01-30T17:39:17.197552Z","close_reason":"Implemented resolve_project() with cascading match (exact, CI, suffix) + 6 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2dk","depends_on_id":"bd-3q2","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2dlt","title":"Implement GraphQL client with partial-error handling","description":"## Background\nGitLab's GraphQL endpoint (/api/graphql) uses different auth than REST (Bearer token, not PRIVATE-TOKEN). We need a minimal GraphQL client that handles the GitLab-specific error codes and partial-data responses per GraphQL spec. The client returns a GraphqlQueryResult struct that propagates partial-error metadata end-to-end.\n\n## Approach\nCreate a new file src/gitlab/graphql.rs with GraphqlClient (uses reqwest). Add httpdate crate for Retry-After HTTP-date parsing. Wire into the module tree. Factory on GitLabClient keeps token encapsulated.\n\n## Files\n- src/gitlab/graphql.rs (NEW) — GraphqlClient struct, GraphqlQueryResult, ansi256_from_rgb\n- src/gitlab/mod.rs (add pub mod graphql;)\n- src/gitlab/client.rs (add graphql_client() factory method)\n- Cargo.toml (add httpdate dependency)\n\n## Implementation\n\nGraphqlClient struct:\n Fields: http (reqwest::Client with 30s timeout), base_url (String), token (String)\n Constructor: new(base_url, token) — trims trailing slash from base_url\n \nquery() method:\n - POST to {base_url}/api/graphql\n - Headers: Authorization: Bearer {token}, Content-Type: application/json\n - Body: {\"query\": \"...\", \"variables\": {...}}\n - Returns Result\n\nGraphqlQueryResult struct (pub):\n data: serde_json::Value\n had_partial_errors: bool\n first_partial_error: Option\n\nHTTP status mapping:\n 401 | 403 -> LoreError::GitLabAuthFailed\n 404 -> LoreError::GitLabNotFound { resource: \"GraphQL endpoint\" }\n 429 -> LoreError::GitLabRateLimited { retry_after } (parse Retry-After: try u64 first, then httpdate::parse_http_date, fallback 60)\n Other non-success -> LoreError::Other\n\nGraphQL-level error handling:\n errors array present + data absent/null -> Err(LoreError::Other(\"GraphQL error: {first_msg}\"))\n errors array present + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: true, first_partial_error: Some(first_msg) })\n No errors + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: false, first_partial_error: None })\n No errors + no data -> Err(LoreError::Other(\"missing 'data' field\"))\n\nansi256_from_rgb(r, g, b) -> u8:\n Maps RGB to nearest ANSI 256-color index using 6x6x6 cube (indices 16-231).\n MUST be placed BEFORE #[cfg(test)] module (clippy::items_after_test_module).\n\nFactory in src/gitlab/client.rs:\n pub fn graphql_client(&self) -> crate::gitlab::graphql::GraphqlClient {\n crate::gitlab::graphql::GraphqlClient::new(&self.base_url, &self.token)\n }\n\n## Acceptance Criteria\n- [ ] query() sends POST with Bearer auth header\n- [ ] Success: returns GraphqlQueryResult { data, had_partial_errors: false }\n- [ ] Errors-only (no data): returns Err with first error message\n- [ ] Partial data + errors: returns Ok with had_partial_errors: true\n- [ ] 401 -> GitLabAuthFailed\n- [ ] 403 -> GitLabAuthFailed\n- [ ] 404 -> GitLabNotFound\n- [ ] 429 -> GitLabRateLimited (parses Retry-After delta-seconds and HTTP-date, fallback 60)\n- [ ] ansi256_from_rgb: (0,0,0)->16, (255,255,255)->231\n- [ ] cargo check --all-targets passes\n\n## TDD Loop\nRED: test_graphql_query_success, test_graphql_query_with_errors_no_data, test_graphql_auth_uses_bearer, test_graphql_401_maps_to_auth_failed, test_graphql_403_maps_to_auth_failed, test_graphql_404_maps_to_not_found, test_graphql_partial_data_with_errors_returns_data, test_retry_after_http_date_format, test_retry_after_invalid_falls_back_to_60, test_ansi256_from_rgb\n Tests use wiremock or similar mock HTTP server\nGREEN: Implement GraphqlClient, add httpdate to Cargo.toml\nVERIFY: cargo test graphql && cargo test ansi256\n\n## Edge Cases\n- Use r##\"...\"## in tests containing \"#1f75cb\" hex colors (# breaks r#\"...\"#)\n- LoreError::GitLabRateLimited uses u64 not Option — use .unwrap_or(60)\n- httpdate::parse_http_date returns SystemTime — compute duration_since(now) for delta\n- GraphqlQueryResult is NOT Clone — tests must check fields individually","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:41:52.833151Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.417835Z","closed_at":"2026-02-11T07:21:33.417793Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2dlt","depends_on_id":"bd-1v8t","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2dlt","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} @@ -180,7 +183,7 @@ {"id":"bd-34ek","title":"OBSERV: Implement MetricsLayer custom tracing subscriber layer","description":"## Background\nMetricsLayer is a custom tracing subscriber layer that records span timing and structured fields, then materializes them into Vec. This avoids threading a mutable collector through every function signature -- spans are the single source of truth.\n\n## Approach\nAdd to src/core/metrics.rs (same file as StageTiming):\n\n```rust\nuse std::collections::HashMap;\nuse std::sync::{Arc, Mutex};\nuse std::time::Instant;\nuse tracing::span::{Attributes, Id, Record};\nuse tracing::Subscriber;\nuse tracing_subscriber::layer::{Context, Layer};\nuse tracing_subscriber::registry::LookupSpan;\n\n#[derive(Debug)]\nstruct SpanData {\n name: String,\n parent_id: Option,\n start: Instant,\n fields: HashMap,\n}\n\n#[derive(Debug, Clone)]\npub struct MetricsLayer {\n spans: Arc>>,\n completed: Arc>>,\n}\n\nimpl MetricsLayer {\n pub fn new() -> Self {\n Self {\n spans: Arc::new(Mutex::new(HashMap::new())),\n completed: Arc::new(Mutex::new(Vec::new())),\n }\n }\n\n /// Extract timing tree for a completed run.\n /// Call this after the root span closes.\n pub fn extract_timings(&self) -> Vec {\n let completed = self.completed.lock().unwrap();\n // Build tree: find root entries (no parent), attach children\n // ... tree construction logic\n }\n}\n\nimpl Layer for MetricsLayer\nwhere\n S: Subscriber + for<'a> LookupSpan<'a>,\n{\n fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {\n let parent_id = ctx.span(id).and_then(|s| s.parent().map(|p| p.id()));\n let mut fields = HashMap::new();\n // Visit attrs to capture initial field values\n let mut visitor = FieldVisitor(&mut fields);\n attrs.record(&mut visitor);\n\n self.spans.lock().unwrap().insert(id.into_u64(), SpanData {\n name: attrs.metadata().name().to_string(),\n parent_id,\n start: Instant::now(),\n fields,\n });\n }\n\n fn on_record(&self, id: &Id, values: &Record<'_>, _ctx: Context<'_, S>) {\n // Capture recorded fields (items_processed, items_skipped, errors)\n if let Some(data) = self.spans.lock().unwrap().get_mut(&id.into_u64()) {\n let mut visitor = FieldVisitor(&mut data.fields);\n values.record(&mut visitor);\n }\n }\n\n fn on_close(&self, id: Id, _ctx: Context<'_, S>) {\n if let Some(data) = self.spans.lock().unwrap().remove(&id.into_u64()) {\n let elapsed = data.start.elapsed();\n let timing = StageTiming {\n name: data.name,\n project: data.fields.get(\"project\").and_then(|v| v.as_str()).map(String::from),\n elapsed_ms: elapsed.as_millis() as u64,\n items_processed: data.fields.get(\"items_processed\").and_then(|v| v.as_u64()).unwrap_or(0) as usize,\n items_skipped: data.fields.get(\"items_skipped\").and_then(|v| v.as_u64()).unwrap_or(0) as usize,\n errors: data.fields.get(\"errors\").and_then(|v| v.as_u64()).unwrap_or(0) as usize,\n sub_stages: vec![], // Will be populated during extract_timings tree construction\n };\n self.completed.lock().unwrap().push((id.into_u64(), timing));\n }\n }\n}\n```\n\nNeed a FieldVisitor struct implementing tracing::field::Visit to capture field values.\n\nRegister in subscriber stack (src/main.rs), alongside stderr and file layers:\n```rust\nlet metrics_layer = MetricsLayer::new();\nlet metrics_handle = metrics_layer.clone(); // Clone Arc for later extraction\n\nregistry()\n .with(stderr_layer.with_filter(stderr_filter))\n .with(file_layer.with_filter(file_filter))\n .with(metrics_layer) // No filter -- captures all spans\n .init();\n```\n\nPass metrics_handle to command handlers so they can call extract_timings() after the pipeline completes.\n\n## Acceptance Criteria\n- [ ] MetricsLayer captures span enter/close timing\n- [ ] on_record captures items_processed, items_skipped, errors fields\n- [ ] extract_timings() returns correctly nested Vec tree\n- [ ] Parallel spans (multiple projects) both appear as sub_stages of parent\n- [ ] Thread-safe: Arc> allows concurrent span operations\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/metrics.rs (add MetricsLayer, FieldVisitor, tree construction)\n- src/main.rs (register MetricsLayer in subscriber stack)\n\n## TDD Loop\nRED:\n - test_metrics_layer_single_span: enter/exit one span, extract, assert one StageTiming\n - test_metrics_layer_nested_spans: parent + child, assert child in parent.sub_stages\n - test_metrics_layer_parallel_spans: two sibling spans, assert both in parent.sub_stages\n - test_metrics_layer_field_recording: record items_processed=42, assert captured\nGREEN: Implement MetricsLayer with on_new_span, on_record, on_close, extract_timings\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Span ID reuse: tracing may reuse span IDs after close. Using remove on close prevents stale data.\n- Lock contention: Mutex per operation. For high-span-count scenarios, consider parking_lot::Mutex. But lore's span count is low (<100 per run), so std::sync::Mutex is fine.\n- extract_timings tree construction: iterate completed Vec, build parent->children map, then recursively construct StageTiming tree. Root entries have parent_id matching the root span or None.\n- MetricsLayer has no filter: it sees ALL spans. To avoid noise from dependency spans, check if span name starts with known stage names, or rely on the \"stage\" field being present.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:31.960669Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:25:25.523811Z","closed_at":"2026-02-04T17:25:25.523730Z","close_reason":"Implemented MetricsLayer custom tracing subscriber layer with span timing capture, rate-limit/retry event detection, tree extraction, and 12 unit tests","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-34ek","depends_on_id":"bd-1o4h","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-34ek","depends_on_id":"bd-24j1","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-34ek","depends_on_id":"bd-3er","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-34o","title":"Implement MR transformer","description":"## Background\nTransforms GitLab MR API responses into normalized schema for database storage. Handles deprecated field fallbacks and extracts metadata (labels, assignees, reviewers).\n\n## Approach\nCreate new transformer module following existing issue transformer pattern:\n- `NormalizedMergeRequest` - Database-ready struct\n- `MergeRequestWithMetadata` - MR + extracted labels/assignees/reviewers\n- `transform_merge_request()` - Main transformation function\n- `extract_labels()` - Label extraction helper\n\n## Files\n- `src/gitlab/transformers/merge_request.rs` - New transformer module\n- `src/gitlab/transformers/mod.rs` - Export new module\n- `tests/mr_transformer_tests.rs` - Unit tests\n\n## Acceptance Criteria\n- [ ] `NormalizedMergeRequest` struct exists with all DB columns\n- [ ] `MergeRequestWithMetadata` contains MR + label_names + assignee_usernames + reviewer_usernames\n- [ ] `transform_merge_request()` returns `Result`\n- [ ] `draft` computed as `gitlab_mr.draft || gitlab_mr.work_in_progress`\n- [ ] `detailed_merge_status` prefers `detailed_merge_status` over `merge_status_legacy`\n- [ ] `merge_user_username` prefers `merge_user` over `merged_by`\n- [ ] `head_sha` extracted from `sha` field\n- [ ] `references_short` and `references_full` extracted from `references` Option\n- [ ] Timestamps parsed with `iso_to_ms()`, errors returned (not zeroed)\n- [ ] `last_seen_at` set to `now_ms()`\n- [ ] `cargo test mr_transformer` passes\n\n## TDD Loop\nRED: `cargo test mr_transformer` -> module not found\nGREEN: Add transformer with all fields\nVERIFY: `cargo test mr_transformer`\n\n## Struct Definitions\n```rust\n#[derive(Debug, Clone)]\npub struct NormalizedMergeRequest {\n pub gitlab_id: i64,\n pub project_id: i64,\n pub iid: i64,\n pub title: String,\n pub description: Option,\n pub state: String,\n pub draft: bool,\n pub author_username: String,\n pub source_branch: String,\n pub target_branch: String,\n pub head_sha: Option,\n pub references_short: Option,\n pub references_full: Option,\n pub detailed_merge_status: Option,\n pub merge_user_username: Option,\n pub created_at: i64,\n pub updated_at: i64,\n pub merged_at: Option,\n pub closed_at: Option,\n pub last_seen_at: i64,\n pub web_url: String,\n}\n\n#[derive(Debug, Clone)]\npub struct MergeRequestWithMetadata {\n pub merge_request: NormalizedMergeRequest,\n pub label_names: Vec,\n pub assignee_usernames: Vec,\n pub reviewer_usernames: Vec,\n}\n```\n\n## Function Signature\n```rust\npub fn transform_merge_request(\n gitlab_mr: &GitLabMergeRequest,\n local_project_id: i64,\n) -> Result\n```\n\n## Key Logic\n```rust\n// Draft: prefer draft, fallback to work_in_progress\nlet is_draft = gitlab_mr.draft || gitlab_mr.work_in_progress;\n\n// Merge status: prefer detailed_merge_status\nlet detailed_merge_status = gitlab_mr.detailed_merge_status\n .clone()\n .or_else(|| gitlab_mr.merge_status_legacy.clone());\n\n// Merge user: prefer merge_user\nlet merge_user_username = gitlab_mr.merge_user\n .as_ref()\n .map(|u| u.username.clone())\n .or_else(|| gitlab_mr.merged_by.as_ref().map(|u| u.username.clone()));\n\n// References extraction\nlet (references_short, references_full) = gitlab_mr.references\n .as_ref()\n .map(|r| (Some(r.short.clone()), Some(r.full.clone())))\n .unwrap_or((None, None));\n\n// Head SHA\nlet head_sha = gitlab_mr.sha.clone();\n```\n\n## Edge Cases\n- Invalid timestamps should return `Err`, not zero values\n- Empty labels/assignees/reviewers should return empty Vecs, not None\n- `state` must pass through as-is (including \"locked\")","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:40.849049Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:11:48.501301Z","closed_at":"2026-01-27T00:11:48.501241Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-34o","depends_on_id":"bd-3ir","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-34o","depends_on_id":"bd-5ta","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-34rr","title":"WHO: Migration 017 — composite indexes for query paths","description":"## Background\n\nWith 280K notes, the path/timestamp queries for lore who will degrade without composite indexes. Existing indexes cover note_type and position_new_path separately (migration 006) but not as composites aligned to the who query patterns. This is a non-breaking, additive-only migration.\n\n## Approach\n\nAdd as entry 17 (index 16) in the MIGRATIONS array in src/core/db.rs. LATEST_SCHEMA_VERSION auto-updates via MIGRATIONS.len() as i32.\n\n### Exact SQL for the migration entry:\n\n```sql\n-- Migration 017: Composite indexes for who query paths\n\n-- Expert/Overlap: DiffNote path prefix + timestamp filter.\n-- Leading with position_new_path (not note_type) because the partial index\n-- predicate already handles the constant filter.\nCREATE INDEX IF NOT EXISTS idx_notes_diffnote_path_created\n ON notes(position_new_path, created_at, project_id)\n WHERE note_type = 'DiffNote' AND is_system = 0;\n\n-- Active/Workload: discussion participation lookups.\nCREATE INDEX IF NOT EXISTS idx_notes_discussion_author\n ON notes(discussion_id, author_username)\n WHERE is_system = 0;\n\n-- Active (project-scoped): unresolved discussions by recency.\nCREATE INDEX IF NOT EXISTS idx_discussions_unresolved_recent\n ON discussions(project_id, last_note_at)\n WHERE resolvable = 1 AND resolved = 0;\n\n-- Active (global): unresolved discussions by recency (no project scope).\n-- Without this, (project_id, last_note_at) can't satisfy ORDER BY last_note_at DESC\n-- efficiently when project_id is unconstrained.\nCREATE INDEX IF NOT EXISTS idx_discussions_unresolved_recent_global\n ON discussions(last_note_at)\n WHERE resolvable = 1 AND resolved = 0;\n\n-- Workload: issue assignees by username.\nCREATE INDEX IF NOT EXISTS idx_issue_assignees_username\n ON issue_assignees(username, issue_id);\n```\n\n### Not added (already adequate):\n- merge_requests(author_username) — idx_mrs_author (migration 006)\n- mr_reviewers(username) — idx_mr_reviewers_username (migration 006)\n- notes(discussion_id) — idx_notes_discussion (migration 002)\n\n## Files\n\n- `src/core/db.rs` — append to MIGRATIONS array as entry index 16\n\n## TDD Loop\n\nRED: `cargo test -- test_migration` (existing migration tests should still pass)\nGREEN: Add the migration SQL string to the array\nVERIFY: `cargo test && cargo check --all-targets`\n\n## Acceptance Criteria\n\n- [ ] MIGRATIONS array has 17 entries (index 0-16)\n- [ ] LATEST_SCHEMA_VERSION is 17\n- [ ] cargo test passes (in-memory DB runs all migrations including 017)\n- [ ] No existing index names conflict\n\n## Edge Cases\n\n- The SQL uses CREATE INDEX IF NOT EXISTS — safe for idempotent reruns\n- Partial indexes (WHERE clause) keep index size small: ~33K of 280K notes for DiffNote index","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:39:49.397860Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.593561Z","closed_at":"2026-02-08T04:10:29.593519Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0} -{"id":"bd-35g5","title":"Implement Dashboard state + action + view","description":"## Background\nThe Dashboard is the home screen — first thing users see. It shows entity counts, per-project sync status, recent activity, and a last-sync summary. Data comes from aggregation queries against the local SQLite database.\n\n## Approach\nState (state/dashboard.rs):\n- DashboardState: counts (EntityCounts), projects (Vec), recent (Vec), last_sync (LastSyncInfo)\n- EntityCounts: issues_open, issues_total, mrs_open, mrs_total, discussions, notes_total, notes_system_pct, documents, embeddings\n- ProjectSyncInfo: path (String), minutes_since_sync (u64)\n- RecentActivityItem: entity_type, iid, title, state, minutes_ago\n- update(data: DashboardData) method\n\nAction (action.rs):\n- fetch_dashboard(conn: &Connection, clock: &dyn Clock) -> Result: runs aggregation queries for counts, recent activity, project sync status. Uses clock.now() for relative time calculations.\n\nView (view/dashboard.rs):\n- render_dashboard(frame, state: &DashboardState, area: Rect, theme: &Theme): responsive layout with breakpoints\n - Wide (>=120 cols): 3-column: [Stats | Projects | Recent]\n - Medium (80-119): 2-column: [Stats+Projects | Recent]\n - Narrow (<80): single column stacked\n- render_stat_panel(): entity counts with colored numbers\n- render_project_list(): project names with sync staleness indicators\n- render_recent_activity(): scrollable list of recent changes\n- render_sync_summary(): last sync stats (if available)\n\n## Acceptance Criteria\n- [ ] DashboardState stores counts, projects, recent activity, last sync info\n- [ ] fetch_dashboard returns correct counts from DB\n- [ ] Dashboard renders with responsive breakpoints (3/2/1 column layouts)\n- [ ] Entity counts show open/total for issues and MRs\n- [ ] Project list shows sync staleness with color coding (green <1h, yellow <6h, red >6h)\n- [ ] Recent activity list is scrollable with j/k\n- [ ] Relative timestamps use injected Clock (not wall-clock)\n\n## Files\n- MODIFY: crates/lore-tui/src/state/dashboard.rs (expand from stub)\n- MODIFY: crates/lore-tui/src/action.rs (add fetch_dashboard)\n- CREATE: crates/lore-tui/src/view/dashboard.rs\n\n## TDD Anchor\nRED: Write test_fetch_dashboard_counts in action.rs that creates in-memory DB with 5 issues (3 open, 2 closed), calls fetch_dashboard, asserts issues_open=3, issues_total=5.\nGREEN: Implement fetch_dashboard with COUNT queries.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_dashboard\n\n## Edge Cases\n- Empty database (first launch before sync): all counts should be 0, no crash\n- Very long project paths: truncate to fit column width\n- notes_system_pct: compute as (system_notes * 100 / total_notes), handle division by zero\n- Clock injection ensures snapshot tests are deterministic (no \"3 minutes ago\" changing between runs)\n\n## Dependency Context\nUses AppState, DashboardState, LoadState from \"Implement AppState composition\" task.\nUses DbManager from \"Implement DbManager\" task.\nUses Clock from \"Implement Clock trait\" task.\nUses theme from \"Implement theme configuration\" task.\nUses render_screen routing from \"Implement common widgets\" task.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:57:44.419736Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:28.506710Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-35g5","depends_on_id":"bd-1cl9","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-35g5","depends_on_id":"bd-1f5b","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-35g5","depends_on_id":"bd-26f2","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-35g5","depends_on_id":"bd-3pm2","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-35g5","depends_on_id":"bd-6pmy","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} +{"id":"bd-35g5","title":"Implement Dashboard state + action + view","description":"## Background\nThe Dashboard is the home screen — first thing users see. It shows entity counts, per-project sync status, recent activity, and a last-sync summary. Data comes from aggregation queries against the local SQLite database.\n\n## Approach\nState (state/dashboard.rs):\n- DashboardState: counts (EntityCounts), projects (Vec), recent (Vec), last_sync (LastSyncInfo)\n- EntityCounts: issues_open, issues_total, mrs_open, mrs_total, discussions, notes_total, notes_system_pct, documents, embeddings\n- ProjectSyncInfo: path (String), minutes_since_sync (u64)\n- RecentActivityItem: entity_type, iid, title, state, minutes_ago\n- update(data: DashboardData) method\n\nAction (action.rs):\n- fetch_dashboard(conn: &Connection, clock: &dyn Clock) -> Result: runs aggregation queries for counts, recent activity, project sync status. Uses clock.now() for relative time calculations.\n\nView (view/dashboard.rs):\n- render_dashboard(frame, state: &DashboardState, area: Rect, theme: &Theme): responsive layout with breakpoints\n - Wide (>=120 cols): 3-column: [Stats | Projects | Recent]\n - Medium (80-119): 2-column: [Stats+Projects | Recent]\n - Narrow (<80): single column stacked\n- render_stat_panel(): entity counts with colored numbers\n- render_project_list(): project names with sync staleness indicators\n- render_recent_activity(): scrollable list of recent changes\n- render_sync_summary(): last sync stats (if available)\n\n## Acceptance Criteria\n- [ ] DashboardState stores counts, projects, recent activity, last sync info\n- [ ] fetch_dashboard returns correct counts from DB\n- [ ] Dashboard renders with responsive breakpoints (3/2/1 column layouts)\n- [ ] Entity counts show open/total for issues and MRs\n- [ ] Project list shows sync staleness with color coding (green <1h, yellow <6h, red >6h)\n- [ ] Recent activity list is scrollable with j/k\n- [ ] Relative timestamps use injected Clock (not wall-clock)\n\n## Files\n- MODIFY: crates/lore-tui/src/state/dashboard.rs (expand from stub)\n- MODIFY: crates/lore-tui/src/action.rs (add fetch_dashboard)\n- CREATE: crates/lore-tui/src/view/dashboard.rs\n\n## TDD Anchor\nRED: Write test_fetch_dashboard_counts in action.rs that creates in-memory DB with 5 issues (3 open, 2 closed), calls fetch_dashboard, asserts issues_open=3, issues_total=5.\nGREEN: Implement fetch_dashboard with COUNT queries.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_dashboard\n\n## Edge Cases\n- Empty database (first launch before sync): all counts should be 0, no crash\n- Very long project paths: truncate to fit column width\n- notes_system_pct: compute as (system_notes * 100 / total_notes), handle division by zero\n- Clock injection ensures snapshot tests are deterministic (no \"3 minutes ago\" changing between runs)\n\n## Dependency Context\nUses AppState, DashboardState, LoadState from \"Implement AppState composition\" task.\nUses DbManager from \"Implement DbManager\" task.\nUses Clock from \"Implement Clock trait\" task.\nUses theme from \"Implement theme configuration\" task.\nUses render_screen routing from \"Implement common widgets\" task.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:57:44.419736Z","created_by":"tayloreernisse","updated_at":"2026-02-12T21:24:51.764669Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-35g5","depends_on_id":"bd-14q8","type":"blocks","created_at":"2026-02-12T21:24:51.677908Z","created_by":"tayloreernisse"},{"issue_id":"bd-35g5","depends_on_id":"bd-1cl9","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-35g5","depends_on_id":"bd-1f5b","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-35g5","depends_on_id":"bd-26f2","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-35g5","depends_on_id":"bd-29wn","type":"blocks","created_at":"2026-02-12T21:24:51.764637Z","created_by":"tayloreernisse"},{"issue_id":"bd-35g5","depends_on_id":"bd-3pm2","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-35g5","depends_on_id":"bd-6pmy","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-35o","title":"Create golden query test suite","description":"## Background\nGolden query tests verify end-to-end search quality with known-good expected results. They use a seeded SQLite DB with deterministic fixture data and fixed embedding vectors (no Ollama dependency). Each test query must return at least one expected URL in the top 10 results. These tests catch search regressions (ranking changes, filter bugs, missing results).\n\n## Approach\nCreate test infrastructure:\n\n**1. tests/fixtures/golden_queries.json:**\n```json\n[\n {\n \"query\": \"authentication login\",\n \"mode\": \"lexical\",\n \"filters\": {},\n \"expected_urls\": [\"https://gitlab.example.com/group/project/-/issues/234\"],\n \"min_results\": 1,\n \"max_rank\": 10\n },\n {\n \"query\": \"jwt token refresh\",\n \"mode\": \"hybrid\",\n \"filters\": {\"type\": \"merge_request\"},\n \"expected_urls\": [\"https://gitlab.example.com/group/project/-/merge_requests/456\"],\n \"min_results\": 1,\n \"max_rank\": 10\n }\n]\n```\n\n**2. Test harness (tests/golden_query_tests.rs):**\n- Load golden_queries.json\n- Create in-memory DB, apply all migrations\n- Seed with deterministic fixture documents (issues, MRs, discussions)\n- For hybrid/semantic queries: seed with fixed embedding vectors (768-dim, manually constructed for known similarity)\n- For each query: run search, verify expected URL in top N results\n\n**Fixture data design:**\n- 10-20 documents covering different source types\n- Known content that matches expected queries\n- Fixed embeddings: construct vectors where similar documents have small cosine distance\n- No randomness — fully deterministic\n\n## Acceptance Criteria\n- [ ] Golden queries file exists with at least 5 test queries\n- [ ] Test harness loads queries and validates each\n- [ ] All golden queries pass: expected URL in top 10\n- [ ] No external dependencies (no Ollama, no GitLab)\n- [ ] Deterministic fixture data (fixed embeddings, fixed content)\n- [ ] `cargo test --test golden_query_tests` passes in CI\n\n## Files\n- `tests/fixtures/golden_queries.json` — new file\n- `tests/golden_query_tests.rs` — new file (or tests/golden_queries.rs)\n\n## TDD Loop\nRED: Create golden_queries.json with expected results, harness fails (no fixture data)\nGREEN: Seed fixture data that satisfies expected results\nVERIFY: `cargo test --test golden_query_tests`\n\n## Edge Cases\n- Query matches multiple expected URLs: all must be present\n- Lexical queries: FTS ranking determines position, not vector\n- Hybrid queries: RRF combines both signals — fixed vectors must be designed to produce expected ranking\n- Empty result for a golden query: test failure with clear message showing actual results","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:27:21.788493Z","created_by":"tayloreernisse","updated_at":"2026-01-30T18:12:47.085563Z","closed_at":"2026-01-30T18:12:47.085363Z","close_reason":"Golden query test suite: 7 golden queries in fixture, 8 seeded documents, 2 test functions (all_pass + fixture_valid), deterministic in-memory DB, no external deps. 312 total tests pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-35o","depends_on_id":"bd-2no","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-35r","title":"[CP1] Discussion and note transformers","description":"Transform GitLab discussion/note payloads to normalized database schema.\n\nFunctions to implement:\n- transformDiscussion(gitlabDiscussion, localProjectId, localIssueId) → NormalizedDiscussion\n- transformNotes(gitlabDiscussion, localProjectId) → NormalizedNote[]\n\nTransformation rules:\n- Compute first_note_at/last_note_at from notes array\n- Compute resolvable/resolved status from notes\n- Set is_system from note.system\n- Preserve note order via position (array index)\n- Convert ISO timestamps to ms epoch\n\nFiles: src/gitlab/transformers/discussion.ts\nTests: tests/unit/discussion-transformer.test.ts\nDone when: Unit tests pass for discussion/note transformation with system note flagging","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:19:16.861421Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.154646Z","closed_at":"2026-01-25T15:21:35.154646Z","deleted_at":"2026-01-25T15:21:35.154643Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-36m","title":"Final validation and test coverage","description":"## Background\nFinal validation gate ensuring all CP2 features work correctly. Verifies tests, lint, and manual smoke tests pass.\n\n## Approach\nRun comprehensive validation:\n1. Automated tests (unit + integration)\n2. Clippy and formatting\n3. Critical test case verification\n4. Gate A/B/C/D/E checklist\n5. Manual smoke tests\n\n## Files\nNone - validation only\n\n## Acceptance Criteria\n- [ ] `cargo test` passes (all tests green)\n- [ ] `cargo test --release` passes\n- [ ] `cargo clippy -- -D warnings` passes (zero warnings)\n- [ ] `cargo fmt --check` passes\n- [ ] Critical tests pass (see list below)\n- [ ] Gate A/B/C/D/E verification complete\n- [ ] Manual smoke tests pass\n\n## Validation Commands\n```bash\n# 1. Build and test\ncargo build --release\ncargo test --release\n\n# 2. Lint\ncargo clippy -- -D warnings\ncargo fmt --check\n\n# 3. Run specific critical tests\ncargo test does_not_advance_discussion_watermark_on_partial_failure\ncargo test prefers_detailed_merge_status_when_both_fields_present\ncargo test prefers_merge_user_when_both_fields_present\ncargo test prefers_draft_when_both_draft_and_work_in_progress_present\ncargo test atomic_note_replacement_preserves_data_on_parse_failure\ncargo test full_sync_resets_discussion_watermarks\n```\n\n## Critical Test Cases\n| Test | What It Verifies |\n|------|------------------|\n| `does_not_advance_discussion_watermark_on_partial_failure` | Pagination failure doesn't lose data |\n| `prefers_detailed_merge_status_when_both_fields_present` | Non-deprecated field wins |\n| `prefers_merge_user_when_both_fields_present` | Non-deprecated field wins |\n| `prefers_draft_when_both_draft_and_work_in_progress_present` | OR semantics for draft |\n| `atomic_note_replacement_preserves_data_on_parse_failure` | Parse before delete |\n| `full_sync_resets_discussion_watermarks` | --full truly refreshes |\n\n## Gate Checklist\n\n### Gate A: MRs Only\n- [ ] `gi ingest --type=merge_requests` fetches all MRs\n- [ ] MR state supports: opened, merged, closed, locked\n- [ ] draft field captured with work_in_progress fallback\n- [ ] detailed_merge_status used with merge_status fallback\n- [ ] head_sha and references captured\n- [ ] Cursor-based sync is resumable\n\n### Gate B: Labels + Assignees + Reviewers\n- [ ] Labels linked via mr_labels junction\n- [ ] Stale labels removed on resync\n- [ ] Assignees linked via mr_assignees\n- [ ] Reviewers linked via mr_reviewers\n\n### Gate C: Dependent Discussion Sync\n- [ ] Discussions fetched for MRs with updated_at advancement\n- [ ] DiffNote position metadata captured\n- [ ] DiffNote SHA triplet captured\n- [ ] Upsert + sweep pattern for notes\n- [ ] Watermark NOT advanced on partial failure\n- [ ] Unchanged MRs skip discussion refetch\n\n### Gate D: Resumability Proof\n- [ ] Kill mid-run, rerun -> bounded redo\n- [ ] `--full` resets cursor AND discussion watermarks\n- [ ] Single-flight lock prevents concurrent runs\n\n### Gate E: CLI Complete\n- [ ] `gi list mrs` with all filters including --draft/--no-draft\n- [ ] `gi show mr ` with discussions and DiffNote context\n- [ ] `gi count mrs` with state breakdown\n- [ ] `gi sync-status` shows MR cursors\n\n## Manual Smoke Tests\n| Command | Expected |\n|---------|----------|\n| `gi ingest --type=merge_requests` | Completes, shows counts |\n| `gi list mrs --limit=10` | Shows 10 MRs with correct columns |\n| `gi list mrs --state=merged` | Only merged MRs |\n| `gi list mrs --draft` | Only draft MRs with [DRAFT] prefix |\n| `gi show mr ` | Full detail with discussions |\n| `gi count mrs` | Count with state breakdown |\n| Re-run ingest | \"0 new MRs\", skipped discussion count |\n| `gi ingest --type=merge_requests --full` | Full resync |\n\n## Data Integrity Checks\n```sql\n-- MR count matches GitLab\nSELECT COUNT(*) FROM merge_requests;\n\n-- Every MR has raw payload\nSELECT COUNT(*) FROM merge_requests WHERE raw_payload_id IS NULL;\n-- Should be 0\n\n-- Labels linked correctly\nSELECT m.iid, COUNT(ml.label_id) \nFROM merge_requests m\nLEFT JOIN mr_labels ml ON ml.merge_request_id = m.id\nGROUP BY m.id;\n\n-- DiffNotes have position metadata\nSELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL;\n```","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:43.697983Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:45:17.794393Z","closed_at":"2026-01-27T00:45:17.794325Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-36m","depends_on_id":"bd-3js","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-36m","depends_on_id":"bd-mk3","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} diff --git a/.beads/last-touched b/.beads/last-touched index 2c03e9d..0c1bd4d 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -bd-26f2 +bd-2cbw diff --git a/crates/lore-tui/src/view/common/breadcrumb.rs b/crates/lore-tui/src/view/common/breadcrumb.rs new file mode 100644 index 0000000..3a0ac22 --- /dev/null +++ b/crates/lore-tui/src/view/common/breadcrumb.rs @@ -0,0 +1,208 @@ +//! Navigation breadcrumb trail ("Dashboard > Issues > #42"). + +use ftui::core::geometry::Rect; +use ftui::render::cell::{Cell, PackedRgba}; +use ftui::render::drawing::Draw; +use ftui::render::frame::Frame; + +use crate::navigation::NavigationStack; + +/// Render the navigation breadcrumb trail. +/// +/// Shows "Dashboard > Issues > Issue" with " > " separators. When the +/// trail exceeds the available width, entries are truncated from the left +/// with a leading "...". +pub fn render_breadcrumb( + frame: &mut Frame<'_>, + area: Rect, + nav: &NavigationStack, + text_color: PackedRgba, + muted_color: PackedRgba, +) { + if area.height == 0 || area.width < 3 { + return; + } + + let crumbs = nav.breadcrumbs(); + let separator = " > "; + + // Build the full breadcrumb string and calculate width. + let full: String = crumbs.join(separator); + let max_width = area.width as usize; + + let display = if full.len() <= max_width { + full + } else { + // Truncate from the left: show "... > last_crumbs" + truncate_breadcrumb_left(&crumbs, separator, max_width) + }; + + let base = Cell { + fg: text_color, + ..Cell::default() + }; + let muted = Cell { + fg: muted_color, + ..Cell::default() + }; + + // Render each segment with separators in muted color. + let mut x = area.x; + let max_x = area.x.saturating_add(area.width); + + if let Some(rest) = display.strip_prefix("...") { + // Render ellipsis in muted, then the rest + x = frame.print_text_clipped(x, area.y, "...", muted, max_x); + if !rest.is_empty() { + render_crumb_segments(frame, x, area.y, rest, separator, base, muted, max_x); + } + } else { + render_crumb_segments(frame, x, area.y, &display, separator, base, muted, max_x); + } +} + +/// Render breadcrumb text with separators in muted color. +#[allow(clippy::too_many_arguments)] +fn render_crumb_segments( + frame: &mut Frame<'_>, + start_x: u16, + y: u16, + text: &str, + separator: &str, + base: Cell, + muted: Cell, + max_x: u16, +) { + let mut x = start_x; + let parts: Vec<&str> = text.split(separator).collect(); + + for (i, part) in parts.iter().enumerate() { + if i > 0 { + x = frame.print_text_clipped(x, y, separator, muted, max_x); + } + x = frame.print_text_clipped(x, y, part, base, max_x); + if x >= max_x { + break; + } + } +} + +/// Truncate breadcrumb from the left to fit within max_width. +fn truncate_breadcrumb_left(crumbs: &[&str], separator: &str, max_width: usize) -> String { + let ellipsis = "..."; + + // Try showing progressively fewer crumbs from the right. + for skip in 1..crumbs.len() { + let tail = &crumbs[skip..]; + let tail_str: String = tail.join(separator); + let candidate = format!("{ellipsis}{separator}{tail_str}"); + if candidate.len() <= max_width { + return candidate; + } + } + + // Last resort: just the current screen truncated. + let last = crumbs.last().unwrap_or(&""); + if last.len() + ellipsis.len() <= max_width { + return format!("{ellipsis}{last}"); + } + + // Truly tiny terminal: just ellipsis. + ellipsis.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::Screen; + use crate::navigation::NavigationStack; + use ftui::render::grapheme_pool::GraphemePool; + + macro_rules! with_frame { + ($width:expr, $height:expr, |$frame:ident| $body:block) => {{ + let mut pool = GraphemePool::new(); + let mut $frame = Frame::new($width, $height, &mut pool); + $body + }}; + } + + fn white() -> PackedRgba { + PackedRgba::rgb(0xFF, 0xFF, 0xFF) + } + + fn gray() -> PackedRgba { + PackedRgba::rgb(0x80, 0x80, 0x80) + } + + #[test] + fn test_breadcrumb_single_screen() { + with_frame!(80, 1, |frame| { + let nav = NavigationStack::new(); + render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray()); + + let cell = frame.buffer.get(0, 0).unwrap(); + assert!( + cell.content.as_char() == Some('D'), + "Expected 'D' at (0,0), got {:?}", + cell.content.as_char() + ); + }); + } + + #[test] + fn test_breadcrumb_multi_screen() { + with_frame!(80, 1, |frame| { + let mut nav = NavigationStack::new(); + nav.push(Screen::IssueList); + render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray()); + + let d = frame.buffer.get(0, 0).unwrap(); + assert_eq!(d.content.as_char(), Some('D')); + + // "Dashboard > Issues" = 'I' at 12 + let i_cell = frame.buffer.get(12, 0).unwrap(); + assert_eq!(i_cell.content.as_char(), Some('I')); + }); + } + + #[test] + fn test_breadcrumb_truncation() { + let crumbs = vec!["Dashboard", "Issues", "Issue"]; + let result = truncate_breadcrumb_left(&crumbs, " > ", 20); + assert!( + result.starts_with("..."), + "Expected ellipsis prefix, got: {result}" + ); + assert!(result.len() <= 20, "Result too long: {result}"); + } + + #[test] + fn test_breadcrumb_zero_height_noop() { + with_frame!(80, 1, |frame| { + let nav = NavigationStack::new(); + render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 0), &nav, white(), gray()); + }); + } + + #[test] + fn test_truncate_breadcrumb_fits() { + let crumbs = vec!["A", "B"]; + let result = truncate_breadcrumb_left(&crumbs, " > ", 100); + assert!(result.contains("..."), "Should always add ellipsis"); + } + + #[test] + fn test_truncate_breadcrumb_single_entry() { + let crumbs = vec!["Dashboard"]; + let result = truncate_breadcrumb_left(&crumbs, " > ", 5); + assert_eq!(result, "..."); + } + + #[test] + fn test_truncate_breadcrumb_shows_last_entries() { + let crumbs = vec!["Dashboard", "Issues", "Issue Detail"]; + let result = truncate_breadcrumb_left(&crumbs, " > ", 30); + assert!(result.starts_with("...")); + assert!(result.contains("Issue Detail")); + } +} diff --git a/crates/lore-tui/src/view/common/error_toast.rs b/crates/lore-tui/src/view/common/error_toast.rs new file mode 100644 index 0000000..30a1c0e --- /dev/null +++ b/crates/lore-tui/src/view/common/error_toast.rs @@ -0,0 +1,124 @@ +//! Floating error toast at bottom-right. + +use ftui::core::geometry::Rect; +use ftui::render::cell::{Cell, PackedRgba}; +use ftui::render::drawing::Draw; +use ftui::render::frame::Frame; + +/// Render a floating error toast at the bottom-right of the area. +/// +/// The toast has a colored background and truncates long messages. +pub fn render_error_toast( + frame: &mut Frame<'_>, + area: Rect, + msg: &str, + error_bg: PackedRgba, + error_fg: PackedRgba, +) { + if area.height < 3 || area.width < 10 || msg.is_empty() { + return; + } + + // Toast dimensions: message + padding, max 60 chars or half screen. + let max_toast_width = (area.width / 2).clamp(20, 60); + let toast_text = if msg.len() as u16 > max_toast_width.saturating_sub(4) { + let trunc_len = max_toast_width.saturating_sub(7) as usize; + format!(" {}... ", &msg[..trunc_len.min(msg.len())]) + } else { + format!(" {msg} ") + }; + let toast_width = toast_text.len() as u16; + let toast_height: u16 = 1; + + // Position: bottom-right with 1-cell margin. + let x = area.right().saturating_sub(toast_width + 1); + let y = area.bottom().saturating_sub(toast_height + 1); + + let toast_rect = Rect::new(x, y, toast_width, toast_height); + + // Fill background. + let bg_cell = Cell { + bg: error_bg, + ..Cell::default() + }; + frame.draw_rect_filled(toast_rect, bg_cell); + + // Render text. + let text_cell = Cell { + fg: error_fg, + bg: error_bg, + ..Cell::default() + }; + frame.print_text_clipped(x, y, &toast_text, text_cell, area.right()); +} + +#[cfg(test)] +mod tests { + use super::*; + use ftui::render::grapheme_pool::GraphemePool; + + macro_rules! with_frame { + ($width:expr, $height:expr, |$frame:ident| $body:block) => {{ + let mut pool = GraphemePool::new(); + let mut $frame = Frame::new($width, $height, &mut pool); + $body + }}; + } + + fn white() -> PackedRgba { + PackedRgba::rgb(0xFF, 0xFF, 0xFF) + } + + fn red_bg() -> PackedRgba { + PackedRgba::rgb(0xFF, 0x00, 0x00) + } + + #[test] + fn test_error_toast_renders() { + with_frame!(80, 24, |frame| { + render_error_toast( + &mut frame, + Rect::new(0, 0, 80, 24), + "Database is busy", + red_bg(), + white(), + ); + + let y = 22u16; + let has_content = (40..80u16).any(|x| { + let cell = frame.buffer.get(x, y).unwrap(); + !cell.is_empty() + }); + assert!(has_content, "Expected error toast at bottom-right"); + }); + } + + #[test] + fn test_error_toast_empty_message_noop() { + with_frame!(80, 24, |frame| { + render_error_toast(&mut frame, Rect::new(0, 0, 80, 24), "", red_bg(), white()); + + let has_content = (0..80u16).any(|x| { + (0..24u16).any(|y| { + let cell = frame.buffer.get(x, y).unwrap(); + !cell.is_empty() + }) + }); + assert!(!has_content, "Empty message should render nothing"); + }); + } + + #[test] + fn test_error_toast_truncates_long_message() { + with_frame!(80, 24, |frame| { + let long_msg = "A".repeat(200); + render_error_toast( + &mut frame, + Rect::new(0, 0, 80, 24), + &long_msg, + red_bg(), + white(), + ); + }); + } +} diff --git a/crates/lore-tui/src/view/common/help_overlay.rs b/crates/lore-tui/src/view/common/help_overlay.rs new file mode 100644 index 0000000..52a5f81 --- /dev/null +++ b/crates/lore-tui/src/view/common/help_overlay.rs @@ -0,0 +1,173 @@ +//! Centered modal listing keybindings for the current screen. + +use ftui::core::geometry::Rect; +use ftui::render::cell::{Cell, PackedRgba}; +use ftui::render::drawing::Draw; +use ftui::render::frame::Frame; + +use crate::commands::CommandRegistry; +use crate::message::Screen; + +/// Render a centered help overlay listing keybindings for the current screen. +/// +/// The overlay is a bordered modal that lists all commands from the +/// registry that are available on the current screen. +#[allow(clippy::too_many_arguments)] +pub fn render_help_overlay( + frame: &mut Frame<'_>, + area: Rect, + registry: &CommandRegistry, + screen: &Screen, + border_color: PackedRgba, + text_color: PackedRgba, + muted_color: PackedRgba, + scroll_offset: usize, +) { + if area.height < 5 || area.width < 20 { + return; + } + + // Overlay dimensions: 60% of screen, capped. + let overlay_width = (area.width * 3 / 5).clamp(30, 70); + let overlay_height = (area.height * 3 / 5).clamp(8, 30); + + let overlay_x = area.x + (area.width.saturating_sub(overlay_width)) / 2; + let overlay_y = area.y + (area.height.saturating_sub(overlay_height)) / 2; + let overlay_rect = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height); + + // Draw border. + let border_cell = Cell { + fg: border_color, + ..Cell::default() + }; + frame.draw_border( + overlay_rect, + ftui::render::drawing::BorderChars::ROUNDED, + border_cell, + ); + + // Title. + let title = " Help (? to close) "; + let title_x = overlay_x + (overlay_width.saturating_sub(title.len() as u16)) / 2; + let title_cell = Cell { + fg: border_color, + ..Cell::default() + }; + frame.print_text_clipped(title_x, overlay_y, title, title_cell, overlay_rect.right()); + + // Inner content area (inside border). + let inner = Rect::new( + overlay_x + 2, + overlay_y + 1, + overlay_width.saturating_sub(4), + overlay_height.saturating_sub(2), + ); + + // Get commands for this screen. + let commands = registry.help_entries(screen); + let visible_lines = inner.height as usize; + + let key_cell = Cell { + fg: text_color, + ..Cell::default() + }; + let desc_cell = Cell { + fg: muted_color, + ..Cell::default() + }; + + for (i, cmd) in commands.iter().skip(scroll_offset).enumerate() { + if i >= visible_lines { + break; + } + let y = inner.y + i as u16; + + // Key binding label (left). + let key_label = cmd + .keybinding + .as_ref() + .map_or_else(String::new, |kb| kb.display()); + let label_end = frame.print_text_clipped(inner.x, y, &key_label, key_cell, inner.right()); + + // Spacer + description (right). + let desc_x = label_end.saturating_add(2); + if desc_x < inner.right() { + frame.print_text_clipped(desc_x, y, cmd.help_text, desc_cell, inner.right()); + } + } + + // Scroll indicator if needed. + if commands.len() > visible_lines + scroll_offset { + let indicator = format!("({}/{})", scroll_offset + visible_lines, commands.len()); + let ind_x = inner.right().saturating_sub(indicator.len() as u16); + let ind_y = overlay_rect.bottom().saturating_sub(1); + frame.print_text_clipped(ind_x, ind_y, &indicator, desc_cell, overlay_rect.right()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::build_registry; + use crate::message::Screen; + use ftui::render::grapheme_pool::GraphemePool; + + macro_rules! with_frame { + ($width:expr, $height:expr, |$frame:ident| $body:block) => {{ + let mut pool = GraphemePool::new(); + let mut $frame = Frame::new($width, $height, &mut pool); + $body + }}; + } + + fn white() -> PackedRgba { + PackedRgba::rgb(0xFF, 0xFF, 0xFF) + } + + fn gray() -> PackedRgba { + PackedRgba::rgb(0x80, 0x80, 0x80) + } + + #[test] + fn test_help_overlay_renders_border() { + with_frame!(80, 24, |frame| { + let registry = build_registry(); + render_help_overlay( + &mut frame, + Rect::new(0, 0, 80, 24), + ®istry, + &Screen::Dashboard, + gray(), + white(), + gray(), + 0, + ); + + // The overlay should have non-empty cells in the center area. + let has_content = (20..60u16).any(|x| { + (8..16u16).any(|y| { + let cell = frame.buffer.get(x, y).unwrap(); + !cell.is_empty() + }) + }); + assert!(has_content, "Expected help overlay in center area"); + }); + } + + #[test] + fn test_help_overlay_tiny_terminal_noop() { + with_frame!(15, 4, |frame| { + let registry = build_registry(); + render_help_overlay( + &mut frame, + Rect::new(0, 0, 15, 4), + ®istry, + &Screen::Dashboard, + gray(), + white(), + gray(), + 0, + ); + }); + } +} diff --git a/crates/lore-tui/src/view/common/loading.rs b/crates/lore-tui/src/view/common/loading.rs new file mode 100644 index 0000000..a75c14d --- /dev/null +++ b/crates/lore-tui/src/view/common/loading.rs @@ -0,0 +1,179 @@ +//! Loading spinner indicators (full-screen and corner). + +use ftui::core::geometry::Rect; +use ftui::render::cell::{Cell, PackedRgba}; +use ftui::render::drawing::Draw; +use ftui::render::frame::Frame; + +use crate::state::LoadState; + +/// Braille spinner frames for loading animation. +const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +/// Select spinner frame from tick count. +#[must_use] +pub(crate) fn spinner_char(tick: u64) -> char { + SPINNER_FRAMES[(tick as usize) % SPINNER_FRAMES.len()] +} + +/// Render a loading indicator. +/// +/// - `LoadingInitial`: centered full-screen spinner with "Loading..." +/// - `Refreshing`: subtle spinner in top-right corner +/// - Other states: no-op +pub fn render_loading( + frame: &mut Frame<'_>, + area: Rect, + load_state: &LoadState, + text_color: PackedRgba, + muted_color: PackedRgba, + tick: u64, +) { + match load_state { + LoadState::LoadingInitial => { + render_centered_spinner(frame, area, "Loading...", text_color, tick); + } + LoadState::Refreshing => { + render_corner_spinner(frame, area, muted_color, tick); + } + _ => {} + } +} + +/// Render a centered spinner with message. +fn render_centered_spinner( + frame: &mut Frame<'_>, + area: Rect, + message: &str, + color: PackedRgba, + tick: u64, +) { + if area.height == 0 || area.width < 5 { + return; + } + + let spinner = spinner_char(tick); + let text = format!("{spinner} {message}"); + let text_len = text.len() as u16; + + // Center horizontally and vertically. + let x = area + .x + .saturating_add(area.width.saturating_sub(text_len) / 2); + let y = area.y.saturating_add(area.height / 2); + + let cell = Cell { + fg: color, + ..Cell::default() + }; + frame.print_text_clipped(x, y, &text, cell, area.right()); +} + +/// Render a subtle spinner in the top-right corner. +fn render_corner_spinner(frame: &mut Frame<'_>, area: Rect, color: PackedRgba, tick: u64) { + if area.width < 2 || area.height == 0 { + return; + } + + let spinner = spinner_char(tick); + let x = area.right().saturating_sub(2); + let y = area.y; + + let cell = Cell { + fg: color, + ..Cell::default() + }; + frame.print_text_clipped(x, y, &spinner.to_string(), cell, area.right()); +} + +#[cfg(test)] +mod tests { + use super::*; + use ftui::render::grapheme_pool::GraphemePool; + + macro_rules! with_frame { + ($width:expr, $height:expr, |$frame:ident| $body:block) => {{ + let mut pool = GraphemePool::new(); + let mut $frame = Frame::new($width, $height, &mut pool); + $body + }}; + } + + fn white() -> PackedRgba { + PackedRgba::rgb(0xFF, 0xFF, 0xFF) + } + + fn gray() -> PackedRgba { + PackedRgba::rgb(0x80, 0x80, 0x80) + } + + #[test] + fn test_loading_initial_renders_spinner() { + with_frame!(80, 24, |frame| { + render_loading( + &mut frame, + Rect::new(0, 0, 80, 24), + &LoadState::LoadingInitial, + white(), + gray(), + 0, + ); + + let center_y = 12u16; + let has_content = (0..80u16).any(|x| { + let cell = frame.buffer.get(x, center_y).unwrap(); + !cell.is_empty() + }); + assert!(has_content, "Expected loading spinner at center row"); + }); + } + + #[test] + fn test_loading_refreshing_renders_corner() { + with_frame!(80, 24, |frame| { + render_loading( + &mut frame, + Rect::new(0, 0, 80, 24), + &LoadState::Refreshing, + white(), + gray(), + 0, + ); + + let cell = frame.buffer.get(78, 0).unwrap(); + assert!(!cell.is_empty(), "Expected corner spinner"); + }); + } + + #[test] + fn test_loading_idle_noop() { + with_frame!(80, 24, |frame| { + render_loading( + &mut frame, + Rect::new(0, 0, 80, 24), + &LoadState::Idle, + white(), + gray(), + 0, + ); + + let has_content = (0..80u16).any(|x| { + (0..24u16).any(|y| { + let cell = frame.buffer.get(x, y).unwrap(); + !cell.is_empty() + }) + }); + assert!(!has_content, "Idle state should render nothing"); + }); + } + + #[test] + fn test_spinner_animation_cycles() { + let frame0 = spinner_char(0); + let frame1 = spinner_char(1); + let frame_wrap = spinner_char(SPINNER_FRAMES.len() as u64); + + assert_ne!(frame0, frame1, "Adjacent frames should differ"); + assert_eq!(frame0, frame_wrap, "Should wrap around"); + } +} diff --git a/crates/lore-tui/src/view/common/mod.rs b/crates/lore-tui/src/view/common/mod.rs index 929ab1c..1e60ca2 100644 --- a/crates/lore-tui/src/view/common/mod.rs +++ b/crates/lore-tui/src/view/common/mod.rs @@ -1,816 +1,17 @@ -#![allow(dead_code)] // Phase 1: consumed by screen views in Phase 2+ - //! Common widgets shared across all TUI screens. //! -//! These are pure rendering functions — they write directly into the +//! Each widget is a pure rendering function — writes directly into the //! [`Frame`] buffer using ftui's `Draw` trait. No state mutation, //! no side effects. -//! -//! - [`render_breadcrumb`] — navigation trail ("Dashboard > Issues > #42") -//! - [`render_status_bar`] — bottom bar with key hints and mode indicator -//! - [`render_loading`] — full-screen spinner or subtle refresh indicator -//! - [`render_error_toast`] — floating error message at bottom-right -//! - [`render_help_overlay`] — centered modal listing keybindings -use ftui::core::geometry::Rect; -use ftui::render::cell::{Cell, PackedRgba}; -use ftui::render::drawing::Draw; -use ftui::render::frame::Frame; - -use crate::commands::CommandRegistry; -use crate::message::{InputMode, Screen}; -use crate::navigation::NavigationStack; -use crate::state::LoadState; - -// --------------------------------------------------------------------------- -// Spinner frames -// --------------------------------------------------------------------------- - -/// Braille spinner frames for loading animation. -const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - -/// Select spinner frame from tick count. -#[must_use] -fn spinner_char(tick: u64) -> char { - SPINNER_FRAMES[(tick as usize) % SPINNER_FRAMES.len()] -} - -// --------------------------------------------------------------------------- -// render_breadcrumb -// --------------------------------------------------------------------------- - -/// Render the navigation breadcrumb trail. -/// -/// Shows "Dashboard > Issues > Issue" with " > " separators. When the -/// trail exceeds the available width, entries are truncated from the left -/// with a leading "...". -pub fn render_breadcrumb( - frame: &mut Frame<'_>, - area: Rect, - nav: &NavigationStack, - text_color: PackedRgba, - muted_color: PackedRgba, -) { - if area.height == 0 || area.width < 3 { - return; - } - - let crumbs = nav.breadcrumbs(); - let separator = " > "; - - // Build the full breadcrumb string and calculate width. - let full: String = crumbs.join(separator); - let max_width = area.width as usize; - - let display = if full.len() <= max_width { - full - } else { - // Truncate from the left: show "... > last_crumbs" - truncate_breadcrumb_left(&crumbs, separator, max_width) - }; - - let base = Cell { - fg: text_color, - ..Cell::default() - }; - let muted = Cell { - fg: muted_color, - ..Cell::default() - }; - - // Render each segment with separators in muted color. - let mut x = area.x; - let max_x = area.x.saturating_add(area.width); - - if let Some(rest) = display.strip_prefix("...") { - // Render ellipsis in muted, then the rest - x = frame.print_text_clipped(x, area.y, "...", muted, max_x); - if !rest.is_empty() { - render_crumb_segments(frame, x, area.y, rest, separator, base, muted, max_x); - } - } else { - render_crumb_segments(frame, x, area.y, &display, separator, base, muted, max_x); - } -} - -/// Render breadcrumb text with separators in muted color. -#[allow(clippy::too_many_arguments)] -fn render_crumb_segments( - frame: &mut Frame<'_>, - start_x: u16, - y: u16, - text: &str, - separator: &str, - base: Cell, - muted: Cell, - max_x: u16, -) { - let mut x = start_x; - let parts: Vec<&str> = text.split(separator).collect(); - - for (i, part) in parts.iter().enumerate() { - if i > 0 { - x = frame.print_text_clipped(x, y, separator, muted, max_x); - } - x = frame.print_text_clipped(x, y, part, base, max_x); - if x >= max_x { - break; - } - } -} - -/// Truncate breadcrumb from the left to fit within max_width. -fn truncate_breadcrumb_left(crumbs: &[&str], separator: &str, max_width: usize) -> String { - let ellipsis = "..."; - - // Try showing progressively fewer crumbs from the right. - for skip in 1..crumbs.len() { - let tail = &crumbs[skip..]; - let tail_str: String = tail.join(separator); - let candidate = format!("{ellipsis}{separator}{tail_str}"); - if candidate.len() <= max_width { - return candidate; - } - } - - // Last resort: just the current screen truncated. - let last = crumbs.last().unwrap_or(&""); - if last.len() + ellipsis.len() <= max_width { - return format!("{ellipsis}{last}"); - } - - // Truly tiny terminal: just ellipsis. - ellipsis.to_string() -} - -// --------------------------------------------------------------------------- -// render_status_bar -// --------------------------------------------------------------------------- - -/// Render the bottom status bar with key hints and mode indicator. -/// -/// Layout: `[mode] ─── [key hints]` -/// -/// Key hints are sourced from the [`CommandRegistry`] filtered to the -/// current screen, showing only the most important bindings. -#[allow(clippy::too_many_arguments)] -pub fn render_status_bar( - frame: &mut Frame<'_>, - area: Rect, - registry: &CommandRegistry, - screen: &Screen, - mode: &InputMode, - bar_bg: PackedRgba, - text_color: PackedRgba, - accent_color: PackedRgba, -) { - if area.height == 0 || area.width < 5 { - return; - } - - // Fill the bar background. - let bg_cell = Cell { - bg: bar_bg, - ..Cell::default() - }; - frame.draw_rect_filled(area, bg_cell); - - let mode_label = match mode { - InputMode::Normal => "NORMAL", - InputMode::Text => "INPUT", - InputMode::Palette => "PALETTE", - InputMode::GoPrefix { .. } => "g...", - }; - - // Left side: mode indicator. - let mode_cell = Cell { - fg: accent_color, - bg: bar_bg, - ..Cell::default() - }; - let mut x = frame.print_text_clipped( - area.x.saturating_add(1), - area.y, - mode_label, - mode_cell, - area.right(), - ); - - // Spacer. - x = x.saturating_add(2); - - // Right side: key hints from registry (formatted as "key:action"). - let hints = registry.status_hints(screen); - let hint_cell = Cell { - fg: text_color, - bg: bar_bg, - ..Cell::default() - }; - let key_cell = Cell { - fg: accent_color, - bg: bar_bg, - ..Cell::default() - }; - - for hint in &hints { - if x >= area.right().saturating_sub(1) { - break; - } - // Split "q:quit" into key part and description part. - if let Some((key_part, desc_part)) = hint.split_once(':') { - x = frame.print_text_clipped(x, area.y, key_part, key_cell, area.right()); - x = frame.print_text_clipped(x, area.y, ":", hint_cell, area.right()); - x = frame.print_text_clipped(x, area.y, desc_part, hint_cell, area.right()); - } else { - x = frame.print_text_clipped(x, area.y, hint, hint_cell, area.right()); - } - x = x.saturating_add(2); - } -} - -// --------------------------------------------------------------------------- -// render_loading -// --------------------------------------------------------------------------- - -/// Render a loading indicator. -/// -/// - `LoadingInitial`: centered full-screen spinner with "Loading..." -/// - `Refreshing`: subtle spinner in top-right corner -/// - Other states: no-op -pub fn render_loading( - frame: &mut Frame<'_>, - area: Rect, - load_state: &LoadState, - text_color: PackedRgba, - muted_color: PackedRgba, - tick: u64, -) { - match load_state { - LoadState::LoadingInitial => { - render_centered_spinner(frame, area, "Loading...", text_color, tick); - } - LoadState::Refreshing => { - render_corner_spinner(frame, area, muted_color, tick); - } - _ => {} - } -} - -/// Render a centered spinner with message. -fn render_centered_spinner( - frame: &mut Frame<'_>, - area: Rect, - message: &str, - color: PackedRgba, - tick: u64, -) { - if area.height == 0 || area.width < 5 { - return; - } - - let spinner = spinner_char(tick); - let text = format!("{spinner} {message}"); - let text_len = text.len() as u16; - - // Center horizontally and vertically. - let x = area - .x - .saturating_add(area.width.saturating_sub(text_len) / 2); - let y = area.y.saturating_add(area.height / 2); - - let cell = Cell { - fg: color, - ..Cell::default() - }; - frame.print_text_clipped(x, y, &text, cell, area.right()); -} - -/// Render a subtle spinner in the top-right corner. -fn render_corner_spinner(frame: &mut Frame<'_>, area: Rect, color: PackedRgba, tick: u64) { - if area.width < 2 || area.height == 0 { - return; - } - - let spinner = spinner_char(tick); - let x = area.right().saturating_sub(2); - let y = area.y; - - let cell = Cell { - fg: color, - ..Cell::default() - }; - frame.print_text_clipped(x, y, &spinner.to_string(), cell, area.right()); -} - -// --------------------------------------------------------------------------- -// render_error_toast -// --------------------------------------------------------------------------- - -/// Render a floating error toast at the bottom-right of the area. -/// -/// The toast has a colored background and truncates long messages. -pub fn render_error_toast( - frame: &mut Frame<'_>, - area: Rect, - msg: &str, - error_bg: PackedRgba, - error_fg: PackedRgba, -) { - if area.height < 3 || area.width < 10 || msg.is_empty() { - return; - } - - // Toast dimensions: message + padding, max 60 chars or half screen. - let max_toast_width = (area.width / 2).clamp(20, 60); - let toast_text = if msg.len() as u16 > max_toast_width.saturating_sub(4) { - let trunc_len = max_toast_width.saturating_sub(7) as usize; - format!(" {}... ", &msg[..trunc_len.min(msg.len())]) - } else { - format!(" {msg} ") - }; - let toast_width = toast_text.len() as u16; - let toast_height: u16 = 1; - - // Position: bottom-right with 1-cell margin. - let x = area.right().saturating_sub(toast_width + 1); - let y = area.bottom().saturating_sub(toast_height + 1); - - let toast_rect = Rect::new(x, y, toast_width, toast_height); - - // Fill background. - let bg_cell = Cell { - bg: error_bg, - ..Cell::default() - }; - frame.draw_rect_filled(toast_rect, bg_cell); - - // Render text. - let text_cell = Cell { - fg: error_fg, - bg: error_bg, - ..Cell::default() - }; - frame.print_text_clipped(x, y, &toast_text, text_cell, area.right()); -} - -// --------------------------------------------------------------------------- -// render_help_overlay -// --------------------------------------------------------------------------- - -/// Render a centered help overlay listing keybindings for the current screen. -/// -/// The overlay is a bordered modal that lists all commands from the -/// registry that are available on the current screen. -#[allow(clippy::too_many_arguments)] -pub fn render_help_overlay( - frame: &mut Frame<'_>, - area: Rect, - registry: &CommandRegistry, - screen: &Screen, - border_color: PackedRgba, - text_color: PackedRgba, - muted_color: PackedRgba, - scroll_offset: usize, -) { - if area.height < 5 || area.width < 20 { - return; - } - - // Overlay dimensions: 60% of screen, capped. - let overlay_width = (area.width * 3 / 5).clamp(30, 70); - let overlay_height = (area.height * 3 / 5).clamp(8, 30); - - let overlay_x = area.x + (area.width.saturating_sub(overlay_width)) / 2; - let overlay_y = area.y + (area.height.saturating_sub(overlay_height)) / 2; - let overlay_rect = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height); - - // Draw border. - let border_cell = Cell { - fg: border_color, - ..Cell::default() - }; - frame.draw_border( - overlay_rect, - ftui::render::drawing::BorderChars::ROUNDED, - border_cell, - ); - - // Title. - let title = " Help (? to close) "; - let title_x = overlay_x + (overlay_width.saturating_sub(title.len() as u16)) / 2; - let title_cell = Cell { - fg: border_color, - ..Cell::default() - }; - frame.print_text_clipped(title_x, overlay_y, title, title_cell, overlay_rect.right()); - - // Inner content area (inside border). - let inner = Rect::new( - overlay_x + 2, - overlay_y + 1, - overlay_width.saturating_sub(4), - overlay_height.saturating_sub(2), - ); - - // Get commands for this screen. - let commands = registry.help_entries(screen); - let visible_lines = inner.height as usize; - - let key_cell = Cell { - fg: text_color, - ..Cell::default() - }; - let desc_cell = Cell { - fg: muted_color, - ..Cell::default() - }; - - for (i, cmd) in commands.iter().skip(scroll_offset).enumerate() { - if i >= visible_lines { - break; - } - let y = inner.y + i as u16; - - // Key binding label (left). - let key_label = cmd - .keybinding - .as_ref() - .map_or_else(String::new, |kb| kb.display()); - let label_end = frame.print_text_clipped(inner.x, y, &key_label, key_cell, inner.right()); - - // Spacer + description (right). - let desc_x = label_end.saturating_add(2); - if desc_x < inner.right() { - frame.print_text_clipped(desc_x, y, cmd.help_text, desc_cell, inner.right()); - } - } - - // Scroll indicator if needed. - if commands.len() > visible_lines + scroll_offset { - let indicator = format!("({}/{})", scroll_offset + visible_lines, commands.len()); - let ind_x = inner.right().saturating_sub(indicator.len() as u16); - let ind_y = overlay_rect.bottom().saturating_sub(1); - frame.print_text_clipped(ind_x, ind_y, &indicator, desc_cell, overlay_rect.right()); - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::commands::build_registry; - use crate::message::Screen; - use crate::navigation::NavigationStack; - use crate::state::LoadState; - use ftui::render::grapheme_pool::GraphemePool; - - macro_rules! with_frame { - ($width:expr, $height:expr, |$frame:ident| $body:block) => {{ - let mut pool = GraphemePool::new(); - let mut $frame = Frame::new($width, $height, &mut pool); - $body - }}; - } - - fn white() -> PackedRgba { - PackedRgba::rgb(0xFF, 0xFF, 0xFF) - } - - fn gray() -> PackedRgba { - PackedRgba::rgb(0x80, 0x80, 0x80) - } - - fn red_bg() -> PackedRgba { - PackedRgba::rgb(0xFF, 0x00, 0x00) - } - - // --- Breadcrumb tests --- - - #[test] - fn test_breadcrumb_single_screen() { - with_frame!(80, 1, |frame| { - let nav = NavigationStack::new(); // Dashboard only - render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray()); - - // Verify "Dashboard" was rendered by checking first cell. - let cell = frame.buffer.get(0, 0).unwrap(); - assert!( - cell.content.as_char() == Some('D'), - "Expected 'D' at (0,0), got {:?}", - cell.content.as_char() - ); - }); - } - - #[test] - fn test_breadcrumb_multi_screen() { - with_frame!(80, 1, |frame| { - let mut nav = NavigationStack::new(); - nav.push(Screen::IssueList); - render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray()); - - // Should render "Dashboard > Issues" - // Check 'D' at start and 'I' after separator. - let d = frame.buffer.get(0, 0).unwrap(); - assert_eq!(d.content.as_char(), Some('D')); - - // "Dashboard > Issues" = 'D' at 0, ' > ' at 9, 'I' at 12 - let i_cell = frame.buffer.get(12, 0).unwrap(); - assert_eq!(i_cell.content.as_char(), Some('I')); - }); - } - - #[test] - fn test_breadcrumb_truncation() { - // Very narrow terminal — should show "..." prefix. - let crumbs = vec!["Dashboard", "Issues", "Issue"]; - let result = truncate_breadcrumb_left(&crumbs, " > ", 20); - assert!( - result.starts_with("..."), - "Expected ellipsis prefix, got: {result}" - ); - assert!(result.len() <= 20, "Result too long: {result}"); - } - - #[test] - fn test_breadcrumb_zero_height_noop() { - // Frame requires height >= 1, but Rect can have height=0. - with_frame!(80, 1, |frame| { - let nav = NavigationStack::new(); - // Should not panic — early return for zero-height area. - render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 0), &nav, white(), gray()); - }); - } - - // --- Status bar tests --- - - #[test] - fn test_status_bar_renders_mode() { - with_frame!(80, 1, |frame| { - let registry = build_registry(); - render_status_bar( - &mut frame, - Rect::new(0, 0, 80, 1), - ®istry, - &Screen::Dashboard, - &InputMode::Normal, - gray(), - white(), - white(), - ); - - // "NORMAL" should appear starting at x=1. - let n_cell = frame.buffer.get(1, 0).unwrap(); - assert_eq!(n_cell.content.as_char(), Some('N')); - }); - } - - #[test] - fn test_status_bar_text_mode() { - with_frame!(80, 1, |frame| { - let registry = build_registry(); - render_status_bar( - &mut frame, - Rect::new(0, 0, 80, 1), - ®istry, - &Screen::Search, - &InputMode::Text, - gray(), - white(), - white(), - ); - - // "INPUT" should appear at x=1. - let i_cell = frame.buffer.get(1, 0).unwrap(); - assert_eq!(i_cell.content.as_char(), Some('I')); - }); - } - - #[test] - fn test_status_bar_narrow_terminal() { - with_frame!(4, 1, |frame| { - let registry = build_registry(); - // Width < 5, should be a no-op. - render_status_bar( - &mut frame, - Rect::new(0, 0, 4, 1), - ®istry, - &Screen::Dashboard, - &InputMode::Normal, - gray(), - white(), - white(), - ); - // First cell should be empty (no-op). - let cell = frame.buffer.get(0, 0).unwrap(); - assert!(cell.is_empty()); - }); - } - - // --- Loading indicator tests --- - - #[test] - fn test_loading_initial_renders_spinner() { - with_frame!(80, 24, |frame| { - render_loading( - &mut frame, - Rect::new(0, 0, 80, 24), - &LoadState::LoadingInitial, - white(), - gray(), - 0, - ); - - // Spinner should be centered at y=12. - // The spinner char is at the center position. - let center_y = 12u16; - // Find any non-empty cell on the center row. - let has_content = (0..80u16).any(|x| { - let cell = frame.buffer.get(x, center_y).unwrap(); - !cell.is_empty() - }); - assert!(has_content, "Expected loading spinner at center row"); - }); - } - - #[test] - fn test_loading_refreshing_renders_corner() { - with_frame!(80, 24, |frame| { - render_loading( - &mut frame, - Rect::new(0, 0, 80, 24), - &LoadState::Refreshing, - white(), - gray(), - 0, - ); - - // Corner spinner should be at (78, 0). - let cell = frame.buffer.get(78, 0).unwrap(); - assert!(!cell.is_empty(), "Expected corner spinner"); - }); - } - - #[test] - fn test_loading_idle_noop() { - with_frame!(80, 24, |frame| { - render_loading( - &mut frame, - Rect::new(0, 0, 80, 24), - &LoadState::Idle, - white(), - gray(), - 0, - ); - - // All cells should be empty. - let has_content = (0..80u16).any(|x| { - (0..24u16).any(|y| { - let cell = frame.buffer.get(x, y).unwrap(); - !cell.is_empty() - }) - }); - assert!(!has_content, "Idle state should render nothing"); - }); - } - - #[test] - fn test_spinner_animation_cycles() { - // Different tick values produce different spinner chars. - let frame0 = spinner_char(0); - let frame1 = spinner_char(1); - let frame_wrap = spinner_char(SPINNER_FRAMES.len() as u64); - - assert_ne!(frame0, frame1, "Adjacent frames should differ"); - assert_eq!(frame0, frame_wrap, "Should wrap around"); - } - - // --- Error toast tests --- - - #[test] - fn test_error_toast_renders() { - with_frame!(80, 24, |frame| { - render_error_toast( - &mut frame, - Rect::new(0, 0, 80, 24), - "Database is busy", - red_bg(), - white(), - ); - - // Toast should be at bottom-right. Check row 22 (24-1-1). - let y = 22u16; - let has_content = (40..80u16).any(|x| { - let cell = frame.buffer.get(x, y).unwrap(); - !cell.is_empty() - }); - assert!(has_content, "Expected error toast at bottom-right"); - }); - } - - #[test] - fn test_error_toast_empty_message_noop() { - with_frame!(80, 24, |frame| { - render_error_toast(&mut frame, Rect::new(0, 0, 80, 24), "", red_bg(), white()); - - // No content should be rendered. - let has_content = (0..80u16).any(|x| { - (0..24u16).any(|y| { - let cell = frame.buffer.get(x, y).unwrap(); - !cell.is_empty() - }) - }); - assert!(!has_content, "Empty message should render nothing"); - }); - } - - #[test] - fn test_error_toast_truncates_long_message() { - with_frame!(80, 24, |frame| { - let long_msg = "A".repeat(200); - // Should not panic and should truncate. - render_error_toast( - &mut frame, - Rect::new(0, 0, 80, 24), - &long_msg, - red_bg(), - white(), - ); - }); - } - - // --- Help overlay tests --- - - #[test] - fn test_help_overlay_renders_border() { - with_frame!(80, 24, |frame| { - let registry = build_registry(); - render_help_overlay( - &mut frame, - Rect::new(0, 0, 80, 24), - ®istry, - &Screen::Dashboard, - gray(), - white(), - gray(), - 0, - ); - - // The overlay should have non-empty cells in the center area. - let has_content = (20..60u16).any(|x| { - (8..16u16).any(|y| { - let cell = frame.buffer.get(x, y).unwrap(); - !cell.is_empty() - }) - }); - assert!(has_content, "Expected help overlay in center area"); - }); - } - - #[test] - fn test_help_overlay_tiny_terminal_noop() { - with_frame!(15, 4, |frame| { - let registry = build_registry(); - // Too small — should be a no-op. - render_help_overlay( - &mut frame, - Rect::new(0, 0, 15, 4), - ®istry, - &Screen::Dashboard, - gray(), - white(), - gray(), - 0, - ); - }); - } - - // --- Truncation helper tests --- - - #[test] - fn test_truncate_breadcrumb_fits() { - let crumbs = vec!["A", "B"]; - let result = truncate_breadcrumb_left(&crumbs, " > ", 100); - // When it doesn't need truncation, this function is not called - // in practice, but it should still work. - assert!(result.contains("..."), "Should always add ellipsis"); - } - - #[test] - fn test_truncate_breadcrumb_single_entry() { - let crumbs = vec!["Dashboard"]; - let result = truncate_breadcrumb_left(&crumbs, " > ", 5); - assert_eq!(result, "..."); - } - - #[test] - fn test_truncate_breadcrumb_shows_last_entries() { - let crumbs = vec!["Dashboard", "Issues", "Issue Detail"]; - let result = truncate_breadcrumb_left(&crumbs, " > ", 30); - assert!(result.starts_with("...")); - assert!(result.contains("Issue Detail")); - } -} +mod breadcrumb; +mod error_toast; +mod help_overlay; +mod loading; +mod status_bar; + +pub use breadcrumb::render_breadcrumb; +pub use error_toast::render_error_toast; +pub use help_overlay::render_help_overlay; +pub use loading::render_loading; +pub use status_bar::render_status_bar; diff --git a/crates/lore-tui/src/view/common/status_bar.rs b/crates/lore-tui/src/view/common/status_bar.rs new file mode 100644 index 0000000..d228aa4 --- /dev/null +++ b/crates/lore-tui/src/view/common/status_bar.rs @@ -0,0 +1,173 @@ +//! Bottom status bar with key hints and mode indicator. + +use ftui::core::geometry::Rect; +use ftui::render::cell::{Cell, PackedRgba}; +use ftui::render::drawing::Draw; +use ftui::render::frame::Frame; + +use crate::commands::CommandRegistry; +use crate::message::{InputMode, Screen}; + +/// Render the bottom status bar with key hints and mode indicator. +/// +/// Layout: `[mode] ─── [key hints]` +/// +/// Key hints are sourced from the [`CommandRegistry`] filtered to the +/// current screen, showing only the most important bindings. +#[allow(clippy::too_many_arguments)] +pub fn render_status_bar( + frame: &mut Frame<'_>, + area: Rect, + registry: &CommandRegistry, + screen: &Screen, + mode: &InputMode, + bar_bg: PackedRgba, + text_color: PackedRgba, + accent_color: PackedRgba, +) { + if area.height == 0 || area.width < 5 { + return; + } + + // Fill the bar background. + let bg_cell = Cell { + bg: bar_bg, + ..Cell::default() + }; + frame.draw_rect_filled(area, bg_cell); + + let mode_label = match mode { + InputMode::Normal => "NORMAL", + InputMode::Text => "INPUT", + InputMode::Palette => "PALETTE", + InputMode::GoPrefix { .. } => "g...", + }; + + // Left side: mode indicator. + let mode_cell = Cell { + fg: accent_color, + bg: bar_bg, + ..Cell::default() + }; + let mut x = frame.print_text_clipped( + area.x.saturating_add(1), + area.y, + mode_label, + mode_cell, + area.right(), + ); + + // Spacer. + x = x.saturating_add(2); + + // Right side: key hints from registry (formatted as "key:action"). + let hints = registry.status_hints(screen); + let hint_cell = Cell { + fg: text_color, + bg: bar_bg, + ..Cell::default() + }; + let key_cell = Cell { + fg: accent_color, + bg: bar_bg, + ..Cell::default() + }; + + for hint in &hints { + if x >= area.right().saturating_sub(1) { + break; + } + // Split "q:quit" into key part and description part. + if let Some((key_part, desc_part)) = hint.split_once(':') { + x = frame.print_text_clipped(x, area.y, key_part, key_cell, area.right()); + x = frame.print_text_clipped(x, area.y, ":", hint_cell, area.right()); + x = frame.print_text_clipped(x, area.y, desc_part, hint_cell, area.right()); + } else { + x = frame.print_text_clipped(x, area.y, hint, hint_cell, area.right()); + } + x = x.saturating_add(2); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::build_registry; + use crate::message::Screen; + use ftui::render::grapheme_pool::GraphemePool; + + macro_rules! with_frame { + ($width:expr, $height:expr, |$frame:ident| $body:block) => {{ + let mut pool = GraphemePool::new(); + let mut $frame = Frame::new($width, $height, &mut pool); + $body + }}; + } + + fn white() -> PackedRgba { + PackedRgba::rgb(0xFF, 0xFF, 0xFF) + } + + fn gray() -> PackedRgba { + PackedRgba::rgb(0x80, 0x80, 0x80) + } + + #[test] + fn test_status_bar_renders_mode() { + with_frame!(80, 1, |frame| { + let registry = build_registry(); + render_status_bar( + &mut frame, + Rect::new(0, 0, 80, 1), + ®istry, + &Screen::Dashboard, + &InputMode::Normal, + gray(), + white(), + white(), + ); + + let n_cell = frame.buffer.get(1, 0).unwrap(); + assert_eq!(n_cell.content.as_char(), Some('N')); + }); + } + + #[test] + fn test_status_bar_text_mode() { + with_frame!(80, 1, |frame| { + let registry = build_registry(); + render_status_bar( + &mut frame, + Rect::new(0, 0, 80, 1), + ®istry, + &Screen::Search, + &InputMode::Text, + gray(), + white(), + white(), + ); + + let i_cell = frame.buffer.get(1, 0).unwrap(); + assert_eq!(i_cell.content.as_char(), Some('I')); + }); + } + + #[test] + fn test_status_bar_narrow_terminal() { + with_frame!(4, 1, |frame| { + let registry = build_registry(); + render_status_bar( + &mut frame, + Rect::new(0, 0, 4, 1), + ®istry, + &Screen::Dashboard, + &InputMode::Normal, + gray(), + white(), + white(), + ); + let cell = frame.buffer.get(0, 0).unwrap(); + assert!(cell.is_empty()); + }); + } +}