From c96675c4626fc95ee2dc77996a2bff929096d561 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Mon, 27 Jul 2026 15:09:12 +0200 Subject: [PATCH] feat: more native menus with expected reactions --- PLAN.md | 18 +--- docs/USER_GUIDE.md | 78 +++++++++++++++ src/app.rs | 225 +++++++++++++++++++++++++++++++++++++++++- src/app/generation.rs | 27 +++-- src/app/projects.rs | 2 + src/app/view.rs | 46 +++++++-- src/app/view/chat.rs | 55 +++++++---- src/engine.rs | 52 ++++++++-- src/native_edit.rs | 198 +++++++++++++++++++++++++++++++++++-- src/native_menu.rs | 104 ++++++++++++++++++- src/runtime.rs | 3 + 11 files changed, 738 insertions(+), 70 deletions(-) create mode 100644 docs/USER_GUIDE.md diff --git a/PLAN.md b/PLAN.md index 1852569..96fa841 100644 --- a/PLAN.md +++ b/PLAN.md @@ -4,23 +4,7 @@ Only unfinished implementation work belongs here. DwarfStar remains the behavioral oracle for model execution, token processing, context accounting, KV-cache behavior, the HTTP API, and the built-in agent loop. -## 1. Finish native project and chat controls - -- 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 +## 1. Add Dev Brain support - Let the user select one Obsidian vault and give the local agent bounded Markdown memory_search, memory_read, memory_create, and memory_append diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 0000000..14a16e9 --- /dev/null +++ b/docs/USER_GUIDE.md @@ -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. diff --git a/src/app.rs b/src/app.rs index ab771b2..badd52c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -47,11 +47,14 @@ pub(super) const MAX_SIDEBAR_WIDTH: i32 = 520; pub(crate) struct App { main_window: window::Id, pub(super) model_manager_window: Option, + pub(super) help_window: Option, pub(super) pending_model_delete: Option, #[cfg(target_os = "macos")] _native_menu: Option, #[cfg(target_os = "macos")] pub(super) native_edit_commands: crate::native_edit::EditCommandQueue, + #[cfg(target_os = "macos")] + pub(super) native_edit_availability: crate::native_edit::EditAvailabilityState, database: Option, projects: Vec, config: Config, @@ -87,6 +90,7 @@ pub(crate) struct App { pub(super) a2ui_modals: HashSet<(String, String)>, pub(super) a2ui_editors: HashMap<(String, String, String), text_editor::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_images: HashMap, pub(super) a2ui_image_requests: HashSet, @@ -133,6 +137,7 @@ pub(crate) struct App { _endpoint: Option, error: Option, pub(super) activity: Option, + pub(super) context_notice: Option, stop_requested: bool, system_prompt_seen_at: u32, manual_compaction_queued: bool, @@ -167,6 +172,11 @@ pub(crate) enum Message { NativeEdit(crate::native_edit::EditCommand), OpenPreferences, OpenModelManager, + OpenHelp, + HelpOpened(window::Id), + NewChat, + ExportChat, + ExportChatPicked(Option), ModelManagerOpened(window::Id), WindowOpened(window::Id), WindowClosed(window::Id), @@ -238,6 +248,7 @@ pub(crate) enum Message { StopModelDownload, DownloadProgressTick, ComposerChanged(String), + TranscriptAction(usize, text_editor::Action), ToggleReasoning(usize), OpenLink(markdown::Uri), CopyToolText(String), @@ -315,11 +326,14 @@ impl App { Self { main_window, model_manager_window: None, + help_window: None, pending_model_delete: None, #[cfg(target_os = "macos")] _native_menu: None, #[cfg(target_os = "macos")] native_edit_commands: crate::native_edit::command_queue(), + #[cfg(target_os = "macos")] + native_edit_availability: crate::native_edit::availability_state(), database: Some(database), projects, config, @@ -348,6 +362,9 @@ impl App { a2ui_modals: HashSet::new(), a2ui_editors: HashMap::new(), a2ui_markdown: HashMap::new(), + help_markdown: markdown::Content::parse(include_str!( + "../docs/USER_GUIDE.md" + )), a2ui_choice_filters: HashMap::new(), a2ui_images: HashMap::new(), a2ui_image_requests: HashSet::new(), @@ -400,6 +417,7 @@ impl App { } }, activity: None, + context_notice: None, stop_requested: false, system_prompt_seen_at: 0, manual_compaction_queued: false, @@ -435,11 +453,14 @@ impl App { Self { main_window, model_manager_window: None, + help_window: None, pending_model_delete: None, #[cfg(target_os = "macos")] _native_menu: None, #[cfg(target_os = "macos")] native_edit_commands: crate::native_edit::command_queue(), + #[cfg(target_os = "macos")] + native_edit_availability: crate::native_edit::availability_state(), database: None, projects: Vec::new(), config, @@ -468,6 +489,7 @@ impl App { a2ui_modals: HashSet::new(), a2ui_editors: HashMap::new(), a2ui_markdown: HashMap::new(), + help_markdown: markdown::Content::parse(include_str!("../docs/USER_GUIDE.md")), a2ui_choice_filters: HashMap::new(), a2ui_images: HashMap::new(), a2ui_image_requests: HashSet::new(), @@ -514,6 +536,7 @@ impl App { None => error, }), activity: None, + context_notice: None, stop_requested: false, system_prompt_seen_at: 0, manual_compaction_queued: false, @@ -524,13 +547,54 @@ impl App { pub(crate) fn update(&mut self, message: Message) -> Task { match message { - Message::Noop => {} + Message::Noop => { + #[cfg(target_os = "macos")] + self.sync_native_menu(); + } #[cfg(target_os = "macos")] Message::NativeEdit(command) => { crate::native_edit::queue_command(&self.native_edit_commands, command) } Message::OpenPreferences => self.open_preferences(), 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) => { if self.model_manager_window == Some(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() { return focus_composer(); } @@ -559,6 +625,9 @@ impl App { self.model_manager_window = None; self.pending_model_delete = None; } + if self.help_window == Some(id) { + self.help_window = None; + } } Message::DismissPanel => { if self.pending_session_delete.is_some() { @@ -612,6 +681,8 @@ impl App { Message::ToggleSidebar => { self.config.interface.sidebar_collapsed = !self.config.interface.sidebar_collapsed; self.store_config(); + #[cfg(target_os = "macos")] + self.sync_native_menu(); } Message::MetricsTick => self.sample_metrics(), Message::PreferenceModelChanged(model) => { @@ -873,6 +944,13 @@ impl App { } Message::DownloadProgressTick => self.update_download_progress(), 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) => { if self.a2ui_history_index.is_some() { return Task::none(); @@ -1158,6 +1236,7 @@ impl App { self.selected_project = None; self.selected_session = None; self.conversation.clear(); + self.context_notice = None; self.clear_a2ui(); self.composer.clear(); self.queued_inputs.clear(); @@ -1321,6 +1400,7 @@ impl App { match loaded { Ok((messages, a2ui)) => { self.conversation = messages.into_iter().map(ChatMessage::from).collect(); + self.context_notice = None; self.clear_a2ui(); let (history, active, errors) = crate::a2ui::replay_epochs(a2ui.iter().map(|message| { @@ -1403,6 +1483,7 @@ impl App { if self.selected_session == Some(session_id) { self.selected_session = None; self.conversation.clear(); + self.context_notice = None; self.clear_a2ui(); self.composer.clear(); self.queued_inputs.clear(); @@ -1450,6 +1531,10 @@ impl App { Some(crate::native_menu::NativeMenuEvent::ModelManager) => { 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)) => { Message::NativeEdit(command) } @@ -1489,6 +1574,8 @@ impl App { pub(crate) fn title(&self, id: window::Id) -> String { if self.model_manager_window == Some(id) { "Model Manager — DS4Server".to_owned() + } else if self.help_window == Some(id) { + "Help — DS4Server".to_owned() } else { "DS4Server".to_owned() } @@ -1594,6 +1681,50 @@ impl App { self.kv_cache_report = crate::metrics::kv_cache_report(&kv_cache_path(), budget); 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 { + 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")] @@ -1655,6 +1786,8 @@ fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option { 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() { Message::FocusPrevious } else { @@ -1664,6 +1797,59 @@ fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option String { + let stem = title + .chars() + .map(|character| match character { + '/' | ':' | '\0' => '-', + character => character, + }) + .collect::(); + 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
\nReasoning\n\n{reasoning}\n\n
\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 { std::env::var_os("HOME") .map(PathBuf::from) @@ -1991,6 +2177,7 @@ mod tests { reasoning_open: true, content: String::new(), markdown: markdown::Content::new(), + transcript: text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: Vec::new(), a2ui_replies: Vec::new(), @@ -2004,4 +2191,40 @@ mod tests { assert_eq!(message.content, "**final answer**"); 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")); + } } diff --git a/src/app/generation.rs b/src/app/generation.rs index 6fc4a19..39959f1 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -61,6 +61,7 @@ pub(crate) struct ChatMessage { pub(super) reasoning_open: bool, pub(super) content: String, pub(super) markdown: markdown::Content, + pub(super) transcript: text_editor::Content, pub(super) a2ui_lines_processed: usize, pub(super) a2ui_errors: Vec, pub(super) a2ui_replies: Vec, @@ -89,14 +90,17 @@ impl ChatMessage { } pub(super) fn refresh_markdown(&mut self) { + let visible = crate::agent::visible_content(&self.content); + let visible = crate::a2ui::transcript_fallback(visible); + let content = if self.reasoning.is_some() { + visible.trim_start() + } else { + &visible + }; + if self.transcript.text() != content { + self.transcript = text_editor::Content::with_text(content); + } if !self.user && !self.tool { - let visible = crate::agent::visible_content(&self.content); - let visible = crate::a2ui::transcript_fallback(visible); - let content = if self.reasoning.is_some() { - visible.trim_start() - } else { - &visible - }; self.markdown = markdown::Content::parse(content); } } @@ -165,6 +169,7 @@ impl From for ChatMessage { reasoning_open: false, content: message.content, markdown: iced::widget::markdown::Content::new(), + transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: Vec::new(), a2ui_replies: Vec::new(), @@ -341,6 +346,7 @@ impl App { return; } self.a2ui_auto_switch_pending = true; + self.context_notice = None; #[cfg(target_os = "macos")] self.tool_cards.clear(); #[cfg(target_os = "macos")] @@ -633,6 +639,9 @@ impl App { match active.events.try_recv() { Ok(GenerationEvent::Loading) => {} Ok(GenerationEvent::Activity(activity)) => { + if activity.starts_with("Rebuilding context:") { + self.context_notice = Some(activity.to_owned()); + } self.activity = Some(activity.into()); } Ok(GenerationEvent::Compacted(_)) => { @@ -1666,6 +1675,7 @@ mod tests { reasoning_open: false, content: content.to_owned(), markdown: iced::widget::markdown::Content::new(), + transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: 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." .to_owned(), markdown: iced::widget::markdown::Content::new(), + transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: Vec::new(), a2ui_replies: Vec::new(), @@ -1792,6 +1803,7 @@ mod tests { reasoning_open: false, content: format!("message {id}"), markdown: iced::widget::markdown::Content::new(), + transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: Vec::new(), a2ui_replies: Vec::new(), @@ -1829,6 +1841,7 @@ mod tests { reasoning_open: false, content: format!("message {id}"), markdown: iced::widget::markdown::Content::new(), + transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: Vec::new(), a2ui_replies: Vec::new(), diff --git a/src/app/projects.rs b/src/app/projects.rs index f3f236f..db02342 100644 --- a/src/app/projects.rs +++ b/src/app/projects.rs @@ -175,6 +175,7 @@ impl App { self.remember_project(project_id); self.selected_session = None; self.conversation.clear(); + self.context_notice = None; self.clear_a2ui(); self.composer.clear(); self.queued_inputs.clear(); @@ -188,6 +189,7 @@ impl App { pub(super) fn discard_session(&mut self, project_id: i32) { if self.drafts.remove(&project_id).is_some() && self.draft_selected(project_id) { self.conversation.clear(); + self.context_notice = None; self.clear_a2ui(); self.composer.clear(); self.queued_inputs.clear(); diff --git a/src/app/view.rs b/src/app/view.rs index a304eb1..0968a53 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -53,16 +53,46 @@ const TRAFFIC_LIGHT_WIDTH: f32 = 78.0; impl App { 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() + } else if self.help_window == Some(id) { + self.help_view() } else { - let content = self.main_view(); - #[cfg(target_os = "macos")] - return crate::native_edit::native_edit(content, self.native_edit_commands.clone()) - .into(); - #[cfg(not(target_os = "macos"))] - content - } + self.main_view() + }; + #[cfg(target_os = "macos")] + return crate::native_edit::native_edit( + content, + self.native_edit_commands.clone(), + self.native_edit_availability.clone(), + ) + .into(); + #[cfg(not(target_os = "macos"))] + 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 diff --git a/src/app/view/chat.rs b/src/app/view/chat.rs index dd533ec..35bf952 100644 --- a/src/app/view/chat.rs +++ b/src/app/view/chat.rs @@ -55,7 +55,6 @@ impl App { .spacing(8), ); } else { - let markdown_style = markdown::Style::from_palette(app_theme().palette()); for (index, message) in self.conversation.iter().enumerate() { if message.compaction { messages = messages.push( @@ -124,22 +123,17 @@ impl App { } } 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( - markdown::view( - message.markdown.items(), - markdown::Settings::with_text_size(14, markdown_style), - ) - .map(Message::OpenLink), - ); - } + body = body.push( + text_editor(&message.transcript) + .id(iced::widget::Id::from(format!( + "transcript-{}-{index}", + message.id + ))) + .on_action(move |action| Message::TranscriptAction(index, action)) + .padding(0) + .size(14) + .style(selectable_text_style), + ); } else if active && message.reasoning.is_none() { body = body.push( text(self.activity.as_deref().unwrap_or("Loading model…")).size(14), @@ -292,6 +286,20 @@ impl App { .spacing(6) .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) .id(chat_scroll_id()) .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> { column![ row![ diff --git a/src/engine.rs b/src/engine.rs index a64d22d..40babf4 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -467,6 +467,19 @@ pub(crate) struct GenerationOutput { 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")] pub(crate) struct CompactionOutput { pub(crate) summary: String, @@ -543,6 +556,7 @@ impl Generator { self.executor.model().summary() } + #[allow(clippy::too_many_arguments)] pub(crate) fn generate( &mut self, checkpoint: &Path, @@ -551,14 +565,19 @@ impl Generator { cancelled: &AtomicBool, mut emit: impl FnMut(bool, String), mut progress: impl FnMut(u32, u32, Option), + mut phase: impl FnMut(&'static str), ) -> Result { let history = messages .split_last() .map_or(messages, |(_, history)| history); - self.select_checkpoint( + let checkpoint_present = checkpoint.is_file(); + let selected = self.select_checkpoint( checkpoint, 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); self.publish_execution_stats(); let (mut output, prompt_complete) = result?; @@ -632,7 +651,7 @@ impl Generator { let mut previous_checkpoint = self.checkpoint.clone(); if self.executor.checkpoint_tag() != history_tag { 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)?; self.last_store_tokens = entry.tokens; previous_checkpoint = Some(entry.checkpoint); @@ -861,19 +880,25 @@ impl Generator { &mut self, checkpoint: &Path, expected_tag: [u8; 32], - ) -> Result { + ) -> Result { let resident_hit = self.activate_resident(checkpoint.to_owned())?; if resident_hit && self.executor.checkpoint_tag() == expected_tag { self.checkpoint = Some(checkpoint.to_owned()); self.metrics.kv_lookup(KvLookup::MemoryHit); - return Ok(true); + return Ok(CheckpointSelection { + found: true, + incompatible: false, + }); } if self.checkpoint.as_deref() == Some(checkpoint) { if !checkpoint.is_file() { self.executor.reset()?; self.checkpoint = None; self.metrics.kv_lookup(KvLookup::Miss); - return Ok(false); + return Ok(CheckpointSelection { + found: false, + incompatible: false, + }); } let found = self.executor.checkpoint_tag() == expected_tag; self.metrics.kv_lookup(if found { @@ -881,7 +906,10 @@ impl Generator { } else { KvLookup::Miss }); - return Ok(found); + return Ok(CheckpointSelection { + found, + incompatible: false, + }); } self.executor.reset()?; self.metrics.kv_read_started(); @@ -892,6 +920,7 @@ impl Generator { self.metrics .kv_read_finished(started.elapsed(), loaded.is_err()); let found = matches!(loaded, Ok(true)) && self.executor.checkpoint_tag() == expected_tag; + let incompatible = loaded.is_err(); let lookup = match loaded { Ok(true) if found => KvLookup::DiskHit, Ok(true) | Ok(false) => KvLookup::Miss, @@ -903,7 +932,10 @@ impl Generator { }; self.metrics.kv_lookup(lookup); self.checkpoint = Some(checkpoint.to_owned()); - Ok(found) + Ok(CheckpointSelection { + found, + incompatible, + }) } fn activate_resident(&mut self, key: PathBuf) -> Result { @@ -1616,6 +1648,12 @@ mod sampling_tests { 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] #[ignore = "requires the 80 GiB Flash checkpoint and Apple Metal"] fn metal_executes_real_flash_token() { diff --git a/src/native_edit.rs b/src/native_edit.rs index 71f7904..5fb9210 100644 --- a/src/native_edit.rs +++ b/src/native_edit.rs @@ -1,4 +1,4 @@ -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex}; use iced::advanced::layout; @@ -22,10 +22,26 @@ pub(crate) enum EditCommand { pub(crate) type EditCommandQueue = Arc>>; +#[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>; + pub(crate) fn command_queue() -> EditCommandQueue { 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) { queue .lock() @@ -44,22 +60,131 @@ fn pop_command(queue: &EditCommandQueue) -> Option { pub(crate) struct NativeEdit<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> { content: Element<'a, Message, Theme, Renderer>, commands: EditCommandQueue, + availability: EditAvailabilityState, } impl<'a, Message, Theme, Renderer> NativeEdit<'a, Message, Theme, Renderer> { fn new( content: impl Into>, commands: EditCommandQueue, + availability: EditAvailabilityState, ) -> Self { Self { content: content.into(), commands, + availability, } } } +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +enum ControlId { + Named(iced::widget::Id), + Position(usize), +} + +#[derive(Debug)] +struct History { + values: Vec, + current: usize, +} + #[derive(Default)] -struct State; +struct State { + histories: HashMap, + active: Option, + window_focused: bool, +} + +#[derive(Default)] +struct FocusedControl { + current: usize, + pending_text: Option, + focused: Option<(ControlId, Option)>, +} + +impl Operation for FocusedControl { + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + 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)>) { + 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 { + 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)>) -> 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 { let Event::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) = event else { @@ -117,7 +242,7 @@ where } fn state(&self) -> tree::State { - tree::State::new(State) + tree::State::new(State::default()) } fn children(&self) -> Vec { @@ -166,11 +291,44 @@ where shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) { - if matches!( - event, - Event::Window(iced::window::Event::RedrawRequested(_)) - ) { + let state = tree.state.downcast_mut::(); + 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::Window(iced::window::Event::RedrawRequested(_)) + ) + { 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) { self.content.as_widget_mut().update( &mut tree.children[0], @@ -209,6 +367,19 @@ where shell, 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( @@ -282,8 +453,9 @@ where pub(crate) fn native_edit<'a, Message, Theme, Renderer>( content: impl Into>, commands: EditCommandQueue, + availability: EditAvailabilityState, ) -> NativeEdit<'a, Message, Theme, Renderer> { - NativeEdit::new(content, commands) + NativeEdit::new(content, commands, availability) } #[cfg(test)] @@ -310,4 +482,14 @@ mod tests { 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")); + } } diff --git a/src/native_menu.rs b/src/native_menu.rs index e123f71..8e6289c 100644 --- a/src/native_menu.rs +++ b/src/native_menu.rs @@ -1,10 +1,14 @@ 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; const PREFERENCES: &str = "preferences"; 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 REDO: &str = "redo"; const CUT: &str = "cut"; @@ -14,20 +18,36 @@ const SELECT_ALL: &str = "select-all"; pub(crate) struct NativeMenu { _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)] pub(crate) enum NativeMenuEvent { Preferences, ModelManager, + NewChat, + ExportChat, + ToggleSidebar, + Help, Edit(EditCommand), } pub(crate) fn install() -> Result { let menu = Menu::new(); let application = Submenu::new("DS4Server", true); + let file = Submenu::new("File", true); let edit = Submenu::new("Edit", true); + let view = Submenu::new("View", true); let window = Submenu::new("Window", true); + let help = Submenu::new("Help", true); let preferences = MenuItem::with_id( PREFERENCES, "Preferences…", @@ -43,6 +63,37 @@ pub(crate) fn install() -> Result { 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 redo = edit_item(REDO, "Redo", Code::KeyZ, Some(Modifiers::SHIFT)); let cut = edit_item(CUT, "Cut", Code::KeyX, None); @@ -65,6 +116,13 @@ pub(crate) fn install() -> Result { &PredefinedMenuItem::quit(None), ]) .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(&[ &undo, &redo, @@ -75,21 +133,53 @@ pub(crate) fn install() -> Result { &select_all, ]) .map_err(|error| error.to_string())?; + view.append_items(&[&sidebar, &PredefinedMenuItem::separator(), &model_manager]) + .map_err(|error| error.to_string())?; window .append_items(&[ - &model_manager, - &PredefinedMenuItem::separator(), &PredefinedMenuItem::minimize(None), &PredefinedMenuItem::maximize(None), &PredefinedMenuItem::close_window(None), &PredefinedMenuItem::bring_all_to_front(None), ]) .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())?; menu.init_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 { @@ -97,6 +187,10 @@ pub(crate) fn next_event() -> Option { let event = match event.id.0.as_str() { PREFERENCES => NativeMenuEvent::Preferences, MODEL_MANAGER => NativeMenuEvent::ModelManager, + NEW_CHAT => NativeMenuEvent::NewChat, + EXPORT_CHAT => NativeMenuEvent::ExportChat, + TOGGLE_SIDEBAR => NativeMenuEvent::ToggleSidebar, + HELP => NativeMenuEvent::Help, UNDO => NativeMenuEvent::Edit(EditCommand::Undo), REDO => NativeMenuEvent::Edit(EditCommand::Redo), CUT => NativeMenuEvent::Edit(EditCommand::Cut), diff --git a/src/runtime.rs b/src/runtime.rs index a402e8e..3cb0345 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -374,6 +374,9 @@ fn run_command( &command.cancel, &mut emit, &mut progress, + |activity| { + let _ = command.events.send(GenerationEvent::Activity(activity)); + }, ), CheckpointTarget::Transient(directory) | CheckpointTarget::OneShot(directory) => { generator.generate_transient(