From 83b30879afcafef4efeb7b6be996f87b7292f044 Mon Sep 17 00:00:00 2001 From: chux Date: Sun, 4 Oct 2026 23:06:48 -0400 Subject: [PATCH 1/4] Linux: pin the layer-shell island to its display A layer surface ignores set_position, so the island landed on whichever output had focus when it was mapped. Pin it with gtk_layer_set_monitor to the display target_monitor picked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BNUKEFycKsFCP5fKkhY1Uf --- windows/src-tauri/src/island.rs | 9 ++++++++ windows/src-tauri/src/platform/linux.rs | 25 +++++++++++++++++++++++ windows/src-tauri/src/platform/windows.rs | 3 +++ 3 files changed, 37 insertions(+) diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 92c9cd4cbf..ad184e7630 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -114,6 +114,13 @@ fn monitor_contains(m: &Monitor, x: f64, y: f64) -> bool { && y < (p.y + s.height as i32) as f64 } +/// A display's logical origin: GDK monitor geometry is in logical pixels. +fn logical_origin(m: &Monitor) -> (i32, i32) { + let scale = m.scale_factor(); + let p = m.position(); + ((p.x as f64 / scale).round() as i32, (p.y as f64 / scale).round() as i32) +} + /// The display the island lives on: the primary one, or the one under the cursor. fn target_monitor(app: &AppHandle, pref: &str) -> Option { let monitors = app.available_monitors().ok()?; @@ -172,6 +179,8 @@ pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { let _ = win.set_resizable(true); let _ = win.set_size(PhysicalSize::new(pw, ph)); let _ = win.set_position(PhysicalPosition::new(x, y)); + let (lx, ly) = logical_origin(&m); + platform::pin_to_monitor(&win, lx, ly); // Moving across displays can rescale the window: re-assert the physical size. let _ = win.set_size(PhysicalSize::new(pw, ph)); let _ = win.set_always_on_top(true); diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e748a336a4..eeab973601 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -181,6 +181,7 @@ mod layer { pub fn gtk_layer_set_anchor(window: *mut GtkWindow, edge: c_int, anchor: c_int); pub fn gtk_layer_set_exclusive_zone(window: *mut GtkWindow, zone: c_int); pub fn gtk_layer_set_keyboard_mode(window: *mut GtkWindow, mode: c_int); + pub fn gtk_layer_set_monitor(window: *mut GtkWindow, monitor: *mut gtk::gdk::ffi::GdkMonitor); } } @@ -263,6 +264,30 @@ pub fn make_non_activating(win: &WebviewWindow) { crate::log::line("island is a layer-shell overlay"); } +/// Puts the layer surface on the display whose logical origin is (`x`, `y`). +/// +/// A layer surface ignores `set_position`: without an explicit output the +/// compositor maps it on whichever display has focus at that moment, so on a +/// multi-monitor Hyprland or Sway desktop the island wandered from one screen +/// to the other every time it was remapped. A no-op for a regular window, which +/// `set_position` already places. +pub fn pin_to_monitor(win: &WebviewWindow, x: i32, y: i32) { + if !LAYER_SURFACE.load(Ordering::Relaxed) { + return; + } + let Ok(gw) = win.gtk_window() else { return }; + let display = gw.display(); + let monitor = (0..display.n_monitors()) + .filter_map(|i| display.monitor(i)) + .find(|m| { + let g = m.geometry(); + g.x() == x && g.y() == y + }); + let Some(monitor) = monitor else { return }; + let mon_ptr: *mut gtk::gdk::ffi::GdkMonitor = monitor.to_glib_none().0; + unsafe { layer::gtk_layer_set_monitor(gtk_window_ptr(&gw), mon_ptr) }; +} + /// Temporarily allow keyboard focus so a text field inside the island can be /// typed in. pub fn set_activating(win: &WebviewWindow, activating: bool) { diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 6e1200a8aa..fe4bcae212 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -233,3 +233,6 @@ pub fn set_activating(win: &WebviewWindow, activating: bool) { /// Click-through here is the poll's WS_EX_TRANSPARENT toggle, not a region. pub fn set_input_region(_win: &WebviewWindow, _rect: Option<(f64, f64, f64, f64)>) {} + +/// `set_position` already places a Win32 window on the right display. +pub fn pin_to_monitor(_win: &WebviewWindow, _x: i32, _y: i32) {} From 4207be9e82f8f9404aa05b2f58dcc04a81592cbc Mon Sep 17 00:00:00 2001 From: chux Date: Sun, 4 Oct 2026 23:07:03 -0400 Subject: [PATCH 2/4] Settings: choose which display the island lives on "Main display" means little on Wayland: GDK has no primary monitor, so it is whichever output GDK lists first. List every display in "Island lives on" and store the choice as at:, (logical origin). Names cannot be the key: two monitors of the same model report the same one. A display that is gone falls back to the primary one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BNUKEFycKsFCP5fKkhY1Uf --- windows/src-tauri/src/island.rs | 39 +++++++++++++++++++++++++++++++-- windows/src-tauri/src/lib.rs | 7 ++++++ windows/src/core/bridge.ts | 3 +++ windows/src/core/state.ts | 3 ++- windows/src/settings/main.ts | 8 ++++++- 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index ad184e7630..0ce036da5f 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -114,16 +114,51 @@ fn monitor_contains(m: &Monitor, x: f64, y: f64) -> bool { && y < (p.y + s.height as i32) as f64 } -/// A display's logical origin: GDK monitor geometry is in logical pixels. +/// A display's logical origin, the key `at:,` preferences are matched on. +/// Names are no good for that: two monitors of the same model share one. fn logical_origin(m: &Monitor) -> (i32, i32) { let scale = m.scale_factor(); let p = m.position(); ((p.x as f64 / scale).round() as i32, (p.y as f64 / scale).round() as i32) } -/// The display the island lives on: the primary one, or the one under the cursor. +/// One entry of the "Island lives on" list in Settings. +#[derive(Serialize, Clone)] +pub struct MonitorChoice { + pub key: String, + pub label: String, +} + +pub fn monitor_choices(app: &AppHandle) -> Vec { + let Ok(monitors) = app.available_monitors() else { return Vec::new() }; + monitors + .iter() + .map(|m| { + let (x, y) = logical_origin(m); + let scale = m.scale_factor(); + let s = m.size(); + let w = (s.width as f64 / scale).round(); + let h = (s.height as f64 / scale).round(); + let name = m.name().cloned().unwrap_or_else(|| "Display".into()); + MonitorChoice { key: format!("at:{x},{y}"), label: format!("{name} — {w}×{h} at {x},{y}") } + }) + .collect() +} + +/// The display the island lives on: a chosen one, the primary one, or the one +/// under the cursor. fn target_monitor(app: &AppHandle, pref: &str) -> Option { let monitors = app.available_monitors().ok()?; + if let Some((x, y)) = pref + .strip_prefix("at:") + .and_then(|r| r.split_once(',')) + .and_then(|(x, y)| Some((x.trim().parse::().ok()?, y.trim().parse::().ok()?))) + { + // Unplugged or rearranged since: fall back to the primary display. + if let Some(m) = monitors.iter().find(|m| logical_origin(m) == (x, y)) { + return Some(m.clone()); + } + } if pref == "cursor" { if let Some((cx, cy)) = cursor_physical() { if let Some(m) = monitors.iter().find(|m| monitor_contains(m, cx, cy)) { diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59e..2c5233700a 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -124,6 +124,12 @@ fn reposition(app: AppHandle, shared: State) { island::apply_geometry(&app, &pref, collapsed); } +/// The displays the island can be pinned to, for Settings. +#[tauri::command] +fn list_monitors(app: AppHandle) -> Vec { + island::monitor_choices(&app) +} + #[tauri::command] fn open_url(url: String) { if !(url.starts_with("http://") || url.starts_with("https://")) { @@ -381,6 +387,7 @@ pub fn run() { set_island_rect, focus_window, reposition, + list_monitors, open_url, open_in_vscode, quit_app, diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff9..32775da810 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -50,6 +50,9 @@ export const Bridge = { reposition: () => call("reposition"), + /** Displays the island can be pinned to: `key` is what `settings.screen` stores. */ + listMonitors: () => call<{ key: string; label: string }[]>("list_monitors"), + openUrl: (url: string) => call("open_url", { url }), /** "Open terminal" → opens the folder in VS Code when `code` is on PATH. */ diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a9..4b1b2335b3 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -87,7 +87,8 @@ export interface Settings { autoCloseInterval: number; absenceInterval: number; activeIntegrations: string[]; - screen: "primary" | "cursor"; + /** "primary", "cursor", or `at:,` for one display (logical origin). */ + screen: string; autostart: boolean; hooksInstalled: boolean; /** Claude model used by the chat. */ diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a7..2e8a02a3ac 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -387,8 +387,14 @@ function generalSection(): HTMLElement { h("option", { value: "cursor", text: "Display under the cursor" }), ); screen.value = settings.screen; + void Bridge.listMonitors().then((list) => { + for (const m of list ?? []) screen.append(h("option", { value: m.key, text: m.label })); + // Set again now the option exists; a display that is gone shows as the main one. + screen.value = settings.screen; + if (!screen.value) screen.value = "primary"; + }); screen.addEventListener("change", () => { - settings.screen = screen.value as Settings["screen"]; + settings.screen = screen.value; void save(); }); From 5b06e1c828797d0ced61cc0d268bfb86f189f056 Mon Sep 17 00:00:00 2001 From: chux Date: Sun, 4 Oct 2026 23:07:03 -0400 Subject: [PATCH 3/4] Island: ignore clicks on a fresh permission card for 600 ms The card opens under a cursor that was busy with the window underneath, so a click meant for that window could land on Allow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BNUKEFycKsFCP5fKkhY1Uf --- windows/src/views/views.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f7..73489e0dbf 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -288,15 +288,32 @@ function buildEmpty(actions: ViewActions): ViewHost { // ── Approval ────────────────────────────────────────────────────────────────── +/** How long a fresh permission card ignores clicks on its buttons. */ +const CLICK_GUARD_MS = 600; + function buildApproval(actions: ViewActions): ViewHost { const who = h("div"); const code = h("div", { class: "code" }); const row = h("div", { class: "actions" }); const el = h("div", { class: "view" }, card("amber", stack(116, 16, who, code, row))); let rowKey = ""; + // The card pops up under a cursor that was busy with something else: a click + // meant for the window underneath must not land on Allow. Clicks in the first + // moments after a new request appears are ignored. + let shownFor: string | null = null; + let shownAt = 0; + const guarded = (d: "allow" | "deny") => () => { + if (performance.now() - shownAt < CLICK_GUARD_MS) return; + actions.decide(d); + }; return { el, sync() { + const req = State.pendingApproval?.requestId ?? null; + if (req !== shownFor) { + shownFor = req; + shownAt = performance.now(); + } clear(who); who.append(agentWho(State.focusTask, "needs permission")); // The whole point of approving here rather than in the terminal: this line @@ -310,8 +327,8 @@ function buildApproval(actions: ViewActions): ViewHost { rowKey = "built"; clear(row); row.append( - btn("Deny", "secondary", () => actions.decide("deny"), "N"), - btn("Allow", "primary", () => actions.decide("allow"), "Y"), + btn("Deny", "secondary", guarded("deny"), "N"), + btn("Allow", "primary", guarded("allow"), "Y"), ); }, }; From 0bd0eb8d4b90c9cd3b32f504d5462d6ae75680c9 Mon Sep 17 00:00:00 2001 From: chux Date: Sun, 4 Oct 2026 23:07:03 -0400 Subject: [PATCH 4/4] Settings: name the Secret Service as the key store on Linux Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BNUKEFycKsFCP5fKkhY1Uf --- windows/src/settings/main.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 2e8a02a3ac..df9a881fba 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -7,6 +7,11 @@ import { Bridge, onEvent, type HookStatus } from "../core/bridge"; import { DEFAULT_SETTINGS, type Settings } from "../core/state"; import { h, clear } from "../views/dom"; +/** Where secrets.rs keeps the keys on this OS. */ +const KEY_STORE = navigator.userAgent.includes("Windows") + ? "Windows Credential Manager" + : "Secret Service (GNOME Keyring, KWallet)"; + let settings: Settings = { ...DEFAULT_SETTINGS }; let version = ""; @@ -181,7 +186,7 @@ const MODELS: [string, string][] = [ function apiSection(hasKey: boolean): HTMLElement { const dot = statusDot(hasKey); - const state = h("span", { class: "hint", text: hasKey ? "Key saved in the Windows Credential Manager." : "No key yet — the chat needs one." }); + const state = h("span", { class: "hint", text: hasKey ? `Key saved in the ${KEY_STORE}.` : "No key yet — the chat needs one." }); const field = h("input", { type: "password", @@ -199,7 +204,7 @@ function apiSection(hasKey: boolean): HTMLElement { const present = (await Bridge.secretPresent("anthropic-api-key")) ?? false; dot.style.background = present ? "#22c55e" : "#f4505e"; state.textContent = present - ? "Key saved in the Windows Credential Manager." + ? `Key saved in the ${KEY_STORE}.` : "No key yet — the chat needs one."; field.placeholder = present ? "•••••••••••• (stored)" : "sk-ant-..."; clearBtn.style.display = present ? "" : "none"; @@ -292,7 +297,7 @@ function integrationsSection(present: Record): HTMLElement { function updateNote() { const used = settings.activeIntegrations.length; - note.textContent = `Pick up to ${MAX_ACTIVE} pills to show next to Mochi — ${used}/${MAX_ACTIVE} in use. Keys are stored in the Windows Credential Manager, never on disk.`; + note.textContent = `Pick up to ${MAX_ACTIVE} pills to show next to Mochi — ${used}/${MAX_ACTIVE} in use. Keys are stored in the ${KEY_STORE}, never on disk.`; } for (const def of INTEGRATIONS) {