mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat(tui): add logs-first tabbed CLI experience (#5131)
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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_*.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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,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,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 —
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user