From 026b3f0754b54289dc030ecc66edd5934e5f9781 Mon Sep 17 00:00:00 2001 From: teernisse Date: Wed, 18 Feb 2026 23:59:47 -0500 Subject: [PATCH] feat(tui): responsive breakpoints for detail views (bd-a6yb) Apply breakpoint-aware layout to issue_detail and mr_detail views: - Issue detail: hide labels on Xs, hide assignees on Xs/Sm, skip milestone row on Xs - MR detail: hide branch names and merge status on Xs/Sm - Issue detail allocate_sections gives description 60% on wide (Lg+) vs 40% narrow - Add responsive tests for both detail views - Close bd-a6yb: all TUI screens now adapt to terminal width 760 lib tests pass, clippy clean. --- .beads/issues.jsonl | 4 +- crates/lore-tui/src/scope.rs | 7 ++ crates/lore-tui/src/view/doctor.rs | 10 ++- crates/lore-tui/src/view/file_history.rs | 66 +++++++++++++----- crates/lore-tui/src/view/issue_detail.rs | 69 +++++++++++++++---- crates/lore-tui/src/view/mr_detail.rs | 38 ++++++++-- crates/lore-tui/src/view/search.rs | 37 ++++++++-- crates/lore-tui/src/view/stats.rs | 8 ++- crates/lore-tui/src/view/sync.rs | 8 ++- crates/lore-tui/src/view/timeline.rs | 16 +++-- crates/lore-tui/src/view/trace.rs | 67 ++++++++++++++---- crates/lore-tui/src/view/who.rs | 88 +++++++++++++++++------- src/main.rs | 31 +++++---- 13 files changed, 345 insertions(+), 104 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index b1a966f..d4e70f8 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -252,7 +252,7 @@ {"id":"bd-3sez","title":"Create surgical.rs core module with preflight fetch, ingest functions, and TOCTOU guards","description":"## Background\n\nThe surgical sync pipeline needs a core module (`src/ingestion/surgical.rs`) that fetches a single issue or MR by IID from GitLab and ingests it into the local SQLite database. This replaces the bulk pagination path (`ingest_issues`/`ingest_merge_requests`) for targeted, on-demand sync of specific entities.\n\nKey constraints:\n- `process_single_issue` (issues.rs:143) and `process_single_mr` (merge_requests.rs:144) are private functions. This bead wraps them with pub(crate) entry points that add TOCTOU guard logic and dirty marking.\n- `updated_at` is a `String` (ISO 8601) in `GitLabIssue`/`GitLabMergeRequest` but stored as `INTEGER` (ms-epoch) in the DB. The TOCTOU guard must parse the ISO string to ms-epoch for comparison.\n- `ProcessMrResult` (merge_requests.rs:138) is a private struct. The MR ingest wrapper returns its own result type or re-exports the needed fields.\n- `SyncRunRecorder` has `succeed()` and `fail()` that consume `self`. Not needed here since surgical.rs is called from the orchestrator which owns the recorder.\n\n## Approach\n\nCreate `src/ingestion/surgical.rs` with:\n\n1. **`preflight_fetch`** (async): Takes `&GitLabClient`, `gitlab_project_id`, and a list of `(entity_type, iid)` targets. Calls `client.get_issue_by_iid()` and `client.get_mr_by_iid()` (from bd-159p). Returns `PreflightResult { issues: Vec, merge_requests: Vec, failures: Vec }`.\n\n2. **`ingest_issue_by_iid`** (sync): Takes `&Connection`, `&Config`, `project_id`, `&GitLabIssue`. Applies TOCTOU guard (compare payload `updated_at` parsed to ms-epoch vs DB `updated_at`), then calls `process_single_issue` (requires making it `pub(crate)` in bd-1sc6), marks dirty via `dirty_tracker::mark_dirty(conn, SourceType::Issue, local_issue_id)`, and returns `IngestIssueResult { upserted: bool, labels_created: usize, skipped_stale: bool, dirty_source_keys: Vec<(SourceType, i64)> }`.\n\n3. **`ingest_mr_by_iid`** (sync): Same pattern for MRs. Calls `process_single_mr` (requires `pub(crate)` in bd-1sc6), returns `IngestMrResult { upserted: bool, labels_created: usize, assignees_linked: usize, reviewers_linked: usize, skipped_stale: bool, dirty_source_keys: Vec<(SourceType, i64)> }`.\n\n4. **TOCTOU guard**: `fn is_stale(payload_updated_at: &str, db_updated_at_ms: Option) -> Result`. Parses ISO 8601 string to ms-epoch using `chrono::DateTime::parse_from_rfc3339`. Returns `true` if `payload_ms <= db_ms` (payload is same age or older than what we already have).\n\nWire the module in `src/ingestion/mod.rs`.\n\n## Acceptance Criteria\n\n- [ ] `preflight_fetch` calls GitLabClient by-IID methods and collects successes + failures\n- [ ] `ingest_issue_by_iid` wraps `process_single_issue` with TOCTOU guard and dirty marking\n- [ ] `ingest_mr_by_iid` wraps `process_single_mr` with TOCTOU guard and dirty marking\n- [ ] TOCTOU guard correctly parses ISO 8601 String to ms-epoch for comparison with DB i64\n- [ ] Stale payloads (payload updated_at <= DB updated_at) are skipped, not ingested\n- [ ] `dirty_source_keys` returned include the `(SourceType, source_id)` tuples for downstream scoped doc regen\n- [ ] Module registered in `src/ingestion/mod.rs`\n- [ ] All tests from bd-x8oq pass\n\n## Files\n\n- `src/ingestion/surgical.rs` (NEW)\n- `src/ingestion/mod.rs` (add `pub(crate) mod surgical;`)\n- `src/ingestion/issues.rs` (change `process_single_issue` to `pub(crate)` — done in bd-1sc6)\n- `src/ingestion/merge_requests.rs` (change `process_single_mr` and `ProcessMrResult` to `pub(crate)` — done in bd-1sc6)\n\n## TDD Anchor\n\nTests live in bd-x8oq (`src/ingestion/surgical_tests.rs`), referenced via `#[cfg(test)] #[path = \"surgical_tests.rs\"] mod tests;` in surgical.rs. Key tests that validate this bead:\n\n- `test_ingest_issue_by_iid_upserts_and_marks_dirty` — verifies full issue ingest path + dirty marking\n- `test_ingest_mr_by_iid_upserts_and_marks_dirty` — verifies full MR ingest path + dirty marking\n- `test_toctou_skips_stale_issue` — inserts issue with updated_at=T1, calls ingest with payload updated_at=T1, asserts skipped_stale=true\n- `test_toctou_skips_stale_mr` — same for MRs\n- `test_toctou_allows_newer_issue` — payload T2 > DB T1, asserts upserted=true\n- `test_is_stale_parses_iso8601` — unit test for the ISO 8601 to ms-epoch parsing\n- `test_is_stale_handles_none_db_value` — first ingest (no existing row), should return false (not stale)\n- `test_preflight_fetch_returns_issues_and_mrs` — wiremock test for successful preflight\n- `test_preflight_fetch_collects_failures` — wiremock 404 returns failure, not error\n\n## Edge Cases\n\n- ISO 8601 with timezone offset (GitLab returns `+00:00` not `Z`) must parse correctly\n- First-ever ingest of an IID: no existing DB row, TOCTOU guard must treat as \"not stale\" (db_updated_at is None)\n- GitLab returns 404 for a deleted issue/MR during preflight: failure, not hard error\n- Concurrent surgical syncs for same IID: `process_single_issue` uses `unchecked_transaction()` with UPSERT, so last-writer-wins is safe\n- `process_single_mr` returns `ProcessMrResult` which is private: either make it `pub(crate)` in bd-1sc6 or replicate needed fields\n\n## Dependency Context\n\n- **Blocked by bd-159p**: `get_issue_by_iid` and `get_mr_by_iid` on GitLabClient (preflight needs these)\n- **Blocked by bd-1sc6**: Visibility changes to `process_single_issue`, `process_single_mr`, `ProcessMrResult` (must be `pub(crate)`)\n- **Blocks bd-1i4i**: Orchestration function calls `preflight_fetch` + `ingest_issue_by_iid` / `ingest_mr_by_iid`\n- **Blocks bd-kanh**: Dependent helpers are called after ingest to fetch discussions, resource events, etc.\n- **Blocks bd-wcja**: SyncResult surgical fields depend on return types from this module\n- **Co-depends with bd-x8oq**: Tests for this code live in that bead's test file","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-17T19:14:19.449695Z","created_by":"tayloreernisse","updated_at":"2026-02-17T20:02:01.692160Z","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-3sez","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-3sez","depends_on_id":"bd-3jqx","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-3sez","depends_on_id":"bd-kanh","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-3sez","depends_on_id":"bd-wcja","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-3sez","depends_on_id":"bd-x8oq","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} {"id":"bd-3sh","title":"Add 'lore count events' command with robot mode","description":"## Background\nNeed to verify event ingestion and report counts by type. The existing count command (src/cli/commands/count.rs) handles issues, mrs, discussions, notes with both human and robot output. This adds 'events' as a new count subcommand.\n\n## Approach\nExtend the existing count command in src/cli/commands/count.rs:\n\n1. Add CountTarget::Events variant (or string match) in the count dispatcher\n2. Query each event table with GROUP BY entity type:\n```sql\nSELECT \n CASE WHEN issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END as entity_type,\n COUNT(*) as count\nFROM resource_state_events\nGROUP BY entity_type;\n-- (repeat for label and milestone events)\n```\n\n3. Human output: table format\n```\nEvent Type Issues MRs Total\nState events 1,234 567 1,801\nLabel events 2,345 890 3,235\nMilestone events 456 123 579\nTotal 4,035 1,580 5,615\n```\n\n4. Robot JSON:\n```json\n{\n \"ok\": true,\n \"data\": {\n \"state_events\": {\"issue\": 1234, \"merge_request\": 567, \"total\": 1801},\n \"label_events\": {\"issue\": 2345, \"merge_request\": 890, \"total\": 3235},\n \"milestone_events\": {\"issue\": 456, \"merge_request\": 123, \"total\": 579},\n \"total\": 5615\n }\n}\n```\n\n5. Register in CLI: add \"events\" to count's entity_type argument in src/cli/mod.rs\n\n## Acceptance Criteria\n- [ ] `lore count events` shows correct counts by event type and entity type\n- [ ] Robot JSON matches the schema above\n- [ ] Works with empty tables (all zeros)\n- [ ] Does not error if migration 011 hasn't been applied (graceful degradation or \"no event tables\" message)\n\n## Files\n- src/cli/commands/count.rs (add events counting logic)\n- src/cli/mod.rs (add \"events\" to count's accepted entity types)\n\n## TDD Loop\nRED: tests/count_tests.rs (or extend existing):\n- `test_count_events_empty_tables` - verify all zeros on fresh DB\n- `test_count_events_with_data` - seed state + label events, verify correct counts\n- `test_count_events_robot_json` - verify JSON structure\n\nGREEN: Add the events branch to count command\n\nVERIFY: `cargo test count -- --nocapture`\n\n## Edge Cases\n- Tables don't exist if user hasn't run migrate — check table existence first or catch the error\n- COUNT with GROUP BY returns no rows for empty tables — need to handle missing entity types as 0","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-02T21:31:57.379702Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:21:21.408874Z","closed_at":"2026-02-03T16:21:21.408806Z","close_reason":"Added 'events' to count CLI parser, run_count_events function, print_event_count (table format) and print_event_count_json (structured JSON). Wired into handle_count in main.rs.","compaction_level":0,"original_size":0,"labels":["cli","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-3sh","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-3sh","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-3t1b","title":"Implement MR Detail (state + action + view)","description":"## Background\nThe MR Detail shows a single merge request with file changes, diff discussions (position-specific comments), and general discussions. Same progressive hydration pattern as Issue Detail. MR detail has additional sections: file change list and diff-context notes.\n\n## Approach\nState (state/mr_detail.rs):\n- MrDetailState: current_key (Option), metadata (Option), discussions (Vec), diff_discussions (Vec), file_changes (Vec), cross_refs (Vec), tree_state (TreePersistState), scroll_offset, active_tab (MrTab: Overview|Files|Discussions)\n- MrMetadata: iid, title, description, state, author, reviewer, assignee, labels, target_branch, source_branch, created_at, updated_at, web_url, draft, merge_status\n- FileChange: old_path, new_path, change_type (added/modified/deleted/renamed), diff_line_count\n- DiffDiscussion: file_path, old_line, new_line, notes (Vec)\n\nAction (action.rs):\n- fetch_mr_detail(conn, key, clock) -> Result: uses with_read_snapshot\n\nView (view/mr_detail.rs):\n- render_mr_detail(frame, state, area, theme): header, tab bar (Overview|Files|Discussions), tab content\n- Overview tab: description + cross-refs\n- Files tab: file change list with change type indicators (+/-/~)\n- Discussions tab: general discussions + diff discussions grouped by file\n\n## Acceptance Criteria\n- [ ] MR metadata loads in Phase 1\n- [ ] Tab navigation between Overview, Files, Discussions\n- [ ] File changes list shows change type and line count\n- [ ] Diff discussions grouped by file path\n- [ ] General discussions rendered in tree widget\n- [ ] Cross-references navigable (related issues, etc.)\n- [ ] All text sanitized via sanitize_for_terminal()\n- [ ] Esc returns to MR List with state preserved\n\n## Files\n- MODIFY: crates/lore-tui/src/state/mr_detail.rs (expand from stub)\n- MODIFY: crates/lore-tui/src/action.rs (add fetch_mr_detail)\n- CREATE: crates/lore-tui/src/view/mr_detail.rs\n\n## TDD Anchor\nRED: Write test_fetch_mr_detail in action.rs that inserts an MR with 3 file changes, calls fetch_mr_detail, asserts 3 files returned.\nGREEN: Implement fetch_mr_detail with file change query.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_mr_detail\n\n## Edge Cases\n- MR with no file changes (draft MR created without pushes): show \"No file changes\" message\n- Diff discussions referencing deleted files: show file path with strikethrough style\n- Very large MRs (hundreds of files): paginate file list, don't load all at once\n\n## Dependency Context\nUses discussion tree and cross-ref widgets from \"Implement discussion tree + cross-reference widgets\" task.\nUses same patterns as \"Implement Issue Detail\" task.\nUses MrDetailState from \"Implement AppState composition\" task.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:59:38.427124Z","created_by":"tayloreernisse","updated_at":"2026-02-18T20:36:38.457188Z","closed_at":"2026-02-18T20:36:38.457090Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-3t1b","depends_on_id":"bd-1d6z","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-3t1b","depends_on_id":"bd-2kr0","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} -{"id":"bd-3t6r","title":"Epic: TUI Phase 5 — Polish","description":"## Background\nPhase 5 adds polish features: responsive breakpoints for all screens, session state persistence (resume where you left off), single-instance locking, entity/render caches for performance, text width handling for Unicode, snapshot tests, and terminal compatibility test matrix.\n\n## Acceptance Criteria\n- [ ] All screens adapt to terminal width with responsive breakpoints\n- [ ] Session state persisted and restored on relaunch\n- [ ] Single-instance lock prevents concurrent TUI launches\n- [ ] Entity cache enables near-instant detail view reopens\n- [ ] Snapshot tests produce deterministic output with FakeClock\n- [ ] Terminal compat verified across iTerm2, tmux, Alacritty, kitty","status":"open","priority":1,"issue_type":"epic","created_at":"2026-02-12T17:02:47.178645Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:51.435708Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-3t6r","depends_on_id":"bd-1df9","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} +{"id":"bd-3t6r","title":"Epic: TUI Phase 5 — Polish","description":"## Background\nPhase 5 adds polish features: responsive breakpoints for all screens, session state persistence (resume where you left off), single-instance locking, entity/render caches for performance, text width handling for Unicode, snapshot tests, and terminal compatibility test matrix.\n\n## Acceptance Criteria\n- [ ] All screens adapt to terminal width with responsive breakpoints\n- [ ] Session state persisted and restored on relaunch\n- [ ] Single-instance lock prevents concurrent TUI launches\n- [ ] Entity cache enables near-instant detail view reopens\n- [ ] Snapshot tests produce deterministic output with FakeClock\n- [ ] Terminal compat verified across iTerm2, tmux, Alacritty, kitty","status":"in_progress","priority":1,"issue_type":"epic","created_at":"2026-02-12T17:02:47.178645Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:01:12.262228Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-3t6r","depends_on_id":"bd-1df9","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-3ty8","title":"Implement Bootstrap screen + schema preflight","description":"## Background\nThe Bootstrap screen handles first-launch and incompatible-database scenarios. Before entering the TUI event loop, a schema preflight check validates the database is compatible. If not, an actionable error is shown. The Bootstrap screen also guides users through initial sync if the database is empty.\n\n## Approach\n- Schema preflight in lib.rs: check schema version before creating LoreApp. If incompatible, print error with lore migrate suggestion and exit non-zero.\n- Bootstrap screen (Screen::Bootstrap): shown when database has zero issues/MRs. Shows: \"No data found. Run sync to get started.\" with option to start sync inline.\n- State: BootstrapState { has_data: bool, schema_ok: bool, config_valid: bool }\n- Action: check_data_readiness(conn) -> DataReadiness { has_issues: bool, has_mrs: bool, has_documents: bool, schema_version: i32 }\n\n## Acceptance Criteria\n- [ ] Schema preflight yields actionable error for incompatible DB versions\n- [ ] Bootstrap screen shown when database is empty\n- [ ] Bootstrap guides user to start sync\n- [ ] After sync completes, Bootstrap auto-transitions to Dashboard\n- [ ] Non-zero exit code on schema incompatibility\n\n## Files\n- CREATE: crates/lore-tui/src/state/bootstrap.rs\n- CREATE: crates/lore-tui/src/view/bootstrap.rs\n- MODIFY: crates/lore-tui/src/lib.rs (add schema preflight check)\n- MODIFY: crates/lore-tui/src/action.rs (add check_data_readiness)\n\n## TDD Anchor\nRED: Write test_schema_preflight_rejects_old that creates DB at schema version 1, asserts preflight returns error.\nGREEN: Implement schema version check.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_schema_preflight\n\n## Edge Cases\n- Database file doesn't exist: create it, then show Bootstrap\n- Database locked by another process: show DbBusy error with suggestion\n- Config file missing: show error with lore init suggestion","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:00:02.185699Z","created_by":"tayloreernisse","updated_at":"2026-02-18T20:58:27.145495Z","closed_at":"2026-02-18T20:58:27.145430Z","close_reason":"Implemented Bootstrap screen + schema preflight: state/bootstrap.rs (DataReadiness, SchemaCheck, BootstrapState), action.rs (check_schema_version, check_data_readiness, MINIMUM_SCHEMA_VERSION), view/bootstrap.rs (render_bootstrap), lib.rs (schema_preflight), update.rs (SyncStarted/SyncCompleted bootstrap auto-transition). 405 tests passing, clippy clean.","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-3ty8","depends_on_id":"bd-1cl9","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-3ty8","depends_on_id":"bd-6pmy","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-3vqk","title":"OBSERV: Add rate_limit_hits and retries counters to StageTiming","description":"## Background\nMetricsLayer counts span timing but doesn't yet count rate-limit hits and retries. These counters complete the observability picture, showing HOW MUCH time was spent waiting vs. working.\n\n## Approach\n### src/core/metrics.rs - StageTiming struct\n\nAdd two new fields:\n```rust\n#[derive(Debug, Clone, Serialize)]\npub struct StageTiming {\n // ... existing fields ...\n #[serde(skip_serializing_if = \"is_zero\")]\n pub rate_limit_hits: usize,\n #[serde(skip_serializing_if = \"is_zero\")]\n pub retries: usize,\n}\n```\n\n### src/core/metrics.rs - MetricsLayer\n\nThe structured log events from bd-12ae use info!() with specific fields (status_code=429, \"Rate limited, retrying\"). MetricsLayer needs to count these events within each span.\n\nAdd to SpanData:\n```rust\nstruct SpanData {\n // ... existing fields ...\n rate_limit_hits: usize,\n retries: usize,\n}\n```\n\nAdd on_event() to MetricsLayer:\n```rust\nfn on_event(&self, event: &tracing::Event<'_>, ctx: Context<'_, S>) {\n // Check if event message contains rate-limit or retry indicators\n // Increment counters on the current span\n if let Some(span_ref) = ctx.event_span(event) {\n let id = span_ref.id();\n if let Some(data) = self.spans.lock().unwrap().get_mut(&id.into_u64()) {\n let mut visitor = EventVisitor::default();\n event.record(&mut visitor);\n\n if visitor.status_code == Some(429) {\n data.rate_limit_hits += 1;\n }\n if visitor.is_retry {\n data.retries += 1;\n }\n }\n }\n}\n```\n\nThe EventVisitor checks for status_code=429 and message containing \"retrying\" to classify events.\n\nOn span close, propagate counts to parent (bubble up):\n```rust\nfn on_close(&self, id: Id, _ctx: Context<'_, S>) {\n if let Some(data) = self.spans.lock().unwrap().remove(&id.into_u64()) {\n let timing = StageTiming {\n // ... existing fields ...\n rate_limit_hits: data.rate_limit_hits,\n retries: data.retries,\n };\n // ... push to completed\n }\n}\n```\n\n## Acceptance Criteria\n- [ ] StageTiming has rate_limit_hits and retries fields\n- [ ] Fields omitted when zero in JSON serialization\n- [ ] MetricsLayer counts 429 events as rate_limit_hits\n- [ ] MetricsLayer counts retry events as retries\n- [ ] Counts bubble up to parent spans in extract_timings()\n- [ ] Rate limit counts appear in metrics_json stored in sync_runs\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/metrics.rs (add fields to StageTiming, add on_event to MetricsLayer, add EventVisitor)\n\n## TDD Loop\nRED:\n - test_stage_timing_rate_limit_counts: simulate 3 rate-limit events, extract, assert rate_limit_hits=3\n - test_stage_timing_retry_counts: simulate 2 retries, extract, assert retries=2\n - test_rate_limit_fields_omitted_when_zero: StageTiming with zero counts, serialize, assert no keys\nGREEN: Add fields to StageTiming, implement on_event in MetricsLayer\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Events outside any span: ctx.event_span() returns None. Skip counting. This shouldn't happen in practice since all GitLab calls happen within stage spans.\n- Event classification: rely on structured fields (status_code=429) not message text. More reliable and less fragile.\n- Count bubbling: parent stage should aggregate child counts. In extract_timings(), sum children's counts into parent.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:55:02.523778Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:25:25.456758Z","closed_at":"2026-02-04T17:25:25.456708Z","close_reason":"Implemented rate_limit_hits and retries counters in StageTiming with skip_serializing_if for zero values","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-3vqk","depends_on_id":"bd-12ae","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-3vqk","depends_on_id":"bd-1o4h","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-3vqk","depends_on_id":"bd-3pk","type":"parent-child","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-4qd","title":"Write unit tests for core algorithms","description":"## Background\nUnit tests verify the core algorithms in isolation: document extraction formatting, FTS query sanitization, RRF scoring, content hashing, backoff curves, and filter helpers. These tests don't require a database or external services — they test pure functions and logic.\n\n## Approach\nAdd #[cfg(test)] mod tests blocks to each module:\n\n**1. src/documents/extractor.rs:**\n- test_source_type_parse_all_aliases — every alias resolves correctly\n- test_source_type_parse_unknown — returns None\n- test_source_type_as_str_roundtrip — as_str matches parse input\n- test_content_hash_deterministic — same input = same hash\n- test_list_hash_order_independent — sorted before hashing\n- test_list_hash_empty — empty vec produces consistent hash\n\n**2. src/documents/truncation.rs:**\n- test_truncation_edge_cases (per bd-18t TDD Loop)\n\n**3. src/search/fts.rs:**\n- test_to_fts_query_basic — \"auth error\" -> quoted tokens\n- test_to_fts_query_prefix — \"auth*\" preserves prefix\n- test_to_fts_query_special_chars — \"C++\" quoted correctly\n- test_to_fts_query_dash — \"-DWITH_SSL\" quoted (not NOT operator)\n- test_to_fts_query_internal_quotes — escaped by doubling\n- test_to_fts_query_empty — empty string returns empty\n\n**4. src/search/rrf.rs:**\n- test_rrf_dual_list — docs in both lists score higher\n- test_rrf_normalization — best score = 1.0\n- test_rrf_empty — empty returns empty\n\n**5. src/core/backoff.rs:**\n- test_exponential_curve — delays double each attempt\n- test_cap_at_one_hour — high attempt_count capped\n- test_jitter_range — within [0.9, 1.1) factor\n\n**6. src/search/filters.rs:**\n- test_has_any_filter — true/false for various filter combos\n- test_clamp_limit — 0->20, 200->100, 50->50\n- test_path_filter_from_str — trailing slash = Prefix\n\n**7. src/search/hybrid.rs (hydration round-trip):**\n- test_single_round_trip_query — verify hydration SQL produces correct structure\n\n## Acceptance Criteria\n- [ ] All edge cases covered per PRD acceptance criteria\n- [ ] Tests are unit tests (no DB, no network, no Ollama)\n- [ ] `cargo test` passes with all new tests\n- [ ] No test depends on execution order\n- [ ] Tests cover: document extractor formats, truncation, RRF, hashing, FTS sanitization, backoff, filters\n\n## Files\n- In-module tests in: extractor.rs, truncation.rs, fts.rs, rrf.rs, backoff.rs, filters.rs, hybrid.rs\n\n## TDD Loop\nThese tests ARE the TDD loop for their respective beads. Each implementation bead should write its tests first (RED), then implement (GREEN).\nVERIFY: `cargo test`\n\n## Edge Cases\n- Tests with Unicode: include emoji, CJK characters in truncation tests\n- Tests with empty strings: empty queries, empty content, empty labels\n- Tests with boundary values: limit=0, limit=100, limit=101","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:27:21.712924Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:46:00.059346Z","closed_at":"2026-01-30T17:46:00.059292Z","close_reason":"All acceptance criteria tests already exist across modules. 276 tests passing (189 unit + 87 integration).","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-4qd","depends_on_id":"bd-18t","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-4qd","depends_on_id":"bd-1k1","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-4qd","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-4qd","depends_on_id":"bd-3ez","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-4qd","depends_on_id":"bd-mem","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} @@ -268,7 +268,7 @@ {"id":"bd-9dd","title":"Implement 'lore trace' command with human and robot output","description":"## Background\n\nThe trace command is Gate 5's capstone CLI. It answers 'Why was this code introduced?' by building file -> MR -> issue -> discussion chains.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 5.3.\n\n## Codebase Context\n\n- CLI pattern: same as file-history (Commands enum, handler in main.rs)\n- trace.rs (bd-2n4): run_trace() returns TraceResult with chains\n- Path parsing: support 'src/foo.rs:45' syntax (line number for future Tier 2)\n- merge_requests.merged_at exists (migration 006) — use COALESCE(merged_at, updated_at) for ordering\n\n## Approach\n\n### 1. TraceArgs (`src/cli/mod.rs`):\n```rust\n#[derive(Parser)]\npub struct TraceArgs {\n pub path: String, // supports :line suffix\n #[arg(short = 'p', long)] pub project: Option,\n #[arg(long)] pub discussions: bool,\n #[arg(long = \"no-follow-renames\")] pub no_follow_renames: bool,\n #[arg(short = 'n', long = \"limit\", default_value = \"20\")] pub limit: usize,\n}\n```\n\n### 2. Path parsing:\n```rust\nfn parse_trace_path(input: &str) -> (String, Option) {\n if let Some((path, line)) = input.rsplit_once(':') {\n if let Ok(n) = line.parse::() { return (path.to_string(), Some(n)); }\n }\n (input.to_string(), None)\n}\n```\nIf line present: warn 'Line-level tracing requires Tier 2. Showing file-level results.'\n\n### 3. Human output shows chains with MR -> issue -> discussion context\n\n### 4. Robot JSON:\n```json\n{\"ok\": true, \"data\": {\"path\": \"...\", \"resolved_paths\": [...], \"trace_chains\": [...]}, \"meta\": {\"tier\": \"api_only\", \"line_requested\": null}}\n```\n\n## Acceptance Criteria\n\n- [ ] `lore trace src/foo.rs` with human output\n- [ ] `lore --robot trace src/foo.rs` with JSON\n- [ ] :line suffix parses and emits Tier 2 warning\n- [ ] -p, --discussions, --no-follow-renames, -n all work\n- [ ] Rename-aware via resolve_rename_chain\n- [ ] meta.tier = 'api_only'\n- [ ] Added to VALID_COMMANDS and robot-docs\n- [ ] `cargo check --all-targets` passes\n\n## Files\n\n- `src/cli/mod.rs` (TraceArgs + Commands::Trace)\n- `src/cli/commands/trace.rs` (NEW)\n- `src/cli/commands/mod.rs` (re-export)\n- `src/main.rs` (handler + VALID_COMMANDS + robot-docs)\n\n## TDD Loop\n\nRED:\n- `test_parse_trace_path_simple` - \"src/foo.rs\" -> (path, None)\n- `test_parse_trace_path_with_line` - \"src/foo.rs:42\" -> (path, Some(42))\n- `test_parse_trace_path_windows` - \"C:/foo.rs\" -> (path, None) — don't misparse drive letter\n\nGREEN: Implement CLI wiring and handlers.\n\nVERIFY: `cargo check --all-targets`\n\n## Edge Cases\n\n- Windows paths: don't misparse C: as line number\n- No MR data: friendly message with suggestion to sync\n- Very deep rename chain: bounded by resolve_rename_chain","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-02-02T21:34:32.788530Z","created_by":"tayloreernisse","updated_at":"2026-02-17T19:08:40.322237Z","compaction_level":0,"original_size":0,"labels":["cli","gate-5","phase-b"],"dependencies":[{"issue_id":"bd-9dd","depends_on_id":"bd-1ht","type":"parent-child","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-9dd","depends_on_id":"bd-2n4","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} {"id":"bd-9lbr","title":"lore explain: auto-generate issue/MR narrative","description":"## Background\nGiven an issue or MR, auto-generate a structured narrative of what happened: who was involved, what decisions were made, what changed, and what is unresolved. Template-based v1 (no LLM dependency), deterministic and reproducible.\n\n## Current Infrastructure (Verified 2026-02-12)\n- show.rs: IssueDetail (line 69) and MrDetail (line 14) — entity detail with discussions\n- timeline.rs: 5-stage pipeline SHIPPED — chronological event reconstruction\n- notes table: 282K rows with body, author, created_at, is_system, discussion_id\n- discussions table: links notes to parent entity (noteable_type, noteable_id), has resolved flag\n- resource_state_events table: state changes with created_at, user_username (src/core/events_db.rs)\n- resource_label_events table: label add/remove with created_at, user_username\n- entity_references table (src/core/references.rs): cross-references between entities (closing MRs, related issues). Column names: `source_entity_type`, `source_entity_id`, `target_entity_type`, `target_entity_id`, `target_project_path`, `target_entity_iid`, `reference_type`, `source_method`\n\n## Approach\nNew command: `lore explain issues N` / `lore explain mrs N`\n\n### Data Assembly (reuse existing internals as library calls)\n1. Entity detail: reuse show.rs query logic for IssueDetail/MrDetail\n2. Timeline events: reuse timeline pipeline with entity-scoped seed\n3. Discussion notes:\n```sql\nSELECT n.id, n.body, n.author_username, n.created_at\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = ? AND d.noteable_id = ?\n AND n.is_system = 0\nORDER BY n.created_at\n```\n4. Cross-references:\n```sql\nSELECT target_entity_type, target_entity_id, target_project_path,\n target_entity_iid, reference_type, source_method\nFROM entity_references\nWHERE (source_entity_type = ?1 AND source_entity_id = ?2)\nUNION ALL\nSELECT source_entity_type, source_entity_id, NULL,\n NULL, reference_type, source_method\nFROM entity_references\nWHERE (target_entity_type = ?1 AND target_entity_id = ?2)\n```\n\n### Key Decisions Heuristic\nNotes from assignees/author that follow state or label changes within 1 hour:\n```rust\nstruct StateOrLabelEvent {\n created_at: i64, // ms epoch\n user: String,\n description: String, // e.g. \"state: opened -> closed\" or \"label: +bug\"\n}\n\nfn extract_key_decisions(\n state_events: &[ResourceStateEvent],\n label_events: &[ResourceLabelEvent],\n notes: &[Note],\n) -> Vec {\n // Merge both event types into a unified chronological list\n let mut events: Vec = Vec::new();\n for e in state_events {\n events.push(StateOrLabelEvent {\n created_at: e.created_at,\n user: e.user_username.clone(),\n description: format!(\"state: {} -> {}\", e.from_state.as_deref().unwrap_or(\"?\"), e.to_state),\n });\n }\n for e in label_events {\n let action = if e.action == \"add\" { \"+\" } else { \"-\" };\n events.push(StateOrLabelEvent {\n created_at: e.created_at,\n user: e.user_username.clone(),\n description: format!(\"label: {}{}\", action, e.label_name.as_deref().unwrap_or(\"?\")),\n });\n }\n events.sort_by_key(|e| e.created_at);\n\n let mut decisions = Vec::new();\n let one_hour_ms: i64 = 60 * 60 * 1000;\n\n for event in &events {\n // Find notes by same actor within 60 min after the event\n for note in notes {\n if note.author_username == event.user\n && note.created_at >= event.created_at\n && note.created_at <= event.created_at + one_hour_ms\n {\n decisions.push(KeyDecision {\n timestamp: event.created_at,\n actor: event.user.clone(),\n action: event.description.clone(),\n context_note: truncate(¬e.body, 500),\n });\n break; // one note per event\n }\n }\n }\n decisions.truncate(10); // Cap at 10 key decisions\n decisions\n}\n```\n\n### Narrative Sections\n1. **Header**: title, author, opened date, state, assignees, labels, status_name\n2. **Description excerpt**: first 500 chars of description (or full if shorter)\n3. **Key decisions**: notes correlated with state/label changes (heuristic above)\n4. **Activity summary**: counts of state changes, label changes, notes, time range\n5. **Open threads**: discussions WHERE resolved = false\n6. **Related entities**: closing MRs (with state), related issues from entity_references\n7. **Timeline excerpt**: first 20 events from timeline pipeline\n\n## Robot Mode Output Schema\n```json\n{\n \"ok\": true,\n \"data\": {\n \"entity\": {\n \"type\": \"issue\", \"iid\": 3864, \"title\": \"...\", \"state\": \"opened\",\n \"author\": \"teernisse\", \"assignees\": [\"teernisse\"],\n \"labels\": [\"customer:BNSF\"], \"created_at\": \"...\", \"updated_at\": \"...\",\n \"url\": \"...\", \"status_name\": \"In progress\"\n },\n \"description_excerpt\": \"First 500 chars of description...\",\n \"key_decisions\": [{\n \"timestamp\": \"2026-01-15T...\",\n \"actor\": \"teernisse\",\n \"action\": \"state: opened -> in_progress\",\n \"context_note\": \"Starting work on the BNSF throw time integration...\"\n }],\n \"activity\": {\n \"state_changes\": 3, \"label_changes\": 5, \"notes\": 42,\n \"first_event\": \"2026-01-10T...\", \"last_event\": \"2026-02-12T...\"\n },\n \"open_threads\": [{\n \"discussion_id\": \"abc123\",\n \"started_by\": \"cseiber\",\n \"started_at\": \"2026-02-01T...\",\n \"note_count\": 5,\n \"last_note_at\": \"2026-02-10T...\"\n }],\n \"related\": {\n \"closing_mrs\": [{ \"iid\": 200, \"title\": \"...\", \"state\": \"merged\" }],\n \"related_issues\": [{ \"iid\": 3800, \"title\": \"Rail Break Card\", \"relation\": \"related\" }]\n },\n \"timeline_excerpt\": [{ \"timestamp\": \"...\", \"event_type\": \"...\", \"actor\": \"...\", \"summary\": \"...\" }]\n },\n \"meta\": { \"elapsed_ms\": 350 }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nExplain {\n /// Entity type: \"issues\" or \"mrs\"\n entity_type: String,\n /// Entity IID\n iid: i64,\n /// Scope to project (fuzzy match)\n #[arg(short, long)]\n project: Option,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/explain.rs:\n- test_explain_issue_basic: insert issue + notes + state events, run explain, assert all sections present (entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt)\n- test_explain_key_decision_heuristic: insert state change event + note by same author within 30 min, assert note appears in key_decisions\n- test_explain_key_decision_ignores_unrelated_notes: insert note by different author, assert it does NOT appear in key_decisions\n- test_explain_open_threads: insert 2 discussions (1 resolved, 1 unresolved), assert only unresolved in open_threads\n- test_explain_no_notes: issue with zero notes produces header + description + empty sections\n- test_explain_mr: insert MR with merged_at, assert entity includes type=\"merge_request\"\n- test_explain_activity_counts: insert 3 state events + 2 label events + 10 notes, assert counts match\n\nGREEN: Implement explain command with section assembly\n\nVERIFY:\n```bash\ncargo test explain:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J explain issues 3864 | jq '.data | keys'\n# Should include: entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt\n```\n\n## Acceptance Criteria\n- [ ] lore explain issues N produces structured output for any synced issue\n- [ ] lore explain mrs N produces structured output for any synced MR\n- [ ] Robot mode returns all 7 sections\n- [ ] Human mode renders readable narrative with headers and indentation\n- [ ] Key decisions heuristic: captures notes within 60 min of state/label changes by same actor\n- [ ] Works fully offline (no API calls, no LLM)\n- [ ] Performance: <500ms for issue with 50 notes\n- [ ] Command registered in main.rs and robot-docs\n- [ ] key_decisions capped at 10, timeline_excerpt capped at 20 events\n\n## Edge Cases\n- Issue with empty description: description_excerpt = \"(no description)\"\n- Issue with 500+ notes: timeline_excerpt capped at 20, key_decisions capped at 10\n- Issue not found in local DB: exit code 17 with suggestion to sync\n- Ambiguous project: exit code 18 with suggestion to use -p flag\n- MR with no review activity: activity section shows zeros\n- Cross-project references: show as unresolved with project path hint\n- Notes that are pure code blocks: include in key_decisions if correlated with events (they may contain implementation decisions)\n- ResourceStateEvent/ResourceLabelEvent field names: check src/core/events_db.rs for exact struct definitions before implementing\n\n## Dependency Context\n- **bd-2g50 (data gaps)**: BLOCKER. Provides `closed_at` field on IssueDetail for the header section. Without it, explain can still show state=\"closed\" but won't have the exact close timestamp.\n\n## Files to Create/Modify\n- NEW: src/cli/commands/explain.rs\n- src/cli/commands/mod.rs (add pub mod explain; re-export)\n- src/main.rs (register Explain subcommand in Commands enum, add handle_explain fn)\n- Reuse: show.rs queries, timeline pipeline, notes/discussions/resource_events queries from src/core/events_db.rs","status":"open","priority":2,"issue_type":"feature","created_at":"2026-02-12T15:46:41.386454Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:31:34.538422Z","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence"],"dependencies":[{"issue_id":"bd-9lbr","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-9lbr","depends_on_id":"bd-2g50","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-9wl5","title":"NOTE-2G: Parent metadata change propagation to note documents","description":"## Background\nNote documents inherit labels and title from parent issue/MR. When parent metadata changes, note documents become stale. The existing pipeline already marks discussion documents dirty on parent changes — note documents need the same treatment.\n\n## Approach\nFind where ingestion detects parent entity changes and marks discussion documents dirty. The dirty marking for discussions happens in:\n- src/ingestion/discussions.rs line 127: mark_dirty_tx(&tx, SourceType::Discussion, local_discussion_id)\n- src/ingestion/mr_discussions.rs line 162 and 362: mark_dirty_tx(&tx, SourceType::Discussion, local_discussion_id)\n\nThese fire when a discussion is upserted (which happens when parent entity is re-ingested). For note documents, we need to additionally mark all non-system notes of that discussion as dirty:\n\nAfter each mark_dirty_tx for Discussion, add:\n // Mark child note documents dirty (they inherit parent metadata)\n let note_ids: Vec = tx.prepare(\"SELECT id FROM notes WHERE discussion_id = ? AND is_system = 0\")?\n .query_map([local_discussion_id], |r| r.get(0))?\n .collect::, _>>()?;\n for note_id in note_ids {\n dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, note_id)?;\n }\n\nAlternative (more efficient, set-based):\n INSERT INTO dirty_sources (source_type, source_id, queued_at)\n SELECT 'note', n.id, ?1\n FROM notes n\n WHERE n.discussion_id = ?2 AND n.is_system = 0\n ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0\n\nUse the set-based approach for better performance with large discussions.\n\n## Files\n- MODIFY: src/ingestion/discussions.rs (add note dirty marking after line 127)\n- MODIFY: src/ingestion/mr_discussions.rs (add note dirty marking after lines 162 and 362)\n\n## TDD Anchor\nRED: test_parent_title_change_marks_notes_dirty — change issue title, re-ingest discussions, assert note documents appear in dirty_sources.\nGREEN: Add set-based INSERT INTO dirty_sources after discussion dirty marking.\nVERIFY: cargo test parent_title_change_marks_notes -- --nocapture\nTests: test_parent_label_change_marks_notes_dirty (modify issue labels, re-ingest, check dirty queue)\n\n## Acceptance Criteria\n- [ ] Discussion upsert for issue marks child non-system note documents dirty\n- [ ] Discussion upsert for MR marks child non-system note documents dirty (both call sites)\n- [ ] Only non-system notes marked dirty (is_system = 0 filter)\n- [ ] Set-based SQL (not per-note loop) for performance\n- [ ] Both tests pass\n\n## Dependency Context\n- Depends on NOTE-2D (bd-2ezb): dirty tracking infrastructure for notes must exist (dirty_sources accepts source_type='note', regenerator handles it)\n\n## Edge Cases\n- Discussion with 0 non-system notes: set-based INSERT is a no-op\n- Discussion with 100+ notes: set-based approach handles efficiently in one SQL statement\n- Concurrent discussion ingestion: ON CONFLICT DO UPDATE handles race safely","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:40.292874Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.717576Z","closed_at":"2026-02-12T18:13:15.717528Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"]} -{"id":"bd-a6yb","title":"Implement responsive breakpoints for all TUI screens","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-02-19T04:52:55.561576Z","created_by":"tayloreernisse","updated_at":"2026-02-19T04:53:08.902955Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-a6yb","depends_on_id":"bd-3t6r","type":"blocks","created_at":"2026-02-19T04:53:02.566163Z","created_by":"tayloreernisse"}]} +{"id":"bd-a6yb","title":"Implement responsive breakpoints for all TUI screens","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T04:52:55.561576Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:10:12.531731Z","closed_at":"2026-02-19T05:10:12.531557Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-a6yb","depends_on_id":"bd-3t6r","type":"blocks","created_at":"2026-02-19T04:53:02.566163Z","created_by":"tayloreernisse"}]} {"id":"bd-am7","title":"Implement embedding pipeline with chunking","description":"## Background\nThe embedding pipeline takes documents, chunks them (paragraph-boundary splitting with overlap), sends chunks to Ollama for embedding via async HTTP, and stores vectors in sqlite-vec + metadata. It uses keyset pagination, concurrent HTTP requests via FuturesUnordered, per-batch transactions, and dimension validation.\n\n## Approach\nCreate \\`src/embedding/pipeline.rs\\` per PRD Section 4.4. **The pipeline is async.**\n\n**Constants (per PRD):**\n```rust\nconst BATCH_SIZE: usize = 32; // texts per Ollama API call\nconst DB_PAGE_SIZE: usize = 500; // keyset pagination page size\nconst EXPECTED_DIMS: usize = 768; // nomic-embed-text dimensions\nconst CHUNK_MAX_CHARS: usize = 32_000; // max chars per chunk\nconst CHUNK_OVERLAP_CHARS: usize = 500; // overlap between chunks\n```\n\n**Core async function:**\n```rust\npub async fn embed_documents(\n conn: &Connection,\n client: &OllamaClient,\n selection: EmbedSelection,\n concurrency: usize, // max in-flight HTTP requests\n progress_callback: Option>,\n) -> Result\n```\n\n**EmbedSelection:** Pending | RetryFailed\n**EmbedResult:** { embedded, failed, skipped }\n\n**Algorithm (per PRD):**\n1. count_pending_documents(conn, selection) for progress total\n2. Keyset pagination loop: find_pending_documents(conn, DB_PAGE_SIZE, last_id, selection)\n3. For each page:\n a. Begin transaction\n b. For each doc: clear_document_embeddings(&tx, doc.id), split_into_chunks(&doc.content)\n c. Build ChunkWork items with doc_hash + chunk_hash\n d. Commit clearing transaction\n4. Batch ChunkWork texts into Ollama calls (BATCH_SIZE=32)\n5. Use **FuturesUnordered** for concurrent HTTP, cap at \\`concurrency\\`\n6. collect_writes() in per-batch transactions: validate dims (768), store LE bytes, write metadata\n7. On error: record_embedding_error per chunk (not abort)\n8. Advance keyset cursor\n\n**ChunkWork struct:**\n```rust\nstruct ChunkWork {\n doc_id: i64,\n chunk_index: usize,\n doc_hash: String, // SHA-256 of FULL document (staleness detection)\n chunk_hash: String, // SHA-256 of THIS chunk (provenance)\n text: String,\n}\n```\n\n**Splitting:** split_into_chunks(content) -> Vec<(usize, String)>\n- Documents <= CHUNK_MAX_CHARS: single chunk (index 0)\n- Longer: split at paragraph boundaries (\\\\n\\\\n), fallback to sentence/word, with CHUNK_OVERLAP_CHARS overlap\n\n**Storage:** embeddings as raw LE bytes, rowid = encode_rowid(doc_id, chunk_idx)\n**Staleness detection:** uses document_hash (not chunk_hash) because it's document-level\n\nAlso create \\`src/embedding/change_detector.rs\\` (referenced in PRD module structure):\n```rust\npub fn detect_embedding_changes(conn: &Connection) -> Result>;\n```\n\n## Acceptance Criteria\n- [ ] Pipeline is async (uses FuturesUnordered for concurrent HTTP)\n- [ ] concurrency parameter caps in-flight HTTP requests\n- [ ] progress_callback reports (processed, total)\n- [ ] New documents embedded, changed re-embedded, unchanged skipped\n- [ ] clear_document_embeddings before re-embedding (range delete vec0 + metadata)\n- [ ] Chunking at paragraph boundaries with 500-char overlap\n- [ ] Short documents (<32k chars) produce exactly 1 chunk\n- [ ] Embeddings stored as raw LE bytes in vec0\n- [ ] Rowids encoded via encode_rowid(doc_id, chunk_index)\n- [ ] Dimension validation: 768 floats per embedding (mismatch -> record error, not store)\n- [ ] Per-batch transactions for writes\n- [ ] Errors recorded in embedding_metadata per chunk (last_error, attempt_count)\n- [ ] Keyset pagination (d.id > last_id, not OFFSET)\n- [ ] Pending detection uses document_hash (not chunk_hash)\n- [ ] \\`cargo build\\` succeeds\n\n## Files\n- \\`src/embedding/pipeline.rs\\` — new file (async)\n- \\`src/embedding/change_detector.rs\\` — new file\n- \\`src/embedding/mod.rs\\` — add \\`pub mod pipeline; pub mod change_detector;\\` + re-exports\n\n## TDD Loop\nRED: Unit tests for chunking:\n- \\`test_short_document_single_chunk\\` — <32k produces [(0, full_content)]\n- \\`test_long_document_multiple_chunks\\` — >32k splits at paragraph boundaries\n- \\`test_chunk_overlap\\` — adjacent chunks share 500-char overlap\n- \\`test_no_paragraph_boundary\\` — falls back to char boundary\nIntegration tests need Ollama or mock.\nGREEN: Implement split_into_chunks, embed_documents (async)\nVERIFY: \\`cargo test pipeline\\`\n\n## Edge Cases\n- Empty document content_text: skip (don't embed)\n- No paragraph boundaries: split at CHUNK_MAX_CHARS with overlap\n- Ollama error for one batch: record error per chunk, continue with next batch\n- Dimension mismatch (model returns 512 instead of 768): record error, don't store corrupt data\n- Document deleted between pagination and embedding: skip gracefully","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:34.093701Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:58:58.908585Z","closed_at":"2026-01-30T17:58:58.908525Z","close_reason":"Implemented embedding pipeline: chunking at paragraph boundaries with 500-char overlap, change detector (keyset pagination, hash-based staleness), async embed via Ollama with batch processing, dimension validation, per-chunk error recording, LE byte vector storage. 7 chunking tests pass. 289 total tests.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-am7","depends_on_id":"bd-1y8","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-am7","depends_on_id":"bd-2ac","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-am7","depends_on_id":"bd-335","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-apmo","title":"OBSERV: Create migration 014 for sync_runs enrichment","description":"## Background\nThe sync_runs table (created in migration 001) has columns id, started_at, heartbeat_at, finished_at, status, command, error, metrics_json but NOTHING writes to it. This migration adds columns for the observability correlation ID and aggregate counts, enabling queryable sync history.\n\n## Approach\nCreate migrations/014_sync_runs_enrichment.sql:\n\n```sql\n-- Migration 014: sync_runs enrichment for observability\n-- Adds correlation ID and aggregate counts for queryable sync history\n\nALTER TABLE sync_runs ADD COLUMN run_id TEXT;\nALTER TABLE sync_runs ADD COLUMN total_items_processed INTEGER DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN total_errors INTEGER DEFAULT 0;\n\n-- Index for correlation queries (find run by run_id from logs)\nCREATE INDEX IF NOT EXISTS idx_sync_runs_run_id ON sync_runs(run_id);\n```\n\nMigration naming convention: check migrations/ directory. Current latest is 013_resource_event_watermarks.sql. Next is 014.\n\nNote: SQLite ALTER TABLE ADD COLUMN is always safe -- it sets NULL for existing rows. DEFAULT 0 applies to new INSERTs only.\n\n## Acceptance Criteria\n- [ ] Migration 014 applies cleanly on a fresh DB (all migrations 001-014)\n- [ ] Migration 014 applies cleanly on existing DB with 001-013 already applied\n- [ ] sync_runs table has run_id TEXT column\n- [ ] sync_runs table has total_items_processed INTEGER DEFAULT 0 column\n- [ ] sync_runs table has total_errors INTEGER DEFAULT 0 column\n- [ ] idx_sync_runs_run_id index exists\n- [ ] Existing sync_runs rows (if any) have NULL run_id, 0 for counts\n- [ ] cargo clippy --all-targets -- -D warnings passes (no code changes, but verify migration is picked up)\n\n## Files\n- migrations/014_sync_runs_enrichment.sql (new file)\n\n## TDD Loop\nRED:\n - test_migration_014_applies: apply all migrations on fresh in-memory DB, query sync_runs schema\n - test_migration_014_idempotent: CREATE INDEX IF NOT EXISTS makes re-run safe; ALTER TABLE ADD COLUMN is NOT idempotent in SQLite (will error). Consider: skip this test or use IF NOT EXISTS workaround\nGREEN: Create migration file\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- ALTER TABLE ADD COLUMN in SQLite: NOT idempotent. Running migration twice will error \"duplicate column name.\" The migration system should prevent re-runs, but IF NOT EXISTS is not available for ALTER TABLE in SQLite. Rely on migration tracking.\n- Migration numbering conflict: if another PR adds 014 first, renumber to 015. Check before merging.\n- metrics_json already exists (from migration 001): we don't touch it. The new columns supplement it with queryable aggregates.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:51.311879Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:34:05.309761Z","closed_at":"2026-02-04T17:34:05.309714Z","close_reason":"Created migration 014 adding run_id TEXT, total_items_processed INTEGER, total_errors INTEGER to sync_runs, with idx_sync_runs_run_id index","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-apmo","depends_on_id":"bd-3pz","type":"parent-child","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-arka","title":"Extend SyncRunRecorder with surgical mode lifecycle methods","description":"## Background\nThe existing `SyncRunRecorder` in `src/core/sync_run.rs` manages sync run lifecycle with three methods: `start()` (creates row, returns Self), `succeed(self, ...)` (consumes self, sets succeeded), and `fail(self, ...)` (consumes self, sets failed). Both `succeed()` and `fail()` take ownership of `self` — this is intentional to prevent double-finalization.\n\nSurgical sync needs additional lifecycle methods to:\n1. Set surgical-specific metadata (mode, phase, IIDs JSON) after `start()`\n2. Record per-entity results (increment counters, store entity-level outcomes)\n3. Cancel a run (distinct from failure — user-initiated or timeout)\n4. Update phase progression during the surgical pipeline\n\nThese methods operate on the columns added by migration 027 (bead bd-tiux).\n\n## Approach\n\n### Step 1: Add `set_surgical_metadata` method\n\nCalled once after `start()` to set the surgical mode columns:\n\n```rust\npub fn set_surgical_metadata(\n &self,\n conn: &Connection,\n mode: &str,\n phase: &str,\n iids_json: &str,\n) -> Result<()> {\n conn.execute(\n \"UPDATE sync_runs SET mode = ?1, phase = ?2, surgical_iids_json = ?3 WHERE id = ?4\",\n rusqlite::params![mode, phase, iids_json, self.row_id],\n )?;\n Ok(())\n}\n```\n\nTakes `&self` (not `self`) because the recorder continues to be used after metadata is set.\n\n### Step 2: Add `update_phase` method\n\nCalled as the surgical pipeline progresses through phases:\n\n```rust\npub fn update_phase(&self, conn: &Connection, phase: &str) -> Result<()> {\n conn.execute(\n \"UPDATE sync_runs SET phase = ?1, heartbeat_at = ?2 WHERE id = ?3\",\n rusqlite::params![phase, now_ms(), self.row_id],\n )?;\n Ok(())\n}\n```\n\n### Step 3: Add `record_entity_result` method\n\nCalled after each entity (issue or MR) is processed to increment counters:\n\n```rust\npub fn record_entity_result(\n &self,\n conn: &Connection,\n entity_type: &str,\n stage: &str,\n) -> Result<()> {\n let column = match (entity_type, stage) {\n (\"issue\", \"fetched\") => \"issues_fetched\",\n (\"issue\", \"ingested\") => \"issues_ingested\",\n (\"mr\", \"fetched\") => \"mrs_fetched\",\n (\"mr\", \"ingested\") => \"mrs_ingested\",\n (\"issue\" | \"mr\", \"skipped_stale\") => \"skipped_stale\",\n (\"doc\", \"regenerated\") => \"docs_regenerated\",\n (\"doc\", \"embedded\") => \"docs_embedded\",\n (_, \"warning\") => \"warnings_count\",\n _ => return Ok(()), // Unknown combinations are silently ignored\n };\n conn.execute(\n &format!(\"UPDATE sync_runs SET {column} = {column} + 1 WHERE id = ?1\"),\n rusqlite::params![self.row_id],\n )?;\n Ok(())\n}\n```\n\nNote: The column name comes from a hardcoded match, NOT from user input — no SQL injection risk.\n\n### Step 4: Add `cancel` method\n\nConsumes self (like succeed/fail) to finalize the run as cancelled:\n\n```rust\npub fn cancel(self, conn: &Connection, reason: &str) -> Result<()> {\n let now = now_ms();\n conn.execute(\n \"UPDATE sync_runs SET finished_at = ?1, cancelled_at = ?2, status = 'cancelled', error = ?3 WHERE id = ?4\",\n rusqlite::params![now, now, reason, self.row_id],\n )?;\n Ok(())\n}\n```\n\nTakes `self` (ownership) like `succeed()` and `fail()` — prevents further use after cancellation.\n\n### Step 5: Expose `row_id` getter\n\nThe orchestrator (bd-1i4i) may need the row_id for logging/tracing:\n\n```rust\npub fn row_id(&self) -> i64 {\n self.row_id\n}\n```\n\n## Acceptance Criteria\n- [ ] `set_surgical_metadata(&self, conn, mode, phase, iids_json)` writes mode/phase/surgical_iids_json columns\n- [ ] `update_phase(&self, conn, phase)` updates phase and heartbeat_at\n- [ ] `record_entity_result(&self, conn, entity_type, stage)` increments the correct counter column\n- [ ] `record_entity_result` silently ignores unknown entity_type/stage combinations\n- [ ] `cancel(self, conn, reason)` consumes self, sets status='cancelled', finished_at, cancelled_at, error\n- [ ] `row_id()` returns the internal row_id\n- [ ] `succeed(self, ...)` still works after `set_surgical_metadata` + `record_entity_result` calls\n- [ ] `fail(self, ...)` still works after `set_surgical_metadata` + `update_phase` calls\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n- [ ] All existing sync_run tests continue to pass\n\n## Files\n- MODIFY: src/core/sync_run.rs (add methods to SyncRunRecorder impl block)\n- MODIFY: src/core/sync_run_tests.rs (add new tests)\n\n## TDD Anchor\nRED: Write tests in `src/core/sync_run_tests.rs`:\n\n```rust\n#[test]\nfn surgical_lifecycle_start_metadata_succeed() {\n let conn = setup_test_db();\n let recorder = SyncRunRecorder::start(&conn, \"sync\", \"surg001\").unwrap();\n let row_id = recorder.row_id();\n\n recorder.set_surgical_metadata(\n &conn, \"surgical\", \"preflight\", r#\"{\"issues\":[7,8],\"mrs\":[101]}\"#,\n ).unwrap();\n\n recorder.update_phase(&conn, \"ingest\").unwrap();\n recorder.record_entity_result(&conn, \"issue\", \"fetched\").unwrap();\n recorder.record_entity_result(&conn, \"issue\", \"fetched\").unwrap();\n recorder.record_entity_result(&conn, \"issue\", \"ingested\").unwrap();\n recorder.record_entity_result(&conn, \"mr\", \"fetched\").unwrap();\n recorder.record_entity_result(&conn, \"mr\", \"ingested\").unwrap();\n\n recorder.succeed(&conn, &[], 3, 0).unwrap();\n\n let (mode, phase, iids, issues_fetched, mrs_fetched, issues_ingested, mrs_ingested, status): (\n String, String, String, i64, i64, i64, i64, String,\n ) = conn.query_row(\n \"SELECT mode, phase, surgical_iids_json, issues_fetched, mrs_fetched, issues_ingested, mrs_ingested, status\n FROM sync_runs WHERE id = ?1\",\n [row_id],\n |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?)),\n ).unwrap();\n\n assert_eq!(mode, \"surgical\");\n assert_eq!(phase, \"ingest\"); // Last phase set before succeed\n assert!(iids.contains(\"101\"));\n assert_eq!(issues_fetched, 2);\n assert_eq!(mrs_fetched, 1);\n assert_eq!(issues_ingested, 1);\n assert_eq!(mrs_ingested, 1);\n assert_eq!(status, \"succeeded\");\n}\n\n#[test]\nfn surgical_lifecycle_cancel() {\n let conn = setup_test_db();\n let recorder = SyncRunRecorder::start(&conn, \"sync\", \"cancel01\").unwrap();\n let row_id = recorder.row_id();\n\n recorder.set_surgical_metadata(&conn, \"surgical\", \"preflight\", \"{}\").unwrap();\n recorder.cancel(&conn, \"User requested cancellation\").unwrap();\n\n let (status, error, cancelled_at, finished_at): (String, Option, Option, Option) = conn.query_row(\n \"SELECT status, error, cancelled_at, finished_at FROM sync_runs WHERE id = ?1\",\n [row_id],\n |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),\n ).unwrap();\n\n assert_eq!(status, \"cancelled\");\n assert_eq!(error.as_deref(), Some(\"User requested cancellation\"));\n assert!(cancelled_at.is_some());\n assert!(finished_at.is_some());\n}\n\n#[test]\nfn record_entity_result_ignores_unknown() {\n let conn = setup_test_db();\n let recorder = SyncRunRecorder::start(&conn, \"sync\", \"unk001\").unwrap();\n // Should not panic or error on unknown combinations\n recorder.record_entity_result(&conn, \"widget\", \"exploded\").unwrap();\n}\n\n#[test]\nfn record_entity_result_json_counters() {\n let conn = setup_test_db();\n let recorder = SyncRunRecorder::start(&conn, \"sync\", \"cnt001\").unwrap();\n let row_id = recorder.row_id();\n\n recorder.record_entity_result(&conn, \"doc\", \"regenerated\").unwrap();\n recorder.record_entity_result(&conn, \"doc\", \"regenerated\").unwrap();\n recorder.record_entity_result(&conn, \"doc\", \"embedded\").unwrap();\n recorder.record_entity_result(&conn, \"issue\", \"skipped_stale\").unwrap();\n\n let (docs_regen, docs_embed, skipped): (i64, i64, i64) = conn.query_row(\n \"SELECT docs_regenerated, docs_embedded, skipped_stale FROM sync_runs WHERE id = ?1\",\n [row_id],\n |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),\n ).unwrap();\n\n assert_eq!(docs_regen, 2);\n assert_eq!(docs_embed, 1);\n assert_eq!(skipped, 1);\n}\n```\n\nGREEN: Add all methods to `SyncRunRecorder`.\nVERIFY: `cargo test surgical_lifecycle && cargo test record_entity_result`\n\n## Edge Cases\n- `succeed()` and `fail()` consume `self` — the compiler enforces that no methods are called after finalization. `cancel()` also consumes self for the same reason.\n- `set_surgical_metadata`, `update_phase`, and `record_entity_result` take `&self` — they can be called multiple times before finalization.\n- The `record_entity_result` match uses a hardcoded column name derived from known string constants, not user input. The `format!` is safe because the column name is always one of the hardcoded strings.\n- `record_entity_result` silently returns Ok(()) for unknown entity_type/stage combos rather than erroring — this avoids breaking the pipeline for non-critical telemetry.\n- Phase is NOT overwritten by `succeed()`/`fail()`/`cancel()` — the last phase set via `update_phase()` is preserved as the \"phase at completion\" for observability.\n\n## Dependency Context\nDepends on bd-tiux (migration 027) for the surgical columns to exist. Downstream beads bd-1i4i (orchestrator) and bd-3jqx (integration tests) use these methods.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-17T19:13:50.827946Z","created_by":"tayloreernisse","updated_at":"2026-02-17T20:04:15.562997Z","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-arka","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-arka","depends_on_id":"bd-3jqx","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} diff --git a/crates/lore-tui/src/scope.rs b/crates/lore-tui/src/scope.rs index a9fa05d..16143ab 100644 --- a/crates/lore-tui/src/scope.rs +++ b/crates/lore-tui/src/scope.rs @@ -40,6 +40,13 @@ pub struct ProjectInfo { /// ``` #[must_use] pub fn scope_filter_sql(project_id: Option, table_alias: &str) -> String { + debug_assert!( + !table_alias.is_empty() + && table_alias + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_'), + "table_alias must be a valid SQL identifier, got: {table_alias:?}" + ); match project_id { Some(id) => format!(" AND {table_alias}.project_id = {id}"), None => String::new(), diff --git a/crates/lore-tui/src/view/doctor.rs b/crates/lore-tui/src/view/doctor.rs index f0cd5fb..274201f 100644 --- a/crates/lore-tui/src/view/doctor.rs +++ b/crates/lore-tui/src/view/doctor.rs @@ -8,6 +8,7 @@ use ftui::render::cell::{Cell, PackedRgba}; use ftui::render::drawing::Draw; use ftui::render::frame::Frame; +use crate::layout::classify_width; use crate::state::doctor::{DoctorState, HealthStatus}; use super::{TEXT, TEXT_MUTED}; @@ -83,9 +84,14 @@ pub fn render_doctor(frame: &mut Frame<'_>, state: &DoctorState, area: Rect) { max_x, ); - // Health check rows. + // Health check rows — name column adapts to breakpoint. + let bp = classify_width(area.width); let rows_start_y = area.y + 4; - let name_width = 16u16; + let name_width = match bp { + ftui::layout::Breakpoint::Xs => 10u16, + ftui::layout::Breakpoint::Sm => 13, + _ => 16, + }; for (i, check) in state.checks.iter().enumerate() { let y = rows_start_y + i as u16; diff --git a/crates/lore-tui/src/view/file_history.rs b/crates/lore-tui/src/view/file_history.rs index 525489a..fdee669 100644 --- a/crates/lore-tui/src/view/file_history.rs +++ b/crates/lore-tui/src/view/file_history.rs @@ -17,22 +17,21 @@ //! +-----------------------------------+ //! ``` +use ftui::layout::Breakpoint; use ftui::render::cell::{Cell, PackedRgba}; use ftui::render::drawing::Draw; use ftui::render::frame::Frame; use super::common::truncate_str; +use super::{ACCENT, BG_SURFACE, TEXT, TEXT_MUTED}; +use crate::layout::classify_width; use crate::state::file_history::{FileHistoryResult, FileHistoryState}; use crate::text_width::cursor_cell_offset; // --------------------------------------------------------------------------- -// Colors (Flexoki palette) +// Colors (Flexoki palette — screen-specific) // --------------------------------------------------------------------------- -const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx -const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2 -const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2 -const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // yellow @@ -53,6 +52,7 @@ pub fn render_file_history( return; // Terminal too small. } + let bp = classify_width(area.width); let x = area.x; let max_x = area.right(); let width = area.width; @@ -105,7 +105,7 @@ pub fn render_file_history( } // --- MR list --- - render_mr_list(frame, result, state, x, y, width, list_height); + render_mr_list(frame, result, state, x, y, width, list_height, bp); // --- Hint bar --- render_hint_bar(frame, x, hint_y, max_x); @@ -248,6 +248,33 @@ fn render_summary(frame: &mut Frame<'_>, result: &FileHistoryResult, x: u16, y: frame.print_text_clipped(x + 1, y, &summary, style, max_x); } +/// Responsive truncation widths for file history MR rows. +const fn fh_title_max(bp: Breakpoint) -> usize { + match bp { + Breakpoint::Xs => 15, + Breakpoint::Sm => 25, + Breakpoint::Md => 35, + Breakpoint::Lg | Breakpoint::Xl => 55, + } +} + +const fn fh_author_max(bp: Breakpoint) -> usize { + match bp { + Breakpoint::Xs | Breakpoint::Sm => 8, + Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 12, + } +} + +const fn fh_disc_max(bp: Breakpoint) -> usize { + match bp { + Breakpoint::Xs => 25, + Breakpoint::Sm => 40, + Breakpoint::Md => 60, + Breakpoint::Lg | Breakpoint::Xl => 80, + } +} + +#[allow(clippy::too_many_arguments)] fn render_mr_list( frame: &mut Frame<'_>, result: &FileHistoryResult, @@ -256,10 +283,14 @@ fn render_mr_list( start_y: u16, width: u16, height: usize, + bp: Breakpoint, ) { let max_x = x + width; let offset = state.scroll_offset as usize; + let title_max = fh_title_max(bp); + let author_max = fh_author_max(bp); + for (i, mr) in result .merge_requests .iter() @@ -315,8 +346,8 @@ fn render_mr_list( }; let after_iid = frame.print_text_clipped(after_icon, y, &iid_str, ref_style, max_x); - // Title (truncated). - let title = truncate_str(&mr.title, 35); + // Title (responsive truncation). + let title = truncate_str(&mr.title, title_max); let title_style = Cell { fg: TEXT, bg: sel_bg, @@ -324,10 +355,10 @@ fn render_mr_list( }; let after_title = frame.print_text_clipped(after_iid + 1, y, &title, title_style, max_x); - // @author + change_type + // @author + change_type (responsive author width). let meta = format!( "@{} {}", - truncate_str(&mr.author_username, 12), + truncate_str(&mr.author_username, author_max), mr.change_type ); let meta_style = Cell { @@ -339,13 +370,11 @@ fn render_mr_list( } // Inline discussion snippets (rendered beneath MRs when toggled on). - // For simplicity, discussions are shown as a separate block after the MR list - // in this initial implementation. Full inline rendering (grouped by MR) is - // a follow-up enhancement. if state.show_discussions && !result.discussions.is_empty() { - let disc_start_y = start_y + result.merge_requests.len().min(height) as u16; - let remaining = height.saturating_sub(result.merge_requests.len().min(height)); - render_discussions(frame, result, x, disc_start_y, max_x, remaining); + let visible_mrs = result.merge_requests.len().saturating_sub(offset).min(height); + let disc_start_y = start_y + visible_mrs as u16; + let remaining = height.saturating_sub(visible_mrs); + render_discussions(frame, result, x, disc_start_y, max_x, remaining, bp); } } @@ -356,11 +385,14 @@ fn render_discussions( start_y: u16, max_x: u16, max_rows: usize, + bp: Breakpoint, ) { if max_rows == 0 { return; } + let disc_max = fh_disc_max(bp); + let sep_style = Cell { fg: TEXT_MUTED, ..Cell::default() @@ -390,7 +422,7 @@ fn render_discussions( author_style, max_x, ); - let snippet = truncate_str(&disc.body_snippet, 60); + let snippet = truncate_str(&disc.body_snippet, disc_max); frame.print_text_clipped(after_author, y, &snippet, disc_style, max_x); } } diff --git a/crates/lore-tui/src/view/issue_detail.rs b/crates/lore-tui/src/view/issue_detail.rs index 0de71ee..ed9d859 100644 --- a/crates/lore-tui/src/view/issue_detail.rs +++ b/crates/lore-tui/src/view/issue_detail.rs @@ -13,6 +13,9 @@ use ftui::render::drawing::Draw; use ftui::render::frame::Frame; use crate::clock::Clock; +use ftui::layout::Breakpoint; + +use crate::layout::{classify_width, detail_side_panel}; use crate::safety::{UrlPolicy, sanitize_for_terminal}; use crate::state::issue_detail::{DetailSection, IssueDetailState, IssueMetadata}; use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs}; @@ -99,6 +102,7 @@ pub fn render_issue_detail( return; }; + let bp = classify_width(area.width); let max_x = area.x.saturating_add(area.width); let mut y = area.y; @@ -106,10 +110,12 @@ pub fn render_issue_detail( y = render_title_bar(frame, meta, area.x, y, max_x); // --- Metadata row --- - y = render_metadata_row(frame, meta, area.x, y, max_x); + y = render_metadata_row(frame, meta, bp, area.x, y, max_x); - // --- Optional milestone / due date row --- - if meta.milestone.is_some() || meta.due_date.is_some() { + // --- Optional milestone / due date row (skip on Xs — too narrow) --- + if !matches!(bp, Breakpoint::Xs) + && (meta.milestone.is_some() || meta.due_date.is_some()) + { y = render_milestone_row(frame, meta, area.x, y, max_x); } @@ -129,7 +135,8 @@ pub fn render_issue_detail( let disc_count = state.discussions.len(); let xref_count = state.cross_refs.len(); - let (desc_h, disc_h, xref_h) = allocate_sections(remaining, desc_lines, disc_count, xref_count); + let wide = detail_side_panel(bp); + let (desc_h, disc_h, xref_h) = allocate_sections(remaining, desc_lines, disc_count, xref_count, wide); // --- Description section --- if desc_h > 0 { @@ -263,9 +270,12 @@ fn render_title_bar( } /// Render the metadata row: `opened | alice | backend, security` +/// +/// Responsive: Xs shows state + author only; Sm adds labels; Md+ adds assignees. fn render_metadata_row( frame: &mut Frame<'_>, meta: &IssueMetadata, + bp: Breakpoint, x: u16, y: u16, max_x: u16, @@ -292,13 +302,15 @@ fn render_metadata_row( cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x); cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x); - if !meta.labels.is_empty() { + // Labels: shown on Sm+ + if !matches!(bp, Breakpoint::Xs) && !meta.labels.is_empty() { cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x); let labels_text = meta.labels.join(", "); cx = frame.print_text_clipped(cx, y, &labels_text, muted_style, max_x); } - if !meta.assignees.is_empty() { + // Assignees: shown on Md+ + if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) && !meta.assignees.is_empty() { cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x); let assignees_text = format!("-> {}", meta.assignees.join(", ")); let _ = frame.print_text_clipped(cx, y, &assignees_text, muted_style, max_x); @@ -424,11 +436,13 @@ fn count_description_lines(meta: &IssueMetadata, _width: u16) -> usize { /// /// Priority: description gets min(content, 40%), discussions get most of the /// remaining space, cross-refs get a fixed portion at the bottom. +/// On wide terminals (`wide = true`), description gets up to 60%. fn allocate_sections( available: u16, desc_lines: usize, _disc_count: usize, xref_count: usize, + wide: bool, ) -> (u16, u16, u16) { if available == 0 { return (0, 0, 0); @@ -445,8 +459,9 @@ fn allocate_sections( let after_xref = total.saturating_sub(xref_need); - // Description: up to 40% of remaining, but at least the content lines. - let desc_max = after_xref * 2 / 5; + // Description: up to 40% on narrow, 60% on wide terminals. + let desc_pct = if wide { 3 } else { 2 }; // numerator over 5 + let desc_max = after_xref * desc_pct / 5; let desc_alloc = desc_lines.min(desc_max).min(after_xref); // Discussions: everything else. @@ -584,12 +599,12 @@ mod tests { #[test] fn test_allocate_sections_empty() { - assert_eq!(allocate_sections(0, 5, 3, 2), (0, 0, 0)); + assert_eq!(allocate_sections(0, 5, 3, 2, false), (0, 0, 0)); } #[test] fn test_allocate_sections_balanced() { - let (d, disc, x) = allocate_sections(20, 5, 3, 2); + let (d, disc, x) = allocate_sections(20, 5, 3, 2, false); assert!(d > 0); assert!(disc > 0); assert!(x > 0); @@ -598,18 +613,25 @@ mod tests { #[test] fn test_allocate_sections_no_xrefs() { - let (d, disc, x) = allocate_sections(20, 5, 3, 0); + let (d, disc, x) = allocate_sections(20, 5, 3, 0, false); assert_eq!(x, 0); assert_eq!(d + disc, 20); } #[test] fn test_allocate_sections_no_discussions() { - let (d, disc, x) = allocate_sections(20, 5, 0, 2); + let (d, disc, x) = allocate_sections(20, 5, 0, 2, false); assert!(d > 0); assert_eq!(d + disc + x, 20); } + #[test] + fn test_allocate_sections_wide_gives_more_description() { + let (d_narrow, _, _) = allocate_sections(20, 10, 3, 2, false); + let (d_wide, _, _) = allocate_sections(20, 10, 3, 2, true); + assert!(d_wide >= d_narrow, "wide should give desc at least as much space"); + } + #[test] fn test_count_description_lines() { let meta = sample_metadata(); @@ -623,4 +645,27 @@ mod tests { meta.description = String::new(); assert_eq!(count_description_lines(&meta, 80), 0); } + + #[test] + fn test_render_issue_detail_responsive_breakpoints() { + let clock = FakeClock::from_ms(1_700_000_060_000); + + // Narrow (Xs=50): milestone row hidden, labels/assignees hidden. + with_frame!(50, 24, |frame| { + let state = sample_state_with_metadata(); + render_issue_detail(&mut frame, &state, Rect::new(0, 0, 50, 24), &clock); + }); + + // Medium (Sm=70): milestone shown, labels shown, assignees hidden. + with_frame!(70, 24, |frame| { + let state = sample_state_with_metadata(); + render_issue_detail(&mut frame, &state, Rect::new(0, 0, 70, 24), &clock); + }); + + // Wide (Lg=130): all metadata, description gets more space. + with_frame!(130, 40, |frame| { + let state = sample_state_with_metadata(); + render_issue_detail(&mut frame, &state, Rect::new(0, 0, 130, 40), &clock); + }); + } } diff --git a/crates/lore-tui/src/view/mr_detail.rs b/crates/lore-tui/src/view/mr_detail.rs index 64c8b0d..46d1dfb 100644 --- a/crates/lore-tui/src/view/mr_detail.rs +++ b/crates/lore-tui/src/view/mr_detail.rs @@ -7,11 +7,13 @@ //! changes render immediately while discussions load async. use ftui::core::geometry::Rect; +use ftui::layout::Breakpoint; use ftui::render::cell::{Cell, PackedRgba}; use ftui::render::drawing::Draw; use ftui::render::frame::Frame; use crate::clock::Clock; +use crate::layout::classify_width; use crate::safety::{UrlPolicy, sanitize_for_terminal}; use crate::state::mr_detail::{FileChangeType, MrDetailState, MrMetadata, MrTab}; use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs}; @@ -85,6 +87,7 @@ pub fn render_mr_detail( return; } + let bp = classify_width(area.width); let Some(ref meta) = state.metadata else { return; }; @@ -96,7 +99,7 @@ pub fn render_mr_detail( y = render_title_bar(frame, meta, area.x, y, max_x); // --- Metadata row --- - y = render_metadata_row(frame, meta, area.x, y, max_x); + y = render_metadata_row(frame, meta, area.x, y, max_x, bp); // --- Tab bar --- y = render_tab_bar(frame, state, area.x, y, max_x); @@ -150,12 +153,16 @@ fn render_title_bar(frame: &mut Frame<'_>, meta: &MrMetadata, x: u16, y: u16, ma } /// Render `opened | alice | fix-auth -> main | mergeable`. +/// +/// On narrow terminals (Xs/Sm), branch names and merge status are hidden +/// to avoid truncating more critical information. fn render_metadata_row( frame: &mut Frame<'_>, meta: &MrMetadata, x: u16, y: u16, max_x: u16, + bp: Breakpoint, ) -> u16 { let state_fg = match meta.state.as_str() { "opened" => GREEN, @@ -179,12 +186,16 @@ fn render_metadata_row( let mut cx = frame.print_text_clipped(x, y, &meta.state, state_style, max_x); cx = frame.print_text_clipped(cx, y, " | ", muted, max_x); cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x); - cx = frame.print_text_clipped(cx, y, " | ", muted, max_x); - let branch_text = format!("{} -> {}", meta.source_branch, meta.target_branch); - cx = frame.print_text_clipped(cx, y, &branch_text, muted, max_x); + // Branch names: hidden on Xs/Sm to save horizontal space. + if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) { + cx = frame.print_text_clipped(cx, y, " | ", muted, max_x); + let branch_text = format!("{} -> {}", meta.source_branch, meta.target_branch); + cx = frame.print_text_clipped(cx, y, &branch_text, muted, max_x); + } - if !meta.merge_status.is_empty() { + // Merge status: hidden on Xs/Sm. + if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) && !meta.merge_status.is_empty() { cx = frame.print_text_clipped(cx, y, " | ", muted, max_x); let status_fg = if meta.merge_status == "mergeable" { GREEN @@ -636,4 +647,21 @@ mod tests { render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock); }); } + + #[test] + fn test_render_mr_detail_responsive_breakpoints() { + let clock = FakeClock::from_ms(1_700_000_060_000); + + // Narrow (Xs=50): branches and merge status hidden. + with_frame!(50, 24, |frame| { + let state = sample_mr_state(); + render_mr_detail(&mut frame, &state, Rect::new(0, 0, 50, 24), &clock); + }); + + // Medium (Md=100): all metadata shown. + with_frame!(100, 24, |frame| { + let state = sample_mr_state(); + render_mr_detail(&mut frame, &state, Rect::new(0, 0, 100, 24), &clock); + }); + } } diff --git a/crates/lore-tui/src/view/search.rs b/crates/lore-tui/src/view/search.rs index 851d562..d5032dd 100644 --- a/crates/lore-tui/src/view/search.rs +++ b/crates/lore-tui/src/view/search.rs @@ -20,6 +20,7 @@ use ftui::render::cell::Cell; use ftui::render::drawing::Draw; use ftui::render::frame::Frame; +use crate::layout::{classify_width, search_show_project}; use crate::message::EntityKind; use crate::state::search::SearchState; use crate::text_width::cursor_cell_offset; @@ -39,6 +40,8 @@ pub fn render_search(frame: &mut Frame<'_>, state: &SearchState, area: Rect) { return; } + let bp = classify_width(area.width); + let show_project = search_show_project(bp); let mut y = area.y; let max_x = area.right(); @@ -99,7 +102,7 @@ pub fn render_search(frame: &mut Frame<'_>, state: &SearchState, area: Rect) { if state.results.is_empty() { render_empty_state(frame, state, area.x + 1, y, max_x); } else { - render_result_list(frame, state, area.x, y, area.width, list_height); + render_result_list(frame, state, area.x, y, area.width, list_height, show_project); } // -- Bottom hint bar ----------------------------------------------------- @@ -228,6 +231,7 @@ fn render_result_list( start_y: u16, width: u16, list_height: usize, + show_project: bool, ) { let max_x = x + width; @@ -294,11 +298,13 @@ fn render_result_list( let after_title = frame.print_text_clipped(after_iid + 1, y, &result.title, label_style, max_x); - // Project path (right-aligned). - let path_width = result.project_path.len() as u16 + 2; - let path_x = max_x.saturating_sub(path_width); - if path_x > after_title + 1 { - frame.print_text_clipped(path_x, y, &result.project_path, detail_style, max_x); + // Project path (right-aligned, hidden on narrow terminals). + if show_project { + let path_width = result.project_path.len() as u16 + 2; + let path_x = max_x.saturating_sub(path_width); + if path_x > after_title + 1 { + frame.print_text_clipped(path_x, y, &result.project_path, detail_style, max_x); + } } } @@ -476,4 +482,23 @@ mod tests { render_search(&mut frame, &state, Rect::new(0, 0, 80, 10)); }); } + + #[test] + fn test_render_search_responsive_breakpoints() { + // Narrow (Xs=50): project path hidden. + with_frame!(50, 24, |frame| { + let mut state = SearchState::default(); + state.enter(fts_caps()); + state.results = sample_results(3); + render_search(&mut frame, &state, Rect::new(0, 0, 50, 24)); + }); + + // Standard (Md=100): project path shown. + with_frame!(100, 24, |frame| { + let mut state = SearchState::default(); + state.enter(fts_caps()); + state.results = sample_results(3); + render_search(&mut frame, &state, Rect::new(0, 0, 100, 24)); + }); + } } diff --git a/crates/lore-tui/src/view/stats.rs b/crates/lore-tui/src/view/stats.rs index 66c5642..77e0a4a 100644 --- a/crates/lore-tui/src/view/stats.rs +++ b/crates/lore-tui/src/view/stats.rs @@ -8,6 +8,7 @@ use ftui::render::cell::{Cell, PackedRgba}; use ftui::render::drawing::Draw; use ftui::render::frame::Frame; +use crate::layout::classify_width; use crate::state::stats::StatsState; use super::{ACCENT, TEXT, TEXT_MUTED}; @@ -63,8 +64,13 @@ pub fn render_stats(frame: &mut Frame<'_>, state: &StatsState, area: Rect) { max_x, ); + let bp = classify_width(area.width); let mut y = area.y + 3; - let label_width = 22u16; + let label_width = match bp { + ftui::layout::Breakpoint::Xs => 16u16, + ftui::layout::Breakpoint::Sm => 18, + _ => 22, + }; let value_x = area.x + 2 + label_width; // --- Entity Counts section --- diff --git a/crates/lore-tui/src/view/sync.rs b/crates/lore-tui/src/view/sync.rs index d6c7586..1c2cc85 100644 --- a/crates/lore-tui/src/view/sync.rs +++ b/crates/lore-tui/src/view/sync.rs @@ -11,6 +11,7 @@ use ftui::render::cell::{Cell, PackedRgba}; use ftui::render::drawing::Draw; use ftui::render::frame::Frame; +use crate::layout::{classify_width, sync_progress_bar_width}; use crate::state::sync::{SyncLane, SyncPhase, SyncState}; use super::{ACCENT, TEXT, TEXT_MUTED}; @@ -109,10 +110,15 @@ fn render_running(frame: &mut Frame<'_>, state: &SyncState, area: Rect) { } // Per-lane progress bars. + let bp = classify_width(area.width); + let max_bar = sync_progress_bar_width(bp); let bar_start_y = area.y + 4; let label_width = 14u16; // "Discussions " is the longest let bar_x = area.x + 2 + label_width; - let bar_width = area.width.saturating_sub(4 + label_width + 12); // 12 for count text + let bar_width = area + .width + .saturating_sub(4 + label_width + 12) + .min(max_bar); // Cap bar width for very wide terminals for (i, lane) in SyncLane::ALL.iter().enumerate() { let y = bar_start_y + i as u16; diff --git a/crates/lore-tui/src/view/timeline.rs b/crates/lore-tui/src/view/timeline.rs index 5ec0a5c..1d91517 100644 --- a/crates/lore-tui/src/view/timeline.rs +++ b/crates/lore-tui/src/view/timeline.rs @@ -20,6 +20,7 @@ use ftui::render::drawing::Draw; use ftui::render::frame::Frame; use crate::clock::Clock; +use crate::layout::{classify_width, timeline_time_width}; use crate::message::TimelineEventKind; use crate::state::timeline::TimelineState; use crate::view::common::discussion_tree::format_relative_time; @@ -121,7 +122,9 @@ pub fn render_timeline( if state.events.is_empty() { render_empty_state(frame, state, area.x + 1, y, max_x); } else { - render_event_list(frame, state, area.x, y, area.width, list_height, clock); + let bp = classify_width(area.width); + let time_col_width = timeline_time_width(bp); + render_event_list(frame, state, area.x, y, area.width, list_height, clock, time_col_width); } // -- Hint bar -- @@ -153,6 +156,7 @@ fn render_empty_state(frame: &mut Frame<'_>, state: &TimelineState, x: u16, y: u // --------------------------------------------------------------------------- /// Render the scrollable list of timeline events. +#[allow(clippy::too_many_arguments)] fn render_event_list( frame: &mut Frame<'_>, state: &TimelineState, @@ -161,6 +165,7 @@ fn render_event_list( width: u16, list_height: usize, clock: &dyn Clock, + time_col_width: u16, ) { let max_x = x + width; @@ -198,10 +203,9 @@ fn render_event_list( let mut cx = x + 1; - // Timestamp gutter (right-aligned in ~10 chars). + // Timestamp gutter (right-aligned, width varies by breakpoint). let time_str = format_relative_time(event.timestamp_ms, clock); - let time_width = 10u16; - let time_x = cx + time_width.saturating_sub(time_str.len() as u16); + let time_x = cx + time_col_width.saturating_sub(time_str.len() as u16); let time_cell = if is_selected { selected_cell } else { @@ -211,8 +215,8 @@ fn render_event_list( ..Cell::default() } }; - frame.print_text_clipped(time_x, y, &time_str, time_cell, cx + time_width); - cx += time_width + 1; + frame.print_text_clipped(time_x, y, &time_str, time_cell, cx + time_col_width); + cx += time_col_width + 1; // Entity prefix: #42 or !99 let prefix = match event.entity_key.kind { diff --git a/crates/lore-tui/src/view/trace.rs b/crates/lore-tui/src/view/trace.rs index fe26dd4..03b7cc7 100644 --- a/crates/lore-tui/src/view/trace.rs +++ b/crates/lore-tui/src/view/trace.rs @@ -23,6 +23,9 @@ use ftui::render::cell::{Cell, PackedRgba}; use ftui::render::drawing::Draw; use ftui::render::frame::Frame; +use ftui::layout::Breakpoint; + +use crate::layout::classify_width; use crate::state::trace::TraceState; use crate::text_width::cursor_cell_offset; use lore::core::trace::TraceResult; @@ -51,6 +54,7 @@ pub fn render_trace(frame: &mut Frame<'_>, state: &TraceState, area: ftui::core: return; } + let bp = classify_width(area.width); let x = area.x; let max_x = area.right(); let width = area.width; @@ -103,7 +107,7 @@ pub fn render_trace(frame: &mut Frame<'_>, state: &TraceState, area: ftui::core: } // --- Chain list --- - render_chain_list(frame, result, state, x, y, width, list_height); + render_chain_list(frame, result, state, x, y, width, list_height, bp); // --- Hint bar --- render_hint_bar(frame, x, hint_y, max_x); @@ -227,6 +231,42 @@ fn render_summary(frame: &mut Frame<'_>, result: &TraceResult, x: u16, y: u16, m frame.print_text_clipped(x + 1, y, &summary, style, max_x); } +/// Responsive truncation widths for trace chain rows. +const fn chain_title_max(bp: Breakpoint) -> usize { + match bp { + Breakpoint::Xs => 15, + Breakpoint::Sm => 22, + Breakpoint::Md => 30, + Breakpoint::Lg | Breakpoint::Xl => 50, + } +} + +const fn chain_author_max(bp: Breakpoint) -> usize { + match bp { + Breakpoint::Xs | Breakpoint::Sm => 8, + Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 12, + } +} + +const fn expanded_issue_title_max(bp: Breakpoint) -> usize { + match bp { + Breakpoint::Xs => 20, + Breakpoint::Sm => 30, + Breakpoint::Md => 40, + Breakpoint::Lg | Breakpoint::Xl => 60, + } +} + +const fn expanded_disc_snippet_max(bp: Breakpoint) -> usize { + match bp { + Breakpoint::Xs => 25, + Breakpoint::Sm => 40, + Breakpoint::Md => 60, + Breakpoint::Lg | Breakpoint::Xl => 80, + } +} + +#[allow(clippy::too_many_arguments)] fn render_chain_list( frame: &mut Frame<'_>, result: &TraceResult, @@ -235,10 +275,16 @@ fn render_chain_list( start_y: u16, width: u16, height: usize, + bp: Breakpoint, ) { let max_x = x + width; let mut row = 0; + let title_max = chain_title_max(bp); + let author_max = chain_author_max(bp); + let issue_title_max = expanded_issue_title_max(bp); + let disc_max = expanded_disc_snippet_max(bp); + for (chain_idx, chain) in result.trace_chains.iter().enumerate() { if row >= height { break; @@ -294,8 +340,8 @@ fn render_chain_list( }; let after_iid = frame.print_text_clipped(after_icon, y, &iid_str, ref_style, max_x); - // Title. - let title = truncate_str(&chain.mr_title, 30); + // Title (responsive). + let title = truncate_str(&chain.mr_title, title_max); let title_style = Cell { fg: TEXT, bg: sel_bg, @@ -303,10 +349,10 @@ fn render_chain_list( }; let after_title = frame.print_text_clipped(after_iid + 1, y, &title, title_style, max_x); - // @author + change_type + // @author + change_type (responsive author width). let meta = format!( "@{} {}", - truncate_str(&chain.mr_author, 12), + truncate_str(&chain.mr_author, author_max), chain.change_type ); let meta_style = Cell { @@ -338,10 +384,6 @@ fn render_chain_list( _ => TEXT_MUTED, }; - let indent_style = Cell { - fg: TEXT_MUTED, - ..Cell::default() - }; let after_indent = frame.print_text_clipped( x + 4, iy, @@ -361,8 +403,7 @@ fn render_chain_list( let after_ref = frame.print_text_clipped(after_indent, iy, &issue_ref, issue_ref_style, max_x); - let issue_title = truncate_str(&issue.title, 40); - let _ = indent_style; // suppress unused + let issue_title = truncate_str(&issue.title, issue_title_max); frame.print_text_clipped( after_ref, iy, @@ -384,7 +425,7 @@ fn render_chain_list( } let dy = start_y + row as u16; - let author = format!("@{}: ", truncate_str(&disc.author_username, 12)); + let author = format!("@{}: ", truncate_str(&disc.author_username, author_max)); let author_style = Cell { fg: CYAN, ..Cell::default() @@ -392,7 +433,7 @@ fn render_chain_list( let after_author = frame.print_text_clipped(x + 4, dy, &author, author_style, max_x); - let snippet = truncate_str(&disc.body, 60); + let snippet = truncate_str(&disc.body, disc_max); let snippet_style = Cell { fg: TEXT_MUTED, ..Cell::default() diff --git a/crates/lore-tui/src/view/who.rs b/crates/lore-tui/src/view/who.rs index d6e5bc6..852fcbf 100644 --- a/crates/lore-tui/src/view/who.rs +++ b/crates/lore-tui/src/view/who.rs @@ -23,7 +23,9 @@ use lore::core::who_types::{ ActiveResult, ExpertResult, OverlapResult, ReviewsResult, WhoResult, WorkloadResult, }; +use crate::layout::{classify_width, who_abbreviated_tabs}; use crate::state::who::{WhoMode, WhoState}; +use crate::text_width::cursor_cell_offset; use super::common::truncate_str; use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED}; @@ -51,7 +53,9 @@ pub fn render_who(frame: &mut Frame<'_>, state: &WhoState, area: Rect) { let max_x = area.right(); // -- Mode tabs ----------------------------------------------------------- - y = render_mode_tabs(frame, state.mode, area.x, y, area.width, max_x); + let bp = classify_width(area.width); + let abbreviated = who_abbreviated_tabs(bp); + y = render_mode_tabs(frame, state.mode, area.x, y, area.width, max_x, abbreviated); // -- Input bar ----------------------------------------------------------- if state.mode.needs_path() || state.mode.needs_username() { @@ -116,15 +120,21 @@ fn render_mode_tabs( y: u16, _width: u16, max_x: u16, + abbreviated: bool, ) -> u16 { let mut cursor_x = x; for mode in WhoMode::ALL { let is_active = mode == current; - let label = if is_active { - format!("[ {} ]", mode.label()) + let name = if abbreviated { + mode.short_label() } else { - format!(" {} ", mode.label()) + mode.label() + }; + let label = if is_active { + format!("[ {name} ]") + } else { + format!(" {name} ") }; let cell = Cell { @@ -193,28 +203,37 @@ fn render_input_bar( frame.print_text_clipped(after_prompt, y, display_text, text_cell, max_x); // Cursor rendering when focused. - if focused && !text.is_empty() { - let cursor_pos = if state.mode.needs_path() { - state.path_cursor - } else { - state.username_cursor + if focused { + let cursor_cell = Cell { + fg: BG_SURFACE, + bg: TEXT, + ..Cell::default() }; - let cursor_col = text[..cursor_pos.min(text.len())] - .chars() - .count() - .min(u16::MAX as usize) as u16; - let cursor_x = after_prompt + cursor_col; - if cursor_x < max_x { - let cursor_cell = Cell { - fg: BG_SURFACE, - bg: TEXT, - ..Cell::default() + if text.is_empty() { + // Show cursor at input start when empty. + if after_prompt < max_x { + frame.print_text_clipped(after_prompt, y, " ", cursor_cell, max_x); + } + } else { + let cursor_pos = if state.mode.needs_path() { + state.path_cursor + } else { + state.username_cursor }; - let cursor_char = text - .get(cursor_pos..) - .and_then(|s| s.chars().next()) - .unwrap_or(' '); - frame.print_text_clipped(cursor_x, y, &cursor_char.to_string(), cursor_cell, max_x); + let cursor_x = after_prompt + cursor_cell_offset(text, cursor_pos); + if cursor_x < max_x { + let cursor_char = text + .get(cursor_pos..) + .and_then(|s| s.chars().next()) + .unwrap_or(' '); + frame.print_text_clipped( + cursor_x, + y, + &cursor_char.to_string(), + cursor_cell, + max_x, + ); + } } } @@ -1033,4 +1052,25 @@ mod tests { }); } } + + #[test] + fn test_render_who_responsive_breakpoints() { + // Narrow (Xs=50): abbreviated tabs should fit. + with_frame!(50, 24, |frame| { + let state = WhoState::default(); + render_who(&mut frame, &state, Rect::new(0, 0, 50, 24)); + }); + + // Medium (Md=90): full tab labels. + with_frame!(90, 24, |frame| { + let state = WhoState::default(); + render_who(&mut frame, &state, Rect::new(0, 0, 90, 24)); + }); + + // Wide (Lg=130): full tab labels, more room. + with_frame!(130, 24, |frame| { + let state = WhoState::default(); + render_who(&mut frame, &state, Rect::new(0, 0, 130, 24)); + }); + } } diff --git a/src/main.rs b/src/main.rs index c4ffc72..afe1704 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,21 +10,22 @@ use lore::Config; use lore::cli::autocorrect::{self, CorrectionResult}; use lore::cli::commands::{ IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters, - NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser, - open_mr_in_browser, parse_trace_path, print_count, print_count_json, print_doctor_results, - print_drift_human, print_drift_json, print_dry_run_preview, print_dry_run_preview_json, - print_embed, print_embed_json, print_event_count, print_event_count_json, print_file_history, - print_file_history_json, print_generate_docs, print_generate_docs_json, print_ingest_summary, - print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs, - print_list_mrs_json, print_list_notes, print_list_notes_csv, print_list_notes_json, - print_list_notes_jsonl, print_search_results, print_search_results_json, print_show_issue, - print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json, - print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline, - print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json, - query_notes, run_auth_test, run_count, run_count_events, run_doctor, run_drift, run_embed, - find_lore_tui, run_file_history, run_generate_docs, run_ingest, run_ingest_dry_run, run_init, - run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync, - run_sync_status, run_timeline, run_tui, run_who, + NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, find_lore_tui, + open_issue_in_browser, open_mr_in_browser, parse_trace_path, print_count, print_count_json, + print_doctor_results, print_drift_human, print_drift_json, print_dry_run_preview, + print_dry_run_preview_json, print_embed, print_embed_json, print_event_count, + print_event_count_json, print_file_history, print_file_history_json, print_generate_docs, + print_generate_docs_json, print_ingest_summary, print_ingest_summary_json, print_list_issues, + print_list_issues_json, print_list_mrs, print_list_mrs_json, print_list_notes, + print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl, print_search_results, + print_search_results_json, print_show_issue, print_show_issue_json, print_show_mr, + print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json, + print_sync_status, print_sync_status_json, print_timeline, print_timeline_json_with_meta, + print_trace, print_trace_json, print_who_human, print_who_json, query_notes, run_auth_test, + run_count, run_count_events, run_doctor, run_drift, run_embed, run_file_history, + run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, run_list_mrs, + run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, run_timeline, + run_tui, run_who, }; use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme}; use lore::cli::robot::{RobotMeta, strip_schemas};