generated from eblume/project-template
feat(tui): heph-tui T1 — read-only 3-pane agenda (§8.1)
New crate crates/heph-tui: a ratatui terminal agenda, thin client of the hephd unix socket (never touches SQLite, same as heph.nvim). The next big surface — the interactive triage UI the §6.2.1 Todoist study calls for. - 3-pane layout: sidebar (the five §8.2 filter views + projects), task list (attention-colored rows with compact human do/late dates), and a preview pane (the highlighted task's canonical-context doc body + log tail). - App state is generic over a `Backend` seam, so navigation/selection logic is unit-testable without a terminal or daemon; `ClientBackend` forwards to the socket. Rendering is a pure `ui::render(frame, &app)`. - Navigation: j/k within the focused pane, Tab / h / l to move focus, selecting a sidebar source reloads the list, moving the task cursor refreshes the preview. r refresh, q quit. - Socket resolution: --socket flag, then $HEPH_SOCKET, then the standard runtime path (the TUI honors the env var the CLI doesn't). Tests: a headless TestBackend render against a real spawned daemon (asserts views/projects/tasks/preview paint, and Top of Mind excludes blue), plus in-memory navigation unit tests. 8 heph-tui tests; clippy/fmt clean. Mutations (add/done/attention/reschedule/blue) + nvim handoff land in T2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a5fc578525
commit
a21f9e575b
11 changed files with 1376 additions and 1 deletions
296
Cargo.lock
generated
296
Cargo.lock
generated
|
|
@ -40,6 +40,12 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
|
|
@ -377,6 +383,21 @@ version = "1.11.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
|
|
@ -499,6 +520,20 @@ version = "1.0.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
|
|
@ -593,6 +628,31 @@ version = "0.8.21"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix 0.38.44",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
|
|
@ -642,6 +702,40 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
|
|
@ -776,6 +870,12 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
|
|
@ -1106,6 +1206,8 @@ version = "0.15.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
|
|
@ -1161,6 +1263,22 @@ dependencies = [
|
|||
"yrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heph-tui"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"heph-core",
|
||||
"hephd",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hephd"
|
||||
version = "0.0.0"
|
||||
|
|
@ -1417,6 +1535,12 @@ version = "2.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
|
|
@ -1450,6 +1574,15 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
|
|
@ -1460,6 +1593,19 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
|
|
@ -1472,6 +1618,15 @@ version = "1.70.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
|
|
@ -1616,6 +1771,15 @@ version = "0.4.30"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
|
|
@ -1669,6 +1833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
|
@ -1890,6 +2055,16 @@ version = "2.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
|
|
@ -1912,6 +2087,12 @@ dependencies = [
|
|||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
|
|
@ -2219,6 +2400,27 @@ dependencies = [
|
|||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cassowary",
|
||||
"compact_str",
|
||||
"crossterm",
|
||||
"indoc",
|
||||
"instability",
|
||||
"itertools",
|
||||
"lru",
|
||||
"paste",
|
||||
"strum",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
|
@ -2650,6 +2852,27 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
|
|
@ -2759,6 +2982,28 @@ version = "0.11.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
|
|
@ -3114,6 +3359,35 @@ version = "1.0.24"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||
dependencies = [
|
||||
"itertools",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
|
|
@ -3366,6 +3640,28 @@ dependencies = [
|
|||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/heph-core", "crates/hephd", "crates/heph"]
|
||||
members = ["crates/heph-core", "crates/hephd", "crates/heph", "crates/heph-tui"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
|
|
@ -32,6 +32,7 @@ tokio = { version = "1", features = [
|
|||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
ratatui = "0.29"
|
||||
fs4 = "0.12"
|
||||
axum = "0.8"
|
||||
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
|
||||
|
|
|
|||
31
crates/heph-tui/Cargo.toml
Normal file
31
crates/heph-tui/Cargo.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
[package]
|
||||
name = "heph-tui"
|
||||
description = "Hephaestus terminal UI: the task agenda/triage surface (a thin client of the local hephd daemon)."
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "heph-tui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "heph_tui"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
heph-core = { path = "../heph-core" }
|
||||
hephd = { path = "../hephd" }
|
||||
ratatui.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio.workspace = true
|
||||
227
crates/heph-tui/src/app.rs
Normal file
227
crates/heph-tui/src/app.rs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
//! The agenda app state + interaction logic (tech-spec §8.1), generic over a
|
||||
//! [`Backend`] so it is testable without a terminal or a daemon. Rendering
|
||||
//! lives in [`crate::ui`]; the terminal/event loop in `main.rs`.
|
||||
|
||||
use anyhow::Result;
|
||||
use heph_core::{RankedTask, BUILTIN_VIEWS};
|
||||
|
||||
use crate::backend::{Backend, Project};
|
||||
|
||||
/// Which pane has the keyboard.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Focus {
|
||||
Sidebar,
|
||||
Tasks,
|
||||
}
|
||||
|
||||
/// One row of the left sidebar. `Header` rows are labels and are skipped by the
|
||||
/// cursor; the others are selectable sources for the task list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SidebarEntry {
|
||||
Header(String),
|
||||
View { name: String, title: String },
|
||||
Project { id: String, title: String },
|
||||
}
|
||||
|
||||
impl SidebarEntry {
|
||||
fn selectable(&self) -> bool {
|
||||
!matches!(self, SidebarEntry::Header(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected sidebar source, as owned values (so reloads don't hold a borrow
|
||||
/// on `self.sidebar` while calling the backend).
|
||||
enum Target {
|
||||
View(String),
|
||||
Project(String),
|
||||
}
|
||||
|
||||
/// The right-pane preview of the highlighted task: its canonical-context doc
|
||||
/// body plus the tail of its log.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Preview {
|
||||
pub title: String,
|
||||
pub body: Vec<String>,
|
||||
pub log: Vec<String>,
|
||||
}
|
||||
|
||||
/// The whole TUI state.
|
||||
pub struct App<B: Backend> {
|
||||
backend: B,
|
||||
pub sidebar: Vec<SidebarEntry>,
|
||||
pub sidebar_cursor: usize,
|
||||
pub tasks: Vec<RankedTask>,
|
||||
pub task_cursor: usize,
|
||||
pub preview: Preview,
|
||||
pub focus: Focus,
|
||||
pub status: String,
|
||||
pub should_quit: bool,
|
||||
}
|
||||
|
||||
const PREVIEW_LOG_LINES: usize = 5;
|
||||
|
||||
impl<B: Backend> App<B> {
|
||||
/// Build the sidebar (built-in views + projects), select the first view, and
|
||||
/// load its tasks + the first task's preview.
|
||||
pub fn new(mut backend: B) -> Result<Self> {
|
||||
let mut sidebar = vec![SidebarEntry::Header("Views".into())];
|
||||
for v in BUILTIN_VIEWS {
|
||||
sidebar.push(SidebarEntry::View {
|
||||
name: v.name.into(),
|
||||
title: v.title.into(),
|
||||
});
|
||||
}
|
||||
sidebar.push(SidebarEntry::Header("Projects".into()));
|
||||
for Project { id, title } in backend.projects()? {
|
||||
sidebar.push(SidebarEntry::Project { id, title });
|
||||
}
|
||||
|
||||
let sidebar_cursor = sidebar
|
||||
.iter()
|
||||
.position(SidebarEntry::selectable)
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut app = App {
|
||||
backend,
|
||||
sidebar,
|
||||
sidebar_cursor,
|
||||
tasks: Vec::new(),
|
||||
task_cursor: 0,
|
||||
preview: Preview::default(),
|
||||
focus: Focus::Sidebar,
|
||||
status: String::new(),
|
||||
should_quit: false,
|
||||
};
|
||||
app.reload();
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
/// The title shown above the task list (the selected source).
|
||||
pub fn task_pane_title(&self) -> String {
|
||||
match self.sidebar.get(self.sidebar_cursor) {
|
||||
Some(SidebarEntry::View { title, .. }) => title.clone(),
|
||||
Some(SidebarEntry::Project { title, .. }) => title.clone(),
|
||||
_ => "Tasks".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The highlighted task, if any.
|
||||
pub fn selected_task(&self) -> Option<&RankedTask> {
|
||||
self.tasks.get(self.task_cursor)
|
||||
}
|
||||
|
||||
fn current_target(&self) -> Option<Target> {
|
||||
match self.sidebar.get(self.sidebar_cursor)? {
|
||||
SidebarEntry::View { name, .. } => Some(Target::View(name.clone())),
|
||||
SidebarEntry::Project { id, .. } => Some(Target::Project(id.clone())),
|
||||
SidebarEntry::Header(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload the task list for the current sidebar selection, then the preview.
|
||||
/// Errors surface in the status line rather than crashing the UI.
|
||||
pub fn reload(&mut self) {
|
||||
match self.load_tasks() {
|
||||
Ok(tasks) => {
|
||||
self.tasks = tasks;
|
||||
if self.task_cursor >= self.tasks.len() {
|
||||
self.task_cursor = self.tasks.len().saturating_sub(1);
|
||||
}
|
||||
}
|
||||
Err(e) => self.status = format!("error: {e}"),
|
||||
}
|
||||
self.reload_preview();
|
||||
}
|
||||
|
||||
fn load_tasks(&mut self) -> Result<Vec<RankedTask>> {
|
||||
match self.current_target() {
|
||||
Some(Target::View(name)) => self.backend.view(&name),
|
||||
Some(Target::Project(id)) => {
|
||||
let filter = heph_core::ListFilter {
|
||||
scope: vec![id],
|
||||
..Default::default()
|
||||
};
|
||||
self.backend.list(&filter)
|
||||
}
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn reload_preview(&mut self) {
|
||||
let Some(task) = self.selected_task().cloned() else {
|
||||
self.preview = Preview::default();
|
||||
return;
|
||||
};
|
||||
let mut preview = Preview {
|
||||
title: task.title.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(ctx) = &task.canonical_context_id {
|
||||
match self.backend.node_body(ctx) {
|
||||
Ok(body) => preview.body = body.lines().map(str::to_string).collect(),
|
||||
Err(e) => preview.body = vec![format!("(preview error: {e})")],
|
||||
}
|
||||
}
|
||||
if let Ok(log) = self.backend.log_tail(&task.node_id, PREVIEW_LOG_LINES) {
|
||||
preview.log = log;
|
||||
}
|
||||
self.preview = preview;
|
||||
}
|
||||
|
||||
// --- navigation ---
|
||||
|
||||
/// Move the sidebar cursor by `delta` rows, skipping headers, reloading the
|
||||
/// task list for the new selection.
|
||||
pub fn move_sidebar(&mut self, delta: isize) {
|
||||
let n = self.sidebar.len();
|
||||
let mut i = self.sidebar_cursor as isize;
|
||||
loop {
|
||||
i += delta;
|
||||
if i < 0 || i >= n as isize {
|
||||
return; // off the end — leave selection unchanged
|
||||
}
|
||||
if self.sidebar[i as usize].selectable() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.sidebar_cursor = i as usize;
|
||||
self.task_cursor = 0;
|
||||
self.reload();
|
||||
}
|
||||
|
||||
/// Move the task cursor by `delta`, clamped, refreshing the preview.
|
||||
pub fn move_task(&mut self, delta: isize) {
|
||||
if self.tasks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let max = self.tasks.len() as isize - 1;
|
||||
let i = (self.task_cursor as isize + delta).clamp(0, max);
|
||||
self.task_cursor = i as usize;
|
||||
self.reload_preview();
|
||||
}
|
||||
|
||||
pub fn focus_sidebar(&mut self) {
|
||||
self.focus = Focus::Sidebar;
|
||||
}
|
||||
|
||||
pub fn focus_tasks(&mut self) {
|
||||
if !self.tasks.is_empty() {
|
||||
self.focus = Focus::Tasks;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_focus(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
Focus::Sidebar => Focus::Tasks,
|
||||
Focus::Tasks => Focus::Sidebar,
|
||||
};
|
||||
}
|
||||
|
||||
/// Run `f` against the backend; any error lands in the status line. Used by
|
||||
/// mutation gestures (T2).
|
||||
pub fn try_mutate(&mut self, f: impl FnOnce(&mut B) -> Result<()>) {
|
||||
if let Err(e) = f(&mut self.backend) {
|
||||
self.status = format!("error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
159
crates/heph-tui/src/backend.rs
Normal file
159
crates/heph-tui/src/backend.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
//! The data seam between the TUI and the daemon (tech-spec §8.1).
|
||||
//!
|
||||
//! [`Backend`] is the small set of reads/writes the agenda needs; the App is
|
||||
//! generic over it, so navigation/triage logic is unit-testable against a fake
|
||||
//! and the real surface ([`ClientBackend`]) just forwards to the `hephd` unix
|
||||
//! socket — the TUI never touches SQLite, same as `heph.nvim`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch};
|
||||
use hephd::Client;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// A project node, as the sidebar lists it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Everything the agenda surface asks of the daemon.
|
||||
pub trait Backend {
|
||||
/// All project nodes (for the sidebar), title-sorted.
|
||||
fn projects(&mut self) -> Result<Vec<Project>>;
|
||||
/// Run a built-in filter view (`tom|ondeck|chores|work|tasks`, §8.2).
|
||||
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>>;
|
||||
/// Run a raw [`ListFilter`] (used for per-project scope).
|
||||
fn list(&mut self, filter: &ListFilter) -> Result<Vec<RankedTask>>;
|
||||
/// A node's markdown body (the canonical-context doc preview). Empty if the
|
||||
/// node is bodiless or missing.
|
||||
fn node_body(&mut self, id: &str) -> Result<String>;
|
||||
/// The last `n` log lines for a task (the resumption breadcrumb).
|
||||
fn log_tail(&mut self, task_id: &str, n: usize) -> Result<Vec<String>>;
|
||||
|
||||
// --- triage mutations (T2) ---
|
||||
|
||||
/// Set a task's lifecycle state (`done` rolls a recurring task forward).
|
||||
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()>;
|
||||
/// Skip a recurring task to its next occurrence (no completion logged).
|
||||
fn skip(&mut self, task_id: &str) -> Result<()>;
|
||||
/// Set a task's attention band.
|
||||
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()>;
|
||||
/// Patch a task's schedule (do-date / late-on / recurrence), §6 double-option.
|
||||
fn set_schedule(&mut self, task_id: &str, patch: SchedulePatch) -> Result<()>;
|
||||
/// Capture a committed task; returns its node id.
|
||||
fn create_task(
|
||||
&mut self,
|
||||
title: &str,
|
||||
attention: Option<Attention>,
|
||||
do_date: Option<i64>,
|
||||
project_id: Option<&str>,
|
||||
) -> Result<String>;
|
||||
}
|
||||
|
||||
/// The real backend: a thin client of the `hephd` unix socket.
|
||||
pub struct ClientBackend {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl ClientBackend {
|
||||
pub fn new(client: Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value> {
|
||||
self.client
|
||||
.call(method, params)
|
||||
.with_context(|| format!("rpc {method}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for ClientBackend {
|
||||
fn projects(&mut self) -> Result<Vec<Project>> {
|
||||
let v = self.call("node.list", json!({ "kind": "project" }))?;
|
||||
let nodes: Vec<heph_core::Node> = serde_json::from_value(v)?;
|
||||
let mut projects: Vec<Project> = nodes
|
||||
.into_iter()
|
||||
.map(|n| Project {
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
})
|
||||
.collect();
|
||||
projects.sort_by(|a, b| a.title.cmp(&b.title));
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>> {
|
||||
let v = self.call("view", json!({ "name": name }))?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
fn list(&mut self, filter: &ListFilter) -> Result<Vec<RankedTask>> {
|
||||
let v = self.call("list", json!(filter))?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
fn node_body(&mut self, id: &str) -> Result<String> {
|
||||
let v = self.call("node.get", json!({ "id": id }))?;
|
||||
if v.is_null() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let node: heph_core::Node = serde_json::from_value(v)?;
|
||||
Ok(node.body.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn log_tail(&mut self, task_id: &str, n: usize) -> Result<Vec<String>> {
|
||||
let v = self.call("log.tail", json!({ "task_id": task_id, "n": n }))?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> {
|
||||
self.call("task.set_state", json!({ "id": task_id, "state": state }))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn skip(&mut self, task_id: &str) -> Result<()> {
|
||||
self.call("task.skip", json!({ "id": task_id }))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()> {
|
||||
self.call(
|
||||
"task.set_attention",
|
||||
json!({ "id": task_id, "attention": attention }),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_schedule(&mut self, task_id: &str, patch: SchedulePatch) -> Result<()> {
|
||||
let mut params = json!({ "id": task_id });
|
||||
let p = serde_json::to_value(&patch)?;
|
||||
if let Value::Object(map) = p {
|
||||
for (k, val) in map {
|
||||
params[k] = val;
|
||||
}
|
||||
}
|
||||
self.call("task.set_schedule", params)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_task(
|
||||
&mut self,
|
||||
title: &str,
|
||||
attention: Option<Attention>,
|
||||
do_date: Option<i64>,
|
||||
project_id: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let v = self.call(
|
||||
"task.create",
|
||||
json!({
|
||||
"title": title,
|
||||
"attention": attention,
|
||||
"do_date": do_date,
|
||||
"project_id": project_id,
|
||||
}),
|
||||
)?;
|
||||
let task: heph_core::Task = serde_json::from_value(v)?;
|
||||
Ok(task.node_id)
|
||||
}
|
||||
}
|
||||
52
crates/heph-tui/src/fmt.rs
Normal file
52
crates/heph-tui/src/fmt.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! Small display helpers (compact human dates for task rows). The UI layer may
|
||||
//! read the wall clock (unlike `heph-core`, which is clock-injected).
|
||||
|
||||
use chrono::{DateTime, Datelike, Local, NaiveDate};
|
||||
|
||||
/// Format an epoch-ms do/late date relative to `today`: `today`, `tomorrow`,
|
||||
/// `yesterday`, `MM-DD` within the year, else `YYYY-MM-DD`.
|
||||
pub fn fmt_date(ms: i64, today: NaiveDate) -> String {
|
||||
let Some(dt) = DateTime::from_timestamp_millis(ms) else {
|
||||
return "?".into();
|
||||
};
|
||||
let date = dt.with_timezone(&Local).date_naive();
|
||||
match (date - today).num_days() {
|
||||
0 => "today".into(),
|
||||
1 => "tomorrow".into(),
|
||||
-1 => "yesterday".into(),
|
||||
_ if date.year() == today.year() => format!("{:02}-{:02}", date.month(), date.day()),
|
||||
_ => date.format("%Y-%m-%d").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Today in the local timezone (the reference for [`fmt_date`]).
|
||||
pub fn today_local() -> NaiveDate {
|
||||
Local::now().date_naive()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||
}
|
||||
|
||||
fn ms(date: NaiveDate) -> i64 {
|
||||
date.and_hms_opt(12, 0, 0)
|
||||
.unwrap()
|
||||
.and_local_timezone(Local)
|
||||
.unwrap()
|
||||
.timestamp_millis()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_and_absolute_dates() {
|
||||
let today = day(2026, 6, 3);
|
||||
assert_eq!(fmt_date(ms(today), today), "today");
|
||||
assert_eq!(fmt_date(ms(day(2026, 6, 4)), today), "tomorrow");
|
||||
assert_eq!(fmt_date(ms(day(2026, 6, 2)), today), "yesterday");
|
||||
assert_eq!(fmt_date(ms(day(2026, 12, 25)), today), "12-25");
|
||||
assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01");
|
||||
}
|
||||
}
|
||||
15
crates/heph-tui/src/lib.rs
Normal file
15
crates/heph-tui/src/lib.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
//! `heph-tui` — the Hephaestus terminal agenda/triage surface (tech-spec §8.1).
|
||||
//!
|
||||
//! A thin client of the local `hephd` unix socket (it never touches SQLite,
|
||||
//! same as `heph.nvim`). The [`app::App`] holds all state and is generic over a
|
||||
//! [`backend::Backend`] so its navigation/triage logic is unit-testable without
|
||||
//! a terminal; [`ui::render`] draws the 3-pane layout; `main.rs` owns the
|
||||
//! terminal and the event loop.
|
||||
|
||||
pub mod app;
|
||||
pub mod backend;
|
||||
pub mod fmt;
|
||||
pub mod ui;
|
||||
|
||||
pub use app::{App, Focus};
|
||||
pub use backend::{Backend, ClientBackend, Project};
|
||||
95
crates/heph-tui/src/main.rs
Normal file
95
crates/heph-tui/src/main.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! `heph-tui` binary: terminal lifecycle + event loop. All state/logic lives in
|
||||
//! the library ([`heph_tui::App`] + [`heph_tui::ui`]); this file is the I/O shell.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use heph_tui::{app::App, backend::ClientBackend, ui, Focus};
|
||||
use hephd::Client;
|
||||
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "heph-tui", about = "Hephaestus task agenda / triage TUI")]
|
||||
struct Cli {
|
||||
/// Path to the hephd unix socket. Falls back to $HEPH_SOCKET, then the
|
||||
/// standard runtime path.
|
||||
#[arg(long)]
|
||||
socket: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn resolve_socket(flag: Option<PathBuf>) -> PathBuf {
|
||||
flag.or_else(|| std::env::var_os("HEPH_SOCKET").map(PathBuf::from))
|
||||
.unwrap_or_else(hephd::default_socket_path)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let socket = resolve_socket(cli.socket);
|
||||
|
||||
let client = Client::connect(&socket).with_context(|| {
|
||||
format!(
|
||||
"could not connect to hephd at {} — is it running? (try: heph daemon start)",
|
||||
socket.display()
|
||||
)
|
||||
})?;
|
||||
let app = App::new(ClientBackend::new(client)).context("loading the agenda")?;
|
||||
|
||||
let mut terminal = ratatui::init();
|
||||
let result = run(&mut terminal, app);
|
||||
ratatui::restore();
|
||||
result
|
||||
}
|
||||
|
||||
fn run<B: heph_tui::Backend>(
|
||||
terminal: &mut ratatui::DefaultTerminal,
|
||||
mut app: App<B>,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui::render(f, &app))?;
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
handle_key(&mut app, key);
|
||||
}
|
||||
}
|
||||
if app.should_quit {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate a key press into an [`App`] action (T1: navigation only).
|
||||
fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) {
|
||||
// Any keypress clears a stale status message.
|
||||
app.status.clear();
|
||||
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||
app.should_quit = true;
|
||||
return;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
|
||||
KeyCode::Char('r') => app.reload(),
|
||||
KeyCode::Tab => app.toggle_focus(),
|
||||
KeyCode::Char('j') | KeyCode::Down => move_down(app),
|
||||
KeyCode::Char('k') | KeyCode::Up => move_up(app),
|
||||
KeyCode::Char('h') | KeyCode::Left => app.focus_sidebar(),
|
||||
KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => app.focus_tasks(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down<B: heph_tui::Backend>(app: &mut App<B>) {
|
||||
match app.focus {
|
||||
Focus::Sidebar => app.move_sidebar(1),
|
||||
Focus::Tasks => app.move_task(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn move_up<B: heph_tui::Backend>(app: &mut App<B>) {
|
||||
match app.focus {
|
||||
Focus::Sidebar => app.move_sidebar(-1),
|
||||
Focus::Tasks => app.move_task(-1),
|
||||
}
|
||||
}
|
||||
216
crates/heph-tui/src/ui.rs
Normal file
216
crates/heph-tui/src/ui.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
//! Rendering — the 3-pane agenda (sidebar · task list · preview) + status line
|
||||
//! (tech-spec §8.1). Pure: it reads [`App`] and draws; no I/O, no mutation.
|
||||
|
||||
use heph_core::Attention;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::{App, Focus, SidebarEntry};
|
||||
use crate::backend::Backend;
|
||||
use crate::fmt::{fmt_date, today_local};
|
||||
|
||||
const HINTS: &str = " j/k move Tab/h/l pane Enter open r refresh q quit";
|
||||
|
||||
/// Draw the whole UI for the current frame.
|
||||
pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
|
||||
let outer = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(3), Constraint::Length(1)])
|
||||
.split(frame.area());
|
||||
|
||||
let panes = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(22),
|
||||
Constraint::Min(28),
|
||||
Constraint::Length(38),
|
||||
])
|
||||
.split(outer[0]);
|
||||
|
||||
render_sidebar(frame, app, panes[0]);
|
||||
render_tasks(frame, app, panes[1]);
|
||||
render_preview(frame, app, panes[2]);
|
||||
render_status(frame, app, outer[1]);
|
||||
}
|
||||
|
||||
fn pane_border(focused: bool) -> Style {
|
||||
if focused {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||
let focused = app.focus == Focus::Sidebar;
|
||||
let items: Vec<ListItem> = app
|
||||
.sidebar
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, entry)| {
|
||||
let selected = i == app.sidebar_cursor;
|
||||
match entry {
|
||||
SidebarEntry::Header(h) => ListItem::new(Line::from(Span::styled(
|
||||
h.clone(),
|
||||
Style::default()
|
||||
.fg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))),
|
||||
SidebarEntry::View { title, .. } | SidebarEntry::Project { title, .. } => {
|
||||
let mut style = Style::default();
|
||||
if selected {
|
||||
style = if focused {
|
||||
style.fg(Color::Black).bg(Color::Cyan)
|
||||
} else {
|
||||
style.add_modifier(Modifier::REVERSED)
|
||||
};
|
||||
}
|
||||
ListItem::new(Line::from(Span::styled(format!(" {title}"), style)))
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(pane_border(focused))
|
||||
.title(" Views "),
|
||||
);
|
||||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
fn attention_style(a: Option<Attention>) -> (char, Style) {
|
||||
match a {
|
||||
Some(Attention::Red) => ('●', Style::default().fg(Color::Red)),
|
||||
Some(Attention::Orange) => ('●', Style::default().fg(Color::Yellow)),
|
||||
Some(Attention::White) => ('○', Style::default().fg(Color::White)),
|
||||
Some(Attention::Blue) => ('·', Style::default().fg(Color::Blue)),
|
||||
None => ('·', Style::default().fg(Color::DarkGray)),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||
let focused = app.focus == Focus::Tasks;
|
||||
let today = today_local();
|
||||
let width = area.width.saturating_sub(2) as usize; // inside borders
|
||||
|
||||
let items: Vec<ListItem> = if app.tasks.is_empty() {
|
||||
vec![ListItem::new(Line::from(Span::styled(
|
||||
" (nothing here)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)))]
|
||||
} else {
|
||||
app.tasks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let (glyph, gstyle) = attention_style(t.attention);
|
||||
// Right-aligned date chip (late > do).
|
||||
let (chip, chip_style) = if let Some(late) = t
|
||||
.late_on
|
||||
.filter(|l| chrono::Local::now().timestamp_millis() > *l)
|
||||
{
|
||||
(
|
||||
format!("late:{}", fmt_date(late, today)),
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else if let Some(do_date) = t.do_date {
|
||||
(
|
||||
format!("do:{}", fmt_date(do_date, today)),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)
|
||||
} else {
|
||||
(String::new(), Style::default())
|
||||
};
|
||||
|
||||
let selected = i == app.task_cursor;
|
||||
let title_style = if selected && focused {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let cursor = if selected { "▌" } else { " " };
|
||||
|
||||
// Pad the title so the chip sits at the right edge.
|
||||
let chip_w = chip.len();
|
||||
let fixed = 1 + 2; // cursor + glyph + space
|
||||
let avail = width.saturating_sub(fixed + chip_w + 1);
|
||||
let mut title: String = t.title.chars().take(avail).collect();
|
||||
let pad = avail.saturating_sub(title.chars().count());
|
||||
title.push_str(&" ".repeat(pad));
|
||||
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(cursor, Style::default().fg(Color::Cyan)),
|
||||
Span::styled(format!("{glyph} "), gstyle),
|
||||
Span::styled(title, title_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(chip, chip_style),
|
||||
]))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let title = format!(" {} ({}) ", app.task_pane_title(), app.tasks.len());
|
||||
let list = List::new(items).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(pane_border(focused))
|
||||
.title(title),
|
||||
);
|
||||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
fn render_preview<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if !app.preview.title.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
app.preview.title.clone(),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
for l in &app.preview.body {
|
||||
lines.push(Line::from(l.clone()));
|
||||
}
|
||||
if !app.preview.log.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"─ log ─",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
for l in &app.preview.log {
|
||||
lines.push(Line::from(Span::styled(
|
||||
l.clone(),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let para = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(pane_border(false))
|
||||
.title(" Preview "),
|
||||
);
|
||||
frame.render_widget(para, area);
|
||||
}
|
||||
|
||||
fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||
let text = if app.status.is_empty() {
|
||||
HINTS.to_string()
|
||||
} else {
|
||||
format!(" {}", app.status)
|
||||
};
|
||||
let style = if app.status.starts_with("error") {
|
||||
Style::default().fg(Color::Red)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area);
|
||||
}
|
||||
134
crates/heph-tui/tests/agenda.rs
Normal file
134
crates/heph-tui/tests/agenda.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//! Headless render test (tech-spec §8.1/§9): a real `hephd` over a real unix
|
||||
//! socket against a temp DB, driven by the TUI's `ClientBackend`, rendered to
|
||||
//! ratatui's `TestBackend` — asserting the agenda actually paints seeded data.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use heph_core::{FixedClock, LocalStore};
|
||||
use heph_tui::{app::App, backend::ClientBackend};
|
||||
use hephd::{Client, Daemon};
|
||||
use ratatui::{backend::TestBackend, Terminal};
|
||||
use serde_json::json;
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
const NOW: i64 = 1_717_400_000_000; // ~2024-06-03
|
||||
|
||||
fn spawn_daemon() -> (PathBuf, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("heph.db");
|
||||
let socket = dir.path().join("d.sock");
|
||||
|
||||
let store = LocalStore::open(&db, Box::new(FixedClock(NOW))).unwrap();
|
||||
let socket_for_thread = socket.clone();
|
||||
thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async move {
|
||||
let listener = UnixListener::bind(&socket_for_thread).unwrap();
|
||||
let _ = Daemon::new(store).serve(listener).await;
|
||||
});
|
||||
});
|
||||
for _ in 0..200 {
|
||||
if socket.exists() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
(socket, dir)
|
||||
}
|
||||
|
||||
fn client(socket: &Path) -> Client {
|
||||
Client::connect(socket).unwrap()
|
||||
}
|
||||
|
||||
/// Render the app to a fixed-size TestBackend and return the screen as text.
|
||||
fn screen<B: heph_tui::Backend>(app: &App<B>) -> String {
|
||||
let mut terminal = Terminal::new(TestBackend::new(110, 24)).unwrap();
|
||||
terminal.draw(|f| heph_tui::ui::render(f, app)).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area.height {
|
||||
for x in 0..buf.area.width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agenda_renders_views_projects_and_tasks() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let mut c = client(&socket);
|
||||
|
||||
// Seed: a red task (Top of Mind) and a blue one (On Deck), plus a project.
|
||||
c.call(
|
||||
"task.create",
|
||||
json!({ "title": "Pay the water bill", "attention": "red" }),
|
||||
)
|
||||
.unwrap();
|
||||
c.call(
|
||||
"task.create",
|
||||
json!({ "title": "Someday backlog item", "attention": "blue" }),
|
||||
)
|
||||
.unwrap();
|
||||
c.call(
|
||||
"node.create",
|
||||
json!({ "kind": "project", "title": "Camano" }),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let app = App::new(ClientBackend::new(client(&socket))).unwrap();
|
||||
let s = screen(&app);
|
||||
|
||||
// Sidebar shows the built-in views and the project.
|
||||
assert!(s.contains("Top of Mind"), "missing view in sidebar:\n{s}");
|
||||
assert!(s.contains("On Deck"), "missing On Deck view:\n{s}");
|
||||
assert!(s.contains("Camano"), "missing project in sidebar:\n{s}");
|
||||
// The default selection (Top of Mind) lists the red task, not the blue one.
|
||||
assert!(s.contains("Pay the water bill"), "red task missing:\n{s}");
|
||||
assert!(
|
||||
!s.contains("Someday backlog item"),
|
||||
"blue task should not be in Top of Mind:\n{s}"
|
||||
);
|
||||
assert!(s.contains("Preview"), "preview pane missing:\n{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_shows_the_selected_tasks_context_body() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let mut c = client(&socket);
|
||||
|
||||
let task = c
|
||||
.call(
|
||||
"task.create",
|
||||
json!({ "title": "Renew passport", "attention": "orange" }),
|
||||
)
|
||||
.unwrap();
|
||||
// Find the canonical-context doc and give it a body.
|
||||
let links = c
|
||||
.call("links.outgoing", json!({ "id": task["node_id"] }))
|
||||
.unwrap();
|
||||
let ctx = links
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|l| l["link_type"] == "canonical-context")
|
||||
.unwrap()["dst_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
c.call(
|
||||
"node.update",
|
||||
json!({ "id": ctx, "body": "- [ ] fill form\n- [ ] passport photo" }),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let app = App::new(ClientBackend::new(client(&socket))).unwrap();
|
||||
let s = screen(&app);
|
||||
assert!(s.contains("fill form"), "context body not previewed:\n{s}");
|
||||
}
|
||||
149
crates/heph-tui/tests/navigation.rs
Normal file
149
crates/heph-tui/tests/navigation.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
//! Navigation/selection logic against an in-memory fake backend — no terminal,
|
||||
//! no daemon. Asserts the App's cursor + reload behavior (tech-spec §8.1).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch, TaskState};
|
||||
use heph_tui::{
|
||||
app::{App, Focus},
|
||||
backend::{Backend, Project},
|
||||
};
|
||||
|
||||
fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask {
|
||||
RankedTask {
|
||||
node_id: id.into(),
|
||||
title: title.into(),
|
||||
attention: Some(attention),
|
||||
do_date: None,
|
||||
late_on: None,
|
||||
state: TaskState::Outstanding,
|
||||
tombstoned: false,
|
||||
project_id: None,
|
||||
canonical_context_id: ctx.map(str::to_string),
|
||||
created_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
views: HashMap<String, Vec<RankedTask>>,
|
||||
projects: Vec<Project>,
|
||||
by_project: HashMap<String, Vec<RankedTask>>,
|
||||
bodies: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Backend for Fake {
|
||||
fn projects(&mut self) -> Result<Vec<Project>> {
|
||||
Ok(self.projects.clone())
|
||||
}
|
||||
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>> {
|
||||
Ok(self.views.get(name).cloned().unwrap_or_default())
|
||||
}
|
||||
fn list(&mut self, filter: &ListFilter) -> Result<Vec<RankedTask>> {
|
||||
let id = filter.scope.first().cloned().unwrap_or_default();
|
||||
Ok(self.by_project.get(&id).cloned().unwrap_or_default())
|
||||
}
|
||||
fn node_body(&mut self, id: &str) -> Result<String> {
|
||||
Ok(self.bodies.get(id).cloned().unwrap_or_default())
|
||||
}
|
||||
fn log_tail(&mut self, _task_id: &str, _n: usize) -> Result<Vec<String>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn set_state(&mut self, _t: &str, _s: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn skip(&mut self, _t: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn set_schedule(&mut self, _t: &str, _p: SchedulePatch) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn create_task(
|
||||
&mut self,
|
||||
_title: &str,
|
||||
_a: Option<Attention>,
|
||||
_d: Option<i64>,
|
||||
_p: Option<&str>,
|
||||
) -> Result<String> {
|
||||
Ok("new".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture() -> Fake {
|
||||
let mut f = Fake::default();
|
||||
f.views.insert(
|
||||
"tom".into(),
|
||||
vec![
|
||||
task("t1", "red one", Attention::Red, Some("c1")),
|
||||
task("t2", "orange two", Attention::Orange, Some("c2")),
|
||||
],
|
||||
);
|
||||
f.views.insert(
|
||||
"ondeck".into(),
|
||||
vec![task("b1", "blue one", Attention::Blue, None)],
|
||||
);
|
||||
f.projects.push(Project {
|
||||
id: "p1".into(),
|
||||
title: "Camano".into(),
|
||||
});
|
||||
f.by_project.insert(
|
||||
"p1".into(),
|
||||
vec![task("pt", "project task", Attention::White, None)],
|
||||
);
|
||||
f.bodies.insert("c1".into(), "body of red one".into());
|
||||
f.bodies.insert("c2".into(), "body of orange two".into());
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starts_on_the_first_view_with_its_tasks() {
|
||||
let app = App::new(fixture()).unwrap();
|
||||
assert_eq!(app.task_pane_title(), "Top of Mind");
|
||||
assert_eq!(app.tasks.len(), 2);
|
||||
assert_eq!(app.selected_task().unwrap().title, "red one");
|
||||
assert_eq!(app.preview.body, vec!["body of red one"]);
|
||||
assert_eq!(app.focus, Focus::Sidebar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_the_sidebar_switches_the_task_list() {
|
||||
let mut app = App::new(fixture()).unwrap();
|
||||
app.move_sidebar(1); // Top of Mind -> On Deck
|
||||
assert_eq!(app.task_pane_title(), "On Deck");
|
||||
assert_eq!(app.tasks.len(), 1);
|
||||
assert_eq!(app.selected_task().unwrap().title, "blue one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_skips_headers_into_the_projects_section() {
|
||||
let mut app = App::new(fixture()).unwrap();
|
||||
// 5 built-in views: step down 5 times crosses the "Projects" header to p1.
|
||||
for _ in 0..5 {
|
||||
app.move_sidebar(1);
|
||||
}
|
||||
assert_eq!(app.task_pane_title(), "Camano");
|
||||
assert_eq!(app.selected_task().unwrap().title, "project task");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_the_task_cursor_updates_the_preview() {
|
||||
let mut app = App::new(fixture()).unwrap();
|
||||
app.focus_tasks();
|
||||
assert_eq!(app.focus, Focus::Tasks);
|
||||
app.move_task(1);
|
||||
assert_eq!(app.selected_task().unwrap().title, "orange two");
|
||||
assert_eq!(app.preview.body, vec!["body of orange two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_task_clamps_at_the_ends() {
|
||||
let mut app = App::new(fixture()).unwrap();
|
||||
app.move_task(-5); // already at top
|
||||
assert_eq!(app.task_cursor, 0);
|
||||
app.move_task(50); // past the end
|
||||
assert_eq!(app.task_cursor, 1);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue