初始化提交
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
This commit is contained in:
33
crates/openfang-cli/Cargo.toml
Normal file
33
crates/openfang-cli/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "openfang-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "CLI tool for the OpenFang Agent OS"
|
||||
|
||||
[[bin]]
|
||||
name = "openfang"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
openfang-types = { path = "../openfang-types" }
|
||||
openfang-kernel = { path = "../openfang-kernel" }
|
||||
openfang-api = { path = "../openfang-api" }
|
||||
openfang-migrate = { path = "../openfang-migrate" }
|
||||
openfang-skills = { path = "../openfang-skills" }
|
||||
openfang-extensions = { path = "../openfang-extensions" }
|
||||
zeroize = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
clap_complete = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["blocking"] }
|
||||
openfang-runtime = { path = "../openfang-runtime" }
|
||||
uuid = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
colored = { workspace = true }
|
||||
56
crates/openfang-cli/src/bundled_agents.rs
Normal file
56
crates/openfang-cli/src/bundled_agents.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! Compile-time embedded agent templates.
|
||||
//!
|
||||
//! All 30 bundled agent templates are embedded into the binary via `include_str!`.
|
||||
//! This ensures `openfang agent new` works immediately after install — no filesystem
|
||||
//! discovery needed.
|
||||
|
||||
/// Returns all bundled agent templates as `(name, toml_content)` pairs.
|
||||
pub fn bundled_agents() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
("analyst", include_str!("../../../agents/analyst/agent.toml")),
|
||||
("architect", include_str!("../../../agents/architect/agent.toml")),
|
||||
("assistant", include_str!("../../../agents/assistant/agent.toml")),
|
||||
("coder", include_str!("../../../agents/coder/agent.toml")),
|
||||
("code-reviewer", include_str!("../../../agents/code-reviewer/agent.toml")),
|
||||
("customer-support", include_str!("../../../agents/customer-support/agent.toml")),
|
||||
("data-scientist", include_str!("../../../agents/data-scientist/agent.toml")),
|
||||
("debugger", include_str!("../../../agents/debugger/agent.toml")),
|
||||
("devops-lead", include_str!("../../../agents/devops-lead/agent.toml")),
|
||||
("doc-writer", include_str!("../../../agents/doc-writer/agent.toml")),
|
||||
("email-assistant", include_str!("../../../agents/email-assistant/agent.toml")),
|
||||
("health-tracker", include_str!("../../../agents/health-tracker/agent.toml")),
|
||||
("hello-world", include_str!("../../../agents/hello-world/agent.toml")),
|
||||
("home-automation", include_str!("../../../agents/home-automation/agent.toml")),
|
||||
("legal-assistant", include_str!("../../../agents/legal-assistant/agent.toml")),
|
||||
("meeting-assistant", include_str!("../../../agents/meeting-assistant/agent.toml")),
|
||||
("ops", include_str!("../../../agents/ops/agent.toml")),
|
||||
("orchestrator", include_str!("../../../agents/orchestrator/agent.toml")),
|
||||
("personal-finance", include_str!("../../../agents/personal-finance/agent.toml")),
|
||||
("planner", include_str!("../../../agents/planner/agent.toml")),
|
||||
("recruiter", include_str!("../../../agents/recruiter/agent.toml")),
|
||||
("researcher", include_str!("../../../agents/researcher/agent.toml")),
|
||||
("sales-assistant", include_str!("../../../agents/sales-assistant/agent.toml")),
|
||||
("security-auditor", include_str!("../../../agents/security-auditor/agent.toml")),
|
||||
("social-media", include_str!("../../../agents/social-media/agent.toml")),
|
||||
("test-engineer", include_str!("../../../agents/test-engineer/agent.toml")),
|
||||
("translator", include_str!("../../../agents/translator/agent.toml")),
|
||||
("travel-planner", include_str!("../../../agents/travel-planner/agent.toml")),
|
||||
("tutor", include_str!("../../../agents/tutor/agent.toml")),
|
||||
("writer", include_str!("../../../agents/writer/agent.toml")),
|
||||
]
|
||||
}
|
||||
|
||||
/// Install bundled agent templates to `~/.openfang/agents/`.
|
||||
/// Skips any template that already exists on disk (user customization preserved).
|
||||
pub fn install_bundled_agents(agents_dir: &std::path::Path) {
|
||||
for (name, content) in bundled_agents() {
|
||||
let dest_dir = agents_dir.join(name);
|
||||
let dest_file = dest_dir.join("agent.toml");
|
||||
if dest_file.exists() {
|
||||
continue; // Preserve user customization
|
||||
}
|
||||
if std::fs::create_dir_all(&dest_dir).is_ok() {
|
||||
let _ = std::fs::write(&dest_file, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
241
crates/openfang-cli/src/dotenv.rs
Normal file
241
crates/openfang-cli/src/dotenv.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
//! Minimal `.env` file loader/saver for `~/.openfang/.env`.
|
||||
//!
|
||||
//! No external crate needed — hand-rolled for simplicity.
|
||||
//! Format: `KEY=VALUE` lines, `#` comments, optional quotes.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Return the path to `~/.openfang/.env`.
|
||||
pub fn env_file_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".openfang").join(".env"))
|
||||
}
|
||||
|
||||
/// Load `~/.openfang/.env` and `~/.openfang/secrets.env` into `std::env`.
|
||||
///
|
||||
/// System env vars take priority — existing vars are NOT overridden.
|
||||
/// `secrets.env` is loaded second so `.env` values take priority over secrets
|
||||
/// (but both yield to system env vars).
|
||||
/// Silently does nothing if the files don't exist.
|
||||
pub fn load_dotenv() {
|
||||
load_env_file(env_file_path());
|
||||
// Also load secrets.env (written by dashboard "Set API Key" button)
|
||||
load_env_file(secrets_env_path());
|
||||
}
|
||||
|
||||
/// Return the path to `~/.openfang/secrets.env`.
|
||||
pub fn secrets_env_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".openfang").join("secrets.env"))
|
||||
}
|
||||
|
||||
fn load_env_file(path: Option<PathBuf>) {
|
||||
let path = match path {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((key, value)) = parse_env_line(trimmed) {
|
||||
if std::env::var(&key).is_err() {
|
||||
std::env::set_var(&key, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upsert a key in `~/.openfang/.env`.
|
||||
///
|
||||
/// Creates the file if missing. Sets 0600 permissions on Unix.
|
||||
/// Also sets the key in the current process environment.
|
||||
pub fn save_env_key(key: &str, value: &str) -> Result<(), String> {
|
||||
let path = env_file_path().ok_or("Could not determine home directory")?;
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
|
||||
}
|
||||
|
||||
let mut entries = read_env_file(&path);
|
||||
entries.insert(key.to_string(), value.to_string());
|
||||
write_env_file(&path, &entries)?;
|
||||
|
||||
// Also set in current process
|
||||
std::env::set_var(key, value);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a key from `~/.openfang/.env`.
|
||||
///
|
||||
/// Also removes it from the current process environment.
|
||||
pub fn remove_env_key(key: &str) -> Result<(), String> {
|
||||
let path = env_file_path().ok_or("Could not determine home directory")?;
|
||||
|
||||
let mut entries = read_env_file(&path);
|
||||
entries.remove(key);
|
||||
write_env_file(&path, &entries)?;
|
||||
|
||||
std::env::remove_var(key);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List key names (without values) from `~/.openfang/.env`.
|
||||
#[allow(dead_code)]
|
||||
pub fn list_env_keys() -> Vec<String> {
|
||||
let path = match env_file_path() {
|
||||
Some(p) => p,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
read_env_file(&path).into_keys().collect()
|
||||
}
|
||||
|
||||
/// Check if the `.env` file exists.
|
||||
#[allow(dead_code)]
|
||||
pub fn env_file_exists() -> bool {
|
||||
env_file_path().map(|p| p.exists()).unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a single `KEY=VALUE` line. Handles optional quotes.
|
||||
fn parse_env_line(line: &str) -> Option<(String, String)> {
|
||||
let eq_pos = line.find('=')?;
|
||||
let key = line[..eq_pos].trim().to_string();
|
||||
let mut value = line[eq_pos + 1..].trim().to_string();
|
||||
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Strip matching quotes
|
||||
if ((value.starts_with('"') && value.ends_with('"'))
|
||||
|| (value.starts_with('\'') && value.ends_with('\'')))
|
||||
&& value.len() >= 2
|
||||
{
|
||||
value = value[1..value.len() - 1].to_string();
|
||||
}
|
||||
|
||||
Some((key, value))
|
||||
}
|
||||
|
||||
/// Read all key-value pairs from the .env file.
|
||||
fn read_env_file(path: &PathBuf) -> BTreeMap<String, String> {
|
||||
let mut map = BTreeMap::new();
|
||||
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return map,
|
||||
};
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = parse_env_line(trimmed) {
|
||||
map.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
/// Write key-value pairs back to the .env file with a header comment.
|
||||
fn write_env_file(path: &PathBuf, entries: &BTreeMap<String, String>) -> Result<(), String> {
|
||||
let mut content =
|
||||
String::from("# OpenFang environment — managed by `openfang config set-key`\n");
|
||||
content.push_str("# Do not edit while the daemon is running.\n\n");
|
||||
|
||||
for (key, value) in entries {
|
||||
// Quote values that contain spaces or special characters
|
||||
if value.contains(' ') || value.contains('#') || value.contains('"') {
|
||||
content.push_str(&format!("{key}=\"{}\"\n", value.replace('"', "\\\"")));
|
||||
} else {
|
||||
content.push_str(&format!("{key}={value}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::write(path, &content).map_err(|e| format!("Failed to write .env file: {e}"))?;
|
||||
|
||||
// Set 0600 permissions on Unix
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_simple() {
|
||||
let (k, v) = parse_env_line("FOO=bar").unwrap();
|
||||
assert_eq!(k, "FOO");
|
||||
assert_eq!(v, "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_quoted() {
|
||||
let (k, v) = parse_env_line("KEY=\"hello world\"").unwrap();
|
||||
assert_eq!(k, "KEY");
|
||||
assert_eq!(v, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_single_quoted() {
|
||||
let (k, v) = parse_env_line("KEY='value'").unwrap();
|
||||
assert_eq!(k, "KEY");
|
||||
assert_eq!(v, "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_spaces() {
|
||||
let (k, v) = parse_env_line(" KEY = value ").unwrap();
|
||||
assert_eq!(k, "KEY");
|
||||
assert_eq!(v, "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_no_value() {
|
||||
let (k, v) = parse_env_line("KEY=").unwrap();
|
||||
assert_eq!(k, "KEY");
|
||||
assert_eq!(v, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_comment() {
|
||||
assert!(
|
||||
parse_env_line("# comment").is_none()
|
||||
|| parse_env_line("# comment").unwrap().0.starts_with('#')
|
||||
);
|
||||
// Comments are filtered before reaching parse_env_line in production code
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_no_equals() {
|
||||
assert!(parse_env_line("NOEQUALS").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_env_line_empty_key() {
|
||||
assert!(parse_env_line("=value").is_none());
|
||||
}
|
||||
}
|
||||
600
crates/openfang-cli/src/launcher.rs
Normal file
600
crates/openfang-cli/src/launcher.rs
Normal file
@@ -0,0 +1,600 @@
|
||||
//! Interactive launcher — lightweight Ratatui one-shot menu.
|
||||
//!
|
||||
//! Shown when `openfang` is run with no subcommand in a TTY.
|
||||
//! Full-width left-aligned layout, adapts for first-time vs returning users.
|
||||
|
||||
use ratatui::crossterm::event::{self, Event as CtEvent, KeyCode, KeyEventKind};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
|
||||
|
||||
use crate::tui::theme;
|
||||
use crate::ui;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
// ── Provider detection ──────────────────────────────────────────────────────
|
||||
|
||||
const PROVIDER_ENV_VARS: &[(&str, &str)] = &[
|
||||
("ANTHROPIC_API_KEY", "Anthropic"),
|
||||
("OPENAI_API_KEY", "OpenAI"),
|
||||
("DEEPSEEK_API_KEY", "DeepSeek"),
|
||||
("GEMINI_API_KEY", "Gemini"),
|
||||
("GOOGLE_API_KEY", "Gemini"),
|
||||
("GROQ_API_KEY", "Groq"),
|
||||
("OPENROUTER_API_KEY", "OpenRouter"),
|
||||
("TOGETHER_API_KEY", "Together"),
|
||||
("MISTRAL_API_KEY", "Mistral"),
|
||||
("FIREWORKS_API_KEY", "Fireworks"),
|
||||
];
|
||||
|
||||
fn detect_provider() -> Option<(&'static str, &'static str)> {
|
||||
for &(var, name) in PROVIDER_ENV_VARS {
|
||||
if std::env::var(var).is_ok() {
|
||||
return Some((name, var));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_first_run() -> bool {
|
||||
let home = match dirs::home_dir() {
|
||||
Some(h) => h,
|
||||
None => return true,
|
||||
};
|
||||
!home.join(".openfang").join("config.toml").exists()
|
||||
}
|
||||
|
||||
fn has_openclaw() -> bool {
|
||||
// Quick check: does ~/.openclaw exist?
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".openclaw").exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LauncherChoice {
|
||||
GetStarted,
|
||||
Chat,
|
||||
Dashboard,
|
||||
DesktopApp,
|
||||
TerminalUI,
|
||||
ShowHelp,
|
||||
Quit,
|
||||
}
|
||||
|
||||
struct MenuItem {
|
||||
label: &'static str,
|
||||
hint: &'static str,
|
||||
choice: LauncherChoice,
|
||||
}
|
||||
|
||||
// Menu for first-time users: "Get started" is first and prominent
|
||||
const MENU_FIRST_RUN: &[MenuItem] = &[
|
||||
MenuItem {
|
||||
label: "Get started",
|
||||
hint: "Providers, API keys, models, migration",
|
||||
choice: LauncherChoice::GetStarted,
|
||||
},
|
||||
MenuItem {
|
||||
label: "Chat with an agent",
|
||||
hint: "Quick chat in the terminal",
|
||||
choice: LauncherChoice::Chat,
|
||||
},
|
||||
MenuItem {
|
||||
label: "Open dashboard",
|
||||
hint: "Launch the web UI in your browser",
|
||||
choice: LauncherChoice::Dashboard,
|
||||
},
|
||||
MenuItem {
|
||||
label: "Open desktop app",
|
||||
hint: "Launch the native desktop app",
|
||||
choice: LauncherChoice::DesktopApp,
|
||||
},
|
||||
MenuItem {
|
||||
label: "Launch terminal UI",
|
||||
hint: "Full interactive TUI dashboard",
|
||||
choice: LauncherChoice::TerminalUI,
|
||||
},
|
||||
MenuItem {
|
||||
label: "Show all commands",
|
||||
hint: "Print full --help output",
|
||||
choice: LauncherChoice::ShowHelp,
|
||||
},
|
||||
];
|
||||
|
||||
// Menu for returning users: action-first, setup at the bottom
|
||||
const MENU_RETURNING: &[MenuItem] = &[
|
||||
MenuItem {
|
||||
label: "Chat with an agent",
|
||||
hint: "Quick chat in the terminal",
|
||||
choice: LauncherChoice::Chat,
|
||||
},
|
||||
MenuItem {
|
||||
label: "Open dashboard",
|
||||
hint: "Launch the web UI in your browser",
|
||||
choice: LauncherChoice::Dashboard,
|
||||
},
|
||||
MenuItem {
|
||||
label: "Launch terminal UI",
|
||||
hint: "Full interactive TUI dashboard",
|
||||
choice: LauncherChoice::TerminalUI,
|
||||
},
|
||||
MenuItem {
|
||||
label: "Open desktop app",
|
||||
hint: "Launch the native desktop app",
|
||||
choice: LauncherChoice::DesktopApp,
|
||||
},
|
||||
MenuItem {
|
||||
label: "Settings",
|
||||
hint: "Providers, API keys, models, routing",
|
||||
choice: LauncherChoice::GetStarted,
|
||||
},
|
||||
MenuItem {
|
||||
label: "Show all commands",
|
||||
hint: "Print full --help output",
|
||||
choice: LauncherChoice::ShowHelp,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Launcher state ──────────────────────────────────────────────────────────
|
||||
|
||||
struct LauncherState {
|
||||
list: ListState,
|
||||
daemon_url: Option<String>,
|
||||
daemon_agents: u64,
|
||||
detecting: bool,
|
||||
tick: usize,
|
||||
first_run: bool,
|
||||
openclaw_detected: bool,
|
||||
}
|
||||
|
||||
impl LauncherState {
|
||||
fn new() -> Self {
|
||||
let first_run = is_first_run();
|
||||
let openclaw_detected = first_run && has_openclaw();
|
||||
let mut list = ListState::default();
|
||||
list.select(Some(0));
|
||||
Self {
|
||||
list,
|
||||
daemon_url: None,
|
||||
daemon_agents: 0,
|
||||
detecting: true,
|
||||
tick: 0,
|
||||
first_run,
|
||||
openclaw_detected,
|
||||
}
|
||||
}
|
||||
|
||||
fn menu(&self) -> &'static [MenuItem] {
|
||||
if self.first_run {
|
||||
MENU_FIRST_RUN
|
||||
} else {
|
||||
MENU_RETURNING
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entry point ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn run(_config: Option<PathBuf>) -> LauncherChoice {
|
||||
let mut terminal = ratatui::init();
|
||||
|
||||
// Panic hook: restore terminal on panic (set AFTER init succeeds)
|
||||
let original_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let _ = ratatui::try_restore();
|
||||
original_hook(info);
|
||||
}));
|
||||
|
||||
let mut state = LauncherState::new();
|
||||
|
||||
// Spawn background daemon detection (catch_unwind protects against thread panics)
|
||||
let (daemon_tx, daemon_rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
let result = crate::find_daemon();
|
||||
let agent_count = result.as_ref().map_or(0, |base| {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.ok();
|
||||
client
|
||||
.and_then(|c| c.get(format!("{base}/api/agents")).send().ok())
|
||||
.and_then(|r| r.json::<serde_json::Value>().ok())
|
||||
.and_then(|v| v.as_array().map(|a| a.len() as u64))
|
||||
.unwrap_or(0)
|
||||
});
|
||||
let _ = daemon_tx.send((result, agent_count));
|
||||
});
|
||||
});
|
||||
|
||||
let choice;
|
||||
|
||||
loop {
|
||||
// Check for daemon detection result
|
||||
if state.detecting {
|
||||
if let Ok((url, agents)) = daemon_rx.try_recv() {
|
||||
state.daemon_url = url;
|
||||
state.daemon_agents = agents;
|
||||
state.detecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
state.tick = state.tick.wrapping_add(1);
|
||||
|
||||
// Draw (gracefully handle render failures)
|
||||
if terminal.draw(|frame| draw(frame, &mut state)).is_err() {
|
||||
choice = LauncherChoice::Quit;
|
||||
break;
|
||||
}
|
||||
|
||||
// Poll for input (50ms = 20fps spinner)
|
||||
if event::poll(Duration::from_millis(50)).unwrap_or(false) {
|
||||
if let Ok(CtEvent::Key(key)) = event::read() {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
let menu = state.menu();
|
||||
if menu.is_empty() {
|
||||
choice = LauncherChoice::Quit;
|
||||
break;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => {
|
||||
choice = LauncherChoice::Quit;
|
||||
break;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
let i = state.list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { menu.len() - 1 } else { i - 1 };
|
||||
state.list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let i = state.list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % menu.len();
|
||||
state.list.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(i) = state.list.selected() {
|
||||
if i < menu.len() {
|
||||
choice = menu[i].choice;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = ratatui::try_restore();
|
||||
choice
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Left margin for content alignment.
|
||||
const MARGIN_LEFT: u16 = 3;
|
||||
|
||||
/// Constrain content to a readable area within the terminal.
|
||||
fn content_area(area: Rect) -> Rect {
|
||||
if area.width < 10 || area.height < 5 {
|
||||
// Terminal too small — use full area with no margin
|
||||
return area;
|
||||
}
|
||||
let margin = MARGIN_LEFT.min(area.width.saturating_sub(10));
|
||||
let w = 80u16.min(area.width.saturating_sub(margin));
|
||||
Rect {
|
||||
x: area.x.saturating_add(margin),
|
||||
y: area.y,
|
||||
width: w,
|
||||
height: area.height,
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(frame: &mut ratatui::Frame, state: &mut LauncherState) {
|
||||
let area = frame.area();
|
||||
|
||||
// Fill background
|
||||
frame.render_widget(
|
||||
ratatui::widgets::Block::default().style(Style::default().bg(theme::BG_PRIMARY)),
|
||||
area,
|
||||
);
|
||||
|
||||
let content = content_area(area);
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let has_provider = detect_provider().is_some();
|
||||
let menu = state.menu();
|
||||
|
||||
// Compute dynamic heights
|
||||
let header_h: u16 = if state.first_run { 3 } else { 1 }; // welcome text or just title
|
||||
let status_h: u16 = if state.detecting {
|
||||
1
|
||||
} else if has_provider {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
};
|
||||
let migration_hint_h: u16 = if state.first_run && state.openclaw_detected {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let menu_h = menu.len() as u16;
|
||||
|
||||
let total_needed = 1 + header_h + 1 + status_h + 1 + menu_h + migration_hint_h + 1;
|
||||
|
||||
// Vertical centering: place content block in the upper-third area
|
||||
let top_pad = if area.height > total_needed + 2 {
|
||||
((area.height - total_needed) / 3).max(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(top_pad), // top space
|
||||
Constraint::Length(header_h), // header / welcome
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Length(status_h), // status indicators
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Length(menu_h), // menu items
|
||||
Constraint::Length(migration_hint_h), // openclaw migration hint (if any)
|
||||
Constraint::Length(1), // keybind hints
|
||||
Constraint::Min(0), // remaining space
|
||||
])
|
||||
.split(content);
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────────────────
|
||||
if state.first_run {
|
||||
let header_lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
"OpenFang",
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" v{version}"),
|
||||
Style::default().fg(theme::TEXT_TERTIARY),
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"Welcome! Let's get you set up.",
|
||||
Style::default().fg(theme::TEXT_PRIMARY),
|
||||
)]),
|
||||
];
|
||||
frame.render_widget(Paragraph::new(header_lines), chunks[1]);
|
||||
} else {
|
||||
let header = Line::from(vec![
|
||||
Span::styled(
|
||||
"OpenFang",
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" v{version}"),
|
||||
Style::default().fg(theme::TEXT_TERTIARY),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(header), chunks[1]);
|
||||
}
|
||||
|
||||
// ── Separator ───────────────────────────────────────────────────────────
|
||||
render_separator(frame, chunks[2]);
|
||||
|
||||
// ── Status block ────────────────────────────────────────────────────────
|
||||
if state.detecting {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
let line = Line::from(vec![
|
||||
Span::styled(format!("{spinner} "), Style::default().fg(theme::YELLOW)),
|
||||
Span::styled("Checking for daemon\u{2026}", theme::dim_style()),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), chunks[3]);
|
||||
} else {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Daemon status
|
||||
if let Some(ref url) = state.daemon_url {
|
||||
let agent_suffix = if state.daemon_agents > 0 {
|
||||
format!(
|
||||
" ({} agent{})",
|
||||
state.daemon_agents,
|
||||
if state.daemon_agents == 1 { "" } else { "s" }
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"\u{25cf} ",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!("Daemon running at {url}"),
|
||||
Style::default().fg(theme::TEXT_PRIMARY),
|
||||
),
|
||||
Span::styled(agent_suffix, Style::default().fg(theme::GREEN)),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("\u{25cb} ", theme::dim_style()),
|
||||
Span::styled("No daemon running", theme::dim_style()),
|
||||
]));
|
||||
}
|
||||
|
||||
// Provider status
|
||||
if let Some((provider, env_var)) = detect_provider() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"\u{2714} ",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!("Provider: {provider}"),
|
||||
Style::default().fg(theme::TEXT_PRIMARY),
|
||||
),
|
||||
Span::styled(format!(" ({env_var})"), theme::dim_style()),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("\u{25cb} ", Style::default().fg(theme::YELLOW)),
|
||||
Span::styled("No API keys detected", Style::default().fg(theme::YELLOW)),
|
||||
]));
|
||||
if !state.first_run {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
" Run 'Re-run setup' to configure a provider",
|
||||
theme::hint_style(),
|
||||
)]));
|
||||
} else {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
" Select 'Get started' to configure",
|
||||
theme::hint_style(),
|
||||
)]));
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), chunks[3]);
|
||||
}
|
||||
|
||||
// ── Separator 2 ─────────────────────────────────────────────────────────
|
||||
render_separator(frame, chunks[4]);
|
||||
|
||||
// ── Menu ────────────────────────────────────────────────────────────────
|
||||
let items: Vec<ListItem> = menu
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
// Highlight "Get started" for first-run users
|
||||
let is_primary = state.first_run && i == 0;
|
||||
let label_style = if is_primary {
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme::TEXT_PRIMARY)
|
||||
};
|
||||
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(format!("{:<26}", item.label), label_style),
|
||||
Span::styled(item.hint, theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.bg(theme::BG_HOVER)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("\u{25b8} ");
|
||||
|
||||
frame.render_stateful_widget(list, chunks[5], &mut state.list);
|
||||
|
||||
// ── OpenClaw migration hint ─────────────────────────────────────────────
|
||||
if state.first_run && state.openclaw_detected {
|
||||
let hint_lines = vec![
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("\u{2192} ", Style::default().fg(theme::BLUE)),
|
||||
Span::styled("Coming from OpenClaw? ", Style::default().fg(theme::BLUE)),
|
||||
Span::styled(
|
||||
"'Get started' includes automatic migration.",
|
||||
theme::hint_style(),
|
||||
),
|
||||
]),
|
||||
];
|
||||
frame.render_widget(Paragraph::new(hint_lines), chunks[6]);
|
||||
}
|
||||
|
||||
// ── Keybind hints ───────────────────────────────────────────────────────
|
||||
let hints = Line::from(vec![Span::styled(
|
||||
"\u{2191}\u{2193} navigate enter select q quit",
|
||||
theme::hint_style(),
|
||||
)]);
|
||||
frame.render_widget(Paragraph::new(hints), chunks[7]);
|
||||
}
|
||||
|
||||
fn render_separator(frame: &mut ratatui::Frame, area: Rect) {
|
||||
let w = (area.width as usize).min(60);
|
||||
let line = Line::from(vec![Span::styled(
|
||||
"\u{2500}".repeat(w),
|
||||
Style::default().fg(theme::BORDER),
|
||||
)]);
|
||||
frame.render_widget(Paragraph::new(line), area);
|
||||
}
|
||||
|
||||
// ── Desktop app launcher ────────────────────────────────────────────────────
|
||||
|
||||
pub fn launch_desktop_app() {
|
||||
let desktop_bin = {
|
||||
let exe = std::env::current_exe().ok();
|
||||
let dir = exe.as_ref().and_then(|e| e.parent());
|
||||
|
||||
#[cfg(windows)]
|
||||
let name = "openfang-desktop.exe";
|
||||
#[cfg(not(windows))]
|
||||
let name = "openfang-desktop";
|
||||
|
||||
// Check sibling of current exe first
|
||||
let sibling = dir.map(|d| d.join(name));
|
||||
|
||||
match sibling {
|
||||
Some(ref path) if path.exists() => sibling,
|
||||
_ => which_lookup(name),
|
||||
}
|
||||
};
|
||||
|
||||
match desktop_bin {
|
||||
Some(ref path) if path.exists() => {
|
||||
match std::process::Command::new(path)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(_) => {
|
||||
ui::success("Desktop app launched.");
|
||||
}
|
||||
Err(e) => {
|
||||
ui::error_with_fix(
|
||||
&format!("Failed to launch desktop app: {e}"),
|
||||
"Build it: cargo build -p openfang-desktop",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui::error_with_fix(
|
||||
"Desktop app not found",
|
||||
"Build it: cargo build -p openfang-desktop",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple PATH lookup for a binary name.
|
||||
fn which_lookup(name: &str) -> Option<PathBuf> {
|
||||
let path_var = std::env::var("PATH").ok()?;
|
||||
let separator = if cfg!(windows) { ';' } else { ':' };
|
||||
for dir in path_var.split(separator) {
|
||||
let candidate = PathBuf::from(dir).join(name);
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
5663
crates/openfang-cli/src/main.rs
Normal file
5663
crates/openfang-cli/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
426
crates/openfang-cli/src/mcp.rs
Normal file
426
crates/openfang-cli/src/mcp.rs
Normal file
@@ -0,0 +1,426 @@
|
||||
//! MCP (Model Context Protocol) server for OpenFang.
|
||||
//!
|
||||
//! Exposes running agents as MCP tools over JSON-RPC 2.0 stdio.
|
||||
//! Each agent becomes a callable tool named `openfang_agent_{name}`.
|
||||
//!
|
||||
//! Protocol: Content-Length framing over stdin/stdout.
|
||||
//! Connects to running daemon via HTTP, falls back to in-process kernel.
|
||||
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
/// Backend for MCP: either a running daemon or an in-process kernel.
|
||||
enum McpBackend {
|
||||
Daemon {
|
||||
base_url: String,
|
||||
client: reqwest::blocking::Client,
|
||||
},
|
||||
InProcess {
|
||||
kernel: Box<OpenFangKernel>,
|
||||
rt: tokio::runtime::Runtime,
|
||||
},
|
||||
}
|
||||
|
||||
impl McpBackend {
|
||||
fn list_agents(&self) -> Vec<(String, String, String)> {
|
||||
// Returns (id, name, description) triples
|
||||
match self {
|
||||
McpBackend::Daemon { base_url, client } => {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/api/agents"))
|
||||
.send()
|
||||
.ok()
|
||||
.and_then(|r| r.json::<Value>().ok());
|
||||
match resp.and_then(|v| v.as_array().cloned()) {
|
||||
Some(agents) => agents
|
||||
.iter()
|
||||
.map(|a| {
|
||||
(
|
||||
a["id"].as_str().unwrap_or("").to_string(),
|
||||
a["name"].as_str().unwrap_or("").to_string(),
|
||||
a["description"].as_str().unwrap_or("").to_string(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
McpBackend::InProcess { kernel, .. } => kernel
|
||||
.registry
|
||||
.list()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e.id.to_string(),
|
||||
e.name.clone(),
|
||||
e.manifest.description.clone(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_message(&self, agent_id: &str, message: &str) -> Result<String, String> {
|
||||
match self {
|
||||
McpBackend::Daemon { base_url, client } => {
|
||||
let resp = client
|
||||
.post(format!("{base_url}/api/agents/{agent_id}/message"))
|
||||
.json(&json!({"message": message}))
|
||||
.send()
|
||||
.map_err(|e| format!("HTTP error: {e}"))?;
|
||||
let body: Value = resp.json().map_err(|e| format!("Parse error: {e}"))?;
|
||||
if let Some(response) = body["response"].as_str() {
|
||||
Ok(response.to_string())
|
||||
} else {
|
||||
Err(body["error"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown error")
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
McpBackend::InProcess { kernel, rt } => {
|
||||
let aid: openfang_types::agent::AgentId =
|
||||
agent_id.parse().map_err(|_| "Invalid agent ID")?;
|
||||
let result = rt
|
||||
.block_on(kernel.send_message(aid, message))
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
Ok(result.response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find agent ID by tool name (strip `openfang_agent_` prefix, match by name).
|
||||
fn resolve_tool_agent(&self, tool_name: &str) -> Option<String> {
|
||||
let agent_name = tool_name.strip_prefix("openfang_agent_")?.replace('_', "-");
|
||||
let agents = self.list_agents();
|
||||
// Try exact match first (with underscores replaced by hyphens)
|
||||
for (id, name, _) in &agents {
|
||||
if name.replace(' ', "-").to_lowercase() == agent_name.to_lowercase() {
|
||||
return Some(id.clone());
|
||||
}
|
||||
}
|
||||
// Try with underscores
|
||||
let agent_name_underscore = tool_name.strip_prefix("openfang_agent_")?;
|
||||
for (id, name, _) in &agents {
|
||||
if name.replace('-', "_").to_lowercase() == agent_name_underscore.to_lowercase() {
|
||||
return Some(id.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the MCP server over stdio.
|
||||
pub fn run_mcp_server(config: Option<std::path::PathBuf>) {
|
||||
let backend = create_backend(config);
|
||||
|
||||
let stdin = io::stdin();
|
||||
let stdout = io::stdout();
|
||||
let mut reader = stdin.lock();
|
||||
let mut writer = stdout.lock();
|
||||
|
||||
loop {
|
||||
match read_message(&mut reader) {
|
||||
Ok(Some(msg)) => {
|
||||
let response = handle_message(&backend, &msg);
|
||||
if let Some(resp) = response {
|
||||
write_message(&mut writer, &resp);
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_backend(config: Option<std::path::PathBuf>) -> McpBackend {
|
||||
// Try daemon first
|
||||
if let Some(base_url) = super::find_daemon() {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client");
|
||||
return McpBackend::Daemon { base_url, client };
|
||||
}
|
||||
|
||||
// Fall back to in-process kernel
|
||||
let kernel = match OpenFangKernel::boot(config.as_deref()) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to boot kernel for MCP: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
|
||||
McpBackend::InProcess {
|
||||
kernel: Box::new(kernel),
|
||||
rt,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a Content-Length framed JSON-RPC message from the reader.
|
||||
fn read_message(reader: &mut impl BufRead) -> io::Result<Option<Value>> {
|
||||
// Read headers until empty line
|
||||
let mut content_length: usize = 0;
|
||||
loop {
|
||||
let mut header = String::new();
|
||||
let bytes_read = reader.read_line(&mut header)?;
|
||||
if bytes_read == 0 {
|
||||
return Ok(None); // EOF
|
||||
}
|
||||
|
||||
let trimmed = header.trim();
|
||||
if trimmed.is_empty() {
|
||||
break; // End of headers
|
||||
}
|
||||
|
||||
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
|
||||
content_length = len_str.parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
if content_length == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// SECURITY: Reject oversized messages to prevent OOM.
|
||||
const MAX_MCP_MESSAGE_SIZE: usize = 10 * 1024 * 1024; // 10MB
|
||||
if content_length > MAX_MCP_MESSAGE_SIZE {
|
||||
// Drain the oversized body to avoid stream desync
|
||||
let mut discard = [0u8; 4096];
|
||||
let mut remaining = content_length;
|
||||
while remaining > 0 {
|
||||
let to_read = remaining.min(4096);
|
||||
if reader.read_exact(&mut discard[..to_read]).is_err() {
|
||||
break;
|
||||
}
|
||||
remaining -= to_read;
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("MCP message too large: {content_length} bytes (max {MAX_MCP_MESSAGE_SIZE})"),
|
||||
));
|
||||
}
|
||||
|
||||
// Read the body
|
||||
let mut body = vec![0u8; content_length];
|
||||
reader.read_exact(&mut body)?;
|
||||
|
||||
match serde_json::from_slice(&body) {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a Content-Length framed JSON-RPC response to the writer.
|
||||
fn write_message(writer: &mut impl Write, msg: &Value) {
|
||||
let body = serde_json::to_string(msg).unwrap_or_default();
|
||||
let _ = write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body);
|
||||
let _ = writer.flush();
|
||||
}
|
||||
|
||||
/// Handle a JSON-RPC message and return an optional response.
|
||||
fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
let method = msg["method"].as_str().unwrap_or("");
|
||||
let id = msg.get("id").cloned();
|
||||
|
||||
match method {
|
||||
"initialize" => {
|
||||
let result = json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "openfang",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
});
|
||||
Some(jsonrpc_response(id?, result))
|
||||
}
|
||||
|
||||
"notifications/initialized" => None, // Notification, no response
|
||||
|
||||
"tools/list" => {
|
||||
let agents = backend.list_agents();
|
||||
let tools: Vec<Value> = agents
|
||||
.iter()
|
||||
.map(|(_, name, description)| {
|
||||
let tool_name = format!("openfang_agent_{}", name.replace('-', "_"));
|
||||
let desc = if description.is_empty() {
|
||||
format!("Send a message to OpenFang agent '{name}'")
|
||||
} else {
|
||||
description.clone()
|
||||
};
|
||||
json!({
|
||||
"name": tool_name,
|
||||
"description": desc,
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Message to send to the agent"
|
||||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(jsonrpc_response(id?, json!({ "tools": tools })))
|
||||
}
|
||||
|
||||
"tools/call" => {
|
||||
let params = &msg["params"];
|
||||
let tool_name = params["name"].as_str().unwrap_or("");
|
||||
let message = params["arguments"]["message"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if message.is_empty() {
|
||||
return Some(jsonrpc_error(id?, -32602, "Missing 'message' argument"));
|
||||
}
|
||||
|
||||
let agent_id = match backend.resolve_tool_agent(tool_name) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Some(jsonrpc_error(
|
||||
id?,
|
||||
-32602,
|
||||
&format!("Unknown tool: {tool_name}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
match backend.send_message(&agent_id, &message) {
|
||||
Ok(response) => Some(jsonrpc_response(
|
||||
id?,
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": response
|
||||
}]
|
||||
}),
|
||||
)),
|
||||
Err(e) => Some(jsonrpc_response(
|
||||
id?,
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Error: {e}")
|
||||
}],
|
||||
"isError": true
|
||||
}),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
// Unknown method
|
||||
id.map(|id| jsonrpc_error(id, -32601, &format!("Method not found: {method}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn jsonrpc_response(id: Value, result: Value) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result
|
||||
})
|
||||
}
|
||||
|
||||
fn jsonrpc_error(id: Value, code: i32, message: &str) -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_handle_initialize() {
|
||||
let msg = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
});
|
||||
// We can't easily create a backend in tests without a kernel,
|
||||
// but we can test the protocol handling
|
||||
let backend = McpBackend::Daemon {
|
||||
base_url: "http://localhost:9999".to_string(),
|
||||
client: reqwest::blocking::Client::new(),
|
||||
};
|
||||
let resp = handle_message(&backend, &msg).unwrap();
|
||||
assert_eq!(resp["id"], 1);
|
||||
assert_eq!(resp["result"]["protocolVersion"], "2024-11-05");
|
||||
assert_eq!(resp["result"]["serverInfo"]["name"], "openfang");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_notifications_initialized() {
|
||||
let msg = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized"
|
||||
});
|
||||
let backend = McpBackend::Daemon {
|
||||
base_url: "http://localhost:9999".to_string(),
|
||||
client: reqwest::blocking::Client::new(),
|
||||
};
|
||||
let resp = handle_message(&backend, &msg);
|
||||
assert!(resp.is_none()); // No response for notifications
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_unknown_method() {
|
||||
let msg = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "unknown/method"
|
||||
});
|
||||
let backend = McpBackend::Daemon {
|
||||
base_url: "http://localhost:9999".to_string(),
|
||||
client: reqwest::blocking::Client::new(),
|
||||
};
|
||||
let resp = handle_message(&backend, &msg).unwrap();
|
||||
assert_eq!(resp["error"]["code"], -32601);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jsonrpc_response() {
|
||||
let resp = jsonrpc_response(json!(1), json!({"status": "ok"}));
|
||||
assert_eq!(resp["jsonrpc"], "2.0");
|
||||
assert_eq!(resp["id"], 1);
|
||||
assert_eq!(resp["result"]["status"], "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jsonrpc_error() {
|
||||
let resp = jsonrpc_error(json!(2), -32601, "Not found");
|
||||
assert_eq!(resp["jsonrpc"], "2.0");
|
||||
assert_eq!(resp["id"], 2);
|
||||
assert_eq!(resp["error"]["code"], -32601);
|
||||
assert_eq!(resp["error"]["message"], "Not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_message() {
|
||||
let body = r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#;
|
||||
let input = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
|
||||
let mut reader = io::BufReader::new(input.as_bytes());
|
||||
let msg = read_message(&mut reader).unwrap().unwrap();
|
||||
assert_eq!(msg["method"], "initialize");
|
||||
assert_eq!(msg["id"], 1);
|
||||
}
|
||||
}
|
||||
322
crates/openfang-cli/src/progress.rs
Normal file
322
crates/openfang-cli/src/progress.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! Progress bars and spinners for CLI output.
|
||||
//!
|
||||
//! Uses raw ANSI escape sequences (no external dependency). Supports:
|
||||
//! - Percentage progress bar with visual block characters
|
||||
//! - Spinner with label
|
||||
//! - OSC 9;4 terminal progress protocol (ConEmu/Windows Terminal/iTerm2)
|
||||
//! - Delay suppression for fast operations
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Default progress bar width (in characters).
|
||||
const DEFAULT_BAR_WIDTH: usize = 30;
|
||||
|
||||
/// Minimum elapsed time before showing progress output. Operations that
|
||||
/// complete faster than this threshold produce no visual noise.
|
||||
const DELAY_SUPPRESS_MS: u64 = 200;
|
||||
|
||||
/// Block characters for the progress bar.
|
||||
const FILLED: char = '\u{2588}'; // █
|
||||
const EMPTY: char = '\u{2591}'; // ░
|
||||
|
||||
/// Spinner animation frames.
|
||||
const SPINNER_FRAMES: &[char] = &[
|
||||
'\u{280b}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283c}', '\u{2834}', '\u{2826}', '\u{2827}',
|
||||
'\u{2807}', '\u{280f}',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OSC 9;4 progress protocol
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Emit an OSC 9;4 progress sequence (supported by Windows Terminal, ConEmu,
|
||||
/// iTerm2). `state`: 1 = set progress, 2 = error, 3 = indeterminate, 0 = clear.
|
||||
fn osc_progress(state: u8, percent: u8) {
|
||||
// ESC ] 9 ; 4 ; state ; percent ST
|
||||
// ST = ESC \ (string terminator)
|
||||
let _ = write!(io::stderr(), "\x1b]9;4;{state};{percent}\x1b\\");
|
||||
let _ = io::stderr().flush();
|
||||
}
|
||||
|
||||
/// Clear the OSC 9;4 progress indicator.
|
||||
fn osc_progress_clear() {
|
||||
osc_progress(0, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProgressBar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A simple percentage-based progress bar.
|
||||
///
|
||||
/// ```text
|
||||
/// Downloading [████████████░░░░░░░░░░░░░░░░░░] 40% (4/10)
|
||||
/// ```
|
||||
pub struct ProgressBar {
|
||||
label: String,
|
||||
total: u64,
|
||||
current: u64,
|
||||
width: usize,
|
||||
start: Instant,
|
||||
suppress_until: Duration,
|
||||
visible: bool,
|
||||
use_osc: bool,
|
||||
}
|
||||
|
||||
impl ProgressBar {
|
||||
/// Create a new progress bar.
|
||||
///
|
||||
/// `label`: text shown before the bar.
|
||||
/// `total`: the 100% value.
|
||||
pub fn new(label: &str, total: u64) -> Self {
|
||||
Self {
|
||||
label: label.to_string(),
|
||||
total: total.max(1),
|
||||
current: 0,
|
||||
width: DEFAULT_BAR_WIDTH,
|
||||
start: Instant::now(),
|
||||
suppress_until: Duration::from_millis(DELAY_SUPPRESS_MS),
|
||||
visible: false,
|
||||
use_osc: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the bar width in characters.
|
||||
pub fn width(mut self, w: usize) -> Self {
|
||||
self.width = w.max(5);
|
||||
self
|
||||
}
|
||||
|
||||
/// Disable delay suppression (always show immediately).
|
||||
pub fn no_delay(mut self) -> Self {
|
||||
self.suppress_until = Duration::ZERO;
|
||||
self
|
||||
}
|
||||
|
||||
/// Disable OSC 9;4 terminal progress protocol.
|
||||
pub fn no_osc(mut self) -> Self {
|
||||
self.use_osc = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Update progress to `n`.
|
||||
pub fn set(&mut self, n: u64) {
|
||||
self.current = n.min(self.total);
|
||||
self.draw();
|
||||
}
|
||||
|
||||
/// Increment progress by `delta`.
|
||||
pub fn inc(&mut self, delta: u64) {
|
||||
self.current = (self.current + delta).min(self.total);
|
||||
self.draw();
|
||||
}
|
||||
|
||||
/// Mark as finished and clear the line.
|
||||
pub fn finish(&mut self) {
|
||||
self.current = self.total;
|
||||
self.draw();
|
||||
if self.visible {
|
||||
// Move to next line
|
||||
eprintln!();
|
||||
}
|
||||
if self.use_osc {
|
||||
osc_progress_clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark as finished with a message replacing the bar.
|
||||
pub fn finish_with_message(&mut self, msg: &str) {
|
||||
self.current = self.total;
|
||||
if self.visible {
|
||||
eprint!("\r\x1b[2K{msg}");
|
||||
eprintln!();
|
||||
} else if self.start.elapsed() >= self.suppress_until {
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
if self.use_osc {
|
||||
osc_progress_clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(&mut self) {
|
||||
// Delay suppression: don't render if op is still fast
|
||||
if self.start.elapsed() < self.suppress_until && self.current < self.total {
|
||||
return;
|
||||
}
|
||||
|
||||
self.visible = true;
|
||||
|
||||
let pct = (self.current as f64 / self.total as f64 * 100.0) as u8;
|
||||
let filled = (self.current as f64 / self.total as f64 * self.width as f64) as usize;
|
||||
let empty = self.width.saturating_sub(filled);
|
||||
|
||||
let bar: String = std::iter::repeat_n(FILLED, filled)
|
||||
.chain(std::iter::repeat_n(EMPTY, empty))
|
||||
.collect();
|
||||
|
||||
eprint!(
|
||||
"\r\x1b[2K{:<14} [{}] {:>3}% ({}/{})",
|
||||
self.label, bar, pct, self.current, self.total
|
||||
);
|
||||
let _ = io::stderr().flush();
|
||||
|
||||
if self.use_osc {
|
||||
osc_progress(1, pct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProgressBar {
|
||||
fn drop(&mut self) {
|
||||
if self.use_osc && self.visible {
|
||||
osc_progress_clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spinner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// An indeterminate spinner for operations without known total.
|
||||
///
|
||||
/// ```text
|
||||
/// ⠋ Loading models...
|
||||
/// ```
|
||||
pub struct Spinner {
|
||||
label: String,
|
||||
frame: usize,
|
||||
start: Instant,
|
||||
suppress_until: Duration,
|
||||
visible: bool,
|
||||
use_osc: bool,
|
||||
}
|
||||
|
||||
impl Spinner {
|
||||
/// Create a spinner with the given label.
|
||||
pub fn new(label: &str) -> Self {
|
||||
Self {
|
||||
label: label.to_string(),
|
||||
frame: 0,
|
||||
start: Instant::now(),
|
||||
suppress_until: Duration::from_millis(DELAY_SUPPRESS_MS),
|
||||
visible: false,
|
||||
use_osc: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Disable delay suppression.
|
||||
pub fn no_delay(mut self) -> Self {
|
||||
self.suppress_until = Duration::ZERO;
|
||||
self
|
||||
}
|
||||
|
||||
/// Disable OSC 9;4 protocol.
|
||||
pub fn no_osc(mut self) -> Self {
|
||||
self.use_osc = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Advance the spinner by one frame and redraw.
|
||||
pub fn tick(&mut self) {
|
||||
if self.start.elapsed() < self.suppress_until {
|
||||
return;
|
||||
}
|
||||
|
||||
self.visible = true;
|
||||
let ch = SPINNER_FRAMES[self.frame % SPINNER_FRAMES.len()];
|
||||
self.frame += 1;
|
||||
|
||||
eprint!("\r\x1b[2K{ch} {}", self.label);
|
||||
let _ = io::stderr().flush();
|
||||
|
||||
if self.use_osc {
|
||||
osc_progress(3, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the label text.
|
||||
pub fn set_label(&mut self, label: &str) {
|
||||
self.label = label.to_string();
|
||||
}
|
||||
|
||||
/// Stop the spinner and clear the line.
|
||||
pub fn finish(&self) {
|
||||
if self.visible {
|
||||
eprint!("\r\x1b[2K");
|
||||
let _ = io::stderr().flush();
|
||||
}
|
||||
if self.use_osc {
|
||||
osc_progress_clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the spinner and print a final message.
|
||||
pub fn finish_with_message(&self, msg: &str) {
|
||||
if self.visible {
|
||||
eprint!("\r\x1b[2K");
|
||||
}
|
||||
eprintln!("{msg}");
|
||||
if self.use_osc {
|
||||
osc_progress_clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Spinner {
|
||||
fn drop(&mut self) {
|
||||
if self.use_osc && self.visible {
|
||||
osc_progress_clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn progress_bar_percentage() {
|
||||
let mut pb = ProgressBar::new("Test", 10).no_delay().no_osc();
|
||||
pb.set(5);
|
||||
assert_eq!(pb.current, 5);
|
||||
pb.inc(3);
|
||||
assert_eq!(pb.current, 8);
|
||||
// Cannot exceed total
|
||||
pb.inc(100);
|
||||
assert_eq!(pb.current, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_bar_zero_total_no_panic() {
|
||||
// total of 0 should be clamped to 1 to avoid division by zero
|
||||
let mut pb = ProgressBar::new("Empty", 0).no_delay().no_osc();
|
||||
pb.set(0);
|
||||
pb.finish();
|
||||
assert_eq!(pb.total, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_frame_advance() {
|
||||
let mut sp = Spinner::new("Loading").no_delay().no_osc();
|
||||
sp.tick();
|
||||
assert_eq!(sp.frame, 1);
|
||||
sp.tick();
|
||||
assert_eq!(sp.frame, 2);
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delay_suppression() {
|
||||
// With default suppress_until, a freshly-created bar should NOT
|
||||
// become visible on the first draw (elapsed < 200ms).
|
||||
let mut pb = ProgressBar::new("Quick", 10).no_osc();
|
||||
pb.set(1);
|
||||
assert!(!pb.visible);
|
||||
}
|
||||
}
|
||||
248
crates/openfang-cli/src/table.rs
Normal file
248
crates/openfang-cli/src/table.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
//! ASCII table renderer with Unicode box-drawing borders for CLI output.
|
||||
//!
|
||||
//! Supports column alignment, auto-width, header styling, and optional colored
|
||||
//! output via the `colored` crate.
|
||||
|
||||
use colored::Colorize;
|
||||
|
||||
/// Column alignment.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Align {
|
||||
Left,
|
||||
Right,
|
||||
Center,
|
||||
}
|
||||
|
||||
/// A table builder that collects headers and rows, then renders to a
|
||||
/// Unicode box-drawing string.
|
||||
pub struct Table {
|
||||
headers: Vec<String>,
|
||||
alignments: Vec<Align>,
|
||||
rows: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
/// Create a new table with the given column headers.
|
||||
/// All columns default to left-alignment.
|
||||
pub fn new(headers: &[&str]) -> Self {
|
||||
let headers: Vec<String> = headers.iter().map(|h| h.to_string()).collect();
|
||||
let alignments = vec![Align::Left; headers.len()];
|
||||
Self {
|
||||
headers,
|
||||
alignments,
|
||||
rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the alignment for a specific column (0-indexed).
|
||||
/// Out-of-range indices are silently ignored.
|
||||
pub fn align(mut self, col: usize, alignment: Align) -> Self {
|
||||
if col < self.alignments.len() {
|
||||
self.alignments[col] = alignment;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a row. Extra cells are truncated; missing cells are filled with "".
|
||||
pub fn add_row(&mut self, cells: &[&str]) {
|
||||
let row: Vec<String> = (0..self.headers.len())
|
||||
.map(|i| cells.get(i).unwrap_or(&"").to_string())
|
||||
.collect();
|
||||
self.rows.push(row);
|
||||
}
|
||||
|
||||
/// Compute the display width of each column (max of header and all cells).
|
||||
fn column_widths(&self) -> Vec<usize> {
|
||||
let mut widths: Vec<usize> = self.headers.iter().map(|h| h.len()).collect();
|
||||
for row in &self.rows {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
if i < widths.len() {
|
||||
widths[i] = widths[i].max(cell.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
widths
|
||||
}
|
||||
|
||||
/// Pad a string to the given width according to alignment.
|
||||
fn pad(text: &str, width: usize, alignment: Align) -> String {
|
||||
let len = text.len();
|
||||
if len >= width {
|
||||
return text.to_string();
|
||||
}
|
||||
let diff = width - len;
|
||||
match alignment {
|
||||
Align::Left => format!("{text}{}", " ".repeat(diff)),
|
||||
Align::Right => format!("{}{text}", " ".repeat(diff)),
|
||||
Align::Center => {
|
||||
let left = diff / 2;
|
||||
let right = diff - left;
|
||||
format!("{}{text}{}", " ".repeat(left), " ".repeat(right))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a horizontal border line.
|
||||
/// `left`, `mid`, `right` are the corner/junction characters.
|
||||
fn border(widths: &[usize], left: &str, mid: &str, right: &str) -> String {
|
||||
let segments: Vec<String> = widths.iter().map(|w| "\u{2500}".repeat(w + 2)).collect();
|
||||
format!("{left}{}{right}", segments.join(mid))
|
||||
}
|
||||
|
||||
/// Render the table to a string with Unicode box-drawing borders.
|
||||
///
|
||||
/// Layout:
|
||||
/// ```text
|
||||
/// ┌──────┬───────┐
|
||||
/// │ Name │ Value │
|
||||
/// ├──────┼───────┤
|
||||
/// │ foo │ bar │
|
||||
/// └──────┴───────┘
|
||||
/// ```
|
||||
pub fn render(&self) -> String {
|
||||
let widths = self.column_widths();
|
||||
|
||||
let top = Self::border(&widths, "\u{250c}", "\u{252c}", "\u{2510}");
|
||||
let sep = Self::border(&widths, "\u{251c}", "\u{253c}", "\u{2524}");
|
||||
let bot = Self::border(&widths, "\u{2514}", "\u{2534}", "\u{2518}");
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Top border
|
||||
lines.push(top);
|
||||
|
||||
// Header row (bold)
|
||||
let header_cells: Vec<String> = self
|
||||
.headers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, h)| format!(" {} ", Self::pad(h, widths[i], self.alignments[i]).bold()))
|
||||
.collect();
|
||||
lines.push(format!("\u{2502}{}\u{2502}", header_cells.join("\u{2502}")));
|
||||
|
||||
// Separator
|
||||
lines.push(sep);
|
||||
|
||||
// Data rows
|
||||
for row in &self.rows {
|
||||
let cells: Vec<String> = row
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, cell)| format!(" {} ", Self::pad(cell, widths[i], self.alignments[i])))
|
||||
.collect();
|
||||
lines.push(format!("\u{2502}{}\u{2502}", cells.join("\u{2502}")));
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
lines.push(bot);
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Render the table and print it to stdout.
|
||||
pub fn print(&self) {
|
||||
println!("{}", self.render());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_table() {
|
||||
let mut t = Table::new(&["Name", "Age", "City"]);
|
||||
t.add_row(&["Alice", "30", "London"]);
|
||||
t.add_row(&["Bob", "25", "Paris"]);
|
||||
|
||||
let rendered = t.render();
|
||||
let lines: Vec<&str> = rendered.lines().collect();
|
||||
|
||||
// 5 lines: top, header, sep, 2 rows, bottom = 6
|
||||
assert_eq!(lines.len(), 6);
|
||||
|
||||
// Top border uses box-drawing
|
||||
assert!(lines[0].starts_with('\u{250c}'));
|
||||
assert!(lines[0].ends_with('\u{2510}'));
|
||||
|
||||
// Bottom border
|
||||
assert!(lines[5].starts_with('\u{2514}'));
|
||||
assert!(lines[5].ends_with('\u{2518}'));
|
||||
|
||||
// Header line contains column names (ignore ANSI codes for bold)
|
||||
assert!(lines[1].contains("Name"));
|
||||
assert!(lines[1].contains("Age"));
|
||||
assert!(lines[1].contains("City"));
|
||||
|
||||
// Data rows contain cell values
|
||||
assert!(lines[3].contains("Alice"));
|
||||
assert!(lines[3].contains("30"));
|
||||
assert!(lines[3].contains("London"));
|
||||
assert!(lines[4].contains("Bob"));
|
||||
assert!(lines[4].contains("25"));
|
||||
assert!(lines[4].contains("Paris"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_alignment() {
|
||||
let mut t = Table::new(&["Item", "Count"]);
|
||||
t = t.align(1, Align::Right);
|
||||
t.add_row(&["apples", "5"]);
|
||||
t.add_row(&["oranges", "123"]);
|
||||
|
||||
let rendered = t.render();
|
||||
// The "5" should be right-padded on the left within its column
|
||||
// Find the data line with "5"
|
||||
let line = rendered.lines().find(|l| l.contains("apples")).unwrap();
|
||||
// After the second box char, the number should be right-aligned
|
||||
assert!(line.contains(" 5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_alignment() {
|
||||
let pad = Table::pad("hi", 6, Align::Center);
|
||||
assert_eq!(pad, " hi ");
|
||||
|
||||
let pad_odd = Table::pad("hi", 7, Align::Center);
|
||||
assert_eq!(pad_odd, " hi ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_table() {
|
||||
let t = Table::new(&["A", "B"]);
|
||||
let rendered = t.render();
|
||||
let lines: Vec<&str> = rendered.lines().collect();
|
||||
// top, header, sep, bottom = 4 lines (no data rows)
|
||||
assert_eq!(lines.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_cells_filled() {
|
||||
let mut t = Table::new(&["X", "Y", "Z"]);
|
||||
t.add_row(&["only-one"]);
|
||||
|
||||
let rendered = t.render();
|
||||
// Row should still have 3 columns; missing ones are empty
|
||||
let data_line = rendered.lines().nth(3).unwrap();
|
||||
// Count box-drawing vertical bars in data line
|
||||
let bars = data_line.matches('\u{2502}').count();
|
||||
assert_eq!(bars, 4); // left + 2 inner + right
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_cells_auto_width() {
|
||||
let mut t = Table::new(&["ID", "Description"]);
|
||||
t.add_row(&["1", "A very long description string"]);
|
||||
|
||||
let rendered = t.render();
|
||||
assert!(rendered.contains("A very long description string"));
|
||||
// The top border should be wide enough to contain the description
|
||||
let top = rendered.lines().next().unwrap();
|
||||
// At minimum: 2 padding + description length for second column
|
||||
assert!(top.len() > 30);
|
||||
}
|
||||
}
|
||||
130
crates/openfang-cli/src/templates.rs
Normal file
130
crates/openfang-cli/src/templates.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
//! Discover and load agent templates from the agents directory.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A discovered agent template.
|
||||
pub struct AgentTemplate {
|
||||
/// Template name (directory name).
|
||||
pub name: String,
|
||||
/// Description from the manifest.
|
||||
pub description: String,
|
||||
/// Raw TOML content.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Discover template directories. Checks:
|
||||
/// 1. The repo `agents/` dir (for dev builds)
|
||||
/// 2. `~/.openfang/agents/` (installed templates)
|
||||
/// 3. `OPENFANG_AGENTS_DIR` env var
|
||||
pub fn discover_template_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
// Dev: repo agents/ directory (relative to the binary)
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
// Walk up from the binary to find the workspace root
|
||||
let mut dir = exe.as_path();
|
||||
for _ in 0..5 {
|
||||
if let Some(parent) = dir.parent() {
|
||||
let agents = parent.join("agents");
|
||||
if agents.is_dir() {
|
||||
dirs.push(agents);
|
||||
break;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Installed templates
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let agents = home.join(".openfang").join("agents");
|
||||
if agents.is_dir() && !dirs.contains(&agents) {
|
||||
dirs.push(agents);
|
||||
}
|
||||
}
|
||||
|
||||
// Environment override
|
||||
if let Ok(env_dir) = std::env::var("OPENFANG_AGENTS_DIR") {
|
||||
let p = PathBuf::from(env_dir);
|
||||
if p.is_dir() && !dirs.contains(&p) {
|
||||
dirs.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Load all templates from discovered directories, falling back to bundled templates.
|
||||
pub fn load_all_templates() -> Vec<AgentTemplate> {
|
||||
let mut templates = Vec::new();
|
||||
let mut seen_names = std::collections::HashSet::new();
|
||||
|
||||
// First: load from filesystem (user-installed or dev repo)
|
||||
for dir in discover_template_dirs() {
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let manifest = path.join("agent.toml");
|
||||
if !manifest.exists() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name == "custom" || !seen_names.insert(name.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(content) = std::fs::read_to_string(&manifest) {
|
||||
let description = extract_description(&content);
|
||||
templates.push(AgentTemplate {
|
||||
name,
|
||||
description,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: load bundled templates for any not found on disk
|
||||
for (name, content) in crate::bundled_agents::bundled_agents() {
|
||||
if seen_names.insert(name.to_string()) {
|
||||
let description = extract_description(content);
|
||||
templates.push(AgentTemplate {
|
||||
name: name.to_string(),
|
||||
description,
|
||||
content: content.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
templates.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
templates
|
||||
}
|
||||
|
||||
/// Extract the `description` field from raw TOML without full parsing.
|
||||
fn extract_description(toml_str: &str) -> String {
|
||||
for line in toml_str.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("description") {
|
||||
if let Some(rest) = rest.trim_start().strip_prefix('=') {
|
||||
let val = rest.trim().trim_matches('"');
|
||||
return val.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Format a template description as a hint for cliclack select items.
|
||||
pub fn template_display_hint(t: &AgentTemplate) -> String {
|
||||
if t.description.is_empty() {
|
||||
String::new()
|
||||
} else if t.description.chars().count() > 60 {
|
||||
let truncated: String = t.description.chars().take(57).collect();
|
||||
format!("{truncated}...")
|
||||
} else {
|
||||
t.description.clone()
|
||||
}
|
||||
}
|
||||
652
crates/openfang-cli/src/tui/chat_runner.rs
Normal file
652
crates/openfang-cli/src/tui/chat_runner.rs
Normal file
@@ -0,0 +1,652 @@
|
||||
//! Standalone chat TUI for `openfang chat`.
|
||||
//!
|
||||
//! Launches a focused ratatui chat screen — same beautiful rendering as the
|
||||
//! full TUI's Chat tab, but without the 17-tab chrome. Reuses 100% of
|
||||
//! `ChatState`, `chat::draw()`, event spawning, and the theme system.
|
||||
|
||||
use super::event::{self, AppEvent};
|
||||
use super::screens::chat::{self, ChatAction, ChatState, Role};
|
||||
use super::theme;
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::llm_driver::StreamEvent;
|
||||
use openfang_types::agent::AgentId;
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{mpsc, Arc};
|
||||
use std::time::Duration;
|
||||
|
||||
// ── Internal state ───────────────────────────────────────────────────────────
|
||||
|
||||
enum Backend {
|
||||
Daemon { base_url: String },
|
||||
InProcess { kernel: Arc<OpenFangKernel> },
|
||||
None,
|
||||
}
|
||||
|
||||
struct StandaloneChat {
|
||||
chat: ChatState,
|
||||
event_tx: mpsc::Sender<AppEvent>,
|
||||
backend: Backend,
|
||||
agent_id_daemon: Option<String>,
|
||||
agent_id_inprocess: Option<AgentId>,
|
||||
agent_name: String,
|
||||
should_quit: bool,
|
||||
booting: bool,
|
||||
boot_error: Option<String>,
|
||||
spinner_frame: usize,
|
||||
}
|
||||
|
||||
impl StandaloneChat {
|
||||
fn new(event_tx: mpsc::Sender<AppEvent>) -> Self {
|
||||
Self {
|
||||
chat: ChatState::new(),
|
||||
event_tx,
|
||||
backend: Backend::None,
|
||||
agent_id_daemon: None,
|
||||
agent_id_inprocess: None,
|
||||
agent_name: String::new(),
|
||||
should_quit: false,
|
||||
booting: false,
|
||||
boot_error: None,
|
||||
spinner_frame: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event dispatch ───────────────────────────────────────────────────────
|
||||
|
||||
fn handle_event(&mut self, ev: AppEvent) {
|
||||
match ev {
|
||||
AppEvent::Key(key) => self.handle_key(key),
|
||||
AppEvent::Tick => self.handle_tick(),
|
||||
AppEvent::Stream(stream_ev) => self.handle_stream(stream_ev),
|
||||
AppEvent::StreamDone(result) => self.handle_stream_done(result),
|
||||
AppEvent::KernelReady(kernel) => self.handle_kernel_ready(kernel),
|
||||
AppEvent::KernelError(err) => self.handle_kernel_error(err),
|
||||
AppEvent::AgentSpawned { id, name } => self.handle_agent_spawned(id, name),
|
||||
AppEvent::AgentSpawnError(err) => self.handle_agent_spawn_error(err),
|
||||
// All other events (tab-specific data loads) are irrelevant in
|
||||
// standalone chat mode — silently ignore.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: ratatui::crossterm::event::KeyEvent) {
|
||||
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
// Ctrl+Q / Ctrl+C always quit
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Char('c') => {
|
||||
self.should_quit = true;
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// If still booting, only allow quit keys
|
||||
if self.booting || self.backend_is_none() {
|
||||
if key.code == KeyCode::Esc {
|
||||
self.should_quit = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let action = self.chat.handle_key(key);
|
||||
self.handle_chat_action(action);
|
||||
}
|
||||
|
||||
fn handle_tick(&mut self) {
|
||||
self.chat.tick();
|
||||
if self.booting {
|
||||
self.spinner_frame = (self.spinner_frame + 1) % theme::SPINNER_FRAMES.len();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_stream(&mut self, ev: StreamEvent) {
|
||||
match ev {
|
||||
StreamEvent::TextDelta { text } => {
|
||||
self.chat.thinking = false;
|
||||
if self.chat.active_tool.is_some() {
|
||||
self.chat.active_tool = None;
|
||||
}
|
||||
self.chat.append_stream(&text);
|
||||
}
|
||||
StreamEvent::ToolUseStart { name, .. } => {
|
||||
if !self.chat.streaming_text.is_empty() {
|
||||
let text = std::mem::take(&mut self.chat.streaming_text);
|
||||
self.chat.push_message(Role::Agent, text);
|
||||
}
|
||||
self.chat.tool_start(&name);
|
||||
}
|
||||
StreamEvent::ToolInputDelta { text } => {
|
||||
self.chat.tool_input_buf.push_str(&text);
|
||||
}
|
||||
StreamEvent::ToolUseEnd { name, input, .. } => {
|
||||
let input_str = if !self.chat.tool_input_buf.is_empty() {
|
||||
std::mem::take(&mut self.chat.tool_input_buf)
|
||||
} else {
|
||||
serde_json::to_string(&input).unwrap_or_default()
|
||||
};
|
||||
self.chat.tool_use_end(&name, &input_str);
|
||||
}
|
||||
StreamEvent::ContentComplete { usage, .. } => {
|
||||
self.chat.last_tokens = Some((usage.input_tokens, usage.output_tokens));
|
||||
}
|
||||
StreamEvent::PhaseChange { phase, detail } => {
|
||||
if phase == "tool_use" {
|
||||
if let Some(tool_name) = detail {
|
||||
self.chat.tool_start(&tool_name);
|
||||
}
|
||||
} else if phase == "thinking" {
|
||||
self.chat.thinking = true;
|
||||
}
|
||||
}
|
||||
StreamEvent::ThinkingDelta { text } => {
|
||||
self.chat.thinking = true;
|
||||
self.chat.append_stream(&text);
|
||||
}
|
||||
StreamEvent::ToolExecutionResult {
|
||||
name,
|
||||
result_preview,
|
||||
is_error,
|
||||
} => {
|
||||
self.chat.tool_result(&name, &result_preview, is_error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_stream_done(
|
||||
&mut self,
|
||||
result: Result<openfang_runtime::agent_loop::AgentLoopResult, String>,
|
||||
) {
|
||||
self.chat.finalize_stream();
|
||||
match result {
|
||||
Ok(r) => {
|
||||
if !r.response.is_empty()
|
||||
&& self.chat.messages.last().map(|m| m.text.as_str()) != Some(&r.response)
|
||||
{
|
||||
self.chat.push_message(Role::Agent, r.response);
|
||||
}
|
||||
if r.total_usage.input_tokens > 0 || r.total_usage.output_tokens > 0 {
|
||||
self.chat.last_tokens =
|
||||
Some((r.total_usage.input_tokens, r.total_usage.output_tokens));
|
||||
}
|
||||
self.chat.last_cost_usd = r.cost_usd;
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.status_msg = Some(format!("Error: {e}"));
|
||||
}
|
||||
}
|
||||
// Auto-send the next staged message if any
|
||||
if let Some(msg) = self.chat.take_staged() {
|
||||
self.send_message(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kernel lifecycle ─────────────────────────────────────────────────────
|
||||
|
||||
fn handle_kernel_ready(&mut self, kernel: Arc<OpenFangKernel>) {
|
||||
self.booting = false;
|
||||
self.boot_error = None;
|
||||
self.backend = Backend::InProcess { kernel };
|
||||
// Spawn or find the agent
|
||||
self.resolve_inprocess_agent();
|
||||
}
|
||||
|
||||
fn handle_kernel_error(&mut self, err: String) {
|
||||
self.booting = false;
|
||||
self.boot_error = Some(err);
|
||||
}
|
||||
|
||||
fn handle_agent_spawned(&mut self, id: String, name: String) {
|
||||
self.enter_chat_daemon(id, name);
|
||||
}
|
||||
|
||||
fn handle_agent_spawn_error(&mut self, err: String) {
|
||||
self.chat.status_msg = Some(format!("Failed to spawn agent: {err}"));
|
||||
}
|
||||
|
||||
// ── Chat action dispatch ─────────────────────────────────────────────────
|
||||
|
||||
fn handle_chat_action(&mut self, action: ChatAction) {
|
||||
match action {
|
||||
ChatAction::Continue => {}
|
||||
ChatAction::Back => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
ChatAction::SendMessage(msg) => self.send_message(msg),
|
||||
ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_message(&mut self, message: String) {
|
||||
self.chat.is_streaming = true;
|
||||
self.chat.thinking = true;
|
||||
self.chat.streaming_chars = 0;
|
||||
self.chat.last_tokens = None;
|
||||
self.chat.last_cost_usd = None;
|
||||
self.chat.status_msg = None;
|
||||
|
||||
match &self.backend {
|
||||
Backend::Daemon { base_url } if self.agent_id_daemon.is_some() => {
|
||||
event::spawn_daemon_stream(
|
||||
base_url.clone(),
|
||||
self.agent_id_daemon.as_ref().unwrap().clone(),
|
||||
message,
|
||||
self.event_tx.clone(),
|
||||
);
|
||||
}
|
||||
Backend::InProcess { kernel } if self.agent_id_inprocess.is_some() => {
|
||||
event::spawn_inprocess_stream(
|
||||
kernel.clone(),
|
||||
self.agent_id_inprocess.unwrap(),
|
||||
message,
|
||||
self.event_tx.clone(),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
self.chat.is_streaming = false;
|
||||
self.chat.status_msg = Some("No active connection".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slash commands (subset — no tab navigation) ──────────────────────────
|
||||
|
||||
fn handle_slash_command(&mut self, cmd: &str) {
|
||||
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
||||
match parts[0] {
|
||||
"/exit" | "/quit" => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
"/help" => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/model \u{2014} show current model",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent & quit",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
"/status" => {
|
||||
let mut s = Vec::new();
|
||||
match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
s.push(format!("Mode: daemon ({base_url})"));
|
||||
s.push(format!("Agent: {}", self.agent_name));
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
s.push("Mode: in-process".to_string());
|
||||
s.push(format!("Agents: {}", kernel.registry.count()));
|
||||
s.push(format!("Agent: {}", self.agent_name));
|
||||
}
|
||||
Backend::None => s.push("Mode: disconnected".to_string()),
|
||||
}
|
||||
self.chat.push_message(Role::System, s.join("\n"));
|
||||
}
|
||||
"/model" => {
|
||||
self.chat
|
||||
.push_message(Role::System, format!("Model: {}", self.chat.model_label));
|
||||
}
|
||||
"/clear" => {
|
||||
let name = self.chat.agent_name.clone();
|
||||
let model = self.chat.model_label.clone();
|
||||
let mode = self.chat.mode_label.clone();
|
||||
self.chat.reset();
|
||||
self.chat.agent_name = name;
|
||||
self.chat.model_label = model;
|
||||
self.chat.mode_label = mode;
|
||||
self.chat
|
||||
.push_message(Role::System, "Chat history cleared.".to_string());
|
||||
}
|
||||
"/kill" => {
|
||||
let name = self.agent_name.clone();
|
||||
match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
if let Some(ref id) = self.agent_id_daemon {
|
||||
let client = crate::daemon_client();
|
||||
let url = format!("{base_url}/api/agents/{id}");
|
||||
match client.delete(&url).send() {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Agent \"{name}\" killed."),
|
||||
);
|
||||
self.should_quit = true;
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Failed to kill agent \"{name}\"."),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
if let Some(id) = self.agent_id_inprocess {
|
||||
match kernel.kill_agent(id) {
|
||||
Ok(()) => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Agent \"{name}\" killed."),
|
||||
);
|
||||
self.should_quit = true;
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat
|
||||
.push_message(Role::System, format!("Kill failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend::None => {
|
||||
self.chat
|
||||
.push_message(Role::System, "No backend connected.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Unknown command: {}. Type /help", parts[0]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent resolution helpers ─────────────────────────────────────────────
|
||||
|
||||
fn enter_chat_daemon(&mut self, id: String, name: String) {
|
||||
self.agent_id_daemon = Some(id.clone());
|
||||
self.agent_name = name.clone();
|
||||
self.chat.agent_name = name;
|
||||
self.chat.mode_label = "daemon".to_string();
|
||||
|
||||
// Fetch model info
|
||||
if let Backend::Daemon { ref base_url } = self.backend {
|
||||
let client = crate::daemon_client();
|
||||
if let Ok(resp) = client.get(format!("{base_url}/api/agents/{id}")).send() {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider = body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
"/help for commands \u{2022} /exit to quit".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
fn enter_chat_inprocess(&mut self, id: AgentId, name: String) {
|
||||
self.agent_id_inprocess = Some(id);
|
||||
self.agent_name = name.clone();
|
||||
self.chat.agent_name = name;
|
||||
self.chat.mode_label = "in-process".to_string();
|
||||
|
||||
if let Backend::InProcess { ref kernel } = self.backend {
|
||||
if let Some(entry) = kernel.registry.get(id) {
|
||||
self.chat.model_label = format!(
|
||||
"{}/{}",
|
||||
entry.manifest.model.provider, entry.manifest.model.model
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
"/help for commands \u{2022} /exit to quit".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolve agent on daemon: find by name/id, or auto-spawn from template.
|
||||
fn resolve_daemon_agent(&mut self, base_url: &str, agent_name: Option<&str>) {
|
||||
let client = crate::daemon_client();
|
||||
let body = crate::daemon_json(client.get(format!("{base_url}/api/agents")).send());
|
||||
let agents = body.as_array();
|
||||
|
||||
// Try to find by name/id
|
||||
let found = match agent_name {
|
||||
Some(name_or_id) => agents.and_then(|arr| {
|
||||
arr.iter().find(|a| {
|
||||
a["name"].as_str() == Some(name_or_id) || a["id"].as_str() == Some(name_or_id)
|
||||
})
|
||||
}),
|
||||
None => agents.and_then(|arr| arr.first()),
|
||||
};
|
||||
|
||||
if let Some(agent) = found {
|
||||
let id = agent["id"].as_str().unwrap_or("").to_string();
|
||||
let name = agent["name"].as_str().unwrap_or("agent").to_string();
|
||||
self.backend = Backend::Daemon {
|
||||
base_url: base_url.to_string(),
|
||||
};
|
||||
self.enter_chat_daemon(id, name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-spawn from template
|
||||
let target_name = agent_name.unwrap_or("assistant");
|
||||
let all_templates = crate::templates::load_all_templates();
|
||||
let template = all_templates
|
||||
.iter()
|
||||
.find(|t| t.name == target_name)
|
||||
.or_else(|| all_templates.first());
|
||||
|
||||
match template {
|
||||
Some(t) => {
|
||||
self.backend = Backend::Daemon {
|
||||
base_url: base_url.to_string(),
|
||||
};
|
||||
event::spawn_daemon_agent(
|
||||
base_url.to_string(),
|
||||
t.content.clone(),
|
||||
self.event_tx.clone(),
|
||||
);
|
||||
self.chat.status_msg = Some(format!("Spawning '{}' agent\u{2026}", t.name));
|
||||
}
|
||||
None => {
|
||||
self.boot_error =
|
||||
Some("No agent templates found. Run `openfang init`.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve agent in-process: find existing or spawn from template.
|
||||
fn resolve_inprocess_agent(&mut self) {
|
||||
let kernel = match &self.backend {
|
||||
Backend::InProcess { kernel } => kernel.clone(),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Check for existing agents
|
||||
let existing = kernel.registry.list();
|
||||
if let Some(entry) = existing
|
||||
.iter()
|
||||
.find(|e| self.agent_name.is_empty() || e.name == self.agent_name)
|
||||
{
|
||||
self.enter_chat_inprocess(entry.id, entry.name.clone());
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn from template
|
||||
let target_name = if self.agent_name.is_empty() {
|
||||
"assistant"
|
||||
} else {
|
||||
&self.agent_name
|
||||
};
|
||||
let all_templates = crate::templates::load_all_templates();
|
||||
let template = all_templates
|
||||
.iter()
|
||||
.find(|t| t.name == target_name)
|
||||
.or_else(|| all_templates.iter().find(|t| t.name == "assistant"))
|
||||
.or_else(|| all_templates.first());
|
||||
|
||||
match template {
|
||||
Some(t) => {
|
||||
let manifest: openfang_types::agent::AgentManifest =
|
||||
match toml::from_str(&t.content) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
self.chat.status_msg =
|
||||
Some(format!("Invalid template '{}': {e}", t.name));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let name = manifest.name.clone();
|
||||
match kernel.spawn_agent(manifest) {
|
||||
Ok(id) => {
|
||||
self.enter_chat_inprocess(id, name);
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.status_msg = Some(format!("Spawn failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.chat.status_msg =
|
||||
Some("No agent templates found. Run `openfang init`.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn backend_is_none(&self) -> bool {
|
||||
matches!(self.backend, Backend::None)
|
||||
}
|
||||
|
||||
// ── Drawing ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw(&mut self, frame: &mut ratatui::Frame) {
|
||||
let area = frame.area();
|
||||
|
||||
if self.booting {
|
||||
self.draw_booting(frame, area);
|
||||
} else if let Some(ref err) = self.boot_error {
|
||||
self.draw_error(frame, area, err);
|
||||
} else {
|
||||
chat::draw(frame, area, &mut self.chat);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_booting(&self, frame: &mut ratatui::Frame, area: Rect) {
|
||||
let spinner = theme::SPINNER_FRAMES[self.spinner_frame];
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Percentage(40),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::ACCENT)),
|
||||
Span::styled(
|
||||
"Booting kernel\u{2026}",
|
||||
Style::default().fg(theme::TEXT_PRIMARY),
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
" This may take a moment while the kernel initializes.",
|
||||
theme::dim_style(),
|
||||
)]),
|
||||
];
|
||||
|
||||
let para = Paragraph::new(lines).alignment(Alignment::Center);
|
||||
frame.render_widget(para, chunks[1]);
|
||||
}
|
||||
|
||||
fn draw_error(&self, frame: &mut ratatui::Frame, area: Rect, err: &str) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Percentage(35),
|
||||
Constraint::Length(5),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" \u{2718} ", Style::default().fg(theme::RED)),
|
||||
Span::styled("Failed to start", Style::default().fg(theme::RED)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" {err}"),
|
||||
Style::default().fg(theme::TEXT_SECONDARY),
|
||||
)]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
" Press Esc to exit.",
|
||||
theme::hint_style(),
|
||||
)]),
|
||||
];
|
||||
|
||||
let para = Paragraph::new(lines).alignment(Alignment::Center);
|
||||
frame.render_widget(para, chunks[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public entry point ───────────────────────────────────────────────────────
|
||||
|
||||
/// Launch the standalone chat TUI.
|
||||
///
|
||||
/// - If a daemon is running, connects to it and resolves the agent.
|
||||
/// - Otherwise, boots the kernel in-process.
|
||||
pub fn run_chat_tui(config: Option<PathBuf>, agent_name: Option<String>) {
|
||||
// Panic hook: always restore terminal
|
||||
let original_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
ratatui::restore();
|
||||
original_hook(info);
|
||||
}));
|
||||
|
||||
let mut terminal = ratatui::init();
|
||||
|
||||
let (tx, rx) = event::spawn_event_thread(Duration::from_millis(50));
|
||||
let mut state = StandaloneChat::new(tx.clone());
|
||||
|
||||
// Store the requested agent name for later resolution
|
||||
if let Some(ref name) = agent_name {
|
||||
state.agent_name = name.clone();
|
||||
}
|
||||
|
||||
// Boot sequence: check for daemon, or boot kernel in-process
|
||||
if let Some(base_url) = crate::find_daemon() {
|
||||
state.resolve_daemon_agent(&base_url, agent_name.as_deref());
|
||||
} else {
|
||||
state.booting = true;
|
||||
event::spawn_kernel_boot(config, tx);
|
||||
}
|
||||
|
||||
// ── Main loop ────────────────────────────────────────────────────────────
|
||||
while !state.should_quit {
|
||||
terminal
|
||||
.draw(|frame| state.draw(frame))
|
||||
.expect("Failed to draw");
|
||||
|
||||
match rx.recv_timeout(Duration::from_millis(33)) {
|
||||
Ok(ev) => state.handle_event(ev),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
// Drain queued events
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
state.handle_event(ev);
|
||||
}
|
||||
}
|
||||
|
||||
ratatui::restore();
|
||||
}
|
||||
2594
crates/openfang-cli/src/tui/event.rs
Normal file
2594
crates/openfang-cli/src/tui/event.rs
Normal file
File diff suppressed because it is too large
Load Diff
2217
crates/openfang-cli/src/tui/mod.rs
Normal file
2217
crates/openfang-cli/src/tui/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
1529
crates/openfang-cli/src/tui/screens/agents.rs
Normal file
1529
crates/openfang-cli/src/tui/screens/agents.rs
Normal file
File diff suppressed because it is too large
Load Diff
346
crates/openfang-cli/src/tui/screens/audit.rs
Normal file
346
crates/openfang-cli/src/tui/screens/audit.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
//! Audit screen: audit log viewer with action filter and chain verification.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AuditEntry {
|
||||
pub timestamp: String,
|
||||
pub action: String,
|
||||
pub agent: String,
|
||||
pub detail: String,
|
||||
pub tip_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuditFilter {
|
||||
All,
|
||||
AgentSpawn,
|
||||
AgentKill,
|
||||
ToolInvoke,
|
||||
NetworkAccess,
|
||||
ShellExec,
|
||||
}
|
||||
|
||||
impl AuditFilter {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "All",
|
||||
Self::AgentSpawn => "Agent Created",
|
||||
Self::AgentKill => "Agent Killed",
|
||||
Self::ToolInvoke => "Tool Used",
|
||||
Self::NetworkAccess => "Network",
|
||||
Self::ShellExec => "Shell Exec",
|
||||
}
|
||||
}
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Self::All => Self::AgentSpawn,
|
||||
Self::AgentSpawn => Self::AgentKill,
|
||||
Self::AgentKill => Self::ToolInvoke,
|
||||
Self::ToolInvoke => Self::NetworkAccess,
|
||||
Self::NetworkAccess => Self::ShellExec,
|
||||
Self::ShellExec => Self::All,
|
||||
}
|
||||
}
|
||||
fn matches(self, action: &str) -> bool {
|
||||
match self {
|
||||
Self::All => true,
|
||||
Self::AgentSpawn => {
|
||||
action.contains("Spawn")
|
||||
|| action.contains("spawn")
|
||||
|| action.contains("Create")
|
||||
|| action.contains("create")
|
||||
}
|
||||
Self::AgentKill => {
|
||||
action.contains("Kill")
|
||||
|| action.contains("kill")
|
||||
|| action.contains("Stop")
|
||||
|| action.contains("stop")
|
||||
}
|
||||
Self::ToolInvoke => {
|
||||
action.contains("Tool")
|
||||
|| action.contains("tool")
|
||||
|| action.contains("Invoke")
|
||||
|| action.contains("invoke")
|
||||
}
|
||||
Self::NetworkAccess => {
|
||||
action.contains("Net")
|
||||
|| action.contains("net")
|
||||
|| action.contains("Fetch")
|
||||
|| action.contains("fetch")
|
||||
|| action.contains("Http")
|
||||
|| action.contains("http")
|
||||
}
|
||||
Self::ShellExec => {
|
||||
action.contains("Shell")
|
||||
|| action.contains("shell")
|
||||
|| action.contains("Exec")
|
||||
|| action.contains("exec")
|
||||
|| action.contains("Process")
|
||||
|| action.contains("process")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map raw action names to friendly display names.
|
||||
fn friendly_action(action: &str) -> &str {
|
||||
match action {
|
||||
"AgentSpawn" | "AgentSpawned" => "Agent Created",
|
||||
"AgentKill" | "AgentKilled" => "Agent Killed",
|
||||
"ToolInvoke" | "ToolInvocation" => "Tool Used",
|
||||
"NetworkAccess" | "NetFetch" => "Network Access",
|
||||
"ShellExec" | "ShellCommand" => "Shell Exec",
|
||||
"CapabilityDenied" => "Access Denied",
|
||||
"ConfigChange" => "Config Changed",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct AuditState {
|
||||
pub entries: Vec<AuditEntry>,
|
||||
pub filtered: Vec<usize>,
|
||||
pub action_filter: AuditFilter,
|
||||
pub list_state: ListState,
|
||||
pub chain_verified: Option<bool>,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum AuditAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
VerifyChain,
|
||||
}
|
||||
|
||||
impl AuditState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
filtered: Vec::new(),
|
||||
action_filter: AuditFilter::All,
|
||||
list_state: ListState::default(),
|
||||
chain_verified: None,
|
||||
loading: false,
|
||||
tick: 0,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn refilter(&mut self) {
|
||||
self.filtered = self
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| self.action_filter.matches(&e.action))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if !self.filtered.is_empty() {
|
||||
self.list_state.select(Some(0));
|
||||
} else {
|
||||
self.list_state.select(None);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> AuditAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return AuditAction::Continue;
|
||||
}
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
self.action_filter = self.action_filter.next();
|
||||
self.refilter();
|
||||
}
|
||||
KeyCode::Char('v') => return AuditAction::VerifyChain,
|
||||
KeyCode::Char('r') => return AuditAction::Refresh,
|
||||
_ => {}
|
||||
}
|
||||
AuditAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut AuditState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Audit Trail ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // header + filter
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(2), // chain status + hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// ── Header + filter ──
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" Filter: ", theme::dim_style()),
|
||||
Span::styled(
|
||||
format!("[{}]", state.action_filter.label()),
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ({} entries)", state.filtered.len()),
|
||||
theme::dim_style(),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<20} {:<16} {:<14} {:<10} {}",
|
||||
"Timestamp", "Action", "Agent", "Hash", "Detail"
|
||||
),
|
||||
theme::table_header(),
|
||||
)]),
|
||||
]),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
// ── List ──
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading audit trail\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.filtered.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No audit entries match the current filter.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.filtered
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
let e = &state.entries[idx];
|
||||
let action_display = friendly_action(&e.action);
|
||||
let action_style = if e.action.contains("Kill") || e.action.contains("Denied") {
|
||||
Style::default().fg(theme::RED)
|
||||
} else if e.action.contains("Spawn") || e.action.contains("Create") {
|
||||
Style::default().fg(theme::GREEN)
|
||||
} else if e.action.contains("Tool") {
|
||||
Style::default().fg(theme::BLUE)
|
||||
} else {
|
||||
Style::default().fg(theme::YELLOW)
|
||||
};
|
||||
let hash_short = if e.tip_hash.len() > 8 {
|
||||
&e.tip_hash[..8]
|
||||
} else {
|
||||
&e.tip_hash
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<20}", truncate(&e.timestamp, 19)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<16}", truncate(action_display, 15)),
|
||||
action_style,
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<14}", truncate(&e.agent, 13)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<10}", hash_short),
|
||||
Style::default().fg(theme::PURPLE),
|
||||
),
|
||||
Span::styled(format!(" {}", truncate(&e.detail, 24)), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.list_state);
|
||||
}
|
||||
|
||||
// ── Chain status + hints ──
|
||||
let chain_line = match state.chain_verified {
|
||||
None => Line::from(vec![Span::styled(
|
||||
" Chain: not verified",
|
||||
theme::dim_style(),
|
||||
)]),
|
||||
Some(true) => Line::from(vec![Span::styled(
|
||||
" Chain: \u{2714} Verified",
|
||||
Style::default().fg(theme::GREEN),
|
||||
)]),
|
||||
Some(false) => Line::from(vec![Span::styled(
|
||||
" Chain: \u{2718} Verification failed",
|
||||
Style::default().fg(theme::RED),
|
||||
)]),
|
||||
};
|
||||
|
||||
let hints = if !state.status_msg.is_empty() {
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" {}", state.status_msg),
|
||||
Style::default().fg(theme::GREEN),
|
||||
)])
|
||||
} else {
|
||||
Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [f] Filter [v] Verify Chain [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])
|
||||
};
|
||||
|
||||
f.render_widget(Paragraph::new(vec![chain_line, hints]), chunks[2]);
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
933
crates/openfang-cli/src/tui/screens/channels.rs
Normal file
933
crates/openfang-cli/src/tui/screens/channels.rs
Normal file
@@ -0,0 +1,933 @@
|
||||
//! Channels screen: list all 40 adapters, setup wizards, test & toggle.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ChannelInfo {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub category: String,
|
||||
pub status: ChannelStatus,
|
||||
pub env_vars: Vec<(String, bool)>, // (var_name, is_set)
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChannelStatus {
|
||||
Ready,
|
||||
MissingEnv,
|
||||
NotConfigured,
|
||||
}
|
||||
|
||||
// ── Channel definitions — all 40 adapters ───────────────────────────────────
|
||||
|
||||
struct ChannelDef {
|
||||
name: &'static str,
|
||||
display_name: &'static str,
|
||||
category: &'static str,
|
||||
env_vars: &'static [&'static str],
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
const CHANNEL_DEFS: &[ChannelDef] = &[
|
||||
// ── Messaging (12)
|
||||
ChannelDef {
|
||||
name: "telegram",
|
||||
display_name: "Telegram",
|
||||
category: "Messaging",
|
||||
env_vars: &["TELEGRAM_BOT_TOKEN"],
|
||||
description: "Telegram Bot API adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "discord",
|
||||
display_name: "Discord",
|
||||
category: "Messaging",
|
||||
env_vars: &["DISCORD_BOT_TOKEN"],
|
||||
description: "Discord bot adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "slack",
|
||||
display_name: "Slack",
|
||||
category: "Messaging",
|
||||
env_vars: &["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN"],
|
||||
description: "Slack Socket Mode adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "whatsapp",
|
||||
display_name: "WhatsApp",
|
||||
category: "Messaging",
|
||||
env_vars: &["WHATSAPP_ACCESS_TOKEN", "WHATSAPP_VERIFY_TOKEN"],
|
||||
description: "WhatsApp Cloud API adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "signal",
|
||||
display_name: "Signal",
|
||||
category: "Messaging",
|
||||
env_vars: &[],
|
||||
description: "Signal via signal-cli REST API",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "matrix",
|
||||
display_name: "Matrix",
|
||||
category: "Messaging",
|
||||
env_vars: &["MATRIX_ACCESS_TOKEN"],
|
||||
description: "Matrix/Element adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "email",
|
||||
display_name: "Email",
|
||||
category: "Messaging",
|
||||
env_vars: &["EMAIL_PASSWORD"],
|
||||
description: "IMAP/SMTP email adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "line",
|
||||
display_name: "LINE",
|
||||
category: "Messaging",
|
||||
env_vars: &["LINE_CHANNEL_SECRET", "LINE_CHANNEL_ACCESS_TOKEN"],
|
||||
description: "LINE Messaging API adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "viber",
|
||||
display_name: "Viber",
|
||||
category: "Messaging",
|
||||
env_vars: &["VIBER_AUTH_TOKEN"],
|
||||
description: "Viber Bot API adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "messenger",
|
||||
display_name: "Messenger",
|
||||
category: "Messaging",
|
||||
env_vars: &["MESSENGER_PAGE_TOKEN", "MESSENGER_VERIFY_TOKEN"],
|
||||
description: "Facebook Messenger adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "threema",
|
||||
display_name: "Threema",
|
||||
category: "Messaging",
|
||||
env_vars: &["THREEMA_SECRET"],
|
||||
description: "Threema Gateway adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "keybase",
|
||||
display_name: "Keybase",
|
||||
category: "Messaging",
|
||||
env_vars: &["KEYBASE_PAPERKEY"],
|
||||
description: "Keybase chat adapter",
|
||||
},
|
||||
// ── Social (5)
|
||||
ChannelDef {
|
||||
name: "reddit",
|
||||
display_name: "Reddit",
|
||||
category: "Social",
|
||||
env_vars: &["REDDIT_CLIENT_SECRET", "REDDIT_PASSWORD"],
|
||||
description: "Reddit API bot adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "mastodon",
|
||||
display_name: "Mastodon",
|
||||
category: "Social",
|
||||
env_vars: &["MASTODON_ACCESS_TOKEN"],
|
||||
description: "Mastodon Streaming API adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "bluesky",
|
||||
display_name: "Bluesky",
|
||||
category: "Social",
|
||||
env_vars: &["BLUESKY_APP_PASSWORD"],
|
||||
description: "Bluesky/AT Protocol adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "linkedin",
|
||||
display_name: "LinkedIn",
|
||||
category: "Social",
|
||||
env_vars: &["LINKEDIN_ACCESS_TOKEN"],
|
||||
description: "LinkedIn Messaging API adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "nostr",
|
||||
display_name: "Nostr",
|
||||
category: "Social",
|
||||
env_vars: &["NOSTR_PRIVATE_KEY"],
|
||||
description: "Nostr relay protocol adapter",
|
||||
},
|
||||
// ── Enterprise (10)
|
||||
ChannelDef {
|
||||
name: "teams",
|
||||
display_name: "Teams",
|
||||
category: "Enterprise",
|
||||
env_vars: &["TEAMS_APP_PASSWORD"],
|
||||
description: "Microsoft Teams Bot Framework adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "mattermost",
|
||||
display_name: "Mattermost",
|
||||
category: "Enterprise",
|
||||
env_vars: &["MATTERMOST_TOKEN"],
|
||||
description: "Mattermost WebSocket adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "google_chat",
|
||||
display_name: "Google Chat",
|
||||
category: "Enterprise",
|
||||
env_vars: &["GOOGLE_CHAT_SERVICE_ACCOUNT"],
|
||||
description: "Google Chat service account adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "webex",
|
||||
display_name: "Webex",
|
||||
category: "Enterprise",
|
||||
env_vars: &["WEBEX_BOT_TOKEN"],
|
||||
description: "Cisco Webex bot adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "feishu",
|
||||
display_name: "Feishu/Lark",
|
||||
category: "Enterprise",
|
||||
env_vars: &["FEISHU_APP_SECRET"],
|
||||
description: "Feishu/Lark Open Platform adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "dingtalk",
|
||||
display_name: "DingTalk",
|
||||
category: "Enterprise",
|
||||
env_vars: &["DINGTALK_ACCESS_TOKEN", "DINGTALK_SECRET"],
|
||||
description: "DingTalk Robot API adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "pumble",
|
||||
display_name: "Pumble",
|
||||
category: "Enterprise",
|
||||
env_vars: &["PUMBLE_BOT_TOKEN"],
|
||||
description: "Pumble bot adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "flock",
|
||||
display_name: "Flock",
|
||||
category: "Enterprise",
|
||||
env_vars: &["FLOCK_BOT_TOKEN"],
|
||||
description: "Flock bot adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "twist",
|
||||
display_name: "Twist",
|
||||
category: "Enterprise",
|
||||
env_vars: &["TWIST_TOKEN"],
|
||||
description: "Twist API v3 adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "zulip",
|
||||
display_name: "Zulip",
|
||||
category: "Enterprise",
|
||||
env_vars: &["ZULIP_API_KEY"],
|
||||
description: "Zulip event queue adapter",
|
||||
},
|
||||
// ── Developer (9)
|
||||
ChannelDef {
|
||||
name: "irc",
|
||||
display_name: "IRC",
|
||||
category: "Developer",
|
||||
env_vars: &[],
|
||||
description: "IRC raw TCP adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "xmpp",
|
||||
display_name: "XMPP",
|
||||
category: "Developer",
|
||||
env_vars: &["XMPP_PASSWORD"],
|
||||
description: "XMPP/Jabber adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "gitter",
|
||||
display_name: "Gitter",
|
||||
category: "Developer",
|
||||
env_vars: &["GITTER_TOKEN"],
|
||||
description: "Gitter Streaming API adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "discourse",
|
||||
display_name: "Discourse",
|
||||
category: "Developer",
|
||||
env_vars: &["DISCOURSE_API_KEY"],
|
||||
description: "Discourse forum API adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "revolt",
|
||||
display_name: "Revolt",
|
||||
category: "Developer",
|
||||
env_vars: &["REVOLT_BOT_TOKEN"],
|
||||
description: "Revolt bot adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "guilded",
|
||||
display_name: "Guilded",
|
||||
category: "Developer",
|
||||
env_vars: &["GUILDED_BOT_TOKEN"],
|
||||
description: "Guilded bot adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "nextcloud",
|
||||
display_name: "Nextcloud",
|
||||
category: "Developer",
|
||||
env_vars: &["NEXTCLOUD_TOKEN"],
|
||||
description: "Nextcloud Talk adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "rocketchat",
|
||||
display_name: "Rocket.Chat",
|
||||
category: "Developer",
|
||||
env_vars: &["ROCKETCHAT_TOKEN"],
|
||||
description: "Rocket.Chat REST adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "twitch",
|
||||
display_name: "Twitch",
|
||||
category: "Developer",
|
||||
env_vars: &["TWITCH_OAUTH_TOKEN"],
|
||||
description: "Twitch IRC gateway adapter",
|
||||
},
|
||||
// ── Notifications (4)
|
||||
ChannelDef {
|
||||
name: "ntfy",
|
||||
display_name: "ntfy",
|
||||
category: "Notifications",
|
||||
env_vars: &["NTFY_TOKEN"],
|
||||
description: "ntfy.sh pub/sub adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "gotify",
|
||||
display_name: "Gotify",
|
||||
category: "Notifications",
|
||||
env_vars: &["GOTIFY_APP_TOKEN", "GOTIFY_CLIENT_TOKEN"],
|
||||
description: "Gotify WebSocket adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "webhook",
|
||||
display_name: "Webhook",
|
||||
category: "Notifications",
|
||||
env_vars: &["WEBHOOK_SECRET"],
|
||||
description: "Generic webhook adapter",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "mumble",
|
||||
display_name: "Mumble",
|
||||
category: "Notifications",
|
||||
env_vars: &["MUMBLE_PASSWORD"],
|
||||
description: "Mumble text chat adapter",
|
||||
},
|
||||
];
|
||||
|
||||
const CATEGORIES: &[&str] = &[
|
||||
"All",
|
||||
"Messaging",
|
||||
"Social",
|
||||
"Enterprise",
|
||||
"Developer",
|
||||
"Notifications",
|
||||
];
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum ChannelSubScreen {
|
||||
List,
|
||||
Setup,
|
||||
Testing,
|
||||
}
|
||||
|
||||
pub struct ChannelState {
|
||||
pub sub: ChannelSubScreen,
|
||||
pub channels: Vec<ChannelInfo>,
|
||||
pub list_state: ListState,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
// Category filter
|
||||
pub category_idx: usize,
|
||||
// Setup wizard
|
||||
pub setup_channel_idx: Option<usize>,
|
||||
pub setup_field_idx: usize,
|
||||
pub setup_input: String,
|
||||
pub setup_values: Vec<(String, String)>, // collected (env_var, value) pairs
|
||||
// Test
|
||||
pub test_result: Option<(bool, String)>,
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum ChannelAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
TestChannel(String),
|
||||
ToggleChannel(String, bool),
|
||||
SaveChannel(String, Vec<(String, String)>),
|
||||
}
|
||||
|
||||
impl ChannelState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sub: ChannelSubScreen::List,
|
||||
channels: Vec::new(),
|
||||
list_state: ListState::default(),
|
||||
loading: false,
|
||||
tick: 0,
|
||||
category_idx: 0,
|
||||
setup_channel_idx: None,
|
||||
setup_field_idx: 0,
|
||||
setup_input: String::new(),
|
||||
setup_values: Vec::new(),
|
||||
test_result: None,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
fn current_category(&self) -> &str {
|
||||
CATEGORIES[self.category_idx]
|
||||
}
|
||||
|
||||
fn filtered_channels(&self) -> Vec<&ChannelInfo> {
|
||||
let cat = self.current_category();
|
||||
self.channels
|
||||
.iter()
|
||||
.filter(|ch| cat == "All" || ch.category == cat)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ready_count(&self) -> usize {
|
||||
self.channels
|
||||
.iter()
|
||||
.filter(|ch| ch.status == ChannelStatus::Ready)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Build the default channel list from env var detection.
|
||||
pub fn build_default_channels(&mut self) {
|
||||
self.channels.clear();
|
||||
for def in CHANNEL_DEFS {
|
||||
let env_vars: Vec<(String, bool)> = def
|
||||
.env_vars
|
||||
.iter()
|
||||
.map(|v| (v.to_string(), std::env::var(v).is_ok()))
|
||||
.collect();
|
||||
let all_set = env_vars.is_empty() || env_vars.iter().all(|(_, set)| *set);
|
||||
let any_set = env_vars.iter().any(|(_, set)| *set);
|
||||
let status = if all_set && !env_vars.is_empty() {
|
||||
ChannelStatus::Ready
|
||||
} else if any_set {
|
||||
ChannelStatus::MissingEnv
|
||||
} else {
|
||||
ChannelStatus::NotConfigured
|
||||
};
|
||||
self.channels.push(ChannelInfo {
|
||||
name: def.name.to_string(),
|
||||
display_name: def.display_name.to_string(),
|
||||
category: def.category.to_string(),
|
||||
status,
|
||||
env_vars,
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> ChannelAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return ChannelAction::Continue;
|
||||
}
|
||||
match self.sub {
|
||||
ChannelSubScreen::List => self.handle_list(key),
|
||||
ChannelSubScreen::Setup => self.handle_setup(key),
|
||||
ChannelSubScreen::Testing => self.handle_testing(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_list(&mut self, key: KeyEvent) -> ChannelAction {
|
||||
let filtered = self.filtered_channels();
|
||||
let total = filtered.len();
|
||||
if total == 0 {
|
||||
match key.code {
|
||||
KeyCode::Char('r') => return ChannelAction::Refresh,
|
||||
KeyCode::Tab => {
|
||||
self.category_idx = (self.category_idx + 1) % CATEGORIES.len();
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
self.category_idx = if self.category_idx == 0 {
|
||||
CATEGORIES.len() - 1
|
||||
} else {
|
||||
self.category_idx - 1
|
||||
};
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return ChannelAction::Continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.category_idx = (self.category_idx + 1) % CATEGORIES.len();
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
self.category_idx = if self.category_idx == 0 {
|
||||
CATEGORIES.len() - 1
|
||||
} else {
|
||||
self.category_idx - 1
|
||||
};
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = self.list_state.selected() {
|
||||
let filtered = self.filtered_channels();
|
||||
if let Some(ch) = filtered.get(sel) {
|
||||
// Find the global index for this channel
|
||||
let ch_name = ch.name.clone();
|
||||
if let Some(idx) = self.channels.iter().position(|c| c.name == ch_name) {
|
||||
self.setup_channel_idx = Some(idx);
|
||||
self.setup_field_idx = 0;
|
||||
self.setup_input.clear();
|
||||
self.setup_values.clear();
|
||||
self.sub = ChannelSubScreen::Setup;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('t') => {
|
||||
if let Some(sel) = self.list_state.selected() {
|
||||
let filtered = self.filtered_channels();
|
||||
if let Some(ch) = filtered.get(sel) {
|
||||
let name = ch.name.clone();
|
||||
self.test_result = None;
|
||||
self.sub = ChannelSubScreen::Testing;
|
||||
return ChannelAction::TestChannel(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
if let Some(sel) = self.list_state.selected() {
|
||||
let filtered = self.filtered_channels();
|
||||
if let Some(ch) = filtered.get(sel) {
|
||||
let name = ch.name.clone();
|
||||
if let Some(c) = self.channels.iter_mut().find(|c| c.name == name) {
|
||||
c.enabled = true;
|
||||
}
|
||||
return ChannelAction::ToggleChannel(name, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
if let Some(sel) = self.list_state.selected() {
|
||||
let filtered = self.filtered_channels();
|
||||
if let Some(ch) = filtered.get(sel) {
|
||||
let name = ch.name.clone();
|
||||
if let Some(c) = self.channels.iter_mut().find(|c| c.name == name) {
|
||||
c.enabled = false;
|
||||
}
|
||||
return ChannelAction::ToggleChannel(name, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return ChannelAction::Refresh,
|
||||
_ => {}
|
||||
}
|
||||
ChannelAction::Continue
|
||||
}
|
||||
|
||||
fn handle_setup(&mut self, key: KeyEvent) -> ChannelAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.sub = ChannelSubScreen::List;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.setup_input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.setup_input.pop();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(idx) = self.setup_channel_idx {
|
||||
if idx < self.channels.len() {
|
||||
let env_vars = &CHANNEL_DEFS
|
||||
.iter()
|
||||
.find(|d| d.name == self.channels[idx].name)
|
||||
.map(|d| d.env_vars)
|
||||
.unwrap_or(&[]);
|
||||
|
||||
// Save current field value
|
||||
if self.setup_field_idx < env_vars.len() && !self.setup_input.is_empty() {
|
||||
self.setup_values.push((
|
||||
env_vars[self.setup_field_idx].to_string(),
|
||||
self.setup_input.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
if self.setup_field_idx + 1 < env_vars.len() {
|
||||
self.setup_field_idx += 1;
|
||||
self.setup_input.clear();
|
||||
} else {
|
||||
// All fields collected — emit save action
|
||||
let name = self.channels[idx].name.clone();
|
||||
let values = self.setup_values.clone();
|
||||
self.sub = ChannelSubScreen::List;
|
||||
if !values.is_empty() {
|
||||
return ChannelAction::SaveChannel(name, values);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ChannelAction::Continue
|
||||
}
|
||||
|
||||
fn handle_testing(&mut self, key: KeyEvent) -> ChannelAction {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Enter => {
|
||||
self.sub = ChannelSubScreen::List;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ChannelAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut ChannelState) {
|
||||
let ready = state.ready_count();
|
||||
let total = state.channels.len();
|
||||
let title = format!(" Channels ({ready}/{total} ready) ");
|
||||
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(title, theme::title_style())]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
match state.sub {
|
||||
ChannelSubScreen::List => draw_list(f, inner, state),
|
||||
ChannelSubScreen::Setup => draw_setup(f, inner, state),
|
||||
ChannelSubScreen::Testing => draw_testing(f, inner, state),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_list(f: &mut Frame, area: Rect, state: &mut ChannelState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // category tabs
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Category tabs
|
||||
let cat_spans: Vec<Span> = CATEGORIES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, cat)| {
|
||||
if i == state.category_idx {
|
||||
Span::styled(
|
||||
format!(" [{cat}] "),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else {
|
||||
Span::styled(format!(" {cat} "), theme::dim_style())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
f.render_widget(Paragraph::new(Line::from(cat_spans)), chunks[0]);
|
||||
|
||||
// Header
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<18} {:<14} {:<16} {}",
|
||||
"Channel", "Category", "Status", "Env Vars"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading channels\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[2],
|
||||
);
|
||||
} else {
|
||||
let filtered = state.filtered_channels();
|
||||
let items: Vec<ListItem> = filtered
|
||||
.iter()
|
||||
.map(|ch| {
|
||||
let (badge, badge_style) = match ch.status {
|
||||
ChannelStatus::Ready => ("[Ready]", theme::channel_ready()),
|
||||
ChannelStatus::MissingEnv => ("[Missing env]", theme::channel_missing()),
|
||||
ChannelStatus::NotConfigured => ("[Not configured]", theme::channel_off()),
|
||||
};
|
||||
let env_summary: String = ch
|
||||
.env_vars
|
||||
.iter()
|
||||
.map(|(v, set)| {
|
||||
if *set {
|
||||
format!("\u{2714}{v}")
|
||||
} else {
|
||||
format!("\u{2718}{v}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let cat_display = format!("{:<14}", ch.category);
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<18}", ch.display_name),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(cat_display, theme::dim_style()),
|
||||
Span::styled(format!(" {:<16}", badge), badge_style),
|
||||
Span::styled(format!(" {env_summary}"), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[2], &mut state.list_state);
|
||||
}
|
||||
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [Tab] Category [Enter] Setup [t] Test [e/d] Enable/Disable [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)]));
|
||||
f.render_widget(hints, chunks[3]);
|
||||
}
|
||||
|
||||
fn draw_setup(f: &mut Frame, area: Rect, state: &ChannelState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(3), // title + description
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Length(2), // current field
|
||||
Constraint::Length(1), // input
|
||||
Constraint::Min(2), // TOML preview
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let (ch_name, ch_display, ch_desc, env_vars) = if let Some(idx) = state.setup_channel_idx {
|
||||
if let Some(def) = CHANNEL_DEFS
|
||||
.iter()
|
||||
.find(|d| idx < state.channels.len() && d.name == state.channels[idx].name)
|
||||
{
|
||||
(def.name, def.display_name, def.description, def.env_vars)
|
||||
} else {
|
||||
("?", "?", "", &[] as &[&str])
|
||||
}
|
||||
} else {
|
||||
("?", "?", "", &[] as &[&str])
|
||||
};
|
||||
|
||||
// Title
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" Setup: {ch_display}"),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" {ch_desc}"),
|
||||
theme::dim_style(),
|
||||
)]),
|
||||
]),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
// Separator
|
||||
let sep = "\u{2500}".repeat(chunks[1].width as usize);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(sep, theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
// Current field
|
||||
if env_vars.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" This channel has no secret env vars — configure via config.toml",
|
||||
theme::dim_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
} else if state.setup_field_idx < env_vars.len() {
|
||||
let var = env_vars[state.setup_field_idx];
|
||||
let field_num = state.setup_field_idx + 1;
|
||||
let total = env_vars.len();
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(format!(" [{field_num}/{total}] Set ")),
|
||||
Span::styled(var, Style::default().fg(theme::YELLOW)),
|
||||
Span::raw(":"),
|
||||
])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
|
||||
// Input
|
||||
let display = if state.setup_input.is_empty() {
|
||||
"paste value here..."
|
||||
} else {
|
||||
&state.setup_input
|
||||
};
|
||||
let style = if state.setup_input.is_empty() {
|
||||
theme::dim_style()
|
||||
} else {
|
||||
theme::input_style()
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(" > "),
|
||||
Span::styled(display, style),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
])),
|
||||
chunks[3],
|
||||
);
|
||||
|
||||
// TOML preview
|
||||
let mut toml_lines = vec![Line::from(Span::styled(
|
||||
" Add to config.toml:",
|
||||
theme::dim_style(),
|
||||
))];
|
||||
toml_lines.push(Line::from(Span::styled(
|
||||
format!(" [channels.{ch_name}]"),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)));
|
||||
for var in env_vars {
|
||||
toml_lines.push(Line::from(Span::styled(
|
||||
format!(" # {var} = \"...\""),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)));
|
||||
}
|
||||
f.render_widget(Paragraph::new(toml_lines), chunks[4]);
|
||||
|
||||
// Hints
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [Enter] Next field / Save [Esc] Back",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[5],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_testing(f: &mut Frame, area: Rect, state: &ChannelState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(2),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let ch_name = state
|
||||
.setup_channel_idx
|
||||
.and_then(|i| state.channels.get(i))
|
||||
.map(|c| c.display_name.as_str())
|
||||
.or_else(|| {
|
||||
state.list_state.selected().and_then(|i| {
|
||||
let filtered = state.filtered_channels();
|
||||
filtered.get(i).map(|c| c.display_name.as_str())
|
||||
})
|
||||
})
|
||||
.unwrap_or("?");
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" Testing {ch_name}\u{2026}"),
|
||||
Style::default().fg(theme::CYAN),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
match &state.test_result {
|
||||
None => {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Checking credentials\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
Some((true, msg)) => {
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN)),
|
||||
Span::raw("Test passed"),
|
||||
]),
|
||||
Line::from(vec![Span::styled(format!(" {msg}"), theme::dim_style())]),
|
||||
]),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
Some((false, msg)) => {
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" \u{2718} ", Style::default().fg(theme::RED)),
|
||||
Span::raw("Test failed"),
|
||||
]),
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" {msg}"),
|
||||
Style::default().fg(theme::RED),
|
||||
)]),
|
||||
]),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [Enter/Esc] Back",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
666
crates/openfang-cli/src/tui/screens/chat.rs
Normal file
666
crates/openfang-cli/src/tui/screens/chat.rs
Normal file
@@ -0,0 +1,666 @@
|
||||
//! Chat screen: scrollable message history, streaming output, tool spinners, input.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Tool call metadata for rich rendering.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolInfo {
|
||||
pub name: String,
|
||||
pub input: String,
|
||||
pub result: String,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
/// A single message in the chat history.
|
||||
#[derive(Clone)]
|
||||
pub struct ChatMessage {
|
||||
pub role: Role,
|
||||
pub text: String,
|
||||
pub tool: Option<ToolInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
User,
|
||||
Agent,
|
||||
System,
|
||||
Tool,
|
||||
}
|
||||
|
||||
pub struct ChatState {
|
||||
/// Agent display name.
|
||||
pub agent_name: String,
|
||||
/// Provider/model for the title bar.
|
||||
pub model_label: String,
|
||||
/// Connection mode label.
|
||||
pub mode_label: String,
|
||||
/// Full chat history.
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// Current streaming text being accumulated.
|
||||
pub streaming_text: String,
|
||||
/// Whether we are currently streaming.
|
||||
pub is_streaming: bool,
|
||||
/// Waiting for first token (shows "thinking..." spinner).
|
||||
pub thinking: bool,
|
||||
/// Current tool being executed (spinner).
|
||||
pub active_tool: Option<String>,
|
||||
/// Spinner frame index.
|
||||
pub spinner_frame: usize,
|
||||
/// Input line buffer.
|
||||
pub input: String,
|
||||
/// Scroll offset (lines from the bottom).
|
||||
pub scroll_offset: u16,
|
||||
/// Token usage from last response.
|
||||
pub last_tokens: Option<(u64, u64)>,
|
||||
/// Cost in USD from last response.
|
||||
pub last_cost_usd: Option<f64>,
|
||||
/// Characters received during current stream (~4 chars ≈ 1 token).
|
||||
pub streaming_chars: usize,
|
||||
/// Status message (errors, etc.)
|
||||
pub status_msg: Option<String>,
|
||||
/// Messages staged while the agent is streaming — sent automatically when done.
|
||||
pub staged_messages: Vec<String>,
|
||||
/// Accumulates ToolInputDelta text for the current tool call.
|
||||
pub tool_input_buf: String,
|
||||
}
|
||||
|
||||
pub enum ChatAction {
|
||||
Continue,
|
||||
SendMessage(String),
|
||||
Back,
|
||||
SlashCommand(String),
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
agent_name: String::new(),
|
||||
model_label: String::new(),
|
||||
mode_label: String::new(),
|
||||
messages: Vec::new(),
|
||||
streaming_text: String::new(),
|
||||
is_streaming: false,
|
||||
thinking: false,
|
||||
active_tool: None,
|
||||
spinner_frame: 0,
|
||||
input: String::new(),
|
||||
scroll_offset: 0,
|
||||
last_tokens: None,
|
||||
last_cost_usd: None,
|
||||
streaming_chars: 0,
|
||||
status_msg: None,
|
||||
staged_messages: Vec::new(),
|
||||
tool_input_buf: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.messages.clear();
|
||||
self.streaming_text.clear();
|
||||
self.is_streaming = false;
|
||||
self.thinking = false;
|
||||
self.active_tool = None;
|
||||
self.spinner_frame = 0;
|
||||
self.input.clear();
|
||||
self.scroll_offset = 0;
|
||||
self.last_tokens = None;
|
||||
self.last_cost_usd = None;
|
||||
self.streaming_chars = 0;
|
||||
self.status_msg = None;
|
||||
self.staged_messages.clear();
|
||||
self.tool_input_buf.clear();
|
||||
}
|
||||
|
||||
/// Push a completed message into history.
|
||||
pub fn push_message(&mut self, role: Role, text: String) {
|
||||
self.messages.push(ChatMessage {
|
||||
role,
|
||||
text,
|
||||
tool: None,
|
||||
});
|
||||
self.scroll_offset = 0; // Auto-scroll to bottom
|
||||
}
|
||||
|
||||
/// Append streaming text delta.
|
||||
pub fn append_stream(&mut self, text: &str) {
|
||||
self.thinking = false;
|
||||
self.streaming_text.push_str(text);
|
||||
self.streaming_chars += text.len();
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
/// Take the next staged message (if any) for auto-send after stream completes.
|
||||
pub fn take_staged(&mut self) -> Option<String> {
|
||||
if self.staged_messages.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.staged_messages.remove(0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalize streaming: move accumulated text to history.
|
||||
pub fn finalize_stream(&mut self) {
|
||||
if !self.streaming_text.is_empty() {
|
||||
let text = sanitize_function_tags(&std::mem::take(&mut self.streaming_text));
|
||||
self.push_message(Role::Agent, text);
|
||||
}
|
||||
self.is_streaming = false;
|
||||
self.thinking = false;
|
||||
self.active_tool = None;
|
||||
self.streaming_chars = 0;
|
||||
self.tool_input_buf.clear();
|
||||
}
|
||||
|
||||
/// Set a tool as active (spinner) and clear the input accumulator.
|
||||
pub fn tool_start(&mut self, name: &str) {
|
||||
self.active_tool = Some(name.to_string());
|
||||
self.tool_input_buf.clear();
|
||||
self.spinner_frame = 0;
|
||||
}
|
||||
|
||||
/// A tool_use block is complete — push a "running" tool message with input.
|
||||
pub fn tool_use_end(&mut self, name: &str, input: &str) {
|
||||
self.messages.push(ChatMessage {
|
||||
role: Role::Tool,
|
||||
text: name.to_string(),
|
||||
tool: Some(ToolInfo {
|
||||
name: name.to_string(),
|
||||
input: input.to_string(),
|
||||
result: String::new(),
|
||||
is_error: false,
|
||||
}),
|
||||
});
|
||||
self.scroll_offset = 0;
|
||||
self.active_tool = None;
|
||||
}
|
||||
|
||||
/// Fill in the result for the most recent matching tool message.
|
||||
pub fn tool_result(&mut self, name: &str, result: &str, is_error: bool) {
|
||||
// Walk backwards to find the last Tool message matching this name
|
||||
for msg in self.messages.iter_mut().rev() {
|
||||
if msg.role == Role::Tool {
|
||||
if let Some(ref mut info) = msg.tool {
|
||||
if info.name == name && info.result.is_empty() {
|
||||
info.result = result.to_string();
|
||||
info.is_error = is_error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.active_tool = None;
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
/// Advance the spinner frame (called on tick).
|
||||
pub fn tick(&mut self) {
|
||||
if self.active_tool.is_some() || self.thinking {
|
||||
self.spinner_frame = (self.spinner_frame + 1) % theme::SPINNER_FRAMES.len();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> ChatAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return ChatAction::Back;
|
||||
}
|
||||
|
||||
// When streaming, allow typing + staging messages, scrolling, and Esc
|
||||
if self.is_streaming {
|
||||
match key.code {
|
||||
KeyCode::Esc => return ChatAction::Back,
|
||||
KeyCode::Enter => {
|
||||
let msg = self.input.trim().to_string();
|
||||
self.input.clear();
|
||||
if !msg.is_empty() && !msg.starts_with('/') {
|
||||
self.staged_messages.push(msg.clone());
|
||||
self.push_message(Role::User, msg);
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.input.pop();
|
||||
}
|
||||
KeyCode::Up => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(1);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(10);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(10);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => ChatAction::Back,
|
||||
KeyCode::Enter => {
|
||||
let msg = self.input.trim().to_string();
|
||||
self.input.clear();
|
||||
if msg.is_empty() {
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
if msg.starts_with('/') {
|
||||
return ChatAction::SlashCommand(msg);
|
||||
}
|
||||
self.push_message(Role::User, msg.clone());
|
||||
ChatAction::SendMessage(msg)
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.input.push(c);
|
||||
ChatAction::Continue
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.input.pop();
|
||||
ChatAction::Continue
|
||||
}
|
||||
KeyCode::Up => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(1);
|
||||
ChatAction::Continue
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
ChatAction::Continue
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(10);
|
||||
ChatAction::Continue
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(10);
|
||||
ChatAction::Continue
|
||||
}
|
||||
_ => ChatAction::Continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the chat screen.
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut ChatState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
format!(" {} ", state.agent_name),
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.title_alignment(Alignment::Left)
|
||||
.title_bottom(Line::from(vec![Span::styled(
|
||||
format!(" {} \u{2014} {} ", state.model_label, state.mode_label),
|
||||
theme::dim_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::BORDER))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Layout: messages | separator | input | hints
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Min(3), // messages area
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Length(1), // input
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// ── Messages ─────────────────────────────────────────────────────────────
|
||||
draw_messages(f, chunks[0], state);
|
||||
|
||||
// ── Separator ────────────────────────────────────────────────────────────
|
||||
let sep_line = "\u{2500}".repeat(chunks[1].width as usize);
|
||||
let sep = Paragraph::new(Line::from(vec![Span::styled(
|
||||
sep_line,
|
||||
Style::default().fg(theme::BORDER),
|
||||
)]));
|
||||
f.render_widget(sep, chunks[1]);
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────────────
|
||||
let input_line = if state.is_streaming {
|
||||
let mut spans = vec![
|
||||
Span::styled(" > ", Style::default().fg(theme::YELLOW)),
|
||||
Span::raw(&state.input),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::YELLOW)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
];
|
||||
if !state.staged_messages.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
format!(" ({} staged)", state.staged_messages.len()),
|
||||
Style::default().fg(theme::PURPLE),
|
||||
));
|
||||
}
|
||||
Line::from(spans)
|
||||
} else {
|
||||
Line::from(vec![
|
||||
Span::styled(" > ", theme::input_style()),
|
||||
Span::raw(&state.input),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
])
|
||||
};
|
||||
f.render_widget(Paragraph::new(input_line), chunks[2]);
|
||||
|
||||
// ── Hints ────────────────────────────────────────────────────────────────
|
||||
let hints = if state.is_streaming {
|
||||
" [Enter] Stage [\u{2191}\u{2193}] Scroll [Esc] Stop"
|
||||
} else {
|
||||
" [Enter] Send [\u{2191}\u{2193}/PgUp/PgDn] Scroll [Esc] Back"
|
||||
};
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(hints, theme::hint_style())]));
|
||||
f.render_widget(hints, chunks[3]);
|
||||
}
|
||||
|
||||
fn draw_messages(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
let width = area.width as usize;
|
||||
if width < 4 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Empty state: show welcome message when no messages yet
|
||||
if state.messages.is_empty() && state.streaming_text.is_empty() && !state.thinking {
|
||||
let blank_lines = area.height.saturating_sub(4) / 2;
|
||||
for _ in 0..blank_lines {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
" Send a message to start chatting.",
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
" Type /help for available commands.",
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
let para = Paragraph::new(lines);
|
||||
f.render_widget(para, area);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build lines from message history
|
||||
for msg in &state.messages {
|
||||
match msg.role {
|
||||
Role::User => {
|
||||
lines.push(Line::from(""));
|
||||
let wrapped = wrap_text(&msg.text, width.saturating_sub(6));
|
||||
for (i, wline) in wrapped.into_iter().enumerate() {
|
||||
if i == 0 {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" \u{276f} ", theme::input_style()),
|
||||
Span::styled(wline, Style::default().fg(theme::TEXT_PRIMARY)),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(wline, Style::default().fg(theme::TEXT_PRIMARY)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
Role::Agent => {
|
||||
lines.push(Line::from(""));
|
||||
let wrapped = wrap_text(&msg.text, width.saturating_sub(4));
|
||||
for wline in wrapped {
|
||||
lines.push(Line::from(vec![Span::raw(" "), Span::raw(wline)]));
|
||||
}
|
||||
}
|
||||
Role::System => {
|
||||
for sline in msg.text.lines() {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" {sline}"),
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
}
|
||||
}
|
||||
Role::Tool => {
|
||||
if let Some(ref info) = msg.tool {
|
||||
let max_val = width.saturating_sub(14);
|
||||
let is_err = info.is_error;
|
||||
let border_color = if is_err { theme::RED } else { theme::GREEN };
|
||||
let icon = if info.result.is_empty() {
|
||||
"\u{2026}" // … (running)
|
||||
} else if is_err {
|
||||
"\u{2718}" // ✘
|
||||
} else {
|
||||
"\u{2714}" // ✔
|
||||
};
|
||||
let icon_color = if is_err { theme::RED } else { theme::GREEN };
|
||||
|
||||
// Header: ┌─ ✔ tool_name ────────
|
||||
let header_rest = width.saturating_sub(6 + info.name.len());
|
||||
let fill = "\u{2500}".repeat(header_rest);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" \u{250c}\u{2500} ", Style::default().fg(border_color)),
|
||||
Span::styled(format!("{icon} "), Style::default().fg(icon_color)),
|
||||
Span::styled(
|
||||
info.name.clone(),
|
||||
Style::default()
|
||||
.fg(theme::YELLOW)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!(" {fill}"), Style::default().fg(border_color)),
|
||||
]));
|
||||
|
||||
// Input line (skip if empty)
|
||||
if !info.input.is_empty() {
|
||||
let val = truncate_line(&info.input, max_val);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" \u{2502} ", Style::default().fg(border_color)),
|
||||
Span::styled("input: ", theme::dim_style()),
|
||||
Span::raw(val),
|
||||
]));
|
||||
}
|
||||
|
||||
// Result / error / running line
|
||||
if info.result.is_empty() {
|
||||
let spinner = theme::SPINNER_FRAMES
|
||||
[state.spinner_frame % theme::SPINNER_FRAMES.len()];
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" \u{2502} ", Style::default().fg(border_color)),
|
||||
Span::styled(
|
||||
format!("{spinner} running\u{2026}"),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
]));
|
||||
} else if is_err {
|
||||
let val = truncate_line(&info.result, max_val);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" \u{2502} ", Style::default().fg(border_color)),
|
||||
Span::styled("error: ", Style::default().fg(theme::RED)),
|
||||
Span::raw(val),
|
||||
]));
|
||||
} else {
|
||||
let val = truncate_line(&info.result, max_val);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" \u{2502} ", Style::default().fg(border_color)),
|
||||
Span::styled("result: ", theme::dim_style()),
|
||||
Span::raw(val),
|
||||
]));
|
||||
}
|
||||
|
||||
// Footer: └───────────
|
||||
let footer_fill = "\u{2500}".repeat(width.saturating_sub(4));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" \u{2514}{footer_fill}"),
|
||||
Style::default().fg(border_color),
|
||||
)]));
|
||||
} else {
|
||||
// Fallback for tool messages without ToolInfo
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" \u{2714} {}", msg.text),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add streaming text if any
|
||||
if !state.streaming_text.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
let wrapped = wrap_text(&state.streaming_text, width.saturating_sub(4));
|
||||
for wline in wrapped {
|
||||
lines.push(Line::from(vec![Span::raw(" "), Span::raw(wline)]));
|
||||
}
|
||||
}
|
||||
|
||||
// Add "thinking..." spinner while waiting for first token
|
||||
if state.thinking {
|
||||
let spinner = theme::SPINNER_FRAMES[state.spinner_frame];
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("thinking\u{2026}", Style::default().fg(theme::DIM)),
|
||||
]));
|
||||
}
|
||||
|
||||
// Add tool spinner if active
|
||||
if let Some(ref tool_name) = state.active_tool {
|
||||
let spinner = theme::SPINNER_FRAMES[state.spinner_frame];
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::RED)),
|
||||
Span::styled(tool_name.clone(), Style::default().fg(theme::YELLOW)),
|
||||
]));
|
||||
}
|
||||
|
||||
// Show estimated token count during streaming (~4 chars per token)
|
||||
if state.is_streaming && state.streaming_chars > 0 {
|
||||
let est_tokens = state.streaming_chars / 4;
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" ~{est_tokens} tokens"),
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
}
|
||||
|
||||
// Add token usage and cost if available
|
||||
if let Some((input, output)) = state.last_tokens {
|
||||
if input > 0 || output > 0 {
|
||||
let cost_str = match state.last_cost_usd {
|
||||
Some(c) if c > 0.0 => format!(" | ${:.4}", c),
|
||||
_ => String::new(),
|
||||
};
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" [tokens: {} in / {} out{}]", input, output, cost_str),
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
}
|
||||
}
|
||||
|
||||
// Add status message if any
|
||||
if let Some(ref msg) = state.status_msg {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" {msg}"),
|
||||
Style::default().fg(theme::RED),
|
||||
)]));
|
||||
}
|
||||
|
||||
// Compute scroll — we want to show the bottom of the chat by default
|
||||
let total_lines = lines.len() as u16;
|
||||
let visible_height = area.height;
|
||||
let max_scroll = total_lines.saturating_sub(visible_height);
|
||||
let scroll = max_scroll
|
||||
.saturating_sub(state.scroll_offset)
|
||||
.min(max_scroll);
|
||||
|
||||
let para = Paragraph::new(lines).scroll((scroll, 0));
|
||||
f.render_widget(para, area);
|
||||
|
||||
// Show scroll indicator if not at bottom
|
||||
if state.scroll_offset > 0 && total_lines > visible_height {
|
||||
let above = scroll;
|
||||
let below = total_lines.saturating_sub(scroll + visible_height);
|
||||
let indicator = format!("{}↑ {}↓", above, below);
|
||||
let ind_area = Rect {
|
||||
x: area.x + area.width.saturating_sub(indicator.len() as u16 + 1),
|
||||
y: area.y + area.height.saturating_sub(1),
|
||||
width: indicator.len() as u16,
|
||||
height: 1,
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(indicator, theme::dim_style())),
|
||||
ind_area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple word-wrapping.
|
||||
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
|
||||
if max_width == 0 {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
for line in text.lines() {
|
||||
if line.is_empty() {
|
||||
result.push(String::new());
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut current = String::new();
|
||||
for word in line.split_whitespace() {
|
||||
if current.is_empty() {
|
||||
current = word.to_string();
|
||||
} else if current.len() + 1 + word.len() <= max_width {
|
||||
current.push(' ');
|
||||
current.push_str(word);
|
||||
} else {
|
||||
result.push(current);
|
||||
current = word.to_string();
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
result.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
result.push(String::new());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Strip leaked `<function>...</function>` tags from streaming text.
|
||||
fn sanitize_function_tags(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(start) = rest.find("<function>") {
|
||||
out.push_str(&rest[..start]);
|
||||
if let Some(end) = rest[start..].find("</function>") {
|
||||
rest = &rest[start + end + "</function>".len()..];
|
||||
} else {
|
||||
// Unclosed tag — drop from <function> to end
|
||||
rest = "";
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Truncate a string to `max_len` chars, appending `…` if truncated.
|
||||
fn truncate_line(s: &str, max_len: usize) -> String {
|
||||
if s.len() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max_len.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
278
crates/openfang-cli/src/tui/screens/dashboard.rs
Normal file
278
crates/openfang-cli/src/tui/screens/dashboard.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
//! Dashboard screen: system overview with stat cards and scrollable audit trail.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AuditRow {
|
||||
pub timestamp: String,
|
||||
pub agent: String,
|
||||
pub action: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct DashboardState {
|
||||
pub agent_count: u64,
|
||||
pub uptime_secs: u64,
|
||||
pub version: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub recent_audit: Vec<AuditRow>,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub audit_scroll: u16,
|
||||
}
|
||||
|
||||
pub enum DashboardAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
GoToAgents,
|
||||
}
|
||||
|
||||
impl DashboardState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
agent_count: 0,
|
||||
uptime_secs: 0,
|
||||
version: String::new(),
|
||||
provider: String::new(),
|
||||
model: String::new(),
|
||||
recent_audit: Vec::new(),
|
||||
loading: false,
|
||||
tick: 0,
|
||||
audit_scroll: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> DashboardAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return DashboardAction::Continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('r') => DashboardAction::Refresh,
|
||||
KeyCode::Char('a') => DashboardAction::GoToAgents,
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.audit_scroll = self.audit_scroll.saturating_add(1);
|
||||
DashboardAction::Continue
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.audit_scroll = self.audit_scroll.saturating_sub(1);
|
||||
DashboardAction::Continue
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
self.audit_scroll = self.audit_scroll.saturating_add(10);
|
||||
DashboardAction::Continue
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
self.audit_scroll = self.audit_scroll.saturating_sub(10);
|
||||
DashboardAction::Continue
|
||||
}
|
||||
_ => DashboardAction::Continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut DashboardState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Dashboard ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(5), // stat cards
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(4), // audit trail
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// ── Stat cards ──────────────────────────────────────────────────────────
|
||||
draw_stat_cards(f, chunks[0], state);
|
||||
|
||||
// ── Separator ───────────────────────────────────────────────────────────
|
||||
let sep = "\u{2500}".repeat(chunks[1].width as usize);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(sep, theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
// ── Audit trail ─────────────────────────────────────────────────────────
|
||||
draw_audit_trail(f, chunks[2], state);
|
||||
|
||||
// ── Hints ───────────────────────────────────────────────────────────────
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [r] Refresh [a] Go to Agents [\u{2191}\u{2193}] Scroll audit",
|
||||
theme::hint_style(),
|
||||
)]));
|
||||
f.render_widget(hints, chunks[3]);
|
||||
}
|
||||
|
||||
fn draw_stat_cards(f: &mut Frame, area: Rect, state: &DashboardState) {
|
||||
let cols = Layout::horizontal([
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(34),
|
||||
Constraint::Percentage(33),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Agents card
|
||||
let agents_block = Block::default()
|
||||
.title(Span::styled(" Agents ", Style::default().fg(theme::CYAN)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::DIM));
|
||||
let agents_inner = agents_block.inner(cols[0]);
|
||||
f.render_widget(agents_block, cols[0]);
|
||||
let count_text = format!("{}", state.agent_count);
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {count_text}"),
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" active", theme::dim_style()),
|
||||
])),
|
||||
agents_inner,
|
||||
);
|
||||
|
||||
// Uptime card
|
||||
let uptime_block = Block::default()
|
||||
.title(Span::styled(" Uptime ", Style::default().fg(theme::CYAN)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::DIM));
|
||||
let uptime_inner = uptime_block.inner(cols[1]);
|
||||
f.render_widget(uptime_block, cols[1]);
|
||||
let uptime_str = format_uptime(state.uptime_secs);
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {uptime_str}"),
|
||||
Style::default()
|
||||
.fg(theme::YELLOW)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)])),
|
||||
uptime_inner,
|
||||
);
|
||||
|
||||
// Provider card
|
||||
let provider_block = Block::default()
|
||||
.title(Span::styled(" Provider ", Style::default().fg(theme::CYAN)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::DIM));
|
||||
let provider_inner = provider_block.inner(cols[2]);
|
||||
f.render_widget(provider_block, cols[2]);
|
||||
let provider_text = if state.provider.is_empty() {
|
||||
"not set".to_string()
|
||||
} else {
|
||||
format!("{}/{}", state.provider, state.model)
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {provider_text}"),
|
||||
Style::default().fg(theme::CYAN),
|
||||
)])),
|
||||
provider_inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_audit_trail(f: &mut Frame, area: Rect, state: &DashboardState) {
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading audit trail\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if state.recent_audit.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(" No audit entries yet.", theme::dim_style())),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Header
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<20} {:<14} {:<16} {}",
|
||||
"Timestamp", "Agent", "Action", "Detail"
|
||||
),
|
||||
theme::table_header(),
|
||||
)]));
|
||||
|
||||
for row in &state.recent_audit {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:<20}", row.timestamp), theme::dim_style()),
|
||||
Span::styled(
|
||||
format!(" {:<14}", truncate(&row.agent, 13)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<16}", truncate(&row.action, 15)),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {}", truncate(&row.detail, 30)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
let total = lines.len() as u16;
|
||||
let visible = area.height;
|
||||
let max_scroll = total.saturating_sub(visible);
|
||||
let scroll = max_scroll
|
||||
.saturating_sub(state.audit_scroll)
|
||||
.min(max_scroll);
|
||||
|
||||
f.render_widget(Paragraph::new(lines).scroll((scroll, 0)), area);
|
||||
}
|
||||
|
||||
fn format_uptime(secs: u64) -> String {
|
||||
if secs < 60 {
|
||||
format!("{secs}s")
|
||||
} else if secs < 3600 {
|
||||
format!("{}m {}s", secs / 60, secs % 60)
|
||||
} else if secs < 86400 {
|
||||
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
|
||||
} else {
|
||||
format!("{}d {}h", secs / 86400, (secs % 86400) / 3600)
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
589
crates/openfang-cli/src/tui/screens/extensions.rs
Normal file
589
crates/openfang-cli/src/tui/screens/extensions.rs
Normal file
@@ -0,0 +1,589 @@
|
||||
//! Extensions screen: browse, install/remove integrations, view MCP health.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ExtensionInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub icon: String,
|
||||
pub installed: bool,
|
||||
pub status: String,
|
||||
pub tags: Vec<String>,
|
||||
#[allow(dead_code)]
|
||||
pub has_oauth: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ExtensionHealthInfo {
|
||||
pub id: String,
|
||||
pub status: String,
|
||||
pub tool_count: usize,
|
||||
#[allow(dead_code)]
|
||||
pub last_ok: String,
|
||||
pub last_error: String,
|
||||
pub consecutive_failures: u32,
|
||||
pub reconnecting: bool,
|
||||
pub connected_since: String,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExtSub {
|
||||
Browse,
|
||||
Installed,
|
||||
Health,
|
||||
}
|
||||
|
||||
pub struct ExtensionsState {
|
||||
pub sub: ExtSub,
|
||||
pub all_extensions: Vec<ExtensionInfo>,
|
||||
pub health_entries: Vec<ExtensionHealthInfo>,
|
||||
pub browse_list: ListState,
|
||||
pub installed_list: ListState,
|
||||
pub health_list: ListState,
|
||||
pub search_query: String,
|
||||
pub searching: bool,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub confirm_remove: bool,
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum ExtensionsAction {
|
||||
Continue,
|
||||
RefreshAll,
|
||||
RefreshHealth,
|
||||
Install(String),
|
||||
Remove(String),
|
||||
Reconnect(String),
|
||||
}
|
||||
|
||||
impl ExtensionsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sub: ExtSub::Browse,
|
||||
all_extensions: Vec::new(),
|
||||
health_entries: Vec::new(),
|
||||
browse_list: ListState::default(),
|
||||
installed_list: ListState::default(),
|
||||
health_list: ListState::default(),
|
||||
search_query: String::new(),
|
||||
searching: false,
|
||||
loading: false,
|
||||
tick: 0,
|
||||
confirm_remove: false,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
fn filtered(&self) -> Vec<&ExtensionInfo> {
|
||||
let q = self.search_query.to_lowercase();
|
||||
self.all_extensions
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
if q.is_empty() {
|
||||
return true;
|
||||
}
|
||||
e.name.to_lowercase().contains(&q)
|
||||
|| e.id.to_lowercase().contains(&q)
|
||||
|| e.category.to_lowercase().contains(&q)
|
||||
|| e.tags.iter().any(|t| t.to_lowercase().contains(&q))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn installed_list_data(&self) -> Vec<&ExtensionInfo> {
|
||||
self.all_extensions.iter().filter(|e| e.installed).collect()
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> ExtensionsAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return ExtensionsAction::Continue;
|
||||
}
|
||||
|
||||
// Search mode
|
||||
if self.searching {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.searching = false;
|
||||
self.search_query.clear();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.searching = false;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.search_query.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.search_query.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return ExtensionsAction::Continue;
|
||||
}
|
||||
|
||||
// Sub-tab switching (1/2/3)
|
||||
match key.code {
|
||||
KeyCode::Char('1') => {
|
||||
self.sub = ExtSub::Browse;
|
||||
return ExtensionsAction::RefreshAll;
|
||||
}
|
||||
KeyCode::Char('2') => {
|
||||
self.sub = ExtSub::Installed;
|
||||
return ExtensionsAction::RefreshAll;
|
||||
}
|
||||
KeyCode::Char('3') => {
|
||||
self.sub = ExtSub::Health;
|
||||
return ExtensionsAction::RefreshHealth;
|
||||
}
|
||||
KeyCode::Char('/') => {
|
||||
if self.sub == ExtSub::Browse {
|
||||
self.searching = true;
|
||||
self.search_query.clear();
|
||||
return ExtensionsAction::Continue;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match self.sub {
|
||||
ExtSub::Browse => self.handle_browse(key),
|
||||
ExtSub::Installed => self.handle_installed(key),
|
||||
ExtSub::Health => self.handle_health(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_browse(&mut self, key: KeyEvent) -> ExtensionsAction {
|
||||
let total = self.filtered().len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let filtered = self.filtered();
|
||||
if let Some(sel) = self.browse_list.selected() {
|
||||
if sel < filtered.len() {
|
||||
let ext = filtered[sel];
|
||||
if !ext.installed {
|
||||
return ExtensionsAction::Install(ext.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return ExtensionsAction::RefreshAll,
|
||||
_ => {}
|
||||
}
|
||||
ExtensionsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_installed(&mut self, key: KeyEvent) -> ExtensionsAction {
|
||||
if self.confirm_remove {
|
||||
match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||
self.confirm_remove = false;
|
||||
let installed = self.installed_list_data();
|
||||
if let Some(sel) = self.installed_list.selected() {
|
||||
if sel < installed.len() {
|
||||
return ExtensionsAction::Remove(installed[sel].id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => self.confirm_remove = false,
|
||||
}
|
||||
return ExtensionsAction::Continue;
|
||||
}
|
||||
|
||||
let total = self.installed_list_data().len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete => {
|
||||
if self.installed_list.selected().is_some() {
|
||||
self.confirm_remove = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return ExtensionsAction::RefreshAll,
|
||||
_ => {}
|
||||
}
|
||||
ExtensionsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_health(&mut self, key: KeyEvent) -> ExtensionsAction {
|
||||
let total = self.health_entries.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') | KeyCode::Enter => {
|
||||
if let Some(sel) = self.health_list.selected() {
|
||||
if sel < self.health_entries.len() {
|
||||
return ExtensionsAction::Reconnect(self.health_entries[sel].id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ExtensionsAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut ExtensionsState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Extensions ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // sub-tab bar
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(3), // content
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
draw_sub_tabs(f, chunks[0], state);
|
||||
|
||||
let sep = "\u{2500}".repeat(chunks[1].width as usize);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(sep, theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
match state.sub {
|
||||
ExtSub::Browse => draw_browse(f, chunks[2], state),
|
||||
ExtSub::Installed => draw_installed(f, chunks[2], state),
|
||||
ExtSub::Health => draw_health(f, chunks[2], state),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_sub_tabs(f: &mut Frame, area: Rect, state: &ExtensionsState) {
|
||||
let tabs = [
|
||||
(ExtSub::Browse, "1 Browse"),
|
||||
(ExtSub::Installed, "2 Installed"),
|
||||
(ExtSub::Health, "3 Health"),
|
||||
];
|
||||
let mut spans = vec![Span::raw(" ")];
|
||||
for (sub, label) in &tabs {
|
||||
let style = if *sub == state.sub {
|
||||
theme::tab_active()
|
||||
} else {
|
||||
theme::tab_inactive()
|
||||
};
|
||||
spans.push(Span::styled(format!(" {label} "), style));
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
|
||||
// Show search query if active
|
||||
if state.searching {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled("Search: ", Style::default().fg(theme::YELLOW)));
|
||||
spans.push(Span::styled(
|
||||
format!("{}_", state.search_query),
|
||||
theme::input_style(),
|
||||
));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||
}
|
||||
|
||||
fn status_badge(status: &str) -> (String, Style) {
|
||||
let lower = status.to_lowercase();
|
||||
if lower.contains("ready") || lower.contains("connected") {
|
||||
("[Ready]".to_string(), Style::default().fg(theme::GREEN))
|
||||
} else if lower.contains("setup") {
|
||||
("[Setup]".to_string(), Style::default().fg(theme::YELLOW))
|
||||
} else if lower.contains("error") {
|
||||
("[Error]".to_string(), Style::default().fg(theme::RED))
|
||||
} else if lower.contains("disabled") {
|
||||
("[Off]".to_string(), theme::dim_style())
|
||||
} else {
|
||||
("".to_string(), theme::dim_style())
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_browse(f: &mut Frame, area: Rect, state: &mut ExtensionsState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<3} {:<18} {:<12} {:<10} {}",
|
||||
"", "Name", "Category", "Status", "Description"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading integrations\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.all_extensions.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No integrations loaded. Press r to refresh.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
// Collect filtered data to avoid borrow conflict with browse_list
|
||||
let items: Vec<ListItem> = state
|
||||
.filtered()
|
||||
.iter()
|
||||
.map(|ext| {
|
||||
let (badge, badge_style) = if ext.installed {
|
||||
("[Installed]".to_string(), Style::default().fg(theme::GREEN))
|
||||
} else {
|
||||
("[Available]".to_string(), theme::dim_style())
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{} ", ext.icon), Style::default()),
|
||||
Span::styled(
|
||||
format!("{:<16} ", ext.name),
|
||||
Style::default().fg(theme::TEXT_PRIMARY),
|
||||
),
|
||||
Span::styled(format!("{:<12} ", ext.category), theme::dim_style()),
|
||||
Span::styled(format!("{:<10} ", badge), badge_style),
|
||||
Span::styled(ext.description.clone(), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items).highlight_style(theme::selected_style());
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.browse_list);
|
||||
}
|
||||
|
||||
let hints = if state.searching {
|
||||
" Type to search \u{2022} Esc cancel \u{2022} Enter confirm"
|
||||
} else {
|
||||
" j/k navigate \u{2022} Enter install \u{2022} / search \u{2022} r refresh"
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(hints, theme::hint_style())),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_installed(f: &mut Frame, area: Rect, state: &mut ExtensionsState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<3} {:<18} {:<12} {:<10} {}",
|
||||
"", "Name", "Category", "Status", "ID"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
// Collect installed items into owned data to avoid borrow conflict with installed_list
|
||||
let items: Vec<ListItem> = state
|
||||
.all_extensions
|
||||
.iter()
|
||||
.filter(|e| e.installed)
|
||||
.map(|ext| {
|
||||
let (badge, badge_style) = status_badge(&ext.status);
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{} ", ext.icon), Style::default()),
|
||||
Span::styled(
|
||||
format!("{:<16} ", ext.name),
|
||||
Style::default().fg(theme::TEXT_PRIMARY),
|
||||
),
|
||||
Span::styled(format!("{:<12} ", ext.category), theme::dim_style()),
|
||||
Span::styled(format!("{:<10} ", badge), badge_style),
|
||||
Span::styled(ext.id.clone(), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if items.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No integrations installed. Browse tab to add.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let list = List::new(items).highlight_style(theme::selected_style());
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.installed_list);
|
||||
}
|
||||
|
||||
let hints = if state.confirm_remove {
|
||||
" Press y to confirm removal, any other key to cancel"
|
||||
} else {
|
||||
" j/k navigate \u{2022} d remove \u{2022} r refresh"
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(hints, theme::hint_style())),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_health(f: &mut Frame, area: Rect, state: &mut ExtensionsState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<18} {:<10} {:<6} {:<12} {:<6} {}",
|
||||
"Server", "Status", "Tools", "Connected", "Fails", "Last Error"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.health_entries.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No MCP health data. Install integrations first.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.health_entries
|
||||
.iter()
|
||||
.map(|h| {
|
||||
let (badge, badge_style) = status_badge(&h.status);
|
||||
let error_display = if h.last_error.is_empty() {
|
||||
"\u{2014}".to_string()
|
||||
} else if h.last_error.len() > 30 {
|
||||
format!("{}...", openfang_types::truncate_str(&h.last_error, 27))
|
||||
} else {
|
||||
h.last_error.clone()
|
||||
};
|
||||
let reconn = if h.reconnecting { " \u{21bb}" } else { "" };
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{:<16} ", h.id),
|
||||
Style::default().fg(theme::TEXT_PRIMARY),
|
||||
),
|
||||
Span::styled(format!("{:<10} ", badge), badge_style),
|
||||
Span::styled(
|
||||
format!("{:<6} ", h.tool_count),
|
||||
Style::default().fg(theme::BLUE),
|
||||
),
|
||||
Span::styled(
|
||||
format!(
|
||||
"{:<12} ",
|
||||
if h.connected_since.is_empty() {
|
||||
"\u{2014}"
|
||||
} else {
|
||||
&h.connected_since
|
||||
}
|
||||
),
|
||||
theme::dim_style(),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{:<6}", h.consecutive_failures),
|
||||
if h.consecutive_failures > 0 {
|
||||
Style::default().fg(theme::RED)
|
||||
} else {
|
||||
theme::dim_style()
|
||||
},
|
||||
),
|
||||
Span::styled(format!(" {error_display}{reconn}"), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items).highlight_style(theme::selected_style());
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.health_list);
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" j/k navigate \u{2022} r/Enter reconnect \u{2022} auto-reconnect active",
|
||||
theme::hint_style(),
|
||||
)),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
440
crates/openfang-cli/src/tui/screens/hands.rs
Normal file
440
crates/openfang-cli/src/tui/screens/hands.rs
Normal file
@@ -0,0 +1,440 @@
|
||||
//! Hands screen: marketplace of curated autonomous capability packages + active instances.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct HandInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub icon: String,
|
||||
pub requirements_met: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
#[allow(dead_code)]
|
||||
pub struct HandInstanceInfo {
|
||||
pub instance_id: String,
|
||||
pub hand_id: String,
|
||||
pub status: String,
|
||||
pub agent_name: String,
|
||||
pub agent_id: String,
|
||||
pub activated_at: String,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HandsSub {
|
||||
Marketplace,
|
||||
Active,
|
||||
}
|
||||
|
||||
pub struct HandsState {
|
||||
pub sub: HandsSub,
|
||||
pub definitions: Vec<HandInfo>,
|
||||
pub instances: Vec<HandInstanceInfo>,
|
||||
pub marketplace_list: ListState,
|
||||
pub active_list: ListState,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub confirm_deactivate: bool,
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum HandsAction {
|
||||
Continue,
|
||||
RefreshDefinitions,
|
||||
RefreshActive,
|
||||
ActivateHand(String),
|
||||
DeactivateHand(String),
|
||||
PauseHand(String),
|
||||
ResumeHand(String),
|
||||
}
|
||||
|
||||
impl HandsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sub: HandsSub::Marketplace,
|
||||
definitions: Vec::new(),
|
||||
instances: Vec::new(),
|
||||
marketplace_list: ListState::default(),
|
||||
active_list: ListState::default(),
|
||||
loading: false,
|
||||
tick: 0,
|
||||
confirm_deactivate: false,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> HandsAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return HandsAction::Continue;
|
||||
}
|
||||
|
||||
// Sub-tab switching (1/2)
|
||||
match key.code {
|
||||
KeyCode::Char('1') => {
|
||||
self.sub = HandsSub::Marketplace;
|
||||
return HandsAction::RefreshDefinitions;
|
||||
}
|
||||
KeyCode::Char('2') => {
|
||||
self.sub = HandsSub::Active;
|
||||
return HandsAction::RefreshActive;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match self.sub {
|
||||
HandsSub::Marketplace => self.handle_marketplace(key),
|
||||
HandsSub::Active => self.handle_active(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_marketplace(&mut self, key: KeyEvent) -> HandsAction {
|
||||
let total = self.definitions.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char('a') => {
|
||||
if let Some(sel) = self.marketplace_list.selected() {
|
||||
if sel < self.definitions.len() {
|
||||
return HandsAction::ActivateHand(self.definitions[sel].id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return HandsAction::RefreshDefinitions,
|
||||
_ => {}
|
||||
}
|
||||
HandsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_active(&mut self, key: KeyEvent) -> HandsAction {
|
||||
if self.confirm_deactivate {
|
||||
match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||
self.confirm_deactivate = false;
|
||||
if let Some(sel) = self.active_list.selected() {
|
||||
if sel < self.instances.len() {
|
||||
return HandsAction::DeactivateHand(
|
||||
self.instances[sel].instance_id.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => self.confirm_deactivate = false,
|
||||
}
|
||||
return HandsAction::Continue;
|
||||
}
|
||||
|
||||
let total = self.instances.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete => {
|
||||
if self.active_list.selected().is_some() {
|
||||
self.confirm_deactivate = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
if let Some(sel) = self.active_list.selected() {
|
||||
if sel < self.instances.len() {
|
||||
let inst = &self.instances[sel];
|
||||
if inst.status == "Active" {
|
||||
return HandsAction::PauseHand(inst.instance_id.clone());
|
||||
} else if inst.status == "Paused" {
|
||||
return HandsAction::ResumeHand(inst.instance_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return HandsAction::RefreshActive,
|
||||
_ => {}
|
||||
}
|
||||
HandsAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut HandsState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Hands ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // sub-tab bar
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(3), // content
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Sub-tab bar
|
||||
draw_sub_tabs(f, chunks[0], state.sub);
|
||||
|
||||
let sep = "\u{2500}".repeat(chunks[1].width as usize);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(sep, theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
match state.sub {
|
||||
HandsSub::Marketplace => draw_marketplace(f, chunks[2], state),
|
||||
HandsSub::Active => draw_active(f, chunks[2], state),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_sub_tabs(f: &mut Frame, area: Rect, active: HandsSub) {
|
||||
let tabs = [
|
||||
(HandsSub::Marketplace, "1 Marketplace"),
|
||||
(HandsSub::Active, "2 Active"),
|
||||
];
|
||||
let mut spans = vec![Span::raw(" ")];
|
||||
for (sub, label) in &tabs {
|
||||
let style = if *sub == active {
|
||||
theme::tab_active()
|
||||
} else {
|
||||
theme::tab_inactive()
|
||||
};
|
||||
spans.push(Span::styled(format!(" {label} "), style));
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||
}
|
||||
|
||||
fn draw_marketplace(f: &mut Frame, area: Rect, state: &mut HandsState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<4} {:<16} {:<14} {:<6} {}",
|
||||
"", "Name", "Category", "Ready", "Description"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading hands\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.definitions.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(" No hands available.", theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|h| {
|
||||
let ready_badge = if h.requirements_met {
|
||||
Span::styled(" Ready ", Style::default().fg(theme::GREEN))
|
||||
} else {
|
||||
Span::styled(" Setup ", Style::default().fg(theme::YELLOW))
|
||||
};
|
||||
let category_style = match h.category.as_str() {
|
||||
"Content" => Style::default().fg(theme::PURPLE),
|
||||
"Security" => Style::default().fg(theme::RED),
|
||||
"Development" => Style::default().fg(theme::BLUE),
|
||||
"Productivity" => Style::default().fg(theme::GREEN),
|
||||
_ => Style::default().fg(theme::CYAN),
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::raw(format!(" {:<4}", &h.icon)),
|
||||
Span::styled(
|
||||
format!("{:<16}", truncate(&h.name, 15)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(format!("{:<14}", truncate(&h.category, 13)), category_style),
|
||||
ready_badge,
|
||||
Span::styled(
|
||||
format!(" {}", truncate(&h.description, 40)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.marketplace_list);
|
||||
}
|
||||
|
||||
if !state.status_msg.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {}", state.status_msg),
|
||||
Style::default().fg(theme::GREEN),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [a/Enter] Activate [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_active(f: &mut Frame, area: Rect, state: &mut HandsState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<16} {:<10} {:<20} {}",
|
||||
"Agent", "Status", "Hand", "Since"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading active hands\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.instances.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No active hands. Press [1] to browse the marketplace.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.instances
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let status_style = match i.status.as_str() {
|
||||
"Active" => Style::default().fg(theme::GREEN),
|
||||
"Paused" => Style::default().fg(theme::YELLOW),
|
||||
_ => Style::default().fg(theme::RED),
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<16}", truncate(&i.agent_name, 15)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(format!("{:<10}", &i.status), status_style),
|
||||
Span::styled(
|
||||
format!("{:<20}", truncate(&i.hand_id, 19)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
Span::styled(
|
||||
truncate(&i.activated_at, 19).to_string(),
|
||||
theme::dim_style(),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.active_list);
|
||||
}
|
||||
|
||||
if state.confirm_deactivate {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" Deactivate this hand? [y] Yes [any] Cancel",
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
} else if !state.status_msg.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {}", state.status_msg),
|
||||
Style::default().fg(theme::GREEN),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [p] Pause/Resume [d] Deactivate [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> &str {
|
||||
openfang_types::truncate_str(s, max)
|
||||
}
|
||||
2210
crates/openfang-cli/src/tui/screens/init_wizard.rs
Normal file
2210
crates/openfang-cli/src/tui/screens/init_wizard.rs
Normal file
File diff suppressed because it is too large
Load Diff
410
crates/openfang-cli/src/tui/screens/logs.rs
Normal file
410
crates/openfang-cli/src/tui/screens/logs.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
//! Logs screen: real-time log viewer with level filter and search.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: String,
|
||||
pub level: LogLevel,
|
||||
pub action: String,
|
||||
pub detail: String,
|
||||
pub agent: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum LogLevel {
|
||||
Error,
|
||||
Warn,
|
||||
#[default]
|
||||
Info,
|
||||
}
|
||||
|
||||
impl LogLevel {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Error => "ERR",
|
||||
Self::Warn => "WRN",
|
||||
Self::Info => "INF",
|
||||
}
|
||||
}
|
||||
|
||||
fn style(self) -> Style {
|
||||
match self {
|
||||
Self::Error => Style::default().fg(theme::RED).add_modifier(Modifier::BOLD),
|
||||
Self::Warn => Style::default()
|
||||
.fg(theme::YELLOW)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Self::Info => Style::default().fg(theme::BLUE),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify log level from action/detail keywords.
|
||||
pub fn classify_level(action: &str, detail: &str) -> LogLevel {
|
||||
let combined = format!("{action} {detail}").to_lowercase();
|
||||
if combined.contains("error")
|
||||
|| combined.contains("fail")
|
||||
|| combined.contains("crash")
|
||||
|| combined.contains("panic")
|
||||
{
|
||||
LogLevel::Error
|
||||
} else if combined.contains("warn")
|
||||
|| combined.contains("deny")
|
||||
|| combined.contains("denied")
|
||||
|| combined.contains("block")
|
||||
|| combined.contains("timeout")
|
||||
{
|
||||
LogLevel::Warn
|
||||
} else {
|
||||
LogLevel::Info
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filter ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LevelFilter {
|
||||
All,
|
||||
Error,
|
||||
Warn,
|
||||
Info,
|
||||
}
|
||||
|
||||
impl LevelFilter {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "All",
|
||||
Self::Error => "Error",
|
||||
Self::Warn => "Warn",
|
||||
Self::Info => "Info",
|
||||
}
|
||||
}
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Self::All => Self::Error,
|
||||
Self::Error => Self::Warn,
|
||||
Self::Warn => Self::Info,
|
||||
Self::Info => Self::All,
|
||||
}
|
||||
}
|
||||
fn matches(self, level: LogLevel) -> bool {
|
||||
match self {
|
||||
Self::All => true,
|
||||
Self::Error => level == LogLevel::Error,
|
||||
Self::Warn => level == LogLevel::Warn,
|
||||
Self::Info => level == LogLevel::Info,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct LogsState {
|
||||
pub entries: Vec<LogEntry>,
|
||||
pub filtered: Vec<usize>,
|
||||
pub level_filter: LevelFilter,
|
||||
pub search_buf: String,
|
||||
pub search_mode: bool,
|
||||
pub auto_refresh: bool,
|
||||
pub list_state: ListState,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub poll_tick: usize,
|
||||
}
|
||||
|
||||
pub enum LogsAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
}
|
||||
|
||||
impl LogsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
filtered: Vec::new(),
|
||||
level_filter: LevelFilter::All,
|
||||
search_buf: String::new(),
|
||||
search_mode: false,
|
||||
auto_refresh: true,
|
||||
list_state: ListState::default(),
|
||||
loading: false,
|
||||
tick: 0,
|
||||
poll_tick: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
self.poll_tick = self.poll_tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Returns true if it's time to auto-refresh (every ~2s at 20fps tick rate).
|
||||
pub fn should_poll(&self) -> bool {
|
||||
self.auto_refresh && self.poll_tick > 0 && self.poll_tick.is_multiple_of(40)
|
||||
}
|
||||
|
||||
pub fn refilter(&mut self) {
|
||||
let search_lower = self.search_buf.to_lowercase();
|
||||
self.filtered = self
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| {
|
||||
if !self.level_filter.matches(e.level) {
|
||||
return false;
|
||||
}
|
||||
if !search_lower.is_empty() {
|
||||
let haystack = format!("{} {}", e.action, e.detail).to_lowercase();
|
||||
if !haystack.contains(&search_lower) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
// Auto-scroll to bottom on new entries
|
||||
if !self.filtered.is_empty() {
|
||||
self.list_state.select(Some(self.filtered.len() - 1));
|
||||
} else {
|
||||
self.list_state.select(None);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> LogsAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return LogsAction::Continue;
|
||||
}
|
||||
|
||||
if self.search_mode {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.search_mode = false;
|
||||
self.search_buf.clear();
|
||||
self.refilter();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.search_mode = false;
|
||||
self.refilter();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.search_buf.pop();
|
||||
self.refilter();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.search_buf.push(c);
|
||||
self.refilter();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return LogsAction::Continue;
|
||||
}
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
self.level_filter = self.level_filter.next();
|
||||
self.refilter();
|
||||
}
|
||||
KeyCode::Char('/') => {
|
||||
self.search_mode = true;
|
||||
self.search_buf.clear();
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
self.auto_refresh = !self.auto_refresh;
|
||||
}
|
||||
KeyCode::Char('r') => return LogsAction::Refresh,
|
||||
KeyCode::End => {
|
||||
if total > 0 {
|
||||
self.list_state.select(Some(total - 1));
|
||||
}
|
||||
}
|
||||
KeyCode::Home => {
|
||||
if total > 0 {
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
LogsAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut LogsState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Logs ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // header: filter + search
|
||||
Constraint::Min(3), // log list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// ── Header ──
|
||||
if state.search_mode {
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" / ", Style::default().fg(theme::ACCENT)),
|
||||
Span::styled(&state.search_buf, theme::input_style()),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<20} {:<6} {:<16} {:<14} {}",
|
||||
"Timestamp", "Level", "Action", "Agent", "Detail"
|
||||
),
|
||||
theme::table_header(),
|
||||
)]),
|
||||
]),
|
||||
chunks[0],
|
||||
);
|
||||
} else {
|
||||
let auto_badge = if state.auto_refresh {
|
||||
Span::styled(" [auto-refresh ON]", Style::default().fg(theme::GREEN))
|
||||
} else {
|
||||
Span::styled(" [auto-refresh OFF]", theme::dim_style())
|
||||
};
|
||||
let search_hint = if state.search_buf.is_empty() {
|
||||
Span::raw("")
|
||||
} else {
|
||||
Span::styled(
|
||||
format!(" filter: \"{}\"", state.search_buf),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" Level: ", theme::dim_style()),
|
||||
Span::styled(
|
||||
format!("[{}]", state.level_filter.label()),
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ({} entries)", state.filtered.len()),
|
||||
theme::dim_style(),
|
||||
),
|
||||
auto_badge,
|
||||
search_hint,
|
||||
]),
|
||||
Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<20} {:<6} {:<16} {:<14} {}",
|
||||
"Timestamp", "Level", "Action", "Agent", "Detail"
|
||||
),
|
||||
theme::table_header(),
|
||||
)]),
|
||||
]),
|
||||
chunks[0],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Log list ──
|
||||
if state.loading && state.entries.is_empty() {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading logs\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.filtered.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No log entries match the current filter.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.filtered
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
let e = &state.entries[idx];
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<20}", truncate(&e.timestamp, 19)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
Span::styled(format!(" {:<6}", e.level.label()), e.level.style()),
|
||||
Span::styled(
|
||||
format!(" {:<16}", truncate(&e.action, 15)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<14}", truncate(&e.agent, 13)),
|
||||
Style::default().fg(theme::PURPLE),
|
||||
),
|
||||
Span::styled(format!(" {}", truncate(&e.detail, 30)), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.list_state);
|
||||
}
|
||||
|
||||
// ── Hints ──
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [f] Filter Level [/] Search [a] Toggle Auto-refresh [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
554
crates/openfang-cli/src/tui/screens/memory.rs
Normal file
554
crates/openfang-cli/src/tui/screens/memory.rs
Normal file
@@ -0,0 +1,554 @@
|
||||
//! Memory screen: per-agent KV store browser and editor.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct KvPair {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AgentEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum MemorySub {
|
||||
AgentSelect,
|
||||
KvBrowser,
|
||||
EditKey,
|
||||
AddKey,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum EditField {
|
||||
Key,
|
||||
Value,
|
||||
}
|
||||
|
||||
pub struct MemoryState {
|
||||
pub sub: MemorySub,
|
||||
pub agents: Vec<AgentEntry>,
|
||||
pub selected_agent: Option<AgentEntry>,
|
||||
pub kv_pairs: Vec<KvPair>,
|
||||
pub agent_list_state: ListState,
|
||||
pub kv_list_state: ListState,
|
||||
pub key_buf: String,
|
||||
pub value_buf: String,
|
||||
pub edit_field: EditField,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub confirm_delete: bool,
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum MemoryAction {
|
||||
Continue,
|
||||
LoadAgents,
|
||||
LoadKv(String),
|
||||
SaveKv {
|
||||
agent_id: String,
|
||||
key: String,
|
||||
value: String,
|
||||
},
|
||||
DeleteKv {
|
||||
agent_id: String,
|
||||
key: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl MemoryState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sub: MemorySub::AgentSelect,
|
||||
agents: Vec::new(),
|
||||
selected_agent: None,
|
||||
kv_pairs: Vec::new(),
|
||||
agent_list_state: ListState::default(),
|
||||
kv_list_state: ListState::default(),
|
||||
key_buf: String::new(),
|
||||
value_buf: String::new(),
|
||||
edit_field: EditField::Key,
|
||||
loading: false,
|
||||
tick: 0,
|
||||
confirm_delete: false,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> MemoryAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return MemoryAction::Continue;
|
||||
}
|
||||
match self.sub {
|
||||
MemorySub::AgentSelect => self.handle_agent_select(key),
|
||||
MemorySub::KvBrowser => self.handle_kv_browser(key),
|
||||
MemorySub::EditKey | MemorySub::AddKey => self.handle_edit(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_agent_select(&mut self, key: KeyEvent) -> MemoryAction {
|
||||
let total = self.agents.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = self.agent_list_state.selected() {
|
||||
if sel < self.agents.len() {
|
||||
let agent = self.agents[sel].clone();
|
||||
let id = agent.id.clone();
|
||||
self.selected_agent = Some(agent);
|
||||
self.sub = MemorySub::KvBrowser;
|
||||
self.loading = true;
|
||||
return MemoryAction::LoadKv(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return MemoryAction::LoadAgents,
|
||||
_ => {}
|
||||
}
|
||||
MemoryAction::Continue
|
||||
}
|
||||
|
||||
fn handle_kv_browser(&mut self, key: KeyEvent) -> MemoryAction {
|
||||
if self.confirm_delete {
|
||||
match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||
self.confirm_delete = false;
|
||||
if let (Some(agent), Some(sel)) =
|
||||
(&self.selected_agent, self.kv_list_state.selected())
|
||||
{
|
||||
if sel < self.kv_pairs.len() {
|
||||
return MemoryAction::DeleteKv {
|
||||
agent_id: agent.id.clone(),
|
||||
key: self.kv_pairs[sel].key.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => self.confirm_delete = false,
|
||||
}
|
||||
return MemoryAction::Continue;
|
||||
}
|
||||
|
||||
let total = self.kv_pairs.len();
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.sub = MemorySub::AgentSelect;
|
||||
self.kv_pairs.clear();
|
||||
self.selected_agent = None;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
self.sub = MemorySub::AddKey;
|
||||
self.key_buf.clear();
|
||||
self.value_buf.clear();
|
||||
self.edit_field = EditField::Key;
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
if let Some(sel) = self.kv_list_state.selected() {
|
||||
if sel < self.kv_pairs.len() {
|
||||
self.key_buf = self.kv_pairs[sel].key.clone();
|
||||
self.value_buf = self.kv_pairs[sel].value.clone();
|
||||
self.edit_field = EditField::Value;
|
||||
self.sub = MemorySub::EditKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
if self.kv_list_state.selected().is_some() {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
if let Some(agent) = &self.selected_agent {
|
||||
self.loading = true;
|
||||
return MemoryAction::LoadKv(agent.id.clone());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
MemoryAction::Continue
|
||||
}
|
||||
|
||||
fn handle_edit(&mut self, key: KeyEvent) -> MemoryAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.sub = MemorySub::KvBrowser;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.edit_field = match self.edit_field {
|
||||
EditField::Key => EditField::Value,
|
||||
EditField::Value => EditField::Key,
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.key_buf.is_empty() {
|
||||
if let Some(agent) = &self.selected_agent {
|
||||
let action = MemoryAction::SaveKv {
|
||||
agent_id: agent.id.clone(),
|
||||
key: self.key_buf.clone(),
|
||||
value: self.value_buf.clone(),
|
||||
};
|
||||
self.sub = MemorySub::KvBrowser;
|
||||
return action;
|
||||
}
|
||||
}
|
||||
self.sub = MemorySub::KvBrowser;
|
||||
}
|
||||
KeyCode::Backspace => match self.edit_field {
|
||||
EditField::Key if self.sub == MemorySub::AddKey => {
|
||||
self.key_buf.pop();
|
||||
}
|
||||
EditField::Value => {
|
||||
self.value_buf.pop();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Char(c) => match self.edit_field {
|
||||
EditField::Key if self.sub == MemorySub::AddKey => self.key_buf.push(c),
|
||||
EditField::Value => self.value_buf.push(c),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
MemoryAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut MemoryState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Memory ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
match state.sub {
|
||||
MemorySub::AgentSelect => draw_agent_select(f, inner, state),
|
||||
MemorySub::KvBrowser => draw_kv_browser(f, inner, state),
|
||||
MemorySub::EditKey | MemorySub::AddKey => draw_edit(f, inner, state),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_agent_select(f: &mut Frame, area: Rect, state: &mut MemoryState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" Select an agent to browse its memory:",
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading agents\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.agents.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(" No agents available.", theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.agents
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let id_short = if a.id.len() > 12 {
|
||||
format!("{}\u{2026}", &a.id[..12])
|
||||
} else {
|
||||
a.id.clone()
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<20}", a.name),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(format!(" ({id_short})"), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.agent_list_state);
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [Enter] Browse KV [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_kv_browser(f: &mut Frame, area: Rect, state: &mut MemoryState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let agent_name = state
|
||||
.selected_agent
|
||||
.as_ref()
|
||||
.map(|a| a.name.as_str())
|
||||
.unwrap_or("?");
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" Memory: {agent_name}"),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ({} pairs)", state.kv_pairs.len()),
|
||||
theme::dim_style(),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" {:<24} {}", "Key", "Value"),
|
||||
theme::table_header(),
|
||||
)]),
|
||||
]),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.kv_pairs.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No key-value pairs. Press [a] to add one.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.kv_pairs
|
||||
.iter()
|
||||
.map(|kv| {
|
||||
let val_display = if kv.value.len() > 40 {
|
||||
format!("{}\u{2026}", &kv.value[..39])
|
||||
} else {
|
||||
kv.value.clone()
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<24}", truncate(&kv.key, 23)),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
),
|
||||
Span::styled(format!(" {val_display}"), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.kv_list_state);
|
||||
}
|
||||
|
||||
if state.confirm_delete {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" Delete this key? [y] Yes [any] Cancel",
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [a] Add [e] Edit [d] Delete [Esc] Back [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_edit(f: &mut Frame, area: Rect, state: &MemoryState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let title = if state.sub == MemorySub::AddKey {
|
||||
"Add Key-Value Pair"
|
||||
} else {
|
||||
"Edit Value"
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {title}"),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
// Key field
|
||||
let key_active = state.edit_field == EditField::Key && state.sub == MemorySub::AddKey;
|
||||
let key_label_style = if key_active {
|
||||
Style::default().fg(theme::ACCENT)
|
||||
} else {
|
||||
theme::dim_style()
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(" Key: ", key_label_style)])),
|
||||
chunks[2],
|
||||
);
|
||||
let key_display = if state.key_buf.is_empty() {
|
||||
"enter key..."
|
||||
} else {
|
||||
&state.key_buf
|
||||
};
|
||||
let key_style = if state.key_buf.is_empty() {
|
||||
theme::dim_style()
|
||||
} else {
|
||||
theme::input_style()
|
||||
};
|
||||
let mut key_spans = vec![Span::raw(" > "), Span::styled(key_display, key_style)];
|
||||
if key_active {
|
||||
key_spans.push(Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(key_spans)), chunks[3]);
|
||||
|
||||
// Value field
|
||||
let val_active = state.edit_field == EditField::Value;
|
||||
let val_label_style = if val_active {
|
||||
Style::default().fg(theme::ACCENT)
|
||||
} else {
|
||||
theme::dim_style()
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(" Value: ", val_label_style)])),
|
||||
chunks[4],
|
||||
);
|
||||
let val_display = if state.value_buf.is_empty() {
|
||||
"enter value..."
|
||||
} else {
|
||||
&state.value_buf
|
||||
};
|
||||
let val_style = if state.value_buf.is_empty() {
|
||||
theme::dim_style()
|
||||
} else {
|
||||
theme::input_style()
|
||||
};
|
||||
let mut val_spans = vec![Span::raw(" > "), Span::styled(val_display, val_style)];
|
||||
if val_active {
|
||||
val_spans.push(Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(val_spans)), chunks[5]);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [Tab] Switch field [Enter] Save [Esc] Cancel",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[6],
|
||||
);
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
21
crates/openfang-cli/src/tui/screens/mod.rs
Normal file
21
crates/openfang-cli/src/tui/screens/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
pub mod agents;
|
||||
pub mod audit;
|
||||
pub mod channels;
|
||||
pub mod chat;
|
||||
pub mod dashboard;
|
||||
pub mod extensions;
|
||||
pub mod hands;
|
||||
pub mod init_wizard;
|
||||
pub mod logs;
|
||||
pub mod memory;
|
||||
pub mod peers;
|
||||
pub mod security;
|
||||
pub mod sessions;
|
||||
pub mod settings;
|
||||
pub mod skills;
|
||||
pub mod templates;
|
||||
pub mod triggers;
|
||||
pub mod usage;
|
||||
pub mod welcome;
|
||||
pub mod wizard;
|
||||
pub mod workflows;
|
||||
213
crates/openfang-cli/src/tui/screens/peers.rs
Normal file
213
crates/openfang-cli/src/tui/screens/peers.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
//! Peers screen: OFP peer network status with auto-refresh.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PeerInfo {
|
||||
pub node_id: String,
|
||||
pub node_name: String,
|
||||
pub address: String,
|
||||
pub state: String,
|
||||
pub agent_count: u64,
|
||||
pub protocol_version: String,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct PeersState {
|
||||
pub peers: Vec<PeerInfo>,
|
||||
pub list_state: ListState,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub poll_tick: usize,
|
||||
}
|
||||
|
||||
pub enum PeersAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
}
|
||||
|
||||
impl PeersState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
peers: Vec::new(),
|
||||
list_state: ListState::default(),
|
||||
loading: false,
|
||||
tick: 0,
|
||||
poll_tick: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
self.poll_tick = self.poll_tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Returns true if it's time to auto-refresh (every ~15s at 20fps tick rate).
|
||||
pub fn should_poll(&self) -> bool {
|
||||
self.poll_tick > 0 && self.poll_tick.is_multiple_of(300)
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> PeersAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return PeersAction::Continue;
|
||||
}
|
||||
let total = self.peers.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return PeersAction::Refresh,
|
||||
_ => {}
|
||||
}
|
||||
PeersAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut PeersState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Peers ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Header
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" OFP Peer Network ({} peers)", state.peers.len()),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<14} {:<16} {:<20} {:<14} {:<8} {}",
|
||||
"Node ID", "Name", "Address", "State", "Agents", "Protocol"
|
||||
),
|
||||
theme::table_header(),
|
||||
)]),
|
||||
]),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
// List
|
||||
if state.loading && state.peers.is_empty() {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Discovering peers\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.peers.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No peers connected. Configure [network] in config.toml to enable OFP.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.peers
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let id_short = if p.node_id.len() > 12 {
|
||||
format!("{}\u{2026}", &p.node_id[..12])
|
||||
} else {
|
||||
p.node_id.clone()
|
||||
};
|
||||
let (state_badge, state_style) = match p.state.to_lowercase().as_str() {
|
||||
"connected" | "active" => {
|
||||
("\u{2714} Connected", Style::default().fg(theme::GREEN))
|
||||
}
|
||||
"disconnected" | "inactive" => {
|
||||
("\u{2718} Disconnected", Style::default().fg(theme::RED))
|
||||
}
|
||||
"connecting" | "pending" => {
|
||||
("\u{25cb} Connecting", Style::default().fg(theme::YELLOW))
|
||||
}
|
||||
_ => (&*p.state, theme::dim_style()),
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<14}", id_short),
|
||||
Style::default().fg(theme::PURPLE),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<16}", truncate(&p.node_name, 15)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<20}", truncate(&p.address, 19)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
Span::styled(format!(" {:<14}", state_badge), state_style),
|
||||
Span::styled(
|
||||
format!(" {:<8}", p.agent_count),
|
||||
Style::default().fg(theme::GREEN),
|
||||
),
|
||||
Span::styled(format!(" {}", &p.protocol_version), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.list_state);
|
||||
}
|
||||
|
||||
// Hints
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [r] Refresh (auto-refreshes every 15s)",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
326
crates/openfang-cli/src/tui/screens/security.rs
Normal file
326
crates/openfang-cli/src/tui/screens/security.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
//! Security screen: security feature dashboard and chain verification.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SecurityFeature {
|
||||
pub name: String,
|
||||
pub active: bool,
|
||||
pub description: String,
|
||||
pub section: SecuritySection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SecuritySection {
|
||||
Core,
|
||||
Configurable,
|
||||
Monitoring,
|
||||
}
|
||||
|
||||
impl SecuritySection {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Core => "Core Security",
|
||||
Self::Configurable => "Configurable",
|
||||
Self::Monitoring => "Monitoring",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Built-in feature definitions ────────────────────────────────────────────
|
||||
|
||||
fn builtin_features() -> Vec<SecurityFeature> {
|
||||
vec![
|
||||
// Core (8)
|
||||
SecurityFeature {
|
||||
name: "Path Traversal Prevention".into(),
|
||||
active: true,
|
||||
description: "safe_resolve_path blocks ../../ attacks".into(),
|
||||
section: SecuritySection::Core,
|
||||
},
|
||||
SecurityFeature {
|
||||
name: "SSRF Protection".into(),
|
||||
active: true,
|
||||
description: "Blocks private IPs and metadata endpoints in HTTP fetches".into(),
|
||||
section: SecuritySection::Core,
|
||||
},
|
||||
SecurityFeature {
|
||||
name: "Subprocess Isolation".into(),
|
||||
active: true,
|
||||
description: "env_clear() + selective vars on child processes".into(),
|
||||
section: SecuritySection::Core,
|
||||
},
|
||||
SecurityFeature {
|
||||
name: "WASM Dual Metering".into(),
|
||||
active: true,
|
||||
description: "Fuel + epoch interruption with watchdog thread".into(),
|
||||
section: SecuritySection::Core,
|
||||
},
|
||||
SecurityFeature {
|
||||
name: "Capability Inheritance".into(),
|
||||
active: true,
|
||||
description: "validate_capability_inheritance prevents privilege escalation".into(),
|
||||
section: SecuritySection::Core,
|
||||
},
|
||||
SecurityFeature {
|
||||
name: "Secret Zeroization".into(),
|
||||
active: true,
|
||||
description: "Zeroizing<String> auto-wipes API keys from memory".into(),
|
||||
section: SecuritySection::Core,
|
||||
},
|
||||
SecurityFeature {
|
||||
name: "Ed25519 Manifest Signing".into(),
|
||||
active: true,
|
||||
description: "Signed agent manifests with Ed25519 verification".into(),
|
||||
section: SecuritySection::Core,
|
||||
},
|
||||
SecurityFeature {
|
||||
name: "Taint Tracking".into(),
|
||||
active: true,
|
||||
description: "Information flow tracking across tool boundaries".into(),
|
||||
section: SecuritySection::Core,
|
||||
},
|
||||
// Configurable (4)
|
||||
SecurityFeature {
|
||||
name: "OFP Wire Auth".into(),
|
||||
active: true,
|
||||
description: "HMAC-SHA256 mutual authentication with nonce".into(),
|
||||
section: SecuritySection::Configurable,
|
||||
},
|
||||
SecurityFeature {
|
||||
name: "RBAC Multi-User".into(),
|
||||
active: true,
|
||||
description: "Role-based access control with user hierarchy".into(),
|
||||
section: SecuritySection::Configurable,
|
||||
},
|
||||
SecurityFeature {
|
||||
name: "Rate Limiting".into(),
|
||||
active: true,
|
||||
description: "GCRA rate limiter with cost-aware tokens".into(),
|
||||
section: SecuritySection::Configurable,
|
||||
},
|
||||
SecurityFeature {
|
||||
name: "Security Headers".into(),
|
||||
active: true,
|
||||
description: "CSP, X-Frame-Options, HSTS middleware".into(),
|
||||
section: SecuritySection::Configurable,
|
||||
},
|
||||
// Monitoring (3)
|
||||
SecurityFeature {
|
||||
name: "Merkle Audit Trail".into(),
|
||||
active: true,
|
||||
description: "Hash chain audit log with tamper detection".into(),
|
||||
section: SecuritySection::Monitoring,
|
||||
},
|
||||
SecurityFeature {
|
||||
name: "Heartbeat Monitor".into(),
|
||||
active: true,
|
||||
description: "Background health checks with restart limits".into(),
|
||||
section: SecuritySection::Monitoring,
|
||||
},
|
||||
SecurityFeature {
|
||||
name: "Prompt Injection Scanner".into(),
|
||||
active: true,
|
||||
description: "Detects override attempts and data exfiltration".into(),
|
||||
section: SecuritySection::Monitoring,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct SecurityState {
|
||||
pub features: Vec<SecurityFeature>,
|
||||
pub chain_verified: Option<bool>,
|
||||
pub verify_result: String,
|
||||
pub scroll: u16,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
}
|
||||
|
||||
pub enum SecurityAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
VerifyChain,
|
||||
}
|
||||
|
||||
impl SecurityState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
features: builtin_features(),
|
||||
chain_verified: None,
|
||||
verify_result: String::new(),
|
||||
scroll: 0,
|
||||
loading: false,
|
||||
tick: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> SecurityAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return SecurityAction::Continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.scroll = self.scroll.saturating_add(1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.scroll = self.scroll.saturating_sub(1);
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
self.scroll = self.scroll.saturating_add(10);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
self.scroll = self.scroll.saturating_sub(10);
|
||||
}
|
||||
KeyCode::Char('v') => return SecurityAction::VerifyChain,
|
||||
KeyCode::Char('r') => return SecurityAction::Refresh,
|
||||
_ => {}
|
||||
}
|
||||
SecurityAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut SecurityState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Security ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Min(4), // features
|
||||
Constraint::Length(2), // verify result
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// ── Features list ──
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let mut current_section: Option<SecuritySection> = None;
|
||||
|
||||
for feat in &state.features {
|
||||
if current_section != Some(feat.section) {
|
||||
if current_section.is_some() {
|
||||
lines.push(Line::raw(""));
|
||||
}
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" \u{2501}\u{2501} {} \u{2501}\u{2501}",
|
||||
feat.section.label()
|
||||
),
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
current_section = Some(feat.section);
|
||||
}
|
||||
|
||||
let (badge, badge_style) = if feat.active {
|
||||
("\u{2714} Active", Style::default().fg(theme::GREEN))
|
||||
} else {
|
||||
("\u{25cb} Inactive", Style::default().fg(theme::RED))
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<30}", feat.name),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!(" {:<12}", badge), badge_style),
|
||||
Span::styled(format!(" {}", feat.description), theme::dim_style()),
|
||||
]));
|
||||
}
|
||||
|
||||
let total = lines.len() as u16;
|
||||
let visible = chunks[0].height;
|
||||
let max_scroll = total.saturating_sub(visible);
|
||||
let scroll = max_scroll.saturating_sub(state.scroll).min(max_scroll);
|
||||
|
||||
f.render_widget(Paragraph::new(lines).scroll((scroll, 0)), chunks[0]);
|
||||
|
||||
// ── Verify result ──
|
||||
match state.chain_verified {
|
||||
None => {
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Verifying audit chain\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" Press [v] to verify audit chain integrity",
|
||||
theme::dim_style(),
|
||||
)])),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(true) => {
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
" \u{2714} Audit chain verified",
|
||||
Style::default().fg(theme::GREEN),
|
||||
)]),
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" {}", state.verify_result),
|
||||
theme::dim_style(),
|
||||
)]),
|
||||
]),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
Some(false) => {
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
" \u{2718} Audit chain verification failed",
|
||||
Style::default().fg(theme::RED),
|
||||
)]),
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" {}", state.verify_result),
|
||||
Style::default().fg(theme::RED),
|
||||
)]),
|
||||
]),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hints ──
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Scroll [v] Verify Chain [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
313
crates/openfang-cli/src/tui/screens/sessions.rs
Normal file
313
crates/openfang-cli/src/tui/screens/sessions.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
//! Sessions screen: browse agent sessions, open in chat, delete.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub agent_name: String,
|
||||
pub agent_id: String,
|
||||
pub message_count: u64,
|
||||
pub created: String,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct SessionsState {
|
||||
pub sessions: Vec<SessionInfo>,
|
||||
pub filtered: Vec<usize>,
|
||||
pub list_state: ListState,
|
||||
pub search_buf: String,
|
||||
pub search_mode: bool,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub confirm_delete: bool,
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum SessionsAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
OpenInChat {
|
||||
agent_id: String,
|
||||
agent_name: String,
|
||||
},
|
||||
DeleteSession(String),
|
||||
}
|
||||
|
||||
impl SessionsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Vec::new(),
|
||||
filtered: Vec::new(),
|
||||
list_state: ListState::default(),
|
||||
search_buf: String::new(),
|
||||
search_mode: false,
|
||||
loading: false,
|
||||
tick: 0,
|
||||
confirm_delete: false,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn refilter(&mut self) {
|
||||
if self.search_buf.is_empty() {
|
||||
self.filtered = (0..self.sessions.len()).collect();
|
||||
} else {
|
||||
let q = self.search_buf.to_lowercase();
|
||||
self.filtered = self
|
||||
.sessions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| s.agent_name.to_lowercase().contains(&q))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
}
|
||||
if !self.filtered.is_empty() {
|
||||
self.list_state.select(Some(0));
|
||||
} else {
|
||||
self.list_state.select(None);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> SessionsAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return SessionsAction::Continue;
|
||||
}
|
||||
|
||||
if self.search_mode {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.search_mode = false;
|
||||
self.search_buf.clear();
|
||||
self.refilter();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.search_mode = false;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.search_buf.pop();
|
||||
self.refilter();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.search_buf.push(c);
|
||||
self.refilter();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return SessionsAction::Continue;
|
||||
}
|
||||
|
||||
if self.confirm_delete {
|
||||
match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||
self.confirm_delete = false;
|
||||
if let Some(sel) = self.list_state.selected() {
|
||||
if let Some(&idx) = self.filtered.get(sel) {
|
||||
let id = self.sessions[idx].id.clone();
|
||||
return SessionsAction::DeleteSession(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.confirm_delete = false;
|
||||
}
|
||||
}
|
||||
return SessionsAction::Continue;
|
||||
}
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = self.list_state.selected() {
|
||||
if let Some(&idx) = self.filtered.get(sel) {
|
||||
let s = &self.sessions[idx];
|
||||
return SessionsAction::OpenInChat {
|
||||
agent_id: s.agent_id.clone(),
|
||||
agent_name: s.agent_name.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
if self.list_state.selected().is_some() {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('/') => {
|
||||
self.search_mode = true;
|
||||
self.search_buf.clear();
|
||||
}
|
||||
KeyCode::Char('r') => return SessionsAction::Refresh,
|
||||
_ => {}
|
||||
}
|
||||
SessionsAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut SessionsState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Sessions ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // header + search
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints / status
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// ── Header / search bar ──
|
||||
if state.search_mode {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(" / ", Style::default().fg(theme::ACCENT)),
|
||||
Span::styled(&state.search_buf, theme::input_style()),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
])),
|
||||
chunks[0],
|
||||
);
|
||||
} else {
|
||||
let search_hint = if state.search_buf.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" (filter: \"{}\")", state.search_buf)
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(
|
||||
" {:<20} {:<16} {:<8} {}",
|
||||
"Agent", "Session ID", "Msgs", "Created"
|
||||
),
|
||||
theme::table_header(),
|
||||
),
|
||||
Span::styled(search_hint, theme::dim_style()),
|
||||
])),
|
||||
chunks[0],
|
||||
);
|
||||
}
|
||||
|
||||
// ── List ──
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading sessions\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.filtered.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(" No sessions found.", theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.filtered
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
let s = &state.sessions[idx];
|
||||
let id_short = if s.id.len() > 12 {
|
||||
format!("{}\u{2026}", &s.id[..12])
|
||||
} else {
|
||||
s.id.clone()
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<20}", truncate(&s.agent_name, 19)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(format!(" {:<16}", id_short), theme::dim_style()),
|
||||
Span::styled(
|
||||
format!(" {:<8}", s.message_count),
|
||||
Style::default().fg(theme::GREEN),
|
||||
),
|
||||
Span::styled(format!(" {}", s.created), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.list_state);
|
||||
}
|
||||
|
||||
// ── Hints / status ──
|
||||
if state.confirm_delete {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" Delete this session? [y] Yes [any] Cancel",
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
} else if !state.status_msg.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {}", state.status_msg),
|
||||
Style::default().fg(theme::GREEN),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [Enter] Open in Chat [d] Delete [/] Search [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
619
crates/openfang-cli/src/tui/screens/settings.rs
Normal file
619
crates/openfang-cli/src/tui/screens/settings.rs
Normal file
@@ -0,0 +1,619 @@
|
||||
//! Settings screen: provider key management, model catalog, tools list.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ProviderInfo {
|
||||
pub name: String,
|
||||
pub configured: bool,
|
||||
pub env_var: String,
|
||||
/// Whether this is a local provider (ollama, vllm, lmstudio).
|
||||
pub is_local: bool,
|
||||
/// Whether the local provider is reachable (only set for local providers).
|
||||
pub reachable: Option<bool>,
|
||||
/// Probe latency in milliseconds (only set for local providers).
|
||||
pub latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ModelInfo {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub tier: String,
|
||||
pub context_window: u64,
|
||||
pub cost_input: f64,
|
||||
pub cost_output: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ToolInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TestResult {
|
||||
pub provider: String,
|
||||
pub success: bool,
|
||||
pub latency_ms: u64,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SettingsSub {
|
||||
Providers,
|
||||
Models,
|
||||
Tools,
|
||||
}
|
||||
|
||||
pub struct SettingsState {
|
||||
pub sub: SettingsSub,
|
||||
pub providers: Vec<ProviderInfo>,
|
||||
pub models: Vec<ModelInfo>,
|
||||
pub tools: Vec<ToolInfo>,
|
||||
pub provider_list: ListState,
|
||||
pub model_list: ListState,
|
||||
pub tool_list: ListState,
|
||||
pub input_buf: String,
|
||||
pub input_mode: bool,
|
||||
pub editing_provider: Option<String>,
|
||||
pub test_result: Option<TestResult>,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum SettingsAction {
|
||||
Continue,
|
||||
RefreshProviders,
|
||||
RefreshModels,
|
||||
RefreshTools,
|
||||
SaveProviderKey { name: String, key: String },
|
||||
DeleteProviderKey(String),
|
||||
TestProvider(String),
|
||||
}
|
||||
|
||||
impl SettingsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sub: SettingsSub::Providers,
|
||||
providers: Vec::new(),
|
||||
models: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
provider_list: ListState::default(),
|
||||
model_list: ListState::default(),
|
||||
tool_list: ListState::default(),
|
||||
input_buf: String::new(),
|
||||
input_mode: false,
|
||||
editing_provider: None,
|
||||
test_result: None,
|
||||
loading: false,
|
||||
tick: 0,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return SettingsAction::Continue;
|
||||
}
|
||||
|
||||
if self.input_mode {
|
||||
return self.handle_input(key);
|
||||
}
|
||||
|
||||
// Sub-tab switching
|
||||
if !self.input_mode {
|
||||
match key.code {
|
||||
KeyCode::Char('1') => {
|
||||
self.sub = SettingsSub::Providers;
|
||||
return SettingsAction::RefreshProviders;
|
||||
}
|
||||
KeyCode::Char('2') => {
|
||||
self.sub = SettingsSub::Models;
|
||||
return SettingsAction::RefreshModels;
|
||||
}
|
||||
KeyCode::Char('3') => {
|
||||
self.sub = SettingsSub::Tools;
|
||||
return SettingsAction::RefreshTools;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match self.sub {
|
||||
SettingsSub::Providers => self.handle_providers(key),
|
||||
SettingsSub::Models => self.handle_models(key),
|
||||
SettingsSub::Tools => self.handle_tools(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_input(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.input_mode = false;
|
||||
self.editing_provider = None;
|
||||
self.input_buf.clear();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.input_mode = false;
|
||||
if let Some(name) = self.editing_provider.take() {
|
||||
if !self.input_buf.is_empty() {
|
||||
let api_key = self.input_buf.clone();
|
||||
self.input_buf.clear();
|
||||
return SettingsAction::SaveProviderKey { name, key: api_key };
|
||||
}
|
||||
}
|
||||
self.input_buf.clear();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.input_buf.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.input_buf.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
SettingsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_providers(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
let total = self.providers.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
if let Some(sel) = self.provider_list.selected() {
|
||||
if sel < self.providers.len() {
|
||||
self.editing_provider = Some(self.providers[sel].name.clone());
|
||||
self.input_mode = true;
|
||||
self.input_buf.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
if let Some(sel) = self.provider_list.selected() {
|
||||
if sel < self.providers.len() {
|
||||
return SettingsAction::DeleteProviderKey(self.providers[sel].name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('t') => {
|
||||
if let Some(sel) = self.provider_list.selected() {
|
||||
if sel < self.providers.len() {
|
||||
self.test_result = None;
|
||||
return SettingsAction::TestProvider(self.providers[sel].name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return SettingsAction::RefreshProviders,
|
||||
_ => {}
|
||||
}
|
||||
SettingsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_models(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
let total = self.models.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return SettingsAction::RefreshModels,
|
||||
_ => {}
|
||||
}
|
||||
SettingsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_tools(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
let total = self.tools.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return SettingsAction::RefreshTools,
|
||||
_ => {}
|
||||
}
|
||||
SettingsAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut SettingsState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Settings ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // sub-tab bar
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(3), // content
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
draw_sub_tabs(f, chunks[0], state.sub);
|
||||
|
||||
let sep = "\u{2500}".repeat(chunks[1].width as usize);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(sep, theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
match state.sub {
|
||||
SettingsSub::Providers => draw_providers(f, chunks[2], state),
|
||||
SettingsSub::Models => draw_models(f, chunks[2], state),
|
||||
SettingsSub::Tools => draw_tools(f, chunks[2], state),
|
||||
}
|
||||
|
||||
// Hints
|
||||
let hint_text = match state.sub {
|
||||
SettingsSub::Providers if state.input_mode => " [Enter] Save [Esc] Cancel",
|
||||
SettingsSub::Providers => {
|
||||
" [\u{2191}\u{2193}] Navigate [e] Set Key [d] Delete Key [t] Test [r] Refresh"
|
||||
}
|
||||
SettingsSub::Models => " [\u{2191}\u{2193}] Navigate [r] Refresh",
|
||||
SettingsSub::Tools => " [\u{2191}\u{2193}] Navigate [r] Refresh",
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
hint_text,
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[3],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_sub_tabs(f: &mut Frame, area: Rect, active: SettingsSub) {
|
||||
let tabs = [
|
||||
(SettingsSub::Providers, "1 Providers"),
|
||||
(SettingsSub::Models, "2 Models"),
|
||||
(SettingsSub::Tools, "3 Tools"),
|
||||
];
|
||||
let mut spans = vec![Span::raw(" ")];
|
||||
for (sub, label) in &tabs {
|
||||
let style = if *sub == active {
|
||||
theme::tab_active()
|
||||
} else {
|
||||
theme::tab_inactive()
|
||||
};
|
||||
spans.push(Span::styled(format!(" {label} "), style));
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||
}
|
||||
|
||||
fn draw_providers(f: &mut Frame, area: Rect, state: &mut SettingsState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(2), // input / test result
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {:<20} {:<20} {}", "Provider", "Status", "Env Variable"),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading && state.providers.is_empty() {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading providers\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.providers.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No providers available.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.providers
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (badge, badge_style) = if p.is_local {
|
||||
match p.reachable {
|
||||
Some(true) => {
|
||||
let ms = p.latency_ms.unwrap_or(0);
|
||||
(
|
||||
format!("\u{2714} Online ({ms}ms)"),
|
||||
Style::default().fg(theme::GREEN),
|
||||
)
|
||||
}
|
||||
Some(false) => (
|
||||
"\u{2718} Offline".to_string(),
|
||||
Style::default().fg(theme::RED),
|
||||
),
|
||||
None => ("\u{25cb} Local".to_string(), theme::dim_style()),
|
||||
}
|
||||
} else if p.configured {
|
||||
(
|
||||
"\u{2714} Configured".to_string(),
|
||||
Style::default().fg(theme::GREEN),
|
||||
)
|
||||
} else {
|
||||
("\u{25cb} Not set".to_string(), theme::dim_style())
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<20}", &p.name),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(format!(" {:<20}", badge), badge_style),
|
||||
Span::styled(format!(" {}", &p.env_var), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.provider_list);
|
||||
}
|
||||
|
||||
// Input mode or test result
|
||||
if state.input_mode {
|
||||
let provider_name = state.editing_provider.as_deref().unwrap_or("?");
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" Enter API key for {provider_name}: "),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)]),
|
||||
Line::from(vec![
|
||||
Span::raw(" > "),
|
||||
Span::styled(
|
||||
"\u{2022}".repeat(state.input_buf.len().min(40)),
|
||||
theme::input_style(),
|
||||
),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
chunks[2],
|
||||
);
|
||||
} else if let Some(result) = &state.test_result {
|
||||
let (icon, style) = if result.success {
|
||||
("\u{2714}", Style::default().fg(theme::GREEN))
|
||||
} else {
|
||||
("\u{2718}", Style::default().fg(theme::RED))
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {icon} "), style),
|
||||
Span::styled(format!("{}: {}", result.provider, result.message), style),
|
||||
]),
|
||||
Line::from(vec![Span::styled(
|
||||
if result.success {
|
||||
format!(" Latency: {}ms", result.latency_ms)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
theme::dim_style(),
|
||||
)]),
|
||||
]),
|
||||
chunks[2],
|
||||
);
|
||||
} else if !state.status_msg.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {}", state.status_msg),
|
||||
Style::default().fg(theme::GREEN),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_models(f: &mut Frame, area: Rect, state: &mut SettingsState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<28} {:<14} {:<10} {:<10} {}",
|
||||
"Model ID", "Provider", "Tier", "Context", "Cost (in/out per 1M)"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading && state.models.is_empty() {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading models\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.models.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(" No models available.", theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.models
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let tier_style = match m.tier.as_str() {
|
||||
"Frontier" => Style::default()
|
||||
.fg(theme::PURPLE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
"Smart" => Style::default()
|
||||
.fg(theme::BLUE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
"Balanced" => Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
"Fast" => Style::default()
|
||||
.fg(theme::YELLOW)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
_ => theme::dim_style(),
|
||||
};
|
||||
let ctx = format_context(m.context_window);
|
||||
let cost = format!("${:.2}/${:.2}", m.cost_input, m.cost_output);
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<28}", truncate(&m.id, 27)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<14}", truncate(&m.provider, 13)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
Span::styled(format!(" {:<10}", &m.tier), tier_style),
|
||||
Span::styled(format!(" {:<10}", ctx), Style::default().fg(theme::YELLOW)),
|
||||
Span::styled(format!(" {cost}"), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.model_list);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_tools(f: &mut Frame, area: Rect, state: &mut SettingsState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {:<24} {}", "Tool Name", "Description"),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.tools.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(" No tools available.", theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.tools
|
||||
.iter()
|
||||
.map(|t| {
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<24}", truncate(&t.name, 23)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {}", truncate(&t.description, 50)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.tool_list);
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_context(n: u64) -> String {
|
||||
if n >= 1_000_000 {
|
||||
format!("{:.1}M", n as f64 / 1_000_000.0)
|
||||
} else if n >= 1_000 {
|
||||
format!("{}K", n / 1_000)
|
||||
} else {
|
||||
format!("{n}")
|
||||
}
|
||||
}
|
||||
627
crates/openfang-cli/src/tui/screens/skills.rs
Normal file
627
crates/openfang-cli/src/tui/screens/skills.rs
Normal file
@@ -0,0 +1,627 @@
|
||||
//! Skills screen: installed skills, ClawHub marketplace, MCP servers.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SkillInfo {
|
||||
pub name: String,
|
||||
pub runtime: String,
|
||||
pub source: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ClawHubResult {
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub description: String,
|
||||
pub downloads: u64,
|
||||
pub runtime: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct McpServerInfo {
|
||||
pub name: String,
|
||||
pub connected: bool,
|
||||
pub tool_count: usize,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SkillsSub {
|
||||
Installed,
|
||||
ClawHub,
|
||||
Mcp,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClawHubSort {
|
||||
Trending,
|
||||
Popular,
|
||||
Recent,
|
||||
}
|
||||
|
||||
impl ClawHubSort {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Trending => "trending",
|
||||
Self::Popular => "popular",
|
||||
Self::Recent => "recent",
|
||||
}
|
||||
}
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Trending => Self::Popular,
|
||||
Self::Popular => Self::Recent,
|
||||
Self::Recent => Self::Trending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SkillsState {
|
||||
pub sub: SkillsSub,
|
||||
pub installed: Vec<SkillInfo>,
|
||||
pub clawhub_results: Vec<ClawHubResult>,
|
||||
pub mcp_servers: Vec<McpServerInfo>,
|
||||
pub installed_list: ListState,
|
||||
pub clawhub_list: ListState,
|
||||
pub mcp_list: ListState,
|
||||
pub search_buf: String,
|
||||
pub search_mode: bool,
|
||||
pub sort: ClawHubSort,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub confirm_uninstall: bool,
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum SkillsAction {
|
||||
Continue,
|
||||
RefreshInstalled,
|
||||
SearchClawHub(String),
|
||||
BrowseClawHub(String),
|
||||
InstallSkill(String),
|
||||
UninstallSkill(String),
|
||||
RefreshMcp,
|
||||
}
|
||||
|
||||
impl SkillsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sub: SkillsSub::Installed,
|
||||
installed: Vec::new(),
|
||||
clawhub_results: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
installed_list: ListState::default(),
|
||||
clawhub_list: ListState::default(),
|
||||
mcp_list: ListState::default(),
|
||||
search_buf: String::new(),
|
||||
search_mode: false,
|
||||
sort: ClawHubSort::Trending,
|
||||
loading: false,
|
||||
tick: 0,
|
||||
confirm_uninstall: false,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> SkillsAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return SkillsAction::Continue;
|
||||
}
|
||||
|
||||
// Tab switching within Skills (1/2/3)
|
||||
if !self.search_mode {
|
||||
match key.code {
|
||||
KeyCode::Char('1') => {
|
||||
self.sub = SkillsSub::Installed;
|
||||
return SkillsAction::RefreshInstalled;
|
||||
}
|
||||
KeyCode::Char('2') => {
|
||||
self.sub = SkillsSub::ClawHub;
|
||||
return SkillsAction::BrowseClawHub(self.sort.label().to_string());
|
||||
}
|
||||
KeyCode::Char('3') => {
|
||||
self.sub = SkillsSub::Mcp;
|
||||
return SkillsAction::RefreshMcp;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match self.sub {
|
||||
SkillsSub::Installed => self.handle_installed(key),
|
||||
SkillsSub::ClawHub => self.handle_clawhub(key),
|
||||
SkillsSub::Mcp => self.handle_mcp(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_installed(&mut self, key: KeyEvent) -> SkillsAction {
|
||||
if self.confirm_uninstall {
|
||||
match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||
self.confirm_uninstall = false;
|
||||
if let Some(sel) = self.installed_list.selected() {
|
||||
if sel < self.installed.len() {
|
||||
return SkillsAction::UninstallSkill(self.installed[sel].name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => self.confirm_uninstall = false,
|
||||
}
|
||||
return SkillsAction::Continue;
|
||||
}
|
||||
|
||||
let total = self.installed.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('u') => {
|
||||
if self.installed_list.selected().is_some() {
|
||||
self.confirm_uninstall = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return SkillsAction::RefreshInstalled,
|
||||
_ => {}
|
||||
}
|
||||
SkillsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_clawhub(&mut self, key: KeyEvent) -> SkillsAction {
|
||||
if self.search_mode {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.search_mode = false;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.search_mode = false;
|
||||
if !self.search_buf.is_empty() {
|
||||
return SkillsAction::SearchClawHub(self.search_buf.clone());
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.search_buf.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.search_buf.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return SkillsAction::Continue;
|
||||
}
|
||||
|
||||
let total = self.clawhub_results.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('i') => {
|
||||
if let Some(sel) = self.clawhub_list.selected() {
|
||||
if sel < self.clawhub_results.len() {
|
||||
return SkillsAction::InstallSkill(self.clawhub_results[sel].slug.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('/') => {
|
||||
self.search_mode = true;
|
||||
self.search_buf.clear();
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
self.sort = self.sort.next();
|
||||
return SkillsAction::BrowseClawHub(self.sort.label().to_string());
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
return SkillsAction::BrowseClawHub(self.sort.label().to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
SkillsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_mcp(&mut self, key: KeyEvent) -> SkillsAction {
|
||||
let total = self.mcp_servers.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return SkillsAction::RefreshMcp,
|
||||
_ => {}
|
||||
}
|
||||
SkillsAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Skills ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // sub-tab bar
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(3), // content
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Sub-tab bar
|
||||
draw_sub_tabs(f, chunks[0], state.sub);
|
||||
|
||||
let sep = "\u{2500}".repeat(chunks[1].width as usize);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(sep, theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
match state.sub {
|
||||
SkillsSub::Installed => draw_installed(f, chunks[2], state),
|
||||
SkillsSub::ClawHub => draw_clawhub(f, chunks[2], state),
|
||||
SkillsSub::Mcp => draw_mcp(f, chunks[2], state),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_sub_tabs(f: &mut Frame, area: Rect, active: SkillsSub) {
|
||||
let tabs = [
|
||||
(SkillsSub::Installed, "1 Installed"),
|
||||
(SkillsSub::ClawHub, "2 ClawHub"),
|
||||
(SkillsSub::Mcp, "3 MCP Servers"),
|
||||
];
|
||||
let mut spans = vec![Span::raw(" ")];
|
||||
for (sub, label) in &tabs {
|
||||
let style = if *sub == active {
|
||||
theme::tab_active()
|
||||
} else {
|
||||
theme::tab_inactive()
|
||||
};
|
||||
spans.push(Span::styled(format!(" {label} "), style));
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||
}
|
||||
|
||||
fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<20} {:<8} {:<12} {}",
|
||||
"Name", "Runtime", "Source", "Description"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading skills\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.installed.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No skills installed. Press [2] to browse ClawHub.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.installed
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let runtime_style = match s.runtime.as_str() {
|
||||
"python" | "py" => Style::default().fg(theme::BLUE),
|
||||
"node" | "js" => Style::default().fg(theme::YELLOW),
|
||||
"wasm" => Style::default().fg(theme::PURPLE),
|
||||
_ => Style::default().fg(theme::GREEN),
|
||||
};
|
||||
let runtime_badge = match s.runtime.as_str() {
|
||||
"python" | "py" => "PY",
|
||||
"node" | "js" => "JS",
|
||||
"wasm" => "WASM",
|
||||
"prompt" => "PROMPT",
|
||||
_ => &s.runtime,
|
||||
};
|
||||
let source_style = match s.source.as_str() {
|
||||
"clawhub" => Style::default().fg(theme::ACCENT),
|
||||
"builtin" | "built-in" => Style::default().fg(theme::GREEN),
|
||||
_ => theme::dim_style(),
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<20}", truncate(&s.name, 19)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(format!(" {:<8}", runtime_badge), runtime_style),
|
||||
Span::styled(format!(" {:<12}", &s.source), source_style),
|
||||
Span::styled(
|
||||
format!(" {}", truncate(&s.description, 30)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.installed_list);
|
||||
}
|
||||
|
||||
if state.confirm_uninstall {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" Uninstall this skill? [y] Yes [any] Cancel",
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
} else if !state.status_msg.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {}", state.status_msg),
|
||||
Style::default().fg(theme::GREEN),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [u] Uninstall [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_clawhub(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // search / sort
|
||||
Constraint::Min(3), // results
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
if state.search_mode {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(" / ", Style::default().fg(theme::ACCENT)),
|
||||
Span::styled(&state.search_buf, theme::input_style()),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
])),
|
||||
chunks[0],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(
|
||||
" {:<24} {:<10} {:<10} {}",
|
||||
"Name", "Downloads", "Runtime", "Description"
|
||||
),
|
||||
theme::table_header(),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" Sort: {}", state.sort.label()),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
),
|
||||
])),
|
||||
chunks[0],
|
||||
);
|
||||
}
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Searching ClawHub\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.clawhub_results.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No results. Press [/] to search or [s] to change sort.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.clawhub_results
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let dl = format_count(r.downloads);
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<24}", truncate(&r.name, 23)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(format!(" {:<10}", dl), Style::default().fg(theme::GREEN)),
|
||||
Span::styled(
|
||||
format!(" {:<10}", &r.runtime),
|
||||
Style::default().fg(theme::BLUE),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {}", truncate(&r.description, 30)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.clawhub_list);
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [i] Install [/] Search [s] Sort [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_mcp(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {:<20} {:<14} {}", "Server", "Status", "Tools"),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading MCP servers\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.mcp_servers.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No MCP servers configured.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let (badge, style) = if s.connected {
|
||||
("\u{2714} Connected", Style::default().fg(theme::GREEN))
|
||||
} else {
|
||||
("\u{2718} Disconnected", Style::default().fg(theme::RED))
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<20}", truncate(&s.name, 19)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(format!(" {:<14}", badge), style),
|
||||
Span::styled(format!(" {}", s.tool_count), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.mcp_list);
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_count(n: u64) -> String {
|
||||
if n >= 1_000_000 {
|
||||
format!("{:.1}M", n as f64 / 1_000_000.0)
|
||||
} else if n >= 1_000 {
|
||||
format!("{:.1}K", n as f64 / 1_000.0)
|
||||
} else {
|
||||
format!("{n}")
|
||||
}
|
||||
}
|
||||
404
crates/openfang-cli/src/tui/screens/templates.rs
Normal file
404
crates/openfang-cli/src/tui/screens/templates.rs
Normal file
@@ -0,0 +1,404 @@
|
||||
//! Templates screen: browse agent templates and spawn with one click.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TemplateInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderAuth {
|
||||
pub name: String,
|
||||
pub configured: bool,
|
||||
}
|
||||
|
||||
// ── Built-in templates ──────────────────────────────────────────────────────
|
||||
|
||||
const BUILTIN_TEMPLATES: &[(&str, &str, &str, &str, &str)] = &[
|
||||
(
|
||||
"General Assistant",
|
||||
"Versatile AI assistant for everyday tasks",
|
||||
"General",
|
||||
"anthropic",
|
||||
"claude-sonnet-4-20250514",
|
||||
),
|
||||
(
|
||||
"Code Helper",
|
||||
"Programming assistant with code review and debugging",
|
||||
"Development",
|
||||
"anthropic",
|
||||
"claude-sonnet-4-20250514",
|
||||
),
|
||||
(
|
||||
"Researcher",
|
||||
"Deep research and analysis with web search",
|
||||
"Research",
|
||||
"anthropic",
|
||||
"claude-sonnet-4-20250514",
|
||||
),
|
||||
(
|
||||
"Writer",
|
||||
"Creative and technical writing assistant",
|
||||
"Writing",
|
||||
"anthropic",
|
||||
"claude-sonnet-4-20250514",
|
||||
),
|
||||
(
|
||||
"Data Analyst",
|
||||
"Data analysis, visualization, and SQL queries",
|
||||
"Development",
|
||||
"gemini",
|
||||
"gemini-2.5-flash",
|
||||
),
|
||||
(
|
||||
"DevOps Engineer",
|
||||
"Infrastructure, CI/CD, and deployment assistance",
|
||||
"Development",
|
||||
"groq",
|
||||
"llama-3.3-70b-versatile",
|
||||
),
|
||||
(
|
||||
"Customer Support",
|
||||
"Professional customer service agent",
|
||||
"Business",
|
||||
"groq",
|
||||
"llama-3.3-70b-versatile",
|
||||
),
|
||||
(
|
||||
"Tutor",
|
||||
"Patient educational assistant for learning any subject",
|
||||
"General",
|
||||
"gemini",
|
||||
"gemini-2.5-flash",
|
||||
),
|
||||
(
|
||||
"API Designer",
|
||||
"REST/GraphQL API design and documentation",
|
||||
"Development",
|
||||
"anthropic",
|
||||
"claude-sonnet-4-20250514",
|
||||
),
|
||||
(
|
||||
"Meeting Notes",
|
||||
"Meeting transcription, summary, and action items",
|
||||
"Business",
|
||||
"groq",
|
||||
"llama-3.3-70b-versatile",
|
||||
),
|
||||
];
|
||||
|
||||
// ── Categories ──────────────────────────────────────────────────────────────
|
||||
|
||||
const CATEGORIES: &[&str] = &[
|
||||
"All",
|
||||
"General",
|
||||
"Development",
|
||||
"Research",
|
||||
"Writing",
|
||||
"Business",
|
||||
];
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct TemplatesState {
|
||||
pub templates: Vec<TemplateInfo>,
|
||||
pub providers: Vec<ProviderAuth>,
|
||||
pub category_filter: usize,
|
||||
pub filtered: Vec<usize>,
|
||||
pub list_state: ListState,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum TemplatesAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
SpawnTemplate(String),
|
||||
}
|
||||
|
||||
impl TemplatesState {
|
||||
pub fn new() -> Self {
|
||||
let templates: Vec<TemplateInfo> = BUILTIN_TEMPLATES
|
||||
.iter()
|
||||
.map(|(name, desc, cat, prov, model)| TemplateInfo {
|
||||
name: name.to_string(),
|
||||
description: desc.to_string(),
|
||||
category: cat.to_string(),
|
||||
provider: prov.to_string(),
|
||||
model: model.to_string(),
|
||||
})
|
||||
.collect();
|
||||
let filtered: Vec<usize> = (0..templates.len()).collect();
|
||||
let mut state = Self {
|
||||
templates,
|
||||
providers: Vec::new(),
|
||||
category_filter: 0,
|
||||
filtered,
|
||||
list_state: ListState::default(),
|
||||
loading: false,
|
||||
tick: 0,
|
||||
status_msg: String::new(),
|
||||
};
|
||||
state.list_state.select(Some(0));
|
||||
state
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
fn refilter(&mut self) {
|
||||
let cat = CATEGORIES[self.category_filter];
|
||||
if cat == "All" {
|
||||
self.filtered = (0..self.templates.len()).collect();
|
||||
} else {
|
||||
self.filtered = self
|
||||
.templates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| t.category == cat)
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
}
|
||||
if !self.filtered.is_empty() {
|
||||
self.list_state.select(Some(0));
|
||||
} else {
|
||||
self.list_state.select(None);
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_configured(&self, provider: &str) -> bool {
|
||||
self.providers
|
||||
.iter()
|
||||
.any(|p| p.name == provider && p.configured)
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> TemplatesAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return TemplatesAction::Continue;
|
||||
}
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = self.list_state.selected() {
|
||||
if let Some(&idx) = self.filtered.get(sel) {
|
||||
let t = &self.templates[idx];
|
||||
if !self.provider_configured(&t.provider) && !self.providers.is_empty() {
|
||||
self.status_msg = format!(
|
||||
"Provider '{}' not configured. Set API key in Settings first.",
|
||||
t.provider
|
||||
);
|
||||
return TemplatesAction::Continue;
|
||||
}
|
||||
return TemplatesAction::SpawnTemplate(t.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
self.category_filter = (self.category_filter + 1) % CATEGORIES.len();
|
||||
self.refilter();
|
||||
}
|
||||
KeyCode::Char('r') => return TemplatesAction::Refresh,
|
||||
_ => {}
|
||||
}
|
||||
TemplatesAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut TemplatesState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Templates ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // header + category filter
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(3), // detail preview
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// ── Category filter + header ──
|
||||
let active_cat = CATEGORIES[state.category_filter];
|
||||
let cat_spans: Vec<Span> = CATEGORIES
|
||||
.iter()
|
||||
.map(|&c| {
|
||||
if c == active_cat {
|
||||
Span::styled(
|
||||
format!(" [{c}] "),
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else {
|
||||
Span::styled(format!(" {c} "), theme::dim_style())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(cat_spans),
|
||||
Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<22} {:<14} {:<16} {}",
|
||||
"Template", "Category", "Provider/Model", "Description"
|
||||
),
|
||||
theme::table_header(),
|
||||
)]),
|
||||
]),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
// ── List ──
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading templates\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.filtered.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No templates in this category.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.filtered
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
let t = &state.templates[idx];
|
||||
let configured = state.provider_configured(&t.provider);
|
||||
let auth_badge = if state.providers.is_empty() {
|
||||
Span::raw("")
|
||||
} else if configured {
|
||||
Span::styled(" \u{2714}", Style::default().fg(theme::GREEN))
|
||||
} else {
|
||||
Span::styled(" \u{2718}", Style::default().fg(theme::RED))
|
||||
};
|
||||
let prov_model = format!("{}/{}", t.provider, truncate(&t.model, 12));
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<22}", truncate(&t.name, 21)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<14}", &t.category),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<16}", truncate(&prov_model, 15)),
|
||||
Style::default().fg(theme::BLUE),
|
||||
),
|
||||
auth_badge,
|
||||
Span::styled(
|
||||
format!(" {}", truncate(&t.description, 28)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.list_state);
|
||||
}
|
||||
|
||||
// ── Detail preview ──
|
||||
if let Some(sel) = state.list_state.selected() {
|
||||
if let Some(&idx) = state.filtered.get(sel) {
|
||||
let t = &state.templates[idx];
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" {} ", t.name),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
Line::from(vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(&t.description, theme::dim_style()),
|
||||
]),
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" Provider: {}/{} ", t.provider, t.model),
|
||||
Style::default().fg(theme::BLUE),
|
||||
)]),
|
||||
]),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hints / status ──
|
||||
if !state.status_msg.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {}", state.status_msg),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)])),
|
||||
chunks[3],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [Enter] Spawn Agent [f] Filter Category [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[3],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
554
crates/openfang-cli/src/tui/screens/triggers.rs
Normal file
554
crates/openfang-cli/src/tui/screens/triggers.rs
Normal file
@@ -0,0 +1,554 @@
|
||||
//! Triggers screen: CRUD with pattern type picker.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct TriggerInfo {
|
||||
pub id: String,
|
||||
pub agent_id: String,
|
||||
pub pattern: String,
|
||||
pub fires: u64,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
const PATTERN_TYPES: &[(&str, &str)] = &[
|
||||
("Lifecycle", "Agent lifecycle events (start, stop, error)"),
|
||||
("AgentSpawned", "Fires when a new agent is spawned"),
|
||||
("ContentMatch", "Match on message content (regex)"),
|
||||
("Schedule", "Cron-like schedule trigger"),
|
||||
("Webhook", "HTTP webhook trigger"),
|
||||
("ChannelMessage", "Message received on a channel"),
|
||||
];
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum TriggerSubScreen {
|
||||
List,
|
||||
Create,
|
||||
}
|
||||
|
||||
pub struct TriggerState {
|
||||
pub sub: TriggerSubScreen,
|
||||
pub triggers: Vec<TriggerInfo>,
|
||||
pub list_state: ListState,
|
||||
// Create wizard
|
||||
pub create_step: usize, // 0=agent, 1=pattern_type, 2=param, 3=prompt, 4=max_fires, 5=review
|
||||
pub create_agent_id: String,
|
||||
pub create_pattern_type: usize,
|
||||
pub create_pattern_param: String,
|
||||
pub create_prompt: String,
|
||||
pub create_max_fires: String,
|
||||
pub pattern_type_list: ListState,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum TriggerAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
CreateTrigger {
|
||||
agent_id: String,
|
||||
pattern_type: String,
|
||||
pattern_param: String,
|
||||
prompt: String,
|
||||
max_fires: u64,
|
||||
},
|
||||
DeleteTrigger(String),
|
||||
}
|
||||
|
||||
impl TriggerState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sub: TriggerSubScreen::List,
|
||||
triggers: Vec::new(),
|
||||
list_state: ListState::default(),
|
||||
create_step: 0,
|
||||
create_agent_id: String::new(),
|
||||
create_pattern_type: 0,
|
||||
create_pattern_param: String::new(),
|
||||
create_prompt: String::new(),
|
||||
create_max_fires: String::new(),
|
||||
pattern_type_list: ListState::default(),
|
||||
loading: false,
|
||||
tick: 0,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> TriggerAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return TriggerAction::Continue;
|
||||
}
|
||||
match self.sub {
|
||||
TriggerSubScreen::List => self.handle_list(key),
|
||||
TriggerSubScreen::Create => self.handle_create(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_list(&mut self, key: KeyEvent) -> TriggerAction {
|
||||
let total = self.triggers.len() + 1; // +1 for "Create new"
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
if let Some(idx) = self.list_state.selected() {
|
||||
if idx < self.triggers.len() {
|
||||
let id = self.triggers[idx].id.clone();
|
||||
return TriggerAction::DeleteTrigger(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(idx) = self.list_state.selected() {
|
||||
if idx >= self.triggers.len() {
|
||||
// "Create new"
|
||||
self.create_step = 0;
|
||||
self.create_agent_id.clear();
|
||||
self.create_pattern_type = 0;
|
||||
self.create_pattern_param.clear();
|
||||
self.create_prompt.clear();
|
||||
self.create_max_fires.clear();
|
||||
self.pattern_type_list.select(Some(0));
|
||||
self.sub = TriggerSubScreen::Create;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return TriggerAction::Refresh,
|
||||
_ => {}
|
||||
}
|
||||
TriggerAction::Continue
|
||||
}
|
||||
|
||||
fn handle_create(&mut self, key: KeyEvent) -> TriggerAction {
|
||||
match self.create_step {
|
||||
1 => return self.handle_pattern_picker(key),
|
||||
5 => return self.handle_review(key),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
if self.create_step == 0 {
|
||||
self.sub = TriggerSubScreen::List;
|
||||
} else {
|
||||
self.create_step -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if self.create_step < 5 {
|
||||
self.create_step += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => match self.create_step {
|
||||
0 => self.create_agent_id.push(c),
|
||||
2 => self.create_pattern_param.push(c),
|
||||
3 => self.create_prompt.push(c),
|
||||
4 => {
|
||||
if c.is_ascii_digit() {
|
||||
self.create_max_fires.push(c);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Backspace => match self.create_step {
|
||||
0 => {
|
||||
self.create_agent_id.pop();
|
||||
}
|
||||
2 => {
|
||||
self.create_pattern_param.pop();
|
||||
}
|
||||
3 => {
|
||||
self.create_prompt.pop();
|
||||
}
|
||||
4 => {
|
||||
self.create_max_fires.pop();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
TriggerAction::Continue
|
||||
}
|
||||
|
||||
fn handle_pattern_picker(&mut self, key: KeyEvent) -> TriggerAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.create_step = 0;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
let i = self.pattern_type_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 {
|
||||
PATTERN_TYPES.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
};
|
||||
self.pattern_type_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let i = self.pattern_type_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % PATTERN_TYPES.len();
|
||||
self.pattern_type_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(idx) = self.pattern_type_list.selected() {
|
||||
self.create_pattern_type = idx;
|
||||
self.create_step = 2;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
TriggerAction::Continue
|
||||
}
|
||||
|
||||
fn handle_review(&mut self, key: KeyEvent) -> TriggerAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.create_step = 4;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let max_fires = self.create_max_fires.parse::<u64>().unwrap_or(0);
|
||||
let pattern_type = PATTERN_TYPES
|
||||
.get(self.create_pattern_type)
|
||||
.map(|(n, _)| n.to_string())
|
||||
.unwrap_or_default();
|
||||
self.sub = TriggerSubScreen::List;
|
||||
return TriggerAction::CreateTrigger {
|
||||
agent_id: self.create_agent_id.clone(),
|
||||
pattern_type,
|
||||
pattern_param: self.create_pattern_param.clone(),
|
||||
prompt: self.create_prompt.clone(),
|
||||
max_fires,
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
TriggerAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut TriggerState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Triggers ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
match state.sub {
|
||||
TriggerSubScreen::List => draw_list(f, inner, state),
|
||||
TriggerSubScreen::Create => draw_create(f, inner, state),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_list(f: &mut Frame, area: Rect, state: &mut TriggerState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<14} {:<20} {:<8} {}",
|
||||
"Agent", "Pattern", "Fires", "Enabled"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading triggers\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let mut items: Vec<ListItem> = state
|
||||
.triggers
|
||||
.iter()
|
||||
.map(|tr| {
|
||||
let enabled_str = if tr.enabled { "\u{2714}" } else { "\u{2718}" };
|
||||
let enabled_style = if tr.enabled {
|
||||
Style::default().fg(theme::GREEN)
|
||||
} else {
|
||||
Style::default().fg(theme::RED)
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<14}", truncate(&tr.agent_id, 13)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<20}", truncate(&tr.pattern, 19)),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
),
|
||||
Span::styled(format!(" {:<8}", tr.fires), theme::dim_style()),
|
||||
Span::styled(format!(" {enabled_str}"), enabled_style),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.push(ListItem::new(Line::from(vec![Span::styled(
|
||||
" + Create new trigger",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)])));
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.list_state);
|
||||
}
|
||||
|
||||
if !state.status_msg.is_empty() {
|
||||
// Overlay status msg at bottom of list area
|
||||
let msg_area = Rect {
|
||||
x: chunks[1].x,
|
||||
y: chunks[1].y + chunks[1].height.saturating_sub(1),
|
||||
width: chunks[1].width,
|
||||
height: 1,
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}", state.status_msg),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)),
|
||||
msg_area,
|
||||
);
|
||||
}
|
||||
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [Enter] Create [d] Delete [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)]));
|
||||
f.render_widget(hints, chunks[2]);
|
||||
}
|
||||
|
||||
fn draw_create(f: &mut Frame, area: Rect, state: &mut TriggerState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // title
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(6), // content
|
||||
Constraint::Length(1), // step indicator
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" Create New Trigger",
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
let sep = "\u{2500}".repeat(chunks[1].width as usize);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(sep, theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
match state.create_step {
|
||||
0 => draw_text_field(
|
||||
f,
|
||||
chunks[2],
|
||||
"Agent ID:",
|
||||
&state.create_agent_id,
|
||||
"agent-uuid",
|
||||
),
|
||||
1 => draw_pattern_picker(f, chunks[2], state),
|
||||
2 => draw_text_field(
|
||||
f,
|
||||
chunks[2],
|
||||
&format!(
|
||||
"Pattern param for {}:",
|
||||
PATTERN_TYPES
|
||||
.get(state.create_pattern_type)
|
||||
.map(|(n, _)| *n)
|
||||
.unwrap_or("?")
|
||||
),
|
||||
&state.create_pattern_param,
|
||||
"e.g. .*error.*",
|
||||
),
|
||||
3 => draw_text_field(
|
||||
f,
|
||||
chunks[2],
|
||||
"Prompt template:",
|
||||
&state.create_prompt,
|
||||
"Handle this: {{event}}",
|
||||
),
|
||||
4 => draw_text_field(
|
||||
f,
|
||||
chunks[2],
|
||||
"Max fires (0 = unlimited):",
|
||||
&state.create_max_fires,
|
||||
"0",
|
||||
),
|
||||
_ => draw_trigger_review(f, chunks[2], state),
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" Step {} of 6", state.create_step + 1),
|
||||
theme::dim_style(),
|
||||
)])),
|
||||
chunks[3],
|
||||
);
|
||||
|
||||
let hint_text = if state.create_step == 5 {
|
||||
" [Enter] Create [Esc] Back"
|
||||
} else if state.create_step == 1 {
|
||||
" [\u{2191}\u{2193}] Navigate [Enter] Select [Esc] Back"
|
||||
} else {
|
||||
" [Enter] Next [Esc] Back"
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
hint_text,
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[4],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_text_field(f: &mut Frame, area: Rect, label: &str, value: &str, placeholder: &str) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::raw(format!(" {label}"))])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
let display = if value.is_empty() { placeholder } else { value };
|
||||
let style = if value.is_empty() {
|
||||
theme::dim_style()
|
||||
} else {
|
||||
theme::input_style()
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(" > "),
|
||||
Span::styled(display, style),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_pattern_picker(f: &mut Frame, area: Rect, state: &mut TriggerState) {
|
||||
let chunks = Layout::vertical([Constraint::Length(2), Constraint::Min(3)]).split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::raw(" Select pattern type:")])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
let items: Vec<ListItem> = PATTERN_TYPES
|
||||
.iter()
|
||||
.map(|(name, desc)| {
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(format!(" {:<20}", name), Style::default().fg(theme::CYAN)),
|
||||
Span::styled(*desc, theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.pattern_type_list);
|
||||
}
|
||||
|
||||
fn draw_trigger_review(f: &mut Frame, area: Rect, state: &TriggerState) {
|
||||
let pattern_name = PATTERN_TYPES
|
||||
.get(state.create_pattern_type)
|
||||
.map(|(n, _)| *n)
|
||||
.unwrap_or("?");
|
||||
let max_fires = if state.create_max_fires.is_empty() {
|
||||
"unlimited"
|
||||
} else {
|
||||
&state.create_max_fires
|
||||
};
|
||||
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
Span::raw(" Agent: "),
|
||||
Span::styled(&state.create_agent_id, Style::default().fg(theme::CYAN)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Pattern: "),
|
||||
Span::styled(pattern_name, Style::default().fg(theme::YELLOW)),
|
||||
Span::raw(format!(" ({})", state.create_pattern_param)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Prompt: "),
|
||||
Span::styled(&state.create_prompt, theme::dim_style()),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Max: "),
|
||||
Span::styled(max_fires, Style::default().fg(theme::GREEN)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
" Press Enter to create this trigger.",
|
||||
theme::dim_style(),
|
||||
)]),
|
||||
];
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
444
crates/openfang-cli/src/tui/screens/usage.rs
Normal file
444
crates/openfang-cli/src/tui/screens/usage.rs
Normal file
@@ -0,0 +1,444 @@
|
||||
//! Usage screen: token/cost analytics with summary, by-model, by-agent views.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct UsageSummary {
|
||||
pub total_input_tokens: u64,
|
||||
pub total_output_tokens: u64,
|
||||
pub total_cost_usd: f64,
|
||||
pub total_calls: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ModelUsage {
|
||||
pub model_id: String,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cost_usd: f64,
|
||||
pub calls: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AgentUsage {
|
||||
pub agent_name: String,
|
||||
pub agent_id: String,
|
||||
pub total_tokens: u64,
|
||||
pub cost_usd: f64,
|
||||
pub tool_calls: u64,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UsageSub {
|
||||
Summary,
|
||||
ByModel,
|
||||
ByAgent,
|
||||
}
|
||||
|
||||
pub struct UsageState {
|
||||
pub sub: UsageSub,
|
||||
pub summary: UsageSummary,
|
||||
pub by_model: Vec<ModelUsage>,
|
||||
pub by_agent: Vec<AgentUsage>,
|
||||
pub model_list: ListState,
|
||||
pub agent_list: ListState,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
}
|
||||
|
||||
pub enum UsageAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
}
|
||||
|
||||
impl UsageState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sub: UsageSub::Summary,
|
||||
summary: UsageSummary::default(),
|
||||
by_model: Vec::new(),
|
||||
by_agent: Vec::new(),
|
||||
model_list: ListState::default(),
|
||||
agent_list: ListState::default(),
|
||||
loading: false,
|
||||
tick: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> UsageAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return UsageAction::Continue;
|
||||
}
|
||||
|
||||
// Sub-tab switching
|
||||
match key.code {
|
||||
KeyCode::Char('1') => {
|
||||
self.sub = UsageSub::Summary;
|
||||
return UsageAction::Continue;
|
||||
}
|
||||
KeyCode::Char('2') => {
|
||||
self.sub = UsageSub::ByModel;
|
||||
return UsageAction::Continue;
|
||||
}
|
||||
KeyCode::Char('3') => {
|
||||
self.sub = UsageSub::ByAgent;
|
||||
return UsageAction::Continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match self.sub {
|
||||
UsageSub::Summary => {
|
||||
if key.code == KeyCode::Char('r') {
|
||||
return UsageAction::Refresh;
|
||||
}
|
||||
}
|
||||
UsageSub::ByModel => {
|
||||
let total = self.by_model.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return UsageAction::Refresh,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
UsageSub::ByAgent => {
|
||||
let total = self.by_agent.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return UsageAction::Refresh,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
UsageAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut UsageState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Usage ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // sub-tab bar
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(3), // content
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Sub-tab bar
|
||||
draw_sub_tabs(f, chunks[0], state.sub);
|
||||
|
||||
let sep = "\u{2500}".repeat(chunks[1].width as usize);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(sep, theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
match state.sub {
|
||||
UsageSub::Summary => draw_summary(f, chunks[2], state),
|
||||
UsageSub::ByModel => draw_by_model(f, chunks[2], state),
|
||||
UsageSub::ByAgent => draw_by_agent(f, chunks[2], state),
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [1] Summary [2] By Model [3] By Agent [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[3],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_sub_tabs(f: &mut Frame, area: Rect, active: UsageSub) {
|
||||
let tabs = [
|
||||
(UsageSub::Summary, "1 Summary"),
|
||||
(UsageSub::ByModel, "2 By Model"),
|
||||
(UsageSub::ByAgent, "3 By Agent"),
|
||||
];
|
||||
let mut spans = vec![Span::raw(" ")];
|
||||
for (sub, label) in &tabs {
|
||||
let style = if *sub == active {
|
||||
theme::tab_active()
|
||||
} else {
|
||||
theme::tab_inactive()
|
||||
};
|
||||
spans.push(Span::styled(format!(" {label} "), style));
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||
}
|
||||
|
||||
fn draw_summary(f: &mut Frame, area: Rect, state: &UsageState) {
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading usage data\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let cols = Layout::horizontal([
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
draw_stat_card(
|
||||
f,
|
||||
cols[0],
|
||||
"Input Tokens",
|
||||
&format_tokens(state.summary.total_input_tokens),
|
||||
theme::BLUE,
|
||||
);
|
||||
draw_stat_card(
|
||||
f,
|
||||
cols[1],
|
||||
"Output Tokens",
|
||||
&format_tokens(state.summary.total_output_tokens),
|
||||
theme::GREEN,
|
||||
);
|
||||
draw_stat_card(
|
||||
f,
|
||||
cols[2],
|
||||
"Total Cost",
|
||||
&format!("${:.4}", state.summary.total_cost_usd),
|
||||
theme::YELLOW,
|
||||
);
|
||||
draw_stat_card(
|
||||
f,
|
||||
cols[3],
|
||||
"API Calls",
|
||||
&format_tokens(state.summary.total_calls),
|
||||
theme::CYAN,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_stat_card(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
title: &str,
|
||||
value: &str,
|
||||
color: ratatui::style::Color,
|
||||
) {
|
||||
let card = Block::default()
|
||||
.title(Span::styled(
|
||||
format!(" {title} "),
|
||||
Style::default().fg(color),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::DIM));
|
||||
let card_inner = card.inner(area);
|
||||
f.render_widget(card, area);
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {value}"),
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
)])),
|
||||
card_inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_by_model(f: &mut Frame, area: Rect, state: &mut UsageState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<28} {:<14} {:<14} {:<10} {}",
|
||||
"Model", "Input Tokens", "Output Tokens", "Cost", "Calls"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.by_model.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(" No usage data.", theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.by_model
|
||||
.iter()
|
||||
.map(|m| {
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<28}", truncate(&m.model_id, 27)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<14}", format_tokens(m.input_tokens)),
|
||||
Style::default().fg(theme::BLUE),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<14}", format_tokens(m.output_tokens)),
|
||||
Style::default().fg(theme::GREEN),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ${:<9.4}", m.cost_usd),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
),
|
||||
Span::styled(format!(" {}", m.calls), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.model_list);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_by_agent(f: &mut Frame, area: Rect, state: &mut UsageState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<24} {:<16} {:<12} {}",
|
||||
"Agent", "Total Tokens", "Cost", "Tool Calls"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if state.by_agent.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(" No usage data.", theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.by_agent
|
||||
.iter()
|
||||
.map(|a| {
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<24}", truncate(&a.agent_name, 23)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<16}", format_tokens(a.total_tokens)),
|
||||
Style::default().fg(theme::BLUE),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ${:<11.4}", a.cost_usd),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
),
|
||||
Span::styled(format!(" {}", a.tool_calls), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.agent_list);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_tokens(n: u64) -> String {
|
||||
if n >= 1_000_000 {
|
||||
format!("{:.1}M", n as f64 / 1_000_000.0)
|
||||
} else if n >= 1_000 {
|
||||
format!("{:.1}K", n as f64 / 1_000.0)
|
||||
} else {
|
||||
format!("{n}")
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
433
crates/openfang-cli/src/tui/screens/welcome.rs
Normal file
433
crates/openfang-cli/src/tui/screens/welcome.rs
Normal file
@@ -0,0 +1,433 @@
|
||||
//! Welcome screen: branded logo, daemon/provider status, mode selection menu.
|
||||
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::tui::theme;
|
||||
|
||||
// ── ASCII Logo ───────────────────────────────────────────────────────────────
|
||||
|
||||
const LOGO: &str = "\
|
||||
\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2557} \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2557} \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2557}\u{2588}\u{2588}\u{2588}\u{2557} \u{2588}\u{2588}\u{2557}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2557} \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2557} \u{2588}\u{2588}\u{2588}\u{2557} \u{2588}\u{2588}\u{2557} \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2557}
|
||||
\u{2588}\u{2588}\u{2554}\u{2550}\u{2550}\u{2550}\u{2588}\u{2588}\u{2557}\u{2588}\u{2588}\u{2554}\u{2550}\u{2550}\u{2588}\u{2588}\u{2557}\u{2588}\u{2588}\u{2554}\u{2550}\u{2550}\u{2550}\u{2550}\u{255d}\u{2588}\u{2588}\u{2588}\u{2588}\u{2557} \u{2588}\u{2588}\u{2551}\u{2588}\u{2588}\u{2554}\u{2550}\u{2550}\u{2550}\u{2550}\u{255d}\u{2588}\u{2588}\u{2554}\u{2550}\u{2550}\u{2588}\u{2588}\u{2557}\u{2588}\u{2588}\u{2588}\u{2588}\u{2557} \u{2588}\u{2588}\u{2551}\u{2588}\u{2588}\u{2554}\u{2550}\u{2550}\u{2550}\u{2550}\u{255d}
|
||||
\u{2588}\u{2588}\u{2551} \u{2588}\u{2588}\u{2551}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2554}\u{255d}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2557} \u{2588}\u{2588}\u{2554}\u{2588}\u{2588}\u{2557} \u{2588}\u{2588}\u{2551}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2557} \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2551}\u{2588}\u{2588}\u{2554}\u{2588}\u{2588}\u{2557} \u{2588}\u{2588}\u{2551}\u{2588}\u{2588}\u{2551} \u{2588}\u{2588}\u{2588}\u{2551}
|
||||
\u{2588}\u{2588}\u{2551} \u{2588}\u{2588}\u{2551}\u{2588}\u{2588}\u{2554}\u{2550}\u{2550}\u{2550}\u{255d} \u{2588}\u{2588}\u{2554}\u{2550}\u{2550}\u{255d} \u{2588}\u{2588}\u{2551}\u{255a}\u{2588}\u{2588}\u{2557}\u{2588}\u{2588}\u{2551}\u{2588}\u{2588}\u{2554}\u{2550}\u{2550}\u{255d} \u{2588}\u{2588}\u{2554}\u{2550}\u{2550}\u{2588}\u{2588}\u{2551}\u{2588}\u{2588}\u{2551}\u{255a}\u{2588}\u{2588}\u{2557}\u{2588}\u{2588}\u{2551}\u{2588}\u{2588}\u{2551} \u{2588}\u{2588}\u{2551}
|
||||
\u{255a}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2554}\u{255d}\u{2588}\u{2588}\u{2551} \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2557}\u{2588}\u{2588}\u{2551} \u{255a}\u{2588}\u{2588}\u{2588}\u{2588}\u{2551}\u{2588}\u{2588}\u{2551} \u{2588}\u{2588}\u{2551} \u{2588}\u{2588}\u{2551}\u{2588}\u{2588}\u{2551} \u{255a}\u{2588}\u{2588}\u{2588}\u{2588}\u{2551}\u{255a}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2554}\u{255d}
|
||||
\u{255a}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{255d} \u{255a}\u{2550}\u{255d} \u{255a}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{255d}\u{255a}\u{2550}\u{255d} \u{255a}\u{2550}\u{2550}\u{2550}\u{255d}\u{255a}\u{2550}\u{255d} \u{255a}\u{2550}\u{255d} \u{255a}\u{2550}\u{255d}\u{255a}\u{2550}\u{255d} \u{255a}\u{2550}\u{2550}\u{2550}\u{255d} \u{255a}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{255d}";
|
||||
|
||||
const LOGO_HEIGHT: u16 = 6;
|
||||
/// Minimum terminal width to show the full ASCII logo.
|
||||
const LOGO_MIN_WIDTH: u16 = 75;
|
||||
|
||||
const COMPACT_LOGO: &str = "O P E N F A N G";
|
||||
|
||||
// ── Provider detection ───────────────────────────────────────────────────────
|
||||
|
||||
/// Known provider env vars, checked in priority order.
|
||||
const PROVIDER_ENV_VARS: &[(&str, &str)] = &[
|
||||
("ANTHROPIC_API_KEY", "Anthropic"),
|
||||
("OPENAI_API_KEY", "OpenAI"),
|
||||
("DEEPSEEK_API_KEY", "DeepSeek"),
|
||||
("GEMINI_API_KEY", "Gemini"),
|
||||
("GOOGLE_API_KEY", "Gemini"),
|
||||
("GROQ_API_KEY", "Groq"),
|
||||
("OPENROUTER_API_KEY", "OpenRouter"),
|
||||
("TOGETHER_API_KEY", "Together"),
|
||||
("MISTRAL_API_KEY", "Mistral"),
|
||||
("FIREWORKS_API_KEY", "Fireworks"),
|
||||
("BRAVE_API_KEY", "Brave Search"),
|
||||
("TAVILY_API_KEY", "Tavily"),
|
||||
("PERPLEXITY_API_KEY", "Perplexity"),
|
||||
];
|
||||
|
||||
/// Returns (provider_name, env_var_name) for the first detected key, or None.
|
||||
fn detect_provider() -> Option<(&'static str, &'static str)> {
|
||||
for &(var, name) in PROVIDER_ENV_VARS {
|
||||
if std::env::var(var).is_ok() {
|
||||
return Some((name, var));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// State for the welcome screen.
|
||||
pub struct WelcomeState {
|
||||
pub menu: ListState,
|
||||
pub daemon_url: Option<String>,
|
||||
pub daemon_agents: u64,
|
||||
pub menu_items: Vec<MenuItem>,
|
||||
/// True while we're probing the daemon in the background.
|
||||
pub detecting: bool,
|
||||
/// Spinner tick counter for the detecting animation.
|
||||
pub tick: usize,
|
||||
/// True after first Ctrl+C — requires a second press to exit.
|
||||
pub ctrl_c_pending: bool,
|
||||
/// Tick at which Ctrl+C was first pressed (auto-resets after timeout).
|
||||
ctrl_c_tick: usize,
|
||||
/// True when the setup wizard just completed — shows guidance banner.
|
||||
pub setup_just_completed: bool,
|
||||
}
|
||||
|
||||
pub struct MenuItem {
|
||||
pub label: &'static str,
|
||||
pub hint: &'static str,
|
||||
pub action: WelcomeAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WelcomeAction {
|
||||
ConnectDaemon,
|
||||
InProcess,
|
||||
Wizard,
|
||||
Exit,
|
||||
}
|
||||
|
||||
impl WelcomeState {
|
||||
/// Ticks before the Ctrl+C pending state auto-resets (~2s at 50ms tick).
|
||||
const CTRL_C_TIMEOUT: usize = 40;
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
menu: ListState::default(),
|
||||
daemon_url: None,
|
||||
daemon_agents: 0,
|
||||
menu_items: Vec::new(),
|
||||
detecting: true,
|
||||
tick: 0,
|
||||
ctrl_c_pending: false,
|
||||
ctrl_c_tick: 0,
|
||||
setup_just_completed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when daemon detection finishes (from background thread).
|
||||
pub fn on_daemon_detected(&mut self, url: Option<String>, agent_count: u64) {
|
||||
self.detecting = false;
|
||||
self.daemon_url = url;
|
||||
self.daemon_agents = agent_count;
|
||||
self.rebuild_menu();
|
||||
}
|
||||
|
||||
fn rebuild_menu(&mut self) {
|
||||
self.menu_items.clear();
|
||||
if self.daemon_url.is_some() {
|
||||
self.menu_items.push(MenuItem {
|
||||
label: "Connect to daemon",
|
||||
hint: "talk to running agents via API",
|
||||
action: WelcomeAction::ConnectDaemon,
|
||||
});
|
||||
}
|
||||
self.menu_items.push(MenuItem {
|
||||
label: "Quick in-process chat",
|
||||
hint: "boot kernel locally, no daemon needed",
|
||||
action: WelcomeAction::InProcess,
|
||||
});
|
||||
self.menu_items.push(MenuItem {
|
||||
label: "Setup wizard",
|
||||
hint: "configure providers & channels",
|
||||
action: WelcomeAction::Wizard,
|
||||
});
|
||||
self.menu_items.push(MenuItem {
|
||||
label: "Exit",
|
||||
hint: "quit OpenFang",
|
||||
action: WelcomeAction::Exit,
|
||||
});
|
||||
self.menu.select(Some(0));
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
// Auto-reset Ctrl+C pending after timeout
|
||||
if self.ctrl_c_pending && self.tick.wrapping_sub(self.ctrl_c_tick) > Self::CTRL_C_TIMEOUT {
|
||||
self.ctrl_c_pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key event. Returns Some(action) if one was selected.
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> Option<WelcomeAction> {
|
||||
let is_ctrl_c =
|
||||
key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
|
||||
if self.detecting {
|
||||
// Block input while detecting — only Ctrl+C (double) or q exits
|
||||
if is_ctrl_c {
|
||||
if self.ctrl_c_pending {
|
||||
return Some(WelcomeAction::Exit);
|
||||
}
|
||||
self.ctrl_c_pending = true;
|
||||
self.ctrl_c_tick = self.tick;
|
||||
return None;
|
||||
}
|
||||
if key.code == KeyCode::Char('q') {
|
||||
return Some(WelcomeAction::Exit);
|
||||
}
|
||||
self.ctrl_c_pending = false;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Double Ctrl+C to exit
|
||||
if is_ctrl_c {
|
||||
if self.ctrl_c_pending {
|
||||
return Some(WelcomeAction::Exit);
|
||||
}
|
||||
self.ctrl_c_pending = true;
|
||||
self.ctrl_c_tick = self.tick;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Any other key clears the Ctrl+C pending state
|
||||
self.ctrl_c_pending = false;
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return Some(WelcomeAction::Exit),
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
let i = self.menu.selected().unwrap_or(0);
|
||||
let next = if i == 0 {
|
||||
self.menu_items.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
};
|
||||
self.menu.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let i = self.menu.selected().unwrap_or(0);
|
||||
let next = (i + 1) % self.menu_items.len();
|
||||
self.menu.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(i) = self.menu.selected() {
|
||||
return Some(self.menu_items[i].action);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Render the welcome screen.
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut WelcomeState) {
|
||||
// Fill background
|
||||
f.render_widget(
|
||||
ratatui::widgets::Block::default().style(Style::default().bg(theme::BG_PRIMARY)),
|
||||
area,
|
||||
);
|
||||
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let compact = area.width < LOGO_MIN_WIDTH;
|
||||
|
||||
// Logo height: full (6 lines) or compact (1 line)
|
||||
let logo_h: u16 = if compact { 1 } else { LOGO_HEIGHT };
|
||||
|
||||
// Status block height
|
||||
let has_provider = detect_provider().is_some();
|
||||
let setup_extra: u16 = if state.setup_just_completed { 1 } else { 0 };
|
||||
let status_h: u16 = if state.detecting {
|
||||
1
|
||||
} else if has_provider {
|
||||
2 + setup_extra
|
||||
} else {
|
||||
3 + setup_extra
|
||||
};
|
||||
|
||||
// Left-aligned content area
|
||||
let content = if area.width < 10 || area.height < 5 {
|
||||
area
|
||||
} else {
|
||||
let margin = 3u16.min(area.width.saturating_sub(10));
|
||||
let w = 80u16.min(area.width.saturating_sub(margin));
|
||||
Rect {
|
||||
x: area.x.saturating_add(margin),
|
||||
y: area.y,
|
||||
width: w,
|
||||
height: area.height,
|
||||
}
|
||||
};
|
||||
|
||||
// Vertical layout with upper-third positioning
|
||||
let total_needed = 1 + logo_h + 1 + 1 + status_h + 1 + 4 + 1;
|
||||
let top_pad = if area.height > total_needed + 2 {
|
||||
((area.height - total_needed) / 3).max(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(top_pad), // top space
|
||||
Constraint::Length(logo_h), // logo
|
||||
Constraint::Length(1), // tagline + version
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Length(status_h), // status block
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(1), // menu
|
||||
Constraint::Length(1), // key hints
|
||||
Constraint::Min(0), // remaining
|
||||
])
|
||||
.split(content);
|
||||
|
||||
// ── Logo ─────────────────────────────────────────────────────────────────
|
||||
if compact {
|
||||
let line = Line::from(vec![Span::styled(
|
||||
COMPACT_LOGO,
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]);
|
||||
f.render_widget(Paragraph::new(line), chunks[1]);
|
||||
} else {
|
||||
let logo_lines: Vec<Line> = LOGO
|
||||
.lines()
|
||||
.map(|l| Line::from(vec![Span::styled(l, Style::default().fg(theme::ACCENT))]))
|
||||
.collect();
|
||||
f.render_widget(Paragraph::new(logo_lines), chunks[1]);
|
||||
}
|
||||
|
||||
// ── Tagline + version ────────────────────────────────────────────────────
|
||||
let tagline = Line::from(vec![
|
||||
Span::styled(
|
||||
"Agent Operating System",
|
||||
Style::default()
|
||||
.fg(theme::TEXT_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!(" v{version}"), theme::dim_style()),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(tagline), chunks[2]);
|
||||
|
||||
// ── Separator ────────────────────────────────────────────────────────────
|
||||
let sep_w = content.width.min(60) as usize;
|
||||
let sep_line = Line::from(vec![Span::styled(
|
||||
"\u{2500}".repeat(sep_w),
|
||||
Style::default().fg(theme::BORDER),
|
||||
)]);
|
||||
f.render_widget(Paragraph::new(sep_line.clone()), chunks[3]);
|
||||
|
||||
// ── Status block ─────────────────────────────────────────────────────────
|
||||
if state.detecting {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
let line = Line::from(vec![
|
||||
Span::styled(format!("{spinner} "), Style::default().fg(theme::YELLOW)),
|
||||
Span::styled("Checking for daemon\u{2026}", theme::dim_style()),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(line), chunks[4]);
|
||||
} else {
|
||||
let mut status_lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Daemon status
|
||||
if let Some(ref url) = state.daemon_url {
|
||||
let agent_suffix = if state.daemon_agents > 0 {
|
||||
format!(
|
||||
" ({} agent{})",
|
||||
state.daemon_agents,
|
||||
if state.daemon_agents == 1 { "" } else { "s" }
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
status_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"\u{25cf} ",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!("Daemon at {url}"),
|
||||
Style::default().fg(theme::TEXT_PRIMARY),
|
||||
),
|
||||
Span::styled(agent_suffix, Style::default().fg(theme::GREEN)),
|
||||
]));
|
||||
} else {
|
||||
status_lines.push(Line::from(vec![
|
||||
Span::styled("\u{25cb} ", theme::dim_style()),
|
||||
Span::styled("No daemon running", theme::dim_style()),
|
||||
]));
|
||||
}
|
||||
|
||||
// Provider detection
|
||||
if let Some((provider, env_var)) = detect_provider() {
|
||||
status_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"\u{2714} ",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!("Provider: {provider}"),
|
||||
Style::default().fg(theme::TEXT_PRIMARY),
|
||||
),
|
||||
Span::styled(format!(" ({env_var})"), theme::dim_style()),
|
||||
]));
|
||||
} else {
|
||||
status_lines.push(Line::from(vec![
|
||||
Span::styled("\u{25cb} ", Style::default().fg(theme::YELLOW)),
|
||||
Span::styled("No API keys detected", Style::default().fg(theme::YELLOW)),
|
||||
]));
|
||||
status_lines.push(Line::from(vec![Span::styled(
|
||||
" Run 'openfang init' to get started",
|
||||
theme::hint_style(),
|
||||
)]));
|
||||
}
|
||||
|
||||
// Post-wizard guidance
|
||||
if state.setup_just_completed {
|
||||
status_lines.push(Line::from(vec![Span::styled(
|
||||
"\u{2714} Setup complete! Select 'Quick in-process chat' to try it out.",
|
||||
Style::default().fg(theme::GREEN),
|
||||
)]));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(status_lines), chunks[4]);
|
||||
}
|
||||
|
||||
// ── Separator 2 ──────────────────────────────────────────────────────────
|
||||
f.render_widget(Paragraph::new(sep_line), chunks[5]);
|
||||
|
||||
// ── Menu ─────────────────────────────────────────────────────────────────
|
||||
if !state.detecting {
|
||||
let items: Vec<ListItem> = state
|
||||
.menu_items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::raw(format!("{:<26}", item.label)),
|
||||
Span::styled(item.hint, theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.bg(theme::BG_HOVER)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("\u{25b8} ");
|
||||
|
||||
f.render_stateful_widget(list, chunks[6], &mut state.menu);
|
||||
}
|
||||
|
||||
// ── Hints ────────────────────────────────────────────────────────────────
|
||||
let hints = if state.ctrl_c_pending {
|
||||
Line::from(vec![Span::styled(
|
||||
"Press Ctrl+C again to exit",
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)])
|
||||
} else {
|
||||
Line::from(vec![Span::styled(
|
||||
"\u{2191}\u{2193} navigate enter select q quit",
|
||||
theme::hint_style(),
|
||||
)])
|
||||
};
|
||||
f.render_widget(Paragraph::new(hints), chunks[7]);
|
||||
}
|
||||
595
crates/openfang-cli/src/tui/screens/wizard.rs
Normal file
595
crates/openfang-cli/src/tui/screens/wizard.rs
Normal file
@@ -0,0 +1,595 @@
|
||||
//! Setup wizard: provider list → API key → model → config save.
|
||||
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::tui::theme;
|
||||
|
||||
/// Provider metadata for the setup wizard.
|
||||
struct ProviderInfo {
|
||||
name: &'static str,
|
||||
env_var: &'static str,
|
||||
default_model: &'static str,
|
||||
needs_key: bool,
|
||||
}
|
||||
|
||||
const PROVIDERS: &[ProviderInfo] = &[
|
||||
ProviderInfo {
|
||||
name: "groq",
|
||||
env_var: "GROQ_API_KEY",
|
||||
default_model: "llama-3.3-70b-versatile",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "anthropic",
|
||||
env_var: "ANTHROPIC_API_KEY",
|
||||
default_model: "claude-sonnet-4-20250514",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "openai",
|
||||
env_var: "OPENAI_API_KEY",
|
||||
default_model: "gpt-4o",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "openrouter",
|
||||
env_var: "OPENROUTER_API_KEY",
|
||||
default_model: "anthropic/claude-sonnet-4-20250514",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "deepseek",
|
||||
env_var: "DEEPSEEK_API_KEY",
|
||||
default_model: "deepseek-chat",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "together",
|
||||
env_var: "TOGETHER_API_KEY",
|
||||
default_model: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "mistral",
|
||||
env_var: "MISTRAL_API_KEY",
|
||||
default_model: "mistral-large-latest",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "fireworks",
|
||||
env_var: "FIREWORKS_API_KEY",
|
||||
default_model: "accounts/fireworks/models/llama-v3p3-70b-instruct",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "ollama",
|
||||
env_var: "OLLAMA_API_KEY",
|
||||
default_model: "llama3.2",
|
||||
needs_key: false,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "vllm",
|
||||
env_var: "VLLM_API_KEY",
|
||||
default_model: "local-model",
|
||||
needs_key: false,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "lmstudio",
|
||||
env_var: "LMSTUDIO_API_KEY",
|
||||
default_model: "local-model",
|
||||
needs_key: false,
|
||||
},
|
||||
];
|
||||
|
||||
/// Check if first-run setup is needed.
|
||||
pub fn needs_setup() -> bool {
|
||||
let home = match dirs::home_dir() {
|
||||
Some(h) => h,
|
||||
None => return true,
|
||||
};
|
||||
!home.join(".openfang").join("config.toml").exists()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WizardStep {
|
||||
Provider,
|
||||
ApiKey,
|
||||
Model,
|
||||
Saving,
|
||||
Done,
|
||||
}
|
||||
|
||||
pub struct WizardState {
|
||||
pub step: WizardStep,
|
||||
pub provider_list: ListState,
|
||||
pub provider_order: Vec<usize>, // indices into PROVIDERS, detected first
|
||||
pub selected_provider: Option<usize>, // index into PROVIDERS
|
||||
pub api_key_input: String,
|
||||
pub api_key_from_env: bool,
|
||||
pub model_input: String,
|
||||
pub status_msg: String,
|
||||
pub created_config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl WizardState {
|
||||
pub fn new() -> Self {
|
||||
let mut state = Self {
|
||||
step: WizardStep::Provider,
|
||||
provider_list: ListState::default(),
|
||||
provider_order: Vec::new(),
|
||||
selected_provider: None,
|
||||
api_key_input: String::new(),
|
||||
api_key_from_env: false,
|
||||
model_input: String::new(),
|
||||
status_msg: String::new(),
|
||||
created_config: None,
|
||||
};
|
||||
state.build_provider_order();
|
||||
state.provider_list.select(Some(0));
|
||||
state
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.step = WizardStep::Provider;
|
||||
self.selected_provider = None;
|
||||
self.api_key_input.clear();
|
||||
self.api_key_from_env = false;
|
||||
self.model_input.clear();
|
||||
self.status_msg.clear();
|
||||
self.created_config = None;
|
||||
self.build_provider_order();
|
||||
self.provider_list.select(Some(0));
|
||||
}
|
||||
|
||||
fn build_provider_order(&mut self) {
|
||||
self.provider_order.clear();
|
||||
// Detected providers first
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
if std::env::var(p.env_var).is_ok() {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
}
|
||||
// Then the rest
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
if std::env::var(p.env_var).is_err() {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_provider_info(&self) -> Option<&'static ProviderInfo> {
|
||||
self.selected_provider.map(|i| &PROVIDERS[i])
|
||||
}
|
||||
|
||||
/// Handle a key event. Returns true if wizard is complete or cancelled.
|
||||
/// `cancelled` is set if the user backed out entirely.
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> WizardResult {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return WizardResult::Cancelled;
|
||||
}
|
||||
|
||||
match self.step {
|
||||
WizardStep::Provider => self.handle_provider(key),
|
||||
WizardStep::ApiKey => self.handle_api_key(key),
|
||||
WizardStep::Model => self.handle_model(key),
|
||||
WizardStep::Saving | WizardStep::Done => WizardResult::Continue,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_provider(&mut self, key: KeyEvent) -> WizardResult {
|
||||
match key.code {
|
||||
KeyCode::Esc => return WizardResult::Cancelled,
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 {
|
||||
self.provider_order.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
};
|
||||
self.provider_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % self.provider_order.len();
|
||||
self.provider_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(list_idx) = self.provider_list.selected() {
|
||||
let Some(&prov_idx) = self.provider_order.get(list_idx) else {
|
||||
return WizardResult::Continue;
|
||||
};
|
||||
let Some(p) = PROVIDERS.get(prov_idx) else {
|
||||
return WizardResult::Continue;
|
||||
};
|
||||
self.selected_provider = Some(prov_idx);
|
||||
|
||||
if !p.needs_key {
|
||||
// No key needed, skip to model
|
||||
self.api_key_from_env = false;
|
||||
self.model_input = p.default_model.to_string();
|
||||
self.step = WizardStep::Model;
|
||||
} else if std::env::var(p.env_var).is_ok() {
|
||||
// Key already in env
|
||||
self.api_key_from_env = true;
|
||||
self.model_input = p.default_model.to_string();
|
||||
self.step = WizardStep::Model;
|
||||
} else {
|
||||
self.api_key_from_env = false;
|
||||
self.api_key_input.clear();
|
||||
self.step = WizardStep::ApiKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
WizardResult::Continue
|
||||
}
|
||||
|
||||
fn handle_api_key(&mut self, key: KeyEvent) -> WizardResult {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.step = WizardStep::Provider;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.api_key_input.is_empty() {
|
||||
if let Some(p) = self.selected_provider_info() {
|
||||
self.model_input = p.default_model.to_string();
|
||||
}
|
||||
self.step = WizardStep::Model;
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.api_key_input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.api_key_input.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
WizardResult::Continue
|
||||
}
|
||||
|
||||
fn handle_model(&mut self, key: KeyEvent) -> WizardResult {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
// Go back
|
||||
if let Some(p) = self.selected_provider_info() {
|
||||
if p.needs_key && !self.api_key_from_env {
|
||||
self.step = WizardStep::ApiKey;
|
||||
} else {
|
||||
self.step = WizardStep::Provider;
|
||||
}
|
||||
} else {
|
||||
self.step = WizardStep::Provider;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.step = WizardStep::Saving;
|
||||
self.save_config();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.model_input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.model_input.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
WizardResult::Continue
|
||||
}
|
||||
|
||||
fn save_config(&mut self) {
|
||||
let p = match self.selected_provider_info() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
self.status_msg = "No provider selected".to_string();
|
||||
self.step = WizardStep::Provider;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let home = match dirs::home_dir() {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
self.status_msg = "Could not determine home directory".to_string();
|
||||
self.step = WizardStep::Done;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let openfang_dir = home.join(".openfang");
|
||||
let _ = std::fs::create_dir_all(openfang_dir.join("agents"));
|
||||
let _ = std::fs::create_dir_all(openfang_dir.join("data"));
|
||||
crate::restrict_dir_permissions(&openfang_dir);
|
||||
|
||||
let api_key_line = if !self.api_key_input.is_empty() {
|
||||
format!("api_key = \"{}\"", self.api_key_input)
|
||||
} else {
|
||||
format!("api_key_env = \"{}\"", p.env_var)
|
||||
};
|
||||
|
||||
let model = if self.model_input.is_empty() {
|
||||
p.default_model
|
||||
} else {
|
||||
&self.model_input
|
||||
};
|
||||
|
||||
let config = format!(
|
||||
r#"# OpenFang Agent OS configuration
|
||||
# Generated by setup wizard
|
||||
|
||||
[default_model]
|
||||
provider = "{provider}"
|
||||
model = "{model}"
|
||||
{api_key_line}
|
||||
|
||||
[memory]
|
||||
decay_rate = 0.05
|
||||
|
||||
[network]
|
||||
listen_addr = "127.0.0.1:4200"
|
||||
"#,
|
||||
provider = p.name,
|
||||
);
|
||||
|
||||
let config_path = openfang_dir.join("config.toml");
|
||||
match std::fs::write(&config_path, &config) {
|
||||
Ok(()) => {
|
||||
crate::restrict_file_permissions(&config_path);
|
||||
self.status_msg = format!("Config saved \u{2014} {} / {}", p.name, model);
|
||||
self.created_config = Some(config_path);
|
||||
}
|
||||
Err(e) => {
|
||||
self.status_msg = format!("Failed to save config: {e}");
|
||||
}
|
||||
}
|
||||
self.step = WizardStep::Done;
|
||||
}
|
||||
}
|
||||
|
||||
pub enum WizardResult {
|
||||
Continue,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Render the wizard screen.
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut WizardState) {
|
||||
// Fill background
|
||||
f.render_widget(
|
||||
ratatui::widgets::Block::default().style(Style::default().bg(theme::BG_PRIMARY)),
|
||||
area,
|
||||
);
|
||||
|
||||
let step_label = match state.step {
|
||||
WizardStep::Provider => "Step 1 of 3",
|
||||
WizardStep::ApiKey => "Step 2 of 3",
|
||||
WizardStep::Model => "Step 3 of 3",
|
||||
WizardStep::Saving => "Saving...",
|
||||
WizardStep::Done => "Complete",
|
||||
};
|
||||
|
||||
// Left-aligned content area
|
||||
let content = if area.width < 10 || area.height < 5 {
|
||||
area
|
||||
} else {
|
||||
let margin = 3u16.min(area.width.saturating_sub(10));
|
||||
let w = 72u16.min(area.width.saturating_sub(margin));
|
||||
Rect {
|
||||
x: area.x.saturating_add(margin),
|
||||
y: area.y,
|
||||
width: w,
|
||||
height: area.height,
|
||||
}
|
||||
};
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // top pad
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(1), // step content
|
||||
])
|
||||
.split(content);
|
||||
|
||||
// Header
|
||||
let header = Line::from(vec![
|
||||
Span::styled(
|
||||
"Setup",
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!(" {step_label}"), theme::dim_style()),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(header), chunks[1]);
|
||||
|
||||
// Separator
|
||||
let sep_w = content.width.min(60) as usize;
|
||||
let sep = Line::from(vec![Span::styled(
|
||||
"\u{2500}".repeat(sep_w),
|
||||
Style::default().fg(theme::BORDER),
|
||||
)]);
|
||||
f.render_widget(Paragraph::new(sep), chunks[2]);
|
||||
|
||||
match state.step {
|
||||
WizardStep::Provider => draw_provider(f, chunks[3], state),
|
||||
WizardStep::ApiKey => draw_api_key(f, chunks[3], state),
|
||||
WizardStep::Model => draw_model(f, chunks[3], state),
|
||||
WizardStep::Saving | WizardStep::Done => draw_done(f, chunks[3], state),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_provider(f: &mut Frame, area: Rect, state: &mut WizardState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // prompt
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let prompt = Paragraph::new(Line::from(vec![Span::raw(" Choose your LLM provider:")]));
|
||||
f.render_widget(prompt, chunks[0]);
|
||||
|
||||
let items: Vec<ListItem> = state
|
||||
.provider_order
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
let p = &PROVIDERS[idx];
|
||||
let hint = if !p.needs_key {
|
||||
"local, no key needed".to_string()
|
||||
} else if std::env::var(p.env_var).is_ok() {
|
||||
format!("{} detected", p.env_var)
|
||||
} else {
|
||||
format!("requires {}", p.env_var)
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::raw(format!(" {:<14}", p.name)),
|
||||
Span::styled(hint, theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.bg(theme::BG_HOVER)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("\u{25b8} ");
|
||||
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.provider_list);
|
||||
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [Enter] Select [Esc] Cancel",
|
||||
theme::hint_style(),
|
||||
)]));
|
||||
f.render_widget(hints, chunks[2]);
|
||||
}
|
||||
|
||||
fn draw_api_key(f: &mut Frame, area: Rect, state: &mut WizardState) {
|
||||
let p = match state.selected_provider_info() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // prompt
|
||||
Constraint::Length(1), // input
|
||||
Constraint::Length(2), // spacer + hint about env var
|
||||
Constraint::Min(0), // spacer
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let prompt = Paragraph::new(Line::from(vec![Span::raw(format!(
|
||||
" Enter your {} API key:",
|
||||
p.name
|
||||
))]));
|
||||
f.render_widget(prompt, chunks[0]);
|
||||
|
||||
// Masked input
|
||||
let masked: String = "\u{2022}".repeat(state.api_key_input.len());
|
||||
let input = Paragraph::new(Line::from(vec![
|
||||
Span::raw(" > "),
|
||||
Span::styled(&masked, theme::input_style()),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
]));
|
||||
f.render_widget(input, chunks[1]);
|
||||
|
||||
let env_hint = Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" Or set {} environment variable", p.env_var),
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
f.render_widget(env_hint, chunks[2]);
|
||||
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [Enter] Confirm [Esc] Back",
|
||||
theme::hint_style(),
|
||||
)]));
|
||||
f.render_widget(hints, chunks[4]);
|
||||
}
|
||||
|
||||
fn draw_model(f: &mut Frame, area: Rect, state: &mut WizardState) {
|
||||
let p = match state.selected_provider_info() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // prompt
|
||||
Constraint::Length(1), // input
|
||||
Constraint::Length(2), // default hint
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let prompt = Paragraph::new(Line::from(vec![Span::raw(" Model name:")]));
|
||||
f.render_widget(prompt, chunks[0]);
|
||||
|
||||
let display_text = if state.model_input.is_empty() {
|
||||
p.default_model
|
||||
} else {
|
||||
&state.model_input
|
||||
};
|
||||
let input = Paragraph::new(Line::from(vec![
|
||||
Span::raw(" > "),
|
||||
Span::styled(display_text, theme::input_style()),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
]));
|
||||
f.render_widget(input, chunks[1]);
|
||||
|
||||
let default_hint = Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" default: {}", p.default_model),
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
f.render_widget(default_hint, chunks[2]);
|
||||
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [Enter] Confirm [Esc] Back",
|
||||
theme::hint_style(),
|
||||
)]));
|
||||
f.render_widget(hints, chunks[4]);
|
||||
}
|
||||
|
||||
fn draw_done(f: &mut Frame, area: Rect, state: &WizardState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let icon = if state.created_config.is_some() {
|
||||
Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN))
|
||||
} else {
|
||||
Span::styled(" \u{2718} ", Style::default().fg(theme::RED))
|
||||
};
|
||||
|
||||
let msg = Paragraph::new(Line::from(vec![icon, Span::raw(&state.status_msg)]));
|
||||
f.render_widget(msg, chunks[0]);
|
||||
|
||||
if state.created_config.is_some() {
|
||||
let cont = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" Continuing...",
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
f.render_widget(cont, chunks[1]);
|
||||
}
|
||||
}
|
||||
702
crates/openfang-cli/src/tui/screens/workflows.rs
Normal file
702
crates/openfang-cli/src/tui/screens/workflows.rs
Normal file
@@ -0,0 +1,702 @@
|
||||
//! Workflows screen: CRUD, run input, run history.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct WorkflowInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub steps: usize,
|
||||
pub created: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct WorkflowRun {
|
||||
pub id: String,
|
||||
pub state: String,
|
||||
pub duration: String,
|
||||
pub output_preview: String,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum WorkflowSubScreen {
|
||||
List,
|
||||
Runs,
|
||||
Create,
|
||||
RunInput,
|
||||
RunResult,
|
||||
}
|
||||
|
||||
pub struct WorkflowState {
|
||||
pub sub: WorkflowSubScreen,
|
||||
pub workflows: Vec<WorkflowInfo>,
|
||||
pub list_state: ListState,
|
||||
pub selected_workflow: Option<usize>,
|
||||
// Run history
|
||||
pub runs: Vec<WorkflowRun>,
|
||||
pub runs_list_state: ListState,
|
||||
// Create wizard
|
||||
pub create_step: usize, // 0=name, 1=desc, 2=steps_json, 3=review
|
||||
pub create_name: String,
|
||||
pub create_desc: String,
|
||||
pub create_steps: String,
|
||||
// Run
|
||||
pub run_input: String,
|
||||
pub run_result: Option<String>,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum WorkflowAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
LoadRuns(String),
|
||||
CreateWorkflow {
|
||||
name: String,
|
||||
description: String,
|
||||
steps_json: String,
|
||||
},
|
||||
RunWorkflow {
|
||||
id: String,
|
||||
input: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorkflowState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sub: WorkflowSubScreen::List,
|
||||
workflows: Vec::new(),
|
||||
list_state: ListState::default(),
|
||||
selected_workflow: None,
|
||||
runs: Vec::new(),
|
||||
runs_list_state: ListState::default(),
|
||||
create_step: 0,
|
||||
create_name: String::new(),
|
||||
create_desc: String::new(),
|
||||
create_steps: String::new(),
|
||||
run_input: String::new(),
|
||||
run_result: None,
|
||||
loading: false,
|
||||
tick: 0,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> WorkflowAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return WorkflowAction::Continue;
|
||||
}
|
||||
match self.sub {
|
||||
WorkflowSubScreen::List => self.handle_list(key),
|
||||
WorkflowSubScreen::Runs => self.handle_runs(key),
|
||||
WorkflowSubScreen::Create => self.handle_create(key),
|
||||
WorkflowSubScreen::RunInput => self.handle_run_input(key),
|
||||
WorkflowSubScreen::RunResult => self.handle_run_result(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_list(&mut self, key: KeyEvent) -> WorkflowAction {
|
||||
let total = self.workflows.len() + 1; // +1 for "Create new"
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(idx) = self.list_state.selected() {
|
||||
if idx < self.workflows.len() {
|
||||
self.selected_workflow = Some(idx);
|
||||
let wf_id = self.workflows[idx].id.clone();
|
||||
self.runs_list_state.select(Some(0));
|
||||
self.sub = WorkflowSubScreen::Runs;
|
||||
return WorkflowAction::LoadRuns(wf_id);
|
||||
} else {
|
||||
// "Create new"
|
||||
self.create_step = 0;
|
||||
self.create_name.clear();
|
||||
self.create_desc.clear();
|
||||
self.create_steps.clear();
|
||||
self.sub = WorkflowSubScreen::Create;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('x') => {
|
||||
if let Some(idx) = self.list_state.selected() {
|
||||
if idx < self.workflows.len() {
|
||||
self.selected_workflow = Some(idx);
|
||||
self.run_input.clear();
|
||||
self.run_result = None;
|
||||
self.sub = WorkflowSubScreen::RunInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return WorkflowAction::Refresh,
|
||||
_ => {}
|
||||
}
|
||||
WorkflowAction::Continue
|
||||
}
|
||||
|
||||
fn handle_runs(&mut self, key: KeyEvent) -> WorkflowAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.sub = WorkflowSubScreen::List;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
let i = self.runs_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 {
|
||||
self.runs.len().saturating_sub(1)
|
||||
} else {
|
||||
i - 1
|
||||
};
|
||||
self.runs_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let i = self.runs_list_state.selected().unwrap_or(0);
|
||||
let total = self.runs.len().max(1);
|
||||
let next = (i + 1) % total;
|
||||
self.runs_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
if let Some(idx) = self.selected_workflow {
|
||||
if idx < self.workflows.len() {
|
||||
let wf_id = self.workflows[idx].id.clone();
|
||||
return WorkflowAction::LoadRuns(wf_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
WorkflowAction::Continue
|
||||
}
|
||||
|
||||
fn handle_create(&mut self, key: KeyEvent) -> WorkflowAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
if self.create_step == 0 {
|
||||
self.sub = WorkflowSubScreen::List;
|
||||
} else {
|
||||
self.create_step -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if self.create_step < 3 {
|
||||
self.create_step += 1;
|
||||
} else {
|
||||
// Submit
|
||||
let action = WorkflowAction::CreateWorkflow {
|
||||
name: self.create_name.clone(),
|
||||
description: self.create_desc.clone(),
|
||||
steps_json: self.create_steps.clone(),
|
||||
};
|
||||
self.sub = WorkflowSubScreen::List;
|
||||
return action;
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => match self.create_step {
|
||||
0 => self.create_name.push(c),
|
||||
1 => self.create_desc.push(c),
|
||||
2 => self.create_steps.push(c),
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Backspace => match self.create_step {
|
||||
0 => {
|
||||
self.create_name.pop();
|
||||
}
|
||||
1 => {
|
||||
self.create_desc.pop();
|
||||
}
|
||||
2 => {
|
||||
self.create_steps.pop();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
WorkflowAction::Continue
|
||||
}
|
||||
|
||||
fn handle_run_input(&mut self, key: KeyEvent) -> WorkflowAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.sub = WorkflowSubScreen::List;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(idx) = self.selected_workflow {
|
||||
if idx < self.workflows.len() {
|
||||
let wf_id = self.workflows[idx].id.clone();
|
||||
let input = self.run_input.clone();
|
||||
self.loading = true;
|
||||
self.sub = WorkflowSubScreen::RunResult;
|
||||
return WorkflowAction::RunWorkflow { id: wf_id, input };
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.run_input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.run_input.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
WorkflowAction::Continue
|
||||
}
|
||||
|
||||
fn handle_run_result(&mut self, key: KeyEvent) -> WorkflowAction {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Enter => {
|
||||
self.sub = WorkflowSubScreen::List;
|
||||
self.loading = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
WorkflowAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut WorkflowState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Workflows ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
match state.sub {
|
||||
WorkflowSubScreen::List => draw_list(f, inner, state),
|
||||
WorkflowSubScreen::Runs => draw_runs(f, inner, state),
|
||||
WorkflowSubScreen::Create => draw_create(f, inner, state),
|
||||
WorkflowSubScreen::RunInput => draw_run_input(f, inner, state),
|
||||
WorkflowSubScreen::RunResult => draw_run_result(f, inner, state),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_list(f: &mut Frame, area: Rect, state: &mut WorkflowState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" {:<12} {:<24} {:<8} {}", "ID", "Name", "Steps", "Created"),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading workflows\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
let mut items: Vec<ListItem> = state
|
||||
.workflows
|
||||
.iter()
|
||||
.map(|wf| {
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<12}", truncate(&wf.id, 11)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<24}", truncate(&wf.name, 23)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:<8}", wf.steps),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
),
|
||||
Span::styled(format!(" {}", wf.created), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.push(ListItem::new(Line::from(vec![Span::styled(
|
||||
" + Create new workflow",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)])));
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.list_state);
|
||||
}
|
||||
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [Enter] View runs [x] Run [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)]));
|
||||
f.render_widget(hints, chunks[2]);
|
||||
}
|
||||
|
||||
fn draw_runs(f: &mut Frame, area: Rect, state: &mut WorkflowState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // title
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let wf_name = state
|
||||
.selected_workflow
|
||||
.and_then(|i| state.workflows.get(i))
|
||||
.map(|w| w.name.as_str())
|
||||
.unwrap_or("?");
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" Runs for: {wf_name}"),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<12} {:<12} {:<12} {}",
|
||||
"Run ID", "State", "Duration", "Output"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
if state.runs.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No runs yet. Press [x] from the list to run.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[2],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
.runs
|
||||
.iter()
|
||||
.map(|run| {
|
||||
let (badge, badge_style) = theme::state_badge(&run.state);
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<12}", truncate(&run.id, 11)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
Span::styled(format!(" {:<12}", badge), badge_style),
|
||||
Span::styled(
|
||||
format!(" {:<12}", run.duration),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {}", truncate(&run.output_preview, 30)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[2], &mut state.runs_list_state);
|
||||
}
|
||||
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [r] Refresh [Esc] Back",
|
||||
theme::hint_style(),
|
||||
)]));
|
||||
f.render_widget(hints, chunks[3]);
|
||||
}
|
||||
|
||||
fn draw_create(f: &mut Frame, area: Rect, state: &WorkflowState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // title
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Length(2), // field label
|
||||
Constraint::Length(1), // input
|
||||
Constraint::Length(1), // step indicator
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" Create New Workflow",
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
let sep = "\u{2500}".repeat(chunks[1].width as usize);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(sep, theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
let (label, value, placeholder) = match state.create_step {
|
||||
0 => ("Workflow name:", &state.create_name, "my-workflow"),
|
||||
1 => (
|
||||
"Description:",
|
||||
&state.create_desc,
|
||||
"What this workflow does",
|
||||
),
|
||||
2 => (
|
||||
"Steps (JSON array):",
|
||||
&state.create_steps,
|
||||
"[{\"action\":\"...\"}]",
|
||||
),
|
||||
_ => (
|
||||
"Review \u{2014} press Enter to create",
|
||||
&state.create_name,
|
||||
"",
|
||||
),
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::raw(format!(" {label}"))])),
|
||||
chunks[2],
|
||||
);
|
||||
|
||||
if state.create_step < 3 {
|
||||
let display = if value.is_empty() {
|
||||
placeholder
|
||||
} else {
|
||||
value.as_str()
|
||||
};
|
||||
let style = if value.is_empty() {
|
||||
theme::dim_style()
|
||||
} else {
|
||||
theme::input_style()
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(" > "),
|
||||
Span::styled(display, style),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
])),
|
||||
chunks[3],
|
||||
);
|
||||
} else {
|
||||
// Review
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::raw(" Name: "),
|
||||
Span::styled(&state.create_name, Style::default().fg(theme::CYAN)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(" Desc: "),
|
||||
Span::styled(&state.create_desc, theme::dim_style()),
|
||||
]),
|
||||
]),
|
||||
chunks[3],
|
||||
);
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" Step {} of 4", state.create_step + 1),
|
||||
theme::dim_style(),
|
||||
)])),
|
||||
chunks[4],
|
||||
);
|
||||
|
||||
let hint_text = if state.create_step == 3 {
|
||||
" [Enter] Create [Esc] Back"
|
||||
} else {
|
||||
" [Enter] Next [Esc] Back"
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
hint_text,
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[6],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_run_input(f: &mut Frame, area: Rect, state: &WorkflowState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let wf_name = state
|
||||
.selected_workflow
|
||||
.and_then(|i| state.workflows.get(i))
|
||||
.map(|w| w.name.as_str())
|
||||
.unwrap_or("?");
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(" Run: {wf_name}"),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
let sep = "\u{2500}".repeat(chunks[1].width as usize);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(sep, theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::raw(" Input (JSON or text):")])),
|
||||
chunks[2],
|
||||
);
|
||||
|
||||
let display = if state.run_input.is_empty() {
|
||||
"enter workflow input..."
|
||||
} else {
|
||||
&state.run_input
|
||||
};
|
||||
let style = if state.run_input.is_empty() {
|
||||
theme::dim_style()
|
||||
} else {
|
||||
theme::input_style()
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(" > "),
|
||||
Span::styled(display, style),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
])),
|
||||
chunks[3],
|
||||
);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [Enter] Run [Esc] Cancel",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[5],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_run_result(f: &mut Frame, area: Rect, state: &WorkflowState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" Workflow Run Result",
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)])),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Running workflow\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
);
|
||||
} else if let Some(ref result) = state.run_result {
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN)),
|
||||
Span::raw("Complete"),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" {result}"),
|
||||
Style::default().fg(theme::CYAN),
|
||||
)]),
|
||||
]),
|
||||
chunks[1],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(" No result.", theme::dim_style())),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [Enter/Esc] Back",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
138
crates/openfang-cli/src/tui/theme.rs
Normal file
138
crates/openfang-cli/src/tui/theme.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
//! Color palette matching the OpenFang landing page design system.
|
||||
//!
|
||||
//! Core palette from globals.css + code syntax from constants.ts.
|
||||
|
||||
#![allow(dead_code)] // Full palette — some colors reserved for future screens.
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
// ── Core Palette (dark mode for terminal) ───────────────────────────────────
|
||||
|
||||
pub const ACCENT: Color = Color::Rgb(255, 92, 0); // #FF5C00 — OpenFang orange
|
||||
pub const ACCENT_DIM: Color = Color::Rgb(224, 82, 0); // #E05200
|
||||
|
||||
pub const BG_PRIMARY: Color = Color::Rgb(15, 14, 14); // #0F0E0E — dark background
|
||||
pub const BG_CARD: Color = Color::Rgb(31, 29, 28); // #1F1D1C — dark surface
|
||||
pub const BG_HOVER: Color = Color::Rgb(42, 39, 37); // #2A2725 — dark hover
|
||||
pub const BG_CODE: Color = Color::Rgb(24, 22, 21); // #181615 — dark code block
|
||||
|
||||
pub const TEXT_PRIMARY: Color = Color::Rgb(240, 239, 238); // #F0EFEE — light text on dark bg
|
||||
pub const TEXT_SECONDARY: Color = Color::Rgb(168, 162, 158); // #A8A29E — muted text
|
||||
pub const TEXT_TERTIARY: Color = Color::Rgb(120, 113, 108); // #78716C — dim text
|
||||
|
||||
pub const BORDER: Color = Color::Rgb(63, 59, 56); // #3F3B38 — dark border
|
||||
|
||||
// ── Semantic Colors (brighter variants for dark background contrast) ────────
|
||||
|
||||
pub const GREEN: Color = Color::Rgb(34, 197, 94); // #22C55E — success
|
||||
pub const BLUE: Color = Color::Rgb(59, 130, 246); // #3B82F6 — info
|
||||
pub const YELLOW: Color = Color::Rgb(234, 179, 8); // #EAB308 — warning
|
||||
pub const RED: Color = Color::Rgb(239, 68, 68); // #EF4444 — error
|
||||
pub const PURPLE: Color = Color::Rgb(168, 85, 247); // #A855F7 — decorators
|
||||
|
||||
// ── Backward-compat aliases ─────────────────────────────────────────────────
|
||||
|
||||
pub const CYAN: Color = BLUE;
|
||||
pub const DIM: Color = TEXT_SECONDARY;
|
||||
|
||||
// ── Reusable styles ─────────────────────────────────────────────────────────
|
||||
|
||||
pub fn title_style() -> Style {
|
||||
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn selected_style() -> Style {
|
||||
Style::default().fg(ACCENT).bg(BG_HOVER)
|
||||
}
|
||||
|
||||
pub fn dim_style() -> Style {
|
||||
Style::default().fg(TEXT_SECONDARY)
|
||||
}
|
||||
|
||||
pub fn input_style() -> Style {
|
||||
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn hint_style() -> Style {
|
||||
Style::default().fg(TEXT_TERTIARY)
|
||||
}
|
||||
|
||||
// ── Tab bar styles ──────────────────────────────────────────────────────────
|
||||
|
||||
pub fn tab_active() -> Style {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(ACCENT)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn tab_inactive() -> Style {
|
||||
Style::default().fg(TEXT_SECONDARY)
|
||||
}
|
||||
|
||||
// ── State badge styles ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn badge_running() -> Style {
|
||||
Style::default().fg(GREEN).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn badge_created() -> Style {
|
||||
Style::default().fg(BLUE).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn badge_suspended() -> Style {
|
||||
Style::default().fg(YELLOW).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn badge_terminated() -> Style {
|
||||
Style::default().fg(TEXT_TERTIARY)
|
||||
}
|
||||
|
||||
pub fn badge_crashed() -> Style {
|
||||
Style::default().fg(RED).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
/// Return badge text + style for an agent state string.
|
||||
pub fn state_badge(state: &str) -> (&'static str, Style) {
|
||||
let lower = state.to_lowercase();
|
||||
if lower.contains("run") {
|
||||
("[RUN]", badge_running())
|
||||
} else if lower.contains("creat") || lower.contains("new") || lower.contains("idle") {
|
||||
("[NEW]", badge_created())
|
||||
} else if lower.contains("sus") || lower.contains("paus") {
|
||||
("[SUS]", badge_suspended())
|
||||
} else if lower.contains("term") || lower.contains("stop") || lower.contains("end") {
|
||||
("[END]", badge_terminated())
|
||||
} else if lower.contains("err") || lower.contains("crash") || lower.contains("fail") {
|
||||
("[ERR]", badge_crashed())
|
||||
} else {
|
||||
("[---]", dim_style())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table / channel styles ──────────────────────────────────────────────────
|
||||
|
||||
pub fn table_header() -> Style {
|
||||
Style::default()
|
||||
.fg(ACCENT)
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
|
||||
}
|
||||
|
||||
pub fn channel_ready() -> Style {
|
||||
Style::default().fg(GREEN).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn channel_missing() -> Style {
|
||||
Style::default().fg(YELLOW)
|
||||
}
|
||||
|
||||
pub fn channel_off() -> Style {
|
||||
dim_style()
|
||||
}
|
||||
|
||||
// ── Spinner ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub const SPINNER_FRAMES: &[&str] = &[
|
||||
"\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}",
|
||||
"\u{2807}", "\u{280f}",
|
||||
];
|
||||
122
crates/openfang-cli/src/ui.rs
Normal file
122
crates/openfang-cli/src/ui.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! Shared UI primitives for non-TUI subcommands (doctor, status, etc.).
|
||||
//!
|
||||
//! Uses `colored` for terminal output. The interactive TUI uses ratatui instead.
|
||||
|
||||
use colored::Colorize;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Existing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Doctor-style check: passed (green checkmark).
|
||||
pub fn check_ok(msg: &str) {
|
||||
println!(" {} {}", "\u{2714}".bright_green(), msg);
|
||||
}
|
||||
|
||||
/// Doctor-style check: warning (yellow dash).
|
||||
pub fn check_warn(msg: &str) {
|
||||
println!(" {} {}", "-".bright_yellow(), msg.yellow());
|
||||
}
|
||||
|
||||
/// Doctor-style check: failed (red cross).
|
||||
pub fn check_fail(msg: &str) {
|
||||
println!(" {} {}", "\u{2718}".bright_red(), msg.bright_red());
|
||||
}
|
||||
|
||||
/// Print a step/section header.
|
||||
pub fn step(msg: &str) {
|
||||
println!(" {} {}", "\u{25cf}".bright_red(), msg.bold());
|
||||
}
|
||||
|
||||
/// Print a success message.
|
||||
pub fn success(msg: &str) {
|
||||
println!(" {} {}", "\u{2714}".bright_green(), msg);
|
||||
}
|
||||
|
||||
/// Print an error message.
|
||||
pub fn error(msg: &str) {
|
||||
println!(" {} {}", "\u{2718}".bright_red(), msg.bright_red());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New themed output helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Brand banner: ">> OpenFang Agent OS"
|
||||
pub fn banner() {
|
||||
println!(
|
||||
" {} {}",
|
||||
">>".bright_cyan().bold(),
|
||||
"OpenFang Agent OS".bold()
|
||||
);
|
||||
println!(" {}", "The open-source agent operating system".dimmed());
|
||||
}
|
||||
|
||||
/// Section header: ">> Title" in cyan.
|
||||
pub fn section(title: &str) {
|
||||
println!(" {} {}", ">>".bright_cyan().bold(), title.bold());
|
||||
}
|
||||
|
||||
/// Key-value display: " Label: value".
|
||||
pub fn kv(label: &str, value: &str) {
|
||||
println!(" {:<13}{}", format!("{label}:"), value);
|
||||
}
|
||||
|
||||
/// Key-value with green value.
|
||||
pub fn kv_ok(label: &str, value: &str) {
|
||||
println!(" {:<13}{}", format!("{label}:"), value.bright_green());
|
||||
}
|
||||
|
||||
/// Key-value with yellow value.
|
||||
pub fn kv_warn(label: &str, value: &str) {
|
||||
println!(" {:<13}{}", format!("{label}:"), value.bright_yellow());
|
||||
}
|
||||
|
||||
/// Hint line: " hint: message" in dimmed text.
|
||||
pub fn hint(msg: &str) {
|
||||
println!(" {} {}", "hint:".dimmed(), msg.dimmed());
|
||||
}
|
||||
|
||||
/// Numbered "Next steps:" list.
|
||||
pub fn next_steps(steps: &[&str]) {
|
||||
println!(" {}:", "Next steps".bold());
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
println!(" {}. {step}", i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Suggest a command: " label command" with command highlighted.
|
||||
pub fn suggest_cmd(label: &str, cmd: &str) {
|
||||
println!(" {:<22}{}", label, cmd.bright_cyan());
|
||||
}
|
||||
|
||||
/// Red error + yellow "fix:" suggestion.
|
||||
pub fn error_with_fix(msg: &str, fix: &str) {
|
||||
println!(" {} {}", "\u{2718}".bright_red(), msg.bright_red());
|
||||
println!(" {} {}", "fix:".bright_yellow(), fix);
|
||||
}
|
||||
|
||||
/// Yellow warning + "try:" suggestion.
|
||||
pub fn warn_with_fix(msg: &str, fix: &str) {
|
||||
println!(" {} {}", "-".bright_yellow(), msg.yellow());
|
||||
println!(" {} {}", "try:".bright_yellow(), fix);
|
||||
}
|
||||
|
||||
/// Provider status line: checkmark/circle + name + env var.
|
||||
pub fn provider_status(name: &str, env_var: &str, configured: bool) {
|
||||
if configured {
|
||||
println!(" {} {:<14} ({})", "\u{2714}".bright_green(), name, env_var);
|
||||
} else {
|
||||
println!(
|
||||
" {} {:<14} ({} not set)",
|
||||
"\u{25cb}".dimmed(),
|
||||
name.dimmed(),
|
||||
env_var.dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty line.
|
||||
pub fn blank() {
|
||||
println!();
|
||||
}
|
||||
Reference in New Issue
Block a user