初始化提交
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:
iven
2026-03-01 16:24:24 +08:00
commit 92e5def702
492 changed files with 211343 additions and 0 deletions

View 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 }

View 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);
}
}
}

View 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());
}
}

View 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
}

File diff suppressed because it is too large Load Diff

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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()
}
}

View 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();
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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)))
}
}

View 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],
);
}

View 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)))
}
}

View 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)))
}
}

View 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],
);
}

View 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)
}

File diff suppressed because it is too large Load Diff

View 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)))
}
}

View 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)))
}
}

View 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;

View 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)))
}
}

View 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],
);
}

View 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)))
}
}

View 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}")
}
}

View 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}")
}
}

View 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)))
}
}

View 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)))
}
}

View 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)))
}
}

View 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]);
}

View 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]);
}
}

View 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)))
}
}

View 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}",
];

View 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!();
}