feat(tui): add logs-first tabbed CLI experience (#5131)

This commit is contained in:
Steven Enamakel
2026-07-23 05:50:40 +03:00
committed by GitHub
parent 2bcd34496c
commit 81d09b6aa7
19 changed files with 1261 additions and 132 deletions
+3 -3
View File
@@ -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.
+3 -3
View File
@@ -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
+2
View File
@@ -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_*.
+5 -3
View File
@@ -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
+5
View File
@@ -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
+49 -3
View File
@@ -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<String, Vec<ControllerSchema>> {
fn print_general_help(grouped: &BTreeMap<String, Vec<ControllerSchema>>) {
println!("OpenHuman core CLI\n");
println!("Usage:");
println!(" openhuman [--no-tui] (tabbed terminal UI on interactive hosts)");
println!(" openhuman run [--host <addr>] [--port <u16>] [--jsonrpc-only] [--verbose]");
println!(" openhuman call --method <name> [--params '<json>']");
println!(
" openhuman mcp [-v|--verbose] (stdio MCP server; read-only memory tools)"
);
println!(" openhuman tui [--thread <id>|--new] (terminal chat UI, alias: chat)");
println!(
" openhuman tui [--thread <id>|--new] (force tabbed terminal UI, alias: chat)"
);
println!(" openhuman skills <subcommand> [options] (skill development runtime)");
println!(" openhuman agent <subcommand> [options] (inspect agent definitions & prompts)");
println!(" openhuman voice [--hotkey <combo>] [--mode <tap|push>] (voice dictation server)");
+75 -1
View File
@@ -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<Mutex<()>> = OnceLock::new();
#[test]
fn bare_cli_auto_launches_tui_only_for_interactive_non_container_hosts() {
let none: Vec<String> = 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(()))
+121 -1
View File
@@ -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<Option<WorkerGuard>> = Mutex::new(None);
/// it without re-deriving the data dir.
static LOG_DIR: OnceLock<PathBuf> = OnceLock::new();
const TUI_LOG_CAPACITY: usize = 2_000;
const TUI_LOG_LINE_MAX_CHARS: usize = 4_096;
static TUI_LOG_BUFFER: OnceLock<std::sync::Arc<Mutex<VecDeque<String>>>> = OnceLock::new();
#[derive(Clone)]
struct TuiLogMakeWriter {
buffer: std::sync::Arc<Mutex<VecDeque<String>>>,
}
struct TuiLogWriter {
buffer: std::sync::Arc<Mutex<VecDeque<String>>>,
pending: Vec<u8>,
}
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<usize> {
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<PathBuf> {
}))
});
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<PathBuf> {
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<String> {
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)
);
}
}
+9 -10
View File
@@ -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 <id>` 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 <id>` 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,
},
+35 -4
View File
@@ -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<RpcOutcome<serde_json::Value>, 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<RpcOutcome<serde_json::Val
// different user signs in to the same sidecar process.
crate::openhuman::subconscious::registry::reset_engine_for_user_switch().await;
// The process stays alive after desktop/TUI logout, so every process-global
// store must follow the now-active pre-login workspace. Without this, a
// signed-out caller can keep reading the previous account's context until
// the process restarts.
match crate::openhuman::config::load_config_with_timeout().await {
Ok(signed_out_config) => {
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(
+144 -24
View File
@@ -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<WebChannelEvent>,
) -> 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::<Event>();
@@ -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<AppTab> {
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<CoreRuntime>,
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<CoreRuntime>, 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<CoreRuntime>, 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"));
}
}
+338
View File
@@ -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<CoreRuntime>, 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<CoreRuntime>, 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<CoreRuntime>, 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<CoreRuntime>, 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<CoreRuntime>, 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<CoreRuntime>, 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<CoreRuntime>, 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<CoreRuntime>, 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::<Vec<_>>()
.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::<Vec<_>>()
.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);
}
}
}
+7 -3
View File
@@ -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;
+249 -64
View File
@@ -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::<Vec<_>>();
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::<u32>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>()
.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::<String>()
}
#[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));
+20 -8
View File
@@ -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<String>, 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 <id>] [--new] [-v|--verbose]");
println!(" openhuman chat [--thread <id>] [--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 <id> 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}`"
);
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 —
+15 -3
View File
@@ -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()
}
+179
View File
@@ -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<ConfigItem>,
pub config_selected: usize,
pub config_edit: Option<String>,
pub config_status: String,
pub settings_selected: usize,
pub auth_summary: String,
pub account_detail: String,
pub login_token: Option<String>,
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);
}
}