From 2ca1e246f0b178de046bb781c60c595a7fdaab16 Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Mon, 8 Jun 2026 14:15:03 -0700 Subject: [PATCH 1/8] Update changelog for v1.3.0 [skip ci] --- CHANGELOG.md | 14 ++++++++++++++ docs/changelog.d/+sync-age-seconds.feature.md | 1 - docs/changelog.d/auth-error-clarity.bugfix.md | 1 - docs/changelog.d/auth-error-clarity.feature.md | 1 - docs/changelog.d/daemon-restart-race.bugfix.md | 1 - .../daemon-self-update-interval.feature.md | 1 - 6 files changed, 14 insertions(+), 5 deletions(-) delete mode 100644 docs/changelog.d/+sync-age-seconds.feature.md delete mode 100644 docs/changelog.d/auth-error-clarity.bugfix.md delete mode 100644 docs/changelog.d/auth-error-clarity.feature.md delete mode 100644 docs/changelog.d/daemon-restart-race.bugfix.md delete mode 100644 docs/changelog.d/daemon-self-update-interval.feature.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9799e3f..1acdbe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.3.0] - 2026-06-08 + +### Features + +- Spoke auth failures now tell you how to recover. When a refresh token is rejected or the hub returns 401, `hephd` records the real cause plus the exact `heph auth login --hub-url … --issuer … --client-id …` command (keyed to this spoke's hub) in its sync health. A new `heph auth status` prints that health and the re-login command, `heph sync --status`'s `last_error` carries it, and `heph-tui`'s status line points at it with a `⚠ auth · heph auth status` chip. +- `heph daemon start`/`restart` can now bake the daemon's full runtime config into the managed service — `--mode`, `--hub-url`, `--http-addr`, `--oidc-issuer`/`--oidc-audience`/`--oidc-client-id`, and `--self-update-interval-secs` (previously only the bare `--self-update` bool was wired). Regenerating preserves whatever is already baked into the on-disk plist/unit, so a bare `start`/`restart` no longer silently drops spoke/hub or self-update config. +- heph-tui's sync indicator now shows the last-sync age in seconds under a minute (`⟳ 26s`) instead of a flat `just now`, so the chip reads as a live heartbeat and a missed sync (the loop runs every 30s) shows up as the age climbing. + +### Bug Fixes + +- hephd no longer reports a rejected OAuth refresh as "identity provider unreachable". A reachable IdP that returns an HTTP error (e.g. `400 invalid_grant` once a refresh token expires/rotates) is now surfaced as a *rejection* — `identity provider rejected the request: HTTP 400 (invalid_grant): …` — with the OAuth error body, distinct from a genuine transport failure. This stops the wording from misdirecting incident response toward the network when the real fix is re-authentication. +- `heph daemon restart` on macOS no longer intermittently fails with `launchctl bootstrap failed: 5: Input/output error`. The old code bootstrapped immediately after `bootout`, racing launchd's asynchronous teardown; it now waits for the service to fully unload and retries the bootstrap. When the plist is unchanged (e.g. a plain binary upgrade) it uses `launchctl kickstart -k` to restart the loaded job atomically, sidestepping the bootout→bootstrap dance entirely. + + ## [v1.2.3] - 2026-06-06 ### Features diff --git a/docs/changelog.d/+sync-age-seconds.feature.md b/docs/changelog.d/+sync-age-seconds.feature.md deleted file mode 100644 index cf453c2..0000000 --- a/docs/changelog.d/+sync-age-seconds.feature.md +++ /dev/null @@ -1 +0,0 @@ -heph-tui's sync indicator now shows the last-sync age in seconds under a minute (`⟳ 26s`) instead of a flat `just now`, so the chip reads as a live heartbeat and a missed sync (the loop runs every 30s) shows up as the age climbing. diff --git a/docs/changelog.d/auth-error-clarity.bugfix.md b/docs/changelog.d/auth-error-clarity.bugfix.md deleted file mode 100644 index 83ba854..0000000 --- a/docs/changelog.d/auth-error-clarity.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -hephd no longer reports a rejected OAuth refresh as "identity provider unreachable". A reachable IdP that returns an HTTP error (e.g. `400 invalid_grant` once a refresh token expires/rotates) is now surfaced as a *rejection* — `identity provider rejected the request: HTTP 400 (invalid_grant): …` — with the OAuth error body, distinct from a genuine transport failure. This stops the wording from misdirecting incident response toward the network when the real fix is re-authentication. diff --git a/docs/changelog.d/auth-error-clarity.feature.md b/docs/changelog.d/auth-error-clarity.feature.md deleted file mode 100644 index ab67867..0000000 --- a/docs/changelog.d/auth-error-clarity.feature.md +++ /dev/null @@ -1 +0,0 @@ -Spoke auth failures now tell you how to recover. When a refresh token is rejected or the hub returns 401, `hephd` records the real cause plus the exact `heph auth login --hub-url … --issuer … --client-id …` command (keyed to this spoke's hub) in its sync health. A new `heph auth status` prints that health and the re-login command, `heph sync --status`'s `last_error` carries it, and `heph-tui`'s status line points at it with a `⚠ auth · heph auth status` chip. diff --git a/docs/changelog.d/daemon-restart-race.bugfix.md b/docs/changelog.d/daemon-restart-race.bugfix.md deleted file mode 100644 index c13a257..0000000 --- a/docs/changelog.d/daemon-restart-race.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -`heph daemon restart` on macOS no longer intermittently fails with `launchctl bootstrap failed: 5: Input/output error`. The old code bootstrapped immediately after `bootout`, racing launchd's asynchronous teardown; it now waits for the service to fully unload and retries the bootstrap. When the plist is unchanged (e.g. a plain binary upgrade) it uses `launchctl kickstart -k` to restart the loaded job atomically, sidestepping the bootout→bootstrap dance entirely. diff --git a/docs/changelog.d/daemon-self-update-interval.feature.md b/docs/changelog.d/daemon-self-update-interval.feature.md deleted file mode 100644 index b5ec9b8..0000000 --- a/docs/changelog.d/daemon-self-update-interval.feature.md +++ /dev/null @@ -1 +0,0 @@ -`heph daemon start`/`restart` can now bake the daemon's full runtime config into the managed service — `--mode`, `--hub-url`, `--http-addr`, `--oidc-issuer`/`--oidc-audience`/`--oidc-client-id`, and `--self-update-interval-secs` (previously only the bare `--self-update` bool was wired). Regenerating preserves whatever is already baked into the on-disk plist/unit, so a bare `start`/`restart` no longer silently drops spoke/hub or self-update config. From 5c2b4bde2cf18f0ba45b1edff7c474cf092d3b9f Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 8 Jun 2026 14:35:10 -0700 Subject: [PATCH 2/8] Relabel changelog v1.3.0 section as v1.4.0 [skip ci] A double workflow_dispatch produced both v1.3.0 and an empty duplicate v1.4.0 (the version actually deployed via self-update). Move the release notes onto v1.4.0 to match what shipped; v1.3.0 release+tag are being removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1acdbe1..aa29354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [v1.3.0] - 2026-06-08 +## [v1.4.0] - 2026-06-08 ### Features From b04a71421ed437320080e1d9cd1a64f6439f3c01 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 8 Jun 2026 15:19:10 -0700 Subject: [PATCH 3/8] fix(hephd): reconnect the socket client across daemon restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Client` connected to the unix socket once and never reconnected, so after an opt-in self-update or `heph daemon restart` dropped the socket, every later `call()` failed — `heph-tui` would sit on errors until relaunched (and the work we just shipped makes restarts more frequent). `Client` now stores the socket path and reconnects on a dropped connection, classifying the failure to stay safe: - write-side failure (request never reached the daemon) → reconnect + retry once; - reply lost after sending (daemon closed mid-request) → reconnect for next time but surface this one, so a mutation is never silently double-applied; - genuine RPC errors are passed through untouched. heph-tui and the CLI use `Client` unchanged, so the TUI self-heals on its next refresh tick. Adds an integration test driving a mock daemon that drops the connection after each request. Closes the "heph-tui: reconnect on a dropped daemon socket" backlog task. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hephd/src/client.rs | 112 +++++++++++++++++--- crates/hephd/tests/client_reconnect.rs | 96 +++++++++++++++++ docs/changelog.d/client-reconnect.bugfix.md | 1 + docs/how-to/run-the-daemon.md | 8 ++ 4 files changed, 204 insertions(+), 13 deletions(-) create mode 100644 crates/hephd/tests/client_reconnect.rs create mode 100644 docs/changelog.d/client-reconnect.bugfix.md diff --git a/crates/hephd/src/client.rs b/crates/hephd/src/client.rs index c3c008b..8a2bd5d 100644 --- a/crates/hephd/src/client.rs +++ b/crates/hephd/src/client.rs @@ -2,59 +2,145 @@ //! //! Used by the `heph` CLI and by tests. Surfaces never touch SQLite directly //! (tech-spec §3) — they go through the daemon socket, which this wraps. +//! +//! The connection self-heals across daemon restarts (opt-in self-update, `heph +//! daemon restart`): a [`call`](Client::call) that finds the socket dropped +//! reconnects. It only auto-retries when the request provably never reached the +//! daemon (a write-side failure); a reply lost *after* sending is surfaced +//! rather than retried, so a mutation is never silently double-applied. use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; -use std::path::Path; +use std::path::{Path, PathBuf}; -use anyhow::{bail, Context, Result}; +use anyhow::{anyhow, Context, Result}; use serde_json::{json, Value}; use crate::rpc::Response; /// A connected client. One request/response per [`call`](Client::call). pub struct Client { + socket_path: PathBuf, reader: BufReader, writer: UnixStream, next_id: u64, } +/// How a single request/response exchange failed — drives the retry decision. +enum ExchangeError { + /// The request could not be written (broken pipe, reset): it never reached + /// the daemon, so retrying on a fresh connection is safe. + Send(anyhow::Error), + /// The request was sent but no reply came back (the daemon closed mid-flight, + /// e.g. it restarted): it may or may not have applied — do not retry. + Recv(anyhow::Error), + /// A well-formed RPC-level error (or an unparseable reply): the connection is + /// fine; nothing to reconnect. + Rpc(anyhow::Error), +} + +impl ExchangeError { + fn into_inner(self) -> anyhow::Error { + match self { + ExchangeError::Send(e) | ExchangeError::Recv(e) | ExchangeError::Rpc(e) => e, + } + } +} + impl Client { /// Connect to a daemon listening at `socket_path`. pub fn connect(socket_path: &Path) -> Result { - let stream = UnixStream::connect(socket_path) - .with_context(|| format!("connecting to hephd at {}", socket_path.display()))?; - let reader = BufReader::new(stream.try_clone()?); + let (reader, writer) = Self::open(socket_path)?; Ok(Client { + socket_path: socket_path.to_path_buf(), reader, - writer: stream, + writer, next_id: 1, }) } + /// Open a fresh reader/writer pair on the socket. + fn open(socket_path: &Path) -> Result<(BufReader, UnixStream)> { + let stream = UnixStream::connect(socket_path) + .with_context(|| format!("connecting to hephd at {}", socket_path.display()))?; + let reader = BufReader::new(stream.try_clone()?); + Ok((reader, stream)) + } + + /// Re-establish the connection (after the daemon restarted and dropped it). + fn reconnect(&mut self) -> Result<()> { + let (reader, writer) = Self::open(&self.socket_path)?; + self.reader = reader; + self.writer = writer; + Ok(()) + } + /// Call `method` with `params`, returning the `result` value (or an error /// carrying the RPC error's code and message). + /// + /// If the daemon has restarted and dropped the socket, this reconnects: it + /// retries transparently when the request never went out, and otherwise + /// reconnects for the next call while surfacing an error for this one (so a + /// mutation whose reply was lost is not silently re-applied). pub fn call(&mut self, method: &str, params: Value) -> Result { let id = self.next_id; self.next_id += 1; - let mut line = serde_json::to_string(&json!({ "id": id, "method": method, "params": params, }))?; line.push('\n'); - self.writer.write_all(line.as_bytes())?; - self.writer.flush()?; + + match self.exchange(&line) { + Ok(v) => Ok(v), + Err(ExchangeError::Rpc(e)) => Err(e), + Err(ExchangeError::Send(_)) => { + // The request never reached the daemon — reconnect and retry once. + self.reconnect() + .context("hephd connection lost and reconnect failed")?; + self.exchange(&line) + .map_err(ExchangeError::into_inner) + .with_context(|| format!("retrying `{method}` after reconnect")) + } + Err(ExchangeError::Recv(e)) => { + // Sent but no reply: the daemon likely restarted mid-request. Don't + // retry (a mutation may have applied); reconnect for next time and + // surface this one. + let _ = self.reconnect(); + Err(e).context( + "hephd closed the connection mid-request (it likely restarted); \ + reconnected — re-run the action if it didn't take effect", + ) + } + } + } + + /// One request/response over the current connection, classifying failures. + fn exchange(&mut self, line: &str) -> std::result::Result { + self.writer + .write_all(line.as_bytes()) + .map_err(|e| ExchangeError::Send(e.into()))?; + self.writer + .flush() + .map_err(|e| ExchangeError::Send(e.into()))?; let mut response_line = String::new(); - let read = self.reader.read_line(&mut response_line)?; + let read = self + .reader + .read_line(&mut response_line) + .map_err(|e| ExchangeError::Recv(e.into()))?; if read == 0 { - bail!("hephd closed the connection"); + return Err(ExchangeError::Recv(anyhow!("hephd closed the connection"))); } - let response: Response = serde_json::from_str(&response_line)?; + let response: Response = + serde_json::from_str(&response_line).map_err(|e| ExchangeError::Rpc(e.into()))?; if let Some(err) = response.error { - bail!("rpc error {}: {}", err.code, err.message); + return Err(ExchangeError::Rpc(anyhow!( + "rpc error {}: {}", + err.code, + err.message + ))); } Ok(response.result.unwrap_or(Value::Null)) } diff --git a/crates/hephd/tests/client_reconnect.rs b/crates/hephd/tests/client_reconnect.rs new file mode 100644 index 0000000..a4d0074 --- /dev/null +++ b/crates/hephd/tests/client_reconnect.rs @@ -0,0 +1,96 @@ +//! [`Client`] survives the daemon dropping the socket (opt-in self-update, `heph +//! daemon restart`). A mock daemon serves exactly one request per connection +//! then closes it, forcing the client to reconnect — without auto-reconnect, +//! every call after the first would fail forever. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixListener; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use hephd::Client; +use serde_json::{json, Value}; + +/// A mock daemon that handles ONE request per connection then closes it, looping +/// to accept the next connection. `served` counts total requests answered. +fn spawn_one_shot_daemon(socket: PathBuf, served: Arc) { + thread::spawn(move || { + let listener = UnixListener::bind(&socket).unwrap(); + for conn in listener.incoming() { + let Ok(mut stream) = conn else { continue }; + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + continue; // client opened then went away; wait for the next one + } + let req: Value = serde_json::from_str(&line).unwrap(); + let n = served.fetch_add(1, Ordering::SeqCst) + 1; + let mut out = serde_json::to_string(&json!({ + "id": req["id"], + "result": { "served": n }, + })) + .unwrap(); + out.push('\n'); + let _ = stream.write_all(out.as_bytes()); + let _ = stream.flush(); + // `stream` drops here → the connection closes after one request. + } + }); +} + +fn wait_for(socket: &std::path::Path) { + for _ in 0..400 { + if socket.exists() { + return; + } + thread::sleep(Duration::from_millis(5)); + } + panic!("mock daemon socket never appeared"); +} + +#[test] +fn client_reconnects_after_the_daemon_drops_the_socket() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("d.sock"); + let served = Arc::new(AtomicUsize::new(0)); + spawn_one_shot_daemon(socket.clone(), served.clone()); + wait_for(&socket); + + let mut c = Client::connect(&socket).unwrap(); + + // First call works on the initial connection. + let r1 = c.call("ping", json!({})).unwrap(); + assert_eq!(r1["served"], 1); + + // The daemon has now closed that connection. With reconnect, the client + // recovers within a call or two (depending on whether the dead socket fails + // on write or on read); without it, every further call would fail forever. + let mut recovered = None; + for _ in 0..2 { + if let Ok(v) = c.call("ping", json!({})) { + recovered = Some(v); + break; + } + } + let r = recovered.expect("client should reconnect after the socket was dropped"); + // The recovered call was served exactly once on the new connection — no + // double-serve from a spurious retry. + assert_eq!(r["served"], 2); + assert_eq!(served.load(Ordering::SeqCst), 2); + + // And it keeps working across subsequent drops. + let r3 = { + let mut got = None; + for _ in 0..2 { + if let Ok(v) = c.call("ping", json!({})) { + got = Some(v); + break; + } + } + got.expect("client should keep reconnecting") + }; + assert_eq!(r3["served"], 3); +} diff --git a/docs/changelog.d/client-reconnect.bugfix.md b/docs/changelog.d/client-reconnect.bugfix.md new file mode 100644 index 0000000..ae987b8 --- /dev/null +++ b/docs/changelog.d/client-reconnect.bugfix.md @@ -0,0 +1 @@ +The `heph` CLI and `heph-tui` now survive a daemon restart. Previously the unix-socket client connected once and never reconnected, so an opt-in self-update or `heph daemon restart` left every subsequent call failing — `heph-tui` would sit on errors until relaunched. The client now reconnects on a dropped socket: a request that never went out is retried transparently, while a reply lost mid-request is surfaced (not silently retried) so a mutation is never double-applied. A long-running TUI self-heals on its next refresh tick. diff --git a/docs/how-to/run-the-daemon.md b/docs/how-to/run-the-daemon.md index cb9e56d..545b3be 100644 --- a/docs/how-to/run-the-daemon.md +++ b/docs/how-to/run-the-daemon.md @@ -86,6 +86,14 @@ still the old binary until you restart it: heph daemon restart ``` +A restart (or an opt-in self-update) drops the daemon's unix socket out from +under any connected surface. The CLI and `heph-tui` **reconnect automatically**: +a read transparently retries on a fresh connection, and a long-running TUI +self-heals on its next tick — so a daemon restart no longer leaves the agenda +view stuck on errors. (A mutating action whose reply is lost mid-restart reports +"reconnected — re-run the action if it didn't take effect" rather than risk +applying twice.) + ## Self-update (opt-in) `hephd` can keep itself current: `heph daemon start --self-update` generates a From 470ef1de0e5551183649df45b8118a63d9f44450 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 8 Jun 2026 20:08:07 -0700 Subject: [PATCH 4/8] fix(quickadd): return focus to the previous app when the popover hides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global ⌘' quick-add overlay is a borderless, transparent, always-on-top accessory window that winit hides with `Visible(false)`. That orders the window out visually but leaves heph-quickadd the *active* application — so after a capture (or Esc / toggle) keyboard focus never returns to the app the user was in, and the lingering overlay can keep intercepting clicks where it used to sit. Hide at the application level instead via `NSApplication.hide:`, which fully orders our windows out and activates the next app in line (the previously focused one). On re-show, `unhide:` clears that hidden flag before the existing viewport `Focus` command makes the field key again. Both are macOS-only no-ops elsewhere, wired through new `app_yield_focus`/`app_take_focus` helpers backed by objc2 / objc2-app-kit (unified to the 0.6/0.3 line global-hotkey already pulls). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 + crates/heph-quickadd/Cargo.toml | 11 ++++- crates/heph-quickadd/src/app.rs | 43 +++++++++++++++++++ .../feature-quickadd-focus-return.bugfix.md | 1 + 4 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 docs/changelog.d/feature-quickadd-focus-return.bugfix.md diff --git a/Cargo.lock b/Cargo.lock index be8f974..cc9b3a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2237,6 +2237,8 @@ dependencies = [ "heph-core", "hephd", "libc", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "serde_json", "winit", ] diff --git a/crates/heph-quickadd/Cargo.toml b/crates/heph-quickadd/Cargo.toml index 5b1889b..57bbb98 100644 --- a/crates/heph-quickadd/Cargo.toml +++ b/crates/heph-quickadd/Cargo.toml @@ -19,7 +19,16 @@ global-hotkey = "0.8" # macOS-only: winit for the accessory-mode activation policy (no Dock icon), # pinned to the same minor eframe carries so cargo unifies to one winit; libc -# for getppid() (orphan detection — self-exit when the supervising daemon dies). +# for getppid() (orphan detection — self-exit when the supervising daemon dies); +# objc2 + objc2-app-kit to hand keyboard focus back to the previously active app +# when the popover hides (NSApplication.hide:/unhide:). Pinned to the 0.6/0.3 +# line global-hotkey already pulls in, so cargo unifies to one copy. [target.'cfg(target_os = "macos")'.dependencies] winit = "0.30" libc = "0.2" +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = [ + "std", + "NSApplication", + "NSResponder", +] } diff --git a/crates/heph-quickadd/src/app.rs b/crates/heph-quickadd/src/app.rs index b08bf03..a334b22 100644 --- a/crates/heph-quickadd/src/app.rs +++ b/crates/heph-quickadd/src/app.rs @@ -226,6 +226,9 @@ impl QuickAdd { } fn show(&mut self, ctx: &egui::Context) { + // Undo the app-level hide from the previous `hide()` so we can take focus + // again (no-op the first time / off macOS). + app_take_focus(); self.visible = true; self.focus_pending = true; self.current_hint = random_hint(self.current_hint); @@ -256,6 +259,13 @@ impl QuickAdd { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(WIN_W, BASE_H))); self.win_h_applied = BASE_H; } + // Hand keyboard focus back to the app underneath us. winit's + // `Visible(false)` alone leaves *us* the active application, so focus + // never returns and the borderless always-on-top overlay can keep eating + // clicks where it used to sit. `NSApplication.hide:` orders our windows + // fully out and activates the next app in line — exactly the one the user + // was in (no-op off macOS). + app_yield_focus(); } /// Optimistic submit: hide now, create in the background. @@ -596,6 +606,39 @@ impl QuickAdd { } } +/// Hide the popover at the *application* level so macOS hands keyboard focus +/// back to the previously active app. `NSApplication.hide:` orders all our +/// windows out and activates the next app in line — the one the user was in — +/// which a plain winit `Visible(false)` does not do. No-op off macOS. +#[cfg(target_os = "macos")] +fn app_yield_focus() { + use objc2::MainThreadMarker; + use objc2_app_kit::NSApplication; + // eframe's `update` runs on the main thread, so this marker is always Some. + if let Some(mtm) = MainThreadMarker::new() { + NSApplication::sharedApplication(mtm).hide(None); + } +} + +#[cfg(not(target_os = "macos"))] +fn app_yield_focus() {} + +/// Undo [`app_yield_focus`]: clear the app-level hidden flag before re-showing, +/// so the window the viewport `Focus` command then makes key actually appears. +/// (`unhide:` also re-activates us; the per-window `Focus`/`Visible` viewport +/// commands do the rest.) No-op off macOS. +#[cfg(target_os = "macos")] +fn app_take_focus() { + use objc2::MainThreadMarker; + use objc2_app_kit::NSApplication; + if let Some(mtm) = MainThreadMarker::new() { + NSApplication::sharedApplication(mtm).unhide(None); + } +} + +#[cfg(not(target_os = "macos"))] +fn app_take_focus() {} + /// The current parent process id, for orphan detection. `None` off macOS (where /// hephd does not supervise a helper — there is no Aqua session to inherit). fn current_parent_pid() -> Option { diff --git a/docs/changelog.d/feature-quickadd-focus-return.bugfix.md b/docs/changelog.d/feature-quickadd-focus-return.bugfix.md new file mode 100644 index 0000000..6835eb5 --- /dev/null +++ b/docs/changelog.d/feature-quickadd-focus-return.bugfix.md @@ -0,0 +1 @@ +Quick-add popover (⌘'): hand keyboard focus back to the previously active app when it hides, and stop the (now invisible) overlay from intercepting clicks where it used to sit. From b34371af873f2d9a6ba3f097093b5efef22e07ef Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Mon, 8 Jun 2026 20:24:38 -0700 Subject: [PATCH 5/8] Update changelog for v1.4.1 [skip ci] --- CHANGELOG.md | 8 ++++++++ docs/changelog.d/client-reconnect.bugfix.md | 1 - docs/changelog.d/feature-quickadd-focus-return.bugfix.md | 1 - 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 docs/changelog.d/client-reconnect.bugfix.md delete mode 100644 docs/changelog.d/feature-quickadd-focus-return.bugfix.md diff --git a/CHANGELOG.md b/CHANGELOG.md index aa29354..1900ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.4.1] - 2026-06-08 + +### Bug Fixes + +- The `heph` CLI and `heph-tui` now survive a daemon restart. Previously the unix-socket client connected once and never reconnected, so an opt-in self-update or `heph daemon restart` left every subsequent call failing — `heph-tui` would sit on errors until relaunched. The client now reconnects on a dropped socket: a request that never went out is retried transparently, while a reply lost mid-request is surfaced (not silently retried) so a mutation is never double-applied. A long-running TUI self-heals on its next refresh tick. +- Quick-add popover (⌘'): hand keyboard focus back to the previously active app when it hides, and stop the (now invisible) overlay from intercepting clicks where it used to sit. + + ## [v1.4.0] - 2026-06-08 ### Features diff --git a/docs/changelog.d/client-reconnect.bugfix.md b/docs/changelog.d/client-reconnect.bugfix.md deleted file mode 100644 index ae987b8..0000000 --- a/docs/changelog.d/client-reconnect.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -The `heph` CLI and `heph-tui` now survive a daemon restart. Previously the unix-socket client connected once and never reconnected, so an opt-in self-update or `heph daemon restart` left every subsequent call failing — `heph-tui` would sit on errors until relaunched. The client now reconnects on a dropped socket: a request that never went out is retried transparently, while a reply lost mid-request is surfaced (not silently retried) so a mutation is never double-applied. A long-running TUI self-heals on its next refresh tick. diff --git a/docs/changelog.d/feature-quickadd-focus-return.bugfix.md b/docs/changelog.d/feature-quickadd-focus-return.bugfix.md deleted file mode 100644 index 6835eb5..0000000 --- a/docs/changelog.d/feature-quickadd-focus-return.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Quick-add popover (⌘'): hand keyboard focus back to the previously active app when it hides, and stop the (now invisible) overlay from intercepting clicks where it used to sit. From ebb236623630d07b2757eabf5ce4c4559ffd794d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 9 Jun 2026 07:50:53 -0700 Subject: [PATCH 6/8] =?UTF-8?q?feat(attention):=20set=20bands=20directly?= =?UTF-8?q?=20as=20a1=E2=80=93a4=20instead=20of=20cycling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire the `A` attention cycle and the duplicate `b` push-to-blue gesture in heph-tui. Attention is now picked directly: press `a` then `1`–`4` (a1=red, a2=orange, a3=white, a4=blue, ordered by intensity). Cycling past blue used to make a task vanish from the current view with no way back — direct selection never does. Quick-add moves from `a` to `n`. Surface the a1–a4 nomenclature everywhere instead of colour words or the old p1–p4 priorities: heph-tui status/legend, the heph-quickadd chip + hint, and the PWA chip/hint plus a new band-picker (replacing its cycle button). The shared quick-add parser now accepts `a1`–`a4` (a1=red … a4=blue) and no longer recognizes `p1`–`p4`. Colour mappings are unchanged; only the words. Add Attention::ui_label() in heph-core so both Rust surfaces share the mapping; bump the PWA service-worker cache; update the PWA how-to. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-core/src/model.rs | 12 ++++ crates/heph-quickadd/src/app.rs | 58 +++++++-------- crates/heph-quickadd/src/main.rs | 2 +- crates/heph-tui/src/app.rs | 71 +++++++++++++------ crates/heph-tui/src/main.rs | 15 +++- crates/heph-tui/src/ui.rs | 4 +- crates/heph-tui/tests/agenda.rs | 10 ++- crates/heph-tui/tests/navigation.rs | 19 ++--- crates/hephd/src/quickadd.rs | 40 +++++++---- .../feature-attention-a1-a4.feature.md | 1 + docs/how-to/heph-pwa.md | 7 +- heph-pwa/src/app.js | 32 ++++++--- heph-pwa/src/fmt.js | 17 ++--- heph-pwa/src/quickadd.js | 19 ++--- heph-pwa/sw.js | 2 +- heph-pwa/test/parsers.test.mjs | 21 ++++-- 16 files changed, 205 insertions(+), 125 deletions(-) create mode 100644 docs/changelog.d/feature-attention-a1-a4.feature.md diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index 783f4cf..49ebee8 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -106,6 +106,18 @@ impl Attention { other => return Err(Error::Integrity(format!("unknown attention: {other}"))), }) } + + /// The UI nomenclature (`a1`..`a4`), ordered by intensity — surfaces show + /// these instead of the colour words. The colour *mapping* is unchanged: + /// a1 = red, a2 = orange, a3 = white, a4 = blue. + pub fn ui_label(self) -> &'static str { + match self { + Attention::Red => "a1", + Attention::Orange => "a2", + Attention::White => "a3", + Attention::Blue => "a4", + } + } } /// A committed task's lifecycle state (tech-spec §4.3). `done` and `dropped` diff --git a/crates/heph-quickadd/src/app.rs b/crates/heph-quickadd/src/app.rs index a334b22..707c66a 100644 --- a/crates/heph-quickadd/src/app.rs +++ b/crates/heph-quickadd/src/app.rs @@ -43,46 +43,46 @@ const HINT_DELAY: f64 = 2.0; /// `#project`). Unresolved `#tags` just stay in the title, so these are safe even /// though they reference projects a given store may not have. const HINTS: &[&str] = &[ - "Water plants tomorrow p2 #Chores every 3 days", - "Call the dentist fri p1", + "Water plants tomorrow a2 #Chores every 3 days", + "Call the dentist fri a1", "Email Sarah the report today", "Buy milk #Errands", - "Renew passport +30d p2", - "Review pull requests p3 #Work", + "Renew passport +30d a2", + "Review pull requests a4 #Work", "Take out recycling every other wed", - "Pay rent every 1st p1", + "Pay rent every 1st a1", "Stretch every day", "Submit timesheet every friday #Work", "Water the garden every 2 days", - "Back up the laptop every week p3", - "Book flights +1w p2 #Travel", - "Doctor appointment 2026-07-15 p1", + "Back up the laptop every week a4", + "Book flights +1w a2 #Travel", + "Doctor appointment 2026-07-15 a1", "Read a chapter today #Reading", "Standup notes every weekday #Work", "Change the air filter every 3 months", - "File taxes every April 15 p1", + "File taxes every April 15 a1", "Clean the gutters every 6 months #Home", - "Wish Mom happy birthday every May 4 p1", + "Wish Mom happy birthday every May 4 a1", "Vacuum the house every saturday #Chores", "Replace toothbrush every 3 months", - "Prep slides for monday p2 #Work", + "Prep slides for monday a2 #Work", "Walk the dog every day", - "Refill prescription every 30 days p2 #Health", + "Refill prescription every 30 days a2 #Health", "Grocery run +2d #Errands", "Mow the lawn every week #Home", - "Schedule a 1:1 with Alex thu p3 #Work", - "Send the invoice every 15th p2", + "Schedule a 1:1 with Alex thu a4 #Work", + "Send the invoice every 15th a2", "Defrost the freezer every 6 months", - "Update the resume +14d p3", + "Update the resume +14d a4", "Check smoke detectors every 6 months #Home", "Plan the sprint every other monday #Work", "Order coffee beans every 2 weeks", - "Call grandma every sunday p2", + "Call grandma every sunday a2", "Rotate the car tires every 6 months #Car", - "Weekly review every friday p2", + "Weekly review every friday a2", "Pick up dry cleaning tomorrow #Errands", - "Pay the credit card every 28th p1", - "Tidy the inbox every day p4", + "Pay the credit card every 28th a1", + "Tidy the inbox every day a3", ]; /// Pick a hint pseudo-randomly, never the same one twice in a row. No `rand` @@ -550,18 +550,14 @@ impl QuickAdd { let mut any = false; if let Some(att) = parsed.attention { - let (label, color) = match att { - heph_core::Attention::Red => { - ("⚑ red", egui::Color32::from_rgb(0xe0, 0x6c, 0x60)) - } - heph_core::Attention::Orange => { - ("⚑ orange", egui::Color32::from_rgb(0xe5, 0xc0, 0x7b)) - } - heph_core::Attention::Blue => { - ("⚑ blue", egui::Color32::from_rgb(0x61, 0xaf, 0xef)) - } - heph_core::Attention::White => ("⚑ white", egui::Color32::from_gray(200)), + // a1–a4 nomenclature; the colour mapping is unchanged. + let color = match att { + heph_core::Attention::Red => egui::Color32::from_rgb(0xe0, 0x6c, 0x60), + heph_core::Attention::Orange => egui::Color32::from_rgb(0xe5, 0xc0, 0x7b), + heph_core::Attention::Blue => egui::Color32::from_rgb(0x61, 0xaf, 0xef), + heph_core::Attention::White => egui::Color32::from_gray(200), }; + let label = format!("⚑ {}", att.ui_label()); ui.label(egui::RichText::new(label).color(color).size(LABEL_SIZE)); any = true; } @@ -597,7 +593,7 @@ impl QuickAdd { if !any { ui.label( - egui::RichText::new("type p1–p4 · #project · a date · every …") + egui::RichText::new("type a1–a4 · #project · a date · every …") .color(egui::Color32::from_gray(140)) .size(LABEL_SIZE), ); diff --git a/crates/heph-quickadd/src/main.rs b/crates/heph-quickadd/src/main.rs index 2e70b81..83763d1 100644 --- a/crates/heph-quickadd/src/main.rs +++ b/crates/heph-quickadd/src/main.rs @@ -1,7 +1,7 @@ //! `heph-quickadd` — the global quick-capture popover (tech-spec §8). //! //! A tiny always-warm egui agent: ⌘' shows a single-line capture field that -//! parses Todoist-style inline syntax (`p2 #Chores tomorrow every 3 days`) and +//! parses Todoist-style inline syntax (`a2 #Chores tomorrow every 3 days`) and //! creates a task over the `hephd` unix socket. It is **supervised by hephd** //! (spawned in local mode on macOS), so the user installs/manages exactly one //! service — there is no separate launch agent. diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index e60a969..51276ea 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -289,15 +289,16 @@ fn fuzzy_match(query: &str, cand: &str) -> bool { true } -/// 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 { - 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, +/// Map an attention-chord digit (`1`..`4`) to its band, ordered by intensity: +/// 1 = a1 (red), 2 = a2 (orange), 3 = a3 (white), 4 = a4 (blue). Any other +/// character is not an attention key. +pub fn attention_for_digit(c: char) -> Option { + match c { + '1' => Some(Attention::Red), + '2' => Some(Attention::Orange), + '3' => Some(Attention::White), + '4' => Some(Attention::Blue), + _ => None, } } @@ -429,6 +430,9 @@ pub struct App { pub search: Option, /// When `Some`, a delete is awaiting y/N confirmation. pub pending_delete: Option, + /// When `true`, an attention chord is in progress: `a` was pressed and the + /// next `1`..`4` sets the highlighted task's band (any other key cancels). + pub pending_attention: bool, /// Reversible triage history (`u` undoes, Ctrl-z redoes). undo_stack: Vec, redo_stack: Vec, @@ -471,6 +475,7 @@ impl App { sort_mode: SortMode::Default, search: None, pending_delete: None, + pending_attention: false, undo_stack: Vec::new(), redo_stack: Vec::new(), status: String::new(), @@ -722,26 +727,46 @@ impl App { 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 { + /// Begin an attention chord: arm `pending_attention` so the next `1`..`4` + /// sets the highlighted task's band directly (§6.2). No-op (with a hint) if + /// nothing is highlighted. The chord replaces the old `A` cycle / `b` blue + /// gestures — picking a band directly never makes the task vanish out of + /// reach the way cycling past blue did. + pub fn begin_attention(&mut self) { + if self.selected_task().is_none() { return; - }; - let next = next_attention(t.attention); - self.push_undo((&t).into(), TriageAction::Attention(next)); - self.mutate(format!("{}: {}", next.as_str(), t.title), |b| { - b.set_attention(&t.node_id, next) - }); + } + self.pending_attention = true; + self.status = "attention: 1=a1 2=a2 3=a3 4=a4 (esc cancels)".into(); } - /// Push the highlighted task to On Deck (blue) — the pressure-relief valve. - pub fn push_to_blue_selected(&mut self) { + /// Resolve an armed attention chord with the pressed key. `1`..`4` set the + /// band; anything else cancels. Returns whether the key was consumed. + pub fn resolve_attention(&mut self, c: char) { + self.pending_attention = false; + let Some(att) = attention_for_digit(c) else { + self.status = "attention: cancelled".into(); + return; + }; + self.set_attention_selected(att); + } + + /// Cancel an armed attention chord (e.g. on Esc / focus change). + pub fn cancel_attention(&mut self) { + if self.pending_attention { + self.pending_attention = false; + self.status = "attention: cancelled".into(); + } + } + + /// Set the highlighted task's attention band directly (the `a`+digit chord). + pub fn set_attention_selected(&mut self, att: Attention) { let Some(t) = self.selected_task().cloned() else { return; }; - self.push_undo((&t).into(), TriageAction::Attention(Attention::Blue)); - self.mutate(format!("→ on deck: {}", t.title), |b| { - b.set_attention(&t.node_id, Attention::Blue) + self.push_undo((&t).into(), TriageAction::Attention(att)); + self.mutate(format!("{}: {}", att.ui_label(), t.title), |b| { + b.set_attention(&t.node_id, att) }); } diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index b672d7b..34648fa 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -119,6 +119,16 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option app.resolve_attention(c), + _ => app.cancel_attention(), + } + return None; + } + // While collecting input, all keys go to the prompt. if matches!(app.mode, Mode::Input(_)) { match key.code { @@ -179,7 +189,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option app.focus_tasks(), // Enter: drill sidebar→tasks, or open the selected task's context in nvim. KeyCode::Enter => return app.enter().map(Action::EditContext), - KeyCode::Char('a') => app.begin_add(), + KeyCode::Char('n') => app.begin_add(), KeyCode::Char('/') => app.begin_search(), KeyCode::Char('s') => app.toggle_sort(), KeyCode::Char('u') => app.undo(), @@ -191,8 +201,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option 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(), + KeyCode::Char('a') => app.begin_attention(), KeyCode::Char('e') => app.begin_reschedule(), KeyCode::Char('m') => app.begin_move(), KeyCode::Char('D') => app.begin_delete(), diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index f6d2f37..bcd885e 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -19,11 +19,11 @@ use crate::fmt::{fmt_age, fmt_date, now_ms, project_color, today_local}; // Task-pane gestures (the focused pane shows its own hints, §8.1). const HINTS: &str = - " j/k move ⏎ edit x done d drop S skip e date A attn b→blue m move D del u undo / search q quit"; + " j/k move ⏎ edit n add x done d drop S skip e date a 1-4 attn m move D del u undo / search q quit"; // Sidebar gestures: navigation + per-project actions (no task triage here). const SIDEBAR_HINTS: &str = - " j/k move ⏎ open a add D del-project u undo s sort / search Tab tasks q quit"; + " j/k move ⏎ open n add D del-project u undo s sort / search Tab tasks q quit"; const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search"; diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 84ca739..81b32d8 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -175,8 +175,8 @@ fn quick_add_captures_a_task_that_appears_in_the_view() { assert!(app.tasks.is_empty()); app.begin_add(); - // Single-line NL: p1 → red, so it lands in Top of Mind (the default view). - type_and_submit(&mut app, "Call the plumber p1"); + // Single-line NL: a1 → red, so it lands in Top of Mind (the default view). + type_and_submit(&mut app, "Call the plumber a1"); assert!(app.status.contains("added"), "status: {}", app.status); assert!( @@ -304,7 +304,11 @@ fn pushing_to_blue_moves_a_task_out_of_top_of_mind() { let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); assert_eq!(app.tasks.len(), 1); - app.push_to_blue_selected(); + // `a` then `4` sets a4 (blue) directly — the chord that replaced push-to-blue. + app.begin_attention(); + assert!(app.pending_attention); + app.resolve_attention('4'); + assert!(!app.pending_attention); assert!(app.tasks.is_empty(), "blue task should leave Top of Mind"); // It now appears under On Deck (the last of the five views). diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 1d1e7b5..83ec24e 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -218,13 +218,14 @@ fn move_task_clamps_at_the_ends() { } #[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); +fn attention_digits_map_by_intensity() { + use heph_tui::app::attention_for_digit; + assert_eq!(attention_for_digit('1'), Some(Attention::Red)); + assert_eq!(attention_for_digit('2'), Some(Attention::Orange)); + assert_eq!(attention_for_digit('3'), Some(Attention::White)); + assert_eq!(attention_for_digit('4'), Some(Attention::Blue)); + assert_eq!(attention_for_digit('5'), None); + assert_eq!(attention_for_digit('a'), None); } fn type_and_submit(app: &mut App, s: &str) { @@ -248,12 +249,12 @@ fn quick_add_files_under_the_current_project_when_no_tag_given() { assert_eq!(app.task_pane_title(), "Camano"); app.begin_add(); - type_and_submit(&mut app, "Fix the dock p2"); + type_and_submit(&mut app, "Fix the dock a2"); let created = &rec.borrow().created; assert_eq!(created.len(), 1); assert_eq!(created[0].0, "Fix the dock"); - assert_eq!(created[0].1, Some(Attention::Orange)); // p2 + assert_eq!(created[0].1, Some(Attention::Orange)); // a2 assert_eq!(created[0].2, None); // no do-date assert_eq!(created[0].3, None); // no recurrence assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano) diff --git a/crates/hephd/src/quickadd.rs b/crates/hephd/src/quickadd.rs index a6fccf6..826639c 100644 --- a/crates/hephd/src/quickadd.rs +++ b/crates/hephd/src/quickadd.rs @@ -1,12 +1,13 @@ //! Single-line natural-language quick-add (tech-spec §8.1) — Todoist-style -//! capture: `Water plants tomorrow p2 #Chores every 3 days`. +//! capture: `Water plants tomorrow a2 #Chores every 3 days`. //! //! Pure and deterministic: `today` and the known projects are passed in, so the //! whole parser is unit-testable. Recognized inline tokens are extracted and the //! remainder is the title (order preserved). The recognized forms mirror the //! owner's Todoist usage ([[design]] §6.2.1): //! -//! - **Priority** `p1`..`p4` → attention (p1 red, p2 orange, p3 blue, p4 white). +//! - **Attention** `a1`..`a4` → attention band, ordered by intensity +//! (a1 red, a2 orange, a3 white, a4 blue). //! - **Project** `#Name` — resolved against existing projects, greedily matching //! multi-word titles (`#Camano Chores`). An unresolved `#tag` is left in the //! title verbatim (no surprise project creation). @@ -40,12 +41,13 @@ pub struct Parsed { pub project_id: Option, } -fn priority_attention(token: &str) -> Option { +/// `a1`..`a4` → attention band, ordered by intensity (a1 = most urgent). +fn attention_token(token: &str) -> Option { match token.to_ascii_lowercase().as_str() { - "p1" => Some(Attention::Red), - "p2" => Some(Attention::Orange), - "p3" => Some(Attention::Blue), - "p4" => Some(Attention::White), + "a1" => Some(Attention::Red), + "a2" => Some(Attention::Orange), + "a3" => Some(Attention::White), + "a4" => Some(Attention::Blue), _ => None, } } @@ -62,7 +64,7 @@ pub fn parse(input: &str, today: NaiveDate, projects: &[Project]) -> Parsed { while i < tokens.len() { let tok = &tokens[i]; - if let Some(a) = priority_attention(tok) { + if let Some(a) = attention_token(tok) { out.attention = Some(a); i += 1; continue; @@ -170,12 +172,20 @@ mod tests { } #[test] - fn priority_maps_to_attention() { - assert_eq!(p("Email boss p1").attention, Some(Attention::Red)); - assert_eq!(p("Email boss p2").attention, Some(Attention::Orange)); - assert_eq!(p("Email boss p3").attention, Some(Attention::Blue)); - assert_eq!(p("Email boss p4").attention, Some(Attention::White)); - assert_eq!(p("Email boss p1").title, "Email boss"); + fn attention_token_maps_to_attention() { + assert_eq!(p("Email boss a1").attention, Some(Attention::Red)); + assert_eq!(p("Email boss a2").attention, Some(Attention::Orange)); + assert_eq!(p("Email boss a3").attention, Some(Attention::White)); + assert_eq!(p("Email boss a4").attention, Some(Attention::Blue)); + assert_eq!(p("Email boss a1").title, "Email boss"); + } + + #[test] + fn old_priority_tokens_are_no_longer_recognized() { + // p1..p4 are retired in favour of a1..a4 — they stay in the title. + let r = p("Email boss p1"); + assert_eq!(r.attention, None); + assert_eq!(r.title, "Email boss p1"); } #[test] @@ -215,7 +225,7 @@ mod tests { #[test] fn everything_at_once() { - let r = p("Plan trip p2 friday #Work every week"); + let r = p("Plan trip a2 friday #Work every week"); assert_eq!(r.title, "Plan trip"); assert_eq!(r.attention, Some(Attention::Orange)); assert_eq!(r.do_date, Some(ms(2026, 6, 5))); // the coming Friday diff --git a/docs/changelog.d/feature-attention-a1-a4.feature.md b/docs/changelog.d/feature-attention-a1-a4.feature.md new file mode 100644 index 0000000..57d1efe --- /dev/null +++ b/docs/changelog.d/feature-attention-a1-a4.feature.md @@ -0,0 +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. diff --git a/docs/how-to/heph-pwa.md b/docs/how-to/heph-pwa.md index 2a158e9..ab72be3 100644 --- a/docs/how-to/heph-pwa.md +++ b/docs/how-to/heph-pwa.md @@ -71,7 +71,7 @@ into preview chips before you submit: | Token | Example | Effect | |-------|---------|--------| -| `p1`–`p4` | `p1` | attention: red / orange / blue / white | +| `a1`–`a4` | `a1` | attention band by intensity: a1=red, a2=orange, a3=white, a4=blue | | `#Project` | `#Camano Chores` | file under a project (greedy multi-word match) | | date | `today` `tomorrow` `+3d` `fri` `2026-07-01` | do-date | | `every …` | `every 3 days` `every other wed` `every workday` | recurrence (RRULE) | @@ -96,8 +96,9 @@ platform. A server-side transcription proxy could be added later if needed.) ## Triage Tap a task to expand its actions, mirroring the TUI keys: **Done** (`x`), -**Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (cycle attention, `A`), -**Date** (reschedule, `e`), **Move** (project picker, `m`), **Delete** +**Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (pick a band a1–a4, +the TUI's `a` then a digit), **Date** (reschedule, `e`), **Move** (project +picker, `m`), **Delete** (tombstone, `D`). Done/Drop show an **Undo**. The expanded view also shows the task's canonical-context body + recent log tail (read-only). diff --git a/heph-pwa/src/app.js b/heph-pwa/src/app.js index 4452c89..70ee947 100644 --- a/heph-pwa/src/app.js +++ b/heph-pwa/src/app.js @@ -1,6 +1,6 @@ // heph-pwa — a mobile-first browser mirror of heph-tui. Browse the built-in // views and projects, triage tasks, and (the primary use case) capture new -// tasks fast with the same quick-add syntax as the TUI's `a` / Cmd-' popover. +// tasks fast with the same quick-add syntax as the TUI's `n` / Cmd-' popover. // // Online-only thin client: every action is an RPC to the configured hub (see // rpc.js). Context/KB is read-only here (no nvim editing surface). @@ -10,11 +10,12 @@ import * as oauth from "./oauth.js"; import { parse as quickParse } from "./quickadd.js"; import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js"; import { + ATTENTION_BANDS, ATTENTION_COLORS, + attentionLabel, fmtRelative, hasFlag, isOverdue, - nextAttention, projectColor, } from "./fmt.js"; @@ -231,7 +232,7 @@ function taskDetail(t) { actionBtn("✓ Done", () => triage(t, "done")), actionBtn("⤓ Drop", () => triage(t, "dropped")), t.recurrence && actionBtn("↻ Skip", () => doSkip(t)), - actionBtn("⚑ Attn", () => cycleAttention(t)), + actionBtn("⚑ Attn", () => openAttention(t)), actionBtn("📅 Date", () => openReschedule(t)), actionBtn("📁 Move", () => openMove(t)), actionBtn("🗑 Delete", () => doDelete(t), "danger"), @@ -353,7 +354,7 @@ function openQuickAdd() { const input = h("input", { class: "qa-input", type: "text", - placeholder: "Buy milk tomorrow p2 #Work every week", + placeholder: "Buy milk tomorrow a2 #Work every week", autocomplete: "off", autocapitalize: "sentences", enterkeyhint: "done", @@ -364,12 +365,12 @@ function openQuickAdd() { const parsed = quickParse(input.value, today(), state.projects); preview.innerHTML = ""; if (!input.value.trim()) { - preview.append(h("span", { class: "qa-hint" }, "p1–p4 · #Project · today/+3d/fri · every week")); + preview.append(h("span", { class: "qa-hint" }, "a1–a4 · #Project · today/+3d/fri · every week")); return; } preview.append(h("span", { class: "qa-title" }, parsed.title || "(no title)")); if (parsed.attention) { - preview.append(h("span", { class: "qa-tag", style: `color:${ATTENTION_COLORS[parsed.attention]}` }, "⚑ " + parsed.attention)); + preview.append(h("span", { class: "qa-tag", style: `color:${ATTENTION_COLORS[parsed.attention]}` }, "⚑ " + attentionLabel(parsed.attention))); } if (parsed.doDate != null) preview.append(h("span", { class: "qa-tag" }, "📅 " + fmtRelative(parsed.doDate))); if (parsed.projectId) preview.append(h("span", { class: "qa-tag" }, "📁 " + projectTitle(parsed.projectId))); @@ -610,11 +611,22 @@ async function doSkip(t) { } } -async function cycleAttention(t) { - const next = nextAttention(t.attention); +// Pick an attention band directly (a1–a4) rather than cycling — cycling could +// skip past the band you wanted, and pushing to a4 (blue) used to drop the task +// out of the view you were on with no way back. Mirrors the TUI's `a`+digit chord. +function openAttention(t) { + const list = h("div", { class: "picker-list" }); + for (const band of ATTENTION_BANDS) { + list.append(pickerItem(attentionLabel(band), () => setAttention(t, band), ATTENTION_COLORS[band])); + } + openModal(h("div", { class: "qa" }, h("div", { class: "modal-title" }, `Attention for "${t.title}"`), list)); +} + +async function setAttention(t, band) { + closeModal(); try { - await state.client.setAttention(t.node_id, next); - toast(`Attention: ${next}`); + await state.client.setAttention(t.node_id, band); + toast(`Attention: ${attentionLabel(band)}`); reload(); } catch (e) { toast(`Failed: ${e.message}`); diff --git a/heph-pwa/src/fmt.js b/heph-pwa/src/fmt.js index a3b98ad..9e84ddc 100644 --- a/heph-pwa/src/fmt.js +++ b/heph-pwa/src/fmt.js @@ -9,15 +9,16 @@ export const ATTENTION_COLORS = { white: "var(--att-white)", }; -/** The cycle order used by the attention toggle (matches the TUI's `A` key). */ -export const ATTENTION_CYCLE = [null, "white", "orange", "red", "blue"]; +/** + * The attention bands a user can pick, in `a1`..`a4` order (by intensity). + * Each entry is the storage color string; the label is its index + 1. + */ +export const ATTENTION_BANDS = ["red", "orange", "white", "blue"]; -/** Next attention in the cycle: none → white → orange → red → blue → white. */ -export function nextAttention(att) { - const i = ATTENTION_CYCLE.indexOf(att ?? null); - // After blue (last), wrap to white (index 1), not back to none. - const next = i < 0 ? 1 : (i + 1) % ATTENTION_CYCLE.length; - return ATTENTION_CYCLE[next === 0 ? 1 : next] ?? "white"; +/** Attention color string → its `a1`..`a4` UI label (or "" if unset). */ +export function attentionLabel(att) { + const i = ATTENTION_BANDS.indexOf(att); + return i < 0 ? "" : `a${i + 1}`; } /** Whether an attention band shows a flag glyph (red/orange/blue; not white). */ diff --git a/heph-pwa/src/quickadd.js b/heph-pwa/src/quickadd.js index b0e5c4d..3149064 100644 --- a/heph-pwa/src/quickadd.js +++ b/heph-pwa/src/quickadd.js @@ -1,10 +1,11 @@ // Single-line natural-language quick-add — a faithful JS port of hephd's // `quickadd.rs` (tech-spec §8.1). Todoist-style capture: -// `Water plants tomorrow p2 #Chores every 3 days` +// `Water plants tomorrow a2 #Chores every 3 days` // // Recognized inline tokens are extracted and the remainder is the title (order // preserved). This mirrors the owner's Todoist usage ([[design]] §6.2.1): -// - Priority p1..p4 → attention (p1 red, p2 orange, p3 blue, p4 white) +// - Attention a1..a4 → attention band, ordered by intensity +// (a1 red, a2 orange, a3 white, a4 blue) // - Project #Name → resolved against existing projects, greedily matching // multi-word titles (#Camano Chores). Unresolved #tags // stay in the title verbatim (no surprise project). @@ -13,13 +14,13 @@ import { parseDate, toEpochMs, parseRecurrenceOrNull } from "./datespec.js"; -/** p1..p4 → attention color string (matching the RPC serialization), or null. */ -function priorityAttention(token) { +/** a1..a4 → attention color string (matching the RPC serialization), or null. */ +function attentionToken(token) { switch (token.toLowerCase()) { - case "p1": return "red"; - case "p2": return "orange"; - case "p3": return "blue"; - case "p4": return "white"; + case "a1": return "red"; + case "a2": return "orange"; + case "a3": return "white"; + case "a4": return "blue"; default: return null; } } @@ -76,7 +77,7 @@ export function parse(input, todayDate, projects = []) { while (i < tokens.length) { const tok = tokens[i]; - const att = priorityAttention(tok); + const att = attentionToken(tok); if (att !== null) { out.attention = att; i += 1; diff --git a/heph-pwa/sw.js b/heph-pwa/sw.js index 5793eab..5990857 100644 --- a/heph-pwa/sw.js +++ b/heph-pwa/sw.js @@ -1,7 +1,7 @@ // Service worker: cache the app shell so heph launches offline. Data is never // cached — every /rpc call must hit the live hub (and POSTs aren't cacheable // anyway). Bump CACHE when shell assets change to evict the old set. -const CACHE = "heph-pwa-v4"; +const CACHE = "heph-pwa-v5"; const SHELL = [ "./", "./index.html", diff --git a/heph-pwa/test/parsers.test.mjs b/heph-pwa/test/parsers.test.mjs index cd984fc..a6695fd 100644 --- a/heph-pwa/test/parsers.test.mjs +++ b/heph-pwa/test/parsers.test.mjs @@ -134,12 +134,19 @@ test("plain title", () => { assert.equal(r.projectId, null); }); -test("priority maps to attention", () => { - assert.equal(p("Email boss p1").attention, "red"); - assert.equal(p("Email boss p2").attention, "orange"); - assert.equal(p("Email boss p3").attention, "blue"); - assert.equal(p("Email boss p4").attention, "white"); - assert.equal(p("Email boss p1").title, "Email boss"); +test("attention token maps to attention", () => { + assert.equal(p("Email boss a1").attention, "red"); + assert.equal(p("Email boss a2").attention, "orange"); + assert.equal(p("Email boss a3").attention, "white"); + assert.equal(p("Email boss a4").attention, "blue"); + assert.equal(p("Email boss a1").title, "Email boss"); +}); + +test("old priority tokens are no longer recognized", () => { + // p1..p4 are retired in favour of a1..a4 — they stay in the title. + const r = p("Email boss p1"); + assert.equal(r.attention, null); + assert.equal(r.title, "Email boss p1"); }); test("relative date is extracted", () => { @@ -169,7 +176,7 @@ test("recurrence phrase is extracted", () => { }); test("everything at once", () => { - const r = p("Plan trip p2 friday #Work every week"); + const r = p("Plan trip a2 friday #Work every week"); assert.equal(r.title, "Plan trip"); assert.equal(r.attention, "orange"); assert.equal(r.doDate, ms(2026, 6, 5)); From 730863b8321a63fcff54bf90496349a5ef16716c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 9 Jun 2026 08:29:46 -0700 Subject: [PATCH 7/8] feat(heph): accept a1-a4, 1-4, or colour words for -a/--attention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/heph-core/src/model.rs | 44 +++++++++++++++++++ crates/heph/src/main.rs | 31 +++++++++---- .../feature-attention-a1-a4.feature.md | 2 +- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index 49ebee8..b2efb2c 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -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 { + 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()); + } +} diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 28d3b5e..471b2a2 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -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, /// 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, - /// 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, /// 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, - /// 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, /// 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, /// 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) -> Result> { + 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> { spec.map(datespec::parse_date_ms).transpose() } diff --git a/docs/changelog.d/feature-attention-a1-a4.feature.md b/docs/changelog.d/feature-attention-a1-a4.feature.md index 57d1efe..ba47a68 100644 --- a/docs/changelog.d/feature-attention-a1-a4.feature.md +++ b/docs/changelog.d/feature-attention-a1-a4.feature.md @@ -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. From 19ababc57feb8d79e4cbb26f82e18a41adeb31ce Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Tue, 9 Jun 2026 09:16:12 -0700 Subject: [PATCH 8/8] Update changelog for v1.4.2 [skip ci] --- CHANGELOG.md | 7 +++++++ docs/changelog.d/feature-attention-a1-a4.feature.md | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) delete mode 100644 docs/changelog.d/feature-attention-a1-a4.feature.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1900ad9..493e6af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.4.2] - 2026-06-09 + +### Features + +- 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. + + ## [v1.4.1] - 2026-06-08 ### Bug Fixes diff --git a/docs/changelog.d/feature-attention-a1-a4.feature.md b/docs/changelog.d/feature-attention-a1-a4.feature.md deleted file mode 100644 index ba47a68..0000000 --- a/docs/changelog.d/feature-attention-a1-a4.feature.md +++ /dev/null @@ -1 +0,0 @@ -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.