Upgrade Iced and render Markdown tables

This commit is contained in:
Georg Bauer
2026-07-26 18:45:33 +02:00
parent 4420b81117
commit fd3f8e45dc
12 changed files with 932 additions and 1063 deletions

1618
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@ cc = "1.3.0"
[dependencies]
diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "64-column-tables"] }
diesel_migrations = "2.3.2"
iced = { version = "0.13.1", features = ["advanced", "highlighter", "markdown", "svg", "tokio"] }
iced = { version = "0.14.0", features = ["advanced", "highlighter", "markdown", "svg", "tokio"] }
memmap2 = "0.9.11"
png = "0.17.16"
rfd = "0.15.4"
@@ -23,6 +23,7 @@ serde_json = { version = "1.0.149", features = ["preserve_order", "raw_value"] }
serde_norway = "0.9.42"
sha2 = "0.11.0"
ureq = { version = "3.3.0", default-features = false, features = ["rustls"] }
url = "2.5.8"
[target.'cfg(target_os = "macos")'.dependencies]
muda = "0.15.3"

View File

@@ -62,6 +62,9 @@ execution targets one self-contained Mac.
- The remaining model-independent execution gaps are fine-grained SSD cache
telemetry, the DS4 expert-locality profiler, and resident multi-session
server batching/scheduling.
- The native UI is on Iced 0.14. Chat transcripts use its table-aware Markdown
content and viewer path, with a regression for code-styled line-count tables
produced by coding models.
## Delivery order

View File

@@ -25,7 +25,7 @@ use crate::settings::{
ReasoningMode, RuntimePreferences, SpeculativePreferences, SsdPreferences, SteeringPreferences,
StreamingCacheBudget,
};
use iced::widget::{markdown, scrollable, text_input};
use iced::widget::{markdown, scrollable};
use iced::{Size, Subscription, Task, keyboard, mouse, window};
use rfd::AsyncFileDialog;
use std::collections::{HashMap, HashSet, VecDeque};
@@ -210,7 +210,7 @@ pub(crate) enum Message {
DownloadProgressTick,
ComposerChanged(String),
ToggleReasoning(usize),
OpenLink(markdown::Url),
OpenLink(markdown::Uri),
CopyToolText(String),
OpenToolOutput(PathBuf),
AllowToolOnce,
@@ -514,9 +514,11 @@ impl App {
self.error = None;
}
}
Message::FocusNext => return iced::widget::focus_next().chain(reveal_focused()),
Message::FocusNext => {
return iced::widget::operation::focus_next().chain(reveal_focused());
}
Message::FocusPrevious => {
return iced::widget::focus_previous().chain(reveal_focused());
return iced::widget::operation::focus_previous().chain(reveal_focused());
}
Message::ShowChat => self.detail_tab = DetailTab::Chat,
Message::ShowStats => {
@@ -807,8 +809,9 @@ impl App {
message.reasoning_open = !message.reasoning_open;
}
}
Message::OpenLink(url) => {
if matches!(url.scheme(), "http" | "https")
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}"));
@@ -1165,7 +1168,10 @@ impl App {
pub(crate) fn subscription(&self) -> Subscription<Message> {
let mut subscriptions = vec![
keyboard::on_key_press(shortcut),
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, _, _| {
@@ -1422,16 +1428,16 @@ fn config_path() -> PathBuf {
application_support_path().join("config.yaml")
}
pub(super) fn chat_scroll_id() -> scrollable::Id {
scrollable::Id::new("chat-transcript")
pub(super) fn chat_scroll_id() -> iced::widget::Id {
iced::widget::Id::new("chat-transcript")
}
pub(super) fn composer_id() -> text_input::Id {
text_input::Id::new("chat-composer")
pub(super) fn composer_id() -> iced::widget::Id {
iced::widget::Id::new("chat-composer")
}
pub(super) fn preferences_scroll_id() -> scrollable::Id {
scrollable::Id::new("preferences-fields")
pub(super) fn preferences_scroll_id() -> iced::widget::Id {
iced::widget::Id::new("preferences-fields")
}
/// Scrolls the preferences form so the field that just took focus is inside
@@ -1447,24 +1453,34 @@ fn reveal_focused() -> Task<Message> {
struct Locate {
rows: Vec<Rectangle>,
next_container: Option<Rectangle>,
focused: Option<Rectangle>,
}
impl<T> Operation<T> for Locate {
fn container(
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) {
self.next_container = Some(bounds);
}
fn focusable(
&mut self,
_id: Option<&Id>,
bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
state: &mut dyn operation::Focusable,
) {
self.rows.push(bounds);
operate_on_children(self);
self.rows.pop();
}
fn focusable(&mut self, state: &mut dyn operation::Focusable, _id: Option<&Id>) {
if state.is_focused() {
self.focused = self.rows.last().copied();
self.focused = self.rows.last().copied().or(Some(bounds));
}
}
@@ -1480,24 +1496,19 @@ fn reveal_focused() -> Task<Message> {
}
impl<T> Operation<T> for Reveal {
fn container(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
operate_on_children(self);
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
operate(self);
}
fn scrollable(
&mut self,
state: &mut dyn operation::Scrollable,
id: Option<&Id>,
bounds: Rectangle,
content_bounds: Rectangle,
translation: Vector,
state: &mut dyn operation::Scrollable,
) {
if id != Some(&Id::from(preferences_scroll_id())) {
if id != Some(&preferences_scroll_id()) {
return;
}
let Some(offset) = reveal_offset(self.field, bounds, translation.y) else {
@@ -1513,6 +1524,7 @@ fn reveal_focused() -> Task<Message> {
iced::advanced::widget::operate(Locate {
rows: Vec::new(),
next_container: None,
focused: None,
})
}
@@ -1541,11 +1553,11 @@ fn reveal_offset(field: iced::Rectangle, viewport: iced::Rectangle, scrolled: f3
}
fn focus_composer() -> Task<Message> {
text_input::focus(composer_id())
iced::widget::operation::focus(composer_id())
}
fn scroll_chat_to_end() -> Task<Message> {
scrollable::snap_to(chat_scroll_id(), scrollable::RelativeOffset::END)
iced::widget::operation::snap_to(chat_scroll_id(), scrollable::RelativeOffset::END)
}
/// Deletes session checkpoints whose session is gone. Deleting a session or a
@@ -1723,7 +1735,7 @@ mod tests {
reasoning_complete: false,
reasoning_open: true,
content: String::new(),
markdown: Vec::new(),
markdown: markdown::Content::new(),
};
message.append(true, "working it out");
message.append(false, "**final answer**");
@@ -1731,6 +1743,6 @@ mod tests {
assert_eq!(message.reasoning.as_deref(), Some("working it out"));
assert!(message.reasoning_complete);
assert_eq!(message.content, "**final answer**");
assert!(!message.markdown.is_empty());
assert!(!message.markdown.items().is_empty());
}
}

View File

@@ -47,7 +47,7 @@ pub(super) struct ToolResultCheck {
stage: ToolCheckStage,
}
#[derive(Clone, Debug)]
#[derive(Debug)]
pub(crate) struct ChatMessage {
pub(super) id: i32,
pub(super) user: bool,
@@ -59,7 +59,7 @@ pub(crate) struct ChatMessage {
pub(super) reasoning_complete: bool,
pub(super) reasoning_open: bool,
pub(super) content: String,
pub(super) markdown: Vec<markdown::Item>,
pub(super) markdown: markdown::Content,
}
impl ChatMessage {
@@ -80,7 +80,7 @@ impl ChatMessage {
} else {
visible
};
self.markdown = markdown::parse(content).collect();
self.markdown = markdown::Content::parse(content);
}
}
}
@@ -98,7 +98,7 @@ impl From<StoredMessage> for ChatMessage {
reasoning_complete: message.reasoning_complete,
reasoning_open: false,
content: message.content,
markdown: Vec::new(),
markdown: iced::widget::markdown::Content::new(),
};
message.refresh_markdown();
message
@@ -1349,6 +1349,37 @@ mod tests {
);
}
#[test]
fn assistant_markdown_keeps_llm_tables() {
let mut message = ChatMessage {
id: 1,
user: false,
tool: false,
system: false,
compaction: false,
compaction_tail_start: None,
reasoning: None,
reasoning_complete: true,
reasoning_open: false,
content: "### Core / Setup\n\n| File | Lines |\n|---|---:|\n| `src/app.rs` | **1,750** |\n| `src/engine.rs` | 2,400 |\n\n### Summary\n\nDone."
.to_owned(),
markdown: iced::widget::markdown::Content::new(),
};
message.refresh_markdown();
let table = message.markdown.items().iter().find_map(|item| {
if let iced::widget::markdown::Item::Table { columns, rows } = item {
Some((columns, rows))
} else {
None
}
});
let (columns, rows) = table.expect("the completed Markdown table must remain renderable");
assert_eq!(columns.len(), 2);
assert_eq!(rows.len(), 2);
}
#[test]
fn last_compaction_selects_its_tail_without_hiding_history() {
let message = |id: i32, compaction: bool, tail: Option<i32>| ChatMessage {
@@ -1362,7 +1393,7 @@ mod tests {
reasoning_complete: true,
reasoning_open: false,
content: format!("message {id}"),
markdown: Vec::new(),
markdown: iced::widget::markdown::Content::new(),
};
let history = vec![
message(1, false, None),
@@ -1394,7 +1425,7 @@ mod tests {
reasoning_complete: true,
reasoning_open: false,
content: format!("message {id}"),
markdown: Vec::new(),
markdown: iced::widget::markdown::Content::new(),
};
let mut history = vec![
message(1, true, false, false, false),

View File

@@ -16,9 +16,8 @@ use crate::model::{
use crate::settings::{GIB, REASONING_MODES};
use iced::theme::{Palette, palette};
use iced::widget::{
Button, Space, Svg, Tooltip, button, checkbox, column, container, horizontal_rule, markdown,
mouse_area, opaque, pick_list, progress_bar, row, scrollable, stack, svg, text, text_input,
tooltip,
Button, Space, Svg, Tooltip, button, checkbox, column, container, markdown, mouse_area, opaque,
pick_list, progress_bar, row, rule, scrollable, stack, svg, text, text_input, tooltip,
};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme, window};
use std::collections::VecDeque;
@@ -87,7 +86,7 @@ impl App {
body = body.push(self.sidebar());
body = body.push(
mouse_area(
container(Space::with_width(Length::Fill))
container(Space::new().width(Length::Fill))
.width(5)
.height(Length::Fill)
.style(divider_style),
@@ -107,7 +106,7 @@ impl App {
// way to the top edge; the spacer below it lets clicks through.
let mut shell = column![stack![
body,
column![self.title_bar(), Space::with_height(Length::Fill)],
column![self.title_bar(), Space::new().height(Length::Fill)],
]];
if let ModelDownload::Active(download) = &self.model_download {
shell = shell.push(download_status_bar(download));
@@ -163,7 +162,7 @@ impl App {
text("Working directory").size(11).color(muted_text()),
text(prompt.working_directory.display().to_string()).size(13),
row![
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
action_button("Deny").on_press(Message::DenyTool),
action_button("Allow once").on_press(Message::AllowToolOnce),
]
@@ -188,7 +187,7 @@ impl App {
let preference_content = row![
icon(ICON_SETTINGS, 17),
text("Preferences").size(14),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text("⌘,").size(12),
]
.spacing(9)
@@ -212,14 +211,14 @@ impl App {
};
let header = row![
text("DS4Server").size(20),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text("Local").size(12),
]
.align_y(Alignment::Center);
let mut projects = column![
row![
text("PROJECTS").size(11),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text("LOCAL").size(10),
],
add_project.style(button::text),
@@ -275,7 +274,7 @@ impl App {
let expanded = self.expanded_archives.contains(&project.id);
projects = projects.push(
row![
Space::with_width(26),
Space::new().width(26),
button(
text(format!(
"{} Archived sessions ({})",
@@ -302,7 +301,7 @@ impl App {
let draft_selected = self.draft_selected(project.id);
projects = projects.push(
row![
Space::with_width(26),
Space::new().width(26),
button(
row![
icon(ICON_CHAT, 15),
@@ -363,7 +362,7 @@ impl App {
}
label = label.push(text(&session.title).size(13));
row![
Space::with_width(26),
Space::new().width(26),
button(label)
.width(Length::Fill)
.on_press(Message::SelectSession(project_id, session.id))
@@ -404,12 +403,12 @@ impl App {
};
stack![
row![
Space::with_width(detail_offset),
Space::new().width(detail_offset),
container(self.detail_tabs())
.center_x(Length::Fill)
.center_y(Length::Fill),
],
container(row![Space::with_width(TRAFFIC_LIGHT_WIDTH), toggle]).center_y(Length::Fill),
container(row![Space::new().width(TRAFFIC_LIGHT_WIDTH), toggle]).center_y(Length::Fill),
]
.width(Length::Fill)
.height(TITLE_BAR_HEIGHT)
@@ -463,7 +462,7 @@ impl App {
.on_submit(Message::ConfirmProject)
.padding(10),
row![
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
action_button("Cancel").on_press(Message::CancelProject),
action_button("Add project").on_press(Message::ConfirmProject),
]
@@ -547,7 +546,7 @@ impl App {
.color(muted_text()),
actions,
row![
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
action_button("Close").on_press(Message::DismissPanel),
],
]
@@ -576,7 +575,7 @@ impl App {
.on_submit(Message::ConfirmRenameSession)
.padding(10),
row![
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
action_button("Cancel").on_press(Message::DismissPanel),
action_button("Rename").on_press(Message::ConfirmRenameSession),
]
@@ -668,9 +667,10 @@ pub(crate) fn app_theme() -> Theme {
text: Color::from_rgb8(235, 235, 235),
primary: Color::from_rgb8(85, 118, 255),
success: Color::from_rgb8(72, 176, 112),
warning: Color::from_rgb8(224, 168, 72),
danger: Color::from_rgb8(220, 80, 86),
};
Theme::custom_with_fn("DS4Server".into(), palette, |palette| {
Theme::custom_with_fn("DS4Server", 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);
@@ -714,7 +714,7 @@ fn metric_card(label: &'static str, value: String, detail: String) -> Element<'s
fn metric_row(label: &'static str, value: impl ToString) -> Element<'static, Message> {
row![
text(label).size(12).color(muted_text()),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text(value.to_string()).size(12),
]
.spacing(12)
@@ -750,7 +750,7 @@ fn mini_chart(
1.0
};
bars = bars.push(
container(Space::new(Length::Fill, height))
container(Space::new().width(Length::Fill).height(height))
.width(Length::FillPortion(1))
.style(move |_| chart_bar_style(color)),
);

View File

@@ -17,7 +17,7 @@ impl App {
icon(ICON_SPARK, 36),
text("Start a local coding session").size(28),
text("Choose a project folder to create your first session.").size(14),
Space::with_height(10),
Space::new().height(10),
open_project,
]
.spacing(10)
@@ -40,7 +40,7 @@ impl App {
);
}
let header = header
.push(Space::with_width(Length::Fill))
.push(Space::new().width(Length::Fill))
.spacing(10)
.align_y(Alignment::Center);
@@ -124,7 +124,7 @@ impl App {
}
}
if !message.content.is_empty() {
if message.user || message.tool || message.markdown.is_empty() {
if message.user || message.tool || message.markdown.items().is_empty() {
let content = if message.reasoning.is_some() {
crate::agent::visible_content(&message.content).trim_start()
} else {
@@ -134,9 +134,8 @@ impl App {
} else {
body = body.push(
markdown::view(
&message.markdown,
markdown::Settings::with_text_size(14),
markdown_style,
message.markdown.items(),
markdown::Settings::with_text_size(14, markdown_style),
)
.map(Message::OpenLink),
);
@@ -283,7 +282,7 @@ impl App {
))
.size(11)
.color(muted_text()),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
icon(ICON_MODEL, 16),
text(self.config.model.to_string()).size(12),
action,
@@ -339,7 +338,7 @@ fn tool_cards(cards: Vec<crate::agent::ToolCard>) -> Element<'static, Message> {
let mut rows = column![].spacing(0);
for (index, card) in cards.into_iter().enumerate() {
if index > 0 {
rows = rows.push(horizontal_rule(1));
rows = rows.push(rule::horizontal(1));
}
let parameters = crate::agent::tool_parameters(&card.call);
let call = crate::agent::tool_call_text(&card.call);
@@ -391,7 +390,7 @@ fn tool_cards(cards: Vec<crate::agent::ToolCard>) -> Element<'static, Message> {
let mut content = column![
row![
text(card.call.name).size(13),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text(card.state.label()).size(11).color(muted_text()),
actions,
]

View File

@@ -7,7 +7,7 @@ impl App {
let mut artifacts = column![];
for (index, artifact) in model::managed_artifacts(&models_path()).iter().enumerate() {
if index > 0 {
artifacts = artifacts.push(horizontal_rule(1));
artifacts = artifacts.push(rule::horizontal(1));
}
artifacts = artifacts.push(model_artifact_row(artifact, busy));
}
@@ -29,7 +29,7 @@ impl App {
if let Some(error) = &self.error {
content = content.push(text(error).style(iced::widget::text::danger));
}
content = content.push(Space::with_height(8)).push(
content = content.push(Space::new().height(8)).push(
container(scrollable(artifacts).height(Length::Fill))
.height(Length::Fill)
.style(overview_style),
@@ -51,7 +51,7 @@ impl App {
))
.size(13),
row![
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
action_button("Cancel").on_press(Message::CancelDeleteArtifact),
danger_button("Delete").on_press(Message::ConfirmDeleteArtifact),
]
@@ -119,7 +119,7 @@ fn model_artifact_row(artifact: &ManagedArtifact, busy: bool) -> Element<'static
.color(muted_text()),
]
.spacing(5),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
row![download, validate, delete]
.spacing(8)
.align_y(Alignment::Center),
@@ -167,11 +167,11 @@ pub(super) fn download_status_bar(download: &ActiveDownload) -> Element<'_, Mess
row![
text(phase_text(progress.phase)).size(12),
progress_bar(0.0..=1.0, progress.fraction())
.width(180)
.height(7),
.length(180)
.girth(7),
text(measurement).size(12),
text(transfer).size(12),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
stop,
]
.spacing(12)
@@ -234,7 +234,7 @@ fn model_download_status(download: &ModelDownload) -> Element<'_, Message> {
} else {
text(heading).size(12)
};
let mut heading_row = row![heading, Space::with_width(Length::Fill)]
let mut heading_row = row![heading, Space::new().width(Length::Fill)]
.align_y(Alignment::Center)
.spacing(8);
if let ModelDownload::Active(active) = download {
@@ -246,7 +246,7 @@ fn model_download_status(download: &ModelDownload) -> Element<'_, Message> {
}
let mut status = column![
heading_row,
progress_bar(0.0..=1.0, progress.fraction()).height(8),
progress_bar(0.0..=1.0, progress.fraction()).girth(8),
text(measurement).size(12),
]
.spacing(6);

View File

@@ -9,10 +9,8 @@ impl App {
.supports_dspark()
.then_some(Message::PreferenceLegacyMtpChanged);
let legacy_mtp = hint(
checkbox(
"Enable legacy MTP for this model",
self.preference_draft.legacy_mtp_enabled,
)
checkbox(self.preference_draft.legacy_mtp_enabled)
.label("Enable legacy MTP for this model")
.on_toggle_maybe(legacy_mtp_toggle),
"Uses the managed one-stage MTP support GGUF. The target model verifies every drafted token; it is mutually exclusive with DSpark.",
);
@@ -22,10 +20,8 @@ impl App {
.supports_dspark()
.then_some(Message::PreferenceDsparkChanged);
let dspark = hint(
checkbox(
"Enable DSpark for this model",
self.preference_draft.dspark_enabled,
)
checkbox(self.preference_draft.dspark_enabled)
.label("Enable DSpark for this model")
.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; the target model may also stream routed experts from SSD.",
);
@@ -130,10 +126,8 @@ impl App {
"LOCAL ENDPOINT",
column![
hint(
checkbox(
"Enable OpenAI-compatible endpoint",
self.preference_draft.endpoint_enabled,
)
checkbox(self.preference_draft.endpoint_enabled)
.label("Enable OpenAI-compatible endpoint")
.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.",
),
@@ -144,10 +138,8 @@ impl App {
.on_input(Message::PreferenceEndpointPortChanged),
),
hint(
checkbox(
"Allow browser clients (CORS)",
self.preference_draft.endpoint_cors,
)
checkbox(self.preference_draft.endpoint_cors)
.label("Allow browser clients (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.",
),
@@ -181,7 +173,7 @@ impl App {
)
.on_input(Message::PreferenceSystemPromptChanged)
.padding(9),
Space::with_height(4),
Space::new().height(4),
text("SAMPLING & REASONING").size(11).color(muted_text()),
preference_input_row(
"Temperature",
@@ -261,12 +253,14 @@ impl App {
prefill,
),
hint(
checkbox("Prefer exact quality kernels", self.preference_draft.quality)
checkbox(self.preference_draft.quality)
.label("Prefer exact quality kernels")
.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)
checkbox(self.preference_draft.warm_weights)
.label("Warm mapped weights at load time")
.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.",
),
@@ -312,15 +306,14 @@ impl App {
.on_input(Message::PreferenceMtpMarginChanged),
),
hint(
checkbox("Enable integrated GLM MTP", self.preference_draft.glm_mtp)
checkbox(self.preference_draft.glm_mtp)
.label("Enable integrated 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,
)
checkbox(self.preference_draft.glm_mtp_timing)
.label("Log GLM MTP timing counters")
.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.",
),
@@ -332,10 +325,8 @@ impl App {
dspark_confidence,
),
hint(
checkbox(
"DSpark target-only decode",
self.preference_draft.dspark_strict,
)
checkbox(self.preference_draft.dspark_strict)
.label("DSpark target-only decode")
.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.",
),
@@ -367,15 +358,17 @@ impl App {
},
))
.size(12),
Space::with_height(6),
Space::new().height(6),
text("SSD STREAMING").size(11).color(muted_text()),
hint(
checkbox("Enable SSD-backed model streaming", self.preference_draft.ssd_streaming)
checkbox(self.preference_draft.ssd_streaming)
.label("Enable SSD-backed model 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; speculative support weights remain resident while target experts stream.",
),
hint(
checkbox("Skip automatic expert preload", self.preference_draft.ssd_streaming_cold)
checkbox(self.preference_draft.ssd_streaming_cold)
.label("Skip automatic expert preload")
.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.",
),
@@ -461,7 +454,7 @@ impl App {
),
))
.size(12),
Space::with_height(6),
Space::new().height(6),
text("ADVANCED DIAGNOSTICS").size(11).color(muted_text()),
preference_input_row(
"Simulated used memory (GiB)",
@@ -561,14 +554,14 @@ impl App {
let header = row![
icon(ICON_SETTINGS, 22),
text("Preferences").size(24),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text("⌘,").size(12),
]
.spacing(10)
.align_y(Alignment::Center);
let footer = row![
action_button("Reset DS4 defaults").on_press(Message::ResetPreferences),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
action_button("Cancel").on_press(Message::DismissPanel),
action_button("Save").on_press(Message::SavePreferences),
]

View File

@@ -47,7 +47,7 @@ impl App {
.color(muted_text()),
]
.spacing(5),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
container(text(stats.phase.label()).size(12).color(phase_color))
.padding([7, 11])
.style(move |_| status_badge_style(phase_color)),
@@ -109,7 +109,7 @@ impl App {
text("Decode")
.size(12)
.color(Color::from_rgb8(84, 170, 255)),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text(format!("{:.1} tok/s", stats.decode_tokens_per_second))
.size(12)
.color(muted_text()),
@@ -123,7 +123,7 @@ impl App {
text("Prefill")
.size(12)
.color(Color::from_rgb8(157, 119, 255)),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text(format!("{:.1} tok/s", stats.prefill_tokens_per_second))
.size(12)
.color(muted_text()),
@@ -142,7 +142,7 @@ impl App {
),
row![
text(format!("{} requests", format_count(stats.http_requests))).size(12),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text(format!(
"{} errors · {} streaming",
stats.http_errors, stats.http_streaming_requests
@@ -171,7 +171,7 @@ impl App {
})
.size(12)
.color(Color::from_rgb8(67, 194, 203)),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text(format_rate(latest.kv_read_bytes_per_second))
.size(12)
.color(muted_text()),
@@ -189,7 +189,7 @@ impl App {
})
.size(12)
.color(Color::from_rgb8(240, 180, 70)),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text(format_rate(latest.kv_write_bytes_per_second))
.size(12)
.color(muted_text()),
@@ -210,7 +210,7 @@ impl App {
text("Selected loads")
.size(12)
.color(Color::from_rgb8(240, 180, 70)),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text(format!("{:.1}/s", latest.ssd_requests_per_second))
.size(12)
.color(muted_text()),
@@ -224,7 +224,7 @@ impl App {
text("Requested expert data")
.size(12)
.color(Color::from_rgb8(67, 194, 203)),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text(format_rate(latest.ssd_bytes_per_second))
.size(12)
.color(muted_text()),
@@ -238,7 +238,7 @@ impl App {
text("Inference wait")
.size(12)
.color(Color::from_rgb8(220, 80, 86)),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text(format!("{:.0} ms/s", latest.ssd_wait_ms_per_second))
.size(12)
.color(muted_text()),
@@ -443,7 +443,7 @@ impl App {
format_milliseconds(stats.last_kv_write_ms),
),
),
progress_bar(0.0..=1.0, cache_fraction.min(1.0)).height(4),
progress_bar(0.0..=1.0, cache_fraction.min(1.0)).girth(4),
]
.spacing(9)
.into(),
@@ -522,15 +522,16 @@ impl App {
continue;
}
bar = bar.push(
container(Space::new(Length::Fill, Length::Fill))
container(Space::new().width(Length::Fill).height(Length::Fill))
.width(Length::FillPortion(portion(bytes, capacity)))
.style(move |_| chart_bar_style(color)),
);
legend = legend.push(
row![
container(Space::new(9, 9)).style(move |_| chart_bar_style(color)),
container(Space::new().width(9).height(9))
.style(move |_| chart_bar_style(color)),
text(label).size(12).color(muted_text()),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text(format!(
"{} · {:.0}%",
format_bytes(bytes),
@@ -544,7 +545,7 @@ impl App {
}
if total == 0 {
bar = bar.push(
container(Space::new(Length::Fill, Length::Fill))
container(Space::new().width(Length::Fill).height(Length::Fill))
.style(|_| chart_bar_style(muted_text().scale_alpha(0.25))),
);
}
@@ -562,7 +563,7 @@ impl App {
rows = rows.push(
row![
text(label).size(12),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
text(format!(
"{} · {} old",
format_bytes(entry.bytes),
@@ -607,7 +608,7 @@ impl App {
usage.transient_bytes as f32 / usage.budget_bytes.max(1) as f32 * 100.0,
),
),
Space::with_height(2),
Space::new().height(2),
text(if usage.entries.is_empty() {
"No checkpoints stored yet."
} else {
@@ -620,7 +621,7 @@ impl App {
text("Discarding a checkpoint costs one prefill to rebuild it.")
.size(11)
.color(muted_text()),
Space::with_width(Length::Fill),
Space::new().width(Length::Fill),
action_button(text("Clear transient cache").size(12))
.on_press(Message::ClearTransientCache),
]

View File

@@ -25,10 +25,8 @@ fn main() -> iced::Result {
if let Err(error) = engine::configure_metal_sources() {
eprintln!("DS4Server: {error}");
}
iced::daemon(App::title, App::update, App::view)
.subscription(App::subscription)
.theme(|_, _| app_theme())
.run_with(|| {
iced::daemon(
|| {
let (main_window, open) = window::open(window::Settings {
size: Size::new(1120.0, 720.0),
min_size: Some(Size::new(760.0, 480.0)),
@@ -44,5 +42,12 @@ fn main() -> iced::Result {
..Default::default()
});
(App::load(main_window), open.map(Message::WindowOpened))
})
},
App::update,
App::view,
)
.title(App::title)
.subscription(App::subscription)
.theme(app_theme())
.run()
}

View File

@@ -6,7 +6,6 @@ use iced::advanced::overlay;
use iced::advanced::renderer;
use iced::advanced::widget::{Operation, Tree, tree};
use iced::advanced::{Clipboard, Layout, Shell, Widget};
use iced::event;
use iced::keyboard::{self, Key, Location, Modifiers, key};
use iced::mouse;
use iced::{Element, Event, Length, Rectangle, Size, Vector};
@@ -95,9 +94,12 @@ fn command_events(command: EditCommand) -> [Event; 4] {
location: Location::Standard,
modifiers,
text: None,
repeat: false,
}),
Event::Keyboard(keyboard::Event::KeyReleased {
key,
modified_key: Key::Character(modified_character.into()),
physical_key: key::Physical::Code(physical_key),
location: Location::Standard,
modifiers,
}),
@@ -131,67 +133,63 @@ where
}
fn layout(
&self,
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget()
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&self,
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
self.content
.as_widget()
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn on_event(
fn update(
&mut self,
tree: &mut Tree,
event: Event,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) -> event::Status {
let mut status = event::Status::Ignored;
) {
if matches!(
event,
Event::Window(iced::window::Event::RedrawRequested(_))
) {
while let Some(command) = pop_command(&self.commands) {
for command_event in command_events(command) {
if self.content.as_widget_mut().on_event(
self.content.as_widget_mut().update(
&mut tree.children[0],
command_event,
&command_event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
) == event::Status::Captured
{
status = event::Status::Captured;
}
);
}
}
}
if let Some(sync_event) = modifier_sync_event(&event) {
let _ = self.content.as_widget_mut().on_event(
if let Some(sync_event) = modifier_sync_event(event) {
self.content.as_widget_mut().update(
&mut tree.children[0],
sync_event,
&sync_event,
layout,
cursor,
renderer,
@@ -201,7 +199,7 @@ where
);
}
if self.content.as_widget_mut().on_event(
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
@@ -210,12 +208,7 @@ where
clipboard,
shell,
viewport,
) == event::Status::Captured
{
event::Status::Captured
} else {
status
}
);
}
fn mouse_interaction(
@@ -259,13 +252,18 @@ where
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'_>,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, translation)
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}