use crate::tui::app::{
AgentConfig, App, ChatMessage, ChatState, MessageRole, SLASH_COMMANDS, SPINNER_FRAMES, Screen,
resolve_state_dir,
};
use crate::tui::ops;
use crossterm::event::{Event, KeyCode, KeyEvent};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
const ROBOT: &[&str] = &[
" o ",
" │ ",
" ╔═══════╗ ",
"═╣ ● ● ╠═",
" ║ ─── ║ ",
" ╚═══════╝ ",
];
pub fn render(f: &mut Frame, state: &ChatState, app: &App) {
let area = f.area();
let agent = app.agent.as_ref();
let bot_name = agent.map(|b| b.bot_name.as_str()).unwrap_or("Asterbot");
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(12),
Constraint::Min(4),
Constraint::Length(1),
Constraint::Length(3),
])
.split(area);
render_banner(f, bot_name, agent, chunks[0]);
let env_name = agent.map(|b| b.env_name.as_str()).unwrap_or("asterbot");
render_messages(f, state, env_name, chunks[1]);
let sep = Paragraph::new(Span::styled(
"─".repeat(chunks[2].width as usize),
Style::default().fg(Color::DarkGray),
));
f.render_widget(sep, chunks[2]);
render_input(f, state, chunks[3]);
let has_slash = state.input.starts_with('/') && !state.input.contains(' ');
let menu_h = match has_slash {
true => state.slash_matches.len().min(6) as u16,
false => 0,
};
if menu_h > 0 {
let menu_area = Rect::new(
chunks[2].x,
chunks[2].y.saturating_sub(menu_h),
chunks[2].width,
menu_h,
);
render_slash_menu(f, state, menu_area);
}
}
pub async fn handle_event(app: &mut App, event: Event) -> eyre::Result<()> {
let Event::Key(KeyEvent { code, .. }) = event else {
return Ok(());
};
let Screen::Chat(state) = &mut app.screen else {
return Ok(());
};
if state.waiting {
return Ok(());
}
let has_slash_menu = !state.slash_matches.is_empty();
match code {
KeyCode::Up if has_slash_menu => {
if state.slash_selected > 0 {
state.slash_selected -= 1;
}
}
KeyCode::Down if has_slash_menu => {
if state.slash_selected + 1 < state.slash_matches.len() {
state.slash_selected += 1;
}
}
KeyCode::Enter if has_slash_menu && state.slash_selected < state.slash_matches.len() => {
let cmd_idx = state.slash_matches[state.slash_selected];
let cmd_name = SLASH_COMMANDS[cmd_idx].name;
state.input = format!("/{cmd_name}");
state.slash_matches.clear();
state.slash_selected = 0;
let input = state.input.clone();
state.input.clear();
dispatch_slash(app, &input).await?;
}
KeyCode::Esc if has_slash_menu => {
state.slash_matches.clear();
state.slash_selected = 0;
}
KeyCode::Up if !has_slash_menu => {
if !state.input_history.is_empty() {
let idx = match state.history_idx {
Some(i) if i > 0 => i - 1,
Some(i) => i,
None => state.input_history.len() - 1,
};
state.history_idx = Some(idx);
state.input = state.input_history[idx].clone();
}
}
KeyCode::Down if !has_slash_menu => {
if let Some(idx) = state.history_idx {
if idx + 1 < state.input_history.len() {
let new_idx = idx + 1;
state.history_idx = Some(new_idx);
state.input = state.input_history[new_idx].clone();
} else {
state.history_idx = None;
state.input.clear();
}
}
}
KeyCode::Enter => {
let input = state.input.trim().to_string();
if input.is_empty() {
return Ok(());
}
state.input_history.push(input.clone());
state.history_idx = None;
state.input.clear();
state.slash_matches.clear();
if input.starts_with('/') {
dispatch_slash(app, &input).await?;
} else {
send_message(app, &input);
}
}
KeyCode::Char(c) => {
let Screen::Chat(state) = &mut app.screen else {
return Ok(());
};
state.input.push(c);
update_slash_menu(state);
}
KeyCode::Backspace => {
state.input.pop();
update_slash_menu(state);
}
KeyCode::Esc => {
app.should_quit = true;
}
_ => {}
}
Ok(())
}
fn render_banner(f: &mut Frame, name: &str, agent: Option<&AgentConfig>, area: Rect) {
let model = agent.and_then(|b| b.model.as_deref()).unwrap_or("not set");
let tools = agent.map(|b| &b.tools).cloned().unwrap_or_default();
let tool_names: Vec<&str> = tools
.iter()
.map(|t| t.split(':').next_back().unwrap_or(t))
.collect();
let dirs_count = agent.map(|b| b.allowed_dirs.len()).unwrap_or(0);
let title = format!(" {} ", name.to_uppercase());
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
f.render_widget(block, area);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(26), Constraint::Min(20)])
.split(inner);
let divider_x = cols[0].x + cols[0].width;
for y in inner.y..inner.y + inner.height {
if divider_x < area.x + area.width - 1 {
let cell = ratatui::buffer::Cell::default()
.set_char('│')
.set_style(Style::default().fg(Color::Cyan))
.clone();
if let Some(c) = f
.buffer_mut()
.cell_mut(ratatui::layout::Position::new(divider_x, y))
{
*c = cell;
}
}
}
let host = hostname();
let mut left_lines: Vec<Line> = Vec::new();
left_lines.push(
Line::from(Span::styled(
format!("GREETINGS, {host}."),
Style::default().fg(Color::Cyan).bold(),
))
.alignment(Alignment::Center),
);
left_lines.push(
Line::from(Span::styled(
"SHALL WE PLAY A GAME?",
Style::default().fg(Color::Cyan).bold(),
))
.alignment(Alignment::Center),
);
left_lines.push(Line::from(""));
for line in ROBOT {
left_lines.push(
Line::from(Span::styled(*line, Style::default().fg(Color::Cyan)))
.alignment(Alignment::Center),
);
}
f.render_widget(Paragraph::new(left_lines), cols[0]);
let right_area = Rect::new(
cols[1].x + 1,
cols[1].y,
cols[1].width.saturating_sub(1),
cols[1].height,
);
let tool_str = match tool_names.is_empty() {
true => "none".to_string(),
false => tool_names.join(" · "),
};
let mut right_lines: Vec<Line> = Vec::new();
right_lines.push(Line::from(vec![
Span::styled("MODEL ", Style::default().fg(Color::DarkGray)),
Span::styled(model, Style::default().fg(Color::White)),
]));
right_lines.push(Line::from(vec![
Span::styled("TOOLS ", Style::default().fg(Color::DarkGray)),
Span::styled(&tool_str, Style::default().fg(Color::White)),
]));
if dirs_count > 0 {
let dirs_label = match dirs_count {
1 => "1 folder".to_string(),
n => format!("{n} folders"),
};
right_lines.push(Line::from(vec![
Span::styled("DIRS ", Style::default().fg(Color::DarkGray)),
Span::styled(dirs_label, Style::default().fg(Color::White)),
]));
}
right_lines.push(Line::from(""));
right_lines.push(Line::from(vec![
Span::styled("Type ", Style::default().fg(Color::DarkGray)),
Span::styled("/", Style::default().fg(Color::Cyan)),
Span::styled(" for commands · ", Style::default().fg(Color::DarkGray)),
Span::styled("Ctrl+C", Style::default().fg(Color::Cyan)),
Span::styled(" to exit", Style::default().fg(Color::DarkGray)),
]));
f.render_widget(Paragraph::new(right_lines), right_area);
}
fn render_messages(f: &mut Frame, state: &ChatState, env_name: &str, area: Rect) {
if state.messages.is_empty() && !state.waiting {
let text = Paragraph::new(Span::styled(
"Send a message to start chatting.",
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Center);
let centered = Rect::new(area.x, area.y + area.height / 2, area.width, 1);
f.render_widget(text, centered);
return;
}
let assistant_prefix = format!("{env_name}: ");
let mut lines: Vec<Line> = Vec::new();
for msg in &state.messages {
let (prefix, style) = match msg.role {
MessageRole::User => ("You: ", Style::default().fg(Color::Cyan)),
MessageRole::Assistant => {
(assistant_prefix.as_str(), Style::default().fg(Color::White))
}
MessageRole::System => ("", Style::default().fg(Color::Yellow)),
};
lines.push(Line::from(""));
for text_line in msg.content.lines() {
let is_first_line = text_line == msg.content.lines().next().unwrap_or("");
let line_prefix = match is_first_line {
true => prefix,
false => "",
};
lines.push(Line::from(vec![
Span::styled(line_prefix, style.bold()),
Span::styled(text_line, style),
]));
}
}
if state.waiting {
let frame = SPINNER_FRAMES[state.spinner_tick % SPINNER_FRAMES.len()];
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(format!("{frame} "), Style::default().fg(Color::Cyan)),
Span::styled("Thinking...", Style::default().fg(Color::DarkGray)),
]));
}
let total_lines = lines.len() as u16;
let visible = area.height;
let scroll = total_lines.saturating_sub(visible);
let text = Paragraph::new(lines).scroll((scroll, 0));
f.render_widget(text, area);
}
fn render_input(f: &mut Frame, state: &ChatState, area: Rect) {
let input_area = Rect::new(
area.x + 2,
area.y,
area.width.saturating_sub(4),
area.height,
);
let cursor = Span::styled("_", Style::default().fg(Color::DarkGray));
let line = Line::from(vec![
Span::styled("> ", Style::default().fg(Color::Cyan)),
Span::raw(&state.input),
cursor,
]);
let input = Paragraph::new(line);
f.render_widget(input, input_area);
}
fn render_slash_menu(f: &mut Frame, state: &ChatState, area: Rect) {
let max_rows = area.height as usize;
let skip = state
.slash_selected
.saturating_sub(max_rows.saturating_sub(1));
let mut lines: Vec<Line> = Vec::new();
for (i, &cmd_idx) in state
.slash_matches
.iter()
.enumerate()
.skip(skip)
.take(max_rows)
{
let cmd = &SLASH_COMMANDS[cmd_idx];
let is_selected = i == state.slash_selected;
let pointer = match is_selected {
true => "▸ ",
false => " ",
};
let name_style = match is_selected {
true => Style::default().fg(Color::Cyan).bold(),
false => Style::default().fg(Color::Cyan),
};
let desc_style = match is_selected {
true => Style::default().fg(Color::White),
false => Style::default().fg(Color::DarkGray),
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::raw(pointer),
Span::styled(format!("/{:<12}", cmd.name), name_style),
Span::styled(cmd.description, desc_style),
]));
}
f.render_widget(Paragraph::new(lines), area);
}
fn send_message(app: &mut App, input: &str) {
let Screen::Chat(state) = &mut app.screen else {
return;
};
state.messages.push(ChatMessage {
role: MessageRole::User,
content: input.to_string(),
});
state.waiting = true;
let agent = app.agent.clone();
let message = input.to_string();
let (tx, rx) = tokio::sync::oneshot::channel();
app.pending_response = Some(rx);
tokio::spawn(async move {
let result = match agent {
Some(ref a) => ops::call_converse(&message, a).await,
None => Err(eyre::eyre!("no active agent")),
};
let _ = tx.send(result);
});
}
async fn dispatch_slash(app: &mut App, input: &str) -> eyre::Result<()> {
let parts: Vec<&str> = input[1..].split_whitespace().collect();
if parts.is_empty() {
return Ok(());
}
let cmd = parts[0].to_lowercase();
let args = &parts[1..];
match cmd.as_str() {
"help" | "h" | "?" => cmd_help(app),
"tools" | "t" => cmd_tools(app, args).await,
"clear" | "c" => cmd_clear(app),
"model" | "m" => cmd_model(app, args),
"name" | "rename" => cmd_name(app, args),
"dir" | "dirs" => cmd_dir(app, args),
"status" | "info" => cmd_status(app),
"push" => cmd_push(app).await,
"pull" | "sync" => cmd_pull(app).await,
"config" | "vars" => cmd_config(app, args).await,
_ => {
push_system(
app,
&format!("Unknown command: /{cmd}. Type /help for commands."),
);
Ok(())
}
}
}
fn update_slash_menu(state: &mut ChatState) {
if state.input.starts_with('/') && !state.input.contains(' ') {
let partial = &state.input[1..].to_lowercase();
state.slash_matches = SLASH_COMMANDS
.iter()
.enumerate()
.filter(|(_, cmd)| cmd.name.starts_with(partial.as_str()))
.map(|(i, _)| i)
.collect();
state.slash_selected = 0;
} else {
state.slash_matches.clear();
state.slash_selected = 0;
}
}
fn push_system(app: &mut App, msg: &str) {
if let Screen::Chat(state) = &mut app.screen {
state.messages.push(ChatMessage {
role: MessageRole::System,
content: msg.to_string(),
});
}
}
fn cmd_help(app: &mut App) -> eyre::Result<()> {
let mut lines = String::from("Commands:\n");
for cmd in SLASH_COMMANDS {
lines.push_str(&format!(" /{:<12} {}\n", cmd.name, cmd.description));
}
push_system(app, &lines);
Ok(())
}
async fn cmd_tools(app: &mut App, args: &[&str]) -> eyre::Result<()> {
let tools = app
.agent
.as_ref()
.map(|b| &b.tools)
.cloned()
.unwrap_or_default();
if args.is_empty() {
let mut msg = String::from("Enabled tools:\n");
if tools.is_empty() {
msg.push_str(" (none)");
} else {
for tool in &tools {
msg.push_str(&format!(" {tool}\n"));
}
}
msg.push_str(
"\n/tools add <namespace:component> Add a tool\n\
/tools remove <component> Remove a tool",
);
push_system(app, &msg);
return Ok(());
}
let Some(agent) = &app.agent else {
push_system(app, "No active agent.");
return Ok(());
};
let env_name = agent.env_name.clone();
if args[0] == "add" && args.len() > 1 {
let component = args[1];
match ops::add_component(&env_name, component).await {
Ok(_) => {
if let Some(agent) = &mut app.agent {
let base = component.split('@').next().unwrap_or(component);
if !agent.tools.contains(&base.to_string()) {
agent.tools.push(base.to_string());
}
}
save_tools(app);
push_system(app, &format!("+ {component}"));
}
Err(e) => push_system(app, &format!("Failed to add: {e:#}")),
}
return Ok(());
}
if (args[0] == "remove" || args[0] == "rm") && args.len() > 1 {
let component = args[1];
match ops::remove_component(&env_name, component).await {
Ok(_) => {
let base = component.split('@').next().unwrap_or(component);
if let Some(agent) = &mut app.agent {
agent.tools.retain(|t| t != base);
}
save_tools(app);
push_system(app, &format!("- {component}"));
}
Err(e) => push_system(app, &format!("Failed to remove: {e:#}")),
}
return Ok(());
}
push_system(app, "Usage: /tools [add|remove] <namespace:component>");
Ok(())
}
fn cmd_clear(app: &mut App) -> eyre::Result<()> {
if let Screen::Chat(state) = &mut app.screen {
state.messages.clear();
}
if let Some(agent) = &app.agent {
let state_dir = resolve_state_dir(&agent.env_name);
let conv_path = state_dir.join("conversation.json");
let _ = std::fs::write(conv_path, "[]");
}
push_system(app, "Conversation history cleared.");
Ok(())
}
fn cmd_model(app: &mut App, args: &[&str]) -> eyre::Result<()> {
if args.is_empty() {
let model = app
.agent
.as_ref()
.and_then(|b| b.model.as_deref())
.unwrap_or("(not set)");
push_system(
app,
&format!("Current model: {model}\n\nSwitch with: /model <provider/model>"),
);
return Ok(());
}
let new_model = args[0].to_string();
if let Some(agent) = &app.agent {
let _ = ops::set_var(&agent.env_name, "ASTERBOT_MODEL", &new_model);
}
if let Some(agent) = &mut app.agent {
agent.model = Some(new_model.clone());
}
push_system(app, &format!("Model set to {new_model}"));
Ok(())
}
fn cmd_name(app: &mut App, args: &[&str]) -> eyre::Result<()> {
if args.is_empty() {
let name = app
.agent
.as_ref()
.map(|b| b.bot_name.as_str())
.unwrap_or("Asterbot");
push_system(
app,
&format!("Agent name: {name}\n\nChange with: /name <new name>"),
);
return Ok(());
}
let new_name = args.join(" ");
if let Some(agent) = &app.agent {
let _ = ops::set_var(&agent.env_name, "ASTERBOT_BOT_NAME", &new_name);
}
if let Some(agent) = &mut app.agent {
agent.bot_name = new_name.clone();
}
push_system(app, &format!("Agent renamed to {new_name}"));
Ok(())
}
fn cmd_dir(app: &mut App, args: &[&str]) -> eyre::Result<()> {
let dirs = app
.agent
.as_ref()
.map(|b| b.allowed_dirs.clone())
.unwrap_or_default();
if args.is_empty() || args[0] == "list" {
let mut msg = String::from("Allowed directories:\n");
if dirs.is_empty() {
msg.push_str(" (none)\n");
} else {
for dir in &dirs {
msg.push_str(&format!(" {dir}\n"));
}
}
msg.push_str("\n/dir add <path> Grant access\n/dir remove <path> Revoke access");
push_system(app, &msg);
return Ok(());
}
if args[0] == "add" && args.len() > 1 {
let path = args[1..].join(" ");
let resolved = std::path::Path::new(&path)
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or(path);
if let Some(agent) = &mut app.agent {
agent.allowed_dirs.push(resolved.clone());
}
save_allowed_dirs(app);
push_system(app, &format!("+ {resolved}"));
return Ok(());
}
if (args[0] == "remove" || args[0] == "rm") && args.len() > 1 {
let path = args[1..].join(" ");
let resolved = std::path::Path::new(&path)
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or(path);
if let Some(agent) = &mut app.agent {
agent.allowed_dirs.retain(|d| d != &resolved);
}
save_allowed_dirs(app);
push_system(app, &format!("- {resolved}"));
return Ok(());
}
push_system(app, "Usage: /dir [list|add|remove] <path>");
Ok(())
}
fn cmd_status(app: &mut App) -> eyre::Result<()> {
let Some(agent) = &app.agent else {
push_system(app, "No active agent.");
return Ok(());
};
let mut msg = format!(
"Agent Status:\n\
\x20 Name: {}\n\
\x20 Environment: {}\n\
\x20 Model: {}\n\
\x20 Provider: {}",
agent.bot_name,
agent.env_name,
agent.model.as_deref().unwrap_or("(not set)"),
agent.provider,
);
if !agent.tools.is_empty() {
let tool_names: Vec<&str> = agent
.tools
.iter()
.map(|t| t.split(':').next_back().unwrap_or(t))
.collect();
msg.push_str(&format!("\n Tools: {}", tool_names.join(", ")));
}
if !agent.allowed_dirs.is_empty() {
msg.push_str(&format!(
"\n Directories: {} folder(s)",
agent.allowed_dirs.len()
));
}
push_system(app, &msg);
Ok(())
}
async fn cmd_push(app: &mut App) -> eyre::Result<()> {
let Some(agent) = &app.agent else {
push_system(app, "No active agent.");
return Ok(());
};
let env_name = agent.env_name.clone();
push_system(app, &format!("Pushing {env_name}..."));
match ops::push_env(&env_name).await {
Ok(_) => push_system(app, &format!("Pushed {env_name} to cloud.")),
Err(e) => push_system(app, &format!("Push failed: {e:#}")),
}
Ok(())
}
async fn cmd_pull(app: &mut App) -> eyre::Result<()> {
let Some(agent) = &app.agent else {
push_system(app, "No active agent.");
return Ok(());
};
let env_name = agent.env_name.clone();
push_system(app, &format!("Pulling {env_name}..."));
match ops::pull_env(&env_name).await {
Ok(_) => push_system(app, &format!("Pulled {env_name} from cloud.")),
Err(e) => push_system(app, &format!("Pull failed: {e:#}")),
}
Ok(())
}
async fn cmd_config(app: &mut App, args: &[&str]) -> eyre::Result<()> {
if args.is_empty() || args[0] == "list" {
let Some(agent) = &app.agent else {
push_system(app, "No active agent.");
return Ok(());
};
let env_name = agent.env_name.clone();
let data = ops::inspect_environment(&env_name).await?;
let vars = data.map(|d| d.vars).unwrap_or_default();
let mut msg = format!("Environment variables ({env_name}):\n");
if vars.is_empty() {
msg.push_str(" (none)");
} else {
for v in &vars {
msg.push_str(&format!(" {v}\n"));
}
}
msg.push_str("\n/config set KEY=VALUE Set a variable");
push_system(app, &msg);
return Ok(());
}
if args[0] == "set" && args.len() > 1 {
let expr = args[1..].join(" ");
let Some((key, value)) = expr.split_once('=') else {
push_system(app, "Usage: /config set KEY=VALUE");
return Ok(());
};
let key = key.trim();
let value = value.trim();
let Some(agent) = &app.agent else {
push_system(app, "No active agent.");
return Ok(());
};
let env_name = agent.env_name.clone();
match ops::set_var(&env_name, key, value) {
Ok(_) => push_system(app, &format!("{key} = {value}")),
Err(e) => push_system(app, &format!("Failed: {e:#}")),
}
return Ok(());
}
push_system(app, "Usage: /config [list|set KEY=VALUE]");
Ok(())
}
fn save_tools(app: &App) {
let Some(agent) = &app.agent else { return };
let value = agent.tools.join(",");
let _ = ops::set_var(&agent.env_name, "ASTERBOT_TOOLS", &value);
}
fn save_allowed_dirs(app: &App) {
let Some(agent) = &app.agent else { return };
let value = agent.allowed_dirs.join(",");
let _ = ops::set_var(&agent.env_name, "ASTERBOT_ALLOWED_DIRS", &value);
}
#[cfg(unix)]
fn hostname() -> String {
let mut buf = [0u8; 256];
unsafe {
libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len());
}
let len = buf.iter().position(|&b| b == 0).unwrap_or(0);
String::from_utf8_lossy(&buf[..len]).to_uppercase()
}
#[cfg(windows)]
fn hostname() -> String {
std::env::var("COMPUTERNAME")
.unwrap_or_else(|_| "USER".to_string())
.to_uppercase()
}