From 81d09b6aa7fb7ed6ea28a688582870cb823fd63f Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 23 Jul 2026 05:50:40 +0300 Subject: [PATCH] feat(tui): add logs-first tabbed CLI experience (#5131) --- AGENTS.md | 6 +- Cargo.toml | 6 +- Dockerfile | 2 + docs/TEST-COVERAGE-MATRIX.md | 8 +- docs/plans/tui-chat-plan.md | 5 + src/core/cli.rs | 52 +++- src/core/cli_tests.rs | 76 +++++- src/core/logging.rs | 122 ++++++++- src/openhuman/about_app/catalog_data.rs | 19 +- src/openhuman/credentials/ops.rs | 39 ++- src/openhuman/tui/app.rs | 168 ++++++++++-- src/openhuman/tui/controls.rs | 338 ++++++++++++++++++++++++ src/openhuman/tui/mod.rs | 10 +- src/openhuman/tui/render.rs | 313 +++++++++++++++++----- src/openhuman/tui/runner.rs | 28 +- src/openhuman/tui/state.rs | 2 +- src/openhuman/tui/stub.rs | 2 +- src/openhuman/tui/terminal.rs | 18 +- src/openhuman/tui/ui_state.rs | 179 +++++++++++++ 19 files changed, 1261 insertions(+), 132 deletions(-) create mode 100644 src/openhuman/tui/controls.rs create mode 100644 src/openhuman/tui/ui_state.rs diff --git a/AGENTS.md b/AGENTS.md index b4625e091..c29e73a17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -243,7 +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` | +| `tui` | ON | `openhuman::tui` — the tabbed ratatui/crossterm CLI UI (Logs, Chat, Config, Settings), auto-opened by bare `openhuman` on interactive non-container hosts and forced with `openhuman tui` (alias `chat`). Runs the core in-process. No controllers, no agent tools. **Intentionally NOT forwarded to the desktop shell** (allowlisted in `check-feature-forwarding.mjs`). | `ratatui`, `crossterm` | | `channels` | ON | `openhuman::channels` (external-messaging providers — Telegram/Discord/Slack/Signal/WhatsApp/iMessage/IRC/… — plus the channel runtime, controllers, host, proactive messaging + inbound dispatch) and the `webview_accounts` / `webview_apis` / `webview_notifications` / `whatsapp_data` webview-bridge domains (incl. the 3 `whatsapp_data_*` agent tools). **Carve-outs `channels::{traits, cli}` stay ungated.** | **none** (`tinychannels` is load-bearing) | **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. @@ -310,10 +310,10 @@ Follows the voice facade+stub pattern for `mcp_server` / `mcp_registry` / `mcp_a #### 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 tabbed terminal UI (`openhuman`, or explicitly `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"`). Bare-command auto-launch requires terminal stdin/stdout and `HostKind::Cli`; Docker, CI, pipes, and `--no-tui` retain the non-TUI CLI path. - **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. +- **No controllers, no agent tools, no `all.rs` changes.** The TUI is a pure *client* of existing registered controllers — it boots the core in-process (`CoreBuilder::new(HostKind::detect_standalone()).domains(DomainSet::full()).services(ServiceSet::none())`), sends chat turns through `web_chat`, reads a bounded in-memory copy of the file-only core log stream, edits only curated safe config getters/updaters, and invokes auth controllers for account/status actions. Never render `config.get` wholesale because the full snapshot can contain secrets. - **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. diff --git a/Cargo.toml b/Cargo.toml index 6b023d390..17575b1c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -585,9 +585,9 @@ crash-reporting = ["dep:sentry"] # NOTE: only `uiautomation` is exclusive and thus shed. `enigo` is shared with the # `voice` domain; `rdev` / `arboard` are voice-owned — none of those are dropped here. desktop-automation = ["dep:uiautomation"] -# 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 +# Tabbed terminal UI: the `openhuman tui` (alias `chat`) CLI subcommand, a +# ratatui/crossterm front-end for logs, orchestrator chat, curated configuration, +# and account settings. 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 diff --git a/Dockerfile b/Dockerfile index d7884f327..a34fae653 100644 --- a/Dockerfile +++ b/Dockerfile @@ -117,6 +117,8 @@ ENV OPENHUMAN_WORKSPACE=/home/openhuman/.openhuman # Bind to all interfaces so the container is reachable ENV OPENHUMAN_CORE_HOST=0.0.0.0 ENV OPENHUMAN_CORE_PORT=7788 +# Stable first-party signal for CLI launch policy; containers default headless. +ENV OPENHUMAN_DOCKER=1 ENV RUST_LOG=info # AgentBox marketplace mode — off by default for desktop builds. The # AgentBox console flips this on per deployment, along with GMI_MAAS_*. diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 717434d05..4c027dd30 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -600,7 +600,7 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an --- -## 14. Terminal Chat UI (`openhuman tui`) +## 14. Tabbed Terminal UI (`openhuman` / `openhuman tui`) ### 14.1 TUI Subcommand (feature-gated `tui`) @@ -608,9 +608,11 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an | ------ | ------------------------------------------------- | ----- | ----------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------- | | 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.3 | Tab order + render layout + persistent footer | RU | `src/openhuman/tui/ui_state.rs`, `src/openhuman/tui/render.rs` | ✅ | Logs-first tab state and 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 | +| 14.1.5 | Bare-command launch policy | RU | `src/core/cli_tests.rs` | ✅ | TTY + CLI auto-launch; Docker, pipes, feature-off, and explicit commands stay headless | +| 14.1.6 | Bounded live core-log buffer | RU | `src/core/logging.rs` | ✅ | Preserves ordering; bounds both line count and individual line length | +| 14.1.7 | Interactive terminal session (raw mode, streaming) | MS | manual PTY smoke | 🚫 | Requires a real PTY to verify key input and terminal restoration | ## Summary diff --git a/docs/plans/tui-chat-plan.md b/docs/plans/tui-chat-plan.md index 76c71356c..a863e9fcf 100644 --- a/docs/plans/tui-chat-plan.md +++ b/docs/plans/tui-chat-plan.md @@ -1,5 +1,10 @@ # Plan: `openhuman tui` — feature-gated terminal chat UI +> Historical v1 plan. The shipped CLI now extends this foundation into a Logs-first four-tab UI +> (Logs, Chat, Config, Settings). Bare `openhuman` auto-launches only with terminal stdin/stdout on +> a non-container host; `--no-tui` suppresses that default and explicit `openhuman tui` still forces +> the UI. Config uses curated safe getters/updaters, and Settings uses registered auth controllers. + ## Goal Running `openhuman-core tui` (alias `chat`) opens a ratatui-based terminal UI that is an diff --git a/src/core/cli.rs b/src/core/cli.rs index 78fa79390..4549b5385 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -7,6 +7,7 @@ use anyhow::Result; use serde_json::{Map, Value}; use std::collections::BTreeMap; +use std::io::IsTerminal; use crate::core::all; use crate::core::autocomplete_cli_adapter; @@ -44,6 +45,25 @@ Contribute & Star us on GitHub: https://github.com/tinyhumansai/openhuman /// Returns an error if the command fails, parameters are invalid, or if /// the subcommand/namespace is unknown. pub fn run_from_cli_args(args: &[String]) -> Result<()> { + load_dotenv_for_cli()?; + + let host = crate::core::types::HostKind::detect_standalone(); + if args == ["--tui"] + || should_auto_launch_tui( + args, + std::io::stdin().is_terminal(), + std::io::stdout().is_terminal(), + host, + cfg!(feature = "tui"), + ) + { + return crate::openhuman::tui::run_from_cli(&[]); + } + + // `--no-tui` is a global opt-out, not a synthetic subcommand. Strip it + // before normal dispatch so `openhuman --no-tui --help` and + // `openhuman --no-tui run ...` retain their ordinary CLI meaning. + let args = strip_no_tui(args); // Print the welcome banner to stderr to keep stdout clean for JSON output. // `mcp`/`mcp-server` speak JSON-RPC on stdout; `tui`/`chat` own the whole // terminal (alternate screen + raw mode) — a banner on either would corrupt @@ -56,8 +76,6 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { eprint!("{CLI_BANNER}"); } - load_dotenv_for_cli()?; - let grouped = grouped_schemas(); if args.is_empty() || is_help(&args[0]) { print_general_help(&grouped); @@ -101,6 +119,31 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { } } +/// Pure launch policy for the bare `openhuman` command. Explicit subcommands +/// are never rewritten. Docker and redirected/CI sessions keep the headless +/// CLI behavior; `openhuman tui` remains an explicit override everywhere. +fn should_auto_launch_tui( + args: &[String], + stdin_is_terminal: bool, + stdout_is_terminal: bool, + host: crate::core::types::HostKind, + tui_compiled: bool, +) -> bool { + args.is_empty() + && stdin_is_terminal + && stdout_is_terminal + && host == crate::core::types::HostKind::Cli + && tui_compiled +} + +fn strip_no_tui(args: &[String]) -> &[String] { + if args.first().map(String::as_str) == Some("--no-tui") { + &args[1..] + } else { + args + } +} + /// Handles the `sentry-test` subcommand used to verify Sentry wiring end-to-end. /// /// Captures an Error-level event against the currently initialized Sentry @@ -588,12 +631,15 @@ fn grouped_schemas() -> BTreeMap> { fn print_general_help(grouped: &BTreeMap>) { println!("OpenHuman core CLI\n"); println!("Usage:"); + println!(" openhuman [--no-tui] (tabbed terminal UI on interactive hosts)"); println!(" openhuman run [--host ] [--port ] [--jsonrpc-only] [--verbose]"); println!(" openhuman call --method [--params '']"); println!( " openhuman mcp [-v|--verbose] (stdio MCP server; read-only memory tools)" ); - println!(" openhuman tui [--thread |--new] (terminal chat UI, alias: chat)"); + println!( + " openhuman tui [--thread |--new] (force tabbed terminal 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 3354a333b..35a1dfe7e 100644 --- a/src/core/cli_tests.rs +++ b/src/core/cli_tests.rs @@ -1,10 +1,84 @@ -use super::{grouped_schemas, load_dotenv_for_cli, parse_function_params, parse_input_value}; +use super::{ + grouped_schemas, load_dotenv_for_cli, parse_function_params, parse_input_value, + should_auto_launch_tui, strip_no_tui, +}; +use crate::core::types::HostKind; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use std::sync::{Mutex, OnceLock}; use tempfile::tempdir; static CLI_ENV_LOCK: OnceLock> = OnceLock::new(); +#[test] +fn bare_cli_auto_launches_tui_only_for_interactive_non_container_hosts() { + let none: Vec = vec![]; + assert!(should_auto_launch_tui( + &none, + true, + true, + HostKind::Cli, + true + )); + assert!(!should_auto_launch_tui( + &none, + false, + true, + HostKind::Cli, + true + )); + assert!(!should_auto_launch_tui( + &none, + true, + false, + HostKind::Cli, + true + )); + assert!(!should_auto_launch_tui( + &none, + true, + true, + HostKind::Docker, + true + )); + assert!(!should_auto_launch_tui( + &none, + true, + true, + HostKind::Cli, + false + )); +} + +#[test] +fn explicit_args_never_trigger_bare_cli_auto_launch() { + for args in [ + vec!["--no-tui".to_string()], + vec!["run".to_string()], + vec!["tui".to_string()], + ] { + assert!(!should_auto_launch_tui( + &args, + true, + true, + HostKind::Cli, + true + )); + } +} + +#[test] +fn no_tui_is_stripped_before_normal_cli_dispatch() { + let args = vec![ + "--no-tui".to_string(), + "run".to_string(), + "--jsonrpc-only".to_string(), + ]; + assert_eq!(strip_no_tui(&args), &args[1..]); + + let ordinary = vec!["run".to_string()]; + assert_eq!(strip_no_tui(&ordinary), ordinary.as_slice()); +} + fn env_lock() -> std::sync::MutexGuard<'static, ()> { CLI_ENV_LOCK .get_or_init(|| Mutex::new(())) diff --git a/src/core/logging.rs b/src/core/logging.rs index fe26d0ccb..82662a07d 100644 --- a/src/core/logging.rs +++ b/src/core/logging.rs @@ -11,8 +11,9 @@ //! calls and core `tracing::*` calls funnel into the same file via //! [`tracing_log::LogTracer`]. +use std::collections::VecDeque; use std::fmt; -use std::io::{self, IsTerminal}; +use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::sync::{Mutex, Once, OnceLock}; @@ -21,6 +22,7 @@ use tracing::{Event, Level}; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::fmt::format::{FormatEvent, FormatFields, Writer}; use tracing_subscriber::fmt::FmtContext; +use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::util::SubscriberInitExt; @@ -47,6 +49,67 @@ static FILE_GUARD: Mutex> = Mutex::new(None); /// it without re-deriving the data dir. static LOG_DIR: OnceLock = OnceLock::new(); +const TUI_LOG_CAPACITY: usize = 2_000; +const TUI_LOG_LINE_MAX_CHARS: usize = 4_096; +static TUI_LOG_BUFFER: OnceLock>>> = OnceLock::new(); + +#[derive(Clone)] +struct TuiLogMakeWriter { + buffer: std::sync::Arc>>, +} + +struct TuiLogWriter { + buffer: std::sync::Arc>>, + pending: Vec, +} + +impl<'a> MakeWriter<'a> for TuiLogMakeWriter { + type Writer = TuiLogWriter; + + fn make_writer(&'a self) -> Self::Writer { + TuiLogWriter { + buffer: self.buffer.clone(), + pending: Vec::new(), + } + } +} + +impl Write for TuiLogWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.pending.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.publish(); + Ok(()) + } +} + +impl TuiLogWriter { + fn publish(&mut self) { + if self.pending.is_empty() { + return; + } + let rendered = String::from_utf8_lossy(&self.pending); + if let Ok(mut lines) = self.buffer.lock() { + for line in rendered.lines().filter(|line| !line.is_empty()) { + if lines.len() == TUI_LOG_CAPACITY { + lines.pop_front(); + } + lines.push_back(line.chars().take(TUI_LOG_LINE_MAX_CHARS).collect()); + } + } + self.pending.clear(); + } +} + +impl Drop for TuiLogWriter { + fn drop(&mut self) { + self.publish(); + } +} + /// Default `RUST_LOG` when it is unset: either global levels or only the inline autocomplete module tree. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CliLogDefault { @@ -377,15 +440,25 @@ pub fn init_for_tui(data_dir: &Path, verbose: bool) -> Option { })) }); + let tui_buffer = std::sync::Arc::new(Mutex::new(VecDeque::new())); + let tui_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .event_format(CleanCliFormat) + .with_writer(TuiLogMakeWriter { + buffer: tui_buffer.clone(), + }); + // 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(tui_layer) .with(sentry_tracing_layer()) .try_init() .is_ok() { + let _ = TUI_LOG_BUFFER.set(tui_buffer); if let Some((_, guard, dir)) = pending_file { if let Ok(mut slot) = FILE_GUARD.lock() { *slot = Some(guard); @@ -400,6 +473,16 @@ pub fn init_for_tui(data_dir: &Path, verbose: bool) -> Option { log_directory().map(Path::to_path_buf) } +/// Snapshot the bounded in-memory log stream rendered by the terminal Logs tab. +/// The file appender remains authoritative for long-term retention. +pub fn tui_log_lines() -> Vec { + TUI_LOG_BUFFER + .get() + .and_then(|buffer| buffer.lock().ok()) + .map(|lines| lines.iter().cloned().collect()) + .unwrap_or_default() +} + /// 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). @@ -671,4 +754,41 @@ mod tests { } } } + + #[test] + fn tui_log_writer_keeps_a_bounded_ordered_ring() { + let buffer = std::sync::Arc::new(Mutex::new(VecDeque::new())); + let mut writer = TuiLogWriter { + buffer: buffer.clone(), + pending: Vec::new(), + }; + for index in 0..=TUI_LOG_CAPACITY { + writeln!(writer, "line-{index}").expect("write log line"); + writer.flush().expect("flush log line"); + } + let lines = buffer.lock().expect("buffer lock"); + assert_eq!(lines.len(), TUI_LOG_CAPACITY); + assert_eq!(lines.front().map(String::as_str), Some("line-1")); + let expected_last = format!("line-{TUI_LOG_CAPACITY}"); + assert_eq!( + lines.back().map(String::as_str), + Some(expected_last.as_str()) + ); + } + + #[test] + fn tui_log_writer_caps_individual_lines() { + let buffer = std::sync::Arc::new(Mutex::new(VecDeque::new())); + let mut writer = TuiLogWriter { + buffer: buffer.clone(), + pending: Vec::new(), + }; + writeln!(writer, "{}", "x".repeat(TUI_LOG_LINE_MAX_CHARS + 50)).expect("write long line"); + writer.flush().expect("flush long line"); + let lines = buffer.lock().expect("buffer lock"); + assert_eq!( + lines.front().map(|line| line.chars().count()), + Some(TUI_LOG_LINE_MAX_CHARS) + ); + } } diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index cc08c6003..74dae9159 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -241,18 +241,17 @@ pub(super) const CAPABILITIES: &[Capability] = &[ }, Capability { id: "conversation.terminal_chat", - name: "Terminal Chat (TUI)", + name: "Tabbed Terminal UI", 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.", + description: "Operate OpenHuman from a terminal through four tabs: live core logs, \ + orchestrator chat, safe configuration, and account settings. Bare \ + `openhuman` opens it on an interactive non-container host; `openhuman tui` \ + (alias `chat`) forces it. The chat streams replies, thinking, and tools live.", + how_to: "Run `openhuman`, or `openhuman tui` to force the UI. Use Tab/Shift+Tab or Alt+1-4 \ + to switch Logs, Chat, Config, and Settings. `--thread ` resumes a chat and \ + `--new` starts one. Settings accepts a one-time login token and supports account \ + refresh and logout. Use `openhuman --no-tui` to suppress automatic launch.", status: CapabilityStatus::Beta, privacy: DERIVED_TO_BACKEND, }, diff --git a/src/openhuman/credentials/ops.rs b/src/openhuman/credentials/ops.rs index 98203bbeb..06d19e78d 100644 --- a/src/openhuman/credentials/ops.rs +++ b/src/openhuman/credentials/ops.rs @@ -739,6 +739,7 @@ fn normalize_local_session_user(user: serde_json::Value, local_user_id: &str) -> } pub async fn clear_session(config: &Config) -> Result, String> { + let mut logs = Vec::new(); // Flip the scheduler-gate override first so any background worker that // is mid-iteration (or wakes up while we tear down) stalls at its next // `wait_for_capacity()` call instead of firing requests at a backend @@ -769,15 +770,45 @@ pub async fn clear_session(config: &Config) -> Result { + let workspace = signed_out_config.workspace_dir.clone(); + if let Err(error) = crate::openhuman::memory::global::init(workspace.clone()) { + tracing::warn!(%error, "failed to rebind memory after logout"); + } + if let Err(error) = + crate::core::runtime::context::CoreContext::rebind_default_workspace_dir(&workspace) + { + tracing::warn!(%error, "failed to rebind core context after logout"); + } + if let Err(error) = crate::openhuman::people::store::init_from_workspace(&workspace) { + tracing::warn!(%error, "failed to rebind people store after logout"); + } + crate::openhuman::memory_conversations::register_conversation_persistence_subscriber( + workspace.clone(), + ); + logs.push(format!( + "process globals rebound to signed-out workspace {}", + workspace.display() + )); + } + Err(error) => { + tracing::warn!(%error, "failed to resolve signed-out workspace after logout"); + logs.push(format!("signed-out workspace rebind warning: {error}")); + } + } + // Drop the Sentry scope user so events surfaced during/after teardown // (and before the next login) are no longer attributed to the // signed-out account — issue #3135. super::sentry_scope::clear(); - Ok(RpcOutcome::single_log( - json!({ "removed": removed }), - "session cleared", - )) + logs.push("session cleared".to_string()); + Ok(RpcOutcome::new(json!({ "removed": removed }), logs)) } pub async fn auth_get_state( diff --git a/src/openhuman/tui/app.rs b/src/openhuman/tui/app.rs index a1c944d5a..a1edd2481 100644 --- a/src/openhuman/tui/app.rs +++ b/src/openhuman/tui/app.rs @@ -24,11 +24,12 @@ use crate::core::runtime::CoreRuntime; use crate::core::socketio::WebChannelEvent; use crate::openhuman::web_chat; -use super::render::{self, UiState}; +use super::render; use super::state::TranscriptState; use super::terminal::TerminalGuard; +use super::ui_state::{AppTab, UiState}; -/// Run the terminal chat loop until the user quits (Ctrl+C / Ctrl+D) or the +/// Run the tabbed terminal 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( @@ -37,13 +38,16 @@ pub async fn run( 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()); + super::controls::refresh_config(&runtime, &mut ui).await; + super::controls::refresh_auth(&runtime, &mut ui).await; + // Resolve local startup state before taking over the terminal. A slow or + // locked config must never strand the user on a blank raw-mode screen. + let mut guard = TerminalGuard::enter()?; // Blocking crossterm reader → async channel. let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -77,8 +81,12 @@ pub async fn run( Some(Event::Key(key)) => { if handle_key(key, &runtime, &client_id, &mut state, &mut ui).await { quit = true; + } else if ui.identity_changed { + ui.identity_changed = false; + new_thread(&runtime, &mut state, &mut ui).await; } } + Some(Event::Paste(text)) => handle_paste(&text, &mut ui), Some(_) => {} // resize / mouse / paste — redraw next iteration None => quit = true, // reader thread gone }, @@ -118,29 +126,103 @@ async fn handle_key( } let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if matches!(key.code, KeyCode::Char('c')) && ctrl { + log::info!("[tui] quit via Ctrl+C"); + return true; + } + if matches!(key.code, KeyCode::Char('d')) && ctrl { + log::info!("[tui] quit via Ctrl+D"); + return true; + } + + if !ui.is_editing() { + if let Some(tab) = tab_shortcut(key, ui.active_tab) { + ui.active_tab = tab; + } else { + return handle_tab_key(key, runtime, client_id, state, ui).await; + } + return false; + } + + handle_tab_key(key, runtime, client_id, state, ui).await +} + +fn tab_shortcut(key: KeyEvent, current: AppTab) -> Option { + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + let alt = key.modifiers.contains(KeyModifiers::ALT); match key.code { - KeyCode::Char('c') if ctrl => { - log::info!("[tui] quit via Ctrl+C"); - return true; + KeyCode::Tab if shift => Some(current.previous()), + KeyCode::Tab => Some(current.next()), + KeyCode::BackTab => Some(current.previous()), + KeyCode::Char('1') if alt => Some(AppTab::Logs), + KeyCode::Char('2') if alt => Some(AppTab::Chat), + KeyCode::Char('3') if alt => Some(AppTab::Config), + KeyCode::Char('4') if alt => Some(AppTab::Settings), + _ => None, + } +} + +fn handle_paste(text: &str, ui: &mut UiState) { + match ui.active_tab { + AppTab::Chat => ui.input.push_str(text), + AppTab::Config => { + if let Some(input) = &mut ui.config_edit { + input.push_str(text); + } } - KeyCode::Char('d') if ctrl => { - log::info!("[tui] quit via Ctrl+D"); - return true; + AppTab::Settings => { + if let Some(token) = &mut ui.login_token { + token.push_str(text); + } } - 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), - _ => {} + AppTab::Logs => {} + } +} + +async fn handle_tab_key( + key: KeyEvent, + runtime: &Arc, + client_id: &str, + state: &mut TranscriptState, + ui: &mut UiState, +) -> bool { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match ui.active_tab { + AppTab::Logs => match key.code { + KeyCode::PageUp | KeyCode::Up => { + ui.log_scroll_from_bottom = ui.log_scroll_from_bottom.saturating_add(5) + } + KeyCode::PageDown | KeyCode::Down => { + ui.log_scroll_from_bottom = ui.log_scroll_from_bottom.saturating_sub(5) + } + _ => {} + }, + AppTab::Chat => 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), + _ => {} + }, + AppTab::Config => super::controls::handle_config_key(key, runtime, ui).await, + AppTab::Settings => super::controls::handle_settings_key(key, runtime, ui).await, } false } @@ -227,6 +309,8 @@ async fn new_thread(runtime: &Arc, state: &mut TranscriptState, ui: .and_then(|v| super::runner::extract_thread_id(&v)) { Some(new_id) => { + let client_id = state.client_id().to_string(); + *state = TranscriptState::new(client_id); ui.thread_id = new_id.clone(); ui.scroll_from_bottom = 0; state.push_system(format!("Started a new thread · {new_id}")); @@ -238,3 +322,39 @@ async fn new_thread(runtime: &Arc, state: &mut TranscriptState, ui: } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) + } + + #[test] + fn plain_digits_remain_chat_input_and_alt_digits_switch_tabs() { + for digit in ['1', '2', '3', '4'] { + assert_eq!( + tab_shortcut(key(KeyCode::Char(digit), KeyModifiers::NONE), AppTab::Chat), + None + ); + } + assert_eq!( + tab_shortcut(key(KeyCode::Char('3'), KeyModifiers::ALT), AppTab::Chat), + Some(AppTab::Config) + ); + } + + #[test] + fn paste_routes_only_to_the_active_editable_surface() { + let mut ui = UiState::new("thread".into(), "client".into()); + ui.active_tab = AppTab::Chat; + handle_paste("model-4", &mut ui); + assert_eq!(ui.input, "model-4"); + + ui.active_tab = AppTab::Settings; + ui.login_token = Some(String::new()); + handle_paste("one-time-token", &mut ui); + assert_eq!(ui.login_token.as_deref(), Some("one-time-token")); + } +} diff --git a/src/openhuman/tui/controls.rs b/src/openhuman/tui/controls.rs new file mode 100644 index 000000000..bc8ebec85 --- /dev/null +++ b/src/openhuman/tui/controls.rs @@ -0,0 +1,338 @@ +//! Config and account actions for the tabbed terminal UI. + +use std::sync::Arc; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use serde_json::json; +use zeroize::Zeroize; + +use crate::core::runtime::CoreRuntime; + +use super::ui_state::{ConfigKey, SettingsAction, UiState}; + +pub async fn handle_config_key(key: KeyEvent, runtime: &Arc, ui: &mut UiState) { + if let Some(input) = ui.config_edit.as_mut() { + match key.code { + KeyCode::Esc => ui.config_edit = None, + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Enter => save_config(runtime, ui).await, + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => input.push(c), + _ => {} + } + return; + } + + match key.code { + KeyCode::Up => ui.config_selected = ui.config_selected.saturating_sub(1), + KeyCode::Down => { + ui.config_selected = (ui.config_selected + 1).min(ui.config_items.len() - 1) + } + KeyCode::Enter => { + ui.config_edit = Some(ui.config_items[ui.config_selected].value.clone()); + } + _ => {} + } +} + +async fn save_config(runtime: &Arc, ui: &mut UiState) { + let Some(value) = ui.config_edit.take() else { + return; + }; + let key = ui.config_items[ui.config_selected].key; + let (method, params) = config_update(key, value); + ui.config_status = "Saving…".to_string(); + match runtime.invoke(method, params).await { + Ok(_) => { + ui.config_status = "Saved.".to_string(); + refresh_config(runtime, ui).await; + } + Err(err) => ui.config_status = format!("Save failed: {err}"), + } +} + +fn config_update(key: ConfigKey, value: String) -> (&'static str, serde_json::Value) { + match key { + ConfigKey::ApiUrl => ( + "openhuman.config_update_model_settings", + json!({"api_url": value}), + ), + ConfigKey::InferenceUrl => ( + "openhuman.config_update_model_settings", + json!({"inference_url": value}), + ), + ConfigKey::DefaultModel => ( + "openhuman.config_update_model_settings", + json!({"default_model": value}), + ), + ConfigKey::AutonomyLevel => ( + "openhuman.config_update_autonomy_settings", + json!({"level": value}), + ), + ConfigKey::PrivacyMode => ("openhuman.config_set_privacy_mode", json!({"mode": value})), + } +} + +pub async fn refresh_config(runtime: &Arc, ui: &mut UiState) { + let client = runtime + .invoke("openhuman.config_get_client_config", json!({})) + .await; + let autonomy = runtime + .invoke("openhuman.config_get_autonomy_settings", json!({})) + .await; + let privacy = runtime + .invoke("openhuman.config_get_privacy_mode", json!({})) + .await; + match (client, autonomy, privacy) { + (Ok(client), Ok(autonomy), Ok(privacy)) => { + let client = rpc_payload(&client); + let autonomy = rpc_payload(&autonomy); + let privacy = rpc_payload(&privacy); + for item in &mut ui.config_items { + item.value = match item.key { + ConfigKey::ApiUrl => string_at(client, &["api_url"]), + ConfigKey::InferenceUrl => string_at(client, &["inference_url"]), + ConfigKey::DefaultModel => string_at(client, &["default_model"]), + ConfigKey::AutonomyLevel => string_at(autonomy, &["level"]), + ConfigKey::PrivacyMode => string_at(privacy, &["mode"]), + }; + } + ui.config_status = "Select a field and press Enter to edit.".to_string(); + } + _ => ui.config_status = "Could not load one or more config sections; see Logs.".to_string(), + } +} + +pub async fn handle_settings_key(key: KeyEvent, runtime: &Arc, ui: &mut UiState) { + if let Some(token) = ui.login_token.as_mut() { + match key.code { + KeyCode::Esc => { + token.zeroize(); + ui.login_token = None; + } + KeyCode::Backspace => { + token.pop(); + } + KeyCode::Enter => login_with_token(runtime, ui).await, + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => token.push(c), + _ => {} + } + return; + } + if ui.logout_confirm { + match key.code { + KeyCode::Esc | KeyCode::Char('n') => ui.logout_confirm = false, + KeyCode::Char('y') | KeyCode::Enter => logout(runtime, ui).await, + _ => {} + } + return; + } + match key.code { + KeyCode::Up => ui.settings_selected = ui.settings_selected.saturating_sub(1), + KeyCode::Down => { + ui.settings_selected = (ui.settings_selected + 1).min(SettingsAction::ALL.len() - 1) + } + KeyCode::Enter => match SettingsAction::ALL[ui.settings_selected] { + SettingsAction::ViewAccount => view_account(runtime, ui).await, + SettingsAction::Login => ui.login_token = Some(String::new()), + SettingsAction::Logout => ui.logout_confirm = true, + }, + _ => {} + } +} + +pub async fn refresh_auth(runtime: &Arc, ui: &mut UiState) { + match runtime.invoke("openhuman.auth_get_state", json!({})).await { + Ok(value) => { + let state = rpc_payload(&value); + if state + .get("isAuthenticated") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + let identity = string_at(state, &["userId"]); + ui.auth_summary = if identity.is_empty() { + "Signed in".to_string() + } else { + format!("Signed in · {identity}") + }; + } else { + ui.auth_summary = "Signed out".to_string(); + ui.account_detail.clear(); + } + } + Err(err) => ui.auth_summary = format!("Account status unavailable: {err}"), + } +} + +async fn view_account(runtime: &Arc, ui: &mut UiState) { + ui.settings_status = "Refreshing account…".to_string(); + match runtime.invoke("openhuman.auth_get_me", json!({})).await { + Ok(value) => { + let user = rpc_payload(&value); + ui.account_detail = account_detail(user); + ui.settings_status = "Account refreshed.".to_string(); + refresh_auth(runtime, ui).await; + } + Err(err) => ui.settings_status = format!("Account refresh failed: {err}"), + } +} + +async fn login_with_token(runtime: &Arc, ui: &mut UiState) { + let mut token = ui.login_token.take().unwrap_or_default(); + if token.trim().is_empty() { + ui.settings_status = "Login token cannot be empty.".to_string(); + token.zeroize(); + return; + } + ui.settings_status = "Signing in…".to_string(); + let consumed = runtime + .invoke( + "openhuman.auth_consume_login_token", + json!({"loginToken": token.trim()}), + ) + .await; + token.zeroize(); + let result = match consumed { + Ok(value) => value, + Err(err) => { + ui.settings_status = format!("Login failed: {err}"); + return; + } + }; + let mut jwt = string_at(rpc_payload(&result), &["jwtToken"]); + if jwt.is_empty() { + ui.settings_status = "Login failed: backend returned no session token.".to_string(); + return; + } + let stored = runtime + .invoke("openhuman.auth_store_session", json!({"token": jwt})) + .await; + jwt.zeroize(); + match stored { + Ok(_) => { + ui.settings_status = "Signed in.".to_string(); + refresh_auth(runtime, ui).await; + ui.identity_changed = true; + } + Err(err) => ui.settings_status = format!("Could not store session: {err}"), + } +} + +async fn logout(runtime: &Arc, ui: &mut UiState) { + ui.logout_confirm = false; + match runtime + .invoke("openhuman.auth_clear_session", json!({})) + .await + { + Ok(_) => { + ui.settings_status = "Signed out.".to_string(); + refresh_auth(runtime, ui).await; + ui.identity_changed = true; + } + Err(err) => ui.settings_status = format!("Logout failed: {err}"), + } +} + +fn rpc_payload(value: &serde_json::Value) -> &serde_json::Value { + value + .get("result") + .or_else(|| value.get("data")) + .map(rpc_payload) + .unwrap_or(value) +} + +fn string_at(value: &serde_json::Value, path: &[&str]) -> String { + path.iter() + .try_fold(value, |current, key| current.get(*key)) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn account_detail(user: &serde_json::Value) -> String { + let name = [ + string_at(user, &["firstName"]), + string_at(user, &["lastName"]), + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join(" "); + let identity = [string_at(user, &["email"]), string_at(user, &["username"])] + .into_iter() + .find(|value| !value.is_empty()) + .unwrap_or_default(); + [name, identity] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join(" · ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rpc_payload_unwraps_runtime_and_api_envelopes() { + let value = json!({"result": {"data": {"mode": "standard"}}, "logs": []}); + assert_eq!(rpc_payload(&value), &json!({"mode": "standard"})); + assert_eq!(string_at(rpc_payload(&value), &["mode"]), "standard"); + } + + #[test] + fn string_at_never_coerces_non_string_or_missing_values() { + let value = json!({"enabled": true}); + assert_eq!(string_at(&value, &["enabled"]), ""); + assert_eq!(string_at(&value, &["missing"]), ""); + } + + #[test] + fn account_detail_uses_canonical_backend_user_fields() { + let user = json!({ + "firstName": "Ada", + "lastName": "Lovelace", + "email": "ada@example.test", + "username": "ada" + }); + assert_eq!(account_detail(&user), "Ada Lovelace · ada@example.test"); + } + + #[test] + fn curated_config_fields_map_to_safe_specific_updates() { + let cases = [ + ( + ConfigKey::ApiUrl, + "openhuman.config_update_model_settings", + json!({"api_url": "value"}), + ), + ( + ConfigKey::InferenceUrl, + "openhuman.config_update_model_settings", + json!({"inference_url": "value"}), + ), + ( + ConfigKey::DefaultModel, + "openhuman.config_update_model_settings", + json!({"default_model": "value"}), + ), + ( + ConfigKey::AutonomyLevel, + "openhuman.config_update_autonomy_settings", + json!({"level": "value"}), + ), + ( + ConfigKey::PrivacyMode, + "openhuman.config_set_privacy_mode", + json!({"mode": "value"}), + ), + ]; + for (key, expected_method, expected_params) in cases { + let (method, params) = config_update(key, "value".to_string()); + assert_eq!(method, expected_method); + assert_eq!(params, expected_params); + } + } +} diff --git a/src/openhuman/tui/mod.rs b/src/openhuman/tui/mod.rs index c684ddfdb..c26cc71c5 100644 --- a/src/openhuman/tui/mod.rs +++ b/src/openhuman/tui/mod.rs @@ -1,7 +1,7 @@ -//! Terminal chat UI — the `openhuman tui` (alias `chat`) CLI subcommand. +//! Tabbed terminal UI — bare `openhuman` or the explicit `tui` / `chat` subcommand. //! -//! A [ratatui]-based terminal front-end onto the **same `web_chat` surface** -//! the desktop app drives (`openhuman.channel_web_chat` / +//! A [ratatui]-based terminal front-end with Logs, Chat, Config, and Settings. +//! Chat uses 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 @@ -26,6 +26,8 @@ #[cfg(feature = "tui")] mod app; #[cfg(feature = "tui")] +mod controls; +#[cfg(feature = "tui")] mod render; #[cfg(feature = "tui")] mod runner; @@ -33,6 +35,8 @@ mod runner; mod state; #[cfg(feature = "tui")] mod terminal; +#[cfg(feature = "tui")] +mod ui_state; #[cfg(feature = "tui")] pub use runner::run_from_cli; diff --git a/src/openhuman/tui/render.rs b/src/openhuman/tui/render.rs index b9e11609b..55504f41b 100644 --- a/src/openhuman/tui/render.rs +++ b/src/openhuman/tui/render.rs @@ -1,4 +1,4 @@ -//! Ratatui rendering for the terminal chat UI — pure view over +//! Ratatui rendering for the tabbed terminal UI — pure view over //! [`TranscriptState`] + [`UiState`]. No state mutation happens here. //! //! Layout (top → bottom): @@ -9,57 +9,207 @@ 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::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; use ratatui::Frame; use unicode_width::UnicodeWidthStr; use super::state::{EntryKind, TranscriptState}; +use super::ui_state::{AppTab, SettingsAction, UiState}; /// 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 + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(1), ]) .split(frame.area()); + draw_tabs(frame, chunks[0], ui); + match ui.active_tab { + AppTab::Logs => draw_logs(frame, chunks[1], ui), + AppTab::Chat => draw_chat(frame, chunks[1], state, ui), + AppTab::Config => draw_config(frame, chunks[1], ui), + AppTab::Settings => draw_settings(frame, chunks[1], ui), + } + draw_footer(frame, chunks[2], state, ui); +} + +fn draw_tabs(frame: &mut Frame, area: Rect, ui: &UiState) { + let selected = AppTab::ALL + .iter() + .position(|tab| *tab == ui.active_tab) + .unwrap_or(0); + let titles = AppTab::ALL + .iter() + .enumerate() + .map(|(idx, tab)| Line::from(format!(" {} {} ", idx + 1, tab.title()))) + .collect::>(); + let tabs = Tabs::new(titles) + .select(selected) + .block( + Block::default() + .borders(Borders::ALL) + .title(" OpenHuman CLI "), + ) + .style(Style::default().fg(Color::DarkGray)) + .highlight_style(Style::default().fg(OCEAN).add_modifier(Modifier::BOLD)); + frame.render_widget(tabs, area); +} + +fn draw_chat(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(3)]) + .split(area); draw_transcript(frame, chunks[0], state, ui); draw_input(frame, chunks[1], ui); - draw_status(frame, chunks[2], state, ui); +} + +fn draw_logs(frame: &mut Frame, area: Rect, ui: &UiState) { + let lines = crate::core::logging::tui_log_lines(); + let text = if lines.is_empty() { + Text::from("Core logs will appear here as OpenHuman starts.") + } else { + Text::from(lines.join("\n")) + }; + let inner_height = area.height.saturating_sub(2).max(1); + let inner_width = area.width.saturating_sub(2).max(1); + let total_rows = text + .lines + .iter() + .map(|line| u32::from(wrapped_line_count(line, inner_width))) + .sum::(); + let max_scroll = total_rows + .saturating_sub(u32::from(inner_height)) + .min(u32::from(u16::MAX)) as u16; + let top = max_scroll.saturating_sub(ui.log_scroll_from_bottom.min(max_scroll)); + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" Core logs ")) + .style(Style::default().fg(Color::Gray)) + .wrap(Wrap { trim: false }) + .scroll((top, 0)); + frame.render_widget(paragraph, area); +} + +fn draw_config(frame: &mut Frame, area: Rect, ui: &UiState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(5), Constraint::Length(4)]) + .split(area); + let items = ui + .config_items + .iter() + .map(|item| { + ListItem::new(Line::from(vec![ + Span::styled( + format!("{:<18}", item.label), + Style::default().fg(Color::Gray), + ), + Span::styled( + if item.value.is_empty() { + "(not set)" + } else { + &item.value + }, + Style::default().fg(Color::White), + ), + ])) + }) + .collect::>(); + let mut list_state = ListState::default().with_selected(Some(ui.config_selected)); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Safe configuration "), + ) + .highlight_symbol("› ") + .highlight_style(Style::default().fg(OCEAN).add_modifier(Modifier::BOLD)); + frame.render_stateful_widget(list, chunks[0], &mut list_state); + + let selected = &ui.config_items[ui.config_selected.min(ui.config_items.len() - 1)]; + let detail = if let Some(input) = &ui.config_edit { + let visible = tail_to_width(input, chunks[1].width.saturating_sub(5) as usize); + format!("Editing {}\n> {}▏", selected.label, visible) + } else { + format!("{}\n{}", selected.hint, ui.config_status) + }; + frame.render_widget( + Paragraph::new(detail) + .block(Block::default().borders(Borders::ALL).title(" Edit ")) + .wrap(Wrap { trim: false }), + chunks[1], + ); +} + +fn draw_settings(frame: &mut Frame, area: Rect, ui: &UiState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Length(5), + Constraint::Min(3), + ]) + .split(area); + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled( + &ui.auth_summary, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )), + Line::from(ui.account_detail.clone()), + ]) + .block(Block::default().borders(Borders::ALL).title(" Account ")) + .wrap(Wrap { trim: false }), + chunks[0], + ); + + let actions = SettingsAction::ALL + .iter() + .map(|action| ListItem::new(action.label())) + .collect::>(); + let mut action_state = ListState::default().with_selected(Some(ui.settings_selected)); + frame.render_stateful_widget( + List::new(actions) + .block(Block::default().borders(Borders::ALL).title(" Actions ")) + .highlight_symbol("› ") + .highlight_style(Style::default().fg(OCEAN).add_modifier(Modifier::BOLD)), + chunks[1], + &mut action_state, + ); + + let detail = if let Some(token) = &ui.login_token { + let visible = "•".repeat( + token + .chars() + .count() + .min(chunks[2].width.saturating_sub(5) as usize), + ); + format!( + "Paste a one-time login token, then press Enter.\n> {}▏", + visible + ) + } else if ui.logout_confirm { + "Log out and stop account-bound services? Press y to confirm or Esc to cancel.".to_string() + } else { + ui.settings_status.clone() + }; + frame.render_widget( + Paragraph::new(detail) + .block(Block::default().borders(Borders::ALL).title(" Status ")) + .wrap(Wrap { trim: false }), + chunks[2], + ); } fn draw_transcript(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) { @@ -106,21 +256,43 @@ fn draw_input(frame: &mut Frame, area: Rect, ui: &UiState) { frame.render_widget(paragraph, area); } -fn draw_status(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) { - let turn = if state.is_streaming() { +fn draw_footer(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) { + let context = match ui.active_tab { + AppTab::Logs => "PgUp/PgDn scroll", + AppTab::Chat => "Enter send · Esc cancel · Ctrl+N new · PgUp/PgDn scroll", + AppTab::Config => { + if ui.config_edit.is_some() { + "Enter save · Esc cancel" + } else { + "↑↓ navigate · Enter edit" + } + } + AppTab::Settings => { + if ui.is_editing() { + "Enter confirm · Esc cancel" + } else { + "↑↓ navigate · Enter select" + } + } + }; + let turn = if ui.active_tab == AppTab::Chat && state.is_streaming() { let frame_ch = SPINNER_FRAMES[ui.spinner_tick % SPINNER_FRAMES.len()]; format!("{frame_ch} streaming") } else { - "idle".to_string() + ui.active_tab.title().to_string() }; - let thread_short = abbreviate(&ui.thread_id, 24); let left = Span::styled( - format!(" thread {thread_short} · {turn} "), + format!(" {turn} "), Style::default().fg(Color::Black).bg(OCEAN), ); + let navigation = if ui.is_editing() { + "Finish or Esc before switching tabs" + } else { + "Tab/Shift+Tab switch · Alt+1-4 tabs" + }; let hints = Span::styled( - " Enter send · Esc cancel · Ctrl+N new · PgUp/PgDn scroll · Ctrl+C quit", + format!(" {navigation} · {context} · Ctrl+C quit"), Style::default().fg(Color::DarkGray), ); let paragraph = Paragraph::new(Line::from(vec![left, hints])); @@ -201,27 +373,48 @@ fn tail_to_width(s: &str, width: usize) -> String { 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::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn rendered(ui: &UiState) -> String { + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("test terminal"); + let transcript = TranscriptState::new("test-client"); + terminal + .draw(|frame| draw(frame, &transcript, ui)) + .expect("draw"); + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::() + } + + #[test] + fn tab_bar_and_navigation_footer_are_always_rendered() { + let ui = UiState::new("thread-1".into(), "client-1".into()); + let output = rendered(&ui); + for title in ["1 Logs", "2 Chat", "3 Config", "4 Settings"] { + assert!(output.contains(title), "missing tab {title}"); + } + assert!(output.contains("Tab/Shift+Tab switch")); + assert!(output.contains("PgUp/PgDn scroll")); + } + + #[test] + fn editing_footer_explains_that_tab_switching_is_paused() { + let mut ui = UiState::new("thread-1".into(), "client-1".into()); + ui.active_tab = AppTab::Config; + ui.config_edit = Some("value".to_string()); + let output = rendered(&ui); + assert!(output.contains("Finish or Esc before switching tabs")); + assert!(!output.contains("Alt+1-4 tabs")); + } #[test] fn tail_to_width_keeps_the_end() { @@ -230,14 +423,6 @@ mod tests { 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)); diff --git a/src/openhuman/tui/runner.rs b/src/openhuman/tui/runner.rs index 9fcc88648..dde021947 100644 --- a/src/openhuman/tui/runner.rs +++ b/src/openhuman/tui/runner.rs @@ -1,4 +1,4 @@ -//! CLI entry point for the terminal chat UI (`openhuman tui` / `chat`). +//! CLI entry point for the tabbed terminal 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 @@ -11,7 +11,7 @@ use std::sync::Arc; use serde_json::{json, Value}; use crate::core::runtime::{ - CoreBuilder, CoreRuntime, DomainSet, ServiceSet, AGENT_WORKER_STACK_BYTES, + CoreBuilder, CoreRuntime, DomainSet, ServiceSet, AGENT_WORKER_STACK_BYTES, MAX_BLOCKING_THREADS, }; use crate::core::types::HostKind; @@ -57,7 +57,7 @@ pub fn run_from_cli(args: &[String]) -> anyhow::Result<()> { 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={:?})", + "[tui] starting tabbed terminal UI (thread={:?} new={} logs={:?})", thread_id, force_new, log_dir @@ -69,6 +69,7 @@ pub fn run_from_cli(args: &[String]) -> anyhow::Result<()> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(MAX_BLOCKING_THREADS) .build()?; rt.block_on(async_main(thread_id, force_new)) } @@ -77,7 +78,7 @@ async fn async_main(thread_flag: Option, force_new: bool) -> anyhow::Res // 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) + CoreBuilder::new(HostKind::detect_standalone()) .domains(DomainSet::full()) .services(ServiceSet::none()) .build() @@ -153,14 +154,14 @@ 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!("Open the tabbed terminal UI for core logs, orchestrator chat, configuration,"); + println!("and account settings. 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!("Keys: Tab/Shift+Tab or Alt+1-4 switch tabs · arrows navigate · Enter select ·"); println!(" Ctrl+C / Ctrl+D quit."); } @@ -213,11 +214,22 @@ mod tests { "openhuman.channel_web_chat", "openhuman.channel_web_cancel", "openhuman.threads_create_new", + "openhuman.config_get_client_config", + "openhuman.config_update_model_settings", + "openhuman.config_get_autonomy_settings", + "openhuman.config_update_autonomy_settings", + "openhuman.config_get_privacy_mode", + "openhuman.config_set_privacy_mode", + "openhuman.auth_get_state", + "openhuman.auth_get_me", + "openhuman.auth_consume_login_token", + "openhuman.auth_store_session", + "openhuman.auth_clear_session", ] { 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}`" + the tabbed terminal UI would fail with `unknown method: {method}`" ); } } diff --git a/src/openhuman/tui/state.rs b/src/openhuman/tui/state.rs index 1758ec413..ce25eb9d9 100644 --- a/src/openhuman/tui/state.rs +++ b/src/openhuman/tui/state.rs @@ -1,4 +1,4 @@ -//! Pure, terminal-free transcript reducer for the terminal chat UI. +//! Pure, terminal-free transcript reducer for the tabbed terminal UI's Chat tab. //! //! [`TranscriptState`] is a plain data structure with **no ratatui / crossterm / //! IO dependencies** — the renderer ([`super::render`]) reads it and the event diff --git a/src/openhuman/tui/stub.rs b/src/openhuman/tui/stub.rs index 2e0355c5e..81ff5ee38 100644 --- a/src/openhuman/tui/stub.rs +++ b/src/openhuman/tui/stub.rs @@ -1,4 +1,4 @@ -//! Disabled-`tui` facade for [`super`] (the terminal chat UI). +//! Disabled-`tui` facade for [`super`] (the tabbed terminal 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 — diff --git a/src/openhuman/tui/terminal.rs b/src/openhuman/tui/terminal.rs index e2c85c0c5..90606fe6c 100644 --- a/src/openhuman/tui/terminal.rs +++ b/src/openhuman/tui/terminal.rs @@ -9,7 +9,9 @@ use std::io::{self, Stdout}; -use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, +}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -33,7 +35,12 @@ impl TerminalGuard { install_panic_hook(); enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + execute!( + stdout, + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste + )?; let backend = CrosstermBackend::new(io::stdout()); let terminal = Terminal::new(backend)?; log::debug!("[tui] terminal: entered alternate screen + raw mode"); @@ -63,7 +70,12 @@ impl Drop for TerminalGuard { /// down as far as possible. fn restore() -> io::Result<()> { let mut stdout = io::stdout(); - let _ = execute!(stdout, LeaveAlternateScreen, DisableMouseCapture); + let _ = execute!( + stdout, + DisableBracketedPaste, + LeaveAlternateScreen, + DisableMouseCapture + ); disable_raw_mode() } diff --git a/src/openhuman/tui/ui_state.rs b/src/openhuman/tui/ui_state.rs new file mode 100644 index 000000000..9020f7847 --- /dev/null +++ b/src/openhuman/tui/ui_state.rs @@ -0,0 +1,179 @@ +//! Pure navigation and form state for the four terminal pages. + +use zeroize::Zeroize; + +/// Top-level terminal pages. The order is part of the CLI UX contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppTab { + Logs, + Chat, + Config, + Settings, +} + +impl AppTab { + pub const ALL: [Self; 4] = [Self::Logs, Self::Chat, Self::Config, Self::Settings]; + + pub fn title(self) -> &'static str { + match self { + Self::Logs => "Logs", + Self::Chat => "Chat", + Self::Config => "Config", + Self::Settings => "Settings", + } + } + + pub fn next(self) -> Self { + Self::ALL[(self as usize + 1) % Self::ALL.len()] + } + + pub fn previous(self) -> Self { + Self::ALL[(self as usize + Self::ALL.len() - 1) % Self::ALL.len()] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigKey { + ApiUrl, + InferenceUrl, + DefaultModel, + AutonomyLevel, + PrivacyMode, +} + +#[derive(Debug, Clone)] +pub struct ConfigItem { + pub key: ConfigKey, + pub label: &'static str, + pub value: String, + pub hint: &'static str, +} + +impl ConfigItem { + fn new(key: ConfigKey, label: &'static str, hint: &'static str) -> Self { + Self { + key, + label, + value: String::new(), + hint, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsAction { + ViewAccount, + Login, + Logout, +} + +impl SettingsAction { + pub const ALL: [Self; 3] = [Self::ViewAccount, Self::Login, Self::Logout]; + + pub fn label(self) -> &'static str { + match self { + Self::ViewAccount => "View account", + Self::Login => "Log in with one-time token", + Self::Logout => "Log out", + } + } +} + +/// UI-only state owned by the event loop and read by the renderer. +pub struct UiState { + pub active_tab: AppTab, + pub input: String, + pub scroll_from_bottom: u16, + pub spinner_tick: usize, + pub thread_id: String, + pub log_scroll_from_bottom: u16, + pub config_items: Vec, + pub config_selected: usize, + pub config_edit: Option, + pub config_status: String, + pub settings_selected: usize, + pub auth_summary: String, + pub account_detail: String, + pub login_token: Option, + pub logout_confirm: bool, + pub settings_status: String, + pub identity_changed: bool, +} + +impl UiState { + pub fn new(thread_id: String, _client_id: String) -> Self { + Self { + active_tab: AppTab::Logs, + input: String::new(), + scroll_from_bottom: 0, + spinner_tick: 0, + thread_id, + log_scroll_from_bottom: 0, + config_items: vec![ + ConfigItem::new( + ConfigKey::ApiUrl, + "Backend URL", + "OpenHuman auth and billing backend", + ), + ConfigItem::new( + ConfigKey::InferenceUrl, + "Inference URL", + "Custom OpenAI-compatible endpoint", + ), + ConfigItem::new( + ConfigKey::DefaultModel, + "Default model", + "Model id used when no route overrides it", + ), + ConfigItem::new( + ConfigKey::AutonomyLevel, + "Agent access", + "readonly, supervised, or full", + ), + ConfigItem::new( + ConfigKey::PrivacyMode, + "Privacy mode", + "local_only, standard, or sensitive", + ), + ], + config_selected: 0, + config_edit: None, + config_status: "Loading safe configuration…".to_string(), + settings_selected: 0, + auth_summary: "Checking account…".to_string(), + account_detail: String::new(), + login_token: None, + logout_confirm: false, + settings_status: "Select an account action and press Enter.".to_string(), + identity_changed: false, + } + } + + pub fn is_editing(&self) -> bool { + self.config_edit.is_some() || self.login_token.is_some() || self.logout_confirm + } +} + +impl Drop for UiState { + fn drop(&mut self) { + if let Some(token) = &mut self.login_token { + token.zeroize(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_starts_on_logs_and_tabs_wrap_in_product_order() { + let ui = UiState::new("thread".into(), "client".into()); + assert_eq!(ui.active_tab, AppTab::Logs); + assert_eq!(AppTab::Logs.next(), AppTab::Chat); + assert_eq!(AppTab::Chat.next(), AppTab::Config); + assert_eq!(AppTab::Config.next(), AppTab::Settings); + assert_eq!(AppTab::Settings.next(), AppTab::Logs); + assert_eq!(AppTab::Logs.previous(), AppTab::Settings); + } +}