Phase 1: v1 prototype #1

Merged
eblume merged 91 commits from feature/v1-prototype into main 2026-06-03 20:48:23 -07:00
5 changed files with 132 additions and 7 deletions
Showing only changes of commit 10cf0fc395 - Show all commits

feat(tui): heph-tui T2a — instant triage gestures (§8.1)

Single-keypress mutations on the highlighted task, each → RPC → reload with a
status confirmation: x done (recurring roll-forward), d drop, s skip, A cycle
attention (white→orange→red→blue, §6.2), b push-to-blue (On Deck). The bulk of
daily triage — the daily orange reconfirm and blue keep/drop review made fast.

Tests: next_attention cycle unit test; integration tests against a real daemon
that completing/pushing-to-blue removes a task from Top of Mind (and it then
shows under On Deck). 11 heph-tui tests; clippy/fmt clean.

Input-requiring actions (a add, e reschedule) + nvim context handoff are T2b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Erich Blume 2026-06-03 07:08:58 -07:00

View file

@ -3,10 +3,22 @@
//! lives in [`crate::ui`]; the terminal/event loop in `main.rs`.
use anyhow::Result;
use heph_core::{RankedTask, BUILTIN_VIEWS};
use heph_core::{Attention, RankedTask, BUILTIN_VIEWS};
use crate::backend::{Backend, Project};
/// The attention cycle for the `A` gesture: default → top-of-mind → consequence
/// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression.
pub fn next_attention(current: Option<Attention>) -> Attention {
match current {
Some(Attention::White) => Attention::Orange,
Some(Attention::Orange) => Attention::Red,
Some(Attention::Red) => Attention::Blue,
Some(Attention::Blue) => Attention::White,
None => Attention::White,
}
}
/// Which pane has the keyboard.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
@ -217,11 +229,66 @@ impl<B: Backend> App<B> {
};
}
/// 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}");
// --- triage mutations (T2a: single-keypress, no input) ---
/// Run `f` against the backend; on success set `ok` as the status and reload,
/// on error surface it in the status line. The shared shape for the gestures.
fn mutate(&mut self, ok: String, f: impl FnOnce(&mut B) -> Result<()>) {
match f(&mut self.backend) {
Ok(()) => {
self.status = ok;
self.reload();
}
Err(e) => self.status = format!("error: {e}"),
}
}
/// Mark the highlighted task done (recurring tasks roll forward).
pub fn complete_selected(&mut self) {
let Some(t) = self.selected_task().cloned() else {
return;
};
self.mutate(format!("done: {}", t.title), |b| {
b.set_state(&t.node_id, "done")
});
}
/// Drop the highlighted task (let it go — the blue keep/drop review).
pub fn drop_selected(&mut self) {
let Some(t) = self.selected_task().cloned() else {
return;
};
self.mutate(format!("dropped: {}", t.title), |b| {
b.set_state(&t.node_id, "dropped")
});
}
/// Skip the highlighted recurring task to its next occurrence.
pub fn skip_selected(&mut self) {
let Some(t) = self.selected_task().cloned() else {
return;
};
self.mutate(format!("skipped: {}", t.title), |b| b.skip(&t.node_id));
}
/// Cycle the highlighted task's attention band (§6.2 white→orange→red→blue).
pub fn cycle_attention_selected(&mut self) {
let Some(t) = self.selected_task().cloned() else {
return;
};
let next = next_attention(t.attention);
self.mutate(format!("{}: {}", next.as_str(), t.title), |b| {
b.set_attention(&t.node_id, next)
});
}
/// Push the highlighted task to On Deck (blue) — the pressure-relief valve.
pub fn push_to_blue_selected(&mut self) {
let Some(t) = self.selected_task().cloned() else {
return;
};
self.mutate(format!("→ on deck: {}", t.title), |b| {
b.set_attention(&t.node_id, Attention::Blue)
});
}
}

View file

@ -76,6 +76,12 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) {
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(),
// triage mutations (act on the highlighted task)
KeyCode::Char('x') => app.complete_selected(),
KeyCode::Char('d') => app.drop_selected(),
KeyCode::Char('s') => app.skip_selected(),
KeyCode::Char('A') => app.cycle_attention_selected(),
KeyCode::Char('b') => app.push_to_blue_selected(),
_ => {}
}
}

View file

@ -14,7 +14,8 @@ 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";
const HINTS: &str =
" j/k move Tab pane x done d drop s skip A attn b→blue r refresh q quit";
/// Draw the whole UI for the current frame.
pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {

View file

@ -132,3 +132,44 @@ fn preview_shows_the_selected_tasks_context_body() {
let s = screen(&app);
assert!(s.contains("fill form"), "context body not previewed:\n{s}");
}
#[test]
fn completing_a_task_removes_it_from_top_of_mind() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
c.call(
"task.create",
json!({ "title": "Pay the bill", "attention": "red" }),
)
.unwrap();
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
assert_eq!(app.tasks.len(), 1);
app.complete_selected();
assert!(app.status.contains("done"), "status: {}", app.status);
assert!(app.tasks.is_empty(), "completed task still listed");
// ...and the screen reflects the empty list.
assert!(screen(&app).contains("nothing here"));
}
#[test]
fn pushing_to_blue_moves_a_task_out_of_top_of_mind() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
c.call(
"task.create",
json!({ "title": "Cool it down", "attention": "orange" }),
)
.unwrap();
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
assert_eq!(app.tasks.len(), 1);
app.push_to_blue_selected();
assert!(app.tasks.is_empty(), "blue task should leave Top of Mind");
// It now appears under On Deck (sidebar row 2).
app.move_sidebar(1);
assert_eq!(app.task_pane_title(), "On Deck");
assert_eq!(app.selected_task().unwrap().title, "Cool it down");
}

View file

@ -147,3 +147,13 @@ fn move_task_clamps_at_the_ends() {
app.move_task(50); // past the end
assert_eq!(app.task_cursor, 1);
}
#[test]
fn attention_cycles_white_orange_red_blue() {
use heph_tui::app::next_attention;
assert_eq!(next_attention(Some(Attention::White)), Attention::Orange);
assert_eq!(next_attention(Some(Attention::Orange)), Attention::Red);
assert_eq!(next_attention(Some(Attention::Red)), Attention::Blue);
assert_eq!(next_attention(Some(Attention::Blue)), Attention::White);
assert_eq!(next_attention(None), Attention::White);
}