From 6487cb94fff5602672ea94d6a0d8b23c283e5049 Mon Sep 17 00:00:00 2001 From: xscriptor Date: Thu, 3 Sep 2026 14:08:54 +0200 Subject: [PATCH 1/4] update structure xtop now as a monocrate --- .gitignore | 3 + CONTRIBUTING.md | 4 +- Cargo.lock | 999 ------------------ Cargo.toml | 36 +- PKGBUILD | 2 +- README.md | 8 +- ROADMAP.md | 25 + crates/xtop-cli/Cargo.toml | 20 - crates/xtop-cli/src/main.rs | 742 ------------- crates/xtop-cli/src/mcp.rs | 77 -- crates/xtop-core/Cargo.toml | 11 - crates/xtop-core/src/application/mod.rs | 3 - crates/xtop-core/src/domain/metrics.rs | 166 --- crates/xtop-core/src/domain/mod.rs | 6 - crates/xtop-core/src/domain/plugin.rs | 220 ---- crates/xtop-core/src/domain/system_info.rs | 32 - crates/xtop-core/src/infrastructure/mod.rs | 5 - crates/xtop-core/src/lib.rs | 3 - crates/xtop-tui/Cargo.toml | 10 - crates/xtop-tui/src/lib.rs | 4 - docs/installation.md | 12 +- docs/multi-repo.md | 106 ++ docs/plugin.md | 26 +- install.ps1 | 2 +- install.sh | 2 +- plugins/xtop-plugin-sentinel/Cargo.toml | 14 - plugins/xtop-plugin-sentinel/README.md | 372 ------- plugins/xtop-plugin-sentinel/src/alert.rs | 45 - plugins/xtop-plugin-sentinel/src/lib.rs | 883 ---------------- plugins/xtop-plugin-sentinel/src/mcp.rs | 331 ------ scripts/ci.sh | 66 ++ src/commands/mcp.rs | 42 + src/commands/mod.rs | 10 + src/commands/plugins.rs | 368 +++++++ src/commands/run.rs | 181 ++++ src/commands/share/assets.rs | 121 +++ src/commands/share/bootstrap.rs | 63 ++ src/commands/share/mod.rs | 7 + .../config.rs => src/config/io.rs | 17 +- .../src/domain => src/config}/keybinding.rs | 3 - src/config/mod.rs | 11 + src/config/platform/linux.rs | 16 + src/config/platform/macos.rs | 13 + src/config/platform/mod.rs | 24 + src/config/platform/other.rs | 12 + src/config/platform/shared/mod.rs | 10 + src/config/platform/windows.rs | 9 + src/config/schema.rs | 59 ++ .../layout_loader.rs => src/layout/loader.rs | 2 +- src/layout/mod.rs | 10 + src/layout/mode.rs | 98 ++ .../domain/layout.rs => src/layout/model.rs | 0 src/main.rs | 53 + src/plugins/extension_host.rs | 38 + src/plugins/host.rs | 54 + .../plugins/manager.rs | 33 +- src/plugins/mod.rs | 11 + .../providers/composite.rs | 4 +- src/providers/mod.rs | 10 + src/providers/sysinfo/mod.rs | 12 + src/providers/sysinfo/platform/fallback.rs | 31 + .../sysinfo/platform/linux/battery.rs | 85 ++ .../sysinfo/platform/linux/governor.rs | 12 + src/providers/sysinfo/platform/linux/gpu.rs | 63 ++ .../sysinfo/platform/linux/interfaces.rs | 38 + src/providers/sysinfo/platform/linux/mod.rs | 18 + .../sysinfo/platform/linux/mounts.rs | 22 + .../sysinfo/platform/linux/threads.rs | 16 + src/providers/sysinfo/platform/macos.rs | 32 + src/providers/sysinfo/platform/mod.rs | 33 + src/providers/sysinfo/platform/shared/gpu.rs | 37 + src/providers/sysinfo/platform/shared/mod.rs | 5 + src/providers/sysinfo/platform/windows.rs | 33 + .../providers/sysinfo/provider.rs | 299 +----- .../application/state.rs => src/state/app.rs | 285 +---- .../src/application => src/state}/history.rs | 1 + src/state/mod.rs | 12 + src/state/view.rs | 118 +++ .../theme_loader.rs => src/theme/loader.rs | 7 +- src/theme/mod.rs | 7 + .../src/domain/theme.rs => src/theme/model.rs | 12 +- .../ui/layout/engine.rs | 25 +- src/ui/layout/mod.rs | 6 + src/ui/mod.rs | 16 + .../src/render/mod.rs => src/ui/screen.rs | 31 +- .../xtop-tui/src => src/ui/share}/color.rs | 0 .../xtop-tui/src => src/ui/share}/format.rs | 0 src/ui/share/mod.rs | 10 + {crates/xtop-tui/src => src/ui}/terminal.rs | 0 .../ui/widgets/battery/mod.rs | 6 +- .../cpu.rs => src/ui/widgets/cpu/mod.rs | 6 +- .../ui/widgets/disk_io/mod.rs | 8 +- .../gpu.rs => src/ui/widgets/gpu/mod.rs | 8 +- .../header.rs => src/ui/widgets/header/mod.rs | 8 +- .../help.rs => src/ui/widgets/help/mod.rs | 8 +- .../memory.rs => src/ui/widgets/memory/mod.rs | 12 +- src/ui/widgets/mod.rs | 16 + .../ui/widgets/network/mod.rs | 10 +- .../ui/widgets/palette/mod.rs | 6 +- .../ui/widgets/processes/mod.rs | 18 +- .../ui/widgets/storage/mod.rs | 8 +- 101 files changed, 2238 insertions(+), 4656 deletions(-) delete mode 100644 Cargo.lock delete mode 100644 crates/xtop-cli/Cargo.toml delete mode 100644 crates/xtop-cli/src/main.rs delete mode 100644 crates/xtop-cli/src/mcp.rs delete mode 100644 crates/xtop-core/Cargo.toml delete mode 100644 crates/xtop-core/src/application/mod.rs delete mode 100644 crates/xtop-core/src/domain/metrics.rs delete mode 100644 crates/xtop-core/src/domain/mod.rs delete mode 100644 crates/xtop-core/src/domain/plugin.rs delete mode 100644 crates/xtop-core/src/domain/system_info.rs delete mode 100644 crates/xtop-core/src/infrastructure/mod.rs delete mode 100644 crates/xtop-core/src/lib.rs delete mode 100644 crates/xtop-tui/Cargo.toml delete mode 100644 crates/xtop-tui/src/lib.rs create mode 100644 docs/multi-repo.md delete mode 100644 plugins/xtop-plugin-sentinel/Cargo.toml delete mode 100644 plugins/xtop-plugin-sentinel/README.md delete mode 100644 plugins/xtop-plugin-sentinel/src/alert.rs delete mode 100644 plugins/xtop-plugin-sentinel/src/lib.rs delete mode 100644 plugins/xtop-plugin-sentinel/src/mcp.rs create mode 100755 scripts/ci.sh create mode 100644 src/commands/mcp.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/plugins.rs create mode 100644 src/commands/run.rs create mode 100644 src/commands/share/assets.rs create mode 100644 src/commands/share/bootstrap.rs create mode 100644 src/commands/share/mod.rs rename crates/xtop-core/src/infrastructure/config.rs => src/config/io.rs (66%) rename {crates/xtop-core/src/domain => src/config}/keybinding.rs (99%) create mode 100644 src/config/mod.rs create mode 100644 src/config/platform/linux.rs create mode 100644 src/config/platform/macos.rs create mode 100644 src/config/platform/mod.rs create mode 100644 src/config/platform/other.rs create mode 100644 src/config/platform/shared/mod.rs create mode 100644 src/config/platform/windows.rs create mode 100644 src/config/schema.rs rename crates/xtop-core/src/infrastructure/layout_loader.rs => src/layout/loader.rs (98%) create mode 100644 src/layout/mod.rs create mode 100644 src/layout/mode.rs rename crates/xtop-core/src/domain/layout.rs => src/layout/model.rs (100%) create mode 100644 src/main.rs create mode 100644 src/plugins/extension_host.rs create mode 100644 src/plugins/host.rs rename crates/xtop-core/src/application/plugin_manager.rs => src/plugins/manager.rs (87%) create mode 100644 src/plugins/mod.rs rename crates/xtop-core/src/infrastructure/composite_provider.rs => src/providers/composite.rs (97%) create mode 100644 src/providers/mod.rs create mode 100644 src/providers/sysinfo/mod.rs create mode 100644 src/providers/sysinfo/platform/fallback.rs create mode 100644 src/providers/sysinfo/platform/linux/battery.rs create mode 100644 src/providers/sysinfo/platform/linux/governor.rs create mode 100644 src/providers/sysinfo/platform/linux/gpu.rs create mode 100644 src/providers/sysinfo/platform/linux/interfaces.rs create mode 100644 src/providers/sysinfo/platform/linux/mod.rs create mode 100644 src/providers/sysinfo/platform/linux/mounts.rs create mode 100644 src/providers/sysinfo/platform/linux/threads.rs create mode 100644 src/providers/sysinfo/platform/macos.rs create mode 100644 src/providers/sysinfo/platform/mod.rs create mode 100644 src/providers/sysinfo/platform/shared/gpu.rs create mode 100644 src/providers/sysinfo/platform/shared/mod.rs create mode 100644 src/providers/sysinfo/platform/windows.rs rename crates/xtop-core/src/infrastructure/sysinfo_provider.rs => src/providers/sysinfo/provider.rs (52%) rename crates/xtop-core/src/application/state.rs => src/state/app.rs (72%) rename {crates/xtop-core/src/application => src/state}/history.rs (99%) create mode 100644 src/state/mod.rs create mode 100644 src/state/view.rs rename crates/xtop-core/src/infrastructure/theme_loader.rs => src/theme/loader.rs (96%) create mode 100644 src/theme/mod.rs rename crates/xtop-core/src/domain/theme.rs => src/theme/model.rs (86%) rename crates/xtop-tui/src/render/layout_engine.rs => src/ui/layout/engine.rs (76%) create mode 100644 src/ui/layout/mod.rs create mode 100644 src/ui/mod.rs rename crates/xtop-tui/src/render/mod.rs => src/ui/screen.rs (90%) rename {crates/xtop-tui/src => src/ui/share}/color.rs (100%) rename {crates/xtop-tui/src => src/ui/share}/format.rs (100%) create mode 100644 src/ui/share/mod.rs rename {crates/xtop-tui/src => src/ui}/terminal.rs (100%) rename crates/xtop-tui/src/render/battery.rs => src/ui/widgets/battery/mod.rs (94%) rename crates/xtop-tui/src/render/cpu.rs => src/ui/widgets/cpu/mod.rs (97%) rename crates/xtop-tui/src/render/disk_io.rs => src/ui/widgets/disk_io/mod.rs (95%) rename crates/xtop-tui/src/render/gpu.rs => src/ui/widgets/gpu/mod.rs (92%) rename crates/xtop-tui/src/render/header.rs => src/ui/widgets/header/mod.rs (91%) rename crates/xtop-tui/src/render/help.rs => src/ui/widgets/help/mod.rs (92%) rename crates/xtop-tui/src/render/memory.rs => src/ui/widgets/memory/mod.rs (93%) create mode 100644 src/ui/widgets/mod.rs rename crates/xtop-tui/src/render/network.rs => src/ui/widgets/network/mod.rs (96%) rename crates/xtop-tui/src/render/palette.rs => src/ui/widgets/palette/mod.rs (95%) rename crates/xtop-tui/src/render/processes.rs => src/ui/widgets/processes/mod.rs (88%) rename crates/xtop-tui/src/render/storage.rs => src/ui/widgets/storage/mod.rs (91%) diff --git a/.gitignore b/.gitignore index ea8c4bf..dcb04d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /target + +# Local development overrides (see .cargo/config.toml inside) +/.cargo diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9609f1..54aeb23 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ First off, thank you for considering contributing to `xtop`! It's people like yo ## How Can I Contribute? ### Reporting Bugs -- Ensure the bug was not already reported by searching on GitHub under [Issues](https://github.com/xscriptor/xtop/issues). +- Ensure the bug was not already reported by searching on GitHub under [Issues](https://github.com/xtop-cli/xtop/issues). - If you're unable to find an open issue addressing the problem, open a new one. Be sure to include a title and clear description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behavior that is not occurring. ### Suggesting Enhancements @@ -23,7 +23,7 @@ First off, thank you for considering contributing to `xtop`! It's people like yo ```bash # Clone the repository -git clone https://github.com/xscriptor/xtop.git +git clone https://github.com/xtop-cli/xtop.git cd xtop # Build the project diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index e6b6971..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,999 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "bitflags" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" - -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "compact_str" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "objc2", -] - -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", -] - -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" - -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - -[[package]] -name = "memchr" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" - -[[package]] -name = "mio" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "ntapi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" -dependencies = [ - "winapi", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "objc2", -] - -[[package]] -name = "objc2-io-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" -dependencies = [ - "libc", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-open-directory" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" -dependencies = [ - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[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", - "smallvec", - "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "ratatui" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" -dependencies = [ - "bitflags", - "cassowary", - "compact_str", - "crossterm", - "indoc", - "instability", - "itertools", - "lru", - "paste", - "strum", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[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.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sysinfo" -version = "0.39.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "objc2-open-directory", - "windows", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width 0.1.14", -] - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "xtop" -version = "0.2.0" -dependencies = [ - "anyhow", - "crossterm", - "serde_json", - "toml", - "xtop-core", - "xtop-plugin-sentinel", - "xtop-tui", -] - -[[package]] -name = "xtop-core" -version = "0.2.0" -dependencies = [ - "ratatui", - "serde", - "serde_json", - "sysinfo", -] - -[[package]] -name = "xtop-plugin-sentinel" -version = "0.2.0" -dependencies = [ - "anyhow", - "ratatui", - "regex", - "serde", - "serde_json", - "xtop-core", -] - -[[package]] -name = "xtop-tui" -version = "0.2.0" -dependencies = [ - "crossterm", - "ratatui", - "xtop-core", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 11409cc..bf1d48c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,33 @@ -[workspace] -resolver = "2" -members = [ - "crates/xtop-core", - "crates/xtop-tui", - "crates/xtop-cli", - "plugins/xtop-plugin-sentinel", -] - -[workspace.package] +[package] +name = "xtop" version = "0.2.0" edition = "2021" license = "MIT" +description = "A modern, cross-platform TUI system monitor written in Rust" +repository = "https://github.com/xtop-cli/xtop" -[workspace.dependencies] -ratatui = "0.29" +[[bin]] +name = "xtop" +path = "src/main.rs" + +[dependencies] crossterm = "0.28" +ratatui = "0.29" sysinfo = "0.39" anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" + +# xtop-cli/api, xtop-cli/plugins y xtop-cli/extensions (repos hermanos). +# Distribution: git dependencies on the published repos, so a clean clone +# builds without needing their sources checked out. +xtop-plugin-api = { git = "https://github.com/xtop-cli/api" } +xtop-extension-api = { git = "https://github.com/xtop-cli/api" } +xtop-extension-mcp = { git = "https://github.com/xtop-cli/extensions", optional = true } +xtop-plugin-samurai = { git = "https://github.com/xtop-cli/plugins", optional = true } + +[features] +default = ["plugin-samurai", "mcp-extension"] +plugin-samurai = ["dep:xtop-plugin-samurai"] +mcp-extension = ["dep:xtop-extension-mcp"] diff --git a/PKGBUILD b/PKGBUILD index 62126ae..853ad06 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -5,7 +5,7 @@ pkgver=r7.37fae84 pkgrel=1 pkgdesc="A btop-like system monitor written in Rust for X" arch=('x86_64') -url="https://github.com/xscriptor/xtop" +url="https://github.com/xtop-cli/xtop" license=('MIT') depends=('gcc-libs') makedepends=('git' 'cargo') diff --git a/README.md b/README.md index fe2f2b3..0c07f31 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![Rust](https://img.shields.io/badge/Rust-1.80%2B-orange) ![License](https://img.shields.io/badge/license-MIT-blue) -![CI](https://img.shields.io/github/actions/workflow/status/xscriptor/xtop/ci.yml?branch=main) +![CI](https://img.shields.io/github/actions/workflow/status/xtop-cli/xtop/ci.yml?branch=main) ![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey) ![ratatui](https://img.shields.io/badge/built%20with-ratatui-red) @@ -69,15 +69,15 @@ A cross-platform TUI system monitor written in Rust. Uses PathBuf { - xtop_core::infrastructure::config::config_dir() -} - -fn key_event_to_str(key: &KeyEvent) -> String { - let mut s = String::new(); - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - if ctrl { - s.push_str("ctrl+"); - } - if key.modifiers.contains(KeyModifiers::ALT) { - s.push_str("alt+"); - } - match key.code { - KeyCode::Char(c) => { - if ctrl { - s.push(c.to_ascii_lowercase()); - } else { - s.push(c); - } - } - KeyCode::Esc => s.push_str("escape"), - KeyCode::Enter => s.push_str("enter"), - KeyCode::Backspace => s.push_str("backspace"), - KeyCode::Tab => s.push_str("tab"), - KeyCode::Up => s.push_str("up"), - KeyCode::Down => s.push_str("down"), - KeyCode::Left => s.push_str("left"), - KeyCode::Right => s.push_str("right"), - KeyCode::Delete => s.push_str("delete"), - KeyCode::Home => s.push_str("home"), - KeyCode::End => s.push_str("end"), - KeyCode::PageUp => s.push_str("pageup"), - KeyCode::PageDown => s.push_str("pagedown"), - _ => return String::new(), - } - s -} - -// Embedded default asset files (shipped with the binary) -const DEFAULT_THEMES: &[(&str, &str)] = &[ - ("x", include_str!("../../../assets/themes/x.jsonc")), - ( - "madrid", - include_str!("../../../assets/themes/madrid.jsonc"), - ), - ( - "lahabana", - include_str!("../../../assets/themes/lahabana.jsonc"), - ), - ("paris", include_str!("../../../assets/themes/paris.jsonc")), - ("tokio", include_str!("../../../assets/themes/tokio.jsonc")), - ("oslo", include_str!("../../../assets/themes/oslo.jsonc")), - ( - "helsinki", - include_str!("../../../assets/themes/helsinki.jsonc"), - ), - ( - "berlin", - include_str!("../../../assets/themes/berlin.jsonc"), - ), - ( - "london", - include_str!("../../../assets/themes/london.jsonc"), - ), - ("praha", include_str!("../../../assets/themes/praha.jsonc")), - ( - "bogota", - include_str!("../../../assets/themes/bogota.jsonc"), - ), -]; - -const DEFAULT_LAYOUTS: &[(&str, &str)] = &[ - ( - "dashboard", - include_str!("../../../assets/layouts/dashboard.jsonc"), - ), - ( - "vertical", - include_str!("../../../assets/layouts/vertical.jsonc"), - ), - ( - "horizontal", - include_str!("../../../assets/layouts/horizontal.jsonc"), - ), - ( - "cpu_focus", - include_str!("../../../assets/layouts/cpu_focus.jsonc"), - ), - ( - "memory_focus", - include_str!("../../../assets/layouts/memory_focus.jsonc"), - ), - ( - "network_focus", - include_str!("../../../assets/layouts/network_focus.jsonc"), - ), - ( - "process_focus", - include_str!("../../../assets/layouts/process_focus.jsonc"), - ), -]; - -fn ensure_default_assets() { - let theme_assets: &[(&str, &str)] = DEFAULT_THEMES; - let layout_assets: &[(&str, &str)] = DEFAULT_LAYOUTS; - - let dir = xtop_core::infrastructure::theme_loader::themes_dir(); - if !dir.join(".xtop_initialized").exists() { - fs::create_dir_all(&dir).ok(); - for (name, content) in theme_assets { - let path = dir.join(format!("{name}.jsonc")); - if !path.exists() { - fs::write(&path, content).ok(); - } - } - fs::write(dir.join(".xtop_initialized"), "").ok(); - } - - let dir = xtop_core::infrastructure::layout_loader::layouts_dir(); - if !dir.join(".xtop_initialized").exists() { - fs::create_dir_all(&dir).ok(); - for (name, content) in layout_assets { - let path = dir.join(format!("{name}.jsonc")); - if !path.exists() { - fs::write(&path, content).ok(); - } - } - fs::write(dir.join(".xtop_initialized"), "").ok(); - } -} - -fn save_config(state: &AppState) { - let layout_name = if state.layout_index < state.layout_defs.len() { - state.layout_defs[state.layout_index].name.clone() - } else { - String::new() - }; - let cfg = Config { - theme: state.current_theme.name.clone(), - layout_mode: state.save_layout_mode(), - layout_name, - update_interval_ms: state.update_interval_ms, - history_points: 100, - alerts: state.alerts, - keybindings: state.keybindings.clone(), - }; - let _ = config::save_config(&cfg); -} - -fn build_plugin_manager(state: &mut AppState, cfg_dir: &Path) -> PluginManager { - let plugins_dir = cfg_dir.join("plugins"); - fs::create_dir_all(&plugins_dir).ok(); - let mut mgr = PluginManager::new(plugins_dir); - - // Register plugins behind feature flags - #[cfg(feature = "plugin-sentinel")] - { - let plugin = Box::new(SentinelPlugin::new()); - if let Err(e) = mgr.register(plugin, state) { - eprintln!("[xtop] failed to load sentinel plugin: {e}"); - } - } - - mgr -} - -// --------------------------------------------------------------------------- -// CLI subcommands -// --------------------------------------------------------------------------- -fn print_usage() { - eprintln!("Usage:"); - eprintln!(" xtop Start the TUI system monitor"); - eprintln!(" xtop mcp Start MCP server (stdio transport) for AI agents"); - eprintln!(" xtop plugin list List installed plugins"); - eprintln!( - " xtop plugin install Install a plugin from github.com/xscriptor/xtop/plugins/" - ); - eprintln!(" xtop plugin install Install a plugin from a git URL"); - eprintln!(" xtop plugin scaffold Create a new plugin crate"); -} - -/// Check if a string looks like a git URL (not a simple name). -fn is_git_url(s: &str) -> bool { - s.contains("://") || s.contains("github.com") || s.contains("git@") -} - -fn cmd_plugin_list() { - let workspace_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("Cargo.toml"); - - let content = match fs::read_to_string(&workspace_path) { - Ok(c) => c, - Err(e) => { - eprintln!("Error reading workspace Cargo.toml: {e}"); - return; - } - }; - - // Parse workspace members for plugin crates - let mut in_members = false; - let mut plugins: Vec = Vec::new(); - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("members") { - in_members = true; - continue; - } - if in_members { - if trimmed == "]" { - break; - } - let name = trimmed.trim_matches(',').trim().trim_matches('"'); - if name.starts_with("plugins/xtop-plugin-") || name.starts_with("crates/xtop-plugin-") { - plugins.push(name.to_string()); - } - } - } - - if plugins.is_empty() { - println!("No plugins installed."); - return; - } - println!("Installed plugins:"); - for p in &plugins { - println!(" {p}"); - } -} - -fn cmd_plugin_install(name_or_url: &str) -> anyhow::Result<()> { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_dir = manifest_dir.parent().unwrap().parent().unwrap(); - let workspace_toml = workspace_dir.join("Cargo.toml"); - let cli_toml = manifest_dir.join("Cargo.toml"); - let plugins_dir = workspace_dir.join("plugins"); - - let tmp = std::env::temp_dir().join("xtop-plugin-install"); - let _ = fs::remove_dir_all(&tmp); - - let repo_url: &str; - let mut plugin_subdir: String = String::new(); - - if is_git_url(name_or_url) { - // URL-based: clone the repo directly - repo_url = name_or_url; - println!("Cloning {repo_url} ..."); - let status = std::process::Command::new("git") - .args(["clone", repo_url, tmp.to_str().unwrap()]) - .status() - .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?; - if !status.success() { - anyhow::bail!("git clone failed"); - } - } else { - // Name-based: look in xtop repo's plugins/ directory - repo_url = "https://github.com/xscriptor/xtop.git"; - let candidate_names = [ - format!("plugins/xtop-plugin-{name_or_url}"), - format!("plugins/{name_or_url}"), - ]; - println!("Looking for plugin '{name_or_url}' in {repo_url} ..."); - let status = std::process::Command::new("git") - .args([ - "clone", - "--depth", - "1", - "--filter=blob:none", - "--sparse", - repo_url, - tmp.to_str().unwrap(), - ]) - .status() - .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?; - if !status.success() { - anyhow::bail!("git clone failed"); - } - - // Try each candidate path - let mut found = false; - for candidate in &candidate_names { - if tmp.join(candidate).join("Cargo.toml").exists() { - plugin_subdir = candidate.clone(); - found = true; - break; - } - } - if !found { - let _ = fs::remove_dir_all(&tmp); - anyhow::bail!( - "Plugin '{name_or_url}' not found in plugins/. \ - Tried: {}", - candidate_names.join(", ") - ); - } - println!("Found plugin at {plugin_subdir}"); - } - - // --- Determine the plugin source directory --- - let plugin_src = if plugin_subdir.is_empty() { - // URL-based: cloned repo root - tmp.clone() - } else { - // Name-based: subdirectory within cloned xtop repo - tmp.join(&plugin_subdir) - }; - - // --- Read the plugin's Cargo.toml to get the package name --- - let plugin_toml_path = plugin_src.join("Cargo.toml"); - let plugin_toml_content = fs::read_to_string(&plugin_toml_path) - .map_err(|e| anyhow::anyhow!("No Cargo.toml found: {e}"))?; - let plugin_pkg: toml::Value = plugin_toml_content - .parse() - .map_err(|e| anyhow::anyhow!("Invalid Cargo.toml: {e}"))?; - - let pkg_name = plugin_pkg - .get("package") - .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) - .ok_or_else(|| anyhow::anyhow!("package.name not found in plugin Cargo.toml"))?; - - let feature_name = pkg_name.replace('-', "_"); - let plugin_dir_name = pkg_name.replace('-', "_"); - - println!("Package name: {pkg_name}"); - - // --- Copy into local plugins/ directory --- - let target_dir = plugins_dir.join(&plugin_dir_name); - if target_dir.exists() { - anyhow::bail!( - "Plugin '{}' already exists at plugins/{plugin_dir_name}", - pkg_name - ); - } - fs::create_dir_all(&plugins_dir)?; - cp_recursive(&plugin_src, &target_dir)?; - - // --- Add to workspace Cargo.toml --- - let ws_content = fs::read_to_string(&workspace_toml)?; - let member_entry = format!(" \"plugins/{plugin_dir_name}\""); - if ws_content.contains(&member_entry) { - anyhow::bail!("Already in workspace"); - } - // Insert before the closing bracket of members - let sentinel_entry = " \"plugins/xtop-plugin-sentinel\","; - let new_ws = if ws_content.contains(sentinel_entry) { - ws_content.replace( - sentinel_entry, - &format!("{sentinel_entry}\n{member_entry},"), - ) - } else { - // Fallback: insert before the closing ] of members - ws_content.replacen("]", &format!(" {member_entry},\n]"), 1) - }; - fs::write(&workspace_toml, &new_ws)?; - - // --- Add to xtop-cli Cargo.toml --- - let cli_content = fs::read_to_string(&cli_toml)?; - - // Build dependency path relative to crates/xtop-cli/ - let dep_path = format!("../../plugins/{plugin_dir_name}"); - let dep_line = format!("{pkg_name} = {{ path = \"{dep_path}\", optional = true }}"); - - if !cli_content.contains(&dep_line) { - // Find the last optional plugin dependency and insert after it - let marker = "# Optional plugins (behind feature flags)"; - let new_cli = cli_content.replace(marker, &format!("{marker}\n{dep_line}")); - fs::write(&cli_toml, &new_cli)?; - } - - // Add feature flag - let feature_line = format!("{feature_name} = [\"dep:{pkg_name}\"]"); - let cli_content2 = fs::read_to_string(&cli_toml)?; - if !cli_content2.contains(&feature_line) { - let sentinel_feature = "plugin-sentinel = [\"dep:xtop-plugin-sentinel\"]"; - let new_cli2 = if cli_content2.contains(sentinel_feature) { - cli_content2.replace( - sentinel_feature, - &format!("{sentinel_feature}\n{feature_line}"), - ) - } else { - cli_content2.replacen("[features]", &format!("[features]\n{feature_line}"), 1) - }; - fs::write(&cli_toml, &new_cli2)?; - } - - // --- Rebuild --- - println!("Building xtop with {pkg_name} ..."); - let build = std::process::Command::new("cargo") - .args(["build", "--release"]) - .current_dir(workspace_dir) - .status() - .map_err(|e| anyhow::anyhow!("cargo build failed: {e}"))?; - if !build.success() { - anyhow::bail!("Build failed. Check the plugin's compatibility."); - } - - // --- Cleanup --- - let _ = fs::remove_dir_all(&tmp); - - println!(); - println!("Plugin '{pkg_name}' installed successfully."); - println!(" Location: plugins/{plugin_dir_name}"); - println!(" Feature flag: {feature_name}"); - println!(); - println!("Note: '{feature_name}' is NOT enabled by default."); - println!("To enable it, add '{feature_name}' to the 'default' feature list"); - println!("in crates/xtop-cli/Cargo.toml, then rebuild."); - - Ok(()) -} - -fn cmd_plugin_scaffold(name: &str) -> anyhow::Result<()> { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_dir = manifest_dir.parent().unwrap().parent().unwrap(); - let plugins_dir = workspace_dir.join("plugins"); - let plugin_dir = plugins_dir.join(format!("xtop-plugin-{name}")); - - if plugin_dir.exists() { - anyhow::bail!("Plugin crate already exists at {}", plugin_dir.display()); - } - - let src_dir = plugin_dir.join("src"); - fs::create_dir_all(&src_dir)?; - - // Cargo.toml (path refs go up from plugins/ to workspace root, then into crates/) - let cargo_toml = format!( - r#"[package] -name = "xtop-plugin-{name}" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "xtop plugin: {name}" - -[dependencies] -xtop-core = {{ path = "../../crates/xtop-core" }} -ratatui.workspace = true -"# - ); - fs::write(plugin_dir.join("Cargo.toml"), &cargo_toml)?; - - // lib.rs - let lib_rs = format!( - r#"use xtop_core::domain::plugin::{{Plugin, PluginCapability, PluginContext, PluginError, PluginManifest}}; - -pub struct {name_cap}Plugin; - -impl {name_cap}Plugin {{ - pub fn new() -> Self {{ - Self - }} -}} - -impl Plugin for {name_cap}Plugin {{ - fn manifest(&self) -> PluginManifest {{ - PluginManifest {{ - id: "{name}".to_string(), - name: "{name_cap}".to_string(), - version: "0.1.0".to_string(), - description: "xtop plugin: {name}".to_string(), - capabilities: vec![PluginCapability::ReadSystemInfo], - }} - }} - - fn on_tick(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> {{ - Ok(()) - }} -}} -"#, - name = name, - name_cap = { - let mut chars = name.chars(); - match chars.next() { - None => String::new(), - Some(c) => c.to_uppercase().to_string() + chars.as_str(), - } - } - ); - fs::write(src_dir.join("lib.rs"), &lib_rs)?; - - println!("Plugin scaffold created at {}", plugin_dir.display()); - println!("To register it:"); - println!(" 1. Add \"plugins/xtop-plugin-{name}\" to [workspace].members in Cargo.toml"); - println!(" 2. Add dependency + feature flag in crates/xtop-cli/Cargo.toml"); - println!(" 3. Add #[cfg(feature = \"plugin-{name}\")] import in main.rs"); - println!(" 4. Implement Plugin trait methods"); - - Ok(()) -} - -fn cp_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { - if src.is_dir() { - fs::create_dir_all(dst)?; - for entry in fs::read_dir(src)? { - let entry = entry?; - let file_type = entry.file_type()?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - if file_type.is_dir() { - // Skip .git directory - if entry.file_name() != ".git" { - cp_recursive(&src_path, &dst_path)?; - } - } else { - fs::copy(&src_path, &dst_path)?; - } - } - Ok(()) - } else { - fs::copy(src, dst)?; - Ok(()) - } -} - -fn main() -> anyhow::Result<()> { - let args: Vec = std::env::args().collect(); - - // CLI subcommands - if args.len() > 1 { - match args[1].as_str() { - "mcp" => { - ensure_default_assets(); - return mcp::run_mcp_server(); - } - "plugin" => { - if args.len() < 3 { - print_usage(); - return Ok(()); - } - match args[2].as_str() { - "list" => { - cmd_plugin_list(); - return Ok(()); - } - "install" => { - if args.len() < 4 { - eprintln!("Usage: xtop plugin install "); - return Ok(()); - } - return cmd_plugin_install(&args[3]); - } - "scaffold" => { - if args.len() < 4 { - eprintln!("Usage: xtop plugin scaffold "); - return Ok(()); - } - return cmd_plugin_scaffold(&args[3]); - } - _ => { - print_usage(); - return Ok(()); - } - } - } - "--help" | "-h" => { - print_usage(); - return Ok(()); - } - _ => {} - } - } - - ensure_default_assets(); - - terminal::install_panic_hook(); - let mut terminal = terminal::init()?; - - let cfg_dir = config_dir(); - - // Build the primary (composite) provider - let sysinfo_provider = SysinfoProvider::new(); - let composite = CompositeProvider::new(Box::new(sysinfo_provider)); - - let themes = load_all_themes(); - let cfg = config::load_config(); - let mut builtin_layouts = layout_loader::builtin_layouts(); - let custom_layouts = layout_loader::load_custom_layouts(); - builtin_layouts.extend(custom_layouts); - let mut state = AppState::new(Box::new(composite), themes, cfg, builtin_layouts); - - // Build and register plugins - let plugin_mgr = build_plugin_manager(&mut state, &cfg_dir); - - // Collect extra data providers from plugins and inject everything into state - let extra_providers = plugin_mgr.collect_data_providers(); - state.init_plugins(plugin_mgr, extra_providers); - - let tick_rate = Duration::from_millis(state.update_interval_ms); - let mut last_tick = Instant::now(); - - loop { - terminal.draw(|f| render::render(f, &state))?; - - let timeout = tick_rate - .checked_sub(last_tick.elapsed()) - .unwrap_or_default(); - - if event::poll(timeout)? { - if let Event::Key(key) = event::read()? { - let key_str = key_event_to_str(&key); - - // Give plugins first chance to consume the key - let key_str_clone = key_str.clone(); - let key_consumed = - state.with_plugin_manager_mut(|mgr, this| mgr.handle_key(this, &key_str_clone)); - if key_consumed { - continue; - } - - // DEBUG: print key for diagnostics - if cfg!(debug_assertions) && !key_str.is_empty() { - eprintln!("[key] '{key_str}'"); - } - - match state.input_mode { - InputMode::Normal => { - // Direct Ctrl+P check (works regardless of keybinding config, important on macOS) - if key_str == "ctrl+p" { - state.open_palette(); - state.input_mode = InputMode::CommandPalette; - } else if let Some(action) = state.keybindings.resolve(&key_str) { - match action { - Action::Quit => { - save_config(&state); - state.quit(); - } - Action::Cancel if state.show_help => { - state.toggle_help(); - } - Action::OpenCommandPalette => { - state.open_palette(); - state.input_mode = InputMode::CommandPalette; - } - Action::KillProcess | Action::ProcessUp | Action::ProcessDown => { - state.execute_action(&action); - } - _ => { - state.execute_action(&action); - } - } - } - } - InputMode::Searching => match key.code { - KeyCode::Esc => { - state.search_query.clear(); - state.end_search(); - } - KeyCode::Enter => { - state.end_search(); - } - KeyCode::Backspace => { - state.search_pop_char(); - } - KeyCode::Char(c) => { - state.search_push_char(c); - } - _ => {} - }, - InputMode::CommandPalette => { - let is_main = state.palette.page == PalettePage::Main; - match key.code { - KeyCode::Esc => { - state.close_palette(); - } - KeyCode::Enter => { - if let Some(action) = state.palette_selected_action() { - state.execute_action(&action); - save_config(&state); - } - } - KeyCode::Down => { - state.palette_select_next(); - } - KeyCode::Up => { - state.palette_select_prev(); - } - KeyCode::Char(c) => { - state.palette.query.push(c); - state.palette_filter(); - } - KeyCode::Backspace => { - if state.palette.query.is_empty() && !is_main { - state.palette_navigate_to(PalettePage::Main); - } else { - state.palette.query.pop(); - state.palette_filter(); - } - } - _ => {} - } - } - } - } - } - - if last_tick.elapsed() >= tick_rate { - state.on_tick(); - last_tick = Instant::now(); - } - - if state.should_quit { - break; - } - } - - // Disable plugins on shutdown - state.with_plugin_manager_mut(|mgr, this| { - mgr.disable_all(this); - }); - - terminal::restore()?; - Ok(()) -} diff --git a/crates/xtop-cli/src/mcp.rs b/crates/xtop-cli/src/mcp.rs deleted file mode 100644 index 9a7e39e..0000000 --- a/crates/xtop-cli/src/mcp.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! MCP server entry point. -//! -//! Initializes xtop state and plugins, then delegates to the Sentinel plugin's -//! MCP module (`xtop_plugin_sentinel::mcp::run_server`) which handles the -//! actual stdin/stdout MCP protocol loop. - -use std::path::{Path, PathBuf}; -use xtop_core::application::plugin_manager::PluginManager; -use xtop_core::application::state::AppState; -use xtop_core::infrastructure::composite_provider::CompositeProvider; -use xtop_core::infrastructure::config; -use xtop_core::infrastructure::layout_loader; -use xtop_core::infrastructure::sysinfo_provider::SysinfoProvider; -use xtop_core::infrastructure::theme_loader::load_all_themes; - -#[cfg(feature = "plugin-sentinel")] -use xtop_plugin_sentinel::SentinelPlugin; - -/// Run the MCP server. -/// -/// Sets up AppState + PluginManager with Sentinel, then delegates to -/// the plugin's MCP module for the protocol loop. -pub fn run_mcp_server() -> anyhow::Result<()> { - let cfg_dir = config_dir(); - let mut state = initialize_state(&cfg_dir)?; - - // Delegate to Sentinel's MCP module - #[cfg(feature = "plugin-sentinel")] - { - xtop_plugin_sentinel::mcp::run_server(&mut state) - } - - #[cfg(not(feature = "plugin-sentinel"))] - { - eprintln!("MCP server requires the 'plugin-sentinel' feature."); - eprintln!("Rebuild with: cargo build --features plugin-sentinel"); - std::process::exit(1); - } -} - -fn config_dir() -> PathBuf { - xtop_core::infrastructure::config::config_dir() -} - -fn build_plugin_manager(state: &mut AppState, cfg_dir: &Path) -> PluginManager { - let plugins_dir = cfg_dir.join("plugins"); - std::fs::create_dir_all(&plugins_dir).ok(); - let mut mgr = PluginManager::new(plugins_dir); - - #[cfg(feature = "plugin-sentinel")] - { - let plugin = Box::new(SentinelPlugin::new()); - if let Err(e) = mgr.register(plugin, state) { - eprintln!("[xtop-mcp] failed to load sentinel plugin: {e}"); - } - } - - mgr -} - -fn initialize_state(cfg_dir: &Path) -> anyhow::Result { - let sysinfo_provider = SysinfoProvider::new(); - let composite = CompositeProvider::new(Box::new(sysinfo_provider)); - - let themes = load_all_themes(); - let cfg = config::load_config(); - let mut builtin_layouts = layout_loader::builtin_layouts(); - let custom_layouts = layout_loader::load_custom_layouts(); - builtin_layouts.extend(custom_layouts); - let mut state = AppState::new(Box::new(composite), themes, cfg, builtin_layouts); - - let plugin_mgr = build_plugin_manager(&mut state, cfg_dir); - let extra_providers = plugin_mgr.collect_data_providers(); - state.init_plugins(plugin_mgr, extra_providers); - - Ok(state) -} diff --git a/crates/xtop-core/Cargo.toml b/crates/xtop-core/Cargo.toml deleted file mode 100644 index 862b526..0000000 --- a/crates/xtop-core/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "xtop-core" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -sysinfo.workspace = true -serde.workspace = true -serde_json.workspace = true -ratatui.workspace = true diff --git a/crates/xtop-core/src/application/mod.rs b/crates/xtop-core/src/application/mod.rs deleted file mode 100644 index 9e59902..0000000 --- a/crates/xtop-core/src/application/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod history; -pub mod plugin_manager; -pub mod state; diff --git a/crates/xtop-core/src/domain/metrics.rs b/crates/xtop-core/src/domain/metrics.rs deleted file mode 100644 index a6f40d4..0000000 --- a/crates/xtop-core/src/domain/metrics.rs +++ /dev/null @@ -1,166 +0,0 @@ -#![allow(clippy::manual_non_exhaustive)] - -#[derive(Debug, Clone)] -pub struct CpuInfo { - pub name: String, - pub usage: f64, - pub cpu_id: usize, - pub frequency: u64, - pub governor: String, -} - -#[derive(Debug, Clone)] -pub struct MemoryInfo { - pub total: u64, - pub used: u64, - pub available: u64, - pub free: u64, - pub percent: f64, -} - -#[derive(Debug, Clone)] -pub struct SwapInfo { - pub total: u64, - pub used: u64, - pub free: u64, - pub percent: f64, -} - -#[derive(Debug, Clone)] -pub struct DiskInfo { - pub mount_point: String, - pub total_space: u64, - pub available_space: u64, - pub used_space: u64, - pub percent: f64, - pub file_system: String, - pub mount_options: String, -} - -#[derive(Debug, Clone)] -pub struct DiskIOInfo { - pub name: String, - pub read_bytes: u64, - pub write_bytes: u64, - pub read_speed: f64, - pub write_speed: f64, -} - -#[derive(Debug, Clone)] -pub struct NetworkInfo { - pub name: String, - pub received: u64, - pub transmitted: u64, - pub rx_speed: f64, - pub tx_speed: f64, - pub ip: Vec, -} - -#[derive(Debug, Clone)] -pub struct ProcessInfo { - pub pid: u32, - pub name: String, - pub cpu_usage: f64, - pub memory: u64, - pub user_id: Option, - pub state: String, - pub cmd: String, - - // P0 -- Malicious process detection essentials - /// Full path to the executable on disk - pub exe_path: Option, - /// Parent process ID - pub parent_pid: Option, - /// Full command-line argument vector (argv) - pub cmd_full: Vec, - - // P1 -- Timing, privilege, and context - /// Process start time as epoch seconds - pub start_time: u64, - /// Seconds since process started - pub run_time: u64, - /// Effective user ID (may differ from uid on SUID binaries) - pub effective_user_id: Option, - /// Group ID - pub group_id: Option, - /// Process working directory - pub cwd: Option, - /// Number of threads - pub thread_count: u64, - - // P2 -- I/O, environment, session - /// Number of open file descriptors - pub open_files: u64, - /// Max allowed file descriptors - pub open_files_limit: u64, - /// Total bytes read from disk by this process - pub disk_total_read_bytes: u64, - /// Total bytes written to disk by this process - pub disk_total_write_bytes: u64, - /// Environment variables - pub environ: Vec, - /// Session ID - pub session_id: Option, -} - -#[derive(Debug, Clone)] -pub struct LoadAvg { - pub one: f64, - pub five: f64, - pub fifteen: f64, -} - -#[derive(Debug, Clone)] -pub struct BatteryInfo { - pub name: String, - pub percentage: f32, - pub state: String, - pub time_to_full: Option, - pub time_to_empty: Option, - pub health: f32, - pub cycle_count: Option, -} - -#[derive(Debug, Clone)] -pub struct GpuInfo { - pub name: String, - pub usage: f64, - pub temperature: f32, - pub memory_total: u64, - pub memory_used: u64, -} - -#[derive(Debug, Clone)] -pub struct DockerInfo { - pub name: String, - pub status: String, - pub cpu_usage: f64, - pub memory_usage: u64, -} - -#[derive(Debug, Clone, Default)] -pub struct SystemInfo { - pub hostname: String, - pub os_version: String, - pub kernel: String, - pub desktop_env: String, - pub shell: String, -} - -#[derive(Debug, Clone)] -pub struct SystemSnapshot { - pub cpus: Vec, - pub memory: MemoryInfo, - pub swap: SwapInfo, - pub disks: Vec, - pub networks: Vec, - pub processes: Vec, - pub load_avg: LoadAvg, - pub uptime: u64, - pub cpu_temp: f64, - pub disk_io: Vec, - pub batteries: Vec, - pub gpus: Vec, - pub dockers: Vec, - pub sys_info: SystemInfo, -} diff --git a/crates/xtop-core/src/domain/mod.rs b/crates/xtop-core/src/domain/mod.rs deleted file mode 100644 index 35bf2ef..0000000 --- a/crates/xtop-core/src/domain/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod keybinding; -pub mod layout; -pub mod metrics; -pub mod plugin; -pub mod system_info; -pub mod theme; diff --git a/crates/xtop-core/src/domain/plugin.rs b/crates/xtop-core/src/domain/plugin.rs deleted file mode 100644 index 22755dd..0000000 --- a/crates/xtop-core/src/domain/plugin.rs +++ /dev/null @@ -1,220 +0,0 @@ -use std::fmt::Debug; - -use crate::application::state::AppState; -use crate::domain::metrics::SystemSnapshot; -use crate::domain::system_info::SystemDataProvider; - -/// Unique identifier for a plugin capability. -/// Used for permission checking and manifest declaration. -#[derive(Clone, Debug, PartialEq)] -#[non_exhaustive] -pub enum PluginCapability { - /// Read system metrics (CPU, memory, network, disks, processes) - ReadSystemInfo, - /// Terminate processes - KillProcesses, - /// Modify configuration (themes, layouts, alerts, interval) - ModifyConfig, - /// Register custom widgets in the TUI - RenderWidgets, - /// Anything not covered above - Custom(String), -} - -/// Static metadata about a plugin. -/// Returned by [`Plugin::manifest`]. -#[derive(Clone, Debug)] -pub struct PluginManifest { - pub id: String, - pub name: String, - pub version: String, - pub description: String, - pub capabilities: Vec, -} - -/// Error type for plugin operations. -#[derive(Debug)] -pub enum PluginError { - /// A recoverable error (e.g. invalid params, resource busy) - Recoverable(String), - /// A fatal error (plugin should be disabled) - Fatal(String), - /// Action not understood by this plugin - UnknownAction(String), -} - -impl std::fmt::Display for PluginError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Recoverable(msg) => write!(f, "{msg}"), - Self::Fatal(msg) => write!(f, "FATAL: {msg}"), - Self::UnknownAction(action) => write!(f, "unknown action: {action}"), - } - } -} - -impl std::error::Error for PluginError {} - -type RenderFn = - std::sync::Arc; - -/// A widget that a plugin registers for rendering in the TUI. -pub struct WidgetRegistration { - pub name: String, - pub render: RenderFn, -} - -impl Debug for WidgetRegistration { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("WidgetRegistration") - .field("name", &self.name) - .finish() - } -} - -/// Context passed to plugin lifecycle methods. -/// Provides safe, limited access to application state and plugin-specific directories. -pub struct PluginContext<'a> { - pub(crate) state: &'a mut AppState, - pub(crate) plugin_data_dir: std::path::PathBuf, - pub(crate) capabilities: Vec, -} - -impl PluginContext<'_> { - fn check_capability(&self, cap: &PluginCapability) -> Result<(), PluginError> { - if self.capabilities.contains(cap) { - Ok(()) - } else { - Err(PluginError::Recoverable(format!( - "plugin does not have required capability: {:?}", - cap - ))) - } - } - - /// Full system snapshot with all available metrics. - /// Requires `ReadSystemInfo` capability. - pub fn snapshot(&self) -> SystemSnapshot { - self.state.snapshot() - } - - /// The top N processes sorted by CPU usage. - /// Requires `ReadSystemInfo` capability. - pub fn top_processes(&self, n: usize) -> Vec { - let snap = self.snapshot(); - snap.processes.into_iter().take(n).collect() - } - - /// Kill a process by PID. Returns true if the signal was sent. - /// Requires `KillProcesses` capability. - pub fn kill_process(&mut self, pid: u32) -> Result { - self.check_capability(&PluginCapability::KillProcesses)?; - Ok(self.state.kill_process_by_pid(pid)) - } - - /// Set alert thresholds for CPU, memory, and disk. - /// Requires `ModifyConfig` capability. - pub fn set_alert_thresholds( - &mut self, - cpu: f64, - mem: f64, - disk: f64, - ) -> Result<(), PluginError> { - self.check_capability(&PluginCapability::ModifyConfig)?; - self.state.set_alert_thresholds(cpu, mem, disk); - Ok(()) - } - - /// Switch to a theme by name. Returns true if found. - /// Requires `ModifyConfig` capability. - pub fn set_theme_by_name(&mut self, name: &str) -> Result { - self.check_capability(&PluginCapability::ModifyConfig)?; - Ok(self.state.set_theme_by_name(name)) - } - - /// Switch to a layout by name. Returns true if found. - /// Requires `ModifyConfig` capability. - pub fn set_layout_by_name(&mut self, name: &str) -> Result { - self.check_capability(&PluginCapability::ModifyConfig)?; - Ok(self.state.set_layout_by_name(name)) - } - - /// Set the update interval in milliseconds. - /// Requires `ModifyConfig` capability. - pub fn set_update_interval(&mut self, ms: u64) -> Result<(), PluginError> { - self.check_capability(&PluginCapability::ModifyConfig)?; - self.state.update_interval_ms = ms; - Ok(()) - } - - /// Current system info (hostname, OS, kernel). - /// Requires `ReadSystemInfo` capability. - pub fn system_info(&self) -> crate::domain::metrics::SystemInfo { - self.state.sys_info.clone() - } - - /// Plugin-specific data directory (`~/.config/xtop/plugins//`). - pub fn data_dir(&self) -> &std::path::Path { - &self.plugin_data_dir - } - - /// Current AppState read-only snapshot for widget rendering data. - /// Requires `ReadSystemInfo` capability. - pub fn state(&self) -> &AppState { - self.state - } -} - -/// The core trait that every plugin must implement. -/// -/// All methods have default empty implementations so plugins only -/// override what they need. -pub trait Plugin: Debug + Send { - /// Static metadata about this plugin. - fn manifest(&self) -> PluginManifest; - - /// Called once when the plugin is loaded and enabled. - fn on_enable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { - Ok(()) - } - - /// Called once when the plugin is disabled or xtop shuts down. - fn on_disable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { - Ok(()) - } - - /// Called on every tick (every ~1s by default). - fn on_tick(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { - Ok(()) - } - - /// Called when a key is pressed. - /// Return `Ok(true)` if the plugin consumed the key event. - fn on_key(&mut self, _ctx: &mut PluginContext, _key: &str) -> Result { - Ok(false) - } - - /// Optionally provide additional system data. - /// The returned provider is merged into the main data stream via CompositeProvider. - fn data_provider(&self) -> Option> { - None - } - - /// Optionally register a custom widget for TUI rendering. - fn widget(&self) -> Option { - None - } - - /// Execute a named command with string parameters. - /// Used by external agents (AI, CLI, IPC) to interact with the plugin. - /// - /// Returns a JSON-like string response. - fn execute( - &mut self, - _ctx: &mut PluginContext, - _action: &str, - _params: &str, - ) -> Result { - Err(PluginError::UnknownAction(_action.to_string())) - } -} diff --git a/crates/xtop-core/src/domain/system_info.rs b/crates/xtop-core/src/domain/system_info.rs deleted file mode 100644 index 6f03984..0000000 --- a/crates/xtop-core/src/domain/system_info.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::domain::metrics::*; - -pub trait SystemDataProvider: Send { - fn refresh_all(&mut self); - fn snapshot(&self) -> SystemSnapshot; - fn disk_io(&self) -> Vec { - vec![] - } - fn batteries(&self) -> Vec { - vec![] - } - fn gpu_info(&self) -> Vec { - vec![] - } - fn docker_info(&self) -> Vec { - vec![] - } - fn system_info(&self) -> SystemInfo { - SystemInfo::default() - } - fn kill_process(&self, _pid: u32) -> bool { - false - } - - /// Downcast to `Any` for internal provider composition. - fn as_any(&self) -> &dyn std::any::Any; - fn as_any_mut(&mut self) -> &mut dyn std::any::Any; - - /// Add extra data providers (used by CompositeProvider). - /// Default no-op implementation for non-composite providers. - fn add_extras(&mut self, _extras: Vec>) {} -} diff --git a/crates/xtop-core/src/infrastructure/mod.rs b/crates/xtop-core/src/infrastructure/mod.rs deleted file mode 100644 index d6c12a1..0000000 --- a/crates/xtop-core/src/infrastructure/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod composite_provider; -pub mod config; -pub mod layout_loader; -pub mod sysinfo_provider; -pub mod theme_loader; diff --git a/crates/xtop-core/src/lib.rs b/crates/xtop-core/src/lib.rs deleted file mode 100644 index 50d0795..0000000 --- a/crates/xtop-core/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod application; -pub mod domain; -pub mod infrastructure; diff --git a/crates/xtop-tui/Cargo.toml b/crates/xtop-tui/Cargo.toml deleted file mode 100644 index dc9be70..0000000 --- a/crates/xtop-tui/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "xtop-tui" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -xtop-core = { path = "../xtop-core" } -ratatui.workspace = true -crossterm.workspace = true diff --git a/crates/xtop-tui/src/lib.rs b/crates/xtop-tui/src/lib.rs deleted file mode 100644 index db59d20..0000000 --- a/crates/xtop-tui/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod color; -pub mod format; -pub mod render; -pub mod terminal; diff --git a/docs/installation.md b/docs/installation.md index 5cd063e..dd05e20 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -21,11 +21,11 @@

Using curl

-
curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
+
curl -fsSL https://raw.githubusercontent.com/xtop-cli/xtop/main/install.sh | bash

Using wget

-
wget -qO- https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
+
wget -qO- https://raw.githubusercontent.com/xtop-cli/xtop/main/install.sh | bash

@@ -33,7 +33,7 @@

Requires Rust (Cargo) to be installed. Run in PowerShell:

-
irm https://raw.githubusercontent.com/xscriptor/xtop/main/install.ps1 | iex
+
irm https://raw.githubusercontent.com/xtop-cli/xtop/main/install.ps1 | iex

@@ -67,7 +67,7 @@
  1. Clone the repository:

    -
    git clone https://github.com/xscriptor/xtop.git
    +    
    git clone https://github.com/xtop-cli/xtop.git
     cd xtop
  2. @@ -82,11 +82,11 @@ cd xtop

    macOS / Linux

    -
    curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash -s -- --uninstall
    +
    curl -fsSL https://raw.githubusercontent.com/xtop-cli/xtop/main/install.sh | bash -s -- --uninstall

    Windows

    -
    irm https://raw.githubusercontent.com/xscriptor/xtop/main/uninstall.ps1 | iex
    +
    irm https://raw.githubusercontent.com/xtop-cli/xtop/main/uninstall.ps1 | iex

    diff --git a/docs/multi-repo.md b/docs/multi-repo.md new file mode 100644 index 0000000..55655bc --- /dev/null +++ b/docs/multi-repo.md @@ -0,0 +1,106 @@ +# Multi-repo architecture (xtop-cli org) + +> Estado: propuesta inicial. Este doc vive en el kernel pero describe todos los repos. + +## Organización + +| Repo (xtop-cli) | Rol | Contenido | +|---|---|---| +| `xtop` | **Kernel** — la app | workspace: `crates/xtop-core`, `xtop-tui`, `xtop` (bin). Nada más | +| `api` | **Contratos** | workspace: `crates/plugin-api`, `effect-api`, `extension-api` → crates publicados `xtop-plugin-api`, `xtop-effect-api`, `xtop-extension-api` | +| `plugins` | Implementaciones de plugins | workspace `plugins/xtop-plugin-*` (1er miembro: samurai) | +| `effects` | Efectos visuales TUI | workspace `effects/xtop-effect-*` (+ `effects-lib` compartido) | +| `extensions` | Hooks/add-ons del kernel | workspace `extensions/xtop-extension-*` | + +Layout local de desarrollo (repos hermanos, como hoy): + +``` +/home/x/xtop-cli/xtop/ + xtop/ api/ plugins/ effects/ extensions/ +``` + +## Principio: dependencias en árbol + +Hoy el kernel define los traits de plugin y `xtop-plugin-samurai` depende de +`xtop-core`. Eso impide separar repos: el kernel es a la vez host y contrato. + +Objetivo (espejo de `xfetch-cli`): + +``` + ┌────────────┐ + │ api │ crates puros de contrato (sin dep del kernel) + └─────┬──────┘ + ┌────────────┼────────────────┐ + ▼ ▼ ▼ + ┌───────────┐ ┌─────────────┐ ┌──────────────┐ + │ kernel │ │ plugins/ │ │ effects/ │ + │ xtop │ │ effects/ │ │ extensions/ │ + │ (host) │ │ extensions │ │ │ + └───────────┘ └─────────────┘ └──────────────┘ +``` + +- **api**: tipos puros + protocolo (manifest, capabilities, errores, snapshot, + provider trait, widget registration, frames de efecto, hooks de extensión). + Depende solo de `ratatui`/`serde`. Publicado a crates.io en su momento. +- **kernel**: implementa el *host* (PluginManager, CompositeProvider, pipeline + de render, hooks) contra los tipos de api. Sin plugins sigue compilando: + la integración es opcional. +- **plugins/effects/extensions**: consumen api únicamente → cada repo compila + standalone y nunca depende del kernel. + +## Qué se mueve de xtop-core a api (Fase 1) + +Candidatos directos (tipo "contrato"): + +- `domain/plugin.rs` → `xtop-plugin-api`: `PluginCapability`, `PluginManifest`, + `PluginError`, `PluginContext` (vía un trait de host, no `AppState` directo), + `WidgetRegistration`, trait `Plugin`. +- `domain/system_info.rs` (trait `SystemDataProvider`) + tipos de datos de + `domain/metrics.rs` (`SystemSnapshot`, `ProcessInfo`, `SystemInfo`) → + `xtop-plugin-api` o crate de datos compartido, porque providers/widgets + externos necesitan esos tipos sin importar el kernel. + +Se queda en `xtop-core`: sysinfo real, `AppState`, `PluginManager`, +config/themes/layouts, keybindings, alerts. El kernel reexporta los tipos de +api para no romper los callers internos durante la transición. + +Nota `PluginContext`: hoy expone `&mut AppState` (estado vivo). Para que el +contrato sea externo, `PluginContext` debe moverse al host: api define un trait +`HostContext`/`XtapContext` que el kernel implementa y el plugin consume. +(La otra vía —estado vivo por valor— choca con el modelo runtime futuro.) + +## Formas de integración (modular opcional) + +| Nivel | Mecanismo | Uso | +|---|---|---| +| Compile-time (hoy) | feature flag + dep opcional sobre api/plugins | Built-ins del kernel | +| Dev-time | `xtop plugin install ` (clona, compila, registra) | Primeros pasos | +| Runtime (futuro) | discovery de binarios `xtop-plugin-*` / `xtop-effect-*` / `xtop-extension-*` en dirs de config + env `XTOP_*_DEV_DIR` | Terceros, sin recompilar | + +El kernel nunca exige ningún repo externo: `cargo build --release +--no-default-features` = core puro. + +## Fases + +1. **F0 (hecha)**: org `xtop-cli`, repos creados (`xtop` movido con historial; + `api`, `plugins`, `effects`, `extensions` iniciados), clones locales. +2. **F1**: api crates esqueleto; extraer tipos contrato de xtop-core; kernel + dependiendo de `../api` (path) y verde de nuevo. +3. **F2**: mover samurai → `plugins/` (subtree split con historia); convertir + su dep a api (sin xtop-core); kernel: feature apunta al repo plugins + (path dev → git dep); actualizar URLs `xtop-cli/xtop` → `xtop-cli/*` + en help, docs, install.sh y CI; `plugin scaffold` apunta al nuevo repo. +4. **F3**: effects: `effect-api` + runner en el TUI + primer efecto demo. +5. **F4**: extensions: `extension-api` (hooks pre/post render, config, tema, + layout) + primer add-on demo. +6. **F5**: publicar api a crates.io; deps registry versionadas + tags; CI por + repo; release del kernel. + +## Deuda detectada al mover + +- 9 archivos del kernel aún referencian `xtop-cli/xtop` (help, docs, + install.sh/ps1, PKGBUILD, README, CONTRIBUTING). +- `cmd_plugin_install`/`cmd_plugin_list` asumen plugin dentro del repo kernel + (`plugins/` miembro del workspace) → deberán apuntar al repo `plugins`. +- LICENSE del kernel dice "Copyright (c) 2025 Xscriptor" → decidir si pasa a + la org xtop-cli. diff --git a/docs/plugin.md b/docs/plugin.md index 7612148..4df4ab1 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -8,7 +8,7 @@

    Or with a specific set of plugins:

    -
    cargo build --release --features plugin-sentinel
    +
    cargo build --release --features plugin-samurai

    @@ -122,7 +122,7 @@ ctx.data_dir() // ~/.config/xtop/plugins/<id>/
    fn widget(&self) -> Option<WidgetRegistration> {
         Some(WidgetRegistration {
    -        name: "sentinel".to_string(),
    +        name: "samurai".to_string(),
             render: Arc::new(|f, state, area| {
                 // Draw using ratatui
             }),
    @@ -137,7 +137,7 @@ ctx.data_dir()             // ~/.config/xtop/plugins/<id>/
    "direction": "vertical", "areas": [ { "widget": "header", "size": 3 }, - { "widget": "sentinel", "size": "30%" }, + { "widget": "samurai", "size": "30%" }, { "widget": "processes", "size": "*" } ] } @@ -160,7 +160,7 @@ ctx.data_dir() // ~/.config/xtop/plugins/<id>/ xtop plugin install <name> - Install a plugin from github.com/xscriptor/xtop/plugins/ + Install a plugin from github.com/xtop-cli/xtop/plugins/ xtop plugin install <url> @@ -175,11 +175,11 @@ ctx.data_dir() // ~/.config/xtop/plugins/<id>/

    Install Flow

    -

    When running xtop plugin install sentinel:

    +

    When running xtop plugin install samurai:

      -
    1. Clones github.com/xscriptor/xtop.git (shallow, sparse)
    2. -
    3. Looks for plugins/xtop-plugin-sentinel/ or plugins/sentinel/ in the clone
    4. +
    5. Clones github.com/xtop-cli/xtop.git (shallow, sparse)
    6. +
    7. Looks for plugins/xtop-plugin-samurai/ or plugins/samurai/ in the clone
    8. Copies to local plugins/ directory
    9. Adds entry to [workspace].members in root Cargo.toml
    10. Adds optional dependency + feature flag in crates/xtop-cli/Cargo.toml
    11. @@ -194,21 +194,21 @@ ctx.data_dir() // ~/.config/xtop/plugins/<id>/
    12. Add it to the default list in crates/xtop-cli/Cargo.toml to enable permanently
    13. -
      # Build xtop with sentinel plugin enabled
      -cargo build --release --features plugin-sentinel
      +
      # Build xtop with samurai plugin enabled
      +cargo build --release --features plugin-samurai
       
      -# Build xtop with sentinel + another plugin
      -cargo build --release --features "plugin-sentinel,plugin-mything"
      +# Build xtop with samurai + another plugin +cargo build --release --features "plugin-samurai,plugin-mything"

      MCP Server for AI Agents

      -

      When the plugin-sentinel feature is enabled, xtop can run an MCP (Model Context Protocol) server on stdio:

      +

      When the plugin-samurai feature is enabled, xtop can run an MCP (Model Context Protocol) server on stdio:

      xtop mcp
      -

      This exposes Sentinel's commands as MCP tools that any AI assistant can call. +

      This exposes Samurai's commands as MCP tools that any AI assistant can call. Compatible clients include Claude Desktop, Cline, Cursor, and Continue.dev.

      Claude Desktop configuration

      diff --git a/install.ps1 b/install.ps1 index 15c973e..ae2ac4a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -4,7 +4,7 @@ $ErrorActionPreference = "Stop" $AppName = "xtop" -$RepoUrl = "https://github.com/xscriptor/xtop.git" # Replace with actual repo URL +$RepoUrl = "https://github.com/xtop-cli/xtop.git" # Replace with actual repo URL $InstallDir = "$env:USERPROFILE\.cargo\bin" # Standard Cargo bin location Write-Host "Installing $AppName..." -ForegroundColor Green diff --git a/install.sh b/install.sh index a2d7a36..20f0022 100644 --- a/install.sh +++ b/install.sh @@ -6,7 +6,7 @@ set -euo pipefail APP_NAME="xtop" -REPO_URL="https://github.com/xscriptor/xtop.git" +REPO_URL="https://github.com/xtop-cli/xtop.git" INSTALL_DIR="/usr/local/bin" VERSION="1.0.0" diff --git a/plugins/xtop-plugin-sentinel/Cargo.toml b/plugins/xtop-plugin-sentinel/Cargo.toml deleted file mode 100644 index 7bb99d8..0000000 --- a/plugins/xtop-plugin-sentinel/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "xtop-plugin-sentinel" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "AI-aware system resource monitoring and management plugin for xtop" - -[dependencies] -xtop-core = { path = "../../crates/xtop-core" } -ratatui.workspace = true -regex = "1" -serde.workspace = true -serde_json.workspace = true -anyhow.workspace = true diff --git a/plugins/xtop-plugin-sentinel/README.md b/plugins/xtop-plugin-sentinel/README.md deleted file mode 100644 index 4128f91..0000000 --- a/plugins/xtop-plugin-sentinel/README.md +++ /dev/null @@ -1,372 +0,0 @@ -

      Sentinel Plugin

      - -

      Sentinel is an AI-aware system monitoring and management plugin for xtop. It exposes system metrics, process information, and configuration through a simple command interface designed to be consumed by AI agents (including LLM-based assistants).

      - -

      It also includes a built-in MCP server (xtop mcp) that exposes all commands as MCP tools for seamless AI integration.

      - -
      - -

      Features

      - -
        -
      • Read system summary (CPU, memory, disks, network, uptime) as JSON
      • -
      • Query top processes with full details and optional regex filter
      • -
      • Search processes by regex on name, cmd, user, or state fields
      • -
      • Kill processes safely by PID
      • -
      • Get and set alert thresholds
      • -
      • Read and update xtop configuration (theme, layout, interval)
      • -
      • Automatic process anomaly detection (CPU spikes, root processes)
      • -
      • Custom TUI widget for visual status
      • -
      • MCP server over stdio for AI tool integration
      • -
      - -
      - -

      Command Interface

      - -

      All interaction happens through a single entry point:

      - -
      plugin.execute(ctx, "<action>", "<params>") -> Result<String, PluginError>
      - -

      The response is always a JSON string, making it easy for any AI or script to parse.

      - -

      system.summary

      - -

      Returns a high-level snapshot of system health.

      - - - - - - -
      ParamsResponse
      (empty)JSON object
      - -

      Example response:

      - -
      {
      -    "cpu_avg": 23.4,
      -    "mem_used_gb": 8.2,
      -    "mem_total_gb": 32.0,
      -    "mem_pct": 26,
      -    "processes": 342,
      -    "disks": 4,
      -    "interfaces": ["en0", "en1"],
      -    "uptime_secs": 482000,
      -    "hostname": "mbp.local"
      -}
      - -

      processes.top

      - -

      Returns the top N processes sorted by CPU usage.

      - - - - - - -
      ParamsResponse
      Count (e.g. "10")JSON array
      - -

      Example response:

      - -
      [
      -    {"pid": 1234, "name": "firefox", "cpu": 45.2, "mem_bytes": 1048576000, "state": "Running", "user": "1000"},
      -    {"pid": 5678, "name": "python3", "cpu": 12.1, "mem_bytes": 524288000, "state": "Sleeping", "user": "1000"}
      -]
      - -

      process.info

      - -

      Returns detailed information about a single process.

      - - - - - - -
      ParamsResponse
      PID (e.g. "1234")JSON object
      - -

      Example response:

      - -
      {
      -    "pid": 1234,
      -    "name": "firefox",
      -    "cpu": 45.2,
      -    "mem_bytes": 1048576000,
      -    "state": "Running",
      -    "cmd": "/usr/lib/firefox/firefox"
      -}
      - -

      process.kill

      - -

      Terminates a process by PID.

      - - - - - - -
      ParamsResponse
      PID (e.g. "1234")JSON object
      - -

      Example response:

      - -
      {"killed": true, "pid": 1234}
      - -

      threshold.set

      - -

      Sets alert thresholds for CPU, memory, and disk usage.

      - - - - - - -
      ParamsResponse
      "cpu,mem,disk" (percentages)JSON object
      - -

      Example: "90,85,80" sets CPU threshold to 90%, memory to 85%, disk to 80%.

      - -

      Response:

      - -
      {"cpu": 90, "mem": 85, "disk": 80, "set": true}
      - -

      threshold.get

      - -

      Returns the current alert thresholds.

      - - - - - - -
      ParamsResponse
      (empty)JSON object
      - -

      Response:

      - -
      {"cpu": 90, "mem": 90, "disk": 90}
      - -

      config.get

      - -

      Returns the current xtop configuration.

      - - - - - - -
      ParamsResponse
      (empty)JSON object
      - -

      Response:

      - -
      {"theme": "x", "layout": "Dashboard", "interval_ms": 1000, "hostname": "mbp.local"}
      - -

      alerts.status

      - -

      Returns any alerts generated by process analysis heuristics.

      - - - - - - -
      ParamsResponse
      (empty)JSON object
      - -

      Response:

      - -
      {"alerts": "high_cpu: firefox (pid=1234, cpu=95.2%)\\nhigh_cpu: python3 (pid=5678, cpu=88.0%)"}
      - -

      processes.search

      - -

      Search processes using a regex pattern. Supports filtering on specific fields.

      - - - - - - -
      ParamsResponse
      "pattern" or "pattern,fields=f1,f2"JSON array of matching processes
      - -

      Supported fields: name, cmd, user, state. Default: name only.

      - -

      Examples:

      - -
      # Search by name
      -processes.search("firefox")
      -
      -# Regex on name
      -processes.search("/python|node/")
      -
      -# Regex on name AND cmd
      -processes.search("/systemd/,fields=name,cmd")
      -
      -# Filter by user
      -processes.search("/^0$/,fields=user")
      - -

      Response:

      - -
      [{"pid": 1234, "name": "firefox", "cpu": 45.2, "mem_bytes": 1048576000, "state": "Running", "user": "1000", "cmd": "/usr/lib/firefox/firefox"}]
      - -

      processes.top (with filter)

      - -

      Top processes with optional regex filter on name or cmd:

      - -
      # Top 5 processes matching the pattern
      -processes.top("5,filter=/firefox/")
      - -

      config.set

      - -

      Update xtop configuration at runtime.

      - - - - - - -
      ParamsResponse
      "interval_ms=<ms>" or "theme=<name>" or "layout=<name>"JSON object
      - -

      Examples:

      - -
      config.set("interval_ms=2000")   # {"interval_ms": 2000, "set": true}
      -config.set("theme=tokio")        # {"theme": "tokio", "set": true}
      -config.set("layout=Vertical")    # {"layout": "Vertical", "set": true}
      - -

      plugin.status

      - -

      Returns the internal state of the Sentinel plugin.

      - - - - - - -
      ParamsResponse
      (empty)JSON object
      - -

      Response:

      - -
      {"enabled": true, "ticks": 42, "last_action": "system.summary", "last_result": "ok (147 chars)"}
      - -
      - -

      TUI Widget

      - -

      Sentinel registers a widget named "sentinel". Include it in any custom layout by referencing it in a JSONC layout file:

      - -
      {
      -    "name": "monitor",
      -    "root": {
      -        "direction": "vertical",
      -        "areas": [
      -            { "widget": "header", "size": 3 },
      -            { "widget": "sentinel", "size": 6 },
      -            { "widget": "processes", "size": "*" }
      -        ]
      -    }
      -}
      - -

      The widget renders a bordered panel with dark background and accent-colored title showing the agent status and a help hint.

      - -
      - -

      MCP Server

      - -

      Sentinel includes a built-in MCP server that exposes all commands as MCP tools. -Run it with:

      - -
      xtop mcp
      - -

      The server speaks JSON-RPC 2.0 over stdin/stdout (standard MCP transport). -Any MCP-compatible AI can connect:

      - -

      Claude Desktop

      - -
      {
      -  "mcpServers": {
      -    "xtop": {
      -      "command": "xtop",
      -      "args": ["mcp"]
      -    }
      -  }
      -}
      - -

      Interactive testing

      - -
      # Call system_summary
      -echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"system_summary","arguments":{}}}' | xtop mcp
      -
      -# Search processes with regex
      -echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"processes_search","arguments":{"pattern":"/firefox|chrome/"}}}' | xtop mcp
      -
      -# Kill a process
      -echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"process_kill","arguments":{"pid":1234}}}' | xtop mcp
      - -

      Available MCP Tools

      - - - - - - - - - - - - - - - - -
      ToolArgumentsDescription
      system_summary(none)System health summary
      processes_topcount, filter (regex)Top processes
      processes_searchpattern, fieldsRegex search
      process_infopidProcess details
      process_killpidKill process
      threshold_setcpu, mem, diskSet alert thresholds
      threshold_get(none)Get thresholds
      config_get(none)Get config
      config_setinterval_ms / theme / layoutUpdate config
      alerts_status(none)Active alerts
      plugin_status(none)Plugin status
      - -
      - -

      Process Analysis

      - -

      Sentinel includes a heuristic analyzer that runs every 10 ticks (~10 seconds):

      - -
        -
      • Flags processes with CPU usage above 80%
      • -
      • Flags root-owned processes with CPU above 50%
      • -
      • Collects up to 20 alerts per cycle
      • -
      • Alerts are exposed via alerts.status command
      • -
      - -

      The analysis is basic by design. Deeper security heuristics are delegated to domain experts.

      - -
      - -

      AI Integration

      - -

      Sentinel exposes two interfaces for AI agents:

      - -

      Direct execute() (for embedded agents)

      -

      Callable from within xtop's plugin system via PluginManager::execute("sentinel", action, params).

      - -

      MCP protocol (for external AI)

      -

      Run xtop mcp and configure your AI assistant's MCP client to connect. -The AI sees all Sentinel tools as callable functions with typed parameters.

      - -

      Typical AI workflow via MCP:

      - -
        -
      1. AI calls system_summary to get a health overview
      2. -
      3. AI calls processes_top with optional regex filter
      4. -
      5. If suspicious, AI calls process_info for details
      6. -
      7. AI may call process_kill to terminate
      8. -
      9. AI calls alerts_status periodically for anomalies
      10. -
      - -
      - -

      Future Plans

      - -
        -
      • MCP Resources: Push-based monitoring (server notifies AI on alerts)
      • -
      • Real-time streaming: Subscribe to metric changes without polling
      • -
      • Security analysis: Deeper heuristics for malicious process detection
      • -
      • Rich process metadata: Parent PID, executable hash, file descriptors, network sockets
      • -
      - -
      - -

      - Plugin System Docs · - Back to xtop -

      diff --git a/plugins/xtop-plugin-sentinel/src/alert.rs b/plugins/xtop-plugin-sentinel/src/alert.rs deleted file mode 100644 index 4e2266d..0000000 --- a/plugins/xtop-plugin-sentinel/src/alert.rs +++ /dev/null @@ -1,45 +0,0 @@ -use serde::Serialize; - -/// Severity level for a Sentinel alert. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub enum Severity { - /// Low-priority, informative - Info, - /// Moderate concern, shown in widget - Warning, - /// High confidence threat - Critical, -} - -/// A single security alert produced by a heuristic rule. -#[derive(Debug, Clone, Serialize)] -pub struct SentinelAlert { - /// Short rule name (e.g. "suspicious_exe_path", "orphan_process") - pub rule: &'static str, - /// Severity level - pub severity: Severity, - /// Process ID that triggered the alert - pub pid: u32, - /// Process name - pub process_name: String, - /// Human-readable detail message - pub message: String, -} - -impl SentinelAlert { - pub fn new( - rule: &'static str, - severity: Severity, - pid: u32, - process_name: String, - message: String, - ) -> Self { - Self { - rule, - severity, - pid, - process_name, - message, - } - } -} diff --git a/plugins/xtop-plugin-sentinel/src/lib.rs b/plugins/xtop-plugin-sentinel/src/lib.rs deleted file mode 100644 index 98d7e71..0000000 --- a/plugins/xtop-plugin-sentinel/src/lib.rs +++ /dev/null @@ -1,883 +0,0 @@ -pub mod alert; -pub mod mcp; - -use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, Paragraph}; -use regex::Regex; -use std::collections::HashMap; -use std::fmt::Debug; -use xtop_core::application::state::AppState; -use xtop_core::domain::metrics::ProcessInfo; -use xtop_core::domain::plugin::{ - Plugin, PluginCapability, PluginContext, PluginError, PluginManifest, WidgetRegistration, -}; - -use alert::{SentinelAlert, Severity}; - -// --------------------------------------------------------------------------- -// Known threat patterns (Rule 6) -// --------------------------------------------------------------------------- -const KNOWN_THREAT_NAMES: &[&str] = &[ - "minerd", - "cpu_miner", - "xmrig", - "kdevtmpfsi", - "kinsing", - "diagree", - "watchbog", - "sysguard", - "crond64", - "mkfile", - "sysupdate", - "xmrig-nvidia", - "xmrig-amd", - "moneroocean", -]; - -const KNOWN_THREAT_CMDS: &[&str] = &[ - r"--donate-level", - r"--max-cpu-usage", - r"--threads", - r"pool\.monero", - r"pool\.supportxmr", - r"mine\.monero", -]; - -/// Path prefixes that are suspicious for executable locations. -const SUSPICIOUS_PATH_PREFIXES: &[&str] = &[ - "/tmp/", - "/dev/shm/", - "/var/tmp/", - "/proc/", - "/private/tmp/", - "/private/var/tmp/", -]; - -/// Processes allowed to be orphans (PPID=1). -const ALLOWED_ORPHANS: &[&str] = &[ - "systemd", "init", "launchd", "sshd", "login", "getty", "nginx", "apache2", "httpd", "bash", - "sh", "zsh", "tmux", "screen", -]; - -/// Browsers whose children are monitored (Rule 5). -const BROWSER_NAMES: &[&str] = &[ - "chrome", "firefox", "safari", "edge", "brave", "opera", "chromium", -]; - -/// Browser helper/sandbox processes that are allowed children. -const BROWSER_HELPERS: &[&str] = &[ - "helper", - "plugin_container", - "plugin_host", - "gpu_process", - "renderer", - "utility", - "crashpad", - "updater", -]; - -/// Pipe/download patterns for Rule 7. -const PIPE_PATTERNS: &[&str] = &[ - r"curl\s+.*\|\s*(ba|z)?sh", - r"wget\s+.*\|\s*(ba|z)?sh", - r"curl\s+.*\s*ba(?:sh)?\s*$", - r"python3?\s+-c\s+.*(?:import|urllib|requests|socket)", - r"base64\s+-d\s*\|", - r"eval\s*\$\(.*curl", - r"eval\s*\$\(.*wget", - r"bash\s+-c\s+.*\$\(curl", - r"bash\s+-c\s+.*\$\(wget", -]; - -/// High-thread-count processes that are allowed (Rule 8). -const ALLOWED_HIGH_THREAD: &[&str] = &[ - "chrome", "firefox", "code", "Code", "idea", "java", "dotnet", "python", "node", "mysqld", - "postgres", "Xorg", "dockerd", -]; - -/// Maximum alerts per cycle -const MAX_ALERTS: usize = 50; - -// --------------------------------------------------------------------------- -// The plugin struct -// --------------------------------------------------------------------------- - -pub struct SentinelPlugin { - enabled: bool, - tick_count: u64, - last_action: String, - last_action_result: String, - alerts: Vec, - // For spawn storm detection (Rule 10): name -> [(pid, start_time)] - spawn_history: HashMap>, -} - -impl Default for SentinelPlugin { - fn default() -> Self { - Self::new() - } -} - -impl SentinelPlugin { - pub fn new() -> Self { - Self { - enabled: true, - tick_count: 0, - last_action: "none".to_string(), - last_action_result: "ok".to_string(), - alerts: Vec::new(), - spawn_history: HashMap::new(), - } - } - - // ----------------------------------------------------------------------- - // Formatting helpers - // ----------------------------------------------------------------------- - - fn fmt_process(p: &ProcessInfo) -> serde_json::Value { - serde_json::json!({ - "pid": p.pid, - "name": p.name, - "cpu": (p.cpu_usage * 10.0).round() / 10.0, - "mem_bytes": p.memory, - "state": p.state, - "user": p.user_id.as_deref().unwrap_or("?"), - "cmd": p.cmd, - "exe": p.exe_path.as_deref().unwrap_or("?"), - "ppid": p.parent_pid, - "threads": p.thread_count, - "run_time": p.run_time, - "cwd": p.cwd.as_deref().unwrap_or("?"), - }) - } - - fn fmt_process_list(procs: &[ProcessInfo]) -> String { - let entries: Vec = procs.iter().map(Self::fmt_process).collect(); - serde_json::to_string(&entries).unwrap_or_default() - } - - fn fmt_alert(a: &SentinelAlert) -> serde_json::Value { - serde_json::json!({ - "rule": a.rule, - "severity": format!("{:?}", a.severity), - "pid": a.pid, - "process": a.process_name, - "message": a.message, - }) - } - - // ----------------------------------------------------------------------- - // System summary - // ----------------------------------------------------------------------- - - fn system_summary(&self, ctx: &PluginContext) -> String { - let snap = ctx.snapshot(); - let cpu_pct: f64 = - snap.cpus.iter().map(|c| c.usage).sum::() / snap.cpus.len().max(1) as f64; - let mem_gb = (snap.memory.used as f64 / 1073741824.0 * 10.0).round() / 10.0; - let mem_total_gb = (snap.memory.total as f64 / 1073741824.0 * 10.0).round() / 10.0; - let net_ifaces: Vec<&str> = snap.networks.iter().map(|n| n.name.as_str()).collect(); - - serde_json::to_string(&serde_json::json!({ - "cpu_avg": (cpu_pct * 10.0).round() / 10.0, - "mem_used_gb": mem_gb, - "mem_total_gb": mem_total_gb, - "mem_pct": snap.memory.percent.round() as u64, - "processes": snap.processes.len(), - "disks": snap.disks.len(), - "interfaces": net_ifaces, - "uptime_secs": snap.uptime, - "hostname": snap.sys_info.hostname, - "alerts": self.alerts.len(), - })) - .unwrap_or_default() - } - - // ----------------------------------------------------------------------- - // Search / listing - // ----------------------------------------------------------------------- - - fn search_processes(&self, ctx: &PluginContext, params: &str) -> Result { - let (pattern_str, fields) = if let Some(idx) = params.find(",fields=") { - let pat = ¶ms[..idx]; - let fields_part = ¶ms[idx + 8..]; - (pat, fields_part.split(',').collect::>()) - } else { - (params, vec!["name"]) - }; - - let pattern_str = pattern_str.trim(); - if pattern_str.is_empty() { - return Err(PluginError::Recoverable( - "search pattern cannot be empty".into(), - )); - } - - let re = Regex::new(pattern_str) - .map_err(|e| PluginError::Recoverable(format!("invalid regex: {e}")))?; - - let snap = ctx.snapshot(); - let mut matched: Vec = snap - .processes - .into_iter() - .filter(|p| { - fields.iter().any(|f| match *f { - "name" => re.is_match(&p.name), - "cmd" => re.is_match(&p.cmd), - "user" => p.user_id.as_deref().is_some_and(|u| re.is_match(u)), - "state" => re.is_match(&p.state), - "exe" => p.exe_path.as_deref().is_some_and(|e| re.is_match(e)), - "cwd" => p.cwd.as_deref().is_some_and(|c| re.is_match(c)), - _ => false, - }) - }) - .collect(); - - matched.sort_by(|a, b| { - b.cpu_usage - .partial_cmp(&a.cpu_usage) - .unwrap_or(std::cmp::Ordering::Equal) - }); - matched.truncate(100); - Ok(Self::fmt_process_list(&matched)) - } - - fn top_processes(&self, ctx: &PluginContext, params: &str) -> Result { - let (count_str, filter_pattern) = if let Some(idx) = params.find(",filter=") { - let cnt = ¶ms[..idx]; - let pat = ¶ms[idx + 8..]; - (cnt, Some(pat)) - } else { - (params, None) - }; - - let count = count_str.parse::().unwrap_or(10); - if count == 0 { - return Err(PluginError::Recoverable("count must be > 0".into())); - } - let snap = ctx.snapshot(); - let mut procs: Vec = snap.processes; - - if let Some(pattern) = filter_pattern { - let re = Regex::new(pattern) - .map_err(|e| PluginError::Recoverable(format!("invalid regex in filter: {e}")))?; - procs.retain(|p| { - re.is_match(&p.name) - || re.is_match(&p.cmd) - || p.exe_path.as_deref().is_some_and(|e| re.is_match(e)) - }); - } - - procs.truncate(count); - Ok(Self::fmt_process_list(&procs)) - } - - fn process_info(&self, ctx: &PluginContext, pid_str: &str) -> Result { - let pid = pid_str - .parse::() - .map_err(|_| PluginError::Recoverable(format!("invalid pid: {pid_str}")))?; - let snap = ctx.snapshot(); - let proc = snap - .processes - .iter() - .find(|p| p.pid == pid) - .ok_or_else(|| PluginError::Recoverable(format!("process {pid} not found")))?; - Ok(serde_json::to_string(&Self::fmt_process(proc)).unwrap_or_default()) - } - - // ----------------------------------------------------------------------- - // Heuristic rules - // ----------------------------------------------------------------------- - - /// Rule 1: Executable running from a suspicious path. - fn rule_suspicious_exe_path(&self, proc: &ProcessInfo) -> Option { - let exe = proc.exe_path.as_deref()?; - if SUSPICIOUS_PATH_PREFIXES.iter().any(|p| exe.starts_with(p)) { - Some(SentinelAlert::new( - "suspicious_exe_path", - Severity::Critical, - proc.pid, - proc.name.clone(), - format!("executable runs from suspicious path: {exe}"), - )) - } else { - None - } - } - - /// Rule 2: Orphan process (PPID=1) that is not a known system daemon. - fn rule_orphan_process(&self, proc: &ProcessInfo) -> Option { - if proc.parent_pid != Some(1) { - return None; - } - let name_lower = proc.name.to_lowercase(); - if ALLOWED_ORPHANS.iter().any(|a| name_lower.contains(a)) { - return None; - } - let severity = if proc.run_time < 60 { - Severity::Critical - } else { - Severity::Warning - }; - Some(SentinelAlert::new( - "orphan_process", - severity, - proc.pid, - proc.name.clone(), - format!( - "orphan process (PPID=1), running for {}s, exe: {}", - proc.run_time, - proc.exe_path.as_deref().unwrap_or("?"), - ), - )) - } - - /// Rule 3: Process masquerading (name != exe file stem OR system name not at canonical path). - fn rule_masquerading(&self, proc: &ProcessInfo) -> Option { - let exe = proc.exe_path.as_deref()?; - // Extract file stem from exe - let stem = std::path::Path::new(exe) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(""); - let name_lower = proc.name.to_lowercase(); - let stem_lower = stem.to_lowercase(); - - // Check if name is a known system process but exe is not at a canonical path - let known_system_names = ["svchost", "lsass", "launchd", "sshd", "systemd", "init"]; - if known_system_names.contains(&name_lower.as_str()) { - let canonical = exe.starts_with("/usr/") - || exe.starts_with("/bin/") - || exe.starts_with("/sbin/") - || exe.starts_with("/System/"); - if !canonical { - return Some(SentinelAlert::new( - "process_masquerading", - Severity::Critical, - proc.pid, - proc.name.clone(), - format!( - "process name '{}' masquerades as system process; exe: {exe}", - proc.name - ), - )); - } - } - - // Check if name differs significantly from exe file stem - if name_lower != stem_lower && !stem_lower.is_empty() { - // Allow common cases like "python3" -> exe "/usr/bin/python3.11" - if !exe.contains(&name_lower) && !name_lower.contains(&stem_lower) { - return Some(SentinelAlert::new( - "process_masquerading", - Severity::Warning, - proc.pid, - proc.name.clone(), - format!( - "name '{}' differs from exe stem '{stem}' ({exe})", - proc.name - ), - )); - } - } - - None - } - - /// Rule 4: Privilege escalation (EUID != UID). - fn rule_privilege_escalation(&self, proc: &ProcessInfo) -> Option { - let euid = proc.effective_user_id.as_deref()?; - let uid = proc.user_id.as_deref()?; - if euid == uid { - return None; - } - // Known SUID binaries that are allowed - let known_suid = [ - "/usr/bin/sudo", - "/usr/bin/passwd", - "/bin/ping", - "/usr/bin/ping", - "/bin/su", - "/usr/bin/su", - "/usr/bin/newgrp", - "/usr/bin/gpasswd", - "/usr/bin/chsh", - "/usr/bin/chfn", - "/usr/bin/mount", - "/usr/bin/umount", - ]; - let exe = proc.exe_path.as_deref().unwrap_or(""); - if known_suid.contains(&exe) { - return None; - } - let severity = if euid == "0" { - Severity::Critical - } else { - Severity::Warning - }; - Some(SentinelAlert::new( - "privilege_escalation", - severity, - proc.pid, - proc.name.clone(), - format!("EUID ({euid}) != UID ({uid}), exe: {exe}"), - )) - } - - /// Rule 5: Suspicious child of a browser process. - fn rule_suspicious_child_of_browser( - &self, - proc: &ProcessInfo, - parent_map: &HashMap, - ) -> Option { - let ppid = proc.parent_pid?; - let parent = parent_map.get(&ppid)?; - let parent_lower = parent.name.to_lowercase(); - let is_browser = BROWSER_NAMES.iter().any(|b| parent_lower.contains(b)); - if !is_browser { - return None; - } - let child_lower = proc.name.to_lowercase(); - let is_helper = BROWSER_HELPERS.iter().any(|h| child_lower.contains(h)); - if is_helper { - return None; - } - Some(SentinelAlert::new( - "suspicious_child_of_browser", - Severity::Warning, - proc.pid, - proc.name.clone(), - format!( - "browser '{}' spawned unknown child '{}'", - parent.name, proc.name - ), - )) - } - - /// Rule 6: Known threat pattern (name or cmd matches miner/rootkit names). - fn rule_known_threat_pattern(&self, proc: &ProcessInfo) -> Option { - let name_lower = proc.name.to_lowercase(); - if KNOWN_THREAT_NAMES.iter().any(|t| name_lower.contains(t)) { - return Some(SentinelAlert::new( - "known_threat_pattern", - Severity::Critical, - proc.pid, - proc.name.clone(), - format!("process name matches known threat pattern: {}", proc.name), - )); - } - let cmd_joined = proc.cmd_full.join(" ").to_lowercase(); - if KNOWN_THREAT_CMDS.iter().any(|t| { - Regex::new(t) - .ok() - .is_some_and(|re| re.is_match(&cmd_joined)) - }) { - return Some(SentinelAlert::new( - "known_threat_pattern", - Severity::Critical, - proc.pid, - proc.name.clone(), - format!("command line matches known threat pattern: {}", proc.cmd), - )); - } - None - } - - /// Rule 7: Suspicious pipe/download pattern in command line. - fn rule_suspicious_pipe_or_download(&self, proc: &ProcessInfo) -> Option { - let cmd_joined = proc.cmd_full.join(" "); - for pattern in PIPE_PATTERNS { - if let Ok(re) = Regex::new(pattern) { - if re.is_match(&cmd_joined) { - return Some(SentinelAlert::new( - "suspicious_pipe_or_download", - Severity::Critical, - proc.pid, - proc.name.clone(), - format!("command matches pipe/download pattern: {}", proc.cmd), - )); - } - } - } - None - } - - /// Rule 8: High thread count anomaly. - fn rule_high_thread_anomaly(&self, proc: &ProcessInfo) -> Option { - if proc.thread_count < 500 { - return None; - } - let name_lower = proc.name.to_lowercase(); - if ALLOWED_HIGH_THREAD.iter().any(|a| name_lower.contains(a)) { - return None; - } - let severity = if proc.thread_count > 1000 || proc.cpu_usage > 200.0 { - Severity::Critical - } else { - Severity::Warning - }; - Some(SentinelAlert::new( - "high_thread_anomaly", - severity, - proc.pid, - proc.name.clone(), - format!( - "{} threads (CPU: {:.1}%)", - proc.thread_count, proc.cpu_usage - ), - )) - } - - /// Rule 9: Suspicious file descriptor anomaly. - fn rule_suspicious_fd_anomaly(&self, proc: &ProcessInfo) -> Option { - if proc.open_files < 1000 { - return None; - } - let name_lower = proc.name.to_lowercase(); - let allowed = [ - "mysql", "postgres", "nginx", "httpd", "apache", "chrome", "firefox", "code", "java", - "dotnet", "dockerd", - ]; - if allowed.iter().any(|a| name_lower.contains(a)) { - return None; - } - Some(SentinelAlert::new( - "suspicious_fd_anomaly", - Severity::Info, - proc.pid, - proc.name.clone(), - format!("{} open file descriptors", proc.open_files), - )) - } - - /// Rule 10: Spawn storm detection. - fn rule_spawn_storm(&mut self, proc: &ProcessInfo, now_run_time: u64) -> Option { - if proc.run_time > 120 { - return None; - } - let entry = self.spawn_history.entry(proc.name.clone()).or_default(); - entry.push((proc.pid, proc.start_time)); - // Purge entries older than 120s - entry.retain(|(_, start)| now_run_time.saturating_sub(*start) < 120); - if entry.len() > 5 { - Some(SentinelAlert::new( - "recent_spawn_storm", - Severity::Warning, - proc.pid, - proc.name.clone(), - format!("{} new instances in last 120s", entry.len()), - )) - } else { - None - } - } - - // ----------------------------------------------------------------------- - // Main analyzer - // ----------------------------------------------------------------------- - - fn analyze_processes(&mut self, ctx: &PluginContext) -> Vec { - let snap = ctx.snapshot(); - let mut alerts: Vec = Vec::new(); - - // Build parent PID map for Rule 5 - let parent_map: HashMap = - snap.processes.iter().map(|p| (p.pid, p)).collect(); - - let now_run_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - for proc in &snap.processes { - // Run rules in priority order - if let Some(a) = self.rule_suspicious_exe_path(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_masquerading(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_known_threat_pattern(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_suspicious_pipe_or_download(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_orphan_process(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_privilege_escalation(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_suspicious_child_of_browser(proc, &parent_map) { - alerts.push(a); - } - if let Some(a) = self.rule_high_thread_anomaly(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_suspicious_fd_anomaly(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_spawn_storm(proc, now_run_time) { - alerts.push(a); - } - } - - alerts.truncate(MAX_ALERTS); - alerts - } - - fn parse_thresholds(params: &str) -> Result<(f64, f64, f64), PluginError> { - let parts: Vec<&str> = params.split(',').collect(); - if parts.len() != 3 { - return Err(PluginError::Recoverable( - "expected cpu,mem,disk (3 comma-separated values)".into(), - )); - } - let cpu = parts[0] - .parse::() - .map_err(|e| PluginError::Recoverable(format!("invalid cpu threshold: {e}")))?; - let mem = parts[1] - .parse::() - .map_err(|e| PluginError::Recoverable(format!("invalid mem threshold: {e}")))?; - let disk = parts[2] - .parse::() - .map_err(|e| PluginError::Recoverable(format!("invalid disk threshold: {e}")))?; - Ok((cpu, mem, disk)) - } -} - -impl Debug for SentinelPlugin { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SentinelPlugin") - .field("enabled", &self.enabled) - .field("tick_count", &self.tick_count) - .field("alerts", &self.alerts.len()) - .finish() - } -} - -impl Plugin for SentinelPlugin { - fn manifest(&self) -> PluginManifest { - PluginManifest { - id: "sentinel".to_string(), - name: "Sentinel".to_string(), - version: "0.1.0".to_string(), - description: "AI-aware system monitoring, management, and heuristic threat detection" - .to_string(), - capabilities: vec![ - PluginCapability::ReadSystemInfo, - PluginCapability::KillProcesses, - PluginCapability::ModifyConfig, - PluginCapability::RenderWidgets, - ], - } - } - - fn on_enable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { - self.enabled = true; - Ok(()) - } - - fn on_disable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { - self.enabled = false; - Ok(()) - } - - fn on_tick(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError> { - self.tick_count += 1; - if self.tick_count.is_multiple_of(5) { - self.alerts = self.analyze_processes(ctx); - } - Ok(()) - } - - fn widget(&self) -> Option { - Some(WidgetRegistration { - name: "sentinel".to_string(), - render: std::sync::Arc::new(|f: &mut ratatui::Frame, _state: &AppState, area: Rect| { - use xtop_core::domain::theme::hex_to_rgb_pub; - let bg = hex_to_rgb_pub("#1a1b2e"); - let fg = hex_to_rgb_pub("#7ec8e3"); - let accent = hex_to_rgb_pub("#c084fc"); - - let block = Block::default() - .title(" Sentinel ") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(accent[0], accent[1], accent[2]))) - .style( - Style::default() - .bg(Color::Rgb(bg[0], bg[1], bg[2])) - .fg(Color::Rgb(fg[0], fg[1], fg[2])), - ); - let inner = block.inner(area); - f.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .split(inner); - - let status = Paragraph::new("Agent: monitoring for threats -- use MCP to interact") - .style(Style::default().fg(Color::Rgb(accent[0], accent[1], accent[2]))); - f.render_widget(status, chunks[0]); - - let info = Paragraph::new("run xtop mcp for AI tool integration") - .style(Style::default().fg(Color::Rgb(fg[0], fg[1], fg[2]))); - f.render_widget(info, chunks[1]); - }), - }) - } - - fn execute( - &mut self, - ctx: &mut PluginContext, - action: &str, - params: &str, - ) -> Result { - self.last_action = format!("{}({})", action, params); - - let result = match action { - "system.summary" => Ok(self.system_summary(ctx)), - "processes.top" => self.top_processes(ctx, params), - "processes.search" => self.search_processes(ctx, params), - "process.info" => self.process_info(ctx, params), - "process.kill" => { - let pid = params - .parse::() - .map_err(|_| PluginError::Recoverable(format!("invalid pid: {params}")))?; - let ok = ctx - .kill_process(pid) - .map_err(|e| PluginError::Recoverable(e.to_string()))?; - Ok(serde_json::to_string(&serde_json::json!({ - "killed": ok, - "pid": pid, - })) - .unwrap_or_default()) - } - "process.alerts" => { - let alerts_json: Vec = - self.alerts.iter().map(Self::fmt_alert).collect(); - Ok(serde_json::to_string(&alerts_json).unwrap_or_default()) - } - "threshold.set" => { - let (cpu, mem, disk) = Self::parse_thresholds(params)?; - ctx.set_alert_thresholds(cpu, mem, disk) - .map_err(|e| PluginError::Recoverable(e.to_string()))?; - Ok(serde_json::to_string(&serde_json::json!({ - "cpu": cpu, "mem": mem, "disk": disk, "set": true, - })) - .unwrap_or_default()) - } - "threshold.get" => { - let alerts = ctx.state().alerts; - Ok(serde_json::to_string(&serde_json::json!({ - "cpu": alerts.cpu_high, - "mem": alerts.mem_high, - "disk": alerts.disk_high, - })) - .unwrap_or_default()) - } - "config.get" => { - let s = ctx.state(); - Ok(serde_json::to_string(&serde_json::json!({ - "theme": s.current_theme.name, - "layout": s.current_layout_name(), - "interval_ms": s.update_interval_ms, - "hostname": s.sys_info.hostname, - })) - .unwrap_or_default()) - } - "config.set" => { - if let Some(val) = params.strip_prefix("interval_ms=") { - let ms = val.parse::().map_err(|e| { - PluginError::Recoverable(format!("invalid interval_ms: {e}")) - })?; - ctx.set_update_interval(ms) - .map_err(|e| PluginError::Recoverable(e.to_string()))?; - Ok(serde_json::to_string(&serde_json::json!({ - "interval_ms": ms, "set": true, - })) - .unwrap_or_default()) - } else if let Some(name) = params.strip_prefix("theme=") { - let ok = ctx - .set_theme_by_name(name) - .map_err(|e| PluginError::Recoverable(e.to_string()))?; - Ok(serde_json::to_string(&serde_json::json!({ - "theme": name, "set": ok, - })) - .unwrap_or_default()) - } else if let Some(name) = params.strip_prefix("layout=") { - let ok = ctx - .set_layout_by_name(name) - .map_err(|e| PluginError::Recoverable(e.to_string()))?; - Ok(serde_json::to_string(&serde_json::json!({ - "layout": name, "set": ok, - })) - .unwrap_or_default()) - } else { - Err(PluginError::Recoverable( - "expected interval_ms=, theme=, or layout=".into(), - )) - } - } - "alerts.status" => { - let critical = self - .alerts - .iter() - .filter(|a| matches!(a.severity, Severity::Critical)) - .count(); - let warning = self - .alerts - .iter() - .filter(|a| matches!(a.severity, Severity::Warning)) - .count(); - let info_count = self - .alerts - .iter() - .filter(|a| matches!(a.severity, Severity::Info)) - .count(); - let top: Vec = - self.alerts.iter().take(5).map(Self::fmt_alert).collect(); - Ok(serde_json::to_string(&serde_json::json!({ - "total": self.alerts.len(), - "critical": critical, - "warning": warning, - "info": info_count, - "alerts": top, - })) - .unwrap_or_default()) - } - "plugin.status" => { - let critical = self - .alerts - .iter() - .filter(|a| matches!(a.severity, Severity::Critical)) - .count(); - Ok(serde_json::to_string(&serde_json::json!({ - "enabled": self.enabled, - "ticks": self.tick_count, - "last_action": self.last_action, - "last_result": self.last_action_result, - "active_alerts": self.alerts.len(), - "critical_alerts": critical, - })) - .unwrap_or_default()) - } - _ => { - return Err(PluginError::UnknownAction(action.to_string())); - } - }; - - match &result { - Ok(r) => self.last_action_result = format!("ok ({} chars)", r.len()), - Err(e) => self.last_action_result = format!("error: {e}"), - } - - result - } -} diff --git a/plugins/xtop-plugin-sentinel/src/mcp.rs b/plugins/xtop-plugin-sentinel/src/mcp.rs deleted file mode 100644 index d6a34c4..0000000 --- a/plugins/xtop-plugin-sentinel/src/mcp.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! MCP (Model Context Protocol) server for the Sentinel plugin. -//! -//! Runs on stdio transport and exposes Sentinel's `execute()` commands as MCP tools. -//! Any MCP-compatible AI (Claude Desktop, Cline, etc.) can connect via: -//! -//! ```json -//! { -//! "mcpServers": { -//! "xtop": { -//! "command": "xtop", -//! "args": ["mcp"] -//! } -//! } -//! } -//! ``` -//! -//! Protocol: JSON-RPC 2.0 over stdin/stdout (one JSON object per line). - -use std::io::{self, BufRead, Write}; -use xtop_core::application::state::AppState; - -const SERVER_NAME: &str = "xtop-sentinel"; -const SERVER_VERSION: &str = "0.1.0"; -const PROTOCOL_VERSION: &str = "2024-11-05"; - -/// Run the MCP server loop. -/// -/// Reads JSON-RPC messages from stdin, processes them via the Sentinel plugin's -/// `execute()` interface, and writes responses to stdout. -/// -/// `state` must already have the Sentinel plugin registered in its PluginManager. -pub fn run_server(state: &mut AppState) -> anyhow::Result<()> { - let stdin = io::stdin(); - let stdout = io::stdout(); - let mut stdout_lock = stdout.lock(); - - for line in stdin.lock().lines() { - let line = line.map_err(|e| anyhow::anyhow!("stdin read error: {e}"))?; - let line = line.trim().to_string(); - if line.is_empty() { - continue; - } - - let parsed: serde_json::Value = - serde_json::from_str(&line).map_err(|e| anyhow::anyhow!("invalid JSON-RPC: {e}"))?; - - let id = parsed.get("id").cloned(); - let method = parsed.get("method").and_then(|m| m.as_str()).unwrap_or(""); - - let params = parsed - .get("params") - .cloned() - .unwrap_or(serde_json::Value::Null); - - let response = match method { - "initialize" => handle_initialize(id, ¶ms), - "tools/list" => handle_tools_list(id), - "tools/call" => handle_tools_call(id, ¶ms, state), - _ => make_error(id, -32601, format!("Method not found: {method}")), - }; - - let response_line = serde_json::to_string(&response)?; - writeln!(stdout_lock, "{response_line}")?; - stdout_lock.flush()?; - } - - Ok(()) -} - -// --------------------------------------------------------------------------- -// JSON-RPC helpers -// --------------------------------------------------------------------------- - -fn make_result(id: Option, result: serde_json::Value) -> serde_json::Value { - serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "result": result - }) -} - -fn make_error(id: Option, code: i32, message: String) -> serde_json::Value { - serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": code, "message": message } - }) -} - -// --------------------------------------------------------------------------- -// MCP: initialize -// --------------------------------------------------------------------------- - -fn handle_initialize( - id: Option, - _params: &serde_json::Value, -) -> serde_json::Value { - make_result( - id, - serde_json::json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": { "tools": {} }, - "serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION } - }), - ) -} - -// --------------------------------------------------------------------------- -// MCP: tools/list -// --------------------------------------------------------------------------- - -fn handle_tools_list(id: Option) -> serde_json::Value { - make_result( - id, - serde_json::json!({ - "tools": [ - { - "name": "system_summary", - "description": "Get a high-level system health summary (CPU, memory, disks, network, uptime, hostname)", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "processes_top", - "description": "Get top N processes by CPU usage, with optional regex filter", - "inputSchema": { - "type": "object", - "properties": { - "count": { "type": "integer", "description": "Number of processes (default 10)", "default": 10 }, - "filter": { "type": "string", "description": "Optional regex to filter by name or command" } - } - } - }, - { - "name": "processes_search", - "description": "Search processes using regex. Fields: name, cmd, user, state, exe, cwd", - "inputSchema": { - "type": "object", - "properties": { - "pattern": { "type": "string", "description": "Regex pattern" }, - "fields": { "type": "string", "description": "Fields to search: name,cmd,user,state,exe,cwd (default: name)" } - }, - "required": ["pattern"] - } - }, - { - "name": "process_info", - "description": "Get detailed info about a process by PID (includes exe, ppid, threads, cwd)", - "inputSchema": { - "type": "object", - "properties": { - "pid": { "type": "integer", "description": "Process ID" } - }, - "required": ["pid"] - } - }, - { - "name": "process_kill", - "description": "Terminate a process by PID", - "inputSchema": { - "type": "object", - "properties": { - "pid": { "type": "integer", "description": "Process ID to kill" } - }, - "required": ["pid"] - } - }, - { - "name": "threshold_set", - "description": "Set alert thresholds for CPU, memory, and disk (percentages)", - "inputSchema": { - "type": "object", - "properties": { - "cpu": { "type": "number", "description": "CPU threshold" }, - "mem": { "type": "number", "description": "Memory threshold" }, - "disk": { "type": "number", "description": "Disk threshold" } - }, - "required": ["cpu", "mem", "disk"] - } - }, - { - "name": "threshold_get", - "description": "Get current alert threshold values", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "config_get", - "description": "Get current xtop configuration", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "config_set", - "description": "Update configuration: interval_ms, theme, or layout", - "inputSchema": { - "type": "object", - "properties": { - "interval_ms": { "type": "integer", "description": "Update interval in milliseconds" }, - "theme": { "type": "string", "description": "Theme name" }, - "layout": { "type": "string", "description": "Layout name" } - } - } - }, - { - "name": "process_alerts", - "description": "Get all heuristic alerts as a JSON array (suspicious_exe_path, masquerading, known_threat, pipe_download, orphan, privilege_escalation, browser_child, thread_anomaly, fd_anomaly, spawn_storm)", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "alerts_status", - "description": "Get alert summary with counts by severity (critical, warning, info)", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "plugin_status", - "description": "Get Sentinel plugin internal status", - "inputSchema": { "type": "object", "properties": {} } - } - ] - }), - ) -} - -// --------------------------------------------------------------------------- -// MCP: tools/call -// --------------------------------------------------------------------------- - -fn handle_tools_call( - id: Option, - params: &serde_json::Value, - state: &mut AppState, -) -> serde_json::Value { - let name = params.get("name").and_then(|n| n.as_str()).unwrap_or(""); - let args = params - .get("arguments") - .cloned() - .unwrap_or(serde_json::Value::Null); - - // Map MCP tool name -> Sentinel action + params string - let (action, params_str): (&str, String) = match name { - "system_summary" => ("system.summary", String::new()), - - "processes_top" => { - let count = args.get("count").and_then(|c| c.as_i64()).unwrap_or(10); - let filter = args.get("filter").and_then(|f| f.as_str()); - let p = match filter { - Some(f) => format!("{count},filter={f}"), - None => count.to_string(), - }; - ("processes.top", p) - } - - "processes_search" => { - let pattern = args.get("pattern").and_then(|p| p.as_str()).unwrap_or(""); - let fields = args.get("fields").and_then(|f| f.as_str()); - let p = match fields { - Some(f) => format!("{pattern},fields={f}"), - None => pattern.to_string(), - }; - ("processes.search", p) - } - - "process_info" => { - let pid = match args.get("pid").and_then(|p| p.as_i64()) { - Some(p) => p.to_string(), - None => return make_error(id, -32602, "missing required argument: pid".into()), - }; - ("process.info", pid) - } - - "process_kill" => { - let pid = match args.get("pid").and_then(|p| p.as_i64()) { - Some(p) => p.to_string(), - None => return make_error(id, -32602, "missing required argument: pid".into()), - }; - ("process.kill", pid) - } - - "threshold_set" => { - let cpu = match args.get("cpu").and_then(|c| c.as_f64()) { - Some(v) => v.to_string(), - None => return make_error(id, -32602, "missing required argument: cpu".into()), - }; - let mem = match args.get("mem").and_then(|m| m.as_f64()) { - Some(v) => v.to_string(), - None => return make_error(id, -32602, "missing required argument: mem".into()), - }; - let disk = match args.get("disk").and_then(|d| d.as_f64()) { - Some(v) => v.to_string(), - None => return make_error(id, -32602, "missing required argument: disk".into()), - }; - ("threshold.set", format!("{cpu},{mem},{disk}")) - } - - "threshold_get" => ("threshold.get", String::new()), - "config_get" => ("config.get", String::new()), - - "config_set" => { - if let Some(ms) = args.get("interval_ms").and_then(|v| v.as_i64()) { - ("config.set", format!("interval_ms={ms}")) - } else if let Some(theme) = args.get("theme").and_then(|v| v.as_str()) { - ("config.set", format!("theme={theme}")) - } else if let Some(layout) = args.get("layout").and_then(|v| v.as_str()) { - ("config.set", format!("layout={layout}")) - } else { - return make_error(id, -32602, "expected interval_ms, theme, or layout".into()); - } - } - - "process_alerts" => ("process.alerts", String::new()), - "alerts_status" => ("alerts.status", String::new()), - "plugin_status" => ("plugin.status", String::new()), - - _ => return make_error(id, -32601, format!("Tool not found: {name}")), - }; - - // Tick to refresh data (also ticks plugins) - state.on_tick(); - - // Execute via Sentinel plugin - let result_str = state - .with_plugin_manager_mut(|mgr, this| mgr.execute(this, "sentinel", action, ¶ms_str)); - - match result_str { - Ok(json_str) => make_result( - id, - serde_json::json!({ - "content": [{"type": "text", "text": json_str}] - }), - ), - Err(e) => make_error(id, -32000, e.to_string()), - } -} diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..fcccb12 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Local CI for the xtop kernel repo (single-crate layout). +# +# Intentionally NOT wired into git: no GitHub Actions, no git hooks. Run it +# yourself from the repo root: +# +# ./scripts/ci.sh # run every stage +# ./scripts/ci.sh fmt # run one stage +# +# Stages: fmt | clippy | check | test | no-default +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ ! -f Cargo.toml ]]; then + echo "[ci] no Cargo.toml at repo root; nothing to run." + exit 0 +fi + +stages=(fmt clippy check test no-default) +requested=("$@") +if [[ ${#requested[@]} -eq 0 ]]; then + requested=("${stages[@]}") +fi + +fmt() { + echo "==> fmt (format check)" + cargo fmt --all -- --check +} + +clippy() { + echo "==> clippy (all targets, warnings denied)" + cargo clippy --all-targets -- -D warnings +} + +check() { + echo "==> check" + cargo check +} + +test() { + echo "==> test" + cargo test +} + +no_default() { + echo "==> check core only (built without the samurai plugin and mcp)" + cargo check --no-default-features +} + +for stage in "${requested[@]}"; do + case "$stage" in + fmt) fmt ;; + clippy) clippy ;; + check) check ;; + test) test ;; + no-default) no_default ;; + *) + echo "[ci] unknown stage: $stage (expected one of: ${stages[*]})" >&2 + exit 1 + ;; + esac +done + +echo "[ci] all stages passed." diff --git a/src/commands/mcp.rs b/src/commands/mcp.rs new file mode 100644 index 0000000..27bf692 --- /dev/null +++ b/src/commands/mcp.rs @@ -0,0 +1,42 @@ +//! MCP server command. +//! +//! Runs the `mcp` server provided by the `xtop-extension-mcp` extension over +//! a freshly initialized application state. The extension drives xtop +//! through the `xtop-extension-api` host contract (tick + plugin actions), +//! so the kernel only wires state into the extension context. +//! +//! Requires both the `plugin-samurai` (tools execute against it) and the +//! `mcp-extension` features. + +#![cfg_attr( + not(all(feature = "plugin-samurai", feature = "mcp-extension")), + allow(dead_code, unused_imports) +)] + +use super::share::bootstrap::initialize_state; +use crate::config; + +#[cfg(all(feature = "plugin-samurai", feature = "mcp-extension"))] +use xtop_extension_api::Extension; + +/// Run the MCP server. +pub fn run_mcp_server() -> anyhow::Result<()> { + #[cfg(all(feature = "plugin-samurai", feature = "mcp-extension"))] + { + let cfg_dir = config::config_dir(); + let mut state = initialize_state(&cfg_dir)?; + + let mut extension = xtop_extension_mcp::McpExtension::new(); + let mut ctx = xtop_extension_api::ExtensionContext::new(&mut state); + extension + .run_server("mcp", &mut ctx) + .map_err(|e| anyhow::anyhow!("mcp server error: {e}")) + } + + #[cfg(not(all(feature = "plugin-samurai", feature = "mcp-extension")))] + { + eprintln!("MCP server requires the 'plugin-samurai' and 'mcp-extension' features."); + eprintln!("Rebuild with: cargo build --features plugin-samurai,mcp-extension"); + std::process::exit(1); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..cda9e37 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,10 @@ +//! CLI commands: interactive run, MCP server and plugin management. +//! +//! Shared assembly and asset helpers live under [`share`]. + +pub mod mcp; +pub mod plugins; +pub mod run; +pub(crate) mod share; + +pub(crate) use share::*; diff --git a/src/commands/plugins.rs b/src/commands/plugins.rs new file mode 100644 index 0000000..168db52 --- /dev/null +++ b/src/commands/plugins.rs @@ -0,0 +1,368 @@ +//! Plugin management subcommands (list, install, scaffold). + +use std::fs; + +/// Handle `xtop plugin ` from the parsed argument vector. +pub fn plugin_command(args: &[String]) -> anyhow::Result<()> { + if args.len() < 3 { + eprintln!("Usage: xtop plugin "); + return Ok(()); + } + match args[2].as_str() { + "list" => { + cmd_plugin_list(); + Ok(()) + } + "install" => { + if args.len() < 4 { + eprintln!("Usage: xtop plugin install "); + return Ok(()); + } + cmd_plugin_install(&args[3]) + } + "scaffold" => { + if args.len() < 4 { + eprintln!("Usage: xtop plugin scaffold "); + return Ok(()); + } + cmd_plugin_scaffold(&args[3]) + } + _ => { + eprintln!("Unknown plugin subcommand: {}", args[2]); + Ok(()) + } + } +} + +use std::path::PathBuf; + +fn is_git_url(s: &str) -> bool { + s.contains("://") || s.contains("github.com") || s.contains("git@") +} + +fn cmd_plugin_list() { + let workspace_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("Cargo.toml"); + + let content = match fs::read_to_string(&workspace_path) { + Ok(c) => c, + Err(e) => { + eprintln!("Error reading workspace Cargo.toml: {e}"); + return; + } + }; + + // Parse workspace members for plugin crates + let mut in_members = false; + let mut plugins: Vec = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("members") { + in_members = true; + continue; + } + if in_members { + if trimmed == "]" { + break; + } + let name = trimmed.trim_matches(',').trim().trim_matches('"'); + if name.starts_with("plugins/xtop-plugin-") || name.starts_with("crates/xtop-plugin-") { + plugins.push(name.to_string()); + } + } + } + + if plugins.is_empty() { + println!("No plugins installed."); + return; + } + println!("Installed plugins:"); + for p in &plugins { + println!(" {p}"); + } +} + +fn cmd_plugin_install(name_or_url: &str) -> anyhow::Result<()> { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_dir = manifest_dir.parent().unwrap().parent().unwrap(); + let workspace_toml = workspace_dir.join("Cargo.toml"); + let cli_toml = manifest_dir.join("Cargo.toml"); + let plugins_dir = workspace_dir.join("plugins"); + + let tmp = std::env::temp_dir().join("xtop-plugin-install"); + let _ = fs::remove_dir_all(&tmp); + + let repo_url: &str; + let mut plugin_subdir: String = String::new(); + + if is_git_url(name_or_url) { + // URL-based: clone the repo directly + repo_url = name_or_url; + println!("Cloning {repo_url} ..."); + let status = std::process::Command::new("git") + .args(["clone", repo_url, tmp.to_str().unwrap()]) + .status() + .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?; + if !status.success() { + anyhow::bail!("git clone failed"); + } + } else { + // Name-based: look in xtop repo's plugins/ directory + repo_url = "https://github.com/xtop-cli/xtop.git"; + let candidate_names = [ + format!("plugins/xtop-plugin-{name_or_url}"), + format!("plugins/{name_or_url}"), + ]; + println!("Looking for plugin '{name_or_url}' in {repo_url} ..."); + let status = std::process::Command::new("git") + .args([ + "clone", + "--depth", + "1", + "--filter=blob:none", + "--sparse", + repo_url, + tmp.to_str().unwrap(), + ]) + .status() + .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?; + if !status.success() { + anyhow::bail!("git clone failed"); + } + + // Try each candidate path + let mut found = false; + for candidate in &candidate_names { + if tmp.join(candidate).join("Cargo.toml").exists() { + plugin_subdir = candidate.clone(); + found = true; + break; + } + } + if !found { + let _ = fs::remove_dir_all(&tmp); + anyhow::bail!( + "Plugin '{name_or_url}' not found in plugins/. \ + Tried: {}", + candidate_names.join(", ") + ); + } + println!("Found plugin at {plugin_subdir}"); + } + + // --- Determine the plugin source directory --- + let plugin_src = if plugin_subdir.is_empty() { + // URL-based: cloned repo root + tmp.clone() + } else { + // Name-based: subdirectory within cloned xtop repo + tmp.join(&plugin_subdir) + }; + + // --- Read the plugin's Cargo.toml to get the package name --- + let plugin_toml_path = plugin_src.join("Cargo.toml"); + let plugin_toml_content = fs::read_to_string(&plugin_toml_path) + .map_err(|e| anyhow::anyhow!("No Cargo.toml found: {e}"))?; + let plugin_pkg: toml::Value = plugin_toml_content + .parse() + .map_err(|e| anyhow::anyhow!("Invalid Cargo.toml: {e}"))?; + + let pkg_name = plugin_pkg + .get("package") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + .ok_or_else(|| anyhow::anyhow!("package.name not found in plugin Cargo.toml"))?; + + let feature_name = pkg_name.replace('-', "_"); + let plugin_dir_name = pkg_name.replace('-', "_"); + + println!("Package name: {pkg_name}"); + + // --- Copy into local plugins/ directory --- + let target_dir = plugins_dir.join(&plugin_dir_name); + if target_dir.exists() { + anyhow::bail!( + "Plugin '{}' already exists at plugins/{plugin_dir_name}", + pkg_name + ); + } + fs::create_dir_all(&plugins_dir)?; + cp_recursive(&plugin_src, &target_dir)?; + + // --- Add to workspace Cargo.toml --- + let ws_content = fs::read_to_string(&workspace_toml)?; + let member_entry = format!(" \"plugins/{plugin_dir_name}\""); + if ws_content.contains(&member_entry) { + anyhow::bail!("Already in workspace"); + } + // Insert before the closing bracket of members + let samurai_entry = " \"plugins/xtop-plugin-samurai\","; + let new_ws = if ws_content.contains(samurai_entry) { + ws_content.replace(samurai_entry, &format!("{samurai_entry}\n{member_entry},")) + } else { + // Fallback: insert before the closing ] of members + ws_content.replacen("]", &format!(" {member_entry},\n]"), 1) + }; + fs::write(&workspace_toml, &new_ws)?; + + // --- Add to xtop-cli Cargo.toml --- + let cli_content = fs::read_to_string(&cli_toml)?; + + // Build dependency path relative to crates/xtop-cli/ + let dep_path = format!("../../plugins/{plugin_dir_name}"); + let dep_line = format!("{pkg_name} = {{ path = \"{dep_path}\", optional = true }}"); + + if !cli_content.contains(&dep_line) { + // Find the last optional plugin dependency and insert after it + let marker = "# Optional plugins (behind feature flags)"; + let new_cli = cli_content.replace(marker, &format!("{marker}\n{dep_line}")); + fs::write(&cli_toml, &new_cli)?; + } + + // Add feature flag + let feature_line = format!("{feature_name} = [\"dep:{pkg_name}\"]"); + let cli_content2 = fs::read_to_string(&cli_toml)?; + if !cli_content2.contains(&feature_line) { + let samurai_feature = "plugin-samurai = [\"dep:xtop-plugin-samurai\"]"; + let new_cli2 = if cli_content2.contains(samurai_feature) { + cli_content2.replace( + samurai_feature, + &format!("{samurai_feature}\n{feature_line}"), + ) + } else { + cli_content2.replacen("[features]", &format!("[features]\n{feature_line}"), 1) + }; + fs::write(&cli_toml, &new_cli2)?; + } + + // --- Rebuild --- + println!("Building xtop with {pkg_name} ..."); + let build = std::process::Command::new("cargo") + .args(["build", "--release"]) + .current_dir(workspace_dir) + .status() + .map_err(|e| anyhow::anyhow!("cargo build failed: {e}"))?; + if !build.success() { + anyhow::bail!("Build failed. Check the plugin's compatibility."); + } + + // --- Cleanup --- + let _ = fs::remove_dir_all(&tmp); + + println!(); + println!("Plugin '{pkg_name}' installed successfully."); + println!(" Location: plugins/{plugin_dir_name}"); + println!(" Feature flag: {feature_name}"); + println!(); + println!("Note: '{feature_name}' is NOT enabled by default."); + println!("To enable it, add '{feature_name}' to the 'default' feature list"); + println!("in crates/xtop-cli/Cargo.toml, then rebuild."); + + Ok(()) +} + +fn cmd_plugin_scaffold(name: &str) -> anyhow::Result<()> { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_dir = manifest_dir.parent().unwrap().parent().unwrap(); + let plugins_dir = workspace_dir.join("plugins"); + let plugin_dir = plugins_dir.join(format!("xtop-plugin-{name}")); + + if plugin_dir.exists() { + anyhow::bail!("Plugin crate already exists at {}", plugin_dir.display()); + } + + let src_dir = plugin_dir.join("src"); + fs::create_dir_all(&src_dir)?; + + // Cargo.toml (path refs go up from plugins/ to workspace root, then into crates/) + let cargo_toml = format!( + r#"[package] +name = "xtop-plugin-{name}" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "xtop plugin: {name}" + +[dependencies] +xtop-core = {{ path = "../../crates/xtop-core" }} +ratatui.workspace = true +"# + ); + fs::write(plugin_dir.join("Cargo.toml"), &cargo_toml)?; + + // lib.rs + let lib_rs = format!( + r#"use xtop_plugin_api::{{Plugin, PluginCapability, PluginContext, PluginError, PluginManifest}}; + +pub struct {name_cap}Plugin; + +impl {name_cap}Plugin {{ + pub fn new() -> Self {{ + Self + }} +}} + +impl Plugin for {name_cap}Plugin {{ + fn manifest(&self) -> PluginManifest {{ + PluginManifest {{ + id: "{name}".to_string(), + name: "{name_cap}".to_string(), + version: "0.1.0".to_string(), + description: "xtop plugin: {name}".to_string(), + capabilities: vec![PluginCapability::ReadSystemInfo], + }} + }} + + fn on_tick(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> {{ + Ok(()) + }} +}} +"#, + name = name, + name_cap = { + let mut chars = name.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().to_string() + chars.as_str(), + } + } + ); + fs::write(src_dir.join("lib.rs"), &lib_rs)?; + + println!("Plugin scaffold created at {}", plugin_dir.display()); + println!("To register it:"); + println!(" 1. Add \"plugins/xtop-plugin-{name}\" to [workspace].members in Cargo.toml"); + println!(" 2. Add dependency + feature flag in crates/xtop-cli/Cargo.toml"); + println!(" 3. Add #[cfg(feature = \"plugin-{name}\")] import in main.rs"); + println!(" 4. Implement Plugin trait methods"); + + Ok(()) +} + +fn cp_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + if src.is_dir() { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if file_type.is_dir() { + // Skip .git directory + if entry.file_name() != ".git" { + cp_recursive(&src_path, &dst_path)?; + } + } else { + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) + } else { + fs::copy(src, dst)?; + Ok(()) + } +} diff --git a/src/commands/run.rs b/src/commands/run.rs new file mode 100644 index 0000000..b03e534 --- /dev/null +++ b/src/commands/run.rs @@ -0,0 +1,181 @@ +//! The interactive TUI run loop. + +use std::time::{Duration, Instant}; + +use crate::config::keybinding::Action; +use crate::state::{InputMode, PalettePage}; +use crate::ui; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; + +use super::share::{config_dir, initialize_state, save_config}; + +fn key_event_to_str(key: &KeyEvent) -> String { + let mut s = String::new(); + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl { + s.push_str("ctrl+"); + } + if key.modifiers.contains(KeyModifiers::ALT) { + s.push_str("alt+"); + } + match key.code { + KeyCode::Char(c) => { + if ctrl { + s.push(c.to_ascii_lowercase()); + } else { + s.push(c); + } + } + KeyCode::Esc => s.push_str("escape"), + KeyCode::Enter => s.push_str("enter"), + KeyCode::Backspace => s.push_str("backspace"), + KeyCode::Tab => s.push_str("tab"), + KeyCode::Up => s.push_str("up"), + KeyCode::Down => s.push_str("down"), + KeyCode::Left => s.push_str("left"), + KeyCode::Right => s.push_str("right"), + KeyCode::Delete => s.push_str("delete"), + KeyCode::Home => s.push_str("home"), + KeyCode::End => s.push_str("end"), + KeyCode::PageUp => s.push_str("pageup"), + KeyCode::PageDown => s.push_str("pagedown"), + _ => return String::new(), + } + s +} + +// Embedded default asset files (shipped with the binary) +/// Run the interactive TUI loop. +pub fn run() -> anyhow::Result<()> { + ui::install_panic_hook(); + let mut terminal = ui::init()?; + + let cfg_dir = config_dir(); + let mut state = initialize_state(&cfg_dir)?; + + let tick_rate = Duration::from_millis(state.update_interval_ms); + let mut last_tick = Instant::now(); + + loop { + terminal.draw(|f| ui::render(f, &state))?; + + let timeout = tick_rate + .checked_sub(last_tick.elapsed()) + .unwrap_or_default(); + + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + let key_str = key_event_to_str(&key); + + // Give plugins first chance to consume the key + let key_str_clone = key_str.clone(); + let key_consumed = + state.with_plugin_manager_mut(|mgr, this| mgr.handle_key(this, &key_str_clone)); + if key_consumed { + continue; + } + + // DEBUG: print key for diagnostics + if cfg!(debug_assertions) && !key_str.is_empty() { + eprintln!("[key] '{key_str}'"); + } + + match state.input_mode { + InputMode::Normal => { + // Direct Ctrl+P check (works regardless of keybinding config, important on macOS) + if key_str == "ctrl+p" { + state.open_palette(); + state.input_mode = InputMode::CommandPalette; + } else if let Some(action) = state.keybindings.resolve(&key_str) { + match action { + Action::Quit => { + save_config(&state); + state.quit(); + } + Action::Cancel if state.show_help => { + state.toggle_help(); + } + Action::OpenCommandPalette => { + state.open_palette(); + state.input_mode = InputMode::CommandPalette; + } + Action::KillProcess | Action::ProcessUp | Action::ProcessDown => { + state.execute_action(&action); + } + _ => { + state.execute_action(&action); + } + } + } + } + InputMode::Searching => match key.code { + KeyCode::Esc => { + state.search_query.clear(); + state.end_search(); + } + KeyCode::Enter => { + state.end_search(); + } + KeyCode::Backspace => { + state.search_pop_char(); + } + KeyCode::Char(c) => { + state.search_push_char(c); + } + _ => {} + }, + InputMode::CommandPalette => { + let is_main = state.palette.page == PalettePage::Main; + match key.code { + KeyCode::Esc => { + state.close_palette(); + } + KeyCode::Enter => { + if let Some(action) = state.palette_selected_action() { + state.execute_action(&action); + save_config(&state); + } + } + KeyCode::Down => { + state.palette_select_next(); + } + KeyCode::Up => { + state.palette_select_prev(); + } + KeyCode::Char(c) => { + state.palette.query.push(c); + state.palette_filter(); + } + KeyCode::Backspace => { + if state.palette.query.is_empty() && !is_main { + state.palette_navigate_to(PalettePage::Main); + } else { + state.palette.query.pop(); + state.palette_filter(); + } + } + _ => {} + } + } + } + } + } + + if last_tick.elapsed() >= tick_rate { + state.on_tick(); + last_tick = Instant::now(); + } + + if state.should_quit { + break; + } + } + + // Disable plugins on shutdown + state.with_plugin_manager_mut(|mgr, this| { + mgr.disable_all(this); + }); + + ui::restore()?; + Ok(()) +} diff --git a/src/commands/share/assets.rs b/src/commands/share/assets.rs new file mode 100644 index 0000000..910e0d2 --- /dev/null +++ b/src/commands/share/assets.rs @@ -0,0 +1,121 @@ +//! Asset bootstrapping and config persistence used by several commands. + +use std::fs; + +use crate::config; +use crate::config::Config; +use crate::state::AppState; + +const DEFAULT_THEMES: &[(&str, &str)] = &[ + ("x", include_str!("../../../assets/themes/x.jsonc")), + ( + "madrid", + include_str!("../../../assets/themes/madrid.jsonc"), + ), + ( + "lahabana", + include_str!("../../../assets/themes/lahabana.jsonc"), + ), + ("paris", include_str!("../../../assets/themes/paris.jsonc")), + ("tokio", include_str!("../../../assets/themes/tokio.jsonc")), + ("oslo", include_str!("../../../assets/themes/oslo.jsonc")), + ( + "helsinki", + include_str!("../../../assets/themes/helsinki.jsonc"), + ), + ( + "berlin", + include_str!("../../../assets/themes/berlin.jsonc"), + ), + ( + "london", + include_str!("../../../assets/themes/london.jsonc"), + ), + ("praha", include_str!("../../../assets/themes/praha.jsonc")), + ( + "bogota", + include_str!("../../../assets/themes/bogota.jsonc"), + ), +]; + +const DEFAULT_LAYOUTS: &[(&str, &str)] = &[ + ( + "dashboard", + include_str!("../../../assets/layouts/dashboard.jsonc"), + ), + ( + "vertical", + include_str!("../../../assets/layouts/vertical.jsonc"), + ), + ( + "horizontal", + include_str!("../../../assets/layouts/horizontal.jsonc"), + ), + ( + "cpu_focus", + include_str!("../../../assets/layouts/cpu_focus.jsonc"), + ), + ( + "memory_focus", + include_str!("../../../assets/layouts/memory_focus.jsonc"), + ), + ( + "network_focus", + include_str!("../../../assets/layouts/network_focus.jsonc"), + ), + ( + "process_focus", + include_str!("../../../assets/layouts/process_focus.jsonc"), + ), +]; + +pub fn config_dir() -> std::path::PathBuf { + crate::config::config_dir() +} + +pub fn ensure_default_assets() { + let theme_assets: &[(&str, &str)] = DEFAULT_THEMES; + let layout_assets: &[(&str, &str)] = DEFAULT_LAYOUTS; + + let dir = crate::theme::themes_dir(); + if !dir.join(".xtop_initialized").exists() { + fs::create_dir_all(&dir).ok(); + for (name, content) in theme_assets { + let path = dir.join(format!("{name}.jsonc")); + if !path.exists() { + fs::write(&path, content).ok(); + } + } + fs::write(dir.join(".xtop_initialized"), "").ok(); + } + + let dir = crate::layout::layouts_dir(); + if !dir.join(".xtop_initialized").exists() { + fs::create_dir_all(&dir).ok(); + for (name, content) in layout_assets { + let path = dir.join(format!("{name}.jsonc")); + if !path.exists() { + fs::write(&path, content).ok(); + } + } + fs::write(dir.join(".xtop_initialized"), "").ok(); + } +} + +pub fn save_config(state: &AppState) { + let layout_name = if state.layout_index < state.layout_defs.len() { + state.layout_defs[state.layout_index].name.clone() + } else { + String::new() + }; + let cfg = Config { + theme: state.current_theme.name.clone(), + layout_mode: state.save_layout_mode(), + layout_name, + update_interval_ms: state.update_interval_ms, + history_points: 100, + alerts: state.alerts, + keybindings: state.keybindings.clone(), + }; + let _ = config::save_config(&cfg); +} diff --git a/src/commands/share/bootstrap.rs b/src/commands/share/bootstrap.rs new file mode 100644 index 0000000..5fbe02b --- /dev/null +++ b/src/commands/share/bootstrap.rs @@ -0,0 +1,63 @@ +//! Bootstrap: assemble the live application state for a command run. + +use std::fs; +use std::path::Path; + +use crate::config; +use crate::layout; +use crate::plugins::PluginManager; +use crate::providers::sysinfo::SysinfoProvider; +use crate::providers::CompositeProvider; +use crate::state::AppState; +use crate::theme::load_all_themes; + +#[cfg(feature = "plugin-samurai")] +use xtop_plugin_samurai::SamuraiPlugin; + +pub(crate) fn build_plugin_manager(state: &mut AppState, cfg_dir: &Path) -> PluginManager { + let plugins_dir = cfg_dir.join("plugins"); + fs::create_dir_all(&plugins_dir).ok(); + let mut mgr = PluginManager::new(plugins_dir); + + // Register plugins behind feature flags. + register_plugins(&mut mgr, state); + + mgr +} + +/// Register the optional compile-time plugins. +#[cfg(feature = "plugin-samurai")] +pub(crate) fn register_plugins(mgr: &mut PluginManager, state: &mut AppState) { + let plugin = Box::new(SamuraiPlugin::new()); + if let Err(e) = mgr.register(plugin, state) { + eprintln!("[xtop] failed to load samurai plugin: {e}"); + } +} + +/// No plugins selected at compile time. +#[cfg(not(feature = "plugin-samurai"))] +fn register_plugins(_mgr: &mut PluginManager, _state: &mut AppState) {} + +// --------------------------------------------------------------------------- +// CLI subcommands +// --------------------------------------------------------------------------- + +/// Assemble a fully initialized `AppState` for a command run. +pub fn initialize_state(cfg_dir: &Path) -> anyhow::Result { + let sysinfo_provider = SysinfoProvider::new(); + let composite = CompositeProvider::new(Box::new(sysinfo_provider)); + + let themes = load_all_themes(); + let cfg = config::load_config(); + let mut builtin_layouts = layout::builtin_layouts(); + let custom_layouts = layout::load_custom_layouts(); + builtin_layouts.extend(custom_layouts); + let mut state = AppState::new(Box::new(composite), themes, cfg, builtin_layouts); + + // Build and register plugins, then wire their providers into the state. + let plugin_mgr = build_plugin_manager(&mut state, cfg_dir); + let extra_providers = plugin_mgr.collect_data_providers(); + state.init_plugins(plugin_mgr, extra_providers); + + Ok(state) +} diff --git a/src/commands/share/mod.rs b/src/commands/share/mod.rs new file mode 100644 index 0000000..227ac6c --- /dev/null +++ b/src/commands/share/mod.rs @@ -0,0 +1,7 @@ +//! Shared helpers across CLI commands. + +pub(crate) mod assets; +pub(crate) mod bootstrap; + +pub(crate) use assets::*; +pub(crate) use bootstrap::*; diff --git a/crates/xtop-core/src/infrastructure/config.rs b/src/config/io.rs similarity index 66% rename from crates/xtop-core/src/infrastructure/config.rs rename to src/config/io.rs index 5eaf243..f46ea94 100644 --- a/crates/xtop-core/src/infrastructure/config.rs +++ b/src/config/io.rs @@ -1,21 +1,17 @@ -use crate::application::state::Config; +//! Config file read/write against the persisted schema. + use std::fs; use std::path::PathBuf; -pub fn config_dir() -> PathBuf { - if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - PathBuf::from(xdg).join("xtop") - } else if let Ok(home) = std::env::var("HOME") { - PathBuf::from(home).join(".config").join("xtop") - } else { - PathBuf::from(".").join(".config").join("xtop") - } -} +use super::schema::Config; +use crate::config::config_dir; +/// Path of the persisted config file. pub fn config_path() -> PathBuf { config_dir().join("config.json") } +/// Load the config, falling back to defaults when missing or invalid. pub fn load_config() -> Config { let path = config_path(); if let Ok(data) = fs::read_to_string(&path) { @@ -26,6 +22,7 @@ pub fn load_config() -> Config { Config::default() } +/// Save the config to disk. pub fn save_config(config: &Config) -> Result<(), String> { let path = config_path(); if let Some(parent) = path.parent() { diff --git a/crates/xtop-core/src/domain/keybinding.rs b/src/config/keybinding.rs similarity index 99% rename from crates/xtop-core/src/domain/keybinding.rs rename to src/config/keybinding.rs index d2a4866..c4fcce3 100644 --- a/crates/xtop-core/src/domain/keybinding.rs +++ b/src/config/keybinding.rs @@ -116,10 +116,7 @@ pub enum Action { KillProcess, ProcessUp, ProcessDown, - SortByPid, - SortByName, SortByCpu, - SortByMem, } impl Keybindings { diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..083b88d --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,11 @@ +//! Configuration area: file persistence, schema, keybindings and platform +//! dirs. + +mod io; +pub mod keybinding; +mod platform; +mod schema; + +pub use io::{load_config, save_config}; +pub use platform::config_dir; +pub use schema::*; diff --git a/src/config/platform/linux.rs b/src/config/platform/linux.rs new file mode 100644 index 0000000..fe4fef4 --- /dev/null +++ b/src/config/platform/linux.rs @@ -0,0 +1,16 @@ +//! Linux config dir: `$XDG_CONFIG_HOME/xtop`, falling back to +//! `$HOME/.config/xtop`. + +use std::path::PathBuf; + +use super::shared::env_path; + +pub fn config_dir() -> PathBuf { + env_path(&["XDG_CONFIG_HOME"]) + .unwrap_or_else(|| { + env_path(&["HOME"]) + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") + }) + .join("xtop") +} diff --git a/src/config/platform/macos.rs b/src/config/platform/macos.rs new file mode 100644 index 0000000..45de87b --- /dev/null +++ b/src/config/platform/macos.rs @@ -0,0 +1,13 @@ +//! macOS config dir: `$HOME/Library/Application Support/xtop`. + +use std::path::PathBuf; + +use super::shared::env_path; + +pub fn config_dir() -> PathBuf { + env_path(&["HOME"]) + .unwrap_or_default() + .join("Library") + .join("Application Support") + .join("xtop") +} diff --git a/src/config/platform/mod.rs b/src/config/platform/mod.rs new file mode 100644 index 0000000..434d2af --- /dev/null +++ b/src/config/platform/mod.rs @@ -0,0 +1,24 @@ +//! Config directory resolution per platform. +//! +//! Only here lives OS-specific path logic. Shared helpers used by every +//! platform live under [`shared`]. + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +mod other; +pub mod shared; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(target_os = "linux")] +pub use linux::config_dir; +#[cfg(target_os = "macos")] +pub use macos::config_dir; +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +pub use other::config_dir; +#[cfg(target_os = "windows")] +pub use windows::config_dir; diff --git a/src/config/platform/other.rs b/src/config/platform/other.rs new file mode 100644 index 0000000..7fcc61e --- /dev/null +++ b/src/config/platform/other.rs @@ -0,0 +1,12 @@ +//! Fallback config dir for other Unix-likes: `$HOME/.config/xtop`. + +use std::path::PathBuf; + +use super::shared::env_path; + +pub fn config_dir() -> PathBuf { + env_path(&["HOME"]) + .unwrap_or_default() + .join(".config") + .join("xtop") +} diff --git a/src/config/platform/shared/mod.rs b/src/config/platform/shared/mod.rs new file mode 100644 index 0000000..74ae275 --- /dev/null +++ b/src/config/platform/shared/mod.rs @@ -0,0 +1,10 @@ +//! Helpers shared by all platform config-dir implementations. + +use std::path::PathBuf; + +/// First environment variable from `names` that resolves to a path. +pub(crate) fn env_path(names: &[&str]) -> Option { + names + .iter() + .find_map(|n| std::env::var_os(n).map(PathBuf::from)) +} diff --git a/src/config/platform/windows.rs b/src/config/platform/windows.rs new file mode 100644 index 0000000..faaf8fc --- /dev/null +++ b/src/config/platform/windows.rs @@ -0,0 +1,9 @@ +//! Windows config dir: `%APPDATA%\xtop`. + +use std::path::PathBuf; + +use super::shared::env_path; + +pub fn config_dir() -> PathBuf { + env_path(&["APPDATA"]).unwrap_or_default().join("xtop") +} diff --git a/src/config/schema.rs b/src/config/schema.rs new file mode 100644 index 0000000..460f62e --- /dev/null +++ b/src/config/schema.rs @@ -0,0 +1,59 @@ +//! Persisted configuration schema. +//! +//! The on-disk config of the app. Theme and layout names are plain strings +//! here; the schema types stay independent of the runtime state. + +use crate::config::keybinding::Keybindings; +use crate::layout::LayoutMode; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct AlertThresholds { + pub cpu_high: f64, + pub mem_high: f64, + pub disk_high: f64, +} + +impl Default for AlertThresholds { + fn default() -> Self { + Self { + cpu_high: 90.0, + mem_high: 90.0, + disk_high: 90.0, + } + } +} + +fn default_layout_mode() -> LayoutMode { + LayoutMode::Dashboard +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Config { + pub theme: String, + #[serde(default = "default_layout_mode")] + pub layout_mode: LayoutMode, + /// Layout name for custom layouts beyond the 7 built-in LayoutMode variants. + /// If non-empty, takes precedence over `layout_mode`. + #[serde(default)] + pub layout_name: String, + pub update_interval_ms: u64, + pub history_points: usize, + pub alerts: AlertThresholds, + #[serde(default)] + pub keybindings: Keybindings, +} + +impl Default for Config { + fn default() -> Self { + Self { + theme: "x".to_string(), + layout_mode: LayoutMode::Dashboard, + layout_name: String::new(), + update_interval_ms: 1000, + history_points: 100, + alerts: AlertThresholds::default(), + keybindings: Keybindings::default(), + } + } +} diff --git a/crates/xtop-core/src/infrastructure/layout_loader.rs b/src/layout/loader.rs similarity index 98% rename from crates/xtop-core/src/infrastructure/layout_loader.rs rename to src/layout/loader.rs index e551bcb..db3de22 100644 --- a/crates/xtop-core/src/infrastructure/layout_loader.rs +++ b/src/layout/loader.rs @@ -1,4 +1,4 @@ -use crate::domain::layout::{Direction, LayoutArea, LayoutConstraint, LayoutDef, LayoutNode}; +use crate::layout::{Direction, LayoutArea, LayoutConstraint, LayoutDef, LayoutNode}; use std::fs; use std::path::Path; diff --git a/src/layout/mod.rs b/src/layout/mod.rs new file mode 100644 index 0000000..148e39d --- /dev/null +++ b/src/layout/mod.rs @@ -0,0 +1,10 @@ +//! Layout area: layout model, modes and layout loading. + +mod loader; +mod mode; +mod model; + +pub use loader::*; +pub use mode::*; +pub(crate) use mode::{layout_index_from_mode, mode_from_layout_index}; +pub use model::*; diff --git a/src/layout/mode.rs b/src/layout/mode.rs new file mode 100644 index 0000000..8b397e5 --- /dev/null +++ b/src/layout/mode.rs @@ -0,0 +1,98 @@ +//! Layout modes and effective-layout detection. +//! +//! How the requested layout mode degrades depending on terminal size. + +use crate::layout::LayoutDef; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum LayoutMode { + Dashboard, + Vertical, + Horizontal, + CpuFocus, + MemoryFocus, + NetworkFocus, + ProcessFocus, +} + +impl LayoutMode { + #[cfg(test)] + pub fn next(self) -> Self { + match self { + Self::Dashboard => Self::Vertical, + Self::Vertical => Self::Horizontal, + Self::Horizontal => Self::CpuFocus, + Self::CpuFocus => Self::MemoryFocus, + Self::MemoryFocus => Self::NetworkFocus, + Self::NetworkFocus => Self::ProcessFocus, + Self::ProcessFocus => Self::Dashboard, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Dashboard => "Dashboard", + Self::Vertical => "Vertical", + Self::Horizontal => "Horizontal", + Self::CpuFocus => "CPU Focus", + Self::MemoryFocus => "Memory Focus", + Self::NetworkFocus => "Network Focus", + Self::ProcessFocus => "Process Focus", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EffectiveLayout { + Dashboard, + Compact, + Vertical, + Horizontal, + CpuFocus, + MemoryFocus, + NetworkFocus, + ProcessFocus, + Minimal, +} + +pub(crate) fn layout_index_from_mode(mode: LayoutMode, defs: &[LayoutDef]) -> usize { + let label = mode.label(); + defs.iter().position(|d| d.name == label).unwrap_or(0) +} + +pub(crate) fn mode_from_layout_index(index: usize) -> LayoutMode { + match index { + 0 => LayoutMode::Dashboard, + 1 => LayoutMode::Vertical, + 2 => LayoutMode::Horizontal, + 3 => LayoutMode::CpuFocus, + 4 => LayoutMode::MemoryFocus, + 5 => LayoutMode::NetworkFocus, + 6 => LayoutMode::ProcessFocus, + _ => LayoutMode::Dashboard, + } +} + +pub fn detect_effective_layout(width: u16, height: u16, user_mode: LayoutMode) -> EffectiveLayout { + if width < 60 || height < 14 { + return EffectiveLayout::Minimal; + } + match user_mode { + LayoutMode::Dashboard => { + if width < 80 { + EffectiveLayout::Vertical + } else if width < 100 || height < 28 { + EffectiveLayout::Compact + } else { + EffectiveLayout::Dashboard + } + } + LayoutMode::Vertical => EffectiveLayout::Vertical, + LayoutMode::Horizontal => EffectiveLayout::Horizontal, + LayoutMode::CpuFocus => EffectiveLayout::CpuFocus, + LayoutMode::MemoryFocus => EffectiveLayout::MemoryFocus, + LayoutMode::NetworkFocus => EffectiveLayout::NetworkFocus, + LayoutMode::ProcessFocus => EffectiveLayout::ProcessFocus, + } +} diff --git a/crates/xtop-core/src/domain/layout.rs b/src/layout/model.rs similarity index 100% rename from crates/xtop-core/src/domain/layout.rs rename to src/layout/model.rs diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..5711286 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,53 @@ +//! xtop CLI entry point. +//! +//! The binary is a thin dispatcher; the app is organized in areas: +//! `commands`, `config`, `layout`, `plugins`, `providers`, `state`, `theme` +//! and `ui`. + +mod commands; +mod config; +mod layout; +mod plugins; +mod providers; +mod state; +mod theme; +mod ui; + +use commands::{ensure_default_assets, mcp, run}; + +fn print_usage() { + eprintln!("Usage:"); + eprintln!(" xtop Start the TUI system monitor"); + eprintln!(" xtop mcp Start MCP server (stdio transport) for AI agents"); + eprintln!(" xtop plugin list List installed plugins"); + eprintln!( + " xtop plugin install Install a plugin from github.com/xtop-cli/xtop/plugins/" + ); + eprintln!(" xtop plugin install Install a plugin from a git URL"); + eprintln!(" xtop plugin scaffold Create a new plugin crate"); +} + +fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + + // CLI subcommands. + if args.len() > 1 { + match args[1].as_str() { + "mcp" => { + ensure_default_assets(); + return mcp::run_mcp_server(); + } + "plugin" => { + return commands::plugins::plugin_command(&args); + } + "--help" | "-h" => { + print_usage(); + return Ok(()); + } + _ => {} + } + } + + ensure_default_assets(); + run::run() +} diff --git a/src/plugins/extension_host.rs b/src/plugins/extension_host.rs new file mode 100644 index 0000000..2cd3dc5 --- /dev/null +++ b/src/plugins/extension_host.rs @@ -0,0 +1,38 @@ +//! Kernel-side implementation of the extension host contract +//! (`xtop-extension-api`). +//! +//! Extensions drive the app by ticking it and executing named actions on +//! hosted plugins; both are delegated to the live [`AppState`]. + +use xtop_extension_api::{ExtensionError, ExtensionHost}; + +use crate::state::AppState; + +impl ExtensionHost for AppState { + fn tick(&mut self) { + AppState::on_tick(self); + } + + fn execute_plugin( + &mut self, + plugin_id: &str, + action: &str, + params: &str, + ) -> Result { + self.with_plugin_manager_mut(|mgr, this| { + mgr.execute(this, plugin_id, action, params) + .map_err(map_plugin_error) + }) + } +} + +fn map_plugin_error(e: xtop_plugin_api::PluginError) -> ExtensionError { + use xtop_plugin_api::PluginError; + match e { + PluginError::Recoverable(msg) => ExtensionError::Recoverable(msg), + PluginError::Fatal(msg) => ExtensionError::Fatal(msg), + PluginError::UnknownAction(action) => { + ExtensionError::Recoverable(format!("unknown action: {action}")) + } + } +} diff --git a/src/plugins/host.rs b/src/plugins/host.rs new file mode 100644 index 0000000..3757ec6 --- /dev/null +++ b/src/plugins/host.rs @@ -0,0 +1,54 @@ +//! Kernel-side implementation of the plugin host contract (`xtop-plugin-api`). +//! +//! The live [`AppState`] is what plugins see through [`HostState`], so plugin +//! code never depends on kernel types. + +use crate::state::AppState; +use xtop_plugin_api::{AlertThresholds, HostState, RuntimeConfig, SystemInfo, SystemSnapshot}; + +impl HostState for AppState { + fn snapshot(&self) -> SystemSnapshot { + AppState::snapshot(self) + } + + fn system_info(&self) -> SystemInfo { + self.sys_info.clone() + } + + fn kill_process(&mut self, pid: u32) -> bool { + AppState::kill_process_by_pid(self, pid) + } + + fn set_alert_thresholds(&mut self, cpu: f64, mem: f64, disk: f64) { + AppState::set_alert_thresholds(self, cpu, mem, disk); + } + + fn alerts(&self) -> AlertThresholds { + AlertThresholds { + cpu_high: self.alerts.cpu_high, + mem_high: self.alerts.mem_high, + disk_high: self.alerts.disk_high, + } + } + + fn config(&self) -> RuntimeConfig { + RuntimeConfig { + theme: self.current_theme.name.clone(), + layout: self.current_layout_name().to_string(), + interval_ms: self.update_interval_ms, + hostname: self.sys_info.hostname.clone(), + } + } + + fn set_theme_by_name(&mut self, name: &str) -> bool { + AppState::set_theme_by_name(self, name) + } + + fn set_layout_by_name(&mut self, name: &str) -> bool { + AppState::set_layout_by_name(self, name) + } + + fn set_update_interval_ms(&mut self, ms: u64) { + self.update_interval_ms = ms; + } +} diff --git a/crates/xtop-core/src/application/plugin_manager.rs b/src/plugins/manager.rs similarity index 87% rename from crates/xtop-core/src/application/plugin_manager.rs rename to src/plugins/manager.rs index 5ffaf43..2ce64ad 100644 --- a/crates/xtop-core/src/application/plugin_manager.rs +++ b/src/plugins/manager.rs @@ -1,11 +1,9 @@ use std::fmt::Debug; use std::path::PathBuf; -use crate::application::state::AppState; -use crate::domain::plugin::{ - Plugin, PluginCapability, PluginContext, PluginError, PluginManifest, WidgetRegistration, -}; -use crate::domain::system_info::SystemDataProvider; +use crate::state::AppState; +use xtop_plugin_api::SystemDataProvider; +use xtop_plugin_api::{Plugin, PluginCapability, PluginContext, PluginError, WidgetRegistration}; /// Manages the lifecycle of all loaded plugins. /// @@ -42,7 +40,8 @@ impl PluginManager { /// Register and enable a plugin. /// /// This calls `on_enable` on the plugin. If it fails, the plugin is not added - /// and the error is logged. + /// and the error is logged. Only reachable when a plugin is compiled in. + #[cfg_attr(not(feature = "plugin-samurai"), allow(dead_code))] pub fn register( &mut self, mut plugin: Box, @@ -55,11 +54,7 @@ impl PluginManager { })?; let capabilities = plugin.manifest().capabilities.clone(); - let mut ctx = PluginContext { - state, - plugin_data_dir: data_dir, - capabilities, - }; + let mut ctx = PluginContext::new(state, data_dir, capabilities); plugin.on_enable(&mut ctx)?; @@ -78,11 +73,7 @@ impl PluginManager { ) -> PluginContext<'a> { let id = plugin.manifest().id.clone(); let capabilities = plugin.manifest().capabilities.clone(); - PluginContext { - state, - plugin_data_dir: base.join(&id), - capabilities, - } + PluginContext::new(state, base.join(&id), capabilities) } /// Call `on_tick` on every enabled plugin. @@ -165,16 +156,6 @@ impl PluginManager { ))) } - /// List all loaded plugin manifests (for display / status). - pub fn manifests(&self) -> Vec { - self.plugins.iter().map(|p| p.manifest()).collect() - } - - /// Number of loaded plugins. - pub fn count(&self) -> usize { - self.plugins.len() - } - /// Call `on_disable` on all plugins (e.g. on shutdown). pub fn disable_all(&mut self, state: &mut AppState) { let base = self.plugin_data_base.clone(); diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs new file mode 100644 index 0000000..794d4f1 --- /dev/null +++ b/src/plugins/mod.rs @@ -0,0 +1,11 @@ +//! Plugin host area: everything the kernel does to drive plugins. +//! +//! `manager` is the host side of the plugin lifecycle; `host` bridges the +//! kernel `AppState` to the contract view `xtop_plugin_api::HostState`; +//! `extension_host` does the same for extensions. + +mod extension_host; +mod host; +mod manager; + +pub use manager::*; diff --git a/crates/xtop-core/src/infrastructure/composite_provider.rs b/src/providers/composite.rs similarity index 97% rename from crates/xtop-core/src/infrastructure/composite_provider.rs rename to src/providers/composite.rs index 4cd083e..7918301 100644 --- a/crates/xtop-core/src/infrastructure/composite_provider.rs +++ b/src/providers/composite.rs @@ -1,5 +1,5 @@ -use crate::domain::metrics::*; -use crate::domain::system_info::SystemDataProvider; +use xtop_plugin_api::model::*; +use xtop_plugin_api::SystemDataProvider; /// A `SystemDataProvider` that composes a primary provider with plugin-provided extras. /// diff --git a/src/providers/mod.rs b/src/providers/mod.rs new file mode 100644 index 0000000..f6c9d87 --- /dev/null +++ b/src/providers/mod.rs @@ -0,0 +1,10 @@ +//! Providers area: data sources that feed the state. +//! +//! `composite` merges several providers; `sysinfo` implements the main +//! cross-platform provider with per-OS probes under its `platform` tree. + +mod composite; + +pub mod sysinfo; + +pub use composite::*; diff --git a/src/providers/sysinfo/mod.rs b/src/providers/sysinfo/mod.rs new file mode 100644 index 0000000..ca6424b --- /dev/null +++ b/src/providers/sysinfo/mod.rs @@ -0,0 +1,12 @@ +//! System metrics provider for the `xtop` kernel. +//! +//! The real-time monitoring data comes from the `sysinfo` crate +//! (cross-platform) plus a small set of OS-specific probes defined under +//! [`platform`]. Platform modules must compile for every supported target and +//! fall back to empty/default values when the OS does not provide the data. + +pub mod platform; + +mod provider; + +pub use provider::SysinfoProvider; diff --git a/src/providers/sysinfo/platform/fallback.rs b/src/providers/sysinfo/platform/fallback.rs new file mode 100644 index 0000000..89a1bb0 --- /dev/null +++ b/src/providers/sysinfo/platform/fallback.rs @@ -0,0 +1,31 @@ +//! Fallback probes for any other Unix-like target (BSD, etc.). +//! +//! Keeps the provider compiling everywhere while only the sysinfo crate data +//! is available on those platforms. + +use std::collections::HashMap; +use xtop_plugin_api::model::{BatteryInfo, GpuInfo}; + +pub fn read_cpu_governor(_cpu_id: usize) -> String { + String::new() +} + +pub fn read_mount_options() -> HashMap { + HashMap::new() +} + +pub fn read_interface_ips() -> HashMap> { + HashMap::new() +} + +pub fn read_batteries() -> Vec { + Vec::new() +} + +pub fn read_thread_count(_pid: sysinfo::Pid) -> u64 { + 0 +} + +pub fn read_gpu_info_from_sysfs() -> Vec { + Vec::new() +} diff --git a/src/providers/sysinfo/platform/linux/battery.rs b/src/providers/sysinfo/platform/linux/battery.rs new file mode 100644 index 0000000..9e267af --- /dev/null +++ b/src/providers/sysinfo/platform/linux/battery.rs @@ -0,0 +1,85 @@ +//! Linux battery probes from `/sys/class/power_supply`. + +use std::fs; +use std::path::Path; + +use xtop_plugin_api::model::BatteryInfo; + +/// Battery state from `/sys/class/power_supply`. +pub fn read_batteries() -> Vec { + let mut batteries = Vec::new(); + let power_supply = Path::new("/sys/class/power_supply"); + if !power_supply.exists() { + return batteries; + } + if let Ok(entries) = fs::read_dir(power_supply) { + for entry in entries.flatten() { + let name = match entry.file_name().to_str() { + Some(n) if n.starts_with("BAT") => n.to_string(), + _ => continue, + }; + let base = entry.path(); + let capacity = fs::read_to_string(base.join("capacity")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0.0); + let state = fs::read_to_string(base.join("status")) + .ok() + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + let charge_full = fs::read_to_string(base.join("charge_full")) + .ok() + .and_then(|s| s.trim().parse::().ok()); + let charge_now = fs::read_to_string(base.join("charge_now")) + .ok() + .and_then(|s| s.trim().parse::().ok()); + let cycles = fs::read_to_string(base.join("cycle_count")) + .ok() + .and_then(|s| s.trim().parse::().ok()); + let power_now = fs::read_to_string(base.join("power_now")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + let charge_full_design = fs::read_to_string(base.join("charge_full_design")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(1); + + let health = if charge_full_design > 0 { + (charge_full.unwrap_or(1) as f32 / charge_full_design as f32) * 100.0 + } else { + 100.0 + }; + + // Time to full/empty estimated from the current power draw. + let (time_to_full, time_to_empty) = if power_now != 0 && power_now.abs() > 0 { + if state == "Charging" { + let remaining = charge_full + .unwrap_or(0) + .saturating_sub(charge_now.unwrap_or(0)); + let secs = (remaining as f64 / power_now.abs() as f64 * 3600.0) as u64; + (Some(secs), None) + } else if state == "Discharging" { + let secs = + (charge_now.unwrap_or(0) as f64 / power_now.abs() as f64 * 3600.0) as u64; + (None, Some(secs)) + } else { + (None, None) + } + } else { + (None, None) + }; + + batteries.push(BatteryInfo { + name, + percentage: capacity, + state, + time_to_full, + time_to_empty, + health, + cycle_count: cycles, + }); + } + } + batteries +} diff --git a/src/providers/sysinfo/platform/linux/governor.rs b/src/providers/sysinfo/platform/linux/governor.rs new file mode 100644 index 0000000..cf0ad88 --- /dev/null +++ b/src/providers/sysinfo/platform/linux/governor.rs @@ -0,0 +1,12 @@ +//! Linux CPU governor probe from the `cpufreq` sysfs interface. + +use std::fs; + +/// CPU governor of a given core, empty when unavailable. +pub fn read_cpu_governor(cpu_id: usize) -> String { + fs::read_to_string(format!( + "/sys/devices/system/cpu/cpu{cpu_id}/cpufreq/scaling_governor" + )) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} diff --git a/src/providers/sysinfo/platform/linux/gpu.rs b/src/providers/sysinfo/platform/linux/gpu.rs new file mode 100644 index 0000000..9b45770 --- /dev/null +++ b/src/providers/sysinfo/platform/linux/gpu.rs @@ -0,0 +1,63 @@ +//! Linux GPU probe from `/sys/class/drm` (used when `nvidia-smi` is absent). + +use std::fs; +use std::path::Path; + +use xtop_plugin_api::model::GpuInfo; + +/// Extra GPU detection from `/sys/class/drm`. +pub fn read_gpu_info_from_sysfs() -> Vec { + let mut gpus = Vec::new(); + if let Ok(entries) = fs::read_dir("/sys/class/drm/") { + for entry in entries.flatten() { + let fname = entry.file_name().to_string_lossy().to_string(); + if fname.starts_with("card") && !fname.contains('-') { + let base = entry.path(); + let dev = base.join("device"); + let gpu_name = fs::read_to_string(dev.join("product_name")) + .ok() + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| fname.clone()); + let mem_total = fs::read_to_string(dev.join("mem_info_vram_total")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + let mem_used = fs::read_to_string(dev.join("mem_info_vram_used")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + let temp = find_hwmon_temp(&base.join("device"), "gpu").unwrap_or(0.0); + gpus.push(GpuInfo { + name: gpu_name, + usage: 0.0, + temperature: temp, + memory_total: mem_total, + memory_used: mem_used, + }); + } + } + } + gpus +} + +/// First hwmon temperature (in celsius) whose label matches `label_filter`. +fn find_hwmon_temp(device_path: &Path, label_filter: &str) -> Option { + let hwmon = device_path.join("hwmon"); + if hwmon.exists() { + if let Ok(entries) = fs::read_dir(&hwmon) { + for entry in entries.flatten() { + let hwmon_dir = entry.path(); + if let Ok(labels) = fs::read_to_string(hwmon_dir.join("temp1_label")) { + if labels.trim().to_lowercase().contains(label_filter) { + if let Ok(input) = fs::read_to_string(hwmon_dir.join("temp1_input")) { + if let Ok(millideg) = input.trim().parse::() { + return Some(millideg / 1000.0); + } + } + } + } + } + } + } + None +} diff --git a/src/providers/sysinfo/platform/linux/interfaces.rs b/src/providers/sysinfo/platform/linux/interfaces.rs new file mode 100644 index 0000000..2b103ce --- /dev/null +++ b/src/providers/sysinfo/platform/linux/interfaces.rs @@ -0,0 +1,38 @@ +//! Linux network interface address probe from `/proc/net`. + +use std::collections::HashMap; +use std::fs; + +/// Interface name to IP addresses. +pub fn read_interface_ips() -> HashMap> { + let mut map: HashMap> = HashMap::new(); + // IPv6 from /proc/net/if_inet6. + if let Ok(content) = fs::read_to_string("/proc/net/if_inet6") { + for line in content.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 5 { + let addr_hex = parts[0]; + let iface = parts[4].to_string(); + if addr_hex.len() == 32 { + let ip: String = (0..8) + .map(|i| { + let start = i * 4; + let group = &addr_hex[start..start + 4]; + let trimmed = group.trim_start_matches('0'); + let val = u16::from_str_radix( + if trimmed.is_empty() { "0" } else { trimmed }, + 16, + ) + .unwrap_or(0); + format!("{:x}", val) + }) + .collect::>() + .join(":"); + map.entry(iface).or_default().push(ip); + } + } + } + } + // IPv4 could be added from /proc/net/fib_trie if ever needed. + map +} diff --git a/src/providers/sysinfo/platform/linux/mod.rs b/src/providers/sysinfo/platform/linux/mod.rs new file mode 100644 index 0000000..d58cba8 --- /dev/null +++ b/src/providers/sysinfo/platform/linux/mod.rs @@ -0,0 +1,18 @@ +//! Linux probes for the sysinfo area. +//! +//! Each subsystem lives in its own file and is re-exported here, so the +//! platform dispatcher only talks to this module. + +mod battery; +mod governor; +mod gpu; +mod interfaces; +mod mounts; +mod threads; + +pub use battery::*; +pub use governor::*; +pub use gpu::*; +pub use interfaces::*; +pub use mounts::*; +pub use threads::*; diff --git a/src/providers/sysinfo/platform/linux/mounts.rs b/src/providers/sysinfo/platform/linux/mounts.rs new file mode 100644 index 0000000..b09ee28 --- /dev/null +++ b/src/providers/sysinfo/platform/linux/mounts.rs @@ -0,0 +1,22 @@ +//! Linux mount option probe from `/proc/self/mountinfo`. + +use std::collections::HashMap; +use std::fs; + +/// Mount option string per mount point. +/// +/// Format per line: `id parent_id maj:min root mount_point options ...` +pub fn read_mount_options() -> HashMap { + let mut map = HashMap::new(); + if let Ok(content) = fs::read_to_string("/proc/self/mountinfo") { + for line in content.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 6 { + let mount_point = parts[4].to_string(); + let opts = parts[5].to_string(); + map.insert(mount_point, opts); + } + } + } + map +} diff --git a/src/providers/sysinfo/platform/linux/threads.rs b/src/providers/sysinfo/platform/linux/threads.rs new file mode 100644 index 0000000..c46d7dd --- /dev/null +++ b/src/providers/sysinfo/platform/linux/threads.rs @@ -0,0 +1,16 @@ +//! Linux process thread count probe from `/proc//status`. + +use std::fs; + +/// Thread count of a process. +pub fn read_thread_count(pid: sysinfo::Pid) -> u64 { + let path = format!("/proc/{pid}/status"); + if let Ok(content) = fs::read_to_string(&path) { + for line in content.lines() { + if let Some(rest) = line.strip_prefix("Threads:\t") { + return rest.trim().parse::().unwrap_or(0); + } + } + } + 0 +} diff --git a/src/providers/sysinfo/platform/macos.rs b/src/providers/sysinfo/platform/macos.rs new file mode 100644 index 0000000..8c50183 --- /dev/null +++ b/src/providers/sysinfo/platform/macos.rs @@ -0,0 +1,32 @@ +//! macOS probes. +//! +//! Real implementations would use IOKit for batteries and GPU, `getifaddrs` +//! for interface addresses and `getmntinfo` for mount options. Until then the +//! sysinfo crate is the only data source and these helpers stay empty. + +use std::collections::HashMap; +use xtop_plugin_api::model::{BatteryInfo, GpuInfo}; + +pub fn read_cpu_governor(_cpu_id: usize) -> String { + String::new() +} + +pub fn read_mount_options() -> HashMap { + HashMap::new() +} + +pub fn read_interface_ips() -> HashMap> { + HashMap::new() +} + +pub fn read_batteries() -> Vec { + Vec::new() +} + +pub fn read_thread_count(_pid: sysinfo::Pid) -> u64 { + 0 +} + +pub fn read_gpu_info_from_sysfs() -> Vec { + Vec::new() +} diff --git a/src/providers/sysinfo/platform/mod.rs b/src/providers/sysinfo/platform/mod.rs new file mode 100644 index 0000000..5c8716a --- /dev/null +++ b/src/providers/sysinfo/platform/mod.rs @@ -0,0 +1,33 @@ +//! OS-specific probes for the [`super`] sysinfo area. +//! +//! Every platform module below provides the same function set. The modules +//! that are not compiled for the current target are simply not exported; +//! `cfg` dispatch happens here in one place. +//! +//! [`shared`] holds probe logic used by more than one platform. +//! +//! Platform notes: +//! - **Linux**: reads `/sys` and `/proc` directly (governor, batteries, +//! interface addresses, threads, DRM GPUs). +//! - **macOS**: would use IOKit / getifaddrs / getmntinfo. +//! - **Windows**: would use WMI / GetAdaptersAddresses / NtQueryInformationProcess. + +pub mod shared; + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +mod fallback; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +pub use fallback::*; +#[cfg(target_os = "linux")] +pub use linux::*; +#[cfg(target_os = "macos")] +pub use macos::*; +#[cfg(target_os = "windows")] +pub use windows::*; diff --git a/src/providers/sysinfo/platform/shared/gpu.rs b/src/providers/sysinfo/platform/shared/gpu.rs new file mode 100644 index 0000000..e0d7ee1 --- /dev/null +++ b/src/providers/sysinfo/platform/shared/gpu.rs @@ -0,0 +1,37 @@ +//! GPU probe via the `nvidia-smi` CLI, shared by Linux and Windows. + +use xtop_plugin_api::model::GpuInfo; + +/// Query NVIDIA GPUs through `nvidia-smi` when available. +pub fn read_gpu_info_nvidia_smi() -> Vec { + let mut gpus = Vec::new(); + if let Ok(output) = std::process::Command::new("nvidia-smi") + .args([ + "--query-gpu=name,utilization.gpu,memory.total,memory.used,temperature.gpu", + "--format=csv,noheader,nounits", + ]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); + if parts.len() >= 5 { + let name = parts[0].to_string(); + let usage = parts[1].parse::().unwrap_or(0.0); + let mem_total = parts[2].parse::().unwrap_or(0) * 1024 * 1024; + let mem_used = parts[3].parse::().unwrap_or(0) * 1024 * 1024; + let temp = parts[4].parse::().unwrap_or(0.0); + gpus.push(GpuInfo { + name, + usage, + temperature: temp, + memory_total: mem_total, + memory_used: mem_used, + }); + } + } + } + } + gpus +} diff --git a/src/providers/sysinfo/platform/shared/mod.rs b/src/providers/sysinfo/platform/shared/mod.rs new file mode 100644 index 0000000..8e9bdb9 --- /dev/null +++ b/src/providers/sysinfo/platform/shared/mod.rs @@ -0,0 +1,5 @@ +//! Shared probes used by more than one platform. + +mod gpu; + +pub use gpu::*; diff --git a/src/providers/sysinfo/platform/windows.rs b/src/providers/sysinfo/platform/windows.rs new file mode 100644 index 0000000..358106d --- /dev/null +++ b/src/providers/sysinfo/platform/windows.rs @@ -0,0 +1,33 @@ +//! Windows probes. +//! +//! Real implementations would use WMI / PowerShell for batteries, +//! `GetAdaptersAddresses` for interface addresses and +//! `NtQueryInformationProcess` for thread counts. Until then the sysinfo +//! crate is the only data source and these helpers stay empty. + +use std::collections::HashMap; +use xtop_plugin_api::model::{BatteryInfo, GpuInfo}; + +pub fn read_cpu_governor(_cpu_id: usize) -> String { + String::new() +} + +pub fn read_mount_options() -> HashMap { + HashMap::new() +} + +pub fn read_interface_ips() -> HashMap> { + HashMap::new() +} + +pub fn read_batteries() -> Vec { + Vec::new() +} + +pub fn read_thread_count(_pid: sysinfo::Pid) -> u64 { + 0 +} + +pub fn read_gpu_info_from_sysfs() -> Vec { + Vec::new() +} diff --git a/crates/xtop-core/src/infrastructure/sysinfo_provider.rs b/src/providers/sysinfo/provider.rs similarity index 52% rename from crates/xtop-core/src/infrastructure/sysinfo_provider.rs rename to src/providers/sysinfo/provider.rs index bdcf373..35f1c62 100644 --- a/crates/xtop-core/src/infrastructure/sysinfo_provider.rs +++ b/src/providers/sysinfo/provider.rs @@ -1,11 +1,22 @@ -use crate::domain::metrics::*; -use crate::domain::system_info::SystemDataProvider; +//! Cross-platform implementation of [`SystemDataProvider`] for the kernel. +//! +//! Real-time data comes from the `sysinfo` crate; only the OS-specific gaps +//! (governors, batteries, interface IPs, thread counts, extra GPUs) are +//! delegated to [`super::platform`]. + +use super::platform::shared::read_gpu_info_nvidia_smi; +use super::platform::{ + read_batteries, read_cpu_governor, read_gpu_info_from_sysfs, read_interface_ips, + read_mount_options, read_thread_count, +}; use std::collections::HashMap; use std::time::Instant; use sysinfo::{ Components, CpuRefreshKind, Disks, MemoryRefreshKind, Networks, Pid, ProcessRefreshKind, RefreshKind, Signal, System, }; +use xtop_plugin_api::model::*; +use xtop_plugin_api::SystemDataProvider; pub const DEFAULT_MAX_PROCESSES: usize = 200; @@ -362,289 +373,15 @@ impl SysinfoProvider { } } -// --------------------------------------------------------------------------- -// Platform-specific helpers with graceful fallbacks -// --------------------------------------------------------------------------- - -#[cfg(target_os = "linux")] -fn read_cpu_governor(_cpu_id: usize) -> String { - std::fs::read_to_string(format!( - "/sys/devices/system/cpu/cpu{_cpu_id}/cpufreq/scaling_governor" - )) - .map(|s| s.trim().to_string()) - .unwrap_or_default() -} - -#[cfg(not(target_os = "linux"))] -fn read_cpu_governor(_cpu_id: usize) -> String { - String::new() -} - -#[cfg(target_os = "linux")] -fn read_mount_options() -> HashMap { - let mut map = HashMap::new(); - if let Ok(content) = std::fs::read_to_string("/proc/self/mountinfo") { - for line in content.lines() { - let parts: Vec<&str> = line.split_whitespace().collect(); - // Format: id parent_id maj:min root mount_point options ... - if parts.len() >= 6 { - let mount_point = parts[4].to_string(); - let opts = parts[5].to_string(); - map.insert(mount_point, opts); - } - } - } - map -} - -#[cfg(not(target_os = "linux"))] -fn read_mount_options() -> HashMap { - HashMap::new() -} - -#[cfg(target_os = "linux")] -fn read_interface_ips() -> HashMap> { - let mut map: HashMap> = HashMap::new(); - // Parse /proc/net/if_inet6 for IPv6 addresses - if let Ok(content) = std::fs::read_to_string("/proc/net/if_inet6") { - for line in content.lines() { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 5 { - let addr_hex = parts[0]; - let iface = parts[4].to_string(); - if addr_hex.len() == 32 { - let ip: String = (0..8) - .map(|i| { - let start = i * 4; - let group = &addr_hex[start..start + 4]; - let trimmed = group.trim_start_matches('0'); - let val = u16::from_str_radix( - if trimmed.is_empty() { "0" } else { trimmed }, - 16, - ) - .unwrap_or(0); - format!("{:x}", val) - }) - .collect::>() - .join(":"); - map.entry(iface).or_default().push(ip); - } - } - } - } - // Parse /proc/net/fib_trie for IPv4 (fallback) - map -} - -#[cfg(not(target_os = "linux"))] -fn read_interface_ips() -> HashMap> { - HashMap::new() -} - -#[cfg(target_os = "linux")] -fn read_batteries() -> Vec { - let mut batteries = Vec::new(); - let power_supply = std::path::Path::new("/sys/class/power_supply"); - if !power_supply.exists() { - return batteries; - } - if let Ok(entries) = std::fs::read_dir(power_supply) { - for entry in entries.flatten() { - let name = match entry.file_name().to_str() { - Some(n) if n.starts_with("BAT") => n.to_string(), - _ => continue, - }; - let base = entry.path(); - let capacity = std::fs::read_to_string(base.join("capacity")) - .ok() - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(0.0); - let state = std::fs::read_to_string(base.join("status")) - .ok() - .map(|s| s.trim().to_string()) - .unwrap_or_default(); - let charge_full = std::fs::read_to_string(base.join("charge_full")) - .ok() - .and_then(|s| s.trim().parse::().ok()); - let charge_now = std::fs::read_to_string(base.join("charge_now")) - .ok() - .and_then(|s| s.trim().parse::().ok()); - let cycles = std::fs::read_to_string(base.join("cycle_count")) - .ok() - .and_then(|s| s.trim().parse::().ok()); - - // time至full/empty estimation from power - let power_now = std::fs::read_to_string(base.join("power_now")) - .ok() - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(0); - let charge_full_design = std::fs::read_to_string(base.join("charge_full_design")) - .ok() - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(1); - - let health = if charge_full_design > 0 { - (charge_full.unwrap_or(1) as f32 / charge_full_design as f32) * 100.0 - } else { - 100.0 - }; - - let (time_to_full, time_to_empty) = if power_now != 0 && power_now.abs() > 0 { - if state == "Charging" { - let remaining = charge_full - .unwrap_or(0) - .saturating_sub(charge_now.unwrap_or(0)); - let secs = (remaining as f64 / power_now.abs() as f64 * 3600.0) as u64; - (Some(secs), None) - } else if state == "Discharging" { - let secs = - (charge_now.unwrap_or(0) as f64 / power_now.abs() as f64 * 3600.0) as u64; - (None, Some(secs)) - } else { - (None, None) - } - } else { - (None, None) - }; - - batteries.push(BatteryInfo { - name, - percentage: capacity, - state, - time_to_full, - time_to_empty, - health, - cycle_count: cycles, - }); - } - } - batteries -} - -#[cfg(not(target_os = "linux"))] -fn read_batteries() -> Vec { - // sysinfo's battery info is limited. On macOS we'd need IOKit. - // On Windows we'd need WMI. For now, return empty. - Vec::new() -} - fn read_gpu_info() -> Vec { - let mut gpus = Vec::new(); - // Try nvidia-smi first (cross-platform, works on Linux and Windows with NVIDIA drivers) - if let Ok(output) = std::process::Command::new("nvidia-smi") - .args([ - "--query-gpu=name,utilization.gpu,memory.total,memory.used,temperature.gpu", - "--format=csv,noheader,nounits", - ]) - .output() - { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); - if parts.len() >= 5 { - let name = parts[0].to_string(); - let usage = parts[1].parse::().unwrap_or(0.0); - let mem_total = parts[2].parse::().unwrap_or(0) * 1024 * 1024; - let mem_used = parts[3].parse::().unwrap_or(0) * 1024 * 1024; - let temp = parts[4].parse::().unwrap_or(0.0); - gpus.push(GpuInfo { - name, - usage, - temperature: temp, - memory_total: mem_total, - memory_used: mem_used, - }); - } - } - } - } + // nvidia-smi is shared by Linux and Windows; the remaining platforms get + // their own fallback probe under platform/. + let mut gpus = read_gpu_info_nvidia_smi(); - // Fallback: try reading from /sys/class/drm/ on Linux - #[cfg(target_os = "linux")] + // Fallback: platform-specific detection (e.g. /sys/class/drm on Linux). if gpus.is_empty() { - if let Ok(entries) = std::fs::read_dir("/sys/class/drm/") { - for entry in entries.flatten() { - let fname = entry.file_name().to_string_lossy().to_string(); - if fname.starts_with("card") && !fname.contains('-') { - let base = entry.path(); - let dev = base.join("device"); - let gpu_name = std::fs::read_to_string(dev.join("product_name")) - .ok() - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| fname.clone()); - let mem_total = std::fs::read_to_string(dev.join("mem_info_vram_total")) - .ok() - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(0); - let mem_used = std::fs::read_to_string(dev.join("mem_info_vram_used")) - .ok() - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(0); - let temp = find_hwmon_temp(&base.join("device"), "gpu").unwrap_or(0.0); - gpus.push(GpuInfo { - name: gpu_name, - usage: 0.0, - temperature: temp, - memory_total: mem_total, - memory_used: mem_used, - }); - } - } - } + gpus.extend(read_gpu_info_from_sysfs()); } gpus } - -#[cfg(target_os = "linux")] -fn find_hwmon_temp(device_path: &std::path::Path, label_filter: &str) -> Option { - let hwmon = device_path.join("hwmon"); - if hwmon.exists() { - if let Ok(entries) = std::fs::read_dir(&hwmon) { - for entry in entries.flatten() { - let hwmon_dir = entry.path(); - if let Ok(labels) = std::fs::read_to_string(hwmon_dir.join("temp1_label")) { - if labels.trim().to_lowercase().contains(label_filter) { - if let Ok(input) = std::fs::read_to_string(hwmon_dir.join("temp1_input")) { - if let Ok(millideg) = input.trim().parse::() { - return Some(millideg / 1000.0); - } - } - } - } - } - } - } - None -} - -#[cfg(not(target_os = "linux"))] -#[allow(dead_code)] -fn find_hwmon_temp(_device_path: &std::path::Path, _label_filter: &str) -> Option { - None -} - -// --------------------------------------------------------------------------- -// Thread count helper -// --------------------------------------------------------------------------- - -#[cfg(target_os = "linux")] -fn read_thread_count(pid: sysinfo::Pid) -> u64 { - use std::fs; - let path = format!("/proc/{}/status", pid); - if let Ok(content) = fs::read_to_string(&path) { - for line in content.lines() { - if let Some(rest) = line.strip_prefix("Threads:\t") { - return rest.trim().parse::().unwrap_or(0); - } - } - } - 0 -} - -#[cfg(not(target_os = "linux"))] -fn read_thread_count(_pid: sysinfo::Pid) -> u64 { - // Fallback: use tasks count from sysinfo (available on some platforms) - 0 -} diff --git a/crates/xtop-core/src/application/state.rs b/src/state/app.rs similarity index 72% rename from crates/xtop-core/src/application/state.rs rename to src/state/app.rs index 80ae584..6de04d3 100644 --- a/crates/xtop-core/src/application/state.rs +++ b/src/state/app.rs @@ -1,270 +1,16 @@ -use crate::application::history::MetricsHistory; -use crate::application::plugin_manager::PluginManager; -use crate::domain::keybinding::{Action, Keybindings}; -use crate::domain::layout::LayoutDef; -use crate::domain::metrics::SystemInfo; -use crate::domain::metrics::SystemSnapshot; -use crate::domain::plugin::WidgetRegistration; -use crate::domain::system_info::SystemDataProvider; -use crate::domain::theme::Theme; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub enum LayoutMode { - Dashboard, - Vertical, - Horizontal, - CpuFocus, - MemoryFocus, - NetworkFocus, - ProcessFocus, -} - -impl LayoutMode { - pub fn next(self) -> Self { - match self { - Self::Dashboard => Self::Vertical, - Self::Vertical => Self::Horizontal, - Self::Horizontal => Self::CpuFocus, - Self::CpuFocus => Self::MemoryFocus, - Self::MemoryFocus => Self::NetworkFocus, - Self::NetworkFocus => Self::ProcessFocus, - Self::ProcessFocus => Self::Dashboard, - } - } - - pub fn label(self) -> &'static str { - match self { - Self::Dashboard => "Dashboard", - Self::Vertical => "Vertical", - Self::Horizontal => "Horizontal", - Self::CpuFocus => "CPU Focus", - Self::MemoryFocus => "Memory Focus", - Self::NetworkFocus => "Network Focus", - Self::ProcessFocus => "Process Focus", - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum EffectiveLayout { - Dashboard, - Compact, - Vertical, - Horizontal, - CpuFocus, - MemoryFocus, - NetworkFocus, - ProcessFocus, - Minimal, -} - -fn layout_index_from_mode(mode: LayoutMode, defs: &[LayoutDef]) -> usize { - let label = mode.label(); - defs.iter().position(|d| d.name == label).unwrap_or(0) -} - -fn mode_from_layout_index(index: usize) -> LayoutMode { - match index { - 0 => LayoutMode::Dashboard, - 1 => LayoutMode::Vertical, - 2 => LayoutMode::Horizontal, - 3 => LayoutMode::CpuFocus, - 4 => LayoutMode::MemoryFocus, - 5 => LayoutMode::NetworkFocus, - 6 => LayoutMode::ProcessFocus, - _ => LayoutMode::Dashboard, - } -} - -pub fn detect_effective_layout(width: u16, height: u16, user_mode: LayoutMode) -> EffectiveLayout { - if width < 60 || height < 14 { - return EffectiveLayout::Minimal; - } - match user_mode { - LayoutMode::Dashboard => { - if width < 80 { - EffectiveLayout::Vertical - } else if width < 100 || height < 28 { - EffectiveLayout::Compact - } else { - EffectiveLayout::Dashboard - } - } - LayoutMode::Vertical => EffectiveLayout::Vertical, - LayoutMode::Horizontal => EffectiveLayout::Horizontal, - LayoutMode::CpuFocus => EffectiveLayout::CpuFocus, - LayoutMode::MemoryFocus => EffectiveLayout::MemoryFocus, - LayoutMode::NetworkFocus => EffectiveLayout::NetworkFocus, - LayoutMode::ProcessFocus => EffectiveLayout::ProcessFocus, - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum FullScreenWidget { - None, - Cpu, - Memory, - Storage, - Network, - Processes, - DiskIO, - Gpu, - Battery, -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub enum ProcessSortBy { - Cpu, - Memory, - Pid, - Name, -} - -impl ProcessSortBy { - pub fn next(self) -> Self { - match self { - Self::Cpu => Self::Memory, - Self::Memory => Self::Pid, - Self::Pid => Self::Name, - Self::Name => Self::Cpu, - } - } - - pub fn label(self) -> &'static str { - match self { - Self::Cpu => "CPU%", - Self::Memory => "Mem", - Self::Pid => "PID", - Self::Name => "Name", - } - } -} - -impl FullScreenWidget { - pub fn next(self) -> Self { - match self { - Self::None => Self::Cpu, - Self::Cpu => Self::Memory, - Self::Memory => Self::Storage, - Self::Storage => Self::Network, - Self::Network => Self::Processes, - Self::Processes => Self::DiskIO, - Self::DiskIO => Self::Gpu, - Self::Gpu => Self::Battery, - Self::Battery => Self::None, - } - } - - pub fn label(self) -> &'static str { - match self { - Self::None => "", - Self::Cpu => "CPU", - Self::Memory => "Memory", - Self::Storage => "Storage", - Self::Network => "Network", - Self::Processes => "Processes", - Self::DiskIO => "Disk I/O", - Self::Gpu => "GPU", - Self::Battery => "Battery", - } - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct PaletteEntry { - pub label: String, - pub action: Action, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum PalettePage { - Main, - Themes, - Layouts, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct PaletteState { - pub open: bool, - pub query: String, - pub selected: usize, - pub entries: Vec, - pub filtered: Vec, - pub page: PalettePage, -} - -impl PaletteState { - pub fn filtered_entries(&self) -> Vec<&PaletteEntry> { - self.filtered.iter().map(|&i| &self.entries[i]).collect() - } - - pub fn title(&self) -> &str { - match self.page { - PalettePage::Main => "Command Palette", - PalettePage::Themes => "Select Theme", - PalettePage::Layouts => "Select Layout", - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum InputMode { - Normal, - Searching, - CommandPalette, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -pub struct AlertThresholds { - pub cpu_high: f64, - pub mem_high: f64, - pub disk_high: f64, -} - -impl Default for AlertThresholds { - fn default() -> Self { - Self { - cpu_high: 90.0, - mem_high: 90.0, - disk_high: 90.0, - } - } -} - -fn default_layout_mode() -> LayoutMode { - LayoutMode::Dashboard -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct Config { - pub theme: String, - #[serde(default = "default_layout_mode")] - pub layout_mode: LayoutMode, - /// Layout name for custom layouts beyond the 7 built-in LayoutMode variants. - /// If non-empty, takes precedence over `layout_mode`. - #[serde(default)] - pub layout_name: String, - pub update_interval_ms: u64, - pub history_points: usize, - pub alerts: AlertThresholds, - #[serde(default)] - pub keybindings: Keybindings, -} - -impl Default for Config { - fn default() -> Self { - Self { - theme: "x".to_string(), - layout_mode: LayoutMode::Dashboard, - layout_name: String::new(), - update_interval_ms: 1000, - history_points: 100, - alerts: AlertThresholds::default(), - keybindings: Keybindings::default(), - } - } -} +use crate::config::keybinding::{Action, Keybindings}; +use crate::config::{AlertThresholds, Config}; +use crate::layout::{layout_index_from_mode, mode_from_layout_index, LayoutDef, LayoutMode}; +use crate::plugins::PluginManager; +use crate::state::history::MetricsHistory; +use crate::state::view::{ + FullScreenWidget, InputMode, PaletteEntry, PalettePage, PaletteState, ProcessSortBy, +}; +use crate::theme::Theme; +use xtop_plugin_api::model::SystemInfo; +use xtop_plugin_api::model::SystemSnapshot; +use xtop_plugin_api::SystemDataProvider; +use xtop_plugin_api::WidgetRegistration; pub struct AppState { provider: Box, @@ -283,7 +29,6 @@ pub struct AppState { pub full_screen_widget: FullScreenWidget, pub alerts: AlertThresholds, pub update_interval_ms: u64, - pub config_path: String, pub palette: PaletteState, pub keybindings: Keybindings, pub process_sort: ProcessSortBy, @@ -330,7 +75,6 @@ impl AppState { full_screen_widget: FullScreenWidget::None, alerts: config.alerts, update_interval_ms: config.update_interval_ms, - config_path: String::new(), palette: PaletteState { open: false, query: String::new(), @@ -731,7 +475,7 @@ impl AppState { } Action::ProcessUp => self.process_select_prev(), Action::ProcessDown => self.process_select_next(), - Action::SortByPid | Action::SortByCpu | Action::SortByName | Action::SortByMem => { + Action::SortByCpu => { self.cycle_sort(); } Action::RandomTheme => { @@ -753,6 +497,7 @@ impl AppState { #[cfg(test)] mod tests { use super::*; + use crate::layout::{detect_effective_layout, EffectiveLayout, LayoutMode}; #[test] fn test_layout_mode_next() { diff --git a/crates/xtop-core/src/application/history.rs b/src/state/history.rs similarity index 99% rename from crates/xtop-core/src/application/history.rs rename to src/state/history.rs index dafd59c..3cf0c51 100644 --- a/crates/xtop-core/src/application/history.rs +++ b/src/state/history.rs @@ -19,6 +19,7 @@ impl MetricsHistory { } } + #[cfg(test)] pub fn set_max_points(&mut self, max: usize) { self.max_points = max; self.mem.truncate(max); diff --git a/src/state/mod.rs b/src/state/mod.rs new file mode 100644 index 0000000..6a6108f --- /dev/null +++ b/src/state/mod.rs @@ -0,0 +1,12 @@ +//! State area: live application state and metrics history. +//! +//! `app` holds the full `AppState`; `history` keeps historical snapshots for +//! charts; `view` groups the view-control types (fullscreen, input mode, +//! palette). The persisted config schema lives under `config`. + +pub mod app; +pub mod history; +pub mod view; + +pub use app::*; +pub use view::*; diff --git a/src/state/view.rs b/src/state/view.rs new file mode 100644 index 0000000..b252450 --- /dev/null +++ b/src/state/view.rs @@ -0,0 +1,118 @@ +//! View-control state shared between the kernel state and the UI. +//! +//! Fullscreen targets, input modes and the command palette state. + +use crate::config::keybinding::Action; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum FullScreenWidget { + #[default] + None, + Cpu, + Memory, + Storage, + Network, + Processes, + DiskIO, + Gpu, + Battery, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum ProcessSortBy { + Cpu, + Memory, + Pid, + Name, +} + +impl ProcessSortBy { + pub fn next(self) -> Self { + match self { + Self::Cpu => Self::Memory, + Self::Memory => Self::Pid, + Self::Pid => Self::Name, + Self::Name => Self::Cpu, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Cpu => "CPU%", + Self::Memory => "Mem", + Self::Pid => "PID", + Self::Name => "Name", + } + } +} + +impl FullScreenWidget { + pub fn next(self) -> Self { + match self { + Self::None => Self::Cpu, + Self::Cpu => Self::Memory, + Self::Memory => Self::Storage, + Self::Storage => Self::Network, + Self::Network => Self::Processes, + Self::Processes => Self::DiskIO, + Self::DiskIO => Self::Gpu, + Self::Gpu => Self::Battery, + Self::Battery => Self::None, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::None => "", + Self::Cpu => "CPU", + Self::Memory => "Memory", + Self::Storage => "Storage", + Self::Network => "Network", + Self::Processes => "Processes", + Self::DiskIO => "Disk I/O", + Self::Gpu => "GPU", + Self::Battery => "Battery", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PaletteEntry { + pub label: String, + pub action: Action, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PalettePage { + Main, + Themes, + Layouts, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PaletteState { + pub open: bool, + pub query: String, + pub selected: usize, + pub entries: Vec, + pub filtered: Vec, + pub page: PalettePage, +} + +impl PaletteState { + pub fn title(&self) -> &str { + match self.page { + PalettePage::Main => "Command Palette", + PalettePage::Themes => "Select Theme", + PalettePage::Layouts => "Select Layout", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum InputMode { + Normal, + Searching, + CommandPalette, +} diff --git a/crates/xtop-core/src/infrastructure/theme_loader.rs b/src/theme/loader.rs similarity index 96% rename from crates/xtop-core/src/infrastructure/theme_loader.rs rename to src/theme/loader.rs index 653aeeb..45a585c 100644 --- a/crates/xtop-core/src/infrastructure/theme_loader.rs +++ b/src/theme/loader.rs @@ -1,11 +1,11 @@ -use crate::domain::theme::Theme; +use crate::theme::Theme; use std::fs; use std::path::Path; fn make_theme(name: &str, colors: [&str; 16]) -> Theme { let mut palette = [[0u8; 3]; 16]; for (i, h) in colors.iter().enumerate() { - palette[i] = crate::domain::theme::hex_to_rgb_pub(h); + palette[i] = crate::theme::hex_to_rgb_pub(h); } Theme { name: name.to_string(), @@ -106,6 +106,7 @@ pub fn load_all_themes() -> Vec { themes } +#[cfg(test)] pub fn builtin_themes() -> Vec { vec![default_theme()] } @@ -150,7 +151,7 @@ mod tests { #[test] fn test_hex_to_rgb() { - let result = crate::domain::theme::hex_to_rgb_pub("#ff0000"); + let result = crate::theme::hex_to_rgb_pub("#ff0000"); assert_eq!(result, [255, 0, 0]); } } diff --git a/src/theme/mod.rs b/src/theme/mod.rs new file mode 100644 index 0000000..082e545 --- /dev/null +++ b/src/theme/mod.rs @@ -0,0 +1,7 @@ +//! Theme area: color theme model, palettes and theme loading. + +mod loader; +mod model; + +pub use loader::*; +pub use model::*; diff --git a/crates/xtop-core/src/domain/theme.rs b/src/theme/model.rs similarity index 86% rename from crates/xtop-core/src/domain/theme.rs rename to src/theme/model.rs index 3fa6c8a..e7e4791 100644 --- a/crates/xtop-core/src/domain/theme.rs +++ b/src/theme/model.rs @@ -2,14 +2,6 @@ use serde::de::{self, Deserializer, MapAccess, Visitor}; use serde::{Deserialize, Serialize}; use std::fmt; -fn hex_to_rgb(hex: &str) -> [u8; 3] { - let hex = hex.trim_start_matches('#'); - let r = u8::from_str_radix(hex.get(0..2).unwrap_or("00"), 16).unwrap_or(0); - let g = u8::from_str_radix(hex.get(2..4).unwrap_or("00"), 16).unwrap_or(0); - let b = u8::from_str_radix(hex.get(4..6).unwrap_or("00"), 16).unwrap_or(0); - [r, g, b] -} - #[derive(Clone, Debug, Serialize)] pub struct Theme { pub name: String, @@ -76,7 +68,7 @@ impl<'de> Deserialize<'de> for Theme { let mut palette = [[0u8; 3]; 16]; for (i, hex) in palette_str.iter().enumerate() { - palette[i] = hex_to_rgb(hex); + palette[i] = xtop_plugin_api::hex_to_rgb(hex); } Ok(Theme { name, palette }) @@ -88,5 +80,5 @@ impl<'de> Deserialize<'de> for Theme { } pub fn hex_to_rgb_pub(hex: &str) -> [u8; 3] { - hex_to_rgb(hex) + xtop_plugin_api::hex_to_rgb(hex) } diff --git a/crates/xtop-tui/src/render/layout_engine.rs b/src/ui/layout/engine.rs similarity index 76% rename from crates/xtop-tui/src/render/layout_engine.rs rename to src/ui/layout/engine.rs index 1113bb0..dfeed2a 100644 --- a/crates/xtop-tui/src/render/layout_engine.rs +++ b/src/ui/layout/engine.rs @@ -1,14 +1,23 @@ -use crate::render::{battery, cpu, disk_io, gpu, header, memory, network, processes, storage}; +use crate::layout::{Direction, LayoutArea, LayoutDef, LayoutNode}; +use crate::state::AppState; +use crate::ui::widgets::{battery, cpu, disk_io, gpu, header, memory, network, processes, storage}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::Frame; use std::collections::HashMap; use std::sync::Arc; -use xtop_core::application::state::AppState; -use xtop_core::domain::layout::{Direction, LayoutArea, LayoutDef, LayoutNode}; +use xtop_plugin_api::HostState; /// A widget renderer: a callable that draws a widget onto the terminal. +/// +/// Built-in widgets receive the concrete [`AppState`]. pub type WidgetFn = Arc; +/// A plugin widget renderer. +/// +/// Plugin widgets only see the API contract ([`HostState`]), never kernel +/// types. The kernel coerces its state at the call site. +pub type PluginWidgetFn = Arc; + /// Create the default built-in widget map. pub fn default_widgets() -> HashMap<&'static str, WidgetFn> { let mut m: HashMap<&'static str, WidgetFn> = HashMap::new(); @@ -34,7 +43,7 @@ pub fn render_layout( area: Rect, def: &LayoutDef, widgets: &HashMap<&'static str, WidgetFn>, - plugin_widgets: &HashMap, + plugin_widgets: &HashMap, ) { render_node(f, state, area, &def.root, widgets, plugin_widgets); } @@ -45,7 +54,7 @@ fn render_node( area: Rect, node: &LayoutNode, widgets: &HashMap<&'static str, WidgetFn>, - plugin_widgets: &HashMap, + plugin_widgets: &HashMap, ) { match node { LayoutNode::Widget { name } => { @@ -81,8 +90,8 @@ fn render_node( fn to_ratatui_constraint(area: &LayoutArea) -> Constraint { match area.constraint { - xtop_core::domain::layout::LayoutConstraint::Length(n) => Constraint::Length(n), - xtop_core::domain::layout::LayoutConstraint::Percentage(p) => Constraint::Percentage(p), - xtop_core::domain::layout::LayoutConstraint::Fill => Constraint::Fill(1), + crate::layout::LayoutConstraint::Length(n) => Constraint::Length(n), + crate::layout::LayoutConstraint::Percentage(p) => Constraint::Percentage(p), + crate::layout::LayoutConstraint::Fill => Constraint::Fill(1), } } diff --git a/src/ui/layout/mod.rs b/src/ui/layout/mod.rs new file mode 100644 index 0000000..53c2f9a --- /dev/null +++ b/src/ui/layout/mod.rs @@ -0,0 +1,6 @@ +//! Layout area: the engine that maps layout definitions onto widget +//! renderers, plus the built-in widget registry. + +mod engine; + +pub use engine::*; diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..42fd9a5 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,16 @@ +//! UI area of xtop: terminal setup, screen composition and widgets. +//! +//! - [`terminal`] terminal backend lifecycle (raw mode, alternate screen) +//! - [`screen`] top-level composition (fullscreen, minimal, layout) +//! - [`layout`] layout engine + built-in widget registry +//! - [`widgets`] one folder per widget +//! - [`share`] UI-wide shared logic (colors, formatting, errors) + +pub mod layout; +pub mod screen; +pub mod share; +pub mod terminal; +pub mod widgets; + +pub use screen::*; +pub use terminal::*; diff --git a/crates/xtop-tui/src/render/mod.rs b/src/ui/screen.rs similarity index 90% rename from crates/xtop-tui/src/render/mod.rs rename to src/ui/screen.rs index f98841c..192fed0 100644 --- a/crates/xtop-tui/src/render/mod.rs +++ b/src/ui/screen.rs @@ -1,26 +1,12 @@ -mod battery; -mod cpu; -mod disk_io; -mod gpu; -mod header; -mod help; -mod memory; -mod network; -mod palette; -mod processes; -mod storage; - -mod layout_engine; - -use crate::color::to_color; -use layout_engine::{default_widgets, render_layout, WidgetFn}; +use crate::layout::{detect_effective_layout, EffectiveLayout}; +use crate::state::{AppState, FullScreenWidget, InputMode}; +use crate::ui::layout::{default_widgets, render_layout, PluginWidgetFn, WidgetFn}; +use crate::ui::share::to_color; +use crate::ui::widgets::*; use ratatui::prelude::*; use ratatui::Frame; use std::collections::HashMap; use std::sync::OnceLock; -use xtop_core::application::state::{ - detect_effective_layout, AppState, EffectiveLayout, FullScreenWidget, InputMode, -}; /// Built-in widgets (lazily initialized). fn widgets() -> &'static HashMap<&'static str, WidgetFn> { @@ -29,8 +15,11 @@ fn widgets() -> &'static HashMap<&'static str, WidgetFn> { } /// Build a plugin widget lookup map from AppState. -fn plugin_widgets(state: &AppState) -> HashMap { - let mut map: HashMap = HashMap::new(); +/// +/// Plugin renderers only see [`HostState`](xtop_plugin_api::HostState), which +/// the layout engine provides by coercing `state`. +fn plugin_widgets(state: &AppState) -> HashMap { + let mut map: HashMap = HashMap::new(); for reg in &state.plugin_widgets { map.insert(reg.name.clone(), reg.render.clone()); } diff --git a/crates/xtop-tui/src/color.rs b/src/ui/share/color.rs similarity index 100% rename from crates/xtop-tui/src/color.rs rename to src/ui/share/color.rs diff --git a/crates/xtop-tui/src/format.rs b/src/ui/share/format.rs similarity index 100% rename from crates/xtop-tui/src/format.rs rename to src/ui/share/format.rs diff --git a/src/ui/share/mod.rs b/src/ui/share/mod.rs new file mode 100644 index 0000000..fa9bfa2 --- /dev/null +++ b/src/ui/share/mod.rs @@ -0,0 +1,10 @@ +//! UI-wide shared logic used by multiple widgets. +//! +//! Widgets never reach outside `share` for rendering helpers; screen-level +//! error handling also belongs here when it grows. + +mod color; +mod format; + +pub use color::*; +pub use format::*; diff --git a/crates/xtop-tui/src/terminal.rs b/src/ui/terminal.rs similarity index 100% rename from crates/xtop-tui/src/terminal.rs rename to src/ui/terminal.rs diff --git a/crates/xtop-tui/src/render/battery.rs b/src/ui/widgets/battery/mod.rs similarity index 94% rename from crates/xtop-tui/src/render/battery.rs rename to src/ui/widgets/battery/mod.rs index ae4fa51..7a2d659 100644 --- a/crates/xtop-tui/src/render/battery.rs +++ b/src/ui/widgets/battery/mod.rs @@ -1,9 +1,11 @@ -use crate::color::to_color; +//! Battery widget: charge, status and health. + +use crate::state::AppState; +use crate::ui::share::to_color; use ratatui::prelude::*; use ratatui::symbols::border; use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap}; use ratatui::Frame; -use xtop_core::application::state::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let fg = to_color(state.current_theme.fg()); diff --git a/crates/xtop-tui/src/render/cpu.rs b/src/ui/widgets/cpu/mod.rs similarity index 97% rename from crates/xtop-tui/src/render/cpu.rs rename to src/ui/widgets/cpu/mod.rs index 5351b3b..92953e2 100644 --- a/crates/xtop-tui/src/render/cpu.rs +++ b/src/ui/widgets/cpu/mod.rs @@ -1,9 +1,11 @@ -use crate::color::{gauge_gradient, to_color}; +//! CPU widget: per-core usage bars and temperature. + +use crate::state::AppState; +use crate::ui::share::{gauge_gradient, to_color}; use ratatui::prelude::*; use ratatui::symbols::border; use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Gauge, GraphType}; use ratatui::Frame; -use xtop_core::application::state::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let fg = to_color(state.current_theme.fg()); diff --git a/crates/xtop-tui/src/render/disk_io.rs b/src/ui/widgets/disk_io/mod.rs similarity index 95% rename from crates/xtop-tui/src/render/disk_io.rs rename to src/ui/widgets/disk_io/mod.rs index cd0ce1a..833e163 100644 --- a/crates/xtop-tui/src/render/disk_io.rs +++ b/src/ui/widgets/disk_io/mod.rs @@ -1,10 +1,12 @@ -use crate::color::to_color; -use crate::format::format_bytes; +//! Disk I/O widget: read/write throughput. + +use crate::state::AppState; +use crate::ui::share::format_bytes; +use crate::ui::share::to_color; use ratatui::prelude::*; use ratatui::symbols::border; use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap}; use ratatui::Frame; -use xtop_core::application::state::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let fg = to_color(state.current_theme.fg()); diff --git a/crates/xtop-tui/src/render/gpu.rs b/src/ui/widgets/gpu/mod.rs similarity index 92% rename from crates/xtop-tui/src/render/gpu.rs rename to src/ui/widgets/gpu/mod.rs index 5aafe69..c6caf5f 100644 --- a/crates/xtop-tui/src/render/gpu.rs +++ b/src/ui/widgets/gpu/mod.rs @@ -1,10 +1,12 @@ -use crate::color::to_color; -use crate::format::format_bytes; +//! GPU widget: driver-reported GPU usage. + +use crate::state::AppState; +use crate::ui::share::format_bytes; +use crate::ui::share::to_color; use ratatui::prelude::*; use ratatui::symbols::border; use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap}; use ratatui::Frame; -use xtop_core::application::state::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let fg = to_color(state.current_theme.fg()); diff --git a/crates/xtop-tui/src/render/header.rs b/src/ui/widgets/header/mod.rs similarity index 91% rename from crates/xtop-tui/src/render/header.rs rename to src/ui/widgets/header/mod.rs index d3f7496..fff25ca 100644 --- a/crates/xtop-tui/src/render/header.rs +++ b/src/ui/widgets/header/mod.rs @@ -1,10 +1,12 @@ -use crate::color::to_color; -use crate::format::format_uptime; +//! Header widget: summary line with host and key metrics. + +use crate::state::{AppState, FullScreenWidget, InputMode}; +use crate::ui::share::format_uptime; +use crate::ui::share::to_color; use ratatui::prelude::*; use ratatui::symbols::border; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; -use xtop_core::application::state::{AppState, FullScreenWidget, InputMode}; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let fg = to_color(state.current_theme.fg()); diff --git a/crates/xtop-tui/src/render/help.rs b/src/ui/widgets/help/mod.rs similarity index 92% rename from crates/xtop-tui/src/render/help.rs rename to src/ui/widgets/help/mod.rs index aba850e..cfeb56f 100644 --- a/crates/xtop-tui/src/render/help.rs +++ b/src/ui/widgets/help/mod.rs @@ -1,9 +1,11 @@ -use crate::color::to_color; +//! Help widget: keybinding reference overlay. + +use crate::state::AppState; +use crate::ui::share::to_color; use ratatui::prelude::*; use ratatui::symbols::border; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; -use xtop_core::application::state::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let fg = to_color(state.current_theme.fg()); @@ -37,7 +39,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { Line::from(""), Line::from(" ─────────────────────────────────────────────"), Line::from(""), - Line::from(" https://github.com/xscriptor/xtop"), + Line::from(" https://github.com/xtop-cli/xtop"), Line::from(""), ]; diff --git a/crates/xtop-tui/src/render/memory.rs b/src/ui/widgets/memory/mod.rs similarity index 93% rename from crates/xtop-tui/src/render/memory.rs rename to src/ui/widgets/memory/mod.rs index 3480083..93f755f 100644 --- a/crates/xtop-tui/src/render/memory.rs +++ b/src/ui/widgets/memory/mod.rs @@ -1,10 +1,12 @@ -use crate::color::{gauge_gradient, to_color}; -use crate::format::format_bytes; +//! Memory widget: RAM and swap usage with history. + +use crate::state::AppState; +use crate::ui::share::format_bytes; +use crate::ui::share::{gauge_gradient, to_color}; use ratatui::prelude::*; use ratatui::symbols::border; use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Gauge, GraphType}; use ratatui::Frame; -use xtop_core::application::state::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let fg = to_color(state.current_theme.fg()); @@ -59,7 +61,7 @@ fn render_ram_gauge( f: &mut Frame, state: &AppState, area: Rect, - snap: &xtop_core::domain::metrics::SystemSnapshot, + snap: &xtop_plugin_api::model::SystemSnapshot, bg: Color, color_idx: usize, ) { @@ -85,7 +87,7 @@ fn render_swap_gauge( f: &mut Frame, state: &AppState, area: Rect, - snap: &xtop_core::domain::metrics::SystemSnapshot, + snap: &xtop_plugin_api::model::SystemSnapshot, bg: Color, ) { let swap_pct = snap.swap.percent as u16; diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs new file mode 100644 index 0000000..6379d08 --- /dev/null +++ b/src/ui/widgets/mod.rs @@ -0,0 +1,16 @@ +//! Widgets area: one folder per widget. +//! +//! Every widget exposes a `render(f, state, area)` entry point. Folders let +//! widgets subdivide into their own modules (and `share/`) as they grow. + +pub mod battery; +pub mod cpu; +pub mod disk_io; +pub mod gpu; +pub mod header; +pub mod help; +pub mod memory; +pub mod network; +pub mod palette; +pub mod processes; +pub mod storage; diff --git a/crates/xtop-tui/src/render/network.rs b/src/ui/widgets/network/mod.rs similarity index 96% rename from crates/xtop-tui/src/render/network.rs rename to src/ui/widgets/network/mod.rs index aa1639a..9702d9d 100644 --- a/crates/xtop-tui/src/render/network.rs +++ b/src/ui/widgets/network/mod.rs @@ -1,10 +1,12 @@ -use crate::color::to_color; -use crate::format::format_bytes; +//! Network widget: RX/TX rates per interface. + +use crate::state::AppState; +use crate::ui::share::format_bytes; +use crate::ui::share::to_color; use ratatui::prelude::*; use ratatui::symbols::border; use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Wrap}; use ratatui::Frame; -use xtop_core::application::state::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let fg = to_color(state.current_theme.fg()); @@ -69,7 +71,7 @@ fn render_stats( total_tx: u64, total_rx_speed: f64, total_tx_speed: f64, - interfaces: &[xtop_core::domain::metrics::NetworkInfo], + interfaces: &[xtop_plugin_api::model::NetworkInfo], ) { let mut text = vec![ Line::from(vec![ diff --git a/crates/xtop-tui/src/render/palette.rs b/src/ui/widgets/palette/mod.rs similarity index 95% rename from crates/xtop-tui/src/render/palette.rs rename to src/ui/widgets/palette/mod.rs index e1f1f11..d9a665f 100644 --- a/crates/xtop-tui/src/render/palette.rs +++ b/src/ui/widgets/palette/mod.rs @@ -1,8 +1,10 @@ -use crate::color::to_color; +//! Command palette widget: themes/layouts quick selection. + +use crate::state::{AppState, PalettePage}; +use crate::ui::share::to_color; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use ratatui::Frame; -use xtop_core::application::state::{AppState, PalettePage}; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let fg = to_color(state.current_theme.fg()); diff --git a/crates/xtop-tui/src/render/processes.rs b/src/ui/widgets/processes/mod.rs similarity index 88% rename from crates/xtop-tui/src/render/processes.rs rename to src/ui/widgets/processes/mod.rs index 565c169..f80395e 100644 --- a/crates/xtop-tui/src/render/processes.rs +++ b/src/ui/widgets/processes/mod.rs @@ -1,10 +1,12 @@ -use crate::color::to_color; +//! Processes widget: sortable live process table with search. + +use crate::state::AppState; +use crate::ui::share::to_color; use ratatui::prelude::*; use ratatui::symbols::border; use ratatui::widgets::{Block, Borders, Cell, Row, Table}; use ratatui::Frame; -use xtop_core::application::state::AppState; -use xtop_core::domain::metrics::ProcessInfo; +use xtop_plugin_api::model::ProcessInfo; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let fg = to_color(state.current_theme.fg()); @@ -42,20 +44,20 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { // Sort match state.process_sort { - xtop_core::application::state::ProcessSortBy::Cpu => { + crate::state::ProcessSortBy::Cpu => { items.sort_by(|a, b| { b.cpu_usage .partial_cmp(&a.cpu_usage) .unwrap_or(std::cmp::Ordering::Equal) }); } - xtop_core::application::state::ProcessSortBy::Memory => { + crate::state::ProcessSortBy::Memory => { items.sort_by_key(|b| std::cmp::Reverse(b.memory)); } - xtop_core::application::state::ProcessSortBy::Pid => { + crate::state::ProcessSortBy::Pid => { items.sort_by_key(|a| a.pid); } - xtop_core::application::state::ProcessSortBy::Name => { + crate::state::ProcessSortBy::Name => { items.sort_by_key(|a| a.name.to_lowercase()); } } @@ -79,7 +81,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { Cell::from(p.pid.to_string()), Cell::from(p.name.clone()), Cell::from(format!("{:.1}%", p.cpu_usage)), - Cell::from(crate::format::format_bytes(p.memory)), + Cell::from(crate::ui::share::format_bytes(p.memory)), Cell::from(p.user_id.clone().unwrap_or_else(|| "?".to_string())), ]) .style(style) diff --git a/crates/xtop-tui/src/render/storage.rs b/src/ui/widgets/storage/mod.rs similarity index 91% rename from crates/xtop-tui/src/render/storage.rs rename to src/ui/widgets/storage/mod.rs index 182a7ac..ec99c87 100644 --- a/crates/xtop-tui/src/render/storage.rs +++ b/src/ui/widgets/storage/mod.rs @@ -1,10 +1,12 @@ -use crate::color::{gauge_gradient, to_color}; -use crate::format::format_bytes; +//! Storage widget: mounted filesystems and usage. + +use crate::state::AppState; +use crate::ui::share::format_bytes; +use crate::ui::share::{gauge_gradient, to_color}; use ratatui::prelude::*; use ratatui::symbols::border; use ratatui::widgets::{Block, Borders, Gauge}; use ratatui::Frame; -use xtop_core::application::state::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { let fg = to_color(state.current_theme.fg()); From 5ca6d0805d5de99e4fa9b6f6f7659feebd1e1ed9 Mon Sep 17 00:00:00 2001 From: xscriptor Date: Fri, 4 Sep 2026 14:10:09 +0200 Subject: [PATCH 2/4] refactor: externalize layouts and widgets; engine pack resolution; bug fixes and audits - layouts: xtop-layout crate (repo layouts) with user-wins overrides; xtop layout check/install - widgets: packs via xtop-widget-api; engine resolves (pack, name); base pack + blocks alt - functioning: rates, chart, PID-anchored kill, per-tick snapshot cache, event loop (ctrl+c/paste/mouse), config merge, theme overrides, versioned asset seeding - plugins split into list/install/scaffold; help from live keybindings; audit script; app.rs split --- .gitignore | 3 + Cargo.lock | 1059 +++++++++++++++++ Cargo.toml | 9 +- assets/layouts/cpu_focus.jsonc | 12 - assets/layouts/dashboard.jsonc | 27 - assets/layouts/horizontal.jsonc | 20 - assets/layouts/memory_focus.jsonc | 12 - assets/layouts/network_focus.jsonc | 19 - assets/layouts/process_focus.jsonc | 21 - assets/layouts/vertical.jsonc | 15 - docs/customization.md | 49 +- docs/multi-repo.md | 6 +- docs/plugin.md | 45 +- scripts/audit.sh | 70 ++ src/commands/layout.rs | 125 ++ src/commands/mod.rs | 1 + src/commands/plugins/install.rs | 150 +++ src/commands/plugins/list.rs | 46 + src/commands/plugins/mod.rs | 52 + src/commands/plugins/scaffold.rs | 82 ++ .../{plugins.rs => plugins_dir_tmp/mod.rs} | 0 src/commands/run.rs | 238 ++-- src/commands/share/assets.rs | 112 +- src/commands/share/bootstrap.rs | 13 +- src/config/io.rs | 5 +- src/config/keybinding.rs | 18 + src/config/schema.rs | 62 +- src/layout/loader.rs | 273 ----- src/layout/mod.rs | 10 - src/layout/mode.rs | 98 -- src/layout/model.rs | 185 --- src/main.rs | 15 +- src/plugins/extension_host.rs | 3 + src/plugins/manager.rs | 1 + src/providers/composite.rs | 1 + src/providers/sysinfo/provider.rs | 38 +- src/state/app.rs | 403 +++---- src/state/mod.rs | 2 + src/state/palette.rs | 134 +++ src/state/widget_state.rs | 102 ++ src/theme/loader.rs | 14 +- src/theme/model.rs | 3 +- src/ui/layout/engine.rs | 125 +- src/ui/mod.rs | 11 +- src/ui/{widgets => overlay}/help/mod.rs | 59 +- src/ui/overlay/mod.rs | 7 + src/ui/{widgets => overlay}/palette/mod.rs | 0 src/ui/screen.rs | 65 +- src/ui/share/color.rs | 16 +- src/ui/share/format.rs | 81 -- src/ui/share/mod.rs | 8 +- src/ui/terminal.rs | 17 +- src/ui/widgets/battery/mod.rs | 63 - src/ui/widgets/cpu/mod.rs | 141 --- src/ui/widgets/disk_io/mod.rs | 87 -- src/ui/widgets/gpu/mod.rs | 59 - src/ui/widgets/header/mod.rs | 73 -- src/ui/widgets/memory/mod.rs | 142 --- src/ui/widgets/mod.rs | 16 - src/ui/widgets/network/mod.rs | 168 --- src/ui/widgets/processes/mod.rs | 113 -- src/ui/widgets/storage/mod.rs | 60 - 62 files changed, 2591 insertions(+), 2273 deletions(-) create mode 100644 Cargo.lock delete mode 100644 assets/layouts/cpu_focus.jsonc delete mode 100644 assets/layouts/dashboard.jsonc delete mode 100644 assets/layouts/horizontal.jsonc delete mode 100644 assets/layouts/memory_focus.jsonc delete mode 100644 assets/layouts/network_focus.jsonc delete mode 100644 assets/layouts/process_focus.jsonc delete mode 100644 assets/layouts/vertical.jsonc create mode 100755 scripts/audit.sh create mode 100644 src/commands/layout.rs create mode 100644 src/commands/plugins/install.rs create mode 100644 src/commands/plugins/list.rs create mode 100644 src/commands/plugins/mod.rs create mode 100644 src/commands/plugins/scaffold.rs rename src/commands/{plugins.rs => plugins_dir_tmp/mod.rs} (100%) delete mode 100644 src/layout/loader.rs delete mode 100644 src/layout/mod.rs delete mode 100644 src/layout/mode.rs delete mode 100644 src/layout/model.rs create mode 100644 src/state/palette.rs create mode 100644 src/state/widget_state.rs rename src/ui/{widgets => overlay}/help/mod.rs (51%) create mode 100644 src/ui/overlay/mod.rs rename src/ui/{widgets => overlay}/palette/mod.rs (100%) delete mode 100644 src/ui/share/format.rs delete mode 100644 src/ui/widgets/battery/mod.rs delete mode 100644 src/ui/widgets/cpu/mod.rs delete mode 100644 src/ui/widgets/disk_io/mod.rs delete mode 100644 src/ui/widgets/gpu/mod.rs delete mode 100644 src/ui/widgets/header/mod.rs delete mode 100644 src/ui/widgets/memory/mod.rs delete mode 100644 src/ui/widgets/mod.rs delete mode 100644 src/ui/widgets/network/mod.rs delete mode 100644 src/ui/widgets/processes/mod.rs delete mode 100644 src/ui/widgets/storage/mod.rs diff --git a/.gitignore b/.gitignore index dcb04d6..eb1eae4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ # Local development overrides (see .cargo/config.toml inside) /.cargo + +# Scaffolded plugins (dev-time tooling) +/plugins-dev diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a1bcef3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1059 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.4", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[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", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[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.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", + "windows", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "xtop" +version = "0.2.0" +dependencies = [ + "anyhow", + "crossterm", + "ratatui", + "serde", + "serde_json", + "sysinfo", + "toml", + "xtop-extension-api", + "xtop-extension-mcp", + "xtop-layout", + "xtop-plugin-api", + "xtop-plugin-samurai", + "xtop-widget-api", + "xtop-widget-blocks", + "xtop-widgets", +] + +[[package]] +name = "xtop-extension-api" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/api#2a3f33ae7151a5b0a15978d37cb6c4afb3359205" + +[[package]] +name = "xtop-extension-mcp" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/extensions#5d9d4b08bca6cf77ac0bc905c2c79148f1d0255c" +dependencies = [ + "serde_json", + "xtop-extension-api", +] + +[[package]] +name = "xtop-layout" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/layouts#99e9c8a2be60e52aa52b41e700206bf25d6d5aca" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "xtop-plugin-api" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/api#2a3f33ae7151a5b0a15978d37cb6c4afb3359205" +dependencies = [ + "ratatui", +] + +[[package]] +name = "xtop-plugin-samurai" +version = "0.2.0" +source = "git+https://github.com/xtop-cli/plugins#7d49f7ec246ec99e964a045ddaa356a627396b8b" +dependencies = [ + "ratatui", + "regex", + "serde", + "serde_json", + "xtop-plugin-api", +] + +[[package]] +name = "xtop-widget-api" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/api#2a3f33ae7151a5b0a15978d37cb6c4afb3359205" +dependencies = [ + "ratatui", + "serde", + "xtop-plugin-api", +] + +[[package]] +name = "xtop-widget-blocks" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/widgets#cdd72c6aac028ed6e71a7d4d37be0f8c8d1f498a" +dependencies = [ + "ratatui", + "xtop-widget-api", +] + +[[package]] +name = "xtop-widgets" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/widgets#cdd72c6aac028ed6e71a7d4d37be0f8c8d1f498a" +dependencies = [ + "ratatui", + "xtop-plugin-api", + "xtop-widget-api", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index bf1d48c..5ceb551 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,11 +19,17 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" -# xtop-cli/api, xtop-cli/plugins y xtop-cli/extensions (repos hermanos). +# xtop-cli/api, xtop-cli/plugins, xtop-cli/extensions y xtop-cli/layouts +# (repos hermanos). # Distribution: git dependencies on the published repos, so a clean clone # builds without needing their sources checked out. xtop-plugin-api = { git = "https://github.com/xtop-cli/api" } xtop-extension-api = { git = "https://github.com/xtop-cli/api" } +xtop-widget-api = { git = "https://github.com/xtop-cli/api" } +# xtop-cli/widgets (packs base y alternativos, en su propio repo). +xtop-widgets = { git = "https://github.com/xtop-cli/widgets" } +xtop-widget-blocks = { git = "https://github.com/xtop-cli/widgets", optional = true } +xtop-layout = { git = "https://github.com/xtop-cli/layouts" } xtop-extension-mcp = { git = "https://github.com/xtop-cli/extensions", optional = true } xtop-plugin-samurai = { git = "https://github.com/xtop-cli/plugins", optional = true } @@ -31,3 +37,4 @@ xtop-plugin-samurai = { git = "https://github.com/xtop-cli/plugins", optional = default = ["plugin-samurai", "mcp-extension"] plugin-samurai = ["dep:xtop-plugin-samurai"] mcp-extension = ["dep:xtop-extension-mcp"] +widget-blocks = ["dep:xtop-widget-blocks"] diff --git a/assets/layouts/cpu_focus.jsonc b/assets/layouts/cpu_focus.jsonc deleted file mode 100644 index 13e24b0..0000000 --- a/assets/layouts/cpu_focus.jsonc +++ /dev/null @@ -1,12 +0,0 @@ -{ - // CPU Focus: CPU takes 60%, processes the rest - "name": "CPU Focus", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { "widget": "cpu", "size": "60%" }, - { "widget": "processes", "size": "*" } - ] - } -} diff --git a/assets/layouts/dashboard.jsonc b/assets/layouts/dashboard.jsonc deleted file mode 100644 index 9444014..0000000 --- a/assets/layouts/dashboard.jsonc +++ /dev/null @@ -1,27 +0,0 @@ -{ - // Dashboard: default 2-column layout - "name": "Dashboard", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { - "direction": "horizontal", - "size": "45%", - "areas": [ - { "widget": "cpu", "size": "50%" }, - { - "direction": "vertical", - "size": "50%", - "areas": [ - { "widget": "memory", "size": "33%" }, - { "widget": "storage", "size": "33%" }, - { "widget": "network", "size": "34%" } - ] - } - ] - }, - { "widget": "processes", "size": "52%" } - ] - } -} diff --git a/assets/layouts/horizontal.jsonc b/assets/layouts/horizontal.jsonc deleted file mode 100644 index 03c39ae..0000000 --- a/assets/layouts/horizontal.jsonc +++ /dev/null @@ -1,20 +0,0 @@ -{ - // Horizontal: 4 widgets side-by-side - "name": "Horizontal", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { - "direction": "horizontal", - "size": "*", - "areas": [ - { "widget": "cpu", "size": "25%" }, - { "widget": "memory", "size": "25%" }, - { "widget": "storage", "size": "25%" }, - { "widget": "network", "size": "25%" } - ] - } - ] - } -} diff --git a/assets/layouts/memory_focus.jsonc b/assets/layouts/memory_focus.jsonc deleted file mode 100644 index 5630296..0000000 --- a/assets/layouts/memory_focus.jsonc +++ /dev/null @@ -1,12 +0,0 @@ -{ - // Memory Focus: Memory takes 60%, processes the rest - "name": "Memory Focus", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { "widget": "memory", "size": "60%" }, - { "widget": "processes", "size": "*" } - ] - } -} diff --git a/assets/layouts/network_focus.jsonc b/assets/layouts/network_focus.jsonc deleted file mode 100644 index d5c2b30..0000000 --- a/assets/layouts/network_focus.jsonc +++ /dev/null @@ -1,19 +0,0 @@ -{ - // Network Focus: Network + Disk I/O side by side, process list below - "name": "Network Focus", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { - "direction": "horizontal", - "size": "50%", - "areas": [ - { "widget": "network", "size": "50%" }, - { "widget": "disk_io", "size": "50%" } - ] - }, - { "widget": "processes", "size": "*" } - ] - } -} diff --git a/assets/layouts/process_focus.jsonc b/assets/layouts/process_focus.jsonc deleted file mode 100644 index a3417ee..0000000 --- a/assets/layouts/process_focus.jsonc +++ /dev/null @@ -1,21 +0,0 @@ -{ - // Process Focus: mini stats row at top, full process list below - "name": "Process Focus", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { - "direction": "horizontal", - "size": 8, - "areas": [ - { "widget": "cpu", "size": "25%" }, - { "widget": "memory", "size": "25%" }, - { "widget": "storage", "size": "25%" }, - { "widget": "network", "size": "25%" } - ] - }, - { "widget": "processes", "size": "*" } - ] - } -} diff --git a/assets/layouts/vertical.jsonc b/assets/layouts/vertical.jsonc deleted file mode 100644 index 864f022..0000000 --- a/assets/layouts/vertical.jsonc +++ /dev/null @@ -1,15 +0,0 @@ -{ - // Vertical: all widgets stacked top-to-bottom - "name": "Vertical", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { "widget": "cpu", "size": 8 }, - { "widget": "memory", "size": 8 }, - { "widget": "storage", "size": 6 }, - { "widget": "network", "size": 5 }, - { "widget": "processes", "size": "*" } - ] - } -} diff --git a/docs/customization.md b/docs/customization.md index e93ff7d..a13aacd 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -347,19 +347,25 @@

      Starter Layouts

      -

      The 7 built-in layouts are embedded in the binary and written to ~/.config/xtop/layouts/ on first run.

      +

      The 7 built-in layouts ship in the xtop-layout crate +(github.com/xtop-cli/layouts, folder layouts/default/) and are embedded in the +binary. On first run their JSONC sources are copied to ~/.config/xtop/layouts/ as +editable templates. Community layouts live in layouts/custom/ of the same repo; +install one with xtop layout install <name> (or copy the file into +~/.config/xtop/layouts/). Validate a local file with xtop layout check <file>.

      -

      To restore them later, copy from the repository:

      +

      A layout file whose name matches a built-in layout overrides it (e.g. +edit dashboard.jsonc to customize the Dashboard). Files with new names show up as +extra layouts.

      -
      cp -r assets/layouts/* ~/.config/xtop/layouts/
      - -

      Available layouts: dashboard, vertical, horizontal, cpu_focus, memory_focus, network_focus, process_focus.

      +

      Built-in layouts: dashboard, vertical, horizontal, cpu_focus, memory_focus, network_focus, process_focus.

      Cycling Order

      1. Built-in layouts (Dashboard → Vertical → Horizontal → CPU Focus → Memory Focus → Network Focus → Process Focus)
      2. -
      3. Custom layouts from ~/.config/xtop/layouts/ (in filesystem order)
      4. +
      5. Any custom layout from ~/.config/xtop/layouts/ with a new name (filesystem order)
      6. +
      7. Custom files that reuse a built-in name override that built-in in place (no duplicates)
      8. Wraps back to Dashboard
      @@ -374,6 +380,37 @@
    14. Very small terminals (under 60×14) fall back to a minimal hardcoded layout (CPU + Memory gauges + process list).
    15. +

      Widget glyph style

      + +

      Los charts (CPU/Memory/Network) y los bordes de los widgets se dibujan con +glifos por defecto (braille, bordes redondeados/unicode). Se pueden cambiar en +~/.config/xtop/config.json dentro de la clave style:

      + +
      {
      +  "theme": "x",
      +  "style": {
      +    "charset": "block",
      +    "borders": "ascii",
      +    "widgets": {
      +      "cpu": { "charset": "bar" },
      +      "network": { "borders": "double" }
      +    }
      +  }
      +}
      + +
        +
      • charset: braille (por defecto), dot, block, half_block, bar.
      • +
      • borders: native (cada widget con su borde clásico, por defecto), rounded, double, plain, ascii (+-|).
      • +
      • widgets: override por widget (los nombres son los que usan los + layouts: header, cpu, memory, storage, + network, processes, disk_io, battery, gpu).
      • +
      + +

      Los estilos son solo de apariencia: la estructura de cada widget sigue +siendo la que dibuja el kernel. Un widget completamente nuevo (otra lógica o + renderer) se puede aportar como plugin (los renderers de plugins tienen + precedencia sobre los built-in).

      +

      diff --git a/docs/multi-repo.md b/docs/multi-repo.md index 55655bc..c22ba58 100644 --- a/docs/multi-repo.md +++ b/docs/multi-repo.md @@ -6,11 +6,13 @@ | Repo (xtop-cli) | Rol | Contenido | |---|---|---| -| `xtop` | **Kernel** — la app | workspace: `crates/xtop-core`, `xtop-tui`, `xtop` (bin). Nada más | -| `api` | **Contratos** | workspace: `crates/plugin-api`, `effect-api`, `extension-api` → crates publicados `xtop-plugin-api`, `xtop-effect-api`, `xtop-extension-api` | +| `xtop` | **Kernel** — la app | monocrate `src/` por áreas (commands, config, plugins, providers, state, theme, ui). Nada más | +| `api` | **Contratos** | workspace: `crates/plugin-api`, `widget-api`, `effect-api`, `extension-api` → crates publicados `xtop-plugin-api`, `xtop-widget-api`, `xtop-effect-api`, `xtop-extension-api` | +| `layouts` | Layouts data-driven | repo `layouts`: crate `xtop-layout` (model + loader jsonc + modos, sin UI) + `layouts/default/` (7 built-ins) + `layouts/custom/` (comunidad, instalables) | | `plugins` | Implementaciones de plugins | workspace `plugins/xtop-plugin-*` (1er miembro: samurai) | | `effects` | Efectos visuales TUI | workspace `effects/xtop-effect-*` (+ `effects-lib` compartido) | | `extensions` | Hooks/add-ons del kernel | workspace `extensions/xtop-extension-*` | +| `widgets` | Packs de widgets | repo `widgets`: pack base `xtop-widgets` + packs alternativos (`packs/xtop-widget-blocks`) + `custom/` comunidad, contra `xtop-widget-api`. El kernel solo conserva engine + estado | Layout local de desarrollo (repos hermanos, como hoy): diff --git a/docs/plugin.md b/docs/plugin.md index 4df4ab1..f100e89 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -156,11 +156,11 @@ ctx.data_dir() // ~/.config/xtop/plugins/<id>/ xtop plugin list - List installed plugins from workspace members + List plugins wired into the kernel Cargo.toml xtop plugin install <name> - Install a plugin from github.com/xtop-cli/xtop/plugins/ + Install a plugin from github.com/xtop-cli/plugins xtop plugin install <url> @@ -168,7 +168,7 @@ ctx.data_dir() // ~/.config/xtop/plugins/<id>/ xtop plugin scaffold <name> - Create a new plugin crate template in plugins/ + Create a new plugin crate template in plugins-dev/ @@ -178,20 +178,19 @@ ctx.data_dir() // ~/.config/xtop/plugins/<id>/

      When running xtop plugin install samurai:

        -
      1. Clones github.com/xtop-cli/xtop.git (shallow, sparse)
      2. -
      3. Looks for plugins/xtop-plugin-samurai/ or plugins/samurai/ in the clone
      4. -
      5. Copies to local plugins/ directory
      6. -
      7. Adds entry to [workspace].members in root Cargo.toml
      8. -
      9. Adds optional dependency + feature flag in crates/xtop-cli/Cargo.toml
      10. -
      11. Runs cargo build --release
      12. -
      13. Cleans up temporary files
      14. +
      15. Resolves the source repo: github.com/xtop-cli/plugins for a name, or the given URL
      16. +
      17. Clones it (shallow, sparse)
      18. +
      19. Locates the xtop-plugin-<name> crate inside the clone
      20. +
      21. Adds an optional git dependency + feature flag in the kernel's root Cargo.toml + (same pattern as the built-in xtop-plugin-samurai)
      22. +
      23. Runs cargo check and cleans up temporary files
      -

      The plugin is registered in the workspace but not enabled by default. To enable it:

      +

      The plugin is registered but not enabled by default. To enable it:

      • Build with --features plugin-<name> for a one-off build
      • -
      • Add it to the default list in crates/xtop-cli/Cargo.toml to enable permanently
      • +
      • Add it to the default list in [features] in the root Cargo.toml to enable permanently
      # Build xtop with samurai plugin enabled
      @@ -240,31 +239,29 @@ xtop mcp
      1. -

        Create the crate in plugins/:

        -
        mkdir -p plugins/xtop-plugin-mything/src
        +

        Scaffold the crate (or write it by hand in your own repo):

        +
        xtop plugin scaffold mything   # creates plugins-dev/xtop-plugin-mything/
      2. -

        Add to workspace Cargo.toml:

        -
        [workspace]
        -members = [
        -    ...
        -    "plugins/xtop-plugin-mything",
        -]
        +

        Make it a git repo and push it (the crate must live at the repo root or under + plugins//crates/, e.g. github.com/you/xtop-plugin-mything).

      3. -

        Add dependency + feature in crates/xtop-cli/Cargo.toml:

        +

        Install it into the kernel (adds optional git dependency + feature flag in the root Cargo.toml):

        +
        xtop plugin install https://github.com/you/xtop-plugin-mything
        +

        Equivalent manual edit:

        [dependencies]
        -xtop-plugin-mything = { path = "../../plugins/xtop-plugin-mything", optional = true }
        +xtop-plugin-mything = { git = "https://github.com/you/xtop-plugin-mything", optional = true }
         
         [features]
         plugin-mything = ["dep:xtop-plugin-mything"]
      4. -

        Register in crates/xtop-cli/src/main.rs:

        +

        Register it behind the feature flag in src/commands/share/bootstrap.rs:

        #[cfg(feature = "plugin-mything")]
         use xtop_plugin_mything::MythingPlugin;
         
        -// In build_plugin_manager():
        +// In register_plugins():
         #[cfg(feature = "plugin-mything")]
         {
             let plugin = Box::new(MythingPlugin::new());
        diff --git a/scripts/audit.sh b/scripts/audit.sh
        new file mode 100755
        index 0000000..ed86744
        --- /dev/null
        +++ b/scripts/audit.sh
        @@ -0,0 +1,70 @@
        +#!/usr/bin/env bash
        +# Structural audit for the xtop kernel (ROADMAP #47).
        +#
        +# Failing thresholds (exit 1 when violated):
        +#   - cfg(target_os) outside platform/ trees        : must be 0
        +#   - files over 600 lines                           : must be 0
        +#   - TODO/FIXME/XXX/HACK markers                    : must be 0
        +#   - `pub use ...::*` wildcard re-exports           : <= 30 (down from 30+)
        +#   - LOC per top-level area                         : <= 2400
        +#
        +# Non-failing metrics are printed for tracking (LOC, module count).
        +set -u
        +cd "$(dirname "$0")/.." || exit 1
        +SRC=src
        +
        +fail=0
        +total_loc=0
        +
        +echo "== xtop structural audit =="
        +
        +# --- cfg(target_os) only inside platform/ trees ---------------------------------
        +bad_cfg=$(grep -rn "cfg(target_os" "$SRC" | grep -v "/platform/" | wc -l)
        +echo "cfg(target_os) outside platform/ trees: $bad_cfg (must be 0)"
        +[ "$bad_cfg" -ne 0 ] && fail=1
        +
        +# --- oversized files --------------------------------------------------------------
        +echo "-- files > 300 lines (watch) and > 600 (fail):"
        +big600=$(find "$SRC" -name '*.rs' -exec wc -l {} + | awk '$1 > 600 && $2 != "total" {print $2": "$1" lines"}')
        +big300=$(find "$SRC" -name '*.rs' -exec wc -l {} + | awk '$1 > 300 && $2 != "total" {print $2": "$1" lines"}')
        +if [ -n "$big600" ]; then
        +    echo "$big600"
        +    echo "  ^ files over 600 lines"
        +    fail=1
        +fi
        +[ -n "$big300" ] && echo "$big300" || echo "  none > 300" 
        +
        +# --- TODO/FIXME markers -------------------------------------------------------------
        +todos=$(grep -rn "TODO\|FIXME\|XXX\|HACK" "$SRC" | wc -l)
        +echo "TODO/FIXME/XXX/HACK markers: $todos (must be 0)"
        +[ "$todos" -ne 0 ] && fail=1
        +
        +# --- wildcard re-exports -------------------------------------------------------------
        +wild=$(grep -rn "pub use .*::\*" "$SRC" | wc -l)
        +echo "wildcard 'pub use ...::*': $wild (allow <= 30)"
        +[ "$wild" -gt 30 ] && fail=1
        +
        +# --- LOC per top-level area ------------------------------------------------------------
        +echo "-- LOC per area:"
        +for area in "$SRC"/*; do
        +    [ -d "$area" ] || continue
        +    loc=$(find "$area" -name '*.rs' -exec cat {} + | wc -l)
        +    total_loc=$((total_loc + loc))
        +    name=$(basename "$area")
        +    echo "  $name: $loc"
        +    [ "$loc" -gt 2400 ] && { echo "    ^ exceeds 2400"; fail=1; }
        +done
        +main_loc=$(wc -l < "$SRC/main.rs")
        +total_loc=$((total_loc + main_loc))
        +echo "main.rs: $main_loc / total kernel: $total_loc"
        +
        +# --- module graph sanity (imports of kernel areas from lower layers) -------------------
        +echo "cross-area imports (info):"
        +grep -rn "use crate::" "$SRC" --include='*.rs' | awk -F'::' '{print $0}' | wc -l | xargs echo "  total use crate:: lines:"
        +
        +if [ "$fail" -eq 0 ]; then
        +    echo "AUDIT OK"
        +else
        +    echo "AUDIT FAILED"
        +fi
        +exit $fail
        diff --git a/src/commands/layout.rs b/src/commands/layout.rs
        new file mode 100644
        index 0000000..5115e9d
        --- /dev/null
        +++ b/src/commands/layout.rs
        @@ -0,0 +1,125 @@
        +//! `xtop layout` subcommands: validate layout files and install community
        +//! layouts from `layouts/custom/` of the `xtop-cli/layouts` repo into the
        +//! user config dir.
        +
        +use std::fs;
        +use std::path::Path;
        +
        +const LAYOUTS_REPO: &str = "https://github.com/xtop-cli/layouts";
        +
        +/// Handle `xtop layout ` from the parsed argument vector.
        +pub fn layout_command(args: &[String]) -> anyhow::Result<()> {
        +    if args.len() < 3 {
        +        eprintln!("Usage: xtop layout ");
        +        return Ok(());
        +    }
        +    match args[2].as_str() {
        +        "install" => {
        +            if args.len() < 4 {
        +                eprintln!("Usage: xtop layout install ");
        +                return Ok(());
        +            }
        +            cmd_install(&args[3])
        +        }
        +        "check" => {
        +            if args.len() < 4 {
        +                eprintln!("Usage: xtop layout check ");
        +                return Ok(());
        +            }
        +            cmd_check(Path::new(&args[3]))
        +        }
        +        other => {
        +            eprintln!("Unknown layout subcommand: {other}");
        +            Ok(())
        +        }
        +    }
        +}
        +
        +/// Validate a layout file against the schema.
        +fn cmd_check(path: &Path) -> anyhow::Result<()> {
        +    let data = fs::read_to_string(path)
        +        .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?;
        +    match xtop_layout::parse_layout_err(&data) {
        +        Ok(def) => {
        +            println!("OK  {} -> layout \"{}\" is valid", path.display(), def.name);
        +            Ok(())
        +        }
        +        Err(e) => anyhow::bail!("INVALID {} -> {e}", path.display()),
        +    }
        +}
        +
        +/// Install a community layout from the repo's `layouts/custom/` folder.
        +fn cmd_install(name: &str) -> anyhow::Result<()> {
        +    let tmp = std::env::temp_dir().join("xtop-layout-install");
        +    let _ = fs::remove_dir_all(&tmp);
        +    println!("Fetching community layouts from {LAYOUTS_REPO} ...");
        +    let status = std::process::Command::new("git")
        +        .args([
        +            "clone",
        +            "--depth",
        +            "1",
        +            "--filter=blob:none",
        +            "--sparse",
        +            LAYOUTS_REPO,
        +            tmp.to_str().unwrap(),
        +        ])
        +        .status()
        +        .map_err(|e| anyhow::anyhow!("failed to run git: {e}"))?;
        +    if !status.success() {
        +        anyhow::bail!("git clone failed");
        +    }
        +    let checkout = std::process::Command::new("git")
        +        .args(["sparse-checkout", "set", "layouts/custom"])
        +        .current_dir(&tmp)
        +        .status()
        +        .map_err(|e| anyhow::anyhow!("failed to run git: {e}"))?;
        +    if !checkout.success() {
        +        anyhow::bail!("git sparse-checkout failed");
        +    }
        +
        +    let custom_dir = tmp.join("layouts").join("custom");
        +    let needle = name.to_lowercase();
        +    let mut found: Option<(String, String)> = None; // (filename, content)
        +    if let Ok(entries) = fs::read_dir(&custom_dir) {
        +        for entry in entries.flatten() {
        +            let path = entry.path();
        +            let ext = path.extension().and_then(|e| e.to_str());
        +            if !matches!(ext, Some("json") | Some("jsonc")) {
        +                continue;
        +            }
        +            let content = fs::read_to_string(&path).unwrap_or_default();
        +            let stem = path
        +                .file_stem()
        +                .and_then(|s| s.to_str())
        +                .unwrap_or("")
        +                .to_lowercase();
        +            let matches = stem == needle
        +                || xtop_layout::parse_layout(&content)
        +                    .map(|d| d.name.to_lowercase() == needle)
        +                    .unwrap_or(false);
        +            if matches {
        +                found = Some((entry.file_name().to_string_lossy().to_string(), content));
        +                break;
        +            }
        +        }
        +    }
        +    let _ = fs::remove_dir_all(&tmp);
        +
        +    let Some((file_name, content)) = found else {
        +        anyhow::bail!("no community layout named '{name}' in layouts/custom/ of {LAYOUTS_REPO}");
        +    };
        +
        +    let target_dir = crate::config::config_dir().join("layouts");
        +    fs::create_dir_all(&target_dir)?;
        +    let target = target_dir.join(&file_name);
        +    if target.exists() {
        +        anyhow::bail!(
        +            "{} already exists (edit it in place instead)",
        +            target.display()
        +        );
        +    }
        +    fs::write(&target, content)?;
        +    println!("Installed '{name}' -> {}", target.display());
        +    println!("Cycle layouts with 'l' (or restart xtop) to use it.");
        +    Ok(())
        +}
        diff --git a/src/commands/mod.rs b/src/commands/mod.rs
        index cda9e37..dc9987b 100644
        --- a/src/commands/mod.rs
        +++ b/src/commands/mod.rs
        @@ -2,6 +2,7 @@
         //!
         //! Shared assembly and asset helpers live under [`share`].
         
        +pub mod layout;
         pub mod mcp;
         pub mod plugins;
         pub mod run;
        diff --git a/src/commands/plugins/install.rs b/src/commands/plugins/install.rs
        new file mode 100644
        index 0000000..aa9d9fe
        --- /dev/null
        +++ b/src/commands/plugins/install.rs
        @@ -0,0 +1,150 @@
        +//! `xtop plugin install`: register a plugin from a repo as an optional git
        +//! dependency + feature flag in the kernel `Cargo.toml`.
        +
        +use std::fs;
        +use std::path::{Path, PathBuf};
        +
        +use super::{repo_root, LOCAL_PLUGINS_MARKER, PLUGINS_REPO};
        +
        +pub(crate) fn cmd_plugin_install(name_or_url: &str) -> anyhow::Result<()> {
        +    let tmp = std::env::temp_dir().join("xtop-plugin-install");
        +
        +    // Resolve the plugin package and its source repo.
        +    let (pkg_name, source_repo) = if is_git_url(name_or_url) {
        +        let repo = name_or_url;
        +        clone_into_tmp(repo, &tmp)?;
        +        let stem = Path::new(repo)
        +            .file_stem()
        +            .and_then(|s| s.to_str())
        +            .unwrap_or("plugin")
        +            .trim_start_matches("xtop-plugin-");
        +        let (_, pkg) = find_plugin_crate(&tmp, stem)?;
        +        (pkg, repo)
        +    } else {
        +        let name = name_or_url.trim().trim_start_matches("xtop-plugin-");
        +        clone_into_tmp(PLUGINS_REPO, &tmp)?;
        +        if !tmp
        +            .join("plugins")
        +            .join(format!("xtop-plugin-{name}"))
        +            .is_dir()
        +        {
        +            let _ = fs::remove_dir_all(&tmp);
        +            anyhow::bail!(
        +                "Plugin '{name}' not found in {PLUGINS_REPO}. \
        +                 Available plugins live under plugins/xtop-plugin-."
        +            );
        +        }
        +        (format!("xtop-plugin-{name}"), PLUGINS_REPO)
        +    };
        +    let feat_name = pkg_name.replace('-', "_");
        +
        +    // Register a git dependency + feature flag in the kernel Cargo.toml.
        +    let manifest_path = repo_root().join("Cargo.toml");
        +    let content = fs::read_to_string(&manifest_path)?;
        +    let dep_line = format!("{pkg_name} = {{ git = \"{source_repo}\", optional = true }}");
        +    let feature_line = format!("{feat_name} = [\"dep:{pkg_name}\"]");
        +    let mut new_content = content.clone();
        +
        +    if content
        +        .lines()
        +        .any(|l| l.trim().starts_with(&format!("{pkg_name} =")))
        +    {
        +        println!("{pkg_name} is already a dependency of the kernel.");
        +    } else {
        +        if !content.contains(LOCAL_PLUGINS_MARKER) {
        +            new_content = new_content.replace(
        +                "[dependencies]",
        +                &format!("[dependencies]\n{LOCAL_PLUGINS_MARKER}"),
        +            );
        +        }
        +        new_content = new_content.replace(
        +            LOCAL_PLUGINS_MARKER,
        +            &format!("{LOCAL_PLUGINS_MARKER}\n{dep_line}"),
        +        );
        +    }
        +    if !content.contains(&feature_line) {
        +        new_content = new_content.replacen("[features]", &format!("[features]\n{feature_line}"), 1);
        +    }
        +    fs::write(&manifest_path, new_content)?;
        +
        +    // Cleanup and verify the manifest resolves.
        +    let _ = fs::remove_dir_all(&tmp);
        +    println!("Verifying with `cargo check` ...");
        +    let status = std::process::Command::new("cargo")
        +        .args(["check"])
        +        .current_dir(repo_root())
        +        .status()
        +        .map_err(|e| anyhow::anyhow!("cargo check failed: {e}"))?;
        +    if !status.success() {
        +        anyhow::bail!("`cargo check` failed; check the plugin's compatibility.");
        +    }
        +
        +    println!();
        +    println!("Plugin '{pkg_name}' installed successfully.");
        +    println!("  Source: {source_repo}");
        +    println!("  Feature flag: {feat_name} (NOT enabled by default)");
        +    println!("  Enable it: add '{feat_name}' to the [features] default list");
        +    println!("  in {manifest_path:?} and rebuild.");
        +    Ok(())
        +}
        +
        +fn is_git_url(s: &str) -> bool {
        +    s.contains("://") || s.contains("github.com") || s.contains("git@")
        +}
        +
        +fn clone_into_tmp(repo_url: &str, tmp: &Path) -> anyhow::Result<()> {
        +    let _ = fs::remove_dir_all(tmp);
        +    println!("Cloning {repo_url} ...");
        +    let status = std::process::Command::new("git")
        +        .args([
        +            "clone",
        +            "--depth",
        +            "1",
        +            "--filter=blob:none",
        +            "--sparse",
        +            repo_url,
        +            tmp.to_str().unwrap(),
        +        ])
        +        .status()
        +        .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?;
        +    if !status.success() {
        +        anyhow::bail!("git clone failed");
        +    }
        +    Ok(())
        +}
        +
        +/// Resolve where the plugin crate lives inside a cloned repo and its package
        +/// name. Accepts: crate at repo root, or `plugins/xtop-plugin-`,
        +/// `plugins/` or `crates/` subdirectories.
        +fn find_plugin_crate(tmp: &Path, name: &str) -> anyhow::Result<(PathBuf, String)> {
        +    let candidates = [
        +        tmp.join("plugins").join(format!("xtop-plugin-{name}")),
        +        tmp.join("plugins").join(name),
        +        tmp.join("crates").join(format!("xtop-plugin-{name}")),
        +        tmp.join("crates").join(name),
        +        tmp.to_path_buf(), // root crate last
        +    ];
        +    for cand in &candidates {
        +        let manifest = cand.join("Cargo.toml");
        +        if !manifest.is_file() {
        +            continue;
        +        }
        +        let content = fs::read_to_string(&manifest).unwrap_or_default();
        +        if let Ok(toml) = content.parse::() {
        +            if let Some(pkg) = toml.get("package").and_then(|p| p.get("name")) {
        +                if let Some(pkg_name) = pkg.as_str() {
        +                    if pkg_name.starts_with("xtop-plugin-") {
        +                        println!("Found plugin at {}", cand.display());
        +                        return Ok((cand.to_path_buf(), pkg_name.to_string()));
        +                    }
        +                }
        +            }
        +        }
        +    }
        +    let _ = fs::remove_dir_all(tmp);
        +    anyhow::bail!(
        +        "No `xtop-plugin-*` package found for '{name}' in {}. \
        +         URL installs need the crate at the repo root or in plugins//crates/.",
        +        tmp.display()
        +    )
        +}
        diff --git a/src/commands/plugins/list.rs b/src/commands/plugins/list.rs
        new file mode 100644
        index 0000000..a755e0b
        --- /dev/null
        +++ b/src/commands/plugins/list.rs
        @@ -0,0 +1,46 @@
        +//! `xtop plugin list`: show which plugins are wired into the kernel.
        +
        +use std::fs;
        +
        +use super::repo_root;
        +
        +pub(crate) fn cmd_plugin_list() {
        +    let manifest = fs::read_to_string(repo_root().join("Cargo.toml"))
        +        .expect("kernel Cargo.toml should exist next to the binary");
        +
        +    // A plugin counts as "installed" when it has a matching `dep:`
        +    // feature entry (like the built-in xtop-plugin-samurai). Contract crates
        +    // (xtop-plugin-api) are excluded.
        +    let in_features = manifest
        +        .lines()
        +        .skip_while(|l| !l.trim().starts_with("[features]"))
        +        .any(|l| l.contains("dep:"));
        +    if !in_features {
        +        println!("No plugins installed.");
        +        return;
        +    }
        +
        +    let mut plugins: Vec = Vec::new();
        +    for line in manifest.lines() {
        +        let l = line.trim();
        +        if !l.starts_with("xtop-plugin-") || !l.contains('=') || l.starts_with('#') {
        +            continue;
        +        }
        +        let name = l.split('=').next().unwrap_or("").trim().to_string();
        +        if !name.is_empty() {
        +            let dep_ref = format!("dep:{name}");
        +            if manifest.lines().any(|f| f.contains(&dep_ref)) {
        +                plugins.push(name);
        +            }
        +        }
        +    }
        +
        +    if plugins.is_empty() {
        +        println!("No plugins installed.");
        +        return;
        +    }
        +    println!("Plugins wired into the kernel (Cargo.toml):");
        +    for p in plugins {
        +        println!("  {p}");
        +    }
        +}
        diff --git a/src/commands/plugins/mod.rs b/src/commands/plugins/mod.rs
        new file mode 100644
        index 0000000..2eb2ee7
        --- /dev/null
        +++ b/src/commands/plugins/mod.rs
        @@ -0,0 +1,52 @@
        +//! Plugin management subcommands (list, install, scaffold).
        +//!
        +//! Since the kernel became a monocrate, plugins live in their own repos
        +//! (`xtop-cli/plugins`) and are integrated through Cargo git dependencies +
        +//! feature flags, exactly like the built-in `xtop-plugin-samurai`. These
        +//! commands edit the kernel's own `Cargo.toml` accordingly.
        +
        +use std::path::Path;
        +
        +mod install;
        +mod list;
        +mod scaffold;
        +
        +pub const PLUGINS_REPO: &str = "https://github.com/xtop-cli/plugins";
        +pub(crate) const LOCAL_PLUGINS_MARKER: &str = "# Local plugin installs (xtop plugin install)";
        +
        +/// Kernel repo root: `Cargo.toml` lives right at `CARGO_MANIFEST_DIR`.
        +pub(crate) fn repo_root() -> &'static Path {
        +    Path::new(env!("CARGO_MANIFEST_DIR"))
        +}
        +
        +/// Handle `xtop plugin ` from the parsed argument vector.
        +pub fn plugin_command(args: &[String]) -> anyhow::Result<()> {
        +    if args.len() < 3 {
        +        eprintln!("Usage: xtop plugin ");
        +        return Ok(());
        +    }
        +    match args[2].as_str() {
        +        "list" => {
        +            list::cmd_plugin_list();
        +            Ok(())
        +        }
        +        "install" => {
        +            if args.len() < 4 {
        +                eprintln!("Usage: xtop plugin install ");
        +                return Ok(());
        +            }
        +            install::cmd_plugin_install(&args[3])
        +        }
        +        "scaffold" => {
        +            if args.len() < 4 {
        +                eprintln!("Usage: xtop plugin scaffold ");
        +                return Ok(());
        +            }
        +            scaffold::cmd_plugin_scaffold(&args[3])
        +        }
        +        _ => {
        +            eprintln!("Unknown plugin subcommand: {}", args[2]);
        +            Ok(())
        +        }
        +    }
        +}
        diff --git a/src/commands/plugins/scaffold.rs b/src/commands/plugins/scaffold.rs
        new file mode 100644
        index 0000000..4df9c81
        --- /dev/null
        +++ b/src/commands/plugins/scaffold.rs
        @@ -0,0 +1,82 @@
        +//! `xtop plugin scaffold`: generate a fresh plugin crate template under
        +//! `plugins-dev/` (ignored by git), ready to push as its own repo.
        +
        +use std::fs;
        +
        +use super::repo_root;
        +
        +pub(crate) fn cmd_plugin_scaffold(name: &str) -> anyhow::Result<()> {
        +    let plugin_dir = repo_root()
        +        .join("plugins-dev")
        +        .join(format!("xtop-plugin-{name}"));
        +    if plugin_dir.exists() {
        +        anyhow::bail!("Plugin crate already exists at {}", plugin_dir.display());
        +    }
        +    let src_dir = plugin_dir.join("src");
        +    fs::create_dir_all(&src_dir)?;
        +
        +    let cap: String = {
        +        let mut chars = name.chars();
        +        match chars.next() {
        +            None => String::new(),
        +            Some(c) => c.to_uppercase().to_string() + chars.as_str(),
        +        }
        +    };
        +
        +    let cargo_toml = format!(
        +        r#"[package]
        +name = "xtop-plugin-{name}"
        +version = "0.1.0"
        +edition = "2021"
        +license = "MIT"
        +description = "xtop plugin: {name}"
        +
        +[dependencies]
        +xtop-plugin-api = {{ git = "https://github.com/xtop-cli/api" }}
        +"#
        +    );
        +    fs::write(plugin_dir.join("Cargo.toml"), &cargo_toml)?;
        +
        +    let lib_rs = format!(
        +        r#"//! {cap} plugin for xtop.
        +use xtop_plugin_api::{{Plugin, PluginCapability, PluginContext, PluginError, PluginManifest}};
        +
        +pub struct {cap}Plugin;
        +
        +impl {cap}Plugin {{
        +    pub fn new() -> Self {{
        +        Self
        +    }}
        +}}
        +
        +impl Plugin for {cap}Plugin {{
        +    fn manifest(&self) -> PluginManifest {{
        +        PluginManifest {{
        +            id: "{name}".to_string(),
        +            name: "{cap}".to_string(),
        +            version: "0.1.0".to_string(),
        +            description: "xtop plugin: {name}".to_string(),
        +            capabilities: vec![PluginCapability::ReadSystemInfo],
        +        }}
        +    }}
        +
        +    fn on_tick(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> {{
        +        Ok(())
        +    }}
        +}}
        +"#,
        +        cap = cap,
        +        name = name
        +    );
        +    fs::write(src_dir.join("lib.rs"), &lib_rs)?;
        +
        +    println!("Plugin scaffold created at {}", plugin_dir.display());
        +    println!("To integrate it into the kernel:");
        +    println!(
        +        "  1. `xtop plugin install {}` (works for repos with the crate",
        +        name
        +    );
        +    println!("     at the root or under plugins//crates/).");
        +    println!("  2. Implement the Plugin trait methods.");
        +    Ok(())
        +}
        diff --git a/src/commands/plugins.rs b/src/commands/plugins_dir_tmp/mod.rs
        similarity index 100%
        rename from src/commands/plugins.rs
        rename to src/commands/plugins_dir_tmp/mod.rs
        diff --git a/src/commands/run.rs b/src/commands/run.rs
        index b03e534..96e93ee 100644
        --- a/src/commands/run.rs
        +++ b/src/commands/run.rs
        @@ -2,12 +2,15 @@
         
         use std::time::{Duration, Instant};
         
        +use crate::config::config_dir;
         use crate::config::keybinding::Action;
         use crate::state::{InputMode, PalettePage};
         use crate::ui;
        -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
        +use crossterm::event::{
        +    self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind,
        +};
         
        -use super::share::{config_dir, initialize_state, save_config};
        +use super::share::{initialize_state, save_config};
         
         fn key_event_to_str(key: &KeyEvent) -> String {
             let mut s = String::new();
        @@ -44,7 +47,6 @@ fn key_event_to_str(key: &KeyEvent) -> String {
             s
         }
         
        -// Embedded default asset files (shipped with the binary)
         /// Run the interactive TUI loop.
         pub fn run() -> anyhow::Result<()> {
             ui::install_panic_hook();
        @@ -53,10 +55,13 @@ pub fn run() -> anyhow::Result<()> {
             let cfg_dir = config_dir();
             let mut state = initialize_state(&cfg_dir)?;
         
        -    let tick_rate = Duration::from_millis(state.update_interval_ms);
        +    // Sample once before the first frame so the UI never shows an empty
        +    // snapshot and every widget shares the same per-tick data.
        +    state.on_tick();
             let mut last_tick = Instant::now();
         
             loop {
        +        let tick_rate = Duration::from_millis(state.update_interval_ms.max(100));
                 terminal.draw(|f| ui::render(f, &state))?;
         
                 let timeout = tick_rate
        @@ -64,100 +69,39 @@ pub fn run() -> anyhow::Result<()> {
                     .unwrap_or_default();
         
                 if event::poll(timeout)? {
        -            if let Event::Key(key) = event::read()? {
        -                let key_str = key_event_to_str(&key);
        -
        -                // Give plugins first chance to consume the key
        -                let key_str_clone = key_str.clone();
        -                let key_consumed =
        -                    state.with_plugin_manager_mut(|mgr, this| mgr.handle_key(this, &key_str_clone));
        -                if key_consumed {
        -                    continue;
        +            match event::read()? {
        +                Event::Key(key) if key.kind == KeyEventKind::Press => {
        +                    handle_key(&mut state, key);
                         }
        -
        -                // DEBUG: print key for diagnostics
        -                if cfg!(debug_assertions) && !key_str.is_empty() {
        -                    eprintln!("[key] '{key_str}'");
        -                }
        -
        -                match state.input_mode {
        -                    InputMode::Normal => {
        -                        // Direct Ctrl+P check (works regardless of keybinding config, important on macOS)
        -                        if key_str == "ctrl+p" {
        -                            state.open_palette();
        -                            state.input_mode = InputMode::CommandPalette;
        -                        } else if let Some(action) = state.keybindings.resolve(&key_str) {
        -                            match action {
        -                                Action::Quit => {
        -                                    save_config(&state);
        -                                    state.quit();
        -                                }
        -                                Action::Cancel if state.show_help => {
        -                                    state.toggle_help();
        -                                }
        -                                Action::OpenCommandPalette => {
        -                                    state.open_palette();
        -                                    state.input_mode = InputMode::CommandPalette;
        -                                }
        -                                Action::KillProcess | Action::ProcessUp | Action::ProcessDown => {
        -                                    state.execute_action(&action);
        -                                }
        -                                _ => {
        -                                    state.execute_action(&action);
        -                                }
        +                Event::Paste(text) => {
        +                    // Bracketed paste: insert as text in the current input.
        +                    match state.input_mode {
        +                        InputMode::Searching => {
        +                            for c in text.chars() {
        +                                state.search_push_char(c);
                                     }
                                 }
        -                    }
        -                    InputMode::Searching => match key.code {
        -                        KeyCode::Esc => {
        -                            state.search_query.clear();
        -                            state.end_search();
        -                        }
        -                        KeyCode::Enter => {
        -                            state.end_search();
        -                        }
        -                        KeyCode::Backspace => {
        -                            state.search_pop_char();
        -                        }
        -                        KeyCode::Char(c) => {
        -                            state.search_push_char(c);
        +                        InputMode::CommandPalette => {
        +                            state.palette.query.push_str(&text);
        +                            state.palette_filter();
                                 }
        +                        InputMode::Normal => {}
        +                    }
        +                }
        +                Event::Mouse(mouse)
        +                    if state.input_mode == InputMode::Normal && !state.show_help =>
        +                {
        +                    // Mouse capture is enabled mainly for wheel scrolling of
        +                    // the process list in Normal mode.
        +                    match mouse.kind {
        +                        MouseEventKind::ScrollUp => state.process_select_prev(),
        +                        MouseEventKind::ScrollDown => state.process_select_next(),
                                 _ => {}
        -                    },
        -                    InputMode::CommandPalette => {
        -                        let is_main = state.palette.page == PalettePage::Main;
        -                        match key.code {
        -                            KeyCode::Esc => {
        -                                state.close_palette();
        -                            }
        -                            KeyCode::Enter => {
        -                                if let Some(action) = state.palette_selected_action() {
        -                                    state.execute_action(&action);
        -                                    save_config(&state);
        -                                }
        -                            }
        -                            KeyCode::Down => {
        -                                state.palette_select_next();
        -                            }
        -                            KeyCode::Up => {
        -                                state.palette_select_prev();
        -                            }
        -                            KeyCode::Char(c) => {
        -                                state.palette.query.push(c);
        -                                state.palette_filter();
        -                            }
        -                            KeyCode::Backspace => {
        -                                if state.palette.query.is_empty() && !is_main {
        -                                    state.palette_navigate_to(PalettePage::Main);
        -                                } else {
        -                                    state.palette.query.pop();
        -                                    state.palette_filter();
        -                                }
        -                            }
        -                            _ => {}
        -                        }
                             }
                         }
        +                // Resize triggers a redraw on the next loop iteration
        +                // (ratatui re-measures inside `draw`).
        +                _ => {}
                     }
                 }
         
        @@ -172,10 +116,120 @@ pub fn run() -> anyhow::Result<()> {
             }
         
             // Disable plugins on shutdown
        -    state.with_plugin_manager_mut(|mgr, this| {
        +    let _ = state.with_plugin_manager_mut(|mgr, this| {
                 mgr.disable_all(this);
             });
         
             ui::restore()?;
             Ok(())
         }
        +
        +fn handle_key(state: &mut crate::state::AppState, key: KeyEvent) {
        +    let key_str = key_event_to_str(&key);
        +    let ctrl_c = key_str == "ctrl+c";
        +
        +    match state.input_mode {
        +        InputMode::Normal => {
        +            if ctrl_c {
        +                save_config(state);
        +                state.quit();
        +                return;
        +            }
        +            // Direct Ctrl+P check (works regardless of keybinding config, important on macOS)
        +            if key_str == "ctrl+p" {
        +                state.open_palette();
        +                state.input_mode = InputMode::CommandPalette;
        +                return;
        +            }
        +            // Give plugins a chance to consume keys only in Normal mode, so a
        +            // plugin key handler can never eat typing inside search/palette.
        +            let key_consumed = state
        +                .with_plugin_manager_mut(|mgr, this| mgr.handle_key(this, &key_str))
        +                .unwrap_or(false);
        +            if key_consumed {
        +                return;
        +            }
        +
        +            if cfg!(debug_assertions) && !key_str.is_empty() {
        +                eprintln!("[key] '{key_str}'");
        +            }
        +
        +            if let Some(action) = state.keybindings.resolve(&key_str) {
        +                match action {
        +                    Action::Quit => {
        +                        save_config(state);
        +                        state.quit();
        +                    }
        +                    Action::Cancel if state.show_help => {
        +                        state.toggle_help();
        +                    }
        +                    Action::OpenCommandPalette => {
        +                        state.open_palette();
        +                        state.input_mode = InputMode::CommandPalette;
        +                    }
        +                    _ => {
        +                        state.execute_action(&action);
        +                        if action.persists() {
        +                            save_config(state);
        +                        }
        +                    }
        +                }
        +            }
        +        }
        +        InputMode::Searching => {
        +            if ctrl_c || key.code == KeyCode::Esc {
        +                state.search_query.clear();
        +                state.end_search();
        +                return;
        +            }
        +            match key.code {
        +                KeyCode::Enter => {
        +                    state.end_search();
        +                }
        +                KeyCode::Backspace => {
        +                    state.search_pop_char();
        +                }
        +                KeyCode::Char(c) => {
        +                    state.search_push_char(c);
        +                }
        +                _ => {}
        +            }
        +        }
        +        InputMode::CommandPalette => {
        +            let is_main = state.palette.page == PalettePage::Main;
        +            if ctrl_c || key.code == KeyCode::Esc {
        +                state.close_palette();
        +                return;
        +            }
        +            match key.code {
        +                KeyCode::Enter => {
        +                    if let Some(action) = state.palette_selected_action() {
        +                        state.execute_action(&action);
        +                        if action.persists() {
        +                            save_config(state);
        +                        }
        +                    }
        +                }
        +                KeyCode::Down => {
        +                    state.palette_select_next();
        +                }
        +                KeyCode::Up => {
        +                    state.palette_select_prev();
        +                }
        +                KeyCode::Char(c) => {
        +                    state.palette.query.push(c);
        +                    state.palette_filter();
        +                }
        +                KeyCode::Backspace => {
        +                    if state.palette.query.is_empty() && !is_main {
        +                        state.palette_navigate_to(PalettePage::Main);
        +                    } else {
        +                        state.palette.query.pop();
        +                        state.palette_filter();
        +                    }
        +                }
        +                _ => {}
        +            }
        +        }
        +    }
        +}
        diff --git a/src/commands/share/assets.rs b/src/commands/share/assets.rs
        index 910e0d2..023dd56 100644
        --- a/src/commands/share/assets.rs
        +++ b/src/commands/share/assets.rs
        @@ -3,7 +3,6 @@
         use std::fs;
         
         use crate::config;
        -use crate::config::Config;
         use crate::state::AppState;
         
         const DEFAULT_THEMES: &[(&str, &str)] = &[
        @@ -38,84 +37,55 @@ const DEFAULT_THEMES: &[(&str, &str)] = &[
             ),
         ];
         
        -const DEFAULT_LAYOUTS: &[(&str, &str)] = &[
        -    (
        -        "dashboard",
        -        include_str!("../../../assets/layouts/dashboard.jsonc"),
        -    ),
        -    (
        -        "vertical",
        -        include_str!("../../../assets/layouts/vertical.jsonc"),
        -    ),
        -    (
        -        "horizontal",
        -        include_str!("../../../assets/layouts/horizontal.jsonc"),
        -    ),
        -    (
        -        "cpu_focus",
        -        include_str!("../../../assets/layouts/cpu_focus.jsonc"),
        -    ),
        -    (
        -        "memory_focus",
        -        include_str!("../../../assets/layouts/memory_focus.jsonc"),
        -    ),
        -    (
        -        "network_focus",
        -        include_str!("../../../assets/layouts/network_focus.jsonc"),
        -    ),
        -    (
        -        "process_focus",
        -        include_str!("../../../assets/layouts/process_focus.jsonc"),
        -    ),
        -];
        -
        -pub fn config_dir() -> std::path::PathBuf {
        -    crate::config::config_dir()
        -}
        +/// Version of the seeded asset templates. Bumped when the shipped defaults
        +/// change so existing installs receive the new templates (without ever
        +/// clobbering files the user has edited).
        +const ASSETS_VERSION: &str = "1";
         
        +/// Write shipped defaults (themes and layouts) into the user config dir.
        +///
        +/// Files are only created when missing: user edits are never clobbered. The
        +/// defaults are the examples users copy to customize; the actual fallbacks
        +/// always live compiled into the binary/crates.
         pub fn ensure_default_assets() {
        -    let theme_assets: &[(&str, &str)] = DEFAULT_THEMES;
        -    let layout_assets: &[(&str, &str)] = DEFAULT_LAYOUTS;
        -
             let dir = crate::theme::themes_dir();
        -    if !dir.join(".xtop_initialized").exists() {
        -        fs::create_dir_all(&dir).ok();
        -        for (name, content) in theme_assets {
        -            let path = dir.join(format!("{name}.jsonc"));
        -            if !path.exists() {
        -                fs::write(&path, content).ok();
        -            }
        -        }
        -        fs::write(dir.join(".xtop_initialized"), "").ok();
        -    }
        +    seed_assets(&dir, DEFAULT_THEMES);
        +    let dir = crate::config::config_dir().join("layouts");
        +    seed_assets(&dir, xtop_layout::default_layout_sources);
        +}
         
        -    let dir = crate::layout::layouts_dir();
        -    if !dir.join(".xtop_initialized").exists() {
        -        fs::create_dir_all(&dir).ok();
        -        for (name, content) in layout_assets {
        -            let path = dir.join(format!("{name}.jsonc"));
        -            if !path.exists() {
        -                fs::write(&path, content).ok();
        -            }
        +fn seed_assets(dir: &std::path::Path, sources: &[(&str, &str)]) {
        +    let marker = dir.join(".xtop_initialized");
        +    let up_to_date = fs::read_to_string(&marker)
        +        .map(|v| v.trim() == ASSETS_VERSION)
        +        .unwrap_or(false);
        +    if up_to_date {
        +        return;
        +    }
        +    fs::create_dir_all(dir).ok();
        +    for (name, content) in sources {
        +        let path = dir.join(format!("{name}.jsonc"));
        +        if !path.exists() {
        +            fs::write(&path, content).ok();
                 }
        -        fs::write(dir.join(".xtop_initialized"), "").ok();
             }
        +    fs::write(marker, ASSETS_VERSION).ok();
         }
         
        +/// Persist the runtime state into the user config file.
        +///
        +/// Existing user values (`history_points`, update interval when untouched,
        +/// keybindings, alerts) are preserved: only the fields the runtime owns are
        +/// overwritten.
         pub fn save_config(state: &AppState) {
        -    let layout_name = if state.layout_index < state.layout_defs.len() {
        -        state.layout_defs[state.layout_index].name.clone()
        -    } else {
        -        String::new()
        -    };
        -    let cfg = Config {
        -        theme: state.current_theme.name.clone(),
        -        layout_mode: state.save_layout_mode(),
        -        layout_name,
        -        update_interval_ms: state.update_interval_ms,
        -        history_points: 100,
        -        alerts: state.alerts,
        -        keybindings: state.keybindings.clone(),
        -    };
        +    let mut cfg = config::load_config();
        +    cfg.theme = state.current_theme.name.clone();
        +    if let Some(def) = state.layout_defs.get(state.layout_index) {
        +        cfg.layout_name = def.name.clone();
        +    }
        +    cfg.layout_mode = state.layout_mode;
        +    cfg.update_interval_ms = state.update_interval_ms;
        +    cfg.alerts = state.alerts;
        +    cfg.keybindings = state.keybindings.clone();
             let _ = config::save_config(&cfg);
         }
        diff --git a/src/commands/share/bootstrap.rs b/src/commands/share/bootstrap.rs
        index 5fbe02b..4c2ac34 100644
        --- a/src/commands/share/bootstrap.rs
        +++ b/src/commands/share/bootstrap.rs
        @@ -4,12 +4,12 @@ use std::fs;
         use std::path::Path;
         
         use crate::config;
        -use crate::layout;
         use crate::plugins::PluginManager;
         use crate::providers::sysinfo::SysinfoProvider;
         use crate::providers::CompositeProvider;
         use crate::state::AppState;
         use crate::theme::load_all_themes;
        +use xtop_layout::{default_layouts, load_layouts_from_dir, merge_layouts};
         
         #[cfg(feature = "plugin-samurai")]
         use xtop_plugin_samurai::SamuraiPlugin;
        @@ -49,10 +49,13 @@ pub fn initialize_state(cfg_dir: &Path) -> anyhow::Result {
         
             let themes = load_all_themes();
             let cfg = config::load_config();
        -    let mut builtin_layouts = layout::builtin_layouts();
        -    let custom_layouts = layout::load_custom_layouts();
        -    builtin_layouts.extend(custom_layouts);
        -    let mut state = AppState::new(Box::new(composite), themes, cfg, builtin_layouts);
        +
        +    // Layouts: embedded defaults first; user files from the config dir then
        +    // override defaults by name (user wins) — see `xtop_layout::merge_layouts`.
        +    let layouts_dir = config::config_dir().join("layouts");
        +    let layout_defs = merge_layouts(default_layouts(), load_layouts_from_dir(&layouts_dir));
        +
        +    let mut state = AppState::new(Box::new(composite), themes, cfg, layout_defs);
         
             // Build and register plugins, then wire their providers into the state.
             let plugin_mgr = build_plugin_manager(&mut state, cfg_dir);
        diff --git a/src/config/io.rs b/src/config/io.rs
        index f46ea94..1793508 100644
        --- a/src/config/io.rs
        +++ b/src/config/io.rs
        @@ -15,7 +15,10 @@ pub fn config_path() -> PathBuf {
         pub fn load_config() -> Config {
             let path = config_path();
             if let Ok(data) = fs::read_to_string(&path) {
        -        if let Ok(cfg) = serde_json::from_str::(&data) {
        +        if let Ok(mut cfg) = serde_json::from_str::(&data) {
        +            // Guard against a busy-looping event loop (interval 0 = spin at
        +            // 100% CPU sampling the system every frame).
        +            cfg.update_interval_ms = cfg.update_interval_ms.clamp(100, 3_600_000);
                     return cfg;
                 }
             }
        diff --git a/src/config/keybinding.rs b/src/config/keybinding.rs
        index c4fcce3..cd6d921 100644
        --- a/src/config/keybinding.rs
        +++ b/src/config/keybinding.rs
        @@ -1,3 +1,4 @@
        +//! Keybinding model: config-driven key -> Action resolution.
         use serde::{Deserialize, Serialize};
         
         #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
        @@ -119,6 +120,23 @@ pub enum Action {
             SortByCpu,
         }
         
        +impl Action {
        +    /// Whether executing this action changes persisted config (theme/layout).
        +    /// Used to avoid rewriting `config.json` on every palette Enter.
        +    pub fn persists(&self) -> bool {
        +        matches!(
        +            self,
        +            Action::Quit
        +                | Action::NextTheme
        +                | Action::PreviousTheme
        +                | Action::RandomTheme
        +                | Action::NextLayout
        +                | Action::SelectTheme(_)
        +                | Action::SelectLayout(_)
        +        )
        +    }
        +}
        +
         impl Keybindings {
             pub fn resolve(&self, key_str: &str) -> Option {
                 if self.quit.contains(&key_str.to_string()) {
        diff --git a/src/config/schema.rs b/src/config/schema.rs
        index 460f62e..fe69233 100644
        --- a/src/config/schema.rs
        +++ b/src/config/schema.rs
        @@ -4,8 +4,11 @@
         //! here; the schema types stay independent of the runtime state.
         
         use crate::config::keybinding::Keybindings;
        -use crate::layout::LayoutMode;
         use serde::{Deserialize, Serialize};
        +use std::collections::HashMap;
        +use xtop_layout::LayoutMode;
        +// Glyph style enums are shared ecosystem-wide (kernel + widget packs).
        +pub use xtop_widget_api::{ChartCharset, WidgetBorders};
         
         #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
         pub struct AlertThresholds {
        @@ -24,6 +27,58 @@ impl Default for AlertThresholds {
             }
         }
         
        +// ---------------------------------------------------------------------------
        +// Widget glyph style
        +// ---------------------------------------------------------------------------
        +
        +/// Per-widget style overrides (key = widget name as used in layouts).
        +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
        +#[serde(default)]
        +pub struct WidgetStyle {
        +    pub charset: Option,
        +    pub borders: Option,
        +    /// Widget pack to render this name with (e.g. "default", "blocks").
        +    pub pack: Option,
        +}
        +
        +/// Global glyph style for widgets. Drives chart markers and block borders so
        +/// users can pick line/block/ascii rendering without touching code.
        +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
        +#[serde(default)]
        +pub struct UiStyle {
        +    pub charset: ChartCharset,
        +    pub borders: WidgetBorders,
        +    pub widgets: HashMap,
        +    /// Widget pack used for every name without a per-widget override.
        +    pub pack: Option,
        +}
        +
        +impl UiStyle {
        +    /// Resolved charset for a widget (per-widget override beats global).
        +    pub fn charset_for(&self, widget: &str) -> ChartCharset {
        +        self.widgets
        +            .get(widget)
        +            .and_then(|w| w.charset)
        +            .unwrap_or(self.charset)
        +    }
        +
        +    /// Resolved border style for a widget (per-widget override beats global).
        +    pub fn borders_for(&self, widget: &str) -> WidgetBorders {
        +        self.widgets
        +            .get(widget)
        +            .and_then(|w| w.borders)
        +            .unwrap_or(self.borders)
        +    }
        +
        +    /// Resolved widget pack for a name (per-widget override beats global).
        +    pub fn pack_for(&self, widget: &str) -> Option<&str> {
        +        self.widgets
        +            .get(widget)
        +            .and_then(|w| w.pack.as_deref())
        +            .or(self.pack.as_deref())
        +    }
        +}
        +
         fn default_layout_mode() -> LayoutMode {
             LayoutMode::Dashboard
         }
        @@ -42,6 +97,10 @@ pub struct Config {
             pub alerts: AlertThresholds,
             #[serde(default)]
             pub keybindings: Keybindings,
        +    /// Widget glyph style (chart charset + borders). Optional; defaults to
        +    /// the classic look.
        +    #[serde(default)]
        +    pub style: UiStyle,
         }
         
         impl Default for Config {
        @@ -54,6 +113,7 @@ impl Default for Config {
                     history_points: 100,
                     alerts: AlertThresholds::default(),
                     keybindings: Keybindings::default(),
        +            style: UiStyle::default(),
                 }
             }
         }
        diff --git a/src/layout/loader.rs b/src/layout/loader.rs
        deleted file mode 100644
        index db3de22..0000000
        --- a/src/layout/loader.rs
        +++ /dev/null
        @@ -1,273 +0,0 @@
        -use crate::layout::{Direction, LayoutArea, LayoutConstraint, LayoutDef, LayoutNode};
        -use std::fs;
        -use std::path::Path;
        -
        -fn strip_jsonc_comments(input: &str) -> String {
        -    let mut out = String::with_capacity(input.len());
        -    let chars: Vec = input.chars().collect();
        -    let mut i = 0;
        -    while i < chars.len() {
        -        if chars[i] == '/' && i + 1 < chars.len() {
        -            if chars[i + 1] == '/' {
        -                i += 2;
        -                while i < chars.len() && chars[i] != '\n' {
        -                    i += 1;
        -                }
        -                continue;
        -            }
        -            if chars[i + 1] == '*' {
        -                i += 2;
        -                while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') {
        -                    i += 1;
        -                }
        -                i += 2;
        -                continue;
        -            }
        -        }
        -        out.push(chars[i]);
        -        i += 1;
        -    }
        -    out
        -}
        -
        -fn load_layout_from_file(path: &Path) -> Option {
        -    let data = fs::read_to_string(path).ok()?;
        -    let cleaned = strip_jsonc_comments(&data);
        -    serde_json::from_str::(&cleaned).ok()
        -}
        -
        -pub fn layouts_dir() -> std::path::PathBuf {
        -    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
        -        std::path::PathBuf::from(xdg).join("xtop").join("layouts")
        -    } else if let Ok(home) = std::env::var("HOME") {
        -        std::path::PathBuf::from(home)
        -            .join(".config")
        -            .join("xtop")
        -            .join("layouts")
        -    } else {
        -        std::path::PathBuf::from(".")
        -            .join(".config")
        -            .join("xtop")
        -            .join("layouts")
        -    }
        -}
        -
        -pub fn load_custom_layouts() -> Vec {
        -    let dir = layouts_dir();
        -    if !dir.exists() {
        -        return vec![];
        -    }
        -    let mut layouts = vec![];
        -    if let Ok(entries) = fs::read_dir(&dir) {
        -        for entry in entries.flatten() {
        -            let path = entry.path();
        -            let ext = path.extension().and_then(|e| e.to_str());
        -            if ext == Some("json") || ext == Some("jsonc") {
        -                if let Some(layout) = load_layout_from_file(&path) {
        -                    layouts.push(layout);
        -                }
        -            }
        -        }
        -    }
        -    layouts
        -}
        -
        -pub fn builtin_layouts() -> Vec {
        -    vec![
        -        dashboard_layout(),
        -        vertical_layout(),
        -        horizontal_layout(),
        -        cpu_focus_layout(),
        -        memory_focus_layout(),
        -        network_focus_layout(),
        -        process_focus_layout(),
        -    ]
        -}
        -
        -fn area(size: u16, node: LayoutNode) -> LayoutArea {
        -    LayoutArea {
        -        constraint: LayoutConstraint::Length(size),
        -        node,
        -    }
        -}
        -
        -fn pct(pct: u16, node: LayoutNode) -> LayoutArea {
        -    LayoutArea {
        -        constraint: LayoutConstraint::Percentage(pct),
        -        node,
        -    }
        -}
        -
        -fn fill(node: LayoutNode) -> LayoutArea {
        -    LayoutArea {
        -        constraint: LayoutConstraint::Fill,
        -        node,
        -    }
        -}
        -
        -fn widget(name: &str) -> LayoutNode {
        -    LayoutNode::Widget {
        -        name: name.to_string(),
        -    }
        -}
        -
        -fn split_h(areas: Vec) -> LayoutNode {
        -    LayoutNode::Split {
        -        direction: Direction::Horizontal,
        -        areas,
        -    }
        -}
        -
        -fn split_v(areas: Vec) -> LayoutNode {
        -    LayoutNode::Split {
        -        direction: Direction::Vertical,
        -        areas,
        -    }
        -}
        -
        -fn dashboard_layout() -> LayoutDef {
        -    LayoutDef {
        -        name: "Dashboard".into(),
        -        root: split_v(vec![
        -            area(3, widget("header")),
        -            pct(
        -                45,
        -                split_h(vec![
        -                    pct(50, widget("cpu")),
        -                    pct(
        -                        50,
        -                        split_v(vec![
        -                            pct(33, widget("memory")),
        -                            pct(33, widget("storage")),
        -                            pct(34, widget("network")),
        -                        ]),
        -                    ),
        -                ]),
        -            ),
        -            pct(52, widget("processes")),
        -        ]),
        -    }
        -}
        -
        -fn vertical_layout() -> LayoutDef {
        -    LayoutDef {
        -        name: "Vertical".into(),
        -        root: split_v(vec![
        -            area(3, widget("header")),
        -            area(8, widget("cpu")),
        -            area(8, widget("memory")),
        -            area(6, widget("storage")),
        -            area(5, widget("network")),
        -            fill(widget("processes")),
        -        ]),
        -    }
        -}
        -
        -fn horizontal_layout() -> LayoutDef {
        -    LayoutDef {
        -        name: "Horizontal".into(),
        -        root: split_v(vec![
        -            area(3, widget("header")),
        -            fill(split_h(vec![
        -                pct(25, widget("cpu")),
        -                pct(25, widget("memory")),
        -                pct(25, widget("storage")),
        -                pct(25, widget("network")),
        -            ])),
        -        ]),
        -    }
        -}
        -
        -fn cpu_focus_layout() -> LayoutDef {
        -    LayoutDef {
        -        name: "CPU Focus".into(),
        -        root: split_v(vec![
        -            area(3, widget("header")),
        -            pct(60, widget("cpu")),
        -            fill(widget("processes")),
        -        ]),
        -    }
        -}
        -
        -fn memory_focus_layout() -> LayoutDef {
        -    LayoutDef {
        -        name: "Memory Focus".into(),
        -        root: split_v(vec![
        -            area(3, widget("header")),
        -            pct(60, widget("memory")),
        -            fill(widget("processes")),
        -        ]),
        -    }
        -}
        -
        -fn network_focus_layout() -> LayoutDef {
        -    LayoutDef {
        -        name: "Network Focus".into(),
        -        root: split_v(vec![
        -            area(3, widget("header")),
        -            pct(
        -                50,
        -                split_h(vec![pct(50, widget("network")), pct(50, widget("disk_io"))]),
        -            ),
        -            fill(widget("processes")),
        -        ]),
        -    }
        -}
        -
        -fn process_focus_layout() -> LayoutDef {
        -    LayoutDef {
        -        name: "Process Focus".into(),
        -        root: split_v(vec![
        -            area(3, widget("header")),
        -            area(
        -                8,
        -                split_h(vec![
        -                    pct(25, widget("cpu")),
        -                    pct(25, widget("memory")),
        -                    pct(25, widget("storage")),
        -                    pct(25, widget("network")),
        -                ]),
        -            ),
        -            fill(widget("processes")),
        -        ]),
        -    }
        -}
        -
        -#[cfg(test)]
        -mod tests {
        -    use super::*;
        -
        -    #[test]
        -    fn test_builtin_layouts_count() {
        -        let layouts = builtin_layouts();
        -        assert_eq!(layouts.len(), 7);
        -    }
        -
        -    #[test]
        -    fn test_builtin_names() {
        -        let layouts = builtin_layouts();
        -        let names: Vec<&str> = layouts.iter().map(|l| l.name.as_str()).collect();
        -        assert!(names.contains(&"Dashboard"));
        -        assert!(names.contains(&"Vertical"));
        -        assert!(names.contains(&"CPU Focus"));
        -        assert!(names.contains(&"Process Focus"));
        -    }
        -
        -    #[test]
        -    fn test_load_layout_from_jsonc() {
        -        let jsonc = r#"{
        -            // my custom layout
        -            "name": "test",
        -            "root": {
        -                "direction": "vertical",
        -                "areas": [
        -                    { "widget": "header", "size": 3 },
        -                    { "widget": "cpu", "size": "*" }
        -                ]
        -            }
        -        }"#;
        -        let cleaned = strip_jsonc_comments(jsonc);
        -        let layout: LayoutDef = serde_json::from_str(&cleaned).unwrap();
        -        assert_eq!(layout.name, "test");
        -    }
        -}
        diff --git a/src/layout/mod.rs b/src/layout/mod.rs
        deleted file mode 100644
        index 148e39d..0000000
        --- a/src/layout/mod.rs
        +++ /dev/null
        @@ -1,10 +0,0 @@
        -//! Layout area: layout model, modes and layout loading.
        -
        -mod loader;
        -mod mode;
        -mod model;
        -
        -pub use loader::*;
        -pub use mode::*;
        -pub(crate) use mode::{layout_index_from_mode, mode_from_layout_index};
        -pub use model::*;
        diff --git a/src/layout/mode.rs b/src/layout/mode.rs
        deleted file mode 100644
        index 8b397e5..0000000
        --- a/src/layout/mode.rs
        +++ /dev/null
        @@ -1,98 +0,0 @@
        -//! Layout modes and effective-layout detection.
        -//!
        -//! How the requested layout mode degrades depending on terminal size.
        -
        -use crate::layout::LayoutDef;
        -use serde::{Deserialize, Serialize};
        -
        -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
        -pub enum LayoutMode {
        -    Dashboard,
        -    Vertical,
        -    Horizontal,
        -    CpuFocus,
        -    MemoryFocus,
        -    NetworkFocus,
        -    ProcessFocus,
        -}
        -
        -impl LayoutMode {
        -    #[cfg(test)]
        -    pub fn next(self) -> Self {
        -        match self {
        -            Self::Dashboard => Self::Vertical,
        -            Self::Vertical => Self::Horizontal,
        -            Self::Horizontal => Self::CpuFocus,
        -            Self::CpuFocus => Self::MemoryFocus,
        -            Self::MemoryFocus => Self::NetworkFocus,
        -            Self::NetworkFocus => Self::ProcessFocus,
        -            Self::ProcessFocus => Self::Dashboard,
        -        }
        -    }
        -
        -    pub fn label(self) -> &'static str {
        -        match self {
        -            Self::Dashboard => "Dashboard",
        -            Self::Vertical => "Vertical",
        -            Self::Horizontal => "Horizontal",
        -            Self::CpuFocus => "CPU Focus",
        -            Self::MemoryFocus => "Memory Focus",
        -            Self::NetworkFocus => "Network Focus",
        -            Self::ProcessFocus => "Process Focus",
        -        }
        -    }
        -}
        -
        -#[derive(Clone, Copy, Debug, PartialEq)]
        -pub enum EffectiveLayout {
        -    Dashboard,
        -    Compact,
        -    Vertical,
        -    Horizontal,
        -    CpuFocus,
        -    MemoryFocus,
        -    NetworkFocus,
        -    ProcessFocus,
        -    Minimal,
        -}
        -
        -pub(crate) fn layout_index_from_mode(mode: LayoutMode, defs: &[LayoutDef]) -> usize {
        -    let label = mode.label();
        -    defs.iter().position(|d| d.name == label).unwrap_or(0)
        -}
        -
        -pub(crate) fn mode_from_layout_index(index: usize) -> LayoutMode {
        -    match index {
        -        0 => LayoutMode::Dashboard,
        -        1 => LayoutMode::Vertical,
        -        2 => LayoutMode::Horizontal,
        -        3 => LayoutMode::CpuFocus,
        -        4 => LayoutMode::MemoryFocus,
        -        5 => LayoutMode::NetworkFocus,
        -        6 => LayoutMode::ProcessFocus,
        -        _ => LayoutMode::Dashboard,
        -    }
        -}
        -
        -pub fn detect_effective_layout(width: u16, height: u16, user_mode: LayoutMode) -> EffectiveLayout {
        -    if width < 60 || height < 14 {
        -        return EffectiveLayout::Minimal;
        -    }
        -    match user_mode {
        -        LayoutMode::Dashboard => {
        -            if width < 80 {
        -                EffectiveLayout::Vertical
        -            } else if width < 100 || height < 28 {
        -                EffectiveLayout::Compact
        -            } else {
        -                EffectiveLayout::Dashboard
        -            }
        -        }
        -        LayoutMode::Vertical => EffectiveLayout::Vertical,
        -        LayoutMode::Horizontal => EffectiveLayout::Horizontal,
        -        LayoutMode::CpuFocus => EffectiveLayout::CpuFocus,
        -        LayoutMode::MemoryFocus => EffectiveLayout::MemoryFocus,
        -        LayoutMode::NetworkFocus => EffectiveLayout::NetworkFocus,
        -        LayoutMode::ProcessFocus => EffectiveLayout::ProcessFocus,
        -    }
        -}
        diff --git a/src/layout/model.rs b/src/layout/model.rs
        deleted file mode 100644
        index cceca80..0000000
        --- a/src/layout/model.rs
        +++ /dev/null
        @@ -1,185 +0,0 @@
        -use serde::de::{self, MapAccess, Visitor};
        -use serde::Deserialize;
        -use std::fmt;
        -
        -#[derive(Clone, Debug, PartialEq)]
        -pub enum Direction {
        -    Horizontal,
        -    Vertical,
        -}
        -
        -#[derive(Clone, Debug, PartialEq)]
        -pub enum LayoutConstraint {
        -    Length(u16),
        -    Percentage(u16),
        -    Fill,
        -}
        -
        -#[derive(Clone, Debug, PartialEq)]
        -pub struct LayoutArea {
        -    pub constraint: LayoutConstraint,
        -    pub node: LayoutNode,
        -}
        -
        -#[derive(Clone, Debug, PartialEq)]
        -pub enum LayoutNode {
        -    Split {
        -        direction: Direction,
        -        areas: Vec,
        -    },
        -    Widget {
        -        name: String,
        -    },
        -}
        -
        -#[derive(Clone, Debug, PartialEq)]
        -pub struct LayoutDef {
        -    pub name: String,
        -    pub root: LayoutNode,
        -}
        -
        -// ---------------------------------------------------------------------------
        -// Deserialization helpers
        -// ---------------------------------------------------------------------------
        -
        -#[derive(Deserialize)]
        -struct LayoutDefRaw {
        -    name: String,
        -    root: LayoutAreaRaw,
        -}
        -
        -#[derive(Deserialize)]
        -struct LayoutAreaRaw {
        -    #[serde(default)]
        -    size: Option,
        -    widget: Option,
        -    direction: Option,
        -    #[serde(default)]
        -    areas: Option>,
        -}
        -
        -#[derive(Deserialize)]
        -#[serde(untagged)]
        -enum SizeRaw {
        -    Num(u16),
        -    Str(String),
        -}
        -
        -impl TryFrom for LayoutArea {
        -    type Error = String;
        -
        -    fn try_from(raw: LayoutAreaRaw) -> Result {
        -        let constraint = match raw.size {
        -            None => LayoutConstraint::Fill,
        -            Some(SizeRaw::Num(n)) => LayoutConstraint::Length(n),
        -            Some(SizeRaw::Str(s)) if s == "*" => LayoutConstraint::Fill,
        -            Some(SizeRaw::Str(s)) if s.ends_with('%') => {
        -                let pct = s
        -                    .trim_end_matches('%')
        -                    .parse::()
        -                    .map_err(|_| format!("invalid percentage: {s}"))?;
        -                LayoutConstraint::Percentage(pct)
        -            }
        -            Some(SizeRaw::Str(s)) => {
        -                return Err(format!("invalid size constraint: {s}"));
        -            }
        -        };
        -
        -        let node = if let Some(name) = raw.widget {
        -            LayoutNode::Widget { name }
        -        } else if let Some(dir) = raw.direction {
        -            let direction = match dir.to_lowercase().as_str() {
        -                "horizontal" => Direction::Horizontal,
        -                "vertical" => Direction::Vertical,
        -                _ => return Err(format!("invalid direction: {dir}")),
        -            };
        -            let areas_raw = raw.areas.unwrap_or_default();
        -            let mut areas = Vec::with_capacity(areas_raw.len());
        -            for a in areas_raw {
        -                areas.push(a.try_into()?);
        -            }
        -            LayoutNode::Split { direction, areas }
        -        } else {
        -            return Err("layout area must have 'widget' or 'direction'".into());
        -        };
        -
        -        Ok(LayoutArea { constraint, node })
        -    }
        -}
        -
        -impl TryFrom for LayoutDef {
        -    type Error = String;
        -
        -    fn try_from(raw: LayoutDefRaw) -> Result {
        -        let area: LayoutArea = raw.root.try_into()?;
        -        Ok(LayoutDef {
        -            name: raw.name,
        -            root: area.node,
        -        })
        -    }
        -}
        -
        -// Custom Deserialize for LayoutDef (handles jsonc-compatible parsing)
        -impl<'de> Deserialize<'de> for LayoutDef {
        -    fn deserialize(deserializer: D) -> Result
        -    where
        -        D: serde::Deserializer<'de>,
        -    {
        -        #[derive(Deserialize)]
        -        #[serde(field_identifier, rename_all = "snake_case")]
        -        enum Field {
        -            Name,
        -            Root,
        -        }
        -
        -        struct LayoutVisitor;
        -        impl<'de> Visitor<'de> for LayoutVisitor {
        -            type Value = LayoutDef;
        -
        -            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
        -                f.write_str("struct LayoutDef")
        -            }
        -
        -            fn visit_map(self, mut map: V) -> Result
        -            where
        -                V: MapAccess<'de>,
        -            {
        -                let mut raw = LayoutDefRaw {
        -                    name: String::new(),
        -                    root: LayoutAreaRaw {
        -                        size: None,
        -                        widget: None,
        -                        direction: None,
        -                        areas: None,
        -                    },
        -                };
        -                let mut found_name = false;
        -                let mut found_root = false;
        -
        -                while let Some(key) = map.next_key::()? {
        -                    match key {
        -                        Field::Name => {
        -                            raw.name = map.next_value::()?;
        -                            found_name = true;
        -                        }
        -                        Field::Root => {
        -                            raw.root = map.next_value::()?;
        -                            found_root = true;
        -                        }
        -                    }
        -                }
        -
        -                if !found_name {
        -                    return Err(de::Error::missing_field("name"));
        -                }
        -                if !found_root {
        -                    return Err(de::Error::missing_field("root"));
        -                }
        -
        -                LayoutDef::try_from(raw).map_err(de::Error::custom)
        -            }
        -        }
        -
        -        deserializer.deserialize_struct("LayoutDef", &["name", "root"], LayoutVisitor)
        -    }
        -}
        diff --git a/src/main.rs b/src/main.rs
        index 5711286..281077d 100644
        --- a/src/main.rs
        +++ b/src/main.rs
        @@ -1,12 +1,12 @@
         //! xtop CLI entry point.
         //!
         //! The binary is a thin dispatcher; the app is organized in areas:
        -//! `commands`, `config`, `layout`, `plugins`, `providers`, `state`, `theme`
        -//! and `ui`.
        +//! `commands`, `config`, `plugins`, `providers`, `state`, `theme` and `ui`.
        +//! Layouts and widget packs live in their own repos and are consumed as
        +//! crates.
         
         mod commands;
         mod config;
        -mod layout;
         mod plugins;
         mod providers;
         mod state;
        @@ -20,11 +20,11 @@ fn print_usage() {
             eprintln!("  xtop                        Start the TUI system monitor");
             eprintln!("  xtop mcp                    Start MCP server (stdio transport) for AI agents");
             eprintln!("  xtop plugin list            List installed plugins");
        -    eprintln!(
        -        "  xtop plugin install   Install a plugin from github.com/xtop-cli/xtop/plugins/"
        -    );
        +    eprintln!("  xtop plugin install   Install a plugin from github.com/xtop-cli/plugins");
             eprintln!("  xtop plugin install    Install a plugin from a git URL");
             eprintln!("  xtop plugin scaffold  Create a new plugin crate");
        +    eprintln!("  xtop layout check     Validate a layout JSONC file");
        +    eprintln!("  xtop layout install   Install a layout from github.com/xtop-cli/layouts");
         }
         
         fn main() -> anyhow::Result<()> {
        @@ -40,6 +40,9 @@ fn main() -> anyhow::Result<()> {
                     "plugin" => {
                         return commands::plugins::plugin_command(&args);
                     }
        +            "layout" => {
        +                return commands::layout::layout_command(&args);
        +            }
                     "--help" | "-h" => {
                         print_usage();
                         return Ok(());
        diff --git a/src/plugins/extension_host.rs b/src/plugins/extension_host.rs
        index 2cd3dc5..a473fe4 100644
        --- a/src/plugins/extension_host.rs
        +++ b/src/plugins/extension_host.rs
        @@ -23,6 +23,9 @@ impl ExtensionHost for AppState {
                     mgr.execute(this, plugin_id, action, params)
                         .map_err(map_plugin_error)
                 })
        +        .unwrap_or(Err(ExtensionError::Recoverable(
        +            "plugin manager not initialized".to_string(),
        +        )))
             }
         }
         
        diff --git a/src/plugins/manager.rs b/src/plugins/manager.rs
        index 2ce64ad..170862d 100644
        --- a/src/plugins/manager.rs
        +++ b/src/plugins/manager.rs
        @@ -1,3 +1,4 @@
        +//! Plugin host: lifecycle, tick/key dispatch and capability routing.
         use std::fmt::Debug;
         use std::path::PathBuf;
         
        diff --git a/src/providers/composite.rs b/src/providers/composite.rs
        index 7918301..9893f3e 100644
        --- a/src/providers/composite.rs
        +++ b/src/providers/composite.rs
        @@ -1,3 +1,4 @@
        +//! Composite data provider: kernel provider plus plugin providers.
         use xtop_plugin_api::model::*;
         use xtop_plugin_api::SystemDataProvider;
         
        diff --git a/src/providers/sysinfo/provider.rs b/src/providers/sysinfo/provider.rs
        index 35f1c62..ea51d47 100644
        --- a/src/providers/sysinfo/provider.rs
        +++ b/src/providers/sysinfo/provider.rs
        @@ -42,6 +42,13 @@ impl Default for SysinfoProvider {
         
         impl SysinfoProvider {
             pub fn new() -> Self {
        +        // The snapshot is capped to the top-CPU processes to bound per-tick
        +        // work; users can raise the cap via XTOP_MAX_PROCESSES.
        +        let max_processes = std::env::var("XTOP_MAX_PROCESSES")
        +            .ok()
        +            .and_then(|v| v.parse::().ok())
        +            .unwrap_or(DEFAULT_MAX_PROCESSES)
        +            .max(1);
                 let sys = System::new_with_specifics(
                     RefreshKind::nothing()
                         .with_cpu(CpuRefreshKind::everything())
        @@ -70,7 +77,7 @@ impl SysinfoProvider {
                     prev_net_tx: HashMap::new(),
                     last_refresh: Instant::now(),
                     cached_sys_info: info,
        -            max_processes: DEFAULT_MAX_PROCESSES,
        +            max_processes,
                 }
             }
         }
        @@ -81,6 +88,20 @@ impl SystemDataProvider for SysinfoProvider {
                 self.disks.refresh(true);
                 self.networks.refresh(true);
                 self.components.refresh(true);
        +
        +        // Record rate baselines *after* this refresh: next sample computes
        +        // bytes/s as the delta since this point over `last_refresh`.
        +        for (name, n) in self.networks.iter() {
        +            self.prev_net_rx.insert(name.clone(), n.received());
        +            self.prev_net_tx.insert(name.clone(), n.transmitted());
        +        }
        +        for d in self.disks.iter() {
        +            let usage = d.usage();
        +            let key = d.mount_point().to_string_lossy().to_string();
        +            self.prev_disk_read.insert(key.clone(), usage.read_bytes);
        +            self.prev_disk_write.insert(key, usage.written_bytes);
        +        }
        +        self.last_refresh = Instant::now();
             }
         
             fn snapshot(&self) -> SystemSnapshot {
        @@ -195,18 +216,23 @@ impl SystemDataProvider for SysinfoProvider {
                     .duration_since(std::time::UNIX_EPOCH)
                     .unwrap_or_default()
                     .as_secs();
        +        let uptime = System::uptime();
        +        // sysinfo reports process start relative to boot; expose it as epoch
        +        // seconds so consumers (widgets, plugins) compare against one clock.
        +        let boot_epoch = now.saturating_sub(uptime);
         
                 let mut procs: Vec = self
                     .sys
                     .processes()
                     .iter()
                     .map(|(pid, p)| {
        -                let start = p.start_time();
        -                let run = if start > 0 {
        -                    now.saturating_sub(start)
        +                let start_raw = p.start_time();
        +                let start = if start_raw > 0 {
        +                    boot_epoch.saturating_add(start_raw)
                         } else {
                             0
                         };
        +                let run = now.saturating_sub(start);
                         ProcessInfo {
                             pid: pid.as_u32(),
                             name: p.name().to_string_lossy().to_string(),
        @@ -282,12 +308,12 @@ impl SystemDataProvider for SysinfoProvider {
                         five: load.five,
                         fifteen: load.fifteen,
                     },
        -            uptime: System::uptime(),
        +            uptime,
                     disk_io: self.disk_io_inner(),
                     batteries: read_batteries(),
                     gpus: read_gpu_info(),
                     dockers: vec![],
        -            sys_info: SystemInfo::default(),
        +            sys_info: self.cached_sys_info.clone(),
                 }
             }
         
        diff --git a/src/state/app.rs b/src/state/app.rs
        index 6de04d3..5ae6e2a 100644
        --- a/src/state/app.rs
        +++ b/src/state/app.rs
        @@ -1,14 +1,14 @@
        +//! Live application state: system sampling per tick, layout/theme/process
        +//! control, plugins.
        +
         use crate::config::keybinding::{Action, Keybindings};
        -use crate::config::{AlertThresholds, Config};
        -use crate::layout::{layout_index_from_mode, mode_from_layout_index, LayoutDef, LayoutMode};
        +use crate::config::{AlertThresholds, Config, UiStyle};
         use crate::plugins::PluginManager;
         use crate::state::history::MetricsHistory;
        -use crate::state::view::{
        -    FullScreenWidget, InputMode, PaletteEntry, PalettePage, PaletteState, ProcessSortBy,
        -};
        +use crate::state::view::{FullScreenWidget, InputMode, PalettePage, PaletteState, ProcessSortBy};
         use crate::theme::Theme;
        -use xtop_plugin_api::model::SystemInfo;
        -use xtop_plugin_api::model::SystemSnapshot;
        +use xtop_layout::{layout_index_from_mode, layout_mode_for_name, LayoutDef, LayoutMode};
        +use xtop_plugin_api::model::{ProcessInfo, SystemInfo, SystemSnapshot};
         use xtop_plugin_api::SystemDataProvider;
         use xtop_plugin_api::WidgetRegistration;
         
        @@ -29,11 +29,18 @@ pub struct AppState {
             pub full_screen_widget: FullScreenWidget,
             pub alerts: AlertThresholds,
             pub update_interval_ms: u64,
        +    /// Widget glyph style (chart charset, borders); from the user config.
        +    pub style: UiStyle,
             pub palette: PaletteState,
             pub keybindings: Keybindings,
             pub process_sort: ProcessSortBy,
        -    pub process_selected: Option,
        +    /// Selected process anchored by PID (not row index), so sorting/filtering
        +    /// or a fresh sample never makes a kill target the wrong process.
        +    pub process_selected_pid: Option,
             pub sys_info: SystemInfo,
        +    /// Latest full system sample, computed once per tick and shared by every
        +    /// widget/action in that frame (avoids N samples per frame).
        +    last_snapshot: Option,
             pub plugin_manager: Option,
             pub plugin_widgets: Vec,
         }
        @@ -75,6 +82,7 @@ impl AppState {
                     full_screen_widget: FullScreenWidget::None,
                     alerts: config.alerts,
                     update_interval_ms: config.update_interval_ms,
        +            style: config.style,
                     palette: PaletteState {
                         open: false,
                         query: String::new(),
        @@ -85,8 +93,9 @@ impl AppState {
                     },
                     keybindings: config.keybindings,
                     process_sort: ProcessSortBy::Cpu,
        -            process_selected: None,
        +            process_selected_pid: None,
                     sys_info: SystemInfo::default(),
        +            last_snapshot: None,
                     plugin_manager: None,
                     plugin_widgets: Vec::new(),
                 }
        @@ -144,7 +153,7 @@ impl AppState {
             pub fn set_layout_by_name(&mut self, name: &str) -> bool {
                 if let Some(idx) = self.layout_defs.iter().position(|l| l.name == name) {
                     self.layout_index = idx;
        -            self.layout_mode = self.save_layout_mode();
        +            self.sync_layout_mode();
                     self.full_screen_widget = FullScreenWidget::None;
                     true
                 } else {
        @@ -156,24 +165,37 @@ impl AppState {
                 &self.layout_defs[self.layout_index]
             }
         
        +    /// The layout mode matching the current definition. Custom layouts fall
        +    /// back to the previously active mode (they are addressed by name).
             pub fn save_layout_mode(&self) -> LayoutMode {
        -        mode_from_layout_index(self.layout_index)
        +        let fallback = if self.layout_index < 7 {
        +            xtop_layout::mode_from_layout_index(self.layout_index)
        +        } else {
        +            self.layout_mode
        +        };
        +        match self.layout_defs.get(self.layout_index) {
        +            Some(def) => layout_mode_for_name(&def.name, fallback),
        +            None => LayoutMode::Dashboard,
        +        }
        +    }
        +
        +    fn sync_layout_mode(&mut self) {
        +        self.layout_mode = self.save_layout_mode();
             }
         
             /// Safely access the plugin manager with a closure.
             /// Ensures the plugin manager is always restored after the operation.
        +    /// Returns `None` when no manager is initialized (pre-bootstrap or tests)
        +    /// instead of panicking; callers decide how to degrade.
             /// NOTE: does NOT call refresh_plugin_widgets — the caller must do it if needed.
             pub fn with_plugin_manager_mut(
                 &mut self,
                 f: impl FnOnce(&mut PluginManager, &mut Self) -> R,
        -    ) -> R {
        -        let mut mgr = self
        -            .plugin_manager
        -            .take()
        -            .expect("PluginManager not initialized");
        +    ) -> Option {
        +        let mut mgr = self.plugin_manager.take()?;
                 let result = f(&mut mgr, self);
                 self.plugin_manager = Some(mgr);
        -        result
        +        Some(result)
             }
         
             pub fn on_tick(&mut self) {
        @@ -195,25 +217,60 @@ impl AppState {
         
                 self.history.push_mem(x, snap.memory.percent);
         
        -        let total_rx: u64 = snap.networks.iter().map(|n| n.received).sum();
        -        let total_tx: u64 = snap.networks.iter().map(|n| n.transmitted).sum();
        -        self.history.push_net(x, total_rx as f64, total_tx as f64);
        +        // Network history tracks *rates* (bytes/s), not cumulative counters,
        +        // so the chart shows throughput over time.
        +        let total_rx_speed: f64 = snap.networks.iter().map(|n| n.rx_speed).sum();
        +        let total_tx_speed: f64 = snap.networks.iter().map(|n| n.tx_speed).sum();
        +        self.history.push_net(x, total_rx_speed, total_tx_speed);
        +
        +        // Cache the sample for every widget and action in this frame.
        +        self.last_snapshot = Some(snap);
         
                 // Let plugins tick
        -        self.with_plugin_manager_mut(|mgr, this| {
        +        let _ = self.with_plugin_manager_mut(|mgr, this| {
                     mgr.tick_all(this);
                 });
                 self.refresh_plugin_widgets();
             }
         
        +    /// The current sample (one per tick). Widgets and process actions read
        +    /// this instead of resampling the system every frame.
        +    pub fn snapshot_cache(&self) -> Option<&SystemSnapshot> {
        +        self.last_snapshot.as_ref()
        +    }
        +
        +    /// Full current snapshot. Prefer [`AppState::snapshot_cache`] in render
        +    /// paths; this forces a fresh system sample (used by plugin hosts).
             pub fn snapshot(&self) -> SystemSnapshot {
        -        let mut snap = self.provider.snapshot();
        -        snap.disk_io = self.provider.disk_io();
        -        snap.batteries = self.provider.batteries();
        -        snap.gpus = self.provider.gpu_info();
        -        snap.dockers = self.provider.docker_info();
        -        snap.sys_info = self.provider.system_info();
        -        snap
        +        self.last_snapshot
        +            .clone()
        +            .unwrap_or_else(|| self.provider.snapshot())
        +    }
        +
        +    /// The process rows the UI shows: search filter + user sort, applied to
        +    /// one shared sample. Single source of truth for the processes widget and
        +    /// the Up/Down/Kill actions (selection is anchored by PID).
        +    pub fn sorted_processes<'a>(&'a self, snap: &'a SystemSnapshot) -> Vec<&'a ProcessInfo> {
        +        let mut items: Vec<&ProcessInfo> = if self.search_query.is_empty() {
        +            snap.processes.iter().collect()
        +        } else {
        +            let q = self.search_query.to_lowercase();
        +            snap.processes
        +                .iter()
        +                .filter(|p| p.name.to_lowercase().contains(&q))
        +                .collect()
        +        };
        +        match self.process_sort {
        +            ProcessSortBy::Cpu => items.sort_by(|a, b| {
        +                b.cpu_usage
        +                    .partial_cmp(&a.cpu_usage)
        +                    .unwrap_or(std::cmp::Ordering::Equal)
        +            }),
        +            ProcessSortBy::Memory => items.sort_by_key(|b| std::cmp::Reverse(b.memory)),
        +            ProcessSortBy::Pid => items.sort_by_key(|a| a.pid),
        +            ProcessSortBy::Name => items.sort_by_key(|a| a.name.to_lowercase()),
        +        }
        +        items
             }
         
             pub fn next_theme(&mut self) {
        @@ -235,8 +292,11 @@ impl AppState {
             }
         
             pub fn next_layout(&mut self) {
        +        if self.layout_defs.is_empty() {
        +            return;
        +        }
                 self.layout_index = (self.layout_index + 1) % self.layout_defs.len();
        -        self.layout_mode = self.save_layout_mode();
        +        self.sync_layout_mode();
                 self.full_screen_widget = FullScreenWidget::None;
             }
         
        @@ -280,154 +340,34 @@ impl AppState {
                 self.should_quit = true;
             }
         
        -    pub fn rebuild_palette(&mut self) {
        -        self.palette.entries.clear();
        -        match self.palette.page {
        -            PalettePage::Main => {
        -                self.palette.entries.push(PaletteEntry {
        -                    label: "Themes →".into(),
        -                    action: Action::NavigateThemes,
        -                });
        -                self.palette.entries.push(PaletteEntry {
        -                    label: "Layouts →".into(),
        -                    action: Action::NavigateLayouts,
        -                });
        -                self.palette.entries.push(PaletteEntry {
        -                    label: "Toggle Fullscreen".into(),
        -                    action: Action::ToggleFullscreen,
        -                });
        -                self.palette.entries.push(PaletteEntry {
        -                    label: "Cycle Fullscreen Widget".into(),
        -                    action: Action::CycleFullscreen,
        -                });
        -                self.palette.entries.push(PaletteEntry {
        -                    label: "Search Processes".into(),
        -                    action: Action::Search,
        -                });
        -                self.palette.entries.push(PaletteEntry {
        -                    label: "Toggle Help".into(),
        -                    action: Action::ToggleHelp,
        -                });
        -                self.palette.entries.push(PaletteEntry {
        -                    label: format!("Sort: {}", self.process_sort.label()),
        -                    action: Action::SortByCpu,
        -                });
        -                self.palette.entries.push(PaletteEntry {
        -                    label: "Random Theme".into(),
        -                    action: Action::RandomTheme,
        -                });
        -                self.palette.entries.push(PaletteEntry {
        -                    label: "Exit".into(),
        -                    action: Action::Quit,
        -                });
        -            }
        -            PalettePage::Themes => {
        -                for (i, theme) in self.themes.iter().enumerate() {
        -                    self.palette.entries.push(PaletteEntry {
        -                        label: theme.name.clone(),
        -                        action: Action::SelectTheme(i),
        -                    });
        -                }
        -            }
        -            PalettePage::Layouts => {
        -                for (i, layout) in self.layout_defs.iter().enumerate() {
        -                    self.palette.entries.push(PaletteEntry {
        -                        label: layout.name.clone(),
        -                        action: Action::SelectLayout(i),
        -                    });
        -                }
        -            }
        -        }
        -        self.palette_filter();
        -    }
        -
        -    pub fn open_palette(&mut self) {
        -        self.palette.open = true;
        -        self.palette.query.clear();
        -        self.palette.selected = 0;
        -        self.palette.page = PalettePage::Main;
        -        self.rebuild_palette();
        -    }
        -
        -    pub fn palette_navigate_to(&mut self, page: PalettePage) {
        -        self.palette.page = page;
        -        self.palette.query.clear();
        -        self.palette.selected = 0;
        -        self.rebuild_palette();
        -    }
        -
        -    pub fn palette_filter(&mut self) {
        -        let q = self.palette.query.to_lowercase();
        -        self.palette.filtered = self
        -            .palette
        -            .entries
        -            .iter()
        -            .enumerate()
        -            .filter(|(_, e)| q.is_empty() || e.label.to_lowercase().contains(&q))
        -            .map(|(i, _)| i)
        -            .collect();
        -        if !self.palette.filtered.is_empty() {
        -            self.palette.selected = self.palette.selected.min(self.palette.filtered.len() - 1);
        -        } else {
        -            self.palette.selected = 0;
        -        }
        -    }
        -
        -    pub fn palette_select_next(&mut self) {
        -        if !self.palette.filtered.is_empty() {
        -            self.palette.selected = (self.palette.selected + 1) % self.palette.filtered.len();
        -        }
        +    pub fn process_select_next(&mut self) {
        +        self.move_process_selection(1);
             }
         
        -    pub fn palette_select_prev(&mut self) {
        -        if !self.palette.filtered.is_empty() {
        -            self.palette.selected = if self.palette.selected == 0 {
        -                self.palette.filtered.len() - 1
        -            } else {
        -                self.palette.selected - 1
        -            };
        -        }
        +    pub fn process_select_prev(&mut self) {
        +        self.move_process_selection(-1);
             }
         
        -    pub fn process_select_next(&mut self) {
        -        let snap = self.snapshot();
        -        if snap.processes.is_empty() {
        +    fn move_process_selection(&mut self, dir: i32) {
        +        let Some(snap) = self.snapshot_cache() else {
                     return;
        -        }
        -        let idx = self.process_selected.unwrap_or(0);
        -        self.process_selected = Some((idx + 1) % snap.processes.len());
        -    }
        -
        -    pub fn process_select_prev(&mut self) {
        -        let snap = self.snapshot();
        -        if snap.processes.is_empty() {
        +        };
        +        let view = self.sorted_processes(snap);
        +        if view.is_empty() {
                     return;
                 }
        -        let idx = self.process_selected.unwrap_or(0);
        -        self.process_selected = Some(if idx == 0 {
        -            snap.processes.len() - 1
        -        } else {
        -            idx - 1
        -        });
        +        let n = view.len() as i32;
        +        let pos = self
        +            .process_selected_pid
        +            .and_then(|pid| view.iter().position(|p| p.pid == pid))
        +            .unwrap_or(0) as i32;
        +        let next = (pos + dir).rem_euclid(n);
        +        self.process_selected_pid = Some(view[next as usize].pid);
             }
         
             pub fn cycle_sort(&mut self) {
                 self.process_sort = self.process_sort.next();
        -        self.process_selected = None;
        -    }
        -
        -    pub fn palette_selected_action(&self) -> Option {
        -        self.palette
        -            .filtered
        -            .get(self.palette.selected)
        -            .and_then(|&i| self.palette.entries.get(i))
        -            .map(|e| e.action.clone())
        -    }
        -
        -    pub fn close_palette(&mut self) {
        -        self.palette.open = false;
        -        self.palette.page = PalettePage::Main;
        -        self.input_mode = InputMode::Normal;
        +        self.process_selected_pid = None;
             }
         
             pub fn execute_action(&mut self, action: &Action) {
        @@ -451,9 +391,11 @@ impl AppState {
                         self.apply_theme();
                     }
                     Action::SelectLayout(i) => {
        -                self.layout_index = *i;
        -                self.layout_mode = self.save_layout_mode();
        -                self.full_screen_widget = FullScreenWidget::None;
        +                if *i < self.layout_defs.len() {
        +                    self.layout_index = *i;
        +                    self.sync_layout_mode();
        +                    self.full_screen_widget = FullScreenWidget::None;
        +                }
                     }
                     Action::NavigateThemes => {
                         self.palette_navigate_to(PalettePage::Themes);
        @@ -464,13 +406,9 @@ impl AppState {
                         return;
                     }
                     Action::KillProcess => {
        -                if let Some(pid) = self.process_selected {
        -                    let snap = self.snapshot();
        -                    if pid < snap.processes.len() {
        -                        let target = snap.processes[pid].pid;
        -                        self.provider.kill_process(target);
        -                        self.process_selected = None;
        -                    }
        +                if let Some(pid) = self.process_selected_pid {
        +                    self.provider.kill_process(pid);
        +                    self.process_selected_pid = None;
                         }
                     }
                     Action::ProcessUp => self.process_select_prev(),
        @@ -497,69 +435,19 @@ impl AppState {
         #[cfg(test)]
         mod tests {
             use super::*;
        -    use crate::layout::{detect_effective_layout, EffectiveLayout, LayoutMode};
        +    use xtop_layout::{default_layouts, LayoutMode};
         
        -    #[test]
        -    fn test_layout_mode_next() {
        -        assert_eq!(LayoutMode::Dashboard.next(), LayoutMode::Vertical);
        -        assert_eq!(LayoutMode::Vertical.next(), LayoutMode::Horizontal);
        -        assert_eq!(LayoutMode::Horizontal.next(), LayoutMode::CpuFocus);
        -        assert_eq!(LayoutMode::CpuFocus.next(), LayoutMode::MemoryFocus);
        -        assert_eq!(LayoutMode::MemoryFocus.next(), LayoutMode::NetworkFocus);
        -        assert_eq!(LayoutMode::NetworkFocus.next(), LayoutMode::ProcessFocus);
        -        assert_eq!(LayoutMode::ProcessFocus.next(), LayoutMode::Dashboard);
        -    }
        -
        -    #[test]
        -    fn test_detect_effective_layout_large() {
        -        assert_eq!(
        -            detect_effective_layout(120, 40, LayoutMode::Dashboard),
        -            EffectiveLayout::Dashboard
        -        );
        -    }
        -
        -    #[test]
        -    fn test_detect_effective_layout_compact() {
        -        assert_eq!(
        -            detect_effective_layout(90, 30, LayoutMode::Dashboard),
        -            EffectiveLayout::Compact
        -        );
        -    }
        -
        -    #[test]
        -    fn test_detect_effective_layout_narrow() {
        -        assert_eq!(
        -            detect_effective_layout(70, 30, LayoutMode::Dashboard),
        -            EffectiveLayout::Vertical
        -        );
        -    }
        -
        -    #[test]
        -    fn test_detect_effective_layout_minimal() {
        -        assert_eq!(
        -            detect_effective_layout(50, 15, LayoutMode::Dashboard),
        -            EffectiveLayout::Minimal
        -        );
        -    }
        -
        -    #[test]
        -    fn test_detect_effective_layout_focus_respected() {
        -        assert_eq!(
        -            detect_effective_layout(80, 30, LayoutMode::CpuFocus),
        -            EffectiveLayout::CpuFocus
        -        );
        -        assert_eq!(
        -            detect_effective_layout(80, 30, LayoutMode::NetworkFocus),
        -            EffectiveLayout::NetworkFocus
        -        );
        -    }
        -
        -    #[test]
        -    fn test_detect_effective_layout_focus_downgrade() {
        -        assert_eq!(
        -            detect_effective_layout(50, 30, LayoutMode::CpuFocus),
        -            EffectiveLayout::Minimal
        -        );
        +    fn test_state(defs: Vec) -> AppState {
        +        let theme = Theme {
        +            name: "test".into(),
        +            palette: [[0, 0, 0]; 16],
        +        };
        +        AppState::new(
        +            Box::new(crate::providers::sysinfo::SysinfoProvider::new()),
        +            vec![theme],
        +            Config::default(),
        +            defs,
        +        )
             }
         
             #[test]
        @@ -568,13 +456,6 @@ mod tests {
                 assert_eq!(FullScreenWidget::Battery.next(), FullScreenWidget::None);
             }
         
        -    #[test]
        -    fn test_layout_mode_label() {
        -        assert_eq!(LayoutMode::Dashboard.label(), "Dashboard");
        -        assert_eq!(LayoutMode::CpuFocus.label(), "CPU Focus");
        -        assert_eq!(LayoutMode::Horizontal.label(), "Horizontal");
        -    }
        -
             #[test]
             fn test_alert_thresholds_default() {
                 let a = AlertThresholds::default();
        @@ -592,5 +473,35 @@ mod tests {
             }
         
             #[test]
        -    fn test_search_operations() {}
        +    fn test_snapshot_cache_empty_before_first_tick() {
        +        let state = test_state(vec![]);
        +        assert!(state.snapshot_cache().is_none());
        +    }
        +
        +    #[test]
        +    fn test_custom_layout_keeps_previous_mode() {
        +        let mut defs = default_layouts();
        +        defs.push(LayoutDef {
        +            name: "My Custom".into(),
        +            root: xtop_layout::LayoutNode::Split {
        +                direction: xtop_layout::Direction::Vertical,
        +                areas: vec![],
        +            },
        +        });
        +        let mut state = test_state(defs.clone());
        +
        +        // Default boot: Dashboard at index 0.
        +        assert_eq!(state.layout_index, 0);
        +        assert_eq!(state.layout_mode, LayoutMode::Dashboard);
        +
        +        // Custom layout (index 7) must not reset the mode to Dashboard-as-mode
        +        // loss; it falls back to the previously active mode (Dashboard here).
        +        assert!(state.set_layout_by_name("My Custom"));
        +        assert_eq!(state.layout_mode, LayoutMode::Dashboard);
        +        assert_eq!(state.current_layout_name(), "My Custom");
        +
        +        // Built-ins still resolve to their real mode by name.
        +        assert!(state.set_layout_by_name("CPU Focus"));
        +        assert_eq!(state.layout_mode, LayoutMode::CpuFocus);
        +    }
         }
        diff --git a/src/state/mod.rs b/src/state/mod.rs
        index 6a6108f..56f7dc9 100644
        --- a/src/state/mod.rs
        +++ b/src/state/mod.rs
        @@ -6,7 +6,9 @@
         
         pub mod app;
         pub mod history;
        +mod palette;
         pub mod view;
        +mod widget_state;
         
         pub use app::*;
         pub use view::*;
        diff --git a/src/state/palette.rs b/src/state/palette.rs
        new file mode 100644
        index 0000000..0be970e
        --- /dev/null
        +++ b/src/state/palette.rs
        @@ -0,0 +1,134 @@
        +//! Command palette state logic (open/navigate/filter/execute pages).
        +//!
        +//! Lives in its own module to keep `AppState` focused; these functions only
        +//! touch public state fields, so they are plain `impl` additions.
        +
        +use crate::config::keybinding::Action;
        +use crate::state::view::{InputMode, PaletteEntry, PalettePage};
        +
        +use super::AppState;
        +
        +impl AppState {
        +    pub fn rebuild_palette(&mut self) {
        +        self.palette.entries.clear();
        +        match self.palette.page {
        +            PalettePage::Main => {
        +                self.palette.entries.push(PaletteEntry {
        +                    label: "Themes →".into(),
        +                    action: Action::NavigateThemes,
        +                });
        +                self.palette.entries.push(PaletteEntry {
        +                    label: "Layouts →".into(),
        +                    action: Action::NavigateLayouts,
        +                });
        +                self.palette.entries.push(PaletteEntry {
        +                    label: "Toggle Fullscreen".into(),
        +                    action: Action::ToggleFullscreen,
        +                });
        +                self.palette.entries.push(PaletteEntry {
        +                    label: "Cycle Fullscreen Widget".into(),
        +                    action: Action::CycleFullscreen,
        +                });
        +                self.palette.entries.push(PaletteEntry {
        +                    label: "Search Processes".into(),
        +                    action: Action::Search,
        +                });
        +                self.palette.entries.push(PaletteEntry {
        +                    label: "Toggle Help".into(),
        +                    action: Action::ToggleHelp,
        +                });
        +                self.palette.entries.push(PaletteEntry {
        +                    label: format!("Sort: {}", self.process_sort.label()),
        +                    action: Action::SortByCpu,
        +                });
        +                self.palette.entries.push(PaletteEntry {
        +                    label: "Random Theme".into(),
        +                    action: Action::RandomTheme,
        +                });
        +                self.palette.entries.push(PaletteEntry {
        +                    label: "Exit".into(),
        +                    action: Action::Quit,
        +                });
        +            }
        +            PalettePage::Themes => {
        +                for (i, theme) in self.themes.iter().enumerate() {
        +                    self.palette.entries.push(PaletteEntry {
        +                        label: theme.name.clone(),
        +                        action: Action::SelectTheme(i),
        +                    });
        +                }
        +            }
        +            PalettePage::Layouts => {
        +                for (i, layout) in self.layout_defs.iter().enumerate() {
        +                    self.palette.entries.push(PaletteEntry {
        +                        label: layout.name.clone(),
        +                        action: Action::SelectLayout(i),
        +                    });
        +                }
        +            }
        +        }
        +        self.palette_filter();
        +    }
        +
        +    pub fn open_palette(&mut self) {
        +        self.palette.open = true;
        +        self.palette.query.clear();
        +        self.palette.selected = 0;
        +        self.palette.page = PalettePage::Main;
        +        self.rebuild_palette();
        +    }
        +
        +    pub fn palette_navigate_to(&mut self, page: PalettePage) {
        +        self.palette.page = page;
        +        self.palette.query.clear();
        +        self.palette.selected = 0;
        +        self.rebuild_palette();
        +    }
        +
        +    pub fn palette_filter(&mut self) {
        +        let q = self.palette.query.to_lowercase();
        +        self.palette.filtered = self
        +            .palette
        +            .entries
        +            .iter()
        +            .enumerate()
        +            .filter(|(_, e)| q.is_empty() || e.label.to_lowercase().contains(&q))
        +            .map(|(i, _)| i)
        +            .collect();
        +        if !self.palette.filtered.is_empty() {
        +            self.palette.selected = self.palette.selected.min(self.palette.filtered.len() - 1);
        +        } else {
        +            self.palette.selected = 0;
        +        }
        +    }
        +
        +    pub fn palette_select_next(&mut self) {
        +        if !self.palette.filtered.is_empty() {
        +            self.palette.selected = (self.palette.selected + 1) % self.palette.filtered.len();
        +        }
        +    }
        +
        +    pub fn palette_select_prev(&mut self) {
        +        if !self.palette.filtered.is_empty() {
        +            self.palette.selected = if self.palette.selected == 0 {
        +                self.palette.filtered.len() - 1
        +            } else {
        +                self.palette.selected - 1
        +            };
        +        }
        +    }
        +
        +    pub fn palette_selected_action(&self) -> Option {
        +        self.palette
        +            .filtered
        +            .get(self.palette.selected)
        +            .and_then(|&i| self.palette.entries.get(i))
        +            .map(|e| e.action.clone())
        +    }
        +
        +    pub fn close_palette(&mut self) {
        +        self.palette.open = false;
        +        self.palette.page = PalettePage::Main;
        +        self.input_mode = InputMode::Normal;
        +    }
        +}
        diff --git a/src/state/widget_state.rs b/src/state/widget_state.rs
        new file mode 100644
        index 0000000..4b29b67
        --- /dev/null
        +++ b/src/state/widget_state.rs
        @@ -0,0 +1,102 @@
        +//! Kernel implementation of the widget renderer contract
        +//! ([`xtop_widget_api::WidgetState`]).
        +//!
        +//! This is the single door widget packs cross: they render against this view
        +//! and never touch kernel types.
        +
        +use crate::state::app::AppState;
        +use crate::state::view::{FullScreenWidget, InputMode};
        +use xtop_plugin_api::model::{ProcessInfo, SystemSnapshot};
        +
        +impl xtop_widget_api::WidgetState for AppState {
        +    fn snapshot(&self) -> Option<&SystemSnapshot> {
        +        self.snapshot_cache()
        +    }
        +
        +    fn theme_name(&self) -> &str {
        +        &self.current_theme.name
        +    }
        +
        +    fn theme_fg(&self) -> &[u8; 3] {
        +        self.current_theme.fg()
        +    }
        +
        +    fn theme_bg(&self) -> &[u8; 3] {
        +        self.current_theme.bg()
        +    }
        +
        +    fn theme_palette(&self) -> &[[u8; 3]; 16] {
        +        &self.current_theme.palette
        +    }
        +
        +    fn alerts(&self) -> xtop_plugin_api::AlertThresholds {
        +        xtop_plugin_api::AlertThresholds {
        +            cpu_high: self.alerts.cpu_high,
        +            mem_high: self.alerts.mem_high,
        +            disk_high: self.alerts.disk_high,
        +        }
        +    }
        +
        +    fn charset(&self, widget: &str) -> xtop_widget_api::ChartCharset {
        +        self.style.charset_for(widget)
        +    }
        +
        +    fn borders(&self, widget: &str) -> xtop_widget_api::WidgetBorders {
        +        self.style.borders_for(widget)
        +    }
        +
        +    fn cpu_history(&self) -> &[std::collections::VecDeque<(f64, f64)>] {
        +        &self.history.cpu
        +    }
        +
        +    fn mem_history(&self) -> &std::collections::VecDeque<(f64, f64)> {
        +        &self.history.mem
        +    }
        +
        +    fn net_rx_history(&self) -> &std::collections::VecDeque<(f64, f64)> {
        +        &self.history.net_rx
        +    }
        +
        +    fn net_tx_history(&self) -> &std::collections::VecDeque<(f64, f64)> {
        +        &self.history.net_tx
        +    }
        +
        +    fn search_query(&self) -> &str {
        +        &self.search_query
        +    }
        +
        +    fn process_selected_pid(&self) -> Option {
        +        self.process_selected_pid
        +    }
        +
        +    fn process_sort_label(&self) -> &str {
        +        self.process_sort.label()
        +    }
        +
        +    fn layout_name(&self) -> &str {
        +        self.current_layout_name()
        +    }
        +
        +    fn is_searching(&self) -> bool {
        +        self.input_mode == InputMode::Searching
        +    }
        +
        +    fn fullscreen_label(&self) -> Option<&str> {
        +        if self.full_screen_widget == FullScreenWidget::None {
        +            None
        +        } else {
        +            Some(self.full_screen_widget.label())
        +        }
        +    }
        +
        +    fn sys_info(&self) -> xtop_plugin_api::SystemInfo {
        +        self.sys_info.clone()
        +    }
        +
        +    fn process_view(&self) -> Vec<&ProcessInfo> {
        +        let Some(snap) = self.snapshot_cache() else {
        +            return Vec::new();
        +        };
        +        self.sorted_processes(snap)
        +    }
        +}
        diff --git a/src/theme/loader.rs b/src/theme/loader.rs
        index 45a585c..2b96e77 100644
        --- a/src/theme/loader.rs
        +++ b/src/theme/loader.rs
        @@ -1,3 +1,4 @@
        +//! Theme loading: embedded default theme and user themes.
         use crate::theme::Theme;
         use std::fs;
         use std::path::Path;
        @@ -93,16 +94,17 @@ pub fn themes_dir() -> std::path::PathBuf {
         }
         
         pub fn load_all_themes() -> Vec {
        +    // Defaults first (index 0 = "x", stable palette position), then user
        +    // files: a user theme reusing a default name overrides it in place
        +    // (parity with layouts); new names are appended.
             let mut themes = vec![default_theme()];
        -
        -    let user_dir = themes_dir();
        -    let custom = load_themes_from_dir(&user_dir);
        -    for t in custom {
        -        if !themes.iter().any(|existing| existing.name == t.name) {
        +    for t in load_themes_from_dir(&themes_dir()) {
        +        if let Some(slot) = themes.iter_mut().find(|existing| existing.name == t.name) {
        +            *slot = t;
        +        } else {
                     themes.push(t);
                 }
             }
        -
             themes
         }
         
        diff --git a/src/theme/model.rs b/src/theme/model.rs
        index e7e4791..4dd5a57 100644
        --- a/src/theme/model.rs
        +++ b/src/theme/model.rs
        @@ -1,3 +1,4 @@
        +//! Theme data model and its custom (hex-string) deserializer.
         use serde::de::{self, Deserializer, MapAccess, Visitor};
         use serde::{Deserialize, Serialize};
         use std::fmt;
        @@ -79,6 +80,6 @@ impl<'de> Deserialize<'de> for Theme {
             }
         }
         
        -pub fn hex_to_rgb_pub(hex: &str) -> [u8; 3] {
        +pub(crate) fn hex_to_rgb_pub(hex: &str) -> [u8; 3] {
             xtop_plugin_api::hex_to_rgb(hex)
         }
        diff --git a/src/ui/layout/engine.rs b/src/ui/layout/engine.rs
        index dfeed2a..f4091ba 100644
        --- a/src/ui/layout/engine.rs
        +++ b/src/ui/layout/engine.rs
        @@ -1,51 +1,101 @@
        -use crate::layout::{Direction, LayoutArea, LayoutDef, LayoutNode};
        +//! Layout render engine: splits rects and dispatches widget renderers.
        +//!
        +//! Widgets live in packs (see the `widgets` repo); the kernel resolves
        +//! `(pack, name)` at render time. Plugin widgets keep precedence over packs
        +//! and can replace any name.
        +
         use crate::state::AppState;
        -use crate::ui::widgets::{battery, cpu, disk_io, gpu, header, memory, network, processes, storage};
         use ratatui::layout::{Constraint, Layout, Rect};
         use ratatui::Frame;
         use std::collections::HashMap;
        -use std::sync::Arc;
        +use std::sync::{Arc, OnceLock};
        +use xtop_layout::{Direction, LayoutArea, LayoutConstraint, LayoutDef, LayoutNode};
         use xtop_plugin_api::HostState;
        +use xtop_widget_api::WidgetRenderer;
         
        -/// A widget renderer: a callable that draws a widget onto the terminal.
        -///
        -/// Built-in widgets receive the concrete [`AppState`].
        -pub type WidgetFn = Arc;
        -
        -/// A plugin widget renderer.
        -///
        -/// Plugin widgets only see the API contract ([`HostState`]), never kernel
        -/// types. The kernel coerces its state at the call site.
        +/// A plugin widget renderer (plugins see only the API contract).
         pub type PluginWidgetFn = Arc;
         
        -/// Create the default built-in widget map.
        -pub fn default_widgets() -> HashMap<&'static str, WidgetFn> {
        -    let mut m: HashMap<&'static str, WidgetFn> = HashMap::new();
        -    m.insert("header", Arc::new(header::render));
        -    m.insert("cpu", Arc::new(cpu::render));
        -    m.insert("memory", Arc::new(memory::render));
        -    m.insert("storage", Arc::new(storage::render));
        -    m.insert("network", Arc::new(network::render));
        -    m.insert("processes", Arc::new(processes::render));
        -    m.insert("disk_io", Arc::new(disk_io::render));
        -    m.insert("battery", Arc::new(battery::render));
        -    m.insert("gpu", Arc::new(gpu::render));
        -    m
        +/// One compiled-in widget pack.
        +struct Pack {
        +    name: &'static str,
        +    renderers: &'static HashMap<&'static str, WidgetRenderer>,
        +}
        +
        +static BASE_PACK: OnceLock> = OnceLock::new();
        +#[cfg(feature = "widget-blocks")]
        +static BLOCKS_PACK: OnceLock> = OnceLock::new();
        +
        +/// The packs compiled into this binary, in precedence order.
        +fn packs() -> &'static [Pack] {
        +    static PACKS: OnceLock> = OnceLock::new();
        +    PACKS.get_or_init(|| {
        +        // `mut` is only used when the blocks pack is compiled in.
        +        #[cfg_attr(not(feature = "widget-blocks"), allow(unused_mut))]
        +        let mut v = vec![Pack {
        +            name: "default",
        +            renderers: BASE_PACK.get_or_init(xtop_widgets::registry),
        +        }];
        +        #[cfg(feature = "widget-blocks")]
        +        v.push(Pack {
        +            name: "blocks",
        +            renderers: BLOCKS_PACK.get_or_init(xtop_widget_blocks::registry),
        +        });
        +        v
        +    })
        +}
        +
        +/// Resolve the renderer for a widget name following the user's pack choice
        +/// (`style.pack` global or per-widget `style.widgets..pack`). Unknown
        +/// packs and names gracefully fall back to the base pack.
        +fn resolve(state: &AppState, name: &str) -> Option<&'static WidgetRenderer> {
        +    let packs = packs();
        +    let chosen = state.style.pack_for(name);
        +    if let Some(pack_name) = chosen {
        +        if let Some(pack) = packs.iter().find(|p| p.name == pack_name) {
        +            if let Some(r) = pack.renderers.get(name) {
        +                return Some(r);
        +            }
        +        }
        +    }
        +    packs
        +        .iter()
        +        .find(|p| p.name == "default")
        +        .and_then(|p| p.renderers.get(name))
         }
         
         /// Render a layout definition within a given area.
         ///
        -/// `widgets` is the built-in registry. `plugin_widgets` is an optional
        -/// extension from plugins. Plugin widgets take precedence over built-ins.
        +/// `plugin_widgets` is an optional extension from plugins; plugin widgets
        +/// take precedence over every pack.
         pub fn render_layout(
             f: &mut Frame,
             state: &AppState,
             area: Rect,
             def: &LayoutDef,
        -    widgets: &HashMap<&'static str, WidgetFn>,
             plugin_widgets: &HashMap,
         ) {
        -    render_node(f, state, area, &def.root, widgets, plugin_widgets);
        +    render_node(f, state, area, &def.root, plugin_widgets);
        +}
        +
        +/// Render a single named widget (used by fullscreen and minimal views).
        +/// Returns false when no renderer is registered for the name.
        +pub fn render_named(
        +    f: &mut Frame,
        +    state: &AppState,
        +    name: &str,
        +    area: Rect,
        +    plugin_widgets: &HashMap,
        +) -> bool {
        +    if let Some(render_fn) = plugin_widgets.get(name) {
        +        render_fn(f, state, area);
        +        return true;
        +    }
        +    if let Some(render_fn) = resolve(state, name) {
        +        render_fn(f, state, area);
        +        return true;
        +    }
        +    false
         }
         
         fn render_node(
        @@ -53,18 +103,11 @@ fn render_node(
             state: &AppState,
             area: Rect,
             node: &LayoutNode,
        -    widgets: &HashMap<&'static str, WidgetFn>,
             plugin_widgets: &HashMap,
         ) {
             match node {
                 LayoutNode::Widget { name } => {
        -            // Plugin widgets take precedence
        -            if let Some(render_fn) = plugin_widgets.get(name) {
        -                render_fn(f, state, area);
        -            } else if let Some(render_fn) = widgets.get(name.as_str()) {
        -                render_fn(f, state, area);
        -            }
        -            // Unknown widgets are silently ignored (backward-compatible)
        +            render_named(f, state, name, area, plugin_widgets);
                 }
                 LayoutNode::Split { direction, areas } => {
                     if areas.is_empty() {
        @@ -81,7 +124,7 @@ fn render_node(
                         .split(area);
                     for (i, chunk) in chunks.iter().enumerate() {
                         if i < areas.len() {
        -                    render_node(f, state, *chunk, &areas[i].node, widgets, plugin_widgets);
        +                    render_node(f, state, *chunk, &areas[i].node, plugin_widgets);
                         }
                     }
                 }
        @@ -90,8 +133,8 @@ fn render_node(
         
         fn to_ratatui_constraint(area: &LayoutArea) -> Constraint {
             match area.constraint {
        -        crate::layout::LayoutConstraint::Length(n) => Constraint::Length(n),
        -        crate::layout::LayoutConstraint::Percentage(p) => Constraint::Percentage(p),
        -        crate::layout::LayoutConstraint::Fill => Constraint::Fill(1),
        +        LayoutConstraint::Length(n) => Constraint::Length(n),
        +        LayoutConstraint::Percentage(p) => Constraint::Percentage(p),
        +        LayoutConstraint::Fill => Constraint::Fill(1),
             }
         }
        diff --git a/src/ui/mod.rs b/src/ui/mod.rs
        index 42fd9a5..4263d58 100644
        --- a/src/ui/mod.rs
        +++ b/src/ui/mod.rs
        @@ -1,16 +1,17 @@
        -//! UI area of xtop: terminal setup, screen composition and widgets.
        +//! UI area of xtop: terminal setup, screen composition, overlays and the
        +//! widget engine.
         //!
         //! - [`terminal`]      terminal backend lifecycle (raw mode, alternate screen)
         //! - [`screen`]        top-level composition (fullscreen, minimal, layout)
        -//! - [`layout`]        layout engine + built-in widget registry
        -//! - [`widgets`]       one folder per widget
        -//! - [`share`]         UI-wide shared logic (colors, formatting, errors)
        +//! - [`layout`]        layout engine: rect split + widget pack resolution
        +//! - [`overlay`]       kernel-owned UI chrome (help, command palette)
        +//! - [`share`]         UI-wide shared logic (colors, formatting)
         
         pub mod layout;
        +pub mod overlay;
         pub mod screen;
         pub mod share;
         pub mod terminal;
        -pub mod widgets;
         
         pub use screen::*;
         pub use terminal::*;
        diff --git a/src/ui/widgets/help/mod.rs b/src/ui/overlay/help/mod.rs
        similarity index 51%
        rename from src/ui/widgets/help/mod.rs
        rename to src/ui/overlay/help/mod.rs
        index cfeb56f..a7cd68f 100644
        --- a/src/ui/widgets/help/mod.rs
        +++ b/src/ui/overlay/help/mod.rs
        @@ -1,4 +1,7 @@
         //! Help widget: keybinding reference overlay.
        +//!
        +//! Built from the *live* `Keybindings` (config-driven), so remapped keys are
        +//! always reflected here.
         
         use crate::state::AppState;
         use crate::ui::share::to_color;
        @@ -10,8 +13,10 @@ use ratatui::Frame;
         pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
             let fg = to_color(state.current_theme.fg());
             let bg = to_color(state.current_theme.bg());
        +    let accent = to_color(&state.current_theme.palette[6]);
        +    let kb = &state.keybindings;
         
        -    let text = vec![
        +    let mut text = vec![
                 Line::from(""),
                 Line::from(vec![Span::styled(
                     "  Keybindings",
        @@ -19,17 +24,32 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
                 )]),
                 Line::from(""),
                 Line::from("  ─────────────────────────────────────────────"),
        -        Line::from("  q            Quit application"),
        -        Line::from("  ?            Toggle this help screen"),
        -        Line::from(""),
        -        Line::from("  t            Next color theme"),
        -        Line::from("  T            Previous color theme"),
        -        Line::from("  l            Next layout mode"),
        -        Line::from("  f            Toggle fullscreen widget"),
        -        Line::from("  F            Cycle fullscreen widget"),
        +    ];
        +    push_key(&mut text, "Quit", &kb.quit, accent);
        +    push_key(&mut text, "Help", &kb.help, accent);
        +    text.push(Line::from(""));
        +    push_key(&mut text, "Next theme", &kb.next_theme, accent);
        +    push_key(&mut text, "Previous theme", &kb.prev_theme, accent);
        +    push_key(&mut text, "Next layout", &kb.next_layout, accent);
        +    push_key(
        +        &mut text,
        +        "Toggle fullscreen",
        +        &kb.toggle_fullscreen,
        +        accent,
        +    );
        +    push_key(&mut text, "Cycle fullscreen", &kb.cycle_fullscreen, accent);
        +    text.push(Line::from(""));
        +    push_key(&mut text, "Search processes", &kb.search, accent);
        +    push_key(&mut text, "Command palette", &kb.command_palette, accent);
        +    push_key(&mut text, "Cancel", &kb.cancel, accent);
        +    text.push(Line::from(""));
        +    push_key(&mut text, "Kill process", &kb.kill_process, accent);
        +    push_key(&mut text, "Select up", &kb.process_up, accent);
        +    push_key(&mut text, "Select down", &kb.process_down, accent);
        +    push_key(&mut text, "Cycle sort", &kb.cycle_sort, accent);
        +    text.extend([
                 Line::from(""),
        -        Line::from("  /            Search/filter processes"),
        -        Line::from("  Esc          Cancel search / close help"),
        +        Line::from("  ─────────────────────────────────────────────"),
                 Line::from(""),
                 Line::from("  Layout modes:"),
                 Line::from(format!("    Current: {}", state.current_layout_name())),
        @@ -37,11 +57,8 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
                 Line::from("    Memory Focus | Network Focus | Process Focus"),
                 Line::from("    + custom layouts from ~/.config/xtop/layouts/"),
                 Line::from(""),
        -        Line::from("  ─────────────────────────────────────────────"),
        -        Line::from(""),
                 Line::from("  https://github.com/xtop-cli/xtop"),
        -        Line::from(""),
        -    ];
        +    ]);
         
             let block = Block::default()
                 .title("Help")
        @@ -54,3 +71,15 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
                 .wrap(Wrap { trim: false });
             f.render_widget(p, area);
         }
        +
        +fn push_key(text: &mut Vec>, action: &str, keys: &[String], accent: Color) {
        +    let rendered = if keys.is_empty() {
        +        "(unbound)".to_string()
        +    } else {
        +        keys.join(", ")
        +    };
        +    text.push(Line::from(vec![
        +        Span::styled(format!("  {rendered:<12}"), Style::default().fg(accent)),
        +        Span::raw(action.to_string()),
        +    ]));
        +}
        diff --git a/src/ui/overlay/mod.rs b/src/ui/overlay/mod.rs
        new file mode 100644
        index 0000000..6c3a8e4
        --- /dev/null
        +++ b/src/ui/overlay/mod.rs
        @@ -0,0 +1,7 @@
        +//! Kernel overlay widgets (help screen and command palette).
        +//!
        +//! These are UI chrome owned by the kernel — unlike the data widgets, which
        +//! live in external packs (`xtop-widgets` and friends).
        +
        +pub mod help;
        +pub mod palette;
        diff --git a/src/ui/widgets/palette/mod.rs b/src/ui/overlay/palette/mod.rs
        similarity index 100%
        rename from src/ui/widgets/palette/mod.rs
        rename to src/ui/overlay/palette/mod.rs
        diff --git a/src/ui/screen.rs b/src/ui/screen.rs
        index 192fed0..ad9f5d6 100644
        --- a/src/ui/screen.rs
        +++ b/src/ui/screen.rs
        @@ -1,23 +1,20 @@
        -use crate::layout::{detect_effective_layout, EffectiveLayout};
        +//! Screen renderer: dispatches to layouts, fullscreen, overlays and the
        +//! minimal view. Data widgets are drawn through the widget packs resolved by
        +//! the engine; overlays (help/palette) are kernel-owned.
        +
         use crate::state::{AppState, FullScreenWidget, InputMode};
        -use crate::ui::layout::{default_widgets, render_layout, PluginWidgetFn, WidgetFn};
        +use crate::ui::layout::{render_layout, render_named, PluginWidgetFn};
        +use crate::ui::overlay::{help, palette};
         use crate::ui::share::to_color;
        -use crate::ui::widgets::*;
         use ratatui::prelude::*;
         use ratatui::Frame;
         use std::collections::HashMap;
        -use std::sync::OnceLock;
        -
        -/// Built-in widgets (lazily initialized).
        -fn widgets() -> &'static HashMap<&'static str, WidgetFn> {
        -    static WIDGETS: OnceLock> = OnceLock::new();
        -    WIDGETS.get_or_init(default_widgets)
        -}
        +use xtop_layout::{detect_effective_layout, EffectiveLayout};
         
         /// Build a plugin widget lookup map from AppState.
         ///
        -/// Plugin renderers only see [`HostState`](xtop_plugin_api::HostState), which
        -/// the layout engine provides by coercing `state`.
        +/// Plugin renderers only see [`HostState`](xtop_plugin_api::HostState);
        +/// they keep precedence over every pack.
         fn plugin_widgets(state: &AppState) -> HashMap {
             let mut map: HashMap = HashMap::new();
             for reg in &state.plugin_widgets {
        @@ -51,7 +48,7 @@ pub fn render(f: &mut Frame, state: &AppState) {
                 render_minimal(f, state, area);
             } else {
                 let def = state.current_layout();
        -        render_layout(f, state, area, def, widgets(), &pw);
        +        render_layout(f, state, area, def, &pw);
             }
         
             if state.input_mode == InputMode::Searching {
        @@ -94,17 +91,28 @@ fn render_fullscreen(f: &mut Frame, state: &AppState, area: Rect) {
                 .direction(Direction::Vertical)
                 .constraints([Constraint::Length(3), Constraint::Min(0)])
                 .split(area);
        -    header::render(f, state, chunks[0]);
        -    match state.full_screen_widget {
        -        FullScreenWidget::Cpu => cpu::render(f, state, chunks[1]),
        -        FullScreenWidget::Memory => memory::render(f, state, chunks[1]),
        -        FullScreenWidget::Storage => storage::render(f, state, chunks[1]),
        -        FullScreenWidget::Network => network::render(f, state, chunks[1]),
        -        FullScreenWidget::Processes => processes::render(f, state, chunks[1]),
        -        FullScreenWidget::DiskIO => disk_io::render(f, state, chunks[1]),
        -        FullScreenWidget::Gpu => gpu::render(f, state, chunks[1]),
        -        FullScreenWidget::Battery => battery::render(f, state, chunks[1]),
        -        FullScreenWidget::None => {}
        +    let pw = plugin_widgets(state);
        +    render_named(f, state, "header", chunks[0], &pw);
        +    let name = fullscreen_widget_name(state.full_screen_widget);
        +    if !render_named(f, state, name, chunks[1], &pw) {
        +        let text = format!("No widget registered for '{name}'");
        +        let fg = to_color(state.current_theme.fg());
        +        let p = ratatui::widgets::Paragraph::new(text).style(Style::default().fg(fg));
        +        f.render_widget(p, chunks[1]);
        +    }
        +}
        +
        +fn fullscreen_widget_name(w: FullScreenWidget) -> &'static str {
        +    match w {
        +        FullScreenWidget::Cpu => "cpu",
        +        FullScreenWidget::Memory => "memory",
        +        FullScreenWidget::Storage => "storage",
        +        FullScreenWidget::Network => "network",
        +        FullScreenWidget::Processes => "processes",
        +        FullScreenWidget::DiskIO => "disk_io",
        +        FullScreenWidget::Gpu => "gpu",
        +        FullScreenWidget::Battery => "battery",
        +        FullScreenWidget::None => "cpu",
             }
         }
         
        @@ -123,9 +131,12 @@ fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) {
                 ])
                 .split(area);
         
        -    header::render(f, state, chunks[0]);
        +    let pw = plugin_widgets(state);
        +    render_named(f, state, "header", chunks[0], &pw);
         
        -    let snap = state.snapshot();
        +    let Some(snap) = state.snapshot_cache() else {
        +        return;
        +    };
             let cpu_pct = snap.cpus.first().map(|c| c.usage).unwrap_or(0.0);
             let cpu_text = format!(
                 "CPU: {:>3.0}%  |  Mem: {:.1}/{:.1}G ({:>3.0}%)",
        @@ -161,5 +172,5 @@ fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) {
                 .label(mem_text);
             f.render_widget(mem_gauge, chunks[2]);
         
        -    processes::render(f, state, chunks[3]);
        +    render_named(f, state, "processes", chunks[3], &pw);
         }
        diff --git a/src/ui/share/color.rs b/src/ui/share/color.rs
        index 6cfd77e..3e6282f 100644
        --- a/src/ui/share/color.rs
        +++ b/src/ui/share/color.rs
        @@ -1,19 +1,7 @@
        +//! Color conversion helpers used by kernel UI chrome (overlays, minimal view).
        +
         use ratatui::prelude::Color;
         
         pub fn to_color(c: &[u8; 3]) -> Color {
             Color::Rgb(c[0], c[1], c[2])
         }
        -
        -/// Returns a palette index for gauge color based on percentage:
        -/// - <50%  → green (2)
        -/// - 50–79% → yellow (3)
        -/// - ≥80%  → red (1)
        -pub fn gauge_gradient(pct: f64, alert_at: f64) -> usize {
        -    if pct >= alert_at {
        -        1
        -    } else if pct >= 50.0 {
        -        3
        -    } else {
        -        2
        -    }
        -}
        diff --git a/src/ui/share/format.rs b/src/ui/share/format.rs
        deleted file mode 100644
        index d3036f2..0000000
        --- a/src/ui/share/format.rs
        +++ /dev/null
        @@ -1,81 +0,0 @@
        -pub fn format_bytes(bytes: u64) -> String {
        -    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
        -    let mut size = bytes as f64;
        -    let mut unit_idx = 0;
        -    while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
        -        size /= 1024.0;
        -        unit_idx += 1;
        -    }
        -    format!("{:.2} {}", size, UNITS[unit_idx])
        -}
        -
        -pub fn format_uptime(secs: u64) -> String {
        -    let days = secs / 86400;
        -    let hours = (secs % 86400) / 3600;
        -    let minutes = (secs % 3600) / 60;
        -    let seconds = secs % 60;
        -    format!("{}d {}h {}m {}s", days, hours, minutes, seconds)
        -}
        -
        -#[cfg(test)]
        -mod tests {
        -    use super::*;
        -
        -    #[test]
        -    fn test_format_bytes_bytes() {
        -        assert_eq!(format_bytes(0), "0.00 B");
        -        assert_eq!(format_bytes(500), "500.00 B");
        -    }
        -
        -    #[test]
        -    fn test_format_bytes_kb() {
        -        assert_eq!(format_bytes(1024), "1.00 KB");
        -        assert_eq!(format_bytes(2048), "2.00 KB");
        -        assert_eq!(format_bytes(1536), "1.50 KB");
        -    }
        -
        -    #[test]
        -    fn test_format_bytes_mb() {
        -        assert_eq!(format_bytes(1048576), "1.00 MB");
        -        assert_eq!(format_bytes(3145728), "3.00 MB");
        -    }
        -
        -    #[test]
        -    fn test_format_bytes_gb() {
        -        assert_eq!(format_bytes(1073741824), "1.00 GB");
        -        let two_gb = 2u64 * 1024 * 1024 * 1024;
        -        assert_eq!(format_bytes(two_gb), "2.00 GB");
        -    }
        -
        -    #[test]
        -    fn test_format_bytes_tb() {
        -        let one_tb = 1024u64 * 1024 * 1024 * 1024;
        -        assert_eq!(format_bytes(one_tb), "1.00 TB");
        -    }
        -
        -    #[test]
        -    fn test_format_uptime_zero() {
        -        assert_eq!(format_uptime(0), "0d 0h 0m 0s");
        -    }
        -
        -    #[test]
        -    fn test_format_uptime_full() {
        -        let secs = 1 + 60 * 2 + 3600 * 3 + 86400 * 4; // 4d 3h 2m 1s
        -        assert_eq!(format_uptime(secs), "4d 3h 2m 1s");
        -    }
        -
        -    #[test]
        -    fn test_format_uptime_seconds_only() {
        -        assert_eq!(format_uptime(59), "0d 0h 0m 59s");
        -    }
        -
        -    #[test]
        -    fn test_format_uptime_exact_hour() {
        -        assert_eq!(format_uptime(3600), "0d 1h 0m 0s");
        -    }
        -
        -    #[test]
        -    fn test_format_uptime_exact_day() {
        -        assert_eq!(format_uptime(86400), "1d 0h 0m 0s");
        -    }
        -}
        diff --git a/src/ui/share/mod.rs b/src/ui/share/mod.rs
        index fa9bfa2..3742478 100644
        --- a/src/ui/share/mod.rs
        +++ b/src/ui/share/mod.rs
        @@ -1,10 +1,8 @@
        -//! UI-wide shared logic used by multiple widgets.
        +//! UI-wide shared logic used by overlays and the screen.
         //!
        -//! Widgets never reach outside `share` for rendering helpers; screen-level
        -//! error handling also belongs here when it grows.
        +//! Data widgets draw with helpers from their own pack; the kernel keeps only
        +//! what its own chrome needs.
         
         mod color;
        -mod format;
         
         pub use color::*;
        -pub use format::*;
        diff --git a/src/ui/terminal.rs b/src/ui/terminal.rs
        index 37df6e7..22ab9b5 100644
        --- a/src/ui/terminal.rs
        +++ b/src/ui/terminal.rs
        @@ -1,5 +1,6 @@
        +//! Terminal lifecycle: raw mode, alternate screen, mouse and paste capture.
         use crossterm::{
        -    event::{DisableMouseCapture, EnableMouseCapture},
        +    event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
             execute,
             terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
         };
        @@ -10,13 +11,23 @@ use std::panic;
         pub type Tui = Terminal>;
         
         pub fn init() -> io::Result {
        -    execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
        +    execute!(
        +        io::stdout(),
        +        EnterAlternateScreen,
        +        EnableMouseCapture,
        +        EnableBracketedPaste
        +    )?;
             enable_raw_mode()?;
             Terminal::new(CrosstermBackend::new(io::stdout()))
         }
         
         pub fn restore() -> io::Result<()> {
        -    execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
        +    execute!(
        +        io::stdout(),
        +        LeaveAlternateScreen,
        +        DisableMouseCapture,
        +        DisableBracketedPaste
        +    )?;
             disable_raw_mode()?;
             Ok(())
         }
        diff --git a/src/ui/widgets/battery/mod.rs b/src/ui/widgets/battery/mod.rs
        deleted file mode 100644
        index 7a2d659..0000000
        --- a/src/ui/widgets/battery/mod.rs
        +++ /dev/null
        @@ -1,63 +0,0 @@
        -//! Battery widget: charge, status and health.
        -
        -use crate::state::AppState;
        -use crate::ui::share::to_color;
        -use ratatui::prelude::*;
        -use ratatui::symbols::border;
        -use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap};
        -use ratatui::Frame;
        -
        -pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
        -    let fg = to_color(state.current_theme.fg());
        -    let bg = to_color(state.current_theme.bg());
        -
        -    let block = Block::default()
        -        .title("Battery")
        -        .borders(Borders::ALL)
        -        .border_set(border::ROUNDED)
        -        .style(Style::default().fg(fg).bg(bg));
        -    let inner = block.inner(area);
        -    f.render_widget(block, area);
        -
        -    let snap = state.snapshot();
        -    if snap.batteries.is_empty() {
        -        let msg = Paragraph::new("No battery data available")
        -            .style(Style::default().fg(fg))
        -            .wrap(Wrap { trim: true });
        -        f.render_widget(msg, inner);
        -        return;
        -    }
        -
        -    let chunks = Layout::default()
        -        .direction(Direction::Vertical)
        -        .constraints(vec![Constraint::Length(3); snap.batteries.len()])
        -        .split(inner);
        -
        -    for (i, bat) in snap.batteries.iter().enumerate() {
        -        if i >= chunks.len() {
        -            break;
        -        }
        -        let time_info = match (bat.time_to_full, bat.time_to_empty) {
        -            (Some(t), _) if bat.state == "Charging" => {
        -                format!(" {}m to full", t / 60)
        -            }
        -            (_, Some(t)) if bat.state == "Discharging" => {
        -                format!(" {}m remaining", t / 60)
        -            }
        -            _ => String::new(),
        -        };
        -        let label = format!(
        -            "{}  {:>3.0}%  {} {}",
        -            bat.name, bat.percentage, bat.state, time_info,
        -        );
        -        let gauge = Gauge::default()
        -            .gauge_style(
        -                Style::default()
        -                    .fg(to_color(&state.current_theme.palette[2]))
        -                    .bg(bg),
        -            )
        -            .percent(bat.percentage as u16)
        -            .label(label);
        -        f.render_widget(gauge, chunks[i]);
        -    }
        -}
        diff --git a/src/ui/widgets/cpu/mod.rs b/src/ui/widgets/cpu/mod.rs
        deleted file mode 100644
        index 92953e2..0000000
        --- a/src/ui/widgets/cpu/mod.rs
        +++ /dev/null
        @@ -1,141 +0,0 @@
        -//! CPU widget: per-core usage bars and temperature.
        -
        -use crate::state::AppState;
        -use crate::ui::share::{gauge_gradient, to_color};
        -use ratatui::prelude::*;
        -use ratatui::symbols::border;
        -use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Gauge, GraphType};
        -use ratatui::Frame;
        -
        -pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
        -    let fg = to_color(state.current_theme.fg());
        -    let bg = to_color(state.current_theme.bg());
        -    let snap = state.snapshot();
        -
        -    let title = if snap.cpu_temp > 0.0 {
        -        format!("CPU (Max: {:.1}°C)", snap.cpu_temp)
        -    } else {
        -        "CPU".to_string()
        -    };
        -
        -    let block = Block::default()
        -        .title(title)
        -        .borders(Borders::ALL)
        -        .border_set(border::ROUNDED)
        -        .style(Style::default().fg(fg).bg(bg));
        -    let inner = block.inner(area);
        -    f.render_widget(block, area);
        -
        -    if snap.cpus.is_empty() {
        -        return;
        -    }
        -
        -    let count = snap.cpus.len();
        -    let cols = if inner.width > 40 { 2 } else { 1 };
        -    let col_constraints = if cols == 2 {
        -        vec![Constraint::Percentage(50); 2]
        -    } else {
        -        vec![Constraint::Percentage(100)]
        -    };
        -    let col_areas = Layout::default()
        -        .direction(Direction::Horizontal)
        -        .constraints(col_constraints)
        -        .split(inner);
        -
        -    let per_col = count.div_ceil(cols);
        -    let chart_avail = inner.height > per_col as u16 + 4;
        -
        -    // Render all core gauges
        -    for (col_idx, col_area) in col_areas.iter().enumerate() {
        -        let start = col_idx * per_col;
        -        let end = (start + per_col).min(count);
        -
        -        let rows = Layout::default()
        -            .direction(Direction::Vertical)
        -            .constraints(vec![Constraint::Length(1); end - start])
        -            .split(*col_area);
        -
        -        for (i, row_area) in rows.iter().enumerate() {
        -            let cpu_idx = start + i;
        -            if cpu_idx >= count {
        -                break;
        -            }
        -            let cpu = &snap.cpus[cpu_idx];
        -            let usage = cpu.usage;
        -            let color_idx = if usage > state.alerts.cpu_high {
        -                1
        -            } else {
        -                gauge_gradient(usage, state.alerts.cpu_high)
        -            };
        -            let label = format!("CPU{:<2} {:>3.0}%", cpu.cpu_id, usage);
        -            let gauge = Gauge::default()
        -                .gauge_style(
        -                    Style::default()
        -                        .fg(to_color(&state.current_theme.palette[color_idx]))
        -                        .bg(bg),
        -                )
        -                .percent(usage as u16)
        -                .label(label);
        -            f.render_widget(gauge, *row_area);
        -        }
        -    }
        -
        -    // Aggregate CPU chart below gauges
        -    if chart_avail {
        -        let gauge_height = per_col as u16; // min height for gauges in one column
        -        let chart_area = Layout::default()
        -            .direction(Direction::Vertical)
        -            .constraints([Constraint::Length(gauge_height), Constraint::Min(0)])
        -            .split(inner)
        -            .last()
        -            .copied()
        -            .unwrap_or(inner);
        -
        -        let max_len = state.history.cpu.iter().map(|h| h.len()).max().unwrap_or(0);
        -        if max_len > 1 {
        -            let mut avg: Vec<(f64, f64)> = Vec::new();
        -            for tick in 0..max_len {
        -                let mut sum = 0.0;
        -                let mut n = 0;
        -                for core_hist in &state.history.cpu {
        -                    if tick < core_hist.len() {
        -                        sum += core_hist[tick].1;
        -                        n += 1;
        -                    }
        -                }
        -                if n > 0 {
        -                    let x = state.history.cpu[0]
        -                        .get(tick)
        -                        .map(|&(x, _)| x)
        -                        .unwrap_or(0.0);
        -                    avg.push((x, sum / n as f64));
        -                }
        -            }
        -
        -            let datasets = vec![Dataset::default()
        -                .name("CPU Avg")
        -                .marker(symbols::Marker::Braille)
        -                .graph_type(GraphType::Line)
        -                .style(Style::default().fg(to_color(&state.current_theme.palette[1])))
        -                .data(&avg)];
        -
        -            let x_min = avg.first().map(|&(x, _)| x).unwrap_or(0.0);
        -            let x_max = avg.last().map(|&(x, _)| x).unwrap_or(100.0);
        -            let x_max = x_max.max(x_min + 1.0);
        -
        -            let chart = Chart::new(datasets)
        -                .block(Block::default().borders(Borders::TOP))
        -                .x_axis(
        -                    Axis::default()
        -                        .bounds([x_min, x_max])
        -                        .labels(vec![Span::raw("")]),
        -                )
        -                .y_axis(Axis::default().bounds([0.0, 100.0]).labels(vec![
        -                    Span::raw("0%"),
        -                    Span::raw("50%"),
        -                    Span::raw("100%"),
        -                ]));
        -            f.render_widget(chart, chart_area);
        -        }
        -    }
        -}
        diff --git a/src/ui/widgets/disk_io/mod.rs b/src/ui/widgets/disk_io/mod.rs
        deleted file mode 100644
        index 833e163..0000000
        --- a/src/ui/widgets/disk_io/mod.rs
        +++ /dev/null
        @@ -1,87 +0,0 @@
        -//! Disk I/O widget: read/write throughput.
        -
        -use crate::state::AppState;
        -use crate::ui::share::format_bytes;
        -use crate::ui::share::to_color;
        -use ratatui::prelude::*;
        -use ratatui::symbols::border;
        -use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap};
        -use ratatui::Frame;
        -
        -pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
        -    let fg = to_color(state.current_theme.fg());
        -    let bg = to_color(state.current_theme.bg());
        -
        -    let block = Block::default()
        -        .title("Disk I/O")
        -        .borders(Borders::ALL)
        -        .border_set(border::PLAIN)
        -        .style(Style::default().fg(fg).bg(bg));
        -    let inner = block.inner(area);
        -    f.render_widget(block, area);
        -
        -    let snap = state.snapshot();
        -    if snap.disk_io.is_empty() {
        -        let msg = Paragraph::new("No disk I/O data")
        -            .style(Style::default().fg(fg))
        -            .wrap(Wrap { trim: true });
        -        f.render_widget(msg, inner);
        -        return;
        -    }
        -
        -    // Find max speed for proportional gauge
        -    let max_speed = snap
        -        .disk_io
        -        .iter()
        -        .map(|d| d.read_speed.max(d.write_speed))
        -        .fold(0.0_f64, f64::max)
        -        .max(1.0);
        -
        -    let per_disk = 3.min(inner.height / snap.disk_io.len().max(1) as u16);
        -    let per_disk = per_disk.max(2);
        -    let constraints = vec![Constraint::Length(per_disk); snap.disk_io.len()];
        -    let chunks = Layout::default()
        -        .direction(Direction::Vertical)
        -        .constraints(constraints)
        -        .split(inner);
        -
        -    for (i, d) in snap.disk_io.iter().enumerate() {
        -        if i >= chunks.len() {
        -            break;
        -        }
        -        let read_speed = format_bytes(d.read_speed as u64);
        -        let write_speed = format_bytes(d.write_speed as u64);
        -
        -        let gauge = Gauge::default()
        -            .gauge_style(
        -                Style::default()
        -                    .fg(to_color(&state.current_theme.palette[4]))
        -                    .bg(bg),
        -            )
        -            .percent((d.read_speed / max_speed * 100.0) as u16)
        -            .label(format!(" {}  R: {}/s", d.name, read_speed));
        -        f.render_widget(gauge, chunks[i]);
        -
        -        // Draw write speed as a second line if there's room
        -        if per_disk >= 3 {
        -            let sub = Layout::default()
        -                .direction(Direction::Vertical)
        -                .constraints([Constraint::Length(1), Constraint::Length(1)])
        -                .split(chunks[i]);
        -            let write_gauge = Gauge::default()
        -                .gauge_style(
        -                    Style::default()
        -                        .fg(to_color(&state.current_theme.palette[5]))
        -                        .bg(bg),
        -                )
        -                .percent((d.write_speed / max_speed * 100.0) as u16)
        -                .label(format!(
        -                    "     W: {}/s  Tot R: {}  Tot W: {}",
        -                    write_speed,
        -                    format_bytes(d.read_bytes),
        -                    format_bytes(d.write_bytes)
        -                ));
        -            f.render_widget(write_gauge, sub[1]);
        -        }
        -    }
        -}
        diff --git a/src/ui/widgets/gpu/mod.rs b/src/ui/widgets/gpu/mod.rs
        deleted file mode 100644
        index c6caf5f..0000000
        --- a/src/ui/widgets/gpu/mod.rs
        +++ /dev/null
        @@ -1,59 +0,0 @@
        -//! GPU widget: driver-reported GPU usage.
        -
        -use crate::state::AppState;
        -use crate::ui::share::format_bytes;
        -use crate::ui::share::to_color;
        -use ratatui::prelude::*;
        -use ratatui::symbols::border;
        -use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap};
        -use ratatui::Frame;
        -
        -pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
        -    let fg = to_color(state.current_theme.fg());
        -    let bg = to_color(state.current_theme.bg());
        -
        -    let block = Block::default()
        -        .title("GPU")
        -        .borders(Borders::ALL)
        -        .border_set(border::ROUNDED)
        -        .style(Style::default().fg(fg).bg(bg));
        -    let inner = block.inner(area);
        -    f.render_widget(block, area);
        -
        -    let snap = state.snapshot();
        -    if snap.gpus.is_empty() {
        -        let msg = Paragraph::new("No GPU data available")
        -            .style(Style::default().fg(fg))
        -            .wrap(Wrap { trim: true });
        -        f.render_widget(msg, inner);
        -        return;
        -    }
        -
        -    let chunks = Layout::default()
        -        .direction(Direction::Vertical)
        -        .constraints(vec![Constraint::Length(3); snap.gpus.len()])
        -        .split(inner);
        -
        -    for (i, gpu) in snap.gpus.iter().enumerate() {
        -        if i >= chunks.len() {
        -            break;
        -        }
        -        let label = format!(
        -            "{}  {:>3.0}%  Mem: {} / {}  Temp: {:.1}°C",
        -            gpu.name,
        -            gpu.usage,
        -            format_bytes(gpu.memory_used),
        -            format_bytes(gpu.memory_total),
        -            gpu.temperature,
        -        );
        -        let gauge = Gauge::default()
        -            .gauge_style(
        -                Style::default()
        -                    .fg(to_color(&state.current_theme.palette[5]))
        -                    .bg(bg),
        -            )
        -            .percent(gpu.usage as u16)
        -            .label(label);
        -        f.render_widget(gauge, chunks[i]);
        -    }
        -}
        diff --git a/src/ui/widgets/header/mod.rs b/src/ui/widgets/header/mod.rs
        deleted file mode 100644
        index fff25ca..0000000
        --- a/src/ui/widgets/header/mod.rs
        +++ /dev/null
        @@ -1,73 +0,0 @@
        -//! Header widget: summary line with host and key metrics.
        -
        -use crate::state::{AppState, FullScreenWidget, InputMode};
        -use crate::ui::share::format_uptime;
        -use crate::ui::share::to_color;
        -use ratatui::prelude::*;
        -use ratatui::symbols::border;
        -use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
        -use ratatui::Frame;
        -
        -pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
        -    let fg = to_color(state.current_theme.fg());
        -    let bg = to_color(state.current_theme.bg());
        -
        -    let snap = state.snapshot();
        -    let load = snap.load_avg;
        -    let uptime = snap.uptime;
        -
        -    let mode_str = state.current_layout_name();
        -
        -    let mut extras = String::new();
        -    if state.full_screen_widget != FullScreenWidget::None {
        -        extras.push_str(&format!(" [Full: {}]", state.full_screen_widget.label()));
        -    }
        -    if state.input_mode == InputMode::Searching {
        -        extras.push_str(" [/] Search");
        -    }
        -
        -    let host = &state.sys_info.hostname;
        -
        -    let wide = area.width >= 80;
        -    let text: Vec = if wide {
        -        vec![Line::from(format!(
        -            "{} | {} | {} | Uptime: {} | Load: {:.2} {:.2} {:.2}{}",
        -            if host.is_empty() {
        -                "xtop".to_string()
        -            } else {
        -                host.clone()
        -            },
        -            state.current_theme.name,
        -            mode_str,
        -            format_uptime(uptime),
        -            load.one,
        -            load.five,
        -            load.fifteen,
        -            extras,
        -        ))]
        -    } else {
        -        let host_part = if host.is_empty() {
        -            mode_str.to_string()
        -        } else {
        -            format!("{} | {}", host, mode_str)
        -        };
        -        vec![
        -            Line::from(format!("{} | Uptime: {}", host_part, format_uptime(uptime),)),
        -            Line::from(format!(
        -                "Load: {:.2} {:.2} {:.2}{}",
        -                load.one, load.five, load.fifteen, extras,
        -            )),
        -        ]
        -    };
        -
        -    let p = Paragraph::new(text)
        -        .style(Style::default().fg(fg).bg(bg))
        -        .block(
        -            Block::default()
        -                .borders(Borders::ALL)
        -                .border_set(border::PLAIN)
        -                .title("System Info"),
        -        )
        -        .wrap(Wrap { trim: true });
        -    f.render_widget(p, area);
        -}
        diff --git a/src/ui/widgets/memory/mod.rs b/src/ui/widgets/memory/mod.rs
        deleted file mode 100644
        index 93f755f..0000000
        --- a/src/ui/widgets/memory/mod.rs
        +++ /dev/null
        @@ -1,142 +0,0 @@
        -//! Memory widget: RAM and swap usage with history.
        -
        -use crate::state::AppState;
        -use crate::ui::share::format_bytes;
        -use crate::ui::share::{gauge_gradient, to_color};
        -use ratatui::prelude::*;
        -use ratatui::symbols::border;
        -use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Gauge, GraphType};
        -use ratatui::Frame;
        -
        -pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
        -    let fg = to_color(state.current_theme.fg());
        -    let bg = to_color(state.current_theme.bg());
        -    let snap = state.snapshot();
        -
        -    let mem_alert = snap.memory.percent > state.alerts.mem_high;
        -    let mem_color_idx = if mem_alert {
        -        1
        -    } else {
        -        gauge_gradient(snap.memory.percent, state.alerts.mem_high)
        -    };
        -
        -    let mut title = "Memory".to_string();
        -    if mem_alert {
        -        title = format!("Memory ⚠ {:.0}%", snap.memory.percent);
        -    }
        -
        -    let block = Block::default()
        -        .title(title)
        -        .borders(Borders::ALL)
        -        .border_set(border::ROUNDED)
        -        .style(Style::default().fg(fg).bg(bg));
        -    let inner = block.inner(area);
        -    f.render_widget(block, area);
        -
        -    let has_chart_area = inner.height > 7;
        -    if has_chart_area {
        -        let chunks = Layout::default()
        -            .direction(Direction::Vertical)
        -            .constraints([
        -                Constraint::Length(3),
        -                Constraint::Length(3),
        -                Constraint::Min(0),
        -            ])
        -            .split(inner);
        -
        -        render_ram_gauge(f, state, chunks[0], &snap, bg, mem_color_idx);
        -        render_swap_gauge(f, state, chunks[1], &snap, bg);
        -        render_chart(f, state, chunks[2], bg);
        -    } else {
        -        let chunks = Layout::default()
        -            .direction(Direction::Vertical)
        -            .constraints([Constraint::Length(3), Constraint::Length(3)])
        -            .split(inner);
        -        render_ram_gauge(f, state, chunks[0], &snap, bg, mem_color_idx);
        -        render_swap_gauge(f, state, chunks[1], &snap, bg);
        -    }
        -}
        -
        -fn render_ram_gauge(
        -    f: &mut Frame,
        -    state: &AppState,
        -    area: Rect,
        -    snap: &xtop_plugin_api::model::SystemSnapshot,
        -    bg: Color,
        -    color_idx: usize,
        -) {
        -    let mem_pct = snap.memory.percent as u16;
        -    let label = format!(
        -        "RAM: {} / {} ({:>3.0}%)",
        -        format_bytes(snap.memory.used),
        -        format_bytes(snap.memory.total),
        -        snap.memory.percent,
        -    );
        -    let gauge = Gauge::default()
        -        .gauge_style(
        -            Style::default()
        -                .fg(to_color(&state.current_theme.palette[color_idx]))
        -                .bg(bg),
        -        )
        -        .percent(mem_pct)
        -        .label(label);
        -    f.render_widget(gauge, area);
        -}
        -
        -fn render_swap_gauge(
        -    f: &mut Frame,
        -    state: &AppState,
        -    area: Rect,
        -    snap: &xtop_plugin_api::model::SystemSnapshot,
        -    bg: Color,
        -) {
        -    let swap_pct = snap.swap.percent as u16;
        -    let color_idx = gauge_gradient(snap.swap.percent, state.alerts.mem_high);
        -    let label = format!(
        -        "SWP: {} / {} ({:>3.0}%)",
        -        format_bytes(snap.swap.used),
        -        format_bytes(snap.swap.total),
        -        snap.swap.percent,
        -    );
        -    let gauge = Gauge::default()
        -        .gauge_style(
        -            Style::default()
        -                .fg(to_color(&state.current_theme.palette[color_idx]))
        -                .bg(bg),
        -        )
        -        .percent(swap_pct)
        -        .label(label);
        -    f.render_widget(gauge, area);
        -}
        -
        -fn render_chart(f: &mut Frame, state: &AppState, area: Rect, _bg: Color) {
        -    let mem_data: Vec<(f64, f64)> = state.history.mem.iter().copied().collect();
        -    if mem_data.is_empty() {
        -        return;
        -    }
        -
        -    let datasets = vec![Dataset::default()
        -        .name("RAM Usage")
        -        .marker(symbols::Marker::Braille)
        -        .graph_type(GraphType::Line)
        -        .style(Style::default().fg(to_color(&state.current_theme.palette[2])))
        -        .data(&mem_data)];
        -
        -    let x_min = mem_data.first().map(|&(x, _)| x).unwrap_or(0.0);
        -    let x_max = mem_data.last().map(|&(x, _)| x).unwrap_or(100.0);
        -    let x_max = x_max.max(x_min + 1.0);
        -
        -    let chart = Chart::new(datasets)
        -        .block(Block::default().borders(Borders::TOP))
        -        .x_axis(
        -            Axis::default()
        -                .bounds([x_min, x_max])
        -                .labels(vec![Span::raw("")]),
        -        )
        -        .y_axis(Axis::default().bounds([0.0, 100.0]).labels(vec![
        -            Span::raw("0%"),
        -            Span::raw("50%"),
        -            Span::raw("100%"),
        -        ]));
        -    f.render_widget(chart, area);
        -}
        diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs
        deleted file mode 100644
        index 6379d08..0000000
        --- a/src/ui/widgets/mod.rs
        +++ /dev/null
        @@ -1,16 +0,0 @@
        -//! Widgets area: one folder per widget.
        -//!
        -//! Every widget exposes a `render(f, state, area)` entry point. Folders let
        -//! widgets subdivide into their own modules (and `share/`) as they grow.
        -
        -pub mod battery;
        -pub mod cpu;
        -pub mod disk_io;
        -pub mod gpu;
        -pub mod header;
        -pub mod help;
        -pub mod memory;
        -pub mod network;
        -pub mod palette;
        -pub mod processes;
        -pub mod storage;
        diff --git a/src/ui/widgets/network/mod.rs b/src/ui/widgets/network/mod.rs
        deleted file mode 100644
        index 9702d9d..0000000
        --- a/src/ui/widgets/network/mod.rs
        +++ /dev/null
        @@ -1,168 +0,0 @@
        -//! Network widget: RX/TX rates per interface.
        -
        -use crate::state::AppState;
        -use crate::ui::share::format_bytes;
        -use crate::ui::share::to_color;
        -use ratatui::prelude::*;
        -use ratatui::symbols::border;
        -use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Wrap};
        -use ratatui::Frame;
        -
        -pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
        -    let fg = to_color(state.current_theme.fg());
        -    let bg = to_color(state.current_theme.bg());
        -
        -    let block = Block::default()
        -        .title("Network")
        -        .borders(Borders::ALL)
        -        .border_set(border::DOUBLE)
        -        .style(Style::default().fg(fg).bg(bg));
        -    let inner = block.inner(area);
        -    f.render_widget(block, area);
        -
        -    let snap = state.snapshot();
        -    let total_rx: u64 = snap.networks.iter().map(|n| n.received).sum();
        -    let total_tx: u64 = snap.networks.iter().map(|n| n.transmitted).sum();
        -    let total_rx_speed: f64 = snap.networks.iter().map(|n| n.rx_speed).sum();
        -    let total_tx_speed: f64 = snap.networks.iter().map(|n| n.tx_speed).sum();
        -
        -    let has_chart = inner.height > 6;
        -
        -    if has_chart {
        -        let chunks = Layout::default()
        -            .direction(Direction::Vertical)
        -            .constraints([Constraint::Length(4), Constraint::Min(0)])
        -            .split(inner);
        -
        -        render_stats(
        -            f,
        -            state,
        -            chunks[0],
        -            fg,
        -            total_rx,
        -            total_tx,
        -            total_rx_speed,
        -            total_tx_speed,
        -            &snap.networks,
        -        );
        -        render_net_chart(f, state, chunks[1], bg);
        -    } else {
        -        render_stats(
        -            f,
        -            state,
        -            inner,
        -            fg,
        -            total_rx,
        -            total_tx,
        -            total_rx_speed,
        -            total_tx_speed,
        -            &snap.networks,
        -        );
        -    }
        -}
        -
        -#[allow(clippy::too_many_arguments)]
        -fn render_stats(
        -    f: &mut Frame,
        -    state: &AppState,
        -    area: Rect,
        -    fg: Color,
        -    total_rx: u64,
        -    total_tx: u64,
        -    total_rx_speed: f64,
        -    total_tx_speed: f64,
        -    interfaces: &[xtop_plugin_api::model::NetworkInfo],
        -) {
        -    let mut text = vec![
        -        Line::from(vec![
        -            Span::styled("RX: ", Style::default().fg(fg)),
        -            Span::styled(
        -                format_bytes(total_rx),
        -                Style::default().fg(to_color(&state.current_theme.palette[4])),
        -            ),
        -            Span::raw("  "),
        -            Span::styled(
        -                format!("{}/s", format_bytes(total_rx_speed as u64)),
        -                Style::default().fg(to_color(&state.current_theme.palette[4])),
        -            ),
        -        ]),
        -        Line::from(vec![
        -            Span::styled("TX: ", Style::default().fg(fg)),
        -            Span::styled(
        -                format_bytes(total_tx),
        -                Style::default().fg(to_color(&state.current_theme.palette[5])),
        -            ),
        -            Span::raw("  "),
        -            Span::styled(
        -                format!("{}/s", format_bytes(total_tx_speed as u64)),
        -                Style::default().fg(to_color(&state.current_theme.palette[5])),
        -            ),
        -        ]),
        -    ];
        -
        -    if area.height > 4 {
        -        for iface in interfaces {
        -            if text.len() as u16 >= area.height.saturating_sub(1) {
        -                break;
        -            }
        -            text.push(Line::from(Span::raw(format!(
        -                " {}  RX: {}  TX: {}",
        -                iface.name,
        -                format_bytes(iface.received),
        -                format_bytes(iface.transmitted),
        -            ))));
        -        }
        -    }
        -
        -    let p = Paragraph::new(text).wrap(Wrap { trim: true });
        -    f.render_widget(p, area);
        -}
        -
        -fn render_net_chart(f: &mut Frame, state: &AppState, area: Rect, _bg: Color) {
        -    let rx_data: Vec<(f64, f64)> = state.history.net_rx.iter().copied().collect();
        -    let tx_data: Vec<(f64, f64)> = state.history.net_tx.iter().copied().collect();
        -    if rx_data.len() < 2 || tx_data.len() < 2 {
        -        return;
        -    }
        -
        -    // Find max value for y-axis bounds
        -    let max_val = rx_data
        -        .iter()
        -        .chain(tx_data.iter())
        -        .map(|&(_, v)| v)
        -        .fold(0.0_f64, f64::max)
        -        .max(1.0);
        -
        -    let datasets = vec![
        -        Dataset::default()
        -            .name("RX")
        -            .marker(symbols::Marker::Braille)
        -            .graph_type(GraphType::Line)
        -            .style(Style::default().fg(to_color(&state.current_theme.palette[4])))
        -            .data(&rx_data),
        -        Dataset::default()
        -            .name("TX")
        -            .marker(symbols::Marker::Braille)
        -            .graph_type(GraphType::Line)
        -            .style(Style::default().fg(to_color(&state.current_theme.palette[5])))
        -            .data(&tx_data),
        -    ];
        -
        -    let x_min = rx_data.first().map(|&(x, _)| x).unwrap_or(0.0);
        -    let x_max = rx_data.last().map(|&(x, _)| x).unwrap_or(100.0);
        -    let x_max = x_max.max(x_min + 1.0);
        -
        -    let chart = Chart::new(datasets)
        -        .block(Block::default().borders(Borders::TOP))
        -        .x_axis(
        -            Axis::default()
        -                .bounds([x_min, x_max])
        -                .labels(vec![Span::raw("")]),
        -        )
        -        .y_axis(Axis::default().bounds([0.0, max_val]).labels(vec![
        -            Span::raw("0"),
        -            Span::raw(format!("{:.0}", max_val / 2.0)),
        -            Span::raw(format!("{:.0}", max_val)),
        -        ]));
        -    f.render_widget(chart, area);
        -}
        diff --git a/src/ui/widgets/processes/mod.rs b/src/ui/widgets/processes/mod.rs
        deleted file mode 100644
        index f80395e..0000000
        --- a/src/ui/widgets/processes/mod.rs
        +++ /dev/null
        @@ -1,113 +0,0 @@
        -//! Processes widget: sortable live process table with search.
        -
        -use crate::state::AppState;
        -use crate::ui::share::to_color;
        -use ratatui::prelude::*;
        -use ratatui::symbols::border;
        -use ratatui::widgets::{Block, Borders, Cell, Row, Table};
        -use ratatui::Frame;
        -use xtop_plugin_api::model::ProcessInfo;
        -
        -pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
        -    let fg = to_color(state.current_theme.fg());
        -    let bg = to_color(state.current_theme.bg());
        -    let dim_bg = to_color(&state.current_theme.palette[8]);
        -    let accent = to_color(&state.current_theme.palette[6]);
        -
        -    let mut title = format!("Processes (sort: {})", state.process_sort.label());
        -    if !state.search_query.is_empty() {
        -        title = format!("Processes (filter: {})", state.search_query);
        -    }
        -
        -    let block = Block::default()
        -        .title(title)
        -        .borders(Borders::ALL)
        -        .border_set(border::PLAIN)
        -        .style(Style::default().fg(fg).bg(bg));
        -    let inner = block.inner(area);
        -    f.render_widget(block, area);
        -
        -    let snap = state.snapshot();
        -
        -    let iter: Box> = if state.search_query.is_empty() {
        -        Box::new(snap.processes.iter())
        -    } else {
        -        let q = state.search_query.to_lowercase();
        -        Box::new(
        -            snap.processes
        -                .iter()
        -                .filter(move |p| p.name.to_lowercase().contains(&q)),
        -        )
        -    };
        -
        -    let mut items: Vec<&ProcessInfo> = iter.collect();
        -
        -    // Sort
        -    match state.process_sort {
        -        crate::state::ProcessSortBy::Cpu => {
        -            items.sort_by(|a, b| {
        -                b.cpu_usage
        -                    .partial_cmp(&a.cpu_usage)
        -                    .unwrap_or(std::cmp::Ordering::Equal)
        -            });
        -        }
        -        crate::state::ProcessSortBy::Memory => {
        -            items.sort_by_key(|b| std::cmp::Reverse(b.memory));
        -        }
        -        crate::state::ProcessSortBy::Pid => {
        -            items.sort_by_key(|a| a.pid);
        -        }
        -        crate::state::ProcessSortBy::Name => {
        -            items.sort_by_key(|a| a.name.to_lowercase());
        -        }
        -    }
        -
        -    let rows: Vec = items
        -        .into_iter()
        -        .enumerate()
        -        .map(|(row_idx, p)| {
        -            let is_selected = state.process_selected == Some(row_idx);
        -            let style = if is_selected {
        -                Style::default()
        -                    .fg(bg)
        -                    .bg(accent)
        -                    .add_modifier(Modifier::BOLD)
        -            } else if row_idx % 2 == 0 {
        -                Style::default().fg(fg)
        -            } else {
        -                Style::default().fg(fg).bg(dim_bg)
        -            };
        -            Row::new(vec![
        -                Cell::from(p.pid.to_string()),
        -                Cell::from(p.name.clone()),
        -                Cell::from(format!("{:.1}%", p.cpu_usage)),
        -                Cell::from(crate::ui::share::format_bytes(p.memory)),
        -                Cell::from(p.user_id.clone().unwrap_or_else(|| "?".to_string())),
        -            ])
        -            .style(style)
        -        })
        -        .collect();
        -
        -    let widths = [
        -        Constraint::Length(10),
        -        Constraint::Percentage(40),
        -        Constraint::Length(12),
        -        Constraint::Length(17),
        -        Constraint::Length(10),
        -    ];
        -
        -    let table = Table::new(rows, widths)
        -        .header(
        -            Row::new(vec!["PID", "Name", "CPU%", "Mem", "User"])
        -                .style(Style::default().fg(accent).add_modifier(Modifier::BOLD))
        -                .bottom_margin(1),
        -        )
        -        .row_highlight_style(
        -            Style::default()
        -                .fg(bg)
        -                .bg(accent)
        -                .add_modifier(Modifier::BOLD),
        -        );
        -
        -    f.render_widget(table, inner);
        -}
        diff --git a/src/ui/widgets/storage/mod.rs b/src/ui/widgets/storage/mod.rs
        deleted file mode 100644
        index ec99c87..0000000
        --- a/src/ui/widgets/storage/mod.rs
        +++ /dev/null
        @@ -1,60 +0,0 @@
        -//! Storage widget: mounted filesystems and usage.
        -
        -use crate::state::AppState;
        -use crate::ui::share::format_bytes;
        -use crate::ui::share::{gauge_gradient, to_color};
        -use ratatui::prelude::*;
        -use ratatui::symbols::border;
        -use ratatui::widgets::{Block, Borders, Gauge};
        -use ratatui::Frame;
        -
        -pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
        -    let fg = to_color(state.current_theme.fg());
        -    let bg = to_color(state.current_theme.bg());
        -
        -    let block = Block::default()
        -        .title("Storage")
        -        .borders(Borders::ALL)
        -        .border_set(border::DOUBLE)
        -        .style(Style::default().fg(fg).bg(bg));
        -    let inner = block.inner(area);
        -    f.render_widget(block, area);
        -
        -    let snap = state.snapshot();
        -    let disks = &snap.disks;
        -    if disks.is_empty() {
        -        return;
        -    }
        -
        -    let per_disk = inner.height.min(3);
        -    let constraints = vec![Constraint::Length(per_disk); disks.len()];
        -    let chunks = Layout::default()
        -        .direction(Direction::Vertical)
        -        .constraints(constraints)
        -        .split(inner);
        -
        -    for (i, disk) in disks.iter().enumerate() {
        -        if i >= chunks.len() {
        -            break;
        -        }
        -        let color_idx = gauge_gradient(disk.percent, state.alerts.disk_high);
        -        let fs_type = &disk.file_system;
        -        let label = format!(
        -            "{} [{}]  Tot: {}  Use: {}  Free: {}",
        -            disk.mount_point,
        -            if fs_type.is_empty() { "?" } else { fs_type },
        -            format_bytes(disk.total_space),
        -            format_bytes(disk.used_space),
        -            format_bytes(disk.available_space),
        -        );
        -        let gauge = Gauge::default()
        -            .gauge_style(
        -                Style::default()
        -                    .fg(to_color(&state.current_theme.palette[color_idx]))
        -                    .bg(bg),
        -            )
        -            .percent(disk.percent as u16)
        -            .label(label);
        -        f.render_widget(gauge, chunks[i]);
        -    }
        -}
        
        From d9adfccb5c6842a8b7e604263f9cb5dd5ae4f6fa Mon Sep 17 00:00:00 2001
        From: xscriptor 
        Date: Fri, 4 Sep 2026 18:47:23 +0000
        Subject: [PATCH 3/4] refactor: consume consolidated contracts;
         theme/version/doc truth pass
        
        - delete dead plugins_dir_tmp; AlertThresholds from plugin-api (byte-identical config JSON); PluginWidget import replaces PluginWidgetFn
        - providers drop docker surface; unknown layout widget ids warn once
        - themes: miami embedded+seeded, runtime count corrected to 12; themes_dir via platform-aware config_dir
        - ratatui 0.30.2 + single crossterm 0.29; rust-version 1.87
        - effects feature (off by default) wiring xtop-effect-fade into the draw path
        - version 0.3.0; CHANGELOG, README, docs/ (incl. moved colors.md), install scripts, kernel ROADMAP and audit.sh synced to reality
        - drop kernel-local to_color (import canonical widget-api glyph helper)
        ---
         CHANGELOG.md                        |  63 +++++
         Cargo.toml                          |  18 +-
         README.md                           |  31 +--
         ROADMAP.md                          |  79 ++++--
         colors.md => docs/colors.md         |   0
         docs/configuration.md               | 177 +++++++++++--
         docs/customization.md               |  65 +++--
         docs/features.md                    |  12 +-
         docs/installation.md                |  22 +-
         docs/multi-repo.md                  | 199 ++++++++-------
         docs/plugin.md                      | 210 +++++++++-------
         docs/usage.md                       |  31 ++-
         install.ps1                         |   2 +-
         install.sh                          |  30 +--
         scripts/audit.sh                    |  22 +-
         scripts/ci.sh                       |   3 +-
         src/commands/plugins_dir_tmp/mod.rs | 368 ----------------------------
         src/commands/run.rs                 |  13 +-
         src/commands/share/assets.rs        |  54 +++-
         src/config/schema.rs                |  43 ++--
         src/plugins/host.rs                 |   6 +-
         src/plugins/manager.rs              |   6 +-
         src/providers/composite.rs          |   6 +-
         src/providers/sysinfo/provider.rs   |   1 -
         src/state/app.rs                    |  11 +-
         src/state/widget_state.rs           |   6 +-
         src/theme/loader.rs                 |  21 +-
         src/ui/effects/mod.rs               |  20 ++
         src/ui/effects/off.rs               |  19 ++
         src/ui/effects/on.rs                |  67 +++++
         src/ui/layout/engine.rs             | 113 +++++++--
         src/ui/mod.rs                       |   5 +-
         src/ui/overlay/help/mod.rs          |   8 +-
         src/ui/overlay/palette/mod.rs       |   8 +-
         src/ui/screen.rs                    |  54 ++--
         src/ui/share/color.rs               |   7 -
         src/ui/share/mod.rs                 |   8 -
         37 files changed, 1001 insertions(+), 807 deletions(-)
         rename colors.md => docs/colors.md (100%)
         delete mode 100644 src/commands/plugins_dir_tmp/mod.rs
         create mode 100644 src/ui/effects/mod.rs
         create mode 100644 src/ui/effects/off.rs
         create mode 100644 src/ui/effects/on.rs
         delete mode 100644 src/ui/share/color.rs
         delete mode 100644 src/ui/share/mod.rs
        
        diff --git a/CHANGELOG.md b/CHANGELOG.md
        index 70ac144..3d3a778 100644
        --- a/CHANGELOG.md
        +++ b/CHANGELOG.md
        @@ -1,5 +1,68 @@
         # Changelog
         
        +## [0.3.0] - 2026-09-04
        +
        +### Ecosystem externalization (monocrate cycle complete)
        +- The kernel is now a thin host over the externalized ecosystem. It
        +  consumes the contract crates from `xtop-cli/api` (`xtop-plugin-api`,
        +  `xtop-widget-api`, `xtop-extension-api`), the widget packs from
        +  `xtop-cli/widgets`, layouts from `xtop-cli/layouts`, the samurai plugin
        +  from `xtop-cli/plugins` and the MCP extension from `xtop-cli/extensions`
        +  as floating git dependencies with optional feature flags.
        +- Deleted the dead uncompiled `src/commands/plugins_dir_tmp/` leftover
        +  from the pre-monocrate layout. (Path spelled in scripts/audit.sh as the
        +  absence guard.)
        +- `crate version 0.2.0 -> 0.3.0`; `rust-version = "1.87"` declared.
        +
        +### Contract consolidation
        +- `config::AlertThresholds` removed: the persisted config now uses
        +  `xtop_plugin_api::AlertThresholds` directly (identical JSON keys
        +  `cpu_high`/`mem_high`/`disk_high`; defaults 90/90/90 constructed at the
        +  config layer). Hand-marshalling bridge code deleted from
        +  `plugins/host.rs` and `state/widget_state.rs`.
        +- Plugin widget registrations use `xtop_plugin_api::PluginWidget`; the
        +  duplicated `PluginWidgetFn` alias in `ui/layout/engine.rs` is gone.
        +- Docker leftovers removed (`dockers: vec![]` assignment, `docker_info`
        +  composite forwarding) — the api model no longer carries Docker data.
        +- Plugin context reads are capability-gated and `Result`-typed upstream;
        +  consumers adapted.
        +
        +### Themes
        +- `miami` is now embedded and seeded like the other 11 themes (12 JSONC
        +  theme files ship, all in `DEFAULT_THEMES`); seed version bumped to "2"
        +  so existing installs receive the new template.
        +- Theme docs/counts corrected everywhere (12 themes; `x` compiled in as
        +  the startup fallback).
        +- `theme::themes_dir()` routes through the platform-aware
        +  `config::config_dir()`, so themes live next to `config.json` and layouts
        +  on macOS/Windows too.
        +
        +### Dependency refresh
        +- ratatui `0.29 -> 0.30.2` (`Layout::vertical`/`Layout::horizontal`
        +  builder API); crossterm aligned with ratatui's backend (single
        +  `crossterm 0.29` in the graph via `ratatui-crossterm`).
        +
        +### New features
        +- Unknown widget names in the active layout produce a one-time stderr
        +  warning per name (`xtop: layout '' references unknown widget
        +  ''`).
        +- Optional `effects` feature (off by default) wires the built-in
        +  `xtop-effect-fade` through the `effect` config key ("fade" activates the
        +  500 ms fade-in; absent/unknown disables). Builds without the feature
        +  carry zero extra dependencies.
        +
        +### Docs and release plumbing
        +- `docs/` refreshed: configuration keys, plugin architecture (plugins as
        +  separate crates, `plugin list|install|scaffold` real behavior, MCP
        +  extension), multi-repo RFC status, feature/theme counts; `colors.md`
        +  moved under `docs/`.
        +- `install.sh` VERSION synced to the crate version; OpenSSL build-dep
        +  claims removed (the crate has no openssl dependency); `install.ps1`
        +  placeholder comment removed.
        +- Kernel `ROADMAP.md` synced with the implemented state (phases 4-6 and
        +  the R2/R3 refactor items); `scripts/audit.sh` extended with dead-dir and
        +  theme-seed gates.
        +
         ## [0.2.1] - 2026-06-18
         
         ### Config: Persistencia de Layouts Personalizados
        diff --git a/Cargo.toml b/Cargo.toml
        index 5ceb551..cbb6a76 100644
        --- a/Cargo.toml
        +++ b/Cargo.toml
        @@ -1,7 +1,8 @@
         [package]
         name = "xtop"
        -version = "0.2.0"
        +version = "0.3.0"
         edition = "2021"
        +rust-version = "1.87"
         license = "MIT"
         description = "A modern, cross-platform TUI system monitor written in Rust"
         repository = "https://github.com/xtop-cli/xtop"
        @@ -11,30 +12,33 @@ name = "xtop"
         path = "src/main.rs"
         
         [dependencies]
        -crossterm = "0.28"
        -ratatui = "0.29"
        +crossterm = "0.29"
        +ratatui = "0.30.2"
         sysinfo = "0.39"
         anyhow = "1"
         serde = { version = "1", features = ["derive"] }
         serde_json = "1"
         toml = "0.8"
         
        -# xtop-cli/api, xtop-cli/plugins, xtop-cli/extensions y xtop-cli/layouts
        -# (repos hermanos).
        +# Contract crates from xtop-cli/api and the ecosystem repos (xtop-cli/plugins,
        +# xtop-cli/extensions, xtop-cli/layouts, xtop-cli/widgets, xtop-cli/effects).
         # Distribution: git dependencies on the published repos, so a clean clone
        -# builds without needing their sources checked out.
        +# builds without needing their sources checked out locally.
         xtop-plugin-api = { git = "https://github.com/xtop-cli/api" }
         xtop-extension-api = { git = "https://github.com/xtop-cli/api" }
         xtop-widget-api = { git = "https://github.com/xtop-cli/api" }
        -# xtop-cli/widgets (packs base y alternativos, en su propio repo).
        +xtop-effect-api = { git = "https://github.com/xtop-cli/api", optional = true }
        +# xtop-cli/widgets (base and alternative packs, in their own repo).
         xtop-widgets = { git = "https://github.com/xtop-cli/widgets" }
         xtop-widget-blocks = { git = "https://github.com/xtop-cli/widgets", optional = true }
         xtop-layout = { git = "https://github.com/xtop-cli/layouts" }
         xtop-extension-mcp = { git = "https://github.com/xtop-cli/extensions", optional = true }
         xtop-plugin-samurai = { git = "https://github.com/xtop-cli/plugins", optional = true }
        +xtop-effect-fade = { git = "https://github.com/xtop-cli/effects", optional = true }
         
         [features]
         default = ["plugin-samurai", "mcp-extension"]
         plugin-samurai = ["dep:xtop-plugin-samurai"]
         mcp-extension = ["dep:xtop-extension-mcp"]
         widget-blocks = ["dep:xtop-widget-blocks"]
        +effects = ["dep:xtop-effect-fade", "dep:xtop-effect-api"]
        diff --git a/README.md b/README.md
        index 0c07f31..42b70ef 100644
        --- a/README.md
        +++ b/README.md
        @@ -2,7 +2,7 @@
         
         
        -![Rust](https://img.shields.io/badge/Rust-1.80%2B-orange) +![Rust](https://img.shields.io/badge/Rust-1.87%2B-orange) ![License](https://img.shields.io/badge/license-MIT-blue) ![CI](https://img.shields.io/github/actions/workflow/status/xtop-cli/xtop/ci.yml?branch=main) ![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey) @@ -38,9 +38,9 @@ A cross-platform TUI system monitor written in Rust. Uses Quick Install -

        macOS / Linux

        +

        Linux

        curl -fsSL https://raw.githubusercontent.com/xtop-cli/xtop/main/install.sh | bash
        @@ -75,6 +75,12 @@ A cross-platform TUI system monitor written in Rust. Uses
        Usage -- keybindings, modules, help overlay
      5. Configuration -- config file and settings reference
      6. Customization -- custom themes and layouts
      7. +
      8. Colors -- palette reference for the 12 shipped themes
      9. +
      10. Plugins -- plugin architecture and authoring
      11. +
      12. Multi-repo architecture -- ecosystem RFC and layout
      13. Roadmap
      14. Changelog
      15. Contributing
      16. @@ -126,14 +135,8 @@ cargo run --release

        X

        - - X Web - +Dev & - - X Github Profile - +Github Profile & - - Xscriptor web - \ No newline at end of file +Xscriptor \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index 07457a1..0a5f037 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,8 @@ It is synced automatically with GitHub Issues. - [x] Dynamic layout manager supporting multiple modes (Dashboard, Vertical, Process Focus) (#9) - [x] Implement 13 built-in color schemes (`x`, `madrid`, `tokio`, etc.) (#10) + (12 theme files ship today, all embedded and seeded; the 0.3.0 docs + count 12. See docs/colors.md for the palette reference.) - [x] Instant theme and layout cycling at runtime (#11) - [x] Responsive design for narrow terminals (#12) @@ -31,25 +33,35 @@ It is synced automatically with GitHub Issues. ## Phase 4: Configuration & Customization -- [ ] Persistent configuration file support (save theme and layout preferences) (#18) -- [ ] Custom user theme creation via configuration (#19) -- [ ] Configurable update intervals for system metrics (#20) -- [ ] Customizable keybindings (#21) +- [x] Persistent configuration file support (save theme and layout preferences) (#18) +- [x] Custom user theme creation via configuration (#19) +- [x] Configurable update intervals for system metrics (#20) +- [x] Customizable keybindings (#21) ## Phase 5: Advanced Monitoring Features -- [ ] Disk I/O read/write speed tracking (#22) -- [ ] Granular network interface selection (#23) -- [ ] GPU usage, temperature, and VRAM monitoring (NVIDIA/AMD) (#24) -- [ ] Battery status monitoring (#25) -- [ ] Docker container resource usage integration (#26) +- [x] Disk I/O read/write speed tracking (#22) +- [ ] Granular network interface selection (#23) — per-interface RX/TX data is + shown, but picking which interface the kernel reports/charts is not + configurable yet. +- [ ] GPU usage, temperature, and VRAM monitoring (NVIDIA/AMD) (#24) — + Linux-only partial (nvidia-smi + /sys/class/drm); macOS/Windows stay + stubs. +- [ ] Battery status monitoring (#25) — Linux probes real; macOS/Windows + stay stubs. +- [ ] Docker container resource usage integration (#26) — Docker support was + removed from the shared data model (api M1.4: nothing consumed it), so + this item is parked until a real consumer appears. ## Phase 6: Interactive Process Management -- [ ] Interactive process termination (send kill signals) (#27) -- [ ] Search, filter, and highlight processes by name (#28) +- [x] Interactive process termination (send kill signals) (#27) +- [ ] Search, filter, and highlight processes by name (#28) — search and + filtering exist; highlighting matches inside the process list is + pending. - [ ] Tree view for process hierarchy (#29) -- [ ] Sorting processes by Memory, PID, or User (#30) +- [ ] Sorting processes by Memory, PID, or User (#30) — CPU/Memory/PID/Name + sorting is implemented; no User column yet. ## Phase 7: X Integration @@ -67,19 +79,36 @@ outside this roadmap). ### R2 - Code quality pass -- [ ] Module doc comments audit across src/ (#40) -- [ ] cfg(target_os) only inside platform/ trees (#41) -- [ ] Wildcard re-export and pub hygiene review (#42) -- [ ] Split commands/plugins.rs into list/install/scaffold modules (#43) -- [ ] key_event_to_str into a shared input module if reused elsewhere (#44) -- [ ] ui/share/error.rs only when real UI error handling appears (no empty - modules) (#45) -- [ ] Widgets subdivide internally when they outgrow one module (#46) +- [x] Module doc comments audit across src/ (#40) — the seven top-level + modules declared in src/main.rs (commands, config, plugins, providers, + state, theme, ui) all carry concise `//!` docs describing their area. +- [x] cfg(target_os) only inside platform/ trees (#41) — enforced by + scripts/audit.sh (0 occurrences outside platform/). +- [ ] Wildcard re-export and pub hygiene review (#42) — 22 wildcard + `pub use ...::*` re-exports remain (audit.sh threshold: 30); review + still open. +- [x] Split commands/plugins.rs into list/install/scaffold modules (#43) +- [x] key_event_to_str into a shared input module if reused elsewhere (#44) — + single use site (commands/run.rs), so no shared module is needed. +- [x] ui/share/error.rs only when real UI error handling appears (no empty + modules) (#45) — intentionally not created; documented in the root + ROADMAP deferred list. +- [x] Widgets subdivide internally when they outgrow one module (#46) — + kernel widget renderers were externalized to the widgets repo (M3); + the kernel no longer owns pack widgets. ### R3 - Structural audit tooling -- [ ] scripts/audit.sh with failing thresholds: LOC per area, files above - 200 lines, cfg outside platform (must be 0), module dependency graph - and cycles, unused pub items, TODO counts (#47) - - +- [x] scripts/audit.sh with failing thresholds (#47) — the script gates: + cfg(target_os) outside platform/ trees = 0, files over 600 lines = 0, + TODO/FIXME/XXX/HACK markers = 0, wildcard `pub use ...::*` <= 30, + LOC per top-level area <= 2400, dead pre-monocrate plugin tree absent + (its path lives only inside the audit script as the guard itself), + `miami` embedded in the theme seeds (12 themes total). Module + dependency graph/cycles and unused-pub detection are NOT implemented + by the script (see the deferred note below). + +Deferred follow-ups (tracked with the root ROADMAP §7 list): +- audit.sh module dependency graph/cycles and unused-pub detection, plus + per-file thresholds above 300/600 lines (the 200-line figure in the + original issue predates the current 300/600 gates). diff --git a/colors.md b/docs/colors.md similarity index 100% rename from colors.md rename to docs/colors.md diff --git a/docs/configuration.md b/docs/configuration.md index 261bf2f..51290aa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,58 +1,139 @@

        Configuration

        -
        - -

        Config File

        +

        xtop persists its configuration automatically on quit. The file is +config.json in the platform config directory, next to the +themes/ and layouts/ folders:

        -

        xtop automatically saves its configuration on quit. The configuration file is located at:

        +
        ~/.config/xtop/config.json                (Linux)
        +~/Library/Application Support/xtop/       (macOS)
        +%APPDATA%\xtop\                           (Windows)
        +
        -
        ~/.config/xtop/config.json
        +

        On Linux you can override the base directory with +$XDG_CONFIG_HOME.


        -

        Persisted Settings

        +

        Keys

        - + + + - + + + + + + + + + - - + + + + - - + + + + - + + + + + + + + + + + + + + + + + + + + + - - + + + +
        SettingKeyTypeDefault Description
        themeCurrently selected color theme namestring"x"Currently selected color theme name (one of the 12 shipped or a user theme).
        layout_modestring"Dashboard"Built-in layout mode. One of Dashboard, Vertical, Horizontal, CpuFocus, MemoryFocus, NetworkFocus, ProcessFocus. Ignored while layout_name names a valid custom layout.
        layoutCurrently selected layout modelayout_namestring""Name of the active layout when it is a custom (non-built-in) layout. When non-empty and found, it takes precedence over layout_mode.
        intervalUpdate interval in millisecondsupdate_interval_msinteger1000Sampling interval in milliseconds. Clamped to 100–3,600,000 on load.
        history_pointsNumber of data points retained for the RAM history chartinteger100Data points retained for the historical charts.
        alertsobjectsee belowAlert thresholds; see Alert Thresholds.
        keybindingsobjectsee belowKey bindings per action; see Keybindings.
        styleobjectsee belowWidget glyph style (chart charset, borders, packs); see Style.
        alert_thresholdsThreshold values for CPU, memory, and disk alertseffectstring (optional)absentFrame effect applied to every rendered frame: "fade" activates the built-in fade-in (only in builds compiled with the effects feature). Any other value disables effects.
        +

        Example

        + +
        {
        +  "theme": "miami",
        +  "layout_mode": "Dashboard",
        +  "layout_name": "",
        +  "update_interval_ms": 1000,
        +  "history_points": 100,
        +  "alerts": {
        +    "cpu_high": 90.0,
        +    "mem_high": 90.0,
        +    "disk_high": 90.0
        +  },
        +  "keybindings": {
        +    "quit": ["q"],
        +    "help": ["?"],
        +    "next_theme": ["t"],
        +    "prev_theme": ["T"],
        +    "next_layout": ["l"],
        +    "toggle_fullscreen": ["f"],
        +    "cycle_fullscreen": ["F"],
        +    "search": ["/"],
        +    "command_palette": ["ctrl+p", "ctrl+P"],
        +    "cancel": ["escape"],
        +    "kill_process": ["k"],
        +    "process_up": ["up"],
        +    "process_down": ["down"],
        +    "cycle_sort": ["s"]
        +  },
        +  "style": {
        +    "charset": "braille",
        +    "borders": "native",
        +    "pack": null,
        +    "widgets": {}
        +  }
        +}
        + +

        Unknown keys and missing optional keys are ignored; a file that cannot be +parsed falls back to the defaults above.

        +

        Alert Thresholds

        -

        When a metric exceeds its configured threshold, the corresponding widget changes color to red and displays a warning indicator in its title.

        +

        When a metric exceeds its configured threshold, the corresponding widget +changes color to red and displays a warning indicator in its title.

        - + @@ -61,23 +142,79 @@ - + - + - +
        ThresholdKey Description Default
        cpu_high CPU usage percentage that triggers a warning90%90.0
        mem_high Memory usage percentage that triggers a warning90%90.0
        disk_high Disk usage percentage that triggers a warning90%90.0

        +

        Keybindings

        + +

        Every action accepts a list of key strings; the first matching key wins. +Keys are written as: single characters ("q", "/", "?"), +shifted characters ("T", "F"), modified keys +("ctrl+p", "alt+x") and named keys +("escape", "enter", "backspace", "tab", +"up", "down", "left", "right", +"delete", "home", "end", "pageup", +"pagedown").

        + + + + + + + + + + + + + + + + + + + + + + + + + +
        Key (action)Default bindingAction
        quit["q"]Save config and quit
        help["?"]Toggle the help overlay
        next_theme["t"]Next theme
        prev_theme["T"]Previous theme
        next_layout["l"]Next layout
        toggle_fullscreen["f"]Toggle full-screen view
        cycle_fullscreen["F"]Cycle the full-screen widget
        search["/"]Start process search
        command_palette["ctrl+p", "ctrl+P"]Open the command palette (ctrl+p also works as a hardcoded fallback)
        cancel["escape"]Cancel search / close overlays
        kill_process["k"]Kill the selected process (same-user safety check)
        process_up["up"]Move the process selection up
        process_down["down"]Move the process selection down
        cycle_sort["s"]Cycle the process sort column (CPU% → Memory → PID → Name)
        + +
        + +

        Style

        + +

        The style object controls widget glyphs. Values are the +ecosystem-wide enums from xtop-widget-api:

        + +
          +
        • charset: braille (default), dot, block, half_block, bar — chart markers used by history charts.
        • +
        • borders: native (default, classic single-line frame), rounded, double, plain, ascii (plain/ascii draw a pure ASCII +-| frame).
        • +
        • pack: widget pack used for every widget without a per-widget override ("default" or "blocks" when compiled with the widget-blocks feature).
        • +
        • widgets: map of widget-name overrides, each accepting charset, borders and pack.
        • +
        + +

        See customization.md for an +example and the full per-widget semantics.

        + +
        +

        Custom Themes and Layouts

        For custom themes and layouts, see the customization guide.

        diff --git a/docs/customization.md b/docs/customization.md index a13aacd..3988134 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -37,13 +37,16 @@

        Location

        -

        Place theme files in:

        +

        Theme files live in the themes/ subfolder of the platform config +directory (the same tree as config.json and the layouts):

        -
        ~/.config/xtop/themes/*.jsonc
        -~/.config/xtop/themes/*.json
        +
        ~/.config/xtop/themes/*.jsonc                (Linux)
        +~/Library/Application Support/xtop/themes/   (macOS)
        +%APPDATA%\xtop\themes\                       (Windows)
         
        -

        The directory is created automatically when you save your config on quit.

        +

        The directory and the shipped theme files are created automatically on +first run; no manual copy is needed.

        Format

        @@ -168,24 +171,33 @@

        Starter Themes

        -

        The built-in default theme is x (almost-black background, purple-pink accents). It is compiled into the binary and always available.

        - -

        When you run xtop for the first time, it automatically creates ~/.config/xtop/themes/ with all extra themes embedded in the binary. No manual copy is needed.

        +

        xtop ships 12 themes. The x palette (almost-black +background, purple-pink accents) is compiled into the binary as the startup +fallback; all 12 definitions — including x and miami — +are embedded in the binary as seeding templates. The first run writes them +into the themes directory above, so every shipped theme is available without +copying anything.

        If you want to restore them later, copy from the repository:

        cp -r assets/themes/* ~/.config/xtop/themes/
        -

        Available themes: x, madrid, lahabana, paris, tokio, oslo, helsinki, berlin, london, praha, bogota, miami.

        +

        Available themes: x, berlin, bogota, +helsinki, lahabana, london, madrid, +miami, oslo, paris, praha, +tokio.

        -

        All theme definitions are documented in colors.md.

        +

        All theme palettes are documented in colors.md.

        Loading Order

          -
        1. Built-in miami theme (always available)
        2. -
        3. Themes from ~/.config/xtop/themes/ loaded alphabetically
        4. -
        5. If a custom theme has the same name as miami, it replaces the built-in
        6. +
        7. The compiled-in x palette (startup fallback, index 0).
        8. +
        9. Themes from the themes directory (seeded on first run) load on top; + a file reusing the name x overrides the compiled palette in + place.
        10. +
        11. If a custom theme has the same name as a shipped one, it replaces + it; new names are appended after the shipped set.

        Tips

        @@ -374,7 +386,7 @@ extra layouts.

        Notes

          -
        • If a widget name in your layout doesn't match any available widget, that area is silently skipped.
        • +
        • If a widget name in your layout doesn't match any available widget, that area is skipped and xtop prints a one-time warning to stderr (xtop: layout '<layout>' references unknown widget '<name>').
        • Nested splits can be arbitrarily deep, but very deep nesting may overflow small terminals.
        • The terminal must be at least 40×8 for any layout to render; smaller terminals show a warning.
        • Very small terminals (under 60×14) fall back to a minimal hardcoded layout (CPU + Memory gauges + process list).
        • @@ -382,9 +394,9 @@ extra layouts.

          Widget glyph style

          -

          Los charts (CPU/Memory/Network) y los bordes de los widgets se dibujan con -glifos por defecto (braille, bordes redondeados/unicode). Se pueden cambiar en -~/.config/xtop/config.json dentro de la clave style:

          +

          Charts (CPU/Memory/Network) and widget borders are drawn with glyph styles +you can change in config.json under the style key (see +configuration.md):

          {
             "theme": "x",
          @@ -399,17 +411,20 @@ glifos por defecto (braille, bordes redondeados/unicode). Se pueden cambiar en
           }
            -
          • charset: braille (por defecto), dot, block, half_block, bar.
          • -
          • borders: native (cada widget con su borde clásico, por defecto), rounded, double, plain, ascii (+-|).
          • -
          • widgets: override por widget (los nombres son los que usan los - layouts: header, cpu, memory, storage, - network, processes, disk_io, battery, gpu).
          • +
          • charset: braille (default), dot, block, half_block, bar.
          • +
          • borders: native (default; the classic single-line box-drawing frame), rounded, double, plain and ascii (both plain and ascii draw a pure ASCII +-| frame).
          • +
          • widgets: per-widget overrides. Keys are the widget names layouts + use: header, cpu, memory, storage, + network, processes, disk_io, battery, gpu. + Each entry accepts charset, borders and an optional + pack (widget pack to render that name with, e.g. "blocks"). + A global style.pack sets the pack for every widget without a + per-widget override.
          -

          Los estilos son solo de apariencia: la estructura de cada widget sigue -siendo la que dibuja el kernel. Un widget completamente nuevo (otra lógica o - renderer) se puede aportar como plugin (los renderers de plugins tienen - precedencia sobre los built-in).

          +

          Glyph styles only change the look: the data behind each widget is drawn by +the widget packs (see plugin.md for how a plugin adds +completely new renderers, which take precedence over packs).


          diff --git a/docs/features.md b/docs/features.md index 18f3c08..59e0629 100644 --- a/docs/features.md +++ b/docs/features.md @@ -55,13 +55,13 @@

          GPU

            -
          • GPU usage gauges (stub implementation, ready for NVIDIA/AMD support).
          • +
          • GPU usage gauges: real data on Linux (NVIDIA via nvidia-smi, AMD/Intel via /sys/class/drm); stub on macOS/Windows.

          Battery

            -
          • Battery charge level gauges (stub implementation, ready for laptop support).
          • +
          • Battery charge level gauges: real data on Linux (/sys/class/power_supply); stub on macOS/Windows.

          @@ -69,10 +69,10 @@

          Theming

            -
          • 13 ready-to-use color schemes built into the binary.
          • +
          • 12 color schemes: 12 JSONC theme files ship in assets/themes/ and are embedded in the binary as first-run seeding templates.
          • Custom themes defined as JSONC files with a 16-entry hex color palette.
          • Instant theme cycling with t (next) and T (previous).
          • -
          • Starter theme files ship in the assets/themes/ directory.
          • +
          • Palette reference in colors.md.

          @@ -101,8 +101,8 @@

          Persistence

            -
          • Current theme, layout mode, update interval, history points, and alert thresholds are saved automatically on quit.
          • -
          • Configuration is stored at ~/.config/xtop/config.json.
          • +
          • Current theme, layout, update interval, history points, alert thresholds and glyph style are saved automatically on quit.
          • +
          • Configuration is stored as config.json in the platform config dir: ~/.config/xtop/ on Linux, ~/Library/Application Support/xtop/ on macOS, %APPDATA%\xtop on Windows.

          diff --git a/docs/installation.md b/docs/installation.md index dd05e20..4c32128 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,8 +5,9 @@

          Table of Contents

            -
          • Quick Install (macOS/Linux)
          • +
          • Quick Install (Linux)
          • Quick Install (Windows)
          • +
          • macOS
          • Installer Options
          • Build from Source
          • Uninstall
          • @@ -15,9 +16,12 @@
            -

            Quick Install (macOS/Linux)

            +

            Quick Install (Linux)

            -

            The installer script automatically detects your distribution and installs all required dependencies, including Rust if needed.

            +

            The installer script detects the distribution and its package manager, +installs the build prerequisites (git and, when missing, the Rust toolchain +via rustup), clones the repository, builds it in release mode and installs +the binary to /usr/local/bin.

            Using curl

            @@ -37,6 +41,13 @@
            +

            macOS

            + +

            There is no dedicated macOS branch in install.sh yet; install a +Rust toolchain (rustup) and build from source as below.

            + +
            +

            Installer Options

            You can run the installer script with additional flags for more control:

            @@ -80,7 +91,10 @@ cd xtop

        Uninstall

        -

        macOS / Linux

        +

        The uninstallers remove the binary only; user configuration under the +config directory is kept.

        + +

        Linux

        curl -fsSL https://raw.githubusercontent.com/xtop-cli/xtop/main/install.sh | bash -s -- --uninstall
        diff --git a/docs/multi-repo.md b/docs/multi-repo.md index c22ba58..c8550df 100644 --- a/docs/multi-repo.md +++ b/docs/multi-repo.md @@ -1,108 +1,127 @@ # Multi-repo architecture (xtop-cli org) -> Estado: propuesta inicial. Este doc vive en el kernel pero describe todos los repos. +> Status: **live** (2026-09-04). This document is the ecosystem architecture +> RFC location: it describes how the xtop-cli organization is split into +> repos, how the pieces depend on each other, and where the design is headed. +> Detailed per-area docs live in each repo's `docs/` folder; the push order +> and milestone state live in the root `ROADMAP.md` of the workspace. -## Organización +## Organization -| Repo (xtop-cli) | Rol | Contenido | +| Repo (xtop-cli) | Role | Content | |---|---|---| -| `xtop` | **Kernel** — la app | monocrate `src/` por áreas (commands, config, plugins, providers, state, theme, ui). Nada más | -| `api` | **Contratos** | workspace: `crates/plugin-api`, `widget-api`, `effect-api`, `extension-api` → crates publicados `xtop-plugin-api`, `xtop-widget-api`, `xtop-effect-api`, `xtop-extension-api` | -| `layouts` | Layouts data-driven | repo `layouts`: crate `xtop-layout` (model + loader jsonc + modos, sin UI) + `layouts/default/` (7 built-ins) + `layouts/custom/` (comunidad, instalables) | -| `plugins` | Implementaciones de plugins | workspace `plugins/xtop-plugin-*` (1er miembro: samurai) | -| `effects` | Efectos visuales TUI | workspace `effects/xtop-effect-*` (+ `effects-lib` compartido) | -| `extensions` | Hooks/add-ons del kernel | workspace `extensions/xtop-extension-*` | -| `widgets` | Packs de widgets | repo `widgets`: pack base `xtop-widgets` + packs alternativos (`packs/xtop-widget-blocks`) + `custom/` comunidad, contra `xtop-widget-api`. El kernel solo conserva engine + estado | +| `api` | **Contracts** | workspace with the four contract crates: `xtop-plugin-api` (data model, plugin/host traits, `AlertThresholds`, `PluginWidget`), `xtop-widget-api` (pack registration + glyph helpers), `xtop-extension-api` (extension host), `xtop-effect-api` (frame effects) | +| `xtop` | **Kernel** | the app: single-crate binary, `src/` by areas (commands, config, plugins, providers, state, theme, ui). Consumes every other repo | +| `widgets` | **Renderers** | packs of widget renderers against `xtop-widget-api`: base pack `xtop-widgets` + alternative packs (`packs/xtop-widget-blocks`) + `custom/` community | +| `layouts` | **Arrangement** | `xtop-layout` crate: data-driven layout model + JSONC loader + layout modes, plus `layouts/default/` (7 built-ins) and `layouts/custom/` (community, installable) | +| `plugins` | **Functionality** | plugin implementations against `xtop-plugin-api` (first member: `xtop-plugin-samurai`) | +| `extensions` | **Kernel hooks** | server-style extensions against `xtop-extension-api` (`xtop-extension-mcp`) | +| `effects` | **Animation** | frame effects against `xtop-effect-api` (`xtop-effect-fade`: 500 ms fade-in from black) | -Layout local de desarrollo (repos hermanos, como hoy): +Local development layout (sibling checkouts in one folder, as in this +workspace): ``` -/home/x/xtop-cli/xtop/ - xtop/ api/ plugins/ effects/ extensions/ +/home/x/xtop-cli/ + api/ xtop/ widgets/ layouts/ plugins/ extensions/ effects/ ``` -## Principio: dependencias en árbol +## Dependency principle: contracts in `api`, consumers point up -Hoy el kernel define los traits de plugin y `xtop-plugin-samurai` depende de -`xtop-core`. Eso impide separar repos: el kernel es a la vez host y contrato. - -Objetivo (espejo de `xfetch-cli`): +Each consumer repo depends only on the contract crates in `api` — never on +the kernel — so every repo compiles standalone: ``` ┌────────────┐ - │ api │ crates puros de contrato (sin dep del kernel) + │ api │ pure contract crates (ratatui/serde only) └─────┬──────┘ - ┌────────────┼────────────────┐ - ▼ ▼ ▼ - ┌───────────┐ ┌─────────────┐ ┌──────────────┐ - │ kernel │ │ plugins/ │ │ effects/ │ - │ xtop │ │ effects/ │ │ extensions/ │ - │ (host) │ │ extensions │ │ │ - └───────────┘ └─────────────┘ └──────────────┘ + ┌───────────┼───────────────┬──────────────┐ + ▼ ▼ ▼ ▼ + ┌──────────┐ ┌───────────┐ ┌────────────┐ ┌──────────────┐ + │ xtop │ │ widgets/ │ │ layouts/ │ │ plugins/ │ + │ (kernel) │ │ effects/ │ │ extensions │ │ (samurai) │ + └──────────┘ └───────────┘ └────────────┘ └──────────────┘ ``` -- **api**: tipos puros + protocolo (manifest, capabilities, errores, snapshot, - provider trait, widget registration, frames de efecto, hooks de extensión). - Depende solo de `ratatui`/`serde`. Publicado a crates.io en su momento. -- **kernel**: implementa el *host* (PluginManager, CompositeProvider, pipeline - de render, hooks) contra los tipos de api. Sin plugins sigue compilando: - la integración es opcional. -- **plugins/effects/extensions**: consumen api únicamente → cada repo compila - standalone y nunca depende del kernel. - -## Qué se mueve de xtop-core a api (Fase 1) - -Candidatos directos (tipo "contrato"): - -- `domain/plugin.rs` → `xtop-plugin-api`: `PluginCapability`, `PluginManifest`, - `PluginError`, `PluginContext` (vía un trait de host, no `AppState` directo), - `WidgetRegistration`, trait `Plugin`. -- `domain/system_info.rs` (trait `SystemDataProvider`) + tipos de datos de - `domain/metrics.rs` (`SystemSnapshot`, `ProcessInfo`, `SystemInfo`) → - `xtop-plugin-api` o crate de datos compartido, porque providers/widgets - externos necesitan esos tipos sin importar el kernel. - -Se queda en `xtop-core`: sysinfo real, `AppState`, `PluginManager`, -config/themes/layouts, keybindings, alerts. El kernel reexporta los tipos de -api para no romper los callers internos durante la transición. - -Nota `PluginContext`: hoy expone `&mut AppState` (estado vivo). Para que el -contrato sea externo, `PluginContext` debe moverse al host: api define un trait -`HostContext`/`XtapContext` que el kernel implementa y el plugin consume. -(La otra vía —estado vivo por valor— choca con el modelo runtime futuro.) - -## Formas de integración (modular opcional) - -| Nivel | Mecanismo | Uso | +- **`api`**: pure types + protocols (manifests, capabilities, errors, + snapshot model, provider/widget/extension/effect contracts). Depends only + on `ratatui`/`serde`. Not published to crates.io yet (see Roadmap §6 + follow-ups). +- **`xtop` kernel**: hosts the ecosystem — implements `HostState` / + `WidgetState` / `ExtensionHost` for its live state, runs `PluginManager` + and the composite provider, renders layouts by resolving widget names into + the packs, and drives effects (feature-gated). Builds fine without any + optional feature: `cargo build --no-default-features` is the pure core. +- **`widgets` / `layouts` / `plugins` / `extensions` / `effects`**: consume + only `api` types; each repo compiles standalone and never depends on the + kernel. + +## Integration modes + +| Level | Mechanism | Use | |---|---|---| -| Compile-time (hoy) | feature flag + dep opcional sobre api/plugins | Built-ins del kernel | -| Dev-time | `xtop plugin install ` (clona, compila, registra) | Primeros pasos | -| Runtime (futuro) | discovery de binarios `xtop-plugin-*` / `xtop-effect-*` / `xtop-extension-*` en dirs de config + env `XTOP_*_DEV_DIR` | Terceros, sin recompilar | - -El kernel nunca exige ningún repo externo: `cargo build --release ---no-default-features` = core puro. - -## Fases - -1. **F0 (hecha)**: org `xtop-cli`, repos creados (`xtop` movido con historial; - `api`, `plugins`, `effects`, `extensions` iniciados), clones locales. -2. **F1**: api crates esqueleto; extraer tipos contrato de xtop-core; kernel - dependiendo de `../api` (path) y verde de nuevo. -3. **F2**: mover samurai → `plugins/` (subtree split con historia); convertir - su dep a api (sin xtop-core); kernel: feature apunta al repo plugins - (path dev → git dep); actualizar URLs `xtop-cli/xtop` → `xtop-cli/*` - en help, docs, install.sh y CI; `plugin scaffold` apunta al nuevo repo. -4. **F3**: effects: `effect-api` + runner en el TUI + primer efecto demo. -5. **F4**: extensions: `extension-api` (hooks pre/post render, config, tema, - layout) + primer add-on demo. -6. **F5**: publicar api a crates.io; deps registry versionadas + tags; CI por - repo; release del kernel. - -## Deuda detectada al mover - -- 9 archivos del kernel aún referencian `xtop-cli/xtop` (help, docs, - install.sh/ps1, PKGBUILD, README, CONTRIBUTING). -- `cmd_plugin_install`/`cmd_plugin_list` asumen plugin dentro del repo kernel - (`plugins/` miembro del workspace) → deberán apuntar al repo `plugins`. -- LICENSE del kernel dice "Copyright (c) 2025 Xscriptor" → decidir si pasa a - la org xtop-cli. +| Compile-time (today) | Cargo git dependencies + optional feature flags | Every integration: samurai plugin, mcp extension, blocks pack, fade effect | +| Dev-time | `xtop plugin install ` (clones, self-edits the kernel `Cargo.toml`, runs `cargo check`) | First steps with a plugin repo | +| Runtime (future, RFC) | binary/ABI discovery of `xtop-plugin-*` / `xtop-effect-*` / `xtop-extension-*` in config dirs + `XTOP_*_DEV_DIR` | Third parties without recompiling — see the root ROADMAP §7 deferred list | + +The kernel never requires any external repo at runtime: optional ecosystem +pieces are Cargo features (`plugin-samurai`, `mcp-extension`, +`widget-blocks`, `effects`); contract crates (the four `xtop-*-api` crates +plus `xtop-widgets`, `xtop-layout`) are unconditional because the kernel's +chrome and state views are written against them. + +## Development flow (temporary path deps) + +The ecosystem repos are edited before they are pushed. To compile a consumer +against un-pushed sibling state, its `Cargo.toml` temporarily replaces the +git dependency with a path dependency (`path = "../api/crates/plugin-api"`, +`path = "../widgets"`, ...), or — when the consumer's own manifest must stay +untouched — a temporary `[patch."https://github.com/xtop-cli/"]` +section redirects the git sources to the sibling checkouts. All temporary +overrides are removed before the owner pushes; final manifests carry the +floating git deps shown above. + +## Why the split exists + +The kernel was originally a workspace of kernel-owned crates +(`xtop-core`/`xtop-tui`/`xtop-cli`), then a monocrate with plugins inside. +Each customization axis grew into its own repo so that: + +- a contributor can ship a widget pack, layout, plugin, extension or effect + without touching the kernel code base; +- every repo compiles against the api contracts alone (testable in + isolation, no kernel import); +- the kernel stays a thin host: metrics model, layouts and widget renderers + all come from the ecosystem crates. + +Single-source rules that keep the split honest (see the root ROADMAP +"decisions" section): + +- the metrics model and plugin protocol exist only in `xtop-plugin-api`; +- widget registration and glyph/style mapping exist only in `xtop-widget-api` + (the plugin-side widget is `xtop_plugin_api::PluginWidget`); +- ecosystem constants live at the producer (`xtop-plugin-samurai` exports + `PLUGIN_ID` and its 12 action names; `xtop-extension-mcp` builds its tool + table from them). + +## Phases + +1. **F0 (done)**: org `xtop-cli`, repos created, local clones in one folder. +2. **F1 (done)**: `api` contract crates extracted from the kernel's domain + model; kernel + siblings consume them. +3. **F2 (done)**: samurai moved to the `plugins` repo; MCP moved to + `extensions`; kernel features point at the sibling repos via git deps. +4. **F3 (done)**: `effects` workspace with the fade effect; kernel wires it + behind the optional `effects` feature. +5. **F4 (done)**: extension host contract + `xtop-extension-mcp` as the + first server-style extension. +6. **F5 (pending)**: publish the api crates to crates.io; tag and pin git + deps; per-repo CI and kernel releases (see the root ROADMAP §6/§7). + +## Open RFC topics + +- Runtime dynamic discovery (ABI or directory-based loading) — explicitly + deferred in the root ROADMAP §7 until the compile-time feature model stops + being coherent. +- Publishing: versioning scheme for the contract crates and the pinning + strategy of every consumer. diff --git a/docs/plugin.md b/docs/plugin.md index f100e89..d8d8974 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -1,64 +1,80 @@

        Plugin System

        -

        xtop has a compile-time plugin system based on Rust feature flags. Plugins live in the plugins/ directory and are registered into the workspace as optional dependencies.

        +

        xtop hosts plugins: extra functionality shipped as separate +crates that implement the contract in xtop-plugin-api (a crate of +the xtop-cli/api repo). Plugins are compile-time: the kernel wires +them through Cargo git dependencies and feature flags, never through runtime +discovery. The built-in plugin is xtop-plugin-samurai +(see multi-repo.md for the ecosystem layout).

        -

        You can build xtop without any plugins:

        +

        You can build xtop without any plugin or extension:

        cargo build --release --no-default-features
        -

        Or with a specific set of plugins:

        +

        Or with the default set (samurai plugin + MCP extension):

        -
        cargo build --release --features plugin-samurai
        +
        cargo build --release

        Architecture

        -

        The plugin system has four main components:

        +

        The plugin system has four kernel components:

        - + - - - + + + - - + + - - - + + + - - - + + +
        ComponentLocationPurpose
        ComponentKernel locationPurpose
        Plugin traitxtop-core::domain::pluginInterface every plugin must implementPlugin trait + contract typesxtop_plugin_api (crate, external)Interface every plugin implements; manifest, capabilities, errors, data model
        PluginManagerxtop-core::application::plugin_managerLoads, ticks, and dispatches events to pluginssrc/plugins/manager.rsRegisters, ticks and dispatches events to plugins
        CompositeProviderxtop-core::infrastructure::composite_providerMerges data from primary provider + plugin providersHostState implsrc/plugins/host.rsKernel-side view of the live state plugins may touch
        WidgetRegistrationxtop-core::domain::pluginAllows plugins to register custom TUI widgetsCompositeProvidersrc/providers/composite.rsMerges the kernel provider with plugin data providers
        +

        Plugins never depend on the kernel: they see state only through +PluginContext, which is built over the HostState trait. +Plugin widgets render over &dyn HostState through +xtop_plugin_api::PluginWidget (distinct from the widget-pack +registration in xtop-widget-api, which draws over +WidgetState).

        +

        The Plugin Trait

        pub trait Plugin: Debug + Send {
        -    fn manifest(&self) -> PluginManifest;
        -    fn on_enable(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
        -    fn on_disable(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
        -    fn on_tick(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
        -    fn on_key(&mut self, ctx: &mut PluginContext, key: &str) -> Result<bool, PluginError>;
        -    fn data_provider(&self) -> Option<Box<dyn SystemDataProvider>>;
        -    fn widget(&self) -> Option<WidgetRegistration>;
        -    fn execute(&mut self, ctx: &mut PluginContext, action: &str, params: &str) -> Result<String, PluginError>;
        +    fn manifest(&self) -> PluginManifest;
        +    fn on_enable(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
        +    fn on_disable(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
        +    fn on_tick(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
        +    fn on_key(&mut self, ctx: &mut PluginContext, key: &str) -> Result<bool, PluginError>;
        +    fn data_provider(&self) -> Option<Box<dyn SystemDataProvider>>;
        +    fn widget(&self) -> Option<PluginWidget>;
        +    fn execute(&mut self, ctx: &mut PluginContext, action: &str, params: &str)
        +        -> Result<String, PluginError>;
         }
        +

        All methods except manifest() have default implementations, so a +minimal plugin only declares its manifest.

        + @@ -67,64 +83,75 @@ - - - - - + + + + +
        MethodDefaultCalled When
        manifest()requiredAny time metadata is needed
        on_enable()no-opPlugin is registered at startup
        on_disable()no-opxtop shuts down
        on_tick()no-opEvery update cycle (~1s)
        on_key()returns falseKey press (consumes if returns true)
        data_provider()NoneStartup (merged into CompositeProvider)
        widget()NoneEvery tick (refreshes widget registry)
        execute()UnknownActionExternal agent (AI, CLI, IPC) invokes command
        on_tick()no-opEvery update cycle (~1s by default)
        on_key()falseKey press in Normal mode (returns true to consume)
        data_provider()NoneStartup (merged into the CompositeProvider)
        widget()NoneAfter every tick (refreshes the plugin widget map)
        execute()UnknownActionExternal agent (AI, CLI, MCP) invokes a command
        -

        PluginCapability

        +

        PluginManifest

        -

        Each plugin declares what it needs via manifest().capabilities:

        +

        Each plugin declares its identity and needs in +manifest().capabilities:

          -
        • ReadSystemInfo -- access system metrics
        • +
        • ReadSystemInfo -- read system metrics
        • KillProcesses -- terminate processes
        • -
        • ModifyConfig -- change themes, layouts, thresholds
        • +
        • ModifyConfig -- change themes, layouts, thresholds, intervals
        • RenderWidgets -- register custom TUI widgets
        • -
        • Custom(&str) -- anything not covered above
        • +
        • Custom(String) -- anything not covered above

        PluginContext

        -

        Safe, limited access to application state:

        - -
        ctx.snapshot()             // Full SystemSnapshot
        -ctx.top_processes(n)       // Top N processes by CPU
        -ctx.kill_process(pid)      // Kill process by PID
        -ctx.set_alert_thresholds(cpu, mem, disk)
        -ctx.set_theme_by_name("tokio")
        -ctx.set_layout_by_name("Dashboard")
        -ctx.set_update_interval(500)
        -ctx.system_info()          // Hostname, OS, kernel
        -ctx.data_dir()             // ~/.config/xtop/plugins/<id>/
        +

        Safe, limited access to application state. Capability-gated reads return +Result; writes require the matching capability and fail with a +PluginError::Recoverable otherwise:

        + +
        ctx.snapshot()?                 // Full SystemSnapshot (needs ReadSystemInfo)
        +ctx.top_processes(n)?           // Top n processes by CPU, sorted desc
        +ctx.system_info()?              // Hostname, OS, kernel (needs ReadSystemInfo)
        +ctx.kill_process(pid)?          // Kill process by PID (needs KillProcesses)
        +ctx.set_alert_thresholds(cpu, mem, disk)?  // (needs ModifyConfig)
        +ctx.set_theme_by_name("tokio")? // (needs ModifyConfig)
        +ctx.set_layout_by_name("CPU Focus")?       // (needs ModifyConfig)
        +ctx.set_update_interval(500)?   // (needs ModifyConfig)
        +ctx.alerts()                    // Current alert thresholds
        +ctx.config()                    // Theme, layout, interval, hostname
        +ctx.data_dir()                  // Host-provided plugin data dir

        -

        CompositeProvider

        +

        Data Providers

        -

        The CompositeProvider wraps the primary SysinfoProvider and merges data from plugin providers:

        +

        The kernel samples the system with its own provider +(SysinfoProvider in src/providers/sysinfo/) and composes +plugin providers through CompositeProvider:

          -
        • refresh_all() refreshes all providers
        • -
        • snapshot() delegates to primary for CPU, memory, disks, networks, processes
        • -
        • gpu_info(), batteries(), docker_info() check extras if primary returns empty
        • -
        • kill_process() tries primary first, then extras
        • +
        • refresh_all() refreshes the primary provider and every extra
        • +
        • snapshot() delegates to the primary
        • +
        • disk_io(), batteries(), gpu_info() and + system_info() use the primary result when non-empty, otherwise + the first non-empty extra provider result
        • +
        • kill_process() tries the primary first, then the extras

        -

        Widget Registration

        +

        Plugin Widgets

        -

        Plugins can register custom widgets via widget():

        +

        A plugin can register one custom widget via widget(). The render +closure receives the plugin view of the state (&dyn HostState) +and draws with plain ratatui:

        -
        fn widget(&self) -> Option<WidgetRegistration> {
        -    Some(WidgetRegistration {
        +
        fn widget(&self) -> Option<PluginWidget> {
        +    Some(PluginWidget {
                 name: "samurai".to_string(),
                 render: Arc::new(|f, state, area| {
        -            // Draw using ratatui
        +            // Draw using ratatui; `state` is &dyn HostState.
                 }),
             })
         }
        @@ -143,7 +170,10 @@ ctx.data_dir() // ~/.config/xtop/plugins/<id>/
        } } -

        Plugin widgets take precedence over built-in widgets with the same name.

        +

        Plugin widgets take precedence over widget-pack widgets with the same name +(the kernel resolves plugin widgets first at render time). A layout +referencing a name no pack and no plugin provides prints a one-time warning +to stderr.


        @@ -156,11 +186,11 @@ ctx.data_dir() // ~/.config/xtop/plugins/<id>/ xtop plugin list - List plugins wired into the kernel Cargo.toml + List plugins wired into the kernel Cargo.toml (feature entries with dep:<name>) xtop plugin install <name> - Install a plugin from github.com/xtop-cli/plugins + Install a plugin by name from github.com/xtop-cli/plugins xtop plugin install <url> @@ -168,32 +198,35 @@ ctx.data_dir() // ~/.config/xtop/plugins/<id>/ xtop plugin scaffold <name> - Create a new plugin crate template in plugins-dev/ + Create a new plugin crate template in plugins-dev/ (git-ignored) -

        Install Flow

        +

        Install Flow (real behavior)

        -

        When running xtop plugin install samurai:

        +

        xtop plugin install does not download a binary and does +not register anything at runtime. It edits the kernel's own +Cargo.toml (a self-modifying-source workflow):

        1. Resolves the source repo: github.com/xtop-cli/plugins for a name, or the given URL
        2. -
        3. Clones it (shallow, sparse)
        4. -
        5. Locates the xtop-plugin-<name> crate inside the clone
        6. -
        7. Adds an optional git dependency + feature flag in the kernel's root Cargo.toml - (same pattern as the built-in xtop-plugin-samurai)
        8. -
        9. Runs cargo check and cleans up temporary files
        10. +
        11. Clones it (shallow, sparse) into a temp dir
        12. +
        13. Locates the xtop-plugin-<name> crate inside the clone + (repo root, or plugins//crates/ subfolders)
        14. +
        15. Adds an optional git dependency + a feature flag to the kernel's root + Cargo.toml (the same pattern the built-in + xtop-plugin-samurai uses)
        16. +
        17. Runs cargo check to verify the manifest resolves, then cleans up
        -

        The plugin is registered but not enabled by default. To enable it:

        +

        The plugin is registered but not enabled by default. To enable +it, add its feature to the default list in [features] in the +kernel Cargo.toml (or build with --features <name>) and +recompile. Every installed plugin also needs a registration line in +src/commands/share/bootstrap.rs under its feature flag.

        -
          -
        • Build with --features plugin-<name> for a one-off build
        • -
        • Add it to the default list in [features] in the root Cargo.toml to enable permanently
        • -
        - -
        # Build xtop with samurai plugin enabled
        +
        # Build xtop with samurai plugin enabled (default already includes it)
         cargo build --release --features plugin-samurai
         
         # Build xtop with samurai + another plugin
        @@ -201,14 +234,20 @@ cargo build --release --features "plugin-samurai,plugin-mything"

        -

        MCP Server for AI Agents

        +

        Extensions: the MCP Server

        -

        When the plugin-samurai feature is enabled, xtop can run an MCP (Model Context Protocol) server on stdio:

        +

        Extensions are the kernel's server-style hooks (contract: +xtop-extension-api). The shipped extension is the MCP +(Model Context Protocol) server in the xtop-cli/extensions repo +(xtop-extension-mcp), which exposes the hosted plugins' actions as +MCP tools over stdio. It is compiled in when the mcp-extension +feature is on (part of the default features).

        xtop mcp
        -

        This exposes Samurai's commands as MCP tools that any AI assistant can call. -Compatible clients include Claude Desktop, Cline, Cursor, and Continue.dev.

        +

        The MCP tools are executed against the samurai plugin, so the +plugin-samurai feature is required too. Compatible clients include +Claude Desktop, Cline, Cursor, and Continue.dev.

        Claude Desktop configuration

        @@ -229,9 +268,10 @@ echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"system_sum # Interactive session xtop mcp
        -

        Monitoreo activo (polling)

        - -

        La IA puede llamar system_summary o alerts.status periodicamente. Para monitoreo proactivo (push), haria falta implementar MCP Resources + notificaciones.

        +

        The MCP extension depends on xtop-plugin-samurai at compile time: +the plugin id and the 12 action names are single-sourced constants +(PLUGIN_ID, actions::*), so the tool table can never drift +from the plugin implementation.


        @@ -247,7 +287,7 @@ xtop mcp plugins//crates/, e.g. github.com/you/xtop-plugin-mything).

      17. -

        Install it into the kernel (adds optional git dependency + feature flag in the root Cargo.toml):

        +

        Install it into the kernel (adds an optional git dependency + feature flag in the root Cargo.toml):

        xtop plugin install https://github.com/you/xtop-plugin-mything

        Equivalent manual edit:

        [dependencies]
        @@ -272,6 +312,10 @@ use xtop_plugin_mything::MythingPlugin;
           
      +

      Contract details for plugin authors (manifest, capabilities, error types, +widgets) live in the api repo docs; the samurai plugin in +xtop-cli/plugins is the reference implementation.

      +

      diff --git a/docs/usage.md b/docs/usage.md index f226ba6..fccda89 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -96,7 +96,7 @@

      Disk I/O -- Read and write speeds per disk device in bytes per second.

    16. -

      Processes -- Scrolling list of the top 50 processes sorted by CPU usage, with live search filtering by process name.

      +

      Processes -- Scrolling list of processes sorted by CPU usage (top 200 by default; override with XTOP_MAX_PROCESSES), with live search filtering by process name.

    17. GPU -- GPU usage gauges (available on supported hardware).

      @@ -114,9 +114,9 @@
      -

      Command Palette

      + -

      xtop provides an interactive search overlay for filtering processes in real time:

      +

      Search filters the process list in real time:

      • Press / to open the search bar at the top of the process list.
      • @@ -125,7 +125,18 @@
      • A centered overlay with a /query_ indicator shows the current search input.
      -

      The help overlay (?) serves as a quick-reference command palette for all available keybindings and actions.

      +
      + +

      Command Palette

      + +

      Press ctrl+p to open the command palette, a searchable list of +actions (also reachable with the command_palette keybinding). +The palette has three pages: Main (go to themes/layouts, +toggle or cycle full-screen, search, help, cycle the process sort, random +theme, exit), Themes (jump to any loaded theme) and +Layouts (jump to any layout). Type to filter, use +up/down to move, Enter to run the selected +action and Esc to close.


      @@ -151,19 +162,23 @@ - Wider than 100 cols and taller than 30 rows + Dashboard mode, 100+ cols and 28+ rows Full dashboard layout with 2 columns - Wider than 80 cols and taller than 24 rows + Dashboard mode, 80–99 cols or under 28 rows Compact layout - Narrower than 80 cols + Dashboard mode, narrower than 80 cols Vertically stacked layout - Narrower than 60 cols or shorter than 18 rows + Other modes + The requested mode layout (Vertical, Horizontal, CPU/Memory/Network/Process Focus) + + + Narrower than 60 cols or shorter than 14 rows Minimal layout: CPU, Memory, and Processes only diff --git a/install.ps1 b/install.ps1 index ae2ac4a..f468335 100644 --- a/install.ps1 +++ b/install.ps1 @@ -4,7 +4,7 @@ $ErrorActionPreference = "Stop" $AppName = "xtop" -$RepoUrl = "https://github.com/xtop-cli/xtop.git" # Replace with actual repo URL +$RepoUrl = "https://github.com/xtop-cli/xtop.git" $InstallDir = "$env:USERPROFILE\.cargo\bin" # Standard Cargo bin location Write-Host "Installing $AppName..." -ForegroundColor Green diff --git a/install.sh b/install.sh index 20f0022..696ebfa 100644 --- a/install.sh +++ b/install.sh @@ -8,7 +8,7 @@ set -euo pipefail APP_NAME="xtop" REPO_URL="https://github.com/xtop-cli/xtop.git" INSTALL_DIR="/usr/local/bin" -VERSION="1.0.0" +VERSION="0.3.0" # Colors GREEN='\033[0;32m' @@ -83,16 +83,16 @@ get_build_deps() { echo "base-devel git" ;; apt) - echo "build-essential git pkg-config libssl-dev" + echo "build-essential git" ;; dnf|yum) - echo "gcc gcc-c++ make git pkg-config openssl-devel" + echo "gcc gcc-c++ make git" ;; zypper) - echo "gcc gcc-c++ make git pkg-config libopenssl-devel" + echo "gcc gcc-c++ make git" ;; apk) - echo "build-base git pkgconfig openssl-dev" + echo "build-base git" ;; *) echo "" @@ -114,7 +114,7 @@ install_build_deps() { if [ -z "$deps" ]; then log_warn "Unknown package manager. Please install build dependencies manually." - log_warn "Required: C compiler, git, pkg-config, OpenSSL development headers" + log_warn "Required: git and a Rust toolchain (rustup installs it automatically)" return 1 fi @@ -203,22 +203,8 @@ check_all_deps() { ((missing++)) fi - # Check for C compiler - if command -v gcc &> /dev/null || command -v clang &> /dev/null; then - log_success "C compiler found" - else - log_warn "No C compiler found (gcc or clang)" - ((missing++)) - fi - - # Check for pkg-config - if command -v pkg-config &> /dev/null; then - log_success "pkg-config found" - else - log_warn "pkg-config not found" - ((missing++)) - fi - + # A C toolchain is not required: xtop and its dependencies are pure Rust. + # git is checked above; cargo is the only remaining hard requirement. if [ $missing -gt 0 ]; then log_warn "$missing dependencies missing" return 1 diff --git a/scripts/audit.sh b/scripts/audit.sh index ed86744..3d407a8 100755 --- a/scripts/audit.sh +++ b/scripts/audit.sh @@ -5,10 +5,14 @@ # - cfg(target_os) outside platform/ trees : must be 0 # - files over 600 lines : must be 0 # - TODO/FIXME/XXX/HACK markers : must be 0 -# - `pub use ...::*` wildcard re-exports : <= 30 (down from 30+) +# - `pub use ...::*` wildcard re-exports : <= 30 # - LOC per top-level area : <= 2400 +# - dead src/commands/plugins_dir_tmp/ tree : must be absent +# - theme seeds embedded (miami included, 12 total): must hold # # Non-failing metrics are printed for tracking (LOC, module count). +# Not implemented (deferred): module dependency graph/cycles, unused-pub +# detection. See the ROADMAP R3 entry for the follow-up note. set -u cd "$(dirname "$0")/.." || exit 1 SRC=src @@ -58,6 +62,22 @@ main_loc=$(wc -l < "$SRC/main.rs") total_loc=$((total_loc + main_loc)) echo "main.rs: $main_loc / total kernel: $total_loc" +# --- dead pre-monocrate leftover tree ------------------------------------------------- +if [ -d "$SRC/commands/plugins_dir_tmp" ]; then + echo "dead src/commands/plugins_dir_tmp/ tree still present (must be absent)" + fail=1 +else + echo "dead src/commands/plugins_dir_tmp/ tree: absent (ok)" +fi + +# --- theme seeds match the shipped set ------------------------------------------------ +seed_file="$SRC/commands/share/assets.rs" +seeds=$(grep -c 'include_str!("../../../assets/themes/' "$seed_file") +miami_seeded=$(grep -c '"miami", include_str!("../../../assets/themes/miami.jsonc")' "$seed_file") +echo "embedded theme seeds: $seeds (expect 12, miami included)" +[ "$seeds" -eq 12 ] || fail=1 +[ "$miami_seeded" -ge 1 ] || { echo " ^ miami missing from the embedded seeds"; fail=1; } + # --- module graph sanity (imports of kernel areas from lower layers) ------------------- echo "cross-area imports (info):" grep -rn "use crate::" "$SRC" --include='*.rs' | awk -F'::' '{print $0}' | wc -l | xargs echo " total use crate:: lines:" diff --git a/scripts/ci.sh b/scripts/ci.sh index fcccb12..9574383 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -45,7 +45,8 @@ test() { } no_default() { - echo "==> check core only (built without the samurai plugin and mcp)" + echo "==> check core only (no default features: samurai plugin, mcp" + echo " extension, blocks pack and effects are all excluded)" cargo check --no-default-features } diff --git a/src/commands/plugins_dir_tmp/mod.rs b/src/commands/plugins_dir_tmp/mod.rs deleted file mode 100644 index 168db52..0000000 --- a/src/commands/plugins_dir_tmp/mod.rs +++ /dev/null @@ -1,368 +0,0 @@ -//! Plugin management subcommands (list, install, scaffold). - -use std::fs; - -/// Handle `xtop plugin ` from the parsed argument vector. -pub fn plugin_command(args: &[String]) -> anyhow::Result<()> { - if args.len() < 3 { - eprintln!("Usage: xtop plugin "); - return Ok(()); - } - match args[2].as_str() { - "list" => { - cmd_plugin_list(); - Ok(()) - } - "install" => { - if args.len() < 4 { - eprintln!("Usage: xtop plugin install "); - return Ok(()); - } - cmd_plugin_install(&args[3]) - } - "scaffold" => { - if args.len() < 4 { - eprintln!("Usage: xtop plugin scaffold "); - return Ok(()); - } - cmd_plugin_scaffold(&args[3]) - } - _ => { - eprintln!("Unknown plugin subcommand: {}", args[2]); - Ok(()) - } - } -} - -use std::path::PathBuf; - -fn is_git_url(s: &str) -> bool { - s.contains("://") || s.contains("github.com") || s.contains("git@") -} - -fn cmd_plugin_list() { - let workspace_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("Cargo.toml"); - - let content = match fs::read_to_string(&workspace_path) { - Ok(c) => c, - Err(e) => { - eprintln!("Error reading workspace Cargo.toml: {e}"); - return; - } - }; - - // Parse workspace members for plugin crates - let mut in_members = false; - let mut plugins: Vec = Vec::new(); - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("members") { - in_members = true; - continue; - } - if in_members { - if trimmed == "]" { - break; - } - let name = trimmed.trim_matches(',').trim().trim_matches('"'); - if name.starts_with("plugins/xtop-plugin-") || name.starts_with("crates/xtop-plugin-") { - plugins.push(name.to_string()); - } - } - } - - if plugins.is_empty() { - println!("No plugins installed."); - return; - } - println!("Installed plugins:"); - for p in &plugins { - println!(" {p}"); - } -} - -fn cmd_plugin_install(name_or_url: &str) -> anyhow::Result<()> { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_dir = manifest_dir.parent().unwrap().parent().unwrap(); - let workspace_toml = workspace_dir.join("Cargo.toml"); - let cli_toml = manifest_dir.join("Cargo.toml"); - let plugins_dir = workspace_dir.join("plugins"); - - let tmp = std::env::temp_dir().join("xtop-plugin-install"); - let _ = fs::remove_dir_all(&tmp); - - let repo_url: &str; - let mut plugin_subdir: String = String::new(); - - if is_git_url(name_or_url) { - // URL-based: clone the repo directly - repo_url = name_or_url; - println!("Cloning {repo_url} ..."); - let status = std::process::Command::new("git") - .args(["clone", repo_url, tmp.to_str().unwrap()]) - .status() - .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?; - if !status.success() { - anyhow::bail!("git clone failed"); - } - } else { - // Name-based: look in xtop repo's plugins/ directory - repo_url = "https://github.com/xtop-cli/xtop.git"; - let candidate_names = [ - format!("plugins/xtop-plugin-{name_or_url}"), - format!("plugins/{name_or_url}"), - ]; - println!("Looking for plugin '{name_or_url}' in {repo_url} ..."); - let status = std::process::Command::new("git") - .args([ - "clone", - "--depth", - "1", - "--filter=blob:none", - "--sparse", - repo_url, - tmp.to_str().unwrap(), - ]) - .status() - .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?; - if !status.success() { - anyhow::bail!("git clone failed"); - } - - // Try each candidate path - let mut found = false; - for candidate in &candidate_names { - if tmp.join(candidate).join("Cargo.toml").exists() { - plugin_subdir = candidate.clone(); - found = true; - break; - } - } - if !found { - let _ = fs::remove_dir_all(&tmp); - anyhow::bail!( - "Plugin '{name_or_url}' not found in plugins/. \ - Tried: {}", - candidate_names.join(", ") - ); - } - println!("Found plugin at {plugin_subdir}"); - } - - // --- Determine the plugin source directory --- - let plugin_src = if plugin_subdir.is_empty() { - // URL-based: cloned repo root - tmp.clone() - } else { - // Name-based: subdirectory within cloned xtop repo - tmp.join(&plugin_subdir) - }; - - // --- Read the plugin's Cargo.toml to get the package name --- - let plugin_toml_path = plugin_src.join("Cargo.toml"); - let plugin_toml_content = fs::read_to_string(&plugin_toml_path) - .map_err(|e| anyhow::anyhow!("No Cargo.toml found: {e}"))?; - let plugin_pkg: toml::Value = plugin_toml_content - .parse() - .map_err(|e| anyhow::anyhow!("Invalid Cargo.toml: {e}"))?; - - let pkg_name = plugin_pkg - .get("package") - .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) - .ok_or_else(|| anyhow::anyhow!("package.name not found in plugin Cargo.toml"))?; - - let feature_name = pkg_name.replace('-', "_"); - let plugin_dir_name = pkg_name.replace('-', "_"); - - println!("Package name: {pkg_name}"); - - // --- Copy into local plugins/ directory --- - let target_dir = plugins_dir.join(&plugin_dir_name); - if target_dir.exists() { - anyhow::bail!( - "Plugin '{}' already exists at plugins/{plugin_dir_name}", - pkg_name - ); - } - fs::create_dir_all(&plugins_dir)?; - cp_recursive(&plugin_src, &target_dir)?; - - // --- Add to workspace Cargo.toml --- - let ws_content = fs::read_to_string(&workspace_toml)?; - let member_entry = format!(" \"plugins/{plugin_dir_name}\""); - if ws_content.contains(&member_entry) { - anyhow::bail!("Already in workspace"); - } - // Insert before the closing bracket of members - let samurai_entry = " \"plugins/xtop-plugin-samurai\","; - let new_ws = if ws_content.contains(samurai_entry) { - ws_content.replace(samurai_entry, &format!("{samurai_entry}\n{member_entry},")) - } else { - // Fallback: insert before the closing ] of members - ws_content.replacen("]", &format!(" {member_entry},\n]"), 1) - }; - fs::write(&workspace_toml, &new_ws)?; - - // --- Add to xtop-cli Cargo.toml --- - let cli_content = fs::read_to_string(&cli_toml)?; - - // Build dependency path relative to crates/xtop-cli/ - let dep_path = format!("../../plugins/{plugin_dir_name}"); - let dep_line = format!("{pkg_name} = {{ path = \"{dep_path}\", optional = true }}"); - - if !cli_content.contains(&dep_line) { - // Find the last optional plugin dependency and insert after it - let marker = "# Optional plugins (behind feature flags)"; - let new_cli = cli_content.replace(marker, &format!("{marker}\n{dep_line}")); - fs::write(&cli_toml, &new_cli)?; - } - - // Add feature flag - let feature_line = format!("{feature_name} = [\"dep:{pkg_name}\"]"); - let cli_content2 = fs::read_to_string(&cli_toml)?; - if !cli_content2.contains(&feature_line) { - let samurai_feature = "plugin-samurai = [\"dep:xtop-plugin-samurai\"]"; - let new_cli2 = if cli_content2.contains(samurai_feature) { - cli_content2.replace( - samurai_feature, - &format!("{samurai_feature}\n{feature_line}"), - ) - } else { - cli_content2.replacen("[features]", &format!("[features]\n{feature_line}"), 1) - }; - fs::write(&cli_toml, &new_cli2)?; - } - - // --- Rebuild --- - println!("Building xtop with {pkg_name} ..."); - let build = std::process::Command::new("cargo") - .args(["build", "--release"]) - .current_dir(workspace_dir) - .status() - .map_err(|e| anyhow::anyhow!("cargo build failed: {e}"))?; - if !build.success() { - anyhow::bail!("Build failed. Check the plugin's compatibility."); - } - - // --- Cleanup --- - let _ = fs::remove_dir_all(&tmp); - - println!(); - println!("Plugin '{pkg_name}' installed successfully."); - println!(" Location: plugins/{plugin_dir_name}"); - println!(" Feature flag: {feature_name}"); - println!(); - println!("Note: '{feature_name}' is NOT enabled by default."); - println!("To enable it, add '{feature_name}' to the 'default' feature list"); - println!("in crates/xtop-cli/Cargo.toml, then rebuild."); - - Ok(()) -} - -fn cmd_plugin_scaffold(name: &str) -> anyhow::Result<()> { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_dir = manifest_dir.parent().unwrap().parent().unwrap(); - let plugins_dir = workspace_dir.join("plugins"); - let plugin_dir = plugins_dir.join(format!("xtop-plugin-{name}")); - - if plugin_dir.exists() { - anyhow::bail!("Plugin crate already exists at {}", plugin_dir.display()); - } - - let src_dir = plugin_dir.join("src"); - fs::create_dir_all(&src_dir)?; - - // Cargo.toml (path refs go up from plugins/ to workspace root, then into crates/) - let cargo_toml = format!( - r#"[package] -name = "xtop-plugin-{name}" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "xtop plugin: {name}" - -[dependencies] -xtop-core = {{ path = "../../crates/xtop-core" }} -ratatui.workspace = true -"# - ); - fs::write(plugin_dir.join("Cargo.toml"), &cargo_toml)?; - - // lib.rs - let lib_rs = format!( - r#"use xtop_plugin_api::{{Plugin, PluginCapability, PluginContext, PluginError, PluginManifest}}; - -pub struct {name_cap}Plugin; - -impl {name_cap}Plugin {{ - pub fn new() -> Self {{ - Self - }} -}} - -impl Plugin for {name_cap}Plugin {{ - fn manifest(&self) -> PluginManifest {{ - PluginManifest {{ - id: "{name}".to_string(), - name: "{name_cap}".to_string(), - version: "0.1.0".to_string(), - description: "xtop plugin: {name}".to_string(), - capabilities: vec![PluginCapability::ReadSystemInfo], - }} - }} - - fn on_tick(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> {{ - Ok(()) - }} -}} -"#, - name = name, - name_cap = { - let mut chars = name.chars(); - match chars.next() { - None => String::new(), - Some(c) => c.to_uppercase().to_string() + chars.as_str(), - } - } - ); - fs::write(src_dir.join("lib.rs"), &lib_rs)?; - - println!("Plugin scaffold created at {}", plugin_dir.display()); - println!("To register it:"); - println!(" 1. Add \"plugins/xtop-plugin-{name}\" to [workspace].members in Cargo.toml"); - println!(" 2. Add dependency + feature flag in crates/xtop-cli/Cargo.toml"); - println!(" 3. Add #[cfg(feature = \"plugin-{name}\")] import in main.rs"); - println!(" 4. Implement Plugin trait methods"); - - Ok(()) -} - -fn cp_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { - if src.is_dir() { - fs::create_dir_all(dst)?; - for entry in fs::read_dir(src)? { - let entry = entry?; - let file_type = entry.file_type()?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - if file_type.is_dir() { - // Skip .git directory - if entry.file_name() != ".git" { - cp_recursive(&src_path, &dst_path)?; - } - } else { - fs::copy(&src_path, &dst_path)?; - } - } - Ok(()) - } else { - fs::copy(src, dst)?; - Ok(()) - } -} diff --git a/src/commands/run.rs b/src/commands/run.rs index 96e93ee..8d84e13 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -55,6 +55,12 @@ pub fn run() -> anyhow::Result<()> { let cfg_dir = config_dir(); let mut state = initialize_state(&cfg_dir)?; + // Frame effects read the persisted `effect` key once at startup (same + // config file `initialize_state` already loaded). The host is + // feature-agnostic: without `--features effects` it is an idle no-op. + let mut effect_host = + ui::effects::EffectHost::from_config(crate::config::load_config().effect.as_deref()); + // Sample once before the first frame so the UI never shows an empty // snapshot and every widget shares the same per-tick data. state.on_tick(); @@ -62,7 +68,12 @@ pub fn run() -> anyhow::Result<()> { loop { let tick_rate = Duration::from_millis(state.update_interval_ms.max(100)); - terminal.draw(|f| ui::render(f, &state))?; + terminal.draw(|f| { + ui::render(f, &state); + // Effects transform the fully rendered frame (after layout, + // before flush); see the `xtop-effect-api` host contract. + effect_host.apply(f.buffer_mut()); + })?; let timeout = tick_rate .checked_sub(last_tick.elapsed()) diff --git a/src/commands/share/assets.rs b/src/commands/share/assets.rs index 023dd56..c610ec5 100644 --- a/src/commands/share/assets.rs +++ b/src/commands/share/assets.rs @@ -35,12 +35,14 @@ const DEFAULT_THEMES: &[(&str, &str)] = &[ "bogota", include_str!("../../../assets/themes/bogota.jsonc"), ), + ("miami", include_str!("../../../assets/themes/miami.jsonc")), ]; /// Version of the seeded asset templates. Bumped when the shipped defaults /// change so existing installs receive the new templates (without ever -/// clobbering files the user has edited). -const ASSETS_VERSION: &str = "1"; +/// clobbering files the user has edited). "2": the `miami` theme joined the +/// embedded seeding set. +const ASSETS_VERSION: &str = "2"; /// Write shipped defaults (themes and layouts) into the user config dir. /// @@ -85,7 +87,53 @@ pub fn save_config(state: &AppState) { } cfg.layout_mode = state.layout_mode; cfg.update_interval_ms = state.update_interval_ms; - cfg.alerts = state.alerts; + cfg.alerts = state.alerts.clone(); cfg.keybindings = state.keybindings.clone(); let _ = config::save_config(&cfg); } + +#[cfg(test)] +mod tests { + use super::DEFAULT_THEMES; + + /// Every shipped theme file is embedded as a seeding template; the docs + /// (README, features, customization) count exactly 12 themes and the + /// assets/ folder must match `DEFAULT_THEMES` name for name. + #[test] + fn every_theme_file_is_embedded_as_a_seed() { + let names: Vec<&str> = DEFAULT_THEMES.iter().map(|(name, _)| *name).collect(); + assert_eq!(names.len(), 12, "docs claim 12 themes"); + for shipped in [ + "x", "berlin", "bogota", "helsinki", "lahabana", "london", "madrid", "miami", "oslo", + "paris", "praha", "tokio", + ] { + assert!( + names.contains(&shipped), + "missing embedded theme: {shipped}" + ); + } + } + + /// The embedded templates must parse through the same JSONC path the + /// loader uses at runtime (name + 16-entry palette), so a first run can + /// never seed a broken file. + #[test] + fn embedded_themes_parse_with_a_full_palette() { + for &(name, content) in DEFAULT_THEMES { + let cleaned = crate::theme::strip_jsonc_comments(content); + let parsed: serde_json::Value = serde_json::from_str(&cleaned) + .unwrap_or_else(|e| panic!("{name} template must be valid JSONC: {e}")); + assert_eq!( + parsed["name"].as_str(), + Some(name), + "theme template name must match its entry" + ); + let palette = parsed["palette"].as_array().expect("palette array"); + assert_eq!( + palette.len(), + 16, + "{name} palette must have exactly 16 colors" + ); + } + } +} diff --git a/src/config/schema.rs b/src/config/schema.rs index fe69233..ec78392 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -9,23 +9,10 @@ use std::collections::HashMap; use xtop_layout::LayoutMode; // Glyph style enums are shared ecosystem-wide (kernel + widget packs). pub use xtop_widget_api::{ChartCharset, WidgetBorders}; - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub struct AlertThresholds { - pub cpu_high: f64, - pub mem_high: f64, - pub disk_high: f64, -} - -impl Default for AlertThresholds { - fn default() -> Self { - Self { - cpu_high: 90.0, - mem_high: 90.0, - disk_high: 90.0, - } - } -} +// Alert thresholds are contract data (DR-1): the plugin-api type is serde +// compatible under the exact keys this config used before (`cpu_high`, +// `mem_high`, `disk_high`), so the persisted JSON stays byte-compatible. +pub use xtop_plugin_api::AlertThresholds; // --------------------------------------------------------------------------- // Widget glyph style @@ -83,7 +70,18 @@ fn default_layout_mode() -> LayoutMode { LayoutMode::Dashboard } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// Default alert thresholds (90/90/90), mirroring the pre-contract values. +/// The contract type itself carries no `Default`: config-level defaults are a +/// kernel concern and stay here, next to the persisted schema. +pub fn default_alerts() -> AlertThresholds { + AlertThresholds { + cpu_high: 90.0, + mem_high: 90.0, + disk_high: 90.0, + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Config { pub theme: String, #[serde(default = "default_layout_mode")] @@ -94,6 +92,7 @@ pub struct Config { pub layout_name: String, pub update_interval_ms: u64, pub history_points: usize, + #[serde(default = "default_alerts")] pub alerts: AlertThresholds, #[serde(default)] pub keybindings: Keybindings, @@ -101,6 +100,11 @@ pub struct Config { /// the classic look. #[serde(default)] pub style: UiStyle, + /// Frame effect id (e.g. "fade"), consumed by the optional `effects` + /// feature. Absent or unknown values disable effects; builds without the + /// feature ignore this key entirely. + #[serde(default)] + pub effect: Option, } impl Default for Config { @@ -111,9 +115,10 @@ impl Default for Config { layout_name: String::new(), update_interval_ms: 1000, history_points: 100, - alerts: AlertThresholds::default(), + alerts: default_alerts(), keybindings: Keybindings::default(), style: UiStyle::default(), + effect: None, } } } diff --git a/src/plugins/host.rs b/src/plugins/host.rs index 3757ec6..94215c2 100644 --- a/src/plugins/host.rs +++ b/src/plugins/host.rs @@ -24,11 +24,7 @@ impl HostState for AppState { } fn alerts(&self) -> AlertThresholds { - AlertThresholds { - cpu_high: self.alerts.cpu_high, - mem_high: self.alerts.mem_high, - disk_high: self.alerts.disk_high, - } + self.alerts.clone() } fn config(&self) -> RuntimeConfig { diff --git a/src/plugins/manager.rs b/src/plugins/manager.rs index 170862d..71267e4 100644 --- a/src/plugins/manager.rs +++ b/src/plugins/manager.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use crate::state::AppState; use xtop_plugin_api::SystemDataProvider; -use xtop_plugin_api::{Plugin, PluginCapability, PluginContext, PluginError, WidgetRegistration}; +use xtop_plugin_api::{Plugin, PluginCapability, PluginContext, PluginError, PluginWidget}; /// Manages the lifecycle of all loaded plugins. /// @@ -122,8 +122,8 @@ impl PluginManager { /// Collect all widget registrations from plugins. /// Only includes widgets from plugins with `RenderWidgets` capability. - pub fn collect_widgets(&self) -> Vec { - let mut widgets: Vec = Vec::new(); + pub fn collect_widgets(&self) -> Vec { + let mut widgets: Vec = Vec::new(); for plugin in &self.plugins { if Self::plugin_has_capability(&**plugin, &PluginCapability::RenderWidgets) { if let Some(widget) = plugin.widget() { diff --git a/src/providers/composite.rs b/src/providers/composite.rs index 9893f3e..e1adb90 100644 --- a/src/providers/composite.rs +++ b/src/providers/composite.rs @@ -6,7 +6,7 @@ use xtop_plugin_api::SystemDataProvider; /// /// The primary provider (usually `SysinfoProvider`) handles `refresh_all()` and `snapshot()`. /// Extra providers (from plugins) override specific methods like `gpu_info()`, `batteries()`, -/// `docker_info()`, `disk_io()`, or `system_info()`. +/// `disk_io()`, or `system_info()`. /// /// This allows plugins to inject data without modifying the primary provider. pub struct CompositeProvider { @@ -67,10 +67,6 @@ impl SystemDataProvider for CompositeProvider { self.first_non_empty(|| self.primary.gpu_info(), |e| e.gpu_info()) } - fn docker_info(&self) -> Vec { - self.first_non_empty(|| self.primary.docker_info(), |e| e.docker_info()) - } - fn system_info(&self) -> SystemInfo { let primary = self.primary.system_info(); if !primary.hostname.is_empty() { diff --git a/src/providers/sysinfo/provider.rs b/src/providers/sysinfo/provider.rs index ea51d47..edc66b8 100644 --- a/src/providers/sysinfo/provider.rs +++ b/src/providers/sysinfo/provider.rs @@ -312,7 +312,6 @@ impl SystemDataProvider for SysinfoProvider { disk_io: self.disk_io_inner(), batteries: read_batteries(), gpus: read_gpu_info(), - dockers: vec![], sys_info: self.cached_sys_info.clone(), } } diff --git a/src/state/app.rs b/src/state/app.rs index 5ae6e2a..dfb9f15 100644 --- a/src/state/app.rs +++ b/src/state/app.rs @@ -2,15 +2,14 @@ //! control, plugins. use crate::config::keybinding::{Action, Keybindings}; -use crate::config::{AlertThresholds, Config, UiStyle}; +use crate::config::{Config, UiStyle}; use crate::plugins::PluginManager; use crate::state::history::MetricsHistory; use crate::state::view::{FullScreenWidget, InputMode, PalettePage, PaletteState, ProcessSortBy}; use crate::theme::Theme; use xtop_layout::{layout_index_from_mode, layout_mode_for_name, LayoutDef, LayoutMode}; use xtop_plugin_api::model::{ProcessInfo, SystemInfo, SystemSnapshot}; -use xtop_plugin_api::SystemDataProvider; -use xtop_plugin_api::WidgetRegistration; +use xtop_plugin_api::{AlertThresholds, PluginWidget, SystemDataProvider}; pub struct AppState { provider: Box, @@ -42,7 +41,7 @@ pub struct AppState { /// widget/action in that frame (avoids N samples per frame). last_snapshot: Option, pub plugin_manager: Option, - pub plugin_widgets: Vec, + pub plugin_widgets: Vec, } impl AppState { @@ -137,7 +136,6 @@ impl AppState { disk_high: disk, }; } - /// Switch to a theme by name. Returns true if found. pub fn set_theme_by_name(&mut self, name: &str) -> bool { if let Some(idx) = self.themes.iter().position(|t| t.name == name) { @@ -435,6 +433,7 @@ impl AppState { #[cfg(test)] mod tests { use super::*; + use crate::config::default_alerts; use xtop_layout::{default_layouts, LayoutMode}; fn test_state(defs: Vec) -> AppState { @@ -458,7 +457,7 @@ mod tests { #[test] fn test_alert_thresholds_default() { - let a = AlertThresholds::default(); + let a = default_alerts(); assert_eq!(a.cpu_high, 90.0); assert_eq!(a.mem_high, 90.0); assert_eq!(a.disk_high, 90.0); diff --git a/src/state/widget_state.rs b/src/state/widget_state.rs index 4b29b67..3ae2500 100644 --- a/src/state/widget_state.rs +++ b/src/state/widget_state.rs @@ -30,11 +30,7 @@ impl xtop_widget_api::WidgetState for AppState { } fn alerts(&self) -> xtop_plugin_api::AlertThresholds { - xtop_plugin_api::AlertThresholds { - cpu_high: self.alerts.cpu_high, - mem_high: self.alerts.mem_high, - disk_high: self.alerts.disk_high, - } + self.alerts.clone() } fn charset(&self, widget: &str) -> xtop_widget_api::ChartCharset { diff --git a/src/theme/loader.rs b/src/theme/loader.rs index 2b96e77..149c9fd 100644 --- a/src/theme/loader.rs +++ b/src/theme/loader.rs @@ -24,7 +24,7 @@ fn default_theme() -> Theme { ) } -fn strip_jsonc_comments(input: &str) -> String { +pub(crate) fn strip_jsonc_comments(input: &str) -> String { let mut out = String::with_capacity(input.len()); let chars: Vec = input.chars().collect(); let mut i = 0; @@ -77,20 +77,13 @@ fn load_themes_from_dir(dir: &Path) -> Vec { themes } +/// Where user themes live: the platform-aware config dir (same tree as +/// `config.json` and the layouts), joined with `themes`. This keeps macOS +/// (`~/Library/Application Support/xtop/themes`) and Windows +/// (`%APPDATA%\xtop\themes`) consistent with the rest of the app instead of +/// hard-coding the XDG `~/.config` layout. pub fn themes_dir() -> std::path::PathBuf { - if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - std::path::PathBuf::from(xdg).join("xtop").join("themes") - } else if let Ok(home) = std::env::var("HOME") { - std::path::PathBuf::from(home) - .join(".config") - .join("xtop") - .join("themes") - } else { - std::path::PathBuf::from(".") - .join(".config") - .join("xtop") - .join("themes") - } + crate::config::config_dir().join("themes") } pub fn load_all_themes() -> Vec { diff --git a/src/ui/effects/mod.rs b/src/ui/effects/mod.rs new file mode 100644 index 0000000..e422518 --- /dev/null +++ b/src/ui/effects/mod.rs @@ -0,0 +1,20 @@ +//! Optional frame-effect host (feature `effects`). +//! +//! Effects (see `xtop-effect-api`) transform the fully rendered frame +//! buffer after layout and before the terminal flush. The kernel hosts at +//! most one active effect, named by the optional persisted `effect` config +//! key, and drives it with the time elapsed since the run loop started. +//! +//! When the feature is off this module still compiles (same type, idle +//! no-op), so the run loop stays feature-agnostic: default builds carry +//! zero extra dependencies and zero runtime cost. + +#[cfg(not(feature = "effects"))] +mod off; +#[cfg(feature = "effects")] +mod on; + +#[cfg(not(feature = "effects"))] +pub use off::EffectHost; +#[cfg(feature = "effects")] +pub use on::EffectHost; diff --git a/src/ui/effects/off.rs b/src/ui/effects/off.rs new file mode 100644 index 0000000..50cc740 --- /dev/null +++ b/src/ui/effects/off.rs @@ -0,0 +1,19 @@ +//! Idle effect host for builds without the `effects` feature. +//! +//! Same public surface as the feature-on host (see the parent module doc), +//! but zero-sized: the config key is ignored, `apply` is a no-op and no +//! effect dependency is compiled in. + +/// Idle frame-effect host (feature `effects` not compiled in). +pub struct EffectHost; + +impl EffectHost { + /// Idle host: without the feature there is no effect to activate, so + /// the config value is deliberately ignored. + pub fn from_config(_name: Option<&str>) -> Self { + Self + } + + /// No-op: the feature is off, the frame is passed through untouched. + pub fn apply(&mut self, _buffer: &mut ratatui::buffer::Buffer) {} +} diff --git a/src/ui/effects/on.rs b/src/ui/effects/on.rs new file mode 100644 index 0000000..597a432 --- /dev/null +++ b/src/ui/effects/on.rs @@ -0,0 +1,67 @@ +//! Effect host compiled when the `effects` feature is on. +//! +//! The host owns the optional active effect plus the instant the run loop +//! started. The run loop calls [`EffectHost::apply`] after every render; +//! with no active effect the call is a cheap no-op (no allocation). + +use std::time::Instant; + +use xtop_effect_api::Effect; +use xtop_effect_fade::FadeEffect; + +/// Runtime state of the frame-effect host. +pub struct EffectHost { + /// When the run loop started; the effect contract's clock (`elapsed` + /// grows from here, never resets). + started: Instant, + /// The active effect, when the config named one we know. + effect: Option>, +} + +impl EffectHost { + /// Create the host from the persisted `effect` config value. + /// + /// `"fade"` activates the built-in `FadeEffect`; an absent, empty or + /// unknown name leaves the host idle (no effect, no allocation). + pub fn from_config(name: Option<&str>) -> Self { + Self { + started: Instant::now(), + effect: select_effect(name), + } + } + + /// Apply the active effect to a rendered frame (after layout, before + /// flush). No-op when no effect is active. + pub fn apply(&mut self, buffer: &mut ratatui::buffer::Buffer) { + if let Some(effect) = self.effect.as_deref_mut() { + effect.on_frame(buffer, self.started.elapsed()); + } + } +} + +/// Map a config value to an effect instance. `None`/empty and unknown names +/// select no effect; the only built-in id is `"fade"`. +pub fn select_effect(name: Option<&str>) -> Option> { + match name { + Some("fade") => Some(Box::new(FadeEffect)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::select_effect; + + #[test] + fn fade_config_value_selects_the_fade_effect() { + let effect = select_effect(Some("fade")).expect("fade must resolve"); + assert_eq!(effect.manifest().id, "fade"); + } + + #[test] + fn absent_empty_or_unknown_names_select_no_effect() { + assert!(select_effect(None).is_none()); + assert!(select_effect(Some("")).is_none()); + assert!(select_effect(Some("wipe")).is_none()); + } +} diff --git a/src/ui/layout/engine.rs b/src/ui/layout/engine.rs index f4091ba..2b30125 100644 --- a/src/ui/layout/engine.rs +++ b/src/ui/layout/engine.rs @@ -1,21 +1,20 @@ //! Layout render engine: splits rects and dispatches widget renderers. //! //! Widgets live in packs (see the `widgets` repo); the kernel resolves -//! `(pack, name)` at render time. Plugin widgets keep precedence over packs -//! and can replace any name. +//! `(pack, name)` at render time. Plugin widgets ([`PluginWidget`]) keep +//! precedence over packs and can replace any name. Unknown names are +//! reported once per process (see [`warn_unknown_widget`]). use crate::state::AppState; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::Frame; use std::collections::HashMap; -use std::sync::{Arc, OnceLock}; +use std::collections::HashSet; +use std::sync::{Mutex, OnceLock}; use xtop_layout::{Direction, LayoutArea, LayoutConstraint, LayoutDef, LayoutNode}; -use xtop_plugin_api::HostState; +use xtop_plugin_api::PluginWidget; use xtop_widget_api::WidgetRenderer; -/// A plugin widget renderer (plugins see only the API contract). -pub type PluginWidgetFn = Arc; - /// One compiled-in widget pack. struct Pack { name: &'static str, @@ -73,8 +72,9 @@ pub fn render_layout( state: &AppState, area: Rect, def: &LayoutDef, - plugin_widgets: &HashMap, + plugin_widgets: &HashMap, ) { + warn_unknown_widgets(state, def, plugin_widgets); render_node(f, state, area, &def.root, plugin_widgets); } @@ -85,10 +85,10 @@ pub fn render_named( state: &AppState, name: &str, area: Rect, - plugin_widgets: &HashMap, + plugin_widgets: &HashMap, ) -> bool { - if let Some(render_fn) = plugin_widgets.get(name) { - render_fn(f, state, area); + if let Some(widget) = plugin_widgets.get(name) { + (widget.render)(f, state, area); return true; } if let Some(render_fn) = resolve(state, name) { @@ -103,7 +103,7 @@ fn render_node( state: &AppState, area: Rect, node: &LayoutNode, - plugin_widgets: &HashMap, + plugin_widgets: &HashMap, ) { match node { LayoutNode::Widget { name } => { @@ -113,15 +113,11 @@ fn render_node( if areas.is_empty() { return; } - let dir = match direction { - Direction::Horizontal => ratatui::prelude::Direction::Horizontal, - Direction::Vertical => ratatui::prelude::Direction::Vertical, - }; let constraints: Vec = areas.iter().map(to_ratatui_constraint).collect(); - let chunks = Layout::default() - .direction(dir) - .constraints(constraints) - .split(area); + let chunks = match direction { + Direction::Horizontal => Layout::horizontal(constraints).split(area), + Direction::Vertical => Layout::vertical(constraints).split(area), + }; for (i, chunk) in chunks.iter().enumerate() { if i < areas.len() { render_node(f, state, *chunk, &areas[i].node, plugin_widgets); @@ -138,3 +134,80 @@ fn to_ratatui_constraint(area: &LayoutArea) -> Constraint { LayoutConstraint::Fill => Constraint::Fill(1), } } + +// --------------------------------------------------------------------------- +// Unknown-widget reporting (DR-3): a layout referencing a name no pack or +// plugin provides renders an empty area; that is a user-facing mistake, so +// the kernel warns once per widget name per process. +// --------------------------------------------------------------------------- + +/// Widget names already reported as unknown this process. +static REPORTED_UNKNOWN_WIDGETS: OnceLock>> = OnceLock::new(); + +/// Emit at most one stderr warning per unknown widget name per process. +fn warn_unknown_widget(layout: &str, name: &str) { + if report_unknown_widget(name) { + eprintln!("xtop: layout '{layout}' references unknown widget '{name}'"); + } +} + +/// Mark a widget name as reported. Returns `true` on the first call for a +/// given name, `false` for every later call (the one-warning-per-name +/// guarantee). The set never shrinks, so the render path only pays for a +/// name once per process. +fn report_unknown_widget(name: &str) -> bool { + let reported = REPORTED_UNKNOWN_WIDGETS.get_or_init(|| Mutex::new(HashSet::new())); + let Ok(mut seen) = reported.lock() else { + return true; + }; + seen.insert(name.to_string()) +} + +/// Walk the layout tree once per frame and warn about leaf names no pack or +/// plugin can render. Cheap (pure name collection over a small tree); the +/// per-name dedup keeps repeated frames silent. +fn warn_unknown_widgets( + state: &AppState, + def: &LayoutDef, + plugin_widgets: &HashMap, +) { + fn walk<'a>(node: &'a LayoutNode, names: &mut Vec<&'a str>) { + match node { + LayoutNode::Widget { name } => names.push(name), + LayoutNode::Split { areas, .. } => { + for area in areas { + walk(&area.node, names); + } + } + } + } + + let mut names = Vec::new(); + walk(&def.root, &mut names); + for name in names { + if !plugin_widgets.contains_key(name) && resolve(state, name).is_none() { + warn_unknown_widget(&def.name, name); + } + } +} + +#[cfg(test)] +mod tests { + use super::{report_unknown_widget, REPORTED_UNKNOWN_WIDGETS}; + use std::collections::HashSet; + use std::sync::Mutex; + + #[test] + fn unknown_widget_warns_once_per_name() { + // Reset the process-wide set so this test is order-independent. + REPORTED_UNKNOWN_WIDGETS + .set(Mutex::new(HashSet::new())) + .ok(); + // First report of a name returns true (a warning is emitted)... + assert!(report_unknown_widget("ghost")); + // ...every later report of the same name is silent. + assert!(!report_unknown_widget("ghost")); + // A different name still warns. + assert!(report_unknown_widget("phantom")); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4263d58..045b1b9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,14 +4,15 @@ //! - [`terminal`] terminal backend lifecycle (raw mode, alternate screen) //! - [`screen`] top-level composition (fullscreen, minimal, layout) //! - [`layout`] layout engine: rect split + widget pack resolution +//! - [`effects`] optional frame-effect host (feature `effects`) //! - [`overlay`] kernel-owned UI chrome (help, command palette) -//! - [`share`] UI-wide shared logic (colors, formatting) pub mod layout; pub mod overlay; pub mod screen; -pub mod share; pub mod terminal; +pub mod effects; + pub use screen::*; pub use terminal::*; diff --git a/src/ui/overlay/help/mod.rs b/src/ui/overlay/help/mod.rs index a7cd68f..992b837 100644 --- a/src/ui/overlay/help/mod.rs +++ b/src/ui/overlay/help/mod.rs @@ -4,16 +4,16 @@ //! always reflected here. use crate::state::AppState; -use crate::ui::share::to_color; use ratatui::prelude::*; use ratatui::symbols::border; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; +use xtop_widget_api::glyph::to_color; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); - let accent = to_color(&state.current_theme.palette[6]); + let fg = to_color(*state.current_theme.fg()); + let bg = to_color(*state.current_theme.bg()); + let accent = to_color(state.current_theme.palette[6]); let kb = &state.keybindings; let mut text = vec![ diff --git a/src/ui/overlay/palette/mod.rs b/src/ui/overlay/palette/mod.rs index d9a665f..a3aa1dd 100644 --- a/src/ui/overlay/palette/mod.rs +++ b/src/ui/overlay/palette/mod.rs @@ -1,15 +1,15 @@ //! Command palette widget: themes/layouts quick selection. use crate::state::{AppState, PalettePage}; -use crate::ui::share::to_color; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use ratatui::Frame; +use xtop_widget_api::glyph::to_color; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); - let accent = to_color(&state.current_theme.palette[6]); + let fg = to_color(*state.current_theme.fg()); + let bg = to_color(*state.current_theme.bg()); + let accent = to_color(state.current_theme.palette[6]); let popup_width = (area.width as f64 * 0.6).min(60.0) as u16; let popup_height = (area.height as f64 * 0.6).min(30.0) as u16; diff --git a/src/ui/screen.rs b/src/ui/screen.rs index ad9f5d6..be4b844 100644 --- a/src/ui/screen.rs +++ b/src/ui/screen.rs @@ -3,22 +3,31 @@ //! the engine; overlays (help/palette) are kernel-owned. use crate::state::{AppState, FullScreenWidget, InputMode}; -use crate::ui::layout::{render_layout, render_named, PluginWidgetFn}; +use crate::ui::layout::{render_layout, render_named}; use crate::ui::overlay::{help, palette}; -use crate::ui::share::to_color; use ratatui::prelude::*; use ratatui::Frame; use std::collections::HashMap; use xtop_layout::{detect_effective_layout, EffectiveLayout}; +use xtop_plugin_api::PluginWidget; +use xtop_widget_api::glyph::to_color; /// Build a plugin widget lookup map from AppState. /// /// Plugin renderers only see [`HostState`](xtop_plugin_api::HostState); /// they keep precedence over every pack. -fn plugin_widgets(state: &AppState) -> HashMap { - let mut map: HashMap = HashMap::new(); +fn plugin_widgets(state: &AppState) -> HashMap { + let mut map: HashMap = HashMap::new(); for reg in &state.plugin_widgets { - map.insert(reg.name.clone(), reg.render.clone()); + // `PluginWidget` is not `Clone` by contract; the render closure + // (an `Arc`) is, so rebuild a lightweight copy per frame. + map.insert( + reg.name.clone(), + PluginWidget { + name: reg.name.clone(), + render: reg.render.clone(), + }, + ); } map } @@ -60,15 +69,15 @@ pub fn render(f: &mut Frame, state: &AppState) { fn render_too_small(f: &mut Frame, state: &AppState, area: Rect) { use ratatui::widgets::Paragraph; - let fg = to_color(state.current_theme.fg()); + let fg = to_color(*state.current_theme.fg()); let text = Paragraph::new("Terminal too small\nMinimum: 40x8").style(Style::default().fg(fg)); f.render_widget(text, area); } fn render_search_overlay(f: &mut Frame, state: &AppState, area: Rect) { use ratatui::widgets::{Block, Borders, Paragraph}; - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); + let fg = to_color(*state.current_theme.fg()); + let bg = to_color(*state.current_theme.bg()); let search_text = format!("/{}_", state.search_query); let overlay = Paragraph::new(search_text) .style(Style::default().fg(fg).bg(bg)) @@ -87,16 +96,13 @@ fn render_search_overlay(f: &mut Frame, state: &AppState, area: Rect) { } fn render_fullscreen(f: &mut Frame, state: &AppState, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(0)]) - .split(area); + let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area); let pw = plugin_widgets(state); render_named(f, state, "header", chunks[0], &pw); let name = fullscreen_widget_name(state.full_screen_widget); if !render_named(f, state, name, chunks[1], &pw) { let text = format!("No widget registered for '{name}'"); - let fg = to_color(state.current_theme.fg()); + let fg = to_color(*state.current_theme.fg()); let p = ratatui::widgets::Paragraph::new(text).style(Style::default().fg(fg)); f.render_widget(p, chunks[1]); } @@ -119,17 +125,15 @@ fn fullscreen_widget_name(w: FullScreenWidget) -> &'static str { fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) { use ratatui::widgets::Gauge; - let bg = to_color(state.current_theme.bg()); + let bg = to_color(*state.current_theme.bg()); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(2), - Constraint::Length(2), - Constraint::Min(0), - ]) - .split(area); + let chunks = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(2), + Constraint::Length(2), + Constraint::Min(0), + ]) + .split(area); let pw = plugin_widgets(state); render_named(f, state, "header", chunks[0], &pw); @@ -148,7 +152,7 @@ fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) { let cpu_gauge = Gauge::default() .gauge_style( Style::default() - .fg(to_color(&state.current_theme.palette[1])) + .fg(to_color(state.current_theme.palette[1])) .bg(bg), ) .percent(cpu_pct as u16) @@ -165,7 +169,7 @@ fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) { let mem_gauge = Gauge::default() .gauge_style( Style::default() - .fg(to_color(&state.current_theme.palette[2])) + .fg(to_color(state.current_theme.palette[2])) .bg(bg), ) .percent(mem_pct) diff --git a/src/ui/share/color.rs b/src/ui/share/color.rs deleted file mode 100644 index 3e6282f..0000000 --- a/src/ui/share/color.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Color conversion helpers used by kernel UI chrome (overlays, minimal view). - -use ratatui::prelude::Color; - -pub fn to_color(c: &[u8; 3]) -> Color { - Color::Rgb(c[0], c[1], c[2]) -} diff --git a/src/ui/share/mod.rs b/src/ui/share/mod.rs deleted file mode 100644 index 3742478..0000000 --- a/src/ui/share/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! UI-wide shared logic used by overlays and the screen. -//! -//! Data widgets draw with helpers from their own pack; the kernel keeps only -//! what its own chrome needs. - -mod color; - -pub use color::*; From 43e947e7393e19607e8ac329b3bedb1e98a826f5 Mon Sep 17 00:00:00 2001 From: xscriptor Date: Fri, 4 Sep 2026 18:52:20 +0000 Subject: [PATCH 4/4] change ci exec --- .github/workflows/ci.yml | 23 ----------------------- CHANGELOG.md | 5 +++++ README.md | 3 +-- 3 files changed, 6 insertions(+), 25 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 3b18485..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -env: - CARGO_TERM_COLOR: always - -jobs: - check: - name: Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - components: clippy, rustfmt - - run: cargo fmt --check - - run: cargo clippy --all-targets -- -D warnings - - run: cargo test diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d3a778..55c7079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ absence guard.) - `crate version 0.2.0 -> 0.3.0`; `rust-version = "1.87"` declared. +### Local-only CI +- The GitHub Actions CI workflow (`.github/workflows/ci.yml`) has been + removed: gating is local only (`scripts/ci.sh`, `scripts/audit.sh`) and + will not be re-enabled until the release pipeline is 100% ready. + ### Contract consolidation - `config::AlertThresholds` removed: the persisted config now uses `xtop_plugin_api::AlertThresholds` directly (identical JSON keys diff --git a/README.md b/README.md index 42b70ef..c6a61c9 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,10 @@ ![Rust](https://img.shields.io/badge/Rust-1.87%2B-orange) ![License](https://img.shields.io/badge/license-MIT-blue) -![CI](https://img.shields.io/github/actions/workflow/status/xtop-cli/xtop/ci.yml?branch=main) ![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey) ![ratatui](https://img.shields.io/badge/built%20with-ratatui-red) -A cross-platform TUI system monitor written in Rust. Uses ratatui for the terminal interface and sysinfo for real-time system metrics. +A cross-platform TUI system monitor written in Rust. Uses ratatui for the terminal interface and sysinfo for real-time system metrics.