From 9bd648d77b44a9f3f4b4874e3f84700832089817 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Sun, 26 Jul 2026 00:04:57 +0200 Subject: [PATCH] Explain each preference in a hover tooltip --- src/app/view.rs | 20 +++- src/app/view/preferences.rs | 189 ++++++++++++++++++++++++++---------- 2 files changed, 154 insertions(+), 55 deletions(-) diff --git a/src/app/view.rs b/src/app/view.rs index 29b679b..e46c97d 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -16,8 +16,8 @@ use crate::model::{ use crate::settings::{GIB, REASONING_MODES}; use iced::theme::{Palette, palette}; use iced::widget::{ - Button, Space, Svg, button, checkbox, column, container, horizontal_rule, markdown, opaque, - pick_list, progress_bar, row, scrollable, stack, svg, text, text_input, + Button, Space, Svg, Tooltip, button, checkbox, column, container, horizontal_rule, markdown, + opaque, pick_list, progress_bar, row, scrollable, stack, svg, text, text_input, tooltip, }; use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme, window}; use std::collections::VecDeque; @@ -521,10 +521,11 @@ impl App { fn preference_input_row<'a>( label: &'a str, + description: &'a str, input: iced::widget::TextInput<'a, Message>, ) -> Element<'a, Message> { row![ - text(label).size(13).width(Length::Fill), + hint(text(label).size(13).width(Length::Fill), description), input.width(240).padding(9), ] .spacing(12) @@ -532,6 +533,19 @@ fn preference_input_row<'a>( .into() } +/// Explains a setting on hover, so the title does not have to carry the detail. +fn hint<'a>(title: impl Into>, description: &'a str) -> Tooltip<'a, Message> { + tooltip( + title, + container(text(description).size(12)) + .padding(10) + .max_width(320) + .style(preference_group_style), + tooltip::Position::Bottom, + ) + .gap(6) +} + fn preference_group<'a>( title: &'a str, content: impl Into>, diff --git a/src/app/view/preferences.rs b/src/app/view/preferences.rs index e1c15d0..2057daf 100644 --- a/src/app/view/preferences.rs +++ b/src/app/view/preferences.rs @@ -8,11 +8,14 @@ impl App { .model .supports_dspark() .then_some(Message::PreferenceDsparkChanged); - let dspark = checkbox( - "Enable DSpark for this model", - self.preference_draft.dspark_enabled, - ) - .on_toggle_maybe(dspark_toggle); + let dspark = hint( + checkbox( + "Enable DSpark for this model", + self.preference_draft.dspark_enabled, + ) + .on_toggle_maybe(dspark_toggle), + "Speculative decoding with the managed DSpark draft artifact: a small model proposes tokens that the main model verifies in one pass. Usually a large speedup, and it cannot be combined with SSD streaming.", + ); let glm_mtp_toggle: Option Message> = (self.preference_draft.model == ModelChoice::Glm52) .then_some(Message::PreferenceGlmMtpChanged); @@ -71,12 +74,15 @@ impl App { let model_group = preference_group( "MODEL & LIFECYCLE", column![ - pick_list( - &MODEL_CHOICES[..], - Some(self.preference_draft.model), - Message::PreferenceModelChanged, - ) - .width(Length::Fill), + hint( + pick_list( + &MODEL_CHOICES[..], + Some(self.preference_draft.model), + Message::PreferenceModelChanged, + ) + .width(Length::Fill), + "Which local weights the Metal engine loads for chats and for the HTTP endpoint. The choice decides which accelerations below apply, and the matching artifacts must be downloaded in the model manager.", + ), text(format!( "Main: {}{}", engine.map_or_else( @@ -96,7 +102,10 @@ impl App { .on_input(Message::PreferenceTimeoutChanged) .width(90) .padding(9), - text("minutes before unloading the model").size(13), + hint( + text("minutes before unloading the model").size(13), + "Idle time after the last request before the weights are released. Unloading gives tens of gigabytes of memory back to the system; the next prompt then pays the full load time again.", + ), ] .spacing(10) .align_y(Alignment::Center), @@ -107,21 +116,28 @@ impl App { let endpoint_group = preference_group( "LOCAL ENDPOINT", column![ - checkbox( - "Enable OpenAI-compatible endpoint", - self.preference_draft.endpoint_enabled, - ) - .on_toggle(Message::PreferenceEndpointEnabledChanged), + hint( + checkbox( + "Enable OpenAI-compatible endpoint", + self.preference_draft.endpoint_enabled, + ) + .on_toggle(Message::PreferenceEndpointEnabledChanged), + "Serves the loaded model over an OpenAI-style HTTP API, so editors, scripts and agents on this machine can use it. Turned off, only this window can generate.", + ), preference_input_row( "Port", + "TCP port the local API listens on. Change it when another program already holds 4000; every client has to be pointed at the same number.", text_input("4000", &self.preference_draft.endpoint_port) .on_input(Message::PreferenceEndpointPortChanged), ), - checkbox( - "Allow browser clients (CORS)", - self.preference_draft.endpoint_cors, - ) - .on_toggle(Message::PreferenceEndpointCorsChanged), + hint( + checkbox( + "Allow browser clients (CORS)", + self.preference_draft.endpoint_cors, + ) + .on_toggle(Message::PreferenceEndpointCorsChanged), + "Answers with permissive CORS headers so JavaScript running in a web page may call the endpoint. Leave it off when only native tools connect.", + ), text("Listens only on 127.0.0.1. Saving changed endpoint settings restarts it.") .size(12), ] @@ -132,15 +148,20 @@ impl App { column![ preference_input_row( "Context tokens", + "Size of the window the model can see: system prompt, history, the new question and the answer all have to fit. Larger windows allow longer sessions but reserve much more memory for the key-value cache.", text_input("32768", &self.preference_draft.context_tokens) .on_input(Message::PreferenceContextChanged), ), preference_input_row( "Maximum generated tokens", + "Hard stop for a single reply, counted from the first generated token. It bounds runaway answers and reasoning loops; it does not reserve memory.", text_input("50000", &self.preference_draft.max_generated_tokens) .on_input(Message::PreferenceMaxTokensChanged), ), - text("System prompt").size(13), + hint( + text("System prompt").size(13), + "Standing instruction sent before every conversation: persona, tone, house rules. Leave it empty to send no system message at all.", + ), text_input( "You are a helpful assistant", &self.preference_draft.system_prompt, @@ -151,26 +172,33 @@ impl App { text("SAMPLING & REASONING").size(11).color(muted_text()), preference_input_row( "Temperature", + "How adventurous token choice is. Near 0 the model repeats the most likely continuation, which suits code and extraction; higher values invent more and drift more.", text_input("DS4 default", &self.preference_draft.temperature) .on_input(Message::PreferenceTemperatureChanged), ), preference_input_row( "Top-p", + "Nucleus sampling: at each step only the likeliest tokens that together reach this probability mass stay candidates. Lower cuts the long tail of odd words.", text_input("DS4 default", &self.preference_draft.top_p) .on_input(Message::PreferenceTopPChanged), ), preference_input_row( "Min-p", + "Drops any token less likely than this fraction of the best token. A cheaper tail cut than top-p that keeps working when temperature is high.", text_input("DS4 default", &self.preference_draft.min_p) .on_input(Message::PreferenceMinPChanged), ), preference_input_row( "Seed", + "Fixes the random stream, so the same prompt and settings reproduce the same answer — useful for comparing configurations. Blank draws a fresh seed per request.", text_input("Random", &self.preference_draft.seed) .on_input(Message::PreferenceSeedChanged), ), row![ - text("Reasoning").size(13).width(Length::Fill), + hint( + text("Reasoning").size(13).width(Length::Fill), + "How much hidden thinking precedes the answer. Direct skips it and replies fastest, Thinking is the balanced default, Think Max reasons longest and needs at least 393216 context tokens.", + ), pick_list( &REASONING_MODES[..], Some(self.preference_draft.reasoning_mode), @@ -205,15 +233,30 @@ impl App { column![ preference_input_row( "CPU helper threads", + "Worker threads for the work that stays on the CPU while the GPU decodes: tokenizing, sampling and cache moves. Blank lets the engine pick; the engine never uses more than 32.", text_input("Automatic", &self.preference_draft.cpu_threads) .on_input(Message::PreferenceCpuThreadsChanged), ), - preference_input_row("GPU power percent", power), - preference_input_row("Prefill chunk", prefill), - checkbox("Prefer exact quality kernels", self.preference_draft.quality) - .on_toggle(Message::PreferenceQualityChanged), - checkbox("Warm mapped weights at load time", self.preference_draft.warm_weights) - .on_toggle(Message::PreferenceWarmWeightsChanged), + preference_input_row( + "GPU power percent", + "Throttles how hard the Metal engine drives the GPU, from 1 to 100. Lowering it keeps the machine cool and the desktop responsive, and costs tokens per second.", + power, + ), + preference_input_row( + "Prefill chunk", + "How many prompt tokens are pushed through the model per batch before generation starts. Bigger chunks prefill long prompts faster but raise peak memory; blank sizes them automatically.", + prefill, + ), + hint( + checkbox("Prefer exact quality kernels", self.preference_draft.quality) + .on_toggle(Message::PreferenceQualityChanged), + "Runs the exact Metal kernels instead of the fast approximations. Slightly slower, and it removes the small numeric differences those approximations introduce.", + ), + hint( + checkbox("Warm mapped weights at load time", self.preference_draft.warm_weights) + .on_toggle(Message::PreferenceWarmWeightsChanged), + "Reads every mapped weight page once at load, so the first reply is not interrupted by page faults from disk. Loading takes longer and memory pressure rises immediately.", + ), text(if self.preference_draft.model == ModelChoice::Glm52 { "GLM 5.2 uses full GPU power and selects prefill chunks automatically." } else { @@ -245,28 +288,43 @@ impl App { text("SPECULATIVE DECODING").size(11).color(muted_text()), preference_input_row( "MTP draft tokens", + "How many tokens the multi-token-prediction head guesses ahead for the main model to check in a single pass. More drafting pays off on predictable text and is wasted work on surprising text; the engine caps it at 16.", text_input("1", &self.preference_draft.mtp_draft_tokens) .on_input(Message::PreferenceMtpDraftChanged), ), preference_input_row( "MTP verifier margin", + "How much more likely the main model must find a drafted token before accepting it. A high margin accepts few drafts and stays close to plain decoding; a low one accepts more and rolls back more often.", text_input("3", &self.preference_draft.mtp_margin) .on_input(Message::PreferenceMtpMarginChanged), ), - checkbox("Enable integrated GLM MTP", self.preference_draft.glm_mtp) - .on_toggle_maybe(glm_mtp_toggle), - checkbox( - "Log GLM MTP timing counters", - self.preference_draft.glm_mtp_timing, - ) - .on_toggle_maybe(glm_mtp_timing_toggle), + hint( + checkbox("Enable integrated GLM MTP", self.preference_draft.glm_mtp) + .on_toggle_maybe(glm_mtp_toggle), + "Uses the prediction head built into GLM 5.2 for speculative decoding, so no separate draft model is loaded. Available for GLM 5.2 only.", + ), + hint( + checkbox( + "Log GLM MTP timing counters", + self.preference_draft.glm_mtp_timing, + ) + .on_toggle_maybe(glm_mtp_timing_toggle), + "Records per-stage timings of the speculative path to the log, to show where the acceleration actually goes. A diagnostic aid that costs a little throughput.", + ), dspark, - preference_input_row("DSpark confidence threshold", dspark_confidence), - checkbox( - "DSpark target-only decode", - self.preference_draft.dspark_strict, - ) - .on_toggle_maybe(dspark_strict_toggle), + preference_input_row( + "DSpark confidence threshold", + "How sure the draft model must be, from 0 to 1, before its token is handed to the verifier. Lower forwards more guesses for more speed and more rejected work; blank uses DS4's 0.9.", + dspark_confidence, + ), + hint( + checkbox( + "DSpark target-only decode", + self.preference_draft.dspark_strict, + ) + .on_toggle_maybe(dspark_strict_toggle), + "Lets the draft model only propose, never decide: every token is sampled by the full model. Gives up some of the speedup in exchange for output identical to non-speculative decoding.", + ), text(if self.preference_draft.model.supports_dspark() { "DSpark uses the managed support artifact; entering a threshold or enabling strict mode also enables DSpark." } else if self.preference_draft.model == ModelChoice::Glm52 { @@ -296,18 +354,30 @@ impl App { .size(12), Space::with_height(6), text("SSD STREAMING").size(11).color(muted_text()), - checkbox("Enable SSD-backed model streaming", self.preference_draft.ssd_streaming) - .on_toggle(Message::PreferenceSsdChanged), - checkbox("Skip automatic expert preload", self.preference_draft.ssd_streaming_cold) - .on_toggle(Message::PreferenceSsdColdChanged), + hint( + checkbox("Enable SSD-backed model streaming", self.preference_draft.ssd_streaming) + .on_toggle(Message::PreferenceSsdChanged), + "Leaves the routed expert weights on disk and pages them in as they are needed, so a model larger than this machine's memory still runs. Every cache miss waits for the SSD, and DSpark cannot run alongside it.", + ), + hint( + checkbox("Skip automatic expert preload", self.preference_draft.ssd_streaming_cold) + .on_toggle(Message::PreferenceSsdColdChanged), + "Starts with an empty expert cache instead of reading the likely experts up front. The model is ready sooner and uses less memory, at the price of slow first replies.", + ), preference_input_row( "Expert cache count or GiB", + "How much of the streamed experts stay in memory: a plain count such as 128, or a size such as 64GB. A larger cache means fewer trips to the SSD once the working set settles.", text_input("Automatic, 128, or 64GB", &self.preference_draft.ssd_cache) .on_input(Message::PreferenceSsdCacheChanged), ), - preference_input_row("Fully resident GLM layers", ssd_full_layers), + preference_input_row( + "Fully resident GLM layers", + "How many of the first GLM layers are held in memory complete rather than streamed, keeping the hottest part of the model off the disk path. Blank decides automatically, an explicit 0 streams everything.", + ssd_full_layers, + ), preference_input_row( "Explicit expert preload count", + "Overrides how many experts are read in before the first token when preload is on. Blank derives the number from the cache budget.", text_input("Automatic", &self.preference_draft.ssd_preload_experts) .on_input(Message::PreferenceSsdPreloadChanged), ), @@ -344,10 +414,21 @@ impl App { "STEERING & DIAGNOSTICS", column![ text("DIRECTIONAL STEERING").size(11).color(muted_text()), - text("Direction-vector file").size(13), + hint( + text("Direction-vector file").size(13), + "Path to a saved activation direction — a vector distilled from contrasting examples — that is added during generation to push the model toward or away from a behaviour. With no file, steering stays off.", + ), steering_file.padding(9), - preference_input_row("FFN scale", steering_ffn), - preference_input_row("Attention scale", steering_attn), + preference_input_row( + "FFN scale", + "How strongly the direction is added to the feed-forward activations, -100 through 100. Negative values steer away from it; with a file and no explicit scale DS4 uses 1.", + steering_ffn, + ), + preference_input_row( + "Attention scale", + "The same direction applied to attention activations instead. It stays at 0 unless set, which leaves attention untouched.", + steering_attn, + ), text(if self.preference_draft.model == ModelChoice::Glm52 { "Directional steering is not supported for GLM 5.2." } else { @@ -369,10 +450,14 @@ impl App { text("ADVANCED DIAGNOSTICS").size(11).color(muted_text()), preference_input_row( "Simulated used memory (GiB)", + "Tells the engine to plan as if this much memory were already taken, to rehearse how the model would behave on a smaller machine. Nothing is actually reserved or freed.", text_input("Disabled", &self.preference_draft.simulated_used_memory_gib) .on_input(Message::PreferenceSimulatedMemoryChanged), ), - text("Routed expert profile output").size(13), + hint( + text("Routed expert profile output").size(13), + "Writes which experts each run actually routed to, so cache and preload sizes can be tuned from measurements instead of guesses. Blank disables profiling.", + ), text_input("Output file path", &self.preference_draft.expert_profile_path) .on_input(Message::PreferenceExpertProfileChanged) .padding(9),