feat(heph): accept a1-a4, 1-4, or colour words for -a/--attention
All checks were successful
Build / validate (pull_request) Successful in 8m24s

The CLI's attention flag (on task/list/attention/edit/promote) now takes the
a1–a4 labels, a bare digit 1–4, or a colour word, normalizing to the storage
colour before the RPC. Adds Attention::parse_input() in heph-core (lenient
human input) alongside the strict storage parse(), with a clear error listing
the accepted forms. `heph attention` now echoes the band as `a1 (red)`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-09 08:29:46 -07:00
commit 730863b832
3 changed files with 67 additions and 10 deletions

View file

@ -118,6 +118,24 @@ impl Attention {
Attention::Blue => "a4",
}
}
/// Parse a *user-facing* attention input: the `a1`..`a4` label, a bare digit
/// `1`..`4`, or a colour word (`red`/`orange`/`white`/`blue`). Surfaces
/// accept any of these; the colour mapping matches [`Attention::ui_label`].
/// Use this for human input; [`Attention::parse`] is the strict storage form.
pub fn parse_input(s: &str) -> Result<Attention> {
Ok(match s.trim().to_ascii_lowercase().as_str() {
"1" | "a1" | "red" => Attention::Red,
"2" | "a2" | "orange" => Attention::Orange,
"3" | "a3" | "white" => Attention::White,
"4" | "a4" | "blue" => Attention::Blue,
other => {
return Err(Error::Integrity(format!(
"unknown attention: {other} (use a1-a4, 1-4, or red/orange/white/blue)"
)))
}
})
}
}
/// A committed task's lifecycle state (tech-spec §4.3). `done` and `dropped`
@ -410,3 +428,29 @@ impl NewNode {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_input_accepts_labels_digits_and_colours() {
for (inputs, want) in [
(["a1", "1", "red"], Attention::Red),
(["a2", "2", "orange"], Attention::Orange),
(["a3", "3", "white"], Attention::White),
(["a4", "4", "blue"], Attention::Blue),
] {
for s in inputs {
assert_eq!(Attention::parse_input(s).unwrap(), want, "input {s:?}");
}
}
// Case-insensitive and whitespace-tolerant.
assert_eq!(Attention::parse_input(" A1 ").unwrap(), Attention::Red);
assert_eq!(Attention::parse_input("RED").unwrap(), Attention::Red);
// The a-label maps to its colour, and round-trips back to the label.
assert_eq!(Attention::Red.ui_label(), "a1");
assert!(Attention::parse_input("p1").is_err());
assert!(Attention::parse_input("5").is_err());
}
}

View file

@ -12,7 +12,7 @@ use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use serde_json::{json, Value};
use heph_core::{Node, RankedTask, Task};
use heph_core::{Attention, Node, RankedTask, Task};
use hephd::{datespec, default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore};
mod service;
@ -43,7 +43,7 @@ enum Command {
Task {
/// The task title.
title: String,
/// Attention-state: white|orange|red|blue.
/// Attention: a1|a2|a3|a4 (or 1-4, or red|orange|white|blue).
#[arg(short = 'a', long)]
attention: Option<String>,
/// Do-date (earliest-actionable): today|tomorrow|+3d|fri|YYYY-MM-DD.
@ -71,7 +71,7 @@ enum Command {
/// Restrict to a project by NAME (subtree-expanded). e.g. --project Hephaestus.
#[arg(long)]
project: Option<String>,
/// Only this attention-state: white|orange|red|blue.
/// Only this attention: a1|a2|a3|a4 (or 1-4, or red|orange|white|blue).
#[arg(short = 'a', long)]
attention: Option<String>,
/// Hide on-deck (blue) items.
@ -105,7 +105,7 @@ enum Command {
Attention {
/// Task node id.
id: String,
/// white|orange|red|blue.
/// a1|a2|a3|a4 (or 1-4, or red|orange|white|blue).
attention: String,
},
/// Reschedule a task: change do-date / late-on / recurrence (use `none` to
@ -125,7 +125,7 @@ enum Command {
/// A raw RRULE or `none`.
#[arg(long)]
rrule: Option<String>,
/// Set attention: white|orange|red|blue.
/// Set attention: a1|a2|a3|a4 (or 1-4, or red|orange|white|blue).
#[arg(short = 'a', long)]
attention: Option<String>,
/// Re-file under a project (by name); `none` unfiles the task.
@ -138,7 +138,7 @@ enum Command {
container_id: String,
/// 1-based index of the context item to promote (document order).
item_ref: usize,
/// Attention for the new task: white|orange|red|blue.
/// Attention for the new task: a1|a2|a3|a4 (or 1-4, or red|orange|white|blue).
#[arg(short = 'a', long)]
attention: Option<String>,
/// Project name to file the new task under.
@ -489,6 +489,7 @@ fn main() -> Result<()> {
recur,
rrule,
} => {
let attention = norm_attention(attention)?;
let recurrence = recurrence_value(recur.as_deref(), rrule.as_deref())?;
let project_id = resolve_project(&mut client, project.as_deref())?;
let result = client.call(
@ -515,6 +516,7 @@ fn main() -> Result<()> {
// `list` takes a ListFilter (tech-spec §8.2). Map the flags: a single
// `--scope` id or `--project` NAME (resolved + subtree-expanded by the
// daemon), a single `--attention` whitelist, and `--no-blue`.
let attention = norm_attention(attention)?;
let mut filter = json!({});
if let Some(s) = scope {
filter["scope"] = json!([s]);
@ -558,11 +560,12 @@ fn main() -> Result<()> {
println!("Skipped occurrence of {id}");
}
Command::Attention { id, attention } => {
let att = Attention::parse_input(&attention)?;
client.call(
"task.set_attention",
json!({ "id": id, "attention": attention }),
json!({ "id": id, "attention": att.as_str() }),
)?;
println!("{id} attention → {attention}");
println!("{id} attention → {} ({})", att.ui_label(), att.as_str());
}
Command::Edit {
id,
@ -588,7 +591,7 @@ fn main() -> Result<()> {
if patch.len() > 1 {
client.call("task.set_schedule", Value::Object(patch))?;
}
if let Some(a) = attention {
if let Some(a) = norm_attention(attention)? {
client.call("task.set_attention", json!({ "id": id, "attention": a }))?;
}
if let Some(spec) = project.as_deref() {
@ -612,6 +615,7 @@ fn main() -> Result<()> {
attention,
project,
} => {
let attention = norm_attention(attention)?;
let project_id = resolve_project(&mut client, project.as_deref())?;
let result = client.call(
"task.promote",
@ -863,6 +867,15 @@ fn main() -> Result<()> {
}
/// Parse an optional human date into epoch-ms JSON (for `task.create`).
/// Normalize a user-facing `--attention` value to its storage colour string.
/// Accepts the `a1`..`a4` labels, a bare digit `1`..`4`, or a colour word
/// (`red`/`orange`/`white`/`blue`). `None` passes through unchanged.
fn norm_attention(a: Option<String>) -> Result<Option<String>> {
a.map(|s| Attention::parse_input(&s).map(|att| att.as_str().to_string()))
.transpose()
.map_err(Into::into)
}
fn opt_date_ms(spec: Option<&str>) -> Result<Option<i64>> {
spec.map(datespec::parse_date_ms).transpose()
}

View file

@ -1 +1 @@
Attention is now set directly instead of cycled, and surfaces it as `a1``a4` (a1=red, a2=orange, a3=white, a4=blue) rather than the colour words. In heph-tui press `a` then `1``4` to set a band (the old `A` cycle and `b` push-to-blue are retired; quick-add moves to `n`); heph-quickadd and the PWA show the same `a1``a4` labels, and the PWA's Attn action now pops a band picker. Quick-add inline syntax changes from `p1``p4` to `a1``a4` across every capture surface. The colour mappings are unchanged.
Attention is now set directly instead of cycled, and surfaces it as `a1``a4` (a1=red, a2=orange, a3=white, a4=blue) rather than the colour words. In heph-tui press `a` then `1``4` to set a band (the old `A` cycle and `b` push-to-blue are retired; quick-add moves to `n`); heph-quickadd and the PWA show the same `a1``a4` labels, and the PWA's Attn action now pops a band picker. Quick-add inline syntax changes from `p1``p4` to `a1``a4` across every capture surface. The `heph` CLI's `-a/--attention` flag now accepts `a1``a4`, a bare `1``4`, or a colour word (`red`/`orange`/`white`/`blue`). The colour mappings are unchanged.