feat: more native menus with expected reactions
This commit is contained in:
18
PLAN.md
18
PLAN.md
@@ -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
78
docs/USER_GUIDE.md
Normal 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.
|
||||||
225
src/app.rs
225
src/app.rs
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +90,17 @@ impl ChatMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn refresh_markdown(&mut self) {
|
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 {
|
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);
|
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(),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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")]
|
};
|
||||||
return crate::native_edit::native_edit(content, self.native_edit_commands.clone())
|
#[cfg(target_os = "macos")]
|
||||||
.into();
|
return crate::native_edit::native_edit(
|
||||||
#[cfg(not(target_os = "macos"))]
|
content,
|
||||||
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
|
/// Whether a dialog covers the window. Focus moves through the whole widget
|
||||||
|
|||||||
@@ -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() {
|
body = body.push(
|
||||||
let content = if message.reasoning.is_some() {
|
text_editor(&message.transcript)
|
||||||
crate::agent::visible_content(&message.content).trim_start()
|
.id(iced::widget::Id::from(format!(
|
||||||
} else {
|
"transcript-{}-{index}",
|
||||||
crate::agent::visible_content(&message.content)
|
message.id
|
||||||
};
|
)))
|
||||||
body = body.push(text(content).size(14));
|
.on_action(move |action| Message::TranscriptAction(index, action))
|
||||||
} else {
|
.padding(0)
|
||||||
body = body.push(
|
.size(14)
|
||||||
markdown::view(
|
.style(selectable_text_style),
|
||||||
message.markdown.items(),
|
);
|
||||||
markdown::Settings::with_text_size(14, markdown_style),
|
|
||||||
)
|
|
||||||
.map(Message::OpenLink),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} 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![
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>();
|
||||||
event,
|
match event {
|
||||||
Event::Window(iced::window::Event::RedrawRequested(_))
|
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) {
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user