Compare commits

...

4 Commits

Author SHA1 Message Date
SimianAstronaut7
4a2c327f7b
Merge branch 'master' into feature/terminal-ui 2026-03-12 12:48:29 +00:00
SimianAstronaut7
658a8eb744
Merge branch 'master' into feature/terminal-ui 2026-03-12 00:20:03 +00:00
argenis de la rosa
7dd8ae4b24 feat(agent): style interactive terminal UI with console crate
Suppress tracing logs (default to "error") in interactive agent mode so
INFO/WARN lines no longer pollute the chat. Add styled banner, prompt,
help, response, error, and compaction helpers using the console crate.
Print [tool] start/complete indicators in CLI mode matching the TUI
pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:14:14 -04:00
argenis de la rosa
d8c548e22b docs(bootstrap): clarify default install behavior for interactive TTY sessions
The one-click-bootstrap guide implied that `./install.sh` always does a
silent build+install. In practice, interactive terminals get the guided
installer with interactive onboarding automatically. Add a note making
this distinction explicit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:46:11 -04:00
3 changed files with 184 additions and 29 deletions

View File

@ -23,6 +23,11 @@ What it does by default:
1. `cargo build --release --locked` 1. `cargo build --release --locked`
2. `cargo install --path . --force --locked` 2. `cargo install --path . --force --locked`
> **Note:** In an interactive terminal (TTY), `./install.sh` automatically runs the
> guided installer with interactive onboarding (`--guided --interactive-onboard`).
> The silent build-and-install described above only applies when the script is run
> non-interactively (e.g., piped or in CI).
### Resource preflight and pre-built flow ### Resource preflight and pre-built flow
Source builds typically require at least: Source builds typically require at least:

View File

@ -11,6 +11,7 @@ use crate::security::SecurityPolicy;
use crate::tools::{self, Tool}; use crate::tools::{self, Tool};
use crate::util::truncate_with_ellipsis; use crate::util::truncate_with_ellipsis;
use anyhow::Result; use anyhow::Result;
use console::{style, Term};
use regex::{Regex, RegexSet}; use regex::{Regex, RegexSet};
use std::collections::HashSet; use std::collections::HashSet;
use std::fmt::Write; use std::fmt::Write;
@ -106,6 +107,69 @@ pub(crate) const PROGRESS_MIN_INTERVAL_MS: u64 = 500;
/// Used before streaming the final answer so progress lines are replaced by the clean response. /// Used before streaming the final answer so progress lines are replaced by the clean response.
pub(crate) const DRAFT_CLEAR_SENTINEL: &str = "\x00CLEAR\x00"; pub(crate) const DRAFT_CLEAR_SENTINEL: &str = "\x00CLEAR\x00";
// ── Interactive agent UI helpers ─────────────────────────────────────
fn agent_clear_screen() {
let _ = Term::stdout().clear_screen();
}
fn print_agent_banner(provider: &str, model: &str) {
println!(
"{}\n{} {} | {} {}\n{}\n",
style("ZeroClaw Agent").color256(39).bold(),
style("provider:").dim(),
style(provider).bold(),
style("model:").dim(),
style(model).bold(),
style("Type /help for commands.").dim(),
);
}
fn print_agent_prompt() {
print!("{} ", style("agent>").color256(45).bold());
let _ = std::io::stdout().flush();
}
fn print_agent_help() {
println!(
"{}\n {} Show this help message\n {} Clear conversation history\n {} Exit interactive mode\n",
style("Commands").color256(45).bold(),
style("/help").bold(),
style("/clear /new").bold(),
style("/quit /exit").bold(),
);
}
fn print_agent_response(response: &str) {
println!("\n{} {}\n", style("ZeroClaw").color256(39).bold(), response,);
}
fn print_agent_error(error: &str) {
eprintln!("{} {}", style("Error:").red().bold(), style(error).red());
}
fn print_agent_compaction() {
println!("{}", style("Context compacted.").dim());
}
fn print_tool_start(tool_name: &str) {
println!(
"{} {}",
style("[tool]").color256(111).bold(),
style(tool_name).bold(),
);
}
fn print_tool_complete(tool_name: &str, success: bool) {
let icon = if success { "done" } else { "failed" };
println!(
"{} {} {}",
style("[tool]").color256(111).bold(),
style(tool_name).bold(),
style(icon).dim(),
);
}
/// Extract a short hint from tool call arguments for progress display. /// Extract a short hint from tool call arguments for progress display.
fn truncate_tool_args_for_progress(name: &str, args: &serde_json::Value, max_len: usize) -> String { fn truncate_tool_args_for_progress(name: &str, args: &serde_json::Value, max_len: usize) -> String {
let hint = match name { let hint = match name {
@ -2622,6 +2686,8 @@ pub(crate) async fn run_tool_call_loop(
}; };
tracing::debug!(tool = %tool_name, "Sending progress start to draft"); tracing::debug!(tool = %tool_name, "Sending progress start to draft");
let _ = tx.send(progress).await; let _ = tx.send(progress).await;
} else if !silent {
print_tool_start(&tool_name);
} }
executable_indices.push(idx); executable_indices.push(idx);
@ -2693,6 +2759,8 @@ pub(crate) async fn run_tool_call_loop(
}; };
tracing::debug!(tool = %call.name, secs, "Sending progress complete to draft"); tracing::debug!(tool = %call.name, secs, "Sending progress complete to draft");
let _ = tx.send(format!("{icon} {} ({secs}s)\n", call.name)).await; let _ = tx.send(format!("{icon} {} ({secs}s)\n", call.name)).await;
} else if !silent {
print_tool_complete(&call.name, outcome.success);
} }
ordered_results[*idx] = Some((call.name.clone(), call.tool_call_id.clone(), outcome)); ordered_results[*idx] = Some((call.name.clone(), call.tool_call_id.clone(), outcome));
@ -3114,23 +3182,21 @@ pub async fn run(
println!("{response}"); println!("{response}");
observer.record_event(&ObserverEvent::TurnComplete); observer.record_event(&ObserverEvent::TurnComplete);
} else { } else {
println!("🦀 ZeroClaw Interactive Mode"); agent_clear_screen();
println!("Type /help for commands.\n"); print_agent_banner(provider_name, model_name);
let cli = crate::channels::CliChannel::new();
// Persistent conversation history across turns // Persistent conversation history across turns
let mut history = vec![ChatMessage::system(&system_prompt)]; let mut history = vec![ChatMessage::system(&system_prompt)];
loop { loop {
print!("> "); print_agent_prompt();
let _ = std::io::stdout().flush();
let mut input = String::new(); let mut input = String::new();
match std::io::stdin().read_line(&mut input) { match std::io::stdin().read_line(&mut input) {
Ok(0) => break, Ok(0) => break,
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
eprintln!("\nError reading input: {e}\n"); print_agent_error(&format!("reading input: {e}"));
break; break;
} }
} }
@ -3140,20 +3206,28 @@ pub async fn run(
continue; continue;
} }
match user_input.as_str() { match user_input.as_str() {
"/quit" | "/exit" => break, "/quit" | "/exit" => {
println!("{}", style("Closing ZeroClaw agent.").dim());
break;
}
"/help" => { "/help" => {
println!("Available commands:"); print_agent_help();
println!(" /help Show this help message");
println!(" /clear /new Clear conversation history");
println!(" /quit /exit Exit interactive mode\n");
continue; continue;
} }
"/clear" | "/new" => { "/clear" | "/new" => {
println!( println!(
"This will clear the current conversation and delete all session memory." "{}",
style(
"This will clear the current conversation and delete all session memory."
)
.yellow()
); );
println!("Core memories (long-term facts/preferences) will be preserved."); println!(
print!("Continue? [y/N] "); "{}",
style("Core memories (long-term facts/preferences) will be preserved.")
.dim()
);
print!("{} ", style("Continue? [y/N]").bold());
let _ = std::io::stdout().flush(); let _ = std::io::stdout().flush();
let mut confirm = String::new(); let mut confirm = String::new();
@ -3161,7 +3235,7 @@ pub async fn run(
continue; continue;
} }
if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") { if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") {
println!("Cancelled.\n"); println!("{}", style("Cancelled.").dim());
continue; continue;
} }
@ -3178,9 +3252,15 @@ pub async fn run(
} }
} }
if cleared > 0 { if cleared > 0 {
println!("Conversation cleared ({cleared} memory entries removed).\n"); println!(
"{}",
style(format!(
"Conversation cleared ({cleared} memory entries removed)."
))
.green()
);
} else { } else {
println!("Conversation cleared.\n"); println!("{}", style("Conversation cleared.").green());
} }
continue; continue;
} }
@ -3235,19 +3315,12 @@ pub async fn run(
{ {
Ok(resp) => resp, Ok(resp) => resp,
Err(e) => { Err(e) => {
eprintln!("\nError: {e}\n"); print_agent_error(&e.to_string());
continue; continue;
} }
}; };
final_output = response.clone(); final_output = response.clone();
if let Err(e) = crate::channels::Channel::send( print_agent_response(&response);
&cli,
&crate::channels::traits::SendMessage::new(format!("\n{response}\n"), "user"),
)
.await
{
eprintln!("\nError sending CLI response: {e}\n");
}
observer.record_event(&ObserverEvent::TurnComplete); observer.record_event(&ObserverEvent::TurnComplete);
// Auto-compaction before hard trimming to preserve long-context signal. // Auto-compaction before hard trimming to preserve long-context signal.
@ -3260,7 +3333,7 @@ pub async fn run(
.await .await
{ {
if compacted { if compacted {
println!("🧹 Auto-compaction complete"); print_agent_compaction();
} }
} }

View File

@ -77,6 +77,7 @@ mod service;
mod skillforge; mod skillforge;
mod skills; mod skills;
mod tools; mod tools;
mod tui;
mod tunnel; mod tunnel;
mod util; mod util;
@ -213,6 +214,39 @@ Examples:
gateway_command: Option<zeroclaw::GatewayCommands>, gateway_command: Option<zeroclaw::GatewayCommands>,
}, },
/// Open the terminal UI connected to the gateway
#[command(long_about = "\
Open the terminal UI connected to the gateway.
Connects to the ZeroClaw gateway websocket chat endpoint and opens a
clean terminal-first chat session with blue ZeroClaw styling.
If --token is not provided, ZeroClaw reuses the first paired gateway token
from your config when one is available.
Examples:
zeroclaw tui
zeroclaw tui --url ws://127.0.0.1:42617 --token <token>
zeroclaw tui --session main --deliver
zeroclaw tui --url https://zeroclawlabs.ai --session prod")]
Tui {
/// Gateway websocket or base URL; defaults to ws://<gateway.host>:<gateway.port>/ws/chat
#[arg(long)]
url: Option<String>,
/// Gateway bearer token; falls back to first paired token from config when available
#[arg(long)]
token: Option<String>,
/// Session label to show in the TUI and forward to the gateway metadata
#[arg(long)]
session: Option<String>,
/// Forward delivery intent metadata for downstream gateways that honor it
#[arg(long)]
deliver: bool,
},
/// Start long-running autonomous runtime (gateway + channels + heartbeat + scheduler) /// Start long-running autonomous runtime (gateway + channels + heartbeat + scheduler)
#[command(long_about = "\ #[command(long_about = "\
Start the long-running autonomous daemon. Start the long-running autonomous daemon.
@ -678,10 +712,15 @@ async fn main() -> Result<()> {
return Ok(()); return Ok(());
} }
// Initialize logging - respects RUST_LOG env var, defaults to INFO // Initialize logging - respects RUST_LOG env var.
// In interactive agent mode, default to "error" so tracing logs don't pollute the chat.
let default_filter = match &cli.command {
Commands::Agent { message: None, .. } => "error",
_ => "info",
};
let subscriber = fmt::Subscriber::builder() let subscriber = fmt::Subscriber::builder()
.with_env_filter( .with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter)),
) )
.finish(); .finish();
@ -930,6 +969,13 @@ async fn main() -> Result<()> {
} }
} }
Commands::Tui {
url,
token,
session,
deliver,
} => tui::run_tui(&config, url, token, session, deliver).await,
Commands::Daemon { port, host } => { Commands::Daemon { port, host } => {
let port = port.unwrap_or(config.gateway.port); let port = port.unwrap_or(config.gateway.port);
let host = host.unwrap_or_else(|| config.gateway.host.clone()); let host = host.unwrap_or_else(|| config.gateway.host.clone());
@ -2158,6 +2204,37 @@ mod tests {
} }
} }
#[test]
fn tui_cli_accepts_gateway_examples() {
let cli = Cli::try_parse_from([
"zeroclaw",
"tui",
"--url",
"ws://127.0.0.1:18789",
"--token",
"zc-token",
"--session",
"main",
"--deliver",
])
.expect("tui invocation should parse");
match cli.command {
Commands::Tui {
url,
token,
session,
deliver,
} => {
assert_eq!(url.as_deref(), Some("ws://127.0.0.1:18789"));
assert_eq!(token.as_deref(), Some("zc-token"));
assert_eq!(session.as_deref(), Some("main"));
assert!(deliver);
}
other => panic!("expected tui command, got {other:?}"),
}
}
#[test] #[test]
fn cli_parses_estop_default_engage() { fn cli_parses_estop_default_engage() {
let cli = Cli::try_parse_from(["zeroclaw", "estop"]).expect("estop command should parse"); let cli = Cli::try_parse_from(["zeroclaw", "estop"]).expect("estop command should parse");