feat(tui): attention flag column + project-colored bullets + scrollbar (§8.1)

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) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 10:53:19 -07:00
commit ecfe64435c
5 changed files with 116 additions and 22 deletions

View file

@ -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 0360, saturation/lightness 01) → 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(..)));
}
}

View file

@ -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<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
frame.render_widget(list, area);
}
fn attention_style(a: Option<Attention>) -> (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<Attention>) -> (&'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<B: Backend>(frame: &mut Frame, app: &App<B>, 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<B: Backend>(frame: &mut Frame, app: &App<B>, 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<B: Backend>(frame: &mut Frame, app: &App<B>, 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<B: Backend>(frame: &mut Frame, app: &App<B>, 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<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {

View file

@ -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}");
}

View file

@ -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 <name>` 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 <name>` 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 (p1p4) + 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 <task> --project <name>` 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.

View file

@ -253,16 +253,16 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba
> **Status: daily-driver core built** (slices T1T2c; 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('<ctx-id>')"` (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 <name>|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.