Files
DS4Server/src/app.rs
2026-08-29 21:57:11 +02:00

2922 lines
118 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, ModelPreferences, PermissionMode,
};
use crate::database::{Database, ProjectWithSessions, SessionState, StoredMessage, SystemMessage};
#[cfg(any(target_os = "macos", test))]
use crate::engine::ChatTurn;
#[cfg(target_os = "macos")]
use crate::metrics::WorkSource;
use crate::metrics::{KvCacheReport, Metrics, MetricsSnapshot};
use crate::model::{DownloadOutcome, DownloadProgress, ManagedArtifactId, ModelChoice};
#[cfg(target_os = "macos")]
use crate::runtime::{
ActiveGeneration, CheckpointTarget, CompactionInput, 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::{BTreeMap, 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<window::Id>,
pub(super) model_manager_window: Option<window::Id>,
pub(super) help_window: Option<window::Id>,
pub(super) pending_model_delete: Option<ManagedArtifactId>,
#[cfg(target_os = "macos")]
_native_menu: Option<crate::native_menu::NativeMenu>,
#[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<Database>,
projects: Vec<ProjectWithSessions>,
config: Config,
preference_draft: PreferenceDraft,
preference_error: Option<String>,
restore_dev_brain_confirmation: bool,
selected_project: Option<i32>,
selected_session: Option<i32>,
pub(super) permission_mode: PermissionMode,
/// 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<i32, String>,
git_states: HashMap<i32, GitState>,
git_worktrees: HashMap<i32, GitWorktree>,
git_selected_project: Option<i32>,
git_selected_files: HashSet<PathBuf>,
git_commit_message: String,
git_diff: Option<GitDiff>,
git_diff_layout: GitDiffLayout,
git_commit_all_confirmation: bool,
git_operation: Option<ActiveGitOperation>,
last_git_scan: Instant,
#[cfg(target_os = "macos")]
background_chats: HashMap<i32, ChatSnapshot>,
/// Session whose quick-actions menu is open.
session_menu: Option<i32>,
/// Session waiting for explicit confirmation before deletion.
pending_session_delete: Option<i32>,
/// 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<i32>,
/// True while the sidebar divider is being dragged.
sidebar_drag: bool,
choosing_folder: bool,
pending_project_path: Option<PathBuf>,
project_name_input: String,
model_download: ModelDownload,
pub(super) composer: text_editor::Content,
pub(super) queued_inputs: VecDeque<String>,
pub(super) conversation: Vec<ChatMessage>,
/// Follow appended chat content until the user scrolls away from the tail.
pub(super) chat_follow_tail: bool,
active_turn: Option<generation::TurnSummary>,
pub(super) a2ui: crate::a2ui::Store,
pub(super) a2ui_history: Vec<crate::a2ui::Store>,
pub(super) a2ui_history_index: Option<usize>,
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<String, iced::widget::image::Handle>,
pub(super) a2ui_image_requests: HashSet<String>,
pub(super) a2ui_image_loading: bool,
pub(super) pending_a2ui_dismissal: Option<String>,
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<f32>,
pub(super) detail_tab: DetailTab,
metrics: Arc<Metrics>,
pub(super) metrics_snapshot: MetricsSnapshot,
pub(super) metrics_history: VecDeque<MetricsPoint>,
/// 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<GenerationService>,
#[cfg(target_os = "macos")]
active_generation: Option<ActiveGeneration>,
#[cfg(target_os = "macos")]
active_compaction: Option<generation::CompactionRequest>,
#[cfg(target_os = "macos")]
active_tool_check: Option<generation::ToolResultCheck>,
#[cfg(target_os = "macos")]
agent_tools: Option<(i32, Arc<Mutex<crate::agent::Tools>>)>,
#[cfg(target_os = "macos")]
active_tools: Option<crate::agent::ActiveTools>,
#[cfg(target_os = "macos")]
pub(super) tool_cards: Vec<crate::agent::ToolCard>,
#[cfg(target_os = "macos")]
pub(super) pending_tool_approval:
Option<(crate::agent::ApprovalPrompt, std::sync::mpsc::Sender<bool>)>,
#[cfg(target_os = "macos")]
active_titling: Option<generation::TitleRequest>,
#[cfg(target_os = "macos")]
runtime_config: Arc<RwLock<Config>>,
#[cfg(target_os = "macos")]
_endpoint: Option<crate::server::ServerHandle>,
error: Option<String>,
pub(super) activity: Option<String>,
pub(super) context_notice: Option<String>,
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<i32>,
selected_session: Option<i32>,
permission_mode: PermissionMode,
composer: text_editor::Content,
queued_inputs: VecDeque<String>,
conversation: Vec<ChatMessage>,
chat_follow_tail: bool,
active_turn: Option<generation::TurnSummary>,
a2ui: crate::a2ui::Store,
a2ui_history: Vec<crate::a2ui::Store>,
a2ui_history_index: Option<usize>,
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<String, iced::widget::image::Handle>,
a2ui_image_requests: HashSet<String>,
a2ui_image_loading: bool,
pending_a2ui_dismissal: Option<String>,
a2ui_auto_switch_pending: bool,
generating: bool,
context_used: u32,
context_limit: u32,
tokens_per_second: Option<f32>,
detail_tab: DetailTab,
active_generation: Option<ActiveGeneration>,
active_compaction: Option<generation::CompactionRequest>,
active_tool_check: Option<generation::ToolResultCheck>,
agent_tools: Option<(i32, Arc<Mutex<crate::agent::Tools>>)>,
active_tools: Option<crate::agent::ActiveTools>,
tool_cards: Vec<crate::agent::ToolCard>,
pending_tool_approval: Option<(crate::agent::ApprovalPrompt, std::sync::mpsc::Sender<bool>)>,
active_titling: Option<generation::TitleRequest>,
error: Option<String>,
activity: Option<String>,
context_notice: Option<String>,
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<String>,
pub(super) label: String,
pub(super) branches: Vec<String>,
}
#[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,
Prompt,
Generation,
Execution,
Acceleration,
KvCache,
Steering,
}
impl PreferenceSection {
const ALL: [Self; 10] = [
Self::Model,
Self::Endpoint,
Self::DevBrain,
Self::Git,
Self::Prompt,
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::Prompt => "preferences-prompt",
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::Prompt => "Prompt",
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<PathBuf>, String),
ModelManagerOpened(window::Id),
WindowOpened(window::Id),
WindowEvent(window::Id, window::Event),
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),
PreferenceDefaultReasoningChanged(ReasoningMode),
PreferenceGenerationModelChanged(ModelChoice),
PreferenceGenerationReasoningChanged(ReasoningMode),
PreferenceAccelerationModelChanged(ModelChoice),
PreferencePermissionModeChanged(PermissionMode),
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<PathBuf>),
RestoreDevBrainDefaultGuides,
ConfirmRestoreDevBrainDefaultGuides,
CancelRestoreDevBrainDefaultGuides,
PreferenceContextChanged(String),
PreferenceMaxTokensChanged(String),
PreferenceSystemPromptAction(text_editor::Action),
PreferenceTemperatureChanged(String),
PreferenceTopPChanged(String),
PreferenceMinPChanged(String),
PreferenceSeedChanged(String),
PreferenceCpuThreadsChanged(String),
PreferencePowerChanged(String),
PreferencePrefillChunkChanged(String),
PreferenceQualityChanged(bool),
PreferenceWarmWeightsChanged(bool),
PreferenceMtpDraftChanged(String),
PreferenceMtpMarginChanged(String),
PreferenceGlmMtpChanged(bool),
PreferenceGlmMtpTimingChanged(bool),
PreferenceDsparkConfidenceChanged(String),
PreferenceDsparkStrictChanged(bool),
PreferenceDsparkExactSamplingChanged(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<String>),
A2uiSelectTab(String, String, usize),
A2uiToggleModal(String, String),
A2uiImageLoaded(Option<i32>, String, Result<Vec<u8>, 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<PathBuf>),
ProjectNameChanged(String),
ConfirmProject,
CancelProject,
ToggleProject(i32),
DeleteProject(i32),
CreateSession(i32),
DraftProjectChanged(i32),
SwitchGitBranch(String),
ReasoningModeChanged(ReasoningMode),
PermissionModeChanged(PermissionMode),
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.active_generation().context_tokens.max(0) as u32;
let default_permission_mode = config.default_permission_mode;
// 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,
permission_mode: default_permission_mode,
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.active_generation().context_tokens.max(0) as u32;
let default_permission_mode = config.default_permission_mode;
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::<String>;
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,
permission_mode: default_permission_mode,
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(),
permission_mode: self.permission_mode,
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.active_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.permission_mode = snapshot.permission_mode;
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::<Vec<_>>();
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<Message> {
let message = match self.update_preference_message(message) {
Ok(message) => message,
Err(task) => return task,
};
let message = match self.update_model_manager_message(message) {
Ok(message) => message,
Err(task) => return 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::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.config.model, &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::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::WindowEvent(id, event) => match event {
window::Event::Opened { position, size } if id == self.main_window => {
self.config.interface.window_size =
[size.width.round() as u32, size.height.round() as u32];
self.config.interface.window_position = position
.map(|position| [position.x.round() as i32, position.y.round() as i32]);
}
window::Event::Moved(position) if id == self.main_window => {
self.config.interface.window_position =
Some([position.x.round() as i32, position.y.round() as i32]);
}
window::Event::Resized(size) if id == self.main_window => {
self.config.interface.window_size =
[size.width.round() as u32, size.height.round() as u32];
}
window::Event::CloseRequested => {
if id == self.main_window {
return self.update(Message::RequestQuit);
}
return window::close(id);
}
window::Event::Closed => {
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 self.quit();
}
self.quit_confirmation = true;
}
Message::ConfirmQuit => return self.quit(),
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::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::<Vec<_>>()
})
.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::ReasoningModeChanged(mode) => {
if self.active_chat_count() > 0 {
self.error =
Some("Stop all active generations before changing thinking mode.".into());
} else if !self
.config
.supported_reasoning_modes(self.config.model)
.contains(&mode)
{
self.error = Some(
"Think Max requires a context window of at least 393216 tokens.".into(),
);
} else {
let mut config = self.config.clone();
config
.model_profiles
.entry(config.model)
.or_default()
.reasoning_mode = mode;
match config.save(&config_path()) {
Ok(()) => {
self.config = config;
self.preference_draft.default_reasoning_mode = mode;
self.preference_draft
.load_generation(self.config.model, mode);
self.context_limit =
self.config.active_generation().context_tokens.max(0) as u32;
#[cfg(target_os = "macos")]
{
self.agent_tools = None;
}
self.error = None;
#[cfg(target_os = "macos")]
preferences::update_runtime_config(&self.runtime_config, &self.config);
}
Err(error) => self.error = Some(error),
}
}
}
Message::PermissionModeChanged(mode) => {
#[cfg(target_os = "macos")]
if self.active_tools.is_some() {
self.error = Some(
"Wait for the current tool batch before changing permission mode.".into(),
);
return Task::none();
}
if let Some(session_id) = self.selected_session {
let Some(database) = &mut self.database else {
self.error = Some("The project database is unavailable.".into());
return Task::none();
};
match database.set_session_permission_mode(session_id, mode) {
Ok(()) => {
self.permission_mode = mode;
self.error = None;
self.reload_projects();
}
Err(error) => {
self.error = Some(format!("Could not save permission mode: {error}"));
}
}
} else {
self.permission_mode = mode;
self.error = None;
}
}
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,
session.permission_mode(),
)
});
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, permission_mode) =
saved_context.unwrap_or((0, 0, None, PermissionMode::default()));
self.context_used = used.max(0) as u32;
self.context_limit = if limit > 0 {
limit as u32
} else {
self.config.active_generation().context_tokens.max(0) as u32
};
self.tokens_per_second = tokens_per_second;
self.permission_mode = permission_mode;
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),
}
}
}
_ => unreachable!("domain messages are dispatched before the main update"),
}
Task::none()
}
pub(crate) fn subscription(&self) -> Subscription<Message> {
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::events().filter_map(|(id, event)| {
matches!(
event,
window::Event::Opened { .. }
| window::Event::Moved(_)
| window::Event::Resized(_)
| window::Event::CloseRequested
| window::Event::Closed
)
.then_some(Message::WindowEvent(id, event))
}),
];
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 quit(&mut self) -> Task<Message> {
if self.database.is_none() {
return iced::exit();
}
match self.config.save(&config_path()) {
Ok(()) => iced::exit(),
Err(error) => {
self.quit_confirmation = false;
self.error = Some(error);
Task::none()
}
}
}
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<Message> {
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<Metrics>,
) -> (
Arc<RwLock<Config>>,
Option<GenerationService>,
Option<crate::server::ServerHandle>,
Option<String>,
) {
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<Message> {
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<Item = bool>) -> 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::<String>();
let stem = stem.trim().trim_matches('.');
if stem.is_empty() {
"DS4Server Chat".to_owned()
} else {
stem.to_owned()
}
}
fn write_block_quote(output: &mut String, text: &str) {
use std::fmt::Write;
for line in text.lines() {
let _ = writeln!(output, "> {line}");
}
}
fn write_code_fence(output: &mut String, language: &str, text: &str) {
use std::fmt::Write;
let length = text
.lines()
.map(|line| {
line.trim_start()
.chars()
.take_while(|character| *character == '`')
.count()
+ 1
})
.max()
.unwrap_or(3)
.max(3);
let fence = "`".repeat(length);
let _ = write!(output, "\n{fence}{language}\n{text}\n{fence}\n");
}
fn export_markdown(title: &str, model: ModelChoice, conversation: &[ChatMessage]) -> String {
use std::fmt::Write;
let mut output = format!("# {title}\n");
let mut index = 0;
while let Some(message) = conversation.get(index) {
index += 1;
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()) {
output.push('\n');
write_block_quote(&mut output, reasoning);
}
let visible = crate::agent::visible_content(&message.content);
let visible = crate::a2ui::transcript_fallback(visible);
let _ = write!(output, "\n{}\n", visible.trim());
if message.user || message.tool {
continue;
}
let tool_result = conversation.get(index).filter(|message| message.tool);
let cards = crate::agent::stored_tool_cards(
model,
&message.content,
tool_result.map(|message| message.content.as_str()),
&message.tool_approval_reasons,
);
if cards.is_empty() {
continue;
}
index += usize::from(tool_result.is_some());
for (tool_index, card) in cards.into_iter().enumerate() {
if card.call.name == "bash"
&& let Some(command) = card
.call
.arguments
.get("command")
.and_then(|value| value.as_str())
{
if let Some(reason) = card.approval_reason {
output.push('\n');
write_block_quote(&mut output, &reason);
}
write_code_fence(&mut output, "bash", command.trim());
if let Some(result) = card.result {
write_code_fence(&mut output, "text", &result);
}
} else if let Some(result) = card.result {
let _ = write!(
output,
"\n## Tool\n\nTool result {} ({}):\n{result}\n",
tool_index + 1,
card.call.name
);
}
}
}
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<Message> {
reveal_preferences(None)
}
fn scroll_preferences_to(section: PreferenceSection) -> Task<Message> {
reveal_preferences(Some(preferences_section_id(section)))
}
fn reveal_preferences(target: Option<iced::widget::Id>) -> Task<Message> {
use iced::advanced::widget::{Id, Operation, operation};
use iced::{Rectangle, Vector};
struct Locate {
target: Option<Id>,
rows: Vec<Rectangle>,
next_container: Option<Rectangle>,
found: Option<Rectangle>,
}
impl<T> Operation<T> for Locate {
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
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<T> {
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<T> Operation<T> for Reveal {
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
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<f32> {
// 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<Message> {
iced::widget::operation::focus(composer_id())
}
fn scroll_chat_to_end() -> Task<Message> {
iced::widget::operation::scroll_to(
chat_scroll_id(),
scrollable::AbsoluteOffset::<f32>::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::<HashSet<_>>();
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::<i32>().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<bool, String> {
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::DeepSeekV4Flash0731.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,
tool_approval_reasons: Vec::new(),
instruction_metadata: 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_formats_visible_turns_reasoning_and_bash_tools() {
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,
tool_approval_reasons: Vec::new(),
instruction_metadata: 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 mut assistant = message(
false,
false,
false,
r#"I will check.
<DSMLtool_calls>
<DSMLinvoke name="bash"><DSMLparameter name="command" string="true">cat <<'EOF'
```
EOF</DSMLparameter></DSMLinvoke>
</DSMLtool_calls>"#,
);
assistant.reasoning = Some("First thought.\nSecond thought.".into());
assistant.tool_approval_reasons = vec![Some("This only prints text.".into())];
let exported = export_markdown(
"A chat",
ModelChoice::DeepSeekV4Flash,
&[
message(false, false, true, "private"),
message(true, false, false, "hello"),
assistant,
message(false, true, false, "Tool result 1 (bash):\n```\none\ntwo\n"),
],
);
assert_eq!(
exported,
"# A chat\n\n## You\n\nhello\n\n## DS4\n\n> First thought.\n> Second thought.\n\nI will check.\n\n> This only prints text.\n\n````bash\ncat <<'EOF'\n```\nEOF\n````\n\n````text\n```\none\ntwo\n````\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);
}
}
}