Upgrade Iced and render Markdown tables
This commit is contained in:
82
src/app.rs
82
src/app.rs
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,11 +9,9 @@ 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,
|
||||
)
|
||||
.on_toggle_maybe(legacy_mtp_toggle),
|
||||
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.",
|
||||
);
|
||||
let dspark_toggle: Option<fn(bool) -> Message> = self
|
||||
@@ -22,11 +20,9 @@ impl App {
|
||||
.supports_dspark()
|
||||
.then_some(Message::PreferenceDsparkChanged);
|
||||
let dspark = hint(
|
||||
checkbox(
|
||||
"Enable DSpark for this model",
|
||||
self.preference_draft.dspark_enabled,
|
||||
)
|
||||
.on_toggle_maybe(dspark_toggle),
|
||||
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.",
|
||||
);
|
||||
let glm_mtp_toggle: Option<fn(bool) -> Message> = (self.preference_draft.model
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
15
src/main.rs
15
src/main.rs
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user