From fcbcebedda9c38f886eb71543f3ea6c027221c03 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:13:44 +0300 Subject: [PATCH] feat(tui): feature-gated terminal chat UI (openhuman tui / chat) (#5084) --- AGENTS.md | 12 + Cargo.lock | 884 ++++++++++++++++++++++-- Cargo.toml | 23 +- docs/TEST-COVERAGE-MATRIX.md | 12 + docs/plans/tui-chat-plan.md | 95 +++ scripts/ci/check-feature-forwarding.mjs | 1 + src/core/cli.rs | 15 +- src/core/cli_tests.rs | 52 ++ src/core/logging.rs | 81 +++ src/openhuman/about_app/catalog_data.rs | 17 + src/openhuman/mod.rs | 1 + src/openhuman/tui/app.rs | 240 +++++++ src/openhuman/tui/mod.rs | 52 ++ src/openhuman/tui/render.rs | 248 +++++++ src/openhuman/tui/runner.rs | 224 ++++++ src/openhuman/tui/state.rs | 491 +++++++++++++ src/openhuman/tui/stub.rs | 37 + src/openhuman/tui/terminal.rs | 83 +++ 18 files changed, 2512 insertions(+), 56 deletions(-) create mode 100644 docs/plans/tui-chat-plan.md create mode 100644 src/openhuman/tui/app.rs create mode 100644 src/openhuman/tui/mod.rs create mode 100644 src/openhuman/tui/render.rs create mode 100644 src/openhuman/tui/runner.rs create mode 100644 src/openhuman/tui/state.rs create mode 100644 src/openhuman/tui/stub.rs create mode 100644 src/openhuman/tui/terminal.rs diff --git a/AGENTS.md b/AGENTS.md index 0bb6a8daa..a9e637719 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -243,6 +243,7 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | `skills` | ON | `openhuman::skills` + `openhuman::skill_runtime` + `openhuman::skill_registry` domains — SKILL.md discovery/parse/install, workflow execution + run logs, remote catalogs, the `skill_setup` / `skill_executor` builtin agents, and the 16 skill agent tools | none (see below) | | `flows` | ON | `openhuman::flows` (saved automation graphs — create/run/schedule, the `workflow_builder` + `flow_discovery` agents), `openhuman::tinyflows` (engine seam), `openhuman::rhai_workflows` (`.ragsh` language-workflow tool) | `tinyflows`, `jaq-core`, `jaq-std`, `jaq-json`, `rhai` | | `mcp` | ON | `openhuman::mcp_server` (the `openhuman mcp` stdio/HTTP server), `openhuman::mcp_registry` (dynamic Smithery installs — `mcp_clients` RPC namespace, SQLite, boot spawn, supervisor, OAuth), `openhuman::mcp_audit` (write-audit log), and the static config-declared server set in `openhuman::mcp_client`. ~19 agent tools, ~20k LOC | **none** (see scope note) | +| `tui` | ON | `openhuman::tui` — the `openhuman tui` (alias `chat`) CLI subcommand: a ratatui/crossterm terminal chat UI onto the `web_chat` surface, running the core in-process. No controllers, no agent tools. **Intentionally NOT forwarded to the desktop shell** (allowlisted in `check-feature-forwarding.mjs`). | `ratatui`, `crossterm` | **Facade pattern (pathfinder for the other gates).** `pub mod voice;` is **always compiled** as a facade: the real submodules are `#[cfg(feature = "voice")]`, and a `#[cfg(not(feature = "voice"))] mod stub;` (`src/openhuman/voice/stub.rs`) re-exposes the same public surface that always-on / other-gated callers use (`server`, `dictation_listener`, `streaming`, `reply_speech`, `cloud_transcribe`, `cli`, `create_stt_provider`, `effective_stt_provider`, `publish_ptt_transcript_committed`) with no-op / `None` / disabled-error bodies. Callers therefore do **not** need per-call `#[cfg]`. When voice is off: the voice/audio controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the `audio_generate_podcast` agent tools are absent, and `openhuman voice` returns a "voice disabled" error. Stub signatures must match the real ones exactly — the disabled build (`--no-default-features --features tokenjuice-treesitter`) is the **only** thing that catches drift, so run it before pushing any change to the voice surface. @@ -306,6 +307,17 @@ Follows the voice facade+stub pattern for `mcp_server` / `mcp_registry` / `mcp_a `src/core/all.rs` needs **no** `#[cfg]` for this gate: the stub aggregators return empty vecs, so the registration sites keep compiling unchanged. +#### The `tui` gate + +The terminal chat UI (`openhuman tui` / alias `chat`) lives in `src/openhuman/tui/` and follows the **`mcp`/`voice` facade+stub** pattern: `pub mod tui;` is always compiled; the behavioural submodules (`app`, `render`, `state`, `terminal`, `runner`) are `#[cfg(feature = "tui")]`; and `#[cfg(not(feature = "tui"))] mod stub;` re-exposes the one symbol an always-compiled caller reaches — `run_from_cli` — with a build-fact error body (`"tui feature disabled at compile time … --features tui"`). + +- **The `"tui" | "chat"` CLI arm in `src/core/cli.rs` is un-`#[cfg]`'d on purpose.** In a slim build it resolves to `tui::stub::run_from_cli`, which bails with the disabled-error rather than falling through to `unknown namespace: tui` (which reads like a typo, not a build fact). Same reasoning as the `mcp` arm. Pinned by `tui_subcommand_reports_disabled_build_when_gate_off` / `chat_alias_reports_disabled_build_when_gate_off` in `src/core/cli_tests.rs` (both `#[cfg(not(feature = "tui"))]`). `"tui" | "chat"` is also added to the banner-suppression `matches!` (a TUI owns the terminal — a banner would corrupt it). +- **No controllers, no agent tools, no `all.rs` changes.** The TUI is a pure *client* of the existing `web_chat` surface — it boots the core in-process (`CoreBuilder::new(HostKind::Cli).domains(DomainSet::full()).services(ServiceSet::none())`), sends turns via `runtime.invoke("openhuman.channel_web_chat", …)`, and streams by draining `web_chat::subscribe_web_channel_events()` filtered by its own `client_id`. `openhuman.channel_web_chat` needs `DomainGroup::Channels`, so `DomainSet::full()` (not `harness()`) is required. +- **Terminal hygiene is load-bearing.** `logging::init_for_tui` installs a **file-only** subscriber (never stderr) — a single core boot log on stdout/stderr would corrupt the alternate-screen UI. `terminal::TerminalGuard` restores raw mode + the main screen on `Drop`, and a panic hook chains a restore ahead of the default hook. All `[tui]` state-transition logs go to the file, never `println!`. +- **Intentionally NOT forwarded to the desktop shell** (the app ships its own Tauri UI). It carries the only current entry in `INTENTIONALLY_NOT_FORWARDED` in `scripts/ci/check-feature-forwarding.mjs`; the pure reducer lives in `src/openhuman/tui/state.rs` (`TranscriptState::apply_event`) with unit tests, so most behaviour is testable without a terminal. + +Drops the exclusive `ratatui` + `crossterm` deps when off. Verify with `cargo tree -i ratatui --no-default-features --features tokenjuice-treesitter` (must return nothing). + ### Event bus (`src/core/event_bus/`) Typed pub/sub + native request/response. Both singletons — use module-level functions. diff --git a/Cargo.lock b/Cargo.lock index 421199256..704cdfef6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "alsa" version = "0.9.1" @@ -91,7 +97,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -171,6 +177,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -314,6 +329,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -500,7 +524,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -514,15 +538,30 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -585,9 +624,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" @@ -688,6 +727,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "byte-slice-cast" version = "1.2.3" @@ -747,6 +792,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1044,6 +1098,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.38" @@ -1152,6 +1220,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.16.2" @@ -1263,7 +1340,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -1276,7 +1353,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "libc", ] @@ -1366,6 +1443,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "cron" version = "0.12.1" @@ -1392,6 +1475,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more 2.1.1", + "document-features", + "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 = "crunchy" version = "0.2.4" @@ -1430,6 +1540,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1475,6 +1595,40 @@ dependencies = [ "syn 2.0.117", ] +[[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 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -1515,6 +1669,12 @@ dependencies = [ "uuid 1.23.1", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.7.10" @@ -1580,6 +1740,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", @@ -1680,7 +1841,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", ] @@ -2042,7 +2203,7 @@ dependencies = [ "rlp", "serde", "serde_json", - "strum", + "strum 0.26.3", "tempfile", "thiserror 1.0.69", "tiny-keccak", @@ -2077,6 +2238,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -2122,13 +2292,23 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fancy-regex" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" dependencies = [ - "bit-set", + "bit-set 0.8.0", "regex-automata", "regex-syntax", ] @@ -2155,6 +2335,12 @@ dependencies = [ "webdriver", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.4.1" @@ -2192,6 +2378,17 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" @@ -2220,6 +2417,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + [[package]] name = "fixed-hash" version = "0.8.0" @@ -2232,6 +2435,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2555,7 +2764,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "libgit2-sys", "log", @@ -2623,6 +2832,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -2631,6 +2842,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashify" @@ -3068,6 +3284,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3166,6 +3388,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -3176,6 +3407,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3381,6 +3625,17 @@ dependencies = [ "signature", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "keccak" version = "0.1.6" @@ -3420,6 +3675,12 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "landlock" version = "0.4.4" @@ -3538,13 +3799,22 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linux-keyutils" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", ] @@ -3594,7 +3864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" dependencies = [ "aes", - "bitflags 2.11.1", + "bitflags 2.13.1", "cbc", "ecb", "encoding_rs", @@ -3615,6 +3885,15 @@ dependencies = [ "weezl", ] +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3632,6 +3911,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "mach2" version = "0.4.3" @@ -3724,6 +4013,21 @@ dependencies = [ "libc", ] +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -3763,6 +4067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -3822,7 +4127,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3845,13 +4150,26 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -4001,6 +4319,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc" version = "0.2.7" @@ -4041,7 +4368,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -4057,7 +4384,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -4069,7 +4396,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4091,7 +4418,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4113,7 +4440,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", ] @@ -4124,7 +4451,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -4169,7 +4496,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -4187,7 +4514,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -4199,7 +4526,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -4212,7 +4539,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -4223,7 +4550,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4235,7 +4562,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4248,7 +4575,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -4269,7 +4596,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-cloud-kit", @@ -4398,6 +4725,7 @@ dependencies = [ "console", "cpal", "cron", + "crossterm", "curve25519-dalek", "dialoguer", "directories", @@ -4447,6 +4775,7 @@ dependencies = [ "proptest", "prost", "rand 0.10.1", + "ratatui", "rdev", "regex", "reqwest 0.12.28", @@ -4518,7 +4847,7 @@ version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4630,6 +4959,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "os_info" version = "3.14.0" @@ -4638,7 +4976,7 @@ checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" dependencies = [ "android_system_properties", "log", - "nix", + "nix 0.30.1", "objc2 0.6.4", "objc2-foundation 0.3.2", "objc2-ui-kit", @@ -4652,6 +4990,30 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -4762,7 +5124,7 @@ dependencies = [ "adobe-cmap-parser", "cff-parser", "encoding_rs", - "euclid", + "euclid 0.20.14", "log", "lopdf", "postscript", @@ -4776,17 +5138,69 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + [[package]] name = "petgraph" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "hashbrown 0.15.5", "indexmap", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.12.1" @@ -4806,16 +5220,36 @@ dependencies = [ "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", + "phf_generator 0.13.1", "phf_shared 0.13.1", ] +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -4826,6 +5260,28 @@ dependencies = [ "phf_shared 0.13.1", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.12.1" @@ -4911,7 +5367,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -5126,9 +5582,9 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set", - "bit-vec", - "bitflags 2.11.1", + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.1", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -5194,7 +5650,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "getopts", "memchr", "pulldown-cmark-escape", @@ -5424,6 +5880,107 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rdev" version = "0.5.3" @@ -5446,7 +6003,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -5633,7 +6190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd4dd0f8c36625202a4ba553c416c19b719947cd2a31d1bda06126e4a5727daf" dependencies = [ "ahash", - "bitflags 2.11.1", + "bitflags 2.13.1", "no-std-compat", "num-traits", "once_cell", @@ -5725,7 +6282,7 @@ version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink", @@ -5767,7 +6324,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -6009,7 +6566,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "core-foundation-sys 0.8.7", "libc", @@ -6022,7 +6579,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys 0.8.7", "libc", @@ -6135,7 +6692,7 @@ version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27701acc51e68db5281802b709010395bfcbcb128b1d0a4e5873680d3b47ff0c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "sentry-backtrace", "sentry-core", "tracing-core", @@ -6361,6 +6918,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -6533,7 +7111,7 @@ dependencies = [ "lazycell", "libc", "mach2 0.5.0", - "nix", + "nix 0.30.1", "num-traits", "plist", "uom", @@ -6587,7 +7165,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -6603,6 +7190,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6664,7 +7263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" dependencies = [ "bincode", - "fancy-regex", + "fancy-regex 0.16.2", "flate2", "fnv", "once_cell", @@ -6694,7 +7293,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6739,6 +7338,82 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.1", + "fancy-regex 0.11.0", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2 0.10.9", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thin-vec" version = "0.2.18" @@ -6816,7 +7491,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -7325,7 +8002,7 @@ version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "futures-util", "http 1.4.0", @@ -7563,6 +8240,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uiautomation" version = "0.25.0" @@ -7652,6 +8335,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.2.2" @@ -7796,6 +8490,7 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "serde_core", @@ -7820,6 +8515,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wacore" version = "0.5.0" @@ -7893,7 +8597,7 @@ checksum = "127a3a6e7554ce002092f1711aa927b0efa3d3fb1ee83506525565c626e68834" dependencies = [ "flate2", "phf 0.13.1", - "phf_codegen", + "phf_codegen 0.13.1", "serde", "serde_json", ] @@ -8139,7 +8843,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -8218,6 +8922,78 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid 1.23.1", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid 0.22.14", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "whatsapp-rust" version = "0.5.0" @@ -8998,7 +9774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.1", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8ac6962ac..aacb8d574 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -274,6 +274,14 @@ pdf-extract = "0.10" # stack) now lives in the tinychannels crate; the `whatsapp-web` feature forwards # to `tinychannels/whatsapp-web`. ppt-rs = "0.2.14" +# Terminal chat UI (`openhuman tui` / `chat`). Exclusive to the default-ON +# `tui` feature (see `[features]` below): a slim / headless build without `tui` +# drops both `ratatui` and `crossterm`. Kept in lockstep — ratatui 0.30 +# re-exports crossterm 0.29, so declaring crossterm directly at the same major +# unifies to a single crossterm in the dep graph. Only compiled into +# `src/openhuman/tui/` behind `#[cfg(feature = "tui")]`. +ratatui = { version = "0.30", optional = true } +crossterm = { version = "0.29", optional = true } # Native-Rust `.docx` writer for the `generate_document` tool (GH #4847). # Pure Rust (no subprocess / managed runtime), MIT-licensed, actively # maintained (2.8M downloads, last release 0.4.20 — Apr 2026). Mirrors the @@ -347,7 +355,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp"] +default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp", "tui"] # AST-aware code compression (tree-sitter Rust/TS/Python grammars; C build). # On by default; disable to fall back to the brace-depth heuristic. tokenjuice-treesitter = [ @@ -458,6 +466,19 @@ skills = [] # `sanitize::sanitize_for_llm`). The gate follows the real dependency graph, # not the directory name. mcp = [] +# Terminal chat UI: the `openhuman tui` (alias `chat`) CLI subcommand, a +# ratatui/crossterm terminal front-end onto the same `web_chat` surface the +# desktop app drives. Default-ON for the standalone `openhuman-core` binary, but +# INTENTIONALLY NOT forwarded to the desktop shell (the desktop app ships its own +# Tauri UI and never needs a terminal one) — see the allowlist entry in +# `scripts/ci/check-feature-forwarding.mjs`. Slim / headless builds opt out via +# `--no-default-features --features ""`, which drops +# the exclusive `ratatui` + `crossterm` dependencies. The module +# `src/openhuman/tui/` is a facade (always compiled) whose behavioural submodules +# are `#[cfg(feature = "tui")]`; when off, `tui::stub::run_from_cli` returns a +# build-fact "tui feature disabled at compile time" error from the untouched +# `"tui" | "chat"` CLI arm (mirrors the `mcp` stub pattern). +tui = ["dep:ratatui", "dep:crossterm"] sandbox-landlock = ["dep:landlock"] sandbox-bubblewrap = [] peripheral-rpi = ["dep:rppal"] diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index a579f09f6..d68f663fa 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -599,6 +599,18 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an --- +## 14. Terminal Chat UI (`openhuman tui`) + +### 14.1 TUI Subcommand (feature-gated `tui`) + +| ID | Feature | Layer | Test path(s) | Status | Notes | +| ------ | ------------------------------------------------- | ----- | ----------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------- | +| 14.1.1 | Transcript reducer (deltas, done, error, tools) | RU | `src/openhuman/tui/state.rs` | ✅ | Pure `TranscriptState::apply_event` — client-id filtering, text/thinking split, `chat_done` finalization | +| 14.1.2 | Runner flags + thread resolution + RPC name pins | RU | `src/openhuman/tui/runner.rs`, `src/openhuman/tui/app.rs` | ✅ | `--thread`/`--new` parsing; canonical `openhuman.*` method names resolve via registry | +| 14.1.3 | Render layout (viewport, input, status) | RU | `src/openhuman/tui/render.rs` | ✅ | ratatui TestBackend | +| 14.1.4 | Disabled-build stub (`--no-default-features`) | RU | `src/core/cli_tests.rs` (`tui`/`chat` `*_reports_disabled_build_when_gate_off`) | ✅ | Build-fact error, not `unknown namespace` | +| 14.1.5 | Interactive terminal session (raw mode, streaming) | MS | manual smoke | 🚫 | Needs a real TTY; not driver-automatable | + ## Summary | Status | Count | diff --git a/docs/plans/tui-chat-plan.md b/docs/plans/tui-chat-plan.md new file mode 100644 index 000000000..76c71356c --- /dev/null +++ b/docs/plans/tui-chat-plan.md @@ -0,0 +1,95 @@ +# Plan: `openhuman tui` — feature-gated terminal chat UI + +## Goal + +Running `openhuman-core tui` (alias `chat`) opens a ratatui-based terminal UI that is an +interface into the general chat (the same `web_chat` surface the desktop app uses), +gated behind a Cargo feature `tui`. + +## Architecture decisions (grounded in current code) + +1. **Cargo feature**: `tui = ["dep:ratatui", "dep:crossterm"]`, added to `default` + (repo convention: gates default-ON). It must NOT ship in the desktop app — + add `'tui': 'Terminal UI subcommand; the desktop app ships its own Tauri UI.'` + to `INTENTIONALLY_NOT_FORWARDED` in `scripts/ci/check-feature-forwarding.mjs`. +2. **Module**: new domain dir `src/openhuman/tui/` using the **mcp/voice facade pattern**: + - `mod.rs` always compiled; real submodules `#[cfg(feature = "tui")]`; + `#[cfg(not(feature = "tui"))] mod stub;` exposing the same `run_from_cli`. + - `stub.rs` `run_from_cli` bails with + `"tui feature disabled at compile time … rebuild with --features tui"` + (mirror `src/openhuman/mcp_server/stub.rs:42`). + - No controllers, no agent tools, no `all.rs` changes (leaf client, like `flows`' + philosophy: absence, not degraded registration — but here the only outside + touch-point is the CLI arm, which uses the stub for a build-fact error). +3. **CLI arm**: in `src/core/cli.rs` match (~line 63), add + `"tui" | "chat" => crate::openhuman::tui::run_from_cli(&args[1..])`. + Arm stays **un-cfg'd** (mcp precedent). Add `"tui" | "chat"` to the banner-suppression + `matches!` at lines 48–50 (a TUI must own the terminal). +4. **In-process core, no HTTP**: build a multi-thread tokio runtime with + `AGENT_WORKER_STACK_BYTES` stack (copy `run_server_command` shape, cli.rs:219–311), + then `CoreBuilder::new(HostKind::Cli).domains(DomainSet::full()).services(ServiceSet::none()).build()`. + `channel.web_chat` needs `DomainGroup::Channels`, so `harness()` is not enough. +5. **Chat flow**: + - `client_id = "tui-"`. + - Threads via `runtime.invoke("threads.list"| "threads.create_new", …)`; + CLI flags: `--thread `, `--new` (default: create new thread). + - Send turn: `runtime.invoke("channel.web_chat", {client_id, thread_id, message, …})` + (schema: `src/openhuman/web_chat/schemas.rs:45`; ops entry `ops.rs:1199`/`start_chat` at 391). + - Stream: drain `web_chat::subscribe_web_channel_events()` (broadcast bus, + `src/openhuman/web_chat/event_bus.rs:14`), filter by our `client_id`. + Render `text_delta`/`thinking_delta` (`delta`, `delta_kind` fields on + `WebChannelEvent`, `src/core/socketio.rs:98`), show `tool_call`/`tool_result` + as status lines, finish on `chat_done` (use `full_response` as authoritative + final text) or `chat_error` (show `message`). + - Cancel in-flight turn: `channel.web_cancel` on Esc. +6. **UI v1 scope** (keep it tight): + - Alternate screen + raw mode; transcript viewport with scrollback + (PgUp/PgDn/mouse optional), single-line input box, status bar + (thread id, model/turn state, key hints), spinner while streaming. + - Keys: Enter=send, Esc=cancel turn, Ctrl+N=new thread, Ctrl+C/Ctrl+D=quit. + - Distinguish user / assistant / thinking (dim) / tool activity / errors. + Ocean-ish accent per design tokens is fine but keep it terminal-native. +7. **Terminal hygiene (critical)**: + - Panic hook + Drop guard that restores the terminal (leave raw mode, + LeaveAlternateScreen) before the panic message prints. + - **Logging must not hit stdout/stderr while the TUI owns the terminal** — + inspect how `run_from_cli`/`run_server_command` init logging + (`src/core/logging.rs`) and route core logs to file only (or suppress console) + for the tui arm. Core boot logs corrupting the UI is a bug. +8. **Separation for testability**: pure state module (`transcript.rs` or `state.rs`) + holding a reducer `apply_event(&mut TranscriptState, &WebChannelEvent)` with no + terminal deps — unit-testable. Rendering (`render.rs`) and event loop (`app.rs`) + stay thin. + +## Tests / verification (definition of done) + +- Unit tests for the reducer: text_delta accumulation, thinking vs text separation, + chat_done replaces with full_response, chat_error, ignores other client_ids, + tool_call/result lines. +- `src/core/cli_tests.rs`: `tui_subcommand_reports_disabled_build_when_gate_off` + (+ `chat` alias) under `#[cfg(not(feature = "tui"))]`, mirroring the mcp tests + (assert error contains "tui feature disabled" and NOT "unknown namespace"). +- Builds (Apple Silicon: prefix `GGML_NATIVE=OFF`): + - `cargo check --manifest-path Cargo.toml` + - `cargo check --no-default-features --features tokenjuice-treesitter` (disabled build) + - `cargo test --lib core::cli` and the tui module tests, both feature directions: + `cargo test --lib --no-default-features --features tokenjuice-treesitter core::` +- `node scripts/ci/check-feature-forwarding.mjs` passes with the allowlist entry. +- `cargo fmt` clean. + +## Docs + +- AGENTS.md: add `tui` row to the feature table + a short gate section + (leaf-ish gate, sheds `ratatui`+`crossterm`, intentionally not forwarded to desktop). +- `src/openhuman/about_app/`: add user-facing feature entry for the terminal chat UI. + +## Non-goals (v1) + +- No thread-picker UI beyond `--thread/--new` flags, no markdown rendering, + no image/artifact display, no approval-request interaction (surface as a status + line telling the user to handle it elsewhere), no remote-core (HTTP) mode. + +## Workflow + +Small focused commits on `feat/tui-chat` in this worktree; don't push. +Debug logging with `[tui]` prefix on all state transitions. diff --git a/scripts/ci/check-feature-forwarding.mjs b/scripts/ci/check-feature-forwarding.mjs index 76844cc4f..a346f4c7d 100644 --- a/scripts/ci/check-feature-forwarding.mjs +++ b/scripts/ci/check-feature-forwarding.mjs @@ -32,6 +32,7 @@ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); */ const INTENTIONALLY_NOT_FORWARDED = { // 'some-gate': 'Reason it must not ship in the desktop build.', + tui: 'Terminal UI subcommand (openhuman tui/chat); the desktop app ships its own Tauri UI and never runs the ratatui terminal front-end.', }; function usage() { diff --git a/src/core/cli.rs b/src/core/cli.rs index 2d6b55be9..87e67be5c 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -45,7 +45,14 @@ Contribute & Star us on GitHub: https://github.com/tinyhumansai/openhuman /// the subcommand/namespace is unknown. pub fn run_from_cli_args(args: &[String]) -> Result<()> { // Print the welcome banner to stderr to keep stdout clean for JSON output. - if !matches!(args.first().map(String::as_str), Some("mcp" | "mcp-server")) { + // `mcp`/`mcp-server` speak JSON-RPC on stdout; `tui`/`chat` own the whole + // terminal (alternate screen + raw mode) — a banner on either would corrupt + // the stream / the UI, so both suppress it. The `matches!` is on the raw + // string, so it stays valid even when the `tui` feature is compiled out. + if !matches!( + args.first().map(String::as_str), + Some("mcp" | "mcp-server" | "tui" | "chat") + ) { eprint!("{CLI_BANNER}"); } @@ -61,6 +68,11 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { match args[0].as_str() { "run" | "serve" => run_server_command(&args[1..]), "mcp" | "mcp-server" => crate::openhuman::mcp_server::run_stdio_from_cli(&args[1..]), + // Terminal chat UI. Un-`#[cfg]`'d on purpose: in a slim build this + // resolves to `tui::stub::run_from_cli`, which bails with a build-fact + // error (see `src/openhuman/tui/stub.rs`) rather than falling through to + // `unknown namespace: tui`. + "tui" | "chat" => crate::openhuman::tui::run_from_cli(&args[1..]), "call" => run_call_command(&args[1..]), // Domain-specific CLI adapters that don't follow the generic namespace pattern. "screen-intelligence" => { @@ -561,6 +573,7 @@ fn print_general_help(grouped: &BTreeMap>) { println!( " openhuman mcp [-v|--verbose] (stdio MCP server; read-only memory tools)" ); + println!(" openhuman tui [--thread |--new] (terminal chat UI, alias: chat)"); println!(" openhuman skills [options] (skill development runtime)"); println!(" openhuman agent [options] (inspect agent definitions & prompts)"); println!(" openhuman voice [--hotkey ] [--mode ] (voice dictation server)"); diff --git a/src/core/cli_tests.rs b/src/core/cli_tests.rs index adf6d7c30..2b8f94d63 100644 --- a/src/core/cli_tests.rs +++ b/src/core/cli_tests.rs @@ -195,3 +195,55 @@ fn mcp_server_alias_reports_disabled_build_when_gate_off() { "the `mcp-server` alias must give the same build-fact diagnostic as `mcp`" ); } + +// --- `tui` compile-time gate -------------------------------------------- + +/// With the `tui` feature compiled out, `openhuman tui` must fail with a +/// diagnostic that names the BUILD as the cause — not a generic +/// "unknown namespace" error. +/// +/// Same reasoning as the `mcp` gate test above: the naive way to gate the CLI +/// is to delete the `"tui" | "chat"` match arm, which is WRONG — `tui` would +/// fall through to generic namespace resolution and die with `unknown +/// namespace: tui`, reading like a user typo. Instead `cli.rs` is untouched and +/// the arm resolves to `tui::stub::run_from_cli`, which bails with the message +/// asserted below. +#[test] +#[cfg(not(feature = "tui"))] +fn tui_subcommand_reports_disabled_build_when_gate_off() { + let _guard = env_lock(); + + let err = crate::core::cli::run_from_cli_args(&["tui".to_string()]) + .expect_err("`openhuman tui` must fail when the `tui` feature is compiled out"); + let msg = err.to_string(); + + assert!( + msg.contains("tui feature disabled"), + "error must name the compile-time gate as the cause; got: {msg}" + ); + assert!( + msg.contains("--features tui"), + "error must tell the user how to get a working build; got: {msg}" + ); + assert!( + !msg.contains("unknown namespace"), + "must NOT degrade into generic namespace resolution — that reads like a typo, \ + not a build fact; got: {msg}" + ); +} + +/// The `chat` alias must behave identically to `tui` — both arms route to the +/// same stub, so neither can silently regress into the fall-through. +#[test] +#[cfg(not(feature = "tui"))] +fn chat_alias_reports_disabled_build_when_gate_off() { + let _guard = env_lock(); + + let err = crate::core::cli::run_from_cli_args(&["chat".to_string()]) + .expect_err("`openhuman chat` must fail when the `tui` feature is compiled out"); + + assert!( + err.to_string().contains("tui feature disabled"), + "the `chat` alias must give the same build-fact diagnostic as `tui`" + ); +} diff --git a/src/core/logging.rs b/src/core/logging.rs index 323caaefe..639d247aa 100644 --- a/src/core/logging.rs +++ b/src/core/logging.rs @@ -319,6 +319,87 @@ pub fn init_for_embedded(data_dir: &Path, verbose: bool) { }); } +/// Initialize logging for the terminal chat UI (`openhuman tui` / `chat`). +/// +/// **File-only, never stderr.** The TUI owns the whole terminal (alternate +/// screen + raw mode); a single `tracing`/`log` line written to stdout or +/// stderr would corrupt the rendered UI. So — unlike [`init_for_cli_run`] +/// (stderr) and [`init_for_embedded`] (stderr + file) — this installs **only** +/// a daily-rotated file appender at `/logs/openhuman-YYYY-MM-DD.log` +/// plus the Sentry layer (which keeps no console handle). Core boot logs and +/// the `[tui]` state-transition logs land in that file for post-mortem +/// debugging without ever touching the screen. +/// +/// Idempotent (`Once`-guarded, shared with the other init entry points). If a +/// subscriber was somehow already installed, this is a no-op and logging keeps +/// whatever destination the first caller chose — still never stderr from *this* +/// path. Returns the resolved log directory on success (for a status line), or +/// `None` when the file appender could not be created. +pub fn init_for_tui(data_dir: &Path, verbose: bool) -> Option { + INIT.call_once(|| { + let scope = CliLogDefault::Global; + seed_rust_log(verbose, scope); + let filter = build_env_filter(verbose, scope); + + let logs_dir = data_dir.join("logs"); + let pending_file: Option<(_, tracing_appender::non_blocking::WorkerGuard, PathBuf)> = + match std::fs::create_dir_all(&logs_dir) { + Ok(()) => match tracing_appender::rolling::Builder::new() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix("openhuman") + .filename_suffix("log") + .max_log_files(7) + .build(&logs_dir) + { + Ok(appender) => { + let (writer, guard) = tracing_appender::non_blocking(appender); + Some((writer, guard, logs_dir.clone())) + } + Err(err) => { + // No tracing subscriber yet, but we deliberately do NOT + // eprintln! here (the TUI is about to take the terminal). + // Losing this one diagnostic is the correct trade. + let _ = err; + None + } + }, + Err(_) => None, + }; + + let file_layer = pending_file.as_ref().map(|(writer, _, _)| { + let constraints = parse_log_file_constraints(); + tracing_subscriber::fmt::layer() + .with_ansi(false) + .event_format(CleanCliFormat) + .with_writer(writer.clone()) + .with_filter(tracing_subscriber::filter::filter_fn(move |meta| { + event_matches_file_constraints(meta, &constraints) + })) + }); + + // NOTE: no stderr layer here — that is the whole point of this entry + // point. Only the file layer + Sentry are attached. + if tracing_subscriber::registry() + .with(filter) + .with(file_layer) + .with(sentry_tracing_layer()) + .try_init() + .is_ok() + { + if let Some((_, guard, dir)) = pending_file { + if let Ok(mut slot) = FILE_GUARD.lock() { + *slot = Some(guard); + } + let _ = LOG_DIR.set(dir); + } + } + + let _ = tracing_log::LogTracer::init(); + }); + + log_directory().map(Path::to_path_buf) +} + /// Path to the active log directory (set by [`init_for_embedded`]). Returns /// `None` if logging hasn't been initialized in embedded mode (e.g. bare /// CLI runs). diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index c8f53db1d..8e48a3334 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -227,6 +227,23 @@ pub(super) const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Stable, privacy: None, }, + Capability { + id: "conversation.terminal_chat", + name: "Terminal Chat (TUI)", + domain: "tui", + category: CapabilityCategory::Conversation, + description: "Chat with the assistant from a terminal instead of the desktop UI. \ + `openhuman tui` (alias `chat`) opens a ratatui full-screen chat onto the \ + same conversation surface the app uses, running the core in-process. \ + Streams replies, thinking, and tool activity live; supports scrollback, \ + cancelling a turn, and starting a new thread. Ships only in the standalone \ + `openhuman-core` binary (the desktop app has its own UI).", + how_to: "Run `openhuman tui` (or `openhuman chat`) from a terminal. Flags: \ + `--thread ` to resume a thread, `--new` for a fresh one. Keys: Enter send, \ + Esc cancel, Ctrl+N new thread, PgUp/PgDn scroll, Ctrl+C quit.", + status: CapabilityStatus::Beta, + privacy: DERIVED_TO_BACKEND, + }, Capability { id: "conversation.suggested_questions", name: "Suggested Questions", diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 8509fcabb..dc2657401 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -142,6 +142,7 @@ pub mod tool_registry; pub mod tool_status; pub mod tool_timeout; pub mod tools; +pub mod tui; pub mod update; pub mod util; pub mod voice; diff --git a/src/openhuman/tui/app.rs b/src/openhuman/tui/app.rs new file mode 100644 index 000000000..a1c944d5a --- /dev/null +++ b/src/openhuman/tui/app.rs @@ -0,0 +1,240 @@ +//! Terminal chat event loop. +//! +//! Bridges three async sources over `tokio::select!`: +//! * **keyboard** — a blocking crossterm reader thread forwards `Event`s over +//! an mpsc channel (crossterm's own async `EventStream` needs the +//! `event-stream` feature; the poll+forward thread keeps the dep surface +//! minimal and exits promptly via the shared `shutdown` flag), +//! * **web-channel broadcast** — the same `web_chat` event stream the desktop +//! app consumes, folded into [`TranscriptState`] by its reducer, +//! * **a spinner ticker** — animates the streaming indicator. +//! +//! All state transitions are logged with the `[tui]` prefix to the file-only +//! subscriber (see `logging::init_for_tui`); nothing is ever `println!`'d. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use serde_json::json; +use tokio::sync::broadcast; + +use crate::core::runtime::CoreRuntime; +use crate::core::socketio::WebChannelEvent; +use crate::openhuman::web_chat; + +use super::render::{self, UiState}; +use super::state::TranscriptState; +use super::terminal::TerminalGuard; + +/// Run the terminal chat loop until the user quits (Ctrl+C / Ctrl+D) or the +/// web-channel bus closes. The [`TerminalGuard`] restores the terminal on every +/// exit path, including panics. +pub async fn run( + runtime: Arc, + client_id: String, + thread_id: String, + mut web_rx: broadcast::Receiver, +) -> anyhow::Result<()> { + let mut guard = TerminalGuard::enter()?; + + let mut state = TranscriptState::new(client_id.clone()); + state.push_system(format!( + "Connected · thread {thread_id}. Type a message and press Enter. Ctrl+C to quit." + )); + let mut ui = UiState::new(thread_id, client_id.clone()); + + // Blocking crossterm reader → async channel. + let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel::(); + let shutdown = Arc::new(AtomicBool::new(false)); + let reader_shutdown = shutdown.clone(); + let reader = std::thread::spawn(move || { + while !reader_shutdown.load(Ordering::Relaxed) { + match event::poll(Duration::from_millis(100)) { + Ok(true) => match event::read() { + Ok(ev) => { + if input_tx.send(ev).is_err() { + break; + } + } + Err(_) => break, + }, + Ok(false) => {} + Err(_) => break, + } + } + }); + + let mut ticker = tokio::time::interval(Duration::from_millis(120)); + let mut quit = false; + + while !quit { + guard.terminal().draw(|f| render::draw(f, &state, &ui))?; + + tokio::select! { + maybe_ev = input_rx.recv() => match maybe_ev { + Some(Event::Key(key)) => { + if handle_key(key, &runtime, &client_id, &mut state, &mut ui).await { + quit = true; + } + } + Some(_) => {} // resize / mouse / paste — redraw next iteration + None => quit = true, // reader thread gone + }, + recv = web_rx.recv() => match recv { + Ok(ev) => state.apply_event(&ev), + Err(broadcast::error::RecvError::Lagged(n)) => { + log::warn!("[tui] web-channel lagged, dropped {n} events"); + } + Err(broadcast::error::RecvError::Closed) => { + log::warn!("[tui] web-channel closed — exiting"); + quit = true; + } + }, + _ = ticker.tick() => { + ui.spinner_tick = ui.spinner_tick.wrapping_add(1); + } + } + } + + shutdown.store(true, Ordering::Relaxed); + let _ = reader.join(); + log::info!("[tui] event loop exited"); + Ok(()) +} + +/// Handle a key event. Returns `true` when the app should quit. +async fn handle_key( + key: KeyEvent, + runtime: &Arc, + client_id: &str, + state: &mut TranscriptState, + ui: &mut UiState, +) -> bool { + // Ignore key-release events (Windows / kitty report both edges). + if key.kind == KeyEventKind::Release { + return false; + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + match key.code { + KeyCode::Char('c') if ctrl => { + log::info!("[tui] quit via Ctrl+C"); + return true; + } + KeyCode::Char('d') if ctrl => { + log::info!("[tui] quit via Ctrl+D"); + return true; + } + KeyCode::Char('n') if ctrl => new_thread(runtime, state, ui).await, + KeyCode::Esc => cancel_turn(runtime, client_id, &ui.thread_id, state), + KeyCode::PageUp => { + ui.scroll_from_bottom = ui.scroll_from_bottom.saturating_add(5); + } + KeyCode::PageDown => { + ui.scroll_from_bottom = ui.scroll_from_bottom.saturating_sub(5); + } + KeyCode::Enter => send_message(runtime, client_id, state, ui), + KeyCode::Backspace => { + ui.input.pop(); + } + KeyCode::Char(c) if !ctrl => ui.input.push(c), + _ => {} + } + false +} + +/// Queue a chat turn on the current thread. Fire-and-forget: the reply streams +/// back over the web-channel bus and is folded in by the reducer. +fn send_message( + runtime: &Arc, + client_id: &str, + state: &mut TranscriptState, + ui: &mut UiState, +) { + let message = ui.input.trim().to_string(); + if message.is_empty() { + return; + } + ui.input.clear(); + ui.scroll_from_bottom = 0; + state.begin_user_turn(&message); + log::info!( + "[tui] send message len={} thread={}", + message.len(), + ui.thread_id + ); + + let rt = runtime.clone(); + let cid = client_id.to_string(); + let tid = ui.thread_id.clone(); + tokio::spawn(async move { + let params = json!({ + "client_id": cid, + "thread_id": tid, + "message": message, + "source": "tui", + }); + if let Err(e) = rt.invoke("openhuman.channel_web_chat", params).await { + log::error!("[tui] openhuman.channel_web_chat failed: {e}"); + // Surface the failure in-transcript via a synthetic chat_error so + // the reducer clears the streaming state and shows the reason. + web_chat::publish_web_channel_event(WebChannelEvent { + event: "chat_error".to_string(), + client_id: cid, + thread_id: tid, + message: Some(format!("Failed to send: {e}")), + error_type: Some("transport".to_string()), + ..Default::default() + }); + } + }); +} + +/// Cancel the in-flight turn on the current thread. The core emits a +/// `chat_error` ("Cancelled") which the reducer renders. +fn cancel_turn( + runtime: &Arc, + client_id: &str, + thread_id: &str, + state: &TranscriptState, +) { + if !state.is_streaming() { + return; + } + log::info!("[tui] cancel turn thread={thread_id}"); + let rt = runtime.clone(); + let cid = client_id.to_string(); + let tid = thread_id.to_string(); + tokio::spawn(async move { + // Omit `request_id` → stop whatever is running on the thread. + let params = json!({ "client_id": cid, "thread_id": tid }); + if let Err(e) = rt.invoke("openhuman.channel_web_cancel", params).await { + log::error!("[tui] openhuman.channel_web_cancel failed: {e}"); + } + }); +} + +/// Create a fresh thread and switch the UI to it. Awaited inline (fast, local +/// SQLite write) so `ui.thread_id` can be updated with the result. +async fn new_thread(runtime: &Arc, state: &mut TranscriptState, ui: &mut UiState) { + log::info!("[tui] creating new thread"); + match runtime + .invoke("openhuman.threads_create_new", json!({})) + .await + .ok() + .and_then(|v| super::runner::extract_thread_id(&v)) + { + Some(new_id) => { + ui.thread_id = new_id.clone(); + ui.scroll_from_bottom = 0; + state.push_system(format!("Started a new thread · {new_id}")); + log::info!("[tui] switched to new thread {new_id}"); + } + None => { + state.push_system("Could not create a new thread (see logs).".to_string()); + log::error!("[tui] threads.create_new returned no thread id"); + } + } +} diff --git a/src/openhuman/tui/mod.rs b/src/openhuman/tui/mod.rs new file mode 100644 index 000000000..c684ddfdb --- /dev/null +++ b/src/openhuman/tui/mod.rs @@ -0,0 +1,52 @@ +//! Terminal chat UI — the `openhuman tui` (alias `chat`) CLI subcommand. +//! +//! A [ratatui]-based terminal front-end onto the **same `web_chat` surface** +//! the desktop app drives (`openhuman.channel_web_chat` / +//! `openhuman.channel_web_cancel` + +//! [`web_chat::subscribe_web_channel_events`](crate::openhuman::web_chat::subscribe_web_channel_events)). +//! It boots the core in-process — no HTTP, no sockets — via +//! `CoreBuilder::new(HostKind::Cli).domains(DomainSet::full()).services(ServiceSet::none())` +//! and streams a live transcript in the terminal. +//! +//! ## Compile-time gate (`tui` feature) +//! +//! `pub mod tui;` is ALWAYS compiled — it is a facade (mirrors +//! [`mcp_server`](crate::openhuman::mcp_server)). The terminal driver, the +//! renderer, the reducer, and the event loop are gated behind the default-ON +//! `tui` Cargo feature; when it is off, [`stub`] mirrors the one surface an +//! always-compiled caller reaches — [`run_from_cli`] — with a build-fact +//! disabled-error body. +//! +//! The `"tui" | "chat"` arm in `src/core/cli.rs` is deliberately left +//! **un-`#[cfg]`'d**: in a slim build it resolves to [`stub::run_from_cli`], +//! which bails with a message naming the compile-time gate as the cause (so the +//! error reads like a build fact, not `unknown namespace: tui`). This is the +//! same reasoning documented on [`mcp_server::stub::run_stdio_from_cli`]. + +#[cfg(feature = "tui")] +mod app; +#[cfg(feature = "tui")] +mod render; +#[cfg(feature = "tui")] +mod runner; +#[cfg(feature = "tui")] +mod state; +#[cfg(feature = "tui")] +mod terminal; + +#[cfg(feature = "tui")] +pub use runner::run_from_cli; + +// State reducer is behaviour-only but has no terminal deps, so its tests run in +// the default (feature-on) build. Exported for the sibling submodules + tests. +#[cfg(feature = "tui")] +pub use state::{Entry, EntryKind, TranscriptState}; + +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `tui` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "tui"))] +mod stub; +#[cfg(not(feature = "tui"))] +pub use stub::*; diff --git a/src/openhuman/tui/render.rs b/src/openhuman/tui/render.rs new file mode 100644 index 000000000..b9e11609b --- /dev/null +++ b/src/openhuman/tui/render.rs @@ -0,0 +1,248 @@ +//! Ratatui rendering for the terminal chat UI — pure view over +//! [`TranscriptState`] + [`UiState`]. No state mutation happens here. +//! +//! Layout (top → bottom): +//! * transcript viewport (fills remaining height, wraps + scrolls) +//! * single-line input box (bordered) +//! * status bar (thread id, turn state, key hints) + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; +use unicode_width::UnicodeWidthStr; + +use super::state::{EntryKind, TranscriptState}; + +/// Ocean accent from the design tokens (`#4A83DD`), kept terminal-native. +const OCEAN: Color = Color::Rgb(0x4A, 0x83, 0xDD); + +const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// View-only UI state owned by the event loop and read by [`draw`]. +pub struct UiState { + /// Current input line contents. + pub input: String, + /// Lines scrolled up from the tail. `0` follows the newest content. + pub scroll_from_bottom: u16, + /// Monotonic tick used to animate the streaming spinner. + pub spinner_tick: usize, + /// The thread id shown in the status bar. + pub thread_id: String, + /// The client stream id (for the status bar, abbreviated). + pub client_id: String, +} + +impl UiState { + pub fn new(thread_id: String, client_id: String) -> Self { + Self { + input: String::new(), + scroll_from_bottom: 0, + spinner_tick: 0, + thread_id, + client_id, + } + } +} + +/// Draw one frame. +pub fn draw(frame: &mut Frame, state: &TranscriptState, ui: &UiState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), // transcript + Constraint::Length(3), // input box + Constraint::Length(1), // status bar + ]) + .split(frame.area()); + + draw_transcript(frame, chunks[0], state, ui); + draw_input(frame, chunks[1], ui); + draw_status(frame, chunks[2], state, ui); +} + +fn draw_transcript(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) { + let block = Block::default() + .borders(Borders::ALL) + .title(" OpenHuman chat ") + .border_style(Style::default().fg(OCEAN)); + let inner = block.inner(area); + + let text = transcript_text(state); + // Inner width available for wrapping (borders eat 2 cols). + let wrap_width = inner.width.max(1); + let total_lines: u16 = text + .lines + .iter() + .map(|line| wrapped_line_count(line, wrap_width)) + .sum::(); + let viewport = inner.height.max(1); + let max_scroll = total_lines.saturating_sub(viewport); + let scroll_from_bottom = ui.scroll_from_bottom.min(max_scroll); + let top = max_scroll.saturating_sub(scroll_from_bottom); + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((top, 0)); + frame.render_widget(paragraph, area); +} + +fn draw_input(frame: &mut Frame, area: Rect, ui: &UiState) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Message ") + .border_style(Style::default().fg(Color::DarkGray)); + let inner_width = block.inner(area).width.max(1) as usize; + + // Keep the caret end of a long input visible. + let display = tail_to_width(&ui.input, inner_width.saturating_sub(1)); + let line = Line::from(vec![ + Span::styled(display, Style::default().fg(Color::White)), + Span::styled("▏", Style::default().fg(OCEAN)), + ]); + let paragraph = Paragraph::new(line).block(block); + frame.render_widget(paragraph, area); +} + +fn draw_status(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) { + let turn = if state.is_streaming() { + let frame_ch = SPINNER_FRAMES[ui.spinner_tick % SPINNER_FRAMES.len()]; + format!("{frame_ch} streaming") + } else { + "idle".to_string() + }; + let thread_short = abbreviate(&ui.thread_id, 24); + + let left = Span::styled( + format!(" thread {thread_short} · {turn} "), + Style::default().fg(Color::Black).bg(OCEAN), + ); + let hints = Span::styled( + " Enter send · Esc cancel · Ctrl+N new · PgUp/PgDn scroll · Ctrl+C quit", + Style::default().fg(Color::DarkGray), + ); + let paragraph = Paragraph::new(Line::from(vec![left, hints])); + frame.render_widget(paragraph, area); +} + +/// Build the styled transcript body from the reducer state. +fn transcript_text(state: &TranscriptState) -> Text<'static> { + let mut lines: Vec = Vec::new(); + for entry in state.entries() { + let (prefix, style) = match entry.kind { + EntryKind::User => ( + "You ", + Style::default().fg(OCEAN).add_modifier(Modifier::BOLD), + ), + EntryKind::Assistant => ("AI ", Style::default().fg(Color::White)), + EntryKind::Thinking => ( + "··· ", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ), + EntryKind::Tool => ("tool ", Style::default().fg(Color::Yellow)), + EntryKind::Error => ( + "err ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + EntryKind::System => (" ", Style::default().fg(Color::DarkGray)), + }; + + let mut first = true; + for raw in entry.text.split('\n') { + let gutter = if first { prefix } else { " " }; + lines.push(Line::from(vec![ + Span::styled(gutter.to_string(), style.add_modifier(Modifier::DIM)), + Span::styled(raw.to_string(), style), + ])); + first = false; + } + // Blank spacer between entries for readability. + lines.push(Line::from("")); + } + Text::from(lines) +} + +/// Approximate the number of visual rows a wrapped line occupies at `width`. +/// ratatui wraps on word boundaries; this display-width estimate is close +/// enough for scroll bookkeeping (off-by-one at most, harmless). +fn wrapped_line_count(line: &Line, width: u16) -> u16 { + let w = width.max(1) as usize; + let content_width: usize = line + .spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); + if content_width == 0 { + 1 + } else { + content_width.div_ceil(w).max(1) as u16 + } +} + +/// Return the trailing slice of `s` that fits within `width` display columns. +fn tail_to_width(s: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + let mut out: Vec = Vec::new(); + let mut used = 0usize; + for ch in s.chars().rev() { + let cw = UnicodeWidthStr::width(ch.to_string().as_str()).max(1); + if used + cw > width { + break; + } + used += cw; + out.push(ch); + } + out.into_iter().rev().collect() +} + +/// Middle-truncate an id to `max` columns (`abc…xyz`). +fn abbreviate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let keep = max.saturating_sub(1) / 2; + let head: String = s.chars().take(keep).collect(); + let tail: String = s + .chars() + .rev() + .take(keep) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{head}…{tail}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tail_to_width_keeps_the_end() { + assert_eq!(tail_to_width("hello world", 5), "world"); + assert_eq!(tail_to_width("hi", 10), "hi"); + assert_eq!(tail_to_width("anything", 0), ""); + } + + #[test] + fn abbreviate_middle_truncates_long_ids() { + let out = abbreviate("thread-0123456789abcdef", 11); + assert!(out.contains('…')); + assert!(out.chars().count() <= 11); + assert_eq!(abbreviate("short", 24), "short"); + } + + #[test] + fn wrapped_line_count_divides_by_width() { + let line = Line::from("a".repeat(25)); + assert_eq!(wrapped_line_count(&line, 10), 3); + let empty = Line::from(""); + assert_eq!(wrapped_line_count(&empty, 10), 1); + } +} diff --git a/src/openhuman/tui/runner.rs b/src/openhuman/tui/runner.rs new file mode 100644 index 000000000..9fcc88648 --- /dev/null +++ b/src/openhuman/tui/runner.rs @@ -0,0 +1,224 @@ +//! CLI entry point for the terminal chat UI (`openhuman tui` / `chat`). +//! +//! Parses flags, initializes **file-only** logging (the TUI owns the terminal — +//! see `logging::init_for_tui`), boots the core in-process with no transport and +//! no background services, resolves the target thread, and hands off to the +//! event loop in [`super::app`]. + +use std::path::PathBuf; +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::core::runtime::{ + CoreBuilder, CoreRuntime, DomainSet, ServiceSet, AGENT_WORKER_STACK_BYTES, +}; +use crate::core::types::HostKind; + +/// Entry point dispatched from the `"tui" | "chat"` arm in `src/core/cli.rs`. +/// +/// Flags: +/// * `--thread ` — attach to an existing thread. +/// * `--new` — force a brand-new thread (default when `--thread` is absent). +/// * `-v` / `--verbose` — debug-level file logging. +pub fn run_from_cli(args: &[String]) -> anyhow::Result<()> { + let mut thread_id: Option = None; + let mut force_new = false; + let mut verbose = false; + + let mut i = 0usize; + while i < args.len() { + match args[i].as_str() { + "--thread" => { + thread_id = Some( + args.get(i + 1) + .ok_or_else(|| anyhow::anyhow!("missing value for --thread"))? + .clone(), + ); + i += 2; + } + "--new" => { + force_new = true; + i += 1; + } + "-v" | "--verbose" => { + verbose = true; + i += 1; + } + "-h" | "--help" => { + print_help(); + return Ok(()); + } + other => return Err(anyhow::anyhow!("unknown tui arg: {other}")), + } + } + + // File-only logging — never stderr while the TUI owns the terminal. + let data_dir = resolve_data_dir(); + let log_dir = crate::core::logging::init_for_tui(&data_dir, verbose); + log::info!( + "[tui] starting terminal chat UI (thread={:?} new={} logs={:?})", + thread_id, + force_new, + log_dir + ); + + // A chat turn is a large async state machine that can delegate to + // sub-agents; give the tokio workers the same roomy stack the server uses + // so a nested turn cannot overflow the default 2 MiB stack. + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(AGENT_WORKER_STACK_BYTES) + .build()?; + rt.block_on(async_main(thread_id, force_new)) +} + +async fn async_main(thread_flag: Option, force_new: bool) -> anyhow::Result<()> { + // In-process core: full domains (channel.web_chat needs DomainGroup::Channels, + // so harness() is not enough), no transport, no background services. + let runtime = Arc::new( + CoreBuilder::new(HostKind::Cli) + .domains(DomainSet::full()) + .services(ServiceSet::none()) + .build() + .await?, + ); + log::info!("[tui] core built (DomainSet::full, ServiceSet::none)"); + + let client_id = format!("tui-{}", short_hex()); + let thread_id = resolve_thread(&runtime, thread_flag, force_new).await?; + log::info!("[tui] resolved thread={thread_id} client_id={client_id}"); + + // Subscribe BEFORE the first turn so no streamed event is missed. + let web_rx = crate::openhuman::web_chat::subscribe_web_channel_events(); + + super::app::run(runtime, client_id, thread_id, web_rx).await +} + +/// Resolve the thread to open: the `--thread` id (unless `--new`), otherwise a +/// freshly created thread. +async fn resolve_thread( + runtime: &CoreRuntime, + thread_flag: Option, + force_new: bool, +) -> anyhow::Result { + if let (Some(id), false) = (thread_flag.as_ref(), force_new) { + log::debug!("[tui] attaching to existing thread {id}"); + return Ok(id.clone()); + } + + let created = runtime + .invoke("openhuman.threads_create_new", json!({})) + .await + .map_err(|e| anyhow::anyhow!("openhuman.threads_create_new failed: {e}"))?; + extract_thread_id(&created).ok_or_else(|| { + anyhow::anyhow!("openhuman.threads_create_new returned no thread id: {created}") + }) +} + +/// Pull a thread id out of a `threads.create_new` / `threads.list` response, +/// tolerant of the `RpcOutcome` log-envelope wrapping (`{result, logs}`) and the +/// `ApiEnvelope` data wrapping (`{data, meta}`). +pub(super) fn extract_thread_id(value: &Value) -> Option { + // Unwrap the optional `{result, logs}` log envelope first. + let inner = value.get("result").unwrap_or(value); + // Then the optional `{data, meta}` ApiEnvelope. + let payload = inner.get("data").unwrap_or(inner); + payload + .get("id") + .and_then(Value::as_str) + .map(str::to_string) +} + +/// Resolve the OpenHuman data dir (host of `logs/`), mirroring the shell's +/// resolution: `OPENHUMAN_WORKSPACE` override, else `~/.openhuman`, else a temp +/// fallback. No `eprintln!` — the TUI is about to take the terminal. +fn resolve_data_dir() -> PathBuf { + if let Ok(workspace) = std::env::var("OPENHUMAN_WORKSPACE") { + if !workspace.is_empty() { + return PathBuf::from(workspace); + } + } + crate::openhuman::config::default_root_openhuman_dir() + .unwrap_or_else(|_| std::env::temp_dir().join("openhuman")) +} + +/// 12 hex chars of randomness for the client stream id. +fn short_hex() -> String { + let u = uuid::Uuid::new_v4(); + u.simple().to_string()[..12].to_string() +} + +fn print_help() { + println!("Usage: openhuman tui [--thread ] [--new] [-v|--verbose]"); + println!(" openhuman chat [--thread ] [--new] [-v|--verbose]"); + println!(); + println!("Open a terminal chat UI onto the general-chat surface (the same one the"); + println!("desktop app uses). Runs the core in-process — no server, no ports."); + println!(); + println!(" --thread Attach to an existing conversation thread."); + println!(" --new Force a new thread (default when --thread is omitted)."); + println!(" -v, --verbose Debug-level logging (written to the log file, never the UI)."); + println!(); + println!("Keys: Enter send · Esc cancel turn · Ctrl+N new thread · PgUp/PgDn scroll ·"); + println!(" Ctrl+C / Ctrl+D quit."); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_thread_id_handles_bare_summary() { + let v = json!({ "id": "thread-1", "title": "x" }); + assert_eq!(extract_thread_id(&v).as_deref(), Some("thread-1")); + } + + #[test] + fn extract_thread_id_handles_api_envelope() { + let v = json!({ "data": { "id": "thread-2" }, "meta": {} }); + assert_eq!(extract_thread_id(&v).as_deref(), Some("thread-2")); + } + + #[test] + fn extract_thread_id_handles_log_envelope_around_api_envelope() { + let v = json!({ + "result": { "data": { "id": "thread-3" }, "meta": {} }, + "logs": ["created"] + }); + assert_eq!(extract_thread_id(&v).as_deref(), Some("thread-3")); + } + + #[test] + fn extract_thread_id_missing_returns_none() { + let v = json!({ "meta": {} }); + assert_eq!(extract_thread_id(&v), None); + } + + #[test] + fn short_hex_is_twelve_chars() { + assert_eq!(short_hex().len(), 12); + } + + /// Regression guard: the RPC method names the TUI invokes must be the + /// canonical `openhuman._` form that the registry + /// resolves — NOT the dotted `namespace.function` short form, which + /// `schema_for_rpc_method` does not recognise and which would make every + /// turn (and the launch-time thread creation) fail with "unknown method". + /// The dispatcher only rewrites a fixed set of legacy aliases; none of + /// these three are in that table, so the short form never resolves. + #[test] + fn tui_invokes_use_canonical_registered_rpc_method_names() { + for method in [ + "openhuman.channel_web_chat", + "openhuman.channel_web_cancel", + "openhuman.threads_create_new", + ] { + assert!( + crate::core::all::schema_for_rpc_method(method).is_some(), + "TUI invokes `{method}`, but it is not a registered RPC method — \ + the terminal chat UI would fail with `unknown method: {method}`" + ); + } + } +} diff --git a/src/openhuman/tui/state.rs b/src/openhuman/tui/state.rs new file mode 100644 index 000000000..1758ec413 --- /dev/null +++ b/src/openhuman/tui/state.rs @@ -0,0 +1,491 @@ +//! Pure, terminal-free transcript reducer for the terminal chat UI. +//! +//! [`TranscriptState`] is a plain data structure with **no ratatui / crossterm / +//! IO dependencies** — the renderer ([`super::render`]) reads it and the event +//! loop ([`super::app`]) mutates it, but the state transitions themselves live +//! here so they can be unit-tested without a terminal. +//! +//! The single entry point is [`TranscriptState::apply_event`], which folds a +//! [`WebChannelEvent`] (the same struct the desktop app receives over Socket.IO) +//! into the transcript. Events for a different `client_id` are ignored, so a +//! process-wide broadcast bus can be drained safely. + +use crate::core::socketio::WebChannelEvent; + +/// The kind of a transcript entry — drives colour / prefix in the renderer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryKind { + /// A message the local user sent. + User, + /// The assistant's streamed / final reply text. + Assistant, + /// The assistant's "thinking" (reasoning) stream — rendered dimmed. + Thinking, + /// A tool-call / tool-result status line. + Tool, + /// A terminal error (`chat_error`). + Error, + /// A local status/system note (never produced by `apply_event`). + System, +} + +/// One line-group in the transcript. `text` accumulates across streaming deltas. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + pub kind: EntryKind, + pub text: String, +} + +impl Entry { + fn new(kind: EntryKind, text: impl Into) -> Self { + Self { + kind, + text: text.into(), + } + } +} + +/// Accumulated transcript + streaming status for one chat client stream. +#[derive(Debug, Clone)] +pub struct TranscriptState { + /// Our stream identity. Events whose `client_id` differs are ignored. + client_id: String, + /// The rendered transcript, oldest first. + entries: Vec, + /// True while a turn is in flight (between send and `chat_done`/`chat_error`). + streaming: bool, + /// Index into `entries` of the assistant entry currently accumulating text + /// deltas for the in-flight turn, if any. + cur_assistant: Option, + /// Index into `entries` of the thinking entry currently accumulating + /// thinking deltas for the in-flight turn, if any. + cur_thinking: Option, +} + +impl TranscriptState { + /// Create an empty transcript bound to `client_id`. + pub fn new(client_id: impl Into) -> Self { + Self { + client_id: client_id.into(), + entries: Vec::new(), + streaming: false, + cur_assistant: None, + cur_thinking: None, + } + } + + /// The transcript entries, oldest first. + pub fn entries(&self) -> &[Entry] { + &self.entries + } + + /// Whether a turn is currently streaming. + pub fn is_streaming(&self) -> bool { + self.streaming + } + + /// Our client stream id. + pub fn client_id(&self) -> &str { + &self.client_id + } + + /// Record a locally-sent user message and begin a new turn. + /// + /// Resets the streaming cursors so the next `text_delta` / `thinking_delta` + /// opens fresh assistant / thinking entries for this turn. + pub fn begin_user_turn(&mut self, message: impl Into) { + let text = message.into(); + log::debug!("[tui] state: begin_user_turn len={}", text.len()); + self.entries.push(Entry::new(EntryKind::User, text)); + self.cur_assistant = None; + self.cur_thinking = None; + self.streaming = true; + } + + /// Push a local system/status note (e.g. "Cancelled", connection info). + pub fn push_system(&mut self, text: impl Into) { + let text = text.into(); + log::debug!("[tui] state: push_system len={}", text.len()); + self.entries.push(Entry::new(EntryKind::System, text)); + } + + /// Fold one [`WebChannelEvent`] into the transcript. + /// + /// Events whose `client_id` does not match ours are ignored (the web-channel + /// bus is process-wide). Returns nothing; inspect [`Self::entries`] / + /// [`Self::is_streaming`] afterwards. + pub fn apply_event(&mut self, ev: &WebChannelEvent) { + if ev.client_id != self.client_id { + log::trace!( + "[tui] state: ignoring event={} for other client_id={}", + ev.event, + ev.client_id + ); + return; + } + + match ev.event.as_str() { + "text_delta" => { + if let Some(delta) = ev.delta.as_deref() { + self.append_assistant(delta); + } + } + "thinking_delta" => { + if let Some(delta) = ev.delta.as_deref() { + self.append_thinking(delta); + } + } + "tool_call" => { + let name = ev.tool_name.as_deref().unwrap_or("tool"); + let args = ev.args.as_ref().map(summarize_json).unwrap_or_default(); + log::debug!("[tui] state: tool_call {name}"); + self.entries + .push(Entry::new(EntryKind::Tool, format!("→ {name}{args}"))); + } + "tool_result" => { + let name = ev.tool_name.as_deref().unwrap_or("tool"); + let ok = ev.success.unwrap_or(true); + let marker = if ok { "✓" } else { "✗" }; + let detail = ev + .output + .as_deref() + .map(truncate_line) + .filter(|s| !s.is_empty()) + .map(|s| format!(" — {s}")) + .unwrap_or_default(); + log::debug!("[tui] state: tool_result {name} ok={ok}"); + self.entries.push(Entry::new( + EntryKind::Tool, + format!("{marker} {name}{detail}"), + )); + } + "chat_done" => { + log::debug!( + "[tui] state: chat_done full_response={}", + ev.full_response.is_some() + ); + // `full_response` is authoritative — it replaces whatever the + // streamed text deltas accumulated (they can lag / be partial). + if let Some(full) = ev.full_response.as_deref() { + match self.cur_assistant { + Some(idx) => self.entries[idx].text = full.to_string(), + None => self + .entries + .push(Entry::new(EntryKind::Assistant, full.to_string())), + } + } + self.finish_turn(); + } + "chat_error" => { + let msg = ev.message.as_deref().unwrap_or("Unknown error").to_string(); + log::debug!("[tui] state: chat_error {msg}"); + self.entries.push(Entry::new(EntryKind::Error, msg)); + self.finish_turn(); + } + other => { + log::trace!("[tui] state: unhandled event={other}"); + } + } + } + + fn append_assistant(&mut self, delta: &str) { + match self.cur_assistant { + Some(idx) => self.entries[idx].text.push_str(delta), + None => { + self.entries + .push(Entry::new(EntryKind::Assistant, delta.to_string())); + self.cur_assistant = Some(self.entries.len() - 1); + } + } + } + + fn append_thinking(&mut self, delta: &str) { + match self.cur_thinking { + Some(idx) => self.entries[idx].text.push_str(delta), + None => { + self.entries + .push(Entry::new(EntryKind::Thinking, delta.to_string())); + self.cur_thinking = Some(self.entries.len() - 1); + } + } + } + + fn finish_turn(&mut self) { + self.streaming = false; + self.cur_assistant = None; + self.cur_thinking = None; + } +} + +/// One-line, length-capped summary of a JSON value for tool-call args display. +fn summarize_json(value: &serde_json::Value) -> String { + let rendered = match value { + serde_json::Value::Object(_) | serde_json::Value::Array(_) => value.to_string(), + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + format!("({})", truncate_line(&rendered)) +} + +/// Collapse to a single line and cap length so a rogue tool output can't blow +/// up the transcript width. +fn truncate_line(s: &str) -> String { + const MAX: usize = 120; + let single = s.replace(['\n', '\r'], " "); + let trimmed = single.trim(); + if trimmed.chars().count() > MAX { + let cut: String = trimmed.chars().take(MAX).collect(); + format!("{cut}…") + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const CLIENT: &str = "tui-abc123"; + + fn ev(event: &str) -> WebChannelEvent { + WebChannelEvent { + event: event.to_string(), + client_id: CLIENT.to_string(), + thread_id: "thread-1".to_string(), + ..Default::default() + } + } + + fn text_delta(delta: &str) -> WebChannelEvent { + WebChannelEvent { + delta: Some(delta.to_string()), + delta_kind: Some("text".to_string()), + ..ev("text_delta") + } + } + + fn thinking_delta(delta: &str) -> WebChannelEvent { + WebChannelEvent { + delta: Some(delta.to_string()), + delta_kind: Some("thinking".to_string()), + ..ev("thinking_delta") + } + } + + #[test] + fn text_deltas_accumulate_into_one_assistant_entry() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("hi"); + s.apply_event(&text_delta("Hel")); + s.apply_event(&text_delta("lo ")); + s.apply_event(&text_delta("world")); + + let assistant: Vec<_> = s + .entries() + .iter() + .filter(|e| e.kind == EntryKind::Assistant) + .collect(); + assert_eq!(assistant.len(), 1, "deltas must fold into a single entry"); + assert_eq!(assistant[0].text, "Hello world"); + assert!(s.is_streaming(), "still streaming before chat_done"); + } + + #[test] + fn thinking_and_text_are_separate_entries() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("q"); + s.apply_event(&thinking_delta("let me think")); + s.apply_event(&text_delta("answer")); + + let kinds: Vec<_> = s.entries().iter().map(|e| e.kind).collect(); + assert_eq!( + kinds, + vec![EntryKind::User, EntryKind::Thinking, EntryKind::Assistant] + ); + let thinking = s + .entries() + .iter() + .find(|e| e.kind == EntryKind::Thinking) + .unwrap(); + assert_eq!(thinking.text, "let me think"); + } + + #[test] + fn thinking_deltas_accumulate_separately_from_text() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("q"); + s.apply_event(&thinking_delta("a")); + s.apply_event(&thinking_delta("b")); + s.apply_event(&text_delta("x")); + s.apply_event(&thinking_delta("c")); // interleaved — same thinking entry + let thinking: Vec<_> = s + .entries() + .iter() + .filter(|e| e.kind == EntryKind::Thinking) + .collect(); + assert_eq!(thinking.len(), 1); + assert_eq!(thinking[0].text, "abc"); + } + + #[test] + fn chat_done_replaces_streamed_text_with_full_response() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("hi"); + s.apply_event(&text_delta("Hel")); // partial / laggy stream + let done = WebChannelEvent { + full_response: Some("Hello, world!".to_string()), + ..ev("chat_done") + }; + s.apply_event(&done); + + let assistant: Vec<_> = s + .entries() + .iter() + .filter(|e| e.kind == EntryKind::Assistant) + .collect(); + assert_eq!(assistant.len(), 1); + assert_eq!( + assistant[0].text, "Hello, world!", + "full_response is authoritative and replaces the streamed text" + ); + assert!(!s.is_streaming(), "chat_done ends the turn"); + } + + #[test] + fn chat_done_without_prior_deltas_still_shows_full_response() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("hi"); + let done = WebChannelEvent { + full_response: Some("Direct answer".to_string()), + ..ev("chat_done") + }; + s.apply_event(&done); + let assistant = s + .entries() + .iter() + .find(|e| e.kind == EntryKind::Assistant) + .expect("chat_done with full_response must produce an assistant entry"); + assert_eq!(assistant.text, "Direct answer"); + assert!(!s.is_streaming()); + } + + #[test] + fn chat_error_pushes_error_entry_and_ends_stream() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("hi"); + let err = WebChannelEvent { + message: Some("rate limited".to_string()), + error_type: Some("rate_limit".to_string()), + ..ev("chat_error") + }; + s.apply_event(&err); + let error = s + .entries() + .iter() + .find(|e| e.kind == EntryKind::Error) + .expect("chat_error must produce an error entry"); + assert_eq!(error.text, "rate limited"); + assert!(!s.is_streaming(), "chat_error ends the turn"); + } + + #[test] + fn events_for_other_client_id_are_ignored() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("hi"); + let before = s.entries().len(); + let foreign = WebChannelEvent { + client_id: "tui-someone-else".to_string(), + delta: Some("not mine".to_string()), + ..WebChannelEvent { + event: "text_delta".to_string(), + thread_id: "thread-1".to_string(), + ..Default::default() + } + }; + s.apply_event(&foreign); + assert_eq!( + s.entries().len(), + before, + "foreign client_id events must not mutate our transcript" + ); + } + + #[test] + fn tool_call_and_result_produce_tool_entries() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("do it"); + let call = WebChannelEvent { + tool_name: Some("web_search".to_string()), + args: Some(serde_json::json!({"query": "rust ratatui"})), + ..ev("tool_call") + }; + s.apply_event(&call); + let result = WebChannelEvent { + tool_name: Some("web_search".to_string()), + success: Some(true), + output: Some("3 results".to_string()), + ..ev("tool_result") + }; + s.apply_event(&result); + + let tools: Vec<_> = s + .entries() + .iter() + .filter(|e| e.kind == EntryKind::Tool) + .collect(); + assert_eq!(tools.len(), 2); + assert!(tools[0].text.starts_with("→ web_search")); + assert!(tools[0].text.contains("rust ratatui")); + assert!(tools[1].text.starts_with("✓ web_search")); + assert!(tools[1].text.contains("3 results")); + } + + #[test] + fn failed_tool_result_uses_cross_marker() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("do it"); + let result = WebChannelEvent { + tool_name: Some("run_shell".to_string()), + success: Some(false), + output: Some("exit code 1".to_string()), + ..ev("tool_result") + }; + s.apply_event(&result); + let tool = s + .entries() + .iter() + .find(|e| e.kind == EntryKind::Tool) + .unwrap(); + assert!(tool.text.starts_with("✗ run_shell")); + } + + #[test] + fn second_turn_opens_fresh_assistant_entry() { + let mut s = TranscriptState::new(CLIENT); + s.begin_user_turn("first"); + s.apply_event(&text_delta("one")); + s.apply_event(&WebChannelEvent { + full_response: Some("one".to_string()), + ..ev("chat_done") + }); + s.begin_user_turn("second"); + s.apply_event(&text_delta("two")); + + let assistant: Vec<_> = s + .entries() + .iter() + .filter(|e| e.kind == EntryKind::Assistant) + .collect(); + assert_eq!(assistant.len(), 2, "each turn gets its own assistant entry"); + assert_eq!(assistant[0].text, "one"); + assert_eq!(assistant[1].text, "two"); + } + + #[test] + fn truncate_line_collapses_newlines_and_caps_length() { + let long = "a\nb\n".repeat(200); + let out = truncate_line(&long); + assert!(!out.contains('\n')); + assert!(out.chars().count() <= 121, "capped to MAX + ellipsis"); + } +} diff --git a/src/openhuman/tui/stub.rs b/src/openhuman/tui/stub.rs new file mode 100644 index 000000000..2e0355c5e --- /dev/null +++ b/src/openhuman/tui/stub.rs @@ -0,0 +1,37 @@ +//! Disabled-`tui` facade for [`super`] (the terminal chat UI). +//! +//! Compiled only when the `tui` Cargo feature is OFF (see the gate in +//! [`super`]). It mirrors the one public symbol always-compiled callers reach — +//! [`run_from_cli`] — with a disabled-error body. +//! +//! The signature MUST match the real one exactly (`&[String] -> anyhow::Result<()>`). +//! The disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is the +//! only thing that catches drift. + +/// Error text returned by the disabled path. Shared so callers / log-greps see +/// one stable string, and asserted by the disabled-build CLI tests. +const DISABLED_MSG: &str = "tui feature disabled at compile time"; + +/// Fails with a build-fact diagnostic instead of opening the terminal UI. +/// +/// This is deliberately a stub rather than a `#[cfg]` on the `"tui" | "chat"` +/// match arm in `src/core/cli.rs`. Deleting the arm is the naive move and is +/// WRONG: the `tui` / `chat` token would fall through to generic namespace +/// resolution and die with `unknown namespace: tui`, which reads like the user +/// typo'd a command rather than like a deliberate property of this build. +/// Keeping the arm and failing here means the user gets a non-zero exit and a +/// one-line stderr diagnostic naming the fix, and `cli.rs` needs no `#[cfg]` at +/// all — the gate stays invisible to the transport layer. +/// +/// Banner suppression in `cli.rs` is a `matches!` on the raw string, so it +/// keeps working here without touching a gated symbol. +pub fn run_from_cli(_args: &[String]) -> anyhow::Result<()> { + log::warn!( + "[tui] {DISABLED_MSG} — `openhuman tui`/`chat` rejected; rebuild with `--features tui`" + ); + anyhow::bail!( + "{DISABLED_MSG}: this build was compiled without the `tui` feature, so the terminal \ + chat UI is unavailable. Rebuild with `--features tui`." + ) +} diff --git a/src/openhuman/tui/terminal.rs b/src/openhuman/tui/terminal.rs new file mode 100644 index 000000000..e2c85c0c5 --- /dev/null +++ b/src/openhuman/tui/terminal.rs @@ -0,0 +1,83 @@ +//! Terminal setup / teardown with panic-safe restoration. +//! +//! Owning the terminal means switching to the alternate screen and enabling raw +//! mode; both **must** be undone on every exit path — normal return, `?` +//! propagation, and panic — or the user's shell is left in a broken state +//! (no echo, no line editing, stuck on the alternate screen). [`TerminalGuard`] +//! restores on `Drop`, and [`install_panic_hook`] chains a restore ahead of the +//! previous panic hook so the panic message prints to a sane terminal. + +use std::io::{self, Stdout}; + +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +/// The concrete ratatui terminal type used by the TUI. +pub type Tui = Terminal>; + +/// RAII guard that enters the alternate screen + raw mode on construction and +/// restores the terminal on drop. +pub struct TerminalGuard { + terminal: Tui, +} + +impl TerminalGuard { + /// Enter the alternate screen, enable raw mode, install the panic hook, and + /// return a ready-to-draw terminal wrapped in a restoring guard. + pub fn enter() -> io::Result { + install_panic_hook(); + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(io::stdout()); + let terminal = Terminal::new(backend)?; + log::debug!("[tui] terminal: entered alternate screen + raw mode"); + Ok(Self { terminal }) + } + + /// Mutable access to the underlying terminal for drawing. + pub fn terminal(&mut self) -> &mut Tui { + &mut self.terminal + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + if let Err(e) = restore() { + // The subscriber writes to a file (never the terminal), so this is + // safe to log here. + log::warn!("[tui] terminal: restore on drop failed: {e}"); + } else { + log::debug!("[tui] terminal: restored on drop"); + } + } +} + +/// Undo everything [`TerminalGuard::enter`] did. Best-effort — each step is +/// attempted even if an earlier one fails, so a partial setup still gets torn +/// down as far as possible. +fn restore() -> io::Result<()> { + let mut stdout = io::stdout(); + let _ = execute!(stdout, LeaveAlternateScreen, DisableMouseCapture); + disable_raw_mode() +} + +/// Chain a terminal-restoring step in front of the process panic hook, so a +/// panic inside the render loop leaves the user with a usable terminal and a +/// readable backtrace instead of a garbled alternate screen. +/// +/// Idempotent in effect: called once from [`TerminalGuard::enter`]. If it were +/// ever called twice, the second restore would simply be a no-op on an +/// already-restored terminal. +fn install_panic_hook() { + let original = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = restore(); + original(info); + })); +}