feat: more native menus with expected reactions
This commit is contained in:
225
src/app.rs
225
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<window::Id>,
|
||||
pub(super) help_window: Option<window::Id>,
|
||||
pub(super) pending_model_delete: Option<ManagedArtifactId>,
|
||||
#[cfg(target_os = "macos")]
|
||||
_native_menu: Option<crate::native_menu::NativeMenu>,
|
||||
#[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<Database>,
|
||||
projects: Vec<ProjectWithSessions>,
|
||||
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<String, iced::widget::image::Handle>,
|
||||
pub(super) a2ui_image_requests: HashSet<String>,
|
||||
@@ -133,6 +137,7 @@ pub(crate) struct App {
|
||||
_endpoint: Option<crate::server::ServerHandle>,
|
||||
error: Option<String>,
|
||||
pub(super) activity: Option<String>,
|
||||
pub(super) context_notice: Option<String>,
|
||||
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<PathBuf>),
|
||||
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<Message> {
|
||||
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<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")]
|
||||
@@ -1655,6 +1786,8 @@ fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Messag
|
||||
keyboard::Key::Character("m") if modifiers.command() && modifiers.shift() => {
|
||||
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<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 {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user