From ecfe64435c900415232dd152c024a70c7361743b Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 10:53:19 -0700 Subject: [PATCH] =?UTF-8?q?feat(tui):=20attention=20flag=20column=20+=20pr?= =?UTF-8?q?oject-colored=20bullets=20+=20scrollbar=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each task row now leads with a colored attention flag (⚑ for red/orange/blue, blank for white/none) and a project-colored bullet (●). The bullet color is derived stably from the project id (FNV-1a → HSL → truecolor RGB) so it survives projects being added/removed; a per-project override on the model is a later refinement. The glyph shape is reserved for future semantics. The task list also gains a scrollbar and ListState-driven scroll-to-visible so a selected task below the fold stays reachable. Tests: fmt::project_color determinism unit; a flag-glyph render assertion. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-tui/src/fmt.rs | 58 +++++++++++++++++++++++ crates/heph-tui/src/ui.rs | 59 ++++++++++++++++++------ crates/heph-tui/tests/agenda.rs | 2 + docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/tech-spec.md | 18 ++++---- 5 files changed, 116 insertions(+), 22 deletions(-) diff --git a/crates/heph-tui/src/fmt.rs b/crates/heph-tui/src/fmt.rs index 439c979..651d805 100644 --- a/crates/heph-tui/src/fmt.rs +++ b/crates/heph-tui/src/fmt.rs @@ -2,6 +2,7 @@ //! read the wall clock (unlike `heph-core`, which is clock-injected). use chrono::{DateTime, Datelike, Local, NaiveDate}; +use ratatui::style::Color; /// Format an epoch-ms do/late date relative to `today`: `today`, `tomorrow`, /// `yesterday`, `MM-DD` within the year, else `YYYY-MM-DD`. @@ -24,6 +25,46 @@ pub fn today_local() -> NaiveDate { Local::now().date_naive() } +/// A stable display color for a project, derived from its node id (§8.1) so the +/// task list's bullets read as project identity. Hashing the id (rather than a +/// position-based palette) keeps each project's color **stable as others are +/// added or removed**, trading perfect spread for occasional near-collisions — +/// acceptable per the design. `None` (no project) is a neutral gray. A future +/// per-project override stored on the model would take precedence over this. +pub fn project_color(project_id: Option<&str>) -> Color { + let Some(id) = project_id else { + return Color::DarkGray; + }; + // FNV-1a over the id → a hue in [0,360); fixed saturation/lightness tuned to + // stay legible on a dark terminal background. + let mut h: u32 = 0x811c_9dc5; + for b in id.bytes() { + h ^= b as u32; + h = h.wrapping_mul(0x0100_0193); + } + let hue = (h as f64 / u32::MAX as f64) * 360.0; + let (r, g, b) = hsl_to_rgb(hue, 0.55, 0.65); + Color::Rgb(r, g, b) +} + +/// HSL (hue 0–360, saturation/lightness 0–1) → 8-bit RGB. +fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) { + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let hp = h / 60.0; + let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs()); + let (r, g, b) = match hp as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = l - c / 2.0; + let to = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8; + (to(r), to(g), to(b)) +} + #[cfg(test)] mod tests { use super::*; @@ -49,4 +90,21 @@ mod tests { assert_eq!(fmt_date(ms(day(2026, 12, 25)), today), "12-25"); assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01"); } + + #[test] + fn project_color_is_stable_distinct_and_neutral_when_absent() { + assert_eq!(project_color(None), Color::DarkGray); + // Deterministic: the same id always maps to the same color. + assert_eq!( + project_color(Some("01J_chores")), + project_color(Some("01J_chores")) + ); + // Distinct ids generally differ (these two do). + assert_ne!( + project_color(Some("01J_chores")), + project_color(Some("01J_garden")) + ); + // A project id resolves to a concrete RGB (not a named palette slot). + assert!(matches!(project_color(Some("01J_work")), Color::Rgb(..))); + } } diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 6151c27..0aa2182 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -3,16 +3,19 @@ use heph_core::Attention; use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, + layout::{Constraint, Direction, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, + widgets::{ + Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, + ScrollbarOrientation, ScrollbarState, Wrap, + }, Frame, }; use crate::app::{App, Focus, InputState, Mode, MoveState, SidebarEntry}; use crate::backend::Backend; -use crate::fmt::{fmt_date, today_local}; +use crate::fmt::{fmt_date, project_color, today_local}; const HINTS: &str = " j/k move a add x done e date A attn b→blue D del o edit / search q quit"; @@ -157,13 +160,15 @@ fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(list, area); } -fn attention_style(a: Option) -> (char, Style) { +/// The leading flag glyph + color for an attention band: a colored `⚑` for the +/// bands that demand attention (red/orange/blue), blank for white/none. The +/// bullet beside it carries project identity instead (§8.1). +fn flag_style(a: Option) -> (&'static str, Style) { match a { - Some(Attention::Red) => ('●', Style::default().fg(Color::Red)), - Some(Attention::Orange) => ('●', Style::default().fg(Color::Yellow)), - Some(Attention::White) => ('○', Style::default().fg(Color::White)), - Some(Attention::Blue) => ('·', Style::default().fg(Color::Blue)), - None => ('·', Style::default().fg(Color::DarkGray)), + Some(Attention::Red) => ("⚑", Style::default().fg(Color::Red)), + Some(Attention::Orange) => ("⚑", Style::default().fg(Color::Yellow)), + Some(Attention::Blue) => ("⚑", Style::default().fg(Color::Blue)), + Some(Attention::White) | None => (" ", Style::default()), } } @@ -214,7 +219,8 @@ fn render_tasks(frame: &mut Frame, app: &App, area: Rect) { .iter() .enumerate() .map(|(i, t)| { - let (glyph, gstyle) = attention_style(t.attention); + let (flag, flag_st) = flag_style(t.attention); + let bullet_st = Style::default().fg(project_color(t.project_id.as_deref())); // Right-aligned date chip (late > do). let (chip, chip_style) = if let Some(late) = t .late_on @@ -245,7 +251,7 @@ fn render_tasks(frame: &mut Frame, app: &App, area: Rect) { // Pad the title so the right side (↻ + date chip) aligns right. let chip_w = chip.len(); let recur_w = if recur { 2 } else { 0 }; // "↻ " - let fixed = 1 + 2 + 1; // cursor + "glyph " + trailing space + let fixed = 1 + 1 + 1 + 1 + 1; // cursor + flag + bullet + space + trailing space let avail = width.saturating_sub(fixed + recur_w + chip_w); let mut title: String = t.title.chars().take(avail).collect(); let pad = avail.saturating_sub(title.chars().count()); @@ -253,7 +259,9 @@ fn render_tasks(frame: &mut Frame, app: &App, area: Rect) { let mut header = vec![ Span::styled(cursor, Style::default().fg(Color::Cyan)), - Span::styled(format!("{glyph} "), gstyle), + Span::styled(flag, flag_st), + Span::styled("●", bullet_st), + Span::raw(" "), Span::styled(title, title_style), Span::raw(" "), ]; @@ -278,7 +286,32 @@ fn render_tasks(frame: &mut Frame, app: &App, area: Rect) { .border_style(pane_border(focused)) .title(title), ); - frame.render_widget(list, area); + // A `ListState` selection drives scroll-to-visible so a task below the fold + // stays reachable; the row's own REVERSED styling remains the highlight. + let mut state = ListState::default(); + if !app.tasks.is_empty() { + state.select(Some(app.task_cursor)); + } + frame.render_stateful_widget(list, area, &mut state); + + // A scrollbar appears once the list can't show every task at once. Position + // tracks the selected row (item-indexed — an approximation with the inline + // detail block expanded, but a faithful "where am I in the list" signal). + let inner_h = area.height.saturating_sub(2) as usize; + if app.tasks.len() > inner_h { + let mut sb = ScrollbarState::new(app.tasks.len()).position(app.task_cursor); + let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None); + frame.render_stateful_widget( + bar, + area.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut sb, + ); + } } fn render_search(frame: &mut Frame, app: &App, area: Rect) { diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 56fa7ed..5625c8a 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -95,6 +95,8 @@ fn agenda_renders_views_projects_and_tasks() { !s.contains("Someday backlog item"), "blue task should not be in Top of Mind:\n{s}" ); + // The red/orange tasks carry a flag glyph in the leading column (§8.1). + assert!(s.contains('⚑'), "attention flag glyph missing:\n{s}"); assert!(s.contains("Preview"), "preview pane missing:\n{s}"); } diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index baa9155..68523d2 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -25,3 +25,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Filter views (§8.2) — saved agenda slices, so the agenda isn't one flat list. `heph view ` runs a built-in view (`tom` Top of Mind, `ondeck` On Deck, `chores`, `work` Work Tasks, `tasks`) seeded from the owner's Todoist filter queries; `heph view` with no name lists them, and `:Heph view ` does the same in Neovim. Under the hood, `list` now takes a `ListFilter` predicate-as-data (attention include/exclude sets, project-subtree scope, project exclusions, an actionable do-date gate), and views resolve project names to ids and expand each to its `parent`-link subtree. The Schedule view is intentionally omitted (time-of-day isn't modeled on date-grained do-dates). - `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim. - Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. In `heph-tui`, **`m`** opens a list-pick overlay ("(Unfile)" then every project) on the highlighted task. `heph edit --project ` now routes through the same RPC (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task. This closes the last Todoist-parity capture gap. +- `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index b72cb7f..e7dbca6 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -253,16 +253,16 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba > **Status: daily-driver core built** (slices T1–T2c; NL quick-add + search are the remaining T3 polish). The §6.2.1 Todoist study shows the dominant task activity is *interactive triage of a large set* (387 active tasks; daily orange reconfirm, blue keep/drop review, browse-by-project) — work that is awkward as either CLI flags or nvim buffers. A terminal UI owns it; the CLI (capture/scripting) and nvim (context) flank it. - **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure. -- **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (attention-colored rows with compact human do/late) · **preview** (canonical-context doc body + `log.tail`). +- **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (each row: a leading attention **flag** + a **project-colored bullet**, the title, recurrence `↻`, and a compact human do/late chip; a scrollbar appears when the list overflows) · **preview** (canonical-context doc body + `log.tail`). - **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `m` **move-to-project** (a list-pick overlay — "(Unfile)" then every project; backed by `task.set_project`) · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: humanizing the displayed RRULE is later polish.)* - **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)* - **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend. - **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`. - **`m` move-to-project** ✅ — re-file the selected task via the `task.set_project` RPC (a list-pick overlay), closing the last Todoist-parity gap. -- **Planned UX wave** (§14 roadmap, 2026-06-03) — all client-side over the existing `RankedTask` rows: - - **flag column + project-colored bullets** — a leading **flag glyph** colored by attention (red/orange/blue; **blank for white**, freeing the bullet for project identity); the **bullet `●` colored by its project** from a stable `hash(project_id) → hue` (HSL→truecolor, 256-color fallback; overlap acceptable; the glyph *shape* is reserved for future semantics). A stored, editable per-project color override is a later refinement. +- **flag column + project-colored bullets** ✅ — a leading **flag glyph** (`⚑`) colored by attention (red/orange/blue; **blank for white/none**, freeing the bullet for project identity); the **bullet `●` colored by its project** from a stable `hash(project_id) → hue` (FNV-1a → HSL → `Color::Rgb` truecolor; overlap acceptable; the glyph *shape* is reserved for future semantics). A stored, editable per-project color override is a later refinement (derived client-side for now). +- **scrollbar** ✅ — a `ratatui` `Scrollbar` on the task list once it overflows (a `ListState` selection drives scroll-to-visible so a task below the fold stays reachable). +- **Planned UX wave** (§14 roadmap, 2026-06-03) — remaining, client-side over the existing `RankedTask` rows: - **sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (desc; no-date = 0) → project name → `created_at` (FIFO); **project mode**: project primary with **non-selectable `──── Name ────` separators** (`j`/`k` skip them). The view filter always runs before the sort. - - **scrollbar** — a `ratatui` `Scrollbar` on the task list when it overflows. ## 8.2 Filter views (saved agenda slices) — built @@ -447,11 +447,11 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi > The remaining work is the **UX roadmap agreed 2026-06-03** (design conversation with the owner). It is documented docs-first — the bigger items have design sections above (`heph-tui` UX in §8.1, frontmatter in §8.3, wiki-links-by-id in §8.4) — and built in this order: 1. ✅ **`heph-tui` — move-to-project (§8.1) — DONE:** the **`task.set_project` RPC** (tombstone the old `in-project` link + add the new — OR-set semantics, no task-scalar op; given project must be a live project-kind node) plus the TUI `m` list-pick overlay and `heph edit --project |none` (which also fixed a duplicate-link bug in the old re-file path). Unblocks the project-edit path of the frontmatter surface (§8.3). -2. ⏳ **`heph-tui` task-list UX wave (§8.1) — no backend (`RankedTask` already carries every field):** - - **(a) flag column + project-colored bullets** — a leading **flag glyph** colored by attention (red/orange/blue; **blank for white**), and the **bullet `●` colored by its project** via a stable `hash(project_id) → hue` (HSL→truecolor `Color::Rgb`, 256-color fallback). Hashing is chosen for stability-under-insertion over golden-angle spread; overlap is acceptable. The bullet **glyph shape** is reserved for future semantics. A per-project **color override** (stored on the project node, editable) is a later refinement — colors are derived client-side for now (no schema change). - - **(b) sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with **non-selectable `──── Name ────` separator rows** between groups (`j`/`k` skip them). View filtering always runs **before** the sort. - - **(e) scrollbar** — the task list grows a `ratatui` `Scrollbar` (tracking the selection) when content overflows the pane. - - **nvim task-navigation polish (§8)** — show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). +2. **`heph-tui` task-list UX wave (§8.1) — no backend (`RankedTask` already carries every field):** + - ✅ **(a) flag column + project-colored bullets — DONE:** a leading `⚑` colored by attention (red/orange/blue; blank for white/none), and the bullet `●` colored by its project via a stable `hash(project_id) → hue` (FNV-1a → HSL → `Color::Rgb`). Hashing is chosen for stability-under-insertion over golden-angle spread; overlap is acceptable. The bullet **glyph shape** is reserved for future semantics. A per-project **color override** (stored on the project node, editable) is a later refinement — colors are derived client-side for now (no schema change). + - ✅ **(e) scrollbar — DONE:** the task list grows a `ratatui` `Scrollbar` (tracking the selection) when content overflows; a `ListState` selection keeps the highlighted row scrolled into view. + - ⏳ **(b) sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with **non-selectable `──── Name ────` separator rows** between groups (`j`/`k` skip them). View filtering always runs **before** the sort. + - ⏳ **nvim task-navigation polish (§8)** — show do/late in `next`/`list` rows and a clean jump-to-context gesture (read/navigate, not field-edit). 3. ⏳ **Tags (§4, §8.3) — promoted from deferred:** `NodeKind::Tag` exists but has no machinery. Add **tag-as-node + an OR-set tag link** (mirroring `in-project`) + `tag.add`/`tag.remove` RPCs + enumeration. Prerequisite for the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import ([[design]]); the goal is **one canonical tag set** across all of heph. 4. ⏳ **YAML frontmatter as an edit surface (§8.3) — docs-first C1:** generated-on-read, stripped-and-ignored-on-write in `heph-core`; `heph.nvim` diffs it into structured RPCs. See §8.3. 5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4.