vtcode-tui 0.98.6

Reusable TUI primitives and session API for VT Code-style terminal interfaces
use crate::core_tui::session::list_navigator::ListNavigator;
use crate::core_tui::types::LocalAgentEntry;
use hashbrown::HashSet;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

use super::{AppSession, InlineEvent, transient::TransientSurface};

#[derive(Clone, Debug, Default)]
pub(super) struct LocalAgentsState {
    entries: Vec<LocalAgentEntry>,
    navigator: ListNavigator,
    active_ids: HashSet<String>,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct LocalAgentsUpdate {
    pub(super) has_new_delegated_entries: bool,
}

pub(super) enum LocalAgentsKeyResult {
    NotHandled,
    Handled,
    Emit(InlineEvent),
}

impl LocalAgentsState {
    pub(super) fn set_entries(&mut self, entries: Vec<LocalAgentEntry>) -> LocalAgentsUpdate {
        let previous_id = self.selected_entry().map(|entry| entry.id.clone());
        let next_active_ids = entries
            .iter()
            .map(|entry| entry.id.clone())
            .collect::<HashSet<_>>();
        let has_new_delegated_entries = entries.iter().any(|entry| {
            entry.kind == crate::core_tui::types::LocalAgentKind::Delegated
                && !self.active_ids.contains(entry.id.as_str())
        });
        self.entries = entries;
        self.navigator.set_item_count(self.entries.len());
        self.active_ids = next_active_ids;

        if self.entries.is_empty() {
            return LocalAgentsUpdate {
                has_new_delegated_entries,
            };
        }

        if let Some(previous_id) = previous_id
            && let Some(index) = self
                .entries
                .iter()
                .position(|entry| entry.id == previous_id)
        {
            self.navigator.select_index(index);
            return LocalAgentsUpdate {
                has_new_delegated_entries,
            };
        }

        self.navigator.select_first();
        LocalAgentsUpdate {
            has_new_delegated_entries,
        }
    }

    pub(super) fn entries(&self) -> &[LocalAgentEntry] {
        &self.entries
    }

    pub(super) fn has_entries(&self) -> bool {
        !self.entries.is_empty()
    }

    pub(super) fn selected(&self) -> Option<usize> {
        self.navigator.selected()
    }

    pub(super) fn select_index(&mut self, index: usize) -> bool {
        self.navigator.select_index(index)
    }

    pub(super) fn move_selection_up(&mut self) -> bool {
        self.navigator.move_up()
    }

    pub(super) fn move_selection_down(&mut self) -> bool {
        self.navigator.move_down()
    }

    pub(super) fn page_up(&mut self, step: usize) -> bool {
        self.navigator.page_up(step)
    }

    pub(super) fn page_down(&mut self, step: usize) -> bool {
        self.navigator.page_down(step)
    }

    pub(super) fn set_visible_rows(&mut self, rows: usize) {
        self.navigator.set_visible_rows(rows);
    }

    pub(super) fn visible_rows(&self) -> usize {
        self.navigator.visible_rows()
    }

    pub(super) fn scroll_offset(&self) -> usize {
        self.navigator.scroll_offset()
    }

    pub(super) fn selected_entry(&self) -> Option<&LocalAgentEntry> {
        self.selected().and_then(|index| self.entries.get(index))
    }
}

impl AppSession {
    pub(super) fn should_open_local_agents_with_down(
        &self,
        key: &KeyEvent,
        has_control: bool,
        has_alt: bool,
        has_command: bool,
    ) -> bool {
        matches!(key.code, KeyCode::Down)
            && !has_control
            && !has_alt
            && !has_command
            && !self.local_agents_visible()
            && !self.has_active_overlay()
            && self.core.input_manager.content().trim().is_empty()
            && self.core.input_manager.history_index().is_none()
            && self.local_agents_state.has_entries()
    }

    pub(super) fn handle_local_agents_key(&mut self, key: &KeyEvent) -> LocalAgentsKeyResult {
        if !self.local_agents_visible() {
            return LocalAgentsKeyResult::NotHandled;
        }

        match key.code {
            KeyCode::Up => {
                self.local_agents_state.move_selection_up();
                self.mark_dirty();
                LocalAgentsKeyResult::Handled
            }
            KeyCode::Down => {
                self.local_agents_state.move_selection_down();
                self.mark_dirty();
                LocalAgentsKeyResult::Handled
            }
            KeyCode::PageUp => {
                let step = self.local_agents_state.visible_rows().max(1);
                self.local_agents_state.page_up(step);
                self.mark_dirty();
                LocalAgentsKeyResult::Handled
            }
            KeyCode::PageDown => {
                let step = self.local_agents_state.visible_rows().max(1);
                self.local_agents_state.page_down(step);
                self.mark_dirty();
                LocalAgentsKeyResult::Handled
            }
            KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.local_agents_state.move_selection_down();
                self.mark_dirty();
                LocalAgentsKeyResult::Handled
            }
            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.local_agents_state.move_selection_up();
                self.mark_dirty();
                LocalAgentsKeyResult::Handled
            }
            KeyCode::Char('o') | KeyCode::Char('O')
                if key.modifiers.contains(KeyModifiers::ALT) =>
            {
                self.selected_local_agent_transcript_event()
                    .map_or(LocalAgentsKeyResult::Handled, LocalAgentsKeyResult::Emit)
            }
            KeyCode::Char('k') | KeyCode::Char('K')
                if key.modifiers.contains(KeyModifiers::CONTROL) =>
            {
                self.selected_local_agent_stop_event()
                    .map_or(LocalAgentsKeyResult::Handled, LocalAgentsKeyResult::Emit)
            }
            KeyCode::Char('x') | KeyCode::Char('X')
                if key.modifiers.contains(KeyModifiers::CONTROL) =>
            {
                self.selected_local_agent_force_cancel_event()
                    .map_or(LocalAgentsKeyResult::Handled, LocalAgentsKeyResult::Emit)
            }
            KeyCode::Enter => self
                .selected_local_agent_inspect_event()
                .map_or(LocalAgentsKeyResult::Handled, LocalAgentsKeyResult::Emit),
            KeyCode::Esc => {
                self.close_transient_surface(TransientSurface::LocalAgents);
                self.mark_dirty();
                LocalAgentsKeyResult::Handled
            }
            _ => LocalAgentsKeyResult::NotHandled,
        }
    }

    fn selected_local_agent_inspect_event(&mut self) -> Option<InlineEvent> {
        let entry = self.local_agents_state.selected_entry()?.clone();
        self.mark_dirty();
        Some(InlineEvent::Submit(match entry.kind {
            crate::core_tui::types::LocalAgentKind::Delegated => {
                format!("/agent inspect {}", entry.id)
            }
            crate::core_tui::types::LocalAgentKind::Background => {
                format!("/subprocesses inspect {}", entry.id)
            }
        }))
    }

    fn selected_local_agent_transcript_event(&mut self) -> Option<InlineEvent> {
        let path = self
            .local_agents_state
            .selected_entry()?
            .transcript_path
            .as_ref()?
            .display()
            .to_string();
        self.mark_dirty();
        Some(InlineEvent::OpenFileInEditor(path))
    }

    fn selected_local_agent_stop_event(&mut self) -> Option<InlineEvent> {
        let entry = self.local_agents_state.selected_entry()?.clone();
        self.mark_dirty();
        Some(InlineEvent::Submit(match entry.kind {
            crate::core_tui::types::LocalAgentKind::Delegated => {
                format!("/agent close {}", entry.id)
            }
            crate::core_tui::types::LocalAgentKind::Background => {
                format!("/subprocesses stop {}", entry.id)
            }
        }))
    }

    fn selected_local_agent_force_cancel_event(&mut self) -> Option<InlineEvent> {
        let entry = self.local_agents_state.selected_entry()?.clone();
        self.mark_dirty();
        Some(InlineEvent::Submit(match entry.kind {
            crate::core_tui::types::LocalAgentKind::Delegated => {
                format!("/agent close {}", entry.id)
            }
            crate::core_tui::types::LocalAgentKind::Background => {
                format!("/subprocesses cancel {}", entry.id)
            }
        }))
    }
}