mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
* claim: #10764 * Assistant: make show/hide shortcut configurable (#10764)
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"ar": {
|
||||
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه"
|
||||
},
|
||||
"de": {
|
||||
"cmd.ui.assistant": "Assistenten ein-/ausblenden"
|
||||
},
|
||||
"en": {
|
||||
"cmd.ui.assistant": "Toggle Assistant"
|
||||
},
|
||||
"he": {
|
||||
"cmd.ui.assistant": "הצגה או הסתרה של העוזר"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.ui.assistant": "アシスタントの表示切り替え"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.ui.assistant": "도우미 표시 전환"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.ui.assistant": "Bật/tắt trợ lý"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.ui.assistant": "切换助手显示"
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,14 @@ use crate::params::{bad, bool_, name_or_id_values, num, str_};
|
||||
use crate::{EngineError, Result, Sel, Session, Snapshot};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("ui.assistant", "Toggle Assistant", crate::checked!("ui.assistant", assistant))
|
||||
.icon("assistant")
|
||||
.key("Ctrl+Shift+A")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&ASSISTANT_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::edit::AssistantResult>)
|
||||
.params("Show or hide Assistant in the app. Headless calls are refused without changing the design"),
|
||||
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo().doc_only(),
|
||||
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
|
||||
CommandSpec::new("edit.repeat", "Repeat Last Command", crate::checked!("edit.repeat", repeat))
|
||||
@@ -119,6 +127,12 @@ fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"redone": label}))
|
||||
}
|
||||
|
||||
const ASSISTANT_CONTRACT: crate::schema::TypedContract<crate::schema::edit::AssistantParams> = crate::schema::TypedContract::new();
|
||||
|
||||
fn assistant(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Err(EngineError::Other("ui.assistant shows or hides Assistant in the app; no assistant panel exists in a headless session".into()))
|
||||
}
|
||||
|
||||
const REPEAT_CONTRACT: crate::schema::TypedContract<crate::schema::edit::RepeatParams> = crate::schema::TypedContract::new();
|
||||
|
||||
/// Repeat Last Command (#7077) starts a tool, which only the app has: it starts the last one in
|
||||
|
||||
@@ -1650,3 +1650,14 @@ fn edits_after_a_canvas_share_its_image_with_every_undo_state() {
|
||||
assert_eq!(s.doc.canvases[0].data.as_ptr(), image, "undo brings back the same image");
|
||||
assert_eq!(s.doc.canvases[0].bytes().as_deref(), Some(png.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_toggle_is_refused_headlessly_without_changing_the_document() {
|
||||
let mut s = Session::default();
|
||||
let before = (s.doc.to_json(), s.undo.len());
|
||||
let error = s.execute("ui.assistant", &json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("headless session"), "{error}");
|
||||
assert_eq!((s.doc.to_json(), s.undo.len()), before);
|
||||
let spec = crate::find_command("ui.assistant").unwrap();
|
||||
assert_eq!((spec.shortcut, spec.tab, spec.undoable), (Some("Ctrl+Shift+A"), "", false));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Assistant visibility is UI-owned and takes no parameters.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AssistantParams {}
|
||||
|
||||
/// Headless sessions refuse Assistant display requests and produce no result.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AssistantResult {}
|
||||
|
||||
/// `edit.repeat` takes no parameters: it starts the last tool with its defaults.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
@@ -39,12 +48,13 @@ pub struct TimelinePlayResult {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RepeatParams;
|
||||
use super::{AssistantParams, RepeatParams};
|
||||
use crate::schema::input_schema_for;
|
||||
use crate::schema::tests::assert_keys;
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<RepeatParams>(), &[]);
|
||||
assert_keys(input_schema_for::<AssistantParams>(), &[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ use crate::{ControlRequest, SolveApp};
|
||||
use backend::{ChatBackend, Event, PermissionOption, PromptPart, ToolStatus};
|
||||
|
||||
pub(crate) use panel::{left_inset, tool_space};
|
||||
pub use panel::{show, tip as panel_tip, toggle};
|
||||
pub use panel::{show, tip as panel_tip, toggle, toggle_shortcut};
|
||||
|
||||
/// Most entries kept in the conversation; older ones are dropped.
|
||||
const MAX_ITEMS: usize = 2000;
|
||||
@@ -907,7 +907,7 @@ pub(crate) fn assistant_mark(app: &SolveApp) -> AssistantMark {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn assistant_tip(mark: AssistantMark) -> String {
|
||||
pub(crate) fn assistant_tip(app: &SolveApp, mark: AssistantMark) -> String {
|
||||
let state = match mark {
|
||||
AssistantMark::Quiet => tr("ui.agent_chat_state_off").to_string(),
|
||||
AssistantMark::Ready => tr("ui.agent_chat_state_ready").to_string(),
|
||||
@@ -916,7 +916,11 @@ pub(crate) fn assistant_tip(mark: AssistantMark) -> String {
|
||||
AssistantMark::Stopped => tr("ui.agent_chat_state_exited").to_string(),
|
||||
AssistantMark::Waiting(count) => trf("ui.assistant_waiting", &[("count", &count.to_string())]),
|
||||
};
|
||||
format!("{} (Ctrl+Shift+A) — {state}", tr("ui.assistant"))
|
||||
let name = match crate::keymap::display_for(app, "ui.assistant") {
|
||||
Some(key) => format!("{} ({key})", tr("ui.assistant")),
|
||||
None => tr("ui.assistant").to_string(),
|
||||
};
|
||||
format!("{name} — {state}")
|
||||
}
|
||||
|
||||
/// Whether a permission question is about one of SolidCraft's own tools (which SolidCraft's own
|
||||
|
||||
@@ -14,8 +14,34 @@ pub const MIN_WIDTH: f32 = 280.0;
|
||||
pub const MAX_WIDTH: f32 = 760.0;
|
||||
/// The folded panel's width.
|
||||
const STRIP: f32 = 30.0;
|
||||
/// The keyboard shortcut that shows and hides the panel (also from its own text field).
|
||||
pub const SHORTCUT: egui::KeyboardShortcut = egui::KeyboardShortcut::new(egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT), egui::Key::A);
|
||||
/// Consume the configured toggle before CAD shortcuts, including in the chat's own window.
|
||||
/// Bare character keys remain text while a field owns the keyboard; chords still hide chat.
|
||||
pub fn toggle_shortcut(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !shown(app) || crate::keymap::capturing(app) {
|
||||
return;
|
||||
}
|
||||
let keys = crate::keymap::keys(app, "ui.assistant");
|
||||
let typing = ctx.egui_wants_keyboard_input();
|
||||
let pressed = ctx.input_mut(|input| {
|
||||
let mut pressed = false;
|
||||
input.events.retain(|event| {
|
||||
if let egui::Event::Key { key, modifiers, pressed: true, repeat, .. } = event {
|
||||
if typing && !modifiers.command && !modifiers.ctrl && !modifiers.alt {
|
||||
return true;
|
||||
}
|
||||
if keys.contains(&crate::keymap::key_name(*key, *modifiers)) {
|
||||
pressed |= !repeat;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
pressed
|
||||
});
|
||||
if pressed {
|
||||
toggle(app);
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_id(left: bool) -> Id {
|
||||
Id::new(if left { "sc_agent_chat_left" } else { "sc_agent_chat_right" })
|
||||
@@ -50,9 +76,11 @@ pub fn toggle(app: &mut SolveApp) {
|
||||
}
|
||||
|
||||
/// The tooltip of the chat's buttons: its name and shortcut.
|
||||
pub fn tip() -> String {
|
||||
let key = if cfg!(target_os = "macos") { "Cmd+Shift+A" } else { "Ctrl+Shift+A" };
|
||||
format!("{} ({key})", tr("ui.agent_chat"))
|
||||
pub fn tip(app: &SolveApp) -> String {
|
||||
match crate::keymap::display_for(app, "ui.assistant") {
|
||||
Some(key) => format!("{} ({key})", tr("ui.agent_chat")),
|
||||
None => tr("ui.agent_chat").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel, when the chat is turned on and open. Call before the central viewport is laid out.
|
||||
@@ -69,9 +97,6 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if !shown(app) {
|
||||
return;
|
||||
}
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
|
||||
toggle(app);
|
||||
}
|
||||
// A window that isn't drawn this frame closes; the next one opens at the saved place.
|
||||
if !(app.agent_chat.open && app.preferences.agent_chat_popped) {
|
||||
app.agent_chat.window_placed = false;
|
||||
@@ -531,6 +556,11 @@ fn window(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
// The side panel isn't drawn: its place no longer counts as "in the chat".
|
||||
app.agent_chat.panel_rect = None;
|
||||
ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("sc_agent_chat_window"), builder, |ui, class| {
|
||||
// Root/embedded inputs are handled by SolveApp::logic. A native chat window owns
|
||||
// its input and must see the toggle before its text editor can consume the chord.
|
||||
if class != egui::ViewportClass::EmbeddedWindow {
|
||||
toggle_shortcut(app, ui.ctx());
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let frame = Container::new(ui.ctx(), Surface::Panel).edge_frame(t.dialog_body);
|
||||
let r = Container::new(ui.ctx(), Surface::Viewport).central().frame(frame).show(ui, |ui| ui_kit::scope(ui, |ui| body(app, ui, false)));
|
||||
@@ -555,9 +585,6 @@ fn window(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if ctx.input(|i| !i.raw.hovered_files.is_empty()) {
|
||||
drop_hint(&ctx, r.response.rect);
|
||||
}
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
|
||||
toggle(app);
|
||||
}
|
||||
// The whole window is the chat, so New chat works anywhere in it.
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) {
|
||||
request_new_chat(app);
|
||||
@@ -667,7 +694,7 @@ fn strip(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if app.preferences.agent_chat_side == "left" { [r.right_top(), r.right_bottom()] } else { [r.left_top(), r.left_bottom()] },
|
||||
t.primary_border(t.border),
|
||||
);
|
||||
let tip = format!("{} · {}", tip(), state.label());
|
||||
let tip = format!("{} · {}", tip(app), state.label());
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
app.preferences.agent_chat_minimized = false;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ pub const RESERVED: &[&str] = &[
|
||||
"Ctrl+O",
|
||||
"Ctrl+W",
|
||||
"Ctrl+Comma",
|
||||
"Ctrl+Shift+A",
|
||||
"Ctrl+C",
|
||||
"Ctrl+X",
|
||||
"Ctrl+V",
|
||||
@@ -872,6 +871,92 @@ pub fn capturing(app: &SolveApp) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn assistant_shortcut_can_be_rebound_cleared_saved_and_reset() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.frames(2);
|
||||
assert_eq!(effective(&h.app, "ui.assistant").as_deref(), Some("Ctrl+Shift+A"));
|
||||
bind(&mut h.app, "ui.assistant", "Ctrl+Shift+J", false).unwrap();
|
||||
h.step(&json!({"key":"A", "cmd":true, "shift":true})).unwrap();
|
||||
assert!(!h.app.agent_chat.open, "the old key no longer opens Assistant");
|
||||
let held_key = |pressed, repeat| egui::Event::Key {
|
||||
key: egui::Key::J,
|
||||
physical_key: None,
|
||||
pressed,
|
||||
repeat,
|
||||
modifiers: egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT),
|
||||
};
|
||||
h.frame_with(vec![held_key(true, false)]);
|
||||
assert!(h.app.agent_chat.open, "the custom key opens Assistant");
|
||||
h.frame_with(vec![held_key(true, true)]);
|
||||
assert!(h.app.agent_chat.open, "holding the key does not repeatedly toggle");
|
||||
h.frame_with(vec![held_key(false, false)]);
|
||||
h.step(&json!({"key":"J", "cmd":true, "shift":true})).unwrap();
|
||||
assert!(!h.app.agent_chat.open, "the custom key also hides Assistant");
|
||||
let saved = h.app.keymap.prefs();
|
||||
let mut restored = app();
|
||||
restored.keymap.load(&saved);
|
||||
assert_eq!(keys(&restored, "ui.assistant"), ["Ctrl+Shift+J"]);
|
||||
assert!(bind(&mut h.app, "solid.box", "Ctrl+Shift+J", false).is_err(), "conflicts are reported");
|
||||
bind(&mut h.app, "ui.assistant", "", false).unwrap();
|
||||
h.step(&json!({"key":"J", "cmd":true, "shift":true})).unwrap();
|
||||
assert!(!h.app.agent_chat.open, "clearing disables the shortcut");
|
||||
reset(&mut h.app, Some("ui.assistant"));
|
||||
h.step(&json!({"key":"A", "cmd":true, "shift":true})).unwrap();
|
||||
assert!(h.app.agent_chat.open, "reset restores the original shortcut");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_released_unbound_assistant_chord_does_not_start_a_plain_letter_command() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
bind(&mut h.app, "ui.assistant", "Ctrl+Shift+J", false).unwrap();
|
||||
h.frames(2);
|
||||
let modifiers = egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT);
|
||||
h.frame_with(vec![
|
||||
egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
egui::Event::ModifiersChanged(egui::Modifiers::NONE),
|
||||
]);
|
||||
assert!(!h.app.agent_chat.open, "the old chord is unbound");
|
||||
assert!(h.app.dialog.is_none() && h.app.tool.is_none(), "Ctrl+Shift+A must not become plain A (Appearance) after release");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_shortcut_respects_text_focus_ai_switch_and_capture() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
bind(&mut h.app, "ui.assistant", "Ctrl+Shift+J", false).unwrap();
|
||||
h.app.preferences.ai_features = false;
|
||||
h.step(&json!({"key":"J", "cmd":true, "shift":true})).unwrap();
|
||||
assert!(!h.app.agent_chat.open);
|
||||
h.app.preferences.ai_features = true;
|
||||
h.step(&json!({"key":"J", "cmd":true, "shift":true, "alt":true})).unwrap();
|
||||
assert!(!h.app.agent_chat.open, "extra modifiers do not match");
|
||||
h.step(&json!({"key":"J", "cmd":true, "shift":true})).unwrap();
|
||||
assert!(h.app.agent_chat.open);
|
||||
h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("sc_agent_chat_input")));
|
||||
h.frames(2);
|
||||
h.step(&json!({"key":"J", "cmd":true, "shift":true})).unwrap();
|
||||
assert!(!h.app.agent_chat.open, "a chord can hide Assistant from its input field");
|
||||
bind(&mut h.app, "ui.assistant", "J", true).unwrap();
|
||||
h.call("ui.palette", json!({}));
|
||||
h.frames(3);
|
||||
h.step(&json!({"key":"J"})).unwrap();
|
||||
assert!(!h.app.agent_chat.open, "a bare letter in a text field remains text");
|
||||
h.call("ui.palette", json!({"open":false}));
|
||||
h.frames(2);
|
||||
crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_SHORTCUTS);
|
||||
h.app.keymap.capturing = Some("solid.box".into());
|
||||
h.step(&json!({"key":"J"})).unwrap();
|
||||
assert!(!h.app.agent_chat.open, "recording a shortcut must not activate it");
|
||||
assert!(crate::agent_chat::assistant_tip(&h.app, crate::agent_chat::AssistantMark::Ready).contains("(J)"));
|
||||
bind(&mut h.app, "ui.assistant", "", false).unwrap();
|
||||
assert!(!crate::agent_chat::assistant_tip(&h.app, crate::agent_chat::AssistantMark::Ready).contains("(J)"));
|
||||
}
|
||||
|
||||
fn app() -> SolveApp {
|
||||
SolveApp::new(solidcraft_engine::Session::default(), Default::default())
|
||||
}
|
||||
|
||||
@@ -1554,6 +1554,12 @@ impl SolveApp {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if id == "ui.assistant" {
|
||||
if agent_chat::shown(self) {
|
||||
agent_chat::toggle(self);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if id == "ui.palette" {
|
||||
shortcut_box::open(self, None);
|
||||
return;
|
||||
@@ -2822,6 +2828,7 @@ impl SolveApp {
|
||||
if !keymap::capturing(self) {
|
||||
expression_complete::begin_frame(ctx);
|
||||
}
|
||||
agent_chat::toggle_shortcut(self, ctx);
|
||||
// A digit for a construction line's Length box is not a shortcut (#10258).
|
||||
sketch_construction::claim_typed_length(self, ctx);
|
||||
if !self.embedded {
|
||||
|
||||
@@ -2148,7 +2148,7 @@ fn assistant_button(app: &mut SolveApp, ui: &mut egui::Ui, at: Rect) {
|
||||
let resp = ui.interact(at, ui.id().with("assistant_button"), Sense::click());
|
||||
crate::scenario::publish_target("titlebar:assistant", at);
|
||||
let mark = crate::agent_chat::assistant_mark(app);
|
||||
let tip = crate::agent_chat::assistant_tip(mark);
|
||||
let tip = crate::agent_chat::assistant_tip(app, mark);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &tip));
|
||||
let state = match mark {
|
||||
AssistantMark::Quiet => crate::ui_kit::AssistantEntryState::Quiet,
|
||||
@@ -3215,13 +3215,15 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (keys, mods) = ctx.input(|i| {
|
||||
let keys: Vec<(egui::Key, bool)> = i
|
||||
.events
|
||||
// A quick chord can press and release all modifiers in one frame. Dispatch each key
|
||||
// with its own modifiers, not the final frame state (an unbound Ctrl+Shift+A is not A).
|
||||
let keys: Vec<_> = ctx.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| if let egui::Event::Key { key, pressed: true, repeat, .. } = e { Some((*key, *repeat)) } else { None })
|
||||
.collect();
|
||||
(keys, i.modifiers)
|
||||
.filter_map(
|
||||
|e| if let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = e { Some((*key, *repeat, *modifiers)) } else { None },
|
||||
)
|
||||
.collect()
|
||||
});
|
||||
// A theme pasted on the Interface page is a theme, not copied components (#8226).
|
||||
crate::prefs::take_theme_paste(app, ctx);
|
||||
@@ -3232,7 +3234,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
} else {
|
||||
crate::edit_clipboard::flush(app, ctx);
|
||||
}
|
||||
for (k, repeat) in keys {
|
||||
for (k, repeat, mods) in keys {
|
||||
use egui::Key;
|
||||
// Only Esc pressed twice in a row finishes a sketch: any other key in between resets it.
|
||||
if k != Key::Escape {
|
||||
|
||||
@@ -501,3 +501,5 @@ the agent and puts your permission setting back.
|
||||
- `--markdown` writes the issue's comment template filled in; add the quantization, download
|
||||
size, and runtime before you post it. `--json` keeps each task's failed rules, conversation, and
|
||||
design before and after. Both files are rewritten after every task.
|
||||
|
||||
The show/hide shortcut defaults to Ctrl+Shift+A (Cmd+Shift+A on macOS). Change, clear or reset **Toggle Assistant** (`ui.assistant`) in Preferences → Shortcuts. The binding is saved with the other keyboard shortcuts; the assistant button tooltip follows it. Disabling AI features also disables the toggle.
|
||||
|
||||
Reference in New Issue
Block a user