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 diff --git a/windows/Cargo.lock b/windows/Cargo.lock index 6278c7525..ad4cd02b8 100644 --- a/windows/Cargo.lock +++ b/windows/Cargo.lock @@ -1180,6 +1180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -3165,7 +3166,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -4283,9 +4286,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "tokio-rustls" version = "0.26.6" diff --git a/windows/README.md b/windows/README.md index 60374024c..8724f5842 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 ``` diff --git a/windows/opencode-plugin/coucou.js b/windows/opencode-plugin/coucou.js new file mode 100644 index 000000000..ffafdadc4 --- /dev/null +++ b/windows/opencode-plugin/coucou.js @@ -0,0 +1,278 @@ +// 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 = 2; + +// ── 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" + ); +} + +/** + * The best one-line summary a lifecycle event can give, or `{}` when it has + * none. + * + * `pick` only yields strings and numbers, so an error object has to be + * unwrapped by hand: the SDK puts the human-readable text on + * `error.data.message`, never on `error` itself, so `pick(props, ["error"])` + * returns "" and the failure reaches the island with no explanation. + */ +function eventMessage(props) { + const err = props.error; + if (err && typeof err === "object") { + const text = pick(err, ["data.message", "message"]); + if (text) return { message: text }; + } + const message = pick(props, ["message", "summary", "title", "error.message"]); + return message ? { message } : {}; +} + +// ── 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 }); + + // Session and permission lifecycle events are NOT hook keys. opencode only + // invokes the keys declared in the Hooks interface — `tool.execute.*`, + // `chat.*`, `permission.ask`, `command.execute.before`, `shell.env` and the + // rest. Everything else arrives through the single `event` hook and has to be + // dispatched here by `event.type`. Registering `session.created` and friends + // as top-level keys looks right and is silently never called. + const EVENT_MAP = { + "session.created": "SessionStart", + "session.idle": "Stop", + "session.error": "StopFailure", + "session.deleted": "SessionEnd", + }; + + return { + // ── Lifecycle, via the one hook that actually fires ───────────────────── + event: async ({ event }) => { + const props = event.properties || {}; + const info = props.info || {}; + const session_id = sessionIdOf(props, "") || sessionIdOf(info, ""); + + const name = EVENT_MAP[event.type]; + if (name) { + fireAndForget(name, base({ session_id, ...eventMessage(props) }), $); + return; + } + + // The user answered in the TUI (island unreachable or too slow): + // clear a stale approval card so it stops lying. + if (event.type === "permission.replied") { + fireAndForget("PostToolUse", base({ session_id }), $); + } + }, + + // The prompt text is not on the message. `UserMessage` carries only + // role/agent/model — the text lives in its parts. `message.updated` hands + // over `properties.info`, a Message, so reading text off it finds nothing + // and that event could never have carried a prompt. `chat.message` is the + // hook that is given the parts. + "chat.message": async (input, output) => { + const parts = Array.isArray(output?.parts) ? output.parts : []; + const text = parts + .filter((p) => p && p.type === "text" && typeof p.text === "string" && p.text) + .map((p) => p.text) + .join("\n") + .trim(); + if (!text) return; + const session_id = + pick(input, ["sessionID"]) || pick(output?.message || {}, ["id"]) || ""; + fireAndForget("UserPromptSubmit", base({ session_id, prompt: text.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) ───────────────────────────────── + // The key is `permission.ask`, per the Hooks interface — there is no + // `permission.asked`. Signature: (input: Permission, output: { status }). + "permission.ask": 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; + + // The Hooks interface types this as `output.status: "ask" | "deny" | + // "allow"` — there is no `decision` or `behavior` field to write. Assign + // only that, and only when it is actually present: touching anything else + // here risks denying a tool, which must never happen by accident. + try { + if (output && typeof output === "object" && "status" in output) { + output.status = decision; + } + } 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; + }, + }; +}; 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/Cargo.toml b/windows/src-tauri/Cargo.toml index f581867d9..5f038fe8f 100644 --- a/windows/src-tauri/Cargo.toml +++ b/windows/src-tauri/Cargo.toml @@ -19,13 +19,14 @@ tauri-plugin-single-instance = "2" 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"] } +tokio = { version = "1", features = ["net", "io-util", "sync", "time", "rt", "macros"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking"] } [target.'cfg(windows)'.dependencies] 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/island.rs b/windows/src-tauri/src/island.rs index 92c9cd4cb..3bc15bfc2 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -13,13 +13,23 @@ use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, Monitor, PhysicalPosition, PhysicalSize, WebviewWindow}; use crate::platform::{self, cursor_physical, left_button_down}; +// The popup check below still needs the raw Win32 calls: it asks whether the window +// under the pointer is one of our own native select menus, which is a Win32-only +// question. Everything else that used to live here moved into `platform`. +#[cfg(windows)] +use windows::Win32::Foundation::POINT; +#[cfg(windows)] +use windows::Win32::UI::WindowsAndMessaging::{GetAncestor, WindowFromPoint, 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; pub const WINDOW_LABEL: &str = "island"; @@ -53,6 +63,27 @@ 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) +} + +/// 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, @@ -114,14 +145,108 @@ 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 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() +} + +/// 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. +/// +/// 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. +/// +/// 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" { + 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" { + // 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 !DRAG_LOGGED.swap(true, Ordering::Relaxed) { + crate::log::line(format!( + "[monitor] cursor: holding {:?} for the drag", + 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()); } + 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() @@ -148,8 +273,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 }; @@ -157,10 +309,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; // GTK never sizes a non-resizable window below its natural size (200 px @@ -194,7 +348,6 @@ 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; @@ -204,6 +357,7 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { let (period, screen_every) = if platform::CURSOR_POLL { (16, 30) } else { (500, 1) }; 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() { @@ -236,7 +390,58 @@ 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. Only + // Windows has such popups — and only Windows can answer the + // question — so elsewhere nothing is exempt. + #[cfg(windows)] + 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 _ + }); + #[cfg(not(windows))] + let own_popup = false; + // 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 { + let handle = app.clone(); + let _ = app.run_on_main_thread(move || platform::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); @@ -244,7 +449,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 @@ -260,20 +464,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 down = left_button_down(); - if down && !was_down { - let handle = app.clone(); - let _ = app.run_on_main_thread(move || platform::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; +// 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); @@ -289,7 +488,7 @@ pub fn spawn_cursor_poll(app: AppHandle, gate: Arc) { /// /// With the cursor poll (Windows) the window takes the mouse again and the next /// tick decides from the cursor. Without it (Linux) the input region is set to -/// the island itself, or to the whole wake strip while collapsed. +/// the island itself, or to the reduced stub while collapsed. pub fn refresh_click_through(app: &AppHandle, gate: &PollGate) { if platform::CURSOR_POLL { set_ignore_cursor(app, false); @@ -298,10 +497,10 @@ pub fn refresh_click_through(app: &AppHandle, gate: &PollGate) { } let Some(win) = window(app) else { return }; let region = if gate.collapsed.load(Ordering::Relaxed) { - // The wake strip itself, never "the whole window": if the window ever - // fails to shrink to the strip, the rest of it must not swallow clicks + // The reduced stub itself, never "the whole window": if the window ever + // fails to shrink to the stub, the rest of it must not swallow clicks // meant for whatever sits under the top of the screen. - Some((0.0, 0.0, STRIP_W, STRIP_H)) + Some((0.0, 0.0, HIDDEN_W, HIDDEN_H)) } else { let r = *gate.rect.lock().unwrap(); if r.w <= 0.0 { @@ -323,3 +522,90 @@ pub fn set_ignore_cursor(app: &AppHandle, ignore: bool) { let _ = win.set_ignore_cursor_events(ignore); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Every width the front end can paint, in logical px: the fully reduced bar, + /// the resting bar (`COMPACT_W`) and the open panel (`EXPANDED_W`) — mirror + /// `src/core/layout.ts`. + const ISLAND_WIDTHS: [f64; 3] = [HIDDEN_W, 240.0, 640.0]; + + #[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 ISLAND_WIDTHS { + 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 ISLAND_WIDTHS { + 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 ISLAND_WIDTHS { + 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-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..78cad096c 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -6,6 +6,9 @@ mod hooks; mod integrations; mod island; mod log; +mod opencode; +mod opencode_chat; +mod opencode_server; mod pipe; mod platform; mod secrets; @@ -24,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; @@ -49,6 +53,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, @@ -61,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() }; @@ -80,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); @@ -90,12 +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::refresh_click_through(&app, &shared.gate); - shared.gate.set_active(!collapsed); + // The poll keeps running while reduced on platforms that have a global cursor + // position. It used to be parked outright, 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. Platforms without a cursor + // poll (Wayland) still park it, since there is nothing to poll. + shared.gate.set_active(platform::CURSOR_POLL || !collapsed); } /// The front end pushes the island shape; Rust decides click-through from it. @@ -119,9 +148,41 @@ 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, 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. +#[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); + island::apply_geometry(&app, &pref, collapsed, position); + let _ = app.emit("settings-changed", saved); } #[tauri::command] @@ -165,8 +226,12 @@ fn open_in_vscode(path: Option) -> bool { false } +// `find_on_path` now lives in `platform`, alongside the Linux equivalent. #[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); } @@ -211,11 +276,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. @@ -234,20 +317,119 @@ 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. +/// +/// 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. +/// Cancels the turn in flight, as Escape does in the chat window. +/// +/// The island keeps its own session id, so this asks the server to abort that one. +/// A failure here is not worth surfacing: the turn ends either way, and the front +/// end already swaps the reply for "Cancelled." on its own. +#[tauri::command] +async fn chat_cancel( + ochat: State<'_, opencode_chat::OpencodeChat>, + session: Option, +) -> Result<(), String> { + opencode_chat::cancel(&ochat, session).await +} + #[tauri::command] async fn chat_send( shared: State<'_, Shared>, chat: State<'_, Chat>, + ochat: State<'_, opencode_chat::OpencodeChat>, query: String, context: Option, + session: Option, + model: Option, ) -> Result { - let model = shared.settings.lock().unwrap().model.clone(); - claude::send(&chat, &model, query, context).await + let picked = model; + 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(), + ) + }; + let use_opencode = provider == "opencode" + || (!secrets::present("anthropic-api-key") + && opencode_chat::resolve_bin(&bin).is_some()); + if use_opencode { + // A model picked from the `/models` popup wins over the one in settings. It goes + // in as the same `provider/model` string the setting holds, so nothing + // downstream has to know where it came from. + let chosen: &str = match picked.as_deref() { + Some(m) if !m.trim().is_empty() => m, + _ => &omodel, + }; + opencode_chat::send(&ochat, &bin, chosen, 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() +} + +/// Models the running opencode reports, for the model picker. +#[tauri::command] +fn opencode_models() -> Vec { + opencode_server::models() +} + +/// 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 chat_reset(chat: State) { +fn opencode_server_url() -> Option { + opencode_server::discover(false) +} + +#[tauri::command] +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. @@ -257,6 +439,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) @@ -362,6 +549,16 @@ pub fn run() { platform::prepare_environment(); 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| { @@ -374,6 +571,7 @@ pub fn run() { }) .manage(Pending::default()) .manage(Chat::default()) + .manage(opencode_chat::OpencodeChat::default()) .invoke_handler(tauri::generate_handler![ boot, save_settings, @@ -381,20 +579,34 @@ pub fn run() { set_island_rect, focus_window, reposition, + set_notch_position, + set_dragging, open_url, open_in_vscode, quit_app, hooks_status, hooks_preview, hooks_apply, + opencode_status, + opencode_preview, + opencode_apply, approval_decision, approval_ack, approval_decline, log_line, - chat_send, +chat_send, + chat_cancel, chat_reset, + chat_status, + opencode_commands, + opencode_sessions, + opencode_models, + opencode_delete_session, + opencode_session_messages, + opencode_server_url, ingest_file, secret_present, + debug_log, secret_set, secret_clear, refresh_integration, @@ -409,8 +621,14 @@ pub fn run() { create_settings_window(&handle); if let Some(win) = island::window(&handle) { - platform::make_non_activating(&win); - island::apply_geometry(&handle, &loaded.screen, false); +platform::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); @@ -424,6 +642,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/opencode.rs b/windows/src-tauri/src/opencode.rs new file mode 100644 index 000000000..e56a8b92a --- /dev/null +++ b/windows/src-tauri/src/opencode.rs @@ -0,0 +1,360 @@ +// 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 { + crate::platform::home_dir() + .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 platform clock. +fn chrono_stamp() -> String { + let t = crate::platform::local_time(); + format!( + "{:04}{:02}{:02}-{:02}{:02}{:02}", + t.year, t.month, t.day, t.hour, t.minute, t.second + ) +} + +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(2)); + } + + #[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\"")); + } + + /// opencode only invokes the keys declared in the plugin `Hooks` interface. + /// Session and permission lifecycle events are not among them: they arrive + /// through the single `event` hook and have to be dispatched by + /// `event.type`. Registering them as top-level keys is accepted silently + /// and never called, which is how v1 of this plugin ended up sending only + /// `PreToolUse` to the island. + #[test] + fn bundled_source_registers_only_real_hook_keys() { + for key in [ + "event:", + "\"tool.execute.before\"", + "\"tool.execute.after\"", + "\"permission.ask\"", + "\"chat.message\"", + ] { + assert!(BUNDLED.contains(key), "missing real hook key: {key}"); + } + // The blocking key is `permission.ask`, not `permission.asked`. + assert!(!BUNDLED.contains("\"permission.asked\"")); + + // These look like hook keys and are not in the Hooks interface. Guard + // against a future edit reintroducing them as top-level keys. Matched + // in their `: async` form specifically: the same names legitimately + // appear as keys of the EVENT_MAP that dispatches them. + for key in [ + "session.created", + "session.idle", + "session.error", + "session.deleted", + "message.updated", + "permission.replied", + ] { + let as_hook = format!("\"{key}\": async"); + assert!( + !BUNDLED.contains(&as_hook), + "{key} is not a hook key; it must be dispatched from the event hook" + ); + } + } +} diff --git a/windows/src-tauri/src/opencode_chat.rs b/windows/src-tauri/src/opencode_chat.rs new file mode 100644 index 000000000..7c4ce39f3 --- /dev/null +++ b/windows/src-tauri/src/opencode_chat.rs @@ -0,0 +1,641 @@ +// 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::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, 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>, + /// Flipped by `cancel`. The blocking HTTP call cannot be interrupted, but the + /// turn awaiting it can: `send` races the request against this and gives up as + /// soon as it is set, so Escape stops waiting rather than posting a request to + /// the server and hoping it does something. + cancelled: Arc, +} + +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::platform::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 +} + +/// 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::platform::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( + chat: &OpencodeChat, + bin_configured: &str, + 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() + })?; + + // 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(); + 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) + } + } + } + }; + + // 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() { + // A fresh turn is not a cancelled one. + chat.cancelled.store(false, Ordering::Relaxed); + let task = 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) + }); + let attempt = tokio::select! { + res = task => res.map_err(|e| format!("opencode task failed: {e}"))?, + _ = wait_for_cancel(chat.cancelled.clone()) => { + // The request itself is still in flight and cannot be pulled back, + // but nothing is waiting on it any more. The front end shows this. + return Err("Cancelled.".to_string()); + } + }; + 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(), + "--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()); + } + // 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 + // 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); + // 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 { + 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(), + }) +} + +/// One chat turn over the opencode server that is already running. +/// +/// The alternative to this is `opencode run`, which boots a whole throwaway +/// Resolves as soon as `flag` is set, so a turn can be raced against a cancel. +/// +/// Polls rather than waiting on a notify: the flag is a plain atomic shared with +/// the command that sets it, and the interval is far below the time it takes a +/// person to notice. +async fn wait_for_cancel(flag: Arc) { + while !flag.load(Ordering::Relaxed) { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } +} + +/// Asks the running turn to stop being waited on. +/// +/// This sets a flag rather than posting to the server: the request is a blocking +/// HTTP call that cannot be recalled, and asking the server to abort it depends on +/// an endpoint that is not part of anything in this repository. Racing the call +/// locally is the part that is certain to work, and the reply is discarded either +/// way. +pub async fn cancel(chat: &OpencodeChat, _session_id: Option) -> Result<(), String> { + chat.cancelled.store(true, Ordering::Relaxed); + Ok(()) +} + +/// 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 { + crate::platform::home_dir().to_string_lossy().to_string() +} + +fn first_turn_context(context: &Option) -> (String, Vec, String) { + let home = crate::platform::home_dir(); + 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; + + let mut cmd = std::process::Command::new(bin); + cmd.args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let mut child = crate::platform::no_console(&mut cmd) + .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()); + } + + /// 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:?}"); + } +} diff --git a/windows/src-tauri/src/opencode_server.rs b/windows/src-tauri/src/opencode_server.rs new file mode 100644 index 000000000..a778941d2 --- /dev/null +++ b/windows/src-tauri/src/opencode_server.rs @@ -0,0 +1,899 @@ +// 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. +#[cfg(windows)] +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. +#[cfg(windows)] +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() + } +} + +/// Same list without the Win32 table: none. +/// +/// Discovery then finds no foreign server and starts Coucou's own, which is +/// what already happens on Windows when nothing is running, so everything below +/// keeps working there too — it just never reuses someone else's server. +#[cfg(not(windows))] +fn listening_loopback_owners() -> Vec<(u16, u32)> { + Vec::new() +} + +/// 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. +#[cfg(windows)] +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(); +} + +/// Nothing here owns a process on this platform (see +/// [`listening_loopback_owners`]), so there is never anything to kill. +#[cfg(not(windows))] +fn kill_pid(_pid: u32) {} + +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 { + 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 mut cmd = std::process::Command::new(bin); + cmd.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()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + // 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. + let child = crate::platform::no_console(&mut cmd).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()) +} + +/// The models the running server reports, as `provider/model`, sorted. +/// +/// A separate call rather than only being reachable through `/models`, so the chat +/// window can open a picker on it the way it does for sessions. Picking one fills +/// the input with the model to send; whether a turn actually uses it depends on the +/// backend honouring the override. +pub fn models() -> Vec { + let Some(base) = discover(false) else { + return Vec::new(); + }; + let Ok(client) = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + else { + return Vec::new(); + }; + let Ok(resp) = client.get(format!("{base}/config/providers")).send() else { + return Vec::new(); + }; + let Ok(json) = resp.json::() else { + return Vec::new(); + }; + let Some(providers) = json.get("providers").and_then(|v| v.as_array()) else { + return Vec::new(); + }; + // `providers` is an array of `{ id, models: { : {...} } }` -- the + // outer level is a list, the inner one a map keyed by model id. Reading the + // outer level as a map returned nothing at all, which is why the picker came up + // empty against a server that plainly had models to offer. + let mut out: Vec = Vec::new(); + for entry in providers { + let Some(provider) = entry.get("id").and_then(|v| v.as_str()) else { + continue; + }; + let Some(available) = entry.get("models").and_then(|m| m.as_object()) else { + continue; + }; + // The provider's configured default goes first: that is the model the user + // actually has working, and a flat alphabetical sort buried it under every + // other model on the same provider. + let fallback = entry + .get("models") + .and_then(|m| m.as_object()) + .and_then(|m| m.get("default")) + .and_then(|d| d.as_str()) + .map(str::to_string); + let mut ids: Vec = available.keys().cloned().collect(); + ids.sort(); + if let Some(first) = fallback { + ids.retain(|id| id != &first); + ids.insert(0, first); + } + for id in ids { + out.push(format!("{provider}/{id}")); + } + } + out +} + +/// 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"), + } + } + + /// 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 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(1500)); + assert_eq!( + owner_of(MANAGED_PORT), + first, + "the running server was replaced instead of kept" + ); + } +} diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 2a8d7d359..ddc6b944d 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -7,42 +7,136 @@ 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, + /// 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)] + 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. + /// "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)] 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. #[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, + /// 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, + /// 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 + /// snap to exact edges. + #[serde(default = "default_notch_position")] + pub notch_position: f64, +} + +fn default_notch_position() -> f64 { + 0.5 +} + + +fn default_true() -> bool { + true +} + +fn default_chat_provider() -> String { + "claude".into() } 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(), + 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 + // 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, + hover_restore: true, + notch_position: default_notch_position(), } } } 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", diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..21a336eb2 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -49,6 +49,12 @@ 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 }), + /** 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 }), @@ -73,6 +79,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. */ @@ -81,14 +98,43 @@ 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, + model?: string | null, + ) => + callOrThrow<{ text: string }>("chat_send", { + query, + context, + session: session ?? null, + model: model ?? null, + }), + /** Abandons the turn in flight, as Escape does. */ + chatCancel: (session?: string | null) => call("chat_cancel", { 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"), + /** Models the running server reports, as `provider/model`, for the model picker. */ + opencodeModels: () => call("opencode_models"), + /** 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. */ 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 }), @@ -118,6 +164,33 @@ export interface DroppedFile { size: number; } +export interface ChatStatus { + binConfigured: string; + binResolved: string | null; + 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; @@ -133,6 +206,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"); diff --git a/windows/src/core/layout.ts b/windows/src/core/layout.ts index 6f70eea4c..8bec268f0 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: NO_NOTCH_W, height: NO_NOTCH_H }; + } + export const ROUNDED_CORNER = 14; // hidden / compact export const EXPANDED_CORNER = 22; @@ -84,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: 160, 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" }, }; @@ -101,14 +112,24 @@ 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: 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 }; + // 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": - return { w: COMPACT_W, h: NOTCH_H }; + // 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; return { w: EXPANDED_W, h }; @@ -123,18 +144,47 @@ export interface BotPlacement { opacity: number; } -/** IslandRootView.botPosition — cy is measured from the island's top edge. */ +/** IslandRootView.botPosition — cy is measured from the island's top edge. + * + * `offscreen` no longer changes Mochi's placement: the island slides up past the + * top edge to hide, so the bot rides out with the rest of it. The flag still + * decides the island's own height, in `islandSize`. + */ export function botPosition( mode: IslandMode, view: IslandViewName, islandH: number, uploadProgress = 0, + _offscreen = false, ): 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": { + // Off-screen is the parked bar with the island slid up past the top edge, so + // Mochi is placed and drawn exactly as it is on screen and the slide is the + // only thing that takes it away. Fading it here instead is what made Mochi + // blink out on the first frame of the hide, with no motion at all. + // Auto-close on Never keeps it parked in the small bar, which is how the + // island behaved before there was anywhere to go. + 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": { + // 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: NO_NOTCH_W / 2, + 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/core/state.ts b/windows/src/core/state.ts index 01236b80a..6fc5a0346 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" | "agent"; +export type AgentSource = "claudeCode" | "opencode" | "n8n" | "agent"; 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"), @@ -84,14 +87,34 @@ 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; + /** 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. */ 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; + /** 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 = { @@ -99,13 +122,20 @@ export const DEFAULT_SETTINGS: Settings = { soundVolume: 0.12, autoCloseInterval: 15, absenceInterval: 180, - activeIntegrations: [ - "integration_resend", "integration_n8n", "integration_vercel", "integration_github", - ], + autoCloseDelay: 0, + pinIsland: false, + wakeOnHover: true, + hoverRestore: true, + activeIntegrations: [], screen: "primary", autostart: false, hooksInstalled: false, model: "claude-opus-5", + chatProvider: "claude", + opencodeBin: "", + opencodeModel: "", + chatViaServer: false, + notchPosition: 0.5, }; type Listener = () => void; @@ -119,6 +149,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. */ @@ -134,6 +168,14 @@ class AppState { promptContext: PromptContext | null = null; droppedFile: { name: string; path: string } | null = null; noteMessage: string | null = null; + /** `provider/model` the chat is pinned to, set from the `/models` picker. Empty + * means the default from settings. */ + modelOverride: string | null = null; + /** Transient line under the chat input, e.g. the Escape-cancel prompt. */ + chatHint: string | null = null; + /** Escape is owned by the chat cancel while set, so the island must not collapse + * the panel on the second press that arrives after the prompt unmounted the input. */ + escapeArmed = false; searchResult: SearchResult | null = null; chatHistory: ChatMessage[] = []; pendingApproval: ApprovalInfo | null = null; @@ -199,11 +241,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); @@ -251,7 +295,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/fsm.ts b/windows/src/island/fsm.ts index 8a24545ed..62ed54b02 100644 --- a/windows/src/island/fsm.ts +++ b/windows/src/island/fsm.ts @@ -1,17 +1,19 @@ // 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"; 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; + /** 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,11 +36,17 @@ export class IslandStateMachine { mouseEntered() { switch (this.state) { case "hidden": + case "gone": this.cancelTimers(); 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"); @@ -51,6 +60,7 @@ export class IslandStateMachine { mouseLeft() { switch (this.state) { case "hidden": + case "gone": break; case "petit": this.schedulePetitHide(); @@ -66,6 +76,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.state === "gone") { + this.cancelTimers(); + this.transition("home"); + return; + } if (this.state !== "petit") return; this.cancelTimers(); this.transition("home"); @@ -106,6 +124,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 +136,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"); @@ -129,13 +152,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"); @@ -145,6 +180,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/hooks.ts b/windows/src/island/hooks.ts index d90a78f09..e5fe597c5 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; @@ -54,6 +62,11 @@ function aliasProjectName(name: string): string { return PROJECT_ALIASES[name.toLowerCase()] ?? name; } +/** 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("/")); @@ -76,6 +89,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 { @@ -85,7 +110,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)}`; @@ -103,6 +128,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 @@ -120,8 +146,8 @@ 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; @@ -132,7 +158,7 @@ function clearSession() { if (!t) return; t.steps = []; t.stepIndex = 0; - t.name = "VS Code"; + t.name = defaultName(CLAUDE_ID); t.pillBadge = null; } @@ -142,7 +168,7 @@ 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); @@ -152,14 +178,18 @@ function handleHook(island: Island, payload: HookPayload) { const name = payload.hook_event_name ?? ""; const cwd = payload.cwd ?? ""; const raw = lastPathComponent(cwd); - const projectName = aliasProjectName(raw || "Session"); - - // Route to the right pill. Valid coucou_agent → dynamic "agent_" pill. - // "claude" is reserved; absent or invalid → Claude Code pill unchanged. +// Route to the right pill. Valid coucou_agent → dynamic "agent_" pill. + // "claude" is reserved; absent or invalid → Claude Code pill unchanged. opencode + // keeps its own integration pill rather than a dynamic one, so it is checked + // before falling back to Claude. const validAgent = validateAgent(payload.coucou_agent); - const agentId = validAgent ? `agent_${validAgent}` : CLAUDE_ID; + const agentId = validAgent + ? `agent_${validAgent}` + : payload.agent === "opencode" ? OPENCODE_ID : CLAUDE_ID; const isExternalAgent = validAgent !== null; + const projectName = aliasProjectName(raw || defaultName(agentId)); + const focused = State.focusId === agentId; /** Alerts force the island open; work events only reveal the compact island. */ @@ -178,7 +208,7 @@ function handleHook(island: Island, payload: HookPayload) { if (isExternalAgent) { State.upsertExternalAgent(agentId, validAgent!, agentColor(validAgent!)); } else { - upsert(projectName, cwd); + upsert(agentId, projectName, cwd); } }; @@ -200,7 +230,7 @@ function handleHook(island: Island, payload: HookPayload) { } case "PreToolUse": { - ensurePill(); +ensurePill(); State.updateTask(agentId, "working"); const tool = payload.tool_name ?? "Tool"; State.appendStep(agentId, stepLabel(tool, payload.tool_input ?? {})); @@ -287,7 +317,7 @@ function handleHook(island: Island, payload: HookPayload) { if (requestId) void Bridge.approvalDecline(requestId); break; } - upsert(projectName, cwd); + ensurePill(); if (pendingTimeout != null) window.clearTimeout(pendingTimeout); const tool = payload.tool_name ?? "Tool"; const input = payload.tool_input ?? {}; @@ -296,11 +326,12 @@ function handleHook(island: Island, payload: HookPayload) { sessionId: payload.session_id ?? "", tool, command: approvalTarget(tool, input), + agentId, }; // 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(agentId, "approval"); State.isPinned = true; Sound.play("approval"); if (focused) { @@ -309,7 +340,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(agentId, "approval"); island.reveal(); } // Coucou answers within 108 s or not at all; after that the terminal has @@ -317,11 +348,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 7fdb61802..c4ff9aafb 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, COMPACT_W, NO_NOTCH_H, NO_NOTCH_W, PANEL_H, PANEL_W, ROUNDED_CORNER, VIEW_LAYOUTS, botGlowColor, botGlowOpacity, botPosition, chatPromptHeight, islandSize, type IslandMode, type IslandViewName, @@ -45,15 +45,17 @@ 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); + // How far the island has slid up past the top edge, 0 while it is on screen. + private slideY = new Tracked(0); + private slideOff = false; private botCx = new Spring(46); private botCy = new Spring(16); private botSize = new Spring(10); @@ -66,7 +68,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; @@ -143,8 +149,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: () => { @@ -162,6 +168,40 @@ 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(); + }, + 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; + 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 +209,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 +256,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(); } @@ -227,15 +266,26 @@ export class Island { this.fsm.homeToPetitDelay = State.settings.autoCloseInterval; this.fsm.onTransition = (from, to) => { switch (to) { +case "gone": case "hidden": - 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"); 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()); @@ -245,10 +295,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(); @@ -315,17 +368,206 @@ 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(); } - /** Alert from the hook server: open on this view. Pinned alerts never auto-close. */ + /** 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[] = []; + /** 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 + * 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) { + this.beginDrag(e, () => this.fsm.click()); + } + + /** + * The open island is grabbed by its header band, the same 42 pt the layout + * reserves for it, and only where that band holds no control of its own. This + * is what makes a resting bar and an open island behave the same way under the + * pointer. + */ + private pressGrabsHeader(e: MouseEvent): boolean { + const band = this.header.el.getBoundingClientRect(); + const island = this.islandEl.getBoundingClientRect(); + // From the island's own top edge, not the header's. `#content` pads 8px above the + // header, so measuring from the header left a dead strip across the full width at + // the top - the far left and right of the top row would not drag. + if (e.clientY < island.top || e.clientY > band.bottom) return false; + const target = e.target; + if (target instanceof Element && target.closest("button, input, textarea, select, a, label")) { + return false; + } + return true; + } + + /** + * Follows the pointer along the top edge and leaves the island where it is + * released. `onSettle` runs only when the press never became a drag, so a + * plain click keeps whatever meaning it had before. + */ + private beginDrag(e: MouseEvent, onSettle: () => void) { + 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, 0, this.hidesOffscreen()).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; + 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); + // Both sides are already logical pixels: `screenX` is CSS pixels, and + // Rust divides the monitor metrics by the scale factor when it reports + // them, so `screen.width` is logical too. Dividing by `screen.scale` here + // scaled the drag twice, which only showed on a scaled display - the bar + // would move a fraction of the pointer's travel. + const next = this.dragStartPosition + dx / 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) { + // 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); + } else { + // It was a click, not a drag, so run whatever the press would have done. + onSettle(); + } + }; + + 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; + } + +/** + * 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 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; + +/** + * 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. + */ +/** + * 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(); +if (now - this.lastDismiss < 50) return; + this.lastDismiss = now; + + this.outsideClicks = this.outsideClicks.filter((t) => now - t < Island.DOUBLE_MS); + // 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 = []; + this.fsm.mouseEntered(); + return; + } + this.outsideClicks.push(now); + + // 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; + + 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(); + } + +/** 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 = State.isPinned; + this.fsm.pinned = this.staysOpen; this.fsm.forceHome(); this.expand(view); } @@ -449,10 +691,35 @@ export class Island { // ── 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) { @@ -469,22 +736,90 @@ export class Island { this.ensureRunning(); } + /** + * Keeps the off-screen slide in step with the FSM. + * + * The slide is the reverse of the pop up: same close curve as the geometry, so + * the bar shrinks and rises off the top edge in one move, and the same spring + * brings it back. It cannot be armed from `animateGeometry`, because `setMode` + * returns early when the mode is unchanged and the FSM reaches `gone` from + * `hidden` — so, like the fade this replaced, it is re-checked every frame. + * + * The island travels the height it had before the collapse, so an expanded + * island clears the window too, and `overflow: hidden` on the body clips it for + * the whole slide. The poll keeps its own 14px band at the resting position, so + * "hover to restore" still finds a bar that is no longer on screen. + */ + private syncSlide() { + const off = this.hidesOffscreen(); + if (off === this.slideOff) return; + this.slideOff = off; + const slide = off ? -Math.max(NO_NOTCH_H, this.height.value) : 0; + this.animateGeometry(off); + if (off) { + this.slideY.curveTowards(slide); + } else { + this.slideY.springTo(slide); + } + } + + /** + * 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%)`; + // 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. Sliding + // the island up covers the background, the pills and the bot in one move, and + // reads better than a fade. `slideY` carries it clear of the window; the poll + // keeps its own 14px band at the resting position, so "hover to restore" still + // finds an island that is no longer on screen. + this.islandEl.style.transform = `translate(${offsetX}px, ${this.slideY.value}px)`; + // The retracted island is the only state in which nothing of it is on screen, so + // it is the only state in which its animations can be paused without being seen + // doing it. Set here rather than in `setMode` because the frame loop parks itself + // while hidden, and this has to be right for the first frame after a reload too. + this.islandEl.toggleAttribute("data-hidden", State.mode === "hidden"); // 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 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})`; + 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 +831,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 +842,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 +857,25 @@ 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.pressGrabsHeader(e)) { + // The open island drags by its header. Opening on press is not a + // concern here - it is already open - so a press that never moves just + // falls through to whatever the header would otherwise do. + this.beginDrag(e, () => {}); return; } if (this.isBotHit(e.clientX, e.clientY)) { @@ -545,14 +885,33 @@ export class Island { }); window.addEventListener("keydown", (e) => { - if (e.key === "Escape" && State.mode === "expanded" && !State.isPinned) this.collapse(); + // Escape belongs to the chat while a cancel is armed. The prompt that asks for the + // second press lives in the note view, which unmounts the chat input, so that + // second press reaches the window with no input handler in the way — and + // without this the panel collapsed instead of cancelling. + if (e.key === "Escape" && !State.escapeArmed && State.mode === "expanded" && !this.staysOpen) this.collapse(); State.lastActivity = performance.now(); }); void onDragDrop((e) => this.onDragDrop(e)); - // Outside Tauri (plain browser) drive the cursor from DOM events so the - // island can be inspected with `npm run dev`. +// 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)); + }); + + // Wayland has no global cursor position, so outside Tauri the cursor comes from + // the page's own events rather than Rust's poll. if (!IS_TAURI) this.followPageCursor(); } @@ -587,12 +946,25 @@ 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. + // "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" && !State.isPinned) { + if (this.fsm.state === "home" && !this.staysOpen && State.settings.autoCloseInterval > 0) { this.homeCollapseAt = performance.now() + State.settings.autoCloseInterval * 1000; } } @@ -679,12 +1051,25 @@ export class Island { } private frame = (nowMs: number) => { - const dt = Math.min(0.05, (nowMs - this.lastFrame) / 1000); + // Clamped at both ends, and the lower clamp is not cosmetic. `ensureRunning` + // stamps `lastFrame` with `performance.now()`, but the loop is resumed by + // `requestAnimationFrame`, whose callback timestamp is the vsync time and can + // therefore be *older* than that stamp — so the first frame after a resume had a + // negative dt. Every exponential smoother downstream reads dt as a rate, so a + // negative one runs them backwards: `1 - 0.0025^dt` goes negative and + // `yaw += (tgYaw - yaw) * kLook` amplifies the gap instead of closing it, at + // roughly 1.13x per frame. A parked loop restarts on every cursor event, so that + // was nearly every frame while the island was reduced, and Mochi's gaze walked off + // instead of following the pointer. The modes that keep the loop running never hit + // it, which is why only the reduced bar was affected. + const dt = Math.min(0.05, Math.max(0, (nowMs - this.lastFrame) / 1000)); this.lastFrame = nowMs; + this.syncSlide(); this.width.step(dt, nowMs); this.height.step(dt, nowMs); this.radius.step(dt, nowMs); + this.slideY.step(dt, nowMs); this.applyGeometry(); if (this.dirty) { @@ -693,6 +1078,9 @@ export class Island { } 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); @@ -727,10 +1115,20 @@ export class Island { // looping animation — breathing, ratelimit sweat, sleeping z's, the search // sweep — so a hidden island went on burning frames in exactly the states it // spends most of its life in. Geometry still has to finish retracting. + // + // The exception is a reduced bar that is still on screen. Parking the loop is + // right for an island nobody can see, but Mochi is drawn by that loop, so a + // parked one left the last painted frame as all there was: his gaze stopped + // following the pointer entirely, and each cursor event bought a single frame + // before it parked again. Keying this on the pointer being over the bar was not + // enough either — moving the pointer *around* the island to watch him look is + // mostly moving it off an 80x24 bar, so it has to be the island's visibility that + // decides, and off-screen is the only state in which nothing is drawn at all. const settling = - this.width.animating || this.height.animating || this.radius.animating; + this.width.animating || this.height.animating || this.radius.animating || this.slideY.animating; + const onScreen = State.mode === "hidden" && !this.hidesOffscreen(); const busy = State.mode === "hidden" - ? settling + ? settling || onScreen : settling || !this.botCx.settled || !this.botCy.settled || !this.botSize.settled || greetingActive || this.engine.busy || UploadSeq.isActive; @@ -744,7 +1142,7 @@ export class Island { }; 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; @@ -819,7 +1217,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; } @@ -860,10 +1258,31 @@ export class Island { } } - // 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) { @@ -885,6 +1304,11 @@ 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; + 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; State.notify(); } diff --git a/windows/src/main.ts b/windows/src/main.ts index f65d5e757..952c66316 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(); @@ -52,6 +53,10 @@ async function main() { }); await onEvent("screen-changed", () => void Bridge.reposition()); + 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) => { 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); diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..b65e75693 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,239 @@ 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), + ), + 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) { + 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][] = [ @@ -370,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(); }); @@ -385,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", () => { @@ -392,6 +642,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", {}, @@ -404,12 +677,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(); }), @@ -428,6 +721,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 +739,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(), diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c..30b06f776 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -34,10 +34,18 @@ padding: 0; } +/* HACK, not the final fix. On the Chromium-based webview (WebView2) the page + box still claims the whole viewport after upstream's viewport/island changes, + which drags the island's own box out of shape and breaks the Mochi + animations. Letting html/body shrink to their content stops the page from + claiming the viewport; #island keeps being positioned by Island.applyGeometry, + so nothing here moves the island itself. Proper fix: have the island be in + flow (position: relative) and let its parents size to it, with the horizontal + offset moved out of the transform onto the parent — revisit before release. */ html, body { - width: 100%; - height: 100%; + width: fit-content; + height: fit-content; background: transparent; overflow: hidden; font-family: var(--font); @@ -47,19 +55,15 @@ body { cursor: default; } -#root { - position: fixed; - 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 +72,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; @@ -271,10 +277,16 @@ body { transition: opacity 0.3s ease-out 0.16s, transform 0.3s cubic-bezier(0.3, 1.2, 0.4, 1) 0.16s; } -/* A view that is not on is only transparent, not gone: an infinite animation - inside it (the drop frame's marching dashes) would keep the webview - repainting every frame for as long as the app runs, island hidden or not. */ -.view:not(.on) * { +/* Upstream's `.view:not(.on)` animation pause, kept but scoped to the retracted + island. Pausing anything inside the island while it is up makes WebView2 raster + the composited island wrongly - Mochi comes back the wrong size or not at all, and + the wake and retreat animations stop making sense - and `animation: none` is + affected identically, so it is the stopping of the animation rather than the + pausing of it. Nothing inside the island is visible while it is retracted, which + is also where the repaints it saves actually happen: a view that is not on is only + transparent, so the drop frame's infinite dashes used to run for as long as the app + did and kept the webview painting every frame. */ +#island[data-hidden] * { animation-play-state: paused; } @@ -800,6 +812,11 @@ input[type="range"]::-webkit-slider-thumb { padding: 2px 0; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.18) transparent; + /* Older messages are prepended above when the reader scrolls up, and this code + restores the position itself by tracking the row being read. Left on, the + browser anchors to a row of its own choosing at the same moment and the two + corrections fight, which threw the reader back to the start. */ + overflow-anchor: none; } .chat-row { @@ -880,6 +897,12 @@ input[type="range"]::-webkit-slider-thumb { color: var(--dim-3); } +/* Armed Escape: the placeholder is the cancel prompt, so it is tinted to read as + an instruction rather than as idle hint text. */ +.chat-input.cancelling::placeholder { + color: var(--warn, #f59e0b); +} + .send-btn { width: 28px; height: 28px; @@ -1365,3 +1388,145 @@ 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; + /* Without this the picker is a flex child of a column and gets shrunk to fit the + space left over, so `max-height` below never applied and roughly one row was + visible. Roughly five rows is the useful amount: enough to scan, few enough + that the list is not a wall. */ + flex: 0 0 auto; + max-height: 168px; + 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; +} + +/* 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/chat.ts b/windows/src/views/chat.ts index 2cd253f7b..d8ff00ab3 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,362 @@ 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[] = []; + /** Which picker row is highlighted. Separate from `menuActive` so the two lists + * cannot move each other. */ + let pickerActive = -1; + // Whether the session picker has already asked for a second look. Reset once a + // real list arrives, so a later empty answer gets its one retry too. + let retriedForSessions = false; + // When the last fetch started, so an empty answer cannot trigger another one on + // every keystroke. + let lastFetchAt = 0; + 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; + pickerActive = -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. + // + // Both lists have to be part of that test. The commands and the sessions come + // from the same server, but they are populated at different moments, so a + // server that answered the command list early still reports no sessions. + // Caching on `commands` alone left `/sessions` permanently empty after that. + // + // But forgetting it on *every* call turns this into a refetch per keystroke + // whenever the user simply has no sessions, which is its own kind of broken. + // The cooldown is the backstop: one retry, then the answer stands. + const now = performance.now(); + if ( + (commands.length === 0 || sessions.length === 0) + && now - lastFetchAt > FETCH_RETRY_MS + ) { + lastFetchAt = now; + fetching = null; + } + } + + /** How long before an empty answer is worth asking again. */ + const FETCH_RETRY_MS = 1500; + + /** 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 rebuilt from scratch by flagging it dirty. */ + async function showConversation(messages: { role: string; text: string }[]) { + State.chatHistory = messages.map((m) => ({ + id: nextId++, + role: m.role === "user" ? "user" : "assistant", + content: m.text, + })); + historyDirty = true; + 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 openModelPicker() { + closePopups(); + const found = (await Bridge.opencodeModels()) ?? []; + if (!found.length) { + State.noteMessage = "No models reported by the server."; + State.view = "note"; + return; + } + for (const [i, model] of found.entries()) { + // Split so the provider reads as secondary, which is what it is. + const slash = model.indexOf("/"); + const row = h( + "div", + // Not `first` on every row: that class is what paints the selection, so a + // hardcoded one lit the whole list up. + { class: i === 0 ? "chat-menu-row pick-row first" : "chat-menu-row pick-row" }, + h("span", { class: "chat-menu-name", text: slash >= 0 ? model.slice(slash + 1) : model }), + h("span", { class: "chat-menu-desc", text: slash >= 0 ? model.slice(0, slash) : "" }), + ); + row.addEventListener("click", () => { + closePopups(); + State.modelOverride = model; + State.noteMessage = `Model set to ${model}.`; + State.view = "note"; + State.notify(); + }); + pickerRows.push(row); + picker.append(row); + } + // First row pre-highlighted so it reads as selectable, but `pickerActive` stays + // -1 until an arrow key is pressed: Enter should not silently pick the first + // session or model over the one you were aiming at. + pickerRows[0]?.classList.add("first"); + el.querySelector(".chat-body")?.append(picker); + } + + async function openPicker() { + await fetchOnce(); + if (pickerRows.length) { + closePopups(); + return; + } + closePopups(); + if (sessions.length === 0) { + // A server that is still starting reports no sessions, and returning here + // without drawing anything is what made the command look dead on first use. + // So draw the popup with a line saying so — and ask again exactly once. An + // unbounded retry loops as fast as the server can answer, which backs every + // later request up behind it; that is what stopped the command list loading + // at all while this was in. + const retried = retriedForSessions; + const waiting = h( + "div", + { class: "chat-menu-row pick-row" }, + h( + "span", + { class: "chat-menu-name", text: retried ? "No sessions found." : "Loading sessions…" }, + ), + ); + picker.append(waiting); + pickerRows.push(waiting); + el.querySelector(".chat-body")?.append(picker); + if (!retried) { + retriedForSessions = true; + void fetchOnce().then(() => { + closePopups(); + void openPicker(); + }); + } + return; + } + retriedForSessions = false; + for (const [i, s] of sessions.entries()) { + 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: i === 0 ? "chat-menu-row pick-row first" : "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); + } + pickerRows[0]?.classList.add("first"); + 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()) { + // A div, not a button. A button takes focus when it is clicked, and the input + // then stops receiving arrow keys -- so the first click on a command left the + // dropdown open with the keyboard dead. A plain row cannot steal focus, and + // the click still works. + const row = h( + "div", + { + 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" }); + } + + /** Same movement for the session and model pickers, which are a separate list. + * They had no keyboard handling at all, so the command menu was navigable and + * the two drop-downs a command opens were not. */ + function movePicker(delta: number) { + if (pickerRows.length === 0) return; + pickerActive = (pickerActive + delta + pickerRows.length) % pickerRows.length; + pickerRows.forEach((r, i) => r.classList.toggle("first", i === pickerActive)); + pickerRows[pickerActive]?.scrollIntoView({ block: "nearest" }); + } + const el = h( "div", { class: "view" }, @@ -55,13 +411,118 @@ 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(); + }; +const NAV_KEYS = new Set(["ArrowDown", "ArrowUp", "Tab", "Enter", "Escape"]); + input.addEventListener("input", onTyped); + // Arrow keys move the selection on `keydown`, so letting the matching `keyup` + // through here re-rendered the menu and reset the highlight to the first row — + // the list visibly bounced back on every press. Only keys that can change the + // text are allowed to re-render. + input.addEventListener("keyup", (e) => { + if (!NAV_KEYS.has((e as KeyboardEvent).key)) onTyped(); + }); + let sending = false; - let renderedCount = -1; + // When Escape will cancel, rather than already having cancelled. Cleared once it + // lapses so a stray second press much later does not cancel out of nowhere. + let armedUntil = 0; + // Which view this pane lives in, captured while it is mounted. The Escape prompt + // swaps to the note view, so returning needs to know where it came from. + // Which view the chat belongs to. Recorded while the pane is on screen rather + // than when it is built: building happens once at startup, long before the user + // is anywhere near the chat, so the value captured then was some other view + // entirely and returning to it landed on a broken screen. + let chatView = State.view; + // Whether this pane is the one on screen. Set by `sync`, and deliberately left + // true across the prompt's unmount so the cancel survives it. + let chatMounted = false; + // Only the tail of the conversation is in the DOM. The island rewrites its width + // every frame while it retracts, and that re-wraps the text of every bubble inside + // it, so the cost of the animation is proportional to the whole conversation. With + // a few hundred messages the retraction crawled. Older messages come back as the + // log is scrolled up; `State.chatHistory` always holds all of them either way. + const RENDER_WINDOW = 20; + // How many messages, counted back from the newest, are loaded. + let expandedTo = RENDER_WINDOW; + // The slice of history currently in the log: [renderedFrom, renderedTo). + let renderedFrom = 0; + let renderedTo = 0; + // Set when the log holds a different conversation, so the next sync starts over. + let historyDirty = true; + // Writing scrollTop fires a scroll event afterwards, asynchronously, so a flag + // cleared synchronously would not be set by the time it arrives. The time is what + // separates a load the renderer just did from the reader reaching the top again. + let loadedAt = 0; + // The typing dots are tracked apart from the message count. They used to be + // folded into it as a half, which meant showing or hiding them re-rendered the + // whole conversation. + let typingEl: HTMLElement | null = null; + + /** The first row with any part of itself above the top of the log, if any. */ + function firstVisibleRow(): HTMLElement | null { + for (const child of Array.from(log.children)) { + if (child instanceof HTMLElement && child.offsetTop + child.offsetHeight > log.scrollTop) { + return child; + } + } + return null; + } + + /** Widens the window by another screenful, leaving the reader's place untouched. */ + function loadEarlier() { + const total = State.chatHistory.length; + if (expandedTo >= total) return; + expandedTo = Math.min(total, expandedTo + RENDER_WINDOW); + loadedAt = performance.now(); + State.notify(); + } + // 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; + } + // `/models` opens a picker for the same reason: the reply is a wall of text + // that cannot be chosen from, which is a dead end in a 640pt island. + if (/^\/models(\s|$)/i.test(query)) { + void openModelPicker(); + 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,57 +536,276 @@ 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); - State.chatHistory.push({ id: nextId++, role: "assistant", content: reply.text }); + const reply = await Bridge.chatSend( + query, + context, + targetSession?.id ?? null, + State.modelOverride, + ); + // A cancelled turn still resolves with whatever had arrived, which would + // leave a half-reply on screen as if it had been answered. + if (armedUntil) { + State.chatHistory.push({ + id: nextId++, + role: "assistant", + content: "Cancelled.", + }); + } else { + State.chatHistory.push({ id: nextId++, role: "assistant", content: reply.text }); + Sound.play("finish"); + } + armedUntil = 0; + State.chatHint = null; State.stateOverride = null; - Sound.play("finish"); } catch (err) { State.stateOverride = null; - State.noteMessage = String(err).replace(/^Error:\s*/, ""); - State.view = "note"; - Sound.play("error"); + // A cancelled turn surfaces as an error from the server call. That is the + // user pressing Escape, not a failure, so it belongs in the log as a + // cancelled reply rather than on the error view. + if (armedUntil || /cancelled/i.test(String(err))) { + State.chatHistory.push({ id: nextId++, role: "assistant", content: "Cancelled." }); + armedUntil = 0; + State.escapeArmed = false; + State.view = chatView; + State.notify(); + } else { + State.noteMessage = String(err).replace(/^Error:\s*/, ""); + State.view = "note"; + Sound.play("error"); + } } finally { sending = false; 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); } } + // Reaching the top of the log pulls in the messages above it, the way a feed does. +// There is no button: the window is only ever short by what the reader has not +// scrolled back to yet. The 250ms guard is what stops the correction the renderer +// makes just after loading from counting as the reader hitting the top again. + log.addEventListener("scroll", () => { + if (log.scrollTop > 8) return; + if (performance.now() - loadedAt < 250) return; + loadEarlier(); + }); + 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; + } + const highlighted = menuRows[menuActive]?.querySelector(".chat-menu-name")?.textContent ?? ""; + const chosen = highlighted.replace(/^\//, ""); +if (key === "Tab") { + e.preventDefault(); + complete(chosen); + return; + } + if (key === "Enter") { + e.preventDefault(); + // Enter runs what was typed when it already spells the command out, and + // only fills it in when it does not. The menu opens on the first + // character, so it was always open here, and Enter always completed rather + // than submitted — which is why every command took two presses. + if (typedCommand() === chosen.toLowerCase()) void submit(); + else complete(chosen); + return; + } + if (key === "Escape") { + e.preventDefault(); + closePopups(); + return; + } + } + // The session and model pickers, which are a separate list. This has to sit + // beside the command menu rather than inside it: nested there, the arrows only + // worked while the command menu happened to be open, and a picker opened by a + // command is never open at the same time. + if (pickerRows.length > 0) { + if (key === "ArrowDown") { + e.preventDefault(); + movePicker(1); + return; + } + if (key === "ArrowUp") { + e.preventDefault(); + movePicker(-1); + return; + } + if (key === "Enter" && pickerActive >= 0) { + // Enter takes the highlighted row, so a session or model can be chosen + // without reaching for the mouse. + e.preventDefault(); + pickerRows[pickerActive].click(); + return; + } + } + if (key === "Enter") { e.preventDefault(); void submit(); } - e.stopPropagation(); // Escape closes the island, not the chat + if (key === "Escape" && sending) { + // The island's own Escape handler is on `window` and was registered first, so + // it runs before any handler here and collapses the panel. Stopping the event + // at the input is the only way to keep it from reaching the window at all — + // `stopPropagation` in a sibling window listener would be too late. + e.stopPropagation(); + e.preventDefault(); + State.escapeArmed = true; + onEscape(); + return; + } + }); + + // Arms or fires the cancel. Shared by the input handler and the window handler: +// the input catches the press while the chat is on screen, and the window catches +// the second one after the prompt has swapped the view and unmounted the input. +function onEscape() { + if (!armedUntil) { + armedUntil = performance.now() + 3000; + State.noteMessage = "Press Escape again to cancel this reply."; + State.view = "note"; + Sound.play("rate"); + // Lapses on its own, so a prompt outliving its turn cannot sit there. + window.setTimeout(() => { + if (armedUntil && performance.now() >= armedUntil) { + armedUntil = 0; + State.escapeArmed = false; + State.view = chatView; + State.notify(); + } + }, 3100); + } else { + armedUntil = 0; + State.escapeArmed = false; + State.view = chatView; + void Bridge.chatCancel(targetSession?.id ?? null); + } + State.notify(); +} + + // The second press arrives here, on the window, because the prompt unmounted the + // input. The island's Escape handler shares this target and runs first, so + // `escapeArmed` is what tells it to stand down. + window.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + // A dropdown owns Escape first — closing it is more immediate than arming a + // cancel, and leaving it open while the panel collapses looked broken. + if (menuRows.length || pickerRows.length) { + e.preventDefault(); + e.stopPropagation(); + closePopups(); + return; + } + // Only in the chat pane: let the island collapse as it always did otherwise. + // Deliberately not `el.isConnected` — the prompt swaps to the note view, which + // unmounts this pane, so that test made the second Escape fall through to the + // island and collapse the whole panel. `sync` running is the better signal: + // it stays true after the unmount, which is exactly what the cancel needs. + if (!chatMounted || !sending) return; + e.preventDefault(); + e.stopPropagation(); + onEscape(); + }); + + // A click anywhere in the chat that is not on a dropdown row dismisses it. The + // popups are children of the chat body, so without this they survived the click + // that was meant to dismiss them. + el.addEventListener("pointerdown", (e) => { + if (!menuRows.length && !pickerRows.length) return; + const target = e.target as Node; + if (menu.contains(target) || picker.contains(target)) return; + closePopups(); }); return { el, sync() { + chatMounted = true; + // Remembered here so the cancel prompt has somewhere correct to return to. + chatView = State.view; const file = State.droppedFile; const wantChip = file?.name ?? ""; if (chipRow.dataset.label !== wantChip) { chipRow.dataset.label = wantChip; clear(chipRow); + chipRow.append(sessionChip); if (wantChip) chipRow.append(contextChip(wantChip)); } - const thinking = State.stateOverride === "thinking"; - const count = State.chatHistory.length + (thinking ? 0.5 : 0); - if (count !== renderedCount) { - renderedCount = count; + const history = State.chatHistory; + if (historyDirty || history.length < renderedTo) { + // A different conversation, or one that shrank, cannot be patched. clear(log); - for (const m of State.chatHistory) log.append(bubble(m)); - if (thinking) log.append(typingDots()); + typingEl = null; + historyDirty = false; + expandedTo = RENDER_WINDOW; + renderedFrom = Math.max(0, history.length - expandedTo); + renderedTo = history.length; + for (let i = renderedFrom; i < history.length; i++) log.append(bubble(history[i])); + log.scrollTop = log.scrollHeight; + } else if (history.length > renderedTo) { + // Only what arrived since last time. + for (let i = renderedTo; i < history.length; i++) log.append(bubble(history[i])); + renderedTo = history.length; log.scrollTop = log.scrollHeight; + } else if (expandedTo > history.length - renderedFrom) { + // The reader scrolled up, and older messages go in above them. Adding content + // above pushes everything down, so the message being read has to be put back + // where it was. The anchor is the first row with any part of itself visible, + // tracked by how far it sat below the top of the viewport: `scrollHeight` on + // its own only says how much taller the log got, not where in it the reader + // was, which is what put them back at the start. + const anchor = firstVisibleRow(); + const gap = anchor ? anchor.offsetTop - log.scrollTop : 0; + const from = Math.max(0, history.length - expandedTo); + const older = document.createDocumentFragment(); + for (let i = from; i < renderedFrom; i++) older.append(bubble(history[i])); + log.prepend(older); + renderedFrom = from; + log.scrollTop = (anchor ? anchor.offsetTop : 0) - gap; + } + + // The typing dots go in last, so a rebuild above cannot wipe them. They are an + // element of their own, which is what keeps them appearing and disappearing + // without touching a single message. + const thinking = State.stateOverride === "thinking"; + if (thinking) { + if (!typingEl) { + typingEl = typingDots(); + log.append(typingEl); + log.scrollTop = log.scrollHeight; + } + } else if (typingEl) { + typingEl.remove(); + typingEl = null; } - 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…"; + State.chatHint = null; + // 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..689fe2ed8 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 @@ -20,12 +23,15 @@ 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) 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/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..dd7e720af 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -24,6 +24,16 @@ 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; + /** 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; } @@ -59,9 +69,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 })); } @@ -84,6 +108,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(); @@ -94,7 +131,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 { @@ -109,6 +146,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"; }, }; @@ -172,10 +215,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 +232,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 +372,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 +397,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 +418,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"; }, }; @@ -407,35 +451,124 @@ 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", { 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", {}); - const segButtons = [10, 15, 30].map((s) => - h("button", { onclick: () => actions.setAutoClose(s) }, `${s}s`), - ); + // 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.chevronDown, 12), + h("span", { text: "Hover to restore" }), + h("div", { class: "grow" }), + restoreSwitch, + ), h( "div", { class: "settings-row" }, svg(ICONS.timer, 12), - autoLabel, - h("div", { class: "seg" }, ...segButtons), + h("span", { text: "Wake on hover" }), + h("div", { class: "grow" }), + hoverSwitch, ), h( "div", { class: "settings-row", style: "gap:14px" }, claudeBadge, apiBadge, + ocBadge, h("div", { class: "grow" }), h("button", { class: "link-btn", @@ -456,8 +589,10 @@ 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])); + compactRow.sync(); + hideRow.sync(); + closeRow.sync(); + hoverSwitch.classList.toggle("on", s.wakeOnHover); clear(claudeBadge); claudeBadge.append( dot(s.hooksInstalled ? "#22C55E" : "#F4505E", 6), @@ -465,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" }), + ); }, }; }