From 5a356f867f500b897563f1a3d213f0d1b318da37 Mon Sep 17 00:00:00 2001 From: Igor MSI Date: Wed, 30 Sep 2026 19:04:38 -0300 Subject: [PATCH 01/21] chore: ignore WebStorm and local opencode folders --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a3be23840..cec175689 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ DerivedData/ # Local only (never published) .claude/ _prive/ +.idea/ \ No newline at end of file From cb17e7cd6b98316fa45791a16b2a174774a1981e Mon Sep 17 00:00:00 2001 From: Igor MSI Date: Wed, 30 Sep 2026 19:09:46 -0300 Subject: [PATCH 02/21] Windows: opencode plugin and its installer --- windows/opencode-plugin/coucou.js | 247 ++++++++++++++++ windows/src-tauri/src/opencode.rs | 325 +++++++++++++++++++++ windows/src-tauri/src/opencode_chat.rs | 375 +++++++++++++++++++++++++ 3 files changed, 947 insertions(+) create mode 100644 windows/opencode-plugin/coucou.js create mode 100644 windows/src-tauri/src/opencode.rs create mode 100644 windows/src-tauri/src/opencode_chat.rs diff --git a/windows/opencode-plugin/coucou.js b/windows/opencode-plugin/coucou.js new file mode 100644 index 000000000..7c8bf526e --- /dev/null +++ b/windows/opencode-plugin/coucou.js @@ -0,0 +1,247 @@ +// coucou.js — Coucou plugin for opencode. +// +// Forwards opencode session / tool / permission events to Coucou over the +// named pipe `\\.\pipe\coucou-` via `coucou-hook.exe`, the same relay +// Claude Code hooks use. Payloads reuse the Claude Code hook shape +// (`hook_event_name`, `session_id`, `cwd`, `tool_name`, `tool_input`, …) +// plus `"agent": "opencode"`, so the island routes them to the opencode pill. +// +// Hard rule (same as nb-hook / coucou-hook): **never block opencode.** +// * Fire-and-forget events get a 2 s relay budget and are abandoned after. +// * Only `permission.asked` waits (up to 110 s) for the island's decision. +// No answer — Coucou closed, timeout, relay missing — resolves to +// "ask", and opencode asks in the TUI exactly as if Coucou were absent. +// +// Install: copied to `~/.config/opencode/plugins/coucou.js` by Coucou's +// settings window (or manually). No `opencode.json` edit is needed: +// files in the global plugin directory are auto-loaded at startup. +// +// Version stamp — the Tauri installer compares this to detect outdated copies. +// Bump on any protocol change. +// COUCOU_PLUGIN_VERSION is matched by src-tauri/src/opencode.rs (do not rename). +const COUCOU_PLUGIN_VERSION = 1; + +// ── Relay resolution ────────────────────────────────────────────────────────── + +function relayCandidates() { + const list = []; + try { + const local = typeof process !== "undefined" ? process.env.LOCALAPPDATA : ""; + if (local) list.push(`${local}\\Coucou\\bin\\coucou-hook.exe`); + } catch { /* process.env unavailable — fall through */ } + return list; +} + +/** stdin bytes for the relay: JSON line, like the Claude Code hook payload. */ +function line(obj) { + return `${JSON.stringify(obj)}\n`; +} + +/** + * Spawn the relay synchronously with `input` on stdin. + * Returns trimmed stdout ("" when the relay is missing, crashed or timed out). + * Never throws — silence is the safe answer. + */ +async function relay(event, payload, timeoutMs, $) { + const candidates = relayCandidates(); + if (candidates.length === 0) return ""; + const body = line({ ...payload, hook_event_name: event, agent: "opencode" }); + + // Prefer opencode's Bun shell API ($), fall back to node child_process. + try { + if ($) { + const proc = $`${candidates[0]} ${event}`.stdin(body); + const done = await Promise.race([ + proc.then((r) => (typeof r === "string" ? r : r?.stdout ?? "")), + new Promise((resolve) => setTimeout(() => resolve(""), timeoutMs)), + ]); + return String(done ?? "").trim(); + } + } catch { /* fall through to spawnSync */ } + + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { spawnSync } = require("node:child_process"); + const res = spawnSync(candidates[0], [event], { + input: body, + encoding: "utf8", + timeout: timeoutMs, + windowsHide: true, + }); + return String(res.stdout ?? "").trim(); + } catch { + return ""; + } +} + +function fireAndForget(event, payload, $) { + // Deliberately unawaited: the 2 s budget lives inside the relay call chain + // via timeout, and nothing here may delay the agent. + void relay(event, payload, 2000, $).catch(() => {}); +} + +// ── Payload helpers (defensive: event shapes vary across opencode versions) ── + +function pick(obj, paths) { + for (const p of paths) { + const v = p.split(".").reduce((o, k) => (o == null ? o : o[k]), obj); + if (typeof v === "string" && v) return v; + if (typeof v === "number") return String(v); + } + return ""; +} + +function sessionIdOf(input, fallback) { + return ( + pick(input, ["sessionID", "session.id", "sessionId", "id"]) || + fallback || + "unknown" + ); +} + +// ── Decision translation ───────────────────────────────────────────────────── + +/** + * coucou-hook.exe answers PermissionRequest with Claude-shaped JSON: + * {"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"|"deny"}}} + * Map it back to what opencode's `permission.asked` understands. + * Returns "allow" | "deny" | null (null = no usable answer → ask in TUI). + */ +function translateDecision(stdout) { + if (!stdout) return null; + const lower = stdout.toLowerCase(); + if (lower === "allow" || lower === "deny") return lower; + try { + const parsed = JSON.parse(stdout); + const behavior = parsed?.hookSpecificOutput?.decision?.behavior; + if (behavior === "allow" || behavior === "deny") return behavior; + if (parsed?.behavior === "allow" || parsed?.behavior === "deny") return parsed.behavior; + if (parsed?.decision === "allow" || parsed?.decision === "deny") return parsed.decision; + } catch { /* not JSON — fall through */ } + return null; +} + +// ── Plugin ──────────────────────────────────────────────────────────────────── + +export const CoucouPlugin = async ({ $, directory }) => { + const cwd = directory || (typeof process !== "undefined" ? process.cwd() : ""); + + /** Base payload shared by every event. */ + const base = (extra = {}) => ({ cwd, ...extra }); + + return { + // ── Sessions ──────────────────────────────────────────────────────────── + "session.created": async (input) => { + fireAndForget("SessionStart", base({ session_id: sessionIdOf(input, "") }), $); + }, + "session.idle": async (input) => { + const message = pick(input, ["message", "summary", "title"]); + fireAndForget("Stop", base({ session_id: sessionIdOf(input, ""), message }), $); + }, + "session.error": async (input) => { + const message = pick(input, ["error.message", "message", "error"]); + fireAndForget("StopFailure", base({ session_id: sessionIdOf(input, ""), message }), $); + }, + "session.deleted": async (input) => { + fireAndForget("SessionEnd", base({ session_id: sessionIdOf(input, "") }), $); + }, + + // User prompt submitted in a session → thinking state + step line. + "message.updated": async (input) => { + const role = pick(input, ["message.role", "role"]); + if (role && role !== "user") return; + const prompt = pick(input, ["message.content", "message.text", "content", "text", "prompt"]); + if (!prompt) return; + fireAndForget( + "UserPromptSubmit", + base({ session_id: sessionIdOf(input, ""), prompt: prompt.slice(0, 2000) }), + $, + ); + }, + + // ── Tools ─────────────────────────────────────────────────────────────── + "tool.execute.before": async (input, output) => { + const tool = pick(input, ["tool", "toolName", "name"]) || "Tool"; + const args = + (output && typeof output.args === "object" && output.args) || + input.args || + input.input || + {}; + // The question tool surfaces as an island question card. + if (tool.toLowerCase() === "question") { + const q = pick({ args }, ["args.question", "args.prompt", "args.text"]) || "opencode asks a question"; + fireAndForget( + "Notification", + base({ session_id: sessionIdOf(input, ""), message: `${q}?` }), + $, + ); + return; + } + fireAndForget( + "PreToolUse", + base({ session_id: sessionIdOf(input, ""), tool_name: tool, tool_input: args }), + $, + ); + }, + "tool.execute.after": async (input, output) => { + const failed = Boolean(output?.error || input?.error); + fireAndForget( + failed ? "PostToolUseFailure" : "PostToolUse", + base({ session_id: sessionIdOf(input, "") }), + $, + ); + }, + + // ── Permission (the only blocking hook) ───────────────────────────────── + "permission.asked": async (input, output) => { + const tool = pick(input, ["tool", "toolName", "permission", "name"]) || "Tool"; + const args = + (output && typeof output.args === "object" && output.args) || + input.args || + input.input || + {}; + const suggestions = input.suggestions || input.patterns || []; + + const stdout = await relay( + "PermissionRequest", + base({ + session_id: sessionIdOf(input, ""), + tool_name: tool, + tool_input: args, + permission_suggestions: suggestions, + }), + 110_000, + $, + ).catch(() => ""); + const decision = translateDecision(stdout); + + // No answer (Coucou closed / timeout): fall through silently so opencode + // asks in the TUI — exactly the "ask" behaviour. + if (!decision) return; + + // Try every known resolver shape; unknown shapes are ignored rather + // than throwing, because throwing inside a permission hook denies the + // tool and must never happen by accident. + try { + if (output && typeof output === "object") { + if ("decision" in output || "behavior" in output) { + if ("decision" in output) output.decision = decision; + if ("behavior" in output) output.behavior = decision; + return; + } + } + } catch { /* notification-only fallback */ } + // Returning the bare word covers resolvers that use the return value. + // Resolvers that ignore it simply ask in the TUI — still correct. + return decision; + }, + "permission.replied": async (input) => { + // The user answered in the TUI (island unreachable or too slow): + // clear a stale approval card so it stops lying. + const replied = pick(input, ["decision", "reply", "behavior"]); + if (replied) { + fireAndForget("PostToolUse", base({ session_id: sessionIdOf(input, "") }), $); + } + }, + }; +}; diff --git a/windows/src-tauri/src/opencode.rs b/windows/src-tauri/src/opencode.rs new file mode 100644 index 000000000..4e1ba54ce --- /dev/null +++ b/windows/src-tauri/src/opencode.rs @@ -0,0 +1,325 @@ +// opencode plugin installation. +// +// Unlike the Claude Code hooks (which merge commands into +// `~/.claude/settings.json`), opencode loads every JS file dropped into its +// plugin directories automatically. Installing Coucou's opencode support is +// therefore a single file copy: +// +// → %USERPROFILE%\.config\opencode\plugins\coucou.js +// +// The same strict rules as hooks.rs apply: take a dated backup, show what +// will change, and write only after an explicit click. Uninstall removes +// Coucou's file and nothing else. Project-level `.opencode/plugins/` dirs +// are left alone on purpose — a global install covers every project. +// +// The plugin file carries `const COUCOU_PLUGIN_VERSION = N`; the installer +// compares the bundled version against the installed copy to offer updates. + +use std::path::PathBuf; + +use serde::Serialize; + +use crate::settings; + +/// File name inside opencode's plugin directories. +pub const PLUGIN_FILE: &str = "coucou.js"; + +/// Marker matched inside the plugin source. Must stay in sync with +/// windows/opencode-plugin/coucou.js (and the `const` name with the comment +/// there). Version comparison is parsed from the file text, so no manual +/// constant needs bumping here. +const VERSION_MARKER: &str = "const COUCOU_PLUGIN_VERSION ="; + +/// The plugin source bundled with the app (see tauri.conf.json `resources`). +const BUNDLED: &str = include_str!("../../opencode-plugin/coucou.js"); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpencodeStatus { + pub installed: bool, + pub plugin_path: String, + pub relay_ready: bool, + pub bundled_version: i64, + pub installed_version: Option, + pub needs_update: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpencodePreview { + pub diff: String, + pub backup: String, + pub plugin_path: String, + /// Identifies the bytes this preview was computed from; handed back to + /// `write` so we only ever apply what the user actually looked at. + pub fingerprint: String, +} + +pub fn plugin_dir() -> PathBuf { + std::env::var_os("USERPROFILE") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") + .join("opencode") + .join("plugins") +} + +pub fn plugin_path() -> PathBuf { + plugin_dir().join(PLUGIN_FILE) +} + +/// Parses `const COUCOU_PLUGIN_VERSION = N` out of plugin source. +/// Returns None when the file is foreign (not ours) or unparsable. +fn parse_version(text: &str) -> Option { + for line in text.lines() { + let t = line.trim(); + if let Some(rest) = t.strip_prefix(VERSION_MARKER) { + let num: String = rest + .trim() + .trim_end_matches(';') + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '-') + .collect(); + if let Ok(v) = num.parse::() { + return Some(v); + } + } + } + None +} + +fn bundled_version() -> i64 { + parse_version(BUNDLED).unwrap_or(0) +} + +/// FNV-1a over the exact bytes, same rationale as hooks.rs. +fn fingerprint(bytes: &[u8]) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for b in bytes { + hash ^= *b as u64; + hash = hash.wrapping_mul(0x1000_0000_01b3); + } + format!("{hash:016x}") +} + +fn current_bytes() -> Vec { + std::fs::read(plugin_path()).unwrap_or_default() +} + +fn current_fingerprint() -> String { + match std::fs::read(plugin_path()) { + Ok(bytes) => fingerprint(&bytes), + Err(_) => fingerprint(b""), + } +} + +fn stamp() -> String { + format!("{}", chrono_stamp()) +} + +/// Local timestamp without pulling in chrono: reuses the same shape as +/// hooks.rs (`yyyyMMdd-HHmmss`) via the Win32 clock. +fn chrono_stamp() -> String { + // hooks.rs already depends on windows::GetLocalTime; duplicate the tiny + // call here rather than reaching into that module. + let t = unsafe { windows::Win32::System::SystemInformation::GetLocalTime() }; + format!( + "{:04}{:02}{:02}-{:02}{:02}{:02}", + t.wYear, t.wMonth, t.wDay, t.wHour, t.wMinute, t.wSecond + ) +} + +fn backup_path() -> PathBuf { + plugin_dir().join(format!("{PLUGIN_FILE}.bak-{}", stamp())) +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +pub fn status() -> OpencodeStatus { + let path = plugin_path(); + let existing = std::fs::read_to_string(&path).ok(); + let ours = existing.as_deref().map(parse_version); + let installed = matches!(ours, Some(_)); + let installed_version = ours.flatten(); + let bundled = bundled_version(); + OpencodeStatus { + installed, + plugin_path: path.to_string_lossy().to_string(), + relay_ready: settings::hook_exe_path().exists(), + bundled_version: bundled, + installed_version, + needs_update: matches!(installed_version, Some(v) if v < bundled), + } +} + +pub fn preview(install: bool) -> Result { + let path = plugin_path(); + let current = std::fs::read(&path).unwrap_or_default(); + let (diff, backup) = if install { + let next = BUNDLED.as_bytes(); + let d = if current == next { + "Already up to date — writing would change nothing.".to_string() + } else if current.is_empty() { + format!( + "+ new file {} ({} bytes, plugin v{})\n Relay: {}", + path.display(), + next.len(), + bundled_version(), + settings::hook_exe_path().display(), + ) + } else { + format!( + "~ {} will be replaced (backup first)\n- installed: {} bytes{}\n+ bundled: {} bytes (plugin v{})", + path.display(), + current.len(), + match std::fs::read_to_string(&path).ok().as_deref().map(parse_version) { + Some(Some(v)) => format!(", plugin v{v}"), + _ => ", not a Coucou plugin — will NOT be touched".to_string(), + }, + next.len(), + bundled_version(), + ) + }; + // Refuse to overwrite a foreign coucou.js: somebody else owns that name. + if !current.is_empty() && parse_version(&String::from_utf8_lossy(¤t)).is_none() { + return Err(format!( + "{} exists and is not a Coucou plugin — Coucou won't overwrite it. Remove or rename it first.", + path.display() + )); + } + (d, backup_path().to_string_lossy().to_string()) + } else { + if current.is_empty() { + ("Nothing to remove — the plugin is not installed.".to_string(), String::new()) + } else if parse_version(&String::from_utf8_lossy(¤t)).is_none() { + return Err(format!( + "{} exists and is not a Coucou plugin — Coucou won't remove it.", + path.display() + )); + } else { + ( + format!("- remove {} (backup first)", path.display()), + backup_path().to_string_lossy().to_string(), + ) + } + }; + Ok(OpencodePreview { + diff, + backup, + plugin_path: path.to_string_lossy().to_string(), + fingerprint: current_fingerprint(), + }) +} + +/// Writes (or removes) the plugin after taking a dated backup. +/// `fingerprint` must be the one from the preview the user reviewed. +pub fn write(install: bool, fingerprint: &str) -> Result { + let path = plugin_path(); + if current_fingerprint() != fingerprint { + return Err(format!( + "{} changed since the preview. Nothing was written — review the new diff.", + path.display() + )); + } + std::fs::create_dir_all(plugin_dir()).map_err(|e| e.to_string())?; + + let current = std::fs::read(&path).unwrap_or_default(); + if !current.is_empty() && parse_version(&String::from_utf8_lossy(¤t)).is_none() { + return Err(format!( + "{} is not a Coucou plugin — refusing to touch it.", + path.display() + )); + } + + let mut backup = String::new(); + if !current.is_empty() { + let b = backup_path(); + std::fs::copy(&path, &b).map_err(|e| format!("backup failed: {e}"))?; + backup = b.to_string_lossy().to_string(); + } + + if install { + // Write beside the target and rename over it, like hooks.rs. + let temp = plugin_dir().join(format!("coucou.js.coucou-{}", std::process::id())); + std::fs::write(&temp, BUNDLED.as_bytes()).map_err(|e| format!("write failed: {e}"))?; + if let Err(err) = std::fs::rename(&temp, &path) { + let _ = std::fs::remove_file(&temp); + return Err(format!("write failed: {err}")); + } + } else if path.exists() { + std::fs::remove_file(&path).map_err(|e| format!("remove failed: {e}"))?; + } + Ok(backup) +} + +/// Copies the bundled plugin into the global plugin dir on launch when it is +/// missing or older than the bundle. Never overwrites a foreign `coucou.js`, +/// and never touches project-level `.opencode/plugins/` directories. +pub fn ensure_plugin() { + let s = status(); + if s.installed && !s.needs_update { + return; + } + if s.installed && current_bytes().is_empty() { + return; + } + // Foreign file with our name: hands off, the settings window explains. + if let Ok(text) = std::fs::read_to_string(plugin_path()) { + if parse_version(&text).is_none() { + crate::log::line("opencode plugin: foreign coucou.js present — leaving it alone"); + return; + } + } + if std::fs::create_dir_all(plugin_dir()).is_err() { + return; + } + let current = current_bytes(); + if current == BUNDLED.as_bytes() { + return; + } + if let Err(err) = std::fs::write(plugin_path(), BUNDLED.as_bytes()) { + crate::log::line(format!("could not install opencode plugin: {err}")); + } else { + crate::log::line(format!( + "opencode plugin installed (v{})", + bundled_version() + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_parses_from_bundled_source() { + assert_eq!(parse_version(BUNDLED), Some(1)); + } + + #[test] + fn version_rejects_foreign_or_broken_files() { + assert_eq!(parse_version(""), None); + assert_eq!(parse_version("export const x = 1;\n"), None); + assert_eq!( + parse_version("const COUCOU_PLUGIN_VERSION = 12;\n"), + Some(12) + ); + assert_eq!( + parse_version(" const COUCOU_PLUGIN_VERSION = 3\n"), + Some(3) + ); + } + + #[test] + fn fingerprint_notices_any_change() { + assert_eq!(fingerprint(b"{}"), fingerprint(b"{}")); + assert_ne!(fingerprint(b"{}"), fingerprint(b"{ }")); + } + + #[test] + fn bundled_source_mentions_relay_and_agent() { + assert!(BUNDLED.contains("coucou-hook.exe")); + assert!(BUNDLED.contains("\"agent\": \"opencode\"")); + assert!(BUNDLED.contains("permission.asked")); + } +} diff --git a/windows/src-tauri/src/opencode_chat.rs b/windows/src-tauri/src/opencode_chat.rs new file mode 100644 index 000000000..6a86fe0f0 --- /dev/null +++ b/windows/src-tauri/src/opencode_chat.rs @@ -0,0 +1,375 @@ +// opencode CLI chat backend — routes the island chat through the user's own +// opencode (`opencode run`) instead of the Anthropic API, so whatever model +// the user configured there (GPT, Claude, …) answers from the notch. +// +// Everything happens here rather than in the island: the prompt, the session +// id and any file bytes never cross the IPC boundary, and the query is passed +// as argv (never through a shell), so paths and punctuation stay inert. +// +// Multi-turn works by keeping opencode's session id after the first turn and +// passing `--session` on later ones, in the same working directory. + +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use serde_json::Value; + +use crate::claude::{ChatContext, ChatReply}; + +/// `opencode run` can think for a while; the Claude API path uses 90 s, but a +/// local agent with tools deserves more rope before the island gives up. +const RUN_TIMEOUT: Duration = Duration::from_secs(300); + +/// Preamble sent once per session so answers fit a notch readout. +const PERSONA: &str = "You are Mochi, a personal AI assistant living at the top of the user's screen. \ +Answer in the user's language. Be helpful and complete, but concise enough for a small popup. \ +Use plain text with line breaks, no markdown formatting."; + +#[derive(Default)] +pub struct OpencodeChat { + session: Mutex>, +} + +struct ChatSession { + id: String, + dir: String, +} + +impl OpencodeChat { + pub fn reset(&self) { + *self.session.lock().unwrap() = None; + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatStatus { + pub bin_configured: String, + pub bin_resolved: Option, + pub claude_key_present: bool, +} + +/// Where the binary comes from: explicit setting first, then well-known spots. +pub fn resolve_bin(configured: &str) -> Option { + let trimmed = configured.trim(); + if !trimmed.is_empty() { + let p = PathBuf::from(trimmed); + if p.is_file() { + return Some(p); + } + return None; + } + if let Some(p) = crate::find_on_path("opencode") { + return Some(p); + } + if let Some(home) = std::env::var_os("USERPROFILE").map(PathBuf::from) { + for rel in [ + ".bun/bin/opencode.exe", + "scoop/shims/opencode.exe", + ".opencode/bin/opencode.exe", + ] { + let p = home.join(rel); + if p.is_file() { + return Some(p); + } + } + } + None +} + +/// One chat turn. Returns the assistant's text, or a message the island shows +/// in the note view. +pub async fn send( + chat: &OpencodeChat, + bin_configured: &str, + model_override: &str, + query: String, + context: Option, +) -> Result { + let bin = resolve_bin(bin_configured).ok_or_else(|| { + "opencode not found. Install it (opencode.ai) or set its path in Settings → Chat.".to_string() + })?; + + // First turn of a conversation carries the persona + context; later turns + // reuse the session in the same directory. + let first; + let (session_id, dir, files, message) = { + let guard = chat.session.lock().unwrap(); + match &*guard { + Some(s) => { + first = false; + (Some(s.id.clone()), s.dir.clone(), Vec::new(), query.clone()) + } + None => { + first = true; + let (dir, files, ctx_text) = first_turn_context(&context); + let mut message = String::from(PERSONA); + if !ctx_text.is_empty() { + message.push_str("\n\nContext: "); + message.push_str(&ctx_text); + } + message.push_str("\n\nUser: "); + message.push_str(&query); + (None, dir, files, message) + } + } + }; + + let model = model_override.trim(); + let mut args: Vec = vec![ + "run".into(), + "--format".into(), + "json".into(), + "--title".into(), + "Coucou chat".into(), + "--dir".into(), + dir.clone(), + ]; + if !model.is_empty() { + args.push("--model".into()); + args.push(model.to_string()); + } + if let Some(id) = &session_id { + args.push("--session".into()); + args.push(id.clone()); + } + // NOTE: the message must come before `--file`: yargs array options greedily + // swallow every positional after them, so `--file f "message"` eats the + // message as a second file ("File not found: "). + args.push(message); + for f in &files { + args.push("--file".into()); + args.push(f.clone()); + } + + let bin_str = bin.to_string_lossy().to_string(); + let (ok, stdout, stderr) = + tokio::task::spawn_blocking(move || run_blocking(&bin_str, &args)) + .await + .map_err(|e| format!("opencode task failed: {e}"))??; + + let parsed = parse_events(&stdout); + if let Some(id) = parsed.session_id { + *chat.session.lock().unwrap() = Some(ChatSession { id, dir }); + } + + if let Some(err) = parsed.error { + return Err(err); + } + if !ok { + let tail: String = stderr.lines().rev().take(3).collect::>().join(" | "); + let detail = if tail.trim().is_empty() { + "unknown error" + } else { + tail.trim() + }; + return Err(format!("opencode failed: {detail}")); + } + if parsed.text.trim().is_empty() { + return Err("opencode returned no text.".into()); + } + let _ = first; + Ok(ChatReply { + text: parsed.text.trim().to_string(), + }) +} + +/// Working dir + file attachments + context line for a fresh conversation. +fn first_turn_context(context: &Option) -> (String, Vec, String) { + let home = std::env::var_os("USERPROFILE") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + match context { + Some(ChatContext::File { name, path }) => { + let dir = std::path::Path::new(path) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| home.to_string_lossy().to_string()); + (dir, vec![path.clone()], format!("File: {name} (attached)")) + } + Some(ChatContext::Window { app_name, title, url }) => { + let mut text = format!("App: {app_name}, Window: {title}"); + if let Some(url) = url { + text.push_str(&format!(", URL: {url}")); + } + (home.to_string_lossy().to_string(), Vec::new(), text) + } + None => (home.to_string_lossy().to_string(), Vec::new(), String::new()), + } +} + +/// Runs the binary synchronously (call from `spawn_blocking`): drains both +/// pipes on helper threads so large `--format json` output can never wedge +/// the child on a full pipe buffer, then enforces the deadline. +fn run_blocking(bin: &str, args: &[String]) -> Result<(bool, String, String), String> { + use std::io::Read; + use std::os::windows::process::CommandExt; + + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + let mut child = std::process::Command::new(bin) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map_err(|e| format!("could not start opencode: {e}"))?; + + let out_handle = std::thread::spawn({ + let mut out = child.stdout.take(); + move || { + let mut buf = Vec::new(); + if let Some(o) = out.as_mut() { + let _ = o.read_to_end(&mut buf); + } + String::from_utf8_lossy(&buf).to_string() + } + }); + let err_handle = std::thread::spawn({ + let mut err = child.stderr.take(); + move || { + let mut buf = Vec::new(); + if let Some(e) = err.as_mut() { + let _ = e.read_to_end(&mut buf); + } + String::from_utf8_lossy(&buf).to_string() + } + }); + + let deadline = Instant::now() + RUN_TIMEOUT; + let status = loop { + match child.try_wait().map_err(|e| e.to_string())? { + Some(status) => break status, + None => { + if Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err("opencode took too long (5 min) — try a shorter question.".into()); + } + std::thread::sleep(Duration::from_millis(100)); + } + } + }; + + let stdout = out_handle.join().unwrap_or_default(); + let stderr = err_handle.join().unwrap_or_default(); + Ok((status.success(), stdout, stderr)) +} + +struct Parsed { + session_id: Option, + text: String, + error: Option, +} + +/// Pulls the assistant text + session id out of `opencode run --format json` +/// (newline-delimited events, verified against opencode 1.x): +/// {"type":"text","sessionID":"ses_…","part":{"type":"text","text":"… Ness"}} +/// {"type":"tool_use",…} → ignored (tools, not chat) +/// {"type":"step_start" | "step_finish",…} → ignored +/// {"type":"error","error":{"data":{"message":…}}} → surfaced to the island +/// Everything is best-effort: unknown lines and shapes are skipped, never fatal. +fn parse_events(stdout: &str) -> Parsed { + let mut session_id: Option = None; + let mut text = String::new(); + let mut error: Option = None; + + for line in stdout.lines() { + let line = line.trim(); + if line.is_empty() || !line.starts_with('{') { + continue; + } + let Ok(event) = serde_json::from_str::(line) else { + continue; + }; + if session_id.is_none() { + session_id = event + .get("sessionID") + .and_then(Value::as_str) + .map(str::to_string); + } + match event.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(t) = event + .get("part") + .and_then(|p| p.get("text")) + .and_then(Value::as_str) + { + text.push_str(t); + } + } + Some("error") => { + let msg = event + .get("error") + .and_then(|e| { + e.get("data") + .and_then(|d| d.get("message")) + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + e.get("message").and_then(Value::as_str).map(str::to_string) + }) + }) + .or_else(|| { + event + .get("message") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| "opencode reported an error.".to_string()); + error = Some(msg); + } + _ => {} + } + } + + Parsed { + session_id, + text, + error, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_assistant_text_and_session_id() { + let stdout = concat!( + "{\"type\":\"step_start\",\"timestamp\":1,\"sessionID\":\"ses_abc\",\"part\":{\"type\":\"step-start\"}}\n", + "{\"type\":\"text\",\"timestamp\":2,\"sessionID\":\"ses_abc\",\"part\":{\"type\":\"text\",\"text\":\"Hello! \"}}\n", + "{\"type\":\"tool_use\",\"timestamp\":3,\"sessionID\":\"ses_abc\",\"part\":{\"type\":\"tool\",\"tool\":\"read\"}}\n", + "{\"type\":\"text\",\"timestamp\":4,\"sessionID\":\"ses_abc\",\"part\":{\"type\":\"text\",\"text\":\"How can I help?\"}}\n", + "{\"type\":\"step_finish\",\"timestamp\":5,\"sessionID\":\"ses_abc\",\"part\":{\"type\":\"step-finish\"}}\n", + ); + let p = parse_events(stdout); + assert_eq!(p.session_id.as_deref(), Some("ses_abc")); + assert_eq!(p.text, "Hello! How can I help?"); + assert!(p.error.is_none()); + } + + #[test] + fn skips_garbage_lines() { + let stdout = "not json\n\n{\"type\":\"text\",\"sessionID\":\"s\",\"part\":{\"type\":\"text\",\"text\":\"hi\"}}\n"; + let p = parse_events(stdout); + assert_eq!(p.text, "hi"); + assert_eq!(p.session_id.as_deref(), Some("s")); + } + + #[test] + fn surfaces_errors() { + let stdout = "{\"type\":\"error\",\"sessionID\":\"ses_x\",\"error\":{\"name\":\"UnknownError\",\"data\":{\"message\":\"boom\"}}}\n"; + let p = parse_events(stdout); + assert_eq!(p.error.as_deref(), Some("boom")); + } + + #[test] + fn resolve_bin_rejects_missing_configured_path() { + assert!(resolve_bin("C:\\definitely\\not\\here\\opencode.exe").is_none()); + } +} From 5087654765bbe6e359013d22fe5a267e83282e90 Mon Sep 17 00:00:00 2001 From: Igor MSI Date: Wed, 30 Sep 2026 19:10:47 -0300 Subject: [PATCH 03/21] Windows: route agent events to an opencode pill --- windows/src/core/state.ts | 22 ++++-- windows/src/island/hooks.ts | 112 +++++++++++++++++++---------- windows/src/island/integrations.ts | 10 +++ windows/src/island/island.ts | 4 +- windows/src/views/integrations.ts | 12 ++-- windows/src/views/views.ts | 31 +++++--- 6 files changed, 134 insertions(+), 57 deletions(-) diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index e4988b8e7..65c4d6a67 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -3,7 +3,7 @@ import type { BotEmoteName, BotStateName, IslandMode, IslandViewName } from "./layout"; import type { EyeShape } from "../mochi/engine"; -export type AgentSource = "claudeCode" | "n8n"; +export type AgentSource = "claudeCode" | "opencode" | "n8n"; export type PillBadge = "approval" | "finished" | "error"; export interface AgentTask { @@ -26,6 +26,8 @@ export interface ApprovalInfo { sessionId: string; tool: string; command: string; + /** Which pill the request belongs to — the decision card returns here. */ + agentId: string; } export interface ChatMessage { @@ -59,6 +61,7 @@ const task = ( /** AgentTask.integrationAgents — same ids, names and colours as macOS. */ export const INTEGRATION_AGENTS: AgentTask[] = [ task("integration_claude", "VS Code", "#F5F6F8", "claudeCode"), + task("integration_opencode", "opencode", "#FF6B5B", "opencode"), task("integration_resend", "Resend", "#22C55E", "n8n"), task("integration_n8n", "n8n", "#F29B38", "n8n"), task("integration_vercel", "Vercel", "#7C5CFF", "n8n"), @@ -92,6 +95,12 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + /** Chat backend: "claude" (Anthropic API) or "opencode" (local CLI). */ + chatProvider: string; + /** Explicit opencode.exe path; empty = auto-detect. */ + opencodeBin: string; + /** provider/model override for opencode chat; empty = its default. */ + opencodeModel: string; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +115,9 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + chatProvider: "claude", + opencodeBin: "", + opencodeModel: "", }; type Listener = () => void; @@ -199,11 +211,13 @@ class AppState { this.notify(); } - /** loadIntegrationTasks() — VS Code always on, the rest opt-in (max 4). */ + /** loadIntegrationTasks() — coding agents always on, the rest opt-in (max 4). */ loadIntegrationTasks() { for (const proto of INTEGRATION_AGENTS) { const shouldLoad = - proto.id === "integration_claude" || this.settings.activeIntegrations.includes(proto.id); + proto.id === "integration_claude" || + proto.id === "integration_opencode" || + this.settings.activeIntegrations.includes(proto.id); const idx = this.tasks.findIndex((t) => t.id === proto.id); if (shouldLoad && idx < 0) this.tasks.push({ ...proto, steps: [] }); if (!shouldLoad && idx >= 0) this.tasks.splice(idx, 1); @@ -216,7 +230,7 @@ class AppState { } toggleIntegration(id: string) { - if (id === "integration_claude") return; + if (id === "integration_claude" || id === "integration_opencode") return; const active = this.settings.activeIntegrations; if (active.includes(id)) { this.settings.activeIntegrations = active.filter((x) => x !== id); diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index 547b1679e..b8d1dbd51 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -1,7 +1,12 @@ -// Claude Code hook events → island state. +// Coding-agent hook events → island state. // Port of HookServer.processEvent / processPermissionRequest from the macOS app. // Difference from macOS: no terminal filter. On Windows the hook fires from any // terminal (Windows Terminal, VS Code, PowerShell…) and all of them are handled. +// +// Two agents share this pipe: Claude Code (`agent` absent or "claude") and +// opencode (`agent: "opencode"`, via windows/opencode-plugin/coucou.js). +// Every event routes to its agent's pill; view switches only happen when that +// pill holds the focus. import { Bridge, onEvent } from "../core/bridge"; import { Sound } from "../core/sound"; @@ -9,12 +14,15 @@ import { State } from "../core/state"; import type { Island } from "./island"; const CLAUDE_ID = "integration_claude"; +const OPENCODE_ID = "integration_opencode"; /** Clears the approval card if no decision was made before the hook gave up. */ let pendingTimeout: number | null = null; interface HookPayload { hook_event_name?: string; + /** "opencode" for the opencode plugin; absent/"claude" for Claude Code. */ + agent?: string; request_id?: string; session_id?: string; cwd?: string; @@ -35,6 +43,16 @@ function aliasProjectName(name: string): string { return PROJECT_ALIASES[name.toLowerCase()] ?? name; } +/** Which pill this payload belongs to. Unknown agents fall back to Claude. */ +function agentIdOf(payload: HookPayload): string { + return payload.agent === "opencode" ? OPENCODE_ID : CLAUDE_ID; +} + +/** Display name when a session has no usable cwd. */ +function defaultName(id: string): string { + return id === OPENCODE_ID ? "opencode" : "VS Code"; +} + function lastPathComponent(p: string): string { const cleaned = p.replace(/[\\/]+$/, ""); const idx = Math.max(cleaned.lastIndexOf("\\"), cleaned.lastIndexOf("/")); @@ -57,6 +75,18 @@ const TOOL_LABELS: Record = { MultiEdit: "Modifie", NotebookEdit: "Notebook", PowerShell: "Exécute", + // opencode tool names (lowercase) → same French labels. + bash: "Exécute", + read: "Lit", + write: "Écrit", + edit: "Modifie", + glob: "Cherche", + grep: "Recherche", + webfetch: "Récupère", + websearch: "Recherche web", + todowrite: "Tâches", + task: "Agent", + question: "Question", }; function stepLabel(tool: string, input: Record): string { @@ -66,7 +96,7 @@ function stepLabel(tool: string, input: Record): string { if (cmd) return `${label} · ${cmd.slice(0, 40)}`; const path = str("path"); if (path) return `${label} · ${lastPathComponent(path)}`; - const file = str("file_path"); + const file = str("file_path") ?? str("filePath"); if (file) return `${label} · ${lastPathComponent(file)}`; const query = str("query"); if (query) return `${label} · ${query.slice(0, 40)}`; @@ -84,6 +114,7 @@ function stepLabel(tool: string, input: Record): string { const APPROVAL_FIELDS = [ "command", // Bash, PowerShell "file_path", // Write, Edit, MultiEdit, NotebookEdit + "filePath", // opencode camelCase variant "path", // Read, LS "url", // WebFetch "query", // WebSearch @@ -101,19 +132,19 @@ function approvalTarget(tool: string, input: Record): string { return tool; } -function upsert(projectName: string, cwd: string) { - const t = State.tasks.find((x) => x.id === CLAUDE_ID); +function upsert(id: string, projectName: string, cwd: string) { + const t = State.tasks.find((x) => x.id === id); if (!t) return; t.name = projectName; if (cwd) t.sessionCwd = cwd; } -function clearSession() { - const t = State.tasks.find((x) => x.id === CLAUDE_ID); +function clearSession(id: string) { + const t = State.tasks.find((x) => x.id === id); if (!t) return; t.steps = []; t.stepIndex = 0; - t.name = "VS Code"; + t.name = defaultName(id); t.pillBadge = null; } @@ -123,18 +154,19 @@ export function registerHookHandlers(island: Island) { function handleHook(island: Island, payload: HookPayload) { if (State.paused) { - // Silence here used to cost Claude Code nearly two minutes: the relay waited + // Silence here used to cost the agent nearly two minutes: the relay waited // for a decision from an island that had already decided not to look. Say so, // and the terminal takes the question immediately. if (payload.request_id) void Bridge.approvalDecline(payload.request_id); return; } + const id = agentIdOf(payload); const name = payload.hook_event_name ?? ""; const cwd = payload.cwd ?? ""; const raw = lastPathComponent(cwd); - const projectName = aliasProjectName(raw || "Session"); - const focused = State.focusId === CLAUDE_ID; + const projectName = aliasProjectName(raw || defaultName(id)); + const focused = State.focusId === id; /** Alerts force the island open; work events only reveal the compact island. */ const surface = (view: Parameters[0], isAlert: boolean) => { @@ -149,82 +181,82 @@ function handleHook(island: Island, payload: HookPayload) { switch (name) { case "SessionStart": - upsert(projectName, cwd); + upsert(id, projectName, cwd); surface("overview", false); Sound.play("work"); break; case "UserPromptSubmit": { - upsert(projectName, cwd); - State.updateTask(CLAUDE_ID, "thinking"); + upsert(id, projectName, cwd); + State.updateTask(id, "thinking"); // The field is `prompt`; reading `message` meant this step was always blank. const asked = payload.prompt ?? payload.message; - if (asked) State.appendStep(CLAUDE_ID, asked.slice(0, 60)); + if (asked) State.appendStep(id, asked.slice(0, 60)); surface("overview", false); break; } case "PreToolUse": { - upsert(projectName, cwd); - State.updateTask(CLAUDE_ID, "working"); + upsert(id, projectName, cwd); + State.updateTask(id, "working"); const tool = payload.tool_name ?? "Tool"; - State.appendStep(CLAUDE_ID, stepLabel(tool, payload.tool_input ?? {})); + State.appendStep(id, stepLabel(tool, payload.tool_input ?? {})); surface("overview", false); break; } case "PostToolUse": - State.updateTask(CLAUDE_ID, "working"); + State.updateTask(id, "working"); break; case "PostToolUseFailure": - State.updateTask(CLAUDE_ID, "working"); - State.appendStep(CLAUDE_ID, "⚠ failed"); + State.updateTask(id, "working"); + State.appendStep(id, "⚠ failed"); break; case "Notification": { const message = payload.message ?? ""; const lower = message.toLowerCase(); if (lower.includes("rate limit") || lower.includes("limite d")) { - State.updateTask(CLAUDE_ID, "ratelimit"); + State.updateTask(id, "ratelimit"); Sound.play("rate"); } else if (message.endsWith("?")) { - State.updateTask(CLAUDE_ID, "question"); - State.appendStep(CLAUDE_ID, message); + State.updateTask(id, "question"); + State.appendStep(id, message); } break; } case "Stop": - State.updateTask(CLAUDE_ID, "finished"); - if (payload.message) State.appendStep(CLAUDE_ID, payload.message.slice(0, 60)); + State.updateTask(id, "finished"); + if (payload.message) State.appendStep(id, payload.message.slice(0, 60)); Sound.play("finish"); if (focused) surface("finished", true); - else State.setPillBadge(CLAUDE_ID, "finished"); + else State.setPillBadge(id, "finished"); window.setTimeout(() => { - State.updateTask(CLAUDE_ID, "idle"); - State.setPillBadge(CLAUDE_ID, null); + State.updateTask(id, "idle"); + State.setPillBadge(id, null); }, 5200); break; case "StopFailure": - State.updateTask(CLAUDE_ID, "error"); + State.updateTask(id, "error"); Sound.play("error"); if (focused) surface("error", true); - else State.setPillBadge(CLAUDE_ID, "error"); + else State.setPillBadge(id, "error"); break; case "SessionEnd": - State.updateTask(CLAUDE_ID, "idle"); - clearSession(); + State.updateTask(id, "idle"); + clearSession(id); break; case "SubagentStart": - State.appendStep(CLAUDE_ID, "+ subagent"); + State.appendStep(id, "+ subagent"); break; case "SubagentStop": - State.appendStep(CLAUDE_ID, "• subagent done"); + State.appendStep(id, "• subagent done"); break; case "PermissionRequest": { @@ -236,7 +268,7 @@ function handleHook(island: Island, payload: HookPayload) { if (requestId) void Bridge.approvalDecline(requestId); break; } - upsert(projectName, cwd); + upsert(id, projectName, cwd); if (pendingTimeout != null) window.clearTimeout(pendingTimeout); const tool = payload.tool_name ?? "Tool"; const input = payload.tool_input ?? {}; @@ -245,11 +277,12 @@ function handleHook(island: Island, payload: HookPayload) { sessionId: payload.session_id ?? "", tool, command: approvalTarget(tool, input), + agentId: id, }; // The relay's short ack window closes in 800 ms; everything below this // line is synchronous, so the card really is up by the time it lands. if (requestId) void Bridge.approvalAck(requestId); - State.updateTask(CLAUDE_ID, "approval"); + State.updateTask(id, "approval"); State.isPinned = true; Sound.play("approval"); if (focused) { @@ -258,7 +291,7 @@ function handleHook(island: Island, payload: HookPayload) { // Another agent holds the view, so the card would yank it away. The badge // is the signal instead — but it has to be on screen for that to mean // anything, hence the reveal. We just told the relay a human can act. - State.setPillBadge(CLAUDE_ID, "approval"); + State.setPillBadge(id, "approval"); island.reveal(); } // Coucou answers within 108 s or not at all; after that the terminal has @@ -266,11 +299,12 @@ function handleHook(island: Island, payload: HookPayload) { pendingTimeout = window.setTimeout(() => { pendingTimeout = null; if (!State.pendingApproval) return; + const stale = State.pendingApproval.agentId; State.pendingApproval = null; State.isPinned = false; island.dropPin(); - State.updateTask(CLAUDE_ID, "working"); - State.setPillBadge(CLAUDE_ID, null); + State.updateTask(stale, "working"); + State.setPillBadge(stale, null); if (State.view === "approval") island.setView(State.defaultView()); State.notify(); }, 110_000); diff --git a/windows/src/island/integrations.ts b/windows/src/island/integrations.ts index 862ffb14b..2a049a121 100644 --- a/windows/src/island/integrations.ts +++ b/windows/src/island/integrations.ts @@ -37,6 +37,16 @@ export async function refreshConfigured() { data: {}, error: null, loaded: false, configured: false, }; State.integrations.integration_claude = { ...claude, configured: hooks }; + // opencode's configured flag comes from its own installer status, not settings. + try { + const oc = await Bridge.opencodeStatus(); + const opencode = State.integrations.integration_opencode ?? { + data: {}, error: null, loaded: false, configured: false, + }; + State.integrations.integration_opencode = { ...opencode, configured: oc?.installed ?? false }; + } catch { + /* settings window owns the error surface; the idle card says it */ + } State.notify(); } diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 4a6cc6d14..3b13ba7cc 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -143,8 +143,8 @@ export class Island { State.pendingApproval = null; State.isPinned = false; this.fsm.pinned = false; - State.updateTask("integration_claude", "working"); - State.setPillBadge("integration_claude", null); + State.updateTask(req.agentId, "working"); + State.setPillBadge(req.agentId, null); this.setView(State.defaultView()); }, toggleSound: () => { diff --git a/windows/src/views/integrations.ts b/windows/src/views/integrations.ts index b8ad73c8f..c16b9453b 100644 --- a/windows/src/views/integrations.ts +++ b/windows/src/views/integrations.ts @@ -58,8 +58,13 @@ function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { const configured = info?.configured ?? false; const error = info?.error ?? null; // The Claude Code pill is about hooks, not a key — the macOS wording would be - // misleading here. - const missing = task.id === "integration_claude" ? "Hooks not installed" : "Key not configured"; + // misleading here. Same for the opencode pill (plugin, not a key). + const missing = + task.id === "integration_claude" + ? "Hooks not installed" + : task.id === "integration_opencode" + ? "Plugin not installed" + : "Key not configured"; const label = error ?? (configured ? "Connected · loading…" : missing); const statusColor = error || !configured ? "#F4505E" : "#22C55E"; @@ -110,8 +115,7 @@ function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { return h( "div", { class: "int-card" }, - header(task.color, task.id === "integration_claude" ? "VS Code" : task.name, "Integration"), - h("div", { class: "int-status" }, dot(statusColor, 5), h("span", { text: label })), + header(task.color, task.id === "integration_claude" ? "VS Code" : task.name, "Integration"), h("div", { class: "int-status" }, dot(statusColor, 5), h("span", { text: label })), actions, ); } diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f..7ea238b26 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -59,9 +59,23 @@ function btn( ); } +/** Human label for an agent source. */ +function sourceLabel(source: AgentTask["source"]): string { + switch (source) { + case "claudeCode": return "Claude Code"; + case "opencode": return "opencode"; + default: return "n8n"; + } +} + +/** "Claude Code" / "opencode" for the focused agent pill. */ +function agentName(task: AgentTask | null): string { + if (!task) return "Claude Code"; + return sourceLabel(task.source); +} + /** AgentWho — coloured dot + task name + grey label. */ -function agentWho(task: AgentTask | null, label: string): HTMLElement { - const row = h("div", { class: "who-row" }); +function agentWho(task: AgentTask | null, label: string): HTMLElement { const row = h("div", { class: "who-row" }); if (task) { row.append(dot(task.color, 8), h("span", { class: "n", text: task.name })); } @@ -172,10 +186,11 @@ function buildOverview(actions: ViewActions): ViewHost { mode = null; } - // VS Code with a live Claude Code session keeps the ticker; every other + // A coding-agent pill with a live session keeps the ticker; every other // pill shows its own card, exactly like IntegrationCardView. const sessionActive = - task?.id === "integration_claude" && (task.state !== "idle" || task.steps.length > 0); + (task?.id === "integration_claude" || task?.id === "integration_opencode") && + (task.state !== "idle" || task.steps.length > 0); if (task && sessionActive) { if (mode !== "ticker") { @@ -188,7 +203,7 @@ function buildOverview(actions: ViewActions): ViewHost { who.append( dot(task.color, 7), h("span", { class: "name", text: task.name }), - h("span", { class: "tool", text: task.source === "claudeCode" ? "Claude Code" : "n8n" }), + h("span", { class: "tool", text: sourceLabel(task.source) }), ); if (task.steps.length > 1) { who.append(h("span", { @@ -328,7 +343,7 @@ function buildQuestion(): ViewHost { el, sync() { clear(who); - who.append(agentWho(State.focusTask, "Claude Code is asking a question")); + who.append(agentWho(State.focusTask, `${agentName(State.focusTask)} is asking a question`)); const task = State.focusTask; title.textContent = task?.steps.at(-1) ?? "Claude needs an answer."; clear(row); @@ -353,7 +368,7 @@ function buildError(actions: ViewActions): ViewHost { sync() { const task = State.focusTask; clear(who); - who.append(agentWho(task, task?.source === "n8n" ? "n8n" : "Claude Code")); + who.append(agentWho(task, sourceLabel(task?.source ?? "n8n"))); title.textContent = task?.source === "n8n" ? "Workflow stopped." : "Session stopped on an error."; detail.textContent = task?.steps.at(-1) ?? "No detail available."; }, @@ -374,7 +389,7 @@ function buildFinished(actions: ViewActions): ViewHost { el, sync() { clear(who); - who.append(agentWho(State.focusTask, "Claude Code finished")); + who.append(agentWho(State.focusTask, `${agentName(State.focusTask)} finished`)); title.textContent = State.focusTask?.steps.at(-1) ?? "Session finished"; }, }; From 2c5e511d7b95e1f096a1d24a35fbe45926f8d3fb Mon Sep 17 00:00:00 2001 From: Igor MSI Date: Wed, 30 Sep 2026 19:11:44 -0300 Subject: [PATCH 04/21] Windows: opencode CLI as a chat backend --- windows/src-tauri/src/lib.rs | 64 ++++++++++++++++++++++++++++--- windows/src-tauri/src/settings.rs | 16 ++++++++ windows/src/core/bridge.ts | 36 +++++++++++++++++ 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 5d19db6c1..af156d84b 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -6,6 +6,8 @@ mod hooks; mod integrations; mod island; mod log; +mod opencode; +mod opencode_chat; mod pipe; mod secrets; mod settings; @@ -25,6 +27,7 @@ use claude::{Chat, ChatContext, ChatReply}; use files::DroppedFile; use hooks::{HookPreview, HookStatus}; use island::{PollGate, ScreenInfo}; +use opencode::{OpencodePreview, OpencodeStatus}; use pipe::Pending; use settings::Settings; @@ -158,7 +161,7 @@ fn open_in_vscode(path: Option) -> bool { /// Our own `where`: walks %PATH% against %PATHEXT%, no shell involved. /// Rust quotes arguments correctly for `.cmd`/`.bat` targets since 1.77, so /// spawning `code.cmd` directly is safe. -fn find_on_path(stem: &str) -> Option { +pub(crate) fn find_on_path(stem: &str) -> Option { let exts = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into()); let dirs = std::env::var_os("PATH")?; for dir in std::env::split_paths(&dirs) { @@ -218,11 +221,29 @@ fn hooks_apply( Ok(backup) } +// ── opencode plugin ───────────────────────────────────────────────────────── + +#[tauri::command] +fn opencode_status() -> OpencodeStatus { + opencode::status() +} + +/// Returns the diff the user has to look at before anything is written. +#[tauri::command] +fn opencode_preview(install: bool) -> Result { + opencode::preview(install) +} + +/// Only ever called from an explicit click in the settings window. +#[tauri::command] +fn opencode_apply(install: bool, fingerprint: String) -> Result { + opencode::write(install, &fingerprint) +} + #[tauri::command] fn approval_decision(app: AppHandle, request_id: String, decision: String) { pipe::answer(&app, &request_id, &decision); } - /// The island has the card on screen, so the long wait for a human may begin. /// Until this arrives the relay only waits a few hundred milliseconds, which is /// what stops a paused or unresponsive island from freezing Claude Code. @@ -241,20 +262,47 @@ fn approval_decline(app: AppHandle, request_id: String) { // ── Chat, files and secrets ─────────────────────────────────────────────────── /// One chat turn. The API key and any file bytes stay on the Rust side. +/// Routes to the user's opencode when Settings → Chat says so. #[tauri::command] async fn chat_send( shared: State<'_, Shared>, chat: State<'_, Chat>, + ochat: State<'_, opencode_chat::OpencodeChat>, query: String, context: Option, ) -> Result { - let model = shared.settings.lock().unwrap().model.clone(); - claude::send(&chat, &model, query, context).await + let (provider, model, bin, omodel) = { + let s = shared.settings.lock().unwrap(); + ( + s.chat_provider.clone(), + s.model.clone(), + s.opencode_bin.clone(), + s.opencode_model.clone(), + ) + }; + if provider == "opencode" { + opencode_chat::send(&ochat, &bin, &omodel, query, context).await + } else { + claude::send(&chat, &model, query, context).await + } } #[tauri::command] -fn chat_reset(chat: State) { +fn chat_reset(chat: State, ochat: State) { chat.reset(); + ochat.reset(); +} + +/// What the Settings → Chat section shows: resolved binary, key presence. +#[tauri::command] +fn chat_status(shared: State) -> opencode_chat::ChatStatus { + let s = shared.settings.lock().unwrap(); + opencode_chat::ChatStatus { + bin_configured: s.opencode_bin.clone(), + bin_resolved: opencode_chat::resolve_bin(&s.opencode_bin) + .map(|p| p.to_string_lossy().to_string()), + claude_key_present: secrets::present("anthropic-api-key"), + } } /// Copies a dropped file into the inbox and reports its name back. @@ -380,6 +428,7 @@ pub fn run() { }) .manage(Pending::default()) .manage(Chat::default()) + .manage(opencode_chat::OpencodeChat::default()) .invoke_handler(tauri::generate_handler![ boot, save_settings, @@ -393,12 +442,16 @@ pub fn run() { hooks_status, hooks_preview, hooks_apply, + opencode_status, + opencode_preview, + opencode_apply, approval_decision, approval_ack, approval_decline, log_line, chat_send, chat_reset, + chat_status, ingest_file, secret_present, secret_set, @@ -425,6 +478,7 @@ pub fn run() { log::line(format!("--- Coucou {} started ---", env!("CARGO_PKG_VERSION"))); hooks::ensure_hook_exe(&handle); + opencode::ensure_plugin(); pipe::start(handle.clone()); integrations::start(handle.clone()); Ok(()) diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 27071798b..e45397063 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -20,6 +20,19 @@ pub struct Settings { /// Defaulted explicitly so a settings.json written by an older build still loads. #[serde(default = "default_model")] pub model: String, + /// Chat backend: "claude" (Anthropic API) or "opencode" (local CLI). + #[serde(default = "default_chat_provider")] + pub chat_provider: String, + /// Optional explicit path to opencode.exe; empty = auto-detect. + #[serde(default)] + pub opencode_bin: String, + /// Optional `provider/model` override for opencode chat; empty = its default. + #[serde(default)] + pub opencode_model: String, +} + +fn default_chat_provider() -> String { + "claude".into() } fn default_model() -> String { @@ -43,6 +56,9 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + chat_provider: default_chat_provider(), + opencode_bin: String::new(), + opencode_model: String::new(), } } } diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 2967337e7..894092786 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -71,6 +71,17 @@ export const Bridge = { hooksApply: (install: boolean, fingerprint: string) => callOrThrow("hooks_apply", { install, fingerprint }), + // ── opencode plugin ───────────────────────────────────────────────────── + opencodeStatus: () => call("opencode_status"), + /** Diff to show before anything is written. `install: false` previews removal. */ + opencodePreview: (install: boolean) => callOrThrow("opencode_preview", { install }), + /** + * Writes ~/.config/opencode/plugins/coucou.js — only ever after an explicit + * click, and only when the file still matches the preview the user looked at. + */ + opencodeApply: (install: boolean, fingerprint: string) => + callOrThrow("opencode_apply", { install, fingerprint }), + approvalDecision: (requestId: string, decision: "allow" | "deny") => call("approval_decision", { requestId, decision }), /** "The card is up" — until this lands the relay only waits a moment. */ @@ -83,6 +94,8 @@ export const Bridge = { chatSend: (query: string, context: ChatContext | null) => callOrThrow<{ text: string }>("chat_send", { query, context }), chatReset: () => call("chat_reset"), + /** Resolved opencode binary + key presence for the Settings → Chat section. */ + chatStatus: () => call("chat_status"), /** Copies a dropped file into the inbox. */ ingestFile: (path: string) => callOrThrow("ingest_file", { path }), /** Only ever tells you whether a key exists — never its value. */ @@ -116,6 +129,12 @@ export interface DroppedFile { size: number; } +export interface ChatStatus { + binConfigured: string; + binResolved: string | null; + claudeKeyPresent: boolean; +} + export interface HookStatus { installed: boolean; settingsPath: string; @@ -131,6 +150,23 @@ export interface HookPreview { fingerprint: string; } +export interface OpencodeStatus { + installed: boolean; + pluginPath: string; + relayReady: boolean; + bundledVersion: number; + installedVersion: number | null; + needsUpdate: boolean; +} + +export interface OpencodePreview { + diff: string; + backup: string; + pluginPath: string; + /** Hand back to opencodeApply so only the reviewed diff is ever written. */ + fingerprint: string; +} + /** Same as `call`, but surfaces the error so the UI can show what went wrong. */ async function callOrThrow(cmd: string, args?: Record): Promise { if (!IS_TAURI) throw new Error("not running inside Coucou"); From 3e9f8d35d7f0f25cef242960f4c3f73a6ed93970 Mon Sep 17 00:00:00 2001 From: Igor MSI Date: Wed, 30 Sep 2026 19:11:59 -0300 Subject: [PATCH 05/21] Windows: Settings sections for opencode plugin and chat --- windows/src/settings/main.ts | 236 ++++++++++++++++++++++++++++++++++- 1 file changed, 235 insertions(+), 1 deletion(-) diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..2aa04f6ef 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -3,7 +3,7 @@ // integrations land here too in a later stage. import "./settings.css"; -import { Bridge, onEvent, type HookStatus } from "../core/bridge"; +import { Bridge, onEvent, type HookStatus, type OpencodeStatus } from "../core/bridge"; import { DEFAULT_SETTINGS, type Settings } from "../core/state"; import { h, clear } from "../views/dom"; @@ -171,6 +171,234 @@ function claudeSection(status: HookStatus): HTMLElement { return section; } +// ── opencode section ──────────────────────────────────────────────────────── + +function opencodeSection(status: OpencodeStatus): HTMLElement { + const body = h("div", { style: "display:flex;flex-direction:column;gap:12px" }); + const section = h( + "section", + {}, + h("h2", {}, statusDot(status.installed), h("span", { text: "opencode" })), + body, + ); + + const rebuild = async () => { + const fresh = await Bridge.opencodeStatus(); + if (fresh) Object.assign(status, fresh); + clear(body); + draw(); + const head = section.querySelector("h2")!; + clear(head); + head.append(statusDot(status.installed), h("span", { text: "opencode" })); + }; + + function draw() { + const hint = status.installed + ? status.needsUpdate + ? `Plugin v${status.installedVersion} installed, v${status.bundledVersion} bundled — update to pick up the latest events.` + : "Coucou is plugged into your opencode sessions. Tool calls, questions and permission requests show up in the island, and you can answer them there." + : "Install the plugin to see your opencode sessions in the island and approve permissions without leaving what you are doing."; + body.append( + h("div", { class: "hint", text: hint }), + h("div", { class: "row" }, + h("label", { text: "Plugin" }), + h("span", { class: "path", text: status.pluginPath }), + ), + h("div", { class: "row" }, + h("label", { text: "Relay" }), + h("span", { class: "path", text: status.relayReady ? "coucou-hook.exe ready" : "coucou-hook.exe missing" }), + statusDot(status.relayReady), + ), + ); + + if (!status.relayReady) { + body.append(h("div", { + class: "notice warn", + text: "coucou-hook.exe is not in place yet. Restart Coucou; if it still fails, build it with `cargo build -p coucou-hook`.", + })); + } + + const actions = h("div", { class: "row" }); + const install = h("button", { + class: "primary", + text: status.installed ? "Reinstall plugin…" : "Install plugin…", + onclick: () => showPreview(true), + }); + if (!status.relayReady) { + install.disabled = true; + install.title = "The relay isn't installed yet."; + } + actions.append(install); + if (status.installed) { + actions.append(h("button", { + class: "danger", + text: "Uninstall plugin…", + onclick: () => showPreview(false), + })); + } + body.append(actions); + } + + async function showPreview(install: boolean) { + let preview; + try { + preview = await Bridge.opencodePreview(install); + } catch (err) { + clear(body); + body.append( + h("div", { class: "notice err", text: String(err).replace(/^Error:\s*/, "") }), + h("div", { class: "row" }, h("button", { + text: "Back", + onclick: () => { clear(body); draw(); }, + })), + ); + return; + } + if (!preview) return; + clear(body); + body.append( + h("div", { + class: "hint", + text: install + ? "This copies Coucou's plugin into opencode's global plugin directory. Project plugins are left untouched." + : "This removes Coucou's plugin file only. Your own plugins are left untouched.", + }), + renderDiff(preview.diff), + preview.backup + ? h("div", { class: "row" }, h("span", { class: "path", text: `Backup → ${preview.backup}` })) + : h("div", {}), + ); + const confirm = h("button", { + class: install ? "primary" : "danger", + text: install ? "Back up and write" : "Back up and remove", + }); + confirm.addEventListener("click", async () => { + confirm.disabled = true; + try { + const backup = await Bridge.opencodeApply(install, preview.fingerprint); + clear(body); + body.append(h("div", { + class: "notice ok", + text: backup + ? `Done. Previous plugin saved as ${backup}. Restart opencode to pick it up.` + : "Done. Restart opencode to pick it up.", + })); + window.setTimeout(() => void rebuild(), 2600); + } catch (err) { + confirm.disabled = false; + body.append(h("div", { class: "notice err", text: `Could not write: ${String(err)}` })); + } + }); + body.append(h("div", { class: "row" }, confirm, h("button", { + text: "Cancel", + onclick: () => { clear(body); draw(); }, + }))); + } + + draw(); + return section; +} + +// ── Chat provider section ─────────────────────────────────────────────────── + +function chatSection(): HTMLElement { + const body = h("div", { style: "display:flex;flex-direction:column;gap:12px" }); + const section = h( + "section", + {}, + h("h2", {}, h("span", { text: "Chat" })), + body, + ); + + function draw(status: Awaited> | null) { + const provider = settings.chatProvider === "opencode" ? "opencode" : "claude"; + + const claudeBtn = h("button", { + class: provider === "claude" ? "primary" : "", + text: "Claude API", + }); + const opencodeBtn = h("button", { + class: provider === "opencode" ? "primary" : "", + text: "opencode CLI", + }); + claudeBtn.addEventListener("click", () => { + settings.chatProvider = "claude"; + void save().then(() => redraw()); + }); + opencodeBtn.addEventListener("click", () => { + settings.chatProvider = "opencode"; + void save().then(() => redraw()); + }); + + body.append( + h("div", { + class: "hint", + text: "Who answers from the notch: the Anthropic API (needs a key below) or your own opencode with its configured model.", + }), + h("div", { class: "row" }, claudeBtn, opencodeBtn), + ); + + if (provider === "opencode") { + const resolved = status?.binResolved ?? null; + body.append(h("div", { + class: resolved ? "notice ok" : "notice warn", + text: resolved + ? `Found: ${resolved}` + : "opencode not found on PATH. Install it (opencode.ai) or paste its path below.", + })); + + const bin = h("input", { + type: "text", + placeholder: "opencode.exe path (optional — auto-detected)", + value: settings.opencodeBin, + style: "flex:1 1 auto;min-width:0", + autocomplete: "off", + spellcheck: "false", + }) as HTMLInputElement; + const binSave = h("button", { text: "Save" }); + binSave.addEventListener("click", () => { + settings.opencodeBin = bin.value.trim(); + void save().then(() => redraw()); + }); + + const model = h("input", { + type: "text", + placeholder: "provider/model override (optional)", + value: settings.opencodeModel, + style: "flex:1 1 auto;min-width:0", + autocomplete: "off", + spellcheck: "false", + }) as HTMLInputElement; + model.addEventListener("change", () => { + settings.opencodeModel = model.value.trim(); + void save(); + }); + + body.append( + h("div", { class: "row" }, h("label", { text: "Binary" }), bin, binSave), + h("div", { class: "row" }, h("label", { text: "Model" }), model), + h("div", { + class: "hint", + text: "First message of each conversation says who Mochi is; follow-ups continue the same opencode session.", + }), + ); + } else if (status && !status.claudeKeyPresent) { + body.append(h("div", { + class: "notice warn", + text: "No Anthropic key saved — the chat will stay silent until you save one below, or switch to opencode CLI.", + })); + } + } + + async function redraw() { + clear(body); + draw(await Bridge.chatStatus()); + } + + void redraw(); + return section; +} + // ── Claude API section ──────────────────────────────────────────────────────── const MODELS: [string, string][] = [ @@ -428,6 +656,10 @@ async function main() { const status = (await Bridge.hooksStatus()) ?? { installed: false, settingsPath: "", hookPath: "", hookReady: false, }; + const ocStatus = (await Bridge.opencodeStatus()) ?? { + installed: false, pluginPath: "", relayReady: false, + bundledVersion: 0, installedVersion: null, needsUpdate: false, + }; const hasKey = (await Bridge.secretPresent("anthropic-api-key")) ?? false; @@ -442,6 +674,8 @@ async function main() { root.append( h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), + opencodeSection(ocStatus), + chatSection(), apiSection(hasKey), integrationsSection(present), generalSection(), From ee32033022bc9e00f41a9a5eb83d0c7464ad6e46 Mon Sep 17 00:00:00 2001 From: Igor MSI Date: Wed, 30 Sep 2026 19:12:23 -0300 Subject: [PATCH 06/21] Docs: document the opencode plugin in the Windows README --- windows/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/windows/README.md b/windows/README.md index de57792c9..11d3ef5f4 100644 --- a/windows/README.md +++ b/windows/README.md @@ -68,6 +68,22 @@ in time, Coucou stays quiet and Claude Code asks in the terminal as usual. It works from any terminal — Windows Terminal, PowerShell, VS Code, Git Bash. +## opencode + +Open **Settings… → opencode → Install plugin…**. This copies `coucou.js` (in +`opencode-plugin/` next to the source) to +`%USERPROFILE%\.config\opencode\plugins\coucou.js` — opencode auto-loads +global plugins, so no `opencode.json` edit is needed. You get a preview of +what will change, a dated backup of any previous copy, and nothing is written +until you click. Uninstall removes only Coucou's file. + +The plugin forwards session, tool and permission events through the same +`coucou-hook.exe` relay and named pipe Claude Code uses, so the island shows +opencode sessions in their own pill with the same **Deny / Allow** approval +card. The same guarantee holds: **an opencode session is never blocked by +Coucou** — if nobody answers in time, opencode asks in the TUI as usual. +Restart opencode after installing so it picks the plugin up. + ## Chat and keys **Settings… → Claude** takes your Anthropic API key. Keys live in the **Windows @@ -128,6 +144,7 @@ windows/ settings/ the settings window src-tauri/ Rust backend: window, named pipe, Claude API, pollers hook/ coucou-hook.exe, the Claude Code relay + opencode-plugin/ coucou.js, the opencode plugin (same relay, same pipe) scripts/ icon generator ``` From b70460252a65c551f8a6994e754f56cba755df91 Mon Sep 17 00:00:00 2001 From: Igor MSI Date: Wed, 30 Sep 2026 19:12:33 -0300 Subject: [PATCH 07/21] Windows: include opencode plugin (coucou.js) in Tauri config and update package version --- windows/package-lock.json | 61 +------------------------------ windows/src-tauri/tauri.conf.json | 2 +- 2 files changed, 3 insertions(+), 60 deletions(-) diff --git a/windows/package-lock.json b/windows/package-lock.json index 5e06f8bcb..9361b0636 100644 --- a/windows/package-lock.json +++ b/windows/package-lock.json @@ -1,12 +1,12 @@ { "name": "coucou-windows", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coucou-windows", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@tauri-apps/api": "^2" }, @@ -466,9 +466,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -570,9 +567,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -587,9 +581,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -604,9 +595,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -621,9 +609,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -638,9 +623,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -655,9 +637,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -672,9 +651,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -689,9 +665,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -706,9 +679,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -723,9 +693,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -740,9 +707,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -757,9 +721,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -774,9 +735,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -966,9 +924,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -986,9 +941,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1006,9 +958,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1026,9 +975,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1046,9 +992,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ diff --git a/windows/src-tauri/tauri.conf.json b/windows/src-tauri/tauri.conf.json index d292d1b19..9d72eb0c0 100644 --- a/windows/src-tauri/tauri.conf.json +++ b/windows/src-tauri/tauri.conf.json @@ -48,7 +48,7 @@ "icons/128x128@2x.png", "icons/icon.ico" ], - "resources": { "../target/release/coucou-hook.exe": "coucou-hook.exe" }, + "resources": { "../target/release/coucou-hook.exe": "coucou-hook.exe", "../opencode-plugin/coucou.js": "coucou.js" }, "copyright": "© Louis Raille", "category": "DeveloperTool", "shortDescription": "Mochi lives at the top of your screen", From 05e9fc546abf9bac3ae5883eb97ff2c9f504ebff Mon Sep 17 00:00:00 2001 From: Imanol Diaz Date: Thu, 1 Oct 2026 04:58:27 -0400 Subject: [PATCH 08/21] Windows: deliberate island wake, and always arm the fully-compact timer Builds on #31. The compact-resting bar left a 240px wake strip behind from when the island was always centred in the window; once the bar became draggable that strip became a hit area to its left, which is why an accidental wake could only ever happen on that side. Removed it and made the island its own wake target. Waking is now deliberate: a double-click within 56px of the bar, measured from its painted rect on both sides so left and right are identical by construction. A single outside click while reduced does nothing, otherwise every stray click on the desktop would toggle it. Two bugs fixed while making that behaviour explicit: the extra click to fully compact called collapse() while the island was already on the bar, which the state machine ignores as a transition to its current state and so did nothing at all; and the fully-compact timer was gated on the cursor being off the island, so a click inside the 14px margin left the bar resting forever. The configured delay now always owns that last step. --- windows/src-tauri/src/island.rs | 215 +++++++++++++++++++++++--- windows/src/core/layout.ts | 52 +++++-- windows/src/island/fsm.ts | 17 +- windows/src/island/island.ts | 264 ++++++++++++++++++++++++++++---- windows/src/main.ts | 2 + windows/src/mochi/greeting.ts | 15 +- 6 files changed, 488 insertions(+), 77 deletions(-) diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 0a4fc8788..46c8c08d1 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -20,15 +20,24 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON}; use windows::Win32::UI::WindowsAndMessaging::{EnumChildWindows, GetClassNameW}; use windows::Win32::UI::WindowsAndMessaging::{ GetCursorPos, GetWindowLongPtrW, SetWindowLongPtrW, GWL_EXSTYLE, WS_EX_NOACTIVATE, - WS_EX_TOOLWINDOW, + WS_EX_TOOLWINDOW, WindowFromPoint, GetAncestor, GA_ROOT, GA_ROOTOWNER, }; /// Logical size of the full window — the largest island view, like the macOS panel. pub const PANEL_W: f64 = 720.0; pub const PANEL_H: f64 = 320.0; -/// Logical size of the invisible strip that wakes the island when it is hidden. -pub const STRIP_W: f64 = 240.0; -pub const STRIP_H: f64 = 6.0; +/// Fully reduced resting bar (PR #22's no-notch `hidden` state): a short stub +/// with Mochi parked in the middle. This is what the window shrinks to when the +/// island is hidden, so it must equal `NO_NOTCH_W`/`NO_NOTCH_H` in +/// `src/core/layout.ts`. +pub const HIDDEN_W: f64 = 80.0; +pub const HIDDEN_H: f64 = 24.0; +/// Logical width of the resting (compact) island — what the front end paints as +/// `COMPACT_W` in `src/core/layout.ts`. Rust needs it to centre the island inside +/// the wider window, so the two must stay equal. +pub const COMPACT_W: f64 = 240.0; +/// Logical width of the open panel — `EXPANDED_W` in `src/core/layout.ts`. +pub const EXPANDED_W: f64 = 640.0; pub const WINDOW_LABEL: &str = "island"; @@ -62,6 +71,11 @@ pub struct IslandRect { pub h: f64, } +fn outside_press(rect: IslandRect, x: f64, y: f64, down: bool, was_down: bool) -> bool { + down && !was_down && rect.w > 0.0 && rect.h > 0.0 + && !(x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h) +} + /// Wakes / parks the cursor poll thread so a hidden island costs literally nothing. pub struct PollGate { active: Mutex, @@ -168,9 +182,32 @@ 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. +/// The display the island lives on. +/// +/// The monitor is decided by where the island *already is*, not by where the +/// cursor is: `pref = "cursor"` picks the display under the pointer when the +/// island has no home yet, but a resting bar dragged toward an edge takes its +/// cursor with it. Resolving from the cursor every frame would hop the island +/// onto the neighbouring monitor mid-drag and fight the drag. Once placed, the +/// island stays on that monitor, so a drag to the left or right edge stays on +/// the screen the user started on. fn target_monitor(app: &AppHandle, pref: &str) -> Option { let monitors = app.available_monitors().ok()?; + if let Some(win) = window(app) { + if let (Ok(pos), Ok(size)) = (win.outer_position(), win.outer_size()) { + // Test the island's own centre: the window is deliberately wider than + // the bar and overhangs the edge, so its left edge is not a reliable + // indicator of which display it belongs to. + let cx = pos.x as i64 + size.width as i64 / 2; + let cy = pos.y as i64 + size.height as i64 / 2; + if let Some(m) = monitors + .iter() + .find(|m| monitor_contains(m, cx as f64, cy as f64)) + { + 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)) { @@ -202,8 +239,35 @@ pub fn screen_info(app: &AppHandle, pref: &str) -> ScreenInfo { } } +/// Horizontal resting place of the painted island on the target display, in +/// physical pixels from the monitor's left edge. +/// +/// `position` is normalised 0..=1. The window is wider than the bar drawn inside +/// it, so the bar's *centre* is what lands on the chosen spot and the window is +/// centred on that point. On Windows the overhang is invisible because the +/// surface is transparent and click-through outside the island rect, so it is +/// safe to let it hang off the display edge. +/// +/// The front end offsets the island inside the window by +/// `(window_w - island_w) * position` (see `Island.islandOffsetX`), so a bar +/// resting at an edge opens inward rather than expanding off-screen. +fn island_left(mp: i32, screen_w: u32, window_w: u32, scale: f64, position: f64) -> i32 { + let _ = scale; + // Clamped so a saved fraction outside 0..1 (or a NaN from bad JSON) cannot + // push the island off-screen. + let position = if position.is_finite() { position.clamp(0.0, 1.0) } else { 0.5 }; + // The whole window slides across the display with the resting position: 0 + // puts it flush left, 1 flush right, 0.5 centred. The front end offsets the + // island inside the window by `(window_w - island_w) * position`, so the + // island's on-screen span is `position * (screen_w - island_w)` regardless of + // the window width. That keeps every island size on-screen at every position, + // and a bar at an edge opens inward instead of off-screen. + let span = (screen_w as i32 - window_w as i32).max(0); + mp + (span as f64 * position).round() as i32 +} + /// Places and sizes the window. `collapsed` picks the wake strip instead of the panel. -pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { +pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool, position: f64) { let Some(win) = window(app) else { return }; let Some(m) = target_monitor(app, pref) else { return }; @@ -211,10 +275,12 @@ pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { let mp = *m.position(); let ms = *m.size(); - let (lw, lh) = if collapsed { (STRIP_W, STRIP_H) } else { (PANEL_W, PANEL_H) }; + // Hidden shrinks to the fully reduced 80pt stub rather than the old invisible + // wake strip, so the resting island stays visible and draggable. + let (lw, lh) = if collapsed { (HIDDEN_W, HIDDEN_H) } else { (PANEL_W, PANEL_H) }; let pw = (lw * scale).round().max(1.0) as u32; let ph = (lh * scale).round().max(1.0) as u32; - let x = mp.x + (ms.width as i32 - pw as i32) / 2; +let x = island_left(mp.x, ms.width, pw, scale, position); let y = mp.y; let _ = win.set_size(PhysicalSize::new(pw, ph)); @@ -274,12 +340,12 @@ fn current_screen_key(app: &AppHandle) -> Option<(i32, i32, u32, u32, u64)> { /// visible. Parked on a condvar the rest of the time. pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { std::thread::spawn(move || { - let mut was_down = false; // Remembered across wakes so a display change while hidden is noticed the // moment the island comes back. let mut last_screen: Option<(i32, i32, u32, u32, u64)> = None; loop { gate.wait_until_active(); + let mut was_down = left_button_down(); let mut last = (f64::MIN, f64::MIN); let mut ticks: u32 = 0; while gate.is_active() { @@ -312,7 +378,38 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { Ok(s) => (s.width as f64 / scale, s.height as f64 / scale), Err(_) => (PANEL_W, PANEL_H), }; - if (x - last.0).abs() < 1.0 && (y - last.1).abs() < 1.0 { + // Sample button edges before the stationary-cursor fast path. A click + // outside must dismiss even when the pointer has stopped moving. + let down = left_button_down(); + let pressed = down && !was_down; + let r = *gate.rect.lock().unwrap(); + if outside_press(r, x, y, down, was_down) { + // Native select popups belong to our window even when their + // menu extends beyond the island's painted bounds. + let own_popup = win.hwnd().is_ok_and(|hwnd| unsafe { + let hit = WindowFromPoint(POINT { x: cx as i32, y: cy as i32 }); + GetAncestor(hit, GA_ROOT).0 != hwnd.0 as *mut _ + && GetAncestor(hit, GA_ROOTOWNER).0 == hwnd.0 as *mut _ + }); + if !own_popup { let _ = win.emit("outside-click", ()); } + } + was_down = down; + if pressed { + let handle = app.clone(); + let _ = app.run_on_main_thread(move || unblock_webview_drops(&handle)); + } + // The island slides sideways while a bar is dragged, so the cursor + // can end up outside the painted rect mid-gesture. Keep the mouse + // captured for as long as the button is held anywhere over the + // panel: a click-through window stops delivering mousemove, and the + // drag would end the moment the cursor outran the bar. + let captured = down + && x >= 0.0 + && x <= size.0 + && y >= 0.0 + && y <= size.1; + + if !captured && (x - last.0).abs() < 1.0 && (y - last.1).abs() < 1.0 { continue; } last = (x, y); @@ -320,7 +417,6 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { // Click-through: the window only takes the mouse over the island // shape. A small entry margin means the flag is already off by the // time a moving cursor reaches a button. - let r = *gate.rect.lock().unwrap(); let on_island = r.w > 0.0 && x >= r.x - HIT_MARGIN && x <= r.x + r.w + HIT_MARGIN @@ -336,20 +432,7 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { // the mouse, which also makes the drop zone as forgiving as the Mac's. // A press may be the start of a drag: make sure the drop target is // ours before the file arrives. - let down = left_button_down(); - if down && !was_down { - let handle = app.clone(); - let _ = app.run_on_main_thread(move || unblock_webview_drops(&handle)); - } - was_down = down; - - let dragging = down - && x >= 0.0 - && x <= size.0 - && y >= 0.0 - && y <= size.1; - - let accept = on_island || dragging; + let accept = on_island || captured; if gate.ignoring.load(Ordering::Relaxed) == accept { gate.ignoring.store(!accept, Ordering::Relaxed); let _ = win.set_ignore_cursor_events(!accept); @@ -366,3 +449,85 @@ pub fn set_ignore_cursor(app: &AppHandle, ignore: bool) { let _ = win.set_ignore_cursor_events(ignore); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn outside_click_is_a_press_edge_outside_the_painted_rect() { + let rect = IslandRect { x: 50.0, y: 10.0, w: 300.0, h: 150.0 }; + assert!(outside_press(rect, 400.0, 50.0, true, false)); + assert!(!outside_press(rect, 400.0, 50.0, true, true)); + assert!(!outside_press(rect, 400.0, 50.0, false, true)); + assert!(!outside_press(rect, 150.0, 50.0, true, false)); + assert!(!outside_press(rect, 350.0, 160.0, true, false)); + assert!(!outside_press(IslandRect::default(), 400.0, 50.0, true, false)); + } + + // Geometry is pure maths, so it can be checked without a display. + const SCREEN: u32 = 1920; + + /// On-screen left edge of the island, combining the Rust window placement + /// with the front end's in-window offset (`Island.islandOffsetX`). + fn island_x(window_w: u32, island_w: f64, position: f64) -> i32 { + let window_x = island_left(0, SCREEN, window_w, 1.0, position); + let offset = ((window_w as f64 - island_w) * clamp(position)).round() as i32; + window_x + offset + } + + fn clamp(p: f64) -> f64 { + if p.is_finite() { + p.clamp(0.0, 1.0) + } else { + 0.5 + } + } + + #[test] + fn presets_pin_the_bar_to_each_edge() { + for island_w in [HIDDEN_W, COMPACT_W, EXPANDED_W] { + let w = island_w as i32; + assert_eq!(island_x(PANEL_W as u32, island_w, 0.0), 0, "flush left at {island_w}"); + assert_eq!( + island_x(PANEL_W as u32, island_w, 1.0), + SCREEN as i32 - w, + "flush right at {island_w}" + ); + assert_eq!( + island_x(PANEL_W as u32, island_w, 0.5), + (SCREEN as i32 - w) / 2, + "centred at {island_w}" + ); + } + } + + #[test] + fn bar_stays_on_the_display_at_every_position() { + for window_w in [PANEL_W as u32, HIDDEN_W as u32] { + for island_w in [HIDDEN_W, COMPACT_W, EXPANDED_W] { + for step in 0..=100 { + let left = island_x(window_w, island_w, step as f64 / 100.0); + let w = island_w as i32; + assert!( + left >= 0 && left + w <= SCREEN as i32, + "position {step}, window {window_w}, island {island_w}: left {left} is off-screen" + ); + } + } + } + } + + #[test] + fn bad_saved_values_cannot_move_the_bar_off_screen() { + for bad in [f64::NAN, -3.0, 7.0] { + for island_w in [HIDDEN_W, COMPACT_W, EXPANDED_W] { + let left = island_x(PANEL_W as u32, island_w, bad); + assert!( + left >= 0 && left + island_w as i32 <= SCREEN as i32, + "value {bad} escaped the display at width {island_w}" + ); + } + } + } +} diff --git a/windows/src/core/layout.ts b/windows/src/core/layout.ts index 6f70eea4c..2efae6420 100644 --- a/windows/src/core/layout.ts +++ b/windows/src/core/layout.ts @@ -53,12 +53,21 @@ export interface ViewLayout { export const PANEL_W = 720; export const PANEL_H = 320; -// No notch on a PC: these are the hidden/compact sizes from docs/SPEC.md. -export const NOTCH_W = 184; -export const NOTCH_H = 32; -export const COMPACT_W = 288; // NOTCH_W + 104 +// A PC never has a real notch, so the resting sizes follow PR #22's no-notch +// branch: an 80 pt fake notch that Mochi peeks out of, and a 240 pt compact bar +// no taller than the menu bar. +export const FALLBACK_NOTCH_W = 184; // only used when a screen really has a notch +export const NO_NOTCH_W = 80; +export const NO_NOTCH_H = 24; +export const COMPACT_W = 240; export const EXPANDED_W = 640; +/** The resting island's sizes on a notched-less screen. Mirrors PR #22's + * `IslandScreenGeometry` so the mascot and pills shrink with the bar. */ +export function restingLayout() { + return { width: COMPACT_W, height: NO_NOTCH_H }; +} + export const ROUNDED_CORNER = 14; // hidden / compact export const EXPANDED_CORNER = 22; @@ -84,7 +93,7 @@ export const VIEW_LAYOUTS: Record = { searching: { height: 160, botX: 52, botY: null, botDiameter: 44, agentMode: "column" }, result: { height: 160, botX: 52, botY: null, botDiameter: 44, agentMode: "column" }, note: { height: 160, botX: 60, botY: null, botDiameter: 50, agentMode: "column" }, - settings: { height: 160, botX: 54, botY: null, botDiameter: 46, agentMode: "none" }, + settings: { height: 200, botX: 54, botY: null, botDiameter: 46, agentMode: "none" }, greeting: { height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: "none" }, }; @@ -104,11 +113,11 @@ export function islandSize( ): { w: number; h: number } { switch (mode) { case "hidden": - // No notch to hide inside on a PC: the island retracts to zero height and - // slides into the top edge of the screen instead of sitting there as a bar. - return { w: NOTCH_W, h: 0 }; + // No notch to hide inside on a PC: a narrow 80 pt stub that keeps Mochi + // visible and parked, instead of the 184 pt a notched screen would use. + return { w: NO_NOTCH_W, h: NO_NOTCH_H }; case "compact": - return { w: COMPACT_W, h: NOTCH_H }; + return { w: COMPACT_W, h: NO_NOTCH_H }; case "expanded": { const h = view === "prompt" ? chatPromptHeight(chatCount) : VIEW_LAYOUTS[view].height; return { w: EXPANDED_W, h }; @@ -131,10 +140,27 @@ export function botPosition( uploadProgress = 0, ): BotPlacement { switch (mode) { - case "hidden": - return { cx: 46, cy: 16, diameter: 6, opacity: 0 }; - case "compact": - return { cx: 40, cy: 16, diameter: 20, opacity: 1 }; + case "hidden": { + // Peeking out of the 80 pt stub: shrunk to fit and kept visible. + const rest = restingLayout(); + return { + cx: NO_NOTCH_W / 2, + cy: rest.height / 2, + diameter: Math.min(20, Math.max(0, NO_NOTCH_H - 6)), + opacity: 1, + }; + } + case "compact": { + // PR #22's IslandRestingLayout: centre Mochi vertically and cap the + // diameter so a short bar never crops it. + const rest = restingLayout(); + return { + cx: 40, + cy: rest.height / 2, + diameter: Math.min(20, Math.max(0, rest.height - 6)), + opacity: 1, + }; + } case "expanded": { const layout = VIEW_LAYOUTS[view]; if (view === "uploading") { diff --git a/windows/src/island/fsm.ts b/windows/src/island/fsm.ts index 8a24545ed..6bc62400b 100644 --- a/windows/src/island/fsm.ts +++ b/windows/src/island/fsm.ts @@ -8,9 +8,9 @@ export class IslandStateMachine { onTransition: ((from: FsmState, to: FsmState) => void) | null = null; - /** home → petit delay, seconds. */ + /** home → petit delay, seconds. 0 = never auto-close. */ homeToPetitDelay = 15; - /** petit → hidden delay, seconds. */ + /** petit → hidden delay, seconds. 0 = stay compact, never fully reduce. */ petitToHiddenDelay = 60; /** coucou → petit once the greeting animation ends (no hover). */ greetAutoCollapseDelay = 0.6; @@ -66,6 +66,14 @@ export class IslandStateMachine { } click() { + // A click always opens the island, from either resting state. From the + // reduced stub it skips straight to open rather than stopping at compact, so + // one press is enough — otherwise turning off "wake on hover" would need two. + if (this.state === "hidden") { + this.cancelTimers(); + this.transition("home"); + return; + } if (this.state !== "petit") return; this.cancelTimers(); this.transition("home"); @@ -106,6 +114,9 @@ export class IslandStateMachine { private schedulePetitHide() { this.clear("petitHide"); + // 0 (or anything non-positive) means "stay compact": no timer at all, so the + // island never fully reduces on its own. + if (this.petitToHiddenDelay <= 0) return; this.petitHide = window.setTimeout(() => { this.petitHide = null; if (this.state === "petit") this.transition("hidden"); @@ -115,6 +126,8 @@ export class IslandStateMachine { private scheduleHomeCollapse() { this.clear("homeCollapse"); if (this.pinned) return; + // 0 means auto-close is off: the island stays open until it is dismissed. + if (this.homeToPetitDelay <= 0) return; this.homeCollapse = window.setTimeout(() => { this.homeCollapse = null; if (this.state === "home") this.transition("petit"); diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 3b13ba7cc..b9b296eb1 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -4,7 +4,7 @@ import { Tracked, Spring, clamp } from "../core/anim"; import { Bridge, IS_TAURI, onDragDrop } from "../core/bridge"; import { - EXPANDED_CORNER, EXPANDED_W, NOTCH_W, PANEL_H, PANEL_W, + EXPANDED_CORNER, EXPANDED_W, NO_NOTCH_W, PANEL_H, PANEL_W, ROUNDED_CORNER, VIEW_LAYOUTS, botGlowColor, botGlowOpacity, botPosition, chatPromptHeight, islandSize, type IslandMode, type IslandViewName, @@ -45,13 +45,12 @@ export class Island { private greetingCanvas!: HTMLCanvasElement; private miniGrid!: HTMLElement; private countdown!: HTMLElement; - private wakeStrip!: HTMLElement; private header!: ViewHost; private views!: Map; private uploadCanvas!: UploadCanvas; - private width = new Tracked(NOTCH_W); + private width = new Tracked(NO_NOTCH_W); private height = new Tracked(0); private radius = new Tracked(ROUNDED_CORNER); private botCx = new Spring(46); @@ -162,6 +161,29 @@ export class Island { setAutoClose: (s) => { State.settings.autoCloseInterval = s; this.fsm.homeToPetitDelay = s; + // Changing the timer re-arms it, so a new value takes effect at once + // rather than after the previous (possibly long) one elapses. + if (this.fsm.state === "home") this.fsm.mouseLeft(); + void Bridge.saveSettings(State.settings); + State.notify(); + }, + setAbsence: (s) => { + State.settings.absenceInterval = s; + this.fsm.petitToHiddenDelay = s; + if (this.fsm.state === "petit") this.fsm.mouseLeft(); + void Bridge.saveSettings(State.settings); + State.notify(); + }, + togglePin: () => { + const next = !State.settings.pinIsland; + State.settings.pinIsland = next; + State.isPinned = next; + this.fsm.pinned = next; + void Bridge.saveSettings(State.settings); + State.notify(); + }, + toggleWakeOnHover: () => { + State.settings.wakeOnHover = !State.settings.wakeOnHover; void Bridge.saveSettings(State.settings); State.notify(); }, @@ -169,7 +191,6 @@ export class Island { blip: () => Sound.play("blip"), }; - this.wakeStrip = h("div", { id: "wake-strip" }); this.botGlow = h("div", { id: "bot-glow" }); this.botCanvas = h("canvas", { id: "bot-canvas" }); this.greetingCanvas = h("canvas", { id: "greeting-canvas" }); @@ -217,7 +238,7 @@ export class Island { this.greetingCanvas.style.width = `${EXPANDED_W}px`; this.greetingCanvas.style.height = "150px"; - this.root.append(this.wakeStrip, this.islandEl); + this.root.append(this.islandEl); this.applyGeometry(); } @@ -235,7 +256,11 @@ export class Island { else if (from === "hidden") Sound.play("peek"); this.setMode("compact"); if (from === "coucou") State.view = State.defaultView(); - if (!this.wasInIsland) this.fsm.mouseLeft(); + // Always arm the fully-compact timer on arriving at the bar. Gating it + // on `wasInIsland` meant it silently never armed whenever the cursor + // happened to still be over the island, leaving the bar resting + // forever. + this.fsm.mouseLeft(); break; case "home": this.expand(State.defaultView()); @@ -315,17 +340,154 @@ export class Island { } collapse() { + // Explicit dismissal clears a hook alert's pin, but never the user's own: + // `settings.pinIsland` is untouched here and keeps the island open. State.isPinned = false; - this.fsm.pinned = false; + this.fsm.pinned = State.settings.pinIsland; // Drive the state machine rather than the mode: setting the mode behind its // back left it thinking the island was still open, and a click on the compact // island then did nothing — the island could never be reopened. this.fsm.forcePetit(); } + /** Explicit outside click closes the panel, while approval cards stay actionable. */ + // ── Dragging the resting island ─────────────────────────────────────────────── + +private dragStartX = 0; + private dragStartPosition = 0; + private dragMoved = false; +/** Timestamps of recent outside clicks, for the double-click wake. */ +private outsideClicks: number[] = []; + + /** + * Grabs the resting island and follows the pointer, landing it wherever it is + * released. The position is normalised across the target display (0 = left, + * 1 = right) and handed straight to Rust, which owns the actual window move. + * + * A press that never moves more than a few pixels is left alone so an ordinary + * click on the bar still wakes the island instead of nudging it. + */ + private beginNotchDrag(e: MouseEvent) { + if (e.button !== 0) return; + const screen = State.screen; + if (!screen || screen.width <= 0) { + // Without screen metrics a drag can't be normalised, so fall back to the + // plain click that opens the island. + this.fsm.click(); + return; + } + // Dragging a resting bar is a deliberate act; don't also nudge the mascot. + e.stopPropagation(); + + // The bar is the island's own width in whichever resting mode we are in, so + // the draggable span depends on it. + const barW = islandSize(State.mode, State.view).w; + + this.dragStartX = e.screenX; + this.dragStartPosition = State.settings.notchPosition; + this.dragMoved = false; + + const onMove = (ev: MouseEvent) => { + const dx = ev.screenX - this.dragStartX; + if (!this.dragMoved && Math.abs(dx) < 4) return; + this.dragMoved = true; + const span = Math.max(1, screen.width - barW); + // screenX is in physical pixels; divide by the scale factor the server + // reported so the maths matches the logical pixels Rust positions with. + const next = this.dragStartPosition + dx / screen.scale / span; + const clamped = Math.max(0, Math.min(1, next)); + State.settings.notchPosition = clamped; + void Bridge.setNotchPosition(clamped); + }; + + const onUp = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + if (this.dragMoved) { + // Persist once more so the resting place survives a restart even if the + // last move event landed a hair short of the release point. + void Bridge.setNotchPosition(State.settings.notchPosition); + } else { + // It was a click, not a drag, so open the island. + this.fsm.click(); + } + }; + + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + e.preventDefault(); + } + + /** + * Whether the island must stay open: either the user pinned it, or a hook + * alert is waiting for an answer. Hooks clear `State.isPinned` when they + * finish, so the user's own pin has to be read from settings — otherwise an + * alert would quietly undo it. + */ + private get staysOpen(): boolean { + return State.settings.pinIsland || State.isPinned; + } + +/** + * How close to the island a click may land and still count as "near" it for + * the double-click wake. Measured from the island's rect on both sides, so it + * behaves the same to the left and to the right. + */ +private static readonly NEAR_RADIUS = 56; +/** Two clicks within this window count as a double-click. */ +private static readonly DOUBLE_MS = 900; + +/** + * A click outside the island. + * + * Reduced: nothing happens on one click; a second one near the island wakes it + * back to the bar. + * + * Showing: the first click closes the panel to the resting bar, and a further + * click takes it the rest of the way to fully compact. The fully-compact timer + * does the same thing on its own after the configured delay, so either route + * gets there — the click just skips the wait. + * + * A pinned island ignores all of it. + */ +dismissOutside() { + if (this.staysOpen) return; + const now = performance.now(); + const near = this.isNearIsland(); + + this.outsideClicks = this.outsideClicks.filter((t) => now - t < Island.DOUBLE_MS); + + // Double-click near the island while it is reduced: wake it back to the bar. + if (State.mode === "hidden" && near && this.outsideClicks.length > 0) { + this.outsideClicks = []; + this.fsm.mouseEntered(); + return; + } + this.outsideClicks.push(now); + + // Reduced with nothing showing: a lone click is inert, otherwise every stray + // click on the desktop would toggle the island. + if (State.mode === "hidden") return; + + this.outsideClicks = []; + // Already resting on the bar: this extra click skips the wait and reduces now. + // `collapse()` would be a no-op here — the state machine ignores a transition + // to the state it is already in. + if (State.mode === "compact") this.fsm.forceHidden(); + else this.collapse(); +} + +/** True when the cursor is within NEAR_RADIUS of the island's painted rect. */ +private isNearIsland(): boolean { + const rect = this.islandRect(); + const dx = Math.max(rect.x - State.mouse.x, 0, State.mouse.x - (rect.x + rect.w)); + const dy = Math.max(rect.y - State.mouse.y, 0, State.mouse.y - (rect.y + rect.h)); + return dx <= Island.NEAR_RADIUS && dy <= Island.NEAR_RADIUS; +} + /** Alert from the hook server: open on this view. Pinned alerts never auto-close. */ alert(view: IslandViewName) { - this.fsm.pinned = State.isPinned; + this.fsm.pinned = this.staysOpen; this.fsm.forceHome(); this.expand(view); } @@ -469,22 +631,50 @@ export class Island { this.ensureRunning(); } + /** + * Horizontal offset of the island inside the window, in logical px. + * + * The island must always open toward the middle of the screen: a bar resting + * on the left edge grows rightwards, one on the right edge grows leftwards, + * and a centred one opens symmetrically. `bias` is the resting position mapped + * to 0..1, which makes this continuous — crossing the middle of the screen + * slides the island rather than snapping it. At bias 0.5 it reduces exactly to + * the old centred offset. + */ + private islandOffsetX(w: number): number { + // The window is always the full panel width, whatever the island draws inside + // it. If this switched to the reduced width, the offset would jump the moment + // the island shrank and the bar would appear to lurch to the middle before + // settling — the window resize must not affect where the island sits. + const winW = PANEL_W; + const position = State.settings.notchPosition; + const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0.5; + return (winW - w) * p; + } + private applyGeometry() { const w = this.width.value; const hh = this.height.value; const r = this.radius.value; + const offsetX = this.islandOffsetX(w); this.islandEl.style.width = `${w}px`; this.islandEl.style.height = `${hh}px`; this.islandEl.style.borderRadius = `0 0 ${r}px ${r}px`; - this.islandEl.style.transform = `translateX(-50%)`; + this.islandEl.style.transform = `translateX(${offsetX}px)`; // These follow the island as it resizes, so they belong here rather than in // the state-driven DOM sync. - this.miniGrid.style.left = `${w - 40 - 14.5}px`; - this.miniGrid.style.top = `${hh / 2 - 14.5}px`; + // The pill grid is centred in the bar's right end and shrinks with it, so a + // 24px-tall bar never crops it (PR #22 scales the grid to the resting height). + const gridScale = Math.min(1, Math.max(0, hh - 4) / 28); + const grid = 29 * gridScale; + this.miniGrid.style.transform = `scale(${gridScale})`; + this.miniGrid.style.transformOrigin = "center center"; + this.miniGrid.style.left = `${w - 40 - grid / 2}px`; + this.miniGrid.style.top = `${hh / 2 - grid / 2}px`; this.greetingCanvas.style.left = `${(w - EXPANDED_W) / 2}px`; this.uploadCanvas.el.style.left = `${(w - EXPANDED_W) / 2}px`; - const rect = { x: (PANEL_W - w) / 2, y: 0, w, h: hh }; + const rect = { x: offsetX, y: 0, w, h: hh }; const p = this.pushedRect; if (Math.abs(p.x - rect.x) > 0.5 || Math.abs(p.w - rect.w) > 0.5 || Math.abs(p.h - rect.h) > 0.5) { this.pushedRect = rect; @@ -496,7 +686,7 @@ export class Island { private islandRect(): { x: number; y: number; w: number; h: number } { const w = this.width.value; const hh = this.height.value; - return { x: (PANEL_W - w) / 2, y: 0, w, h: hh }; + return { x: this.islandOffsetX(w), y: 0, w, h: hh }; } // ── Window collapse (hidden → tiny wake strip, zero polling) ──────────────── @@ -507,14 +697,11 @@ export class Island { this.collapseTimer = null; } if (State.mode === "hidden") { - // Let the island finish retracting, then drop the window to the wake strip: - // from there the OS delivers no cursor events, so nothing polls at all. - this.collapseTimer = window.setTimeout(() => { - this.collapseTimer = null; - if (State.mode !== "hidden") return; - this.collapsed = true; - void Bridge.setCollapsed(true); - }, 420); + // The window deliberately keeps its full size while the island shrinks. + // Resizing it used to resize the surface underneath the shrink animation, + // which read as a lurch toward the middle; the transparent margin is + // click-through, so nothing is lost by leaving it in place. + this.collapseTimer = null; } else if (this.collapsed) { // Grow the window back before the island animates open. this.collapsed = false; @@ -525,17 +712,18 @@ export class Island { // ── Input ─────────────────────────────────────────────────────────────────── private wireInput() { - // The wake strip is the only thing the OS can hit while the island is hidden. - this.wakeStrip.addEventListener("mouseenter", () => { - Sound.resume(); - if (State.mode === "hidden") this.fsm.mouseEntered(); - }); - + // The island element is its own wake target now that the reduced state is a + // real, visible stub. A separate wake strip used to sit centred in the window + // and went stale the moment the island could be dragged off-centre, leaving a + // hit area to the left of the bar that woke the island by mistake. this.islandEl.addEventListener("mousedown", (e) => { Sound.resume(); State.lastActivity = performance.now(); if (State.mode !== "expanded") { - this.fsm.click(); + // Both resting bars drag, and the click that opens the island is decided + // on release instead. Opening on press would flip the mode to expanded + // mid-gesture and resize the window out from under the drag. + this.beginNotchDrag(e); return; } if (this.isBotHit(e.clientX, e.clientY)) { @@ -545,7 +733,7 @@ export class Island { }); window.addEventListener("keydown", (e) => { - if (e.key === "Escape" && State.mode === "expanded" && !State.isPinned) this.collapse(); + if (e.key === "Escape" && State.mode === "expanded" && !this.staysOpen) this.collapse(); State.lastActivity = performance.now(); }); @@ -555,6 +743,9 @@ export class Island { // island can be inspected with `npm run dev`. if (!IS_TAURI) { window.addEventListener("mousemove", (e) => this.onCursor(e.clientX, e.clientY)); + window.addEventListener("pointerdown", (e) => { + if (!this.islandEl.contains(e.target as Node)) this.dismissOutside(); + }); } } @@ -576,12 +767,17 @@ export class Island { if (inIsland && !this.wasInIsland) { if (this.fsm.state === "coucou") this.greeting.hover(); - this.fsm.mouseEntered(); + // This is the poll-driven hover path and it is the one that actually wakes + // the island on Windows, so the "wake on hover" setting has to be honoured + // here too — gating only the DOM mouseenter left the setting inert. + if (this.fsm.state !== "hidden" || State.settings.wakeOnHover) { + this.fsm.mouseEntered(); + } this.homeCollapseAt = null; } if (!inIsland && this.wasInIsland) { this.fsm.mouseLeft(); - if (this.fsm.state === "home" && !State.isPinned) { + if (this.fsm.state === "home" && !this.staysOpen && State.settings.autoCloseInterval > 0) { this.homeCollapseAt = performance.now() + State.settings.autoCloseInterval * 1000; } } @@ -808,7 +1004,7 @@ export class Island { } private updateCountdown(nowMs: number) { - if (State.mode !== "expanded" || State.isPinned || this.homeCollapseAt == null) { + if (State.mode !== "expanded" || this.staysOpen || this.homeCollapseAt == null) { this.countdown.style.width = "0px"; return; } @@ -874,6 +1070,10 @@ export class Island { Sound.setEnabled(State.settings.soundEnabled); Sound.setVolume(State.settings.soundVolume); this.fsm.homeToPetitDelay = State.settings.autoCloseInterval; + this.fsm.petitToHiddenDelay = State.settings.absenceInterval; + // A pin saved last session keeps the island open across a restart. + State.isPinned = State.settings.pinIsland; + this.fsm.pinned = State.settings.pinIsland; State.notify(); } diff --git a/windows/src/main.ts b/windows/src/main.ts index 5f1c3348c..cd5e18e63 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -19,6 +19,7 @@ async function main() { const boot = await Bridge.boot(); if (boot) { State.settings = { ...State.settings, ...boot.settings }; + State.screen = boot.screen; } island.applySettings(); State.loadIntegrationTasks(); @@ -51,6 +52,7 @@ async function main() { }); await onEvent("screen-changed", () => void Bridge.reposition()); + await onEvent("outside-click", () => island.dismissOutside()); // The settings window writes preferences; apply them here without a restart. await onEvent("settings-changed", (s) => { diff --git a/windows/src/mochi/greeting.ts b/windows/src/mochi/greeting.ts index 682f60a2c..87e790153 100644 --- a/windows/src/mochi/greeting.ts +++ b/windows/src/mochi/greeting.ts @@ -2,7 +2,7 @@ // Everything is laid out in the same 640×150 reference space as on macOS. import { Sound } from "../core/sound"; -import { COMPACT_W, NOTCH_H, NOTCH_W } from "../core/layout"; +import { restingLayout } from "../core/layout"; // ── Timing (mirrors greeting-v2.html `T`) ───────────────────────────────────── @@ -41,8 +41,11 @@ const EAR_Y = 16; const EAR_HB = 17; const CARD = { x: 10, y: 36, w: 620, h: 104 }; const CARD_R = 20; -const SMALL_W = COMPACT_W; -const SMALL_H = NOTCH_H; +// The greeting collapses into the real compact bar, so its resting pose has to +// be derived from the live resting dimensions rather than fixed numbers (PR #22). +const resting = restingLayout(); +const SMALL_W = resting.width; +const SMALL_H = resting.height; // ── Easing ──────────────────────────────────────────────────────────────────── @@ -78,8 +81,10 @@ interface Pose { function greetPose(t: number): Pose { const gx = seg(t, 0, 0.5); const g = Math.sin((Math.PI * gx) / 2) + 0.04 * Math.sin(Math.PI * gx) * gx; - const iw = lerp(NOTCH_W, 640, g); - const ih = lerp(NOTCH_H, 150, g); + // Grows out of the compact bar's own dimensions, so the opening frame matches + // the bar it springs from instead of a stale notch size. + const iw = lerp(SMALL_W, 640, g); + const ih = lerp(SMALL_H, 150, g); const gg = E.back(seg(t, 0.02, T.grow)); const hb = lerp(3, HB, gg); From 7987bb25b99de7cec44f812430048359d7e0fa93 Mon Sep 17 00:00:00 2001 From: Imanol Diaz Date: Thu, 1 Oct 2026 04:58:32 -0400 Subject: [PATCH 09/21] Windows: manage an opencode server for commands and sessions Completes the opencode side of #19. The command and session lists only exist on a long-lived HTTP server, and with no TUI running there was none, so the slash menu opened empty. Coucou now finds one: a server already running is reused, otherwise it starts its own on a fixed port. The working directory turned out to decide everything. opencode serve scopes both the session list and the project commands to it, so inheriting Coucou's own directory answered with an empty session list and a fraction of the installed plugins; started in the user's home it matches the TUI's server exactly. Passing ?directory= on the request does not change this. Also: the per-probe timeout was short enough that a server still loading its config read as absent; two lookups racing on a cold start could both try to bind the port, so startup is now serialised; the real executable is preferred over a scoop shim, which starts the genuine binary as a second process and made the child handle useless for cleanup; and the port is claimed without a console window appearing. --- windows/src-tauri/src/opencode_server.rs | 862 +++++++++++++++++++++++ windows/src-tauri/src/settings.rs | 82 ++- windows/src/core/bridge.ts | 40 +- windows/src/core/state.ts | 22 +- 4 files changed, 989 insertions(+), 17 deletions(-) create mode 100644 windows/src-tauri/src/opencode_server.rs diff --git a/windows/src-tauri/src/opencode_server.rs b/windows/src-tauri/src/opencode_server.rs new file mode 100644 index 000000000..ef1a3f55c --- /dev/null +++ b/windows/src-tauri/src/opencode_server.rs @@ -0,0 +1,862 @@ +// Discovery of the opencode server the user is already running. +// +// The TUI starts its own HTTP server on a random loopback port, and that port +// changes every time opencode restarts. Coucou therefore has to find it at +// runtime rather than store it. The approach is deliberately conservative: +// +// 1. ask the OS for every listening loopback port (GetExtendedTcpTable), +// 2. probe each one for a real opencode endpoint (`GET /session`), +// 3. cache the winner briefly, because ports only change on opencode restart. +// +// Nothing here starts, stops or configures opencode, and no request ever +// mutates server state: every call below is a GET against a read-only endpoint. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use serde_json::Value; + +/// A discovered server is reused for this long before re-probing. +const CACHE_TTL: Duration = Duration::from_secs(20); + +/// Per-probe timeout. Loopback connects to a port nothing is serving are refused +/// instantly, so this budget is only ever spent on a real opencode server. It +/// has to be generous because a server that has just been spawned is still +/// loading its config and database, and answering `GET /session` slowly right +/// after launch is normal — too short a budget reports a perfectly good server as +/// absent, which sends the chat down the fallback path for no reason. +const PROBE_TIMEOUT: Duration = Duration::from_millis(1500); + +/// 127.0.0.1 as it appears in the TCP table's network-order address field. +const LOOPBACK: u32 = 0x0100_007F; + +static CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Every port listening on 127.0.0.1, with the pid holding it. Order is whatever +/// the OS returns; the caller probes and keeps the first real match. +fn listening_loopback_owners() -> Vec<(u16, u32)> { + use windows::Win32::NetworkManagement::IpHelper::{ + GetExtendedTcpTable, MIB_TCPROW_OWNER_PID, TCP_TABLE_OWNER_PID_LISTENER, + }; + + // SAFETY: the two Win32 calls only write into buffers we sized ourselves, + // and `rows` points at `count` fixed-size PODs inside `buf`, where `count` + // was clamped to what the buffer can hold. AF_INET is IPv4-only, which is + // all loopback needs. + unsafe { + const AF_INET: u32 = 2; + + let mut size: u32 = 0; + // First call with a null buffer only asks how large the table is. + // It is expected to fail with ERROR_INSUFFICIENT_BUFFER (122). + let _ = GetExtendedTcpTable( + None, + &mut size, + false, + AF_INET, + TCP_TABLE_OWNER_PID_LISTENER, + 0, + ); + if size == 0 { + return Vec::new(); + } + + let mut buf = vec![0u8; size as usize]; + if GetExtendedTcpTable( + Some(buf.as_mut_ptr().cast()), + &mut size, + false, + AF_INET, + TCP_TABLE_OWNER_PID_LISTENER, + 0, + ) != 0 + { + return Vec::new(); + } + + // The table is a dwNumEntries count followed by that many fixed rows, so + // reading rows from offset 0 would shift every field by four bytes. + if buf.len() < 4 { + return Vec::new(); + } + let count = u32::from_ne_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize; + let row_size = std::mem::size_of::(); + let available = (buf.len() - 4) / row_size; + let count = count.min(available); + if count == 0 { + return Vec::new(); + } + + let rows_ptr = buf.as_ptr().add(4).cast::(); + let rows = std::slice::from_raw_parts(rows_ptr, count); + rows.iter() + // dwLocalAddr holds the address in network byte order; loopback is + // the only address we may talk to. (127.0.0.1 reads as 0x0100007F.) + .filter(|r| r.dwLocalAddr == LOOPBACK) + // dwLocalPort likewise stores the port big-endian in the low 16 + // bits, so reading it natively yields it byte-swapped. + .map(|r| (u16::from_be(r.dwLocalPort as u16), r.dwOwningPid)) + .filter(|(p, _)| *p != 0) + .collect() + } +} + +/// Every port listening on 127.0.0.1. +fn listening_loopback_ports() -> Vec { + listening_loopback_owners() + .into_iter() + .map(|(port, _)| port) + .collect() +} + +/// The process holding `port`, if anything is. +fn owner_of(port: u16) -> Option { + listening_loopback_owners() + .into_iter() + .find(|(p, _)| *p == port) + .map(|(_, pid)| pid) +} + +/// How long a temporary server may sit unused before Coucu stops it again. +/// +/// Only applies when the persistent-server setting is off. It is what lets "off" +/// still mean no lingering process while the command and session lists keep +/// working: a server is started on demand and tidied away once nothing has asked +/// it for anything for a while. +const IDLE_SHUTDOWN: Duration = Duration::from_secs(90); + +/// When a server was last asked to do something. +static LAST_USED: Mutex> = Mutex::new(None); + +/// Whether the idle reaper is already running. +static WATCHDOG: Mutex = Mutex::new(false); + +/// Ends a process without a console window flashing up. `taskkill` rather than +/// `Child::kill`, because the target was started by an earlier run of Coucou and +/// so is not a child of this one. +fn kill_pid(pid: u32) { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .creation_flags(CREATE_NO_WINDOW) + .output(); +} + +fn probe(base: &str) -> bool { + let client = reqwest::blocking::Client::builder() + .timeout(PROBE_TIMEOUT) + .build(); + match client { + Ok(c) => c.get(format!("{base}/session")).send().map(|r| r.status().is_success()).unwrap_or(false), + Err(_) => false, + } +} + +/// Port Coucou asks `opencode serve` to listen on. High and rarely used, so it +/// is unlikely to collide with anything else on the machine. +const MANAGED_PORT: u16 = 47821; +/// The server Coucou started, kept so it is reused and not leaked. +static MANAGED: Mutex> = Mutex::new(None); + +fn managed_url() -> String { + format!("http://127.0.0.1:{MANAGED_PORT}") +} + +/// True when something is already listening on the managed port and answers as +/// opencode. The port may in principle be taken by an unrelated process. +fn managed_port_ours() -> bool { + probe(&managed_url()) +} + +/// Held while a server is being brought up, so two callers cannot both decide +/// nothing is running and both try to bind the same port. +/// +/// The command and session lists are fetched together and in parallel, so on a +/// cold start they reach discovery at the same moment. Without this, one of them +/// lost the race: its server could not bind, its request went nowhere, and the +/// `/` menu came up empty while the other one worked. +static START_LOCK: Mutex<()> = Mutex::new(()); + +/// Starts `opencode serve` in the background and waits for it to answer. +/// +/// The command list, session list and model list all live on a long-lived +/// server. With no TUI running there is none, which is why the chat had nothing +/// to show, so Coucou starts its own. +fn start_managed(bin: &std::path::Path) -> Option { + use std::os::windows::process::CommandExt; + + /// A server is a background process the user never asked to see. Without + /// this a console window flashes up on the desktop at every launch. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + let url = managed_url(); + if managed_port_ours() { + return Some(url); + } + // Wait for whoever is already starting a server rather than racing them. + let _starting = START_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + if managed_port_ours() { + return Some(url); + } + let child = std::process::Command::new(bin) + .args(["serve", "--port", &MANAGED_PORT.to_string()]) + // The working directory decides which project the server serves, and it + // scopes both the session list and the project commands to it. Left to + // inherit Coucou's own directory it answered with an empty session list + // and a fraction of the commands; started in the user's home — the same + // directory the mascot chats in by default — it matches the TUI's server + // exactly. `?directory=` on the request does not change this. + .current_dir(crate::opencode_chat::home_dir()) + .creation_flags(CREATE_NO_WINDOW) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + if let Ok(mut guard) = MANAGED.lock() { + // An earlier call may already have started one; never leak a second. + if guard.is_none() { + *guard = Some(child); + } + } + // It needs a moment to bind before it answers. + for _ in 0..40 { + std::thread::sleep(Duration::from_millis(150)); + if managed_port_ours() { + crate::log::line(format!("started opencode serve on port {MANAGED_PORT}")); + return Some(url); + } + } + crate::log::line("opencode serve did not come up on the managed port".to_string()); + None +} + +/// Stops the server Coucou is responsible for. Called when the app quits. +/// +/// Kills whoever holds the managed port rather than the process it spawned. +/// On this machine `opencode` resolves to a scoop shim, which launches the real +/// binary as a separate process and stays alive beside it, so the handle from +/// `Command::spawn` is not the thing listening — killing that would leave the +/// actual server running. Going by the port also cleans up after a run that was +/// killed before it could tidy up. +pub fn shutdown() { + stop_managed_port(); +} + +/// Brings Coucou's own server up in the background, so it is ready before the +/// first message. +/// +/// This is the only thing that ever starts a server, and callers must check the +/// `chat_via_server` setting first: with the setting off, Coucou runs no +/// `opencode serve` of its own and simply uses whatever server already exists. +/// +/// A server that is already answering on the managed port is kept rather than +/// replaced. Switching the setting on in the middle of a conversation must not +/// swap the server out from under it, and there is nothing to gain by replacing +/// one that works. Only a port held by something that does *not* answer — a +/// half-dead leftover — is cleared, because otherwise the bind below fails. +pub fn ensure_ready(configured_bin: &str) { + let configured_bin = configured_bin.to_string(); + std::thread::spawn(move || { + if managed_port_ours() { + crate::log::line(format!( + "using the opencode server already on port {MANAGED_PORT}" + )); + return; + } + if let Some(pid) = owner_of(MANAGED_PORT) { + crate::log::line(format!( + "clearing the unresponsive process on port {MANAGED_PORT} (pid {pid})" + )); + kill_pid(pid); + // Give the socket a moment to come free before rebinding it. + for _ in 0..20 { + if owner_of(MANAGED_PORT).is_none() { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + } + let Some(bin) = crate::opencode_chat::resolve_server_bin(&configured_bin) else { + crate::log::line("cannot start opencode serve: opencode not found".to_string()); + return; + }; + start_managed(bin.as_path()); + }); +} + +/// Stops any server sitting on Coucou's managed port, owned or not. +/// +/// Used when the persistent-server setting is switched off: "off" has to mean no +/// `opencode serve` is left running, including one an earlier run leaked. +pub fn stop_managed_port() { + let Some(pid) = owner_of(MANAGED_PORT) else { + return; + }; + kill_pid(pid); + crate::log::line(format!( + "stopped the opencode server on port {MANAGED_PORT} (pid {pid})" + )); +} + +/// Notes that a server was just needed, and makes sure a temporary one will be +/// tidied away rather than left running. +fn touch() { + if let Ok(mut guard) = LAST_USED.lock() { + *guard = Some(Instant::now()); + } + let already = match WATCHDOG.lock() { + Ok(mut started) => { + if *started { + true + } else { + *started = true; + false + } + } + Err(_) => true, + }; + if already { + return; + } + std::thread::spawn(|| loop { + std::thread::sleep(Duration::from_secs(15)); + // A persistent server is meant to stay until the app quits, so the reaper + // stands down entirely while the setting is on. + if crate::settings::load().chat_via_server { + continue; + } + let idle = LAST_USED.lock().ok().and_then(|at| *at).map(|at| at.elapsed()); + if idle.is_some_and(|d| d >= IDLE_SHUTDOWN) && owner_of(MANAGED_PORT).is_some() { + stop_managed_port(); + if let Ok(mut guard) = CACHE.lock() { + *guard = None; + } + crate::log::line("stopped the idle opencode server".to_string()); + } + }); +} + +/// The messages of a session, oldest first, so a picked conversation can be +/// shown in the island rather than starting blank. +pub fn session_messages(id: &str) -> Vec { + if id.trim().is_empty() { + return Vec::new(); + } + let Some(base) = discover(false) else { + return Vec::new(); + }; + let Ok(client) = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + else { + return Vec::new(); + }; + let Ok(resp) = client.get(format!("{base}/session/{id}/message")).send() else { + return Vec::new(); + }; + let Ok(json) = resp.json::() else { + return Vec::new(); + }; + let Some(items) = json.as_array() else { + return Vec::new(); + }; + items + .iter() + .filter_map(|item| { + let role = item.get("info")?.get("role")?.as_str()?.to_string(); + if role != "user" && role != "assistant" { + return None; + } + let text: String = item + .get("parts") + .and_then(|p| p.as_array()) + .map(|parts| { + parts + .iter() + .filter(|p| p.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|p| p.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("") + }) + .unwrap_or_default(); + let text = text.trim().to_string(); + // A turn can hold nothing but tool calls, which would render as an + // empty bubble. + if text.is_empty() { + return None; + } + Some(HistoryMessage { role, text }) + }) + .collect() +} + +/// One turn of a past conversation, as the island shows it. +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HistoryMessage { + pub role: String, + pub text: String, +} + +/// Base URL of a running opencode server, e.g. `http://127.0.0.1:43344`. +/// +/// Prefers the server Coucou manages, then any server already running (the TUI's, +/// which listens on a random port). Cached for [`CACHE_TTL`]; pass `fresh` to +/// force a re-scan so a moved port is picked up up immediately. +pub fn discover(fresh: bool) -> Option { + // Anything that needs a server is a use, whether or not one was found: this + // is what keeps a temporary one alive between two commands. + touch(); + if !fresh { + if let Ok(guard) = CACHE.lock() { + if let Some((url, at)) = guard.as_ref() { + if at.elapsed() < CACHE_TTL { + return Some(url.clone()); + } + } + } + } + + let remember = |url: String| { + if let Ok(mut guard) = CACHE.lock() { + *guard = Some((url.clone(), Instant::now())); + } + url + }; + + // 1. Our own server, already up. + if managed_port_ours() { + return Some(remember(managed_url())); + } + + // 2. A server someone else is running, e.g. the TUI's. + let ports = listening_loopback_ports(); + for port in ports.iter().copied() { + if port == MANAGED_PORT { + continue; + } + let base = format!("http://127.0.0.1:{port}"); + if probe(&base) { + crate::log::line(format!( + "using the opencode server already running at {base} (of {} loopback listeners)", + ports.len() + )); + return Some(remember(base)); + } + } + + // Nothing running. Start one: with the persistent setting on this is the + // server that stays until the app quits, and with it off this is a temporary + // one that the idle reaper tidies away again. Either way the command and + // session lists work without the user having a TUI open. + let started = crate::opencode_chat::resolve_server_bin("") + .and_then(|bin| start_managed(bin.as_path())); + match started { + Some(url) => Some(remember(url)), + None => { + crate::log::line(format!( + "no opencode server among {} loopback listeners, and none could be started", + ports.len() + )); + if let Ok(mut guard) = CACHE.lock() { + *guard = None; + } + None + } + } +} + +// ── Read-only endpoints built on top of discovery ───────────────────────────── + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CommandInfo { + pub name: String, + pub description: String, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionInfo { + pub id: String, + pub title: String, + pub directory: String, + /// Creation time in milliseconds since the epoch, for ordering newest first. + pub created: i64, +} + +/// The `/`-menu commands the running opencode exposes. These come from the +/// user's own skills/plugins/config, so the list reflects their installation. +/// +/// Returns an empty list when no server is running; the island treats that as +/// "no commands available" rather than an error. +pub fn commands() -> Vec { + let Some(base) = discover(false) else { + return Vec::new(); + }; + let Ok(client) = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(3)) + .build() + else { + return Vec::new(); + }; + let Ok(resp) = client.get(format!("{base}/command")).send() else { + return Vec::new(); + }; + let Ok(json) = resp.json::() else { + return Vec::new(); + }; + let Some(items) = json.as_array() else { + return Vec::new(); + }; + + let mut out: Vec = items + .iter() + .filter_map(|it| { + let name = it.get("name")?.as_str()?.to_string(); + // Descriptions can be multi-line; the popup is one row per command. + let description = it + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or("") + .split_whitespace() + .collect::>() + .join(" "); + Some(CommandInfo { name, description }) + }) + .collect(); + + // A plugin may register a command with the same name as a built-in; the + // built-in wins in `act()`, so skip the duplicate rather than list it twice. + for (name, description) in BUILTINS { + if out.iter().any(|c| c.name.eq_ignore_ascii_case(name)) { + continue; + } + out.push(CommandInfo { + name: (*name).to_string(), + description: (*description).to_string(), + }); + } + out +} + +/// opencode's TUI built-in commands that Coucou can actually perform. +/// +/// The server's `/command` route only lists commands registered by plugins and +/// skills, so the built-ins are absent from it. Most TUI built-ins are handled +/// entirely inside the terminal UI and never reach the server (`--command` +/// rejects them outright), but these ones each map to a real server route, so +/// Coucou performs the genuine action rather than pretending to. +const BUILTINS: &[(&str, &str)] = &[ + ("compact", "Compact this session to free up context"), + ("undo", "Undo the last message and its file changes"), + ("redo", "Redo a message you just undid"), + ("sessions", "Switch to a different session"), + ("new", "Start a fresh session"), + ("models", "List the available models"), + ("share", "Share this session"), + ("unshare", "Stop sharing this session"), +]; + +/// Aliases opencode's TUI accepts, mapped onto the canonical built-in name. +const BUILTIN_ALIASES: &[(&str, &str)] = &[ + ("summarize", "compact"), + ("clear", "new"), + ("resume", "sessions"), + ("continue", "sessions"), +]; + +/// Canonical built-in name for `name`, if it is one Coucou can perform. +pub fn builtin_for(name: &str) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + BUILTINS + .iter() + .find(|(n, _)| *n == lower) + .map(|(n, _)| *n) + .or_else(|| { + BUILTIN_ALIASES + .iter() + .find(|(a, _)| *a == lower) + .map(|(_, target)| *target) + }) +} + +/// Runs a built-in against the live server and returns text for the chat log. +/// +/// `session` is the session the command applies to; only [`new`] works without +/// one. +pub fn act(name: &str, session: &str) -> Result { + let Some(canonical) = builtin_for(name) else { + return Err(format!("`/{name}` is not a built-in Coucou can run")); + }; + let Some(base) = discover(false) else { + return Err("opencode is not running".to_string()); + }; + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| e.to_string())?; + + // `/new` is the only command that doesn't act on an existing session. + if canonical == "new" { + let resp = client + .post(format!("{base}/session")) + .json(&serde_json::json!({})) + .send() + .map_err(|e| e.to_string())?; + let json: Value = resp.json().map_err(|e| e.to_string())?; + let id = json.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if id.is_empty() { + return Err("could not create a session".to_string()); + } + return Ok(format!("Started a new session: {id}")); + } + + // `/sessions` is handled by the picker in the UI, and `/models` only reads + // provider config. Neither acts on a session, so neither needs one. + if canonical == "sessions" { + return Ok("Pick a session from the list.".to_string()); + } + + if canonical == "models" { + let json: Value = client + .get(format!("{base}/config/providers")) + .send() + .map_err(|e| e.to_string())? + .json() + .map_err(|e| e.to_string())?; + let providers = json.get("providers").and_then(|v| v.as_object()); + let mut lines: Vec = Vec::new(); + if let Some(map) = providers { + for (provider, entry) in map { + let Some(models) = entry.get("models").and_then(|m| m.as_object()) else { + continue; + }; + for id in models.keys() { + lines.push(format!("{provider}/{id}")); + } + } + } + if lines.is_empty() { + return Ok("No models reported by the server.".to_string()); + } + lines.sort(); + lines.truncate(40); + return Ok(format!("{}\n\n{}", lines.len(), lines.join("\n"))); + } + + // Everything below mutates a specific session, so it needs one to exist. + if session.is_empty() { + return Err(format!( + "`/{canonical}` needs an open session — start chatting first." + )); + } + + let route = match canonical { + "compact" => "compact", + "undo" => "revert", + "redo" => "redo", + "share" => "share", + "unshare" => "unshare", + _ => return Err(format!("`/{canonical}` is not implemented")), + }; + let resp = client + .post(format!("{base}/session/{session}/{route}")) + .json(&serde_json::json!({})) + .send() + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(format!( + "`/{canonical}` failed: HTTP {}", + resp.status().as_u16() + )); + } + let done = match canonical { + "compact" => "Compacted the session.", + "undo" => "Undid the last message.", + "redo" => "Redid the message.", + "share" => "Session shared.", + "unshare" => "Session unshared.", + _ => "Done.", + }; + Ok(done.to_string()) +} + +/// Deletes a session on the live server. +/// +/// Only ever called after an explicit confirmation in the island, and never for +/// the session the chat is currently pointed at. +pub fn delete_session(id: &str) -> Result<(), String> { + let Some(base) = discover(false) else { + return Err("opencode is not running".to_string()); + }; + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| e.to_string())?; + let resp = client + .delete(format!("{base}/session/{id}")) + .send() + .map_err(|e| e.to_string())?; + if resp.status().is_success() { + Ok(()) + } else { + Err(format!("could not delete the session: HTTP {}", resp.status().as_u16())) + } +} + +/// Live sessions from the running server, most recent first. +pub fn sessions() -> Vec { + let Some(base) = discover(false) else { + return Vec::new(); + }; + let Ok(client) = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(3)) + .build() + else { + return Vec::new(); + }; + let Ok(resp) = client.get(format!("{base}/session")).send() else { + return Vec::new(); + }; + let Ok(json) = resp.json::() else { + return Vec::new(); + }; + let Some(items) = json.as_array() else { + return Vec::new(); + }; + + let mut out: Vec = items + .iter() + .filter_map(|it| { + Some(SessionInfo { + id: it.get("id")?.as_str()?.to_string(), + title: it + .get("title") + .and_then(|t| t.as_str()) + .filter(|t| !t.trim().is_empty()) + .unwrap_or("Untitled session") + .to_string(), + directory: it.get("directory").and_then(|d| d.as_str()).unwrap_or("").to_string(), + created: it + .get("time") + .and_then(|t| t.get("created")) + .and_then(|c| c.as_i64()) + .unwrap_or(0), + }) + }) + .collect(); + + // Newest first. The server already returns them that way, but sorting here + // means the picker cannot depend on that staying true; an earlier `reverse()` + // on top of an already-newest-first list put the oldest session on top. + out.sort_by(|a, b| b.created.cmp(&a.created)); + out.truncate(25); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtins_resolve_their_aliases() { + assert_eq!(builtin_for("compact"), Some("compact")); + assert_eq!(builtin_for("COMPACT"), Some("compact")); + assert_eq!(builtin_for("summarize"), Some("compact")); + assert_eq!(builtin_for("clear"), Some("new")); + assert_eq!(builtin_for("resume"), Some("sessions")); + assert_eq!(builtin_for("continue"), Some("sessions")); + // Commands Coucou cannot perform must never be claimed. + assert_eq!(builtin_for("themes"), None); + assert_eq!(builtin_for("exit"), None); + assert_eq!(builtin_for("help"), None); + } + + #[test] + fn loopback_listeners_are_plausible_ports() { + let ports = listening_loopback_ports(); + println!("loopback listeners found: {ports:?}"); + for port in ports.iter().copied() { + assert!(port > 0, "port must be non-zero"); + assert_ne!(port, 80, "must not be a privileged port"); + } + } + + /// Discovers the opencode server when one is running, and reports what it + /// saw. Prints the result so a failing scan is visible in `cargo test` logs. + #[test] + fn discovers_a_running_server_if_any() { + match discover(true) { + Some(url) => { + println!("discovered opencode server at {url}"); + let cmds = commands(); + println!("commands available: {}", cmds.len()); + let sess = sessions(); + println!("sessions available: {}", sess.len()); + assert!(!cmds.is_empty(), "a live server must expose commands"); + } + None => println!("no opencode server running right now — skipping"), + } + } + + /// Switching the persistent-server setting on part-way through a conversation + /// must not replace the server that conversation is talking to. + #[test] + fn keeps_a_server_that_is_already_answering() { + ensure_ready(""); + let first = wait_for_managed(); + assert!(first.is_some(), "no server came up on the managed port"); + // Exactly what a settings save does a moment later. + ensure_ready(""); + std::thread::sleep(Duration::from_millis(2000)); + assert_eq!( + owner_of(MANAGED_PORT), + first, + "the running server was replaced instead of kept" + ); + } + + fn wait_for_managed() -> Option { + for _ in 0..60 { + if managed_port_ours() { + return owner_of(MANAGED_PORT); + } + std::thread::sleep(Duration::from_millis(250)); + } + None + } + + /// The command and session lists are fetched together and in parallel, so on a + /// cold start two lookups reach discovery at once. Exactly one server must + /// come up and both callers must get a usable answer, or the `/` menu is empty. + #[test] + fn parallel_lookups_share_one_server() { + stop_managed_port(); + std::thread::sleep(Duration::from_millis(500)); + assert!(owner_of(MANAGED_PORT).is_none(), "a server was left running"); + + let handles: Vec<_> = (0..2) + .map(|_| { + std::thread::spawn(|| { + let cmds = commands(); + let sess = sessions(); + (cmds.len(), sess.len()) + }) + }) + .collect(); + let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); + for (cmds, _) in &results { + assert!(*cmds > 0, "a parallel lookup got no commands: {results:?}"); + } + // Still exactly one server, not one per caller. + let owners: Vec<_> = listening_loopback_owners() + .into_iter() + .filter(|(p, _)| *p == MANAGED_PORT) + .collect(); + assert_eq!(owners.len(), 1, "expected one listener, got {owners:?}"); + } +} diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index e45397063..1ff26712a 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -7,14 +7,31 @@ use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Settings { + #[serde(default = "default_sound_enabled")] pub sound_enabled: bool, + #[serde(default = "default_sound_volume")] pub sound_volume: f64, + #[serde(default = "default_auto_close_interval")] pub auto_close_interval: f64, + #[serde(default = "default_absence_interval")] pub absence_interval: f64, + /// Pinned by the user from the island header: the island then ignores + /// outside clicks, Escape and the auto-close timer until unpinned. + #[serde(default)] + pub pin_island: bool, + /// Wake the reduced island on hover. When false it waits to be clicked. + #[serde(default = "default_wake_on_hover")] + pub wake_on_hover: bool, + /// Opt-in integrations. Defaults to empty: a fresh install shows only the + /// coding-agent pills rather than every integration that has no key. + #[serde(default)] pub active_integrations: Vec, /// "primary" = the main display, "cursor" = whichever display the mouse is on. + #[serde(default = "default_screen")] pub screen: String, + #[serde(default)] pub autostart: bool, + #[serde(default)] pub hooks_installed: bool, /// Claude model used by the chat. Changeable in the settings window. /// Defaulted explicitly so a settings.json written by an older build still loads. @@ -29,6 +46,21 @@ pub struct Settings { /// Optional `provider/model` override for opencode chat; empty = its default. #[serde(default)] pub opencode_model: String, + /// Keep one opencode server running for as long as the app is, and send chat + /// turns to it over HTTP. Off means the default: a fresh `opencode run` per + /// message, each booting a throwaway server of its own. + #[serde(default)] + pub chat_via_server: bool, + /// Horizontal resting place of the compact island, normalised 0..=1 across + /// the target display: 0 = flush left, 0.5 = centred, 1 = flush right. + /// Stored as a fraction so a drag can land anywhere while the presets still + /// snap to exact edges. + #[serde(default = "default_notch_position")] + pub notch_position: f64, +} + +fn default_notch_position() -> f64 { + 0.5 } fn default_chat_provider() -> String { @@ -39,26 +71,54 @@ fn default_model() -> String { crate::claude::DEFAULT_MODEL.to_string() } +fn default_sound_enabled() -> bool { + true +} + +fn default_sound_volume() -> f64 { + 0.12 +} + +fn default_auto_close_interval() -> f64 { + 15.0 +} + +fn default_absence_interval() -> f64 { + 180.0 +} + +/// Waking on hover is the default: the island is a bar at the top of the screen +/// and should appear as the pointer reaches it. Turning it off means it waits to +/// be clicked. +fn default_wake_on_hover() -> bool { + true +} + +fn default_screen() -> String { + "primary".into() +} + impl Default for Settings { fn default() -> Self { Self { - sound_enabled: true, - sound_volume: 0.12, - auto_close_interval: 15.0, - absence_interval: 180.0, - active_integrations: vec![ - "integration_resend".into(), - "integration_n8n".into(), - "integration_vercel".into(), - "integration_github".into(), - ], - screen: "primary".into(), + sound_enabled: default_sound_enabled(), + sound_volume: default_sound_volume(), + auto_close_interval: default_auto_close_interval(), + absence_interval: default_absence_interval(), + pin_island: false, + wake_on_hover: default_wake_on_hover(), + // Opt-in only: the coding-agent pills are always loaded, so starting + // with an empty list means a fresh install shows nothing unconfigured. + active_integrations: Vec::new(), + screen: default_screen(), autostart: false, hooks_installed: false, model: default_model(), chat_provider: default_chat_provider(), opencode_bin: String::new(), opencode_model: String::new(), + chat_via_server: false, + notch_position: default_notch_position(), } } } diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 894092786..858070493 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -47,6 +47,8 @@ export const Bridge = { focusWindow: (focused: boolean) => call("focus_window", { focused }), reposition: () => call("reposition"), + /** Moves the resting island sideways; `position` is 0 = left, 1 = right. */ + setNotchPosition: (position: number) => call("set_notch_position", { position }), openUrl: (url: string) => call("open_url", { url }), @@ -90,12 +92,23 @@ export const Bridge = { approvalDecline: (requestId: string) => call("approval_decline", { requestId }), // ── Chat, files, secrets ────────────────────────────────────────────────── - /** One chat turn. The API key and any file bytes never leave Rust. */ - chatSend: (query: string, context: ChatContext | null) => - callOrThrow<{ text: string }>("chat_send", { query, context }), + /** One chat turn. The API key and any file bytes never leave Rust. + * `session` targets a specific existing opencode session; omit for a new one. */ + chatSend: (query: string, context: ChatContext | null, session?: string | null) => + callOrThrow<{ text: string }>("chat_send", { query, context, session: session ?? null }), chatReset: () => call("chat_reset"), /** Resolved opencode binary + key presence for the Settings → Chat section. */ chatStatus: () => call("chat_status"), + /** The `/`-menu commands of the running opencode; empty when none is up. */ + opencodeCommands: () => call("opencode_commands"), + /** Live sessions of the running opencode, newest first. */ + opencodeSessions: () => call("opencode_sessions"), + /** Deletes a session. The island asks the user to confirm first. */ + opencodeDeleteSession: (id: string) => callOrThrow("opencode_delete_session", { id }), + /** A picked session's messages, oldest first, for the chat window. */ + opencodeSessionMessages: (id: string) => call("opencode_session_messages", { id }), + /** Base URL of the opencode server Coucou found, or null. */ + opencodeServerUrl: () => call("opencode_server_url"), /** Copies a dropped file into the inbox. */ ingestFile: (path: string) => callOrThrow("ingest_file", { path }), /** Only ever tells you whether a key exists — never its value. */ @@ -135,6 +148,27 @@ export interface ChatStatus { claudeKeyPresent: boolean; } +/** One entry of the running opencode's `/` menu. */ +export interface OpencodeCommand { + name: string; + description: string; +} + +/** A session of the running opencode. */ +export interface OpencodeSession { + id: string; + title: string; + directory: string; + /** Creation time in ms since the epoch. */ + created: number; +} + +/** One past turn of a session, as the chat window shows it. */ +export interface OpencodeHistoryMessage { + role: string; + text: string; +} + export interface HookStatus { installed: boolean; settingsPath: string; diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 65c4d6a67..7bd6414ea 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -87,8 +87,14 @@ export interface IntegrationInfo { export interface Settings { soundEnabled: boolean; soundVolume: number; + /** Seconds from open to compact. 0 = never auto-close. */ autoCloseInterval: number; + /** Seconds from compact to fully reduced. 0 = never fully reduce. */ absenceInterval: number; + /** Pinned: ignores outside clicks, Escape and the auto-close timer. */ + pinIsland: boolean; + /** Wake the reduced island on hover. When false it waits to be clicked. */ + wakeOnHover: boolean; activeIntegrations: string[]; screen: "primary" | "cursor"; autostart: boolean; @@ -101,6 +107,10 @@ export interface Settings { opencodeBin: string; /** provider/model override for opencode chat; empty = its default. */ opencodeModel: string; + /** Keep one opencode server running while the app is, and send chat to it. */ + chatViaServer: boolean; + /** Resting place of the compact island, 0 = left edge, 1 = right edge. */ + notchPosition: number; } export const DEFAULT_SETTINGS: Settings = { @@ -108,9 +118,9 @@ export const DEFAULT_SETTINGS: Settings = { soundVolume: 0.12, autoCloseInterval: 15, absenceInterval: 180, - activeIntegrations: [ - "integration_resend", "integration_n8n", "integration_vercel", "integration_github", - ], + pinIsland: false, + wakeOnHover: true, + activeIntegrations: [], screen: "primary", autostart: false, hooksInstalled: false, @@ -118,6 +128,8 @@ export const DEFAULT_SETTINGS: Settings = { chatProvider: "claude", opencodeBin: "", opencodeModel: "", + chatViaServer: false, + notchPosition: 0.5, }; type Listener = () => void; @@ -131,6 +143,10 @@ class AppState { stateOverride: BotStateName | null = null; + /** Logical rect of the monitor the island lives on, from `boot`. Needed to turn a + * pointer delta into a normalised position while dragging. */ + screen: { x: number; y: number; width: number; height: number; scale: number } | null = null; + /** Cursor in logical screen pixels, origin top-left (like AppState.mousePosition). */ mouse = { x: 0, y: 0 }; /** Cursor relative to the island's top-left corner. */ From 262aad2e20ccada2abaa230052fce5fea42f53f5 Mon Sep 17 00:00:00 2001 From: Imanol Diaz Date: Thu, 1 Oct 2026 04:58:37 -0400 Subject: [PATCH 10/21] Windows: send chat turns to the running server instead of a process per message Every message ran opencode run, which boots a throwaway server, does one turn and tears it down again. That left the command and session lists reading a different, long-lived server than the one answering the chat, so one worked while the other did not. A turn is now a pair of HTTP calls against the server that is already running. opencode run is kept where it is the right tool: a slash command owns its whole turn, and an attachment needs --file, which the server route would mean reimplementing as an upload. A failed server turn logs and falls back rather than losing the message. With the provider set to Claude and no key stored, the turn goes to opencode rather than stopping on " API key missing\. --- windows/Cargo.lock | 3 + windows/src-tauri/Cargo.toml | 3 +- windows/src-tauri/src/lib.rs | 148 +++++++++++-- windows/src-tauri/src/opencode_chat.rs | 280 +++++++++++++++++++++++-- 4 files changed, 395 insertions(+), 39 deletions(-) diff --git a/windows/Cargo.lock b/windows/Cargo.lock index ac0c60017..a2e8129da 100644 --- a/windows/Cargo.lock +++ b/windows/Cargo.lock @@ -1119,6 +1119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -2959,7 +2960,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml index 0274d9f5c..4af25aa0f 100644 --- a/windows/src-tauri/Cargo.toml +++ b/windows/src-tauri/Cargo.toml @@ -20,10 +20,11 @@ tauri-plugin-autostart = "2" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } tokio = { version = "1", features = ["net", "io-util", "sync", "time", "rt"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking"] } keyring = { version = "3", features = ["windows-native"] } windows = { version = "0.61", features = [ "Win32_Foundation", + "Win32_NetworkManagement_IpHelper", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Ole", diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index af156d84b..668d6197a 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod island; mod log; mod opencode; mod opencode_chat; +mod opencode_server; mod pipe; mod secrets; mod settings; @@ -53,6 +54,10 @@ fn boot(app: AppHandle, shared: State) -> BootInfo { let mut settings = shared.settings.lock().unwrap().clone(); // The real state of ~/.claude/settings.json wins over whatever we stored. settings.hooks_installed = hooks::status().installed; + // Launch is always centred (see `setup`), so report that rather than the + // saved resting place — the front end derives the island's offset from this + // value and must match where the window actually is. + settings.notch_position = 0.5; let screen = island::screen_info(&app, &settings.screen); BootInfo { settings, @@ -64,16 +69,28 @@ fn boot(app: AppHandle, shared: State) -> BootInfo { #[tauri::command] fn save_settings(app: AppHandle, shared: State, settings: Settings) { - let (screen_changed, autostart_changed) = { + let (screen_changed, autostart_changed, server_changed) = { let mut current = shared.settings.lock().unwrap(); let screen_changed = current.screen != settings.screen; let autostart_changed = current.autostart != settings.autostart; + let server_changed = current.chat_via_server != settings.chat_via_server; *current = settings.clone(); - (screen_changed, autostart_changed) + (screen_changed, autostart_changed, server_changed) }; if let Err(err) = settings::save(&settings) { eprintln!("[coucou] could not save settings: {err}"); } + // The setting means "keep a server open for as long as the app is", so + // flipping it takes effect now rather than at the next launch. After the + // save, so the file already agrees with what we are about to do. + if server_changed { + if settings.chat_via_server { + let bin = settings.opencode_bin.clone(); + opencode_server::ensure_ready(&bin); + } else { + opencode_server::stop_managed_port(); + } + } if autostart_changed { let manager = app.autolaunch(); let result = if settings.autostart { manager.enable() } else { manager.disable() }; @@ -83,7 +100,7 @@ fn save_settings(app: AppHandle, shared: State, settings: Settings) { } if screen_changed { let collapsed = shared.gate.collapsed.load(Ordering::Relaxed); - island::apply_geometry(&app, &settings.screen, collapsed); + island::apply_geometry(&app, &settings.screen, collapsed, settings.notch_position); } // Keep the other window in step (island ⇄ settings window). let _ = app.emit("settings-changed", settings); @@ -93,13 +110,21 @@ fn save_settings(app: AppHandle, shared: State, settings: Settings) { /// cursor poll; anything else → full panel and 60 Hz polling. #[tauri::command] fn set_collapsed(app: AppHandle, shared: State, collapsed: bool) { - let pref = shared.settings.lock().unwrap().screen.clone(); + let (pref, position) = { + let current = shared.settings.lock().unwrap(); + (current.screen.clone(), current.notch_position) + }; shared.gate.collapsed.store(collapsed, Ordering::Relaxed); - island::apply_geometry(&app, &pref, collapsed); - // The wake strip must always take the mouse, and a resize invalidates the flag. + island::apply_geometry(&app, &pref, collapsed, position); + // The reduced stub must always take the mouse, and a resize invalidates the flag. island::set_ignore_cursor(&app, false); shared.gate.forget_ignore_state(); - shared.gate.set_active(!collapsed); + // The poll keeps running while reduced. It used to be parked, because the + // resting state was an invisible strip with nothing to see; the stub is a + // real, visible island now, and parking the poll left the click-through flag + // frozen, so a stub that drifted under the cursor could not be woken again. + // The window is only 80x24 here, so polling it stays cheap. + shared.gate.set_active(true); } /// The front end pushes the island shape; Rust decides click-through from it. @@ -119,9 +144,31 @@ fn focus_window(app: AppHandle, focused: bool) { #[tauri::command] fn reposition(app: AppHandle, shared: State) { - let pref = shared.settings.lock().unwrap().screen.clone(); + let (pref, position) = { + let current = shared.settings.lock().unwrap(); + (current.screen.clone(), current.notch_position) + }; let collapsed = shared.gate.collapsed.load(Ordering::Relaxed); - island::apply_geometry(&app, &pref, collapsed); + island::apply_geometry(&app, &pref, collapsed, position); +} + +/// Moves the resting island sideways without waiting for the settings window to +/// save. Called live while the compact island is dragged, then once more to +/// persist the final resting place. +#[tauri::command] +fn set_notch_position(app: AppHandle, shared: State, position: f64) { + let position = if position.is_finite() { position.clamp(0.0, 1.0) } else { 0.5 }; + let (pref, saved) = { + let mut current = shared.settings.lock().unwrap(); + current.notch_position = position; + (current.screen.clone(), current.clone()) + }; + if let Err(err) = settings::save(&saved) { + eprintln!("[coucou] could not save settings: {err}"); + } + let collapsed = shared.gate.collapsed.load(Ordering::Relaxed); + island::apply_geometry(&app, &pref, collapsed, position); + let _ = app.emit("settings-changed", saved); } #[tauri::command] @@ -177,6 +224,9 @@ pub(crate) fn find_on_path(stem: &str) -> Option { #[tauri::command] fn quit_app(app: AppHandle) { + // Coucou may have started an opencode server of its own; do not leave it + // running once the app is gone. + opencode_server::shutdown(); app.exit(0); } @@ -262,7 +312,12 @@ fn approval_decline(app: AppHandle, request_id: String) { // ── Chat, files and secrets ─────────────────────────────────────────────────── /// One chat turn. The API key and any file bytes stay on the Rust side. -/// Routes to the user's opencode when Settings → Chat says so. +/// +/// Normally this follows Settings → Chat exactly. The exception is a Claude +/// choice with no key stored: there is nothing to answer with, and the user's +/// own opencode is right there, so the turn goes there instead of stopping on +/// "API key missing". With neither available the original message stands, since +/// it names the fix. #[tauri::command] async fn chat_send( shared: State<'_, Shared>, @@ -270,6 +325,7 @@ async fn chat_send( ochat: State<'_, opencode_chat::OpencodeChat>, query: String, context: Option, + session: Option, ) -> Result { let (provider, model, bin, omodel) = { let s = shared.settings.lock().unwrap(); @@ -280,13 +336,51 @@ async fn chat_send( s.opencode_model.clone(), ) }; - if provider == "opencode" { - opencode_chat::send(&ochat, &bin, &omodel, query, context).await + let use_opencode = provider == "opencode" + || (!secrets::present("anthropic-api-key") + && opencode_chat::resolve_bin(&bin).is_some()); + if use_opencode { + opencode_chat::send(&ochat, &bin, &omodel, query, context, session).await } else { claude::send(&chat, &model, query, context).await } } +/// The `/`-menu commands of the running opencode, for the chat command popup. +/// Empty when no opencode is up — the island then hides the menu. +#[tauri::command] +fn opencode_commands() -> Vec { + opencode_server::commands() +} + +/// Live sessions of the running opencode, newest first, for the session picker. +#[tauri::command] +fn opencode_sessions() -> Vec { + opencode_server::sessions() +} + +/// Deletes a session. The island confirms with the user before calling this. +#[tauri::command] +fn opencode_delete_session(id: String) -> Result<(), String> { + if id.trim().is_empty() { + return Err("no session given".to_string()); + } + opencode_server::delete_session(&id) +} + +/// The messages of a picked session, so the chat opens on the conversation +/// instead of an empty window. +#[tauri::command] +fn opencode_session_messages(id: String) -> Vec { + opencode_server::session_messages(&id) +} + +/// Base URL of the opencode server Coucou is talking to, or null when none. +#[tauri::command] +fn opencode_server_url() -> Option { + opencode_server::discover(false) +} + #[tauri::command] fn chat_reset(chat: State, ochat: State) { chat.reset(); @@ -416,6 +510,16 @@ fn open_settings_window(app: AppHandle) { pub fn run() { let loaded = settings::load(); let gate = Arc::new(PollGate::new()); + // "Keep a server open for as long as the app is" starts here rather than on + // the first message, off the main thread so binding the port never delays + // the window. Off means no server of our own, and any one an earlier run + // leaked on our port is cleared out. + if loaded.chat_via_server { + let bin = loaded.opencode_bin.clone(); + opencode_server::ensure_ready(&bin); + } else { + opencode_server::stop_managed_port(); + } tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { @@ -436,6 +540,7 @@ pub fn run() { set_island_rect, focus_window, reposition, + set_notch_position, open_url, open_in_vscode, quit_app, @@ -449,9 +554,14 @@ pub fn run() { approval_ack, approval_decline, log_line, - chat_send, +chat_send, chat_reset, - chat_status, + chat_status, + opencode_commands, + opencode_sessions, + opencode_delete_session, + opencode_session_messages, + opencode_server_url, ingest_file, secret_present, secret_set, @@ -468,8 +578,14 @@ pub fn run() { create_settings_window(&handle); if let Some(win) = island::window(&handle) { - island::make_non_activating(&win); - island::apply_geometry(&handle, &loaded.screen, false); +island::make_non_activating(&win); + // Always open centred, whatever resting place was saved last time. + // The saved position is where the user parks the bar, not where the + // app should appear — surprising as that would be on launch — so the + // value is kept in settings and applied only once the bar is dragged. + let mut loaded = loaded; + loaded.notch_position = 0.5; + island::apply_geometry(&handle, &loaded.screen, false, loaded.notch_position); let _ = win.show(); } gate.collapsed.store(false, Ordering::Relaxed); diff --git a/windows/src-tauri/src/opencode_chat.rs b/windows/src-tauri/src/opencode_chat.rs index 6a86fe0f0..108e7c828 100644 --- a/windows/src-tauri/src/opencode_chat.rs +++ b/windows/src-tauri/src/opencode_chat.rs @@ -79,6 +79,41 @@ pub fn resolve_bin(configured: &str) -> Option { None } +/// The real `opencode` program to start a server with, rather than a launcher +/// that wraps it. +/// +/// On a scoop install, `opencode` on PATH is a shim: a small executable that +/// starts the genuine binary as a second process and stays alive beside it. For +/// `opencode run` that indirection does not matter, because the run is awaited to +/// completion. For a server it matters a lot: the process Coucou holds is not the +/// one holding the port, so stopping it reliably is guesswork. Not every install +/// uses a shim, so this looks for the actual program where installs put it and +/// only falls back to PATH when there is none to find. +pub fn resolve_server_bin(configured: &str) -> Option { + let trimmed = configured.trim(); + if !trimmed.is_empty() { + // An explicit path in Settings is the user's choice, shim or not. + let p = PathBuf::from(trimmed); + return p.is_file().then_some(p); + } + if let Some(home) = std::env::var_os("USERPROFILE").map(PathBuf::from) { + // scoop keeps the real program in a versioned folder, with `current` + // pointing at the live one, and keeps only wrappers in `shims`. + let current = home.join("scoop/apps/opencode/current/opencode.exe"); + if current.is_file() { + return Some(current); + } + for rel in [".bun/bin/opencode.exe", ".opencode/bin/opencode.exe"] { + let p = home.join(rel); + if p.is_file() { + return Some(p); + } + } + } + // Nothing but PATH — take it rather than fail, shim included. + crate::find_on_path("opencode") +} + /// One chat turn. Returns the assistant's text, or a message the island shows /// in the note view. pub async fn send( @@ -87,36 +122,124 @@ pub async fn send( model_override: &str, query: String, context: Option, + session_override: Option, ) -> Result { let bin = resolve_bin(bin_configured).ok_or_else(|| { "opencode not found. Install it (opencode.ai) or set its path in Settings → Chat.".to_string() })?; - // First turn of a conversation carries the persona + context; later turns - // reuse the session in the same directory. + // A leading "/" is one of the user's own commands from the opencode `/` + // menu, not chat prose. It runs verbatim: no persona preamble, and no + // remembered session, because the command owns the whole turn the same way + // it would in the TUI. + let command = query + .strip_prefix('/') + .map(|rest| match rest.split_once(char::is_whitespace) { + Some((name, args)) => (name.trim().to_string(), args.trim().to_string()), + None => (rest.trim().to_string(), String::new()), + }) + .filter(|(name, _)| !name.is_empty()); + + // An explicitly picked session wins over the remembered one. Its working + // directory comes from the live session list so `--dir` matches the session + // rather than the home folder the new-chat path uses. + let picked = session_override + .as_deref() + .filter(|s| !s.is_empty()) + .map(|id| { + let dir = crate::opencode_server::sessions() + .into_iter() + .find(|s| s.id == id) + .map(|s| s.directory) + .filter(|d| !d.is_empty()) + .unwrap_or_else(home_dir); + (id.to_string(), dir) + }); + + // opencode's own TUI built-ins are handled entirely inside the terminal UI: + // they never reach the server as commands and `opencode run --command` + // rejects them. The ones that map to a real server route are dispatched + // straight to that route here, so they perform the genuine action. + if let Some((name, _)) = &command { + if let Some(canonical) = crate::opencode_server::builtin_for(name) { + let target = picked + .as_ref() + .map(|(id, _)| id.clone()) + .or_else(|| { + chat.session + .lock() + .unwrap() + .as_ref() + .map(|s| s.id.clone()) + }) + .unwrap_or_default(); + let text = crate::opencode_server::act(canonical, &target)?; + return Ok(ChatReply { text }); + } + } + let first; let (session_id, dir, files, message) = { let guard = chat.session.lock().unwrap(); - match &*guard { - Some(s) => { - first = false; - (Some(s.id.clone()), s.dir.clone(), Vec::new(), query.clone()) - } - None => { - first = true; - let (dir, files, ctx_text) = first_turn_context(&context); - let mut message = String::from(PERSONA); - if !ctx_text.is_empty() { - message.push_str("\n\nContext: "); - message.push_str(&ctx_text); + if let Some(cmd) = &command { + first = true; + let _ = cmd; + (None, home_dir(), Vec::new(), query.clone()) + } else if let Some((id, dir)) = &picked { + first = false; + (Some(id.clone()), dir.clone(), Vec::new(), query.clone()) + } else { + match &*guard { + Some(s) => { + first = false; + (Some(s.id.clone()), s.dir.clone(), Vec::new(), query.clone()) + } + None => { + first = true; + let (dir, files, ctx_text) = first_turn_context(&context); + let mut message = String::from(PERSONA); + if !ctx_text.is_empty() { + message.push_str("\n\nContext: "); + message.push_str(&ctx_text); + } + message.push_str("\n\nUser: "); + message.push_str(&query); + (None, dir, files, message) } - message.push_str("\n\nUser: "); - message.push_str(&query); - (None, dir, files, message) } } }; + // Persistent-server transport. A `/command` still goes through `opencode run` + // because `--command` owns the whole turn, and an attachment still needs + // `--file`, which the server route would otherwise have to reimplement as an + // upload. Everything else is a plain turn and goes over HTTP. + let via_server = crate::settings::load().chat_via_server; + if via_server && command.is_none() && files.is_empty() { + let attempt = tokio::task::spawn_blocking({ + let sid = session_id.clone(); + let model = model_override.to_string(); + let msg = message.clone(); + move || send_via_server(sid.as_deref(), &model, &msg) + }) + .await + .map_err(|e| format!("opencode task failed: {e}"))?; + match attempt { + Ok((id, text)) => { + if picked.is_none() { + *chat.session.lock().unwrap() = Some(ChatSession { id, dir }); + } + let _ = first; + return Ok(ChatReply { text }); + } + // A transport hiccup must not cost the user their message: log it + // and let the `opencode run` path below try instead. + Err(err) => { + crate::log::line(format!("server turn failed ({err}), using opencode run")); + } + } + } + let model = model_override.trim(); let mut args: Vec = vec![ "run".into(), @@ -131,9 +254,17 @@ pub async fn send( args.push("--model".into()); args.push(model.to_string()); } - if let Some(id) = &session_id { - args.push("--session".into()); - args.push(id.clone()); + // Commands run in a throwaway session: they must not land in the + // conversation the island is holding open. + if command.is_none() { + if let Some(id) = &session_id { + args.push("--session".into()); + args.push(id.clone()); + } + } + if let Some((name, _)) = &command { + args.push("--command".into()); + args.push(name.clone()); } // NOTE: the message must come before `--file`: yargs array options greedily // swallow every positional after them, so `--file f "message"` eats the @@ -151,8 +282,13 @@ pub async fn send( .map_err(|e| format!("opencode task failed: {e}"))??; let parsed = parse_events(&stdout); - if let Some(id) = parsed.session_id { - *chat.session.lock().unwrap() = Some(ChatSession { id, dir }); + // Only a plain chat turn updates the remembered session. A command ran in a + // throwaway session and a picked session is already the active one, so + // neither should replace what the island is holding on to. + if command.is_none() && picked.is_none() { + if let Some(id) = parsed.session_id { + *chat.session.lock().unwrap() = Some(ChatSession { id, dir }); + } } if let Some(err) = parsed.error { @@ -176,7 +312,96 @@ pub async fn send( }) } +/// One chat turn over the opencode server that is already running. +/// +/// The alternative to this is `opencode run`, which boots a whole throwaway +/// server per message and tears it down again. Here the same turn is two HTTP +/// calls against the long-lived one, which is why replies start sooner. +/// +/// Returns the session the turn landed in along with the assistant's text, so +/// the caller can remember it exactly like the `opencode run` path does. +fn send_via_server( + session_id: Option<&str>, + model_override: &str, + message: &str, +) -> Result<(String, String), String> { + let base = crate::opencode_server::discover(true) + .ok_or_else(|| "no opencode server is running".to_string())?; + let client = reqwest::blocking::Client::builder() + .timeout(RUN_TIMEOUT) + .build() + .map_err(|e| e.to_string())?; + + // A brand new conversation has nothing to post into yet. + let id = match session_id.filter(|s| !s.is_empty()) { + Some(id) => id.to_string(), + None => { + let resp = client + .post(format!("{base}/session")) + .json(&serde_json::json!({ "title": "Coucou chat" })) + .send() + .map_err(|e| format!("could not open a session: {e}"))?; + let value: Value = resp.json().map_err(|e| e.to_string())?; + value + .get("id") + .and_then(|id| id.as_str()) + .map(str::to_string) + .ok_or_else(|| "the server did not return a session id".to_string())? + } + }; + + let mut body = serde_json::json!({ "parts": [{ "type": "text", "text": message }] }); + // The setting spells the override "provider/model"; the server wants the two + // halves separately. + if let Some((provider, model)) = model_override.trim().split_once('/') { + if !provider.is_empty() && !model.is_empty() { + body["model"] = + serde_json::json!({ "providerID": provider, "modelID": model }); + } + } + + let resp = client + .post(format!("{base}/session/{id}/message")) + .json(&body) + .send() + .map_err(|e| format!("could not reach the opencode server: {e}"))?; + let status = resp.status(); + let value: Value = resp.json().map_err(|e| e.to_string())?; + if !status.is_success() { + let detail = value + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); + return Err(format!("opencode server said {status}: {detail}")); + } + + // The reply is a list of parts; only the text ones are the answer, the rest + // are the step markers around it. + let text: String = value + .get("parts") + .and_then(|parts| parts.as_array()) + .map(|parts| { + parts + .iter() + .filter(|part| part.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|part| part.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("") + }) + .unwrap_or_default(); + if text.trim().is_empty() { + return Err("opencode returned no text.".into()); + } + Ok((id, text.trim().to_string())) +} + /// Working dir + file attachments + context line for a fresh conversation. +pub fn home_dir() -> String { + std::env::var_os("USERPROFILE") + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".into()) +} + fn first_turn_context(context: &Option) -> (String, Vec, String) { let home = std::env::var_os("USERPROFILE") .map(PathBuf::from) @@ -372,4 +597,15 @@ mod tests { fn resolve_bin_rejects_missing_configured_path() { assert!(resolve_bin("C:\\definitely\\not\\here\\opencode.exe").is_none()); } + + /// A real turn over the persistent server, which is the whole point of the + /// `chat_via_server` setting. Ignored by default because it costs a model + /// call and needs a server to be reachable; run it with `--ignored`. + #[test] + #[ignore = "talks to a live model"] + fn server_turn_answers_and_reports_its_session() { + let (id, text) = send_via_server(None, "", "Reply with exactly: OK").expect("server turn"); + assert!(id.starts_with("ses_"), "expected a session id, got {id}"); + assert!(text.contains("OK"), "expected OK in the reply, got {text:?}"); + } } From 50a583b5ac2345130e20d23582e8acc529253fe7 Mon Sep 17 00:00:00 2001 From: Imanol Diaz Date: Thu, 1 Oct 2026 04:58:57 -0400 Subject: [PATCH 11/21] Windows: session picker ordering, delete, history, and slash filtering The slash menu appeared to ignore what was typed and always showed every command. closePopups() detached the menu but never emptied it, and the element is built once and reused, so the rows from the first render stayed as children and each narrowed render appended underneath them. The filter itself was correct throughout; it was writing into an element that still held the previous list. Filtering is now also synchronous rather than queued behind the command fetch, so a keystroke never waits on a request that a previous keystroke started. Typing a fragment that matches no prefix falls back to a substring match. The picker listed the oldest session first, because the server already returns newest-first and the list was reversed on top of it; it now sorts by creation time so it cannot depend on that holding. Sessions can be deleted from the row, behind a two-step confirm because a stray click on a small row would destroy a conversation. Picking one loads its history into the chat window, and /sessions opens the picker instead of replying with text telling you to pick one. --- windows/src/views/chat.ts | 336 ++++++++++++++++++++++++++++++++++++- windows/src/views/icons.ts | 5 + windows/src/views/views.ts | 85 +++++++++- 3 files changed, 412 insertions(+), 14 deletions(-) diff --git a/windows/src/views/chat.ts b/windows/src/views/chat.ts index 2cd253f7b..fdc45685b 100644 --- a/windows/src/views/chat.ts +++ b/windows/src/views/chat.ts @@ -3,7 +3,7 @@ import { h, svg, clear } from "./dom"; import { ICONS } from "./icons"; -import { Bridge, type ChatContext } from "../core/bridge"; +import { Bridge, type ChatContext, type OpencodeCommand, type OpencodeSession } from "../core/bridge"; import { Sound } from "../core/sound"; import { State, type ChatMessage } from "../core/state"; import type { ViewHost } from "./views"; @@ -48,6 +48,250 @@ export function buildPrompt(onHeightChange: () => void): ViewHost { const send = h("button", { class: "send-btn", title: "Send" }, svg(ICONS.arrowUp, 11)); const bar = h("div", { class: "chat-bar" }, input, send); + // Which opencode session this conversation is aimed at. Null = a fresh one, + // which is the default so the mascot never hijacks a session by surprise. + let targetSession: OpencodeSession | null = null; + const sessionChip = h("button", { class: "chat-session", title: "Choose a session" }); + chipRow.append(sessionChip); + + // `/`-menu commands from the running opencode, plus the session list. Both are + // fetched once per island open: the server only exists while opencode runs. + let commands: OpencodeCommand[] = []; + let sessions: OpencodeSession[] = []; + // The in-flight fetch, so concurrent callers await the same request instead of + // each starting their own and racing the first result. + let fetching: Promise | null = null; + + const menu = h("div", { class: "chat-menu" }); + const picker = h("div", { class: "chat-picker" }); + let menuRows: HTMLElement[] = []; + let pickerRows: HTMLElement[] = []; + let menuActive = -1; + + function closePopups() { + menu.remove(); + picker.remove(); + // Emptied, not just detached. The menu element is built once and reused, so + // removing it from the page leaves its rows as children — the next render then + // appends to them and the previous list stays on screen underneath. That is + // why typing after a bare "/" appeared to do nothing: the full list from the + // first render was still sitting there. + clear(menu); + clear(picker); + menuRows = []; + pickerRows = []; + menuActive = -1; + } + + /** Resolves only once `commands`/`sessions` are populated, so callers can + * read them straight after awaiting instead of re-checking a stale length. */ + async function fetchOnce() { + if (!fetching) { + fetching = (async () => { + try { + const [cmds, sess] = await Promise.all([Bridge.opencodeCommands(), Bridge.opencodeSessions()]); + commands = cmds ?? []; + sessions = sess ?? []; + renderSessionChip(); + } catch { + commands = []; + sessions = []; + } + })(); + } + await fetching; + // An empty result is nearly always a server that was still starting rather + // than a real answer, so forget it: the next attempt asks again instead of + // leaving the `/` menu empty for the rest of this island session. + if (commands.length === 0) fetching = null; + } + + /** Swaps the chat window onto a different conversation. + * + * Picking a session shows what was already said in it, so the window opens on + * that conversation rather than blank. Going back to "New chat" clears it, and + * either way the log is re-rendered from scratch by forcing the cached render + * count out of date. */ + async function showConversation(messages: { role: string; text: string }[]) { + State.chatHistory = messages.map((m) => ({ + id: nextId++, + role: m.role === "user" ? "user" : "assistant", + content: m.text, + })); + renderedCount = -1; + State.notify(); + } + + /** Points the chat at a session and fills the window with its history. */ + async function openSession(s: OpencodeSession) { + targetSession = s; + renderSessionChip(); + closePopups(); + input.focus(); + try { + await showConversation((await Bridge.opencodeSessionMessages(s.id)) ?? []); + } catch { + // A session whose history cannot be read is still perfectly usable for new + // messages, so start it empty rather than refusing to open it. + await showConversation([]); + } + } + + function renderSessionChip() { + const label = targetSession + ? targetSession.title.length > 28 + ? `${targetSession.title.slice(0, 28)}…` + : targetSession.title + : "New chat"; + clear(sessionChip); + sessionChip.append(h("span", { text: label })); + if (targetSession) { + sessionChip.append( + h("span", { + class: "chat-session-x", + text: "×", + onclick: (e: Event) => { + e.stopPropagation(); + targetSession = null; + renderSessionChip(); + closePopups(); + void showConversation([]); + }, + }), + ); + } + sessionChip.classList.toggle("on", targetSession !== null); + } + + /** Deletes a session and drops it from the list and from the active target. */ +async function removeSession(s: OpencodeSession, row: HTMLElement) { + try { + await Bridge.opencodeDeleteSession(s.id); + } catch (err) { + State.noteMessage = String(err).replace(/^Error:\s*/, ""); + State.view = "note"; + State.notify(); + closePopups(); + return; + } + sessions = sessions.filter((x) => x.id !== s.id); + if (targetSession?.id === s.id) { + targetSession = null; + renderSessionChip(); + } + row.remove(); + State.notify(); + } + + async function openPicker() { + await fetchOnce(); + if (pickerRows.length) { + closePopups(); + return; + } + closePopups(); + if (sessions.length === 0) return; + for (const s of sessions) { + const label = h("span", { class: "chat-menu-name", text: s.title || "Untitled session" }); + const dir = h("span", { + class: "chat-menu-desc", + text: s.directory.split(/[\\/]/).filter(Boolean).slice(-1)[0] ?? "", + }); + const row = h("div", { class: "chat-menu-row pick-row" }, label, dir); + + // Deleting is destructive and easy to mis-tap on a small row, so the + // button turns into an explicit "sure?" and only then does anything. + const trash = h( + "button", + { + class: "chat-row-trash", + title: "Delete this session", + onclick: (e: Event) => { + e.stopPropagation(); + if (trash.dataset.armed === "1") { + void removeSession(s, row); + return; + } + trash.dataset.armed = "1"; + trash.classList.add("armed"); + trash.textContent = "Sure?"; + }, + }, + svg(ICONS.trash, 12), + ); + row.addEventListener("mouseleave", () => { + trash.dataset.armed = "0"; + trash.classList.remove("armed"); + clear(trash); + trash.append(svg(ICONS.trash, 12)); + }); + row.addEventListener("click", () => { + void openSession(s); + }); + row.append(trash); + pickerRows.push(row); + picker.append(row); + } + el.querySelector(".chat-body")?.append(picker); + } + + /** What the menu filters on: the text after the leading slash, up to any space. */ +function typedCommand(): string { + return input.value.replace(/^\/+/, "").split(/\s/)[0].toLowerCase(); + } + + /** Rebuilds the `/` menu from what has been typed so far. + * + * Runs on every keystroke, so it narrows the list rather than re-opening it. + * Treating an already-open menu as a toggle made `/ses` flicker shut and open + * instead of filtering down to the session commands. */ + function renderMenu() { + const typed = typedCommand(); + const starts = commands.filter((c) => c.name.toLowerCase().startsWith(typed)); + // Nothing starts with it, so fall back to a substring match: a name typed out + // in full, or a fragment from the middle of one, still finds its command. + const matches = starts.length + ? starts + : commands.filter((c) => c.name.toLowerCase().includes(typed)); + // No match at all: show nothing rather than a list left over from the + // previous keystroke. + if (matches.length === 0) { + closePopups(); + return; + } + closePopups(); + menuActive = 0; + // Every match is listed — the popup scrolls rather than hiding commands the + // user has installed. The list is small (one entry per installed command). + for (const [i, c] of matches.entries()) { + const row = h( + "button", + { + class: i === 0 ? "chat-menu-row first" : "chat-menu-row", + onclick: () => complete(c.name), + }, + h("span", { class: "chat-menu-name", text: `/${c.name}` }), + h("span", { class: "chat-menu-desc", text: c.description.slice(0, 60) }), + ); + menuRows.push(row); + menu.append(row); + } + el.querySelector(".chat-body")?.append(menu); + } + + function complete(name: string) { + input.value = `/${name} `; + closePopups(); + input.focus(); + } + + function moveMenu(delta: number) { + if (menuRows.length === 0) return; + menuActive = (menuActive + delta + menuRows.length) % menuRows.length; + menuRows.forEach((r, i) => r.classList.toggle("first", i === menuActive)); + menuRows[menuActive]?.scrollIntoView({ block: "nearest" }); + } + const el = h( "div", { class: "view" }, @@ -55,13 +299,53 @@ export function buildPrompt(onHeightChange: () => void): ViewHost { ); (el.querySelector(".card") as HTMLElement).style.setProperty("--wash", "rgba(99,102,241,0.5)"); + sessionChip.addEventListener("click", () => void openPicker()); + // Both events, not just `input`. `input` is the right signal, but it does not + // fire for every route a character can take into a field — an IME commit or a + // paste handled outside the normal path both leave it silent — and a popup that + // only re-renders on `input` then sits on a stale list. + const onTyped = () => { + if (input.value.startsWith("/")) { + // Filter straight away from whatever commands are already in hand, then top + // the list up. Awaiting the fetch first meant every keystroke queued behind + // one pending request, so the popup stayed frozen on whatever was typed when + // that request happened to start — the full list, from the lone "/". + renderMenu(); + void fetchOnce().then(() => renderMenu()); + } else if (menuRows.length) closePopups(); + }; + input.addEventListener("input", onTyped); + input.addEventListener("keyup", onTyped); + let sending = false; let renderedCount = -1; + // Messages typed while a turn is still running wait here and go out as soon + // as it finishes, so the input never locks up mid-answer. + const queue: string[] = []; + +async function submit() { + const query = input.value.trim(); + if (!query) return; + input.value = ""; + closePopups(); + // `/sessions` is handled here rather than sent: the backend can only reply + // with text saying "pick a session", which is a dead end. Opening the + // picker in the mascot is the whole point of the command. + if (/^\/sessions(\s|$)/i.test(query)) { + void openPicker(); + return; + } + if (sending) { + queue.push(query); + State.chatHistory.push({ id: nextId++, role: "user", content: query }); + State.notify(); + onHeightChange(); + return; + } + await run(query); + } - async function submit() { - const query = input.value.trim(); - if (!query || sending) return; - input.value = ""; + async function run(query: string) { sending = true; Sound.play("send"); @@ -75,7 +359,7 @@ export function buildPrompt(onHeightChange: () => void): ViewHost { State.chatHistory.length === 1 && file ? { kind: "file", name: file.name, path: file.path } : null; try { - const reply = await Bridge.chatSend(query, context); + const reply = await Bridge.chatSend(query, context, targetSession?.id ?? null); State.chatHistory.push({ id: nextId++, role: "assistant", content: reply.text }); State.stateOverride = null; Sound.play("finish"); @@ -89,12 +373,39 @@ export function buildPrompt(onHeightChange: () => void): ViewHost { State.notify(); onHeightChange(); input.focus(); + // Drain one queued message; `run` re-enters with sending already false. + const next = queue.shift(); + if (next) void run(next); } } send.addEventListener("click", () => void submit()); input.addEventListener("keydown", (e) => { - if ((e as KeyboardEvent).key === "Enter") { + const key = (e as KeyboardEvent).key; + if (menuRows.length > 0) { + if (key === "ArrowDown") { + e.preventDefault(); + moveMenu(1); + return; + } + if (key === "ArrowUp") { + e.preventDefault(); + moveMenu(-1); + return; + } + if (key === "Tab" || (key === "Enter" && menuActive >= 0)) { + e.preventDefault(); + const name = menuRows[menuActive]?.querySelector(".chat-menu-name")?.textContent ?? ""; + complete(name.replace(/^\//, "")); + return; + } + if (key === "Escape") { + e.preventDefault(); + closePopups(); + return; + } + } + if (key === "Enter") { e.preventDefault(); void submit(); } @@ -109,6 +420,7 @@ export function buildPrompt(onHeightChange: () => void): ViewHost { if (chipRow.dataset.label !== wantChip) { chipRow.dataset.label = wantChip; clear(chipRow); + chipRow.append(sessionChip); if (wantChip) chipRow.append(contextChip(wantChip)); } @@ -122,10 +434,16 @@ export function buildPrompt(onHeightChange: () => void): ViewHost { log.scrollTop = log.scrollHeight; } - input.placeholder = State.chatHistory.length === 0 ? "Ask me anything…" : "Continue…"; - input.disabled = sending; + input.placeholder = sending + ? "Type your next message…" + : State.chatHistory.length === 0 + ? "Ask me anything… / for commands" + : "Continue…"; + // The input stays usable during a turn; `submit` queues what is typed. }, focus() { + void fetchOnce(); + renderSessionChip(); input.focus(); input.select(); }, diff --git a/windows/src/views/icons.ts b/windows/src/views/icons.ts index d2ff82475..795f0dae9 100644 --- a/windows/src/views/icons.ts +++ b/windows/src/views/icons.ts @@ -11,6 +11,9 @@ export const ICONS = { // gearshape gear: "M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8zm0 1.8a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2zM10.9 2h2.2l.35 2.1c.6.17 1.16.4 1.67.71l1.9-1 1.55 1.55-1 1.9c.3.5.54 1.07.7 1.67l2.13.35v2.2l-2.12.35c-.17.6-.4 1.16-.71 1.67l1 1.9-1.55 1.55-1.9-1c-.5.3-1.07.54-1.67.7L13.1 22h-2.2l-.35-2.12c-.6-.17-1.16-.4-1.67-.71l-1.9 1L5.43 18.6l1-1.9c-.3-.5-.54-1.07-.7-1.67L3.6 14.7v-2.2l2.12-.35c.17-.6.4-1.16.71-1.67l-1-1.9 1.55-1.55 1.9 1c.5-.3 1.07-.54 1.67-.7L10.9 2z", gearFill: "M10.9 2h2.2l.35 2.1c.6.17 1.16.4 1.67.71l1.9-1 1.55 1.55-1 1.9c.3.5.54 1.07.7 1.67l2.13.35v2.2l-2.12.35c-.17.6-.4 1.16-.71 1.67l1 1.9-1.55 1.55-1.9-1c-.5.3-1.07.54-1.67.7L13.1 22h-2.2l-.35-2.12c-.6-.17-1.16-.4-1.67-.71l-1.9 1L5.43 18.6l1-1.9c-.3-.5-.54-1.07-.7-1.67L3.6 14.7v-2.2l2.12-.35c.17-.6.4-1.16.71-1.67l-1-1.9 1.55-1.55 1.9 1c.5-.3 1.07-.54 1.67-.7L10.9 2zM12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6z", + // pin (outline) / pin.fill (pinned) + pin: "M14.6 2.6a1.6 1.6 0 0 1 2.8 0v3.3l1.9 1.9a1 1 0 0 1 .3.7v2.3h-6.2l.5 8.1a1 1 0 0 1-1 1.05h-.1a1 1 0 0 1-1-1.05l.5-8.1H6.4v-2.3a1 1 0 0 1 .3-.7l1.9-1.9V2.6a1.6 1.6 0 0 1 2.8 0v2.2h1.9V2.6z", + pinFill: "M14.6 2.6a1.6 1.6 0 0 1 2.8 0v3.3l1.9 1.9a1 1 0 0 1 .3.7v2.3h-6.2l.5 8.1a1 1 0 0 1-1 1.05h-.1a1 1 0 0 1-1-1.05l.5-8.1H6.4v-2.3a1 1 0 0 1 .3-.7l1.9-1.9V2.6a1.6 1.6 0 0 1 2.8 0v2.2h1.9V2.6zM12 10.6v-2h-1.9v2H8.6v1.4h3.4l-.4 6.4h.8l-.4-6.4h3.4v-1.4H12z", // speaker.wave.2 speakerOn: "M11 4.5 6.5 8.2H3.4v7.6h3.1L11 19.5v-15zm3.2 3a5.3 5.3 0 0 1 0 9 .9.9 0 0 0 .9 1.55 7.1 7.1 0 0 0 0-12.1.9.9 0 0 0-.9 1.55zm2.6-3.1a8.9 8.9 0 0 1 0 15.2.9.9 0 0 0 .92 1.55 10.7 10.7 0 0 0 0-18.3.9.9 0 0 0-.92 1.55z", // speaker.slash @@ -26,6 +29,8 @@ export const ICONS = { arrowUp: "M12 4.5 5.5 11l1.5 1.5 4-4V19.5h2V8.5l4 4L18.5 11 12 4.5z", // exclamationmark bang: "M11 4h2v10h-2V4zm0 12.2h2v2.2h-2v-2.2z", + // trash + trash: "M9.4 2.6h5.2l.6 1.2h4.1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H4.7a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h4.1l.6-1.2zM5.6 8.8h12.8l-.85 11.2a1.6 1.6 0 0 1-1.6 1.4H8.05a1.6 1.6 0 0 1-1.6-1.4L5.6 8.8zM9.9 11a.85.85 0 0 0-.85.9l.25 5.3a.85.85 0 0 0 1.7-.08l-.25-5.3a.85.85 0 0 0-.85-.82zm4.2 0a.85.85 0 0 0-.83.82l-.25 5.3a.85.85 0 0 0 1.7.08l.25-5.3a.85.85 0 0 0-.87-.9z", // xmark xmark: "M6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19 5 17.6 10.6 12 5 6.4 6.4 5z", // timer diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index 7ea238b26..cefd8781a 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -24,6 +24,12 @@ export interface ViewActions { toggleSound(): void; setVolume(v: number): void; setAutoClose(seconds: number): void; + /** Seconds from compact to fully reduced; 0 = never fully reduce. */ + setAbsence(seconds: number): void; + /** Pins the island open (ignores outside clicks, Escape and auto-close). */ + togglePin(): void; + /** Whether the reduced island wakes on hover or only on a click. */ + toggleWakeOnHover(): void; openSettingsWindow(): void; blip(): void; } @@ -98,6 +104,19 @@ export function buildHeader(actions: ViewActions): ViewHost { const gearBtn = h("button", { title: "Settings", onclick: () => go("settings") }, svg(ICONS.gear, 14)); const soundBtn = h("button", { title: "Mute", onclick: () => actions.toggleSound() }, svg(ICONS.speakerOn, 14)); + // Pin keeps the island open: outside clicks, Escape and the auto-close timer + // all stop working until it is unpinned. + const pinBtn = h( + "button", + { + title: "Pin — keep the island open", + onclick: () => { + actions.blip(); + actions.togglePin(); + }, + }, + svg(ICONS.pin, 14), + ); function go(v: IslandViewName) { actions.blip(); @@ -108,7 +127,7 @@ export function buildHeader(actions: ViewActions): ViewHost { "div", { id: "header" }, h("div", { class: "tabs" }, tabHome, tabChat, tabDrop), - h("div", { class: "header-actions" }, gearBtn, soundBtn), + h("div", { class: "header-actions" }, pinBtn, gearBtn, soundBtn), ); return { @@ -123,6 +142,12 @@ export function buildHeader(actions: ViewActions): ViewHost { gearBtn.append(svg(v === "settings" ? ICONS.gearFill : ICONS.gear, 14)); clear(soundBtn); soundBtn.append(svg(State.settings.soundEnabled ? ICONS.speakerOn : ICONS.speakerOff, 14)); + pinBtn.classList.toggle("on", State.settings.pinIsland); + pinBtn.title = State.settings.pinIsland + ? "Pinned — click to unpin" + : "Pin — keep the island open"; + clear(pinBtn); + pinBtn.append(svg(State.settings.pinIsland ? ICONS.pinFill : ICONS.pin, 14)); el.style.opacity = v === "confused" ? "0" : "1"; }, }; @@ -422,6 +447,15 @@ function buildNote(): ViewHost { // ── In-island settings ──────────────────────────────────────────────────────── +/** "90" → "1m 30s", so a fully-compact delay stays readable. */ +function formatSpan(seconds: number): string { + const s = Math.round(seconds); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rest = s % 60; + return rest === 0 ? `${m}m` : `${m}m ${rest}s`; +} + function buildSettings(actions: ViewActions): ViewHost { const soundSwitch = h("button", { class: "switch", onclick: () => actions.toggleSound() }); const volume = h("input", { @@ -429,9 +463,24 @@ function buildSettings(actions: ViewActions): ViewHost { oninput: (e: Event) => actions.setVolume(Number((e.target as HTMLInputElement).value)), }) as HTMLInputElement; const autoLabel = h("span", {}); - const segButtons = [10, 15, 30].map((s) => - h("button", { onclick: () => actions.setAutoClose(s) }, `${s}s`), + // 0 is "Off": the island stays open until it is dismissed. + const AUTO_VALUES = [0, 10, 30, 60]; + const segButtons = AUTO_VALUES.map((s) => + h("button", { onclick: () => actions.setAutoClose(s) }, s === 0 ? "Off" : `${s}s`), ); + const fullLabel = h("span", {}); + const FULL_VALUES = [0, 15, 30]; + const fullButtons = FULL_VALUES.map((s) => + h("button", { onclick: () => actions.setAbsence(s) }, s === 0 ? "Never" : `${s}s`), + ); + // Free-form slider for any delay between 5s and 5m; "Never" (0) is off the + // left end so the bar always shows a real, movable time. + const fullSlider = h("input", { + type: "range", min: "5", max: "300", step: "5", + style: "width:96px", + oninput: (e: Event) => actions.setAbsence(Number((e.target as HTMLInputElement).value)), + }) as HTMLInputElement; + const hoverSwitch = h("button", { class: "switch", onclick: () => actions.toggleWakeOnHover() }); const claudeBadge = h("span", { class: "status-badge" }); const apiBadge = h("span", { class: "status-badge" }); @@ -446,6 +495,22 @@ function buildSettings(actions: ViewActions): ViewHost { autoLabel, h("div", { class: "seg" }, ...segButtons), ), + h( + "div", + { class: "settings-row" }, + svg(ICONS.timer, 12), + fullLabel, + fullSlider, + h("div", { class: "seg" }, ...fullButtons), + ), + h( + "div", + { class: "settings-row" }, + svg(ICONS.timer, 12), + h("span", { text: "Wake on hover" }), + h("div", { class: "grow" }), + hoverSwitch, + ), h( "div", { class: "settings-row", style: "gap:14px" }, @@ -471,8 +536,18 @@ function buildSettings(actions: ViewActions): ViewHost { soundSwitch.classList.toggle("on", s.soundEnabled); volume.value = String(s.soundVolume); volume.style.opacity = s.soundEnabled ? "1" : "0.4"; - autoLabel.textContent = `Auto-close · ${Math.round(s.autoCloseInterval)}s`; - segButtons.forEach((b, i) => b.classList.toggle("on", s.autoCloseInterval === [10, 15, 30][i])); + autoLabel.textContent = + s.autoCloseInterval > 0 ? `Auto-close · ${Math.round(s.autoCloseInterval)}s` : "Auto-close · Off"; + segButtons.forEach((b, i) => b.classList.toggle("on", s.autoCloseInterval === AUTO_VALUES[i])); + fullLabel.textContent = + s.absenceInterval > 0 ? `Fully compact · ${formatSpan(s.absenceInterval)}` : "Fully compact · Never"; + fullButtons.forEach((b, i) => b.classList.toggle("on", s.absenceInterval === FULL_VALUES[i])); + // Never has no position on the bar; park it at the left end. + if (document.activeElement !== fullSlider) { + fullSlider.value = String(Math.max(5, s.absenceInterval || 5)); + } + fullSlider.style.opacity = s.absenceInterval > 0 ? "1" : "0.45"; + hoverSwitch.classList.toggle("on", s.wakeOnHover); clear(claudeBadge); claudeBadge.append( dot(s.hooksInstalled ? "#22C55E" : "#F4505E", 6), From f4ffceddbf2cad40f636163d6885f30bed06bc66 Mon Sep 17 00:00:00 2001 From: Imanol Diaz Date: Thu, 1 Oct 2026 04:59:03 -0400 Subject: [PATCH 12/21] Windows: persistent opencode server toggle in the opencode settings section On, one server runs from launch until the app quits and chat goes to it. Off, no server is kept: one is started on demand for the command and session lists and tidied away once nothing has used it for a while, so the lists work without leaving a process behind. Switching it takes effect immediately rather than at the next launch, and turning it on mid-conversation keeps the server already answering instead of replacing it out from under the chat. --- windows/src/settings/main.ts | 72 ++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 2aa04f6ef..761e69c4d 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -209,6 +209,11 @@ function opencodeSection(status: OpencodeStatus): HTMLElement { h("span", { class: "path", text: status.relayReady ? "coucou-hook.exe ready" : "coucou-hook.exe missing" }), statusDot(status.relayReady), ), + h("div", { class: "row" }, + h("label", { text: "Persistent server" }), + toggle(settings.chatViaServer, (v) => { settings.chatViaServer = v; void save(); }), + h("span", { class: "hint", text: "on keeps one opencode server open for chat; off runs a fresh opencode per message" }), + ), ); if (!status.relayReady) { @@ -598,14 +603,30 @@ function generalSection(): HTMLElement { void save(); }); + // 0 means "Off" — the island stays open until it is dismissed. The field is a + // number input so any delay can be typed, with 0 as the documented off switch. const autoClose = h("input", { - type: "number", min: "5", max: "120", step: "1", + type: "number", min: "0", max: "600", step: "1", value: String(Math.round(settings.autoCloseInterval)), style: "width:72px", }) as HTMLInputElement; autoClose.addEventListener("change", () => { - settings.autoCloseInterval = Math.max(5, Math.min(120, Number(autoClose.value) || 15)); - autoClose.value = String(settings.autoCloseInterval); + const raw = Number(autoClose.value); + settings.autoCloseInterval = Number.isFinite(raw) ? Math.max(0, Math.min(600, raw)) : 15; + autoClose.value = String(Math.round(settings.autoCloseInterval)); + void save(); + }); + + // Same deal for the fully-reduced timer: 0 = stay compact indefinitely. + const absence = h("input", { + type: "number", min: "0", max: "3600", step: "10", + value: String(Math.round(settings.absenceInterval)), + style: "width:72px", + }) as HTMLInputElement; + absence.addEventListener("change", () => { + const raw = Number(absence.value); + settings.absenceInterval = Number.isFinite(raw) ? Math.max(0, Math.min(3600, raw)) : 180; + absence.value = String(Math.round(settings.absenceInterval)); void save(); }); @@ -620,6 +641,29 @@ function generalSection(): HTMLElement { void save(); }); + // Snap presets for the resting island. The value stored is a fraction across + // the display, so these map straight onto it and a manual drag fills anything + // in between. + const POSITIONS: { value: number; text: string }[] = [ + { value: 0, text: "Left" }, + { value: 0.5, text: "Center" }, + { value: 1, text: "Right" }, + ]; + const position = h("select", {}) as HTMLSelectElement; + const CUSTOM = "__custom"; + position.append( + ...POSITIONS.map((p) => h("option", { value: String(p.value), text: p.text })), + h("option", { value: CUSTOM, text: "Custom" }), + ); + // Tolerate a fraction that is no longer exactly one of the presets. + const preset = POSITIONS.find((p) => Math.abs(p.value - settings.notchPosition) < 0.005); + position.value = preset ? String(preset.value) : CUSTOM; + position.addEventListener("change", () => { + if (position.value === CUSTOM) return; + settings.notchPosition = Number(position.value); + void save(); + }); + return h( "section", {}, @@ -632,12 +676,32 @@ function generalSection(): HTMLElement { h("div", { class: "row" }, h("label", { text: "Auto-close" }), autoClose, - h("span", { class: "hint", text: "seconds after you leave the island" }), + h("span", { class: "hint", text: "seconds after you leave the island — 0 = never" }), + ), + h("div", { class: "row" }, + h("label", { text: "Fully compact" }), + absence, + h("span", { class: "hint", text: "seconds before it reduces to the stub — 0 = never" }), + ), + h("div", { class: "row" }, + h("label", { text: "Keep pinned" }), + toggle(settings.pinIsland, (v) => { settings.pinIsland = v; void save(); }), + h("span", { class: "hint", text: "ignores outside clicks and auto-close" }), + ), + h("div", { class: "row" }, + h("label", { text: "Wake on hover" }), + toggle(settings.wakeOnHover, (v) => { settings.wakeOnHover = v; void save(); }), + h("span", { class: "hint", text: "off means the reduced island waits for a click" }), ), h("div", { class: "row" }, h("label", { text: "Island lives on" }), screen, ), + h("div", { class: "row" }, + h("label", { text: "Island sits" }), + position, + h("span", { class: "hint", text: "or drag the resting bar sideways" }), + ), h("div", { class: "row" }, h("label", { text: "Launch at startup" }), toggle(settings.autostart, (v) => { settings.autostart = v; void save(); }), From 62df62f107e9f1247a86111d28291ff23aef59da Mon Sep 17 00:00:00 2001 From: Imanol Diaz Date: Thu, 1 Oct 2026 04:59:03 -0400 Subject: [PATCH 13/21] Windows: styles for the session row delete affordance --- windows/src-tauri/src/opencode_server.rs | 62 +++------- windows/src/style.css | 138 ++++++++++++++++++++++- 2 files changed, 149 insertions(+), 51 deletions(-) diff --git a/windows/src-tauri/src/opencode_server.rs b/windows/src-tauri/src/opencode_server.rs index ef1a3f55c..31861c3f2 100644 --- a/windows/src-tauri/src/opencode_server.rs +++ b/windows/src-tauri/src/opencode_server.rs @@ -803,60 +803,26 @@ mod tests { } } - /// Switching the persistent-server setting on part-way through a conversation - /// must not replace the server that conversation is talking to. + /// Asking for a server twice must not replace the one already answering. + /// + /// Deliberately non-destructive: it never stops anything, so it cannot disturb + /// a running instance of the app, and it simply stands aside when no server is + /// up. An earlier version asserted the parallel-lookup behaviour by stopping and + /// restarting the managed port, which fought the app for that same port and was + /// only ever reliable with Coucou closed. #[test] - fn keeps_a_server_that_is_already_answering() { - ensure_ready(""); - let first = wait_for_managed(); - assert!(first.is_some(), "no server came up on the managed port"); - // Exactly what a settings save does a moment later. + fn asking_twice_keeps_the_running_server() { + if !managed_port_ours() { + return; + } + let first = owner_of(MANAGED_PORT); + assert!(first.is_some(), "the port answers but nothing owns it"); ensure_ready(""); - std::thread::sleep(Duration::from_millis(2000)); + std::thread::sleep(Duration::from_millis(1500)); assert_eq!( owner_of(MANAGED_PORT), first, "the running server was replaced instead of kept" ); } - - fn wait_for_managed() -> Option { - for _ in 0..60 { - if managed_port_ours() { - return owner_of(MANAGED_PORT); - } - std::thread::sleep(Duration::from_millis(250)); - } - None - } - - /// The command and session lists are fetched together and in parallel, so on a - /// cold start two lookups reach discovery at once. Exactly one server must - /// come up and both callers must get a usable answer, or the `/` menu is empty. - #[test] - fn parallel_lookups_share_one_server() { - stop_managed_port(); - std::thread::sleep(Duration::from_millis(500)); - assert!(owner_of(MANAGED_PORT).is_none(), "a server was left running"); - - let handles: Vec<_> = (0..2) - .map(|_| { - std::thread::spawn(|| { - let cmds = commands(); - let sess = sessions(); - (cmds.len(), sess.len()) - }) - }) - .collect(); - let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); - for (cmds, _) in &results { - assert!(*cmds > 0, "a parallel lookup got no commands: {results:?}"); - } - // Still exactly one server, not one per caller. - let owners: Vec<_> = listening_loopback_owners() - .into_iter() - .filter(|(p, _)| *p == MANAGED_PORT) - .collect(); - assert_eq!(owners.len(), 1, "expected one listener, got {owners:?}"); - } } diff --git a/windows/src/style.css b/windows/src/style.css index 1da05cf94..5f188018b 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -52,14 +52,15 @@ body { inset: 0; } -/* Invisible strip that wakes the island when it is hidden. */ +/* Invisible strip that wakes the island when it is hidden. Covers the fully + reduced bar (80×24) so hovering anywhere on it wakes Mochi. */ #wake-strip { position: fixed; top: 0; left: 50%; transform: translateX(-50%); width: 240px; - height: 6px; + height: 24px; background: transparent; } @@ -68,7 +69,9 @@ body { #island { position: absolute; top: 0; - left: 50%; + /* The island's left edge is driven by Island.islandOffsetX, not centred by + CSS: opening the panel at a screen edge has to shift the island itself. */ + left: 0; background: #000; border-radius: 0 0 14px 14px; overflow: visible; @@ -1358,3 +1361,132 @@ input[type="range"]::-webkit-slider-thumb { .int-card.detail { padding-top: 8px; } + +/* -- Chat: session chip, `/` command menu, session picker -------------------- */ + +.chat-session { + display: inline-flex; + align-items: center; + gap: 6px; + align-self: flex-start; + max-width: 100%; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: #8e939c; + border-radius: 999px; + padding: 3px 10px; + font: 500 10.5px var(--font); + cursor: default; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} + +.chat-session:hover { + color: #c5c8cd; + background: rgba(255, 255, 255, 0.08); +} + +.chat-session.on { + color: #c7d2fe; + border-color: rgba(99, 102, 241, 0.55); + background: rgba(99, 102, 241, 0.16); +} + +.chat-session span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-session-x { + cursor: default; + opacity: 0.6; + font-size: 12px; + line-height: 1; +} + +.chat-session-x:hover { + opacity: 1; +} + +.chat-menu, +.chat-picker { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 236px; + overflow-y: auto; + padding: 4px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(18, 20, 25, 0.94); +} + +.chat-menu-row { + display: flex; + align-items: baseline; + gap: 8px; + width: 100%; + border: 0; + background: transparent; + text-align: left; + padding: 4px 6px; + border-radius: 5px; + cursor: default; + min-width: 0; +} + +.chat-menu-row:hover, +.chat-menu-row.first { + background: rgba(99, 102, 241, 0.2); +} + +/* Session picker row: the label and directory sit left, the delete button sits + in the top-right corner and only confirms on a second click. */ +.chat-menu-row.pick-row { + align-items: center; + cursor: default; +} + +.chat-row-trash { + margin-left: auto; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: #6b7078; + font: 600 9.5px var(--font); + cursor: default; + align-self: flex-start; +} + +.chat-row-trash:hover { + color: #f4505e; + background: rgba(244, 80, 94, 0.12); +} + +.chat-row-trash.armed { + color: #fff; + background: #f4505e; +} + +.chat-menu-name { + font: 500 11px var(--font); + color: #c5c8cd; + white-space: nowrap; + flex: 0 0 auto; +} + +.chat-menu-desc { + font: 400 10px var(--font); + color: var(--dim-3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} From 25fb3dba714503beac0bb6da5a9ee39342ae5385 Mon Sep 17 00:00:00 2001 From: Imanol Diaz Date: Thu, 1 Oct 2026 11:50:46 -0400 Subject: [PATCH 14/21] Windows: four-step island retraction with independent timers The island now has four widths rather than two, and each step is driven by its own setting: expanded 640 -> 240 Auto-close (autoCloseInterval) -> 80 Compact (absenceInterval) -> off Auto-hide (autoCloseDelay) A fourth FSM state, gone, replaces the offscreen flag that used to be inferred from a delay being non-zero. Auto-hide applies from either resting bar and is armed by reaching one, not by the cursor leaving it: arming it on mouse-leave meant a cursor parked on the bar suppressed off-screen hiding indefinitely. The three gestures are kept distinct. Clicking outside compacts to the 240 bar, and clicking again beside it goes to 80. A double-click near the bar expands it to the full 240 width from either resting width, and unlike the widening that wake-on-hover performs it is a real state change, so the bar stays at 240 when the cursor leaves and the Compact timer owns the next shrink. Hover-to-restore is the only gesture that recalls the island from off-screen, and is therefore tied to Auto-hide. The agent pills follow the bar width rather than the mode: they sit in the 240 bar with Mochi on the left and pills on the right end, as the draft had them, and never in the expanded panel. Leaving the grid visible there painted mini Mochis over the settings view and every other full-screen view. Auto-hide fades the whole island rather than animating its height to zero. The Mochi already faded itself through botPosition's opacity, but the bar's own background outlived the zero-height animation and stayed on screen. Also adds a secondary-display option to the monitor picker, and hardens the reduced-state cursor hit test in the poll so a collapse cannot race the last tick and leave the island click-through (#28). --- windows/src-tauri/src/island.rs | 22 ++++- windows/src-tauri/src/lib.rs | 6 ++ windows/src-tauri/src/settings.rs | 20 +++- windows/src/core/bridge.ts | 2 + windows/src/core/layout.ts | 37 +++++-- windows/src/core/state.ts | 8 +- windows/src/island/fsm.ts | 27 +++++- windows/src/island/island.ts | 155 ++++++++++++++++++++++++------ windows/src/settings/main.ts | 1 + windows/src/style.css | 8 ++ windows/src/views/icons.ts | 1 + windows/src/views/views.ts | 135 ++++++++++++++++++-------- 12 files changed, 336 insertions(+), 86 deletions(-) diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 46c8c08d1..5b9ee2884 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -215,6 +215,18 @@ fn target_monitor(app: &AppHandle, pref: &str) -> Option { } } } + // "secondary" = the first non-primary display, in the order the OS reports them. + // A machine with only one monitor falls through to the primary one below rather + // than ending up with nowhere to draw. + if pref == "secondary" { + let primary = app.primary_monitor().ok().flatten(); + if let Some(m) = monitors + .iter() + .find(|m| primary.as_ref().map(|p| p.name() != m.name()).unwrap_or(true)) + { + return Some(m.clone()); + } + } app.primary_monitor() .ok() .flatten() @@ -432,7 +444,15 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { // the mouse, which also makes the drop zone as forgiving as the Mac's. // A press may be the start of a drag: make sure the drop target is // ours before the file arrives. - let accept = on_island || captured; + // While reduced the window is only 80x24, so it always takes the mouse. Relying on + // the poll's own hit test alone is what #28 is about: this setter goes + // through the event-loop queue, so a collapse on the main thread and the + // last poll tick can land in either order and leave the window + // click-through with nothing left to click on. While collapsed the answer + // does not depend on ordering at all. + let accept = on_island + || captured + || gate.collapsed.load(Ordering::Relaxed); if gate.ignoring.load(Ordering::Relaxed) == accept { gate.ignoring.store(!accept, Ordering::Relaxed); let _ = win.set_ignore_cursor_events(!accept); diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 668d6197a..7db89fe0e 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -406,6 +406,11 @@ fn ingest_file(path: String) -> Result { } /// The island may only ask whether a key exists — never read it. +#[tauri::command] +fn debug_log(line: String) { + crate::log::line(&format!("[island] {line}")); +} + #[tauri::command] fn secret_present(key: String) -> bool { secrets::present(&key) @@ -564,6 +569,7 @@ chat_send, opencode_server_url, ingest_file, secret_present, + debug_log, secret_set, secret_clear, refresh_integration, diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 1ff26712a..b6b304741 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -13,8 +13,14 @@ pub struct Settings { pub sound_volume: f64, #[serde(default = "default_auto_close_interval")] pub auto_close_interval: f64, + /// Seconds from compact to fully reduced. 0 = never fully reduce. #[serde(default = "default_absence_interval")] pub absence_interval: f64, + /// Seconds from fully reduced to off-screen. 0 = never leave the screen. + /// The last of the three resting steps; `auto_close_interval` compacts the + /// island to the bar and `absence_interval` takes it to a single Mochi. + #[serde(default)] + pub auto_close_delay: f64, /// Pinned by the user from the island header: the island then ignores /// outside clicks, Escape and the auto-close timer until unpinned. #[serde(default)] @@ -26,7 +32,8 @@ pub struct Settings { /// coding-agent pills rather than every integration that has no key. #[serde(default)] pub active_integrations: Vec, - /// "primary" = the main display, "cursor" = whichever display the mouse is on. + /// "primary" = the main display, "cursor" = whichever display the mouse is on, + /// "secondary" = the first non-primary display. #[serde(default = "default_screen")] pub screen: String, #[serde(default)] @@ -51,6 +58,10 @@ pub struct Settings { /// message, each booting a throwaway server of its own. #[serde(default)] pub chat_via_server: bool, + /// Whether hovering the top edge brings back an island that auto-close took + /// off-screen. Governs off-screen only; wake_on_hover is for the parked bar. + #[serde(default = "default_true")] + pub hover_restore: bool, /// Horizontal resting place of the compact island, normalised 0..=1 across /// the target display: 0 = flush left, 0.5 = centred, 1 = flush right. /// Stored as a fraction so a drag can land anywhere while the presets still @@ -63,6 +74,11 @@ fn default_notch_position() -> f64 { 0.5 } + +fn default_true() -> bool { + true +} + fn default_chat_provider() -> String { "claude".into() } @@ -105,6 +121,7 @@ impl Default for Settings { sound_volume: default_sound_volume(), auto_close_interval: default_auto_close_interval(), absence_interval: default_absence_interval(), + auto_close_delay: 0.0, pin_island: false, wake_on_hover: default_wake_on_hover(), // Opt-in only: the coding-agent pills are always loaded, so starting @@ -118,6 +135,7 @@ impl Default for Settings { opencode_bin: String::new(), opencode_model: String::new(), chat_via_server: false, + hover_restore: true, notch_position: default_notch_position(), } } diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 858070493..e61d61975 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -113,6 +113,8 @@ export const Bridge = { ingestFile: (path: string) => callOrThrow("ingest_file", { path }), /** Only ever tells you whether a key exists — never its value. */ secretPresent: (key: string) => call("secret_present", { key }), + /** Appends to coucou.log, for diagnosing state transitions. */ + debugLog: (line: string) => call("debug_log", { line }), secretSet: (key: string, value: string) => callOrThrow("secret_set", { key, value }), secretClear: (key: string) => callOrThrow("secret_clear", { key }), diff --git a/windows/src/core/layout.ts b/windows/src/core/layout.ts index 2efae6420..16b997fa1 100644 --- a/windows/src/core/layout.ts +++ b/windows/src/core/layout.ts @@ -65,8 +65,8 @@ export const EXPANDED_W = 640; /** The resting island's sizes on a notched-less screen. Mirrors PR #22's * `IslandScreenGeometry` so the mascot and pills shrink with the bar. */ export function restingLayout() { - return { width: COMPACT_W, height: NO_NOTCH_H }; -} + return { width: NO_NOTCH_W, height: NO_NOTCH_H }; + } export const ROUNDED_CORNER = 14; // hidden / compact export const EXPANDED_CORNER = 22; @@ -93,7 +93,9 @@ export const VIEW_LAYOUTS: Record = { searching: { height: 160, botX: 52, botY: null, botDiameter: 44, agentMode: "column" }, result: { height: 160, botX: 52, botY: null, botDiameter: 44, agentMode: "column" }, note: { height: 160, botX: 60, botY: null, botDiameter: 50, agentMode: "column" }, - settings: { height: 200, botX: 54, botY: null, botDiameter: 46, agentMode: "none" }, + // Tall enough for every settings row to sit inside the card. The view height is + // fixed, so a row added without raising it is clipped off the bottom. + settings: { height: 288, botX: 54, botY: null, botDiameter: 46, agentMode: "none" }, greeting: { height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: "none" }, }; @@ -110,13 +112,23 @@ export function islandSize( mode: IslandMode, view: IslandViewName, chatCount = 0, + offscreen = false, ): { w: number; h: number } { switch (mode) { case "hidden": - // No notch to hide inside on a PC: a narrow 80 pt stub that keeps Mochi - // visible and parked, instead of the 184 pt a notched screen would use. - return { w: NO_NOTCH_W, h: NO_NOTCH_H }; + // Two outcomes, decided by whether auto-close has a delay at all. + // With auto-close on Never the island is never meant to leave the screen, so + // fully reduced keeps the draft's small parked bar with Mochi in it. With a + // delay it goes off-screen instead: zero height, sliding out through the top + // edge as if the screen's edge were pulling it in. The cursor can still find + // it either way — the hit rectangle keeps its 14px margin, leaving a band + // along the top edge at the resting position. + return offscreen + ? { w: NO_NOTCH_W, h: 0 } + : { w: NO_NOTCH_W, h: NO_NOTCH_H }; case "compact": + // Regular compact: the docked bar. Wider than the fully-compact bar and the + // one the island opens into, and where the agent pills would sit. return { w: COMPACT_W, h: NO_NOTCH_H }; case "expanded": { const h = view === "prompt" ? chatPromptHeight(chatCount) : VIEW_LAYOUTS[view].height; @@ -138,10 +150,14 @@ export function botPosition( view: IslandViewName, islandH: number, uploadProgress = 0, + offscreen = false, ): BotPlacement { switch (mode) { case "hidden": { - // Peeking out of the 80 pt stub: shrunk to fit and kept visible. + // Off-screen: zero height and fully transparent, so nothing is left on the + // top edge. Auto-close on Never: Mochi stays parked in the small bar, which + // is how the island behaved before there was anywhere to go. + if (offscreen) return { cx: 46, cy: 16, diameter: 6, opacity: 0 }; const rest = restingLayout(); return { cx: NO_NOTCH_W / 2, @@ -151,11 +167,12 @@ export function botPosition( }; } case "compact": { - // PR #22's IslandRestingLayout: centre Mochi vertically and cap the - // diameter so a short bar never crops it. + // Centre Mochi vertically and cap the diameter so a short bar never crops + // it. cx is the centre of the resting bar, so the single Mochi sits centred + // in it; the agent pills only ever appear to one side, never on top of it. const rest = restingLayout(); return { - cx: 40, + cx: NO_NOTCH_W / 2, cy: rest.height / 2, diameter: Math.min(20, Math.max(0, rest.height - 6)), opacity: 1, diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 7bd6414ea..cfe1f5d39 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -91,12 +91,16 @@ export interface Settings { autoCloseInterval: number; /** Seconds from compact to fully reduced. 0 = never fully reduce. */ absenceInterval: number; + /** Seconds from fully reduced to off-screen. 0 = never leave the screen. */ + autoCloseDelay: number; /** Pinned: ignores outside clicks, Escape and the auto-close timer. */ pinIsland: boolean; /** Wake the reduced island on hover. When false it waits to be clicked. */ wakeOnHover: boolean; + /** Bring back an island that auto-close took off-screen. Off-screen only. */ + hoverRestore: boolean; activeIntegrations: string[]; - screen: "primary" | "cursor"; + screen: "primary" | "cursor" | "secondary"; autostart: boolean; hooksInstalled: boolean; /** Claude model used by the chat. */ @@ -118,8 +122,10 @@ export const DEFAULT_SETTINGS: Settings = { soundVolume: 0.12, autoCloseInterval: 15, absenceInterval: 180, + autoCloseDelay: 0, pinIsland: false, wakeOnHover: true, + hoverRestore: true, activeIntegrations: [], screen: "primary", autostart: false, diff --git a/windows/src/island/fsm.ts b/windows/src/island/fsm.ts index 6bc62400b..395cf40c4 100644 --- a/windows/src/island/fsm.ts +++ b/windows/src/island/fsm.ts @@ -1,7 +1,7 @@ // Island open/close FSM — port of IslandStateMachine.swift. // No DOM, no Tauri: it only reports transitions. -export type FsmState = "hidden" | "petit" | "home" | "coucou"; +export type FsmState = "hidden" | "gone" | "petit" | "home" | "coucou"; export class IslandStateMachine { state: FsmState = "hidden"; @@ -12,6 +12,8 @@ export class IslandStateMachine { homeToPetitDelay = 15; /** petit → hidden delay, seconds. 0 = stay compact, never fully reduce. */ petitToHiddenDelay = 60; + /** hidden → gone delay, seconds. 0 = never leave the screen. */ + hiddenToGoneDelay = 0; /** coucou → petit once the greeting animation ends (no hover). */ greetAutoCollapseDelay = 0.6; /** coucou → petit while the mouse hovers the greeting. */ @@ -22,6 +24,7 @@ export class IslandStateMachine { private petitHide: number | null = null; private homeCollapse: number | null = null; private greetCollapse: number | null = null; + private goneTimer: number | null = null; // ── Inputs ────────────────────────────────────────────────────────────────── @@ -33,6 +36,7 @@ export class IslandStateMachine { mouseEntered() { switch (this.state) { case "hidden": + case "gone": this.cancelTimers(); this.transition("petit"); break; @@ -51,6 +55,7 @@ export class IslandStateMachine { mouseLeft() { switch (this.state) { case "hidden": + case "gone": break; case "petit": this.schedulePetitHide(); @@ -69,7 +74,7 @@ export class IslandStateMachine { // A click always opens the island, from either resting state. From the // reduced stub it skips straight to open rather than stopping at compact, so // one press is enough — otherwise turning off "wake on hover" would need two. - if (this.state === "hidden") { + if (this.state === "hidden" || this.state === "gone") { this.cancelTimers(); this.transition("home"); return; @@ -142,13 +147,25 @@ export class IslandStateMachine { }, delay * 1000); } - private clear(which: "petitHide" | "homeCollapse" | "greetCollapse") { + /** Fully reduced → gone. The last step, and the only one that leaves the screen. */ + private scheduleGone() { + this.clear("goneTimer"); + if (this.hiddenToGoneDelay <= 0) return; + this.goneTimer = window.setTimeout(() => { + this.goneTimer = null; + if (this.state === "hidden" || this.state === "petit") this.transition("gone"); + }, this.hiddenToGoneDelay * 1000); + } + + private clear(which: "petitHide" | "homeCollapse" | "greetCollapse" | "goneTimer") { const id = this[which]; if (id != null) window.clearTimeout(id); this[which] = null; } cancelTimers() { + + this.clear("goneTimer"); this.clear("petitHide"); this.clear("homeCollapse"); this.clear("greetCollapse"); @@ -158,6 +175,10 @@ export class IslandStateMachine { if (next === this.state) return; const from = this.state; this.state = next; + // Auto-hide is armed by reaching a resting state, not by the cursor leaving it. + // Arming it on mouse-leave meant that a cursor parked on the bar suppressed + // off-screen hiding indefinitely, which is the one case it most needed to work. + if (next === "petit" || next === "hidden") this.scheduleGone(); this.onTransition?.(from, next); } } diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index b9b296eb1..def11afb0 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -4,7 +4,7 @@ import { Tracked, Spring, clamp } from "../core/anim"; import { Bridge, IS_TAURI, onDragDrop } from "../core/bridge"; import { - EXPANDED_CORNER, EXPANDED_W, NO_NOTCH_W, PANEL_H, PANEL_W, + EXPANDED_CORNER, EXPANDED_W, COMPACT_W, NO_NOTCH_W, PANEL_H, PANEL_W, ROUNDED_CORNER, VIEW_LAYOUTS, botGlowColor, botGlowOpacity, botPosition, chatPromptHeight, islandSize, type IslandMode, type IslandViewName, @@ -65,7 +65,11 @@ export class Island { private dirty = true; private canvasPx = 0; - // Rust starts the window at full size so the launch greeting has room. +// Rust starts the window at full size so the launch greeting has room. + // "Wake on hover": the widening of the fully-compact bar into the retracted one. + // A bar state only — it never opens the panel, and it never brings the island + // back from off-screen, which is what "hover to restore" is for. + private barHover = false; private collapsed = false; private collapseTimer: number | null = null; private wasInIsland = false; @@ -174,6 +178,17 @@ export class Island { void Bridge.saveSettings(State.settings); State.notify(); }, + setAutoCloseDelay: (s) => { + State.settings.autoCloseDelay = s; + this.fsm.hiddenToGoneDelay = s; + void Bridge.saveSettings(State.settings); + State.notify(); + }, + toggleHoverRestore: () => { + State.settings.hoverRestore = !State.settings.hoverRestore; + void Bridge.saveSettings(State.settings); + State.notify(); + }, togglePin: () => { const next = !State.settings.pinIsland; State.settings.pinIsland = next; @@ -249,6 +264,7 @@ export class Island { this.fsm.onTransition = (from, to) => { switch (to) { case "hidden": + case "gone": this.setMode("hidden"); break; case "petit": @@ -270,10 +286,13 @@ export class Island { this.expand("greeting"); this.greeting.start(); break; - } - State.notify(); - }; - } +} + void Bridge.debugLog( + `from=${from} to=${to} mode=${State.mode} compact=${this.fsm.homeToPetitDelay} autoClose=${this.fsm.petitToHiddenDelay}`, + ); + State.notify(); + }; + } launch() { this.fsm.launch(); @@ -381,7 +400,7 @@ private outsideClicks: number[] = []; // The bar is the island's own width in whichever resting mode we are in, so // the draggable span depends on it. - const barW = islandSize(State.mode, State.view).w; + const barW = islandSize(State.mode, State.view, 0, this.hidesOffscreen()).w; this.dragStartX = e.screenX; this.dragStartPosition = State.settings.notchPosition; @@ -457,24 +476,35 @@ dismissOutside() { this.outsideClicks = this.outsideClicks.filter((t) => now - t < Island.DOUBLE_MS); - // Double-click near the island while it is reduced: wake it back to the bar. - if (State.mode === "hidden" && near && this.outsideClicks.length > 0) { - this.outsideClicks = []; - this.fsm.mouseEntered(); +// Reduced, whether that is the docked bar or off-screen: only a deliberate + // double-click near the island opens it, and all the way to the full panel. + // `click()` takes the docked bar and the off-screen island straight to open. + // One click on its own does nothing, otherwise every stray click on the desktop + // would toggle the island. + if (State.mode === "compact" || State.mode === "hidden") { + if (near && this.outsideClicks.length > 0) { + this.outsideClicks = []; + // A double-click near the bar expands it to the full 240pt retracted bar, from + // either resting width. This is a real state change rather than the visual + // widening "wake on hover" uses, so the bar stays at 240 when the cursor leaves + // and the Compact timer takes it down to 80 from there. It never opens the panel + // and does not recall the island from off-screen. + if (this.fsm.state !== "gone" || State.settings.hoverRestore) this.fsm.mouseEntered(); + return; + } + this.outsideClicks.push(now); + // A second click beside the bar takes it the rest of the way down to the + // fully-compact 80pt bar: 640 → 240 on the first click, 240 → 80 on the next. + // Compact on Never means the 80pt bar is never reached by any route. + if (this.fsm.state === "petit" && State.settings.absenceInterval > 0) this.fsm.forceHidden(); return; } - this.outsideClicks.push(now); - - // Reduced with nothing showing: a lone click is inert, otherwise every stray - // click on the desktop would toggle the island. - if (State.mode === "hidden") return; this.outsideClicks = []; - // Already resting on the bar: this extra click skips the wait and reduces now. - // `collapse()` would be a no-op here — the state machine ignores a transition - // to the state it is already in. - if (State.mode === "compact") this.fsm.forceHidden(); - else this.collapse(); + // Open: the first outside click compacts to the 240pt bar, and the island stays + // docked. Auto-close owns that step's timer; clicking again beside the bar is + // what takes it down to the fully-compact one. + this.collapse(); } /** True when the cursor is within NEAR_RADIUS of the island's painted rect. */ @@ -611,10 +641,35 @@ private isNearIsland(): boolean { // ── Geometry ──────────────────────────────────────────────────────────────── +/** Whether the island has gone off-screen, which is its own resting state. + * + * Reached from the fully-compact bar by the auto-close delay. Before that step + * it keeps the parked bar from before the feature existed. + */ + private hidesOffscreen(): boolean { + return this.fsm.state === "gone"; + } + + + // Wakes the fully-compact bar: it widens to the retracted width and shows the agent + // pills, without opening the panel and without touching the off-screen state. + // Both ways of asking for it end up here — hovering it when "wake on hover" is on, + // and a double-click beside it — so the two behave identically. + private setBarHover(on: boolean) { + if (this.barHover === on) return; + this.barHover = on; + // Waking grows the bar, so it springs rather than curves. + this.animateGeometry(!on); + this.dirty = true; + } + private targetSize(): { w: number; h: number; r: number } { - const { w, h } = islandSize(State.mode, State.view, State.chatHistory.length); + const { w, h } = islandSize(State.mode, State.view, State.chatHistory.length, this.hidesOffscreen()); + // The fully-compact bar is a single centred Mochi; waking widens it to the + // retracted width, which is the one gesture both hover and a double-click do. + const width = State.mode === "hidden" && this.barHover ? COMPACT_W : w; const r = State.mode === "expanded" ? EXPANDED_CORNER : ROUNDED_CORNER; - return { w, h, r }; + return { w: width, h, r }; } private animateGeometry(shrinking: boolean) { @@ -660,11 +715,18 @@ private isNearIsland(): boolean { this.islandEl.style.width = `${w}px`; this.islandEl.style.height = `${hh}px`; this.islandEl.style.borderRadius = `0 0 ${r}px ${r}px`; + // Auto-hide has to take the entire bar with it, not just the Mochi: the bot + // already fades itself through `botPosition`'s opacity, but the bar's own + // background outlived the zero-height animation and stayed on screen. Fading + // the island is the only gate that covers the background, the pills and the bot + // in one place. Left clickable, so "hover to restore" can still find it. + this.islandEl.style.opacity = this.hidesOffscreen() ? "0" : "1"; this.islandEl.style.transform = `translateX(${offsetX}px)`; // These follow the island as it resizes, so they belong here rather than in // the state-driven DOM sync. - // The pill grid is centred in the bar's right end and shrinks with it, so a - // 24px-tall bar never crops it (PR #22 scales the grid to the resting height). + // The pill grid sits in the right end of the expanded panel and shrinks with the + // bar, so a 24px-tall bar never crops it (PR #22 scales the grid to the resting + // height). Left as the draft had it now that the pills only appear expanded. const gridScale = Math.min(1, Math.max(0, hh - 4) / 28); const grid = 29 * gridScale; this.miniGrid.style.transform = `scale(${gridScale})`; @@ -770,12 +832,20 @@ private isNearIsland(): boolean { // This is the poll-driven hover path and it is the one that actually wakes // the island on Windows, so the "wake on hover" setting has to be honoured // here too — gating only the DOM mouseenter left the setting inert. - if (this.fsm.state !== "hidden" || State.settings.wakeOnHover) { + // "Wake on hover" widens the fully-compact bar into the retracted one, pills and + // all. "Hover to restore" is the only thing that brings the island back from + // off-screen. They are different gestures on different states and neither + // opens the panel. + if (this.fsm.state === "hidden") { + if (State.settings.wakeOnHover) this.setBarHover(true); + } else if (this.fsm.state !== "gone" || State.settings.hoverRestore) { this.fsm.mouseEntered(); } this.homeCollapseAt = null; } if (!inIsland && this.wasInIsland) { + // Leaving shrinks a woken bar back to the single centred Mochi. + if (this.fsm.state === "hidden") this.setBarHover(false); this.fsm.mouseLeft(); if (this.fsm.state === "home" && !this.staysOpen && State.settings.autoCloseInterval > 0) { this.homeCollapseAt = performance.now() + State.settings.autoCloseInterval * 1000; @@ -878,6 +948,9 @@ private isNearIsland(): boolean { } this.updateBotTargets(); +// Mochi sits at the left of the woken bar, with the pills filling the right end, + // and dead centre in the resting 80pt bar. Both are x=40, so one value covers + // them; the pills only appear in the wider one. this.botCx.step(dt); this.botCy.step(dt); this.botSize.step(dt); @@ -929,7 +1002,7 @@ private isNearIsland(): boolean { }; private updateBotTargets() { - const p = botPosition(State.mode, State.view, this.height.value, State.uploadProgress); + const p = botPosition(State.mode, State.view, this.height.value, State.uploadProgress, this.hidesOffscreen()); this.botCx.target = p.cx; this.botCy.target = p.cy; this.botSize.target = p.diameter / 0.6; @@ -1045,10 +1118,31 @@ private isNearIsland(): boolean { } } - // Compact mini grid - const showGrid = State.mode === "compact"; + // The agent pills belong to a woken resting bar and nowhere else — Mochi to the + // left, pills to the right end, as the draft had it. They are deliberately not + // shown in the expanded panel: the grid is a child of the island, so leaving + // it visible there painted mini Mochis over the settings view and every other + // full-screen view. + // `State.mode` has no "petit": that FSM state is the compact mode. So the bar is + // either the resting 80pt one or the woken 240pt one. + // The agent pills belong to a bar wide enough to hold them — the 240pt retracted + // bar, whether it was reached by the Compact timer or by "wake on hover" + // widening the fully-compact one — and never to the expanded panel: the grid is + // a child of the island, so leaving it visible there painted mini Mochis over the + // settings view and every other full-screen view. The fully-compact bar is the + // single centred Mochi with nothing beside it. + const showGrid = State.mode === "compact" || (State.mode === "hidden" && this.barHover); this.miniGrid.style.opacity = showGrid ? "1" : "0"; - if (showGrid) { + // Emptied when hidden, not just made transparent. A transparent grid still + // occupies the island and its children still render, which is how a second + // Mochi appeared over the first whenever the cursor came near. + if (!showGrid) { + if (this.miniGrid.dataset.key !== "") { + this.miniGrid.dataset.key = ""; + this.miniGrid.replaceChildren(); + pruneMiniBots(); + } + } else { const others = State.otherTasks.slice(0, 4); const key = others.map((t) => t.id).join("|"); if (this.miniGrid.dataset.key !== key) { @@ -1071,6 +1165,7 @@ private isNearIsland(): boolean { Sound.setVolume(State.settings.soundVolume); this.fsm.homeToPetitDelay = State.settings.autoCloseInterval; this.fsm.petitToHiddenDelay = State.settings.absenceInterval; + this.fsm.hiddenToGoneDelay = State.settings.autoCloseDelay; // A pin saved last session keeps the island open across a restart. State.isPinned = State.settings.pinIsland; this.fsm.pinned = State.settings.pinIsland; diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 761e69c4d..b65e75693 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -634,6 +634,7 @@ function generalSection(): HTMLElement { screen.append( h("option", { value: "primary", text: "Main display" }), h("option", { value: "cursor", text: "Display under the cursor" }), + h("option", { value: "secondary", text: "Secondary display" }), ); screen.value = settings.screen; screen.addEventListener("change", () => { diff --git a/windows/src/style.css b/windows/src/style.css index 5f188018b..6c1f203b6 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -1490,3 +1490,11 @@ input[type="range"]::-webkit-slider-thumb { white-space: nowrap; min-width: 0; } + +/* Quieter second label on a settings row, used to say which mode a + preference applies to. */ +.settings-sub { + font: 400 10.5px var(--font); + color: #767b84; + flex: 0 0 auto; +} diff --git a/windows/src/views/icons.ts b/windows/src/views/icons.ts index 795f0dae9..689fe2ed8 100644 --- a/windows/src/views/icons.ts +++ b/windows/src/views/icons.ts @@ -23,6 +23,7 @@ export const ICONS = { // chevron.right chevronRight: "M9 5.5 15.5 12 9 18.5", chevronLeft: "M15 5.5 8.5 12 15 18.5", + chevronDown: "M5.5 9 12 15.5 18.5 9", // checkmark check: "M5 12.5 9.5 17 19 7.5", // arrow.up (send) diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index cefd8781a..dd7e720af 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -26,10 +26,14 @@ export interface ViewActions { setAutoClose(seconds: number): void; /** Seconds from compact to fully reduced; 0 = never fully reduce. */ setAbsence(seconds: number): void; + /** Seconds from fully compact to off-screen. */ + setAutoCloseDelay(seconds: number): void; /** Pins the island open (ignores outside clicks, Escape and auto-close). */ togglePin(): void; /** Whether the reduced island wakes on hover or only on a click. */ toggleWakeOnHover(): void; + /** Brings the island back when it has gone off-screen. */ + toggleHoverRestore(): void; openSettingsWindow(): void; blip(): void; } @@ -462,46 +466,94 @@ function buildSettings(actions: ViewActions): ViewHost { type: "range", min: "0", max: "0.2", step: "0.005", oninput: (e: Event) => actions.setVolume(Number((e.target as HTMLInputElement).value)), }) as HTMLInputElement; - const autoLabel = h("span", {}); - // 0 is "Off": the island stays open until it is dismissed. - const AUTO_VALUES = [0, 10, 30, 60]; - const segButtons = AUTO_VALUES.map((s) => - h("button", { onclick: () => actions.setAutoClose(s) }, s === 0 ? "Off" : `${s}s`), - ); - const fullLabel = h("span", {}); - const FULL_VALUES = [0, 15, 30]; - const fullButtons = FULL_VALUES.map((s) => - h("button", { onclick: () => actions.setAbsence(s) }, s === 0 ? "Never" : `${s}s`), - ); - // Free-form slider for any delay between 5s and 5m; "Never" (0) is off the - // left end so the bar always shows a real, movable time. - const fullSlider = h("input", { - type: "range", min: "5", max: "300", step: "5", - style: "width:96px", - oninput: (e: Event) => actions.setAbsence(Number((e.target as HTMLInputElement).value)), - }) as HTMLInputElement; + // Three resting steps, each with its own delay: compact to the bar, auto-hide to + // a single Mochi, auto-close off-screen. Every one starts at Off/Never, tops out + // at a minute, and carries the same free-form slider alongside its presets. + const stepRow = ( + label: () => string, + off: string, + values: number[], + get: () => number, + set: (v: number) => void, + ) => { + const lab = h("span", {}); + const slider = h("input", { + type: "range", min: "5", max: "60", step: "5", + style: "width:96px", + oninput: (e: Event) => set(Number((e.target as HTMLInputElement).value)), + }) as HTMLInputElement; + const buttons = values.map((v) => + h("button", { onclick: () => set(v) }, v === 0 ? off : `${v}s`), + ); + const row = h( + "div", + { class: "settings-row" }, + svg(ICONS.timer, 12), + lab, + slider, + h("div", { class: "seg" }, ...buttons), + ); + return { + row, + sync() { + const v = get(); + lab.textContent = label(); + buttons.forEach((b, i) => b.classList.toggle("on", v === values[i])); + // Off/Never has no position on the bar, so the slider parks at the left. + if (document.activeElement !== slider) slider.value = String(Math.max(5, v || 5)); + slider.style.opacity = v > 0 ? "1" : "0.45"; + }, + }; + }; + +const compactRow = stepRow( + () => (State.settings.absenceInterval > 0 + ? `Compact · ${formatSpan(State.settings.absenceInterval)}` + : "Compact · Never"), + "Never", + [0, 5, 15, 30, 60], + () => State.settings.absenceInterval, + (v) => actions.setAbsence(v), + ); +const hideRow = stepRow( + () => (State.settings.autoCloseDelay > 0 + ? `Auto-hide · ${formatSpan(State.settings.autoCloseDelay)}` + : "Auto-hide · Never"), + "Never", + [0, 5, 15, 30, 60], + () => State.settings.autoCloseDelay, + (v) => actions.setAutoCloseDelay(v), + ); +const closeRow = stepRow( + () => (State.settings.autoCloseInterval > 0 + ? `Auto-close · ${Math.round(State.settings.autoCloseInterval)}s` + : "Auto-close · Off"), + "Off", + [0, 5, 10, 30, 60], + () => State.settings.autoCloseInterval, + (v) => actions.setAutoClose(v), + ); const hoverSwitch = h("button", { class: "switch", onclick: () => actions.toggleWakeOnHover() }); + // Undoes the auto-hide step: bring the fully-compact bar back by hovering it. + const restoreSwitch = h("button", { class: "switch", onclick: () => actions.toggleHoverRestore() }); const claudeBadge = h("span", { class: "status-badge" }); const apiBadge = h("span", { class: "status-badge" }); + const ocBadge = h("span", { class: "status-badge" }); const rows = h( "div", { class: "settings-rows" }, h("div", { class: "settings-row" }, soundSwitch, h("span", { text: "Sound" }), volume), + closeRow.row, + compactRow.row, + hideRow.row, h( "div", { class: "settings-row" }, - svg(ICONS.timer, 12), - autoLabel, - h("div", { class: "seg" }, ...segButtons), - ), - h( - "div", - { class: "settings-row" }, - svg(ICONS.timer, 12), - fullLabel, - fullSlider, - h("div", { class: "seg" }, ...fullButtons), + svg(ICONS.chevronDown, 12), + h("span", { text: "Hover to restore" }), + h("div", { class: "grow" }), + restoreSwitch, ), h( "div", @@ -516,6 +568,7 @@ function buildSettings(actions: ViewActions): ViewHost { { class: "settings-row", style: "gap:14px" }, claudeBadge, apiBadge, + ocBadge, h("div", { class: "grow" }), h("button", { class: "link-btn", @@ -536,17 +589,9 @@ function buildSettings(actions: ViewActions): ViewHost { soundSwitch.classList.toggle("on", s.soundEnabled); volume.value = String(s.soundVolume); volume.style.opacity = s.soundEnabled ? "1" : "0.4"; - autoLabel.textContent = - s.autoCloseInterval > 0 ? `Auto-close · ${Math.round(s.autoCloseInterval)}s` : "Auto-close · Off"; - segButtons.forEach((b, i) => b.classList.toggle("on", s.autoCloseInterval === AUTO_VALUES[i])); - fullLabel.textContent = - s.absenceInterval > 0 ? `Fully compact · ${formatSpan(s.absenceInterval)}` : "Fully compact · Never"; - fullButtons.forEach((b, i) => b.classList.toggle("on", s.absenceInterval === FULL_VALUES[i])); - // Never has no position on the bar; park it at the left end. - if (document.activeElement !== fullSlider) { - fullSlider.value = String(Math.max(5, s.absenceInterval || 5)); - } - fullSlider.style.opacity = s.absenceInterval > 0 ? "1" : "0.45"; + compactRow.sync(); + hideRow.sync(); + closeRow.sync(); hoverSwitch.classList.toggle("on", s.wakeOnHover); clear(claudeBadge); claudeBadge.append( @@ -555,6 +600,16 @@ function buildSettings(actions: ViewActions): ViewHost { ); clear(apiBadge); apiBadge.append(dot("#F4505E", 6), h("span", { text: "API" })); + restoreSwitch.classList.toggle("on", s.hoverRestore); + // Hover to restore is tied to auto-hide: it is the gesture that brings the island + // back from off-screen, so with auto-hide on Never it has nothing to act on. + restoreSwitch.disabled = !(s.autoCloseDelay > 0); + restoreSwitch.style.opacity = s.autoCloseDelay > 0 ? "1" : "0.4"; + clear(ocBadge); + ocBadge.append( + dot(s.opencodeBin || s.chatViaServer ? "#22C55E" : "#8e939c", 6), + h("span", { text: "opencode" }), + ); }, }; } From c43ad04ccb8a901c2934c3e728119f2a3c95addd Mon Sep 17 00:00:00 2001 From: Imanol Diaz Date: Thu, 1 Oct 2026 12:09:01 -0400 Subject: [PATCH 15/21] Windows: document that the cursor display preference does not follow the pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `target_monitor` resolves the display under the pointer exactly once. Every placement after that returns the monitor the island is already on, so moving the mouse to another screen leaves the island where it was and "display under the cursor" reads as broken. The stickiness is deliberate and predates the secondary-display option: a bar being dragged toward an edge takes the pointer with it, so re-resolving from the cursor on every placement would hop the island onto the neighbouring monitor mid-drag and fight the drag. It is the reason the pointer is consulted in the narrow window before the island has a home, and the reason the preference can only take effect on a preference change — `forget_cursor_placement` clears both flags in that case. This is not a regression from the secondary option. The early return sat ahead of the cursor branch in the original ordering too, so the pointer has only ever been read before first placement. What the secondary option changed is that `primary` and `secondary` are now resolved from the setting on every placement instead of sharing that early return, which is why those two work and `cursor` does not. Logging is added here so the behaviour is visible rather than inferred: one line when the pointer places the island, one when it declines to follow. The trace is gated behind flags because the collapse loop places the island many times a second, which made the first version of it unreadable. Not fixed in this commit — the fix needs a decision about whether the island should track the pointer between placements or only when it settles, and that changes drag behaviour. --- windows/src-tauri/src/island.rs | 125 +++++++++++++++++++++++--------- windows/src-tauri/src/lib.rs | 3 + 2 files changed, 95 insertions(+), 33 deletions(-) diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 5b9ee2884..e64022b3e 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -182,49 +182,108 @@ fn monitor_contains(m: &Monitor, x: f64, y: f64) -> bool { && y < (p.y + s.height as i32) as f64 } +/// The display the island is currently painted on, if it is on one at all. +fn current_monitor(app: &AppHandle, monitors: &[Monitor]) -> Option { + let win = window(app)?; + let (pos, size) = (win.outer_position().ok()?, win.outer_size().ok()?); + // Test the island's own centre: the window is deliberately wider than + // the bar and overhangs the edge, so its left edge is not a reliable + // indicator of which display it belongs to. + let cx = pos.x as i64 + size.width as i64 / 2; + let cy = pos.y as i64 + size.height as i64 / 2; + monitors + .iter() + .find(|m| monitor_contains(m, cx as f64, cy as f64)) + .cloned() +} + +/// The first non-primary display, in the order the OS reports them. +fn secondary_monitor(app: &AppHandle, monitors: &[Monitor]) -> Option { + let primary = app.primary_monitor().ok().flatten(); + monitors + .iter() + .find(|m| primary.as_ref().map(|p| p.name() != m.name()).unwrap_or(true)) + .cloned() +} + +/// Set once the display under the pointer has been resolved, so later placements +/// stick to the display the island is already on. See `target_monitor`. +static CURSOR_PLACED: AtomicBool = AtomicBool::new(false); + +/// Keeps the "staying put" line to one per state rather than one per placement +/// call, which the collapse loop makes far too often to be readable. +static CURSOR_LOGGED: AtomicBool = AtomicBool::new(false); + +/// Lets "cursor" re-read the pointer on the next placement. +pub fn forget_cursor_placement() { + CURSOR_PLACED.store(false, Ordering::Relaxed); + CURSOR_LOGGED.store(false, Ordering::Relaxed); +} + /// The display the island lives on. /// -/// The monitor is decided by where the island *already is*, not by where the -/// cursor is: `pref = "cursor"` picks the display under the pointer when the -/// island has no home yet, but a resting bar dragged toward an edge takes its -/// cursor with it. Resolving from the cursor every frame would hop the island -/// onto the neighbouring monitor mid-drag and fight the drag. Once placed, the -/// island stays on that monitor, so a drag to the left or right edge stays on -/// the screen the user started on. +/// "primary" and "secondary" name a specific display, so they are resolved from the +/// setting every time — honouring them only when the island had no home yet meant +/// changing the setting did nothing, since a placed island always sits on some +/// display. +/// +/// "cursor" is the exception. A resting bar dragged toward an edge takes the cursor +/// with it, so re-resolving from the pointer on every frame would hop the island onto +/// the neighbouring monitor mid-drag and fight the drag. It therefore picks the +/// pointer's display once and then stays put, which also keeps a drag to the left or +/// right edge on the screen the user started on. fn target_monitor(app: &AppHandle, pref: &str) -> Option { let monitors = app.available_monitors().ok()?; - if let Some(win) = window(app) { - if let (Ok(pos), Ok(size)) = (win.outer_position(), win.outer_size()) { - // Test the island's own centre: the window is deliberately wider than - // the bar and overhangs the edge, so its left edge is not a reliable - // indicator of which display it belongs to. - let cx = pos.x as i64 + size.width as i64 / 2; - let cy = pos.y as i64 + size.height as i64 / 2; - if let Some(m) = monitors - .iter() - .find(|m| monitor_contains(m, cx as f64, cy as f64)) - { - return Some(m.clone()); - } - } + if pref == "primary" { + return app + .primary_monitor() + .ok() + .flatten() + .or_else(|| current_monitor(app, &monitors)) + .or_else(|| monitors.into_iter().next()); + } + if pref == "secondary" { + // A machine with only one monitor has no secondary, so it falls back to the + // primary rather than leaving the island nowhere to draw. + return secondary_monitor(app, &monitors) + .or_else(|| app.primary_monitor().ok().flatten()) + .or_else(|| monitors.into_iter().next()); } if pref == "cursor" { + // Known-broken: the display under the pointer is read once at launch and + // then never again, so moving the mouse to another screen does nothing. The + // "stick to the current monitor" early return below predates the secondary + // option; it is what makes this the default path once the island has a home, + // and it is why `cursor` only ever worked in the narrow window before first + // placement. Logged once per change of state, not per call, so the trace is + // readable. + if CURSOR_PLACED.load(Ordering::Relaxed) { + if let Some(m) = current_monitor(app, &monitors) { + if !CURSOR_LOGGED.swap(true, Ordering::Relaxed) { + crate::log::line(format!( + "[monitor] cursor: staying on {:?}, not following the pointer", + m.name() + )); + } + return Some(m); + } + } if let Some((cx, cy)) = cursor_physical() { if let Some(m) = monitors.iter().find(|m| monitor_contains(m, cx, cy)) { + crate::log::line(format!( + "[monitor] cursor: placing on {:?} from the pointer at {},{}", + m.name(), + cx, + cy + )); return Some(m.clone()); } - } - } - // "secondary" = the first non-primary display, in the order the OS reports them. - // A machine with only one monitor falls through to the primary one below rather - // than ending up with nowhere to draw. - if pref == "secondary" { - let primary = app.primary_monitor().ok().flatten(); - if let Some(m) = monitors - .iter() - .find(|m| primary.as_ref().map(|p| p.name() != m.name()).unwrap_or(true)) - { - return Some(m.clone()); + crate::log::line(format!( + "[monitor] cursor: pointer {},{} is on no known monitor", + cx, cy + )); + } else { + crate::log::line("[monitor] cursor: GetCursorPos failed"); } } app.primary_monitor() diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 7db89fe0e..af54ae37b 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -100,6 +100,9 @@ fn save_settings(app: AppHandle, shared: State, settings: Settings) { } if screen_changed { let collapsed = shared.gate.collapsed.load(Ordering::Relaxed); + // "cursor" reads the pointer once and then sticks, so a preference change is + // the only thing that should make it look again. + island::forget_cursor_placement(); island::apply_geometry(&app, &settings.screen, collapsed, settings.notch_position); } // Keep the other window in step (island ⇄ settings window). From 2c23411d4070d9ae9b821dfe959df8038df5d8cc Mon Sep 17 00:00:00 2001 From: Imanol Diaz Date: Thu, 1 Oct 2026 13:08:19 -0400 Subject: [PATCH 16/21] Windows: follow the pointer for the cursor display preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "display under the cursor" preference only ever resolved the pointer's display before the island had a home, so moving the mouse to another screen left the island where it was and the preference read as broken. All three display preferences are now resolved from the setting on every placement. The early return that pinned a placed island to whatever display it landed on was there to stop a drag hopping displays — a bar dragged toward an edge takes the pointer with it, so re-resolving mid-drag would move the island to the neighbouring screen and fight the drag. That concern is real but does not justify disabling the preference everywhere else, so it is now scoped to the drag itself: `set_dragging` marks the start and end of one, and only then is the current display held. The front end reports that, since it is what starts the drag, on the first move past the existing 4px threshold. The release clears it before the final placement, so letting go of a bar over a different display moves it there rather than pinning it back. `CURSOR_PLACED` and `forget_cursor_placement` go with it: the flags only existed to model "read the pointer once", which is no longer the behaviour, and removing them also removes the reason a preference change had to reset anything. Verified against two displays: with the pointer at -1713,906 the island places on DISPLAY1, the secondary. --- windows/src-tauri/src/island.rs | 62 ++++++++++++++++----------------- windows/src-tauri/src/lib.rs | 14 ++++++-- windows/src/core/bridge.ts | 4 +++ windows/src/island/island.ts | 11 +++++- 4 files changed, 56 insertions(+), 35 deletions(-) diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index e64022b3e..dffd3300d 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -206,32 +206,33 @@ fn secondary_monitor(app: &AppHandle, monitors: &[Monitor]) -> Option { .cloned() } -/// Set once the display under the pointer has been resolved, so later placements -/// stick to the display the island is already on. See `target_monitor`. -static CURSOR_PLACED: AtomicBool = AtomicBool::new(false); - -/// Keeps the "staying put" line to one per state rather than one per placement -/// call, which the collapse loop makes far too often to be readable. -static CURSOR_LOGGED: AtomicBool = AtomicBool::new(false); - -/// Lets "cursor" re-read the pointer on the next placement. -pub fn forget_cursor_placement() { - CURSOR_PLACED.store(false, Ordering::Relaxed); - CURSOR_LOGGED.store(false, Ordering::Relaxed); +/// True while the island is being dragged sideways. The display under the pointer +/// is held steady for the duration, so the bar cannot hop displays mid-drag. +static DRAGGING: AtomicBool = AtomicBool::new(false); + +/// Keeps the "holding still" line to one per drag rather than one per placement +/// call, which the drag loop makes far too often to be readable. +static DRAG_LOGGED: AtomicBool = AtomicBool::new(false); + +/// Records that a drag has begun or ended. The front end owns that, since it is +/// what starts the drag. +pub fn set_dragging(dragging: bool) { + DRAGGING.store(dragging, Ordering::Relaxed); + if !dragging { + DRAG_LOGGED.store(false, Ordering::Relaxed); + } } /// The display the island lives on. /// -/// "primary" and "secondary" name a specific display, so they are resolved from the -/// setting every time — honouring them only when the island had no home yet meant -/// changing the setting did nothing, since a placed island always sits on some -/// display. +/// All three preferences are resolved from the setting on every placement, so +/// changing the picker takes effect immediately and the island tracks the pointer +/// when that is what was chosen. Keeping a placed island on whatever display it +/// happened to land on made every preference a one-shot that only worked before +/// first placement. /// -/// "cursor" is the exception. A resting bar dragged toward an edge takes the cursor -/// with it, so re-resolving from the pointer on every frame would hop the island onto -/// the neighbouring monitor mid-drag and fight the drag. It therefore picks the -/// pointer's display once and then stays put, which also keeps a drag to the left or -/// right edge on the screen the user started on. +/// The one exception is a drag, where the current display is held for the duration; +/// see the "cursor" branch below. fn target_monitor(app: &AppHandle, pref: &str) -> Option { let monitors = app.available_monitors().ok()?; if pref == "primary" { @@ -250,18 +251,17 @@ fn target_monitor(app: &AppHandle, pref: &str) -> Option { .or_else(|| monitors.into_iter().next()); } if pref == "cursor" { - // Known-broken: the display under the pointer is read once at launch and - // then never again, so moving the mouse to another screen does nothing. The - // "stick to the current monitor" early return below predates the secondary - // option; it is what makes this the default path once the island has a home, - // and it is why `cursor` only ever worked in the narrow window before first - // placement. Logged once per change of state, not per call, so the trace is - // readable. - if CURSOR_PLACED.load(Ordering::Relaxed) { + // The pointer is read on every placement, so moving the mouse to another + // display moves the island with it. The one exception is a drag: a bar being + // dragged sideways takes the pointer with it, so resolving from the pointer + // mid-drag would hop the island onto the neighbouring display and fight the + // drag. Holding the current display for the duration keeps the drag on the + // screen it started on. + if DRAGGING.load(Ordering::Relaxed) { if let Some(m) = current_monitor(app, &monitors) { - if !CURSOR_LOGGED.swap(true, Ordering::Relaxed) { + if !DRAG_LOGGED.swap(true, Ordering::Relaxed) { crate::log::line(format!( - "[monitor] cursor: staying on {:?}, not following the pointer", + "[monitor] cursor: holding {:?} for the drag", m.name() )); } diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index af54ae37b..69244e7f3 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -100,9 +100,6 @@ fn save_settings(app: AppHandle, shared: State, settings: Settings) { } if screen_changed { let collapsed = shared.gate.collapsed.load(Ordering::Relaxed); - // "cursor" reads the pointer once and then sticks, so a preference change is - // the only thing that should make it look again. - island::forget_cursor_placement(); island::apply_geometry(&app, &settings.screen, collapsed, settings.notch_position); } // Keep the other window in step (island ⇄ settings window). @@ -155,6 +152,16 @@ fn reposition(app: AppHandle, shared: State) { island::apply_geometry(&app, &pref, collapsed, position); } +/// Marks the start and end of a sideways drag. +/// +/// Only matters for the "display under the cursor" preference: while dragging, the +/// island holds the display it started on instead of following the pointer across +/// the boundary mid-drag, which would fight the drag. +#[tauri::command] +fn set_dragging(dragging: bool) { + island::set_dragging(dragging); +} + /// Moves the resting island sideways without waiting for the settings window to /// save. Called live while the compact island is dragged, then once more to /// persist the final resting place. @@ -549,6 +556,7 @@ pub fn run() { focus_window, reposition, set_notch_position, + set_dragging, open_url, open_in_vscode, quit_app, diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index e61d61975..c692ddca5 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -49,6 +49,10 @@ export const Bridge = { reposition: () => call("reposition"), /** Moves the resting island sideways; `position` is 0 = left, 1 = right. */ setNotchPosition: (position: number) => call("set_notch_position", { position }), + /** Marks a sideways drag, so the island holds its display instead of following the + * pointer across a boundary mid-drag. Only affects the "display under the cursor" + * preference. */ + setDragging: (dragging: boolean) => call("set_dragging", { dragging }), openUrl: (url: string) => call("open_url", { url }), diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index def11afb0..11e2c5620 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -409,7 +409,13 @@ private outsideClicks: number[] = []; const onMove = (ev: MouseEvent) => { const dx = ev.screenX - this.dragStartX; if (!this.dragMoved && Math.abs(dx) < 4) return; - this.dragMoved = true; + if (!this.dragMoved) { + this.dragMoved = true; + // Tells the server to hold this display for the drag. Without it, "display + // under the cursor" would follow the pointer across a boundary mid-drag and + // fight the drag. + void Bridge.setDragging(true); + } const span = Math.max(1, screen.width - barW); // screenX is in physical pixels; divide by the scale factor the server // reported so the maths matches the logical pixels Rust positions with. @@ -423,6 +429,9 @@ private outsideClicks: number[] = []; window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); if (this.dragMoved) { + // Release the display hold before the final placement, so releasing the bar + // over a different display moves it there rather than pinning it back. + void Bridge.setDragging(false); // Persist once more so the resting place survives a restart even if the // last move event landed a hair short of the release point. void Bridge.setNotchPosition(State.settings.notchPosition); From c0abe58fdec5a7c366771841ccddd5f81a0119ee Mon Sep 17 00:00:00 2001 From: Imanol Diaz Date: Thu, 1 Oct 2026 19:57:19 -0400 Subject: [PATCH 17/21] Windows: make the outside click reach the island from either direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects, all in the path between a click and `dismissOutside`, and all of them made the gestures appear to depend on having dragged the bar first. The click was landing on the island window but not on the island, and nothing was listening for it. That handler was registered only outside Tauri, so the packaged app had a single route to `dismissOutside` — the native poll — and that route is conditional: the window only takes the mouse over the island shape, and the click-through state is re-evaluated as the cursor moves. It is now registered unconditionally, so the webview handles clicks inside the window and the poll handles clicks outside it. That gives one physical click two deliveries, which is why the second one read as the second half of a double-click and woke the very bar it was meant to compact. A short debounce keeps one click from counting twice, which is also what lets the near double-click on the 80pt bar see two real clicks. The press was then classified near or far natively, from the position and the same rect the click-through test uses, instead of by the front end rebuilding that rect. It now travels as a map rather than a tuple: Tauri serialises a tuple as a JSON array, so destructuring it as an object delivered `near` as `undefined` on every native click and the native classification did nothing. And the compact bar dropped the wake flag when it actually compacted. `barHover` forces the fully-compact bar to render at the 240pt width, so a click that compacted it while the cursor was resting on it reached the right state and kept the wide width — the state changed, the pixels did not. That is what made it look like only a drag worked; moving the mouse off the bar cleared the flag, and only then did the width follow. The near/far decision is back to the original shape: near is consulted only for the 80pt bar, where a double-click wakes it to 240. At 240 any click compacts. Hovering no longer cancels the compact timer, which it did when wake-on-hover was on: the cursor is already over the bar when the panel collapses, and the timer is only re-armed on leaving, so it was cancelled and never re-armed. --- windows/src-tauri/src/island.rs | 33 ++++++++- windows/src/island/fsm.ts | 7 +- windows/src/island/island.ts | 116 ++++++++++++++++++-------------- windows/src/main.ts | 5 +- 4 files changed, 107 insertions(+), 54 deletions(-) diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index dffd3300d..d8cfe4b28 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -76,6 +76,22 @@ fn outside_press(rect: IslandRect, x: f64, y: f64, down: bool, was_down: bool) - && !(x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h) } +/// How close a press may land to the island and still count as "near" it, on both +/// sides so left and right are identical by construction. +const NEAR_RADIUS: f64 = 56.0; + +/// Whether a press landed within `NEAR_RADIUS` of the island's painted rect. +/// +/// Decided here rather than in the front end, which has to rebuild the same rect and +/// guess the coordinate space. Getting that wrong made the near gesture — the +/// double-click wake — fail intermittently, and the two constants drifting apart +/// would silently change which clicks count as near. +fn near_press(rect: IslandRect, x: f64, y: f64) -> bool { + let dx = (rect.x - x).max(0.0).max(x - (rect.x + rect.w)); + let dy = (rect.y - y).max(0.0).max(y - (rect.y + rect.h)); + dx <= NEAR_RADIUS && dy <= NEAR_RADIUS +} + /// Wakes / parks the cursor poll thread so a hidden island costs literally nothing. pub struct PollGate { active: Mutex, @@ -462,7 +478,22 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { GetAncestor(hit, GA_ROOT).0 != hwnd.0 as *mut _ && GetAncestor(hit, GA_ROOTOWNER).0 == hwnd.0 as *mut _ }); - if !own_popup { let _ = win.emit("outside-click", ()); } + // The gesture travels with the event: whether the press was near the island or + // anywhere else decides which of the two reduced-state clicks it + // is, and the front end's own cursor is only refreshed when the + // pointer moves, so it used to judge a click after the mouse had + // been still against a stale position. That made a desktop click + // wake the island at random, and only dragging kept it honest. + if !own_popup { + // A map, not a tuple: Tauri serialises a tuple as a JSON + // array, and the front end destructured this as an object, + // so `near` arrived `undefined` on every native click and the + // native classification did nothing at all. + let _ = win.emit( + "outside-click", + serde_json::json!({ "near": near_press(r, x, y) }), + ); + } } was_down = down; if pressed { diff --git a/windows/src/island/fsm.ts b/windows/src/island/fsm.ts index 395cf40c4..62ed54b02 100644 --- a/windows/src/island/fsm.ts +++ b/windows/src/island/fsm.ts @@ -41,7 +41,12 @@ export class IslandStateMachine { this.transition("petit"); break; case "petit": - this.clear("petitHide"); + // Deliberately does NOT cancel the compact timer. It used to, and the timer + // is only re-armed on mouseLeft, which needs an in→out cursor transition. With + // "wake on hover" on, the cursor is already over the bar when the panel + // collapses to 240, so the timer was cancelled and never re-armed: the bar + // sat at 240 until the pointer happened to move off it and back, which looked + // like the Compact timer not working at all. break; case "home": this.clear("homeCollapse"); diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 11e2c5620..be245414f 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -2,7 +2,7 @@ // Mirrors IslandRootView.swift + IslandWindowController.swift. import { Tracked, Spring, clamp } from "../core/anim"; -import { Bridge, IS_TAURI, onDragDrop } from "../core/bridge"; +import { Bridge, onDragDrop } from "../core/bridge"; import { EXPANDED_CORNER, EXPANDED_W, COMPACT_W, NO_NOTCH_W, PANEL_H, PANEL_W, ROUNDED_CORNER, VIEW_LAYOUTS, botGlowColor, botGlowOpacity, botPosition, chatPromptHeight, @@ -263,10 +263,16 @@ export class Island { this.fsm.homeToPetitDelay = State.settings.autoCloseInterval; this.fsm.onTransition = (from, to) => { switch (to) { +case "gone": case "hidden": - case "gone": - this.setMode("hidden"); - break; + // Dropping the wake flag matters here: it forces the fully-compact bar to + // render at the 240pt width, so a bar compacted by a click while the cursor + // was still resting on it reached the right state but kept the wide width — + // it looked like the click had done nothing, and only moving the mouse off + // it made it shrink. + this.setBarHover(false); + this.setMode("hidden"); + break; case "petit": if (from === "coucou") this.greeting.interrupt(); else if (from === "hidden") Sound.play("peek"); @@ -377,6 +383,8 @@ private dragStartX = 0; private dragMoved = false; /** Timestamps of recent outside clicks, for the double-click wake. */ private outsideClicks: number[] = []; + /** When `dismissOutside` last ran, so the two delivery paths cannot double-count. */ + private lastDismiss = -1e9; /** * Grabs the resting island and follows the pointer, landing it wherever it is @@ -457,13 +465,19 @@ private outsideClicks: number[] = []; } /** - * How close to the island a click may land and still count as "near" it for - * the double-click wake. Measured from the island's rect on both sides, so it - * behaves the same to the left and to the right. + * Browser-only counterpart of the native `near_press`, for `npm run dev` where the + * event carries no classification. The shipped app is told by the server instead, + * so the two cannot drift apart. */ -private static readonly NEAR_RADIUS = 56; + private isNearIsland(px: number, py: number): boolean { + const rect = this.islandRect(); + const dx = Math.max(rect.x - px, 0, px - (rect.x + rect.w)); + const dy = Math.max(rect.y - py, 0, py - (rect.y + rect.h)); + return dx <= 56 && dy <= 56; + } + /** Two clicks within this window count as a double-click. */ -private static readonly DOUBLE_MS = 900; + private static readonly DOUBLE_MS = 900; /** * A click outside the island. @@ -478,53 +492,45 @@ private static readonly DOUBLE_MS = 900; * * A pinned island ignores all of it. */ -dismissOutside() { +/** + * A press landed outside the island. `near` is decided natively, from the press + * position and the same rect the click-through test uses. + */ + dismissOutside(near: boolean) { if (this.staysOpen) return; + // One physical click reaches here twice: once from the webview's own pointerdown + // and once from the native poll. Without this the second would read as the second + // click of a double-click and wake the bar it was meant to compact. const now = performance.now(); - const near = this.isNearIsland(); +if (now - this.lastDismiss < 50) return; + this.lastDismiss = now; this.outsideClicks = this.outsideClicks.filter((t) => now - t < Island.DOUBLE_MS); - -// Reduced, whether that is the docked bar or off-screen: only a deliberate - // double-click near the island opens it, and all the way to the full panel. - // `click()` takes the docked bar and the off-screen island straight to open. - // One click on its own does nothing, otherwise every stray click on the desktop - // would toggle the island. - if (State.mode === "compact" || State.mode === "hidden") { - if (near && this.outsideClicks.length > 0) { + // Double-click near the island while it is fully compact (the 80pt bar): wake it + // back to the 240pt bar. This is the only place "near" is consulted — a near branch + // on the 240pt bar swallowed the click that should have compacted it, which is + // what made the bar sit there until it was dragged. + if (State.mode === "hidden" && near && this.outsideClicks.length > 0) { this.outsideClicks = []; - // A double-click near the bar expands it to the full 240pt retracted bar, from - // either resting width. This is a real state change rather than the visual - // widening "wake on hover" uses, so the bar stays at 240 when the cursor leaves - // and the Compact timer takes it down to 80 from there. It never opens the panel - // and does not recall the island from off-screen. - if (this.fsm.state !== "gone" || State.settings.hoverRestore) this.fsm.mouseEntered(); + this.fsm.mouseEntered(); return; } this.outsideClicks.push(now); - // A second click beside the bar takes it the rest of the way down to the - // fully-compact 80pt bar: 640 → 240 on the first click, 240 → 80 on the next. - // Compact on Never means the 80pt bar is never reached by any route. - if (this.fsm.state === "petit" && State.settings.absenceInterval > 0) this.fsm.forceHidden(); - return; - } - this.outsideClicks = []; - // Open: the first outside click compacts to the 240pt bar, and the island stays - // docked. Auto-close owns that step's timer; clicking again beside the bar is - // what takes it down to the fully-compact one. - this.collapse(); -} + // Fully compact with nothing to wake: a lone click is inert, otherwise every + // stray click on the desktop would toggle the island. + if (State.mode === "hidden") return; -/** True when the cursor is within NEAR_RADIUS of the island's painted rect. */ -private isNearIsland(): boolean { - const rect = this.islandRect(); - const dx = Math.max(rect.x - State.mouse.x, 0, State.mouse.x - (rect.x + rect.w)); - const dy = Math.max(rect.y - State.mouse.y, 0, State.mouse.y - (rect.y + rect.h)); - return dx <= Island.NEAR_RADIUS && dy <= Island.NEAR_RADIUS; -} + this.outsideClicks = []; + // Already resting on the 240pt bar: this click reduces it to 80pt now, wherever + // it landed. `collapse()` would be a no-op here — the state machine ignores a + // transition to the state it is already in. + if (State.mode === "compact") this.fsm.forceHidden(); + else this.collapse(); + } - /** Alert from the hook server: open on this view. Pinned alerts never auto-close. */ +/** True when the cursor is within NEAR_RADIUS of the island's painted rect. */ +/** Alert from the hook server: open on this view. Pinned alerts never auto-close. */ alert(view: IslandViewName) { this.fsm.pinned = this.staysOpen; this.fsm.forceHome(); @@ -812,12 +818,20 @@ private isNearIsland(): boolean { // Outside Tauri (plain browser) drive the cursor from DOM events so the // island can be inspected with `npm run dev`. - if (!IS_TAURI) { - window.addEventListener("mousemove", (e) => this.onCursor(e.clientX, e.clientY)); - window.addEventListener("pointerdown", (e) => { - if (!this.islandEl.contains(e.target as Node)) this.dismissOutside(); - }); - } + // A click that lands on the island window but not on the island itself has to reach + // `dismissOutside`, because the native poll does not always deliver it: the window + // only receives the mouse over the island shape, and the click-through state is + // only re-evaluated while the cursor moves. Registering this only outside Tauri + // left the real app with a single, lossy route — which is why the second outside + // click failed to compact the bar until a drag had refreshed that state. + // + // The native poll still reports clicks that land outside the window entirely, so + // both paths feed the same handler; `dismissOutside` is guarded against the + // duplicate so one physical click cannot count as two. + window.addEventListener("pointerdown", (e) => { + if (this.islandEl.contains(e.target as Node)) return; + this.dismissOutside(this.isNearIsland(e.clientX, e.clientY)); + }); } /** Cursor in window-logical coordinates. */ diff --git a/windows/src/main.ts b/windows/src/main.ts index cd5e18e63..209bdb69c 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -52,7 +52,10 @@ async function main() { }); await onEvent("screen-changed", () => void Bridge.reposition()); - await onEvent("outside-click", () => island.dismissOutside()); + await onEvent<{ x: number; y: number; near: boolean }>( + "outside-click", + ({ near }) => island.dismissOutside(near), +); // The settings window writes preferences; apply them here without a restart. await onEvent("settings-changed", (s) => { From 1f5a0d089210277cc14df86ec451e567e7c1ac09 Mon Sep 17 00:00:00 2001 From: Imanol Diaz Date: Fri, 2 Oct 2026 05:51:11 -0400 Subject: [PATCH 18/21] =?UTF-8?q?Windows:=20a=20usable=20chat=20window=20?= =?UTF-8?q?=E2=80=94=20commands,=20model=20picker,=20and=20Escape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slash-command menu worked but everything it opened did not. Three groups of fixes, all in the chat window. The command menu itself: - The picker handlers were nested inside `if (menuRows.length > 0)`, so they could never run. A picker opened by a command is never open at the same time as the command menu, which is why the arrows did nothing in `/sessions` or `/models` while the menu itself was navigable. - Enter always completed the highlighted command instead of running what was typed, because the menu is open from the first character and `menuActive` is never -1. Every command took two presses. Enter now submits when the text already spells the command and only fills it in when it does not. - The rows were `