generated from eblume/project-template
Phase 1: v1 prototype #1
5 changed files with 132 additions and 7 deletions
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>
commit
10cf0fc395
|
|
@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue