icebreaker 2026.2.0

A local AI chat app powered by 🦀Rust, 🧊iced, 🤗Hugging Face, and 🦙llama.cpp
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use icebreaker_core as core;

mod browser;
mod icon;
mod screen;
mod theme;
mod ui;
mod widget;

use crate::core::assistant;
use crate::core::model;
use crate::core::{Chat, Error, Settings};
use crate::screen::Screen;
use crate::screen::conversation;
use crate::screen::search;
use crate::screen::settings;

use iced::system;
use iced::widget::{Text, button, column, container, row, rule, space};
use iced::{Element, Fill, Subscription, Task, Theme};

use std::mem;

pub fn main() -> iced::Result {
    tracing_subscriber::fmt::init();

    iced::application(Icebreaker::new, Icebreaker::update, Icebreaker::view)
        .title(Icebreaker::title)
        .subscription(Icebreaker::subscription)
        .theme(Icebreaker::theme)
        .font(icon::FONT)
        .run()
}

struct Icebreaker {
    screen: Screen,
    last_conversation: Option<screen::Conversation>,
    system: Option<system::Information>,
    library: model::Library,
    theme: Theme,
}

#[derive(Debug, Clone)]
enum Message {
    Loaded {
        last_chat: Result<Chat, Error>,
        system: Box<system::Information>,
    },
    Scanned(Result<model::Library, Error>),
    Escape,
    Search(search::Message),
    Conversation(conversation::Message),
    Settings(settings::Message),
    OpenChats,
    OpenSearch,
    OpenSettings,
    SettingsSaved(Result<(), Error>),
}

impl Icebreaker {
    pub fn new() -> (Self, Task<Message>) {
        let settings = Settings::fetch().unwrap_or_default();
        let scan = model::Library::scan(settings.library);

        (
            Self {
                screen: Screen::Loading,
                library: model::Library::default(),
                last_conversation: None,
                system: None,
                theme: theme::from_data(&settings.theme),
            },
            Task::batch([
                Task::future(Chat::fetch_last_opened()).then(|last_chat| {
                    system::information()
                        .map(Box::new)
                        .map(move |system| Message::Loaded {
                            last_chat: last_chat.clone(),
                            system,
                        })
                }),
                Task::perform(scan, Message::Scanned),
            ]),
        )
    }

    fn title(&self) -> String {
        let title = match &self.screen {
            Screen::Loading => return "Icebreaker".to_owned(),
            Screen::Search(search) => search.title(),
            Screen::Conversation(conversation) => conversation.title(),
            Screen::Settings(settings) => settings.title(),
        };

        format!("{title} - Icebreaker")
    }

    fn update(&mut self, message: Message) -> Task<Message> {
        match message {
            Message::Loaded { last_chat, system } => {
                let backend = assistant::Backend::detect(&system.graphics_adapter);
                self.system = Some(*system);

                match last_chat {
                    Ok(last_chat) => {
                        let (conversation, task) =
                            screen::Conversation::open(&self.library, last_chat, backend);

                        self.screen = Screen::Conversation(conversation);

                        task.map(Message::Conversation)
                    }
                    Err(error) => {
                        log::warn!("{error}");

                        self.open_search()
                    }
                }
            }
            Message::Scanned(Ok(library)) => {
                let old_library = std::mem::replace(&mut self.library, library);

                if old_library.directory() != self.library.directory() {
                    self.save_settings()
                } else {
                    Task::none()
                }
            }
            Message::Search(message) => {
                if let Screen::Search(search) = &mut self.screen {
                    let action = search.update(message);

                    match action {
                        search::Action::None => Task::none(),
                        search::Action::Run(task) => task.map(Message::Search),
                        search::Action::Boot(file) => {
                            let backend = self
                                .system
                                .as_ref()
                                .map(|system| assistant::Backend::detect(&system.graphics_adapter))
                                .unwrap_or(assistant::Backend::Cpu);

                            let (conversation, task) =
                                screen::Conversation::new(&self.library, file, backend);

                            self.screen = Screen::Conversation(conversation);
                            self.last_conversation = None;

                            task.map(Message::Conversation)
                        }
                    }
                } else {
                    Task::none()
                }
            }
            Message::Conversation(message) => {
                let conversation = if let Screen::Conversation(conversation) = &mut self.screen {
                    Some(conversation)
                } else {
                    self.last_conversation.as_mut()
                };

                let Some(conversation) = conversation else {
                    return Task::none();
                };

                let action = conversation.update(&self.library, message);

                match action {
                    conversation::Action::None => Task::none(),
                    conversation::Action::Run(task) => task.map(Message::Conversation),
                }
            }
            Message::Settings(message) => {
                let Screen::Settings(settings) = &mut self.screen else {
                    return Task::none();
                };

                match settings.update(message) {
                    settings::Action::None => Task::none(),
                    settings::Action::ChangeTheme(theme) => {
                        self.theme = theme;

                        self.save_settings()
                    }
                    settings::Action::ChangeLibraryFolder(library) => {
                        Task::perform(model::Library::scan(library), Message::Scanned)
                    }
                    settings::Action::Run(task) => task.map(Message::Settings),
                }
            }
            Message::Escape => {
                if matches!(self.screen, Screen::Search(_)) {
                    Task::none()
                } else {
                    self.open_search()
                }
            }
            Message::OpenChats => {
                if let Some(conversation) = self.last_conversation.take() {
                    self.screen = Screen::Conversation(conversation);
                }

                Task::none()
            }
            Message::OpenSearch => {
                if let Screen::Conversation(conversation) =
                    mem::replace(&mut self.screen, Screen::Loading)
                {
                    self.last_conversation = Some(conversation);
                }

                self.open_search()
            }
            Message::OpenSettings => {
                if let Screen::Conversation(conversation) =
                    mem::replace(&mut self.screen, Screen::Loading)
                {
                    self.last_conversation = Some(conversation);
                }

                self.open_settings()
            }
            Message::SettingsSaved(Ok(())) => Task::none(),
            Message::Scanned(Err(error)) | Message::SettingsSaved(Err(error)) => {
                log::error!("{error}");

                Task::none()
            }
        }
    }

    fn view(&self) -> Element<'_, Message> {
        let sidebar = {
            let content = match &self.screen {
                Screen::Conversation(conversation) => {
                    conversation.sidebar().map(Message::Conversation)
                }
                Screen::Search(search) => search.sidebar(&self.library).map(Message::Search),
                Screen::Settings(settings) => settings.sidebar().map(Message::Settings),
                Screen::Loading => space::vertical().into(),
            };

            let tab = |icon: Text<'static>, toggled, message| {
                button(icon.width(Fill).height(Fill).center())
                    .padding(0)
                    .height(40)
                    .on_press_maybe(message)
                    .width(Fill)
                    .style(move |theme: &Theme, status| {
                        let palette = theme.extended_palette();

                        let base = button::text(theme, status);

                        if toggled {
                            button::Style {
                                text_color: palette.background.neutral.text,
                                background: Some(palette.background.neutral.color.into()),
                                border: base.border.rounded(10),
                                ..base
                            }
                        } else {
                            base
                        }
                    })
            };

            let tabs = container(row![
                tab(
                    icon::chat(),
                    matches!(self.screen, Screen::Conversation(_)),
                    self.last_conversation
                        .is_some()
                        .then_some(Message::OpenChats),
                ),
                tab(
                    icon::cubes(),
                    matches!(self.screen, Screen::Search(_)),
                    Some(Message::OpenSearch),
                ),
                tab(
                    icon::cog(),
                    matches!(self.screen, Screen::Settings(_)),
                    Some(Message::OpenSettings)
                ),
            ])
            .padding(10)
            .style(|theme| {
                container::Style::default()
                    .background(theme.extended_palette().background.weaker.color)
            });

            row![
                container(column![container(content).padding(10).height(Fill), tabs])
                    .width(250)
                    .style(|theme| {
                        container::Style::default()
                            .background(theme.extended_palette().background.weakest.color)
                    }),
                rule::vertical(1).style(rule::weak),
            ]
        };

        let screen = match &self.screen {
            Screen::Loading => screen::loading(),
            Screen::Search(search) => search.view(&self.library).map(Message::Search),
            Screen::Conversation(conversation) => {
                conversation.view(&self.theme).map(Message::Conversation)
            }
            Screen::Settings(settings) => settings
                .view(&self.library, &self.theme)
                .map(Message::Settings),
        };

        row![sidebar, container(screen).padding(10)].into()
    }

    fn subscription(&self) -> Subscription<Message> {
        use iced::keyboard;

        let screen = match &self.screen {
            Screen::Loading => Subscription::none(),
            Screen::Search(_) => Subscription::none(),
            Screen::Conversation(conversation) => {
                conversation.subscription().map(Message::Conversation)
            }
            Screen::Settings(_) => Subscription::none(),
        };

        let hotkeys = keyboard::listen().filter_map(|event| {
            let keyboard::Event::KeyPressed {
                modified_key,
                repeat: false,
                ..
            } = event
            else {
                return None;
            };

            match modified_key {
                keyboard::Key::Named(keyboard::key::Named::Escape) => Some(Message::Escape),
                _ => None,
            }
        });

        Subscription::batch([screen, hotkeys])
    }

    fn theme(&self) -> Theme {
        self.theme.clone()
    }

    fn open_search(&mut self) -> Task<Message> {
        let (search, task) = screen::Search::new();

        self.screen = Screen::Search(search);

        Task::batch([
            Task::perform(
                model::Library::scan(self.library.directory().clone()),
                Message::Scanned,
            ),
            task.map(Message::Search),
        ])
    }

    fn open_settings(&mut self) -> Task<Message> {
        let (settings, task) = screen::Settings::new();

        self.screen = Screen::Settings(settings);

        task.map(Message::Settings)
    }

    fn save_settings(&self) -> Task<Message> {
        let settings = Settings {
            library: self.library.directory().clone(),
            theme: theme::to_data(&self.theme),
        };

        Task::perform(settings.save(), Message::SettingsSaved)
    }
}