diff --git a/AGENTS.md b/AGENTS.md index 4accf05..5f6b4b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ Run before every commit: ```sh cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings -cargo build --all-targets --all-features +make bundle cargo test --all-features ``` diff --git a/Cargo.toml b/Cargo.toml index 3c883d7..c965870 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,12 @@ diesel_migrations = "2.3.2" iced = { version = "0.13.1", features = ["svg"] } rfd = "0.15.4" -[package.metadata.bundle] -name = "DS4Server" +[package.metadata.packager] +product-name = "DS4Server" identifier = "DS4Server.rfc1437.de" -osx_minimum_system_version = "13.0" +description = "A native macOS coding-agent GUI for DwarfStar" +binaries = [{ path = "ds4-server", main = true }] +icons = ["assets/DS4Server.icns", "assets/app-icon.png"] + +[package.metadata.packager.macos] +minimum-system-version = "13.0" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..01f4893 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +.PHONY: bundle + +bundle: + cargo build --release --all-targets --all-features + cargo packager --release --formats app + codesign --force --deep --sign - --timestamp=none target/release/DS4Server.app diff --git a/PLAN.md b/PLAN.md index 09c18ca..65dfe09 100644 --- a/PLAN.md +++ b/PLAN.md @@ -8,11 +8,30 @@ reference: `ds4.c`/`ds4.h` define model and session behavior, `ds4_server.c` defines the compatible API, and `ds4_agent.c` defines agent sessions, tools, and turn orchestration. -## Phase 0 — project and session shell (implemented) +## Current status + +| Area | Status | Available now | Still open | +| --- | --- | --- | --- | +| App shell | Implemented | Native Iced window, Codex-inspired two-pane layout, dark theme, embedded SVG icons, `Command-,` Preferences panel | Functional chat content and native app packaging | +| Projects and sessions | Implemented for metadata | Native folder picker, project-name dialog, create/select/delete projects and sessions | Transcripts, KV payloads, rename/archive, and model binding | +| Persistence | Implemented for metadata and preferences | SQLite in Application Support, Diesel ORM, embedded migrations, constrained defaults and CRUD tests | Messages, downloaded-artifact metadata, and session-runtime migrations | +| Model runtime | Preferences only | Persisted model choice, DFlash toggle, and idle timeout | Downloads, loading, inference, idle unloading, and Metal integration | +| Local endpoint | Not started | Nothing listening | OpenAI-compatible HTTP and streaming surfaces | +| Agent | UI shell only | Empty conversation pane and composer placeholder | Messages, generation, tools, approvals, compaction, and cancellation | +| Release | Not started | Development binary builds and tests | `.app` packaging, signing, entitlements, and notarization | + +The application currently manages durable project/session metadata and model +preferences. It does not yet download or load a model, serve HTTP, save chat +messages, or run an agent. + +## Phase 0 — project and session shell + +Status: **implemented**. - Show projects in a sidebar with their sessions nested underneath. - Create projects with a native macOS folder picker and a project-name dialog. - Create, select, and delete projects and sessions. +- Use a Codex-inspired two-pane layout with embedded, action-specific SVG icons. - Persist projects and sessions in SQLite under `~/Library/Application Support/DS4Server.rfc1437.de/`, using Diesel ORM queries and embedded Diesel migrations. @@ -22,6 +41,9 @@ Exit criterion: restart the app and see the same project/session tree. ## Phase 1 — model library and on-demand loading +Status: **partially implemented**. Preferences and their schema are complete; +model downloads, loading, inference, and idle unloading remain open. + 1. Port the narrow GGUF loader, tokenizer, prompt renderer, and Metal session API behind a small Rust `Engine` surface modeled on `ds4.h`: `open`, `close`, `create_session`, `sync`, `sample`, and KV save/load. @@ -30,31 +52,76 @@ Exit criterion: restart the app and see the same project/session tree. 3. Reuse the existing `.metal` kernels and preserve mmap-backed resident loading plus the explicit SSD-streaming path. Keep Objective-C only at the Metal interop boundary. -4. Add a model library screen that records GGUF paths and loading options. - Load only after the user chooses a model. Expose - `Unloaded → Loading → Ready/Failed → Unloading` state and progress in Iced. -5. Keep one engine resident initially, matching DwarfStar's instance-lock and - memory assumptions. Switching models unloads the current engine first. -6. Move engine work off the UI thread and support cooperative cancellation at +4. Keep one engine resident at most, matching DwarfStar's instance-lock and + memory assumptions. +5. Move engine work off the UI thread and support cooperative cancellation at the safe session boundaries already defined by `ds4_session_sync`. -Persist model references and tuning options in Application Support. For a -sandboxed distribution, store security-scoped bookmarks rather than assuming -raw paths remain accessible. +### Lazy lifecycle + +- Start the application and the local HTTP endpoint with no model loaded. +- Acquire the configured model only when generation starts, whether initiated + by a local chat or an OpenAI-compatible endpoint request. Concurrent first + requests share the same load instead of opening the model more than once. +- Expose `Unloaded → Downloading → Loading → Ready → Unloading` plus failure + states and progress in the GUI. A request that triggered loading waits for + the shared load and receives a clear error if download or initialization + fails. +- Treat active local generations, endpoint requests, prefills, and tool turns + as model activity. Start the idle timer only when all activity has stopped. +- Unload the engine after the configured idle timeout. New work cancels a + pending unload; work arriving after unload transparently loads the model + again. Persisted transcripts and KV files remain available when model memory + is released. +- Changing the selected model prevents new work from using the old model and + unloads it as soon as current work reaches a safe boundary. + +### Preferences (implemented) + +- Provide a Preferences panel from the sidebar and the standard + macOS `Command-,` shortcut. +- Let the user choose the active model. Default to `deepseek-v4-flash`. +- Let the user enable or disable DFlash for the selected model. Disable the + control for models that do not support DFlash. +- Let the user configure the inactivity timeout; default to 10 minutes. +- Persist preferences in the application SQLite database. Changing preferences + never loads a model by itself. + +### Targeted downloads and storage + +- Store managed model artifacts under + `~/Library/Application Support/DS4Server.rfc1437.de/models//` and + mmap/load them from there. Do not place managed models in the app bundle or a + project directory. +- Download only the selected model's main artifact. Download its DFlash + artifact only when DFlash is enabled; never prefetch other catalog models or + optional artifacts. +- Verify size/checksum before making a download usable, resume partial + downloads, and remove failed temporary files without touching a valid model. +- When the selected model changes or DFlash is disabled, offer removal of the + now-unused artifact; do not delete large existing downloads without explicit + confirmation. Exit criterion: select a supported DeepSeek V4 or GLM 5.2 GGUF, load it without -blocking the UI, run a deterministic prompt, unload it, and pass the matching -`../ds4` token-output fixture. +blocking the UI only when a chat begins, run a deterministic prompt, unload it +after the configured idle timeout, and pass the matching `../ds4` token-output +fixture. Repeat through the local endpoint and verify both paths share the same +engine lifecycle. ## Phase 2 — OpenAI-compatible local endpoint -1. Add a localhost-only HTTP service owned by the loaded engine. Start and stop - it from the GUI; never expose a LAN listener without an explicit setting. +Status: **open; not started**. + +1. Add a localhost-only HTTP service whose lifecycle is independent of the + engine. It can listen while the model is unloaded and acquires the configured + model only for inference. Start and stop it from the GUI; never expose a LAN + listener without an explicit setting. 2. Implement the DwarfStar compatibility surface in this order: - `GET /v1/models` - `POST /v1/chat/completions` - `POST /v1/responses` - `POST /v1/completions` + `GET /v1/models` reports the configured model without forcing it to load. 3. Support streaming SSE, reasoning output, usage accounting, cancellation, sampling parameters, tool schemas, and tool choice. 4. Port exact sampled tool-call replay and deterministic canonicalization from @@ -72,11 +139,17 @@ multi-turn tool call. ## Phase 3 — durable agent sessions +Status: **partially open**. Project/session metadata rows exist; transcripts, +model binding, and KV payload persistence are not implemented. + Replace each metadata-only session with a directory: ```text Application Support/DS4Server.rfc1437.de/ data.sqlite3 + models// + main.gguf + dflash.gguf # only when enabled sessions// kv.bin ``` @@ -97,6 +170,9 @@ the same conversation without prefill when its KV payload is compatible. ## Phase 4 — agent chat and tools +Status: **UI shell only**. The conversation area, composer, and icons are +visual placeholders with no message or agent runtime behind them. + 1. Build the classic chat surface: transcript, reasoning disclosure, tool-call cards, composer, queued input, stop button, prefill progress, and generation statistics. @@ -123,6 +199,11 @@ continue from the persisted session. ## Verification and packaging +Status: **partially implemented**. Formatting, Clippy, build, and unit-test +commit gates are documented in `AGENTS.md`; database migration/CRUD constraints +and the preferences shortcut have unit coverage. Model fixtures, integration +tests, and release packaging remain open. + - Keep unit coverage narrow: persistence round trips, format compatibility, request parsing, and tool-boundary checks. - Reuse `../ds4` fixtures for prompt rendering, sampling, server streaming, and @@ -131,3 +212,14 @@ continue from the persisted session. reused or adapted source and kernels. - Produce a signed `.app` with the exact identifier above, then add hardened runtime, entitlements, notarization, and update delivery as release work. + +## Recommended next milestones + +1. Add the targeted download manager and model catalog, storing only selected + artifacts under Application Support with resumable, verified downloads. +2. Implement the single-engine lifecycle and local generation path: lazy load, + shared concurrent acquisition, activity tracking, and idle unload. +3. Put the OpenAI-compatible endpoint on that same engine lifecycle so local + chats and HTTP requests cannot load duplicate engines. +4. Add transcript/KV persistence, then connect the existing conversation UI and + finally port the agent tools. diff --git a/README.md b/README.md index 4b4b1b4..63763ba 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,14 @@ OpenAI-compatible localhost endpoint, and project-scoped agent chat in one app. The current milestone provides a Codex-inspired project/session layout. A native macOS folder picker selects each workspace, then the app asks for its display -name. Projects and sessions are persisted through Diesel in SQLite. +name. Projects, sessions, and model preferences are persisted through Diesel in +SQLite. Open Preferences with `Command-,` to select the model, toggle DFlash, +and configure the model idle timeout. ```sh -cargo run +cargo install cargo-packager --locked --version 0.11.8 +make bundle +open target/release/DS4Server.app ``` State is stored at: diff --git a/assets/DS4Server.icns b/assets/DS4Server.icns new file mode 100644 index 0000000..52bf65b Binary files /dev/null and b/assets/DS4Server.icns differ diff --git a/assets/app-icon.png b/assets/app-icon.png new file mode 100644 index 0000000..41babde Binary files /dev/null and b/assets/app-icon.png differ diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg new file mode 100644 index 0000000..171dd11 --- /dev/null +++ b/assets/icons/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/migrations/20260724140000_create_preferences/down.sql b/migrations/20260724140000_create_preferences/down.sql new file mode 100644 index 0000000..5dab8c4 --- /dev/null +++ b/migrations/20260724140000_create_preferences/down.sql @@ -0,0 +1 @@ +DROP TABLE preferences; diff --git a/migrations/20260724140000_create_preferences/up.sql b/migrations/20260724140000_create_preferences/up.sql new file mode 100644 index 0000000..9ab8926 --- /dev/null +++ b/migrations/20260724140000_create_preferences/up.sql @@ -0,0 +1,12 @@ +CREATE TABLE preferences ( + id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1), + selected_model TEXT NOT NULL DEFAULT 'deepseek-v4-flash' + CHECK (selected_model IN ('deepseek-v4-flash', 'deepseek-v4-pro', 'glm-5.2')), + dflash_enabled BOOLEAN NOT NULL DEFAULT 0 + CHECK (dflash_enabled IN (0, 1)), + idle_timeout_minutes INTEGER NOT NULL DEFAULT 10 + CHECK (idle_timeout_minutes BETWEEN 1 AND 1440), + CHECK (dflash_enabled = 0 OR selected_model = 'deepseek-v4-flash') +); + +INSERT INTO preferences (id) VALUES (1); diff --git a/src/database.rs b/src/database.rs index ed567f0..bf1b541 100644 --- a/src/database.rs +++ b/src/database.rs @@ -4,10 +4,39 @@ use std::collections::HashMap; use std::fs; use std::path::Path; -use crate::schema::{projects, sessions}; +use crate::schema::{preferences, projects, sessions}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); +#[derive(Clone, Debug, Identifiable, Queryable, Selectable)] +#[diesel(table_name = preferences)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct AppPreferences { + pub id: i32, + pub selected_model: String, + pub dflash_enabled: bool, + pub idle_timeout_minutes: i32, +} + +impl Default for AppPreferences { + fn default() -> Self { + Self { + id: 1, + selected_model: "deepseek-v4-flash".into(), + dflash_enabled: false, + idle_timeout_minutes: 10, + } + } +} + +#[derive(AsChangeset)] +#[diesel(table_name = preferences)] +struct PreferenceChanges<'a> { + selected_model: &'a str, + dflash_enabled: bool, + idle_timeout_minutes: i32, +} + #[derive(Clone, Debug, Identifiable, Queryable, Selectable)] #[diesel(table_name = projects)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] @@ -95,6 +124,31 @@ impl Database { .collect()) } + pub fn load_preferences(&mut self) -> Result { + preferences::table + .find(1) + .select(AppPreferences::as_select()) + .first(&mut self.connection) + .map_err(|error| error.to_string()) + } + + pub fn update_preferences( + &mut self, + selected_model: &str, + dflash_enabled: bool, + idle_timeout_minutes: i32, + ) -> Result { + diesel::update(preferences::table.find(1)) + .set(PreferenceChanges { + selected_model, + dflash_enabled, + idle_timeout_minutes, + }) + .returning(AppPreferences::as_returning()) + .get_result(&mut self.connection) + .map_err(|error| error.to_string()) + } + pub fn create_project(&mut self, name: &str, path: &str) -> Result { diesel::insert_into(projects::table) .values(NewProject { name, path }) @@ -144,6 +198,18 @@ mod tests { let path = std::env::temp_dir().join(format!("ds4-server-{id}.sqlite3")); let mut database = Database::open(&path).unwrap(); + let preferences = database.load_preferences().unwrap(); + assert_eq!(preferences.selected_model, "deepseek-v4-flash"); + assert!(!preferences.dflash_enabled); + assert_eq!(preferences.idle_timeout_minutes, 10); + assert!(database.update_preferences("glm-5.2", true, 30).is_err()); + assert!( + database + .update_preferences("deepseek-v4-flash", false, 0) + .is_err() + ); + database.update_preferences("glm-5.2", false, 30).unwrap(); + let project = database.create_project("DS4", "/tmp/ds4").unwrap(); let first = database .create_session(project.id, "First session") @@ -158,6 +224,12 @@ mod tests { database.delete_project(project.id).unwrap(); assert!(database.load_projects().unwrap().is_empty()); drop(database); + + let mut reopened = Database::open(&path).unwrap(); + let preferences = reopened.load_preferences().unwrap(); + assert_eq!(preferences.selected_model, "glm-5.2"); + assert_eq!(preferences.idle_timeout_minutes, 30); + drop(reopened); fs::remove_file(path).unwrap(); } } diff --git a/src/main.rs b/src/main.rs index af90a51..43bf141 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,15 @@ mod database; mod schema; -use database::{Database, ProjectWithSessions, Session}; -use iced::theme::Palette; +use database::{AppPreferences, Database, ProjectWithSessions, Session}; +use iced::theme::{Palette, palette}; use iced::widget::{ - Space, Svg, button, column, container, opaque, row, scrollable, stack, svg, text, text_input, + Space, Svg, button, checkbox, column, container, opaque, pick_list, row, scrollable, stack, + svg, text, text_input, }; -use iced::{Alignment, Color, Element, Length, Size, Task, Theme}; +use iced::{Alignment, Color, Element, Length, Size, Subscription, Task, Theme, keyboard}; use rfd::AsyncFileDialog; +use std::fmt; use std::fs; use std::path::{Path, PathBuf}; @@ -16,7 +18,7 @@ const ICON_FOLDER: &[u8] = include_bytes!("../assets/icons/folder.svg"); const ICON_FOLDER_PLUS: &[u8] = include_bytes!("../assets/icons/folder-plus.svg"); const ICON_NEW_SESSION: &[u8] = include_bytes!("../assets/icons/new-session.svg"); const ICON_CHAT: &[u8] = include_bytes!("../assets/icons/chat.svg"); -const ICON_SEARCH: &[u8] = include_bytes!("../assets/icons/search.svg"); +const ICON_SETTINGS: &[u8] = include_bytes!("../assets/icons/settings.svg"); const ICON_TRASH: &[u8] = include_bytes!("../assets/icons/trash.svg"); const ICON_MORE: &[u8] = include_bytes!("../assets/icons/more.svg"); const ICON_PAPERCLIP: &[u8] = include_bytes!("../assets/icons/paperclip.svg"); @@ -24,20 +26,71 @@ const ICON_SEND: &[u8] = include_bytes!("../assets/icons/send.svg"); const ICON_MODEL: &[u8] = include_bytes!("../assets/icons/model.svg"); const ICON_SPARK: &[u8] = include_bytes!("../assets/icons/spark.svg"); +const MODEL_CHOICES: [ModelChoice; 3] = [ + ModelChoice::DeepSeekV4Flash, + ModelChoice::DeepSeekV4Pro, + ModelChoice::Glm52, +]; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum ModelChoice { + #[default] + DeepSeekV4Flash, + DeepSeekV4Pro, + Glm52, +} + +impl ModelChoice { + fn id(self) -> &'static str { + match self { + Self::DeepSeekV4Flash => "deepseek-v4-flash", + Self::DeepSeekV4Pro => "deepseek-v4-pro", + Self::Glm52 => "glm-5.2", + } + } + + fn from_id(id: &str) -> Option { + MODEL_CHOICES.into_iter().find(|model| model.id() == id) + } + + fn supports_dflash(self) -> bool { + self == Self::DeepSeekV4Flash + } +} + +impl fmt::Display for ModelChoice { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::DeepSeekV4Flash => "DeepSeek V4 Flash", + Self::DeepSeekV4Pro => "DeepSeek V4 Pro", + Self::Glm52 => "GLM 5.2", + }) + } +} + +#[derive(Clone)] +struct PreferenceDraft { + model: ModelChoice, + dflash_enabled: bool, + idle_timeout_minutes: String, +} + +impl PreferenceDraft { + fn from_saved(preferences: &AppPreferences) -> Result { + let model = ModelChoice::from_id(&preferences.selected_model) + .ok_or_else(|| format!("Unsupported model: {}", preferences.selected_model))?; + Ok(Self { + model, + dflash_enabled: model.supports_dflash() && preferences.dflash_enabled, + idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(), + }) + } +} + fn main() -> iced::Result { iced::application("DS4Server", App::update, App::view) - .theme(|_| { - Theme::custom( - "DS4Server".into(), - Palette { - background: Color::from_rgb8(29, 29, 31), - text: Color::from_rgb8(235, 235, 235), - primary: Color::from_rgb8(85, 118, 255), - success: Color::from_rgb8(72, 176, 112), - danger: Color::from_rgb8(220, 80, 86), - }, - ) - }) + .subscription(App::subscription) + .theme(|_| app_theme()) .window(iced::window::Settings { size: Size::new(1120.0, 720.0), min_size: Some(Size::new(760.0, 480.0)), @@ -49,17 +102,26 @@ fn main() -> iced::Result { struct App { database: Option, projects: Vec, + preferences: AppPreferences, + preference_draft: PreferenceDraft, + preferences_open: bool, + preference_error: Option, selected_project: Option, selected_session: Option, choosing_folder: bool, pending_project_path: Option, project_name_input: String, - session_title_input: String, error: Option, } #[derive(Debug, Clone)] enum Message { + OpenPreferences, + DismissPanel, + PreferenceModelChanged(ModelChoice), + PreferenceDflashChanged(bool), + PreferenceTimeoutChanged(String), + SavePreferences, ChooseProjectFolder, ProjectFolderPicked(Option), ProjectNameChanged(String), @@ -67,7 +129,6 @@ enum Message { CancelProject, SelectProject(i32), DeleteProject(i32), - SessionTitleChanged(String), CreateSession, SelectSession(i32, i32), DeleteSession(i32), @@ -77,40 +138,83 @@ impl App { fn load() -> Self { let path = application_support_path().join("data.sqlite3"); match Database::open(&path) { - Ok(mut database) => match database.load_projects() { - Ok(projects) => Self { - database: Some(database), - projects, - selected_project: None, - selected_session: None, - choosing_folder: false, - pending_project_path: None, - project_name_input: String::new(), - session_title_input: String::new(), - error: None, - }, - Err(error) => Self::failed(error), + Ok(mut database) => match (database.load_projects(), database.load_preferences()) { + (Ok(projects), Ok(preferences)) => { + let preference_draft = match PreferenceDraft::from_saved(&preferences) { + Ok(draft) => draft, + Err(error) => return Self::failed(error), + }; + Self { + database: Some(database), + projects, + preferences, + preference_draft, + preferences_open: false, + preference_error: None, + selected_project: None, + selected_session: None, + choosing_folder: false, + pending_project_path: None, + project_name_input: String::new(), + error: None, + } + } + (Err(error), _) | (_, Err(error)) => Self::failed(error), }, Err(error) => Self::failed(error), } } fn failed(error: String) -> Self { + let preferences = AppPreferences::default(); + let preference_draft = PreferenceDraft::from_saved(&preferences) + .expect("default preferences must use a supported model"); Self { database: None, projects: Vec::new(), + preferences, + preference_draft, + preferences_open: false, + preference_error: None, selected_project: None, selected_session: None, choosing_folder: false, pending_project_path: None, project_name_input: String::new(), - session_title_input: String::new(), error: Some(format!("Could not open the project database: {error}")), } } fn update(&mut self, message: Message) -> Task { match message { + Message::OpenPreferences => self.open_preferences(), + Message::DismissPanel => { + if self.preferences_open { + self.preferences_open = false; + self.preference_error = None; + } else if self.pending_project_path.is_some() { + self.pending_project_path = None; + self.project_name_input.clear(); + self.error = None; + } + } + Message::PreferenceModelChanged(model) => { + self.preference_draft.model = model; + if !model.supports_dflash() { + self.preference_draft.dflash_enabled = false; + } + self.preference_error = None; + } + Message::PreferenceDflashChanged(enabled) => { + self.preference_draft.dflash_enabled = + self.preference_draft.model.supports_dflash() && enabled; + self.preference_error = None; + } + Message::PreferenceTimeoutChanged(value) => { + self.preference_draft.idle_timeout_minutes = value; + self.preference_error = None; + } + Message::SavePreferences => self.save_preferences(), Message::ChooseProjectFolder => { self.choosing_folder = true; return Task::perform( @@ -156,7 +260,6 @@ impl App { } } } - Message::SessionTitleChanged(value) => self.session_title_input = value, Message::CreateSession => self.create_session(), Message::SelectSession(project_id, session_id) => { self.selected_project = Some(project_id); @@ -180,6 +283,57 @@ impl App { Task::none() } + fn subscription(&self) -> Subscription { + keyboard::on_key_press(shortcut) + } + + fn open_preferences(&mut self) { + if self.database.is_none() || self.pending_project_path.is_some() || self.choosing_folder { + return; + } + match PreferenceDraft::from_saved(&self.preferences) { + Ok(draft) => { + self.preference_draft = draft; + self.preference_error = None; + self.preferences_open = true; + } + Err(error) => self.error = Some(error), + } + } + + fn save_preferences(&mut self) { + let Ok(idle_timeout_minutes) = self + .preference_draft + .idle_timeout_minutes + .trim() + .parse::() + else { + self.preference_error = Some("Idle timeout must be a whole number.".into()); + return; + }; + if !(1..=1440).contains(&idle_timeout_minutes) { + self.preference_error = Some("Idle timeout must be between 1 and 1440 minutes.".into()); + return; + } + + let model = self.preference_draft.model; + let dflash_enabled = model.supports_dflash() && self.preference_draft.dflash_enabled; + let Some(database) = &mut self.database else { + return; + }; + match database.update_preferences(model.id(), dflash_enabled, idle_timeout_minutes) { + Ok(preferences) => { + self.preferences = preferences; + self.preference_draft = PreferenceDraft::from_saved(&self.preferences) + .expect("the saved model was selected from the supported catalog"); + self.preferences_open = false; + self.preference_error = None; + self.error = None; + } + Err(error) => self.preference_error = Some(error), + } + } + fn prepare_project(&mut self, path: PathBuf) { let Ok(path) = fs::canonicalize(path) else { self.error = Some("The selected folder is no longer available.".into()); @@ -246,10 +400,7 @@ impl App { .selected_project() .map(|project| project.sessions.len() + 1) .unwrap_or(1); - let title = match self.session_title_input.trim() { - "" => format!("Session {default_number}"), - title => title.to_owned(), - }; + let title = format!("Session {default_number}"); let Some(database) = &mut self.database else { return; }; @@ -257,7 +408,6 @@ impl App { match database.create_session(project_id, &title) { Ok(session) => { self.selected_session = Some(session.id); - self.session_title_input.clear(); self.error = None; self.reload_projects(); } @@ -290,7 +440,9 @@ impl App { let content: Element<'_, Message> = shell.into(); let mut layers = vec![content]; - if let Some(path) = &self.pending_project_path { + if self.preferences_open { + layers.push(self.preferences_panel()); + } else if let Some(path) = &self.pending_project_path { layers.push(self.project_dialog(path)); } @@ -311,6 +463,21 @@ impl App { } else { button(new_session_content).width(Length::Fill) }; + let preference_content = row![ + icon(ICON_SETTINGS, 17), + text("Preferences").size(14), + Space::with_width(Length::Fill), + text("⌘,").size(12), + ] + .spacing(9) + .align_y(Alignment::Center); + let preferences = if self.database.is_some() { + button(preference_content) + .width(Length::Fill) + .on_press(Message::OpenPreferences) + } else { + button(preference_content).width(Length::Fill) + }; let add_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Add project…").size(14),] .spacing(9) .align_y(Alignment::Center); @@ -321,14 +488,17 @@ impl App { .width(Length::Fill) .on_press(Message::ChooseProjectFolder) }; - let mut projects = column![ + let header = column![ row![ text("DS4Server").size(20), Space::with_width(Length::Fill), - icon(ICON_SEARCH, 18), + text("Local").size(12), ] .align_y(Alignment::Center), new_session.style(button::text), + ] + .spacing(10); + let mut projects = column![ row![ text("PROJECTS").size(11), Space::with_width(Length::Fill), @@ -386,12 +556,19 @@ impl App { } } - container(scrollable(projects).height(Length::Fill)) - .width(276) - .height(Length::Fill) - .padding(16) - .style(sidebar_style) - .into() + container( + column![ + header, + scrollable(projects).height(Length::Fill), + preferences.style(button::text), + ] + .spacing(10), + ) + .width(276) + .height(Length::Fill) + .padding(16) + .style(sidebar_style) + .into() } fn detail(&self) -> Element<'_, Message> { @@ -432,14 +609,6 @@ impl App { text(selected_title).size(18), icon(ICON_MORE, 18), Space::with_width(Length::Fill), - text_input("Session title", &self.session_title_input) - .on_input(Message::SessionTitleChanged) - .on_submit(Message::CreateSession) - .width(190) - .padding(8), - button("New session") - .on_press(Message::CreateSession) - .style(button::primary), ] .spacing(10) .align_y(Alignment::Center); @@ -461,7 +630,12 @@ impl App { icon(ICON_PAPERCLIP, 19), Space::with_width(Length::Fill), icon(ICON_MODEL, 16), - text("Local model").size(12), + text( + ModelChoice::from_id(&self.preferences.selected_model) + .unwrap_or_default() + .to_string(), + ) + .size(12), button(icon(ICON_SEND, 18)), ] .align_y(Alignment::Center), @@ -505,6 +679,87 @@ impl App { .into() } + fn preferences_panel(&self) -> Element<'_, Message> { + let dflash_toggle: Option Message> = self + .preference_draft + .model + .supports_dflash() + .then_some(Message::PreferenceDflashChanged); + let dflash = checkbox( + "Enable DFlash for this model", + self.preference_draft.dflash_enabled, + ) + .on_toggle_maybe(dflash_toggle); + + let mut content = column![ + row![ + icon(ICON_SETTINGS, 22), + text("Preferences").size(24), + Space::with_width(Length::Fill), + text("⌘,").size(12), + ] + .spacing(10) + .align_y(Alignment::Center), + Space::with_height(6), + text("MODEL").size(11), + pick_list( + &MODEL_CHOICES[..], + Some(self.preference_draft.model), + Message::PreferenceModelChanged, + ) + .width(Length::Fill), + dflash, + text(if self.preference_draft.model.supports_dflash() { + "DFlash is downloaded and used only when enabled." + } else { + "DFlash is not available for the selected model." + }) + .size(12), + Space::with_height(8), + text("INACTIVITY").size(11), + row![ + text_input("10", &self.preference_draft.idle_timeout_minutes) + .on_input(Message::PreferenceTimeoutChanged) + .width(90) + .padding(9), + text("minutes before unloading the model").size(13), + ] + .spacing(10) + .align_y(Alignment::Center), + text("Enter a whole number from 1 to 1440.").size(12), + ] + .spacing(10); + + if let Some(error) = &self.preference_error { + content = content.push(text(error).style(iced::widget::text::danger)); + } + content = content.push( + row![ + Space::with_width(Length::Fill), + button("Cancel") + .on_press(Message::DismissPanel) + .style(button::secondary), + button("Save") + .on_press(Message::SavePreferences) + .style(button::primary), + ] + .spacing(8), + ); + + let panel = container(content) + .padding(24) + .width(520) + .style(container::rounded_box); + opaque( + container(panel) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(|_| { + container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68)) + }), + ) + } + fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> { let dialog = container( column![ @@ -556,6 +811,33 @@ impl App { } } +fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option { + match key.as_ref() { + keyboard::Key::Character(",") if modifiers.command() => Some(Message::OpenPreferences), + keyboard::Key::Named(keyboard::key::Named::Escape) => Some(Message::DismissPanel), + _ => None, + } +} + +fn app_theme() -> Theme { + let palette = Palette { + background: Color::from_rgb8(29, 29, 31), + text: Color::from_rgb8(235, 235, 235), + primary: Color::from_rgb8(85, 118, 255), + success: Color::from_rgb8(72, 176, 112), + danger: Color::from_rgb8(220, 80, 86), + }; + Theme::custom_with_fn("DS4Server".into(), palette, |palette| { + let mut extended = palette::Extended::generate(palette); + extended.background.weak = palette::Pair::new(Color::from_rgb8(38, 38, 40), palette.text); + extended.background.strong = palette::Pair::new(Color::from_rgb8(58, 58, 61), palette.text); + extended.secondary.base = palette::Pair::new(Color::from_rgb8(47, 47, 50), palette.text); + extended.secondary.weak = palette::Pair::new(Color::from_rgb8(41, 41, 44), palette.text); + extended.secondary.strong = palette::Pair::new(Color::from_rgb8(57, 57, 60), palette.text); + extended + }) +} + fn sidebar_style(_: &Theme) -> container::Style { container::Style::default().background(Color::from_rgb8(23, 23, 25)) } @@ -577,3 +859,28 @@ fn application_support_path() -> PathBuf { .join("Application Support") .join(APP_ID) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preferences_shortcut_and_dflash_support_are_explicit() { + let message = shortcut( + keyboard::Key::Character(",".into()), + keyboard::Modifiers::COMMAND, + ); + assert!(matches!(message, Some(Message::OpenPreferences))); + assert!(ModelChoice::DeepSeekV4Flash.supports_dflash()); + assert!(!ModelChoice::DeepSeekV4Pro.supports_dflash()); + assert!(!ModelChoice::Glm52.supports_dflash()); + } + + #[test] + fn raised_surfaces_stay_dark() { + let theme = app_theme(); + let palette = theme.extended_palette(); + assert_eq!(palette.background.weak.color, Color::from_rgb8(38, 38, 40)); + assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50)); + } +} diff --git a/src/schema.rs b/src/schema.rs index 550e4a2..7600ed2 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -6,6 +6,15 @@ diesel::table! { } } +diesel::table! { + preferences (id) { + id -> Integer, + selected_model -> Text, + dflash_enabled -> Bool, + idle_timeout_minutes -> Integer, + } +} + diesel::table! { sessions (id) { id -> Integer, @@ -15,4 +24,4 @@ diesel::table! { } diesel::joinable!(sessions -> projects (project_id)); -diesel::allow_tables_to_appear_in_same_query!(projects, sessions); +diesel::allow_tables_to_appear_in_same_query!(preferences, projects, sessions);