robit 0.1.19

Terminal UI for the robit AI automation agent.
//! App state — conversation model and event handling.

use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::mpsc;

use robit_agent::{AgentEvent, FrontendMessage, SkillRegistry, ToolRegistry};

use crate::input::InputEditor;

// ============================================================================
// Conversation model
// ============================================================================

/// A single entry in the conversation display.
#[derive(Debug)]
pub enum ConversationEntry {
    UserMessage(String),
    AssistantText(String),
    ToolCard {
        tool_call_id: String,
        name: String,
        arguments: String,
        status: ToolStatus,
    },
    Error(String),
    SystemNotice(String),
}

/// Tool call display status.
#[derive(Debug)]
#[allow(dead_code)]
pub enum ToolStatus {
    Pending,
    Running,
    Success(String),
    Failed(String),
    Rejected,
    AwaitingConfirmation,
}

// ============================================================================
// Input mode
// ============================================================================

/// The current input mode of the TUI.
pub enum InputMode {
    /// Normal text input.
    Normal,
    /// Waiting for Y/N confirmation.
    Confirmation {
        tool_call_id: String,
        responder: Option<tokio::sync::oneshot::Sender<bool>>,
    },
}

// ============================================================================
// Status info
// ============================================================================

/// Information displayed in the status bar.
pub struct StatusInfo {
    pub model: String,
    /// Image generation model (model_id), if configured. Displayed alongside
    /// the chat model as "chat / image".
    pub image_model: Option<String>,
    pub tools_enabled: usize,
    pub tools_total: usize,
    pub skills_total: usize,
}

// ============================================================================
// App
// ============================================================================

/// Main application state.
pub struct App {
    pub conversation: Vec<ConversationEntry>,
    pub current_assistant_text: String,
    pub input: InputEditor,
    pub input_mode: InputMode,
    /// Distance from the bottom of the conversation, in visual (wrapped)
    /// lines. `0` means pinned to the latest content.
    pub scroll_offset: usize,
    /// Whether the view follows the bottom of the conversation. New content
    /// never forces this on — it is controlled by user actions only.
    pub auto_scroll: bool,
    pub scroll_mode: bool,
    pub status: StatusInfo,
    pub is_agent_busy: bool,
    pub should_quit: bool,
    pub skills: Arc<SkillRegistry>,
    /// Tool call IDs the user rejected; used to show [`ToolStatus::Rejected`]
    /// when the agent reports the (error) result back.
    pub rejected_tool_ids: HashSet<String>,
    /// Rendered-line cache for committed conversation entries.
    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(),
        }
    }

    /// Flush the streaming assistant text into a conversation entry.
    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));
        }
    }

    /// Handle an incoming agent event, updating the UI state.
    ///
    /// Note: events intentionally do **not** touch `auto_scroll` (sticky
    /// bottom). The view only follows the bottom when the user is already at
    /// the bottom; scrolling away to read history is never undone by new
    /// content arriving.
    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,
            } => {
                // Minimal TUI handling: the agent wakes the LLM, whose reply
                // arrives via TextDelta as usual. A dedicated task-progress
                // panel is future work; for now the fields are just dropped.
                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);
        }
    }

    /// Toggle scroll mode on/off. Leaving scroll mode returns to the latest
    /// position.
    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;
        }
    }

    /// Process user input text (slash commands or send to agent).
    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()));
            // The user's own message always re-pins the view to the bottom.
            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));
                }
            }
            _ => {
                // Check if this is a skill trigger — forward to Agent
                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]
                    )));
                }
            }
        }
    }
}