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

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,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),
]

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),
]