mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 22:23:01 +00:00
feat(tui): feature-gated terminal chat UI (openhuman tui / chat) (#5084)
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
+22
-1
@@ -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"]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Plan: `openhuman tui` — feature-gated terminal chat UI
|
||||
|
||||
## Goal
|
||||
|
||||
Running `openhuman-core tui` (alias `chat`) opens a ratatui-based terminal UI that is an
|
||||
interface into the general chat (the same `web_chat` surface the desktop app uses),
|
||||
gated behind a Cargo feature `tui`.
|
||||
|
||||
## Architecture decisions (grounded in current code)
|
||||
|
||||
1. **Cargo feature**: `tui = ["dep:ratatui", "dep:crossterm"]`, added to `default`
|
||||
(repo convention: gates default-ON). It must NOT ship in the desktop app —
|
||||
add `'tui': 'Terminal UI subcommand; the desktop app ships its own Tauri UI.'`
|
||||
to `INTENTIONALLY_NOT_FORWARDED` in `scripts/ci/check-feature-forwarding.mjs`.
|
||||
2. **Module**: new domain dir `src/openhuman/tui/` using the **mcp/voice facade pattern**:
|
||||
- `mod.rs` always compiled; real submodules `#[cfg(feature = "tui")]`;
|
||||
`#[cfg(not(feature = "tui"))] mod stub;` exposing the same `run_from_cli`.
|
||||
- `stub.rs` `run_from_cli` bails with
|
||||
`"tui feature disabled at compile time … rebuild with --features tui"`
|
||||
(mirror `src/openhuman/mcp_server/stub.rs:42`).
|
||||
- No controllers, no agent tools, no `all.rs` changes (leaf client, like `flows`'
|
||||
philosophy: absence, not degraded registration — but here the only outside
|
||||
touch-point is the CLI arm, which uses the stub for a build-fact error).
|
||||
3. **CLI arm**: in `src/core/cli.rs` match (~line 63), add
|
||||
`"tui" | "chat" => crate::openhuman::tui::run_from_cli(&args[1..])`.
|
||||
Arm stays **un-cfg'd** (mcp precedent). Add `"tui" | "chat"` to the banner-suppression
|
||||
`matches!` at lines 48–50 (a TUI must own the terminal).
|
||||
4. **In-process core, no HTTP**: build a multi-thread tokio runtime with
|
||||
`AGENT_WORKER_STACK_BYTES` stack (copy `run_server_command` shape, cli.rs:219–311),
|
||||
then `CoreBuilder::new(HostKind::Cli).domains(DomainSet::full()).services(ServiceSet::none()).build()`.
|
||||
`channel.web_chat` needs `DomainGroup::Channels`, so `harness()` is not enough.
|
||||
5. **Chat flow**:
|
||||
- `client_id = "tui-<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.
|
||||
@@ -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
@@ -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)");
|
||||
|
||||
@@ -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`"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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`."
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user