mod generation; mod git; mod model_manager; mod preferences; mod projects; mod view; pub(crate) use view::app_theme; use generation::ChatMessage; use git::{ActiveGitOperation, GitDiff, GitWorktree}; use model_manager::{ActiveDownload, ModelDownload, ModelOperation}; use preferences::PreferenceDraft; #[cfg(test)] use preferences::{parse_optional_gib, parse_streaming_cache}; use crate::config::{ Config, DevBrainConfig, EndpointConfig, GitConfig, GitDiffAlgorithm, GitDiffLayout, GitDiffWhitespace, }; use crate::database::{Database, ProjectWithSessions, SessionState, StoredMessage}; #[cfg(any(target_os = "macos", test))] use crate::engine::ChatTurn; use crate::metrics::{KvCacheReport, Metrics, MetricsSnapshot}; use crate::model::{self, DownloadOutcome, DownloadProgress, ManagedArtifactId, ModelChoice}; #[cfg(target_os = "macos")] use crate::runtime::{ActiveGeneration, CheckpointTarget, GenerationEvent, GenerationService}; use crate::settings::{ DiagnosticPreferences, ExecutionPreferences, GIB, GenerationPreferences, KvCachePreferences, ReasoningMode, RuntimePreferences, SpeculativePreferences, SsdPreferences, SteeringPreferences, StreamingCacheBudget, }; use iced::widget::{markdown, scrollable, text_editor}; use iced::{Size, Subscription, Task, keyboard, mouse, window}; use rfd::AsyncFileDialog; use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, TryRecvError}; #[cfg(target_os = "macos")] use std::sync::{Mutex, RwLock}; use std::thread; use std::time::{Duration, Instant}; const APP_ID: &str = "de.rfc1437.ds4server"; const METRICS_SAMPLE_INTERVAL: Duration = Duration::from_millis(200); /// Disc scans are cheap but not free; the explorer does not need 5 Hz. const CACHE_SCAN_INTERVAL: Duration = Duration::from_secs(2); const GIT_SCAN_INTERVAL: Duration = Duration::from_secs(2); pub(super) const MIN_SIDEBAR_WIDTH: i32 = 180; pub(super) const MAX_SIDEBAR_WIDTH: i32 = 520; pub(crate) struct App { main_window: window::Id, pub(super) preferences_window: Option, pub(super) model_manager_window: Option, pub(super) help_window: Option, pub(super) pending_model_delete: Option, #[cfg(target_os = "macos")] _native_menu: Option, #[cfg(target_os = "macos")] pub(super) native_edit_commands: crate::native_edit::EditCommandQueue, #[cfg(target_os = "macos")] pub(super) native_edit_availability: crate::native_edit::EditAvailabilityState, database: Option, projects: Vec, config: Config, preference_draft: PreferenceDraft, preference_error: Option, restore_dev_brain_confirmation: bool, selected_project: Option, selected_session: Option, /// Unsaved sessions, keyed by project. A draft only becomes a `sessions` row /// when its first chat turn is stored, so empty ones vanish on restart. drafts: HashMap, git_states: HashMap, git_worktrees: HashMap, git_selected_project: Option, git_selected_files: HashSet, git_commit_message: String, git_diff: Option, git_diff_layout: GitDiffLayout, git_commit_all_confirmation: bool, git_operation: Option, last_git_scan: Instant, #[cfg(target_os = "macos")] background_chats: HashMap, /// Session whose quick-actions menu is open. session_menu: Option, /// Session waiting for explicit confirmation before deletion. pending_session_delete: Option, /// Session being renamed, with the in-progress title. session_rename: Option<(i32, String)>, /// Projects whose archived sessions are expanded in the sidebar. expanded_archives: HashSet, /// True while the sidebar divider is being dragged. sidebar_drag: bool, choosing_folder: bool, pending_project_path: Option, project_name_input: String, model_download: ModelDownload, pub(super) composer: text_editor::Content, pub(super) queued_inputs: VecDeque, pub(super) conversation: Vec, /// Follow appended chat content until the user scrolls away from the tail. pub(super) chat_follow_tail: bool, active_turn: Option, pub(super) a2ui: crate::a2ui::Store, pub(super) a2ui_history: Vec, pub(super) a2ui_history_index: Option, pub(super) a2ui_tabs: HashMap<(String, String), usize>, pub(super) a2ui_modals: HashSet<(String, String)>, pub(super) a2ui_editors: HashMap<(String, String, String), text_editor::Content>, pub(super) a2ui_markdown: HashMap<(String, String, String), markdown::Content>, pub(super) help_markdown: markdown::Content, pub(super) a2ui_choice_filters: HashMap<(String, String, String), String>, pub(super) a2ui_images: HashMap, pub(super) a2ui_image_requests: HashSet, pub(super) a2ui_image_loading: bool, pub(super) pending_a2ui_dismissal: Option, pub(super) a2ui_auto_switch_pending: bool, pub(super) generating: bool, pub(super) context_used: u32, pub(super) context_limit: u32, pub(super) tokens_per_second: Option, pub(super) detail_tab: DetailTab, metrics: Arc, pub(super) metrics_snapshot: MetricsSnapshot, pub(super) metrics_history: VecDeque, /// Disc usage of the KV cache directories, rescanned while Stats is open. pub(super) kv_cache_report: KvCacheReport, last_cache_scan: Instant, last_http_requests: u64, last_ssd_selected_requests: u64, last_ssd_requested_bytes: u64, last_ssd_wait_ms: u64, #[cfg(target_os = "macos")] generation_service: Option, #[cfg(target_os = "macos")] active_generation: Option, #[cfg(target_os = "macos")] active_compaction: Option, #[cfg(target_os = "macos")] active_tool_check: Option, #[cfg(target_os = "macos")] agent_tools: Option<(i32, Arc>)>, #[cfg(target_os = "macos")] active_tools: Option, #[cfg(target_os = "macos")] pub(super) tool_cards: Vec, #[cfg(target_os = "macos")] pub(super) pending_tool_approval: Option<(crate::agent::ApprovalPrompt, std::sync::mpsc::Sender)>, #[cfg(target_os = "macos")] active_titling: Option, #[cfg(target_os = "macos")] runtime_config: Arc>, #[cfg(target_os = "macos")] _endpoint: Option, error: Option, pub(super) activity: Option, pub(super) context_notice: Option, stop_requested: bool, system_prompt_seen_at: u32, manual_compaction_queued: bool, #[cfg(target_os = "macos")] skip_compaction_once: bool, quit_confirmation: bool, } #[cfg(target_os = "macos")] struct ChatSnapshot { selected_project: Option, selected_session: Option, composer: text_editor::Content, queued_inputs: VecDeque, conversation: Vec, chat_follow_tail: bool, active_turn: Option, a2ui: crate::a2ui::Store, a2ui_history: Vec, a2ui_history_index: Option, a2ui_tabs: HashMap<(String, String), usize>, a2ui_modals: HashSet<(String, String)>, a2ui_editors: HashMap<(String, String, String), text_editor::Content>, a2ui_markdown: HashMap<(String, String, String), markdown::Content>, a2ui_choice_filters: HashMap<(String, String, String), String>, a2ui_images: HashMap, a2ui_image_requests: HashSet, a2ui_image_loading: bool, pending_a2ui_dismissal: Option, a2ui_auto_switch_pending: bool, generating: bool, context_used: u32, context_limit: u32, tokens_per_second: Option, detail_tab: DetailTab, active_generation: Option, active_compaction: Option, active_tool_check: Option, agent_tools: Option<(i32, Arc>)>, active_tools: Option, tool_cards: Vec, pending_tool_approval: Option<(crate::agent::ApprovalPrompt, std::sync::mpsc::Sender)>, active_titling: Option, error: Option, activity: Option, context_notice: Option, stop_requested: bool, system_prompt_seen_at: u32, manual_compaction_queued: bool, skip_compaction_once: bool, } #[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct ProjectChoice { id: i32, name: String, } impl std::fmt::Display for ProjectChoice { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(&self.name) } } #[derive(Clone, Debug)] pub(super) struct GitState { pub(super) current: Option, pub(super) label: String, pub(super) branches: Vec, } #[cfg(target_os = "macos")] impl ChatSnapshot { fn needs_poll(&self) -> bool { chat_needs_poll(self.generating, self.active_titling.is_some()) } } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(super) enum DetailTab { #[default] Chat, A2ui, Git, Stats, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum PreferenceSection { Model, Endpoint, DevBrain, Git, Generation, Execution, Acceleration, KvCache, Steering, } impl PreferenceSection { const ALL: [Self; 9] = [ Self::Model, Self::Endpoint, Self::DevBrain, Self::Git, Self::Generation, Self::Execution, Self::Acceleration, Self::KvCache, Self::Steering, ]; fn anchor(self) -> &'static str { match self { Self::Model => "preferences-model", Self::Endpoint => "preferences-endpoint", Self::DevBrain => "preferences-dev-brain", Self::Git => "preferences-git", Self::Generation => "preferences-generation", Self::Execution => "preferences-execution", Self::Acceleration => "preferences-acceleration", Self::KvCache => "preferences-kv-cache", Self::Steering => "preferences-steering", } } fn label(self) -> &'static str { match self { Self::Model => "Model & lifecycle", Self::Endpoint => "Local endpoint", Self::DevBrain => "Dev Brain", Self::Git => "Git diffs", Self::Generation => "Generation", Self::Execution => "Execution", Self::Acceleration => "Acceleration & memory", Self::KvCache => "KV cache", Self::Steering => "Steering & diagnostics", } } } #[derive(Clone, Copy, Debug, Default)] pub(super) struct MetricsPoint { pub(super) decode_tokens_per_second: f32, pub(super) prefill_tokens_per_second: f32, pub(super) http_requests_per_second: f32, pub(super) kv_read_bytes_per_second: f32, pub(super) kv_write_bytes_per_second: f32, pub(super) ssd_requests_per_second: f32, pub(super) ssd_bytes_per_second: f32, pub(super) ssd_wait_ms_per_second: f32, } #[derive(Debug, Clone)] pub(crate) enum Message { Noop, #[cfg(target_os = "macos")] NativeEdit(crate::native_edit::EditCommand), OpenPreferences, PreferencesOpened(window::Id), ClosePreferences, ScrollPreferences(PreferenceSection), OpenModelManager, OpenHelp, HelpOpened(window::Id), NewChat, ExportChat, ExportChatPicked(Option, String), ModelManagerOpened(window::Id), WindowOpened(window::Id), WindowCloseRequested(window::Id), WindowClosed(window::Id), RequestQuit, ConfirmQuit, CancelQuit, Escape(window::Id), DismissPanel, /// Tab and shift-tab: iced leaves the key to the application, so the fields /// of a form are only linked once we move the focus ourselves. FocusNext, FocusPrevious, PreferenceModelChanged(ModelChoice), PreferenceLegacyMtpChanged(bool), PreferenceDsparkChanged(bool), PreferenceTimeoutChanged(String), PreferenceA2uiChanged(bool), PreferenceEndpointPortChanged(String), PreferenceEndpointEnabledChanged(bool), PreferenceEndpointCorsChanged(bool), PreferenceDevBrainEnabledChanged(bool), PreferenceDevBrainVaultChanged(String), PreferenceGitDiffLayoutChanged(GitDiffLayout), PreferenceGitDiffAlgorithmChanged(GitDiffAlgorithm), PreferenceGitContextLinesChanged(String), PreferenceGitInterhunkLinesChanged(String), PreferenceGitIndentHeuristicChanged(bool), PreferenceGitWhitespaceChanged(GitDiffWhitespace), PreferenceGitIgnoreBlankLinesChanged(bool), ChooseDevBrainVault, DevBrainVaultPicked(Option), RestoreDevBrainDefaultGuides, ConfirmRestoreDevBrainDefaultGuides, CancelRestoreDevBrainDefaultGuides, PreferenceContextChanged(String), PreferenceMaxTokensChanged(String), PreferenceSystemPromptAction(text_editor::Action), PreferenceTemperatureChanged(String), PreferenceTopPChanged(String), PreferenceMinPChanged(String), PreferenceSeedChanged(String), PreferenceReasoningChanged(ReasoningMode), PreferenceCpuThreadsChanged(String), PreferencePowerChanged(String), PreferencePrefillChunkChanged(String), PreferenceQualityChanged(bool), PreferenceWarmWeightsChanged(bool), PreferenceMtpDraftChanged(String), PreferenceMtpMarginChanged(String), PreferenceGlmMtpChanged(bool), PreferenceGlmMtpTimingChanged(bool), PreferenceDsparkConfidenceChanged(String), PreferenceDsparkStrictChanged(bool), PreferenceSsdChanged(bool), PreferenceSsdColdChanged(bool), PreferenceSsdCacheChanged(String), PreferenceSsdFullLayersChanged(String), PreferenceSsdPreloadChanged(String), PreferenceSteeringFileChanged(String), PreferenceSteeringFfnChanged(String), PreferenceSteeringAttnChanged(String), PreferenceSimulatedMemoryChanged(String), PreferenceExpertProfileChanged(String), PreferenceKvBudgetChanged(String), PreferenceKvMinTokensChanged(String), PreferenceKvColdMaxChanged(String), PreferenceKvContinuedIntervalChanged(String), A2uiDataChanged(String, String, serde_json::Value), A2uiEditorAction(String, String, String, text_editor::Action), A2uiChoiceFilterChanged(String, String, String, String), A2uiAction(String, String, Option), A2uiSelectTab(String, String, usize), A2uiToggleModal(String, String), A2uiImageLoaded(Option, String, Result, String>), A2uiPlayMedia(String, String, bool), RequestA2uiDismiss(String), ConfirmA2uiDismiss, A2uiPreviousSurface, A2uiNextSurface, ResetPreferences, SavePreferences, DownloadArtifact(ManagedArtifactId), ValidateArtifact(ManagedArtifactId), DeleteArtifact(ManagedArtifactId), ConfirmDeleteArtifact, CancelDeleteArtifact, StopModelDownload, DownloadProgressTick, ComposerAction(text_editor::Action), TranscriptAction(usize, text_editor::Action), ToggleReasoning(usize), OpenLink(markdown::Uri), CopyToolText(String), OpenToolOutput(PathBuf), AllowToolOnce, DenyTool, SubmitPrompt, StopGeneration, GenerationTick, ChatScrolled(scrollable::Viewport), ChooseProjectFolder, ProjectFolderPicked(Option), ProjectNameChanged(String), ConfirmProject, CancelProject, ToggleProject(i32), DeleteProject(i32), CreateSession(i32), DraftProjectChanged(i32), SwitchGitBranch(String), ToggleGitFile(PathBuf), OpenGitDiff(PathBuf), SetGitDiffLayout(GitDiffLayout), GitDiffScrolled(scrollable::Viewport), GitCommitMessageChanged(String), GitStageSelected, GitUnstageSelected, GitCommit, ConfirmGitCommitAll, GitFetch, GitPull, GitPush, GitOperationTick, DiscardSession(i32), SelectSession(i32, i32), RequestDeleteSession(i32), ConfirmDeleteSession, OpenSessionMenu(i32), StartRenameSession(i32), SessionTitleChanged(String), ConfirmRenameSession, RetitleSession(i32), CompactSession(i32), RebuildSessionContext(i32), SetSessionState(i32, SessionState), ToggleArchivedSessions(i32), ShowChat, ShowA2ui, ShowGit, ShowStats, ToggleSidebar, StartSidebarDrag, DragSidebar(f32), EndSidebarDrag, MetricsTick, DiscardCacheEntry(PathBuf), ClearTransientCache, } impl App { pub(crate) fn load(main_window: window::Id) -> Self { let config = match Config::load(&config_path()) { Ok(config) => config, Err(error) => return Self::failed(error, main_window), }; let path = application_support_path().join("data.sqlite3"); match Database::open(&path) { Ok(mut database) => match database.load_projects() { Ok(projects) => { let preference_draft = PreferenceDraft::from_saved(&config); // Before the counters are taken, so they measure what is // actually reachable. sweep_orphan_checkpoints(&kv_cache_path(), &projects); #[cfg(target_os = "macos")] crate::engine::sweep_transient_cache(&transient_cache_path()); let context_limit = config.generation.context_tokens.max(0) as u32; // Reopen on the project we left, with a fresh draft chat. let last_project = config .interface .last_project_id .filter(|id| projects.iter().any(|item| item.project.id == *id)); let drafts = last_project .map(|id| (id, projects::draft_title(&projects, id))) .into_iter() .collect(); let metrics = Arc::new(Metrics::new(&application_support_path().join("kv-cache"))); let metrics_snapshot = metrics.snapshot(); let git_diff_layout = config.git.diff_layout; #[cfg(target_os = "macos")] let (runtime_config, generation_service, endpoint, service_error) = spawn_services(&config, Arc::clone(&metrics)); Self { main_window, preferences_window: None, model_manager_window: None, help_window: None, pending_model_delete: None, #[cfg(target_os = "macos")] _native_menu: None, #[cfg(target_os = "macos")] native_edit_commands: crate::native_edit::command_queue(), #[cfg(target_os = "macos")] native_edit_availability: crate::native_edit::availability_state(), database: Some(database), projects, config, preference_draft, preference_error: None, restore_dev_brain_confirmation: false, selected_project: last_project, selected_session: None, drafts, git_states: HashMap::new(), git_worktrees: HashMap::new(), git_selected_project: None, git_selected_files: HashSet::new(), git_commit_message: String::new(), git_diff: None, git_diff_layout, git_commit_all_confirmation: false, git_operation: None, last_git_scan: Instant::now() - GIT_SCAN_INTERVAL, #[cfg(target_os = "macos")] background_chats: HashMap::new(), session_menu: None, pending_session_delete: None, session_rename: None, expanded_archives: HashSet::new(), sidebar_drag: false, choosing_folder: false, pending_project_path: None, project_name_input: String::new(), model_download: ModelDownload::Idle, composer: text_editor::Content::new(), queued_inputs: VecDeque::new(), conversation: Vec::new(), chat_follow_tail: true, active_turn: None, a2ui: crate::a2ui::Store::default(), a2ui_history: Vec::new(), a2ui_history_index: None, a2ui_tabs: HashMap::new(), a2ui_modals: HashSet::new(), a2ui_editors: HashMap::new(), a2ui_markdown: HashMap::new(), help_markdown: markdown::Content::parse(include_str!( "../docs/USER_GUIDE.md" )), a2ui_choice_filters: HashMap::new(), a2ui_images: HashMap::new(), a2ui_image_requests: HashSet::new(), a2ui_image_loading: false, pending_a2ui_dismissal: None, a2ui_auto_switch_pending: false, generating: false, context_used: 0, context_limit, tokens_per_second: None, detail_tab: DetailTab::Chat, metrics, metrics_snapshot, metrics_history: VecDeque::with_capacity(120), kv_cache_report: KvCacheReport::default(), last_cache_scan: Instant::now() - CACHE_SCAN_INTERVAL, last_http_requests: 0, last_ssd_selected_requests: 0, last_ssd_requested_bytes: 0, last_ssd_wait_ms: 0, #[cfg(target_os = "macos")] generation_service, #[cfg(target_os = "macos")] active_generation: None, #[cfg(target_os = "macos")] active_compaction: None, #[cfg(target_os = "macos")] active_tool_check: None, #[cfg(target_os = "macos")] agent_tools: None, #[cfg(target_os = "macos")] active_tools: None, #[cfg(target_os = "macos")] tool_cards: Vec::new(), #[cfg(target_os = "macos")] pending_tool_approval: None, #[cfg(target_os = "macos")] active_titling: None, #[cfg(target_os = "macos")] runtime_config, #[cfg(target_os = "macos")] _endpoint: endpoint, error: { #[cfg(target_os = "macos")] { service_error } #[cfg(not(target_os = "macos"))] { None } }, activity: None, context_notice: None, stop_requested: false, system_prompt_seen_at: 0, manual_compaction_queued: false, #[cfg(target_os = "macos")] skip_compaction_once: false, quit_confirmation: false, } } Err(error) => Self::failed( format!("Could not open the project database: {error}"), main_window, ), }, Err(error) => Self::failed( format!("Could not open the project database: {error}"), main_window, ), } } fn failed(error: String, main_window: window::Id) -> Self { let config = Config::default(); let preference_draft = PreferenceDraft::from_saved(&config); let context_limit = config.generation.context_tokens.max(0) as u32; let metrics = Arc::new(Metrics::new(&application_support_path().join("kv-cache"))); let metrics_snapshot = metrics.snapshot(); let git_diff_layout = config.git.diff_layout; #[cfg(target_os = "macos")] let (runtime_config, generation_service, endpoint, service_error) = spawn_services(&config, Arc::clone(&metrics)); #[cfg(target_os = "macos")] let startup_error = service_error; #[cfg(not(target_os = "macos"))] let startup_error = None::; Self { main_window, preferences_window: None, model_manager_window: None, help_window: None, pending_model_delete: None, #[cfg(target_os = "macos")] _native_menu: None, #[cfg(target_os = "macos")] native_edit_commands: crate::native_edit::command_queue(), #[cfg(target_os = "macos")] native_edit_availability: crate::native_edit::availability_state(), database: None, projects: Vec::new(), config, preference_draft, preference_error: None, restore_dev_brain_confirmation: false, selected_project: None, selected_session: None, drafts: HashMap::new(), git_states: HashMap::new(), git_worktrees: HashMap::new(), git_selected_project: None, git_selected_files: HashSet::new(), git_commit_message: String::new(), git_diff: None, git_diff_layout, git_commit_all_confirmation: false, git_operation: None, last_git_scan: Instant::now() - GIT_SCAN_INTERVAL, #[cfg(target_os = "macos")] background_chats: HashMap::new(), session_menu: None, pending_session_delete: None, session_rename: None, expanded_archives: HashSet::new(), sidebar_drag: false, choosing_folder: false, pending_project_path: None, project_name_input: String::new(), model_download: ModelDownload::Idle, composer: text_editor::Content::new(), queued_inputs: VecDeque::new(), conversation: Vec::new(), chat_follow_tail: true, active_turn: None, a2ui: crate::a2ui::Store::default(), a2ui_history: Vec::new(), a2ui_history_index: None, a2ui_tabs: HashMap::new(), a2ui_modals: HashSet::new(), a2ui_editors: HashMap::new(), a2ui_markdown: HashMap::new(), help_markdown: markdown::Content::parse(include_str!("../docs/USER_GUIDE.md")), a2ui_choice_filters: HashMap::new(), a2ui_images: HashMap::new(), a2ui_image_requests: HashSet::new(), a2ui_image_loading: false, pending_a2ui_dismissal: None, a2ui_auto_switch_pending: false, generating: false, context_used: 0, context_limit, tokens_per_second: None, detail_tab: DetailTab::Chat, metrics, metrics_snapshot, metrics_history: VecDeque::with_capacity(120), kv_cache_report: KvCacheReport::default(), last_cache_scan: Instant::now() - CACHE_SCAN_INTERVAL, last_http_requests: 0, last_ssd_selected_requests: 0, last_ssd_requested_bytes: 0, last_ssd_wait_ms: 0, #[cfg(target_os = "macos")] generation_service, #[cfg(target_os = "macos")] active_generation: None, #[cfg(target_os = "macos")] active_compaction: None, #[cfg(target_os = "macos")] active_tool_check: None, #[cfg(target_os = "macos")] agent_tools: None, #[cfg(target_os = "macos")] active_tools: None, #[cfg(target_os = "macos")] tool_cards: Vec::new(), #[cfg(target_os = "macos")] pending_tool_approval: None, #[cfg(target_os = "macos")] active_titling: None, #[cfg(target_os = "macos")] runtime_config, #[cfg(target_os = "macos")] _endpoint: endpoint, error: Some(match startup_error { Some(service_error) => format!("{error} {service_error}"), None => error, }), activity: None, context_notice: None, stop_requested: false, system_prompt_seen_at: 0, manual_compaction_queued: false, #[cfg(target_os = "macos")] skip_compaction_once: false, quit_confirmation: false, } } #[cfg(target_os = "macos")] fn take_chat_snapshot(&mut self) -> ChatSnapshot { ChatSnapshot { selected_project: self.selected_project.take(), selected_session: self.selected_session.take(), composer: std::mem::take(&mut self.composer), queued_inputs: std::mem::take(&mut self.queued_inputs), conversation: std::mem::take(&mut self.conversation), chat_follow_tail: std::mem::replace(&mut self.chat_follow_tail, true), active_turn: self.active_turn.take(), a2ui: std::mem::take(&mut self.a2ui), a2ui_history: std::mem::take(&mut self.a2ui_history), a2ui_history_index: self.a2ui_history_index.take(), a2ui_tabs: std::mem::take(&mut self.a2ui_tabs), a2ui_modals: std::mem::take(&mut self.a2ui_modals), a2ui_editors: std::mem::take(&mut self.a2ui_editors), a2ui_markdown: std::mem::take(&mut self.a2ui_markdown), a2ui_choice_filters: std::mem::take(&mut self.a2ui_choice_filters), a2ui_images: std::mem::take(&mut self.a2ui_images), a2ui_image_requests: std::mem::take(&mut self.a2ui_image_requests), a2ui_image_loading: std::mem::take(&mut self.a2ui_image_loading), pending_a2ui_dismissal: self.pending_a2ui_dismissal.take(), a2ui_auto_switch_pending: std::mem::take(&mut self.a2ui_auto_switch_pending), generating: std::mem::take(&mut self.generating), context_used: std::mem::take(&mut self.context_used), context_limit: std::mem::replace( &mut self.context_limit, self.config.generation.context_tokens.max(0) as u32, ), tokens_per_second: self.tokens_per_second.take(), detail_tab: std::mem::take(&mut self.detail_tab), active_generation: self.active_generation.take(), active_compaction: self.active_compaction.take(), active_tool_check: self.active_tool_check.take(), agent_tools: self.agent_tools.take(), active_tools: self.active_tools.take(), tool_cards: std::mem::take(&mut self.tool_cards), pending_tool_approval: self.pending_tool_approval.take(), active_titling: self.active_titling.take(), error: self.error.take(), activity: self.activity.take(), context_notice: self.context_notice.take(), stop_requested: std::mem::take(&mut self.stop_requested), system_prompt_seen_at: std::mem::take(&mut self.system_prompt_seen_at), manual_compaction_queued: std::mem::take(&mut self.manual_compaction_queued), skip_compaction_once: std::mem::take(&mut self.skip_compaction_once), } } #[cfg(target_os = "macos")] fn restore_chat_snapshot(&mut self, snapshot: ChatSnapshot) { self.selected_project = snapshot.selected_project; self.selected_session = snapshot.selected_session; self.composer = snapshot.composer; self.queued_inputs = snapshot.queued_inputs; self.conversation = snapshot.conversation; self.chat_follow_tail = snapshot.chat_follow_tail; self.active_turn = snapshot.active_turn; self.a2ui = snapshot.a2ui; self.a2ui_history = snapshot.a2ui_history; self.a2ui_history_index = snapshot.a2ui_history_index; self.a2ui_tabs = snapshot.a2ui_tabs; self.a2ui_modals = snapshot.a2ui_modals; self.a2ui_editors = snapshot.a2ui_editors; self.a2ui_markdown = snapshot.a2ui_markdown; self.a2ui_choice_filters = snapshot.a2ui_choice_filters; self.a2ui_images = snapshot.a2ui_images; self.a2ui_image_requests = snapshot.a2ui_image_requests; self.a2ui_image_loading = snapshot.a2ui_image_loading; self.pending_a2ui_dismissal = snapshot.pending_a2ui_dismissal; self.a2ui_auto_switch_pending = snapshot.a2ui_auto_switch_pending; self.generating = snapshot.generating; self.context_used = snapshot.context_used; self.context_limit = snapshot.context_limit; self.tokens_per_second = snapshot.tokens_per_second; self.detail_tab = snapshot.detail_tab; self.active_generation = snapshot.active_generation; self.active_compaction = snapshot.active_compaction; self.active_tool_check = snapshot.active_tool_check; self.agent_tools = snapshot.agent_tools; self.active_tools = snapshot.active_tools; self.tool_cards = snapshot.tool_cards; self.pending_tool_approval = snapshot.pending_tool_approval; self.active_titling = snapshot.active_titling; self.error = snapshot.error; self.activity = snapshot.activity; self.context_notice = snapshot.context_notice; self.stop_requested = snapshot.stop_requested; self.system_prompt_seen_at = snapshot.system_prompt_seen_at; self.manual_compaction_queued = snapshot.manual_compaction_queued; self.skip_compaction_once = snapshot.skip_compaction_once; } #[cfg(target_os = "macos")] pub(super) fn leave_current_chat(&mut self) { let snapshot = self.take_chat_snapshot(); if let Some(session_id) = snapshot.selected_session && (snapshot.needs_poll() || snapshot.a2ui_image_loading) { self.background_chats.insert(session_id, snapshot); } } #[cfg(target_os = "macos")] fn restore_background_chat(&mut self, session_id: i32) -> bool { let Some(mut snapshot) = self.background_chats.remove(&session_id) else { return false; }; snapshot.chat_follow_tail = true; self.restore_chat_snapshot(snapshot); true } #[cfg(target_os = "macos")] fn poll_background_chats(&mut self) { let session_ids = self .background_chats .iter() .filter_map(|(session_id, chat)| chat.needs_poll().then_some(*session_id)) .collect::>(); for session_id in session_ids { let foreground = self.take_chat_snapshot(); let Some(background) = self.background_chats.remove(&session_id) else { self.restore_chat_snapshot(foreground); continue; }; self.restore_chat_snapshot(background); self.poll_titling(); self.poll_generation(); let background = self.take_chat_snapshot(); self.background_chats.insert(session_id, background); self.restore_chat_snapshot(foreground); } } #[cfg(target_os = "macos")] pub(super) fn session_is_active(&self, session_id: i32) -> bool { (self.selected_session == Some(session_id) && self.generating) || self .background_chats .get(&session_id) .is_some_and(|chat| chat.generating) } #[cfg(not(target_os = "macos"))] pub(super) fn session_is_active(&self, _session_id: i32) -> bool { false } fn active_chat_count(&self) -> usize { #[cfg(target_os = "macos")] return active_chat_total( self.generating, self.background_chats.values().map(|chat| chat.generating), ); #[cfg(not(target_os = "macos"))] active_chat_total(self.generating, std::iter::empty()) } fn project_has_active_chat(&self, project_id: i32) -> bool { self.projects .iter() .find(|item| item.project.id == project_id) .is_some_and(|item| { item.sessions .iter() .any(|session| self.session_is_active(session.id)) }) } pub(crate) fn update(&mut self, message: Message) -> Task { match message { Message::Noop => { #[cfg(target_os = "macos")] self.sync_native_menu(); } #[cfg(target_os = "macos")] Message::NativeEdit(command) => { crate::native_edit::queue_command(&self.native_edit_commands, command) } Message::OpenPreferences => return self.open_preferences(), Message::PreferencesOpened(id) => { if self.preferences_window == Some(id) { return window::gain_focus(id); } } Message::ClosePreferences => { if let Some(id) = self.preferences_window { self.preference_error = None; self.restore_dev_brain_confirmation = false; return window::close(id); } } Message::ScrollPreferences(section) => return scroll_preferences_to(section), Message::OpenModelManager => return self.open_model_manager(), Message::OpenHelp => return self.open_help(), Message::HelpOpened(id) => { if self.help_window == Some(id) { return window::gain_focus(id); } } Message::NewChat => { if let Some(project_id) = self.selected_project { self.create_session(project_id); return focus_composer(); } } Message::ExportChat => { let Some(title) = self.active_chat_title().map(str::to_owned) else { return Task::none(); }; let file_name = format!("{}.md", export_file_stem(&title)); let content = export_markdown(&title, &self.conversation); return Task::perform( async move { AsyncFileDialog::new() .set_title("Export chat as Markdown") .set_file_name(&file_name) .add_filter("Markdown", &["md"]) .save_file() .await .map(|file| file.path().to_path_buf()) }, move |path| Message::ExportChatPicked(path, content), ); } Message::ExportChatPicked(path, content) => { if let Some(path) = path && let Err(error) = fs::write(&path, content) { self.error = Some(format!("Could not export the chat: {error}")); } } Message::ModelManagerOpened(id) => { if self.model_manager_window == Some(id) { return window::gain_focus(id); } } Message::WindowOpened(id) => { if id == self.main_window { self.refresh_git_state(); } #[cfg(target_os = "macos")] if id == self.main_window && self._native_menu.is_none() { match crate::native_menu::install() { Ok(menu) => self._native_menu = Some(menu), Err(error) => { self.error = Some(format!("Could not install the application menu: {error}")) } } } #[cfg(target_os = "macos")] self.sync_native_menu(); if id == self.main_window && self.selected_project.is_some() { return focus_composer(); } } Message::WindowCloseRequested(id) => { if id == self.main_window { return self.update(Message::RequestQuit); } return window::close(id); } Message::WindowClosed(id) => { if self.model_manager_window == Some(id) { self.model_manager_window = None; self.pending_model_delete = None; } if self.preferences_window == Some(id) { self.preferences_window = None; self.preference_error = None; self.restore_dev_brain_confirmation = false; } if self.help_window == Some(id) { self.help_window = None; } } Message::RequestQuit => { if self.active_chat_count() == 0 { return iced::exit(); } self.quit_confirmation = true; } Message::ConfirmQuit => return iced::exit(), Message::CancelQuit => self.quit_confirmation = false, Message::Escape(id) => { if self.preferences_window == Some(id) { if self.restore_dev_brain_confirmation { self.restore_dev_brain_confirmation = false; return Task::none(); } return self.update(Message::ClosePreferences); } if id == self.main_window { return self.update(Message::DismissPanel); } } Message::DismissPanel => { if self.quit_confirmation { self.quit_confirmation = false; } else if self.git_diff.is_some() { self.git_diff = None; } else if self.git_commit_all_confirmation { self.git_commit_all_confirmation = false; } else if self.pending_session_delete.is_some() { self.pending_session_delete = None; } else if self.pending_a2ui_dismissal.is_some() { self.pending_a2ui_dismissal = None; } else if self.session_rename.is_some() || self.session_menu.is_some() { self.session_rename = None; self.session_menu = None; } else if self.pending_project_path.is_some() { self.pending_project_path = None; self.project_name_input.clear(); self.error = None; } } Message::FocusNext => { return iced::widget::operation::focus_next().chain(reveal_focused()); } Message::FocusPrevious => { return iced::widget::operation::focus_previous().chain(reveal_focused()); } Message::ShowChat => { self.detail_tab = DetailTab::Chat; self.chat_follow_tail = true; return scroll_chat_to_end(); } Message::ShowA2ui => self.detail_tab = DetailTab::A2ui, Message::ShowGit => { self.detail_tab = DetailTab::Git; if let Some(project_id) = self.selected_project { self.refresh_git_state(); self.refresh_git_worktree(project_id); } } Message::ShowStats => { self.detail_tab = DetailTab::Stats; self.scan_kv_cache(); } Message::DiscardCacheEntry(path) => self.discard_cache_entry(&path), Message::ClearTransientCache => self.clear_transient_cache(), // The sidebar starts at the window's left edge, so the cursor's x is // the width the user is asking for. Only the final width is stored. Message::StartSidebarDrag => self.sidebar_drag = true, Message::DragSidebar(x) => { if self.sidebar_drag { self.config.interface.sidebar_width = (x.round() as i32).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); } } Message::EndSidebarDrag => { if self.sidebar_drag { self.sidebar_drag = false; self.store_config(); } } Message::ToggleSidebar => { self.config.interface.sidebar_collapsed = !self.config.interface.sidebar_collapsed; self.store_config(); #[cfg(target_os = "macos")] self.sync_native_menu(); } Message::MetricsTick => self.sample_metrics(), Message::PreferenceModelChanged(model) => { self.preference_draft.model = model; if !model.supports_dspark() { self.preference_draft.legacy_mtp_enabled = false; self.preference_draft.dspark_enabled = false; self.preference_draft.dspark_confidence_threshold.clear(); self.preference_draft.dspark_strict = false; } if model == ModelChoice::Glm52 { self.preference_draft.power_percent.clear(); self.preference_draft.prefill_chunk.clear(); self.preference_draft.directional_steering_file.clear(); self.preference_draft.directional_steering_ffn.clear(); self.preference_draft.directional_steering_attn.clear(); } else { self.preference_draft.glm_mtp = false; self.preference_draft.glm_mtp_timing = false; self.preference_draft.ssd_full_layers.clear(); } self.preference_error = None; } Message::PreferenceLegacyMtpChanged(enabled) => { self.preference_draft.legacy_mtp_enabled = self.preference_draft.model.supports_dspark() && enabled; if self.preference_draft.legacy_mtp_enabled { self.preference_draft.dspark_enabled = false; self.preference_draft.dspark_confidence_threshold.clear(); self.preference_draft.dspark_strict = false; } self.preference_error = None; } Message::PreferenceDsparkChanged(enabled) => { self.preference_draft.dspark_enabled = self.preference_draft.model.supports_dspark() && enabled; if !self.preference_draft.dspark_enabled { self.preference_draft.dspark_confidence_threshold.clear(); self.preference_draft.dspark_strict = false; } else { self.preference_draft.legacy_mtp_enabled = false; } self.preference_error = None; } Message::PreferenceTimeoutChanged(value) => { self.preference_draft.idle_timeout_minutes = value; self.preference_error = None; } Message::PreferenceA2uiChanged(enabled) => { self.preference_draft.a2ui_enabled = enabled; self.preference_error = None; } Message::PreferenceEndpointPortChanged(value) => { self.preference_draft.endpoint_port = value; self.preference_error = None; } Message::PreferenceEndpointEnabledChanged(enabled) => { self.preference_draft.endpoint_enabled = enabled; self.preference_error = None; } Message::PreferenceEndpointCorsChanged(enabled) => { self.preference_draft.endpoint_cors = enabled; self.preference_error = None; } Message::PreferenceDevBrainEnabledChanged(enabled) => { self.preference_draft.dev_brain_enabled = enabled; self.preference_error = None; } Message::PreferenceDevBrainVaultChanged(value) => { self.preference_draft.dev_brain_vault_path = value; self.preference_error = None; } Message::PreferenceGitDiffLayoutChanged(layout) => { self.preference_draft.git_diff_layout = layout; self.preference_error = None; } Message::PreferenceGitDiffAlgorithmChanged(algorithm) => { self.preference_draft.git_diff_algorithm = algorithm; self.preference_error = None; } Message::PreferenceGitContextLinesChanged(value) => { self.preference_draft.git_context_lines = value; self.preference_error = None; } Message::PreferenceGitInterhunkLinesChanged(value) => { self.preference_draft.git_interhunk_lines = value; self.preference_error = None; } Message::PreferenceGitIndentHeuristicChanged(enabled) => { self.preference_draft.git_indent_heuristic = enabled; self.preference_error = None; } Message::PreferenceGitWhitespaceChanged(whitespace) => { self.preference_draft.git_whitespace = whitespace; self.preference_error = None; } Message::PreferenceGitIgnoreBlankLinesChanged(enabled) => { self.preference_draft.git_ignore_blank_lines = enabled; self.preference_error = None; } Message::ChooseDevBrainVault => { return Task::perform( async { AsyncFileDialog::new() .set_title("Choose an Obsidian vault") .pick_folder() .await .map(|folder| folder.path().to_path_buf()) }, Message::DevBrainVaultPicked, ); } Message::DevBrainVaultPicked(path) => { if let Some(path) = path { self.preference_draft.dev_brain_vault_path = path.to_string_lossy().into_owned(); self.preference_error = None; } } Message::RestoreDevBrainDefaultGuides => { self.preference_error = None; self.restore_dev_brain_confirmation = true; } Message::ConfirmRestoreDevBrainDefaultGuides => { self.restore_dev_brain_confirmation = false; self.preference_error = crate::dev_brain::restore_default_guides(Path::new( &self.preference_draft.dev_brain_vault_path, )) .err(); } Message::CancelRestoreDevBrainDefaultGuides => { self.restore_dev_brain_confirmation = false; } Message::PreferenceContextChanged(value) => { self.preference_draft.context_tokens = value; self.preference_error = None; } Message::PreferenceMaxTokensChanged(value) => { self.preference_draft.max_generated_tokens = value; self.preference_error = None; } Message::PreferenceSystemPromptAction(action) => { self.preference_draft.system_prompt.perform(action); self.preference_error = None; } Message::PreferenceTemperatureChanged(value) => { self.preference_draft.temperature = value; self.preference_error = None; } Message::PreferenceTopPChanged(value) => { self.preference_draft.top_p = value; self.preference_error = None; } Message::PreferenceMinPChanged(value) => { self.preference_draft.min_p = value; self.preference_error = None; } Message::PreferenceSeedChanged(value) => { self.preference_draft.seed = value; self.preference_error = None; } Message::PreferenceReasoningChanged(value) => { self.preference_draft.reasoning_mode = value; self.preference_error = None; } Message::PreferenceCpuThreadsChanged(value) => { self.preference_draft.cpu_threads = value; self.preference_error = None; } Message::PreferencePowerChanged(value) => { self.preference_draft.power_percent = value; self.preference_error = None; } Message::PreferencePrefillChunkChanged(value) => { self.preference_draft.prefill_chunk = value; self.preference_error = None; } Message::PreferenceQualityChanged(value) => { self.preference_draft.quality = value; self.preference_error = None; } Message::PreferenceWarmWeightsChanged(value) => { self.preference_draft.warm_weights = value; self.preference_error = None; } Message::PreferenceMtpDraftChanged(value) => { self.preference_draft.mtp_draft_tokens = value; self.preference_error = None; } Message::PreferenceMtpMarginChanged(value) => { self.preference_draft.mtp_margin = value; self.preference_error = None; } Message::PreferenceGlmMtpChanged(value) => { self.preference_draft.glm_mtp = self.preference_draft.model == ModelChoice::Glm52 && value; if !self.preference_draft.glm_mtp { self.preference_draft.glm_mtp_timing = false; } self.preference_error = None; } Message::PreferenceGlmMtpTimingChanged(value) => { self.preference_draft.glm_mtp_timing = self.preference_draft.model == ModelChoice::Glm52 && value; if self.preference_draft.glm_mtp_timing { self.preference_draft.glm_mtp = true; } self.preference_error = None; } Message::PreferenceDsparkConfidenceChanged(value) => { self.preference_draft.dspark_confidence_threshold = value; if self.preference_draft.model.supports_dspark() && !self .preference_draft .dspark_confidence_threshold .trim() .is_empty() { self.preference_draft.dspark_enabled = true; self.preference_draft.legacy_mtp_enabled = false; } self.preference_error = None; } Message::PreferenceDsparkStrictChanged(value) => { self.preference_draft.dspark_strict = self.preference_draft.model.supports_dspark() && value; if self.preference_draft.dspark_strict { self.preference_draft.dspark_enabled = true; self.preference_draft.legacy_mtp_enabled = false; } self.preference_error = None; } Message::PreferenceSsdChanged(value) => { self.preference_draft.ssd_streaming = value; self.preference_error = None; } Message::PreferenceSsdColdChanged(value) => { self.preference_draft.ssd_streaming_cold = value; self.preference_error = None; } Message::PreferenceSsdCacheChanged(value) => { self.preference_draft.ssd_cache = value; self.preference_error = None; } Message::PreferenceSsdFullLayersChanged(value) => { if self.preference_draft.model == ModelChoice::Glm52 { self.preference_draft.ssd_full_layers = value; } self.preference_error = None; } Message::PreferenceSsdPreloadChanged(value) => { self.preference_draft.ssd_preload_experts = value; self.preference_error = None; } Message::PreferenceSteeringFileChanged(value) => { if self.preference_draft.model != ModelChoice::Glm52 { self.preference_draft.directional_steering_file = value; } self.preference_error = None; } Message::PreferenceSteeringFfnChanged(value) => { if self.preference_draft.model != ModelChoice::Glm52 { self.preference_draft.directional_steering_ffn = value; } self.preference_error = None; } Message::PreferenceSteeringAttnChanged(value) => { if self.preference_draft.model != ModelChoice::Glm52 { self.preference_draft.directional_steering_attn = value; } self.preference_error = None; } Message::PreferenceSimulatedMemoryChanged(value) => { self.preference_draft.simulated_used_memory_gib = value; self.preference_error = None; } Message::PreferenceExpertProfileChanged(value) => { self.preference_draft.expert_profile_path = value; self.preference_error = None; } Message::PreferenceKvBudgetChanged(value) => { self.preference_draft.kv_budget_gib = value; self.preference_error = None; } Message::PreferenceKvMinTokensChanged(value) => { self.preference_draft.kv_min_tokens = value; self.preference_error = None; } Message::PreferenceKvColdMaxChanged(value) => { self.preference_draft.kv_cold_max_tokens = value; self.preference_error = None; } Message::PreferenceKvContinuedIntervalChanged(value) => { self.preference_draft.kv_continued_interval_tokens = value; self.preference_error = None; } Message::ResetPreferences => { self.preference_draft.reset(); self.preference_error = None; } Message::SavePreferences => { self.save_preferences(); if self.preference_error.is_none() && let Some(id) = self.preferences_window { return window::close(id); } } Message::DownloadArtifact(artifact) => { self.start_model_operation(artifact, ModelOperation::Download) } Message::ValidateArtifact(artifact) => { self.start_model_operation(artifact, ModelOperation::Validate) } Message::DeleteArtifact(artifact) => self.pending_model_delete = Some(artifact), Message::ConfirmDeleteArtifact => { if let Some(artifact) = self.pending_model_delete.take() { match model::delete_managed_artifact(artifact, &models_path()) { Ok(()) => { self.model_download = ModelDownload::Idle; self.error = None; } Err(error) => { self.error = Some(format!("Could not delete {artifact}: {error}")) } } } } Message::CancelDeleteArtifact => self.pending_model_delete = None, Message::StopModelDownload => { if let ModelDownload::Active(download) = &mut self.model_download { download.stopping = true; download.cancel.store(true, Ordering::Relaxed); } } Message::DownloadProgressTick => self.update_download_progress(), Message::ComposerAction(action) => self.composer.perform(action), Message::TranscriptAction(index, action) => { if !action.is_edit() && let Some(message) = self.conversation.get_mut(index) { message.transcript.perform(action); } } Message::A2uiDataChanged(surface_id, path, value) => { if self.a2ui_history_index.is_some() { return Task::none(); } return self.change_a2ui_data(surface_id, path, value); } Message::A2uiEditorAction(surface_id, component_id, path, action) => { if self.a2ui_history_index.is_some() { return Task::none(); } let key = (surface_id.clone(), component_id, path.clone()); let Some(editor) = self.a2ui_editors.get_mut(&key) else { return Task::none(); }; editor.perform(action); let value = serde_json::Value::String(editor.text()); return self.change_a2ui_data(surface_id, path, value); } Message::A2uiChoiceFilterChanged(surface_id, component_id, context_path, value) => { self.a2ui_choice_filters .insert((surface_id, component_id, context_path), value); } Message::A2uiAction(surface_id, component_id, context_path) => { if self.a2ui_history_index.is_some() { return Task::none(); } match self .a2ui .action(&surface_id, &component_id, context_path.as_deref()) { Ok(action) => { self.composer = text_editor::Content::with_text(&format!( "A2UI client event:\n{}\nA2UI client metadata:\n{}", serde_json::to_string(&action).unwrap_or_default(), self.a2ui.client_metadata() )); self.chat_follow_tail = true; self.start_generation(); return scroll_chat_to_end(); } Err(error) => self.error = Some(error), } } Message::A2uiSelectTab(surface_id, component_id, index) => { self.a2ui_tabs.insert((surface_id, component_id), index); } Message::A2uiToggleModal(surface_id, component_id) => { let key = (surface_id, component_id); if !self.a2ui_modals.remove(&key) { self.a2ui_modals.insert(key); } } Message::A2uiImageLoaded(session_id, url, result) => { if session_id != self.selected_session { #[cfg(target_os = "macos")] { let foreground = self.take_chat_snapshot(); if let Some(background) = session_id .and_then(|session_id| self.background_chats.remove(&session_id)) { self.restore_chat_snapshot(background); let task = self.update(Message::A2uiImageLoaded(session_id, url, result)); let background = self.take_chat_snapshot(); self.background_chats.insert( session_id.expect("background chat has a session"), background, ); self.restore_chat_snapshot(foreground); return task; } self.restore_chat_snapshot(foreground); } return Task::none(); } self.a2ui_image_loading = false; match result { Ok(bytes) => { self.a2ui_images .insert(url, iced::widget::image::Handle::from_bytes(bytes)); } Err(error) => { self.error = Some(format!("Could not load an A2UI image: {error}")) } } return self.load_next_a2ui_image(); } Message::A2uiPlayMedia(url, title, video) => { #[cfg(target_os = "macos")] if let Err(error) = crate::native_media::open(&url, &title, video) { self.error = Some(format!("Could not play media: {error}")); } #[cfg(not(target_os = "macos"))] { let _ = (title, video); if let Err(error) = std::process::Command::new("open").arg(url).spawn() { self.error = Some(format!("Could not open media: {error}")); } } } Message::RequestA2uiDismiss(surface_id) => { if self.generating { self.error = Some( "Stop the active generation before dismissing its A2UI surface.".into(), ); } else if self.a2ui_history_index.is_none() && self.a2ui.surface(&surface_id).is_some() { self.pending_a2ui_dismissal = Some(surface_id); } } Message::ConfirmA2uiDismiss => { let Some(surface_id) = self.pending_a2ui_dismissal.clone() else { return Task::none(); }; let Some(session_id) = self.selected_session else { self.error = Some("The A2UI surface is not attached to a saved chat.".into()); return Task::none(); }; let Some(database) = &mut self.database else { self.error = Some("The project database is unavailable.".into()); return Task::none(); }; match database.dismiss_a2ui_surface(session_id, &surface_id) { Ok(message) => { self.conversation.push(ChatMessage::from(message)); self.a2ui_history.push(self.a2ui.clone()); self.a2ui.clear(); self.a2ui_history_index = None; self.pending_a2ui_dismissal = None; self.a2ui_tabs.clear(); self.a2ui_modals.clear(); self.sync_a2ui_renderer_state(); self.error = None; } Err(error) => { self.error = Some(format!("Could not dismiss A2UI surface: {error}")); } } } Message::A2uiPreviousSurface => { if !self.a2ui_history.is_empty() { self.a2ui_history_index = Some( self.a2ui_history_index .map_or(self.a2ui_history.len() - 1, |index| index.saturating_sub(1)), ); self.a2ui_tabs.clear(); self.a2ui_modals.clear(); self.sync_a2ui_renderer_state(); return self.load_next_a2ui_image(); } } Message::A2uiNextSurface => { if let Some(index) = self.a2ui_history_index { self.a2ui_history_index = (index + 1 < self.a2ui_history.len()).then_some(index + 1); self.a2ui_tabs.clear(); self.a2ui_modals.clear(); self.sync_a2ui_renderer_state(); return self.load_next_a2ui_image(); } } Message::ToggleReasoning(index) => { if let Some(message) = self.conversation.get_mut(index) && message.reasoning.is_some() { message.reasoning_open = !message.reasoning_open; } } Message::OpenLink(uri) => { if let Ok(url) = url::Url::parse(&uri) && matches!(url.scheme(), "http" | "https") && let Err(error) = std::process::Command::new("open").arg(url.as_str()).spawn() { self.error = Some(format!("Could not open the link: {error}")); } } Message::CopyToolText(value) => return iced::clipboard::write(value), Message::OpenToolOutput(path) => { if let Err(error) = std::process::Command::new("open").arg(path).spawn() { self.error = Some(format!("Could not open the tool output: {error}")); } } Message::AllowToolOnce => { #[cfg(target_os = "macos")] if let Some((_, decision)) = self.pending_tool_approval.take() { let _ = decision.send(true); } } Message::DenyTool => { #[cfg(target_os = "macos")] if let Some((_, decision)) = self.pending_tool_approval.take() { let _ = decision.send(false); } } Message::SubmitPrompt => { self.chat_follow_tail = true; self.start_generation(); return scroll_chat_to_end(); } Message::StopGeneration => { self.stop_requested = true; self.activity = Some("Stopping…".into()); #[cfg(target_os = "macos")] if let Some(active) = &self.active_generation { active.cancel.store(true, Ordering::Relaxed); } #[cfg(target_os = "macos")] if let Some(active) = &self.active_tools { active.cancel.store(true, Ordering::Relaxed); } #[cfg(target_os = "macos")] if let Some((_, decision)) = self.pending_tool_approval.take() { let _ = decision.send(false); } #[cfg(target_os = "macos")] self.stop_agent_jobs(); #[cfg(target_os = "macos")] if let Some(compaction) = &self.active_compaction { compaction.active.cancel.store(true, Ordering::Relaxed); } #[cfg(target_os = "macos")] if let Some(check) = &self.active_tool_check { check.active.cancel.store(true, Ordering::Relaxed); } } Message::GenerationTick => { #[cfg(target_os = "macos")] self.poll_titling(); self.poll_generation(); #[cfg(target_os = "macos")] self.poll_background_chats(); let images = self.load_next_a2ui_image(); return images; } Message::ChatScrolled(viewport) => { let offset = viewport.absolute_offset(); let reversed = viewport.absolute_offset_reversed(); let follow = chat_near_end(self.chat_follow_tail, offset.y, reversed.y); if follow != self.chat_follow_tail { self.chat_follow_tail = follow; return iced::widget::operation::scroll_to(chat_scroll_id(), reversed); } } Message::ChooseProjectFolder => { self.choosing_folder = true; return Task::perform( async { AsyncFileDialog::new() .set_title("Choose a project folder") .pick_folder() .await .map(|folder| folder.path().to_path_buf()) }, Message::ProjectFolderPicked, ); } Message::ProjectFolderPicked(path) => { self.choosing_folder = false; if let Some(path) = path { self.prepare_project(path); } } Message::ProjectNameChanged(value) => self.project_name_input = value, Message::ConfirmProject => self.create_project(), Message::CancelProject => { self.pending_project_path = None; self.project_name_input.clear(); self.error = None; } // Sidebar-only: collapsing a project leaves the open chat alone. Message::ToggleProject(project_id) => { let Some(item) = self .projects .iter_mut() .find(|item| item.project.id == project_id) else { return Task::none(); }; let collapsed = !item.project.collapsed; item.project.collapsed = collapsed; if let Some(database) = &mut self.database && let Err(error) = database.set_project_collapsed(project_id, collapsed) { self.error = Some(error); } } Message::DeleteProject(project_id) => { if self.project_has_active_chat(project_id) { self.error = Some("Stop the active generation before deleting its project.".into()); return Task::none(); } let checkpoint_ids = self .projects .iter() .find(|item| item.project.id == project_id) .map(|item| { item.sessions .iter() .map(|session| session.id) .collect::>() }) .unwrap_or_default(); for session_id in &checkpoint_ids { if let Err(error) = discard_session_checkpoint_files(&kv_cache_path(), *session_id) { self.error = Some(error); return Task::none(); } } self.finish_cache_change(); if let Some(database) = &mut self.database { match database.delete_project(project_id) { Ok(()) => { #[cfg(not(target_os = "macos"))] let _ = checkpoint_ids; #[cfg(target_os = "macos")] for session_id in checkpoint_ids { self.background_chats.remove(&session_id); } self.drafts.remove(&project_id); self.git_states.remove(&project_id); self.git_worktrees.remove(&project_id); if self.git_selected_project == Some(project_id) { self.git_selected_project = None; self.git_selected_files.clear(); } if self.config.interface.last_project_id == Some(project_id) { self.config.interface.last_project_id = None; let _ = self.config.save(&config_path()); } if self.selected_project == Some(project_id) { self.selected_project = None; self.selected_session = None; self.conversation.clear(); self.context_notice = None; self.clear_a2ui(); self.composer = text_editor::Content::new(); self.queued_inputs.clear(); self.system_prompt_seen_at = 0; self.context_used = 0; self.tokens_per_second = None; } self.reload_projects(); self.invalidate_dev_brain_context(); self.finish_cache_change(); } Err(error) => self.error = Some(error), } } } Message::CreateSession(project_id) => { self.create_session(project_id); if self.draft_selected(project_id) { return focus_composer(); } } Message::DraftProjectChanged(project_id) => self.move_draft_to_project(project_id), Message::SwitchGitBranch(branch) => self.switch_git_branch(&branch), Message::ToggleGitFile(path) => self.toggle_git_file(path), Message::OpenGitDiff(path) => self.open_git_diff(&path), Message::SetGitDiffLayout(layout) => self.git_diff_layout = layout, Message::GitDiffScrolled(viewport) => { let offset = scrollable::AbsoluteOffset { x: viewport.absolute_offset().x, y: 0.0, }; return Task::batch([ iced::widget::operation::scroll_to(git_diff_old_scroll_id(), offset), iced::widget::operation::scroll_to(git_diff_new_scroll_id(), offset), ]); } Message::GitCommitMessageChanged(message) => self.git_commit_message = message, Message::GitStageSelected => self.stage_selected_git_files(), Message::GitUnstageSelected => self.unstage_selected_git_files(), Message::GitCommit => self.commit_git(), Message::ConfirmGitCommitAll => self.confirm_commit_all_git(), Message::GitFetch => self.start_git_remote("fetch"), Message::GitPull => self.start_git_remote("pull"), Message::GitPush => self.start_git_remote("push"), Message::GitOperationTick => self.poll_git_operation(), Message::DiscardSession(project_id) => self.discard_session(project_id), Message::OpenSessionMenu(session_id) => { self.session_rename = None; self.session_menu = if self.session_menu == Some(session_id) { None } else { Some(session_id) }; } Message::StartRenameSession(session_id) => { let title = self .projects .iter() .flat_map(|project| &project.sessions) .find(|session| session.id == session_id) .map(|session| session.title.clone()) .unwrap_or_default(); self.session_menu = None; self.session_rename = Some((session_id, title)); } Message::SessionTitleChanged(title) => { if let Some((_, draft)) = &mut self.session_rename { *draft = title; } } Message::ConfirmRenameSession => { // Keep the dialog open on failure so the edit is not lost. let Some((session_id, title)) = self.session_rename.clone() else { return Task::none(); }; if let Some(database) = &mut self.database { match database.rename_session(session_id, &title) { Ok(()) => { self.session_rename = None; self.error = None; self.reload_projects(); } Err(error) => self.error = Some(error), } } } Message::RetitleSession(session_id) => { self.session_menu = None; #[cfg(target_os = "macos")] self.retitle_session(session_id); #[cfg(not(target_os = "macos"))] { let _ = session_id; self.error = Some("Local Metal generation requires macOS.".into()); } } Message::CompactSession(session_id) => { self.session_menu = None; if !self.can_compact_session(session_id) { self.error = Some("Open an idle session with new chat before compacting it.".into()); } else { #[cfg(target_os = "macos")] if let Err(error) = self.start_compaction( generation::PendingContinuation::None, "manual compact action", ) { self.error = Some(error); } #[cfg(not(target_os = "macos"))] { self.error = Some("Local Metal generation requires macOS.".into()); } } } Message::RebuildSessionContext(session_id) => { self.session_menu = None; if self.session_is_active(session_id) { self.error = Some("Stop the active generation before rebuilding context.".into()); } else { match discard_session_checkpoint_files(&kv_cache_path(), session_id) { Ok(_) => { self.error = None; self.finish_cache_change(); } Err(error) => { self.error = Some(error); } } } } Message::SetSessionState(session_id, state) => { self.session_menu = None; if state == SessionState::Archived { if self.session_is_active(session_id) { self.error = Some("Stop the active generation before archiving its session.".into()); return Task::none(); } if let Err(error) = discard_session_checkpoint_files(&kv_cache_path(), session_id) { self.error = Some(error); return Task::none(); } self.finish_cache_change(); } if let Some(database) = &mut self.database { match database.set_session_state(session_id, state) { Ok(()) => { self.error = None; self.reload_projects(); } Err(error) => self.error = Some(error), } } } Message::ToggleArchivedSessions(project_id) => { if !self.expanded_archives.remove(&project_id) { self.expanded_archives.insert(project_id); } } Message::SelectSession(project_id, session_id) => { if self.selected_session == Some(session_id) { return Task::none(); } #[cfg(target_os = "macos")] { self.leave_current_chat(); if self.restore_background_chat(session_id) { self.chat_follow_tail = true; self.remember_project(project_id); if let Some(database) = &mut self.database && let Err(error) = database.touch_session(session_id) { self.error = Some(format!("Could not update the session: {error}")); } self.reload_projects(); return Task::batch([scroll_chat_to_end(), self.load_next_a2ui_image()]); } } let saved_context = self .projects .iter() .flat_map(|project| &project.sessions) .find(|session| session.id == session_id) .map(|session| { ( session.context_used, session.context_limit, session.last_tokens_per_second, ) }); let Some(database) = &mut self.database else { return Task::none(); }; let loaded = database.load_messages(session_id).and_then(|messages| { database .load_a2ui_messages(session_id) .map(|a2ui| (messages, a2ui)) }); match loaded { Ok((messages, a2ui)) => { if let Err(error) = database.touch_session(session_id) { self.error = Some(format!("Could not update the session: {error}")); return Task::none(); } self.conversation = messages.into_iter().map(ChatMessage::from).collect(); self.chat_follow_tail = true; generation::promote_legacy_turn_summaries(&mut self.conversation); self.context_notice = None; self.clear_a2ui(); let (history, active, errors) = crate::a2ui::replay_epochs(a2ui.iter().map(|message| { ( message.id, message.message_id, message.dismissed, message.json.as_str(), ) })); self.a2ui_history = history; self.a2ui = active; let restore_error = (!errors.is_empty()).then(|| { format!("Could not restore some A2UI history: {}", errors.join("; ")) }); self.sync_a2ui_renderer_state(); self.composer = text_editor::Content::new(); self.remember_project(project_id); self.selected_session = Some(session_id); self.system_prompt_seen_at = 0; self.queued_inputs.clear(); let (used, limit, tokens_per_second) = saved_context.unwrap_or_default(); self.context_used = used.max(0) as u32; self.context_limit = if limit > 0 { limit as u32 } else { self.config.generation.context_tokens.max(0) as u32 }; self.tokens_per_second = tokens_per_second; self.error = restore_error; self.reload_projects(); return Task::batch([scroll_chat_to_end(), self.load_next_a2ui_image()]); } Err(error) => { self.error = Some(format!("Could not load the chat session: {error}")); } } } Message::RequestDeleteSession(session_id) => { if self.session_is_active(session_id) { self.error = Some("Stop the active generation before deleting its session.".into()); return Task::none(); } if self .projects .iter() .flat_map(|project| &project.sessions) .any(|session| session.id == session_id) { self.pending_session_delete = Some(session_id); self.session_menu = None; self.session_rename = None; self.error = None; } } Message::ConfirmDeleteSession => { let Some(session_id) = self.pending_session_delete.take() else { return Task::none(); }; if self.session_is_active(session_id) { self.error = Some("Stop the active generation before deleting its session.".into()); return Task::none(); } if let Err(error) = discard_session_checkpoint_files(&kv_cache_path(), session_id) { self.error = Some(error); return Task::none(); } self.finish_cache_change(); if let Some(database) = &mut self.database { match database.delete_session(session_id) { Ok(()) => { #[cfg(target_os = "macos")] self.background_chats.remove(&session_id); if self.session_menu == Some(session_id) { self.session_menu = None; } if self .session_rename .as_ref() .is_some_and(|(id, _)| *id == session_id) { self.session_rename = None; } if self.selected_session == Some(session_id) { self.selected_session = None; self.conversation.clear(); self.context_notice = None; self.clear_a2ui(); self.composer = text_editor::Content::new(); self.queued_inputs.clear(); self.system_prompt_seen_at = 0; self.context_used = 0; self.tokens_per_second = None; } self.reload_projects(); } Err(error) => self.error = Some(error), } } } } Task::none() } pub(crate) fn subscription(&self) -> Subscription { let mut subscriptions = vec![ keyboard::listen().filter_map(|event| match event { keyboard::Event::KeyPressed { key, modifiers, .. } => shortcut(key, modifiers), _ => None, }), // A focused text field takes escape for itself to drop its own // focus, so a dialog would never see it through `on_key_press`. iced::event::listen_with(|event, _, id| { matches!( event, iced::Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Escape), .. }) ) .then_some(Message::Escape(id)) }), window::close_requests().map(Message::WindowCloseRequested), window::close_events().map(Message::WindowClosed), ]; subscriptions .push(iced::time::every(METRICS_SAMPLE_INTERVAL).map(|_| Message::MetricsTick)); #[cfg(target_os = "macos")] subscriptions.push(iced::time::every(Duration::from_millis(50)).map(|_| { match crate::native_menu::next_event() { Some(crate::native_menu::NativeMenuEvent::Preferences) => Message::OpenPreferences, Some(crate::native_menu::NativeMenuEvent::ModelManager) => { Message::OpenModelManager } Some(crate::native_menu::NativeMenuEvent::NewChat) => Message::NewChat, Some(crate::native_menu::NativeMenuEvent::ExportChat) => Message::ExportChat, Some(crate::native_menu::NativeMenuEvent::ToggleSidebar) => Message::ToggleSidebar, Some(crate::native_menu::NativeMenuEvent::ShowChat) => Message::ShowChat, Some(crate::native_menu::NativeMenuEvent::ShowA2ui) => Message::ShowA2ui, Some(crate::native_menu::NativeMenuEvent::ShowGit) => Message::ShowGit, Some(crate::native_menu::NativeMenuEvent::ShowStats) => Message::ShowStats, Some(crate::native_menu::NativeMenuEvent::Help) => Message::OpenHelp, Some(crate::native_menu::NativeMenuEvent::Quit) => Message::RequestQuit, Some(crate::native_menu::NativeMenuEvent::Edit(command)) => { Message::NativeEdit(command) } None => Message::Noop, } })); if matches!(self.model_download, ModelDownload::Active(_)) { subscriptions.push( iced::time::every(Duration::from_secs(1)).map(|_| Message::DownloadProgressTick), ); } if self.git_operation.is_some() { subscriptions.push( iced::time::every(Duration::from_millis(100)).map(|_| Message::GitOperationTick), ); } #[cfg(target_os = "macos")] let titling = self.active_titling.is_some(); #[cfg(not(target_os = "macos"))] let titling = false; #[cfg(target_os = "macos")] let background_polling = self.background_chats.values().any(ChatSnapshot::needs_poll); #[cfg(not(target_os = "macos"))] let background_polling = false; if self.generating || titling || background_polling { subscriptions.push( iced::time::every(Duration::from_millis(50)).map(|_| Message::GenerationTick), ); } // The cursor leaves the thin divider as soon as it moves, so the drag is // followed through raw window events instead of the widget. if self.sidebar_drag { subscriptions.push(iced::event::listen_with(|event, _, _| match event { iced::Event::Mouse(mouse::Event::CursorMoved { position }) => { Some(Message::DragSidebar(position.x)) } iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { Some(Message::EndSidebarDrag) } _ => None, })); } Subscription::batch(subscriptions) } pub(crate) fn title(&self, id: window::Id) -> String { if self.preferences_window == Some(id) { "Preferences — DS4Server".to_owned() } else if self.model_manager_window == Some(id) { "Model Manager — DS4Server".to_owned() } else if self.help_window == Some(id) { "Help — DS4Server".to_owned() } else { "DS4Server".to_owned() } } fn sample_metrics(&mut self) { let prefill_tokens_per_second = self.metrics.take_prefill_sample(); let (kv_read_bytes, kv_write_bytes) = self.metrics.take_kv_io_sample(); let snapshot = self.metrics.snapshot(); let http_requests_per_second = snapshot .http_requests .saturating_sub(self.last_http_requests) as f32 / METRICS_SAMPLE_INTERVAL.as_secs_f32(); let kv_read_bytes_per_second = kv_read_bytes as f32 / METRICS_SAMPLE_INTERVAL.as_secs_f32(); let kv_write_bytes_per_second = kv_write_bytes as f32 / METRICS_SAMPLE_INTERVAL.as_secs_f32(); let sample_seconds = METRICS_SAMPLE_INTERVAL.as_secs_f32(); let ssd_requests_per_second = snapshot .ssd_selected_requests .saturating_sub(self.last_ssd_selected_requests) as f32 / sample_seconds; let ssd_bytes_per_second = snapshot .ssd_requested_bytes .saturating_sub(self.last_ssd_requested_bytes) as f32 / sample_seconds; let ssd_wait_ms_per_second = snapshot.ssd_wait_ms.saturating_sub(self.last_ssd_wait_ms) as f32 / sample_seconds; self.last_http_requests = snapshot.http_requests; self.last_ssd_selected_requests = snapshot.ssd_selected_requests; self.last_ssd_requested_bytes = snapshot.ssd_requested_bytes; self.last_ssd_wait_ms = snapshot.ssd_wait_ms; self.metrics_history.push_back(MetricsPoint { decode_tokens_per_second: if snapshot.phase == crate::metrics::RuntimePhase::Generating { snapshot.decode_tokens_per_second } else { 0.0 }, prefill_tokens_per_second, http_requests_per_second, kv_read_bytes_per_second, kv_write_bytes_per_second, ssd_requests_per_second, ssd_bytes_per_second, ssd_wait_ms_per_second, }); if self.metrics_history.len() > 120 { self.metrics_history.pop_front(); } self.metrics_snapshot = snapshot; if self.detail_tab == DetailTab::Stats && self.last_cache_scan.elapsed() >= CACHE_SCAN_INTERVAL { self.scan_kv_cache(); } if self.detail_tab == DetailTab::Git && self.git_operation.is_none() && self.last_git_scan.elapsed() >= GIT_SCAN_INTERVAL && let Some(project_id) = self.selected_project { self.refresh_git_state(); self.refresh_git_worktree(project_id); } } /// Removes one cache file. Checkpoints are disposable: the next turn on that /// conversation prefills again. fn discard_cache_entry(&mut self, path: &Path) { if let Err(error) = fs::remove_file(path) { self.error = Some(format!("Could not delete the checkpoint: {error}")); return; } let _ = fs::remove_file(path.with_extension("meta")); self.finish_cache_change(); } /// Empties the transient store only. Session checkpoints belong to their /// session and are removed with it. fn clear_transient_cache(&mut self) { let Ok(files) = fs::read_dir(transient_cache_path()) else { return; }; for file in files.flatten() { if file.metadata().is_ok_and(|metadata| metadata.is_file()) && let Err(error) = fs::remove_file(file.path()) { self.error = Some(format!("Could not clear the transient cache: {error}")); break; } } self.finish_cache_change(); } /// Writes the configuration file, surfacing a failure in the error banner. fn store_config(&mut self) { if let Err(error) = self.config.save(&config_path()) { self.error = Some(error); } } fn finish_cache_change(&mut self) { self.metrics.rescan_cache(&kv_cache_path()); self.metrics_snapshot = self.metrics.snapshot(); self.scan_kv_cache(); } fn scan_kv_cache(&mut self) { let budget = self.config.runtime.kv_cache.settings().budget_bytes; self.kv_cache_report = crate::metrics::kv_cache_report(&kv_cache_path(), budget); self.last_cache_scan = Instant::now(); } fn active_chat_title(&self) -> Option<&str> { let project_id = self.selected_project?; if let Some(session_id) = self.selected_session { return self .projects .iter() .flat_map(|project| &project.sessions) .find(|session| session.id == session_id) .map(|session| session.title.as_str()); } self.drafts.get(&project_id).map(String::as_str) } fn open_help(&mut self) -> Task { if let Some(id) = self.help_window { return window::gain_focus(id); } let (id, open) = window::open(window::Settings { size: Size::new(760.0, 640.0), min_size: Some(Size::new(560.0, 420.0)), icon: Some(app_icon()), ..Default::default() }); self.help_window = Some(id); open.map(Message::HelpOpened) } #[cfg(target_os = "macos")] fn sync_native_menu(&self) { let Some(menu) = &self._native_menu else { return; }; let edit = *self .native_edit_availability .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); menu.update( self.selected_project.is_some(), self.active_chat_title().is_some(), !self.config.interface.sidebar_collapsed, self.detail_tab, edit, ); } } #[cfg(target_os = "macos")] fn spawn_services( config: &Config, metrics: Arc, ) -> ( Arc>, Option, Option, Option, ) { let runtime_config = Arc::new(RwLock::new(config.clone())); let generation = match GenerationService::spawn(Arc::clone(&metrics)) { Ok(generation) => generation, Err(error) => return (runtime_config, None, None, Some(error)), }; let endpoint = config.endpoint.enabled.then(|| { crate::server::ServerHandle::spawn( generation.clone(), Arc::clone(&runtime_config), models_path(), transient_cache_path(), u16::try_from(config.endpoint.port).unwrap_or(4000), config.endpoint.cors, metrics, ) }); match endpoint { Some(Ok(endpoint)) => (runtime_config, Some(generation), Some(endpoint), None), Some(Err(error)) => (runtime_config, Some(generation), None, Some(error)), None => (runtime_config, Some(generation), None, None), } } impl Drop for App { fn drop(&mut self) { if let ModelDownload::Active(download) = &self.model_download { download.cancel.store(true, Ordering::Relaxed); } #[cfg(target_os = "macos")] if let Some(active) = &self.active_generation { active.cancel.store(true, Ordering::Relaxed); } #[cfg(target_os = "macos")] if let Some(active) = &self.active_tools { active.cancel.store(true, Ordering::Relaxed); } #[cfg(target_os = "macos")] if let Some((_, decision)) = self.pending_tool_approval.take() { let _ = decision.send(false); } } } fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option { match key.as_ref() { keyboard::Key::Character(",") if modifiers.command() => Some(Message::OpenPreferences), keyboard::Key::Character("m") if modifiers.command() && modifiers.shift() => { Some(Message::OpenModelManager) } keyboard::Key::Character("b") if modifiers.command() => Some(Message::ToggleSidebar), keyboard::Key::Character("n") if modifiers.command() => Some(Message::NewChat), keyboard::Key::Character("q") if modifiers.command() => Some(Message::RequestQuit), keyboard::Key::Named(keyboard::key::Named::Tab) => Some(if modifiers.shift() { Message::FocusPrevious } else { Message::FocusNext }), _ => None, } } fn chat_needs_poll(generating: bool, titling: bool) -> bool { generating || titling } fn active_chat_total(foreground: bool, background: impl IntoIterator) -> usize { usize::from(foreground) + background.into_iter().filter(|active| *active).count() } fn export_file_stem(title: &str) -> String { let stem = title .chars() .map(|character| match character { '/' | ':' | '\0' => '-', character => character, }) .collect::(); let stem = stem.trim().trim_matches('.'); if stem.is_empty() { "DS4Server Chat".to_owned() } else { stem.to_owned() } } fn export_markdown(title: &str, conversation: &[ChatMessage]) -> String { use std::fmt::Write; let mut output = format!("# {title}\n"); for message in conversation { if message.system { continue; } if message.compaction { let _ = write!( output, "\n> Context compacted: {}\n", message.content.trim() ); continue; } let label = if message.user { "You" } else if message.tool { "Tool" } else { "DS4" }; let _ = write!(output, "\n## {label}\n"); if let Some(reasoning) = message.reasoning.as_deref().filter(|text| !text.is_empty()) { let _ = write!( output, "\n
\nReasoning\n\n{reasoning}\n\n
\n" ); } let visible = crate::agent::visible_content(&message.content); let visible = crate::a2ui::transcript_fallback(visible); let _ = write!(output, "\n{}\n", visible.trim()); } output } pub(crate) fn application_support_path() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join("Library") .join("Application Support") .join(APP_ID) } pub(crate) fn models_path() -> PathBuf { application_support_path().join("models") } pub(crate) fn browser_profile_path() -> PathBuf { application_support_path().join("browser") } /// The settings file, beside the project database. pub(crate) fn config_path() -> PathBuf { application_support_path().join("config.yaml") } pub(super) fn chat_scroll_id() -> iced::widget::Id { iced::widget::Id::new("chat-transcript") } pub(super) fn git_diff_old_scroll_id() -> iced::widget::Id { iced::widget::Id::new("git-diff-old") } pub(super) fn git_diff_new_scroll_id() -> iced::widget::Id { iced::widget::Id::new("git-diff-new") } pub(super) fn composer_id() -> iced::widget::Id { iced::widget::Id::new("chat-composer") } pub(super) fn preferences_scroll_id() -> iced::widget::Id { iced::widget::Id::new("preferences-fields") } fn preferences_section_id(section: PreferenceSection) -> iced::widget::Id { iced::widget::Id::new(section.anchor()) } /// Scrolls the preferences form so the field that just took focus is inside /// the viewport. Iced moves focus without touching the scroll offset, so a tab /// past the fold would otherwise send the typing to a field nobody can see. /// /// The focus callback carries no bounds, so the position is taken from the /// innermost container around the focused widget — the row that holds the /// label and its input. fn reveal_focused() -> Task { reveal_preferences(None) } fn scroll_preferences_to(section: PreferenceSection) -> Task { reveal_preferences(Some(preferences_section_id(section))) } fn reveal_preferences(target: Option) -> Task { use iced::advanced::widget::{Id, Operation, operation}; use iced::{Rectangle, Vector}; struct Locate { target: Option, rows: Vec, next_container: Option, found: Option, } impl Operation for Locate { fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { let container = self.next_container.take(); if let Some(bounds) = container { self.rows.push(bounds); } operate(self); if container.is_some() { self.rows.pop(); } } fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { if self .target .as_ref() .is_some_and(|target| id == Some(target)) { self.found = Some(bounds); } self.next_container = Some(bounds); } fn focusable( &mut self, _id: Option<&Id>, bounds: Rectangle, state: &mut dyn operation::Focusable, ) { if self.target.is_none() && state.is_focused() { self.found = self.rows.last().copied().or(Some(bounds)); } } fn finish(&self) -> operation::Outcome { self.found.map_or(operation::Outcome::None, |field| { operation::Outcome::Chain(Box::new(Reveal { field, align_top: self.target.is_some(), })) }) } } struct Reveal { field: Rectangle, align_top: bool, } impl Operation for Reveal { fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { operate(self); } fn scrollable( &mut self, id: Option<&Id>, bounds: Rectangle, content_bounds: Rectangle, translation: Vector, state: &mut dyn operation::Scrollable, ) { if id != Some(&preferences_scroll_id()) { return; } let offset = if self.align_top { section_offset(self.field, bounds, translation.y) } else { let Some(offset) = reveal_offset(self.field, bounds, translation.y) else { return; }; offset }; state.scroll_by( scrollable::AbsoluteOffset { x: 0.0, y: offset }, bounds, content_bounds, ); } } iced::advanced::widget::operate(Locate { target, rows: Vec::new(), next_container: None, found: None, }) } fn section_offset(field: iced::Rectangle, viewport: iced::Rectangle, scrolled: f32) -> f32 { field.y - scrolled - viewport.y } /// How far the scroll area has to move for `field` to sit fully inside /// `viewport`, or `None` when it already does. A field taller than the viewport /// lines up with its top edge. /// /// Layout places the content in its own unscrolled coordinates, so `field` only /// says where it sits once `scrolled` — the offset the area is already at — is /// taken off it. fn reveal_offset(field: iced::Rectangle, viewport: iced::Rectangle, scrolled: f32) -> Option { // Keep a row's worth of margin, so a revealed field never sits flush // against the edge of the viewport. const MARGIN: f32 = 12.0; let top = field.y - scrolled; let above = top - MARGIN - viewport.y; let below = top + field.height + MARGIN - (viewport.y + viewport.height); if above < 0.0 { Some(above) } else if below > 0.0 { Some(below) } else { None } } fn focus_composer() -> Task { iced::widget::operation::focus(composer_id()) } fn scroll_chat_to_end() -> Task { iced::widget::operation::scroll_to( chat_scroll_id(), scrollable::AbsoluteOffset::::default(), ) } fn chat_near_end(following: bool, offset: f32, reversed_offset: f32) -> bool { const TAIL_THRESHOLD: f32 = 24.0; if following { offset <= TAIL_THRESHOLD } else { reversed_offset <= TAIL_THRESHOLD } } /// Deletes session checkpoints whose session is gone. Deleting a session or a /// project removes its checkpoint directly; this catches the cases that path /// misses — an interrupted or failed delete, or files left by an older /// database — so no unreachable checkpoint keeps occupying disc. fn sweep_orphan_checkpoints(directory: &Path, projects: &[ProjectWithSessions]) -> bool { let known = projects .iter() .flat_map(|item| item.sessions.iter().map(|session| session.id)) .collect::>(); let Ok(files) = fs::read_dir(directory) else { return false; }; let mut removed = false; for file in files.flatten() { let path = file.path(); if path .extension() .is_some_and(|value| value == "compacting" || value == "tmp") { removed |= fs::remove_file(&path).is_ok(); continue; } if path.extension().is_none_or(|value| value != "bin") { continue; } // Only numbered session checkpoints are ours to judge; anything else in // the directory is left alone. let orphan = path .file_stem() .and_then(|value| value.to_str()) .and_then(|value| value.parse::().ok()) .is_some_and(|id| !known.contains(&id)); if orphan { removed |= fs::remove_file(&path).is_ok(); } } removed } fn kv_cache_path() -> PathBuf { application_support_path().join("kv-cache") } fn session_checkpoint_path(session_id: i32) -> PathBuf { kv_cache_path().join(format!("{session_id}.bin")) } fn discard_session_checkpoint_files(directory: &Path, session_id: i32) -> Result { let mut removed = false; for extension in ["bin", "tmp", "compacting"] { let path = directory.join(format!("{session_id}.{extension}")); match fs::remove_file(&path) { Ok(()) => removed = true, Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { return Err(format!( "Could not discard session checkpoint {}: {error}", path.display() )); } } } Ok(removed) } fn session_compaction_checkpoint_path(session_id: i32) -> PathBuf { kv_cache_path().join(format!("{session_id}.compacting")) } /// Content-addressed KV cache for turns that belong to no stored session: the /// HTTP endpoint and the app's own one-shot requests share it. fn transient_cache_path() -> PathBuf { kv_cache_path().join("http") } pub(crate) fn app_icon() -> window::Icon { let decoder = png::Decoder::new(std::io::Cursor::new(include_bytes!( "../assets/app-icon.png" ))); let mut reader = decoder .read_info() .expect("bundled application icon must be valid PNG"); let mut rgba = vec![0; reader.output_buffer_size()]; let info = reader .next_frame(&mut rgba) .expect("bundled application icon must decode"); assert_eq!(info.color_type, png::ColorType::Rgba); assert_eq!(info.bit_depth, png::BitDepth::Eight); rgba.truncate(info.buffer_size()); window::icon::from_rgba(rgba, info.width, info.height) .expect("bundled application icon dimensions must be valid") } #[cfg(test)] mod tests { use super::*; #[test] fn preferences_shortcut_and_dspark_support_are_explicit() { let message = shortcut( keyboard::Key::Character(",".into()), keyboard::Modifiers::COMMAND, ); assert!(matches!(message, Some(Message::OpenPreferences))); let message = shortcut( keyboard::Key::Character("m".into()), keyboard::Modifiers::COMMAND | keyboard::Modifiers::SHIFT, ); assert!(matches!(message, Some(Message::OpenModelManager))); assert!(matches!( shortcut( keyboard::Key::Character("q".into()), keyboard::Modifiers::COMMAND ), Some(Message::RequestQuit) )); let tab = keyboard::Key::Named(keyboard::key::Named::Tab); assert!(matches!( shortcut(tab.clone(), keyboard::Modifiers::empty()), Some(Message::FocusNext) )); assert!(matches!( shortcut(tab, keyboard::Modifiers::SHIFT), Some(Message::FocusPrevious) )); assert!(ModelChoice::DeepSeekV4Flash.supports_dspark()); assert!(!ModelChoice::DeepSeekV4Pro.supports_dspark()); assert!(!ModelChoice::Glm52.supports_dspark()); } #[test] fn background_activity_drives_polling_indicators_and_quit_confirmation() { assert!(chat_needs_poll(true, false)); assert!(chat_needs_poll(false, true)); assert!(!chat_needs_poll(false, false)); assert_eq!(active_chat_total(false, [true, false, true]), 2); assert_eq!(active_chat_total(true, [true, false, true]), 3); } #[test] fn tab_scrolls_a_field_back_into_the_preferences_viewport() { let viewport = iced::Rectangle { x: 0.0, y: 100.0, width: 700.0, height: 400.0, }; let field = |y| iced::Rectangle { x: 0.0, y, width: 700.0, height: 30.0, }; assert_eq!(reveal_offset(field(200.0), viewport, 0.0), None); // Above the fold: scroll back by the gap plus the margin. assert_eq!(reveal_offset(field(80.0), viewport, 0.0), Some(-32.0)); // Below it: the bottom edge plus the margin comes into view. assert_eq!(reveal_offset(field(490.0), viewport, 0.0), Some(32.0)); // A field the scroll already brought into view stays put, and one // further down moves by the gap alone, not by the whole scroll. assert_eq!(reveal_offset(field(690.0), viewport, 500.0), None); assert_eq!(reveal_offset(field(990.0), viewport, 500.0), Some(32.0)); assert_eq!(section_offset(field(990.0), viewport, 500.0), 390.0); } #[test] fn chat_only_follows_updates_while_near_the_tail() { assert!(chat_near_end(true, 0.0, 500.0)); assert!(!chat_near_end(true, 25.0, 475.0)); assert!(chat_near_end(false, 500.0, 24.0)); assert!(!chat_near_end(false, 400.0, 100.0)); } #[test] fn ds4_gib_and_streaming_cache_inputs_are_typed() { assert_eq!( parse_streaming_cache("128").unwrap(), Some(StreamingCacheBudget::Experts(128)) ); assert_eq!( parse_streaming_cache("64gB").unwrap(), Some(StreamingCacheBudget::Gib(64)) ); assert!(parse_streaming_cache("1.5GB").is_err()); assert_eq!(parse_optional_gib("Memory", "8GB").unwrap(), Some(8)); } #[test] fn one_shot_titles_are_reduced_to_a_single_sidebar_line() { use generation::session_title; assert_eq!( session_title("\"Metal kernel debugging\"").as_deref(), Some("Metal kernel debugging") ); assert_eq!( session_title("Fixing the tokenizer.\nThat is my answer.").as_deref(), Some("Fixing the tokenizer") ); assert_eq!(session_title(" \n \n").as_deref(), None); let long = "word ".repeat(40); let title = session_title(&long).expect("a long reply still yields a title"); assert!(title.chars().count() <= 61, "{title}"); assert!(title.ends_with('…')); } #[test] fn assistant_stream_keeps_reasoning_separate_from_the_answer() { let mut message = ChatMessage { id: 1, user: false, tool: false, system: false, compaction: false, compaction_tail_start: None, generation_stats: None, reasoning: Some(String::new()), reasoning_complete: false, reasoning_open: true, content: String::new(), model_content: None, markdown: markdown::Content::new(), transcript: text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: Vec::new(), a2ui_replies: Vec::new(), a2ui_open_urls: Vec::new(), }; message.append(true, "working it out"); message.append(false, "**final answer**"); message.refresh_markdown(); assert_eq!(message.reasoning.as_deref(), Some("working it out")); assert!(message.reasoning_complete); assert_eq!(message.content, "**final answer**"); assert!(!message.markdown.items().is_empty()); } #[test] fn chat_export_keeps_visible_turns_and_omits_system_messages() { let message = |user, tool, system, content: &str| ChatMessage { id: 1, user, tool, system, compaction: false, compaction_tail_start: None, generation_stats: None, reasoning: None, reasoning_complete: true, reasoning_open: false, content: content.into(), model_content: None, markdown: markdown::Content::new(), transcript: text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: Vec::new(), a2ui_replies: Vec::new(), a2ui_open_urls: Vec::new(), }; let exported = export_markdown( "A chat", &[ message(false, false, true, "private"), message(true, false, false, "hello"), message(false, false, false, "**hi**"), ], ); assert_eq!( exported, "# A chat\n\n## You\n\nhello\n\n## DS4\n\n**hi**\n" ); assert!(!exported.contains("private")); } #[test] fn owned_runtime_paths_stay_in_application_support() { let root = application_support_path(); assert!(models_path().starts_with(&root)); assert!(browser_profile_path().starts_with(&root)); assert!(config_path().starts_with(&root)); assert!(kv_cache_path().starts_with(&root)); } #[test] fn source_does_not_depend_on_a_sibling_ds4_checkout() { fn scan(path: &Path, forbidden: &str) { if path.is_dir() { for entry in fs::read_dir(path).unwrap() { scan(&entry.unwrap().path(), forbidden); } } else if let Ok(source) = fs::read_to_string(path) { assert!( !source.contains(forbidden), "{} references a sibling DS4 checkout", path.display() ); } } let root = Path::new(env!("CARGO_MANIFEST_DIR")); let forbidden = format!("{}/", ["..", "ds4"].join("/")); for path in ["src", "native", "build.rs", "Cargo.toml", "Makefile"] { scan(&root.join(path), &forbidden); } } }