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`
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
Source builds typically require at least:

View File

@ -11,6 +11,7 @@ use crate::security::SecurityPolicy;
use crate::tools::{self, Tool};
use crate::util::truncate_with_ellipsis;
use anyhow::Result;
use console::{style, Term};
use regex::{Regex, RegexSet};
use std::collections::HashSet;
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.
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.
fn truncate_tool_args_for_progress(name: &str, args: &serde_json::Value, max_len: usize) -> String {
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");
let _ = tx.send(progress).await;
} else if !silent {
print_tool_start(&tool_name);
}
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");
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));
@ -3114,23 +3182,21 @@ pub async fn run(
println!("{response}");
observer.record_event(&ObserverEvent::TurnComplete);
} else {
println!("🦀 ZeroClaw Interactive Mode");
println!("Type /help for commands.\n");
let cli = crate::channels::CliChannel::new();
agent_clear_screen();
print_agent_banner(provider_name, model_name);
// Persistent conversation history across turns
let mut history = vec![ChatMessage::system(&system_prompt)];
loop {
print!("> ");
let _ = std::io::stdout().flush();
print_agent_prompt();
let mut input = String::new();
match std::io::stdin().read_line(&mut input) {
Ok(0) => break,
Ok(_) => {}
Err(e) => {
eprintln!("\nError reading input: {e}\n");
print_agent_error(&format!("reading input: {e}"));
break;
}
}
@ -3140,20 +3206,28 @@ pub async fn run(
continue;
}
match user_input.as_str() {
"/quit" | "/exit" => break,
"/quit" | "/exit" => {
println!("{}", style("Closing ZeroClaw agent.").dim());
break;
}
"/help" => {
println!("Available commands:");
println!(" /help Show this help message");
println!(" /clear /new Clear conversation history");
println!(" /quit /exit Exit interactive mode\n");
print_agent_help();
continue;
}
"/clear" | "/new" => {
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.");
print!("Continue? [y/N] ");
println!(
"{}",
style("Core memories (long-term facts/preferences) will be preserved.")
.dim()
);
print!("{} ", style("Continue? [y/N]").bold());
let _ = std::io::stdout().flush();
let mut confirm = String::new();
@ -3161,7 +3235,7 @@ pub async fn run(
continue;
}
if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") {
println!("Cancelled.\n");
println!("{}", style("Cancelled.").dim());
continue;
}
@ -3178,9 +3252,15 @@ pub async fn run(
}
}
if cleared > 0 {
println!("Conversation cleared ({cleared} memory entries removed).\n");
println!(
"{}",
style(format!(
"Conversation cleared ({cleared} memory entries removed)."
))
.green()
);
} else {
println!("Conversation cleared.\n");
println!("{}", style("Conversation cleared.").green());
}
continue;
}
@ -3235,19 +3315,12 @@ pub async fn run(
{
Ok(resp) => resp,
Err(e) => {
eprintln!("\nError: {e}\n");
print_agent_error(&e.to_string());
continue;
}
};
final_output = response.clone();
if let Err(e) = crate::channels::Channel::send(
&cli,
&crate::channels::traits::SendMessage::new(format!("\n{response}\n"), "user"),
)
.await
{
eprintln!("\nError sending CLI response: {e}\n");
}
print_agent_response(&response);
observer.record_event(&ObserverEvent::TurnComplete);
// Auto-compaction before hard trimming to preserve long-context signal.
@ -3260,7 +3333,7 @@ pub async fn run(
.await
{
if compacted {
println!("🧹 Auto-compaction complete");
print_agent_compaction();
}
}

View File

@ -77,6 +77,7 @@ mod service;
mod skillforge;
mod skills;
mod tools;
mod tui;
mod tunnel;
mod util;
@ -213,6 +214,39 @@ Examples:
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)
#[command(long_about = "\
Start the long-running autonomous daemon.
@ -678,10 +712,15 @@ async fn main() -> Result<()> {
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()
.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();
@ -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 } => {
let port = port.unwrap_or(config.gateway.port);
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]
fn cli_parses_estop_default_engage() {
let cli = Cli::try_parse_from(["zeroclaw", "estop"]).expect("estop command should parse");