feat(tui): heph-tui T1 — read-only 3-pane agenda (§8.1)

New crate crates/heph-tui: a ratatui terminal agenda, thin client of the
hephd unix socket (never touches SQLite, same as heph.nvim). The next big
surface — the interactive triage UI the §6.2.1 Todoist study calls for.

- 3-pane layout: sidebar (the five §8.2 filter views + projects), task list
  (attention-colored rows with compact human do/late dates), and a preview
  pane (the highlighted task's canonical-context doc body + log tail).
- App state is generic over a `Backend` seam, so navigation/selection logic
  is unit-testable without a terminal or daemon; `ClientBackend` forwards to
  the socket. Rendering is a pure `ui::render(frame, &app)`.
- Navigation: j/k within the focused pane, Tab / h / l to move focus,
  selecting a sidebar source reloads the list, moving the task cursor
  refreshes the preview. r refresh, q quit.
- Socket resolution: --socket flag, then $HEPH_SOCKET, then the standard
  runtime path (the TUI honors the env var the CLI doesn't).

Tests: a headless TestBackend render against a real spawned daemon (asserts
views/projects/tasks/preview paint, and Top of Mind excludes blue), plus
in-memory navigation unit tests. 8 heph-tui tests; clippy/fmt clean.

Mutations (add/done/attention/reschedule/blue) + nvim handoff land in T2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 07:06:48 -07:00
commit a21f9e575b
11 changed files with 1376 additions and 1 deletions

296
Cargo.lock generated
View file

@ -40,6 +40,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
@ -377,6 +383,21 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cbc"
version = "0.1.2"
@ -499,6 +520,20 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compact_str"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@ -593,6 +628,31 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crypto-bigint"
version = "0.5.5"
@ -642,6 +702,40 @@ dependencies = [
"syn",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "dashmap"
version = "6.2.1"
@ -776,6 +870,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "elliptic-curve"
version = "0.13.8"
@ -1106,6 +1206,8 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
@ -1161,6 +1263,22 @@ dependencies = [
"yrs",
]
[[package]]
name = "heph-tui"
version = "0.0.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"heph-core",
"hephd",
"ratatui",
"serde",
"serde_json",
"tempfile",
"tokio",
]
[[package]]
name = "hephd"
version = "0.0.0"
@ -1417,6 +1535,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
@ -1450,6 +1574,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "inout"
version = "0.1.4"
@ -1460,6 +1593,19 @@ dependencies = [
"generic-array",
]
[[package]]
name = "instability"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@ -1472,6 +1618,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@ -1616,6 +1771,15 @@ version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "matchers"
version = "0.2.0"
@ -1669,6 +1833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@ -1890,6 +2055,16 @@ version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
@ -1912,6 +2087,12 @@ dependencies = [
"regex",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pem"
version = "3.0.6"
@ -2219,6 +2400,27 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "ratatui"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"instability",
"itertools",
"lru",
"paste",
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -2650,6 +2852,27 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@ -2759,6 +2982,28 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "subtle"
version = "2.6.1"
@ -3114,6 +3359,35 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "unicode-xid"
version = "0.2.6"
@ -3366,6 +3640,28 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"

View file

@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["crates/heph-core", "crates/hephd", "crates/heph"]
members = ["crates/heph-core", "crates/hephd", "crates/heph", "crates/heph-tui"]
[workspace.package]
edition = "2021"
@ -32,6 +32,7 @@ tokio = { version = "1", features = [
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = { version = "4", features = ["derive"] }
ratatui = "0.29"
fs4 = "0.12"
axum = "0.8"
jsonwebtoken = { version = "10", features = ["rust_crypto"] }

View file

@ -0,0 +1,31 @@
[package]
name = "heph-tui"
description = "Hephaestus terminal UI: the task agenda/triage surface (a thin client of the local hephd daemon)."
edition.workspace = true
version.workspace = true
license.workspace = true
publish.workspace = true
authors.workspace = true
rust-version.workspace = true
[[bin]]
name = "heph-tui"
path = "src/main.rs"
[lib]
name = "heph_tui"
path = "src/lib.rs"
[dependencies]
heph-core = { path = "../heph-core" }
hephd = { path = "../hephd" }
ratatui.workspace = true
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
clap.workspace = true
[dev-dependencies]
tempfile = "3"
tokio.workspace = true

227
crates/heph-tui/src/app.rs Normal file
View file

@ -0,0 +1,227 @@
//! The agenda app state + interaction logic (tech-spec §8.1), generic over a
//! [`Backend`] so it is testable without a terminal or a daemon. Rendering
//! lives in [`crate::ui`]; the terminal/event loop in `main.rs`.
use anyhow::Result;
use heph_core::{RankedTask, BUILTIN_VIEWS};
use crate::backend::{Backend, Project};
/// Which pane has the keyboard.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Sidebar,
Tasks,
}
/// One row of the left sidebar. `Header` rows are labels and are skipped by the
/// cursor; the others are selectable sources for the task list.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SidebarEntry {
Header(String),
View { name: String, title: String },
Project { id: String, title: String },
}
impl SidebarEntry {
fn selectable(&self) -> bool {
!matches!(self, SidebarEntry::Header(_))
}
}
/// The selected sidebar source, as owned values (so reloads don't hold a borrow
/// on `self.sidebar` while calling the backend).
enum Target {
View(String),
Project(String),
}
/// The right-pane preview of the highlighted task: its canonical-context doc
/// body plus the tail of its log.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Preview {
pub title: String,
pub body: Vec<String>,
pub log: Vec<String>,
}
/// The whole TUI state.
pub struct App<B: Backend> {
backend: B,
pub sidebar: Vec<SidebarEntry>,
pub sidebar_cursor: usize,
pub tasks: Vec<RankedTask>,
pub task_cursor: usize,
pub preview: Preview,
pub focus: Focus,
pub status: String,
pub should_quit: bool,
}
const PREVIEW_LOG_LINES: usize = 5;
impl<B: Backend> App<B> {
/// Build the sidebar (built-in views + projects), select the first view, and
/// load its tasks + the first task's preview.
pub fn new(mut backend: B) -> Result<Self> {
let mut sidebar = vec![SidebarEntry::Header("Views".into())];
for v in BUILTIN_VIEWS {
sidebar.push(SidebarEntry::View {
name: v.name.into(),
title: v.title.into(),
});
}
sidebar.push(SidebarEntry::Header("Projects".into()));
for Project { id, title } in backend.projects()? {
sidebar.push(SidebarEntry::Project { id, title });
}
let sidebar_cursor = sidebar
.iter()
.position(SidebarEntry::selectable)
.unwrap_or(0);
let mut app = App {
backend,
sidebar,
sidebar_cursor,
tasks: Vec::new(),
task_cursor: 0,
preview: Preview::default(),
focus: Focus::Sidebar,
status: String::new(),
should_quit: false,
};
app.reload();
Ok(app)
}
/// The title shown above the task list (the selected source).
pub fn task_pane_title(&self) -> String {
match self.sidebar.get(self.sidebar_cursor) {
Some(SidebarEntry::View { title, .. }) => title.clone(),
Some(SidebarEntry::Project { title, .. }) => title.clone(),
_ => "Tasks".into(),
}
}
/// The highlighted task, if any.
pub fn selected_task(&self) -> Option<&RankedTask> {
self.tasks.get(self.task_cursor)
}
fn current_target(&self) -> Option<Target> {
match self.sidebar.get(self.sidebar_cursor)? {
SidebarEntry::View { name, .. } => Some(Target::View(name.clone())),
SidebarEntry::Project { id, .. } => Some(Target::Project(id.clone())),
SidebarEntry::Header(_) => None,
}
}
/// Reload the task list for the current sidebar selection, then the preview.
/// Errors surface in the status line rather than crashing the UI.
pub fn reload(&mut self) {
match self.load_tasks() {
Ok(tasks) => {
self.tasks = tasks;
if self.task_cursor >= self.tasks.len() {
self.task_cursor = self.tasks.len().saturating_sub(1);
}
}
Err(e) => self.status = format!("error: {e}"),
}
self.reload_preview();
}
fn load_tasks(&mut self) -> Result<Vec<RankedTask>> {
match self.current_target() {
Some(Target::View(name)) => self.backend.view(&name),
Some(Target::Project(id)) => {
let filter = heph_core::ListFilter {
scope: vec![id],
..Default::default()
};
self.backend.list(&filter)
}
None => Ok(Vec::new()),
}
}
fn reload_preview(&mut self) {
let Some(task) = self.selected_task().cloned() else {
self.preview = Preview::default();
return;
};
let mut preview = Preview {
title: task.title.clone(),
..Default::default()
};
if let Some(ctx) = &task.canonical_context_id {
match self.backend.node_body(ctx) {
Ok(body) => preview.body = body.lines().map(str::to_string).collect(),
Err(e) => preview.body = vec![format!("(preview error: {e})")],
}
}
if let Ok(log) = self.backend.log_tail(&task.node_id, PREVIEW_LOG_LINES) {
preview.log = log;
}
self.preview = preview;
}
// --- navigation ---
/// Move the sidebar cursor by `delta` rows, skipping headers, reloading the
/// task list for the new selection.
pub fn move_sidebar(&mut self, delta: isize) {
let n = self.sidebar.len();
let mut i = self.sidebar_cursor as isize;
loop {
i += delta;
if i < 0 || i >= n as isize {
return; // off the end — leave selection unchanged
}
if self.sidebar[i as usize].selectable() {
break;
}
}
self.sidebar_cursor = i as usize;
self.task_cursor = 0;
self.reload();
}
/// Move the task cursor by `delta`, clamped, refreshing the preview.
pub fn move_task(&mut self, delta: isize) {
if self.tasks.is_empty() {
return;
}
let max = self.tasks.len() as isize - 1;
let i = (self.task_cursor as isize + delta).clamp(0, max);
self.task_cursor = i as usize;
self.reload_preview();
}
pub fn focus_sidebar(&mut self) {
self.focus = Focus::Sidebar;
}
pub fn focus_tasks(&mut self) {
if !self.tasks.is_empty() {
self.focus = Focus::Tasks;
}
}
pub fn toggle_focus(&mut self) {
self.focus = match self.focus {
Focus::Sidebar => Focus::Tasks,
Focus::Tasks => Focus::Sidebar,
};
}
/// Run `f` against the backend; any error lands in the status line. Used by
/// mutation gestures (T2).
pub fn try_mutate(&mut self, f: impl FnOnce(&mut B) -> Result<()>) {
if let Err(e) = f(&mut self.backend) {
self.status = format!("error: {e}");
}
}
}

View file

@ -0,0 +1,159 @@
//! The data seam between the TUI and the daemon (tech-spec §8.1).
//!
//! [`Backend`] is the small set of reads/writes the agenda needs; the App is
//! generic over it, so navigation/triage logic is unit-testable against a fake
//! and the real surface ([`ClientBackend`]) just forwards to the `hephd` unix
//! socket — the TUI never touches SQLite, same as `heph.nvim`.
use anyhow::{Context, Result};
use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch};
use hephd::Client;
use serde_json::{json, Value};
/// A project node, as the sidebar lists it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Project {
pub id: String,
pub title: String,
}
/// Everything the agenda surface asks of the daemon.
pub trait Backend {
/// All project nodes (for the sidebar), title-sorted.
fn projects(&mut self) -> Result<Vec<Project>>;
/// Run a built-in filter view (`tom|ondeck|chores|work|tasks`, §8.2).
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>>;
/// Run a raw [`ListFilter`] (used for per-project scope).
fn list(&mut self, filter: &ListFilter) -> Result<Vec<RankedTask>>;
/// A node's markdown body (the canonical-context doc preview). Empty if the
/// node is bodiless or missing.
fn node_body(&mut self, id: &str) -> Result<String>;
/// The last `n` log lines for a task (the resumption breadcrumb).
fn log_tail(&mut self, task_id: &str, n: usize) -> Result<Vec<String>>;
// --- triage mutations (T2) ---
/// Set a task's lifecycle state (`done` rolls a recurring task forward).
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()>;
/// Skip a recurring task to its next occurrence (no completion logged).
fn skip(&mut self, task_id: &str) -> Result<()>;
/// Set a task's attention band.
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()>;
/// Patch a task's schedule (do-date / late-on / recurrence), §6 double-option.
fn set_schedule(&mut self, task_id: &str, patch: SchedulePatch) -> Result<()>;
/// Capture a committed task; returns its node id.
fn create_task(
&mut self,
title: &str,
attention: Option<Attention>,
do_date: Option<i64>,
project_id: Option<&str>,
) -> Result<String>;
}
/// The real backend: a thin client of the `hephd` unix socket.
pub struct ClientBackend {
client: Client,
}
impl ClientBackend {
pub fn new(client: Client) -> Self {
Self { client }
}
fn call(&mut self, method: &str, params: Value) -> Result<Value> {
self.client
.call(method, params)
.with_context(|| format!("rpc {method}"))
}
}
impl Backend for ClientBackend {
fn projects(&mut self) -> Result<Vec<Project>> {
let v = self.call("node.list", json!({ "kind": "project" }))?;
let nodes: Vec<heph_core::Node> = serde_json::from_value(v)?;
let mut projects: Vec<Project> = nodes
.into_iter()
.map(|n| Project {
id: n.id,
title: n.title,
})
.collect();
projects.sort_by(|a, b| a.title.cmp(&b.title));
Ok(projects)
}
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>> {
let v = self.call("view", json!({ "name": name }))?;
Ok(serde_json::from_value(v)?)
}
fn list(&mut self, filter: &ListFilter) -> Result<Vec<RankedTask>> {
let v = self.call("list", json!(filter))?;
Ok(serde_json::from_value(v)?)
}
fn node_body(&mut self, id: &str) -> Result<String> {
let v = self.call("node.get", json!({ "id": id }))?;
if v.is_null() {
return Ok(String::new());
}
let node: heph_core::Node = serde_json::from_value(v)?;
Ok(node.body.unwrap_or_default())
}
fn log_tail(&mut self, task_id: &str, n: usize) -> Result<Vec<String>> {
let v = self.call("log.tail", json!({ "task_id": task_id, "n": n }))?;
Ok(serde_json::from_value(v)?)
}
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> {
self.call("task.set_state", json!({ "id": task_id, "state": state }))?;
Ok(())
}
fn skip(&mut self, task_id: &str) -> Result<()> {
self.call("task.skip", json!({ "id": task_id }))?;
Ok(())
}
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()> {
self.call(
"task.set_attention",
json!({ "id": task_id, "attention": attention }),
)?;
Ok(())
}
fn set_schedule(&mut self, task_id: &str, patch: SchedulePatch) -> Result<()> {
let mut params = json!({ "id": task_id });
let p = serde_json::to_value(&patch)?;
if let Value::Object(map) = p {
for (k, val) in map {
params[k] = val;
}
}
self.call("task.set_schedule", params)?;
Ok(())
}
fn create_task(
&mut self,
title: &str,
attention: Option<Attention>,
do_date: Option<i64>,
project_id: Option<&str>,
) -> Result<String> {
let v = self.call(
"task.create",
json!({
"title": title,
"attention": attention,
"do_date": do_date,
"project_id": project_id,
}),
)?;
let task: heph_core::Task = serde_json::from_value(v)?;
Ok(task.node_id)
}
}

View file

@ -0,0 +1,52 @@
//! Small display helpers (compact human dates for task rows). The UI layer may
//! read the wall clock (unlike `heph-core`, which is clock-injected).
use chrono::{DateTime, Datelike, Local, NaiveDate};
/// Format an epoch-ms do/late date relative to `today`: `today`, `tomorrow`,
/// `yesterday`, `MM-DD` within the year, else `YYYY-MM-DD`.
pub fn fmt_date(ms: i64, today: NaiveDate) -> String {
let Some(dt) = DateTime::from_timestamp_millis(ms) else {
return "?".into();
};
let date = dt.with_timezone(&Local).date_naive();
match (date - today).num_days() {
0 => "today".into(),
1 => "tomorrow".into(),
-1 => "yesterday".into(),
_ if date.year() == today.year() => format!("{:02}-{:02}", date.month(), date.day()),
_ => date.format("%Y-%m-%d").to_string(),
}
}
/// Today in the local timezone (the reference for [`fmt_date`]).
pub fn today_local() -> NaiveDate {
Local::now().date_naive()
}
#[cfg(test)]
mod tests {
use super::*;
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn ms(date: NaiveDate) -> i64 {
date.and_hms_opt(12, 0, 0)
.unwrap()
.and_local_timezone(Local)
.unwrap()
.timestamp_millis()
}
#[test]
fn relative_and_absolute_dates() {
let today = day(2026, 6, 3);
assert_eq!(fmt_date(ms(today), today), "today");
assert_eq!(fmt_date(ms(day(2026, 6, 4)), today), "tomorrow");
assert_eq!(fmt_date(ms(day(2026, 6, 2)), today), "yesterday");
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");
}
}

View file

@ -0,0 +1,15 @@
//! `heph-tui` — the Hephaestus terminal agenda/triage surface (tech-spec §8.1).
//!
//! A thin client of the local `hephd` unix socket (it never touches SQLite,
//! same as `heph.nvim`). The [`app::App`] holds all state and is generic over a
//! [`backend::Backend`] so its navigation/triage logic is unit-testable without
//! a terminal; [`ui::render`] draws the 3-pane layout; `main.rs` owns the
//! terminal and the event loop.
pub mod app;
pub mod backend;
pub mod fmt;
pub mod ui;
pub use app::{App, Focus};
pub use backend::{Backend, ClientBackend, Project};

View file

@ -0,0 +1,95 @@
//! `heph-tui` binary: terminal lifecycle + event loop. All state/logic lives in
//! the library ([`heph_tui::App`] + [`heph_tui::ui`]); this file is the I/O shell.
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use heph_tui::{app::App, backend::ClientBackend, ui, Focus};
use hephd::Client;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
#[derive(Parser)]
#[command(name = "heph-tui", about = "Hephaestus task agenda / triage TUI")]
struct Cli {
/// Path to the hephd unix socket. Falls back to $HEPH_SOCKET, then the
/// standard runtime path.
#[arg(long)]
socket: Option<PathBuf>,
}
fn resolve_socket(flag: Option<PathBuf>) -> PathBuf {
flag.or_else(|| std::env::var_os("HEPH_SOCKET").map(PathBuf::from))
.unwrap_or_else(hephd::default_socket_path)
}
fn main() -> Result<()> {
let cli = Cli::parse();
let socket = resolve_socket(cli.socket);
let client = Client::connect(&socket).with_context(|| {
format!(
"could not connect to hephd at {} — is it running? (try: heph daemon start)",
socket.display()
)
})?;
let app = App::new(ClientBackend::new(client)).context("loading the agenda")?;
let mut terminal = ratatui::init();
let result = run(&mut terminal, app);
ratatui::restore();
result
}
fn run<B: heph_tui::Backend>(
terminal: &mut ratatui::DefaultTerminal,
mut app: App<B>,
) -> Result<()> {
loop {
terminal.draw(|f| ui::render(f, &app))?;
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
handle_key(&mut app, key);
}
}
if app.should_quit {
return Ok(());
}
}
}
/// Translate a key press into an [`App`] action (T1: navigation only).
fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) {
// Any keypress clears a stale status message.
app.status.clear();
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
app.should_quit = true;
return;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Char('r') => app.reload(),
KeyCode::Tab => app.toggle_focus(),
KeyCode::Char('j') | KeyCode::Down => move_down(app),
KeyCode::Char('k') | KeyCode::Up => move_up(app),
KeyCode::Char('h') | KeyCode::Left => app.focus_sidebar(),
KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => app.focus_tasks(),
_ => {}
}
}
fn move_down<B: heph_tui::Backend>(app: &mut App<B>) {
match app.focus {
Focus::Sidebar => app.move_sidebar(1),
Focus::Tasks => app.move_task(1),
}
}
fn move_up<B: heph_tui::Backend>(app: &mut App<B>) {
match app.focus {
Focus::Sidebar => app.move_sidebar(-1),
Focus::Tasks => app.move_task(-1),
}
}

216
crates/heph-tui/src/ui.rs Normal file
View file

@ -0,0 +1,216 @@
//! Rendering — the 3-pane agenda (sidebar · task list · preview) + status line
//! (tech-spec §8.1). Pure: it reads [`App`] and draws; no I/O, no mutation.
use heph_core::Attention;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
Frame,
};
use crate::app::{App, Focus, SidebarEntry};
use crate::backend::Backend;
use crate::fmt::{fmt_date, today_local};
const HINTS: &str = " j/k move Tab/h/l pane Enter open r refresh q quit";
/// Draw the whole UI for the current frame.
pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(1)])
.split(frame.area());
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(22),
Constraint::Min(28),
Constraint::Length(38),
])
.split(outer[0]);
render_sidebar(frame, app, panes[0]);
render_tasks(frame, app, panes[1]);
render_preview(frame, app, panes[2]);
render_status(frame, app, outer[1]);
}
fn pane_border(focused: bool) -> Style {
if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
}
}
fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let focused = app.focus == Focus::Sidebar;
let items: Vec<ListItem> = app
.sidebar
.iter()
.enumerate()
.map(|(i, entry)| {
let selected = i == app.sidebar_cursor;
match entry {
SidebarEntry::Header(h) => ListItem::new(Line::from(Span::styled(
h.clone(),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
))),
SidebarEntry::View { title, .. } | SidebarEntry::Project { title, .. } => {
let mut style = Style::default();
if selected {
style = if focused {
style.fg(Color::Black).bg(Color::Cyan)
} else {
style.add_modifier(Modifier::REVERSED)
};
}
ListItem::new(Line::from(Span::styled(format!(" {title}"), style)))
}
}
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(pane_border(focused))
.title(" Views "),
);
frame.render_widget(list, area);
}
fn attention_style(a: Option<Attention>) -> (char, 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)),
}
}
fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let focused = app.focus == Focus::Tasks;
let today = today_local();
let width = area.width.saturating_sub(2) as usize; // inside borders
let items: Vec<ListItem> = if app.tasks.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" (nothing here)",
Style::default().fg(Color::DarkGray),
)))]
} else {
app.tasks
.iter()
.enumerate()
.map(|(i, t)| {
let (glyph, gstyle) = attention_style(t.attention);
// Right-aligned date chip (late > do).
let (chip, chip_style) = if let Some(late) = t
.late_on
.filter(|l| chrono::Local::now().timestamp_millis() > *l)
{
(
format!("late:{}", fmt_date(late, today)),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
} else if let Some(do_date) = t.do_date {
(
format!("do:{}", fmt_date(do_date, today)),
Style::default().fg(Color::DarkGray),
)
} else {
(String::new(), Style::default())
};
let selected = i == app.task_cursor;
let title_style = if selected && focused {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let cursor = if selected { "" } else { " " };
// Pad the title so the chip sits at the right edge.
let chip_w = chip.len();
let fixed = 1 + 2; // cursor + glyph + space
let avail = width.saturating_sub(fixed + chip_w + 1);
let mut title: String = t.title.chars().take(avail).collect();
let pad = avail.saturating_sub(title.chars().count());
title.push_str(&" ".repeat(pad));
ListItem::new(Line::from(vec![
Span::styled(cursor, Style::default().fg(Color::Cyan)),
Span::styled(format!("{glyph} "), gstyle),
Span::styled(title, title_style),
Span::raw(" "),
Span::styled(chip, chip_style),
]))
})
.collect()
};
let title = format!(" {} ({}) ", app.task_pane_title(), app.tasks.len());
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(pane_border(focused))
.title(title),
);
frame.render_widget(list, area);
}
fn render_preview<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
if !app.preview.title.is_empty() {
lines.push(Line::from(Span::styled(
app.preview.title.clone(),
Style::default().add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
}
for l in &app.preview.body {
lines.push(Line::from(l.clone()));
}
if !app.preview.log.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"─ log ─",
Style::default().fg(Color::DarkGray),
)));
for l in &app.preview.log {
lines.push(Line::from(Span::styled(
l.clone(),
Style::default().fg(Color::DarkGray),
)));
}
}
let para = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
Block::default()
.borders(Borders::ALL)
.border_style(pane_border(false))
.title(" Preview "),
);
frame.render_widget(para, area);
}
fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let text = if app.status.is_empty() {
HINTS.to_string()
} else {
format!(" {}", app.status)
};
let style = if app.status.starts_with("error") {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::DarkGray)
};
frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area);
}

View file

@ -0,0 +1,134 @@
//! Headless render test (tech-spec §8.1/§9): a real `hephd` over a real unix
//! socket against a temp DB, driven by the TUI's `ClientBackend`, rendered to
//! ratatui's `TestBackend` — asserting the agenda actually paints seeded data.
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
use heph_core::{FixedClock, LocalStore};
use heph_tui::{app::App, backend::ClientBackend};
use hephd::{Client, Daemon};
use ratatui::{backend::TestBackend, Terminal};
use serde_json::json;
use tokio::net::UnixListener;
const NOW: i64 = 1_717_400_000_000; // ~2024-06-03
fn spawn_daemon() -> (PathBuf, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("heph.db");
let socket = dir.path().join("d.sock");
let store = LocalStore::open(&db, Box::new(FixedClock(NOW))).unwrap();
let socket_for_thread = socket.clone();
thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
let listener = UnixListener::bind(&socket_for_thread).unwrap();
let _ = Daemon::new(store).serve(listener).await;
});
});
for _ in 0..200 {
if socket.exists() {
break;
}
thread::sleep(Duration::from_millis(5));
}
(socket, dir)
}
fn client(socket: &Path) -> Client {
Client::connect(socket).unwrap()
}
/// Render the app to a fixed-size TestBackend and return the screen as text.
fn screen<B: heph_tui::Backend>(app: &App<B>) -> String {
let mut terminal = Terminal::new(TestBackend::new(110, 24)).unwrap();
terminal.draw(|f| heph_tui::ui::render(f, app)).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn agenda_renders_views_projects_and_tasks() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
// Seed: a red task (Top of Mind) and a blue one (On Deck), plus a project.
c.call(
"task.create",
json!({ "title": "Pay the water bill", "attention": "red" }),
)
.unwrap();
c.call(
"task.create",
json!({ "title": "Someday backlog item", "attention": "blue" }),
)
.unwrap();
c.call(
"node.create",
json!({ "kind": "project", "title": "Camano" }),
)
.unwrap();
let app = App::new(ClientBackend::new(client(&socket))).unwrap();
let s = screen(&app);
// Sidebar shows the built-in views and the project.
assert!(s.contains("Top of Mind"), "missing view in sidebar:\n{s}");
assert!(s.contains("On Deck"), "missing On Deck view:\n{s}");
assert!(s.contains("Camano"), "missing project in sidebar:\n{s}");
// The default selection (Top of Mind) lists the red task, not the blue one.
assert!(s.contains("Pay the water bill"), "red task missing:\n{s}");
assert!(
!s.contains("Someday backlog item"),
"blue task should not be in Top of Mind:\n{s}"
);
assert!(s.contains("Preview"), "preview pane missing:\n{s}");
}
#[test]
fn preview_shows_the_selected_tasks_context_body() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let task = c
.call(
"task.create",
json!({ "title": "Renew passport", "attention": "orange" }),
)
.unwrap();
// Find the canonical-context doc and give it a body.
let links = c
.call("links.outgoing", json!({ "id": task["node_id"] }))
.unwrap();
let ctx = links
.as_array()
.unwrap()
.iter()
.find(|l| l["link_type"] == "canonical-context")
.unwrap()["dst_id"]
.as_str()
.unwrap()
.to_string();
c.call(
"node.update",
json!({ "id": ctx, "body": "- [ ] fill form\n- [ ] passport photo" }),
)
.unwrap();
let app = App::new(ClientBackend::new(client(&socket))).unwrap();
let s = screen(&app);
assert!(s.contains("fill form"), "context body not previewed:\n{s}");
}

View file

@ -0,0 +1,149 @@
//! Navigation/selection logic against an in-memory fake backend — no terminal,
//! no daemon. Asserts the App's cursor + reload behavior (tech-spec §8.1).
use std::collections::HashMap;
use anyhow::Result;
use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch, TaskState};
use heph_tui::{
app::{App, Focus},
backend::{Backend, Project},
};
fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask {
RankedTask {
node_id: id.into(),
title: title.into(),
attention: Some(attention),
do_date: None,
late_on: None,
state: TaskState::Outstanding,
tombstoned: false,
project_id: None,
canonical_context_id: ctx.map(str::to_string),
created_at: 0,
}
}
#[derive(Default)]
struct Fake {
views: HashMap<String, Vec<RankedTask>>,
projects: Vec<Project>,
by_project: HashMap<String, Vec<RankedTask>>,
bodies: HashMap<String, String>,
}
impl Backend for Fake {
fn projects(&mut self) -> Result<Vec<Project>> {
Ok(self.projects.clone())
}
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>> {
Ok(self.views.get(name).cloned().unwrap_or_default())
}
fn list(&mut self, filter: &ListFilter) -> Result<Vec<RankedTask>> {
let id = filter.scope.first().cloned().unwrap_or_default();
Ok(self.by_project.get(&id).cloned().unwrap_or_default())
}
fn node_body(&mut self, id: &str) -> Result<String> {
Ok(self.bodies.get(id).cloned().unwrap_or_default())
}
fn log_tail(&mut self, _task_id: &str, _n: usize) -> Result<Vec<String>> {
Ok(Vec::new())
}
fn set_state(&mut self, _t: &str, _s: &str) -> Result<()> {
Ok(())
}
fn skip(&mut self, _t: &str) -> Result<()> {
Ok(())
}
fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> {
Ok(())
}
fn set_schedule(&mut self, _t: &str, _p: SchedulePatch) -> Result<()> {
Ok(())
}
fn create_task(
&mut self,
_title: &str,
_a: Option<Attention>,
_d: Option<i64>,
_p: Option<&str>,
) -> Result<String> {
Ok("new".into())
}
}
fn fixture() -> Fake {
let mut f = Fake::default();
f.views.insert(
"tom".into(),
vec![
task("t1", "red one", Attention::Red, Some("c1")),
task("t2", "orange two", Attention::Orange, Some("c2")),
],
);
f.views.insert(
"ondeck".into(),
vec![task("b1", "blue one", Attention::Blue, None)],
);
f.projects.push(Project {
id: "p1".into(),
title: "Camano".into(),
});
f.by_project.insert(
"p1".into(),
vec![task("pt", "project task", Attention::White, None)],
);
f.bodies.insert("c1".into(), "body of red one".into());
f.bodies.insert("c2".into(), "body of orange two".into());
f
}
#[test]
fn starts_on_the_first_view_with_its_tasks() {
let app = App::new(fixture()).unwrap();
assert_eq!(app.task_pane_title(), "Top of Mind");
assert_eq!(app.tasks.len(), 2);
assert_eq!(app.selected_task().unwrap().title, "red one");
assert_eq!(app.preview.body, vec!["body of red one"]);
assert_eq!(app.focus, Focus::Sidebar);
}
#[test]
fn moving_the_sidebar_switches_the_task_list() {
let mut app = App::new(fixture()).unwrap();
app.move_sidebar(1); // Top of Mind -> On Deck
assert_eq!(app.task_pane_title(), "On Deck");
assert_eq!(app.tasks.len(), 1);
assert_eq!(app.selected_task().unwrap().title, "blue one");
}
#[test]
fn sidebar_skips_headers_into_the_projects_section() {
let mut app = App::new(fixture()).unwrap();
// 5 built-in views: step down 5 times crosses the "Projects" header to p1.
for _ in 0..5 {
app.move_sidebar(1);
}
assert_eq!(app.task_pane_title(), "Camano");
assert_eq!(app.selected_task().unwrap().title, "project task");
}
#[test]
fn moving_the_task_cursor_updates_the_preview() {
let mut app = App::new(fixture()).unwrap();
app.focus_tasks();
assert_eq!(app.focus, Focus::Tasks);
app.move_task(1);
assert_eq!(app.selected_task().unwrap().title, "orange two");
assert_eq!(app.preview.body, vec!["body of orange two"]);
}
#[test]
fn move_task_clamps_at_the_ends() {
let mut app = App::new(fixture()).unwrap();
app.move_task(-5); // already at top
assert_eq!(app.task_cursor, 0);
app.move_task(50); // past the end
assert_eq!(app.task_cursor, 1);
}