mindfork 0.10.2

A terminal AI chat written in Rust: local models via llama.cpp or OpenAI, Anthropic, Gemini and Grok in the cloud, with persistent memory, notes, RAG and tools.
//! Chat-list screen (FSD "page"): a full-screen list with search, sorting,
//! in-place renaming, create/clone/copy/delete.
//! Opened from the chat via `Esc`, closed via `Esc`. See spec §11.2.
//!
//! A thin wrapper over the [`ChatListState`] widget: holds the render context
//! (the active chat — for the marker, the theme palette) and translates the widget's
//! [`ChatListAction`] into a [`ChatListIntent`] — an intent that `app` translates into an
//! `AppCommand` or into screen management. The screen doesn't know about `app`/channels
//! (FSD, dependencies flow downward) — like `ChatScreen`/`SettingsScreen`.

use ratatui::Frame;
use ratatui::crossterm::event::KeyEvent;
use uuid::Uuid;

use crate::entities::chat::ChatSummary;
use crate::features::chat_search_sort::SortMode;
use crate::features::spellcheck::SpellChecker;
use crate::screens::awaited_chat::AwaitedChat;
use crate::shared::i18n::Locale;
use crate::shared::theme::Palette;
use crate::widgets::chat_list::{ChatListAction, ChatListState};

/// Intent from the chat-list screen (translated by `app`). A counterpart to
/// [`ChatIntent`](crate::screens::chat::ChatIntent)/`SettingsIntent`.
#[derive(Debug, Clone, PartialEq)]
pub enum ChatListIntent {
    /// Close the list, return to the chat (`Esc`).
    Close,
    /// Quit the application (`Ctrl+Q`/`F10`).
    Quit,
    /// Make a chat active and return to it (`Enter`).
    Switch(Uuid),
    /// Create a new chat (profile selection is delegated to the chat screen) (`Ctrl+N`).
    NewChat,
    /// Clone a chat and return to it (`Ctrl+D`).
    Clone(Uuid),
    /// Copy a chat's conversation to the clipboard (the list stays open) (`F5`).
    Copy(Uuid),
    /// Soft-delete a chat (the list stays open) (`Del`).
    Delete(Uuid),
    /// Rename a chat (the list stays open) (`F2`).
    Rename { id: Uuid, title: String },
    /// Auto-title a chat via the model (the list stays open) (`Ctrl+R`).
    AutoRename(Uuid),
    /// Run a full-text search over chat content with this raw query (content
    /// mode, `Ctrl+F`; the list stays open). The result arrives as
    /// `AppEvent::ChatSearchResults` → [`ChatListScreen::set_search_results`].
    /// See docs/research/chat-content-search.md.
    SearchContent(String),
    /// Open the message-level search screen for this raw query (`Ctrl+G` in
    /// content mode). The result arrives as `AppEvent::MessageSearchResults`,
    /// which opens the screen. See docs/history/chat-search-stage2.md.
    SearchMessages { query: String, sort: SortMode },
    /// Open a chat at its first message matching the query (`Enter` in content
    /// mode); the orchestrator resolves which message that is.
    OpenFirstMatch { chat: Uuid, query: String },
    /// Fold or unfold a chat's sub-agent transcripts in the list (`Ctrl+O`;
    /// the list stays open). Stored on the chat — `/subagents` on the chat
    /// screen is the same state's other route. See spec §11.2.
    SetChildrenExpanded { id: Uuid, expanded: bool },
}

/// Chat-list screen: widget state + render context.
pub struct ChatListScreen {
    state: ChatListState,
    /// Active chat (the `●` marker in the list). Updated on `ChatActivated`.
    active: Option<Uuid>,
    /// Theme palette for rendering (updated on `Settings`).
    palette: Palette,
    /// Interface locale (updated on `Settings`). See docs/i18n-ui.md.
    loc: &'static Locale,
    /// The chat this list asked for (`Enter`, `Ctrl+N`, `Ctrl+D`) and stays on
    /// screen for: the list gives way when its `ChatActivated` arrives, so as not
    /// to flash the previous chat before the asked-for one. See spec §11.2.
    awaited: AwaitedChat,
}

impl ChatListScreen {
    /// Opens the screen with a snapshot of the list; the selection is on the active chat.
    pub fn new(
        chats: Vec<ChatSummary>,
        active: Option<Uuid>,
        palette: Palette,
        loc: &'static Locale,
    ) -> Self {
        Self {
            state: ChatListState::new(chats, active),
            active,
            palette,
            loc,
            awaited: AwaitedChat::Nothing,
        }
    }

    /// The list asked the orchestrator for this chat and stays up until its
    /// `ChatActivated` arrives, so the switch is one frame (without showing the
    /// previous chat in between). See `dispatch_chat_list`.
    pub fn await_chat(&mut self, awaited: AwaitedChat) {
        self.awaited = awaited;
    }

    /// Whether the activation that just arrived is the one the list was waiting
    /// for (consumed) — time to close the list.
    pub fn take_awaited(&mut self, id: Uuid) -> bool {
        self.awaited.take_if_arrived(id)
    }

    /// The request was refused (`ChatListError`): the list is just open again,
    /// and a later unrelated activation must not close it.
    pub fn stop_awaiting(&mut self) {
        self.awaited.clear();
    }

    /// Updates the list snapshot (after the chat set changes) — the
    /// `AppEvent::ChatList` event. Keeps the selection on the same chat where possible.
    pub fn set_chats(&mut self, chats: Vec<ChatSummary>) {
        self.state.set_chats(chats);
    }

    /// Updates the active-chat marker (the `AppEvent::ChatActivated` event, e.g.
    /// after deleting the active chat while the list is open).
    pub fn set_active(&mut self, active: Option<Uuid>) {
        self.active = active;
    }

    /// Updates the theme palette (the `AppEvent::Settings` event).
    pub fn set_palette(&mut self, palette: Palette) {
        self.palette = palette;
    }

    /// Updates the interface locale (the `AppEvent::Settings` event).
    pub fn set_loc(&mut self, loc: &'static Locale) {
        self.loc = loc;
    }

    /// Shows a list-operation error in its status area (fades on keypress).
    pub fn set_error(&mut self, message: String) {
        self.state.set_error(message);
    }

    /// Shows an operation confirmation (success) in its status area.
    pub fn set_notice(&mut self, message: String) {
        self.state.set_notice(message);
    }

    /// Handles a keypress, returning an intent for `app` (or `None` if the
    /// key was handled internally: navigation, search/rename input).
    pub fn handle_key(&mut self, key: KeyEvent) -> Option<ChatListIntent> {
        // A key while the list waits for a chat means the user has moved on: an
        // answer that never came (a vanished chat) must not leave the list set to
        // close on whatever activates next. `app` sets the wait *after* this
        // returns, so the key that asks does not clear its own request.
        self.awaited.clear();
        match self.state.on_key(key) {
            ChatListAction::None => None,
            ChatListAction::Close => Some(ChatListIntent::Close),
            ChatListAction::Quit => Some(ChatListIntent::Quit),
            ChatListAction::Switch(id) => Some(ChatListIntent::Switch(id)),
            ChatListAction::New => Some(ChatListIntent::NewChat),
            ChatListAction::Clone(id) => Some(ChatListIntent::Clone(id)),
            ChatListAction::Copy(id) => Some(ChatListIntent::Copy(id)),
            ChatListAction::Delete(id) => Some(ChatListIntent::Delete(id)),
            ChatListAction::Rename { id, title } => Some(ChatListIntent::Rename { id, title }),
            ChatListAction::AutoRename(id) => Some(ChatListIntent::AutoRename(id)),
            ChatListAction::SearchContent(query) => Some(ChatListIntent::SearchContent(query)),
            ChatListAction::SearchMessages { query, sort } => {
                Some(ChatListIntent::SearchMessages { query, sort })
            }
            ChatListAction::OpenFirstMatch { chat, query } => {
                Some(ChatListIntent::OpenFirstMatch { chat, query })
            }
            ChatListAction::SetChildrenExpanded { id, expanded } => {
                Some(ChatListIntent::SetChildrenExpanded { id, expanded })
            }
        }
    }

    /// Reopens the list still searching message content for `query` — used when
    /// the message-level results screen closes (`Esc`), so the user lands back
    /// on the search they were doing.
    pub fn restore_content_query(&mut self, query: String) {
        self.state.restore_content_query(query);
    }

    /// Applies a content-search result (the `AppEvent::ChatSearchResults`
    /// event). `chat_ids: None` — not a searchable query, don't filter.
    pub fn set_search_results(&mut self, query: String, chat_ids: Option<Vec<Uuid>>) {
        self.state.set_search_results(query, chat_ids);
    }

    /// Inserts clipboard text into the rename field (if open).
    /// Outside rename mode — a no-op. See spec §11.5.
    pub fn handle_paste(&mut self, text: &str) {
        self.state.handle_paste(text);
    }

    /// Rechecks spelling in the rename field (if open and changed).
    /// Returns `true` if the highlighting was updated (a redraw is needed). The checker
    /// is lent from the chat screen — its owner (`app` reconciles this in the loop).
    pub fn recheck_spelling(&mut self, spell: &SpellChecker) -> bool {
        self.state.recheck_rename_spelling(spell)
    }

    /// Draws the list full-screen. `&mut self` — the rename field draws
    /// an [`InputBox`](crate::widgets::input_box::InputBox), which needs `&mut`.
    pub fn render(&mut self, frame: &mut Frame) {
        self.state
            .render(frame, frame.area(), self.active, &self.palette, self.loc);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::crossterm::event::{KeyCode, KeyModifiers};

    fn ru() -> &'static Locale {
        crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
    }

    fn chat(title: &str) -> ChatSummary {
        ChatSummary::fixture(title)
    }

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    fn ctrl(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::CONTROL)
    }

    #[test]
    fn esc_maps_to_close() {
        let mut s = ChatListScreen::new(vec![chat("A")], None, Palette::default(), ru());
        assert_eq!(s.handle_key(key(KeyCode::Esc)), Some(ChatListIntent::Close));
    }

    #[test]
    fn ctrl_q_and_f10_map_to_quit() {
        let mut s = ChatListScreen::new(vec![chat("A")], None, Palette::default(), ru());
        assert_eq!(
            s.handle_key(ctrl(KeyCode::Char('q'))),
            Some(ChatListIntent::Quit)
        );
        assert_eq!(
            s.handle_key(key(KeyCode::F(10))),
            Some(ChatListIntent::Quit)
        );
    }

    #[test]
    fn enter_maps_to_switch_of_selected() {
        let chats = vec![chat("A")];
        let id = chats[0].id;
        let mut s = ChatListScreen::new(chats, Some(id), Palette::default(), ru());
        assert_eq!(
            s.handle_key(key(KeyCode::Enter)),
            Some(ChatListIntent::Switch(id))
        );
    }

    #[test]
    fn ctrl_n_maps_to_new_chat() {
        let mut s = ChatListScreen::new(vec![chat("A")], None, Palette::default(), ru());
        assert_eq!(
            s.handle_key(ctrl(KeyCode::Char('n'))),
            Some(ChatListIntent::NewChat)
        );
    }

    #[test]
    fn typing_filters_and_returns_none() {
        let mut s = ChatListScreen::new(
            vec![chat("Альфа"), chat("Бета")],
            None,
            Palette::default(),
            ru(),
        );
        // Typing a character into the search field is handled internally (no intent).
        assert_eq!(s.handle_key(key(KeyCode::Char('Б'))), None);
    }

    #[test]
    fn an_awaited_chat_is_taken_once() {
        let mut s = ChatListScreen::new(vec![chat("A")], None, Palette::default(), ru());
        let id = Uuid::new_v4();
        assert!(!s.take_awaited(id));
        s.await_chat(AwaitedChat::Created);
        // Taken exactly once (resets) — a second activation shouldn't close it.
        assert!(s.take_awaited(id));
        assert!(!s.take_awaited(id));
    }

    /// A wait nobody answered must not outlive the user's attention: the next
    /// key ends it, and so does a refusal.
    #[test]
    fn a_key_or_a_refusal_ends_the_wait() {
        let mut s = ChatListScreen::new(vec![chat("A")], None, Palette::default(), ru());
        let id = Uuid::new_v4();
        s.await_chat(AwaitedChat::Chat(id));
        s.handle_key(key(KeyCode::Down));
        assert!(!s.take_awaited(id), "the user moved on");

        s.await_chat(AwaitedChat::Created);
        s.stop_awaiting();
        assert!(!s.take_awaited(id), "the request was refused");
    }

    #[test]
    fn render_does_not_panic() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;
        let mut s = ChatListScreen::new(vec![chat("Альфа")], None, Palette::default(), ru());
        let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
        term.draw(|f| s.render(f)).unwrap();
    }
}