generated from eblume/project-template
Compare commits
12 commits
feature/au
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19ababc57f | ||
| 2911f418a5 | |||
| 730863b832 | |||
| ebb2366236 | |||
|
|
b34371af87 | ||
| 17dab0e281 | |||
| 470ef1de0e | |||
| aec807fd28 | |||
| b04a71421e | |||
| 5c2b4bde2c | |||
|
|
2ca1e246f0 | ||
| 9a4f18fbd5 |
27 changed files with 556 additions and 152 deletions
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -12,6 +12,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
<!-- towncrier release notes start -->
|
<!-- towncrier release notes start -->
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- 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
|
## [v1.2.3] - 2026-06-06
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -2237,6 +2237,8 @@ dependencies = [
|
||||||
"heph-core",
|
"heph-core",
|
||||||
"hephd",
|
"hephd",
|
||||||
"libc",
|
"libc",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-app-kit 0.3.2",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,36 @@ impl Attention {
|
||||||
other => return Err(Error::Integrity(format!("unknown attention: {other}"))),
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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`
|
/// A committed task's lifecycle state (tech-spec §4.3). `done` and `dropped`
|
||||||
|
|
@ -398,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,16 @@ global-hotkey = "0.8"
|
||||||
|
|
||||||
# macOS-only: winit for the accessory-mode activation policy (no Dock icon),
|
# 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
|
# 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]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
winit = "0.30"
|
winit = "0.30"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
objc2 = "0.6"
|
||||||
|
objc2-app-kit = { version = "0.3", default-features = false, features = [
|
||||||
|
"std",
|
||||||
|
"NSApplication",
|
||||||
|
"NSResponder",
|
||||||
|
] }
|
||||||
|
|
|
||||||
|
|
@ -43,46 +43,46 @@ const HINT_DELAY: f64 = 2.0;
|
||||||
/// `#project`). Unresolved `#tags` just stay in the title, so these are safe even
|
/// `#project`). Unresolved `#tags` just stay in the title, so these are safe even
|
||||||
/// though they reference projects a given store may not have.
|
/// though they reference projects a given store may not have.
|
||||||
const HINTS: &[&str] = &[
|
const HINTS: &[&str] = &[
|
||||||
"Water plants tomorrow p2 #Chores every 3 days",
|
"Water plants tomorrow a2 #Chores every 3 days",
|
||||||
"Call the dentist fri p1",
|
"Call the dentist fri a1",
|
||||||
"Email Sarah the report today",
|
"Email Sarah the report today",
|
||||||
"Buy milk #Errands",
|
"Buy milk #Errands",
|
||||||
"Renew passport +30d p2",
|
"Renew passport +30d a2",
|
||||||
"Review pull requests p3 #Work",
|
"Review pull requests a4 #Work",
|
||||||
"Take out recycling every other wed",
|
"Take out recycling every other wed",
|
||||||
"Pay rent every 1st p1",
|
"Pay rent every 1st a1",
|
||||||
"Stretch every day",
|
"Stretch every day",
|
||||||
"Submit timesheet every friday #Work",
|
"Submit timesheet every friday #Work",
|
||||||
"Water the garden every 2 days",
|
"Water the garden every 2 days",
|
||||||
"Back up the laptop every week p3",
|
"Back up the laptop every week a4",
|
||||||
"Book flights +1w p2 #Travel",
|
"Book flights +1w a2 #Travel",
|
||||||
"Doctor appointment 2026-07-15 p1",
|
"Doctor appointment 2026-07-15 a1",
|
||||||
"Read a chapter today #Reading",
|
"Read a chapter today #Reading",
|
||||||
"Standup notes every weekday #Work",
|
"Standup notes every weekday #Work",
|
||||||
"Change the air filter every 3 months",
|
"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",
|
"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",
|
"Vacuum the house every saturday #Chores",
|
||||||
"Replace toothbrush every 3 months",
|
"Replace toothbrush every 3 months",
|
||||||
"Prep slides for monday p2 #Work",
|
"Prep slides for monday a2 #Work",
|
||||||
"Walk the dog every day",
|
"Walk the dog every day",
|
||||||
"Refill prescription every 30 days p2 #Health",
|
"Refill prescription every 30 days a2 #Health",
|
||||||
"Grocery run +2d #Errands",
|
"Grocery run +2d #Errands",
|
||||||
"Mow the lawn every week #Home",
|
"Mow the lawn every week #Home",
|
||||||
"Schedule a 1:1 with Alex thu p3 #Work",
|
"Schedule a 1:1 with Alex thu a4 #Work",
|
||||||
"Send the invoice every 15th p2",
|
"Send the invoice every 15th a2",
|
||||||
"Defrost the freezer every 6 months",
|
"Defrost the freezer every 6 months",
|
||||||
"Update the resume +14d p3",
|
"Update the resume +14d a4",
|
||||||
"Check smoke detectors every 6 months #Home",
|
"Check smoke detectors every 6 months #Home",
|
||||||
"Plan the sprint every other monday #Work",
|
"Plan the sprint every other monday #Work",
|
||||||
"Order coffee beans every 2 weeks",
|
"Order coffee beans every 2 weeks",
|
||||||
"Call grandma every sunday p2",
|
"Call grandma every sunday a2",
|
||||||
"Rotate the car tires every 6 months #Car",
|
"Rotate the car tires every 6 months #Car",
|
||||||
"Weekly review every friday p2",
|
"Weekly review every friday a2",
|
||||||
"Pick up dry cleaning tomorrow #Errands",
|
"Pick up dry cleaning tomorrow #Errands",
|
||||||
"Pay the credit card every 28th p1",
|
"Pay the credit card every 28th a1",
|
||||||
"Tidy the inbox every day p4",
|
"Tidy the inbox every day a3",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Pick a hint pseudo-randomly, never the same one twice in a row. No `rand`
|
/// Pick a hint pseudo-randomly, never the same one twice in a row. No `rand`
|
||||||
|
|
@ -226,6 +226,9 @@ impl QuickAdd {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show(&mut self, ctx: &egui::Context) {
|
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.visible = true;
|
||||||
self.focus_pending = true;
|
self.focus_pending = true;
|
||||||
self.current_hint = random_hint(self.current_hint);
|
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)));
|
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(WIN_W, BASE_H)));
|
||||||
self.win_h_applied = 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.
|
/// Optimistic submit: hide now, create in the background.
|
||||||
|
|
@ -540,18 +550,14 @@ impl QuickAdd {
|
||||||
let mut any = false;
|
let mut any = false;
|
||||||
|
|
||||||
if let Some(att) = parsed.attention {
|
if let Some(att) = parsed.attention {
|
||||||
let (label, color) = match att {
|
// a1–a4 nomenclature; the colour mapping is unchanged.
|
||||||
heph_core::Attention::Red => {
|
let color = match att {
|
||||||
("⚑ red", egui::Color32::from_rgb(0xe0, 0x6c, 0x60))
|
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::Orange => {
|
heph_core::Attention::Blue => egui::Color32::from_rgb(0x61, 0xaf, 0xef),
|
||||||
("⚑ orange", egui::Color32::from_rgb(0xe5, 0xc0, 0x7b))
|
heph_core::Attention::White => egui::Color32::from_gray(200),
|
||||||
}
|
|
||||||
heph_core::Attention::Blue => {
|
|
||||||
("⚑ blue", egui::Color32::from_rgb(0x61, 0xaf, 0xef))
|
|
||||||
}
|
|
||||||
heph_core::Attention::White => ("⚑ white", egui::Color32::from_gray(200)),
|
|
||||||
};
|
};
|
||||||
|
let label = format!("⚑ {}", att.ui_label());
|
||||||
ui.label(egui::RichText::new(label).color(color).size(LABEL_SIZE));
|
ui.label(egui::RichText::new(label).color(color).size(LABEL_SIZE));
|
||||||
any = true;
|
any = true;
|
||||||
}
|
}
|
||||||
|
|
@ -587,7 +593,7 @@ impl QuickAdd {
|
||||||
|
|
||||||
if !any {
|
if !any {
|
||||||
ui.label(
|
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))
|
.color(egui::Color32::from_gray(140))
|
||||||
.size(LABEL_SIZE),
|
.size(LABEL_SIZE),
|
||||||
);
|
);
|
||||||
|
|
@ -596,6 +602,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
|
/// 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).
|
/// hephd does not supervise a helper — there is no Aqua session to inherit).
|
||||||
fn current_parent_pid() -> Option<i32> {
|
fn current_parent_pid() -> Option<i32> {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! `heph-quickadd` — the global quick-capture popover (tech-spec §8).
|
//! `heph-quickadd` — the global quick-capture popover (tech-spec §8).
|
||||||
//!
|
//!
|
||||||
//! A tiny always-warm egui agent: ⌘' shows a single-line capture field that
|
//! 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**
|
//! 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
|
//! (spawned in local mode on macOS), so the user installs/manages exactly one
|
||||||
//! service — there is no separate launch agent.
|
//! service — there is no separate launch agent.
|
||||||
|
|
|
||||||
|
|
@ -289,15 +289,16 @@ fn fuzzy_match(query: &str, cand: &str) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The attention cycle for the `A` gesture: default → top-of-mind → consequence
|
/// Map an attention-chord digit (`1`..`4`) to its band, ordered by intensity:
|
||||||
/// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression.
|
/// 1 = a1 (red), 2 = a2 (orange), 3 = a3 (white), 4 = a4 (blue). Any other
|
||||||
pub fn next_attention(current: Option<Attention>) -> Attention {
|
/// character is not an attention key.
|
||||||
match current {
|
pub fn attention_for_digit(c: char) -> Option<Attention> {
|
||||||
Some(Attention::White) => Attention::Orange,
|
match c {
|
||||||
Some(Attention::Orange) => Attention::Red,
|
'1' => Some(Attention::Red),
|
||||||
Some(Attention::Red) => Attention::Blue,
|
'2' => Some(Attention::Orange),
|
||||||
Some(Attention::Blue) => Attention::White,
|
'3' => Some(Attention::White),
|
||||||
None => Attention::White,
|
'4' => Some(Attention::Blue),
|
||||||
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -429,6 +430,9 @@ pub struct App<B: Backend> {
|
||||||
pub search: Option<SearchView>,
|
pub search: Option<SearchView>,
|
||||||
/// When `Some`, a delete is awaiting y/N confirmation.
|
/// When `Some`, a delete is awaiting y/N confirmation.
|
||||||
pub pending_delete: Option<PendingDelete>,
|
pub pending_delete: Option<PendingDelete>,
|
||||||
|
/// 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).
|
/// Reversible triage history (`u` undoes, Ctrl-z redoes).
|
||||||
undo_stack: Vec<UndoEntry>,
|
undo_stack: Vec<UndoEntry>,
|
||||||
redo_stack: Vec<UndoEntry>,
|
redo_stack: Vec<UndoEntry>,
|
||||||
|
|
@ -471,6 +475,7 @@ impl<B: Backend> App<B> {
|
||||||
sort_mode: SortMode::Default,
|
sort_mode: SortMode::Default,
|
||||||
search: None,
|
search: None,
|
||||||
pending_delete: None,
|
pending_delete: None,
|
||||||
|
pending_attention: false,
|
||||||
undo_stack: Vec::new(),
|
undo_stack: Vec::new(),
|
||||||
redo_stack: Vec::new(),
|
redo_stack: Vec::new(),
|
||||||
status: String::new(),
|
status: String::new(),
|
||||||
|
|
@ -722,26 +727,46 @@ impl<B: Backend> App<B> {
|
||||||
self.mutate(format!("skipped: {}", t.title), |b| b.skip(&t.node_id));
|
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).
|
/// Begin an attention chord: arm `pending_attention` so the next `1`..`4`
|
||||||
pub fn cycle_attention_selected(&mut self) {
|
/// sets the highlighted task's band directly (§6.2). No-op (with a hint) if
|
||||||
let Some(t) = self.selected_task().cloned() else {
|
/// 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;
|
return;
|
||||||
};
|
}
|
||||||
let next = next_attention(t.attention);
|
self.pending_attention = true;
|
||||||
self.push_undo((&t).into(), TriageAction::Attention(next));
|
self.status = "attention: 1=a1 2=a2 3=a3 4=a4 (esc cancels)".into();
|
||||||
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.
|
/// Resolve an armed attention chord with the pressed key. `1`..`4` set the
|
||||||
pub fn push_to_blue_selected(&mut self) {
|
/// 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 {
|
let Some(t) = self.selected_task().cloned() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
self.push_undo((&t).into(), TriageAction::Attention(Attention::Blue));
|
self.push_undo((&t).into(), TriageAction::Attention(att));
|
||||||
self.mutate(format!("→ on deck: {}", t.title), |b| {
|
self.mutate(format!("{}: {}", att.ui_label(), t.title), |b| {
|
||||||
b.set_attention(&t.node_id, Attention::Blue)
|
b.set_attention(&t.node_id, att)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,16 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An armed attention chord (`a` then a digit) captures the next key: `1`..`4`
|
||||||
|
// set the band, anything else cancels.
|
||||||
|
if app.pending_attention {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char(c) => app.resolve_attention(c),
|
||||||
|
_ => app.cancel_attention(),
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
// While collecting input, all keys go to the prompt.
|
// While collecting input, all keys go to the prompt.
|
||||||
if matches!(app.mode, Mode::Input(_)) {
|
if matches!(app.mode, Mode::Input(_)) {
|
||||||
match key.code {
|
match key.code {
|
||||||
|
|
@ -179,7 +189,7 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
|
||||||
KeyCode::Char('l') | KeyCode::Right => app.focus_tasks(),
|
KeyCode::Char('l') | KeyCode::Right => app.focus_tasks(),
|
||||||
// Enter: drill sidebar→tasks, or open the selected task's context in nvim.
|
// Enter: drill sidebar→tasks, or open the selected task's context in nvim.
|
||||||
KeyCode::Enter => return app.enter().map(Action::EditContext),
|
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('/') => app.begin_search(),
|
||||||
KeyCode::Char('s') => app.toggle_sort(),
|
KeyCode::Char('s') => app.toggle_sort(),
|
||||||
KeyCode::Char('u') => app.undo(),
|
KeyCode::Char('u') => app.undo(),
|
||||||
|
|
@ -191,8 +201,7 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
|
||||||
KeyCode::Char('x') => app.complete_selected(),
|
KeyCode::Char('x') => app.complete_selected(),
|
||||||
KeyCode::Char('d') => app.drop_selected(),
|
KeyCode::Char('d') => app.drop_selected(),
|
||||||
KeyCode::Char('S') => app.skip_selected(),
|
KeyCode::Char('S') => app.skip_selected(),
|
||||||
KeyCode::Char('A') => app.cycle_attention_selected(),
|
KeyCode::Char('a') => app.begin_attention(),
|
||||||
KeyCode::Char('b') => app.push_to_blue_selected(),
|
|
||||||
KeyCode::Char('e') => app.begin_reschedule(),
|
KeyCode::Char('e') => app.begin_reschedule(),
|
||||||
KeyCode::Char('m') => app.begin_move(),
|
KeyCode::Char('m') => app.begin_move(),
|
||||||
KeyCode::Char('D') => app.begin_delete(),
|
KeyCode::Char('D') => app.begin_delete(),
|
||||||
|
|
|
||||||
|
|
@ -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).
|
// Task-pane gestures (the focused pane shows its own hints, §8.1).
|
||||||
const HINTS: &str =
|
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).
|
// Sidebar gestures: navigation + per-project actions (no task triage here).
|
||||||
const SIDEBAR_HINTS: &str =
|
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";
|
const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -175,8 +175,8 @@ fn quick_add_captures_a_task_that_appears_in_the_view() {
|
||||||
assert!(app.tasks.is_empty());
|
assert!(app.tasks.is_empty());
|
||||||
|
|
||||||
app.begin_add();
|
app.begin_add();
|
||||||
// Single-line NL: p1 → red, so it lands in Top of Mind (the default view).
|
// Single-line NL: a1 → red, so it lands in Top of Mind (the default view).
|
||||||
type_and_submit(&mut app, "Call the plumber p1");
|
type_and_submit(&mut app, "Call the plumber a1");
|
||||||
|
|
||||||
assert!(app.status.contains("added"), "status: {}", app.status);
|
assert!(app.status.contains("added"), "status: {}", app.status);
|
||||||
assert!(
|
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();
|
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
|
||||||
assert_eq!(app.tasks.len(), 1);
|
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");
|
assert!(app.tasks.is_empty(), "blue task should leave Top of Mind");
|
||||||
|
|
||||||
// It now appears under On Deck (the last of the five views).
|
// It now appears under On Deck (the last of the five views).
|
||||||
|
|
|
||||||
|
|
@ -218,13 +218,14 @@ fn move_task_clamps_at_the_ends() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attention_cycles_white_orange_red_blue() {
|
fn attention_digits_map_by_intensity() {
|
||||||
use heph_tui::app::next_attention;
|
use heph_tui::app::attention_for_digit;
|
||||||
assert_eq!(next_attention(Some(Attention::White)), Attention::Orange);
|
assert_eq!(attention_for_digit('1'), Some(Attention::Red));
|
||||||
assert_eq!(next_attention(Some(Attention::Orange)), Attention::Red);
|
assert_eq!(attention_for_digit('2'), Some(Attention::Orange));
|
||||||
assert_eq!(next_attention(Some(Attention::Red)), Attention::Blue);
|
assert_eq!(attention_for_digit('3'), Some(Attention::White));
|
||||||
assert_eq!(next_attention(Some(Attention::Blue)), Attention::White);
|
assert_eq!(attention_for_digit('4'), Some(Attention::Blue));
|
||||||
assert_eq!(next_attention(None), Attention::White);
|
assert_eq!(attention_for_digit('5'), None);
|
||||||
|
assert_eq!(attention_for_digit('a'), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn type_and_submit<B: Backend>(app: &mut App<B>, s: &str) {
|
fn type_and_submit<B: Backend>(app: &mut App<B>, 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");
|
assert_eq!(app.task_pane_title(), "Camano");
|
||||||
|
|
||||||
app.begin_add();
|
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;
|
let created = &rec.borrow().created;
|
||||||
assert_eq!(created.len(), 1);
|
assert_eq!(created.len(), 1);
|
||||||
assert_eq!(created[0].0, "Fix the dock");
|
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].2, None); // no do-date
|
||||||
assert_eq!(created[0].3, None); // no recurrence
|
assert_eq!(created[0].3, None); // no recurrence
|
||||||
assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano)
|
assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use anyhow::{bail, Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use serde_json::{json, Value};
|
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};
|
use hephd::{datespec, default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore};
|
||||||
|
|
||||||
mod service;
|
mod service;
|
||||||
|
|
@ -43,7 +43,7 @@ enum Command {
|
||||||
Task {
|
Task {
|
||||||
/// The task title.
|
/// The task title.
|
||||||
title: String,
|
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)]
|
#[arg(short = 'a', long)]
|
||||||
attention: Option<String>,
|
attention: Option<String>,
|
||||||
/// Do-date (earliest-actionable): today|tomorrow|+3d|fri|YYYY-MM-DD.
|
/// 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.
|
/// Restrict to a project by NAME (subtree-expanded). e.g. --project Hephaestus.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
project: Option<String>,
|
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)]
|
#[arg(short = 'a', long)]
|
||||||
attention: Option<String>,
|
attention: Option<String>,
|
||||||
/// Hide on-deck (blue) items.
|
/// Hide on-deck (blue) items.
|
||||||
|
|
@ -105,7 +105,7 @@ enum Command {
|
||||||
Attention {
|
Attention {
|
||||||
/// Task node id.
|
/// Task node id.
|
||||||
id: String,
|
id: String,
|
||||||
/// white|orange|red|blue.
|
/// a1|a2|a3|a4 (or 1-4, or red|orange|white|blue).
|
||||||
attention: String,
|
attention: String,
|
||||||
},
|
},
|
||||||
/// Reschedule a task: change do-date / late-on / recurrence (use `none` to
|
/// Reschedule a task: change do-date / late-on / recurrence (use `none` to
|
||||||
|
|
@ -125,7 +125,7 @@ enum Command {
|
||||||
/// A raw RRULE or `none`.
|
/// A raw RRULE or `none`.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
rrule: Option<String>,
|
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)]
|
#[arg(short = 'a', long)]
|
||||||
attention: Option<String>,
|
attention: Option<String>,
|
||||||
/// Re-file under a project (by name); `none` unfiles the task.
|
/// Re-file under a project (by name); `none` unfiles the task.
|
||||||
|
|
@ -138,7 +138,7 @@ enum Command {
|
||||||
container_id: String,
|
container_id: String,
|
||||||
/// 1-based index of the context item to promote (document order).
|
/// 1-based index of the context item to promote (document order).
|
||||||
item_ref: usize,
|
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)]
|
#[arg(short = 'a', long)]
|
||||||
attention: Option<String>,
|
attention: Option<String>,
|
||||||
/// Project name to file the new task under.
|
/// Project name to file the new task under.
|
||||||
|
|
@ -489,6 +489,7 @@ fn main() -> Result<()> {
|
||||||
recur,
|
recur,
|
||||||
rrule,
|
rrule,
|
||||||
} => {
|
} => {
|
||||||
|
let attention = norm_attention(attention)?;
|
||||||
let recurrence = recurrence_value(recur.as_deref(), rrule.as_deref())?;
|
let recurrence = recurrence_value(recur.as_deref(), rrule.as_deref())?;
|
||||||
let project_id = resolve_project(&mut client, project.as_deref())?;
|
let project_id = resolve_project(&mut client, project.as_deref())?;
|
||||||
let result = client.call(
|
let result = client.call(
|
||||||
|
|
@ -515,6 +516,7 @@ fn main() -> Result<()> {
|
||||||
// `list` takes a ListFilter (tech-spec §8.2). Map the flags: a single
|
// `list` takes a ListFilter (tech-spec §8.2). Map the flags: a single
|
||||||
// `--scope` id or `--project` NAME (resolved + subtree-expanded by the
|
// `--scope` id or `--project` NAME (resolved + subtree-expanded by the
|
||||||
// daemon), a single `--attention` whitelist, and `--no-blue`.
|
// daemon), a single `--attention` whitelist, and `--no-blue`.
|
||||||
|
let attention = norm_attention(attention)?;
|
||||||
let mut filter = json!({});
|
let mut filter = json!({});
|
||||||
if let Some(s) = scope {
|
if let Some(s) = scope {
|
||||||
filter["scope"] = json!([s]);
|
filter["scope"] = json!([s]);
|
||||||
|
|
@ -558,11 +560,12 @@ fn main() -> Result<()> {
|
||||||
println!("Skipped occurrence of {id}");
|
println!("Skipped occurrence of {id}");
|
||||||
}
|
}
|
||||||
Command::Attention { id, attention } => {
|
Command::Attention { id, attention } => {
|
||||||
|
let att = Attention::parse_input(&attention)?;
|
||||||
client.call(
|
client.call(
|
||||||
"task.set_attention",
|
"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 {
|
Command::Edit {
|
||||||
id,
|
id,
|
||||||
|
|
@ -588,7 +591,7 @@ fn main() -> Result<()> {
|
||||||
if patch.len() > 1 {
|
if patch.len() > 1 {
|
||||||
client.call("task.set_schedule", Value::Object(patch))?;
|
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 }))?;
|
client.call("task.set_attention", json!({ "id": id, "attention": a }))?;
|
||||||
}
|
}
|
||||||
if let Some(spec) = project.as_deref() {
|
if let Some(spec) = project.as_deref() {
|
||||||
|
|
@ -612,6 +615,7 @@ fn main() -> Result<()> {
|
||||||
attention,
|
attention,
|
||||||
project,
|
project,
|
||||||
} => {
|
} => {
|
||||||
|
let attention = norm_attention(attention)?;
|
||||||
let project_id = resolve_project(&mut client, project.as_deref())?;
|
let project_id = resolve_project(&mut client, project.as_deref())?;
|
||||||
let result = client.call(
|
let result = client.call(
|
||||||
"task.promote",
|
"task.promote",
|
||||||
|
|
@ -863,6 +867,15 @@ fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse an optional human date into epoch-ms JSON (for `task.create`).
|
/// 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>> {
|
fn opt_date_ms(spec: Option<&str>) -> Result<Option<i64>> {
|
||||||
spec.map(datespec::parse_date_ms).transpose()
|
spec.map(datespec::parse_date_ms).transpose()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,59 +2,145 @@
|
||||||
//!
|
//!
|
||||||
//! Used by the `heph` CLI and by tests. Surfaces never touch SQLite directly
|
//! 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.
|
//! (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::io::{BufRead, BufReader, Write};
|
||||||
use std::os::unix::net::UnixStream;
|
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 serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::rpc::Response;
|
use crate::rpc::Response;
|
||||||
|
|
||||||
/// A connected client. One request/response per [`call`](Client::call).
|
/// A connected client. One request/response per [`call`](Client::call).
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
|
socket_path: PathBuf,
|
||||||
reader: BufReader<UnixStream>,
|
reader: BufReader<UnixStream>,
|
||||||
writer: UnixStream,
|
writer: UnixStream,
|
||||||
next_id: u64,
|
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 {
|
impl Client {
|
||||||
/// Connect to a daemon listening at `socket_path`.
|
/// Connect to a daemon listening at `socket_path`.
|
||||||
pub fn connect(socket_path: &Path) -> Result<Client> {
|
pub fn connect(socket_path: &Path) -> Result<Client> {
|
||||||
let stream = UnixStream::connect(socket_path)
|
let (reader, writer) = Self::open(socket_path)?;
|
||||||
.with_context(|| format!("connecting to hephd at {}", socket_path.display()))?;
|
|
||||||
let reader = BufReader::new(stream.try_clone()?);
|
|
||||||
Ok(Client {
|
Ok(Client {
|
||||||
|
socket_path: socket_path.to_path_buf(),
|
||||||
reader,
|
reader,
|
||||||
writer: stream,
|
writer,
|
||||||
next_id: 1,
|
next_id: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a fresh reader/writer pair on the socket.
|
||||||
|
fn open(socket_path: &Path) -> Result<(BufReader<UnixStream>, 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
|
/// Call `method` with `params`, returning the `result` value (or an error
|
||||||
/// carrying the RPC error's code and message).
|
/// 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<Value> {
|
pub fn call(&mut self, method: &str, params: Value) -> Result<Value> {
|
||||||
let id = self.next_id;
|
let id = self.next_id;
|
||||||
self.next_id += 1;
|
self.next_id += 1;
|
||||||
|
|
||||||
let mut line = serde_json::to_string(&json!({
|
let mut line = serde_json::to_string(&json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"method": method,
|
"method": method,
|
||||||
"params": params,
|
"params": params,
|
||||||
}))?;
|
}))?;
|
||||||
line.push('\n');
|
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<Value, ExchangeError> {
|
||||||
|
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 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 {
|
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 {
|
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))
|
Ok(response.result.unwrap_or(Value::Null))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
//! Single-line natural-language quick-add (tech-spec §8.1) — Todoist-style
|
//! 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
|
//! 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
|
//! whole parser is unit-testable. Recognized inline tokens are extracted and the
|
||||||
//! remainder is the title (order preserved). The recognized forms mirror the
|
//! remainder is the title (order preserved). The recognized forms mirror the
|
||||||
//! owner's Todoist usage ([[design]] §6.2.1):
|
//! 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
|
//! - **Project** `#Name` — resolved against existing projects, greedily matching
|
||||||
//! multi-word titles (`#Camano Chores`). An unresolved `#tag` is left in the
|
//! multi-word titles (`#Camano Chores`). An unresolved `#tag` is left in the
|
||||||
//! title verbatim (no surprise project creation).
|
//! title verbatim (no surprise project creation).
|
||||||
|
|
@ -40,12 +41,13 @@ pub struct Parsed {
|
||||||
pub project_id: Option<String>,
|
pub project_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn priority_attention(token: &str) -> Option<Attention> {
|
/// `a1`..`a4` → attention band, ordered by intensity (a1 = most urgent).
|
||||||
|
fn attention_token(token: &str) -> Option<Attention> {
|
||||||
match token.to_ascii_lowercase().as_str() {
|
match token.to_ascii_lowercase().as_str() {
|
||||||
"p1" => Some(Attention::Red),
|
"a1" => Some(Attention::Red),
|
||||||
"p2" => Some(Attention::Orange),
|
"a2" => Some(Attention::Orange),
|
||||||
"p3" => Some(Attention::Blue),
|
"a3" => Some(Attention::White),
|
||||||
"p4" => Some(Attention::White),
|
"a4" => Some(Attention::Blue),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +64,7 @@ pub fn parse(input: &str, today: NaiveDate, projects: &[Project]) -> Parsed {
|
||||||
while i < tokens.len() {
|
while i < tokens.len() {
|
||||||
let tok = &tokens[i];
|
let tok = &tokens[i];
|
||||||
|
|
||||||
if let Some(a) = priority_attention(tok) {
|
if let Some(a) = attention_token(tok) {
|
||||||
out.attention = Some(a);
|
out.attention = Some(a);
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -170,12 +172,20 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn priority_maps_to_attention() {
|
fn attention_token_maps_to_attention() {
|
||||||
assert_eq!(p("Email boss p1").attention, Some(Attention::Red));
|
assert_eq!(p("Email boss a1").attention, Some(Attention::Red));
|
||||||
assert_eq!(p("Email boss p2").attention, Some(Attention::Orange));
|
assert_eq!(p("Email boss a2").attention, Some(Attention::Orange));
|
||||||
assert_eq!(p("Email boss p3").attention, Some(Attention::Blue));
|
assert_eq!(p("Email boss a3").attention, Some(Attention::White));
|
||||||
assert_eq!(p("Email boss p4").attention, Some(Attention::White));
|
assert_eq!(p("Email boss a4").attention, Some(Attention::Blue));
|
||||||
assert_eq!(p("Email boss p1").title, "Email boss");
|
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]
|
#[test]
|
||||||
|
|
@ -215,7 +225,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn everything_at_once() {
|
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.title, "Plan trip");
|
||||||
assert_eq!(r.attention, Some(Attention::Orange));
|
assert_eq!(r.attention, Some(Attention::Orange));
|
||||||
assert_eq!(r.do_date, Some(ms(2026, 6, 5))); // the coming Friday
|
assert_eq!(r.do_date, Some(ms(2026, 6, 5))); // the coming Friday
|
||||||
|
|
|
||||||
96
crates/hephd/tests/client_reconnect.rs
Normal file
96
crates/hephd/tests/client_reconnect.rs
Normal file
|
|
@ -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<AtomicUsize>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -71,7 +71,7 @@ into preview chips before you submit:
|
||||||
|
|
||||||
| Token | Example | Effect |
|
| 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) |
|
| `#Project` | `#Camano Chores` | file under a project (greedy multi-word match) |
|
||||||
| date | `today` `tomorrow` `+3d` `fri` `2026-07-01` | do-date |
|
| date | `today` `tomorrow` `+3d` `fri` `2026-07-01` | do-date |
|
||||||
| `every …` | `every 3 days` `every other wed` `every workday` | recurrence (RRULE) |
|
| `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
|
## Triage
|
||||||
|
|
||||||
Tap a task to expand its actions, mirroring the TUI keys: **Done** (`x`),
|
Tap a task to expand its actions, mirroring the TUI keys: **Done** (`x`),
|
||||||
**Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (cycle attention, `A`),
|
**Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (pick a band a1–a4,
|
||||||
**Date** (reschedule, `e`), **Move** (project picker, `m`), **Delete**
|
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
|
(tombstone, `D`). Done/Drop show an **Undo**. The expanded view also shows the
|
||||||
task's canonical-context body + recent log tail (read-only).
|
task's canonical-context body + recent log tail (read-only).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,14 @@ still the old binary until you restart it:
|
||||||
heph daemon restart
|
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)
|
## Self-update (opt-in)
|
||||||
|
|
||||||
`hephd` can keep itself current: `heph daemon start --self-update` generates a
|
`hephd` can keep itself current: `heph daemon start --self-update` generates a
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// heph-pwa — a mobile-first browser mirror of heph-tui. Browse the built-in
|
// 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
|
// 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
|
// 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).
|
// 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 { parse as quickParse } from "./quickadd.js";
|
||||||
import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js";
|
import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js";
|
||||||
import {
|
import {
|
||||||
|
ATTENTION_BANDS,
|
||||||
ATTENTION_COLORS,
|
ATTENTION_COLORS,
|
||||||
|
attentionLabel,
|
||||||
fmtRelative,
|
fmtRelative,
|
||||||
hasFlag,
|
hasFlag,
|
||||||
isOverdue,
|
isOverdue,
|
||||||
nextAttention,
|
|
||||||
projectColor,
|
projectColor,
|
||||||
} from "./fmt.js";
|
} from "./fmt.js";
|
||||||
|
|
||||||
|
|
@ -231,7 +232,7 @@ function taskDetail(t) {
|
||||||
actionBtn("✓ Done", () => triage(t, "done")),
|
actionBtn("✓ Done", () => triage(t, "done")),
|
||||||
actionBtn("⤓ Drop", () => triage(t, "dropped")),
|
actionBtn("⤓ Drop", () => triage(t, "dropped")),
|
||||||
t.recurrence && actionBtn("↻ Skip", () => doSkip(t)),
|
t.recurrence && actionBtn("↻ Skip", () => doSkip(t)),
|
||||||
actionBtn("⚑ Attn", () => cycleAttention(t)),
|
actionBtn("⚑ Attn", () => openAttention(t)),
|
||||||
actionBtn("📅 Date", () => openReschedule(t)),
|
actionBtn("📅 Date", () => openReschedule(t)),
|
||||||
actionBtn("📁 Move", () => openMove(t)),
|
actionBtn("📁 Move", () => openMove(t)),
|
||||||
actionBtn("🗑 Delete", () => doDelete(t), "danger"),
|
actionBtn("🗑 Delete", () => doDelete(t), "danger"),
|
||||||
|
|
@ -353,7 +354,7 @@ function openQuickAdd() {
|
||||||
const input = h("input", {
|
const input = h("input", {
|
||||||
class: "qa-input",
|
class: "qa-input",
|
||||||
type: "text",
|
type: "text",
|
||||||
placeholder: "Buy milk tomorrow p2 #Work every week",
|
placeholder: "Buy milk tomorrow a2 #Work every week",
|
||||||
autocomplete: "off",
|
autocomplete: "off",
|
||||||
autocapitalize: "sentences",
|
autocapitalize: "sentences",
|
||||||
enterkeyhint: "done",
|
enterkeyhint: "done",
|
||||||
|
|
@ -364,12 +365,12 @@ function openQuickAdd() {
|
||||||
const parsed = quickParse(input.value, today(), state.projects);
|
const parsed = quickParse(input.value, today(), state.projects);
|
||||||
preview.innerHTML = "";
|
preview.innerHTML = "";
|
||||||
if (!input.value.trim()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
preview.append(h("span", { class: "qa-title" }, parsed.title || "(no title)"));
|
preview.append(h("span", { class: "qa-title" }, parsed.title || "(no title)"));
|
||||||
if (parsed.attention) {
|
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.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)));
|
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) {
|
// Pick an attention band directly (a1–a4) rather than cycling — cycling could
|
||||||
const next = nextAttention(t.attention);
|
// 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 {
|
try {
|
||||||
await state.client.setAttention(t.node_id, next);
|
await state.client.setAttention(t.node_id, band);
|
||||||
toast(`Attention: ${next}`);
|
toast(`Attention: ${attentionLabel(band)}`);
|
||||||
reload();
|
reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(`Failed: ${e.message}`);
|
toast(`Failed: ${e.message}`);
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,16 @@ export const ATTENTION_COLORS = {
|
||||||
white: "var(--att-white)",
|
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. */
|
/** Attention color string → its `a1`..`a4` UI label (or "" if unset). */
|
||||||
export function nextAttention(att) {
|
export function attentionLabel(att) {
|
||||||
const i = ATTENTION_CYCLE.indexOf(att ?? null);
|
const i = ATTENTION_BANDS.indexOf(att);
|
||||||
// After blue (last), wrap to white (index 1), not back to none.
|
return i < 0 ? "" : `a${i + 1}`;
|
||||||
const next = i < 0 ? 1 : (i + 1) % ATTENTION_CYCLE.length;
|
|
||||||
return ATTENTION_CYCLE[next === 0 ? 1 : next] ?? "white";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether an attention band shows a flag glyph (red/orange/blue; not white). */
|
/** Whether an attention band shows a flag glyph (red/orange/blue; not white). */
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
// Single-line natural-language quick-add — a faithful JS port of hephd's
|
// Single-line natural-language quick-add — a faithful JS port of hephd's
|
||||||
// `quickadd.rs` (tech-spec §8.1). Todoist-style capture:
|
// `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
|
// Recognized inline tokens are extracted and the remainder is the title (order
|
||||||
// preserved). This mirrors the owner's Todoist usage ([[design]] §6.2.1):
|
// 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
|
// - Project #Name → resolved against existing projects, greedily matching
|
||||||
// multi-word titles (#Camano Chores). Unresolved #tags
|
// multi-word titles (#Camano Chores). Unresolved #tags
|
||||||
// stay in the title verbatim (no surprise project).
|
// stay in the title verbatim (no surprise project).
|
||||||
|
|
@ -13,13 +14,13 @@
|
||||||
|
|
||||||
import { parseDate, toEpochMs, parseRecurrenceOrNull } from "./datespec.js";
|
import { parseDate, toEpochMs, parseRecurrenceOrNull } from "./datespec.js";
|
||||||
|
|
||||||
/** p1..p4 → attention color string (matching the RPC serialization), or null. */
|
/** a1..a4 → attention color string (matching the RPC serialization), or null. */
|
||||||
function priorityAttention(token) {
|
function attentionToken(token) {
|
||||||
switch (token.toLowerCase()) {
|
switch (token.toLowerCase()) {
|
||||||
case "p1": return "red";
|
case "a1": return "red";
|
||||||
case "p2": return "orange";
|
case "a2": return "orange";
|
||||||
case "p3": return "blue";
|
case "a3": return "white";
|
||||||
case "p4": return "white";
|
case "a4": return "blue";
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +77,7 @@ export function parse(input, todayDate, projects = []) {
|
||||||
while (i < tokens.length) {
|
while (i < tokens.length) {
|
||||||
const tok = tokens[i];
|
const tok = tokens[i];
|
||||||
|
|
||||||
const att = priorityAttention(tok);
|
const att = attentionToken(tok);
|
||||||
if (att !== null) {
|
if (att !== null) {
|
||||||
out.attention = att;
|
out.attention = att;
|
||||||
i += 1;
|
i += 1;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Service worker: cache the app shell so heph launches offline. Data is never
|
// 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
|
// 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.
|
// anyway). Bump CACHE when shell assets change to evict the old set.
|
||||||
const CACHE = "heph-pwa-v4";
|
const CACHE = "heph-pwa-v5";
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
"./",
|
"./",
|
||||||
"./index.html",
|
"./index.html",
|
||||||
|
|
|
||||||
|
|
@ -134,12 +134,19 @@ test("plain title", () => {
|
||||||
assert.equal(r.projectId, null);
|
assert.equal(r.projectId, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("priority maps to attention", () => {
|
test("attention token maps to attention", () => {
|
||||||
assert.equal(p("Email boss p1").attention, "red");
|
assert.equal(p("Email boss a1").attention, "red");
|
||||||
assert.equal(p("Email boss p2").attention, "orange");
|
assert.equal(p("Email boss a2").attention, "orange");
|
||||||
assert.equal(p("Email boss p3").attention, "blue");
|
assert.equal(p("Email boss a3").attention, "white");
|
||||||
assert.equal(p("Email boss p4").attention, "white");
|
assert.equal(p("Email boss a4").attention, "blue");
|
||||||
assert.equal(p("Email boss p1").title, "Email boss");
|
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", () => {
|
test("relative date is extracted", () => {
|
||||||
|
|
@ -169,7 +176,7 @@ test("recurrence phrase is extracted", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("everything at once", () => {
|
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.title, "Plan trip");
|
||||||
assert.equal(r.attention, "orange");
|
assert.equal(r.attention, "orange");
|
||||||
assert.equal(r.doDate, ms(2026, 6, 5));
|
assert.equal(r.doDate, ms(2026, 6, 5));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue