feat(tui): feature-gated terminal chat UI (openhuman tui / chat) (#5084)

This commit is contained in:
Steven Enamakel
2026-07-21 15:13:44 +03:00
committed by GitHub
parent f1811150c8
commit fcbcebedda
18 changed files with 2512 additions and 56 deletions
+12
View File
@@ -243,6 +243,7 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \
| `skills` | ON | `openhuman::skills` + `openhuman::skill_runtime` + `openhuman::skill_registry` domains — SKILL.md discovery/parse/install, workflow execution + run logs, remote catalogs, the `skill_setup` / `skill_executor` builtin agents, and the 16 skill agent tools | none (see below) |
| `flows` | ON | `openhuman::flows` (saved automation graphs — create/run/schedule, the `workflow_builder` + `flow_discovery` agents), `openhuman::tinyflows` (engine seam), `openhuman::rhai_workflows` (`.ragsh` language-workflow tool) | `tinyflows`, `jaq-core`, `jaq-std`, `jaq-json`, `rhai` |
| `mcp` | ON | `openhuman::mcp_server` (the `openhuman mcp` stdio/HTTP server), `openhuman::mcp_registry` (dynamic Smithery installs — `mcp_clients` RPC namespace, SQLite, boot spawn, supervisor, OAuth), `openhuman::mcp_audit` (write-audit log), and the static config-declared server set in `openhuman::mcp_client`. ~19 agent tools, ~20k LOC | **none** (see scope note) |
| `tui` | ON | `openhuman::tui` — the `openhuman tui` (alias `chat`) CLI subcommand: a ratatui/crossterm terminal chat UI onto the `web_chat` surface, running the core in-process. No controllers, no agent tools. **Intentionally NOT forwarded to the desktop shell** (allowlisted in `check-feature-forwarding.mjs`). | `ratatui`, `crossterm` |
**Facade pattern (pathfinder for the other gates).** `pub mod voice;` is **always compiled** as a facade: the real submodules are `#[cfg(feature = "voice")]`, and a `#[cfg(not(feature = "voice"))] mod stub;` (`src/openhuman/voice/stub.rs`) re-exposes the same public surface that always-on / other-gated callers use (`server`, `dictation_listener`, `streaming`, `reply_speech`, `cloud_transcribe`, `cli`, `create_stt_provider`, `effective_stt_provider`, `publish_ptt_transcript_committed`) with no-op / `None` / disabled-error bodies. Callers therefore do **not** need per-call `#[cfg]`. When voice is off: the voice/audio controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the `audio_generate_podcast` agent tools are absent, and `openhuman voice` returns a "voice disabled" error. Stub signatures must match the real ones exactly — the disabled build (`--no-default-features --features tokenjuice-treesitter`) is the **only** thing that catches drift, so run it before pushing any change to the voice surface.
@@ -306,6 +307,17 @@ Follows the voice facade+stub pattern for `mcp_server` / `mcp_registry` / `mcp_a
`src/core/all.rs` needs **no** `#[cfg]` for this gate: the stub aggregators return empty vecs, so the registration sites keep compiling unchanged.
#### The `tui` gate
The terminal chat UI (`openhuman tui` / alias `chat`) lives in `src/openhuman/tui/` and follows the **`mcp`/`voice` facade+stub** pattern: `pub mod tui;` is always compiled; the behavioural submodules (`app`, `render`, `state`, `terminal`, `runner`) are `#[cfg(feature = "tui")]`; and `#[cfg(not(feature = "tui"))] mod stub;` re-exposes the one symbol an always-compiled caller reaches — `run_from_cli` — with a build-fact error body (`"tui feature disabled at compile time … --features tui"`).
- **The `"tui" | "chat"` CLI arm in `src/core/cli.rs` is un-`#[cfg]`'d on purpose.** In a slim build it resolves to `tui::stub::run_from_cli`, which bails with the disabled-error rather than falling through to `unknown namespace: tui` (which reads like a typo, not a build fact). Same reasoning as the `mcp` arm. Pinned by `tui_subcommand_reports_disabled_build_when_gate_off` / `chat_alias_reports_disabled_build_when_gate_off` in `src/core/cli_tests.rs` (both `#[cfg(not(feature = "tui"))]`). `"tui" | "chat"` is also added to the banner-suppression `matches!` (a TUI owns the terminal — a banner would corrupt it).
- **No controllers, no agent tools, no `all.rs` changes.** The TUI is a pure *client* of the existing `web_chat` surface — it boots the core in-process (`CoreBuilder::new(HostKind::Cli).domains(DomainSet::full()).services(ServiceSet::none())`), sends turns via `runtime.invoke("openhuman.channel_web_chat", …)`, and streams by draining `web_chat::subscribe_web_channel_events()` filtered by its own `client_id`. `openhuman.channel_web_chat` needs `DomainGroup::Channels`, so `DomainSet::full()` (not `harness()`) is required.
- **Terminal hygiene is load-bearing.** `logging::init_for_tui` installs a **file-only** subscriber (never stderr) — a single core boot log on stdout/stderr would corrupt the alternate-screen UI. `terminal::TerminalGuard` restores raw mode + the main screen on `Drop`, and a panic hook chains a restore ahead of the default hook. All `[tui]` state-transition logs go to the file, never `println!`.
- **Intentionally NOT forwarded to the desktop shell** (the app ships its own Tauri UI). It carries the only current entry in `INTENTIONALLY_NOT_FORWARDED` in `scripts/ci/check-feature-forwarding.mjs`; the pure reducer lives in `src/openhuman/tui/state.rs` (`TranscriptState::apply_event`) with unit tests, so most behaviour is testable without a terminal.
Drops the exclusive `ratatui` + `crossterm` deps when off. Verify with `cargo tree -i ratatui --no-default-features --features tokenjuice-treesitter` (must return nothing).
### Event bus (`src/core/event_bus/`)
Typed pub/sub + native request/response. Both singletons — use module-level functions.
Generated
+830 -54
View File
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -274,6 +274,14 @@ pdf-extract = "0.10"
# stack) now lives in the tinychannels crate; the `whatsapp-web` feature forwards
# to `tinychannels/whatsapp-web`.
ppt-rs = "0.2.14"
# Terminal chat UI (`openhuman tui` / `chat`). Exclusive to the default-ON
# `tui` feature (see `[features]` below): a slim / headless build without `tui`
# drops both `ratatui` and `crossterm`. Kept in lockstep — ratatui 0.30
# re-exports crossterm 0.29, so declaring crossterm directly at the same major
# unifies to a single crossterm in the dep graph. Only compiled into
# `src/openhuman/tui/` behind `#[cfg(feature = "tui")]`.
ratatui = { version = "0.30", optional = true }
crossterm = { version = "0.29", optional = true }
# Native-Rust `.docx` writer for the `generate_document` tool (GH #4847).
# Pure Rust (no subprocess / managed runtime), MIT-licensed, actively
# maintained (2.8M downloads, last release 0.4.20 — Apr 2026). Mirrors the
@@ -347,7 +355,7 @@ tokio = { version = "1", features = ["test-util"] }
proptest = "1"
[features]
default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp"]
default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp", "tui"]
# AST-aware code compression (tree-sitter Rust/TS/Python grammars; C build).
# On by default; disable to fall back to the brace-depth heuristic.
tokenjuice-treesitter = [
@@ -458,6 +466,19 @@ skills = []
# `sanitize::sanitize_for_llm`). The gate follows the real dependency graph,
# not the directory name.
mcp = []
# Terminal chat UI: the `openhuman tui` (alias `chat`) CLI subcommand, a
# ratatui/crossterm terminal front-end onto the same `web_chat` surface the
# desktop app drives. Default-ON for the standalone `openhuman-core` binary, but
# INTENTIONALLY NOT forwarded to the desktop shell (the desktop app ships its own
# Tauri UI and never needs a terminal one) — see the allowlist entry in
# `scripts/ci/check-feature-forwarding.mjs`. Slim / headless builds opt out via
# `--no-default-features --features "<explicit list without tui>"`, which drops
# the exclusive `ratatui` + `crossterm` dependencies. The module
# `src/openhuman/tui/` is a facade (always compiled) whose behavioural submodules
# are `#[cfg(feature = "tui")]`; when off, `tui::stub::run_from_cli` returns a
# build-fact "tui feature disabled at compile time" error from the untouched
# `"tui" | "chat"` CLI arm (mirrors the `mcp` stub pattern).
tui = ["dep:ratatui", "dep:crossterm"]
sandbox-landlock = ["dep:landlock"]
sandbox-bubblewrap = []
peripheral-rpi = ["dep:rppal"]
+12
View File
@@ -599,6 +599,18 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
---
## 14. Terminal Chat UI (`openhuman tui`)
### 14.1 TUI Subcommand (feature-gated `tui`)
| ID | Feature | Layer | Test path(s) | Status | Notes |
| ------ | ------------------------------------------------- | ----- | ----------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------- |
| 14.1.1 | Transcript reducer (deltas, done, error, tools) | RU | `src/openhuman/tui/state.rs` | ✅ | Pure `TranscriptState::apply_event` — client-id filtering, text/thinking split, `chat_done` finalization |
| 14.1.2 | Runner flags + thread resolution + RPC name pins | RU | `src/openhuman/tui/runner.rs`, `src/openhuman/tui/app.rs` | ✅ | `--thread`/`--new` parsing; canonical `openhuman.*` method names resolve via registry |
| 14.1.3 | Render layout (viewport, input, status) | RU | `src/openhuman/tui/render.rs` | ✅ | ratatui TestBackend |
| 14.1.4 | Disabled-build stub (`--no-default-features`) | RU | `src/core/cli_tests.rs` (`tui`/`chat` `*_reports_disabled_build_when_gate_off`) | ✅ | Build-fact error, not `unknown namespace` |
| 14.1.5 | Interactive terminal session (raw mode, streaming) | MS | manual smoke | 🚫 | Needs a real TTY; not driver-automatable |
## Summary
| Status | Count |
+95
View File
@@ -0,0 +1,95 @@
# Plan: `openhuman tui` — feature-gated terminal chat UI
## Goal
Running `openhuman-core tui` (alias `chat`) opens a ratatui-based terminal UI that is an
interface into the general chat (the same `web_chat` surface the desktop app uses),
gated behind a Cargo feature `tui`.
## Architecture decisions (grounded in current code)
1. **Cargo feature**: `tui = ["dep:ratatui", "dep:crossterm"]`, added to `default`
(repo convention: gates default-ON). It must NOT ship in the desktop app —
add `'tui': 'Terminal UI subcommand; the desktop app ships its own Tauri UI.'`
to `INTENTIONALLY_NOT_FORWARDED` in `scripts/ci/check-feature-forwarding.mjs`.
2. **Module**: new domain dir `src/openhuman/tui/` using the **mcp/voice facade pattern**:
- `mod.rs` always compiled; real submodules `#[cfg(feature = "tui")]`;
`#[cfg(not(feature = "tui"))] mod stub;` exposing the same `run_from_cli`.
- `stub.rs` `run_from_cli` bails with
`"tui feature disabled at compile time … rebuild with --features tui"`
(mirror `src/openhuman/mcp_server/stub.rs:42`).
- No controllers, no agent tools, no `all.rs` changes (leaf client, like `flows`'
philosophy: absence, not degraded registration — but here the only outside
touch-point is the CLI arm, which uses the stub for a build-fact error).
3. **CLI arm**: in `src/core/cli.rs` match (~line 63), add
`"tui" | "chat" => crate::openhuman::tui::run_from_cli(&args[1..])`.
Arm stays **un-cfg'd** (mcp precedent). Add `"tui" | "chat"` to the banner-suppression
`matches!` at lines 4850 (a TUI must own the terminal).
4. **In-process core, no HTTP**: build a multi-thread tokio runtime with
`AGENT_WORKER_STACK_BYTES` stack (copy `run_server_command` shape, cli.rs:219311),
then `CoreBuilder::new(HostKind::Cli).domains(DomainSet::full()).services(ServiceSet::none()).build()`.
`channel.web_chat` needs `DomainGroup::Channels`, so `harness()` is not enough.
5. **Chat flow**:
- `client_id = "tui-<random hex>"`.
- Threads via `runtime.invoke("threads.list"| "threads.create_new", …)`;
CLI flags: `--thread <id>`, `--new` (default: create new thread).
- Send turn: `runtime.invoke("channel.web_chat", {client_id, thread_id, message, …})`
(schema: `src/openhuman/web_chat/schemas.rs:45`; ops entry `ops.rs:1199`/`start_chat` at 391).
- Stream: drain `web_chat::subscribe_web_channel_events()` (broadcast bus,
`src/openhuman/web_chat/event_bus.rs:14`), filter by our `client_id`.
Render `text_delta`/`thinking_delta` (`delta`, `delta_kind` fields on
`WebChannelEvent`, `src/core/socketio.rs:98`), show `tool_call`/`tool_result`
as status lines, finish on `chat_done` (use `full_response` as authoritative
final text) or `chat_error` (show `message`).
- Cancel in-flight turn: `channel.web_cancel` on Esc.
6. **UI v1 scope** (keep it tight):
- Alternate screen + raw mode; transcript viewport with scrollback
(PgUp/PgDn/mouse optional), single-line input box, status bar
(thread id, model/turn state, key hints), spinner while streaming.
- Keys: Enter=send, Esc=cancel turn, Ctrl+N=new thread, Ctrl+C/Ctrl+D=quit.
- Distinguish user / assistant / thinking (dim) / tool activity / errors.
Ocean-ish accent per design tokens is fine but keep it terminal-native.
7. **Terminal hygiene (critical)**:
- Panic hook + Drop guard that restores the terminal (leave raw mode,
LeaveAlternateScreen) before the panic message prints.
- **Logging must not hit stdout/stderr while the TUI owns the terminal** —
inspect how `run_from_cli`/`run_server_command` init logging
(`src/core/logging.rs`) and route core logs to file only (or suppress console)
for the tui arm. Core boot logs corrupting the UI is a bug.
8. **Separation for testability**: pure state module (`transcript.rs` or `state.rs`)
holding a reducer `apply_event(&mut TranscriptState, &WebChannelEvent)` with no
terminal deps — unit-testable. Rendering (`render.rs`) and event loop (`app.rs`)
stay thin.
## Tests / verification (definition of done)
- Unit tests for the reducer: text_delta accumulation, thinking vs text separation,
chat_done replaces with full_response, chat_error, ignores other client_ids,
tool_call/result lines.
- `src/core/cli_tests.rs`: `tui_subcommand_reports_disabled_build_when_gate_off`
(+ `chat` alias) under `#[cfg(not(feature = "tui"))]`, mirroring the mcp tests
(assert error contains "tui feature disabled" and NOT "unknown namespace").
- Builds (Apple Silicon: prefix `GGML_NATIVE=OFF`):
- `cargo check --manifest-path Cargo.toml`
- `cargo check --no-default-features --features tokenjuice-treesitter` (disabled build)
- `cargo test --lib core::cli` and the tui module tests, both feature directions:
`cargo test --lib --no-default-features --features tokenjuice-treesitter core::`
- `node scripts/ci/check-feature-forwarding.mjs` passes with the allowlist entry.
- `cargo fmt` clean.
## Docs
- AGENTS.md: add `tui` row to the feature table + a short gate section
(leaf-ish gate, sheds `ratatui`+`crossterm`, intentionally not forwarded to desktop).
- `src/openhuman/about_app/`: add user-facing feature entry for the terminal chat UI.
## Non-goals (v1)
- No thread-picker UI beyond `--thread/--new` flags, no markdown rendering,
no image/artifact display, no approval-request interaction (surface as a status
line telling the user to handle it elsewhere), no remote-core (HTTP) mode.
## Workflow
Small focused commits on `feat/tui-chat` in this worktree; don't push.
Debug logging with `[tui]` prefix on all state transitions.
+1
View File
@@ -32,6 +32,7 @@ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
*/
const INTENTIONALLY_NOT_FORWARDED = {
// 'some-gate': 'Reason it must not ship in the desktop build.',
tui: 'Terminal UI subcommand (openhuman tui/chat); the desktop app ships its own Tauri UI and never runs the ratatui terminal front-end.',
};
function usage() {
+14 -1
View File
@@ -45,7 +45,14 @@ Contribute & Star us on GitHub: https://github.com/tinyhumansai/openhuman
/// the subcommand/namespace is unknown.
pub fn run_from_cli_args(args: &[String]) -> Result<()> {
// Print the welcome banner to stderr to keep stdout clean for JSON output.
if !matches!(args.first().map(String::as_str), Some("mcp" | "mcp-server")) {
// `mcp`/`mcp-server` speak JSON-RPC on stdout; `tui`/`chat` own the whole
// terminal (alternate screen + raw mode) — a banner on either would corrupt
// the stream / the UI, so both suppress it. The `matches!` is on the raw
// string, so it stays valid even when the `tui` feature is compiled out.
if !matches!(
args.first().map(String::as_str),
Some("mcp" | "mcp-server" | "tui" | "chat")
) {
eprint!("{CLI_BANNER}");
}
@@ -61,6 +68,11 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> {
match args[0].as_str() {
"run" | "serve" => run_server_command(&args[1..]),
"mcp" | "mcp-server" => crate::openhuman::mcp_server::run_stdio_from_cli(&args[1..]),
// Terminal chat UI. Un-`#[cfg]`'d on purpose: in a slim build this
// resolves to `tui::stub::run_from_cli`, which bails with a build-fact
// error (see `src/openhuman/tui/stub.rs`) rather than falling through to
// `unknown namespace: tui`.
"tui" | "chat" => crate::openhuman::tui::run_from_cli(&args[1..]),
"call" => run_call_command(&args[1..]),
// Domain-specific CLI adapters that don't follow the generic namespace pattern.
"screen-intelligence" => {
@@ -561,6 +573,7 @@ fn print_general_help(grouped: &BTreeMap<String, Vec<ControllerSchema>>) {
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 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)");
+52
View File
@@ -195,3 +195,55 @@ fn mcp_server_alias_reports_disabled_build_when_gate_off() {
"the `mcp-server` alias must give the same build-fact diagnostic as `mcp`"
);
}
// --- `tui` compile-time gate --------------------------------------------
/// With the `tui` feature compiled out, `openhuman tui` must fail with a
/// diagnostic that names the BUILD as the cause — not a generic
/// "unknown namespace" error.
///
/// Same reasoning as the `mcp` gate test above: the naive way to gate the CLI
/// is to delete the `"tui" | "chat"` match arm, which is WRONG — `tui` would
/// fall through to generic namespace resolution and die with `unknown
/// namespace: tui`, reading like a user typo. Instead `cli.rs` is untouched and
/// the arm resolves to `tui::stub::run_from_cli`, which bails with the message
/// asserted below.
#[test]
#[cfg(not(feature = "tui"))]
fn tui_subcommand_reports_disabled_build_when_gate_off() {
let _guard = env_lock();
let err = crate::core::cli::run_from_cli_args(&["tui".to_string()])
.expect_err("`openhuman tui` must fail when the `tui` feature is compiled out");
let msg = err.to_string();
assert!(
msg.contains("tui feature disabled"),
"error must name the compile-time gate as the cause; got: {msg}"
);
assert!(
msg.contains("--features tui"),
"error must tell the user how to get a working build; got: {msg}"
);
assert!(
!msg.contains("unknown namespace"),
"must NOT degrade into generic namespace resolution — that reads like a typo, \
not a build fact; got: {msg}"
);
}
/// The `chat` alias must behave identically to `tui` — both arms route to the
/// same stub, so neither can silently regress into the fall-through.
#[test]
#[cfg(not(feature = "tui"))]
fn chat_alias_reports_disabled_build_when_gate_off() {
let _guard = env_lock();
let err = crate::core::cli::run_from_cli_args(&["chat".to_string()])
.expect_err("`openhuman chat` must fail when the `tui` feature is compiled out");
assert!(
err.to_string().contains("tui feature disabled"),
"the `chat` alias must give the same build-fact diagnostic as `tui`"
);
}
+81
View File
@@ -319,6 +319,87 @@ pub fn init_for_embedded(data_dir: &Path, verbose: bool) {
});
}
/// Initialize logging for the terminal chat UI (`openhuman tui` / `chat`).
///
/// **File-only, never stderr.** The TUI owns the whole terminal (alternate
/// screen + raw mode); a single `tracing`/`log` line written to stdout or
/// stderr would corrupt the rendered UI. So — unlike [`init_for_cli_run`]
/// (stderr) and [`init_for_embedded`] (stderr + file) — this installs **only**
/// a daily-rotated file appender at `<data_dir>/logs/openhuman-YYYY-MM-DD.log`
/// plus the Sentry layer (which keeps no console handle). Core boot logs and
/// the `[tui]` state-transition logs land in that file for post-mortem
/// debugging without ever touching the screen.
///
/// Idempotent (`Once`-guarded, shared with the other init entry points). If a
/// subscriber was somehow already installed, this is a no-op and logging keeps
/// whatever destination the first caller chose — still never stderr from *this*
/// path. Returns the resolved log directory on success (for a status line), or
/// `None` when the file appender could not be created.
pub fn init_for_tui(data_dir: &Path, verbose: bool) -> Option<PathBuf> {
INIT.call_once(|| {
let scope = CliLogDefault::Global;
seed_rust_log(verbose, scope);
let filter = build_env_filter(verbose, scope);
let logs_dir = data_dir.join("logs");
let pending_file: Option<(_, tracing_appender::non_blocking::WorkerGuard, PathBuf)> =
match std::fs::create_dir_all(&logs_dir) {
Ok(()) => match tracing_appender::rolling::Builder::new()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("openhuman")
.filename_suffix("log")
.max_log_files(7)
.build(&logs_dir)
{
Ok(appender) => {
let (writer, guard) = tracing_appender::non_blocking(appender);
Some((writer, guard, logs_dir.clone()))
}
Err(err) => {
// No tracing subscriber yet, but we deliberately do NOT
// eprintln! here (the TUI is about to take the terminal).
// Losing this one diagnostic is the correct trade.
let _ = err;
None
}
},
Err(_) => None,
};
let file_layer = pending_file.as_ref().map(|(writer, _, _)| {
let constraints = parse_log_file_constraints();
tracing_subscriber::fmt::layer()
.with_ansi(false)
.event_format(CleanCliFormat)
.with_writer(writer.clone())
.with_filter(tracing_subscriber::filter::filter_fn(move |meta| {
event_matches_file_constraints(meta, &constraints)
}))
});
// NOTE: no stderr layer here — that is the whole point of this entry
// point. Only the file layer + Sentry are attached.
if tracing_subscriber::registry()
.with(filter)
.with(file_layer)
.with(sentry_tracing_layer())
.try_init()
.is_ok()
{
if let Some((_, guard, dir)) = pending_file {
if let Ok(mut slot) = FILE_GUARD.lock() {
*slot = Some(guard);
}
let _ = LOG_DIR.set(dir);
}
}
let _ = tracing_log::LogTracer::init();
});
log_directory().map(Path::to_path_buf)
}
/// Path to the active log directory (set by [`init_for_embedded`]). Returns
/// `None` if logging hasn't been initialized in embedded mode (e.g. bare
/// CLI runs).
+17
View File
@@ -227,6 +227,23 @@ pub(super) const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::Stable,
privacy: None,
},
Capability {
id: "conversation.terminal_chat",
name: "Terminal Chat (TUI)",
domain: "tui",
category: CapabilityCategory::Conversation,
description: "Chat with the assistant from a terminal instead of the desktop UI. \
`openhuman tui` (alias `chat`) opens a ratatui full-screen chat onto the \
same conversation surface the app uses, running the core in-process. \
Streams replies, thinking, and tool activity live; supports scrollback, \
cancelling a turn, and starting a new thread. Ships only in the standalone \
`openhuman-core` binary (the desktop app has its own UI).",
how_to: "Run `openhuman tui` (or `openhuman chat`) from a terminal. Flags: \
`--thread <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.",
status: CapabilityStatus::Beta,
privacy: DERIVED_TO_BACKEND,
},
Capability {
id: "conversation.suggested_questions",
name: "Suggested Questions",
+1
View File
@@ -142,6 +142,7 @@ pub mod tool_registry;
pub mod tool_status;
pub mod tool_timeout;
pub mod tools;
pub mod tui;
pub mod update;
pub mod util;
pub mod voice;
+240
View File
@@ -0,0 +1,240 @@
//! Terminal chat event loop.
//!
//! Bridges three async sources over `tokio::select!`:
//! * **keyboard** — a blocking crossterm reader thread forwards `Event`s over
//! an mpsc channel (crossterm's own async `EventStream` needs the
//! `event-stream` feature; the poll+forward thread keeps the dep surface
//! minimal and exits promptly via the shared `shutdown` flag),
//! * **web-channel broadcast** — the same `web_chat` event stream the desktop
//! app consumes, folded into [`TranscriptState`] by its reducer,
//! * **a spinner ticker** — animates the streaming indicator.
//!
//! All state transitions are logged with the `[tui]` prefix to the file-only
//! subscriber (see `logging::init_for_tui`); nothing is ever `println!`'d.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use serde_json::json;
use tokio::sync::broadcast;
use crate::core::runtime::CoreRuntime;
use crate::core::socketio::WebChannelEvent;
use crate::openhuman::web_chat;
use super::render::{self, UiState};
use super::state::TranscriptState;
use super::terminal::TerminalGuard;
/// Run the terminal chat loop until the user quits (Ctrl+C / Ctrl+D) or the
/// web-channel bus closes. The [`TerminalGuard`] restores the terminal on every
/// exit path, including panics.
pub async fn run(
runtime: Arc<CoreRuntime>,
client_id: String,
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());
// Blocking crossterm reader → async channel.
let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
let shutdown = Arc::new(AtomicBool::new(false));
let reader_shutdown = shutdown.clone();
let reader = std::thread::spawn(move || {
while !reader_shutdown.load(Ordering::Relaxed) {
match event::poll(Duration::from_millis(100)) {
Ok(true) => match event::read() {
Ok(ev) => {
if input_tx.send(ev).is_err() {
break;
}
}
Err(_) => break,
},
Ok(false) => {}
Err(_) => break,
}
}
});
let mut ticker = tokio::time::interval(Duration::from_millis(120));
let mut quit = false;
while !quit {
guard.terminal().draw(|f| render::draw(f, &state, &ui))?;
tokio::select! {
maybe_ev = input_rx.recv() => match maybe_ev {
Some(Event::Key(key)) => {
if handle_key(key, &runtime, &client_id, &mut state, &mut ui).await {
quit = true;
}
}
Some(_) => {} // resize / mouse / paste — redraw next iteration
None => quit = true, // reader thread gone
},
recv = web_rx.recv() => match recv {
Ok(ev) => state.apply_event(&ev),
Err(broadcast::error::RecvError::Lagged(n)) => {
log::warn!("[tui] web-channel lagged, dropped {n} events");
}
Err(broadcast::error::RecvError::Closed) => {
log::warn!("[tui] web-channel closed — exiting");
quit = true;
}
},
_ = ticker.tick() => {
ui.spinner_tick = ui.spinner_tick.wrapping_add(1);
}
}
}
shutdown.store(true, Ordering::Relaxed);
let _ = reader.join();
log::info!("[tui] event loop exited");
Ok(())
}
/// Handle a key event. Returns `true` when the app should quit.
async fn handle_key(
key: KeyEvent,
runtime: &Arc<CoreRuntime>,
client_id: &str,
state: &mut TranscriptState,
ui: &mut UiState,
) -> bool {
// Ignore key-release events (Windows / kitty report both edges).
if key.kind == KeyEventKind::Release {
return false;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('c') if ctrl => {
log::info!("[tui] quit via Ctrl+C");
return true;
}
KeyCode::Char('d') if ctrl => {
log::info!("[tui] quit via Ctrl+D");
return true;
}
KeyCode::Char('n') if ctrl => new_thread(runtime, state, ui).await,
KeyCode::Esc => cancel_turn(runtime, client_id, &ui.thread_id, state),
KeyCode::PageUp => {
ui.scroll_from_bottom = ui.scroll_from_bottom.saturating_add(5);
}
KeyCode::PageDown => {
ui.scroll_from_bottom = ui.scroll_from_bottom.saturating_sub(5);
}
KeyCode::Enter => send_message(runtime, client_id, state, ui),
KeyCode::Backspace => {
ui.input.pop();
}
KeyCode::Char(c) if !ctrl => ui.input.push(c),
_ => {}
}
false
}
/// Queue a chat turn on the current thread. Fire-and-forget: the reply streams
/// back over the web-channel bus and is folded in by the reducer.
fn send_message(
runtime: &Arc<CoreRuntime>,
client_id: &str,
state: &mut TranscriptState,
ui: &mut UiState,
) {
let message = ui.input.trim().to_string();
if message.is_empty() {
return;
}
ui.input.clear();
ui.scroll_from_bottom = 0;
state.begin_user_turn(&message);
log::info!(
"[tui] send message len={} thread={}",
message.len(),
ui.thread_id
);
let rt = runtime.clone();
let cid = client_id.to_string();
let tid = ui.thread_id.clone();
tokio::spawn(async move {
let params = json!({
"client_id": cid,
"thread_id": tid,
"message": message,
"source": "tui",
});
if let Err(e) = rt.invoke("openhuman.channel_web_chat", params).await {
log::error!("[tui] openhuman.channel_web_chat failed: {e}");
// Surface the failure in-transcript via a synthetic chat_error so
// the reducer clears the streaming state and shows the reason.
web_chat::publish_web_channel_event(WebChannelEvent {
event: "chat_error".to_string(),
client_id: cid,
thread_id: tid,
message: Some(format!("Failed to send: {e}")),
error_type: Some("transport".to_string()),
..Default::default()
});
}
});
}
/// Cancel the in-flight turn on the current thread. The core emits a
/// `chat_error` ("Cancelled") which the reducer renders.
fn cancel_turn(
runtime: &Arc<CoreRuntime>,
client_id: &str,
thread_id: &str,
state: &TranscriptState,
) {
if !state.is_streaming() {
return;
}
log::info!("[tui] cancel turn thread={thread_id}");
let rt = runtime.clone();
let cid = client_id.to_string();
let tid = thread_id.to_string();
tokio::spawn(async move {
// Omit `request_id` → stop whatever is running on the thread.
let params = json!({ "client_id": cid, "thread_id": tid });
if let Err(e) = rt.invoke("openhuman.channel_web_cancel", params).await {
log::error!("[tui] openhuman.channel_web_cancel failed: {e}");
}
});
}
/// Create a fresh thread and switch the UI to it. Awaited inline (fast, local
/// SQLite write) so `ui.thread_id` can be updated with the result.
async fn new_thread(runtime: &Arc<CoreRuntime>, state: &mut TranscriptState, ui: &mut UiState) {
log::info!("[tui] creating new thread");
match runtime
.invoke("openhuman.threads_create_new", json!({}))
.await
.ok()
.and_then(|v| super::runner::extract_thread_id(&v))
{
Some(new_id) => {
ui.thread_id = new_id.clone();
ui.scroll_from_bottom = 0;
state.push_system(format!("Started a new thread · {new_id}"));
log::info!("[tui] switched to new thread {new_id}");
}
None => {
state.push_system("Could not create a new thread (see logs).".to_string());
log::error!("[tui] threads.create_new returned no thread id");
}
}
}
+52
View File
@@ -0,0 +1,52 @@
//! Terminal chat UI — the `openhuman tui` (alias `chat`) CLI subcommand.
//!
//! A [ratatui]-based terminal front-end onto the **same `web_chat` surface**
//! the desktop app drives (`openhuman.channel_web_chat` /
//! `openhuman.channel_web_cancel` +
//! [`web_chat::subscribe_web_channel_events`](crate::openhuman::web_chat::subscribe_web_channel_events)).
//! It boots the core in-process — no HTTP, no sockets — via
//! `CoreBuilder::new(HostKind::Cli).domains(DomainSet::full()).services(ServiceSet::none())`
//! and streams a live transcript in the terminal.
//!
//! ## Compile-time gate (`tui` feature)
//!
//! `pub mod tui;` is ALWAYS compiled — it is a facade (mirrors
//! [`mcp_server`](crate::openhuman::mcp_server)). The terminal driver, the
//! renderer, the reducer, and the event loop are gated behind the default-ON
//! `tui` Cargo feature; when it is off, [`stub`] mirrors the one surface an
//! always-compiled caller reaches — [`run_from_cli`] — with a build-fact
//! disabled-error body.
//!
//! The `"tui" | "chat"` arm in `src/core/cli.rs` is deliberately left
//! **un-`#[cfg]`'d**: in a slim build it resolves to [`stub::run_from_cli`],
//! which bails with a message naming the compile-time gate as the cause (so the
//! error reads like a build fact, not `unknown namespace: tui`). This is the
//! same reasoning documented on [`mcp_server::stub::run_stdio_from_cli`].
#[cfg(feature = "tui")]
mod app;
#[cfg(feature = "tui")]
mod render;
#[cfg(feature = "tui")]
mod runner;
#[cfg(feature = "tui")]
mod state;
#[cfg(feature = "tui")]
mod terminal;
#[cfg(feature = "tui")]
pub use runner::run_from_cli;
// State reducer is behaviour-only but has no terminal deps, so its tests run in
// the default (feature-on) build. Exported for the sibling submodules + tests.
#[cfg(feature = "tui")]
pub use state::{Entry, EntryKind, TranscriptState};
// ---------------------------------------------------------------------------
// Disabled facade — compiled only when the `tui` feature is OFF.
// ---------------------------------------------------------------------------
#[cfg(not(feature = "tui"))]
mod stub;
#[cfg(not(feature = "tui"))]
pub use stub::*;
+248
View File
@@ -0,0 +1,248 @@
//! Ratatui rendering for the terminal chat UI — pure view over
//! [`TranscriptState`] + [`UiState`]. No state mutation happens here.
//!
//! Layout (top → bottom):
//! * transcript viewport (fills remaining height, wraps + scrolls)
//! * single-line input box (bordered)
//! * status bar (thread id, turn state, key hints)
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
use unicode_width::UnicodeWidthStr;
use super::state::{EntryKind, TranscriptState};
/// Ocean accent from the design tokens (`#4A83DD`), kept terminal-native.
const OCEAN: Color = Color::Rgb(0x4A, 0x83, 0xDD);
const SPINNER_FRAMES: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];
/// View-only UI state owned by the event loop and read by [`draw`].
pub struct UiState {
/// Current input line contents.
pub input: String,
/// Lines scrolled up from the tail. `0` follows the newest content.
pub scroll_from_bottom: u16,
/// Monotonic tick used to animate the streaming spinner.
pub spinner_tick: usize,
/// The thread id shown in the status bar.
pub thread_id: String,
/// The client stream id (for the status bar, abbreviated).
pub client_id: String,
}
impl UiState {
pub fn new(thread_id: String, client_id: String) -> Self {
Self {
input: String::new(),
scroll_from_bottom: 0,
spinner_tick: 0,
thread_id,
client_id,
}
}
}
/// Draw one frame.
pub fn draw(frame: &mut Frame, state: &TranscriptState, ui: &UiState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), // transcript
Constraint::Length(3), // input box
Constraint::Length(1), // status bar
])
.split(frame.area());
draw_transcript(frame, chunks[0], state, ui);
draw_input(frame, chunks[1], ui);
draw_status(frame, chunks[2], state, ui);
}
fn draw_transcript(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" OpenHuman chat ")
.border_style(Style::default().fg(OCEAN));
let inner = block.inner(area);
let text = transcript_text(state);
// Inner width available for wrapping (borders eat 2 cols).
let wrap_width = inner.width.max(1);
let total_lines: u16 = text
.lines
.iter()
.map(|line| wrapped_line_count(line, wrap_width))
.sum::<u16>();
let viewport = inner.height.max(1);
let max_scroll = total_lines.saturating_sub(viewport);
let scroll_from_bottom = ui.scroll_from_bottom.min(max_scroll);
let top = max_scroll.saturating_sub(scroll_from_bottom);
let paragraph = Paragraph::new(text)
.block(block)
.wrap(Wrap { trim: false })
.scroll((top, 0));
frame.render_widget(paragraph, area);
}
fn draw_input(frame: &mut Frame, area: Rect, ui: &UiState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Message ")
.border_style(Style::default().fg(Color::DarkGray));
let inner_width = block.inner(area).width.max(1) as usize;
// Keep the caret end of a long input visible.
let display = tail_to_width(&ui.input, inner_width.saturating_sub(1));
let line = Line::from(vec![
Span::styled(display, Style::default().fg(Color::White)),
Span::styled("", Style::default().fg(OCEAN)),
]);
let paragraph = Paragraph::new(line).block(block);
frame.render_widget(paragraph, area);
}
fn draw_status(frame: &mut Frame, area: Rect, state: &TranscriptState, ui: &UiState) {
let turn = if state.is_streaming() {
let frame_ch = SPINNER_FRAMES[ui.spinner_tick % SPINNER_FRAMES.len()];
format!("{frame_ch} streaming")
} else {
"idle".to_string()
};
let thread_short = abbreviate(&ui.thread_id, 24);
let left = Span::styled(
format!(" thread {thread_short} · {turn} "),
Style::default().fg(Color::Black).bg(OCEAN),
);
let hints = Span::styled(
" Enter send · Esc cancel · Ctrl+N new · PgUp/PgDn scroll · Ctrl+C quit",
Style::default().fg(Color::DarkGray),
);
let paragraph = Paragraph::new(Line::from(vec![left, hints]));
frame.render_widget(paragraph, area);
}
/// Build the styled transcript body from the reducer state.
fn transcript_text(state: &TranscriptState) -> Text<'static> {
let mut lines: Vec<Line> = Vec::new();
for entry in state.entries() {
let (prefix, style) = match entry.kind {
EntryKind::User => (
"You ",
Style::default().fg(OCEAN).add_modifier(Modifier::BOLD),
),
EntryKind::Assistant => ("AI ", Style::default().fg(Color::White)),
EntryKind::Thinking => (
"··· ",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
),
EntryKind::Tool => ("tool ", Style::default().fg(Color::Yellow)),
EntryKind::Error => (
"err ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
EntryKind::System => (" ", Style::default().fg(Color::DarkGray)),
};
let mut first = true;
for raw in entry.text.split('\n') {
let gutter = if first { prefix } else { " " };
lines.push(Line::from(vec![
Span::styled(gutter.to_string(), style.add_modifier(Modifier::DIM)),
Span::styled(raw.to_string(), style),
]));
first = false;
}
// Blank spacer between entries for readability.
lines.push(Line::from(""));
}
Text::from(lines)
}
/// Approximate the number of visual rows a wrapped line occupies at `width`.
/// ratatui wraps on word boundaries; this display-width estimate is close
/// enough for scroll bookkeeping (off-by-one at most, harmless).
fn wrapped_line_count(line: &Line, width: u16) -> u16 {
let w = width.max(1) as usize;
let content_width: usize = line
.spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
if content_width == 0 {
1
} else {
content_width.div_ceil(w).max(1) as u16
}
}
/// Return the trailing slice of `s` that fits within `width` display columns.
fn tail_to_width(s: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
let mut out: Vec<char> = Vec::new();
let mut used = 0usize;
for ch in s.chars().rev() {
let cw = UnicodeWidthStr::width(ch.to_string().as_str()).max(1);
if used + cw > width {
break;
}
used += cw;
out.push(ch);
}
out.into_iter().rev().collect()
}
/// Middle-truncate an id to `max` columns (`abc…xyz`).
fn abbreviate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let keep = max.saturating_sub(1) / 2;
let head: String = s.chars().take(keep).collect();
let tail: String = s
.chars()
.rev()
.take(keep)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{head}{tail}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tail_to_width_keeps_the_end() {
assert_eq!(tail_to_width("hello world", 5), "world");
assert_eq!(tail_to_width("hi", 10), "hi");
assert_eq!(tail_to_width("anything", 0), "");
}
#[test]
fn abbreviate_middle_truncates_long_ids() {
let out = abbreviate("thread-0123456789abcdef", 11);
assert!(out.contains('…'));
assert!(out.chars().count() <= 11);
assert_eq!(abbreviate("short", 24), "short");
}
#[test]
fn wrapped_line_count_divides_by_width() {
let line = Line::from("a".repeat(25));
assert_eq!(wrapped_line_count(&line, 10), 3);
let empty = Line::from("");
assert_eq!(wrapped_line_count(&empty, 10), 1);
}
}
+224
View File
@@ -0,0 +1,224 @@
//! CLI entry point for the terminal chat UI (`openhuman tui` / `chat`).
//!
//! Parses flags, initializes **file-only** logging (the TUI owns the terminal —
//! see `logging::init_for_tui`), boots the core in-process with no transport and
//! no background services, resolves the target thread, and hands off to the
//! event loop in [`super::app`].
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::{json, Value};
use crate::core::runtime::{
CoreBuilder, CoreRuntime, DomainSet, ServiceSet, AGENT_WORKER_STACK_BYTES,
};
use crate::core::types::HostKind;
/// Entry point dispatched from the `"tui" | "chat"` arm in `src/core/cli.rs`.
///
/// Flags:
/// * `--thread <id>` — attach to an existing thread.
/// * `--new` — force a brand-new thread (default when `--thread` is absent).
/// * `-v` / `--verbose` — debug-level file logging.
pub fn run_from_cli(args: &[String]) -> anyhow::Result<()> {
let mut thread_id: Option<String> = None;
let mut force_new = false;
let mut verbose = false;
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--thread" => {
thread_id = Some(
args.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value for --thread"))?
.clone(),
);
i += 2;
}
"--new" => {
force_new = true;
i += 1;
}
"-v" | "--verbose" => {
verbose = true;
i += 1;
}
"-h" | "--help" => {
print_help();
return Ok(());
}
other => return Err(anyhow::anyhow!("unknown tui arg: {other}")),
}
}
// File-only logging — never stderr while the TUI owns the terminal.
let data_dir = resolve_data_dir();
let log_dir = crate::core::logging::init_for_tui(&data_dir, verbose);
log::info!(
"[tui] starting terminal chat UI (thread={:?} new={} logs={:?})",
thread_id,
force_new,
log_dir
);
// A chat turn is a large async state machine that can delegate to
// sub-agents; give the tokio workers the same roomy stack the server uses
// so a nested turn cannot overflow the default 2 MiB stack.
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_stack_size(AGENT_WORKER_STACK_BYTES)
.build()?;
rt.block_on(async_main(thread_id, force_new))
}
async fn async_main(thread_flag: Option<String>, force_new: bool) -> anyhow::Result<()> {
// In-process core: full domains (channel.web_chat needs DomainGroup::Channels,
// so harness() is not enough), no transport, no background services.
let runtime = Arc::new(
CoreBuilder::new(HostKind::Cli)
.domains(DomainSet::full())
.services(ServiceSet::none())
.build()
.await?,
);
log::info!("[tui] core built (DomainSet::full, ServiceSet::none)");
let client_id = format!("tui-{}", short_hex());
let thread_id = resolve_thread(&runtime, thread_flag, force_new).await?;
log::info!("[tui] resolved thread={thread_id} client_id={client_id}");
// Subscribe BEFORE the first turn so no streamed event is missed.
let web_rx = crate::openhuman::web_chat::subscribe_web_channel_events();
super::app::run(runtime, client_id, thread_id, web_rx).await
}
/// Resolve the thread to open: the `--thread` id (unless `--new`), otherwise a
/// freshly created thread.
async fn resolve_thread(
runtime: &CoreRuntime,
thread_flag: Option<String>,
force_new: bool,
) -> anyhow::Result<String> {
if let (Some(id), false) = (thread_flag.as_ref(), force_new) {
log::debug!("[tui] attaching to existing thread {id}");
return Ok(id.clone());
}
let created = runtime
.invoke("openhuman.threads_create_new", json!({}))
.await
.map_err(|e| anyhow::anyhow!("openhuman.threads_create_new failed: {e}"))?;
extract_thread_id(&created).ok_or_else(|| {
anyhow::anyhow!("openhuman.threads_create_new returned no thread id: {created}")
})
}
/// Pull a thread id out of a `threads.create_new` / `threads.list` response,
/// tolerant of the `RpcOutcome` log-envelope wrapping (`{result, logs}`) and the
/// `ApiEnvelope` data wrapping (`{data, meta}`).
pub(super) fn extract_thread_id(value: &Value) -> Option<String> {
// Unwrap the optional `{result, logs}` log envelope first.
let inner = value.get("result").unwrap_or(value);
// Then the optional `{data, meta}` ApiEnvelope.
let payload = inner.get("data").unwrap_or(inner);
payload
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
}
/// Resolve the OpenHuman data dir (host of `logs/`), mirroring the shell's
/// resolution: `OPENHUMAN_WORKSPACE` override, else `~/.openhuman`, else a temp
/// fallback. No `eprintln!` — the TUI is about to take the terminal.
fn resolve_data_dir() -> PathBuf {
if let Ok(workspace) = std::env::var("OPENHUMAN_WORKSPACE") {
if !workspace.is_empty() {
return PathBuf::from(workspace);
}
}
crate::openhuman::config::default_root_openhuman_dir()
.unwrap_or_else(|_| std::env::temp_dir().join("openhuman"))
}
/// 12 hex chars of randomness for the client stream id.
fn short_hex() -> String {
let u = uuid::Uuid::new_v4();
u.simple().to_string()[..12].to_string()
}
fn print_help() {
println!("Usage: openhuman tui [--thread <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!();
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!(" Ctrl+C / Ctrl+D quit.");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_thread_id_handles_bare_summary() {
let v = json!({ "id": "thread-1", "title": "x" });
assert_eq!(extract_thread_id(&v).as_deref(), Some("thread-1"));
}
#[test]
fn extract_thread_id_handles_api_envelope() {
let v = json!({ "data": { "id": "thread-2" }, "meta": {} });
assert_eq!(extract_thread_id(&v).as_deref(), Some("thread-2"));
}
#[test]
fn extract_thread_id_handles_log_envelope_around_api_envelope() {
let v = json!({
"result": { "data": { "id": "thread-3" }, "meta": {} },
"logs": ["created"]
});
assert_eq!(extract_thread_id(&v).as_deref(), Some("thread-3"));
}
#[test]
fn extract_thread_id_missing_returns_none() {
let v = json!({ "meta": {} });
assert_eq!(extract_thread_id(&v), None);
}
#[test]
fn short_hex_is_twelve_chars() {
assert_eq!(short_hex().len(), 12);
}
/// Regression guard: the RPC method names the TUI invokes must be the
/// canonical `openhuman.<namespace>_<function>` form that the registry
/// resolves — NOT the dotted `namespace.function` short form, which
/// `schema_for_rpc_method` does not recognise and which would make every
/// turn (and the launch-time thread creation) fail with "unknown method".
/// The dispatcher only rewrites a fixed set of legacy aliases; none of
/// these three are in that table, so the short form never resolves.
#[test]
fn tui_invokes_use_canonical_registered_rpc_method_names() {
for method in [
"openhuman.channel_web_chat",
"openhuman.channel_web_cancel",
"openhuman.threads_create_new",
] {
assert!(
crate::core::all::schema_for_rpc_method(method).is_some(),
"TUI invokes `{method}`, but it is not a registered RPC method — \
the terminal chat UI would fail with `unknown method: {method}`"
);
}
}
}
+491
View File
@@ -0,0 +1,491 @@
//! Pure, terminal-free transcript reducer for the terminal chat UI.
//!
//! [`TranscriptState`] is a plain data structure with **no ratatui / crossterm /
//! IO dependencies** — the renderer ([`super::render`]) reads it and the event
//! loop ([`super::app`]) mutates it, but the state transitions themselves live
//! here so they can be unit-tested without a terminal.
//!
//! The single entry point is [`TranscriptState::apply_event`], which folds a
//! [`WebChannelEvent`] (the same struct the desktop app receives over Socket.IO)
//! into the transcript. Events for a different `client_id` are ignored, so a
//! process-wide broadcast bus can be drained safely.
use crate::core::socketio::WebChannelEvent;
/// The kind of a transcript entry — drives colour / prefix in the renderer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
/// A message the local user sent.
User,
/// The assistant's streamed / final reply text.
Assistant,
/// The assistant's "thinking" (reasoning) stream — rendered dimmed.
Thinking,
/// A tool-call / tool-result status line.
Tool,
/// A terminal error (`chat_error`).
Error,
/// A local status/system note (never produced by `apply_event`).
System,
}
/// One line-group in the transcript. `text` accumulates across streaming deltas.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub kind: EntryKind,
pub text: String,
}
impl Entry {
fn new(kind: EntryKind, text: impl Into<String>) -> Self {
Self {
kind,
text: text.into(),
}
}
}
/// Accumulated transcript + streaming status for one chat client stream.
#[derive(Debug, Clone)]
pub struct TranscriptState {
/// Our stream identity. Events whose `client_id` differs are ignored.
client_id: String,
/// The rendered transcript, oldest first.
entries: Vec<Entry>,
/// True while a turn is in flight (between send and `chat_done`/`chat_error`).
streaming: bool,
/// Index into `entries` of the assistant entry currently accumulating text
/// deltas for the in-flight turn, if any.
cur_assistant: Option<usize>,
/// Index into `entries` of the thinking entry currently accumulating
/// thinking deltas for the in-flight turn, if any.
cur_thinking: Option<usize>,
}
impl TranscriptState {
/// Create an empty transcript bound to `client_id`.
pub fn new(client_id: impl Into<String>) -> Self {
Self {
client_id: client_id.into(),
entries: Vec::new(),
streaming: false,
cur_assistant: None,
cur_thinking: None,
}
}
/// The transcript entries, oldest first.
pub fn entries(&self) -> &[Entry] {
&self.entries
}
/// Whether a turn is currently streaming.
pub fn is_streaming(&self) -> bool {
self.streaming
}
/// Our client stream id.
pub fn client_id(&self) -> &str {
&self.client_id
}
/// Record a locally-sent user message and begin a new turn.
///
/// Resets the streaming cursors so the next `text_delta` / `thinking_delta`
/// opens fresh assistant / thinking entries for this turn.
pub fn begin_user_turn(&mut self, message: impl Into<String>) {
let text = message.into();
log::debug!("[tui] state: begin_user_turn len={}", text.len());
self.entries.push(Entry::new(EntryKind::User, text));
self.cur_assistant = None;
self.cur_thinking = None;
self.streaming = true;
}
/// Push a local system/status note (e.g. "Cancelled", connection info).
pub fn push_system(&mut self, text: impl Into<String>) {
let text = text.into();
log::debug!("[tui] state: push_system len={}", text.len());
self.entries.push(Entry::new(EntryKind::System, text));
}
/// Fold one [`WebChannelEvent`] into the transcript.
///
/// Events whose `client_id` does not match ours are ignored (the web-channel
/// bus is process-wide). Returns nothing; inspect [`Self::entries`] /
/// [`Self::is_streaming`] afterwards.
pub fn apply_event(&mut self, ev: &WebChannelEvent) {
if ev.client_id != self.client_id {
log::trace!(
"[tui] state: ignoring event={} for other client_id={}",
ev.event,
ev.client_id
);
return;
}
match ev.event.as_str() {
"text_delta" => {
if let Some(delta) = ev.delta.as_deref() {
self.append_assistant(delta);
}
}
"thinking_delta" => {
if let Some(delta) = ev.delta.as_deref() {
self.append_thinking(delta);
}
}
"tool_call" => {
let name = ev.tool_name.as_deref().unwrap_or("tool");
let args = ev.args.as_ref().map(summarize_json).unwrap_or_default();
log::debug!("[tui] state: tool_call {name}");
self.entries
.push(Entry::new(EntryKind::Tool, format!("{name}{args}")));
}
"tool_result" => {
let name = ev.tool_name.as_deref().unwrap_or("tool");
let ok = ev.success.unwrap_or(true);
let marker = if ok { "" } else { "" };
let detail = ev
.output
.as_deref()
.map(truncate_line)
.filter(|s| !s.is_empty())
.map(|s| format!("{s}"))
.unwrap_or_default();
log::debug!("[tui] state: tool_result {name} ok={ok}");
self.entries.push(Entry::new(
EntryKind::Tool,
format!("{marker} {name}{detail}"),
));
}
"chat_done" => {
log::debug!(
"[tui] state: chat_done full_response={}",
ev.full_response.is_some()
);
// `full_response` is authoritative — it replaces whatever the
// streamed text deltas accumulated (they can lag / be partial).
if let Some(full) = ev.full_response.as_deref() {
match self.cur_assistant {
Some(idx) => self.entries[idx].text = full.to_string(),
None => self
.entries
.push(Entry::new(EntryKind::Assistant, full.to_string())),
}
}
self.finish_turn();
}
"chat_error" => {
let msg = ev.message.as_deref().unwrap_or("Unknown error").to_string();
log::debug!("[tui] state: chat_error {msg}");
self.entries.push(Entry::new(EntryKind::Error, msg));
self.finish_turn();
}
other => {
log::trace!("[tui] state: unhandled event={other}");
}
}
}
fn append_assistant(&mut self, delta: &str) {
match self.cur_assistant {
Some(idx) => self.entries[idx].text.push_str(delta),
None => {
self.entries
.push(Entry::new(EntryKind::Assistant, delta.to_string()));
self.cur_assistant = Some(self.entries.len() - 1);
}
}
}
fn append_thinking(&mut self, delta: &str) {
match self.cur_thinking {
Some(idx) => self.entries[idx].text.push_str(delta),
None => {
self.entries
.push(Entry::new(EntryKind::Thinking, delta.to_string()));
self.cur_thinking = Some(self.entries.len() - 1);
}
}
}
fn finish_turn(&mut self) {
self.streaming = false;
self.cur_assistant = None;
self.cur_thinking = None;
}
}
/// One-line, length-capped summary of a JSON value for tool-call args display.
fn summarize_json(value: &serde_json::Value) -> String {
let rendered = match value {
serde_json::Value::Object(_) | serde_json::Value::Array(_) => value.to_string(),
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
format!("({})", truncate_line(&rendered))
}
/// Collapse to a single line and cap length so a rogue tool output can't blow
/// up the transcript width.
fn truncate_line(s: &str) -> String {
const MAX: usize = 120;
let single = s.replace(['\n', '\r'], " ");
let trimmed = single.trim();
if trimmed.chars().count() > MAX {
let cut: String = trimmed.chars().take(MAX).collect();
format!("{cut}")
} else {
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
const CLIENT: &str = "tui-abc123";
fn ev(event: &str) -> WebChannelEvent {
WebChannelEvent {
event: event.to_string(),
client_id: CLIENT.to_string(),
thread_id: "thread-1".to_string(),
..Default::default()
}
}
fn text_delta(delta: &str) -> WebChannelEvent {
WebChannelEvent {
delta: Some(delta.to_string()),
delta_kind: Some("text".to_string()),
..ev("text_delta")
}
}
fn thinking_delta(delta: &str) -> WebChannelEvent {
WebChannelEvent {
delta: Some(delta.to_string()),
delta_kind: Some("thinking".to_string()),
..ev("thinking_delta")
}
}
#[test]
fn text_deltas_accumulate_into_one_assistant_entry() {
let mut s = TranscriptState::new(CLIENT);
s.begin_user_turn("hi");
s.apply_event(&text_delta("Hel"));
s.apply_event(&text_delta("lo "));
s.apply_event(&text_delta("world"));
let assistant: Vec<_> = s
.entries()
.iter()
.filter(|e| e.kind == EntryKind::Assistant)
.collect();
assert_eq!(assistant.len(), 1, "deltas must fold into a single entry");
assert_eq!(assistant[0].text, "Hello world");
assert!(s.is_streaming(), "still streaming before chat_done");
}
#[test]
fn thinking_and_text_are_separate_entries() {
let mut s = TranscriptState::new(CLIENT);
s.begin_user_turn("q");
s.apply_event(&thinking_delta("let me think"));
s.apply_event(&text_delta("answer"));
let kinds: Vec<_> = s.entries().iter().map(|e| e.kind).collect();
assert_eq!(
kinds,
vec![EntryKind::User, EntryKind::Thinking, EntryKind::Assistant]
);
let thinking = s
.entries()
.iter()
.find(|e| e.kind == EntryKind::Thinking)
.unwrap();
assert_eq!(thinking.text, "let me think");
}
#[test]
fn thinking_deltas_accumulate_separately_from_text() {
let mut s = TranscriptState::new(CLIENT);
s.begin_user_turn("q");
s.apply_event(&thinking_delta("a"));
s.apply_event(&thinking_delta("b"));
s.apply_event(&text_delta("x"));
s.apply_event(&thinking_delta("c")); // interleaved — same thinking entry
let thinking: Vec<_> = s
.entries()
.iter()
.filter(|e| e.kind == EntryKind::Thinking)
.collect();
assert_eq!(thinking.len(), 1);
assert_eq!(thinking[0].text, "abc");
}
#[test]
fn chat_done_replaces_streamed_text_with_full_response() {
let mut s = TranscriptState::new(CLIENT);
s.begin_user_turn("hi");
s.apply_event(&text_delta("Hel")); // partial / laggy stream
let done = WebChannelEvent {
full_response: Some("Hello, world!".to_string()),
..ev("chat_done")
};
s.apply_event(&done);
let assistant: Vec<_> = s
.entries()
.iter()
.filter(|e| e.kind == EntryKind::Assistant)
.collect();
assert_eq!(assistant.len(), 1);
assert_eq!(
assistant[0].text, "Hello, world!",
"full_response is authoritative and replaces the streamed text"
);
assert!(!s.is_streaming(), "chat_done ends the turn");
}
#[test]
fn chat_done_without_prior_deltas_still_shows_full_response() {
let mut s = TranscriptState::new(CLIENT);
s.begin_user_turn("hi");
let done = WebChannelEvent {
full_response: Some("Direct answer".to_string()),
..ev("chat_done")
};
s.apply_event(&done);
let assistant = s
.entries()
.iter()
.find(|e| e.kind == EntryKind::Assistant)
.expect("chat_done with full_response must produce an assistant entry");
assert_eq!(assistant.text, "Direct answer");
assert!(!s.is_streaming());
}
#[test]
fn chat_error_pushes_error_entry_and_ends_stream() {
let mut s = TranscriptState::new(CLIENT);
s.begin_user_turn("hi");
let err = WebChannelEvent {
message: Some("rate limited".to_string()),
error_type: Some("rate_limit".to_string()),
..ev("chat_error")
};
s.apply_event(&err);
let error = s
.entries()
.iter()
.find(|e| e.kind == EntryKind::Error)
.expect("chat_error must produce an error entry");
assert_eq!(error.text, "rate limited");
assert!(!s.is_streaming(), "chat_error ends the turn");
}
#[test]
fn events_for_other_client_id_are_ignored() {
let mut s = TranscriptState::new(CLIENT);
s.begin_user_turn("hi");
let before = s.entries().len();
let foreign = WebChannelEvent {
client_id: "tui-someone-else".to_string(),
delta: Some("not mine".to_string()),
..WebChannelEvent {
event: "text_delta".to_string(),
thread_id: "thread-1".to_string(),
..Default::default()
}
};
s.apply_event(&foreign);
assert_eq!(
s.entries().len(),
before,
"foreign client_id events must not mutate our transcript"
);
}
#[test]
fn tool_call_and_result_produce_tool_entries() {
let mut s = TranscriptState::new(CLIENT);
s.begin_user_turn("do it");
let call = WebChannelEvent {
tool_name: Some("web_search".to_string()),
args: Some(serde_json::json!({"query": "rust ratatui"})),
..ev("tool_call")
};
s.apply_event(&call);
let result = WebChannelEvent {
tool_name: Some("web_search".to_string()),
success: Some(true),
output: Some("3 results".to_string()),
..ev("tool_result")
};
s.apply_event(&result);
let tools: Vec<_> = s
.entries()
.iter()
.filter(|e| e.kind == EntryKind::Tool)
.collect();
assert_eq!(tools.len(), 2);
assert!(tools[0].text.starts_with("→ web_search"));
assert!(tools[0].text.contains("rust ratatui"));
assert!(tools[1].text.starts_with("✓ web_search"));
assert!(tools[1].text.contains("3 results"));
}
#[test]
fn failed_tool_result_uses_cross_marker() {
let mut s = TranscriptState::new(CLIENT);
s.begin_user_turn("do it");
let result = WebChannelEvent {
tool_name: Some("run_shell".to_string()),
success: Some(false),
output: Some("exit code 1".to_string()),
..ev("tool_result")
};
s.apply_event(&result);
let tool = s
.entries()
.iter()
.find(|e| e.kind == EntryKind::Tool)
.unwrap();
assert!(tool.text.starts_with("✗ run_shell"));
}
#[test]
fn second_turn_opens_fresh_assistant_entry() {
let mut s = TranscriptState::new(CLIENT);
s.begin_user_turn("first");
s.apply_event(&text_delta("one"));
s.apply_event(&WebChannelEvent {
full_response: Some("one".to_string()),
..ev("chat_done")
});
s.begin_user_turn("second");
s.apply_event(&text_delta("two"));
let assistant: Vec<_> = s
.entries()
.iter()
.filter(|e| e.kind == EntryKind::Assistant)
.collect();
assert_eq!(assistant.len(), 2, "each turn gets its own assistant entry");
assert_eq!(assistant[0].text, "one");
assert_eq!(assistant[1].text, "two");
}
#[test]
fn truncate_line_collapses_newlines_and_caps_length() {
let long = "a\nb\n".repeat(200);
let out = truncate_line(&long);
assert!(!out.contains('\n'));
assert!(out.chars().count() <= 121, "capped to MAX + ellipsis");
}
}
+37
View File
@@ -0,0 +1,37 @@
//! Disabled-`tui` facade for [`super`] (the terminal chat UI).
//!
//! Compiled only when the `tui` Cargo feature is OFF (see the gate in
//! [`super`]). It mirrors the one public symbol always-compiled callers reach —
//! [`run_from_cli`] — with a disabled-error body.
//!
//! The signature MUST match the real one exactly (`&[String] -> anyhow::Result<()>`).
//! The disabled build
//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is the
//! only thing that catches drift.
/// Error text returned by the disabled path. Shared so callers / log-greps see
/// one stable string, and asserted by the disabled-build CLI tests.
const DISABLED_MSG: &str = "tui feature disabled at compile time";
/// Fails with a build-fact diagnostic instead of opening the terminal UI.
///
/// This is deliberately a stub rather than a `#[cfg]` on the `"tui" | "chat"`
/// match arm in `src/core/cli.rs`. Deleting the arm is the naive move and is
/// WRONG: the `tui` / `chat` token would fall through to generic namespace
/// resolution and die with `unknown namespace: tui`, which reads like the user
/// typo'd a command rather than like a deliberate property of this build.
/// Keeping the arm and failing here means the user gets a non-zero exit and a
/// one-line stderr diagnostic naming the fix, and `cli.rs` needs no `#[cfg]` at
/// all — the gate stays invisible to the transport layer.
///
/// Banner suppression in `cli.rs` is a `matches!` on the raw string, so it
/// keeps working here without touching a gated symbol.
pub fn run_from_cli(_args: &[String]) -> anyhow::Result<()> {
log::warn!(
"[tui] {DISABLED_MSG} — `openhuman tui`/`chat` rejected; rebuild with `--features tui`"
);
anyhow::bail!(
"{DISABLED_MSG}: this build was compiled without the `tui` feature, so the terminal \
chat UI is unavailable. Rebuild with `--features tui`."
)
}
+83
View File
@@ -0,0 +1,83 @@
//! Terminal setup / teardown with panic-safe restoration.
//!
//! Owning the terminal means switching to the alternate screen and enabling raw
//! mode; both **must** be undone on every exit path — normal return, `?`
//! propagation, and panic — or the user's shell is left in a broken state
//! (no echo, no line editing, stuck on the alternate screen). [`TerminalGuard`]
//! restores on `Drop`, and [`install_panic_hook`] chains a restore ahead of the
//! previous panic hook so the panic message prints to a sane terminal.
use std::io::{self, Stdout};
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
/// The concrete ratatui terminal type used by the TUI.
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
/// RAII guard that enters the alternate screen + raw mode on construction and
/// restores the terminal on drop.
pub struct TerminalGuard {
terminal: Tui,
}
impl TerminalGuard {
/// Enter the alternate screen, enable raw mode, install the panic hook, and
/// return a ready-to-draw terminal wrapped in a restoring guard.
pub fn enter() -> io::Result<Self> {
install_panic_hook();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend)?;
log::debug!("[tui] terminal: entered alternate screen + raw mode");
Ok(Self { terminal })
}
/// Mutable access to the underlying terminal for drawing.
pub fn terminal(&mut self) -> &mut Tui {
&mut self.terminal
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
if let Err(e) = restore() {
// The subscriber writes to a file (never the terminal), so this is
// safe to log here.
log::warn!("[tui] terminal: restore on drop failed: {e}");
} else {
log::debug!("[tui] terminal: restored on drop");
}
}
}
/// Undo everything [`TerminalGuard::enter`] did. Best-effort — each step is
/// attempted even if an earlier one fails, so a partial setup still gets torn
/// down as far as possible.
fn restore() -> io::Result<()> {
let mut stdout = io::stdout();
let _ = execute!(stdout, LeaveAlternateScreen, DisableMouseCapture);
disable_raw_mode()
}
/// Chain a terminal-restoring step in front of the process panic hook, so a
/// panic inside the render loop leaves the user with a usable terminal and a
/// readable backtrace instead of a garbled alternate screen.
///
/// Idempotent in effect: called once from [`TerminalGuard::enter`]. If it were
/// ever called twice, the second restore would simply be a no-op on an
/// already-restored terminal.
fn install_panic_hook() {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = restore();
original(info);
}));
}