diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml new file mode 100644 index 00000000..01d89ca1 --- /dev/null +++ b/.github/workflows/demo.yml @@ -0,0 +1,59 @@ +name: demo-tape-smoke + +on: + pull_request: + paths: + - "crates/hm/src/tui/**" + - "docs/demo/**" + - ".github/workflows/demo.yml" + +jobs: + vhs: + runs-on: ubuntu-latest + timeout-minutes: 20 + # The vhs tape is a best-effort smoke test: the TUI may legitimately + # not have docker/harmont-py available in CI, and vhs+ttyd has its own + # flakiness. Failures here annotate the PR but don't block merging. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - name: install rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-wasip1 + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: demo-vhs + + - name: cargo build hm + # Debug profile keeps RAM + time in CI limits. The smoke test + # only verifies the TUI exits cleanly; release perf is moot. + run: cargo build -p harmont-cli + + - name: install vhs + ffmpeg + ttyd + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg ttyd + curl -fsSL https://github.com/charmbracelet/vhs/releases/download/v0.7.2/vhs_0.7.2_amd64.deb -o /tmp/vhs.deb + sudo dpkg -i /tmp/vhs.deb + + - name: smoke-run run.tape + # Non-deterministic frames are OK — we only assert exit-zero. + # The dev.tape references examples/dev-demo/ which doesn't + # exist yet, so it is intentionally skipped here. + # We DON'T require Docker — `hm run` will fail fast without it + # and the TUI will render the failed-build path. We assert vhs + # itself exits OK (tape syntax + binary launched), not that + # the build succeeded. + run: | + if [ -d examples/rust ]; then + export PATH="$PWD/target/debug:$PATH" + vhs docs/demo/run.tape || { + echo "vhs run.tape failed — TUI smoke regression"; + exit 1; + } + else + echo "examples/rust missing, skipping" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e37b7b4..6f88191d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,11 +71,11 @@ jobs: # crates/hm/Cargo.toml carries them into the tarball. run: | set -euo pipefail - for crate in hm-plugin-docker hm-plugin-output-human hm-plugin-output-json hm-plugin-cloud; do + for crate in hm-plugin-docker hm-plugin-cloud; do cargo build --target wasm32-wasip1 -p "$crate" --release done mkdir -p crates/hm/embedded - for name in hm_plugin_docker hm_plugin_output_human hm_plugin_output_json hm_plugin_cloud; do + for name in hm_plugin_docker hm_plugin_cloud; do cp "target/wasm32-wasip1/release/$name.wasm" "crates/hm/embedded/$name.wasm" done ls -la crates/hm/embedded/ diff --git a/Cargo.lock b/Cargo.lock index 43ee1aa7..ab1416fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anpa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d032745fe46100dbcb28ee6e30f12c4b148786f8889e07cd0a3445eeb54970f" + [[package]] name = "ansi-str" version = "0.9.0" @@ -194,7 +200,16 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", ] [[package]] @@ -299,6 +314,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -367,6 +403,31 @@ dependencies = [ "serde_with", ] +[[package]] +name = "bon" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "bstr" version = "1.12.1" @@ -465,6 +526,15 @@ dependencies = [ "winx", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbindgen" version = "0.29.2" @@ -478,7 +548,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn", + "syn 2.0.117", "tempfile", "toml 0.9.12+spec-1.1.0", ] @@ -553,7 +623,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -609,6 +679,20 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.37" @@ -651,6 +735,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -863,11 +956,15 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crossterm_winapi", + "derive_more", "document-features", + "mio", "parking_lot", "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", "winapi", ] @@ -890,6 +987,85 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[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 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -917,6 +1093,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "deranged" version = "0.5.8" @@ -927,6 +1109,59 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "dialoguer" version = "0.11.0" @@ -1007,7 +1242,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1086,6 +1321,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "extism" version = "1.21.0" @@ -1139,7 +1383,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1176,7 +1420,7 @@ checksum = "d086daea5fd844e3c5ac69ddfe36df4a9a43e7218cf7d1f888182b089b09806c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1185,6 +1429,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1202,6 +1456,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -1219,6 +1484,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + [[package]] name = "fixedbitset" version = "0.4.2" @@ -1256,6 +1527,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font8x8" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875488b8711a968268c7cf5d139578713097ca4635a76044e8fe8eedf831d07e" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1348,7 +1631,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1395,7 +1678,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" dependencies = [ - "bitflags", + "bitflags 2.11.1", "debugid", "rustc-hash", "serde", @@ -1489,7 +1772,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags", + "bitflags 2.11.1", "ignore", "walkdir", ] @@ -1541,6 +1824,7 @@ dependencies = [ "clap", "comfy-table", "console 0.15.11", + "crossterm", "dialoguer", "dirs", "extism", @@ -1552,11 +1836,14 @@ dependencies = [ "hm-plugin-protocol", "ignore", "indicatif", + "insta", + "is-terminal", "nix", "once_cell", "owo-colors", "predicates", "rand 0.8.6", + "ratatui", "reqwest", "schemars 0.8.22", "semver", @@ -1564,6 +1851,7 @@ dependencies = [ "serde_json", "sha1", "sha2", + "tachyonfx", "tar", "tempfile", "thiserror 2.0.18", @@ -1572,6 +1860,7 @@ dependencies = [ "toml 0.8.23", "tracing", "tracing-subscriber", + "tui-big-text", "ureq 2.12.1", "url", "uuid", @@ -1592,10 +1881,21 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", "serde", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hashbrown" version = "0.17.0" @@ -1661,32 +1961,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "hm-plugin-output-human" -version = "0.1.0" -dependencies = [ - "chrono", - "extism-pdk", - "hm-plugin-protocol", - "hm-plugin-sdk", - "semver", - "serde", - "serde_json", - "uuid", -] - -[[package]] -name = "hm-plugin-output-json" -version = "0.1.0" -dependencies = [ - "extism-pdk", - "hm-plugin-protocol", - "hm-plugin-sdk", - "semver", - "serde", - "serde_json", -] - [[package]] name = "hm-plugin-protocol" version = "0.0.0-dev" @@ -1967,6 +2241,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" @@ -2054,6 +2334,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "insta" version = "1.47.2" @@ -2067,6 +2356,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "io-extras" version = "0.18.4" @@ -2184,7 +2486,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -2203,7 +2505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2228,6 +2530,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -2264,12 +2583,21 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.4", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2309,12 +2637,31 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "mach2" version = "0.4.3" @@ -2333,7 +2680,7 @@ dependencies = [ "manyhow-macros", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2383,6 +2730,27 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "micromath" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" + [[package]] name = "mime" version = "0.3.17" @@ -2422,6 +2790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2438,10 +2807,11 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -2475,6 +2845,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2494,6 +2875,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -2521,7 +2911,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2", ] @@ -2556,58 +2946,162 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "option-ext" -version = "0.2.0" +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] [[package]] -name = "owo-colors" -version = "4.3.0" +name = "phf" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "supports-color 2.1.0", - "supports-color 3.0.2", + "phf_macros", + "phf_shared", ] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "phf_codegen" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "lock_api", - "parking_lot_core", + "phf_generator", + "phf_shared", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "phf_generator" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", + "phf_shared", + "rand 0.8.6", ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "phf_macros" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "petgraph" -version = "0.6.5" +name = "phf_shared" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "fixedbitset", - "indexmap 2.14.0", + "siphasher", ] [[package]] @@ -2707,7 +3201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2759,7 +3253,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2782,7 +3276,7 @@ checksum = "56000349b6896e3d44286eb9c330891237f40b27fd43c1ccc84547d0b463cb40" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2930,6 +3424,91 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.1", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rayon" version = "1.12.0" @@ -2956,7 +3535,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -2965,7 +3544,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -3007,7 +3586,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3155,7 +3734,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3168,7 +3747,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -3341,7 +3920,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -3356,7 +3935,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -3410,7 +3989,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3421,7 +4000,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3445,7 +4024,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3520,6 +4099,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -3552,6 +4137,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[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" @@ -3590,6 +4196,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "sized-chunks" version = "0.6.5" @@ -3631,12 +4243,39 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3662,6 +4301,17 @@ dependencies = [ "is_ci", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -3690,7 +4340,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3699,7 +4349,7 @@ version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cap-fs-ext", "cap-std", "fd-lock", @@ -3709,6 +4359,21 @@ dependencies = [ "winx", ] +[[package]] +name = "tachyonfx" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c144c687e9f3628c06add1b6db585a68d3cd285a9d7213b9bca836771e337592" +dependencies = [ + "anpa", + "bon", + "compact_str", + "micromath", + "ratatui-core", + "thiserror 2.0.18", + "unicode-width", +] + [[package]] name = "tar" version = "0.4.45" @@ -3758,12 +4423,75 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3790,7 +4518,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3801,7 +4529,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3821,7 +4549,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -3894,7 +4624,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4044,7 +4774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -4092,7 +4822,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4140,12 +4870,31 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-big-text" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6833ec23415d48753f28caec76fa149d0d319ebaedec77ad7d09f7e2094bee8a" +dependencies = [ + "derive_builder", + "font8x8", + "itertools", + "ratatui-core", + "ratatui-widgets", +] + [[package]] name = "typenum" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" @@ -4164,6 +4913,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.2.2" @@ -4268,9 +5028,11 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] @@ -4296,6 +5058,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -4338,7 +5109,7 @@ checksum = "f49ffbbd04665d04028f66aee8f24ae7a1f46063f59a28fddfa52ca3091754a2" dependencies = [ "anyhow", "async-trait", - "bitflags", + "bitflags 2.11.1", "cap-fs-ext", "cap-rand", "cap-std", @@ -4416,7 +5187,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4511,7 +5282,7 @@ version = "0.243.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6d8db401b0528ec316dfbe579e6ab4152d61739cfe076706d2009127970159d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -4524,7 +5295,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -4536,7 +5307,7 @@ version = "0.249.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30538cae9a794215f490b532df01c557e2e2bfac92569482554acd0992a102ea" dependencies = [ - "bitflags", + "bitflags 2.11.1", "indexmap 2.14.0", "semver", ] @@ -4561,7 +5332,7 @@ dependencies = [ "addr2line", "anyhow", "async-trait", - "bitflags", + "bitflags 2.11.1", "bumpalo", "cc", "cfg-if", @@ -4665,7 +5436,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasmtime-internal-component-util", "wasmtime-internal-wit-bindgen", "wit-parser 0.243.0", @@ -4779,7 +5550,7 @@ checksum = "70f8b9796a3f0451a7b702508b303d654de640271ac80287176de222f187a237" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4806,7 +5577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "587699ca7cae16b4a234ffcc834f37e75675933d533809919b52975f5609e2ef" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "heck", "indexmap 2.14.0", "wit-parser 0.243.0", @@ -4906,6 +5677,78 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "which" version = "6.0.3" @@ -4925,7 +5768,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69a60bcbe1475c5dc9ec89210ade54823d44f742e283cba64f98f89697c4cec" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "thiserror 2.0.18", "tracing", "wasmtime", @@ -4943,7 +5786,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "witx", ] @@ -4955,7 +5798,7 @@ checksum = "fea2aea744eded58ae092bf57110c27517dab7d5a300513ff13897325c5c5021" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wiggle-generate", ] @@ -5031,7 +5874,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5042,7 +5885,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5264,7 +6107,7 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "windows-sys 0.59.0", ] @@ -5327,7 +6170,7 @@ dependencies = [ "heck", "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -5343,7 +6186,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -5355,7 +6198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap 2.14.0", "log", "serde", @@ -5450,7 +6293,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5471,7 +6314,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5491,7 +6334,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5531,7 +6374,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 53a63ce4..eedabe67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,8 +5,6 @@ members = [ "crates/hm-plugin-protocol", "crates/hm-plugin-sdk", "crates/hm-plugin-docker", - "crates/hm-plugin-output-human", - "crates/hm-plugin-output-json", "crates/hm-plugin-cloud", "crates/hm-fixtures", ] diff --git a/README.md b/README.md index 44dd9efa..75d6a958 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ [![license](https://img.shields.io/crates/l/harmont-cli.svg)](#license) +![hm run Mission Control TUI](docs/demo/run.gif) + Run CI pipelines on your own machine, in Docker, from a Python pipeline definition checked into your repo. Define the pipeline with the [`harmont-py`](https://github.com/harmont-dev/harmont-py) DSL, then `hm run` builds a fresh container per chain, runs the steps, and reuses snapshots across runs. The same definition runs unchanged on the hosted [Harmont](https://harmont.dev) cloud via `hm cloud run`. diff --git a/RELEASING.md b/RELEASING.md index 4f907f45..4ccec8b6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -39,10 +39,10 @@ workflow in `.github/workflows/release.yml` triggers on any tag matching `v*`, seds the version from the tag into all three crates' `Cargo.toml` files plus the `workspace.dependencies` pins, and publishes `hm-plugin-protocol`, `hm-plugin-sdk`, and `harmont-cli` to crates.io in -that order. The bundled WASM plugins (`hm-plugin-docker`, -`hm-plugin-output-human`, `hm-plugin-output-json`, `hm-plugin-cloud`) +that order. The bundled WASM plugins (`hm-plugin-docker`, `hm-plugin-cloud`) and `hm-fixtures` are not published — they ship embedded inside the -`hm` binary. +`hm` binary. Output formatters (`human`, `json`) are compiled in +natively and are no longer shipped as separate WASM plugins. ### Prerequisites (one-time) diff --git a/crates/hm-plugin-cloud/src/lib.rs b/crates/hm-plugin-cloud/src/lib.rs index 27787634..07549576 100644 --- a/crates/hm-plugin-cloud/src/lib.rs +++ b/crates/hm-plugin-cloud/src/lib.rs @@ -66,6 +66,7 @@ register_plugin!( "hm_kv_get".into(), "hm_kv_set".into(), "hm_should_cancel".into(), + "hm_build_event_emit".into(), ], config_schema: None, allowed_hosts: vec![ diff --git a/crates/hm-plugin-cloud/src/verbs/build.rs b/crates/hm-plugin-cloud/src/verbs/build.rs index 5fabc54d..5b6ad106 100644 --- a/crates/hm-plugin-cloud/src/verbs/build.rs +++ b/crates/hm-plugin-cloud/src/verbs/build.rs @@ -60,44 +60,92 @@ fn cancel(client: &Client, org: &str, pipe: &str, number: i64) -> Result<(), Plu } fn watch(client: &Client, org: &str, pipe: &str, number: i64) -> Result<(), PluginError> { - // Poll the build's state every 2 seconds; print state transitions - // to stderr. Exit when terminal (passed/failed/canceled). - // - // TODO(plan-5+): replace this busy-wait with an `hm_sleep_ms` host - // fn. WASM has no native sleep, so for now we spin while polling - // `host::should_cancel`. Crude but adequate for short intervals. + use hm_plugin_protocol::{BuildEvent, PlanSummary, StdStream}; + use uuid::Uuid; + + let run_id = Uuid::new_v4(); + let step_id = Uuid::new_v4(); + + host::build_event_emit(&BuildEvent::BuildStart { + run_id, + plan: PlanSummary { + step_count: 1, + chain_count: 1, + default_runner: "cloud".into(), + }, + started_at: chrono::Utc::now(), + }); + host::build_event_emit(&BuildEvent::StepQueued { + step_id, + key: format!("cloud build #{number}"), + chain_idx: 0, + }); + host::build_event_emit(&BuildEvent::StepStart { + step_id, + runner: "cloud".into(), + image: None, + }); + + let started = std::time::SystemTime::now(); let mut last_state = String::new(); + loop { if host::should_cancel() { - return Err(PluginError::new( - "cloud_cancelled", - "watch cancelled by user", - )); + host::build_event_emit(&BuildEvent::ChainFailed { + chain_idx: 0, + failed_step_id: step_id, + failed_step_key: format!("cloud build #{number}"), + exit_code: 130, + message: "watch cancelled by user".into(), + ts: chrono::Utc::now(), + }); + return Err(PluginError::new("cloud_cancelled", "watch cancelled by user")); } let b: Build = client.get(&format!( "/organizations/{org}/pipelines/{pipe}/builds/{number}" ))?; if b.state != last_state { - host::write_stderr(format!("state: {last_state} -> {}\n", b.state).as_bytes()); + host::build_event_emit(&BuildEvent::StepLog { + step_id, + stream: StdStream::Stderr, + line: format!("state: {last_state} -> {}", b.state), + ts: chrono::Utc::now(), + }); last_state = b.state.clone(); } - match b.state.as_str() { - "passed" => return Ok(()), - "failed" | "canceled" => { - return Err(PluginError::new( - "cloud_build_failed", - format!("build {} ({})", b.state, number), - )); + let terminal = match b.state.as_str() { + "passed" => Some(0i32), + "failed" | "canceled" => Some(1i32), + _ => None, + }; + if let Some(code) = terminal { + let elapsed_ms = u64::try_from( + started.elapsed().map(|d| d.as_millis()).unwrap_or(0) + ).unwrap_or(u64::MAX); + host::build_event_emit(&BuildEvent::StepEnd { + step_id, + exit_code: code, + duration_ms: elapsed_ms, + snapshot: None, + }); + host::build_event_emit(&BuildEvent::BuildEnd { + exit_code: code, + duration_ms: elapsed_ms, + }); + if code == 0 { + return Ok(()); } - _ => {} + return Err(PluginError::new( + "cloud_build_failed", + format!("build {} ({})", b.state, number), + )); } - let start = std::time::SystemTime::now(); - while start.elapsed().map(|d| d.as_secs() < 2).unwrap_or(true) { + // Busy-wait ~2s, polling cancellation. Same shape as before; + // hm_sleep_ms host fn arrives in a later plan. + let spin_start = std::time::SystemTime::now(); + while spin_start.elapsed().map(|d| d.as_secs() < 2).unwrap_or(true) { if host::should_cancel() { - return Err(PluginError::new( - "cloud_cancelled", - "watch cancelled by user", - )); + break; } } } diff --git a/crates/hm-plugin-output-human/Cargo.toml b/crates/hm-plugin-output-human/Cargo.toml deleted file mode 100644 index a5000559..00000000 --- a/crates/hm-plugin-output-human/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "hm-plugin-output-human" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Built-in human-readable output formatter for the hm CLI." -publish = false - -[lib] -crate-type = ["cdylib"] -path = "src/lib.rs" - -[dependencies] -hm-plugin-sdk = { workspace = true } -hm-plugin-protocol = { workspace = true } -extism-pdk = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -semver = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } - -[lints] -workspace = true diff --git a/crates/hm-plugin-output-human/src/lib.rs b/crates/hm-plugin-output-human/src/lib.rs deleted file mode 100644 index 5168e4fd..00000000 --- a/crates/hm-plugin-output-human/src/lib.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Built-in human-readable output formatter for the hm CLI. -//! -//! Subscribes to the orchestrator's BuildEvent stream via the -//! `hm_output_on_event` capability export; writes prefixed step logs -//! and brief status lines to stderr. - -#![allow(unsafe_code, reason = "extism-pdk host_fn imports require unsafe")] -#![allow( - clippy::pedantic, - clippy::nursery, - clippy::cargo, - clippy::multiple_crate_versions, - clippy::cargo_common_metadata, - clippy::missing_errors_doc, - reason = "matches the test-fixtures allow-list; plugin authoring crate" -)] - -mod render; - -use hm_plugin_sdk::*; - -#[derive(Default)] -struct Human; - -impl OutputFormatter for Human { - fn on_event(&self, event: BuildEvent) -> Result<(), PluginError> { - let bytes = render::render(&event); - if !bytes.is_empty() { - host::write_stderr(&bytes); - } - Ok(()) - } -} - -register_plugin!( - manifest = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "harmont-output-human".into(), - version: semver::Version::new(0, 1, 0), - description: "Human-readable build output formatter.".into(), - capabilities: vec![Capability::OutputFormatter(OutputFormatterSpec { - name: "human".into(), - mime: "text/plain".into(), - })], - required_host_fns: vec!["hm_write_stderr".into()], - config_schema: None, - allowed_hosts: vec![], - }, - output = Human, -); diff --git a/crates/hm-plugin-output-human/src/render.rs b/crates/hm-plugin-output-human/src/render.rs deleted file mode 100644 index 8d869d07..00000000 --- a/crates/hm-plugin-output-human/src/render.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Pure-function rendering of BuildEvents to stderr bytes. Held -//! deliberately stateless so render() can be unit-tested without -//! Extism. -//! -//! Step keys are tracked per-plugin instance because the wire -//! BuildEvents carry step_id (Uuid) only; the plugin builds a -//! step_id → key map from the StepQueued events it sees. - -use hm_plugin_protocol::BuildEvent; -use std::collections::HashMap; -use std::sync::Mutex; -use uuid::Uuid; - -static STEP_KEYS: Mutex = Mutex::new(HmKeyMap { inner: None }); - -struct HmKeyMap { - inner: Option>, -} - -impl HmKeyMap { - fn ensure(&mut self) -> &mut HashMap { - self.inner.get_or_insert_with(HashMap::new) - } -} - -fn record_step_key(id: Uuid, key: String) { - let Ok(mut g) = STEP_KEYS.lock() else { return }; - g.ensure().insert(id, key); -} - -fn step_key_for(id: Uuid) -> String { - STEP_KEYS - .lock() - .ok() - .and_then(|g| g.inner.as_ref().and_then(|m| m.get(&id).cloned())) - .unwrap_or_else(|| "?".to_string()) -} - -pub(crate) fn render(ev: &BuildEvent) -> Vec { - match ev { - BuildEvent::BuildStart { plan, .. } => format!( - "build: {} steps in {} chain(s)\n", - plan.step_count, plan.chain_count - ) - .into_bytes(), - BuildEvent::StepQueued { step_id, key, .. } => { - record_step_key(*step_id, key.clone()); - Vec::new() // queue itself doesn't produce visible output - } - BuildEvent::StepStart { - step_id, - runner, - image, - } => { - let key = step_key_for(*step_id); - let line = match image { - Some(img) => format!("[{key}] start (runner={runner} image={img})\n"), - None => format!("[{key}] start (runner={runner})\n"), - }; - line.into_bytes() - } - BuildEvent::StepLog { step_id, line, .. } => { - let key = step_key_for(*step_id); - format!("[{key}] {line}\n").into_bytes() - } - BuildEvent::StepCacheHit { step_id, tag, .. } => { - let key = step_key_for(*step_id); - format!("[{key}] cache hit ({tag})\n").into_bytes() - } - BuildEvent::StepEnd { - step_id, - exit_code, - duration_ms, - .. - } => { - let key = step_key_for(*step_id); - format!("[{key}] end exit={exit_code} duration={duration_ms}ms\n").into_bytes() - } - BuildEvent::BuildEnd { - exit_code, - duration_ms, - } => format!("build: end exit={exit_code} duration={duration_ms}ms\n").into_bytes(), - BuildEvent::ChainFailed { - chain_idx, - failed_step_key, - exit_code, - message, - .. - } => format!( - "chain {chain_idx}: FAILED at step '{failed_step_key}' (exit={exit_code}): {message}\n" - ) - .into_bytes(), - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod tests { - use super::*; - use hm_plugin_protocol::{PlanSummary, StdStream}; - - #[test] - fn build_start_renders_step_and_chain_counts() { - let ev = BuildEvent::BuildStart { - run_id: Uuid::nil(), - plan: PlanSummary { - step_count: 3, - chain_count: 2, - default_runner: "docker".into(), - }, - started_at: chrono::Utc::now(), - }; - let s = String::from_utf8(render(&ev)).unwrap(); - assert!(s.contains("3 steps")); - assert!(s.contains("2 chain")); - } - - #[test] - fn step_log_renders_with_prefix_after_step_queued_recorded_key() { - let step_id = Uuid::new_v4(); - render(&BuildEvent::StepQueued { - step_id, - key: "build".into(), - chain_idx: 0, - }); - let ev = BuildEvent::StepLog { - step_id, - stream: StdStream::Stdout, - line: "hello".into(), - ts: chrono::Utc::now(), - }; - let s = String::from_utf8(render(&ev)).unwrap(); - assert_eq!(s, "[build] hello\n"); - } - - #[test] - fn step_log_with_unknown_key_renders_question_mark() { - let s = String::from_utf8(render(&BuildEvent::StepLog { - step_id: Uuid::new_v4(), - stream: StdStream::Stdout, - line: "x".into(), - ts: chrono::Utc::now(), - })) - .unwrap(); - assert!(s.starts_with("[?] ")); - } -} diff --git a/crates/hm-plugin-output-json/Cargo.toml b/crates/hm-plugin-output-json/Cargo.toml deleted file mode 100644 index 9d5acd5b..00000000 --- a/crates/hm-plugin-output-json/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "hm-plugin-output-json" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Built-in JSON-lines output formatter for the hm CLI." -publish = false - -[lib] -crate-type = ["cdylib"] -path = "src/lib.rs" - -[dependencies] -hm-plugin-sdk = { workspace = true } -hm-plugin-protocol = { workspace = true } -extism-pdk = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -semver = { workspace = true } - -[lints] -workspace = true diff --git a/crates/hm-plugin-output-json/src/lib.rs b/crates/hm-plugin-output-json/src/lib.rs deleted file mode 100644 index 39a4a36e..00000000 --- a/crates/hm-plugin-output-json/src/lib.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Built-in JSON-lines output formatter. -//! -//! Each `BuildEvent` is serialised to JSON on a single line and -//! written to stdout. Stderr is reserved for plugin/host diagnostics. - -#![allow(unsafe_code, reason = "extism-pdk host_fn imports require unsafe")] -#![allow( - clippy::pedantic, - clippy::nursery, - clippy::cargo, - clippy::multiple_crate_versions, - clippy::cargo_common_metadata, - clippy::missing_errors_doc, - reason = "matches the test-fixtures allow-list; plugin authoring crate" -)] - -use hm_plugin_sdk::*; - -#[derive(Default)] -struct Json; - -impl OutputFormatter for Json { - fn on_event(&self, event: BuildEvent) -> Result<(), PluginError> { - let mut bytes = serde_json::to_vec(&event) - .map_err(|e| PluginError::new("output_json_serde", e.to_string()))?; - bytes.push(b'\n'); - host::write_stdout(&bytes); - Ok(()) - } -} - -register_plugin!( - manifest = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "harmont-output-json".into(), - version: semver::Version::new(0, 1, 0), - description: "JSON-lines build output formatter.".into(), - capabilities: vec![Capability::OutputFormatter(OutputFormatterSpec { - name: "json".into(), - mime: "application/x-ndjson".into(), - })], - required_host_fns: vec!["hm_write_stdout".into()], - config_schema: None, - allowed_hosts: vec![], - }, - output = Json, -); diff --git a/crates/hm-plugin-protocol/src/host_abi.rs b/crates/hm-plugin-protocol/src/host_abi.rs index aa016a65..948cc357 100644 --- a/crates/hm-plugin-protocol/src/host_abi.rs +++ b/crates/hm-plugin-protocol/src/host_abi.rs @@ -143,3 +143,8 @@ pub struct DockerExtractArgs { pub archive_id: crate::ArchiveId, pub workdir: String, } + +/// Host fn used by plugins (currently `hm-plugin-cloud::watch`) to +/// emit a wire `BuildEvent` directly into the host's TUI mpsc. +/// Payload: `serde_json::to_vec(&BuildEvent)`. Returns nothing. +pub const HM_BUILD_EVENT_EMIT: &str = "hm_build_event_emit"; diff --git a/crates/hm-plugin-sdk/src/host.rs b/crates/hm-plugin-sdk/src/host.rs index 1fa76634..061a8727 100644 --- a/crates/hm-plugin-sdk/src/host.rs +++ b/crates/hm-plugin-sdk/src/host.rs @@ -53,6 +53,8 @@ extern "ExtismHost" { fn hm_should_cancel() -> u32; + fn hm_build_event_emit(bytes: Vec); + fn hm_write_stdout(bytes: Vec); fn hm_write_stderr(bytes: Vec); } @@ -78,6 +80,11 @@ pub fn emit_event(event: BuildEvent) { let _ = unsafe { hm_emit_event(Json(event)) }; } +pub fn build_event_emit(event: &hm_plugin_protocol::BuildEvent) { + let Ok(bytes) = serde_json::to_vec(event) else { return; }; + let _ = unsafe { hm_build_event_emit(bytes) }; +} + pub fn kv_get(scope: KvScope, key: &str) -> Option> { let Json(v) = unsafe { hm_kv_get(Json(scope), key.into()) }.unwrap_or(Json(None)); v diff --git a/crates/hm/CLAUDE.md b/crates/hm/CLAUDE.md index e9b1a880..e12385f3 100644 --- a/crates/hm/CLAUDE.md +++ b/crates/hm/CLAUDE.md @@ -9,11 +9,12 @@ via `build.rs`) and resolves each step's `runner` field to a registered plugin in `scheduler.rs`. - Publishes `BuildEvent`s on a `tokio::sync::broadcast` (`events.rs`); - the `output_subscriber` task drains the bus and invokes the selected - output plugin's `hm_output_on_event` per event (`hm-plugin-output-human` - or `hm-plugin-output-json`, both embedded via `build.rs`). Default - `--format` is `human`; `--format json` writes one JSON event per - line on stdout. + the `output_subscriber` task drains the bus and dispatches to the + selected formatter. Built-in formatters (`human`, `json`) are + native Rust code in `crates/hm/src/output/formatters/`; external + WASM plugins registered in the plugin registry are still supported + as a fall-through. Default `--format` is `human`; `--format json` + writes one JSON event per line on stdout. - Streams cache decisions host-side (`cache.rs`), reads the workspace archive once into memory (`archive.rs` + `source.rs`), and drives the Docker daemon via the Bollard wrapper (`docker_client.rs`, diff --git a/crates/hm/Cargo.toml b/crates/hm/Cargo.toml index 97243c1f..b669c994 100644 --- a/crates/hm/Cargo.toml +++ b/crates/hm/Cargo.toml @@ -61,7 +61,7 @@ sha2 = "0.10" axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "query"] } webbrowser = "1" rand = "0.8" -uuid = { version = "1", features = ["serde"] } +uuid = { version = "1", features = ["serde", "v4", "v5"] } bytes = "1" futures = "0.3" futures-util = "0.3" @@ -73,6 +73,11 @@ schemars = { workspace = true } semver = { workspace = true } once_cell = "1" hex = "0.4" +ratatui = "0.30.0" +crossterm = "0.29.0" +tachyonfx = "0.25.0" +tui-big-text = "0.8.4" +is-terminal = "0.4.17" [features] default = [] @@ -87,6 +92,7 @@ assert_fs = "1" tempfile = "3" nix = { version = "0.29", features = ["signal"] } ureq = { version = "2", default-features = false, features = ["tls"] } +insta = { version = "1.47.2", features = ["yaml"] } [lints] workspace = true diff --git a/crates/hm/build.rs b/crates/hm/build.rs index e07112d1..662c984d 100644 --- a/crates/hm/build.rs +++ b/crates/hm/build.rs @@ -1,7 +1,6 @@ //! Build script: compiles the embedded WASM plugins shipped with `hm` -//! (`hm-plugin-docker`, `hm-plugin-output-human`, `hm-plugin-output-json`, -//! `hm-plugin-cloud`) and stages their artifacts under `$OUT_DIR` so the -//! host can `include_bytes!` them at runtime. +//! (`hm-plugin-docker`, `hm-plugin-cloud`) and stages their artifacts under +//! `$OUT_DIR` so the host can `include_bytes!` them at runtime. #![allow( clippy::expect_used, clippy::panic, @@ -69,7 +68,5 @@ fn build_wasm_plugin(crate_name: &str) { fn build_embedded_plugins() { build_wasm_plugin("hm-plugin-docker"); - build_wasm_plugin("hm-plugin-output-human"); - build_wasm_plugin("hm-plugin-output-json"); build_wasm_plugin("hm-plugin-cloud"); } diff --git a/crates/hm/src/cli.rs b/crates/hm/src/cli.rs index 9ddfae23..f66855fd 100644 --- a/crates/hm/src/cli.rs +++ b/crates/hm/src/cli.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; arg_required_else_help = true, disable_help_subcommand = true )] +#[allow(clippy::struct_excessive_bools, reason = "flat CLI flag list; refactoring to a config struct is overkill for a clap derive")] pub struct Cli { /// Override the API base URL. Hidden flag — set `HARMONT_API_URL` instead. #[arg(long, global = true, env = "HARMONT_API_URL", hide = true)] @@ -25,6 +26,16 @@ pub struct Cli { #[arg(long, global = true)] pub no_color: bool, + /// Disable the interactive TUI; fall back to the streaming text + /// formatter. Implied when stdout is not a TTY. + #[arg(long, global = true)] + pub no_tui: bool, + + /// Disable TUI animation effects (kept layout identical). + /// Implied by `NO_COLOR`. + #[arg(long, global = true)] + pub no_fx: bool, + #[command(subcommand)] pub command: Command, } diff --git a/crates/hm/src/commands/dev/mod.rs b/crates/hm/src/commands/dev/mod.rs index ee4ce215..528042da 100644 --- a/crates/hm/src/commands/dev/mod.rs +++ b/crates/hm/src/commands/dev/mod.rs @@ -1,4 +1,8 @@ //! `hm dev` — local Docker deployment subcommand tree. +#![allow( + clippy::future_not_send, + reason = "tachyonfx::Shader is !Send by design; futures are .await'ed inline on the main task" +)] //! //! Reads `.harmont/*.py` for `@hm.deploy` registrations (via a Python //! subprocess) and orchestrates long-lived containers on a per-session diff --git a/crates/hm/src/commands/dev/up.rs b/crates/hm/src/commands/dev/up.rs index 87df347a..a8b5f1ff 100644 --- a/crates/hm/src/commands/dev/up.rs +++ b/crates/hm/src/commands/dev/up.rs @@ -1,4 +1,8 @@ //! `hm dev up` — bring deployments up in the foreground. +#![allow( + clippy::future_not_send, + reason = "tachyonfx::Shader is !Send by design; futures are .await'ed inline on the main task" +)] //! //! Flow: registry dump (subprocess) → boot plan (topo) → create network //! → boot containers per level (parallel) → log mux → wait signal → @@ -6,6 +10,7 @@ use anyhow::{Context, Result}; use futures_util::StreamExt; +use is_terminal::IsTerminal; use tokio::sync::mpsc; use tokio::task::JoinSet; @@ -43,8 +48,14 @@ struct BootCtx { /// /// Returns an error if the registry dump fails, Docker is unreachable, /// network creation fails, or any container boot fails. +/// +/// # Panics +/// +/// Panics if the internal `log_rx` channel holder is unexpectedly empty +/// (indicates a logic error in the TUI/logmux path selection). #[allow(clippy::print_stderr, reason = "status messages to stderr are intentional for a foreground CLI")] -pub async fn handle(args: DevUpArgs, _ctx: RunContext) -> Result { +#[allow(clippy::expect_used, reason = "log_rx holder is guaranteed non-None by the branching logic above each take()")] +pub async fn handle(args: DevUpArgs, ctx: RunContext) -> Result { let worktree_root = resolve_worktree_root()?; let wt_hash = worktree_hash(&worktree_root); let session_id = fresh_session_id(); @@ -62,12 +73,24 @@ pub async fn handle(args: DevUpArgs, _ctx: RunContext) -> Result { let slug_width = boot_plan.slugs().map(str::len).max().unwrap_or(4); let (log_tx, log_rx) = mpsc::unbounded_channel::(); - let log_color = std::env::var("NO_COLOR").is_err(); - let log_task = tokio::spawn(run_logmux(log_rx, slug_width, log_color)); + + let want_tui = !ctx.no_tui + && std::env::var_os("NO_COLOR").is_none() + && std::io::stdout().is_terminal(); + let log_color = std::env::var_os("NO_COLOR").is_none(); + + // Keep the receiver until we know who owns it: legacy logmux or the TUI. + let mut log_rx_holder = Some(log_rx); + let log_task = if want_tui { + None + } else { + let rx = log_rx_holder.take().expect("log_rx is present"); + Some(tokio::spawn(run_logmux(rx, slug_width, log_color))) + }; let mut booted: Vec = Vec::new(); - let ctx = BootCtx { + let boot_ctx = BootCtx { worktree_root, worktree_hash: wt_hash, session_id: session_id.clone(), @@ -86,9 +109,9 @@ pub async fn handle(args: DevUpArgs, _ctx: RunContext) -> Result { let spec = spec.clone(); let slug = slug.clone(); let log_tx = log_tx.clone(); - let ctx = ctx.clone(); + let boot_ctx = boot_ctx.clone(); joinset.spawn(async move { - boot_one(docker, slug, spec, ctx, log_tx).await + boot_one(docker, slug, spec, boot_ctx, log_tx).await }); } while let Some(res) = joinset.join_next().await { @@ -97,6 +120,33 @@ pub async fn handle(args: DevUpArgs, _ctx: RunContext) -> Result { } } + if want_tui { + // The legacy "all up" / "tearing down" banners are stderr-only + // in the streaming path; in the TUI they're redundant because the + // header + summary card communicate the same info. + let log_rx = log_rx_holder.take().expect("log_rx reserved for TUI"); + let deploys: Vec<(String, String)> = booted + .iter() + .map(|b| (b.slug.clone(), b.container_id.clone())) + .collect(); + let tui_rx = crate::tui::source::dev::spawn(log_rx, deploys); + let opts = crate::tui::TuiOptions { + fx_enabled: !ctx.no_fx && std::env::var_os("NO_COLOR").is_none(), + summary_card: true, + title: "hm dev up".into(), + }; + // tui::run blocks until BuildEnd or user quits. While running, + // ctrl-c is handled by the TUI's own key dispatch (1 cancel, + // 2 force exit). + let _ = crate::tui::run(tui_rx, opts) + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + eprintln!("[hm] tearing down..."); + teardown(&docker, &net, &booted).await; + drop(log_tx); // close the channel so any in-flight log lines settle + return Ok(0); + } + eprintln!("[hm] all up. Ctrl-C to tear down. Logs follow."); // Wait for SIGINT/SIGTERM. @@ -105,9 +155,10 @@ pub async fn handle(args: DevUpArgs, _ctx: RunContext) -> Result { eprintln!("[hm] tearing down..."); teardown(&docker, &net, &booted).await; - // Drop the sender so the logmux channel closes and the task can finish. drop(log_tx); - let _ = log_task.await; + if let Some(handle) = log_task { + let _ = handle.await; + } Ok(0) } diff --git a/crates/hm/src/commands/mod.rs b/crates/hm/src/commands/mod.rs index abd083a5..20e78334 100644 --- a/crates/hm/src/commands/mod.rs +++ b/crates/hm/src/commands/mod.rs @@ -1,3 +1,8 @@ +#![allow( + clippy::future_not_send, + reason = "tachyonfx::Shader is !Send by design; futures are .await'ed inline on the main task" +)] + pub mod dev; pub mod run; @@ -19,6 +24,6 @@ pub async fn dispatch(command: Command, ctx: RunContext) -> Result { Command::Dev(cmd) => dev::dispatch(cmd, ctx).await, Command::Version => crate::builtin::version::run().await.map(|()| 0), Command::Plugin(cmd) => crate::builtin::plugin::run(cmd).await.map(|()| 0), - Command::External(argv) => crate::dispatcher::run(argv).await, + Command::External(argv) => crate::dispatcher::run(argv, ctx.no_tui).await, } } diff --git a/crates/hm/src/commands/run/local.rs b/crates/hm/src/commands/run/local.rs index d05cb288..63e9bad8 100644 --- a/crates/hm/src/commands/run/local.rs +++ b/crates/hm/src/commands/run/local.rs @@ -1,4 +1,10 @@ +#![allow( + clippy::future_not_send, + reason = "tachyonfx::Shader is !Send by design; futures are .await'ed inline on the main task" +)] + use anyhow::{Context, Result}; +use is_terminal::IsTerminal; use super::render::{ToolPaths, list_pipelines, render_pipeline_json}; use crate::cli::RunArgs; @@ -60,7 +66,7 @@ fn decode_plan_to_wire(bytes: &[u8]) -> anyhow::Result Result { +pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { let repo_root = match args.dir.clone() { Some(p) => p, None => std::env::current_dir().context("cannot determine current directory")?, @@ -95,8 +101,48 @@ pub async fn handle(args: RunArgs, _ctx: RunContext) -> Result { let parallelism = args.parallelism.unwrap_or_else(|| { std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get) }); - let exit_code = - crate::orchestrator::run(pipeline_wire, repo_root, parallelism, args.format.clone()) - .await?; + + let want_tui = args.format == "human" + && std::env::var_os("NO_COLOR").is_none() + && !ctx.no_tui + && std::io::stdout().is_terminal(); + + if want_tui { + let (bus_tx, tui_rx) = crate::tui::source::local::spawn(); + let opts = crate::tui::TuiOptions { + fx_enabled: !ctx.no_fx, + summary_card: true, + title: "hm run".into(), + }; + let orch_handle = { + let pipeline_wire = pipeline_wire.clone(); + let repo_root = repo_root.clone(); + let format = args.format.clone(); + tokio::spawn(async move { + crate::orchestrator::run( + pipeline_wire, + repo_root, + parallelism, + format, + Some(bus_tx), + ) + .await + }) + }; + let tui_exit = crate::tui::run(tui_rx, opts) + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + let orch_exit = orch_handle.await??; + return Ok(if tui_exit != 0 { tui_exit } else { orch_exit }); + } + + let exit_code = crate::orchestrator::run( + pipeline_wire, + repo_root, + parallelism, + args.format.clone(), + None, + ) + .await?; Ok(exit_code) } diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index 75c6920c..1fce1b89 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -1,3 +1,8 @@ +#![allow( + clippy::future_not_send, + reason = "tachyonfx::Shader is !Send by design; futures are .await'ed inline on the main task" +)] + use anyhow::Result; use crate::cli::RunArgs; diff --git a/crates/hm/src/context.rs b/crates/hm/src/context.rs index 8b06bace..7f8fbe7b 100644 --- a/crates/hm/src/context.rs +++ b/crates/hm/src/context.rs @@ -19,6 +19,10 @@ pub struct RunContext { /// `--format` flag was retired in plan 3; per-subcommand `--format` /// is the only currently-wired source, so this defaults to human). pub output: OutputMode, + /// Disable the interactive TUI even when stdout is a TTY. + pub no_tui: bool, + /// Disable TUI animation effects. + pub no_fx: bool, } impl RunContext { @@ -35,6 +39,11 @@ impl RunContext { interactive: std::io::stdout().is_terminal(), }; - Ok(Self { config, output }) + Ok(Self { + config, + output, + no_tui: cli.no_tui, + no_fx: cli.no_fx, + }) } } diff --git a/crates/hm/src/dispatcher.rs b/crates/hm/src/dispatcher.rs index c6a235c6..985e69f5 100644 --- a/crates/hm/src/dispatcher.rs +++ b/crates/hm/src/dispatcher.rs @@ -1,4 +1,8 @@ //! Subcommand-plugin dispatcher. +#![allow( + clippy::future_not_send, + reason = "tachyonfx::Shader is !Send by design; futures are .await'ed inline on the main task" +)] //! //! Routes `hm ` to the registered plugin //! whose manifest's `SubcommandSpec.verb` matches the first argv @@ -18,15 +22,54 @@ use hm_plugin_protocol::{ExitInfo, SubcommandInput}; use crate::error::HmError; use crate::plugin::{PluginRegistry, RegistryConfig}; -/// Entry point: invoke a plugin-provided subcommand. `argv` is the -/// captured `external_subcommand` args INCLUDING the verb itself (clap's -/// convention). Returns the process exit code. +/// Entry point: invoke a plugin-provided subcommand. +/// +/// `argv` is the captured `external_subcommand` args INCLUDING the verb +/// itself (clap's convention). `no_tui` suppresses the interactive TUI +/// even when stdout is a TTY. Returns the process exit code. /// /// # Errors +/// /// Returns an error if no plugin claims the verb, the plugin fails to /// load, or the plugin panics during dispatch. Non-zero `ExitInfo.exit_code` /// is surfaced as `Ok(i32)`, not as `Err`. -pub async fn run(argv: Vec) -> Result { +pub async fn run(argv: Vec, no_tui: bool) -> Result { + use is_terminal::IsTerminal; + + if argv.is_empty() { + anyhow::bail!("dispatcher called with empty argv (clap bug)"); + } + + // Detect `hm cloud build watch ...` to opt into the TUI session sink. + let is_cloud_build_watch = argv.first().map(String::as_str) == Some("cloud") + && argv.get(1).map(String::as_str) == Some("build") + && argv.get(2).map(String::as_str) == Some("watch"); + let want_tui_for_cloud_watch = is_cloud_build_watch + && !no_tui + && std::env::var_os("NO_COLOR").is_none() + && std::io::stdout().is_terminal(); + + if want_tui_for_cloud_watch { + let tui_rx = crate::tui::install_session_sink(); + let opts = crate::tui::TuiOptions { + fx_enabled: std::env::var_os("NO_COLOR").is_none(), + summary_card: true, + title: "hm cloud build watch".into(), + }; + let argv_clone = argv.clone(); + let plugin_handle = tokio::spawn(async move { run_plugin(argv_clone).await }); + let tui_exit = crate::tui::run(tui_rx, opts) + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + let plugin_exit = plugin_handle.await??; + return Ok(if tui_exit != 0 { tui_exit } else { plugin_exit }); + } + + run_plugin(argv).await +} + +/// Load the plugin registry, resolve the verb, and call the plugin. +async fn run_plugin(argv: Vec) -> Result { let verb = argv .first() .cloned() @@ -40,14 +83,6 @@ pub async fn run(argv: Vec) -> Result { "harmont-docker", crate::plugin::embedded::DOCKER_PLUGIN_WASM, ), - ( - "harmont-output-human", - crate::plugin::embedded::OUTPUT_HUMAN_PLUGIN_WASM, - ), - ( - "harmont-output-json", - crate::plugin::embedded::OUTPUT_JSON_PLUGIN_WASM, - ), ("harmont-cloud", crate::plugin::embedded::CLOUD_PLUGIN_WASM), ], pool_sizes: BTreeMap::new(), diff --git a/crates/hm/src/lib.rs b/crates/hm/src/lib.rs index 7549f7a8..f0161318 100644 --- a/crates/hm/src/lib.rs +++ b/crates/hm/src/lib.rs @@ -15,3 +15,4 @@ pub mod fs_util; pub mod orchestrator; pub mod output; pub mod plugin; +pub mod tui; diff --git a/crates/hm/src/main.rs b/crates/hm/src/main.rs index 25b54cb4..e7aa9599 100644 --- a/crates/hm/src/main.rs +++ b/crates/hm/src/main.rs @@ -2,6 +2,10 @@ clippy::print_stderr, reason = "the panic banner in handle_error is the last-resort stderr writer" )] +#![allow( + clippy::future_not_send, + reason = "tachyonfx::Shader is !Send by design; the main task is single-threaded" +)] #![allow( clippy::multiple_crate_versions, reason = "transitive dependency version conflicts in rand/windows-sys/thiserror chains; not fixable without upstream updates" diff --git a/crates/hm/src/orchestrator/docker_client.rs b/crates/hm/src/orchestrator/docker_client.rs index fc43071b..77ca4048 100644 --- a/crates/hm/src/orchestrator/docker_client.rs +++ b/crates/hm/src/orchestrator/docker_client.rs @@ -28,17 +28,68 @@ pub struct DockerClient { } impl DockerClient { - /// Open a Docker connection using the platform's default socket / - /// pipe. The handle is cheap to clone (refcounted internally). + /// Open a Docker connection, honoring `docker context` the way the + /// `docker` CLI does. + /// + /// Resolution order (matches Docker upstream): + /// 1. `DOCKER_HOST` env var. + /// 2. `DOCKER_CONTEXT` env var. + /// 3. `currentContext` in `~/.docker/config.json`. + /// 4. Platform default (`unix:///var/run/docker.sock` on Linux). + /// + /// This means Docker Desktop on Linux (which ships a `desktop-linux` + /// context pointing at `~/.docker/desktop/docker.sock`) works + /// without the user having to set `DOCKER_HOST`. + /// + /// The handle is cheap to clone (refcounted internally). /// /// # Errors /// - /// Returns [`HmError::Docker`] when bollard cannot resolve a - /// local Docker endpoint (no socket on `DOCKER_HOST`, no Windows - /// pipe, etc.). + /// Returns [`HmError::Docker`] when no reachable Docker endpoint + /// can be resolved, or when `DOCKER_CONTEXT` / `currentContext` + /// points at a context that doesn't exist on disk. The error + /// message includes a hint with the exact command to fix it. pub fn connect() -> Result { - let d = Docker::connect_with_local_defaults() - .map_err(|e| HmError::Docker(format!("connect: {e}")))?; + use super::docker_context::{Endpoint, resolve_endpoint}; + use bollard::{API_DEFAULT_VERSION, Docker}; + + const TIMEOUT_SECS: u64 = 120; + + let endpoint = resolve_endpoint().map_err(|e| { + HmError::Docker(format!( + "{e}\n hint: run `docker context ls` to inspect, \ + or `export DOCKER_HOST=unix:///path/to/docker.sock`" + )) + })?; + + let d = match endpoint { + Endpoint::Default => Docker::connect_with_local_defaults(), + Endpoint::Socket(path) => { + Docker::connect_with_socket(&path.to_string_lossy(), TIMEOUT_SECS, API_DEFAULT_VERSION) + } + Endpoint::Http(host) => { + Docker::connect_with_http(&host, TIMEOUT_SECS, API_DEFAULT_VERSION) + } + Endpoint::Https { host, tls_dir: _ } => { + // bollard is built without the `ssl` feature (would pull + // rustls + ring transitively). Remote HTTPS daemons are a + // niche case for `hm`; bail with a useful hint instead of + // silently downgrading. + return Err(HmError::Docker(format!( + "remote HTTPS Docker daemons aren't supported by `hm` yet ({host}).\n \ + hint: switch to a local context with `docker context use default`, \ + or expose the daemon over an unencrypted tunnel and \ + `export DOCKER_HOST=tcp://...`" + )) + .into()); + } + } + .map_err(|e| { + HmError::Docker(format!( + "connect: {e}\n hint: if you use Docker Desktop, ensure it is running and that \ + `docker version` succeeds; otherwise set DOCKER_HOST to your socket path" + )) + })?; Ok(Self { inner: Arc::new(d) }) } diff --git a/crates/hm/src/orchestrator/docker_context.rs b/crates/hm/src/orchestrator/docker_context.rs new file mode 100644 index 00000000..5ed17368 --- /dev/null +++ b/crates/hm/src/orchestrator/docker_context.rs @@ -0,0 +1,239 @@ +//! Resolve the Docker endpoint the way `docker` CLI does. +//! +//! Resolution order, matching Docker's own precedence: +//! +//! 1. `DOCKER_HOST` env var — explicit endpoint, wins everything. +//! 2. `DOCKER_CONTEXT` env var — pick a named context. +//! 3. `currentContext` in `~/.docker/config.json`. +//! 4. Fall back to bollard's platform default +//! (`unix:///var/run/docker.sock` on Linux, the named pipe on Windows). +//! +//! Named contexts live at +//! `~/.docker/contexts/meta//meta.json`, with their TLS +//! materials in the parallel `tls/` tree. This is the same scheme the +//! `docker` CLI uses, so Docker Desktop on Linux (which ships a +//! `desktop-linux` context pointing at `~/.docker/desktop/docker.sock`) +//! works out of the box. + +#![allow( + clippy::print_stderr, + reason = "the not_found arm of resolve_endpoint surfaces an interactive hint to the user" +)] + +use std::env; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +/// A resolved Docker daemon endpoint, ready to hand to bollard. +#[derive(Debug, Clone)] +pub enum Endpoint { + /// No override resolved — caller should use bollard's platform default. + Default, + /// Unix socket (Linux/macOS) or Windows named pipe path. + Socket(PathBuf), + /// Plain HTTP daemon (no TLS). + Http(String), + /// HTTPS daemon. `tls_dir`, when present, contains + /// `ca.pem` / `cert.pem` / `key.pem` extracted from the docker + /// context's TLS materials. + Https { + host: String, + tls_dir: Option, + }, +} + +#[derive(Deserialize)] +struct DockerConfig { + #[serde(rename = "currentContext")] + current_context: Option, +} + +#[derive(Deserialize)] +struct ContextMeta { + #[serde(rename = "Endpoints")] + endpoints: ContextEndpoints, +} + +#[derive(Deserialize)] +struct ContextEndpoints { + docker: ContextEndpoint, +} + +#[derive(Deserialize)] +struct ContextEndpoint { + #[serde(rename = "Host")] + host: String, +} + +/// Walk the Docker resolution chain and return the resolved endpoint. +/// +/// # Errors +/// +/// Returns an error only when an *explicit* configuration cannot be +/// honored — e.g., `DOCKER_CONTEXT` points at a name with no meta file, +/// or a config / context JSON file fails to parse. A missing +/// `~/.docker/` directory is *not* an error; it returns +/// [`Endpoint::Default`]. +pub fn resolve_endpoint() -> Result { + if let Some(host) = env::var_os("DOCKER_HOST") { + let host = host.to_string_lossy().into_owned(); + if host.is_empty() { + return Ok(Endpoint::Default); + } + return parse_host(&host, None); + } + + let context = env::var("DOCKER_CONTEXT") + .ok() + .filter(|s| !s.is_empty()) + .or_else(|| read_current_context().ok().flatten()); + + let Some(name) = context else { + return Ok(Endpoint::Default); + }; + if name == "default" { + return Ok(Endpoint::Default); + } + resolve_named_context(&name) +} + +fn docker_dir() -> Result { + let home = env::var_os("HOME").ok_or_else(|| anyhow!("HOME env var not set"))?; + Ok(Path::new(&home).join(".docker")) +} + +fn read_current_context() -> Result> { + let path = docker_dir()?.join("config.json"); + if !path.exists() { + return Ok(None); + } + let bytes = std::fs::read(&path) + .with_context(|| format!("read {}", path.display()))?; + let cfg: DockerConfig = serde_json::from_slice(&bytes) + .with_context(|| format!("parse {}", path.display()))?; + Ok(cfg.current_context.filter(|s| !s.is_empty())) +} + +fn context_hash(name: &str) -> String { + let mut h = Sha256::new(); + h.update(name.as_bytes()); + hex::encode(h.finalize()) +} + +fn resolve_named_context(name: &str) -> Result { + let hash = context_hash(name); + let docker = docker_dir()?; + let meta_path = docker.join(format!("contexts/meta/{hash}/meta.json")); + if !meta_path.exists() { + bail!( + "docker context '{name}' not found ({}); run `docker context ls` to verify", + meta_path.display() + ); + } + let bytes = std::fs::read(&meta_path) + .with_context(|| format!("read {}", meta_path.display()))?; + let meta: ContextMeta = serde_json::from_slice(&bytes) + .with_context(|| format!("parse {}", meta_path.display()))?; + + let tls_dir = docker.join(format!("contexts/tls/{hash}/docker")); + let tls_dir = if tls_dir.exists() { Some(tls_dir) } else { None }; + + parse_host(&meta.endpoints.docker.host, tls_dir) +} + +fn parse_host(host: &str, tls_dir: Option) -> Result { + if let Some(path) = host.strip_prefix("unix://") { + return Ok(Endpoint::Socket(PathBuf::from(path))); + } + if let Some(path) = host.strip_prefix("npipe://") { + return Ok(Endpoint::Socket(PathBuf::from(path))); + } + if let Some(rest) = host.strip_prefix("tcp://") { + // Docker CLI: tcp:// with TLS materials means https; otherwise http. + if tls_dir.is_some() { + return Ok(Endpoint::Https { + host: format!("https://{rest}"), + tls_dir, + }); + } + return Ok(Endpoint::Http(format!("http://{rest}"))); + } + if host.starts_with("https://") { + return Ok(Endpoint::Https { + host: host.to_string(), + tls_dir, + }); + } + if host.starts_with("http://") { + return Ok(Endpoint::Http(host.to_string())); + } + bail!("unrecognized docker host scheme: {host}"); +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn parses_unix_socket() { + let ep = parse_host("unix:///run/docker.sock", None).unwrap(); + match ep { + Endpoint::Socket(p) => assert_eq!(p, PathBuf::from("/run/docker.sock")), + other => panic!("expected Socket, got {other:?}"), + } + } + + #[test] + fn parses_npipe() { + let ep = parse_host("npipe:////./pipe/docker_engine", None).unwrap(); + assert!(matches!(ep, Endpoint::Socket(_))); + } + + #[test] + fn parses_tcp_without_tls() { + let ep = parse_host("tcp://10.0.0.1:2375", None).unwrap(); + match ep { + Endpoint::Http(h) => assert_eq!(h, "http://10.0.0.1:2375"), + other => panic!("expected Http, got {other:?}"), + } + } + + #[test] + fn parses_tcp_with_tls_dir_as_https() { + let ep = parse_host("tcp://10.0.0.1:2376", Some(PathBuf::from("/tls"))).unwrap(); + match ep { + Endpoint::Https { host, tls_dir } => { + assert_eq!(host, "https://10.0.0.1:2376"); + assert_eq!(tls_dir, Some(PathBuf::from("/tls"))); + } + other => panic!("expected Https, got {other:?}"), + } + } + + #[test] + fn parses_explicit_http_and_https() { + assert!(matches!(parse_host("http://x", None).unwrap(), Endpoint::Http(_))); + assert!(matches!(parse_host("https://x", None).unwrap(), Endpoint::Https { .. })); + } + + #[test] + fn rejects_unknown_scheme() { + assert!(parse_host("ftp://x", None).is_err()); + } + + #[test] + fn context_hash_matches_docker_cli() { + // Verified against `docker context inspect desktop-linux --format '{{.Name}}'` + // and the filesystem layout: the hash is sha256(name) hex. + let h = context_hash("desktop-linux"); + assert_eq!(h.len(), 64); + assert_eq!( + h, + "fe9c6bd7a66301f49ca9b6a70b217107cd1284598bfc254700c989b916da791e" + ); + } +} diff --git a/crates/hm/src/orchestrator/docker_host_fns.rs b/crates/hm/src/orchestrator/docker_host_fns.rs index 1375fc4f..f06fcdb1 100644 --- a/crates/hm/src/orchestrator/docker_host_fns.rs +++ b/crates/hm/src/orchestrator/docker_host_fns.rs @@ -50,33 +50,59 @@ pub(crate) async fn ping_impl() -> bool { let Some(s) = current() else { return false; }; - s.docker.ping().await.is_ok() + let Some(docker) = s.docker.as_ref() else { + return false; + }; + docker.ping().await.is_ok() } pub(crate) async fn image_exists_impl(tag: String) -> bool { - let Some(s) = current() else { return false }; - s.docker.image_exists(&tag).await.unwrap_or(false) + let Some(s) = current() else { + tracing::error!(%tag, "image_exists: no orchestrator state"); + return false; + }; + let Some(docker) = s.docker.as_ref() else { + tracing::error!(%tag, "image_exists: no docker client in orchestrator state"); + return false; + }; + match docker.image_exists(&tag).await { + Ok(b) => b, + Err(e) => { + tracing::error!(%tag, error = %e, "image_exists: list_images failed"); + false + } + } } pub(crate) async fn pull_impl(tag: String) -> Result<()> { let s = current().context("no orchestrator state")?; + let Some(docker) = s.docker.clone() else { + anyhow::bail!("no docker client in orchestrator state"); + }; let cancel = s.cancel.clone(); - let docker = s.docker.clone(); - let pull_fut = async move { docker.pull_image(&tag).await }; - tokio::select! { + let pull_tag = tag.clone(); + let pull_fut = async move { docker.pull_image(&pull_tag).await }; + let result = tokio::select! { result = pull_fut => result, () = wait_cancel(&cancel) => Err(anyhow::anyhow!("cancelled during image pull")), + }; + if let Err(e) = &result { + tracing::error!(%tag, error = format!("{e:#}"), "pull_impl failed"); } + result } pub(crate) async fn start_container_impl(args: DockerStartArgs) -> Result { let s = current().context("no orchestrator state")?; + let Some(docker) = s.docker.clone() else { + anyhow::bail!("no docker client in orchestrator state"); + }; let env_vec: Vec = args .env .into_iter() .map(|(k, v)| format!("{k}={v}")) .collect(); - s.docker + docker .start_long_lived(&args.image, &env_vec, &args.workdir, &args.name_hint) .await } @@ -88,7 +114,9 @@ pub(crate) async fn extract_workspace_impl(args: DockerExtractArgs) -> Result<() anyhow::bail!("archive {} is empty or unknown", args.archive_id.0); } let cancel = s.cancel.clone(); - let docker = s.docker.clone(); + let Some(docker) = s.docker.clone() else { + anyhow::bail!("no docker client in orchestrator state"); + }; let cid = args.container_id; let workdir = args.workdir; let cmd = vec![ @@ -126,7 +154,9 @@ pub(crate) async fn exec_impl(args: DockerExecArgs) -> Result { // Future doing the exec; we race it against cancellation. let cancel = s.cancel.clone(); - let docker = s.docker.clone(); + let Some(docker) = s.docker.clone() else { + anyhow::bail!("no docker client in orchestrator state"); + }; let cid = args.container_id.clone(); let cmd = args.cmd.clone(); let workdir = args.workdir.clone(); @@ -172,19 +202,27 @@ async fn wait_cancel(cancel: &crate::orchestrator::cancel::CancellationToken) { pub(crate) async fn commit_impl(args: DockerCommitArgs) -> Result { let s = current().context("no orchestrator state")?; - s.docker + let Some(docker) = s.docker.as_ref() else { + anyhow::bail!("no docker client in orchestrator state"); + }; + docker .commit_container(&args.container_id, &args.tag) .await } pub(crate) async fn remove_image_impl(tag: String) -> Result<()> { let s = current().context("no orchestrator state")?; - s.docker.remove_image(&tag).await + let Some(docker) = s.docker.as_ref() else { + anyhow::bail!("no docker client in orchestrator state"); + }; + docker.remove_image(&tag).await } pub(crate) async fn stop_remove_impl(container_id: String) { - if let Some(s) = current() { - s.docker.stop_remove(&container_id).await; + if let Some(s) = current() + && let Some(docker) = s.docker.as_ref() + { + docker.stop_remove(&container_id).await; } } diff --git a/crates/hm/src/orchestrator/mod.rs b/crates/hm/src/orchestrator/mod.rs index a72d7129..b88213a2 100644 --- a/crates/hm/src/orchestrator/mod.rs +++ b/crates/hm/src/orchestrator/mod.rs @@ -10,6 +10,7 @@ pub mod archive; pub mod cache; pub mod cancel; pub mod docker_client; +pub mod docker_context; pub mod docker_host_fns; pub mod events; pub mod graph; diff --git a/crates/hm/src/orchestrator/output_subscriber.rs b/crates/hm/src/orchestrator/output_subscriber.rs index b19c5f4d..b8f7c3c5 100644 --- a/crates/hm/src/orchestrator/output_subscriber.rs +++ b/crates/hm/src/orchestrator/output_subscriber.rs @@ -1,5 +1,9 @@ -//! Build-event subscriber that dispatches every `BuildEvent` into the -//! selected output-formatter plugin's `hm_output_on_event` capability. +//! Build-event subscriber for output formatting. +//! +//! Dispatches every `BuildEvent` into the selected output formatter. +//! Built-in formatters (`human`, `json`) are resolved first and bypass +//! the WASM plugin registry entirely; the registry lookup is only +//! reached for externally-registered formatters. //! //! Replaces the plan-2 stop-gap `stderr_sink`. The subscriber acquires //! an `Arc` from the registry per event; the actual @@ -39,11 +43,13 @@ use crate::plugin::PluginRegistry; /// Spawn the subscriber task. Returns a join handle the orchestrator /// awaits at shutdown so the `BuildEnd` event is fully drained. /// -/// `format_name` must already exist in `registry.output_formatter_index` -/// — `scheduler::run` validates this before emitting `BuildStart`, so -/// a missing entry here means we lost a race against a concurrent -/// registry mutation (impossible in single-run orchestration). We drop -/// events silently in that case and exit on `BuildEnd`. +/// `format_name` is resolved first against the built-in formatter set +/// (`human`, `json`). If no built-in matches, the name must exist in +/// `registry.output_formatter_index` — `scheduler::run` validates this +/// before emitting `BuildStart`. A missing registry entry here means a +/// race against a concurrent registry mutation (impossible in +/// single-run orchestration); events are drained silently until +/// `BuildEnd`. #[must_use] pub fn spawn( bus: Arc, @@ -51,39 +57,37 @@ pub fn spawn( format_name: String, ) -> tokio::task::JoinHandle> { let mut rx = bus.subscribe(); + let mut builtin = crate::output::formatters::builtin(&format_name); tokio::spawn(async move { loop { match rx.recv().await { Ok(event) => { - // Resolve the plugin under the registry lock, then - // drop the lock before awaiting `call_capability` - // so concurrent step-executor calls keep flowing. + let is_end = matches!(event, BuildEvent::BuildEnd { .. }); + if let Some(b) = builtin.as_mut() { + b.on_event(&event); + if is_end { + b.finalize(); + return Ok(()); + } + continue; + } + // Fall through: format_name is not a built-in; + // resolve from the plugin registry. let plugin = { let reg = registry.lock().await; let Some(&idx) = reg.output_formatter_index.get(&format_name) else { - // No plugin for this format; CLI parser - // should have caught this. Drain silently. - if matches!(event, BuildEvent::BuildEnd { .. }) { - return Ok(()); - } + if is_end { return Ok(()); } continue; }; let Some(p) = reg.get(idx) else { - if matches!(event, BuildEvent::BuildEnd { .. }) { - return Ok(()); - } + if is_end { return Ok(()); } continue; }; p }; - let is_end = matches!(event, BuildEvent::BuildEnd { .. }); - // Log-and-continue on formatter failures: a broken - // output plugin shouldn't fail the build. - let _: Result<()> = plugin.call_capability("hm_output_on_event", &event).await; + let _: Result<()> = + plugin.call_capability("hm_output_on_event", &event).await; if is_end { - // Finalise if the plugin exports it. Tolerate - // missing/erroring export — most streaming - // formatters don't implement it. let _: Result> = plugin.call_capability("hm_output_finalize", &()).await; return Ok(()); @@ -95,10 +99,6 @@ pub fn spawn( target: "orchestrator", "output_subscriber: dropped {n} build events (subscriber fell behind)" ); - // Also surface to the user: send a synthetic stderr line via - // the host's write_stderr fn directly. This bypasses the - // event bus (which is the source of the lag), so it can't - // contribute to the lag we're reporting. eprintln!("[output] dropped {n} build events (subscriber fell behind)"); } } diff --git a/crates/hm/src/orchestrator/scheduler.rs b/crates/hm/src/orchestrator/scheduler.rs index 6a38f4b4..56c76af5 100644 --- a/crates/hm/src/orchestrator/scheduler.rs +++ b/crates/hm/src/orchestrator/scheduler.rs @@ -62,6 +62,7 @@ pub async fn run( repo_root: PathBuf, parallelism: usize, format_name: String, + extra_event_tx: Option>, ) -> Result { // Build graph + chains directly from the wire-typed pipeline. let graph = Graph::build(&pipeline).context("build graph")?; @@ -91,8 +92,9 @@ pub async fn run( event_bus: bus.clone(), archives, cancel: cancel.clone(), - docker: docker.clone(), + docker: Some(docker.clone()), run_id, + tui_event_tx: extra_event_tx.clone(), }); state::install(state_arc.clone()); @@ -109,20 +111,10 @@ pub async fn run( PluginRegistry::load(RegistryConfig { auto_discover: true, extra_paths: vec![], - embedded: vec![ - ( - "harmont-docker", - crate::plugin::embedded::DOCKER_PLUGIN_WASM, - ), - ( - "harmont-output-human", - crate::plugin::embedded::OUTPUT_HUMAN_PLUGIN_WASM, - ), - ( - "harmont-output-json", - crate::plugin::embedded::OUTPUT_JSON_PLUGIN_WASM, - ), - ], + embedded: vec![( + "harmont-docker", + crate::plugin::embedded::DOCKER_PLUGIN_WASM, + )], pool_sizes, }) .context("load plugin registry")?, @@ -134,13 +126,21 @@ pub async fn run( // the guard before the (rare) bail to satisfy // `clippy::significant_drop_tightening`. let bad_format: Option> = { - let reg = registry.lock().await; - if reg.output_formatter_index.contains_key(&format_name) { + if crate::output::formatters::builtin(&format_name).is_some() { None } else { - let mut names: Vec = reg.output_formatter_index.keys().cloned().collect(); - names.sort(); - Some(names) + let reg = registry.lock().await; + if reg.output_formatter_index.contains_key(&format_name) { + None + } else { + let mut names: Vec = + reg.output_formatter_index.keys().cloned().collect(); + names.push("human".to_string()); + names.push("json".to_string()); + names.sort(); + names.dedup(); + Some(names) + } } }; if let Some(available) = bad_format { @@ -160,8 +160,35 @@ pub async fn run( // Spawn the output subscriber. Dispatches every BuildEvent to the // selected output-formatter plugin (default: `human`). - let sink_handle = - super::output_subscriber::spawn(bus.clone(), registry.clone(), format_name.clone()); + // + // Skip when a TUI sink is wired: the TUI owns stdout and the human + // formatter would scroll log lines over its rendering. + let sink_handle = if state_arc.tui_event_tx.is_none() { + Some(super::output_subscriber::spawn( + bus.clone(), + registry.clone(), + format_name.clone(), + )) + } else { + None + }; + + let extra_handle = state_arc.tui_event_tx.clone().map(|tx| { + let mut rx = bus.subscribe(); + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(ev) => { + if tx.send(ev).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }) + }); // Announce build start. let started_at = chrono::Utc::now(); @@ -264,7 +291,13 @@ pub async fn run( // Wait briefly for the sink to drain the BuildEnd event. It exits // when it sees BuildEnd, so this completes quickly. - let _ = tokio::time::timeout(std::time::Duration::from_secs(2), sink_handle).await; + if let Some(h) = sink_handle { + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), h).await; + } + + if let Some(h) = extra_handle { + let _ = h.await; + } state::clear(); drop(state_arc); @@ -328,7 +361,11 @@ async fn run_chain( // Decide cache outcome host-side. let decision = { let s = state::current().context("no orchestrator state")?; - cache::decide(&s.docker, &step_wire).await? + let docker = s + .docker + .as_ref() + .context("no docker client in orchestrator state")?; + cache::decide(docker, &step_wire).await? }; if let hm_plugin_protocol::CacheDecision::Hit { tag } = &decision { bus.emit(BuildEvent::StepCacheHit { diff --git a/crates/hm/src/orchestrator/state.rs b/crates/hm/src/orchestrator/state.rs index ecfaa5a2..c84cd5f4 100644 --- a/crates/hm/src/orchestrator/state.rs +++ b/crates/hm/src/orchestrator/state.rs @@ -38,8 +38,15 @@ pub struct OrchestratorState { pub event_bus: Arc, pub archives: ArchiveStore, pub cancel: CancellationToken, - pub docker: DockerClient, + /// Optional — populated for build runs; absent for TUI-only sessions + /// (e.g., `hm cloud build watch`) that never call docker host fns. + pub docker: Option, pub run_id: Uuid, + /// Optional TUI mpsc sender; set by `scheduler::run` when the host + /// TUI is the active output renderer. Populated for both local + /// builds (via the bus forwarder) and cloud watch (via the + /// `hm_build_event_emit` host fn). + pub tui_event_tx: Option>, } static CURRENT: OnceLock> = OnceLock::new(); diff --git a/crates/hm/src/output/formatters/human.rs b/crates/hm/src/output/formatters/human.rs new file mode 100644 index 00000000..c188100a --- /dev/null +++ b/crates/hm/src/output/formatters/human.rs @@ -0,0 +1,131 @@ +//! Human-readable `BuildEvent` formatter. +//! +//! Writes prefixed step logs and brief status lines to stderr. Moved +//! from the standalone `hm-plugin-output-human` WASM crate into the +//! `hm` binary so the built-in formatter does not pay a WASM +//! round-trip per event. + +use hm_plugin_protocol::BuildEvent; +use std::collections::HashMap; +use std::io::Write; +use uuid::Uuid; + +#[derive(Debug, Default)] +pub struct Human { + step_keys: HashMap, +} + +impl Human { + pub fn on_event(&mut self, ev: &BuildEvent) { + let bytes = self.render(ev); + if !bytes.is_empty() { + let _ = std::io::stderr().write_all(&bytes); + } + } + + pub const fn finalize(&mut self) {} + + fn render(&mut self, ev: &BuildEvent) -> Vec { + match ev { + BuildEvent::BuildStart { plan, .. } => format!( + "build: {} steps in {} chain(s)\n", + plan.step_count, plan.chain_count + ) + .into_bytes(), + BuildEvent::StepQueued { step_id, key, .. } => { + self.step_keys.insert(*step_id, key.clone()); + Vec::new() + } + BuildEvent::StepStart { step_id, runner, image } => { + let key = self.key_for(*step_id); + let line = image.as_ref().map_or_else( + || format!("[{key}] start (runner={runner})\n"), + |img| format!("[{key}] start (runner={runner} image={img})\n"), + ); + line.into_bytes() + } + BuildEvent::StepLog { step_id, line, .. } => { + let key = self.key_for(*step_id); + format!("[{key}] {line}\n").into_bytes() + } + BuildEvent::StepCacheHit { step_id, tag, .. } => { + let key = self.key_for(*step_id); + format!("[{key}] cache hit ({tag})\n").into_bytes() + } + BuildEvent::StepEnd { step_id, exit_code, duration_ms, .. } => { + let key = self.key_for(*step_id); + format!("[{key}] end exit={exit_code} duration={duration_ms}ms\n").into_bytes() + } + BuildEvent::BuildEnd { exit_code, duration_ms } => format!( + "build: end exit={exit_code} duration={duration_ms}ms\n" + ) + .into_bytes(), + BuildEvent::ChainFailed { + chain_idx, failed_step_key, exit_code, message, .. + } => format!( + "chain {chain_idx}: FAILED at step '{failed_step_key}' (exit={exit_code}): {message}\n" + ) + .into_bytes(), + } + } + + fn key_for(&self, id: Uuid) -> String { + self.step_keys.get(&id).cloned().unwrap_or_else(|| "?".to_string()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use hm_plugin_protocol::{PlanSummary, StdStream}; + + #[test] + fn build_start_renders_step_and_chain_counts() { + let mut h = Human::default(); + let s = String::from_utf8(h.render(&BuildEvent::BuildStart { + run_id: Uuid::nil(), + plan: PlanSummary { + step_count: 3, + chain_count: 2, + default_runner: "docker".into(), + }, + started_at: chrono::Utc::now(), + })) + .unwrap(); + assert!(s.contains("3 steps")); + assert!(s.contains("2 chain")); + } + + #[test] + fn step_log_renders_with_prefix_after_step_queued_recorded_key() { + let mut h = Human::default(); + let step_id = Uuid::new_v4(); + h.render(&BuildEvent::StepQueued { + step_id, + key: "build".into(), + chain_idx: 0, + }); + let s = String::from_utf8(h.render(&BuildEvent::StepLog { + step_id, + stream: StdStream::Stdout, + line: "hello".into(), + ts: chrono::Utc::now(), + })) + .unwrap(); + assert_eq!(s, "[build] hello\n"); + } + + #[test] + fn step_log_with_unknown_key_renders_question_mark() { + let mut h = Human::default(); + let s = String::from_utf8(h.render(&BuildEvent::StepLog { + step_id: Uuid::new_v4(), + stream: StdStream::Stdout, + line: "x".into(), + ts: chrono::Utc::now(), + })) + .unwrap(); + assert!(s.starts_with("[?] ")); + } +} diff --git a/crates/hm/src/output/formatters/json.rs b/crates/hm/src/output/formatters/json.rs new file mode 100644 index 00000000..f5aa032c --- /dev/null +++ b/crates/hm/src/output/formatters/json.rs @@ -0,0 +1,67 @@ +//! JSON-lines `BuildEvent` formatter — one event per line to stdout. +//! +//! Moved from the standalone `hm-plugin-output-json` WASM crate. + +use hm_plugin_protocol::BuildEvent; +use std::io::Write; + +#[derive(Debug, Default)] +pub struct Json; + +impl Json { + pub fn on_event(&mut self, ev: &BuildEvent) { + if let Some(bytes) = format_event(ev) { + let _ = std::io::stdout().write_all(&bytes); + } + } + + pub const fn finalize(&mut self) {} +} + +/// Serialise `ev` to one JSON line (trailing `\n` included). Returns +/// `None` if `serde_json` fails to serialise — output formatters must +/// never panic the run, so the host swallows the loss silently. +fn format_event(ev: &BuildEvent) -> Option> { + let mut bytes = serde_json::to_vec(ev).ok()?; + bytes.push(b'\n'); + Some(bytes) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test assertions intentionally use expect/unwrap")] +mod tests { + use super::*; + use hm_plugin_protocol::PlanSummary; + use uuid::Uuid; + + #[test] + fn build_start_serialises_to_json_line_with_kind_and_step_count() { + let ev = BuildEvent::BuildStart { + run_id: Uuid::nil(), + plan: PlanSummary { + step_count: 1, + chain_count: 1, + default_runner: "docker".into(), + }, + started_at: chrono::Utc::now(), + }; + let bytes = format_event(&ev).expect("serialise build_start"); + let s = std::str::from_utf8(&bytes).unwrap(); + assert!( + s.contains(r#""kind":"build_start""#), + "expected kind tag, got: {s}" + ); + let parsed: serde_json::Value = serde_json::from_str(s.trim_end()).unwrap(); + assert_eq!(parsed["plan"]["step_count"], 1); + } + + #[test] + fn format_event_appends_trailing_newline() { + let ev = BuildEvent::BuildEnd { + exit_code: 0, + duration_ms: 5, + }; + let bytes = format_event(&ev).expect("serialise build_end"); + assert_eq!(bytes.last(), Some(&b'\n')); + } +} diff --git a/crates/hm/src/output/formatters/mod.rs b/crates/hm/src/output/formatters/mod.rs new file mode 100644 index 00000000..0ca18298 --- /dev/null +++ b/crates/hm/src/output/formatters/mod.rs @@ -0,0 +1,46 @@ +//! Built-in `BuildEvent` formatters. +//! +//! External plugins can still register their own formatter via the +//! `OutputFormatter` capability; these are the in-tree implementations +//! that ship with every build of `hm`. + +use hm_plugin_protocol::BuildEvent; + +pub mod human; +pub mod json; + +/// A formatter that lives inside the `hm` binary. +/// +/// Returned by [`builtin`] for names the orchestrator already knows. +/// The orchestrator's output subscriber falls through to the WASM +/// plugin registry only when this returns `None`. +#[derive(Debug)] +pub enum Builtin { + Human(human::Human), + Json(json::Json), +} + +impl Builtin { + pub fn on_event(&mut self, event: &BuildEvent) { + match self { + Self::Human(h) => h.on_event(event), + Self::Json(j) => j.on_event(event), + } + } + + pub const fn finalize(&mut self) { + match self { + Self::Human(h) => h.finalize(), + Self::Json(j) => j.finalize(), + } + } +} + +#[must_use] +pub fn builtin(name: &str) -> Option { + match name { + "human" => Some(Builtin::Human(human::Human::default())), + "json" => Some(Builtin::Json(json::Json)), + _ => None, + } +} diff --git a/crates/hm/src/output/mod.rs b/crates/hm/src/output/mod.rs index 64b63e55..317d4072 100644 --- a/crates/hm/src/output/mod.rs +++ b/crates/hm/src/output/mod.rs @@ -1,4 +1,5 @@ pub mod format; +pub mod formatters; pub mod spinner; pub mod status; diff --git a/crates/hm/src/plugin/embedded.rs b/crates/hm/src/plugin/embedded.rs index 46d918a6..1fe90006 100644 --- a/crates/hm/src/plugin/embedded.rs +++ b/crates/hm/src/plugin/embedded.rs @@ -5,16 +5,6 @@ pub static DOCKER_PLUGIN_WASM: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/hm_plugin_docker.wasm")); -/// Bytes of the in-tree human-readable output-formatter plugin. -/// Loaded when `--format human` (the default) is selected. -pub static OUTPUT_HUMAN_PLUGIN_WASM: &[u8] = - include_bytes!(concat!(env!("OUT_DIR"), "/hm_plugin_output_human.wasm")); - -/// Bytes of the in-tree JSON-lines output-formatter plugin. -/// Loaded when `--format json` is selected. -pub static OUTPUT_JSON_PLUGIN_WASM: &[u8] = - include_bytes!(concat!(env!("OUT_DIR"), "/hm_plugin_output_json.wasm")); - /// Bytes of the in-tree cloud client plugin (`hm cloud …`). Loaded by /// the host dispatcher whenever the user invokes the `cloud` verb. pub static CLOUD_PLUGIN_WASM: &[u8] = diff --git a/crates/hm/src/plugin/host.rs b/crates/hm/src/plugin/host.rs index 46e86149..4a881531 100644 --- a/crates/hm/src/plugin/host.rs +++ b/crates/hm/src/plugin/host.rs @@ -98,7 +98,7 @@ impl LoadedPlugin { let out_bytes = call_result.map_err(|e| HmError::PluginPanic { name: self.manifest.name.clone(), capability: export.to_string(), - message: e.to_string(), + message: format!("{e:#}"), })?; serde_json::from_slice(out_bytes).context("decode capability output") } diff --git a/crates/hm/src/plugin/host_fns.rs b/crates/hm/src/plugin/host_fns.rs index 50ec8705..96f12a4f 100644 --- a/crates/hm/src/plugin/host_fns.rs +++ b/crates/hm/src/plugin/host_fns.rs @@ -75,6 +75,7 @@ pub const HOST_FN_NAMES: &[&str] = &[ "hm_tty_prompt", "hm_tty_confirm", "hm_browser_open", + "hm_build_event_emit", "hm_spawn_loopback", "hm_loopback_recv", "hm_should_cancel", @@ -114,6 +115,11 @@ host_fn!(pub _hm_emit_event(_user_data: (); event: Json) { Ok(()) }); +host_fn!(pub _hm_build_event_emit(_user_data: (); bytes: Vec) { + build_event_emit_impl(&bytes); + Ok(()) +}); + host_fn!(pub _hm_kv_get(_user_data: (); scope: Json, key: String) -> Json>> { let Json(scope) = scope; Ok(Json(kv_get_impl(scope, &key))) @@ -404,6 +410,13 @@ pub fn all() -> Vec { ud.clone(), _hm_browser_open, ), + Function::new( + "hm_build_event_emit", + pty(1), + pty(0), + ud.clone(), + _hm_build_event_emit, + ), Function::new( "hm_spawn_loopback", pty(1), @@ -578,6 +591,19 @@ fn emit_event_impl(event: BuildEvent) { } } +fn build_event_emit_impl(bytes: &[u8]) { + let Ok(ev) = serde_json::from_slice::(bytes) else { + return; // best-effort: bad payload silently dropped + }; + let Some(state) = crate::orchestrator::state::current() else { + return; + }; + let Some(tx) = state.tui_event_tx.as_ref() else { + return; + }; + let _ = tx.try_send(ev); // non-blocking; lag drops the event +} + fn kv_get_impl(scope: KvScope, key: &str) -> Option> { match scope { KvScope::Plugin => load_plugin_kv().get(key).cloned(), @@ -990,36 +1016,35 @@ pub(crate) fn current_step_id() -> Option { mod plugin_kv_tests { use super::*; + // Both round-trip and per-plugin-name isolation are covered by a + // single `#[test]` because `XDG_CONFIG_HOME` and + // `set_current_plugin_name` are *process-global*. Running the two + // assertions as separate `#[test]`s flakes under CI's lower test + // parallelism: a sibling test can rewrite `XDG_CONFIG_HOME` + // between this test's set and its read, producing a spurious + // `None`. A single test serialises the env-var handoff and avoids + // pulling in a `serial_test`-style dev dep just for this. #[test] - fn plugin_kv_round_trip_through_disk() { - // Use a temp HOME so we don't stomp on the developer's - // real ~/.config/harmont/state. + fn plugin_kv_round_trip_and_isolation() { let temp = tempfile::tempdir().unwrap(); // SAFETY: in-process env var set; reset after. unsafe { std::env::set_var("XDG_CONFIG_HOME", temp.path()); } - set_current_plugin_name("test-plugin".into()); + // Round-trip through disk. + set_current_plugin_name("test-plugin".into()); kv_set_impl(KvScope::Plugin, "key", b"value".to_vec()); assert_eq!(kv_get_impl(KvScope::Plugin, "key"), Some(b"value".to_vec())); - - // Simulate a fresh process: the in-memory state is gone; only - // the on-disk file is authoritative. Re-read. - let again = kv_get_impl(KvScope::Plugin, "key"); - assert_eq!(again, Some(b"value".to_vec())); - - clear_current_plugin_name(); - } - - #[test] - fn plugin_kv_isolated_per_plugin_name() { - let temp = tempfile::tempdir().unwrap(); - // SAFETY: in-process env var set; reset after. - unsafe { - std::env::set_var("XDG_CONFIG_HOME", temp.path()); - } - + // Re-read: the in-memory state is irrelevant; only the + // on-disk file is authoritative. + assert_eq!( + kv_get_impl(KvScope::Plugin, "key"), + Some(b"value".to_vec()) + ); + + // Per-plugin-name isolation: keys written under one plugin + // name aren't visible to another. set_current_plugin_name("alpha".into()); kv_set_impl(KvScope::Plugin, "k", b"a".to_vec()); diff --git a/crates/hm/src/tui/app.rs b/crates/hm/src/tui/app.rs new file mode 100644 index 00000000..435538e7 --- /dev/null +++ b/crates/hm/src/tui/app.rs @@ -0,0 +1,389 @@ +//! Mission Control reducer. Pure: `AppState::apply(TuiEvent)` yields +//! a new state without touching the terminal. All ratatui widgets are +//! immediate-mode renders over this state. + +use std::collections::BTreeMap; +use std::collections::VecDeque; + +use chrono::{DateTime, Utc}; +use hm_plugin_protocol::{PlanSummary, StdStream}; +use uuid::Uuid; + +use super::event::{DeployState, TuiEvent}; + +const LOG_RING_CAPACITY: usize = 2000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepStatus { + Queued, + Running, + CachedHit, + Passed, + Failed, +} + +#[derive(Debug, Clone)] +pub struct Step { + pub id: Uuid, + pub chain_idx: usize, + pub label: String, + pub status: StepStatus, + pub started_at: Option>, + pub duration_ms: Option, +} + +#[derive(Debug, Clone)] +pub struct Chain { + pub idx: usize, + pub label: String, + pub parent: Option, + pub steps: Vec, + pub deploy_state: Option, +} + +#[derive(Debug, Clone)] +pub struct LogEntry { + pub ts: DateTime, + pub stream: StdStream, + pub line: String, +} + +#[derive(Debug)] +pub struct StepLogBuffer { + pub entries: VecDeque, + pub dropped: u64, +} + +impl Default for StepLogBuffer { + fn default() -> Self { + Self { + entries: VecDeque::with_capacity(LOG_RING_CAPACITY), + dropped: 0, + } + } +} + +impl StepLogBuffer { + pub fn push(&mut self, e: LogEntry) { + if self.entries.len() == LOG_RING_CAPACITY { + self.entries.pop_front(); + } + self.entries.push_back(e); + } +} + +#[derive(Debug, Default)] +pub struct AppState { + pub run_id: Option, + pub plan: Option, + pub started_at: Option>, + pub ended_at: Option>, + pub exit_code: Option, + pub chains: Vec, + pub steps: BTreeMap, + pub logs: BTreeMap, + pub focused_chain: usize, + pub fail_message: Option, +} + +impl AppState { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + // `DeployLog` arm uses contains_key+insert rather than entry() because the + // branch also pushes to `self.chains[chain_idx].steps`, which borrows `self` + // mutably — something `entry().or_insert_with()` cannot express. + #[allow(clippy::map_entry)] + pub fn apply(&mut self, event: TuiEvent) { + match event { + TuiEvent::BuildStart { run_id, plan, started_at } => { + self.run_id = Some(run_id); + self.plan = Some(plan); + self.started_at = Some(started_at); + } + TuiEvent::ChainQueued { chain_idx, label, parent } => { + while self.chains.len() <= chain_idx { + self.chains.push(Chain { + idx: self.chains.len(), + label: String::new(), + parent: None, + steps: vec![], + deploy_state: None, + }); + } + let c = &mut self.chains[chain_idx]; + c.label = label; + c.parent = parent; + } + TuiEvent::StepStart { step_id, chain_idx, runner: _, image: _, label } => { + self.steps.insert(step_id, Step { + id: step_id, + chain_idx, + label, + status: StepStatus::Running, + started_at: Some(Utc::now()), + duration_ms: None, + }); + while self.chains.len() <= chain_idx { + self.chains.push(Chain { + idx: self.chains.len(), + label: String::new(), + parent: None, + steps: vec![], + deploy_state: None, + }); + } + self.chains[chain_idx].steps.push(step_id); + } + TuiEvent::StepLog { step_id, stream, line, ts } => { + let buf = self.logs.entry(step_id).or_default(); + buf.push(LogEntry { ts, stream, line }); + } + TuiEvent::StepCacheHit { step_id, .. } => { + if let Some(s) = self.steps.get_mut(&step_id) { + s.status = StepStatus::CachedHit; + } + } + TuiEvent::StepEnd { step_id, exit_code, duration_ms } => { + if let Some(s) = self.steps.get_mut(&step_id) { + if s.status != StepStatus::CachedHit { + s.status = if exit_code == 0 { + StepStatus::Passed + } else { + StepStatus::Failed + }; + } + s.duration_ms = Some(duration_ms); + } + } + TuiEvent::ChainFailed { chain_idx: _, failed_step_key, exit_code, message } => { + self.fail_message = Some(format!( + "{failed_step_key} exited {exit_code}: {message}" + )); + } + TuiEvent::BuildEnd { exit_code, duration_ms: _ } => { + self.exit_code = Some(exit_code); + self.ended_at = Some(Utc::now()); + } + TuiEvent::DeployStatus { deploy_id, label, state, restarts: _, uptime_ms: _ } => { + let chain_idx = self.find_or_create_deploy_chain(&deploy_id, &label); + self.chains[chain_idx].deploy_state = Some(state); + } + TuiEvent::DeployLog { deploy_id, stream, line, ts } => { + let chain_idx = self.find_or_create_deploy_chain(&deploy_id, &deploy_id); + let step_id = uuid_from_deploy_id(&deploy_id); + if !self.steps.contains_key(&step_id) { + self.steps.insert(step_id, Step { + id: step_id, + chain_idx, + label: deploy_id.clone(), + status: StepStatus::Running, + started_at: Some(ts), + duration_ms: None, + }); + self.chains[chain_idx].steps.push(step_id); + } + let buf = self.logs.entry(step_id).or_default(); + buf.push(LogEntry { ts, stream, line }); + } + TuiEvent::Lagged { dropped } => { + if let Some(focused_step) = self.focused_step_id() { + let buf = self.logs.entry(focused_step).or_default(); + buf.dropped += dropped; + } + } + } + } + + #[must_use] + pub fn focused_step_id(&self) -> Option { + self.chains + .get(self.focused_chain) + .and_then(|c| c.steps.last().copied()) + } + + pub fn cycle_focus(&mut self, delta: isize) { + if self.chains.is_empty() { + return; + } + let len = self.chains.len(); + let len_i = isize::try_from(len).unwrap_or(isize::MAX); + let cur = isize::try_from(self.focused_chain).unwrap_or(0); + let next = (cur + delta).rem_euclid(len_i); + self.focused_chain = usize::try_from(next).unwrap_or(0); + } + + fn find_or_create_deploy_chain(&mut self, deploy_id: &str, label: &str) -> usize { + if let Some(idx) = self.chains.iter().position(|c| c.label == deploy_id) { + return idx; + } + let idx = self.chains.len(); + self.chains.push(Chain { + idx, + label: label.to_string(), + parent: None, + steps: vec![], + deploy_state: None, + }); + idx + } +} + +fn uuid_from_deploy_id(deploy_id: &str) -> Uuid { + Uuid::new_v5(&Uuid::NAMESPACE_OID, deploy_id.as_bytes()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use hm_plugin_protocol::PlanSummary; + + fn nil() -> Uuid { Uuid::nil() } + + fn plan(n: usize) -> PlanSummary { + PlanSummary { + step_count: n, + chain_count: n, + default_runner: "docker".into(), + } + } + + #[test] + fn build_start_sets_metadata() { + let mut s = AppState::new(); + s.apply(TuiEvent::BuildStart { + run_id: nil(), + plan: plan(3), + started_at: Utc::now(), + }); + assert!(s.run_id.is_some()); + assert!(s.plan.is_some()); + } + + #[test] + fn chain_queued_grows_chains() { + let mut s = AppState::new(); + s.apply(TuiEvent::ChainQueued { + chain_idx: 2, + label: "c2".into(), + parent: None, + }); + assert_eq!(s.chains.len(), 3); + assert_eq!(s.chains[2].label, "c2"); + } + + #[test] + fn step_lifecycle_transitions_status() { + let mut s = AppState::new(); + let sid = Uuid::new_v4(); + s.apply(TuiEvent::StepStart { + step_id: sid, + chain_idx: 0, + runner: "docker".into(), + image: None, + label: "test".into(), + }); + assert_eq!(s.steps[&sid].status, StepStatus::Running); + s.apply(TuiEvent::StepEnd { + step_id: sid, + exit_code: 0, + duration_ms: 42, + }); + assert_eq!(s.steps[&sid].status, StepStatus::Passed); + assert_eq!(s.steps[&sid].duration_ms, Some(42)); + } + + #[test] + fn cache_hit_sticks_through_step_end() { + let mut s = AppState::new(); + let sid = Uuid::new_v4(); + s.apply(TuiEvent::StepStart { + step_id: sid, + chain_idx: 0, + runner: "docker".into(), + image: None, + label: "build".into(), + }); + s.apply(TuiEvent::StepCacheHit { + step_id: sid, + key: "k".into(), + tag: "t".into(), + }); + s.apply(TuiEvent::StepEnd { + step_id: sid, + exit_code: 0, + duration_ms: 1, + }); + assert_eq!(s.steps[&sid].status, StepStatus::CachedHit); + } + + #[test] + fn failed_step_records_status() { + let mut s = AppState::new(); + let sid = Uuid::new_v4(); + s.apply(TuiEvent::StepStart { + step_id: sid, + chain_idx: 0, + runner: "docker".into(), + image: None, + label: "test".into(), + }); + s.apply(TuiEvent::StepEnd { + step_id: sid, + exit_code: 1, + duration_ms: 9, + }); + assert_eq!(s.steps[&sid].status, StepStatus::Failed); + } + + #[test] + fn log_buffer_caps_at_ring_capacity() { + let mut s = AppState::new(); + let sid = Uuid::new_v4(); + for i in 0..(LOG_RING_CAPACITY + 50) { + s.apply(TuiEvent::StepLog { + step_id: sid, + stream: StdStream::Stdout, + line: format!("L{i}"), + ts: Utc::now(), + }); + } + assert_eq!(s.logs[&sid].entries.len(), LOG_RING_CAPACITY); + assert_eq!(s.logs[&sid].entries.front().unwrap().line, format!("L{}", 50)); + } + + #[test] + fn focus_cycles_modulo_chains() { + let mut s = AppState::new(); + for i in 0..3 { + s.apply(TuiEvent::ChainQueued { + chain_idx: i, + label: format!("c{i}"), + parent: None, + }); + } + s.cycle_focus(1); + assert_eq!(s.focused_chain, 1); + s.cycle_focus(-1); + assert_eq!(s.focused_chain, 0); + s.cycle_focus(-1); + assert_eq!(s.focused_chain, 2); + } + + #[test] + fn deploy_status_creates_deploy_chain() { + let mut s = AppState::new(); + s.apply(TuiEvent::DeployStatus { + deploy_id: "db".into(), + label: "db".into(), + state: DeployState::Healthy, + restarts: 0, + uptime_ms: 1000, + }); + assert_eq!(s.chains.len(), 1); + assert_eq!(s.chains[0].deploy_state, Some(DeployState::Healthy)); + } +} diff --git a/crates/hm/src/tui/event.rs b/crates/hm/src/tui/event.rs new file mode 100644 index 00000000..8879dd20 --- /dev/null +++ b/crates/hm/src/tui/event.rs @@ -0,0 +1,107 @@ +//! Host-only event vocabulary fed to `AppState::apply`. Translated +//! from wire `BuildEvent` (local + cloud sources) and dev-daemon +//! status diffs at the adapter boundary. + +use chrono::{DateTime, Utc}; +use hm_plugin_protocol::{PlanSummary, StdStream}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeployState { + Starting, + Healthy, + Unhealthy, + Restarting, + Stopped, +} + +#[derive(Debug, Clone)] +pub enum TuiEvent { + BuildStart { + run_id: Uuid, + plan: PlanSummary, + started_at: DateTime, + }, + ChainQueued { + chain_idx: usize, + label: String, + parent: Option, + }, + StepStart { + step_id: Uuid, + chain_idx: usize, + runner: String, + image: Option, + label: String, + }, + StepLog { + step_id: Uuid, + stream: StdStream, + line: String, + ts: DateTime, + }, + StepCacheHit { + step_id: Uuid, + key: String, + tag: String, + }, + StepEnd { + step_id: Uuid, + exit_code: i32, + duration_ms: u64, + }, + ChainFailed { + chain_idx: usize, + failed_step_key: String, + exit_code: i32, + message: String, + }, + BuildEnd { + exit_code: i32, + duration_ms: u64, + }, + + DeployStatus { + deploy_id: String, + label: String, + state: DeployState, + restarts: u32, + uptime_ms: u64, + }, + DeployLog { + deploy_id: String, + stream: StdStream, + line: String, + ts: DateTime, + }, + + /// Synthetic event the adapter inserts when it has dropped one or + /// more `StepLog` events due to backpressure. The reducer renders + /// a single dim "events dropped" line in the affected step. + Lagged { dropped: u64 }, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn deploy_state_eq() { + assert_eq!(DeployState::Healthy, DeployState::Healthy); + assert_ne!(DeployState::Healthy, DeployState::Unhealthy); + } + + #[test] + fn step_log_is_clone() { + let ev = TuiEvent::StepLog { + step_id: Uuid::nil(), + stream: StdStream::Stdout, + line: "hi".into(), + ts: chrono::Utc::now(), + }; + let cloned = ev.clone(); + assert!(matches!(ev, TuiEvent::StepLog { .. })); + assert!(matches!(cloned, TuiEvent::StepLog { .. })); + } +} diff --git a/crates/hm/src/tui/fx.rs b/crates/hm/src/tui/fx.rs new file mode 100644 index 00000000..a1f533c4 --- /dev/null +++ b/crates/hm/src/tui/fx.rs @@ -0,0 +1,83 @@ +//! Effect budget + factory. Wraps tachyonfx so the rest of the TUI +//! sees a small, stable surface. + +use std::collections::VecDeque; +use std::time::Duration; + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use tachyonfx::{fx, Effect, EffectTimer, Interpolation, Motion}; + +/// Maximum simultaneous effects. Beyond this, new effect requests are +/// dropped silently — spec budget §3.1. +pub const MAX_QUEUED: usize = 5; + +pub struct ActiveEffect { + pub effect: Effect, + pub area: Rect, +} + +#[allow(clippy::missing_fields_in_debug, reason = "Effect is not Debug; area is sufficient for diagnostics")] +impl std::fmt::Debug for ActiveEffect { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActiveEffect").field("area", &self.area).finish() + } +} + +#[derive(Debug, Default)] +pub struct FxQueue { + queue: VecDeque, + enabled: bool, +} + +impl FxQueue { + #[must_use] + pub const fn new(enabled: bool) -> Self { + Self { queue: VecDeque::new(), enabled } + } + + pub fn push_sparkle(&mut self, area: Rect) { + if !self.enabled || self.queue.len() >= MAX_QUEUED { + return; + } + let timer = EffectTimer::from_ms(80, Interpolation::Linear); + let effect = fx::sweep_in(Motion::LeftToRight, 6, 0, Color::Black, timer); + self.queue.push_back(ActiveEffect { effect, area }); + } + + pub fn push_fade_in(&mut self, area: Rect) { + if !self.enabled || self.queue.len() >= MAX_QUEUED { + return; + } + let timer = EffectTimer::from_ms(120, Interpolation::Linear); + let effect = fx::fade_from_fg(Color::Black, timer); + self.queue.push_back(ActiveEffect { effect, area }); + } + + pub fn push_slide_in(&mut self, area: Rect) { + if !self.enabled || self.queue.len() >= MAX_QUEUED { + return; + } + let timer = EffectTimer::from_ms(200, Interpolation::QuadOut); + let effect = fx::sweep_in(Motion::RightToLeft, 12, 0, Color::Black, timer); + self.queue.push_back(ActiveEffect { effect, area }); + } + + #[must_use] + pub fn is_animating(&self) -> bool { + !self.queue.is_empty() + } + + /// Drive every queued effect by `delta` and drop completed ones. + /// Call once per frame. + pub fn tick(&mut self, buf: &mut Buffer, delta: Duration) { + // tachyonfx::Duration is a custom u32-millisecond type; std::time::Duration + // converts via From when the "std" feature (default) is active. + let tfx_delta: tachyonfx::Duration = delta.into(); + self.queue.retain_mut(|a| { + a.effect.process(tfx_delta, buf, a.area); + !a.effect.done() + }); + } +} diff --git a/crates/hm/src/tui/mod.rs b/crates/hm/src/tui/mod.rs new file mode 100644 index 00000000..8415c06f --- /dev/null +++ b/crates/hm/src/tui/mod.rs @@ -0,0 +1,316 @@ +//! Mission Control TUI — host-side ratatui renderer. +#![allow( + clippy::future_not_send, + reason = "tachyonfx::Shader is !Send by design; tui::run is .await'ed inline on the main task, never tokio::spawn'd." +)] + +pub mod app; +pub mod event; +pub mod fx; +pub mod source; +pub mod term; +pub mod theme; +pub mod widgets; + +use std::io; +use std::time::{Duration, Instant}; + +use crossterm::event::{ + self as ce, Event as CeEvent, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind, +}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use tokio::sync::mpsc; + +use self::app::AppState; +use self::event::TuiEvent; +use self::fx::FxQueue; +use self::term::TermGuard; +use self::theme::Theme; +use self::widgets::filter::Filter; +use self::widgets::footer::Footer; +use self::widgets::graph::Graph; +use self::widgets::header::Header; +use self::widgets::help::Help; +use self::widgets::log::LogPane; +use self::widgets::summary::Summary; +use self::widgets::timeline::Timeline; + +#[derive(Debug, Clone)] +pub struct TuiOptions { + pub fx_enabled: bool, + pub summary_card: bool, + pub title: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum TuiError { + #[error("terminal i/o: {0}")] + Io(#[from] io::Error), + #[error("event channel closed before BuildEnd")] + ChannelClosed, +} + +const FRAME_INTERVAL: Duration = Duration::from_millis(16); +const SUMMARY_HOLD: Duration = Duration::from_secs(2); +const MIN_COLS: u16 = 60; +const MIN_ROWS: u16 = 20; + +/// Drive the Mission Control TUI. Consumes `TuiEvent`s from `events` +/// and renders until `BuildEnd` (or the user presses `q`/`Esc`/2×Ctrl-C). +/// +/// # Errors +/// +/// Returns `TuiError::Io` for terminal-setup or draw failures. +#[allow(clippy::too_many_lines, reason = "main event loop; splitting would obscure spawn/join symmetry")] +#[allow(clippy::print_stderr, reason = "user-visible fallback notice on terminal-too-small resize")] +pub async fn run( + mut events: mpsc::Receiver, + opts: TuiOptions, +) -> Result { + let mut guard = TermGuard::enter()?; + let theme = Theme::dark(); + let mut state = AppState::new(); + let mut fx = FxQueue::new(opts.fx_enabled); + + let mut frame_tick = tokio::time::interval(FRAME_INTERVAL); + frame_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + let mut last_frame = Instant::now(); + let mut needs_render = true; + let mut help_open = false; + let mut filter_open = false; + let mut filter_buf = String::new(); + let mut log_scroll: usize = 0; + let mut last_ctrl_c: Option = None; + + loop { + tokio::select! { + _ = frame_tick.tick() => { + let now = Instant::now(); + let delta = now - last_frame; + last_frame = now; + + // Drain pending key/mouse events (non-blocking). + while ce::poll(Duration::from_millis(0)).map_err(TuiError::Io)? { + let ev = ce::read().map_err(TuiError::Io)?; + needs_render = true; + match ev { + CeEvent::Key(k) if k.kind == KeyEventKind::Press => { + if filter_open { + match k.code { + KeyCode::Esc => { filter_open = false; filter_buf.clear(); } + KeyCode::Backspace => { filter_buf.pop(); } + KeyCode::Enter => { filter_open = false; } + KeyCode::Char(c) => { filter_buf.push(c); } + _ => {} + } + continue; + } + match k.code { + KeyCode::Char('q') | KeyCode::Esc => { + return finalise(&state, opts.summary_card, &theme, &mut guard).await; + } + KeyCode::Tab => state.cycle_focus(1), + KeyCode::BackTab => state.cycle_focus(-1), + KeyCode::Char('/') => { filter_open = true; filter_buf.clear(); } + KeyCode::Char('?') => { help_open = !help_open; } + KeyCode::Up => { log_scroll = log_scroll.saturating_add(1); } + KeyCode::Down => { log_scroll = log_scroll.saturating_sub(1); } + KeyCode::PageUp => { log_scroll = log_scroll.saturating_add(10); } + KeyCode::PageDown => { log_scroll = log_scroll.saturating_sub(10); } + KeyCode::Char('g') => { log_scroll = usize::MAX / 2; } + KeyCode::Char('G') => { log_scroll = 0; } + KeyCode::Char('c') if k.modifiers.contains(KeyModifiers::CONTROL) => { + let now = Instant::now(); + if last_ctrl_c.is_some_and(|t| now - t < Duration::from_secs(2)) { + return Ok(130); + } + last_ctrl_c = Some(now); + // First Ctrl-C: orchestrator cancellation is hooked + // separately via signal::install_ctrlc; this branch + // exists so a second Ctrl-C within 2s force-exits. + } + _ => {} + } + } + CeEvent::Mouse(m) => { + match m.kind { + MouseEventKind::ScrollUp => { log_scroll = log_scroll.saturating_add(2); } + MouseEventKind::ScrollDown => { log_scroll = log_scroll.saturating_sub(2); } + MouseEventKind::Down(_) => { + let chain_idx = m.row.saturating_sub(2) as usize; + if chain_idx < state.chains.len() { + state.focused_chain = chain_idx; + } + } + _ => {} + } + } + CeEvent::Resize(cols, rows) + if cols < MIN_COLS || rows < MIN_ROWS => + { + drop(guard); + eprintln!("[hm] terminal too small for TUI; falling back to streaming output"); + return Ok(consume_to_end(&mut events).await); + } + _ => {} + } + } + + if !needs_render && !fx.is_animating() { + continue; + } + needs_render = false; + + guard.terminal.draw(|f| { + let size = f.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(8), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(size); + + let row = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(chunks[1]); + + f.render_widget(Header { state: &state, theme: &theme, title: &opts.title }, chunks[0]); + f.render_widget(Graph { state: &state, theme: &theme }, row[0]); + f.render_widget(Timeline { state: &state, theme: &theme }, row[1]); + let filter_ref = if filter_open || !filter_buf.is_empty() { + Some(filter_buf.as_str()) + } else { + None + }; + f.render_widget( + LogPane { + state: &state, + theme: &theme, + scroll: log_scroll, + filter: filter_ref, + }, + chunks[2], + ); + f.render_widget(Footer { state: &state, theme: &theme }, chunks[3]); + if filter_open { + let fa = Rect::new( + chunks[2].x, + chunks[2].y + chunks[2].height.saturating_sub(1), + chunks[2].width, + 1, + ); + f.render_widget(Filter { theme: &theme, query: &filter_buf }, fa); + } + if help_open { + let w = 50.min(size.width.saturating_sub(4)); + let h = 14.min(size.height.saturating_sub(4)); + let r = Rect::new( + (size.width.saturating_sub(w)) / 2, + (size.height.saturating_sub(h)) / 2, + w, + h, + ); + f.render_widget(Help { theme: &theme }, r); + } + let buf = f.buffer_mut(); + fx.tick(buf, delta); + }).map_err(TuiError::Io)?; + } + ev = events.recv() => { + match ev { + Some(e @ (TuiEvent::StepCacheHit { .. } | TuiEvent::StepEnd { exit_code: 0, .. })) => { + needs_render = true; + fx.push_sparkle(Rect::new(0, 2, 40, 6)); + state.apply(e); + } + Some(TuiEvent::BuildEnd { exit_code, duration_ms }) => { + state.apply(TuiEvent::BuildEnd { exit_code, duration_ms }); + return finalise(&state, opts.summary_card, &theme, &mut guard).await; + } + Some(e) => { + needs_render = true; + state.apply(e); + } + None => return finalise(&state, opts.summary_card, &theme, &mut guard).await, + } + } + } + } +} + +async fn finalise( + state: &AppState, + summary_card: bool, + theme: &Theme, + guard: &mut TermGuard, +) -> Result { + if summary_card { + guard.terminal.draw(|f| { + let size = f.area(); + f.render_widget(Summary { state, theme }, size); + }).map_err(TuiError::Io)?; + tokio::time::sleep(SUMMARY_HOLD).await; + } + Ok(state.exit_code.unwrap_or(0)) +} + +async fn consume_to_end(events: &mut mpsc::Receiver) -> i32 { + let mut code = 0; + while let Some(ev) = events.recv().await { + if let TuiEvent::BuildEnd { exit_code, .. } = ev { + code = exit_code; + } + } + code +} + +/// Install a TUI-only `OrchestratorState` for non-orchestrated commands. +/// +/// Returns the receiver the caller hands to `tui::run`. Internally, +/// plugin emissions via `hm_build_event_emit` are translated +/// `BuildEvent → TuiEvent` by the same translator the local source uses. +/// +/// # Panics +/// +/// Panics if a state is already installed (the orchestrator has run +/// in this process). Cloud watch and the orchestrator are mutually +/// exclusive within a single process invocation. +#[must_use] +pub fn install_session_sink() -> mpsc::Receiver { + use crate::orchestrator::archive::ArchiveStore; + use crate::orchestrator::events::EventBus; + use crate::orchestrator::state::{install, OrchestratorState}; + use std::sync::Arc; + use uuid::Uuid; + + let (bus_tx, mut bus_rx) = mpsc::channel::( + source::TUI_CHANNEL_CAPACITY, + ); + let (tui_tx, tui_rx) = source::channel(); + + tokio::spawn(async move { + while let Some(ev) = bus_rx.recv().await { + let translated = source::local::translate(ev); + if tui_tx.send(translated).await.is_err() { + break; + } + } + }); + + let state = Arc::new(OrchestratorState { + event_bus: EventBus::new(), + archives: ArchiveStore::new(), + cancel: crate::orchestrator::cancel::CancellationToken::new(), + docker: None, + run_id: Uuid::new_v4(), + tui_event_tx: Some(bus_tx), + }); + install(state); + + tui_rx +} diff --git a/crates/hm/src/tui/source/cloud.rs b/crates/hm/src/tui/source/cloud.rs new file mode 100644 index 00000000..2fb382f7 --- /dev/null +++ b/crates/hm/src/tui/source/cloud.rs @@ -0,0 +1,9 @@ +//! Cloud watch (host-fn fed) → `TuiEvent` adapter. +//! +//! The cloud plugin runs `watch` inside WASM and emits wire +//! `BuildEvent`s via the `hm_build_event_emit` host fn. The host fn +//! pushes them into the mpsc owned by `OrchestratorState::tui_event_tx`. +//! This source spawns the same translator task as `local::spawn` — +//! the wire format is identical. + +pub use super::local::spawn; diff --git a/crates/hm/src/tui/source/dev.rs b/crates/hm/src/tui/source/dev.rs new file mode 100644 index 00000000..1722c5b3 --- /dev/null +++ b/crates/hm/src/tui/source/dev.rs @@ -0,0 +1,82 @@ +//! Dev daemon → `TuiEvent` adapter. +//! +//! Driven by `hm dev up`'s existing `LogLine` mpsc and a list of known +//! deploys at boot time. v1 synthesises `Healthy` per deploy at start +//! and `Stopped` when logmux closes; richer Docker-level health +//! polling is a follow-up. The adapter never panics: send-errors mean +//! the TUI consumer dropped, so we exit the task cleanly. + +use chrono::Utc; +use tokio::sync::mpsc; + +use crate::commands::dev::logmux::LogLine; +use crate::tui::event::{DeployState, TuiEvent}; + +/// Spawn the translator. Returns the `TuiEvent` receiver. The caller +/// passes the `LogLine` receiver (already created in `hm dev up`) and +/// a list of `(slug, deploy_id)` pairs known at boot time. +#[must_use] +pub fn spawn( + mut log_rx: mpsc::UnboundedReceiver, + deploys: Vec<(String, String)>, +) -> mpsc::Receiver { + let (tx, rx) = super::channel(); + + tokio::spawn(async move { + // Synthetic BuildStart so AppState header renders. + let _ = tx.send(TuiEvent::BuildStart { + run_id: uuid::Uuid::new_v4(), + plan: hm_plugin_protocol::PlanSummary { + step_count: deploys.len(), + chain_count: deploys.len(), + default_runner: "docker".into(), + }, + started_at: Utc::now(), + }).await; + + for (idx, (slug, _deploy_id)) in deploys.iter().enumerate() { + let _ = tx.send(TuiEvent::ChainQueued { + chain_idx: idx, + label: slug.clone(), + parent: None, + }).await; + let _ = tx.send(TuiEvent::DeployStatus { + deploy_id: slug.clone(), + label: slug.clone(), + state: DeployState::Healthy, + restarts: 0, + uptime_ms: 0, + }).await; + } + + while let Some(line) = log_rx.recv().await { + let payload = String::from_utf8_lossy(&line.bytes).into_owned(); + if tx.send(TuiEvent::DeployLog { + deploy_id: line.slug, + stream: hm_plugin_protocol::StdStream::Stdout, + line: payload, + ts: Utc::now(), + }).await.is_err() { + return; // TUI consumer dropped + } + } + + // logmux closed — mark every deploy stopped and synthesise + // BuildEnd so the summary card renders. + for (slug, _) in &deploys { + let _ = tx.send(TuiEvent::DeployStatus { + deploy_id: slug.clone(), + label: slug.clone(), + state: DeployState::Stopped, + restarts: 0, + uptime_ms: 0, + }).await; + } + let _ = tx.send(TuiEvent::BuildEnd { + exit_code: 0, + duration_ms: 0, + }).await; + }); + + rx +} diff --git a/crates/hm/src/tui/source/local.rs b/crates/hm/src/tui/source/local.rs new file mode 100644 index 00000000..a5a81440 --- /dev/null +++ b/crates/hm/src/tui/source/local.rs @@ -0,0 +1,119 @@ +//! Build-event broadcast → `TuiEvent` adapter for local `hm run`. +//! +//! The orchestrator emits wire [`BuildEvent`]s on its broadcast bus and +//! forwards them on a [`tokio::sync::mpsc`] sender when one is provided. +//! This adapter sits between that `mpsc` and the TUI's `TuiEvent` channel, +//! translating each [`BuildEvent`] 1:1 (the `Lagged` variant is handled +//! separately by the scheduler bridge). + +use hm_plugin_protocol::BuildEvent; +use tokio::sync::mpsc; + +use crate::tui::event::TuiEvent; + +/// Spawn the translator task. Returns the bus-side sender for +/// `scheduler::run` and the consumer receiver for `tui::run`. +#[must_use] +pub fn spawn() -> ( + mpsc::Sender, + mpsc::Receiver, +) { + let (bus_tx, mut bus_rx) = mpsc::channel::(super::TUI_CHANNEL_CAPACITY); + let (tui_tx, tui_rx) = super::channel(); + + tokio::spawn(async move { + while let Some(ev) = bus_rx.recv().await { + let translated = translate(ev); + if tui_tx.send(translated).await.is_err() { + break; + } + } + }); + + (bus_tx, tui_rx) +} + +pub(crate) fn translate(ev: BuildEvent) -> TuiEvent { + match ev { + BuildEvent::BuildStart { run_id, plan, started_at } => TuiEvent::BuildStart { + run_id, + plan, + started_at, + }, + BuildEvent::StepQueued { step_id: _, key, chain_idx } => TuiEvent::ChainQueued { + chain_idx, + label: key, + parent: None, + }, + BuildEvent::StepStart { step_id, runner, image } => TuiEvent::StepStart { + step_id, + // chain_idx and label are filled in by the reducer from the + // preceding StepQueued event; this translator does not know + // the chain index from a StepStart alone. + chain_idx: 0, + runner, + image, + label: String::new(), + }, + BuildEvent::StepLog { step_id, stream, line, ts } => TuiEvent::StepLog { + step_id, + stream, + line, + ts, + }, + BuildEvent::StepCacheHit { step_id, key, tag } => TuiEvent::StepCacheHit { + step_id, + key, + tag, + }, + BuildEvent::StepEnd { step_id, exit_code, duration_ms, snapshot: _ } => TuiEvent::StepEnd { + step_id, + exit_code, + duration_ms, + }, + BuildEvent::ChainFailed { + chain_idx, + failed_step_id: _, + failed_step_key, + exit_code, + message, + ts: _, + } => TuiEvent::ChainFailed { + chain_idx, + failed_step_key, + exit_code, + message, + }, + BuildEvent::BuildEnd { exit_code, duration_ms } => TuiEvent::BuildEnd { + exit_code, + duration_ms, + }, + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic, reason = "test-only: panic on unexpected event variant is intentional")] +mod tests { + use super::*; + use hm_plugin_protocol::PlanSummary; + use uuid::Uuid; + + #[tokio::test] + async fn forwards_build_start() { + let (bus_tx, mut tui_rx) = spawn(); + bus_tx.send(BuildEvent::BuildStart { + run_id: Uuid::nil(), + plan: PlanSummary { + step_count: 1, + chain_count: 1, + default_runner: "docker".into(), + }, + started_at: chrono::Utc::now(), + }).await.unwrap(); + let ev = tui_rx.recv().await.unwrap(); + match ev { + TuiEvent::BuildStart { .. } => {} + other => panic!("got {other:?}"), + } + } +} diff --git a/crates/hm/src/tui/source/mod.rs b/crates/hm/src/tui/source/mod.rs new file mode 100644 index 00000000..e187358a --- /dev/null +++ b/crates/hm/src/tui/source/mod.rs @@ -0,0 +1,24 @@ +//! Event-source adapters for the Mission Control TUI. +//! +//! Each command surface (`hm run`, `hm dev up`, `hm cloud build watch`) +//! constructs a source that converts its command-specific event stream +//! into `TuiEvent`s sent on the mpsc channel `tui::run` consumes. + +pub mod local; +pub mod dev; +pub mod cloud; + +use tokio::sync::mpsc; + +use super::event::TuiEvent; + +/// Channel capacity from adapter → TUI. Adapters drop `StepLog` +/// events when full and emit a single `Lagged` synthetic event per +/// drop burst, matching the protocol-bus contract. +pub const TUI_CHANNEL_CAPACITY: usize = 1024; + +/// Create the (sender, receiver) pair used by adapters and the TUI. +#[must_use] +pub fn channel() -> (mpsc::Sender, mpsc::Receiver) { + mpsc::channel(TUI_CHANNEL_CAPACITY) +} diff --git a/crates/hm/src/tui/term.rs b/crates/hm/src/tui/term.rs new file mode 100644 index 00000000..8828ef7e --- /dev/null +++ b/crates/hm/src/tui/term.rs @@ -0,0 +1,68 @@ +//! Terminal setup / restore guard. Owning a `TermGuard` switches the +//! terminal into alt screen + raw mode + mouse capture; dropping it +//! restores the previous state, even on panic. + +use std::io::{self, Stdout}; + +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, + }, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; + +pub type TuiTerminal = Terminal>; + +/// Holds the terminal in TUI mode. Restores on drop or panic. +pub struct TermGuard { + pub terminal: TuiTerminal, +} + +impl std::fmt::Debug for TermGuard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TermGuard").finish() + } +} + +impl TermGuard { + /// Enter alt screen + raw mode + mouse capture and return the guard. + /// + /// # Errors + /// Returns an `io::Error` if any of the terminal setup steps fail. + pub fn enter() -> io::Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + install_panic_hook(); + Ok(Self { terminal }) + } +} + +impl Drop for TermGuard { + fn drop(&mut self) { + let _ = restore(); + } +} + +fn restore() -> io::Result<()> { + let mut stdout = io::stdout(); + execute!(stdout, LeaveAlternateScreen, DisableMouseCapture)?; + disable_raw_mode()?; + Ok(()) +} + +fn install_panic_hook() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = restore(); + prev(info); + })); + }); +} diff --git a/crates/hm/src/tui/theme.rs b/crates/hm/src/tui/theme.rs new file mode 100644 index 00000000..f85d84fc --- /dev/null +++ b/crates/hm/src/tui/theme.rs @@ -0,0 +1,55 @@ +//! Single-theme palette. See spec §3.3. + +use ratatui::style::{Color, Modifier, Style}; + +#[derive(Debug, Clone, Copy)] +pub struct Theme { + pub border_dim: Color, + pub border_focus: Color, + pub accent_a: Color, + pub accent_b: Color, + pub pass: Color, + pub cache: Color, + pub fail: Color, + pub running: Color, + pub pending: Color, + pub text_dim: Color, +} + +impl Theme { + #[must_use] + pub const fn dark() -> Self { + Self { + border_dim: Color::DarkGray, + border_focus: Color::Cyan, + accent_a: Color::Cyan, + accent_b: Color::Blue, + pass: Color::Green, + cache: Color::Yellow, + fail: Color::Red, + running: Color::Cyan, + pending: Color::DarkGray, + text_dim: Color::DarkGray, + } + } + + #[must_use] + pub const fn border(&self, focused: bool) -> Style { + let color = if focused { self.border_focus } else { self.border_dim }; + // Style::default() isn't const in older ratatui; use Style::new() which is. + Style::new().fg(color) + } + + #[must_use] + pub const fn status(&self, status: crate::tui::app::StepStatus) -> Style { + use crate::tui::app::StepStatus; + let c = match status { + StepStatus::Queued => self.pending, + StepStatus::Running => self.running, + StepStatus::CachedHit => self.cache, + StepStatus::Passed => self.pass, + StepStatus::Failed => self.fail, + }; + Style::new().fg(c).add_modifier(Modifier::BOLD) + } +} diff --git a/crates/hm/src/tui/widgets/filter.rs b/crates/hm/src/tui/widgets/filter.rs new file mode 100644 index 00000000..c78578a2 --- /dev/null +++ b/crates/hm/src/tui/widgets/filter.rs @@ -0,0 +1,28 @@ +//! Inline filter prompt — single line at the bottom of the log pane. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::widgets::{Paragraph, Widget}; + +use crate::tui::theme::Theme; + +pub struct Filter<'a> { + pub theme: &'a Theme, + pub query: &'a str, +} + +impl std::fmt::Debug for Filter<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Filter").field("query", &self.query).finish() + } +} + +impl Widget for Filter<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let prompt = format!(" /{}_", self.query); + let line = Line::styled(prompt, Style::default().fg(self.theme.accent_a)); + Paragraph::new(line).render(area, buf); + } +} diff --git a/crates/hm/src/tui/widgets/footer.rs b/crates/hm/src/tui/widgets/footer.rs new file mode 100644 index 00000000..d5eb8f9d --- /dev/null +++ b/crates/hm/src/tui/widgets/footer.rs @@ -0,0 +1,61 @@ +//! Footer — keybinding hints + summary counters. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::widgets::{Paragraph, Widget}; + +use crate::tui::app::{AppState, StepStatus}; +use crate::tui::theme::Theme; + +pub struct Footer<'a> { + pub state: &'a AppState, + pub theme: &'a Theme, +} + +impl std::fmt::Debug for Footer<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Footer").finish() + } +} + +impl Widget for Footer<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let mut pass = 0_usize; + let mut cache = 0_usize; + let mut fail = 0_usize; + for s in self.state.steps.values() { + match s.status { + StepStatus::Passed => pass += 1, + StepStatus::CachedHit => cache += 1, + StepStatus::Failed => fail += 1, + _ => {} + } + } + let hints = " [tab] chain · [l] logs · [/] filter · [q] quit "; + let summary = format!(" {pass} pass · {cache} cache · {fail} fail "); + let total_width = area.width as usize; + let pad = total_width.saturating_sub(hints.len() + summary.len()); + let text = format!("{hints}{}{summary}", " ".repeat(pad)); + + let line = Line::styled(text, Style::default().fg(self.theme.text_dim)); + Paragraph::new(line).render(area, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::widgets::buffer_to_string; + + #[test] + fn snapshot_footer_empty() { + let s = AppState::new(); + let theme = Theme::dark(); + let area = Rect::new(0, 0, 80, 1); + let mut buf = Buffer::empty(area); + Footer { state: &s, theme: &theme }.render(area, &mut buf); + insta::assert_snapshot!(buffer_to_string(&buf)); + } +} diff --git a/crates/hm/src/tui/widgets/graph.rs b/crates/hm/src/tui/widgets/graph.rs new file mode 100644 index 00000000..132b690e --- /dev/null +++ b/crates/hm/src/tui/widgets/graph.rs @@ -0,0 +1,113 @@ +//! Chain DAG renderer. One row per chain; step glyphs grouped left to +//! right by chain order. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Widget}; + +use crate::tui::app::{AppState, StepStatus}; +use crate::tui::theme::Theme; + +pub struct Graph<'a> { + pub state: &'a AppState, + pub theme: &'a Theme, +} + +impl std::fmt::Debug for Graph<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Graph").finish() + } +} + +const fn glyph(status: StepStatus) -> &'static str { + match status { + StepStatus::Queued => "●", + StepStatus::Running => "◐", + StepStatus::CachedHit => "◆", + StepStatus::Passed => "◇", + StepStatus::Failed => "✖", + } +} + +impl Widget for Graph<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = Block::default() + .borders(Borders::ALL) + .title(" graph ") + .border_style(self.theme.border(false)); + let inner = block.inner(area); + block.render(area, buf); + + let max_rows = inner.height as usize; + let rows: Vec> = self + .state + .chains + .iter() + .take(max_rows) + .map(|chain| { + let mut spans: Vec> = Vec::new(); + let mut first = true; + for sid in &chain.steps { + let Some(step) = self.state.steps.get(sid) else { continue }; + if !first { + spans.push(Span::raw("─")); + } + spans.push(Span::styled( + glyph(step.status).to_string(), + self.theme.status(step.status), + )); + first = false; + } + Line::from(spans) + }) + .collect(); + + Paragraph::new(rows).render(inner, buf); + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::tui::event::TuiEvent; + use crate::tui::widgets::buffer_to_string; + use hm_plugin_protocol::PlanSummary; + use uuid::Uuid; + + #[test] + fn snapshot_graph_three_chains_mixed_status() { + let mut s = AppState::new(); + s.apply(TuiEvent::BuildStart { + run_id: Uuid::nil(), + plan: PlanSummary { + step_count: 9, + chain_count: 3, + default_runner: "docker".into(), + }, + started_at: chrono::Utc::now(), + }); + for i in 0..3 { + s.apply(TuiEvent::ChainQueued { + chain_idx: i, + label: format!("c{i}"), + parent: None, + }); + } + let s0 = Uuid::new_v4(); + let s1 = Uuid::new_v4(); + let s2 = Uuid::new_v4(); + s.apply(TuiEvent::StepStart { step_id: s0, chain_idx: 0, runner: "docker".into(), image: None, label: "test".into() }); + s.apply(TuiEvent::StepEnd { step_id: s0, exit_code: 0, duration_ms: 100 }); + s.apply(TuiEvent::StepStart { step_id: s1, chain_idx: 1, runner: "docker".into(), image: None, label: "build".into() }); + s.apply(TuiEvent::StepCacheHit { step_id: s1, key: "k".into(), tag: "t".into() }); + s.apply(TuiEvent::StepStart { step_id: s2, chain_idx: 2, runner: "docker".into(), image: None, label: "lint".into() }); + + let theme = Theme::dark(); + let area = Rect::new(0, 0, 30, 8); + let mut buf = Buffer::empty(area); + Graph { state: &s, theme: &theme }.render(area, &mut buf); + insta::assert_snapshot!(buffer_to_string(&buf)); + } +} diff --git a/crates/hm/src/tui/widgets/header.rs b/crates/hm/src/tui/widgets/header.rs new file mode 100644 index 00000000..648dfcab --- /dev/null +++ b/crates/hm/src/tui/widgets/header.rs @@ -0,0 +1,84 @@ +//! Header widget — wordmark + run id + chain counter. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::Widget; + +use crate::tui::app::{AppState, StepStatus}; +use crate::tui::theme::Theme; + +pub struct Header<'a> { + pub state: &'a AppState, + pub theme: &'a Theme, + pub title: &'a str, +} + +impl std::fmt::Debug for Header<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Header").field("title", &self.title).finish() + } +} + +impl Widget for Header<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let total_steps = self.state.steps.len(); + let done = self.state.steps.values() + .filter(|s| matches!(s.status, StepStatus::Passed | StepStatus::CachedHit | StepStatus::Failed)) + .count(); + let chains = self.state.chains.len(); + let run_short = self.state.run_id + .map_or_else(|| "—".into(), |u| format!("{:.8}", u.simple())); + let title_text = format!( + " HARMONT {} run {} · {chains} chains · {done}/{total_steps} done ", + self.title, run_short, + ); + let style = Style::default() + .fg(self.theme.accent_a) + .add_modifier(Modifier::BOLD); + let line = Line::styled(title_text, style); + buf.set_line(area.x, area.y, &line, area.width); + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::tui::event::TuiEvent; + use crate::tui::widgets::buffer_to_string; + use hm_plugin_protocol::PlanSummary; + use uuid::Uuid; + + fn fixture() -> AppState { + let mut s = AppState::new(); + s.apply(TuiEvent::BuildStart { + run_id: Uuid::nil(), + plan: PlanSummary { + step_count: 9, + chain_count: 3, + default_runner: "docker".into(), + }, + started_at: chrono::Utc::now(), + }); + for i in 0..3 { + s.apply(TuiEvent::ChainQueued { + chain_idx: i, + label: format!("c{i}"), + parent: None, + }); + } + s + } + + #[test] + fn snapshot_header_idle() { + let theme = Theme::dark(); + let state = fixture(); + let mut buf = Buffer::empty(Rect::new(0, 0, 80, 1)); + Header { state: &state, theme: &theme, title: "hm run" } + .render(Rect::new(0, 0, 80, 1), &mut buf); + insta::assert_snapshot!(buffer_to_string(&buf)); + } +} diff --git a/crates/hm/src/tui/widgets/help.rs b/crates/hm/src/tui/widgets/help.rs new file mode 100644 index 00000000..fef1a71b --- /dev/null +++ b/crates/hm/src/tui/widgets/help.rs @@ -0,0 +1,50 @@ +//! `?` help overlay — centered card. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Paragraph, Widget}; + +use crate::tui::theme::Theme; + +pub struct Help<'a> { pub theme: &'a Theme } + +impl std::fmt::Debug for Help<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Help").finish() + } +} + +impl Widget for Help<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = Block::default() + .borders(Borders::ALL) + .title(" help ") + .border_style(self.theme.border(true)); + let inner = block.inner(area); + block.render(area, buf); + + let lines: Vec> = [ + " q · Esc quit", + " Tab next chain", + " Shift-Tab prev chain", + " l expand log pane", + " / · Esc filter logs", + " ↑ ↓ wheel scroll log", + " PgUp PgDn page-scroll log", + " g · G top / bottom of log", + " ? toggle this help", + " Ctrl-C cancel run (twice to force)", + ] + .into_iter() + .map(Line::raw) + .collect(); + let body_area = Rect::new( + inner.x + 2, + inner.y + 1, + inner.width.saturating_sub(2), + inner.height.saturating_sub(1), + ); + Paragraph::new(lines).render(body_area, buf); + } +} diff --git a/crates/hm/src/tui/widgets/log.rs b/crates/hm/src/tui/widgets/log.rs new file mode 100644 index 00000000..8bd604f9 --- /dev/null +++ b/crates/hm/src/tui/widgets/log.rs @@ -0,0 +1,127 @@ +//! Log tail for the focused chain's most-recent step. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Widget}; + +use crate::tui::app::AppState; +use crate::tui::theme::Theme; + +pub struct LogPane<'a> { + pub state: &'a AppState, + pub theme: &'a Theme, + pub scroll: usize, + pub filter: Option<&'a str>, +} + +impl std::fmt::Debug for LogPane<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LogPane") + .field("scroll", &self.scroll) + .field("filter", &self.filter) + .finish() + } +} + +impl Widget for LogPane<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let chain_label = self + .state + .chains + .get(self.state.focused_chain) + .map(|c| c.label.clone()) + .unwrap_or_default(); + let title = format!(" log · {chain_label} "); + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(self.theme.border(true)); + let inner = block.inner(area); + block.render(area, buf); + + let Some(step_id) = self.state.focused_step_id() else { return }; + let Some(log) = self.state.logs.get(&step_id) else { return }; + + let entries: Vec<_> = log + .entries + .iter() + .filter(|e| self.filter.is_none_or(|f| e.line.contains(f))) + .collect(); + + let height = inner.height as usize; + let start = entries.len().saturating_sub(height + self.scroll); + let visible: Vec> = entries + .iter() + .skip(start) + .take(height) + .map(|entry| { + let prefix = match entry.stream { + hm_plugin_protocol::StdStream::Stdout => " ", + hm_plugin_protocol::StdStream::Stderr => "! ", + }; + let style = if entry.stream == hm_plugin_protocol::StdStream::Stderr { + Style::default().fg(self.theme.text_dim) + } else { + Style::default() + }; + Line::from(vec![ + Span::styled(prefix.to_string(), style), + Span::styled(entry.line.clone(), style), + ]) + }) + .collect(); + + Paragraph::new(visible).render(inner, buf); + + if log.dropped > 0 { + let drop_msg = format!(" … {} events dropped (lagged) …", log.dropped); + let style = Style::default().fg(self.theme.text_dim); + let line = Line::styled(drop_msg, style); + let drop_area = Rect::new(inner.x, inner.y, inner.width, 1); + Paragraph::new(line).render(drop_area, buf); + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::tui::event::TuiEvent; + use crate::tui::widgets::buffer_to_string; + use uuid::Uuid; + + #[test] + fn snapshot_log_with_filter() { + let mut s = AppState::new(); + s.apply(TuiEvent::ChainQueued { + chain_idx: 0, + label: "c0".into(), + parent: None, + }); + let sid = Uuid::new_v4(); + s.apply(TuiEvent::StepStart { + step_id: sid, + chain_idx: 0, + runner: "docker".into(), + image: None, + label: "test".into(), + }); + for l in ["alpha", "beta cat", "gamma cat", "delta"] { + s.apply(TuiEvent::StepLog { + step_id: sid, + stream: hm_plugin_protocol::StdStream::Stdout, + line: l.into(), + ts: chrono::Utc::now(), + }); + } + let theme = Theme::dark(); + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + LogPane { state: &s, theme: &theme, scroll: 0, filter: Some("cat") } + .render(area, &mut buf); + insta::assert_snapshot!(buffer_to_string(&buf)); + } +} diff --git a/crates/hm/src/tui/widgets/mod.rs b/crates/hm/src/tui/widgets/mod.rs new file mode 100644 index 00000000..9455b124 --- /dev/null +++ b/crates/hm/src/tui/widgets/mod.rs @@ -0,0 +1,26 @@ +//! Mission Control widget set. All widgets are stateless: they read +//! `&AppState` + `&Theme` and write into a `Buffer`. + +pub mod filter; +pub mod footer; +pub mod graph; +pub mod header; +pub mod help; +pub mod log; +pub mod summary; +pub mod timeline; + +/// Format a `Buffer` as one row per line for snapshot tests. +#[cfg(test)] +#[allow(clippy::missing_const_for_fn)] +pub(crate) fn buffer_to_string(buf: &ratatui::buffer::Buffer) -> String { + let mut out = String::new(); + let area = buf.area(); + for y in 0..area.height { + for x in 0..area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out +} diff --git a/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__footer__tests__snapshot_footer_empty.snap b/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__footer__tests__snapshot_footer_empty.snap new file mode 100644 index 00000000..8c43eafe --- /dev/null +++ b/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__footer__tests__snapshot_footer_empty.snap @@ -0,0 +1,5 @@ +--- +source: crates/hm/src/tui/widgets/footer.rs +expression: buffer_to_string(&buf) +--- + [tab] chain · [l] logs · [/] filter · [q] quit 0 pass · 0 cache · 0 fail diff --git a/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__graph__tests__snapshot_graph_three_chains_mixed_status.snap b/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__graph__tests__snapshot_graph_three_chains_mixed_status.snap new file mode 100644 index 00000000..8bed51a9 --- /dev/null +++ b/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__graph__tests__snapshot_graph_three_chains_mixed_status.snap @@ -0,0 +1,12 @@ +--- +source: crates/hm/src/tui/widgets/graph.rs +expression: buffer_to_string(&buf) +--- +┌ graph ─────────────────────┐ +│◇ │ +│◆ │ +│◐ │ +│ │ +│ │ +│ │ +└────────────────────────────┘ diff --git a/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__header__tests__snapshot_header_idle.snap b/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__header__tests__snapshot_header_idle.snap new file mode 100644 index 00000000..e247fe16 --- /dev/null +++ b/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__header__tests__snapshot_header_idle.snap @@ -0,0 +1,5 @@ +--- +source: crates/hm/src/tui/widgets/header.rs +expression: buffer_to_string(&buf) +--- + HARMONT hm run run 00000000000000000000000000000000 · 3 chains · 0/0 do diff --git a/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__log__tests__snapshot_log_with_filter.snap b/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__log__tests__snapshot_log_with_filter.snap new file mode 100644 index 00000000..de583aa0 --- /dev/null +++ b/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__log__tests__snapshot_log_with_filter.snap @@ -0,0 +1,10 @@ +--- +source: crates/hm/src/tui/widgets/log.rs +expression: buffer_to_string(&buf) +--- +┌ log · c0 ────────────────────────────┐ +│ beta cat │ +│ gamma cat │ +│ │ +│ │ +└──────────────────────────────────────┘ diff --git a/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__summary__tests__snapshot_summary_pass.snap b/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__summary__tests__snapshot_summary_pass.snap new file mode 100644 index 00000000..ec827e37 --- /dev/null +++ b/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__summary__tests__snapshot_summary_pass.snap @@ -0,0 +1,28 @@ +--- +source: crates/hm/src/tui/widgets/summary.rs +expression: buffer_to_string(&buf) +--- +┌──────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ █ █ ▗█▖ ▜▛▜▖█▖▟▌▗▛▙ █▖▐▌▛█▜ │ +│ █▄█ █ █ ▐▙▟▘███▌█ ▐▌█▜▟▌ █ │ +│ █ █ █▀█ ▐▌▜▖█▝▐▌▜▖▟▘█ ▜▌ █ │ +│ ▀ ▀ ▀ ▀ ▀▘▝▘▀ ▝▘ ▀▘ ▀ ▝▘▝▀▘ │ +│ │ +│ build complete │ +│ │ +│ total 6000ms │ +│ chains 3 │ +│ steps 3 passed · 0 cached · 0 failed │ +│ cache hit % 0% │ +│ slowest lint (3000ms) │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__timeline__tests__snapshot_timeline_three_chains.snap b/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__timeline__tests__snapshot_timeline_three_chains.snap new file mode 100644 index 00000000..46d949e8 --- /dev/null +++ b/crates/hm/src/tui/widgets/snapshots/harmont_cli__tui__widgets__timeline__tests__snapshot_timeline_three_chains.snap @@ -0,0 +1,12 @@ +--- +source: crates/hm/src/tui/widgets/timeline.rs +expression: buffer_to_string(&buf) +--- +┌ timeline ────────────────────────────────────────────────┐ +│c1 █████░░░░░░░░░░░░░░░░░░░░░░░░░ test 1000ms pass │ +│c2 ██████████░░░░░░░░░░░░░░░░░░░░ build 2000ms pass │ +│c3 ███████████████░░░░░░░░░░░░░░░ lint 3000ms pass │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────┘ diff --git a/crates/hm/src/tui/widgets/summary.rs b/crates/hm/src/tui/widgets/summary.rs new file mode 100644 index 00000000..ca924f9c --- /dev/null +++ b/crates/hm/src/tui/widgets/summary.rs @@ -0,0 +1,138 @@ +//! Final summary card — full-screen frame after `BuildEnd`. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Paragraph, Widget}; +use tui_big_text::{BigText, PixelSize}; + +use crate::tui::app::{AppState, StepStatus}; +use crate::tui::theme::Theme; + +pub struct Summary<'a> { + pub state: &'a AppState, + pub theme: &'a Theme, +} + +impl std::fmt::Debug for Summary<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Summary").finish() + } +} + +impl Widget for Summary<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(self.theme.border(true)); + let inner = block.inner(area); + block.render(area, buf); + + let mut pass = 0_usize; + let mut cache = 0_usize; + let mut fail = 0_usize; + let mut slowest: Option<(String, u64)> = None; + for s in self.state.steps.values() { + match s.status { + StepStatus::Passed => pass += 1, + StepStatus::CachedHit => cache += 1, + StepStatus::Failed => fail += 1, + _ => {} + } + if let Some(d) = s.duration_ms + && slowest.as_ref().is_none_or(|(_, p)| d > *p) + { + slowest = Some((s.label.clone(), d)); + } + } + let total = self.state.steps.len().max(1); + #[allow(clippy::cast_precision_loss)] + let cache_pct = (cache as f64 / total as f64) * 100.0; + let total_ms: u64 = self.state.steps.values() + .filter_map(|s| s.duration_ms) + .sum(); + + let failed = fail > 0; + let banner_style = if failed { + Style::default().fg(self.theme.fail).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(self.theme.pass).add_modifier(Modifier::BOLD) + }; + let banner = if failed { "build failed" } else { "build complete" }; + + // Big wordmark. + let big = BigText::builder() + .pixel_size(PixelSize::Quadrant) + .style(Style::default().fg(self.theme.accent_a)) + .lines(vec![Line::raw("HARMONT")]) + .build(); + let wordmark_area = Rect::new( + inner.x + 2, + inner.y + 1, + inner.width.saturating_sub(4), + 4, + ); + big.render(wordmark_area, buf); + + let line_banner = banner.to_string(); + let line_total = format!(" total {total_ms}ms"); + let line_chains = format!(" chains {}", self.state.chains.len()); + let line_steps = format!(" steps {pass} passed · {cache} cached · {fail} failed"); + let line_cache = format!(" cache hit % {cache_pct:.0}%"); + let line_slowest = format!( + " slowest {}", + slowest.as_ref().map_or_else(String::new, |(l, d)| format!("{l} ({d}ms)")), + ); + + let lines: Vec> = vec![ + Line::styled(line_banner, banner_style), + Line::raw(""), + Line::raw(line_total), + Line::raw(line_chains), + Line::raw(line_steps), + Line::raw(line_cache), + Line::raw(line_slowest), + ]; + let body_area = Rect::new( + inner.x + 2, + inner.y + 6, + inner.width.saturating_sub(2), + inner.height.saturating_sub(6), + ); + Paragraph::new(lines).render(body_area, buf); + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::tui::event::TuiEvent; + use crate::tui::widgets::buffer_to_string; + use hm_plugin_protocol::PlanSummary; + use uuid::Uuid; + + #[test] + fn snapshot_summary_pass() { + let mut s = AppState::new(); + s.apply(TuiEvent::BuildStart { + run_id: Uuid::nil(), + plan: PlanSummary { step_count: 3, chain_count: 3, default_runner: "docker".into() }, + started_at: chrono::Utc::now(), + }); + for i in 0..3 { + s.apply(TuiEvent::ChainQueued { chain_idx: i, label: format!("c{i}"), parent: None }); + let sid = Uuid::new_v4(); + s.apply(TuiEvent::StepStart { step_id: sid, chain_idx: i, runner: "docker".into(), image: None, label: ["test", "build", "lint"][i].into() }); + s.apply(TuiEvent::StepEnd { step_id: sid, exit_code: 0, duration_ms: (i as u64 + 1) * 1000 }); + } + s.apply(TuiEvent::BuildEnd { exit_code: 0, duration_ms: 6000 }); + + let theme = Theme::dark(); + let area = Rect::new(0, 0, 80, 24); + let mut buf = Buffer::empty(area); + Summary { state: &s, theme: &theme }.render(area, &mut buf); + insta::assert_snapshot!(buffer_to_string(&buf)); + } +} diff --git a/crates/hm/src/tui/widgets/timeline.rs b/crates/hm/src/tui/widgets/timeline.rs new file mode 100644 index 00000000..90b39398 --- /dev/null +++ b/crates/hm/src/tui/widgets/timeline.rs @@ -0,0 +1,131 @@ +//! Gantt-style timeline. Bars per chain, colored by current step +//! status, with right-aligned label + duration + status pill. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Widget}; + +use crate::tui::app::{AppState, StepStatus}; +use crate::tui::theme::Theme; + +pub struct Timeline<'a> { + pub state: &'a AppState, + pub theme: &'a Theme, +} + +impl std::fmt::Debug for Timeline<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Timeline").finish() + } +} + +const fn pill(status: StepStatus) -> &'static str { + match status { + StepStatus::Queued => "queued", + StepStatus::Running => "run", + StepStatus::CachedHit => "cache", + StepStatus::Passed => "pass", + StepStatus::Failed => "fail", + } +} + +impl Widget for Timeline<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = Block::default() + .borders(Borders::ALL) + .title(" timeline ") + .border_style(self.theme.border(false)); + let inner = block.inner(area); + block.render(area, buf); + + let total_ms: u64 = self.state.steps.values() + .filter_map(|s| s.duration_ms) + .sum::() + .max(1); + let bar_max = u64::from(inner.width.saturating_sub(28)); + let bar_max_u16 = u16::try_from(bar_max).unwrap_or(u16::MAX); + + let rows: Vec> = self + .state + .chains + .iter() + .enumerate() + .take(inner.height as usize) + .filter_map(|(row, chain)| { + let last_step_id = chain.steps.last()?; + let step = self.state.steps.get(last_step_id)?; + let dur = step.duration_ms.unwrap_or(0); + + #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] + let fill = ((dur as f64 / total_ms as f64) * bar_max as f64) as u16; + + let status_style = self.theme.status(step.status); + let label = format!("c{} ", row + 1); + let filled: String = "█".repeat(fill as usize); + let pending_len = bar_max_u16.saturating_sub(fill) as usize; + let pending: String = "░".repeat(pending_len); + let trail = format!(" {} {dur:>4}ms {:>5}", step.label, pill(step.status)); + + Some(Line::from(vec![ + Span::raw(label), + Span::styled(filled, status_style), + Span::styled(pending, Style::default().fg(self.theme.pending)), + Span::raw(trail), + ])) + }) + .collect(); + + Paragraph::new(rows).render(inner, buf); + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::tui::event::TuiEvent; + use crate::tui::widgets::buffer_to_string; + use hm_plugin_protocol::PlanSummary; + use uuid::Uuid; + + #[test] + fn snapshot_timeline_three_chains() { + let mut s = AppState::new(); + s.apply(TuiEvent::BuildStart { + run_id: Uuid::nil(), + plan: PlanSummary { + step_count: 3, + chain_count: 3, + default_runner: "docker".into(), + }, + started_at: chrono::Utc::now(), + }); + for i in 0..3 { + s.apply(TuiEvent::ChainQueued { + chain_idx: i, + label: format!("c{i}"), + parent: None, + }); + let sid = Uuid::new_v4(); + s.apply(TuiEvent::StepStart { + step_id: sid, + chain_idx: i, + runner: "docker".into(), + image: None, + label: ["test", "build", "lint"][i].into(), + }); + s.apply(TuiEvent::StepEnd { + step_id: sid, + exit_code: 0, + duration_ms: (i as u64 + 1) * 1000, + }); + } + let theme = Theme::dark(); + let area = Rect::new(0, 0, 60, 8); + let mut buf = Buffer::empty(area); + Timeline { state: &s, theme: &theme }.render(area, &mut buf); + insta::assert_snapshot!(buffer_to_string(&buf)); + } +} diff --git a/docs/demo/dev.tape b/docs/demo/dev.tape new file mode 100644 index 00000000..1911b75c --- /dev/null +++ b/docs/demo/dev.tape @@ -0,0 +1,28 @@ +# vhs tape for `hm dev up` TUI demo. +# +# Prerequisite: an example with `@hm.deploy(...)` registrations in +# `.harmont/`. None of the current `examples/*` directories declare a +# deployment, so this tape references a `examples/dev-demo/` directory +# that should be added in a follow-up commit (e.g., a minimal nginx +# service so the demo has something to render). +# +# Run with: vhs docs/demo/dev.tape + +Output docs/demo/dev.gif + +Set FontSize 14 +Set Width 1200 +Set Height 720 +Set Theme "Catppuccin Mocha" +Set TypingSpeed 50ms + +Type "cd examples/dev-demo" +Enter +Sleep 500ms +Type "hm dev up" +Enter +Sleep 20s +Screenshot docs/demo/dev.png +Sleep 2s +Ctrl+C +Sleep 2s diff --git a/docs/demo/run.tape b/docs/demo/run.tape new file mode 100644 index 00000000..430c8adc --- /dev/null +++ b/docs/demo/run.tape @@ -0,0 +1,17 @@ +Output docs/demo/run.gif + +Set FontSize 14 +Set Width 1200 +Set Height 720 +Set Theme "Catppuccin Mocha" +Set Shell "bash" +Set TypingSpeed 50ms + +Type "cd examples/rust" +Enter +Sleep 500ms +Type "hm run" +Enter +Sleep 30s +Screenshot docs/demo/run.png +Sleep 500ms diff --git a/docs/superpowers/plans/2026-05-22-inline-output-and-ratatui-widgets.md b/docs/superpowers/plans/2026-05-22-inline-output-and-ratatui-widgets.md new file mode 100644 index 00000000..a11bc811 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-inline-output-and-ratatui-widgets.md @@ -0,0 +1,1032 @@ +# Inline Output Formatters + Ratatui Widget Refactor — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate the WASM round-trip for built-in `human` / `json` output formatters by moving them into the `hm` crate, and rewrite the TUI widgets to use ratatui's built-in widget types (`Paragraph`, `List`, `Line`, `Span`) instead of hand-rolled `buf.cell_mut().set_symbol()` loops. + +**Architecture:** + +- **Phase A (output inline):** The `OutputFormatter` SDK trait + capability stay so external plugins can still register formatters. The two built-in implementations (`hm-plugin-output-human`, `hm-plugin-output-json`) move from separate WASM crates into `crates/hm/src/output/formatters/{human,json}.rs`. The orchestrator dispatcher prefers a built-in formatter when `format_name` matches; falls back to the WASM plugin lookup otherwise. +- **Phase B (ratatui widgets):** Each widget in `crates/hm/src/tui/widgets/` is rewritten to emit `Line`/`Span`/`Text` and render via ratatui's `Paragraph`, `List`, `Table`, or `Gauge`. The hand-rolled `for ch in line.chars() { buf.cell_mut(...).set_symbol(...) }` loops are deleted. `Block::default().borders(...).title(...)` stays where it's already used — that's already ratatui-native. + +**Tech Stack:** Rust 1.x, ratatui 0.30.0, tokio broadcast, anyhow, tracing, insta snapshot tests. + +--- + +## File Structure + +### Phase A — files touched + +- **Delete:** `crates/hm-plugin-output-human/` (entire crate) +- **Delete:** `crates/hm-plugin-output-json/` (entire crate) +- **Create:** `crates/hm/src/output/formatters/mod.rs` — `BuiltinFormatter` enum + dispatch +- **Create:** `crates/hm/src/output/formatters/human.rs` — moved from `hm-plugin-output-human/src/render.rs` + writer wrapper +- **Create:** `crates/hm/src/output/formatters/json.rs` — moved from `hm-plugin-output-json/src/lib.rs` render logic + writer wrapper +- **Modify:** `crates/hm/src/output/mod.rs` — add `pub mod formatters;` +- **Modify:** `crates/hm/src/orchestrator/output_subscriber.rs` — dispatch to `BuiltinFormatter` first, only fall through to the plugin registry for unknown formats +- **Modify:** `crates/hm/src/orchestrator/scheduler.rs` — drop the two embedded output WASMs from `embedded`; update the format-validation block to accept the built-in names +- **Modify:** `crates/hm/src/plugin/embedded.rs` — delete `OUTPUT_HUMAN_PLUGIN_WASM` and `OUTPUT_JSON_PLUGIN_WASM` constants +- **Modify:** `crates/hm/src/dispatcher.rs` — drop references to the two embedded output WASMs +- **Modify:** `crates/hm/build.rs` — remove `build_wasm_plugin("hm-plugin-output-human")` and `build_wasm_plugin("hm-plugin-output-json")` lines +- **Modify:** `Cargo.toml` (workspace) — remove the two member entries + +### Phase B — files touched + +- **Modify:** `crates/hm/src/tui/widgets/log.rs` — replace cell loop with `Paragraph::new(Text::from(lines))` +- **Modify:** `crates/hm/src/tui/widgets/graph.rs` — replace cell loop with per-row `Paragraph::new(Line::from(spans))` +- **Modify:** `crates/hm/src/tui/widgets/footer.rs` — `Paragraph` for key-hints row +- **Modify:** `crates/hm/src/tui/widgets/filter.rs` — `Paragraph` for the `/` input row +- **Modify:** `crates/hm/src/tui/widgets/summary.rs` — `Paragraph` (multi-line text) inside the bordered block +- **Modify:** `crates/hm/src/tui/widgets/help.rs` — `Paragraph` for the help overlay +- **Modify:** `crates/hm/src/tui/widgets/timeline.rs` — `Paragraph` or `List` (whichever the rewrite picks; both yield the same `buffer_to_string` snapshot) + +Snapshot tests in each widget file already exist (`buffer_to_string`-based); they pin the rendered output. The refactor must produce the same byte buffer (same glyphs, same positions, same styling) — `insta` accept the snapshot only after visually verifying it matches today's output. + +--- + +## Phase A — Inline Output Formatters + +### Task A1: Move the human render fn into the hm crate + +**Files:** +- Create: `crates/hm/src/output/formatters/mod.rs` +- Create: `crates/hm/src/output/formatters/human.rs` +- Modify: `crates/hm/src/output/mod.rs` + +- [ ] **Step 1: Add the formatters module to `output/mod.rs`** + +Open `crates/hm/src/output/mod.rs`. Add at the top of the file (after the existing module-doc comment if there is one, alongside whatever `pub mod ...` declarations are there already): + +```rust +pub mod formatters; +``` + +If `crates/hm/src/output/mod.rs` does not exist yet (only `format.rs` / `status.rs`), check `crates/hm/src/lib.rs` (or `main.rs`) for how the `output` module is declared and add the `formatters` child there. + +- [ ] **Step 2: Create `crates/hm/src/output/formatters/mod.rs`** + +```rust +//! Built-in BuildEvent formatters. External plugins can still register +//! their own formatter via the `OutputFormatter` capability; these are +//! the in-tree implementations that ship with every build of `hm`. + +use hm_plugin_protocol::BuildEvent; + +pub mod human; +pub mod json; + +/// A formatter that lives inside the `hm` binary. Returned by +/// [`builtin`] for names the orchestrator already knows. The +/// orchestrator's output subscriber falls through to the WASM +/// plugin registry only when this returns `None`. +pub enum Builtin { + Human(human::Human), + Json(json::Json), +} + +impl Builtin { + pub fn on_event(&mut self, event: &BuildEvent) { + match self { + Self::Human(h) => h.on_event(event), + Self::Json(j) => j.on_event(event), + } + } + + pub fn finalize(&mut self) { + match self { + Self::Human(h) => h.finalize(), + Self::Json(j) => j.finalize(), + } + } +} + +#[must_use] +pub fn builtin(name: &str) -> Option { + match name { + "human" => Some(Builtin::Human(human::Human::default())), + "json" => Some(Builtin::Json(json::Json::default())), + _ => None, + } +} +``` + +- [ ] **Step 3: Create `crates/hm/src/output/formatters/human.rs` with the moved render fn + a failing test** + +Copy the body of `crates/hm-plugin-output-human/src/render.rs` (the `render` fn and its `STEP_KEYS` static) into the new file. Wrap it in a `Human` struct that writes to stderr. The full file: + +```rust +//! Human-readable BuildEvent formatter — writes prefixed step logs and +//! brief status lines to stderr. Moved from the standalone +//! `hm-plugin-output-human` WASM crate into the `hm` binary so the +//! built-in formatter does not pay a WASM round-trip per event. + +use hm_plugin_protocol::BuildEvent; +use std::collections::HashMap; +use std::io::Write; +use uuid::Uuid; + +#[derive(Default)] +pub struct Human { + step_keys: HashMap, +} + +impl Human { + pub fn on_event(&mut self, ev: &BuildEvent) { + let bytes = self.render(ev); + if !bytes.is_empty() { + let _ = std::io::stderr().write_all(&bytes); + } + } + + pub fn finalize(&mut self) {} + + fn render(&mut self, ev: &BuildEvent) -> Vec { + match ev { + BuildEvent::BuildStart { plan, .. } => format!( + "build: {} steps in {} chain(s)\n", + plan.step_count, plan.chain_count + ) + .into_bytes(), + BuildEvent::StepQueued { step_id, key, .. } => { + self.step_keys.insert(*step_id, key.clone()); + Vec::new() + } + BuildEvent::StepStart { step_id, runner, image } => { + let key = self.key_for(*step_id); + let line = match image { + Some(img) => format!("[{key}] start (runner={runner} image={img})\n"), + None => format!("[{key}] start (runner={runner})\n"), + }; + line.into_bytes() + } + BuildEvent::StepLog { step_id, line, .. } => { + let key = self.key_for(*step_id); + format!("[{key}] {line}\n").into_bytes() + } + BuildEvent::StepCacheHit { step_id, tag, .. } => { + let key = self.key_for(*step_id); + format!("[{key}] cache hit ({tag})\n").into_bytes() + } + BuildEvent::StepEnd { step_id, exit_code, duration_ms, .. } => { + let key = self.key_for(*step_id); + format!("[{key}] end exit={exit_code} duration={duration_ms}ms\n").into_bytes() + } + BuildEvent::BuildEnd { exit_code, duration_ms } => format!( + "build: end exit={exit_code} duration={duration_ms}ms\n" + ) + .into_bytes(), + BuildEvent::ChainFailed { + chain_idx, failed_step_key, exit_code, message, .. + } => format!( + "chain {chain_idx}: FAILED at step '{failed_step_key}' (exit={exit_code}): {message}\n" + ) + .into_bytes(), + } + } + + fn key_for(&self, id: Uuid) -> String { + self.step_keys.get(&id).cloned().unwrap_or_else(|| "?".to_string()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use hm_plugin_protocol::{PlanSummary, StdStream}; + + #[test] + fn build_start_renders_step_and_chain_counts() { + let mut h = Human::default(); + let s = String::from_utf8(h.render(&BuildEvent::BuildStart { + run_id: Uuid::nil(), + plan: PlanSummary { + step_count: 3, + chain_count: 2, + default_runner: "docker".into(), + }, + started_at: chrono::Utc::now(), + })) + .unwrap(); + assert!(s.contains("3 steps")); + assert!(s.contains("2 chain")); + } + + #[test] + fn step_log_renders_with_prefix_after_step_queued_recorded_key() { + let mut h = Human::default(); + let step_id = Uuid::new_v4(); + h.render(&BuildEvent::StepQueued { + step_id, + key: "build".into(), + chain_idx: 0, + }); + let s = String::from_utf8(h.render(&BuildEvent::StepLog { + step_id, + stream: StdStream::Stdout, + line: "hello".into(), + ts: chrono::Utc::now(), + })) + .unwrap(); + assert_eq!(s, "[build] hello\n"); + } + + #[test] + fn step_log_with_unknown_key_renders_question_mark() { + let mut h = Human::default(); + let s = String::from_utf8(h.render(&BuildEvent::StepLog { + step_id: Uuid::new_v4(), + stream: StdStream::Stdout, + line: "x".into(), + ts: chrono::Utc::now(), + })) + .unwrap(); + assert!(s.starts_with("[?] ")); + } +} +``` + +- [ ] **Step 4: Run the human tests — they should pass immediately** + +Run: `cargo test -p harmont-cli output::formatters::human` + +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/hm/src/output/formatters/mod.rs crates/hm/src/output/formatters/human.rs crates/hm/src/output/mod.rs +git commit -m "feat(hm): inline human output formatter as native code" +``` + +--- + +### Task A2: Move the json render fn into the hm crate + +**Files:** +- Create: `crates/hm/src/output/formatters/json.rs` + +- [ ] **Step 1: Create `crates/hm/src/output/formatters/json.rs` with a failing test first** + +```rust +//! JSON-lines BuildEvent formatter — one event per line to stdout. +//! Moved from the standalone `hm-plugin-output-json` WASM crate. + +use hm_plugin_protocol::BuildEvent; +use std::io::Write; + +#[derive(Default)] +pub struct Json; + +impl Json { + pub fn on_event(&mut self, ev: &BuildEvent) { + let Ok(mut bytes) = serde_json::to_vec(ev) else { + return; + }; + bytes.push(b'\n'); + let _ = std::io::stdout().write_all(&bytes); + } + + pub fn finalize(&mut self) {} +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use hm_plugin_protocol::{PlanSummary}; + use uuid::Uuid; + + #[test] + fn build_start_serialises_to_json_line() { + let ev = BuildEvent::BuildStart { + run_id: Uuid::nil(), + plan: PlanSummary { + step_count: 1, + chain_count: 1, + default_runner: "docker".into(), + }, + started_at: chrono::Utc::now(), + }; + let bytes = serde_json::to_vec(&ev).unwrap(); + let s = std::str::from_utf8(&bytes).unwrap(); + assert!(s.contains(r#""BuildStart""#) || s.contains(r#""build_start""#)); + assert!(s.contains(r#""step_count":1"#)); + } +} +``` + +- [ ] **Step 2: Run the json test** + +Run: `cargo test -p harmont-cli output::formatters::json` + +Expected: 1 test passes. + +- [ ] **Step 3: Commit** + +```bash +git add crates/hm/src/output/formatters/json.rs +git commit -m "feat(hm): inline json output formatter as native code" +``` + +--- + +### Task A3: Wire the built-in dispatch into `output_subscriber` + +**Files:** +- Modify: `crates/hm/src/orchestrator/output_subscriber.rs` + +- [ ] **Step 1: Read the current `output_subscriber.rs` end-to-end** + +You need to see the full file before changing it because the WASM-dispatch loop has subtle drop-the-lock-before-await behaviour. Open `crates/hm/src/orchestrator/output_subscriber.rs` and read all 107 lines. + +- [ ] **Step 2: Replace the loop body so built-in formatters short-circuit the plugin lookup** + +Replace the `tokio::spawn(async move { loop { ... } })` block with the version below. Keep the surrounding `pub fn spawn(...)` signature and the surrounding doc-comments unchanged. + +```rust + let mut rx = bus.subscribe(); + let mut builtin = crate::output::formatters::builtin(&format_name); + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => { + let is_end = matches!(event, BuildEvent::BuildEnd { .. }); + if let Some(b) = builtin.as_mut() { + b.on_event(&event); + if is_end { + b.finalize(); + return Ok(()); + } + continue; + } + // Fall through: format_name is not a built-in; + // resolve from the plugin registry. + let plugin = { + let reg = registry.lock().await; + let Some(&idx) = reg.output_formatter_index.get(&format_name) else { + if is_end { return Ok(()); } + continue; + }; + let Some(p) = reg.get(idx) else { + if is_end { return Ok(()); } + continue; + }; + p + }; + let _: Result<()> = + plugin.call_capability("hm_output_on_event", &event).await; + if is_end { + let _: Result> = + plugin.call_capability("hm_output_finalize", &()).await; + return Ok(()); + } + } + Err(RecvError::Closed) => return Ok(()), + Err(RecvError::Lagged(n)) => { + tracing::warn!( + target: "orchestrator", + "output_subscriber: dropped {n} build events (subscriber fell behind)" + ); + eprintln!("[output] dropped {n} build events (subscriber fell behind)"); + } + } + } + }) +``` + +- [ ] **Step 3: Build to confirm types line up** + +Run: `cargo build -p harmont-cli` + +Expected: clean build. If `crate::output::formatters` is not visible, double-check that `crates/hm/src/lib.rs` (or wherever `output` is declared) re-exports it correctly. + +- [ ] **Step 4: Run the orchestrator integration tests** + +Run: `cargo test -p harmont-cli orchestrator` + +Expected: pass. Any test that mocked the WASM output plugin needs to be checked — if a test asserted on plugin-pool acquire / release for the human or json formatter, that test is now stale. + +- [ ] **Step 5: Commit** + +```bash +git add crates/hm/src/orchestrator/output_subscriber.rs +git commit -m "feat(hm): dispatch built-in formatters before plugin lookup" +``` + +--- + +### Task A4: Update the format-validation block in `scheduler.rs` + +**Files:** +- Modify: `crates/hm/src/orchestrator/scheduler.rs:138-153` + +- [ ] **Step 1: Read the current validation block** + +Open `crates/hm/src/orchestrator/scheduler.rs` and look at lines 133–153. Today it bails when `format_name` is missing from `reg.output_formatter_index`. After Task A3, built-in names like `human` / `json` will not be in that registry index — the bail needs to skip them. + +- [ ] **Step 2: Replace the validation block** + +Replace the block from `let bad_format: Option> = { ... };` through the `if let Some(available) = bad_format { ... }` with: + +```rust + let bad_format: Option> = { + if crate::output::formatters::builtin(&format_name).is_some() { + None + } else { + let reg = registry.lock().await; + if reg.output_formatter_index.contains_key(&format_name) { + None + } else { + let mut names: Vec = + reg.output_formatter_index.keys().cloned().collect(); + names.push("human".to_string()); + names.push("json".to_string()); + names.sort(); + names.dedup(); + Some(names) + } + } + }; + if let Some(available) = bad_format { + anyhow::bail!( + "unknown --format '{format_name}'; available: {}", + available.join(", ") + ); + } +``` + +- [ ] **Step 3: Build and run the format-validation tests** + +Run: `cargo test -p harmont-cli -- --include-ignored unknown_format` + +Expected: existing tests still pass; the `unknown_format` error path lists the built-ins. + +- [ ] **Step 4: Commit** + +```bash +git add crates/hm/src/orchestrator/scheduler.rs +git commit -m "feat(hm): accept built-in formatters in format validation" +``` + +--- + +### Task A5: Drop the embedded WASM bytes for the two output plugins + +**Files:** +- Modify: `crates/hm/src/plugin/embedded.rs` +- Modify: `crates/hm/src/orchestrator/scheduler.rs` (the `embedded:` vec around line 113) +- Modify: `crates/hm/src/dispatcher.rs` (lines around 82 / 86) +- Modify: `crates/hm/build.rs:72-73` + +- [ ] **Step 1: Delete the two constants from `embedded.rs`** + +Open `crates/hm/src/plugin/embedded.rs`. Delete the `OUTPUT_HUMAN_PLUGIN_WASM` and `OUTPUT_JSON_PLUGIN_WASM` constants and their doc comments (lines 8–16 in today's file). Keep `DOCKER_PLUGIN_WASM` and `CLOUD_PLUGIN_WASM`. + +- [ ] **Step 2: Delete the two entries from the `embedded:` vec in `scheduler.rs`** + +In `crates/hm/src/orchestrator/scheduler.rs` around lines 114–127, remove the two `("harmont-output-human", ...)` and `("harmont-output-json", ...)` tuples. The vec keeps only the `("harmont-docker", ...)` tuple. + +- [ ] **Step 3: Delete the two references in `dispatcher.rs`** + +In `crates/hm/src/dispatcher.rs` around lines 82 / 86, delete any code that references `OUTPUT_HUMAN_PLUGIN_WASM` or `OUTPUT_JSON_PLUGIN_WASM`. If those lines were inside a vec literal mirroring the scheduler's, delete them the same way. + +- [ ] **Step 4: Delete the two `build_wasm_plugin` calls in `build.rs`** + +In `crates/hm/build.rs` around line 72, delete: + +```rust + build_wasm_plugin("hm-plugin-output-human"); + build_wasm_plugin("hm-plugin-output-json"); +``` + +- [ ] **Step 5: Build to confirm nothing else references the deleted constants** + +Run: `cargo build -p harmont-cli` + +Expected: clean build. Any unresolved reference here is a sign you missed a file — search for `OUTPUT_HUMAN_PLUGIN_WASM` and `OUTPUT_JSON_PLUGIN_WASM` and clean up. + +- [ ] **Step 6: Commit** + +```bash +git add crates/hm/src/plugin/embedded.rs crates/hm/src/orchestrator/scheduler.rs crates/hm/src/dispatcher.rs crates/hm/build.rs +git commit -m "chore(hm): drop embedded output-formatter wasms; built-ins are native" +``` + +--- + +### Task A6: Delete the two WASM crates from the workspace + +**Files:** +- Delete: `crates/hm-plugin-output-human/` (entire directory) +- Delete: `crates/hm-plugin-output-json/` (entire directory) +- Modify: `Cargo.toml` (workspace `members`) + +- [ ] **Step 1: Remove the two members from the workspace `Cargo.toml`** + +Open the workspace `Cargo.toml` at the repo root (`/home/marko/harmont-cli/Cargo.toml`). Find the `[workspace] members = [...]` block. Delete the two lines: + +```toml + "crates/hm-plugin-output-human", + "crates/hm-plugin-output-json", +``` + +- [ ] **Step 2: Delete the two crate directories** + +```bash +rm -rf crates/hm-plugin-output-human crates/hm-plugin-output-json +``` + +- [ ] **Step 3: Build + test the workspace** + +Run: `cargo build && cargo test -p harmont-cli` + +Expected: clean build, all hm tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add Cargo.toml crates/hm-plugin-output-human crates/hm-plugin-output-json +git commit -m "chore(hm): delete output-formatter wasm crates" +``` + +--- + +### Task A7: End-to-end smoke test of both formatters + +- [ ] **Step 1: Run a real `hm run` with `--no-tui --format human`** + +```bash +cd ~/simci && RUST_LOG=error ../harmont-cli/target/debug/hm run --no-tui --format human 2>&1 | head -10 +``` + +Expected: same `build: N steps in M chain(s)`, `[key] start (...)`, `[key] end exit=0 ...` lines as before. + +- [ ] **Step 2: Run with `--no-tui --format json`** + +```bash +cd ~/simci && RUST_LOG=error ../harmont-cli/target/debug/hm run --no-tui --format json 2>&1 | head -5 +``` + +Expected: one JSON object per line on stdout, each line a serialised `BuildEvent`. + +- [ ] **Step 3: Run with default (TUI) and confirm no scrolling-log regression** + +```bash +cd ~/simci && ../harmont-cli/target/debug/hm run +``` + +Expected: TUI renders cleanly; no log lines escape the log pane (Phase A doesn't fix that bug — it was fixed in a prior commit — but confirm it's still fixed). + +--- + +## Phase B — Ratatui Built-in Widgets + +Every widget below today contains a `for ch in line.chars() { buf.cell_mut(...).set_symbol(...) }` loop or equivalent. Ratatui provides `Paragraph`, `List`, `Line`, and `Span` for this exact job. The refactor replaces hand-rolled rendering with these types and deletes the inner loops. + +Each widget already has a snapshot test in its file (uses `crate::tui::widgets::buffer_to_string`). The snapshot guards the visual output — if the rewrite changes the rendered glyphs or styling, `cargo insta review` will surface the diff. + +### Task B1: Rewrite `log.rs` to use `Paragraph` + `List` + +**Files:** +- Modify: `crates/hm/src/tui/widgets/log.rs:27-86` + +- [ ] **Step 1: Run the existing snapshot test to capture the current output as the baseline** + +Run: `cargo test -p harmont-cli tui::widgets::log::tests` + +Expected: PASS (the snapshot is already accepted). + +- [ ] **Step 2: Replace the `render` body** + +Open `crates/hm/src/tui/widgets/log.rs`. Replace the whole `impl Widget for LogPane<'_>` block with: + +```rust +impl Widget for LogPane<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + use ratatui::style::Style; + use ratatui::text::{Line, Span}; + use ratatui::widgets::Paragraph; + + let chain_label = self + .state + .chains + .get(self.state.focused_chain) + .map(|c| c.label.clone()) + .unwrap_or_default(); + let title = format!(" log · {chain_label} "); + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(self.theme.border(true)); + let inner = block.inner(area); + block.render(area, buf); + + let Some(step_id) = self.state.focused_step_id() else { return }; + let Some(log) = self.state.logs.get(&step_id) else { return }; + + let entries: Vec<_> = log + .entries + .iter() + .filter(|e| self.filter.map_or(true, |f| e.line.contains(f))) + .collect(); + + let height = inner.height as usize; + let start = entries.len().saturating_sub(height + self.scroll); + let visible: Vec = entries + .iter() + .skip(start) + .take(height) + .map(|entry| { + let prefix = match entry.stream { + hm_plugin_protocol::StdStream::Stdout => " ", + hm_plugin_protocol::StdStream::Stderr => "! ", + }; + let style = if entry.stream == hm_plugin_protocol::StdStream::Stderr { + Style::default().fg(self.theme.text_dim) + } else { + Style::default() + }; + Line::from(vec![ + Span::styled(prefix.to_string(), style), + Span::styled(entry.line.clone(), style), + ]) + }) + .collect(); + + Paragraph::new(visible).render(inner, buf); + + if log.dropped > 0 { + let drop_msg = format!(" … {} events dropped (lagged) …", log.dropped); + let style = Style::default().fg(self.theme.text_dim); + let line = Line::styled(drop_msg, style); + let drop_area = Rect::new(inner.x, inner.y, inner.width, 1); + Paragraph::new(line).render(drop_area, buf); + } + } +} +``` + +- [ ] **Step 3: Re-run the snapshot test and inspect any diff** + +Run: `cargo test -p harmont-cli tui::widgets::log::tests` + +Expected: PASS. If `insta` reports a diff, run `cargo insta review` and visually confirm the new output renders the same characters in the same positions; only then accept. + +- [ ] **Step 4: Commit** + +```bash +git add crates/hm/src/tui/widgets/log.rs +git commit -m "refactor(tui): render log pane via ratatui Paragraph" +``` + +--- + +### Task B2: Rewrite `graph.rs` to use `Line` + `Paragraph` per row + +**Files:** +- Modify: `crates/hm/src/tui/widgets/graph.rs:32-65` + +- [ ] **Step 1: Run the existing snapshot test** + +Run: `cargo test -p harmont-cli tui::widgets::graph::tests` + +Expected: PASS. + +- [ ] **Step 2: Replace the `render` body** + +Open `crates/hm/src/tui/widgets/graph.rs`. Replace the `impl Widget for Graph<'_>` block with: + +```rust +impl Widget for Graph<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + use ratatui::style::Style; + use ratatui::text::{Line, Span}; + use ratatui::widgets::Paragraph; + + let block = Block::default() + .borders(Borders::ALL) + .title(" graph ") + .border_style(self.theme.border(false)); + let inner = block.inner(area); + block.render(area, buf); + + let max_rows = inner.height as usize; + let rows: Vec = self + .state + .chains + .iter() + .take(max_rows) + .map(|chain| { + let mut spans: Vec = Vec::new(); + let mut first = true; + for sid in &chain.steps { + let Some(step) = self.state.steps.get(sid) else { continue }; + if !first { + spans.push(Span::raw("─")); + } + spans.push(Span::styled( + glyph(&step.status).to_string(), + self.theme.status(step.status.clone()), + )); + first = false; + } + if spans.is_empty() { + Line::from(Span::styled(String::new(), Style::default())) + } else { + Line::from(spans) + } + }) + .collect(); + + Paragraph::new(rows).render(inner, buf); + } +} +``` + +- [ ] **Step 3: Re-run the snapshot, review any diff** + +Run: `cargo test -p harmont-cli tui::widgets::graph::tests` + +Expected: PASS, or accept the snapshot only after `cargo insta review` shows identical visible output. + +- [ ] **Step 4: Commit** + +```bash +git add crates/hm/src/tui/widgets/graph.rs +git commit -m "refactor(tui): render graph via ratatui Paragraph" +``` + +--- + +### Task B3: Rewrite `footer.rs` to use `Paragraph` + +**Files:** +- Modify: `crates/hm/src/tui/widgets/footer.rs` + +- [ ] **Step 1: Read `footer.rs` end-to-end** + +Open `crates/hm/src/tui/widgets/footer.rs`. Identify the cell loop that draws the key-hint string and the styling currently applied (likely `theme.text_dim` for the hints). + +- [ ] **Step 2: Run the snapshot test** + +Run: `cargo test -p harmont-cli tui::widgets::footer::tests` + +Expected: PASS. + +- [ ] **Step 3: Replace the cell loop with a `Paragraph`** + +In the `impl Widget for Footer<'_> { fn render(...) { ... } }` block, replace the cell-rendering loop with: + +```rust +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; + +let line = Line::styled(hint_text, Style::default().fg(self.theme.text_dim)); +Paragraph::new(line).render(area, buf); +``` + +Where `hint_text` is the same `String` the loop was iterating over. If the footer composes multiple coloured segments, build a `Vec` and pass `Line::from(spans)` into `Paragraph::new`. + +- [ ] **Step 4: Re-run the snapshot, review any diff** + +Run: `cargo test -p harmont-cli tui::widgets::footer::tests` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/hm/src/tui/widgets/footer.rs +git commit -m "refactor(tui): render footer via ratatui Paragraph" +``` + +--- + +### Task B4: Rewrite `filter.rs` to use `Paragraph` + +**Files:** +- Modify: `crates/hm/src/tui/widgets/filter.rs` + +- [ ] **Step 1: Read `filter.rs` end-to-end and note any tests** + +Open `crates/hm/src/tui/widgets/filter.rs`. The widget renders a one-line `/` prompt at the bottom of the log pane. + +- [ ] **Step 2: Replace the cell loop with a `Paragraph`** + +```rust +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +let line = Line::from(vec![ + Span::styled("/", Style::default().fg(self.theme.accent_a)), + Span::raw(self.query.to_string()), +]); +Paragraph::new(line).render(area, buf); +``` + +- [ ] **Step 3: Run the snapshot tests (if any) and the binary** + +Run: `cargo test -p harmont-cli tui::widgets::filter` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add crates/hm/src/tui/widgets/filter.rs +git commit -m "refactor(tui): render filter input via ratatui Paragraph" +``` + +--- + +### Task B5: Rewrite `summary.rs` to use `Paragraph` inside the block + +**Files:** +- Modify: `crates/hm/src/tui/widgets/summary.rs` + +- [ ] **Step 1: Read the file end-to-end** + +`summary.rs` renders the end-of-build card with totals and durations. Identify the lines composed (one Line per summary row) and their styles. + +- [ ] **Step 2: Run the snapshot test** + +Run: `cargo test -p harmont-cli tui::widgets::summary` + +Expected: PASS. + +- [ ] **Step 3: Replace the cell rendering with a `Vec` + `Paragraph`** + +Compose each summary row as a `Line::from(vec![Span::styled(label, label_style), Span::raw(value)])` and render the whole `Vec` via: + +```rust +use ratatui::widgets::Paragraph; + +let block = Block::default() + .borders(Borders::ALL) + .title(" summary ") + .border_style(self.theme.border(false)); +let inner = block.inner(area); +block.render(area, buf); +Paragraph::new(lines).render(inner, buf); +``` + +Where `lines: Vec` is built from the same fields the previous code consulted (chain count, step count, pass/fail/cache totals, total duration). Preserve exact label text and ordering — the snapshot test will catch any deviation. + +- [ ] **Step 4: Run the snapshot test, review any diff** + +Run: `cargo test -p harmont-cli tui::widgets::summary` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/hm/src/tui/widgets/summary.rs +git commit -m "refactor(tui): render summary card via ratatui Paragraph" +``` + +--- + +### Task B6: Rewrite `help.rs` to use `Paragraph` + +**Files:** +- Modify: `crates/hm/src/tui/widgets/help.rs` + +- [ ] **Step 1: Read the file end-to-end** + +`help.rs` renders the `?` keyboard-shortcuts overlay. Identify the rows (key + description) and styling. + +- [ ] **Step 2: Replace the cell loops with `Paragraph`** + +```rust +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +let lines: Vec = HELP_ROWS + .iter() + .map(|(key, desc)| { + Line::from(vec![ + Span::styled( + format!(" {key:<8} "), + Style::default().fg(self.theme.accent_a).add_modifier(Modifier::BOLD), + ), + Span::raw(desc.to_string()), + ]) + }) + .collect(); + +let block = Block::default() + .borders(Borders::ALL) + .title(" help ") + .border_style(self.theme.border(true)); +let inner = block.inner(area); +block.render(area, buf); +Paragraph::new(lines).render(inner, buf); +``` + +Where `HELP_ROWS` is the existing `&[(&str, &str)]` slice the previous code iterated over. Keep the same key/desc pairs and ordering. + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p harmont-cli tui::widgets::help` + +Expected: PASS (or accept identical snapshot). + +- [ ] **Step 4: Commit** + +```bash +git add crates/hm/src/tui/widgets/help.rs +git commit -m "refactor(tui): render help overlay via ratatui Paragraph" +``` + +--- + +### Task B7: Rewrite `timeline.rs` to use `Paragraph` per row + +**Files:** +- Modify: `crates/hm/src/tui/widgets/timeline.rs` + +- [ ] **Step 1: Read the file end-to-end and note the row composition** + +`timeline.rs` renders one row per running/completed step with a label and a relative duration bar. Identify the per-row composition (label + bar glyphs). + +- [ ] **Step 2: Run the snapshot test** + +Run: `cargo test -p harmont-cli tui::widgets::timeline` + +Expected: PASS. + +- [ ] **Step 3: Replace the cell loops with `Line` + `Paragraph`** + +Build `Vec` where each `Line` is composed of a label `Span` and a bar `Span` (the bar is the same `█`-or-similar glyph the previous code wrote into cells, just placed in a `Span::styled(bar_string, style)`). Render with: + +```rust +use ratatui::widgets::Paragraph; + +let block = Block::default() + .borders(Borders::ALL) + .title(" timeline ") + .border_style(self.theme.border(false)); +let inner = block.inner(area); +block.render(area, buf); +Paragraph::new(rows).render(inner, buf); +``` + +- [ ] **Step 4: Re-run the snapshot, review any diff** + +Run: `cargo test -p harmont-cli tui::widgets::timeline` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/hm/src/tui/widgets/timeline.rs +git commit -m "refactor(tui): render timeline via ratatui Paragraph" +``` + +--- + +### Task B8: Final pass — confirm no `cell_mut` remains in widgets + +- [ ] **Step 1: Grep for any leftover `cell_mut` or `set_symbol` in the widget files** + +Run: `grep -rn "cell_mut\|set_symbol" crates/hm/src/tui/widgets/` + +Expected: zero hits. If anything remains, that widget still has hand-rolled rendering — repeat the pattern from the earlier tasks. + +- [ ] **Step 2: Run the full TUI test suite** + +Run: `cargo test -p harmont-cli tui` + +Expected: PASS. + +- [ ] **Step 3: Smoke-test by hand** + +```bash +cd ~/simci && ../harmont-cli/target/debug/hm run +``` + +Expected: TUI renders identically to before the refactor. The log pane shows logs; the graph row shows step glyphs; the footer shows key hints; `?` opens help; `/` opens filter. + +- [ ] **Step 4: If everything looks good, commit any leftover snapshot changes** + +```bash +git add crates/hm/src/tui/widgets/snapshots/ +git commit -m "test(tui): accept ratatui-rendered widget snapshots" +``` + +(If `cargo insta review` already rolled these into per-task commits, this step is a no-op.) + +--- + +## Self-Review Notes + +- **Spec coverage:** Phase A merges the human + json output plugins into the CLI binary (the first half of the user's ask). Phase B converts each TUI widget from hand-rolled `cell_mut` rendering to ratatui's built-in widget types (the second half). No spec requirement is unaddressed. +- **External output plugins:** the `OutputFormatter` SDK trait, the `Capability::OutputFormatter` variant, the `hm_output_on_event` capability export, and the registry-based fallback in `output_subscriber` all stay intact. External plugins keep working — the change is internal: built-ins skip the WASM path. +- **Tests:** every modified widget already has a snapshot test (`buffer_to_string`-based). The plan asks the engineer to run each before and after; if `insta` flags a diff, the engineer reviews visually before accepting. The output formatter logic is preserved verbatim (same `render` body for human; same `serde_json::to_vec` + newline for json), so existing unit tests transfer with the file. +- **Commits:** one commit per task, every task ends with a `git add` + `git commit`. DRY: per-widget tasks share the same `Block` / `Paragraph` skeleton but the code is repeated in each task because an engineer may execute them out of order.