feat: more native menus with expected reactions

This commit is contained in:
Georg Bauer
2026-07-27 15:09:12 +02:00
parent 8f02f0934b
commit c96675c462
11 changed files with 738 additions and 70 deletions

18
PLAN.md
View File

@@ -4,23 +4,7 @@ Only unfinished implementation work belongs here. DwarfStar remains the
behavioral oracle for model execution, token processing, context accounting, behavioral oracle for model execution, token processing, context accounting,
KV-cache behavior, the HTTP API, and the built-in agent loop. KV-cache behavior, the HTTP API, and the built-in agent loop.
## 1. Finish native project and chat controls ## 1. Add Dev Brain support
- Add functional File, View, and Help menus; update native menu enabled state
from the focused control; complete undo/redo behavior; and make transcript
text selectable and copyable.
- File menu has an "export" menu to save a chat as markdown
- File menu has a new "menu" that opens a new chat in the currently active
project. if the app is not on a project or session of a project in the
sidebar, "new" is disabled
- View menu gets the "manage models" menu item, remove it from Window menu
- View menu gets toggle menu for sidebar with hotkey cmd-B
- Explain model/checkpoint mismatches where a session must rebuild context
instead of resuming its existing checkpoint.
- the hellp menu must open a window with an actual documentation in it,
that still needs to be constructed from the information and implementation
## 2. Add Dev Brain support
- Let the user select one Obsidian vault and give the local agent bounded - Let the user select one Obsidian vault and give the local agent bounded
Markdown memory_search, memory_read, memory_create, and memory_append Markdown memory_search, memory_read, memory_create, and memory_append

78
docs/USER_GUIDE.md Normal file
View File

@@ -0,0 +1,78 @@
# DS4Server Help
DS4Server runs supported DwarfStar language models locally on your Mac. Chats,
project references, and settings stay on this computer.
## Projects and chats
Choose **Add project** to give the coding agent access to a folder. The agent's
file tools are bounded to that project. Use **File > New Chat** (`⌘N`) to start
a chat in the active project. Chats are saved after their first message and can
be renamed, pinned, archived, compacted, rebuilt, or deleted from the sidebar.
Use **File > Export Chat as Markdown** (`⇧⌘S`) to save the visible conversation,
including reasoning and tool results. System-only messages are omitted.
## Writing and editing
Enter a prompt at the bottom of the Chat view. While a response is running,
additional prompts are queued. **Stop** cancels generation and active agent
work. The Edit menu follows the focused text control and supports undo, redo,
cut, copy, paste, and select all. Transcript text can be selected and copied.
The context indicator beside the composer shows used and available tokens.
DS4Server automatically compacts long chats near the context limit; the full
visible transcript remains available.
## Checkpoints and model changes
Each saved chat keeps a local KV checkpoint so its next turn can resume without
prefilling the whole conversation. A checkpoint belongs to the exact model,
quantization, context size, and executor configuration that created it.
If the selected model, model file, quantization, context size, or relevant
runtime configuration changes, DS4Server cannot safely resume that checkpoint.
It reports that the context is being rebuilt, prefills the saved transcript,
and writes a compatible replacement. No chat messages are lost. **Rebuild
context on next use** performs the same safe rebuild manually.
## Models
Open **View > Model Manager** (`⇧⌘M`) to download, resume, verify, or remove
supported model artifacts. Preferences choose the active model and control
generation, context, speculative decoding, Metal execution, SSD expert
streaming, steering, checkpoint storage, diagnostics, and the local endpoint.
Model files are large. Verification checks the complete artifact before it is
used. Removing a model never removes projects or chat history.
## Agent tools and approvals
The local agent can inspect and edit project files, search text, and run shell
commands. Operations that can affect data outside the ordinary project workflow
show an approval dialog. Read the command and working directory before choosing
**Allow once**. Choose **Deny** to return the refusal to the agent.
Tool calls and results appear in the transcript. Use their copy actions for the
complete, untruncated text; large outputs can also be opened from their saved
file.
## Views and sidebar
Use **View > Show Sidebar** (`⌘B`) to hide or restore the project sidebar. The
Chat view shows the transcript, A2UI shows the latest interactive surface, and
Stats reports model, generation, HTTP, SSD, and KV-cache activity.
## Local HTTP endpoint
Preferences can enable an OpenAI- and Anthropic-compatible endpoint on
`127.0.0.1` (port `4000` by default). Endpoint conversations are owned by their
client and do not appear in the project sidebar. CORS is off by default and
should only be enabled for trusted local browser clients.
## Data and recovery
Application data is stored under `~/Library/Application Support/de.rfc1437.ds4server/`.
Deleting a project from DS4Server removes its saved sessions and checkpoints,
but never deletes the referenced project folder. Deleting a session removes its
transcript and checkpoint permanently.

View File

@@ -47,11 +47,14 @@ pub(super) const MAX_SIDEBAR_WIDTH: i32 = 520;
pub(crate) struct App { pub(crate) struct App {
main_window: window::Id, main_window: window::Id,
pub(super) model_manager_window: Option<window::Id>, pub(super) model_manager_window: Option<window::Id>,
pub(super) help_window: Option<window::Id>,
pub(super) pending_model_delete: Option<ManagedArtifactId>, pub(super) pending_model_delete: Option<ManagedArtifactId>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
_native_menu: Option<crate::native_menu::NativeMenu>, _native_menu: Option<crate::native_menu::NativeMenu>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub(super) native_edit_commands: crate::native_edit::EditCommandQueue, pub(super) native_edit_commands: crate::native_edit::EditCommandQueue,
#[cfg(target_os = "macos")]
pub(super) native_edit_availability: crate::native_edit::EditAvailabilityState,
database: Option<Database>, database: Option<Database>,
projects: Vec<ProjectWithSessions>, projects: Vec<ProjectWithSessions>,
config: Config, config: Config,
@@ -87,6 +90,7 @@ pub(crate) struct App {
pub(super) a2ui_modals: HashSet<(String, String)>, pub(super) a2ui_modals: HashSet<(String, String)>,
pub(super) a2ui_editors: HashMap<(String, String, String), text_editor::Content>, pub(super) a2ui_editors: HashMap<(String, String, String), text_editor::Content>,
pub(super) a2ui_markdown: HashMap<(String, String, String), markdown::Content>, pub(super) a2ui_markdown: HashMap<(String, String, String), markdown::Content>,
pub(super) help_markdown: markdown::Content,
pub(super) a2ui_choice_filters: HashMap<(String, String, String), String>, pub(super) a2ui_choice_filters: HashMap<(String, String, String), String>,
pub(super) a2ui_images: HashMap<String, iced::widget::image::Handle>, pub(super) a2ui_images: HashMap<String, iced::widget::image::Handle>,
pub(super) a2ui_image_requests: HashSet<String>, pub(super) a2ui_image_requests: HashSet<String>,
@@ -133,6 +137,7 @@ pub(crate) struct App {
_endpoint: Option<crate::server::ServerHandle>, _endpoint: Option<crate::server::ServerHandle>,
error: Option<String>, error: Option<String>,
pub(super) activity: Option<String>, pub(super) activity: Option<String>,
pub(super) context_notice: Option<String>,
stop_requested: bool, stop_requested: bool,
system_prompt_seen_at: u32, system_prompt_seen_at: u32,
manual_compaction_queued: bool, manual_compaction_queued: bool,
@@ -167,6 +172,11 @@ pub(crate) enum Message {
NativeEdit(crate::native_edit::EditCommand), NativeEdit(crate::native_edit::EditCommand),
OpenPreferences, OpenPreferences,
OpenModelManager, OpenModelManager,
OpenHelp,
HelpOpened(window::Id),
NewChat,
ExportChat,
ExportChatPicked(Option<PathBuf>),
ModelManagerOpened(window::Id), ModelManagerOpened(window::Id),
WindowOpened(window::Id), WindowOpened(window::Id),
WindowClosed(window::Id), WindowClosed(window::Id),
@@ -238,6 +248,7 @@ pub(crate) enum Message {
StopModelDownload, StopModelDownload,
DownloadProgressTick, DownloadProgressTick,
ComposerChanged(String), ComposerChanged(String),
TranscriptAction(usize, text_editor::Action),
ToggleReasoning(usize), ToggleReasoning(usize),
OpenLink(markdown::Uri), OpenLink(markdown::Uri),
CopyToolText(String), CopyToolText(String),
@@ -315,11 +326,14 @@ impl App {
Self { Self {
main_window, main_window,
model_manager_window: None, model_manager_window: None,
help_window: None,
pending_model_delete: None, pending_model_delete: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
_native_menu: None, _native_menu: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
native_edit_commands: crate::native_edit::command_queue(), native_edit_commands: crate::native_edit::command_queue(),
#[cfg(target_os = "macos")]
native_edit_availability: crate::native_edit::availability_state(),
database: Some(database), database: Some(database),
projects, projects,
config, config,
@@ -348,6 +362,9 @@ impl App {
a2ui_modals: HashSet::new(), a2ui_modals: HashSet::new(),
a2ui_editors: HashMap::new(), a2ui_editors: HashMap::new(),
a2ui_markdown: HashMap::new(), a2ui_markdown: HashMap::new(),
help_markdown: markdown::Content::parse(include_str!(
"../docs/USER_GUIDE.md"
)),
a2ui_choice_filters: HashMap::new(), a2ui_choice_filters: HashMap::new(),
a2ui_images: HashMap::new(), a2ui_images: HashMap::new(),
a2ui_image_requests: HashSet::new(), a2ui_image_requests: HashSet::new(),
@@ -400,6 +417,7 @@ impl App {
} }
}, },
activity: None, activity: None,
context_notice: None,
stop_requested: false, stop_requested: false,
system_prompt_seen_at: 0, system_prompt_seen_at: 0,
manual_compaction_queued: false, manual_compaction_queued: false,
@@ -435,11 +453,14 @@ impl App {
Self { Self {
main_window, main_window,
model_manager_window: None, model_manager_window: None,
help_window: None,
pending_model_delete: None, pending_model_delete: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
_native_menu: None, _native_menu: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
native_edit_commands: crate::native_edit::command_queue(), native_edit_commands: crate::native_edit::command_queue(),
#[cfg(target_os = "macos")]
native_edit_availability: crate::native_edit::availability_state(),
database: None, database: None,
projects: Vec::new(), projects: Vec::new(),
config, config,
@@ -468,6 +489,7 @@ impl App {
a2ui_modals: HashSet::new(), a2ui_modals: HashSet::new(),
a2ui_editors: HashMap::new(), a2ui_editors: HashMap::new(),
a2ui_markdown: HashMap::new(), a2ui_markdown: HashMap::new(),
help_markdown: markdown::Content::parse(include_str!("../docs/USER_GUIDE.md")),
a2ui_choice_filters: HashMap::new(), a2ui_choice_filters: HashMap::new(),
a2ui_images: HashMap::new(), a2ui_images: HashMap::new(),
a2ui_image_requests: HashSet::new(), a2ui_image_requests: HashSet::new(),
@@ -514,6 +536,7 @@ impl App {
None => error, None => error,
}), }),
activity: None, activity: None,
context_notice: None,
stop_requested: false, stop_requested: false,
system_prompt_seen_at: 0, system_prompt_seen_at: 0,
manual_compaction_queued: false, manual_compaction_queued: false,
@@ -524,13 +547,54 @@ impl App {
pub(crate) fn update(&mut self, message: Message) -> Task<Message> { pub(crate) fn update(&mut self, message: Message) -> Task<Message> {
match message { match message {
Message::Noop => {} Message::Noop => {
#[cfg(target_os = "macos")]
self.sync_native_menu();
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
Message::NativeEdit(command) => { Message::NativeEdit(command) => {
crate::native_edit::queue_command(&self.native_edit_commands, command) crate::native_edit::queue_command(&self.native_edit_commands, command)
} }
Message::OpenPreferences => self.open_preferences(), Message::OpenPreferences => self.open_preferences(),
Message::OpenModelManager => return self.open_model_manager(), Message::OpenModelManager => return self.open_model_manager(),
Message::OpenHelp => return self.open_help(),
Message::HelpOpened(id) => {
if self.help_window == Some(id) {
return window::gain_focus(id);
}
}
Message::NewChat => {
if let Some(project_id) = self.selected_project {
self.create_session(project_id);
return focus_composer();
}
}
Message::ExportChat => {
let Some(title) = self.active_chat_title().map(str::to_owned) else {
return Task::none();
};
let file_name = format!("{}.md", export_file_stem(&title));
return Task::perform(
async move {
AsyncFileDialog::new()
.set_title("Export chat as Markdown")
.set_file_name(&file_name)
.add_filter("Markdown", &["md"])
.save_file()
.await
.map(|file| file.path().to_path_buf())
},
Message::ExportChatPicked,
);
}
Message::ExportChatPicked(path) => {
if let Some(path) = path
&& let Some(title) = self.active_chat_title()
&& let Err(error) = fs::write(&path, export_markdown(title, &self.conversation))
{
self.error = Some(format!("Could not export the chat: {error}"));
}
}
Message::ModelManagerOpened(id) => { Message::ModelManagerOpened(id) => {
if self.model_manager_window == Some(id) { if self.model_manager_window == Some(id) {
return window::gain_focus(id); return window::gain_focus(id);
@@ -547,6 +611,8 @@ impl App {
} }
} }
} }
#[cfg(target_os = "macos")]
self.sync_native_menu();
if id == self.main_window && self.selected_project.is_some() { if id == self.main_window && self.selected_project.is_some() {
return focus_composer(); return focus_composer();
} }
@@ -559,6 +625,9 @@ impl App {
self.model_manager_window = None; self.model_manager_window = None;
self.pending_model_delete = None; self.pending_model_delete = None;
} }
if self.help_window == Some(id) {
self.help_window = None;
}
} }
Message::DismissPanel => { Message::DismissPanel => {
if self.pending_session_delete.is_some() { if self.pending_session_delete.is_some() {
@@ -612,6 +681,8 @@ impl App {
Message::ToggleSidebar => { Message::ToggleSidebar => {
self.config.interface.sidebar_collapsed = !self.config.interface.sidebar_collapsed; self.config.interface.sidebar_collapsed = !self.config.interface.sidebar_collapsed;
self.store_config(); self.store_config();
#[cfg(target_os = "macos")]
self.sync_native_menu();
} }
Message::MetricsTick => self.sample_metrics(), Message::MetricsTick => self.sample_metrics(),
Message::PreferenceModelChanged(model) => { Message::PreferenceModelChanged(model) => {
@@ -873,6 +944,13 @@ impl App {
} }
Message::DownloadProgressTick => self.update_download_progress(), Message::DownloadProgressTick => self.update_download_progress(),
Message::ComposerChanged(value) => self.composer = value, Message::ComposerChanged(value) => self.composer = value,
Message::TranscriptAction(index, action) => {
if !action.is_edit()
&& let Some(message) = self.conversation.get_mut(index)
{
message.transcript.perform(action);
}
}
Message::A2uiDataChanged(surface_id, path, value) => { Message::A2uiDataChanged(surface_id, path, value) => {
if self.a2ui_history_index.is_some() { if self.a2ui_history_index.is_some() {
return Task::none(); return Task::none();
@@ -1158,6 +1236,7 @@ impl App {
self.selected_project = None; self.selected_project = None;
self.selected_session = None; self.selected_session = None;
self.conversation.clear(); self.conversation.clear();
self.context_notice = None;
self.clear_a2ui(); self.clear_a2ui();
self.composer.clear(); self.composer.clear();
self.queued_inputs.clear(); self.queued_inputs.clear();
@@ -1321,6 +1400,7 @@ impl App {
match loaded { match loaded {
Ok((messages, a2ui)) => { Ok((messages, a2ui)) => {
self.conversation = messages.into_iter().map(ChatMessage::from).collect(); self.conversation = messages.into_iter().map(ChatMessage::from).collect();
self.context_notice = None;
self.clear_a2ui(); self.clear_a2ui();
let (history, active, errors) = let (history, active, errors) =
crate::a2ui::replay_epochs(a2ui.iter().map(|message| { crate::a2ui::replay_epochs(a2ui.iter().map(|message| {
@@ -1403,6 +1483,7 @@ impl App {
if self.selected_session == Some(session_id) { if self.selected_session == Some(session_id) {
self.selected_session = None; self.selected_session = None;
self.conversation.clear(); self.conversation.clear();
self.context_notice = None;
self.clear_a2ui(); self.clear_a2ui();
self.composer.clear(); self.composer.clear();
self.queued_inputs.clear(); self.queued_inputs.clear();
@@ -1450,6 +1531,10 @@ impl App {
Some(crate::native_menu::NativeMenuEvent::ModelManager) => { Some(crate::native_menu::NativeMenuEvent::ModelManager) => {
Message::OpenModelManager Message::OpenModelManager
} }
Some(crate::native_menu::NativeMenuEvent::NewChat) => Message::NewChat,
Some(crate::native_menu::NativeMenuEvent::ExportChat) => Message::ExportChat,
Some(crate::native_menu::NativeMenuEvent::ToggleSidebar) => Message::ToggleSidebar,
Some(crate::native_menu::NativeMenuEvent::Help) => Message::OpenHelp,
Some(crate::native_menu::NativeMenuEvent::Edit(command)) => { Some(crate::native_menu::NativeMenuEvent::Edit(command)) => {
Message::NativeEdit(command) Message::NativeEdit(command)
} }
@@ -1489,6 +1574,8 @@ impl App {
pub(crate) fn title(&self, id: window::Id) -> String { pub(crate) fn title(&self, id: window::Id) -> String {
if self.model_manager_window == Some(id) { if self.model_manager_window == Some(id) {
"Model Manager — DS4Server".to_owned() "Model Manager — DS4Server".to_owned()
} else if self.help_window == Some(id) {
"Help — DS4Server".to_owned()
} else { } else {
"DS4Server".to_owned() "DS4Server".to_owned()
} }
@@ -1594,6 +1681,50 @@ impl App {
self.kv_cache_report = crate::metrics::kv_cache_report(&kv_cache_path(), budget); self.kv_cache_report = crate::metrics::kv_cache_report(&kv_cache_path(), budget);
self.last_cache_scan = Instant::now(); self.last_cache_scan = Instant::now();
} }
fn active_chat_title(&self) -> Option<&str> {
let project_id = self.selected_project?;
if let Some(session_id) = self.selected_session {
return self
.projects
.iter()
.flat_map(|project| &project.sessions)
.find(|session| session.id == session_id)
.map(|session| session.title.as_str());
}
self.drafts.get(&project_id).map(String::as_str)
}
fn open_help(&mut self) -> Task<Message> {
if let Some(id) = self.help_window {
return window::gain_focus(id);
}
let (id, open) = window::open(window::Settings {
size: Size::new(760.0, 640.0),
min_size: Some(Size::new(560.0, 420.0)),
icon: Some(app_icon()),
..Default::default()
});
self.help_window = Some(id);
open.map(Message::HelpOpened)
}
#[cfg(target_os = "macos")]
fn sync_native_menu(&self) {
let Some(menu) = &self._native_menu else {
return;
};
let edit = *self
.native_edit_availability
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
menu.update(
self.selected_project.is_some() && !self.generating,
self.active_chat_title().is_some(),
!self.config.interface.sidebar_collapsed,
edit,
);
}
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -1655,6 +1786,8 @@ fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Messag
keyboard::Key::Character("m") if modifiers.command() && modifiers.shift() => { keyboard::Key::Character("m") if modifiers.command() && modifiers.shift() => {
Some(Message::OpenModelManager) Some(Message::OpenModelManager)
} }
keyboard::Key::Character("b") if modifiers.command() => Some(Message::ToggleSidebar),
keyboard::Key::Character("n") if modifiers.command() => Some(Message::NewChat),
keyboard::Key::Named(keyboard::key::Named::Tab) => Some(if modifiers.shift() { keyboard::Key::Named(keyboard::key::Named::Tab) => Some(if modifiers.shift() {
Message::FocusPrevious Message::FocusPrevious
} else { } else {
@@ -1664,6 +1797,59 @@ fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Messag
} }
} }
fn export_file_stem(title: &str) -> String {
let stem = title
.chars()
.map(|character| match character {
'/' | ':' | '\0' => '-',
character => character,
})
.collect::<String>();
let stem = stem.trim().trim_matches('.');
if stem.is_empty() {
"DS4Server Chat".to_owned()
} else {
stem.to_owned()
}
}
fn export_markdown(title: &str, conversation: &[ChatMessage]) -> String {
use std::fmt::Write;
let mut output = format!("# {title}\n");
for message in conversation {
if message.system {
continue;
}
if message.compaction {
let _ = write!(
output,
"\n> Context compacted: {}\n",
message.content.trim()
);
continue;
}
let label = if message.user {
"You"
} else if message.tool {
"Tool"
} else {
"DS4"
};
let _ = write!(output, "\n## {label}\n");
if let Some(reasoning) = message.reasoning.as_deref().filter(|text| !text.is_empty()) {
let _ = write!(
output,
"\n<details>\n<summary>Reasoning</summary>\n\n{reasoning}\n\n</details>\n"
);
}
let visible = crate::agent::visible_content(&message.content);
let visible = crate::a2ui::transcript_fallback(visible);
let _ = write!(output, "\n{}\n", visible.trim());
}
output
}
fn application_support_path() -> PathBuf { fn application_support_path() -> PathBuf {
std::env::var_os("HOME") std::env::var_os("HOME")
.map(PathBuf::from) .map(PathBuf::from)
@@ -1991,6 +2177,7 @@ mod tests {
reasoning_open: true, reasoning_open: true,
content: String::new(), content: String::new(),
markdown: markdown::Content::new(), markdown: markdown::Content::new(),
transcript: text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,
a2ui_errors: Vec::new(), a2ui_errors: Vec::new(),
a2ui_replies: Vec::new(), a2ui_replies: Vec::new(),
@@ -2004,4 +2191,40 @@ mod tests {
assert_eq!(message.content, "**final answer**"); assert_eq!(message.content, "**final answer**");
assert!(!message.markdown.items().is_empty()); assert!(!message.markdown.items().is_empty());
} }
#[test]
fn chat_export_keeps_visible_turns_and_omits_system_messages() {
let message = |user, tool, system, content: &str| ChatMessage {
id: 1,
user,
tool,
system,
compaction: false,
compaction_tail_start: None,
generation_stats: None,
reasoning: None,
reasoning_complete: true,
reasoning_open: false,
content: content.into(),
markdown: markdown::Content::new(),
transcript: text_editor::Content::new(),
a2ui_lines_processed: 0,
a2ui_errors: Vec::new(),
a2ui_replies: Vec::new(),
a2ui_open_urls: Vec::new(),
};
let exported = export_markdown(
"A chat",
&[
message(false, false, true, "private"),
message(true, false, false, "hello"),
message(false, false, false, "**hi**"),
],
);
assert_eq!(
exported,
"# A chat\n\n## You\n\nhello\n\n## DS4\n\n**hi**\n"
);
assert!(!exported.contains("private"));
}
} }

View File

@@ -61,6 +61,7 @@ pub(crate) struct ChatMessage {
pub(super) reasoning_open: bool, pub(super) reasoning_open: bool,
pub(super) content: String, pub(super) content: String,
pub(super) markdown: markdown::Content, pub(super) markdown: markdown::Content,
pub(super) transcript: text_editor::Content,
pub(super) a2ui_lines_processed: usize, pub(super) a2ui_lines_processed: usize,
pub(super) a2ui_errors: Vec<String>, pub(super) a2ui_errors: Vec<String>,
pub(super) a2ui_replies: Vec<serde_json::Value>, pub(super) a2ui_replies: Vec<serde_json::Value>,
@@ -89,7 +90,6 @@ impl ChatMessage {
} }
pub(super) fn refresh_markdown(&mut self) { pub(super) fn refresh_markdown(&mut self) {
if !self.user && !self.tool {
let visible = crate::agent::visible_content(&self.content); let visible = crate::agent::visible_content(&self.content);
let visible = crate::a2ui::transcript_fallback(visible); let visible = crate::a2ui::transcript_fallback(visible);
let content = if self.reasoning.is_some() { let content = if self.reasoning.is_some() {
@@ -97,6 +97,10 @@ impl ChatMessage {
} else { } else {
&visible &visible
}; };
if self.transcript.text() != content {
self.transcript = text_editor::Content::with_text(content);
}
if !self.user && !self.tool {
self.markdown = markdown::Content::parse(content); self.markdown = markdown::Content::parse(content);
} }
} }
@@ -165,6 +169,7 @@ impl From<StoredMessage> for ChatMessage {
reasoning_open: false, reasoning_open: false,
content: message.content, content: message.content,
markdown: iced::widget::markdown::Content::new(), markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,
a2ui_errors: Vec::new(), a2ui_errors: Vec::new(),
a2ui_replies: Vec::new(), a2ui_replies: Vec::new(),
@@ -341,6 +346,7 @@ impl App {
return; return;
} }
self.a2ui_auto_switch_pending = true; self.a2ui_auto_switch_pending = true;
self.context_notice = None;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
self.tool_cards.clear(); self.tool_cards.clear();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -633,6 +639,9 @@ impl App {
match active.events.try_recv() { match active.events.try_recv() {
Ok(GenerationEvent::Loading) => {} Ok(GenerationEvent::Loading) => {}
Ok(GenerationEvent::Activity(activity)) => { Ok(GenerationEvent::Activity(activity)) => {
if activity.starts_with("Rebuilding context:") {
self.context_notice = Some(activity.to_owned());
}
self.activity = Some(activity.into()); self.activity = Some(activity.into());
} }
Ok(GenerationEvent::Compacted(_)) => { Ok(GenerationEvent::Compacted(_)) => {
@@ -1666,6 +1675,7 @@ mod tests {
reasoning_open: false, reasoning_open: false,
content: content.to_owned(), content: content.to_owned(),
markdown: iced::widget::markdown::Content::new(), markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,
a2ui_errors: Vec::new(), a2ui_errors: Vec::new(),
a2ui_replies: Vec::new(), a2ui_replies: Vec::new(),
@@ -1727,6 +1737,7 @@ mod tests {
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." 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(), .to_owned(),
markdown: iced::widget::markdown::Content::new(), markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,
a2ui_errors: Vec::new(), a2ui_errors: Vec::new(),
a2ui_replies: Vec::new(), a2ui_replies: Vec::new(),
@@ -1792,6 +1803,7 @@ mod tests {
reasoning_open: false, reasoning_open: false,
content: format!("message {id}"), content: format!("message {id}"),
markdown: iced::widget::markdown::Content::new(), markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,
a2ui_errors: Vec::new(), a2ui_errors: Vec::new(),
a2ui_replies: Vec::new(), a2ui_replies: Vec::new(),
@@ -1829,6 +1841,7 @@ mod tests {
reasoning_open: false, reasoning_open: false,
content: format!("message {id}"), content: format!("message {id}"),
markdown: iced::widget::markdown::Content::new(), markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0, a2ui_lines_processed: 0,
a2ui_errors: Vec::new(), a2ui_errors: Vec::new(),
a2ui_replies: Vec::new(), a2ui_replies: Vec::new(),

View File

@@ -175,6 +175,7 @@ impl App {
self.remember_project(project_id); self.remember_project(project_id);
self.selected_session = None; self.selected_session = None;
self.conversation.clear(); self.conversation.clear();
self.context_notice = None;
self.clear_a2ui(); self.clear_a2ui();
self.composer.clear(); self.composer.clear();
self.queued_inputs.clear(); self.queued_inputs.clear();
@@ -188,6 +189,7 @@ impl App {
pub(super) fn discard_session(&mut self, project_id: i32) { pub(super) fn discard_session(&mut self, project_id: i32) {
if self.drafts.remove(&project_id).is_some() && self.draft_selected(project_id) { if self.drafts.remove(&project_id).is_some() && self.draft_selected(project_id) {
self.conversation.clear(); self.conversation.clear();
self.context_notice = None;
self.clear_a2ui(); self.clear_a2ui();
self.composer.clear(); self.composer.clear();
self.queued_inputs.clear(); self.queued_inputs.clear();

View File

@@ -53,16 +53,46 @@ const TRAFFIC_LIGHT_WIDTH: f32 = 78.0;
impl App { impl App {
pub(crate) fn view(&self, id: window::Id) -> Element<'_, Message> { pub(crate) fn view(&self, id: window::Id) -> Element<'_, Message> {
if self.model_manager_window == Some(id) { let content = if self.model_manager_window == Some(id) {
self.model_manager() self.model_manager()
} else if self.help_window == Some(id) {
self.help_view()
} else { } else {
let content = self.main_view(); self.main_view()
};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
return crate::native_edit::native_edit(content, self.native_edit_commands.clone()) return crate::native_edit::native_edit(
content,
self.native_edit_commands.clone(),
self.native_edit_availability.clone(),
)
.into(); .into();
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
content content
} }
fn help_view(&self) -> Element<'_, Message> {
container(
scrollable(
container(
markdown::view(
self.help_markdown.items(),
markdown::Settings::with_text_size(
15,
markdown::Style::from_palette(app_theme().palette()),
),
)
.map(Message::OpenLink),
)
.max_width(760)
.padding(32),
)
.height(Length::Fill),
)
.center_x(Length::Fill)
.height(Length::Fill)
.style(sidebar_style)
.into()
} }
/// Whether a dialog covers the window. Focus moves through the whole widget /// Whether a dialog covers the window. Focus moves through the whole widget

View File

@@ -55,7 +55,6 @@ impl App {
.spacing(8), .spacing(8),
); );
} else { } else {
let markdown_style = markdown::Style::from_palette(app_theme().palette());
for (index, message) in self.conversation.iter().enumerate() { for (index, message) in self.conversation.iter().enumerate() {
if message.compaction { if message.compaction {
messages = messages.push( messages = messages.push(
@@ -124,22 +123,17 @@ impl App {
} }
} }
if !message.content.is_empty() { if !message.content.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 {
crate::agent::visible_content(&message.content)
};
body = body.push(text(content).size(14));
} else {
body = body.push( body = body.push(
markdown::view( text_editor(&message.transcript)
message.markdown.items(), .id(iced::widget::Id::from(format!(
markdown::Settings::with_text_size(14, markdown_style), "transcript-{}-{index}",
) message.id
.map(Message::OpenLink), )))
.on_action(move |action| Message::TranscriptAction(index, action))
.padding(0)
.size(14)
.style(selectable_text_style),
); );
}
} else if active && message.reasoning.is_none() { } else if active && message.reasoning.is_none() {
body = body.push( body = body.push(
text(self.activity.as_deref().unwrap_or("Loading model…")).size(14), text(self.activity.as_deref().unwrap_or("Loading model…")).size(14),
@@ -292,6 +286,20 @@ impl App {
.spacing(6) .spacing(6)
.align_y(Alignment::Center), .align_y(Alignment::Center),
); );
if let Some(notice) = &self.context_notice {
messages = messages.push(
container(
column![
text("Context rebuild").size(11),
text(notice).size(13).color(muted_text()),
]
.spacing(5),
)
.padding(14)
.width(Length::Fill)
.style(preference_group_style),
);
}
let transcript = scrollable(messages) let transcript = scrollable(messages)
.id(chat_scroll_id()) .id(chat_scroll_id())
.height(Length::Fill); .height(Length::Fill);
@@ -337,6 +345,19 @@ impl App {
} }
} }
fn selectable_text_style(
theme: &Theme,
_status: iced::widget::text_editor::Status,
) -> iced::widget::text_editor::Style {
iced::widget::text_editor::Style {
background: Background::Color(Color::TRANSPARENT),
border: Border::default(),
placeholder: muted_text(),
value: theme.palette().text,
selection: theme.extended_palette().primary.weak.color,
}
}
fn generation_summary(stats: crate::app::generation::GenerationStats) -> Element<'static, Message> { fn generation_summary(stats: crate::app::generation::GenerationStats) -> Element<'static, Message> {
column![ column![
row![ row![

View File

@@ -467,6 +467,19 @@ pub(crate) struct GenerationOutput {
pub(crate) checkpoint_bytes: u64, pub(crate) checkpoint_bytes: u64,
} }
struct CheckpointSelection {
found: bool,
incompatible: bool,
}
fn checkpoint_rebuild_activity(incompatible: bool) -> &'static str {
if incompatible {
"Rebuilding context: the checkpoint belongs to a different model or model configuration."
} else {
"Rebuilding context: the saved history or generation settings changed."
}
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub(crate) struct CompactionOutput { pub(crate) struct CompactionOutput {
pub(crate) summary: String, pub(crate) summary: String,
@@ -543,6 +556,7 @@ impl Generator {
self.executor.model().summary() self.executor.model().summary()
} }
#[allow(clippy::too_many_arguments)]
pub(crate) fn generate( pub(crate) fn generate(
&mut self, &mut self,
checkpoint: &Path, checkpoint: &Path,
@@ -551,14 +565,19 @@ impl Generator {
cancelled: &AtomicBool, cancelled: &AtomicBool,
mut emit: impl FnMut(bool, String), mut emit: impl FnMut(bool, String),
mut progress: impl FnMut(u32, u32, Option<f32>), mut progress: impl FnMut(u32, u32, Option<f32>),
mut phase: impl FnMut(&'static str),
) -> Result<GenerationOutput, String> { ) -> Result<GenerationOutput, String> {
let history = messages let history = messages
.split_last() .split_last()
.map_or(messages, |(_, history)| history); .map_or(messages, |(_, history)| history);
self.select_checkpoint( let checkpoint_present = checkpoint.is_file();
let selected = self.select_checkpoint(
checkpoint, checkpoint,
conversation_tag(&settings.system_prompt, settings.reasoning_mode, history), conversation_tag(&settings.system_prompt, settings.reasoning_mode, history),
)?; )?;
if checkpoint_present && !selected.found {
phase(checkpoint_rebuild_activity(selected.incompatible));
}
let result = self.generate_inner(messages, settings, cancelled, &mut emit, &mut progress); let result = self.generate_inner(messages, settings, cancelled, &mut emit, &mut progress);
self.publish_execution_stats(); self.publish_execution_stats();
let (mut output, prompt_complete) = result?; let (mut output, prompt_complete) = result?;
@@ -632,7 +651,7 @@ impl Generator {
let mut previous_checkpoint = self.checkpoint.clone(); let mut previous_checkpoint = self.checkpoint.clone();
if self.executor.checkpoint_tag() != history_tag { if self.executor.checkpoint_tag() != history_tag {
if let Some(entry) = store.find(&history_key, self.executor.context()) { if let Some(entry) = store.find(&history_key, self.executor.context()) {
if self.select_checkpoint(&entry.checkpoint, entry.tag)? { if self.select_checkpoint(&entry.checkpoint, entry.tag)?.found {
store.touch(&entry)?; store.touch(&entry)?;
self.last_store_tokens = entry.tokens; self.last_store_tokens = entry.tokens;
previous_checkpoint = Some(entry.checkpoint); previous_checkpoint = Some(entry.checkpoint);
@@ -861,19 +880,25 @@ impl Generator {
&mut self, &mut self,
checkpoint: &Path, checkpoint: &Path,
expected_tag: [u8; 32], expected_tag: [u8; 32],
) -> Result<bool, String> { ) -> Result<CheckpointSelection, String> {
let resident_hit = self.activate_resident(checkpoint.to_owned())?; let resident_hit = self.activate_resident(checkpoint.to_owned())?;
if resident_hit && self.executor.checkpoint_tag() == expected_tag { if resident_hit && self.executor.checkpoint_tag() == expected_tag {
self.checkpoint = Some(checkpoint.to_owned()); self.checkpoint = Some(checkpoint.to_owned());
self.metrics.kv_lookup(KvLookup::MemoryHit); self.metrics.kv_lookup(KvLookup::MemoryHit);
return Ok(true); return Ok(CheckpointSelection {
found: true,
incompatible: false,
});
} }
if self.checkpoint.as_deref() == Some(checkpoint) { if self.checkpoint.as_deref() == Some(checkpoint) {
if !checkpoint.is_file() { if !checkpoint.is_file() {
self.executor.reset()?; self.executor.reset()?;
self.checkpoint = None; self.checkpoint = None;
self.metrics.kv_lookup(KvLookup::Miss); self.metrics.kv_lookup(KvLookup::Miss);
return Ok(false); return Ok(CheckpointSelection {
found: false,
incompatible: false,
});
} }
let found = self.executor.checkpoint_tag() == expected_tag; let found = self.executor.checkpoint_tag() == expected_tag;
self.metrics.kv_lookup(if found { self.metrics.kv_lookup(if found {
@@ -881,7 +906,10 @@ impl Generator {
} else { } else {
KvLookup::Miss KvLookup::Miss
}); });
return Ok(found); return Ok(CheckpointSelection {
found,
incompatible: false,
});
} }
self.executor.reset()?; self.executor.reset()?;
self.metrics.kv_read_started(); self.metrics.kv_read_started();
@@ -892,6 +920,7 @@ impl Generator {
self.metrics self.metrics
.kv_read_finished(started.elapsed(), loaded.is_err()); .kv_read_finished(started.elapsed(), loaded.is_err());
let found = matches!(loaded, Ok(true)) && self.executor.checkpoint_tag() == expected_tag; let found = matches!(loaded, Ok(true)) && self.executor.checkpoint_tag() == expected_tag;
let incompatible = loaded.is_err();
let lookup = match loaded { let lookup = match loaded {
Ok(true) if found => KvLookup::DiskHit, Ok(true) if found => KvLookup::DiskHit,
Ok(true) | Ok(false) => KvLookup::Miss, Ok(true) | Ok(false) => KvLookup::Miss,
@@ -903,7 +932,10 @@ impl Generator {
}; };
self.metrics.kv_lookup(lookup); self.metrics.kv_lookup(lookup);
self.checkpoint = Some(checkpoint.to_owned()); self.checkpoint = Some(checkpoint.to_owned());
Ok(found) Ok(CheckpointSelection {
found,
incompatible,
})
} }
fn activate_resident(&mut self, key: PathBuf) -> Result<bool, String> { fn activate_resident(&mut self, key: PathBuf) -> Result<bool, String> {
@@ -1616,6 +1648,12 @@ mod sampling_tests {
assert!(conversation_key("System", ReasoningMode::High, &messages).starts_with(&prefix)); assert!(conversation_key("System", ReasoningMode::High, &messages).starts_with(&prefix));
} }
#[test]
fn checkpoint_rebuilds_explain_compatibility_and_history_misses() {
assert!(checkpoint_rebuild_activity(true).contains("different model"));
assert!(checkpoint_rebuild_activity(false).contains("history"));
}
#[test] #[test]
#[ignore = "requires the 80 GiB Flash checkpoint and Apple Metal"] #[ignore = "requires the 80 GiB Flash checkpoint and Apple Metal"]
fn metal_executes_real_flash_token() { fn metal_executes_real_flash_token() {

View File

@@ -1,4 +1,4 @@
use std::collections::VecDeque; use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use iced::advanced::layout; use iced::advanced::layout;
@@ -22,10 +22,26 @@ pub(crate) enum EditCommand {
pub(crate) type EditCommandQueue = Arc<Mutex<VecDeque<EditCommand>>>; pub(crate) type EditCommandQueue = Arc<Mutex<VecDeque<EditCommand>>>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct EditAvailability {
pub(crate) undo: bool,
pub(crate) redo: bool,
pub(crate) cut: bool,
pub(crate) copy: bool,
pub(crate) paste: bool,
pub(crate) select_all: bool,
}
pub(crate) type EditAvailabilityState = Arc<Mutex<EditAvailability>>;
pub(crate) fn command_queue() -> EditCommandQueue { pub(crate) fn command_queue() -> EditCommandQueue {
Arc::new(Mutex::new(VecDeque::new())) Arc::new(Mutex::new(VecDeque::new()))
} }
pub(crate) fn availability_state() -> EditAvailabilityState {
Arc::new(Mutex::new(EditAvailability::default()))
}
pub(crate) fn queue_command(queue: &EditCommandQueue, command: EditCommand) { pub(crate) fn queue_command(queue: &EditCommandQueue, command: EditCommand) {
queue queue
.lock() .lock()
@@ -44,22 +60,131 @@ fn pop_command(queue: &EditCommandQueue) -> Option<EditCommand> {
pub(crate) struct NativeEdit<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> { pub(crate) struct NativeEdit<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> {
content: Element<'a, Message, Theme, Renderer>, content: Element<'a, Message, Theme, Renderer>,
commands: EditCommandQueue, commands: EditCommandQueue,
availability: EditAvailabilityState,
} }
impl<'a, Message, Theme, Renderer> NativeEdit<'a, Message, Theme, Renderer> { impl<'a, Message, Theme, Renderer> NativeEdit<'a, Message, Theme, Renderer> {
fn new( fn new(
content: impl Into<Element<'a, Message, Theme, Renderer>>, content: impl Into<Element<'a, Message, Theme, Renderer>>,
commands: EditCommandQueue, commands: EditCommandQueue,
availability: EditAvailabilityState,
) -> Self { ) -> Self {
Self { Self {
content: content.into(), content: content.into(),
commands, commands,
availability,
} }
} }
} }
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
enum ControlId {
Named(iced::widget::Id),
Position(usize),
}
#[derive(Debug)]
struct History {
values: Vec<String>,
current: usize,
}
#[derive(Default)] #[derive(Default)]
struct State; struct State {
histories: HashMap<ControlId, History>,
active: Option<ControlId>,
window_focused: bool,
}
#[derive(Default)]
struct FocusedControl {
current: usize,
pending_text: Option<String>,
focused: Option<(ControlId, Option<String>)>,
}
impl<T> Operation<T> for FocusedControl {
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<T>)) {
operate(self);
}
fn text_input(
&mut self,
_id: Option<&iced::widget::Id>,
_bounds: Rectangle,
state: &mut dyn iced::advanced::widget::operation::TextInput,
) {
self.pending_text = Some(state.text().to_owned());
}
fn focusable(
&mut self,
id: Option<&iced::widget::Id>,
_bounds: Rectangle,
state: &mut dyn iced::advanced::widget::operation::Focusable,
) {
if state.is_focused() {
self.focused = Some((
id.cloned()
.map_or(ControlId::Position(self.current), ControlId::Named),
self.pending_text.take(),
));
} else {
self.pending_text = None;
}
self.current += 1;
}
}
impl State {
fn observe(&mut self, focused: Option<&(ControlId, Option<String>)>) {
self.active = focused.map(|(control, _)| control.clone());
let Some((control, Some(value))) = focused else {
return;
};
let history = self
.histories
.entry(control.clone())
.or_insert_with(|| History {
values: vec![value.clone()],
current: 0,
});
if history.values[history.current] != *value {
history.values.truncate(history.current + 1);
history.values.push(value.clone());
history.current += 1;
}
}
fn replacement(&mut self, command: EditCommand) -> Option<String> {
let history = self.histories.get_mut(self.active.as_ref()?)?;
match command {
EditCommand::Undo if history.current > 0 => history.current -= 1,
EditCommand::Redo if history.current + 1 < history.values.len() => {
history.current += 1;
}
_ => return None,
}
Some(history.values[history.current].clone())
}
fn availability(&self, focused: Option<&(ControlId, Option<String>)>) -> EditAvailability {
let Some((control, text)) = focused else {
return EditAvailability::default();
};
let editable = text.is_some();
let history = self.histories.get(control);
EditAvailability {
undo: editable && history.is_some_and(|history| history.current > 0),
redo: editable
&& history.is_some_and(|history| history.current + 1 < history.values.len()),
cut: editable,
copy: true,
paste: editable,
select_all: true,
}
}
}
fn modifier_sync_event(event: &Event) -> Option<Event> { fn modifier_sync_event(event: &Event) -> Option<Event> {
let Event::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) = event else { let Event::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) = event else {
@@ -117,7 +242,7 @@ where
} }
fn state(&self) -> tree::State { fn state(&self) -> tree::State {
tree::State::new(State) tree::State::new(State::default())
} }
fn children(&self) -> Vec<Tree> { fn children(&self) -> Vec<Tree> {
@@ -166,11 +291,44 @@ where
shell: &mut Shell<'_, Message>, shell: &mut Shell<'_, Message>,
viewport: &Rectangle, viewport: &Rectangle,
) { ) {
if matches!( let state = tree.state.downcast_mut::<State>();
match event {
Event::Window(iced::window::Event::Focused) => state.window_focused = true,
Event::Window(iced::window::Event::Unfocused) => state.window_focused = false,
_ => {}
}
if state.window_focused
&& matches!(
event, event,
Event::Window(iced::window::Event::RedrawRequested(_)) Event::Window(iced::window::Event::RedrawRequested(_))
) { )
{
while let Some(command) = pop_command(&self.commands) { while let Some(command) = pop_command(&self.commands) {
if let Some(replacement) = state.replacement(command) {
let previous = clipboard.read(iced::advanced::clipboard::Kind::Standard);
clipboard.write(iced::advanced::clipboard::Kind::Standard, replacement);
for command in [EditCommand::SelectAll, EditCommand::Paste] {
for command_event in command_events(command) {
self.content.as_widget_mut().update(
&mut tree.children[0],
&command_event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
}
if let Some(previous) = previous {
clipboard.write(iced::advanced::clipboard::Kind::Standard, previous);
}
continue;
}
if matches!(command, EditCommand::Undo | EditCommand::Redo) {
continue;
}
for command_event in command_events(command) { for command_event in command_events(command) {
self.content.as_widget_mut().update( self.content.as_widget_mut().update(
&mut tree.children[0], &mut tree.children[0],
@@ -209,6 +367,19 @@ where
shell, shell,
viewport, viewport,
); );
let mut focused = FocusedControl::default();
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, &mut focused);
if state.window_focused {
state.observe(focused.focused.as_ref());
*self
.availability
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) =
state.availability(focused.focused.as_ref());
}
} }
fn mouse_interaction( fn mouse_interaction(
@@ -282,8 +453,9 @@ where
pub(crate) fn native_edit<'a, Message, Theme, Renderer>( pub(crate) fn native_edit<'a, Message, Theme, Renderer>(
content: impl Into<Element<'a, Message, Theme, Renderer>>, content: impl Into<Element<'a, Message, Theme, Renderer>>,
commands: EditCommandQueue, commands: EditCommandQueue,
availability: EditAvailabilityState,
) -> NativeEdit<'a, Message, Theme, Renderer> { ) -> NativeEdit<'a, Message, Theme, Renderer> {
NativeEdit::new(content, commands) NativeEdit::new(content, commands, availability)
} }
#[cfg(test)] #[cfg(test)]
@@ -310,4 +482,14 @@ mod tests {
Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) if modifiers.is_empty() Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) if modifiers.is_empty()
)); ));
} }
#[test]
fn edit_history_supports_undo_and_redo() {
let control = ControlId::Position(0);
let mut state = State::default();
state.observe(Some(&(control.clone(), Some("a".into()))));
state.observe(Some(&(control.clone(), Some("ab".into()))));
assert_eq!(state.replacement(EditCommand::Undo).as_deref(), Some("a"));
assert_eq!(state.replacement(EditCommand::Redo).as_deref(), Some("ab"));
}
} }

View File

@@ -1,10 +1,14 @@
use muda::accelerator::{Accelerator, CMD_OR_CTRL, Code, Modifiers}; use muda::accelerator::{Accelerator, CMD_OR_CTRL, Code, Modifiers};
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use crate::native_edit::EditCommand; use crate::native_edit::EditCommand;
const PREFERENCES: &str = "preferences"; const PREFERENCES: &str = "preferences";
const MODEL_MANAGER: &str = "model-manager"; const MODEL_MANAGER: &str = "model-manager";
const NEW_CHAT: &str = "new-chat";
const EXPORT_CHAT: &str = "export-chat";
const TOGGLE_SIDEBAR: &str = "toggle-sidebar";
const HELP: &str = "help";
const UNDO: &str = "undo"; const UNDO: &str = "undo";
const REDO: &str = "redo"; const REDO: &str = "redo";
const CUT: &str = "cut"; const CUT: &str = "cut";
@@ -14,20 +18,36 @@ const SELECT_ALL: &str = "select-all";
pub(crate) struct NativeMenu { pub(crate) struct NativeMenu {
_menu: Menu, _menu: Menu,
new_chat: MenuItem,
export_chat: MenuItem,
sidebar: CheckMenuItem,
undo: MenuItem,
redo: MenuItem,
cut: MenuItem,
copy: MenuItem,
paste: MenuItem,
select_all: MenuItem,
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub(crate) enum NativeMenuEvent { pub(crate) enum NativeMenuEvent {
Preferences, Preferences,
ModelManager, ModelManager,
NewChat,
ExportChat,
ToggleSidebar,
Help,
Edit(EditCommand), Edit(EditCommand),
} }
pub(crate) fn install() -> Result<NativeMenu, String> { pub(crate) fn install() -> Result<NativeMenu, String> {
let menu = Menu::new(); let menu = Menu::new();
let application = Submenu::new("DS4Server", true); let application = Submenu::new("DS4Server", true);
let file = Submenu::new("File", true);
let edit = Submenu::new("Edit", true); let edit = Submenu::new("Edit", true);
let view = Submenu::new("View", true);
let window = Submenu::new("Window", true); let window = Submenu::new("Window", true);
let help = Submenu::new("Help", true);
let preferences = MenuItem::with_id( let preferences = MenuItem::with_id(
PREFERENCES, PREFERENCES,
"Preferences…", "Preferences…",
@@ -43,6 +63,37 @@ pub(crate) fn install() -> Result<NativeMenu, String> {
Code::KeyM, Code::KeyM,
)), )),
); );
let new_chat = MenuItem::with_id(
NEW_CHAT,
"New Chat",
false,
Some(Accelerator::new(Some(Modifiers::SUPER), Code::KeyN)),
);
let export_chat = MenuItem::with_id(
EXPORT_CHAT,
"Export Chat as Markdown…",
false,
Some(Accelerator::new(
Some(Modifiers::SUPER | Modifiers::SHIFT),
Code::KeyS,
)),
);
let sidebar = CheckMenuItem::with_id(
TOGGLE_SIDEBAR,
"Show Sidebar",
true,
true,
Some(Accelerator::new(Some(Modifiers::SUPER), Code::KeyB)),
);
let open_help = MenuItem::with_id(
HELP,
"DS4Server Help",
true,
Some(Accelerator::new(
Some(Modifiers::SUPER | Modifiers::SHIFT),
Code::Slash,
)),
);
let undo = edit_item(UNDO, "Undo", Code::KeyZ, None); let undo = edit_item(UNDO, "Undo", Code::KeyZ, None);
let redo = edit_item(REDO, "Redo", Code::KeyZ, Some(Modifiers::SHIFT)); let redo = edit_item(REDO, "Redo", Code::KeyZ, Some(Modifiers::SHIFT));
let cut = edit_item(CUT, "Cut", Code::KeyX, None); let cut = edit_item(CUT, "Cut", Code::KeyX, None);
@@ -65,6 +116,13 @@ pub(crate) fn install() -> Result<NativeMenu, String> {
&PredefinedMenuItem::quit(None), &PredefinedMenuItem::quit(None),
]) ])
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
file.append_items(&[
&new_chat,
&export_chat,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::close_window(None),
])
.map_err(|error| error.to_string())?;
edit.append_items(&[ edit.append_items(&[
&undo, &undo,
&redo, &redo,
@@ -75,21 +133,53 @@ pub(crate) fn install() -> Result<NativeMenu, String> {
&select_all, &select_all,
]) ])
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
view.append_items(&[&sidebar, &PredefinedMenuItem::separator(), &model_manager])
.map_err(|error| error.to_string())?;
window window
.append_items(&[ .append_items(&[
&model_manager,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::minimize(None), &PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None), &PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::close_window(None), &PredefinedMenuItem::close_window(None),
&PredefinedMenuItem::bring_all_to_front(None), &PredefinedMenuItem::bring_all_to_front(None),
]) ])
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
menu.append_items(&[&application, &edit, &window]) help.append(&open_help).map_err(|error| error.to_string())?;
menu.append_items(&[&application, &file, &edit, &view, &window, &help])
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
menu.init_for_nsapp(); menu.init_for_nsapp();
window.set_as_windows_menu_for_nsapp(); window.set_as_windows_menu_for_nsapp();
Ok(NativeMenu { _menu: menu }) Ok(NativeMenu {
_menu: menu,
new_chat,
export_chat,
sidebar,
undo,
redo,
cut,
copy,
paste,
select_all,
})
}
impl NativeMenu {
pub(crate) fn update(
&self,
project_active: bool,
chat_active: bool,
sidebar_visible: bool,
edit: crate::native_edit::EditAvailability,
) {
self.new_chat.set_enabled(project_active);
self.export_chat.set_enabled(chat_active);
self.sidebar.set_checked(sidebar_visible);
self.undo.set_enabled(edit.undo);
self.redo.set_enabled(edit.redo);
self.cut.set_enabled(edit.cut);
self.copy.set_enabled(edit.copy);
self.paste.set_enabled(edit.paste);
self.select_all.set_enabled(edit.select_all);
}
} }
pub(crate) fn next_event() -> Option<NativeMenuEvent> { pub(crate) fn next_event() -> Option<NativeMenuEvent> {
@@ -97,6 +187,10 @@ pub(crate) fn next_event() -> Option<NativeMenuEvent> {
let event = match event.id.0.as_str() { let event = match event.id.0.as_str() {
PREFERENCES => NativeMenuEvent::Preferences, PREFERENCES => NativeMenuEvent::Preferences,
MODEL_MANAGER => NativeMenuEvent::ModelManager, MODEL_MANAGER => NativeMenuEvent::ModelManager,
NEW_CHAT => NativeMenuEvent::NewChat,
EXPORT_CHAT => NativeMenuEvent::ExportChat,
TOGGLE_SIDEBAR => NativeMenuEvent::ToggleSidebar,
HELP => NativeMenuEvent::Help,
UNDO => NativeMenuEvent::Edit(EditCommand::Undo), UNDO => NativeMenuEvent::Edit(EditCommand::Undo),
REDO => NativeMenuEvent::Edit(EditCommand::Redo), REDO => NativeMenuEvent::Edit(EditCommand::Redo),
CUT => NativeMenuEvent::Edit(EditCommand::Cut), CUT => NativeMenuEvent::Edit(EditCommand::Cut),

View File

@@ -374,6 +374,9 @@ fn run_command(
&command.cancel, &command.cancel,
&mut emit, &mut emit,
&mut progress, &mut progress,
|activity| {
let _ = command.events.send(GenerationEvent::Activity(activity));
},
), ),
CheckpointTarget::Transient(directory) | CheckpointTarget::OneShot(directory) => { CheckpointTarget::Transient(directory) | CheckpointTarget::OneShot(directory) => {
generator.generate_transient( generator.generate_transient(