3006 lines
122 KiB
Rust
3006 lines
122 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(target_os = "macos")]
|
|
use crate::engine::ChatTurn;
|
|
use crate::metrics::{KvCacheReport, Metrics, MetricsSnapshot};
|
|
use crate::model::{self, DownloadOutcome, DownloadProgress, ManagedArtifactId, ModelChoice};
|
|
#[cfg(target_os = "macos")]
|
|
use crate::runtime::{ActiveGeneration, CheckpointTarget, GenerationEvent, GenerationService};
|
|
use crate::settings::{
|
|
DiagnosticPreferences, ExecutionPreferences, GIB, GenerationPreferences, KvCachePreferences,
|
|
ReasoningMode, RuntimePreferences, SpeculativePreferences, SsdPreferences, SteeringPreferences,
|
|
StreamingCacheBudget,
|
|
};
|
|
use iced::widget::{markdown, scrollable, text_editor};
|
|
use iced::{Size, Subscription, Task, keyboard, mouse, window};
|
|
use rfd::AsyncFileDialog;
|
|
use std::collections::{HashMap, HashSet, VecDeque};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
use std::sync::mpsc::{self, TryRecvError};
|
|
use std::sync::{Arc, 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>,
|
|
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: String,
|
|
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: String,
|
|
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,
|
|
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,
|
|
ComposerChanged(String),
|
|
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,
|
|
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: String::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,
|
|
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,
|
|
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: String::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,
|
|
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> {
|
|
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;
|
|
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;
|
|
}
|
|
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) {
|
|
return self.update(Message::ClosePreferences);
|
|
}
|
|
if id == self.main_window {
|
|
return self.update(Message::DismissPanel);
|
|
}
|
|
}
|
|
Message::DismissPanel => {
|
|
if self.quit_confirmation {
|
|
self.quit_confirmation = false;
|
|
} else if self.git_diff.is_some() {
|
|
self.git_diff = None;
|
|
} else if self.git_commit_all_confirmation {
|
|
self.git_commit_all_confirmation = false;
|
|
} else if self.pending_session_delete.is_some() {
|
|
self.pending_session_delete = None;
|
|
} else if self.pending_a2ui_dismissal.is_some() {
|
|
self.pending_a2ui_dismissal = None;
|
|
} else if self.session_rename.is_some() || self.session_menu.is_some() {
|
|
self.session_rename = None;
|
|
self.session_menu = None;
|
|
} else if self.pending_project_path.is_some() {
|
|
self.pending_project_path = None;
|
|
self.project_name_input.clear();
|
|
self.error = None;
|
|
}
|
|
}
|
|
Message::FocusNext => {
|
|
return iced::widget::operation::focus_next().chain(reveal_focused());
|
|
}
|
|
Message::FocusPrevious => {
|
|
return iced::widget::operation::focus_previous().chain(reveal_focused());
|
|
}
|
|
Message::ShowChat => {
|
|
self.detail_tab = DetailTab::Chat;
|
|
self.chat_follow_tail = true;
|
|
return scroll_chat_to_end();
|
|
}
|
|
Message::ShowA2ui => self.detail_tab = DetailTab::A2ui,
|
|
Message::ShowGit => {
|
|
self.detail_tab = DetailTab::Git;
|
|
if let Some(project_id) = self.selected_project {
|
|
self.refresh_git_state();
|
|
self.refresh_git_worktree(project_id);
|
|
}
|
|
}
|
|
Message::ShowStats => {
|
|
self.detail_tab = DetailTab::Stats;
|
|
self.scan_kv_cache();
|
|
}
|
|
Message::DiscardCacheEntry(path) => self.discard_cache_entry(&path),
|
|
Message::ClearTransientCache => self.clear_transient_cache(),
|
|
// The sidebar starts at the window's left edge, so the cursor's x is
|
|
// the width the user is asking for. Only the final width is stored.
|
|
Message::StartSidebarDrag => self.sidebar_drag = true,
|
|
Message::DragSidebar(x) => {
|
|
if self.sidebar_drag {
|
|
self.config.interface.sidebar_width =
|
|
(x.round() as i32).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH);
|
|
}
|
|
}
|
|
Message::EndSidebarDrag => {
|
|
if self.sidebar_drag {
|
|
self.sidebar_drag = false;
|
|
self.store_config();
|
|
}
|
|
}
|
|
Message::ToggleSidebar => {
|
|
self.config.interface.sidebar_collapsed = !self.config.interface.sidebar_collapsed;
|
|
self.store_config();
|
|
#[cfg(target_os = "macos")]
|
|
self.sync_native_menu();
|
|
}
|
|
Message::MetricsTick => self.sample_metrics(),
|
|
Message::PreferenceModelChanged(model) => {
|
|
self.preference_draft.model = model;
|
|
if !model.supports_dspark() {
|
|
self.preference_draft.legacy_mtp_enabled = false;
|
|
self.preference_draft.dspark_enabled = false;
|
|
self.preference_draft.dspark_confidence_threshold.clear();
|
|
self.preference_draft.dspark_strict = false;
|
|
}
|
|
if model == ModelChoice::Glm52 {
|
|
self.preference_draft.power_percent.clear();
|
|
self.preference_draft.prefill_chunk.clear();
|
|
self.preference_draft.directional_steering_file.clear();
|
|
self.preference_draft.directional_steering_ffn.clear();
|
|
self.preference_draft.directional_steering_attn.clear();
|
|
} else {
|
|
self.preference_draft.glm_mtp = false;
|
|
self.preference_draft.glm_mtp_timing = false;
|
|
self.preference_draft.ssd_full_layers.clear();
|
|
}
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceLegacyMtpChanged(enabled) => {
|
|
self.preference_draft.legacy_mtp_enabled =
|
|
self.preference_draft.model.supports_dspark() && enabled;
|
|
if self.preference_draft.legacy_mtp_enabled {
|
|
self.preference_draft.dspark_enabled = false;
|
|
self.preference_draft.dspark_confidence_threshold.clear();
|
|
self.preference_draft.dspark_strict = false;
|
|
}
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceDsparkChanged(enabled) => {
|
|
self.preference_draft.dspark_enabled =
|
|
self.preference_draft.model.supports_dspark() && enabled;
|
|
if !self.preference_draft.dspark_enabled {
|
|
self.preference_draft.dspark_confidence_threshold.clear();
|
|
self.preference_draft.dspark_strict = false;
|
|
} else {
|
|
self.preference_draft.legacy_mtp_enabled = false;
|
|
}
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceTimeoutChanged(value) => {
|
|
self.preference_draft.idle_timeout_minutes = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceA2uiChanged(enabled) => {
|
|
self.preference_draft.a2ui_enabled = enabled;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceEndpointPortChanged(value) => {
|
|
self.preference_draft.endpoint_port = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceEndpointEnabledChanged(enabled) => {
|
|
self.preference_draft.endpoint_enabled = enabled;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceEndpointCorsChanged(enabled) => {
|
|
self.preference_draft.endpoint_cors = enabled;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceDevBrainEnabledChanged(enabled) => {
|
|
self.preference_draft.dev_brain_enabled = enabled;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceDevBrainVaultChanged(value) => {
|
|
self.preference_draft.dev_brain_vault_path = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceGitDiffLayoutChanged(layout) => {
|
|
self.preference_draft.git_diff_layout = layout;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceGitDiffAlgorithmChanged(algorithm) => {
|
|
self.preference_draft.git_diff_algorithm = algorithm;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceGitContextLinesChanged(value) => {
|
|
self.preference_draft.git_context_lines = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceGitInterhunkLinesChanged(value) => {
|
|
self.preference_draft.git_interhunk_lines = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceGitIndentHeuristicChanged(enabled) => {
|
|
self.preference_draft.git_indent_heuristic = enabled;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceGitWhitespaceChanged(whitespace) => {
|
|
self.preference_draft.git_whitespace = whitespace;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceGitIgnoreBlankLinesChanged(enabled) => {
|
|
self.preference_draft.git_ignore_blank_lines = enabled;
|
|
self.preference_error = None;
|
|
}
|
|
Message::ChooseDevBrainVault => {
|
|
return Task::perform(
|
|
async {
|
|
AsyncFileDialog::new()
|
|
.set_title("Choose an Obsidian vault")
|
|
.pick_folder()
|
|
.await
|
|
.map(|folder| folder.path().to_path_buf())
|
|
},
|
|
Message::DevBrainVaultPicked,
|
|
);
|
|
}
|
|
Message::DevBrainVaultPicked(path) => {
|
|
if let Some(path) = path {
|
|
self.preference_draft.dev_brain_vault_path =
|
|
path.to_string_lossy().into_owned();
|
|
self.preference_error = None;
|
|
}
|
|
}
|
|
Message::RestoreDevBrainDefaultGuides => {
|
|
self.preference_error = crate::dev_brain::restore_default_guides(Path::new(
|
|
&self.preference_draft.dev_brain_vault_path,
|
|
))
|
|
.err();
|
|
}
|
|
Message::PreferenceContextChanged(value) => {
|
|
self.preference_draft.context_tokens = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceMaxTokensChanged(value) => {
|
|
self.preference_draft.max_generated_tokens = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceSystemPromptAction(action) => {
|
|
self.preference_draft.system_prompt.perform(action);
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceTemperatureChanged(value) => {
|
|
self.preference_draft.temperature = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceTopPChanged(value) => {
|
|
self.preference_draft.top_p = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceMinPChanged(value) => {
|
|
self.preference_draft.min_p = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceSeedChanged(value) => {
|
|
self.preference_draft.seed = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceReasoningChanged(value) => {
|
|
self.preference_draft.reasoning_mode = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceCpuThreadsChanged(value) => {
|
|
self.preference_draft.cpu_threads = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferencePowerChanged(value) => {
|
|
self.preference_draft.power_percent = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferencePrefillChunkChanged(value) => {
|
|
self.preference_draft.prefill_chunk = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceQualityChanged(value) => {
|
|
self.preference_draft.quality = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceWarmWeightsChanged(value) => {
|
|
self.preference_draft.warm_weights = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceMtpDraftChanged(value) => {
|
|
self.preference_draft.mtp_draft_tokens = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceMtpMarginChanged(value) => {
|
|
self.preference_draft.mtp_margin = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceGlmMtpChanged(value) => {
|
|
self.preference_draft.glm_mtp =
|
|
self.preference_draft.model == ModelChoice::Glm52 && value;
|
|
if !self.preference_draft.glm_mtp {
|
|
self.preference_draft.glm_mtp_timing = false;
|
|
}
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceGlmMtpTimingChanged(value) => {
|
|
self.preference_draft.glm_mtp_timing =
|
|
self.preference_draft.model == ModelChoice::Glm52 && value;
|
|
if self.preference_draft.glm_mtp_timing {
|
|
self.preference_draft.glm_mtp = true;
|
|
}
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceDsparkConfidenceChanged(value) => {
|
|
self.preference_draft.dspark_confidence_threshold = value;
|
|
if self.preference_draft.model.supports_dspark()
|
|
&& !self
|
|
.preference_draft
|
|
.dspark_confidence_threshold
|
|
.trim()
|
|
.is_empty()
|
|
{
|
|
self.preference_draft.dspark_enabled = true;
|
|
self.preference_draft.legacy_mtp_enabled = false;
|
|
}
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceDsparkStrictChanged(value) => {
|
|
self.preference_draft.dspark_strict =
|
|
self.preference_draft.model.supports_dspark() && value;
|
|
if self.preference_draft.dspark_strict {
|
|
self.preference_draft.dspark_enabled = true;
|
|
self.preference_draft.legacy_mtp_enabled = false;
|
|
}
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceSsdChanged(value) => {
|
|
self.preference_draft.ssd_streaming = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceSsdColdChanged(value) => {
|
|
self.preference_draft.ssd_streaming_cold = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceSsdCacheChanged(value) => {
|
|
self.preference_draft.ssd_cache = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceSsdFullLayersChanged(value) => {
|
|
if self.preference_draft.model == ModelChoice::Glm52 {
|
|
self.preference_draft.ssd_full_layers = value;
|
|
}
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceSsdPreloadChanged(value) => {
|
|
self.preference_draft.ssd_preload_experts = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceSteeringFileChanged(value) => {
|
|
if self.preference_draft.model != ModelChoice::Glm52 {
|
|
self.preference_draft.directional_steering_file = value;
|
|
}
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceSteeringFfnChanged(value) => {
|
|
if self.preference_draft.model != ModelChoice::Glm52 {
|
|
self.preference_draft.directional_steering_ffn = value;
|
|
}
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceSteeringAttnChanged(value) => {
|
|
if self.preference_draft.model != ModelChoice::Glm52 {
|
|
self.preference_draft.directional_steering_attn = value;
|
|
}
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceSimulatedMemoryChanged(value) => {
|
|
self.preference_draft.simulated_used_memory_gib = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceExpertProfileChanged(value) => {
|
|
self.preference_draft.expert_profile_path = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceKvBudgetChanged(value) => {
|
|
self.preference_draft.kv_budget_gib = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceKvMinTokensChanged(value) => {
|
|
self.preference_draft.kv_min_tokens = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceKvColdMaxChanged(value) => {
|
|
self.preference_draft.kv_cold_max_tokens = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::PreferenceKvContinuedIntervalChanged(value) => {
|
|
self.preference_draft.kv_continued_interval_tokens = value;
|
|
self.preference_error = None;
|
|
}
|
|
Message::ResetPreferences => {
|
|
self.preference_draft.reset();
|
|
self.preference_error = None;
|
|
}
|
|
Message::SavePreferences => {
|
|
self.save_preferences();
|
|
if self.preference_error.is_none()
|
|
&& let Some(id) = self.preferences_window
|
|
{
|
|
return window::close(id);
|
|
}
|
|
}
|
|
Message::DownloadArtifact(artifact) => {
|
|
self.start_model_operation(artifact, ModelOperation::Download)
|
|
}
|
|
Message::ValidateArtifact(artifact) => {
|
|
self.start_model_operation(artifact, ModelOperation::Validate)
|
|
}
|
|
Message::DeleteArtifact(artifact) => self.pending_model_delete = Some(artifact),
|
|
Message::ConfirmDeleteArtifact => {
|
|
if let Some(artifact) = self.pending_model_delete.take() {
|
|
match model::delete_managed_artifact(artifact, &models_path()) {
|
|
Ok(()) => {
|
|
self.model_download = ModelDownload::Idle;
|
|
self.error = None;
|
|
}
|
|
Err(error) => {
|
|
self.error = Some(format!("Could not delete {artifact}: {error}"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Message::CancelDeleteArtifact => self.pending_model_delete = None,
|
|
Message::StopModelDownload => {
|
|
if let ModelDownload::Active(download) = &mut self.model_download {
|
|
download.stopping = true;
|
|
download.cancel.store(true, Ordering::Relaxed);
|
|
}
|
|
}
|
|
Message::DownloadProgressTick => self.update_download_progress(),
|
|
Message::ComposerChanged(value) => self.composer = value,
|
|
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 = 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"))]
|
|
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(()) => {
|
|
for session_id in checkpoint_ids {
|
|
#[cfg(target_os = "macos")]
|
|
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.clear();
|
|
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.clear();
|
|
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.clear();
|
|
self.queued_inputs.clear();
|
|
self.system_prompt_seen_at = 0;
|
|
self.context_used = 0;
|
|
self.tokens_per_second = None;
|
|
}
|
|
self.reload_projects();
|
|
}
|
|
Err(error) => self.error = Some(error),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
pub(crate) fn subscription(&self) -> Subscription<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);
|
|
}
|
|
}
|
|
}
|