Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion windows/src-tauri/src/island.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,51 @@ fn monitor_contains(m: &Monitor, x: f64, y: f64) -> bool {
&& y < (p.y + s.height as i32) as f64
}

/// The display the island lives on: the primary one, or the one under the cursor.
/// A display's logical origin, the key `at:<x>,<y>` 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)
}

/// 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<MonitorChoice> {
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<Monitor> {
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::<i32>().ok()?, y.trim().parse::<i32>().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)) {
Expand Down Expand Up @@ -172,6 +214,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);
Expand Down
7 changes: 7 additions & 0 deletions windows/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ fn reposition(app: AppHandle, shared: State<Shared>) {
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::MonitorChoice> {
island::monitor_choices(&app)
}

#[tauri::command]
fn open_url(url: String) {
if !(url.starts_with("http://") || url.starts_with("https://")) {
Expand Down Expand Up @@ -381,6 +387,7 @@ pub fn run() {
set_island_rect,
focus_window,
reposition,
list_monitors,
open_url,
open_in_vscode,
quit_app,
Expand Down
25 changes: 25 additions & 0 deletions windows/src-tauri/src/platform/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions windows/src-tauri/src/platform/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
3 changes: 3 additions & 0 deletions windows/src/core/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export const Bridge = {

reposition: () => call<void>("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<void>("open_url", { url }),

/** "Open terminal" → opens the folder in VS Code when `code` is on PATH. */
Expand Down
3 changes: 2 additions & 1 deletion windows/src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ export interface Settings {
autoCloseInterval: number;
absenceInterval: number;
activeIntegrations: string[];
screen: "primary" | "cursor";
/** "primary", "cursor", or `at:<x>,<y>` for one display (logical origin). */
screen: string;
autostart: boolean;
hooksInstalled: boolean;
/** Claude model used by the chat. */
Expand Down
19 changes: 15 additions & 4 deletions windows/src/settings/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";

Expand Down Expand Up @@ -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",
Expand All @@ -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";
Expand Down Expand Up @@ -292,7 +297,7 @@ function integrationsSection(present: Record<string, boolean>): 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) {
Expand Down Expand Up @@ -387,8 +392,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();
});

Expand Down
21 changes: 19 additions & 2 deletions windows/src/views/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
);
},
};
Expand Down