Files
DS4Server/src/app.rs
2026-07-29 10:40:39 +00:00

2737 lines
110 KiB
Rust

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;
#[cfg(target_os = "macos")]
use crate::metrics::WorkSource;
use crate::metrics::{KvCacheReport, Metrics, MetricsSnapshot};
use crate::model::{self, 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::{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>,
/// 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>,
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,
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<PathBuf>, 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<PathBuf>),
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<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),
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::<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,
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::<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,
};
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::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::<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::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),
}
}
}
_ => unreachable!("preference 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::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<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 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<details>\n<summary>Reasoning</summary>\n\n{reasoning}\n\n</details>\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<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::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);
}
}
}