use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::mpsc;
use robit_agent::{AgentEvent, FrontendMessage, SkillRegistry, ToolRegistry};
use crate::input::InputEditor;
#[derive(Debug)]
pub enum ConversationEntry {
UserMessage(String),
AssistantText(String),
ToolCard {
tool_call_id: String,
name: String,
arguments: String,
status: ToolStatus,
},
Error(String),
SystemNotice(String),
}
#[derive(Debug)]
#[allow(dead_code)]
pub enum ToolStatus {
Pending,
Running,
Success(String),
Failed(String),
Rejected,
AwaitingConfirmation,
}
pub enum InputMode {
Normal,
Confirmation {
tool_call_id: String,
responder: Option<tokio::sync::oneshot::Sender<bool>>,
},
}
pub struct StatusInfo {
pub model: String,
pub image_model: Option<String>,
pub tools_enabled: usize,
pub tools_total: usize,
pub skills_total: usize,
}
pub struct App {
pub conversation: Vec<ConversationEntry>,
pub current_assistant_text: String,
pub input: InputEditor,
pub input_mode: InputMode,
pub scroll_offset: usize,
pub auto_scroll: bool,
pub scroll_mode: bool,
pub status: StatusInfo,
pub is_agent_busy: bool,
pub should_quit: bool,
pub skills: Arc<SkillRegistry>,
pub rejected_tool_ids: HashSet<String>,
pub render_cache: crate::ui::RenderCache,
}
impl App {
pub fn new(
model: String,
image_model: Option<String>,
tools: &ToolRegistry,
skills: Arc<SkillRegistry>,
) -> Self {
let tool_names = tools.tool_names();
Self {
conversation: Vec::new(),
current_assistant_text: String::new(),
input: InputEditor::new(),
input_mode: InputMode::Normal,
scroll_offset: 0,
auto_scroll: true,
scroll_mode: false,
status: StatusInfo {
model,
image_model,
tools_enabled: tool_names.len(),
tools_total: tool_names.len(),
skills_total: skills.count(),
},
is_agent_busy: false,
should_quit: false,
skills,
rejected_tool_ids: HashSet::new(),
render_cache: crate::ui::RenderCache::default(),
}
}
pub fn commit_assistant_text(&mut self) {
if !self.current_assistant_text.is_empty() {
let text = std::mem::take(&mut self.current_assistant_text);
self.conversation.push(ConversationEntry::AssistantText(text));
}
}
pub fn handle_agent_event(&mut self, event: AgentEvent) {
match event {
AgentEvent::TextDelta(text) => {
self.is_agent_busy = true;
self.current_assistant_text.push_str(&text);
}
AgentEvent::ToolCallRequested {
tool_call_id,
name,
arguments,
} => {
self.is_agent_busy = true;
self.commit_assistant_text();
self.conversation.push(ConversationEntry::ToolCard {
tool_call_id,
name,
arguments,
status: ToolStatus::Pending,
});
}
AgentEvent::ToolCallResult {
tool_call_id,
result,
} => {
self.update_tool_status(&tool_call_id, result);
}
AgentEvent::TurnComplete => {
self.commit_assistant_text();
self.is_agent_busy = false;
}
AgentEvent::Error(e) => {
self.commit_assistant_text();
self.conversation
.push(ConversationEntry::Error(format!("{}", e)));
self.is_agent_busy = false;
}
AgentEvent::SkillTriggered { name, description } => {
self.conversation.push(ConversationEntry::SystemNotice(
format!("Skill: {} — {}", name, description),
));
}
AgentEvent::AsyncToolCompleted {
task_id,
tool_call_id,
result,
} => {
let _ = (task_id, tool_call_id, result);
}
}
}
fn update_tool_status(&mut self, tool_call_id: &str, result: robit_agent::ToolResult) {
let rejected = self.rejected_tool_ids.remove(tool_call_id);
let idx = self.conversation.iter().position(|entry| match entry {
ConversationEntry::ToolCard { tool_call_id: id, .. } => id == tool_call_id,
_ => false,
});
if let Some(idx) = idx {
let new_status = if rejected {
ToolStatus::Rejected
} else if result.is_error {
ToolStatus::Failed(result.content)
} else {
ToolStatus::Success(result.content)
};
if let ConversationEntry::ToolCard { status, .. } = &mut self.conversation[idx] {
*status = new_status;
}
self.render_cache.invalidate(idx);
}
}
pub fn toggle_scroll_mode(&mut self) {
self.scroll_mode = !self.scroll_mode;
if self.scroll_mode {
self.auto_scroll = false;
} else {
self.scroll_offset = 0;
self.auto_scroll = true;
}
}
pub async fn handle_user_input(
&mut self,
text: String,
message_tx: &mpsc::Sender<FrontendMessage>,
) {
if text.starts_with('/') {
self.handle_slash_command(&text, message_tx).await;
} else {
self.conversation
.push(ConversationEntry::UserMessage(text.clone()));
self.scroll_offset = 0;
self.auto_scroll = true;
let _ = message_tx
.send(FrontendMessage::UserInput {
text,
attachments: vec![],
})
.await;
}
}
async fn handle_slash_command(
&mut self,
cmd: &str,
message_tx: &mpsc::Sender<FrontendMessage>,
) {
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
match parts[0] {
"/exit" | "/quit" => {
self.should_quit = true;
}
"/clear" => {
self.conversation.clear();
self.current_assistant_text.clear();
self.rejected_tool_ids.clear();
self.scroll_offset = 0;
self.auto_scroll = true;
let _ = message_tx
.send(FrontendMessage::UserInput {
text: "/clear".to_string(),
attachments: vec![],
})
.await;
self.conversation
.push(ConversationEntry::SystemNotice("Conversation history cleared".to_string()));
}
"/model" => {
let msg = match &self.status.image_model {
Some(img) => format!(
"Current model: {} / {} (chat / image)",
self.status.model, img
),
None => format!("Current model: {}", self.status.model),
};
self.conversation
.push(ConversationEntry::SystemNotice(msg));
}
"/tools" => {
let msg = format!(
"Enabled tools: {}",
self.status.tools_enabled
);
self.conversation
.push(ConversationEntry::SystemNotice(msg));
}
"/scroll" => {
self.toggle_scroll_mode();
let msg = if self.scroll_mode {
"Scroll mode enabled — use ↑↓ keys to browse history".to_string()
} else {
"Scroll mode disabled — returned to latest position".to_string()
};
self.conversation
.push(ConversationEntry::SystemNotice(msg));
}
"/skills" => {
let skills = self.skills.skills();
if skills.is_empty() {
self.conversation.push(ConversationEntry::SystemNotice(
"No available skills. Add .md files to ~/.robit/skills/ or .robit/skills/."
.to_string(),
));
} else {
let mut msg = format!("Available skills ({}):\n", skills.len());
for skill in skills {
msg.push_str(&format!(
"- {} ({}) — {}\n",
skill.frontmatter.name,
skill.frontmatter.version,
skill.frontmatter.description
));
if !skill.frontmatter.triggers.is_empty() {
msg.push_str(&format!(
" Trigger commands: {}\n",
skill.frontmatter.triggers.join(", ")
));
}
}
self.conversation
.push(ConversationEntry::SystemNotice(msg));
}
}
_ => {
if let Some((skill, _)) = self.skills.match_trigger(cmd) {
self.conversation.push(ConversationEntry::SystemNotice(
format!("Triggered skill: {} — {}", skill.frontmatter.name, skill.frontmatter.description),
));
let _ = message_tx
.send(FrontendMessage::UserInput {
text: cmd.to_string(),
attachments: vec![],
})
.await;
} else {
self.conversation.push(ConversationEntry::Error(format!(
"Unknown command: {}",
parts[0]
)));
}
}
}
}
}