hephaestus/crates/heph-tui/src/backend.rs
Erich Blume a21f9e575b 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>
2026-06-03 07:06:48 -07:00

159 lines
5.4 KiB
Rust

//! 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)
}
}