From b30dd8e3eb33d5f7307f1771d7084e51a5215556 Mon Sep 17 00:00:00 2001 From: Beto Doescher Date: Fri, 2 Oct 2026 16:17:52 -0300 Subject: [PATCH 01/77] Linux: put the island at the top on GNOME and keep it through Super+D GNOME has no layer-shell and ignores where a Wayland window asks to go, so the island opened in the middle of the screen with no way to move it. On a GNOME Wayland session Coucou now runs through XWayland (GDK_BACKEND=x11, overriding one inherited from an editor or terminal), where set_position works, and the regular island window is a keep-above Dock: the only kind of window, besides the desktop, that "show desktop" leaves on screen. COUCOU_X11=0 keeps the native Wayland window, COUCOU_DOCK=0 makes it a utility window instead of a dock. Checked on Ubuntu GNOME (Wayland) with xprop: _NET_WM_WINDOW_TYPE_DOCK, _NET_WM_STATE_ABOVE and _NET_WM_STATE_STICKY, input hint False, top centre of the primary display. Co-authored-by: Cursor --- README.md | 2 +- windows/README.md | 8 +++-- windows/src-tauri/src/platform/linux.rs | 39 +++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 09985a4c5..9090ccf05 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Check a download with `sha256sum -c SHA256SUMS --ignore-missing`. Gemini CLI, An The island sits on the top edge on compositors with layer-shell — COSMIC, KDE Plasma, Hyprland, Sway and other wlroots compositors. GNOME has no layer-shell, -so there it opens as a regular window. See [`windows/README.md`](windows/README.md#linux). +so there it runs through XWayland as a dock window at the top of the screen. See [`windows/README.md`](windows/README.md#linux). ### Build from source diff --git a/windows/README.md b/windows/README.md index 60374024c..8dca2cd55 100644 --- a/windows/README.md +++ b/windows/README.md @@ -166,8 +166,12 @@ What changes on Linux: - **The island** is a gtk-layer-shell overlay anchored to the top edge, over any top panel, on compositors that support it: COSMIC, KDE Plasma, Hyprland, Sway - and other wlroots compositors. GNOME has no layer-shell, so there the island - is a regular window. `COUCOU_LAYER_SHELL=0` forces that mode anywhere. + and other wlroots compositors. GNOME has no layer-shell and ignores where a + Wayland window asks to go, so there Coucou runs through XWayland as a dock + window: top centre, on every workspace, still there after Super+D. + `COUCOU_X11=0` keeps the native Wayland window, `COUCOU_DOCK=0` makes it a + utility window instead of a dock. `COUCOU_LAYER_SHELL=0` forces the regular + window anywhere. - **Click-through** is the window's input region, kept equal to the island shape, so the compositor sends every other click to what is underneath. - **Mochi's eyes** follow the pointer only while it is over the island: Wayland diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e748a336a..9f9c6501b 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -60,6 +60,7 @@ pub fn local_dir() -> PathBuf { /// every launch, so each launch would rewrite the system's registry with /// plugin paths that vanish once Coucou quits. Give ours its own file. pub fn prepare_environment() { + prefer_x11_on_gnome(); if std::env::var_os("APPIMAGE").is_none() || std::env::var_os("GST_REGISTRY").is_some() { return; } @@ -69,6 +70,24 @@ pub fn prepare_environment() { } } +/// GNOME on Wayland has no layer-shell and ignores where a regular window asks +/// to go, so the island lands in the middle of the screen. Through XWayland it +/// can be placed, and a Dock window survives "show desktop" (Super+D). +/// An inherited GDK_BACKEND=wayland is overridden too: editors and terminals +/// pass theirs down to every child. COUCOU_X11=0 keeps the Wayland window. +fn prefer_x11_on_gnome() { + let env = |k: &str| std::env::var(k).unwrap_or_default(); + if should_prefer_x11(&env("XDG_SESSION_TYPE"), &env("XDG_CURRENT_DESKTOP"), &env("COUCOU_X11")) { + std::env::set_var("GDK_BACKEND", "x11"); + } +} + +fn should_prefer_x11(session: &str, desktop: &str, opt: &str) -> bool { + session.eq_ignore_ascii_case("wayland") + && desktop.split(':').any(|d| d.eq_ignore_ascii_case("gnome")) + && opt != "0" +} + pub fn local_time() -> LocalTime { let mut tm: libc::tm = unsafe { std::mem::zeroed() }; unsafe { @@ -223,6 +242,18 @@ pub fn make_non_activating(win: &WebviewWindow) { }; crate::log::line(format!("island is a regular window ({why})")); gw.set_accept_focus(false); + if !gw.is_realized() { + // On X11 a Dock is kept above everything and is the only kind of + // window, besides the desktop, that "show desktop" leaves alone. + // COUCOU_DOCK=0 falls back to a utility window. + let dock = std::env::var("COUCOU_DOCK").map(|v| v != "0").unwrap_or(true); + gw.set_type_hint(if dock { + gtk::gdk::WindowTypeHint::Dock + } else { + gtk::gdk::WindowTypeHint::Utility + }); + } + gw.set_keep_above(true); return; } // tao gives undecorated Wayland windows an empty titlebar to force @@ -333,6 +364,14 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[test] + fn x11_is_only_preferred_on_gnome_wayland_unless_opted_out() { + assert!(should_prefer_x11("wayland", "ubuntu:GNOME", "")); + assert!(!should_prefer_x11("x11", "ubuntu:GNOME", "")); + assert!(!should_prefer_x11("wayland", "KDE", "")); + assert!(!should_prefer_x11("wayland", "GNOME", "0")); + } + #[test] fn private_dirs_are_closed_to_everyone_else() { use std::os::unix::fs::MetadataExt; From 353633a9898a2feeb1366c8e24ca118d04190ee7 Mon Sep 17 00:00:00 2001 From: German <106097211+4rchila@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:32:19 -0600 Subject: [PATCH 02/77] linux: auto-close the island reliably on Wayland layer-shell On KDE Plasma (layer-shell, Wayland) the expanded island often never folded back after the mouse left: - WebKitGTK does not send mouseout when the pointer really leaves the layer surface; - GTK's leave-notify fires spuriously while the pointer is still over the island, immediately followed by a re-enter, which cancels the collapse timer; - WebKitGTK can deliver a queued mousemove after the pointer has left, pulling the island back to "inside". Rust now polls GDK's pointer focus (it follows the compositor's wl_pointer enter/leave) every 100 ms while the island is shown, reports both edges to the page as `pointer-inside`, and emits the usual far-away cursor on leave. The page ignores its own mouse events while the compositor says the pointer is outside. The timer is removed while the island is hidden (set_pointer_watch from set_collapsed), so a hidden island still costs nothing. Windows is unchanged (no-op). --- windows/src-tauri/src/lib.rs | 1 + windows/src-tauri/src/platform/linux.rs | 74 +++++++++++++++++++++++ windows/src-tauri/src/platform/windows.rs | 3 + windows/src/island/island.ts | 16 +++++ windows/src/main.ts | 1 + 5 files changed, 95 insertions(+) diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..3018d8c2d 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -96,6 +96,7 @@ fn set_collapsed(app: AppHandle, shared: State, collapsed: bool) { // The wake strip must always take the mouse, and a resize invalidates the flag. island::refresh_click_through(&app, &shared.gate); shared.gate.set_active(!collapsed); + platform::set_pointer_watch(!collapsed); } /// The front end pushes the island shape; Rust decides click-through from it. diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e748a336a..d8271840d 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -259,10 +259,84 @@ pub fn make_non_activating(win: &WebviewWindow) { } gtk::glib::Propagation::Proceed }); + ISLAND_GTK.with(|cell| *cell.borrow_mut() = Some(gw.clone())); + watch_pointer_leave(win); LAYER_SURFACE.store(true, Ordering::Relaxed); crate::log::line("island is a layer-shell overlay"); } +thread_local! { + /// The island's GTK window, for the pointer watch (GTK main thread only). + static ISLAND_GTK: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +/// Reports the pointer leaving the island, which the page cannot see by itself. +/// +/// On a layer surface WebKitGTK does not send `mouseout` when the pointer leaves +/// for good, and GTK's leave-notify fires spuriously while the pointer is still +/// over the island (each followed by a re-enter), so neither tells "really +/// gone". GDK's pointer focus does: it follows the compositor's wl_pointer +/// enter/leave. It is read every 100 ms while the island is shown; both edges go +/// to the page as `pointer-inside`, and leaving also emits the far-away cursor +/// the page uses. While the island is hidden the timer is removed altogether +/// (set_pointer_watch), so a hidden island costs nothing. +static POINTER_WIN: std::sync::OnceLock = std::sync::OnceLock::new(); +static WATCH_WANTED: AtomicBool = AtomicBool::new(true); +static WATCH_RUNNING: AtomicBool = AtomicBool::new(false); + +fn watch_pointer_leave(win: &WebviewWindow) { + let _ = POINTER_WIN.set(win.clone()); + start_pointer_watch(); +} + +/// Starts or parks the pointer watch. Called with the island's collapsed state; +/// safe from any thread (the timer itself lives on the GTK main loop). +pub fn set_pointer_watch(active: bool) { + WATCH_WANTED.store(active, Ordering::Relaxed); + if active { + gtk::glib::MainContext::default().invoke(start_pointer_watch); + } +} + +/// Main thread only. +fn start_pointer_watch() { + if !WATCH_WANTED.load(Ordering::Relaxed) || WATCH_RUNNING.swap(true, Ordering::Relaxed) { + return; + } + use tauri::Emitter; + let Some(win) = POINTER_WIN.get().cloned() else { + WATCH_RUNNING.store(false, Ordering::Relaxed); + return; + }; + let inside = std::cell::Cell::new(false); + gtk::glib::timeout_add_local(std::time::Duration::from_millis(100), move || { + if !WATCH_WANTED.load(Ordering::Relaxed) { + WATCH_RUNNING.store(false, Ordering::Relaxed); + return gtk::glib::ControlFlow::Break; + } + let now_inside = ISLAND_GTK + .with(|cell| { + let gw = cell.borrow().clone()?; + let ours = gw.window()?; + let pointer = gtk::gdk::Display::default()?.default_seat()?.pointer()?; + let (under, _, _) = pointer.window_at_position(); + Some(under?.toplevel() == ours.toplevel()) + }) + .unwrap_or(false); + if inside.replace(now_inside) != now_inside { + // The page gates its own (late, sometimes stale) mouse events on + // this state, so a mousemove queued before the pointer left cannot + // pull the island back to "inside". + let _ = win.emit("pointer-inside", now_inside); + if !now_inside { + let _ = win.emit("cursor", crate::island::CursorPayload { x: -10_000.0, y: -10_000.0 }); + } + } + gtk::glib::ControlFlow::Continue + }); +} + /// Temporarily allow keyboard focus so a text field inside the island can be /// typed in. pub fn set_activating(win: &WebviewWindow, activating: bool) { diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 6e1200a8a..714586e27 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -233,3 +233,6 @@ pub fn set_activating(win: &WebviewWindow, activating: bool) { /// Click-through here is the poll's WS_EX_TRANSPARENT toggle, not a region. pub fn set_input_region(_win: &WebviewWindow, _rect: Option<(f64, f64, f64, f64)>) {} + +/// The cursor poll already parks itself with the island (island.rs): nothing to do. +pub fn set_pointer_watch(_active: bool) {} diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb61802..96efccce3 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -569,8 +569,24 @@ export class Island { }); } + /** + * Pointer on/off the island as the compositor sees it (Linux only). Null + * until the first report, so a platform that never sends it is not gated. + */ + private pointerInside: boolean | null = null; + + setPointerInside(inside: boolean) { + this.pointerInside = inside; + } + /** Cursor in window-logical coordinates. */ onCursor(x: number, y: number) { + // WebKitGTK can deliver a mousemove after the pointer has left the layer + // surface; trusting it re-enters the island and the auto-close never runs. + if (this.pointerInside === false) { + x = -10_000; + y = -10_000; + } State.mouse = { x, y }; const rect = this.islandRect(); State.mouseInIsland = { x: x - rect.x, y: y - rect.y }; diff --git a/windows/src/main.ts b/windows/src/main.ts index f65d5e757..d71525e65 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -25,6 +25,7 @@ async function main() { if (boot && !boot.cursorPoll) island.followPageCursor(); await onEvent<{ x: number; y: number }>("cursor", ({ x, y }) => island.onCursor(x, y)); + await onEvent("pointer-inside", (inside) => island.setPointerInside(inside)); /** Pause has to reach Rust too, or the pollers keep calling out. */ const setPaused = (on: boolean) => { From 32484d449fa6895696f70be3738bfabf41f2c9b9 Mon Sep 17 00:00:00 2001 From: Andrev-91 Date: Sun, 4 Oct 2026 07:49:29 -0500 Subject: [PATCH 03/77] Windows: stop Vite from watching target/ (EBUSY crash on tauri dev) On a fresh Windows build, `npm run tauri dev` dies when Cargo reaches the last crates: Vite's watcher tries to watch target/debug/deps/coucou_lib.dll while Cargo holds it locked, throws EBUSY, and the beforeDevCommand exits. Ignore target/ and src-tauri/ in the Vite watcher. Tauri already watches src-tauri/ itself. Co-Authored-By: Claude Opus 5.5 (1M context) --- windows/vite.config.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/windows/vite.config.ts b/windows/vite.config.ts index 1ff3b6565..46c91cc3d 100644 --- a/windows/vite.config.ts +++ b/windows/vite.config.ts @@ -45,7 +45,14 @@ function sharedSounds(): Plugin { export default defineConfig({ plugins: [sharedSounds()], clearScreen: false, - server: { port: 1420, strictPort: true, host: "127.0.0.1" }, + server: { + port: 1420, + strictPort: true, + host: "127.0.0.1", + // Cargo writes and locks files under target/ while Vite starts; watching them + // crashes Vite on Windows with EBUSY. Tauri watches src-tauri/ itself. + watch: { ignored: ["**/target/**", "**/src-tauri/**"] }, + }, envPrefix: ["VITE_", "TAURI_ENV_"], build: { target: "chrome110", From 9fc0ad44fd70bcc4fc96795a1109e00606e960c4 Mon Sep 17 00:00:00 2001 From: PythonTilk Date: Sun, 4 Oct 2026 23:57:36 +0200 Subject: [PATCH 04/77] feat(windows): answer Claude Code's questions from the island AskUserQuestion arrived as a permission request and got the Deny / Allow card, which says nothing about what is being asked and cannot answer it. The question view now shows the question and its options as buttons. Picking one sends the answer back to the session; several questions are stepped through in turn, pick-several questions get toggles and a Done button, and "Answer in terminal" hands the request back as before. The answer travels as {"answers":{...}} on the relay connection. coucou-hook turns it into an allow whose updatedInput is the question's own input plus that map, and refuses anything else: no other field and no other tool's input can be changed from the island. Co-Authored-By: Claude Opus 5.5 --- windows/README.md | 3 +- windows/hook/src/main.rs | 77 ++++++++++++++++++++++++----- windows/src-tauri/src/lib.rs | 11 +++++ windows/src-tauri/src/pipe.rs | 27 ++++++++-- windows/src/core/bridge.ts | 4 ++ windows/src/core/layout.ts | 3 ++ windows/src/core/state.ts | 9 ++++ windows/src/island/hooks.ts | 39 +++++++++++++-- windows/src/island/island.ts | 37 +++++++++++--- windows/src/style.css | 18 +++++++ windows/src/views/views.ts | 93 ++++++++++++++++++++++++++++++++--- 11 files changed, 283 insertions(+), 38 deletions(-) diff --git a/windows/README.md b/windows/README.md index 60374024c..2e0693291 100644 --- a/windows/README.md +++ b/windows/README.md @@ -48,7 +48,8 @@ installs for the current user only — no admin prompt. | Tray icon | Open, Settings…, Pause, Quit | Everything else happens on its own: a Claude Code permission request opens the -island with **Deny / Allow**, a finished session shows what it did, and +island with **Deny / Allow**, a question from Claude Code shows its options to +pick from, a finished session shows what it did, and your integrations sit in the coloured pills next to Mochi. ## Claude Code diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs index 5bb363781..a4eb45653 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -45,7 +45,7 @@ mod unix; use unix::connect; fn main() { - let Some((payload, event)) = read_event() else { std::process::exit(0) }; + let Some((payload, event, question)) = read_event() else { std::process::exit(0) }; let waits_for_answer = event == "PermissionRequest"; let budget = if waits_for_answer { DECISION_BUDGET } else { FIRE_AND_FORGET_BUDGET }; @@ -60,7 +60,7 @@ fn main() { }); if let Ok(Some(decision)) = rx.recv_timeout(budget) { - if let Some(json) = decision_json(&decision) { + if let Some(json) = decision_json(&decision, question.as_ref()) { let mut out = std::io::stdout(); let _ = writeln!(out, "{json}"); let _ = out.flush(); @@ -73,7 +73,30 @@ fn main() { /// The documented PermissionRequest output. Anything we do not recognise prints /// nothing at all rather than guessing — silence is the safe answer. /// See https://code.claude.com/docs/en/hooks -fn decision_json(decision: &str) -> Option { +/// +/// `question` is the AskUserQuestion input, when that is what is being asked: +/// the island may then answer it, which Claude Code takes as the same input +/// with an `answers` map added. Nothing else of the input can be changed from +/// the island, and no other tool's input can be changed at all. +fn decision_json(decision: &str, question: Option<&serde_json::Value>) -> Option { + if decision.trim_start().starts_with('{') { + let reply = serde_json::from_str::(decision).ok()?; + let answers = reply.get("answers")?.as_object()?; + if answers.is_empty() || !answers.values().all(|v| v.is_string()) { + return None; + } + let mut input = question?.as_object()?.clone(); + input.insert("answers".into(), serde_json::Value::Object(answers.clone())); + return Some( + serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { "behavior": "allow", "updatedInput": input }, + } + }) + .to_string(), + ); + } let behavior = match decision.trim() { // "always" still answers a plain allow; remembering it is the island's // business, not Claude Code's. @@ -86,8 +109,9 @@ fn decision_json(decision: &str) -> Option { )) } -/// Reads stdin and returns the payload to forward plus the event name. -fn read_event() -> Option<(String, String)> { +/// Reads stdin and returns the payload to forward, the event name, and — for an +/// AskUserQuestion permission request — the question as Claude Code sent it. +fn read_event() -> Option<(String, String, Option)> { let mut raw = Vec::new(); if std::io::stdin().read_to_end(&mut raw).is_err() || raw.is_empty() { return None; @@ -161,11 +185,17 @@ fn read_event() -> Option<(String, String)> { } } + // Kept whole: what goes back to Claude Code must be its own input, not the + // shortened copy the island is shown. + let question = (map.get("tool_name").and_then(|v| v.as_str()) == Some("AskUserQuestion")) + .then(|| map.get("tool_input").cloned()) + .flatten(); + truncate_strings(&mut payload); let mut line = payload.to_string(); line.push('\n'); - Some((line, event)) + Some((line, event, question)) } /// Caps every string in the payload. A single Write can carry a whole file. @@ -226,23 +256,46 @@ mod tests { #[test] fn decision_json_matches_the_documented_shape() { assert_eq!( - decision_json("allow").unwrap(), + decision_json("allow", None).unwrap(), r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}"# ); assert_eq!( - decision_json("deny").unwrap(), + decision_json("deny", None).unwrap(), r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied from Coucou"}}}"# ); // "always" is an island concept; Claude Code just gets an allow. - assert!(decision_json("always").unwrap().contains(r#""behavior":"allow""#)); + assert!(decision_json("always", None).unwrap().contains(r#""behavior":"allow""#)); } #[test] fn anything_unrecognised_prints_nothing() { - assert!(decision_json("").is_none()); - assert!(decision_json("maybe").is_none()); + assert!(decision_json("", None).is_none()); + assert!(decision_json("maybe", None).is_none()); // The shape the app used to send must not be mistaken for a decision. - assert!(decision_json(r#"{"permissionDecision":"allow"}"#).is_none()); + assert!(decision_json(r#"{"permissionDecision":"allow"}"#, None).is_none()); + } + + #[test] + fn an_answered_question_goes_back_as_the_same_input_plus_answers() { + let question = serde_json::json!({ + "questions": [{ "question": "Which one?", "options": [{ "label": "A" }, { "label": "B" }] }] + }); + let out = decision_json(r#"{"answers":{"Which one?":"B"}}"#, Some(&question)).unwrap(); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + let decision = &v["hookSpecificOutput"]["decision"]; + assert_eq!(decision["behavior"], "allow"); + assert_eq!(decision["updatedInput"]["questions"], question["questions"]); + assert_eq!(decision["updatedInput"]["answers"]["Which one?"], "B"); + } + + #[test] + fn answers_are_only_accepted_for_a_question() { + // Any other tool: the island cannot rewrite its input. + assert!(decision_json(r#"{"answers":{"q":"a"}}"#, None).is_none()); + let question = serde_json::json!({ "questions": [] }); + assert!(decision_json(r#"{"answers":{}}"#, Some(&question)).is_none()); + assert!(decision_json(r#"{"answers":{"q":1}}"#, Some(&question)).is_none()); + assert!(decision_json(r#"{"command":"rm -rf /"}"#, Some(&question)).is_none()); } #[test] diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..c3f8881cc 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -216,6 +216,16 @@ fn approval_decision(app: AppHandle, request_id: String, decision: String) { pipe::answer(&app, &request_id, &decision); } +/// An option picked on the island for a question Claude Code asked. +#[tauri::command] +fn approval_answer( + app: AppHandle, + request_id: String, + answers: std::collections::HashMap, +) { + pipe::answer_question(&app, &request_id, &answers); +} + /// 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. @@ -388,6 +398,7 @@ pub fn run() { hooks_preview, hooks_apply, approval_decision, + approval_answer, approval_ack, approval_decline, log_line, diff --git a/windows/src-tauri/src/pipe.rs b/windows/src-tauri/src/pipe.rs index 36b6316c2..8e0bbcada 100644 --- a/windows/src-tauri/src/pipe.rs +++ b/windows/src-tauri/src/pipe.rs @@ -14,9 +14,11 @@ // * whatever happens we drop the connection after the decision timeout, and // the terminal takes over. // -// What we write back is the bare word `allow` or `deny`. Turning that into the -// documented hookSpecificOutput JSON is coucou-hook's job, so the wire format -// Claude Code expects lives in exactly one place. +// What we write back is the bare word `allow` or `deny` — or, when the request +// was Claude Code asking a question, `{"answers":{…}}` with what was picked on +// the island. Turning either into the documented hookSpecificOutput JSON is +// coucou-hook's job, so the wire format Claude Code expects lives in exactly +// one place. use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; @@ -230,7 +232,7 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option {} // A click that beats the ack is still a click. Ok(Some(Reply::Decision(d))) => { - log::line(format!("hook id={id} answered {d}")); + log::line(format!("hook id={id} answered {}", loggable(&d))); return Some(d); } Ok(Some(Reply::Decline)) => { @@ -246,7 +248,7 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option { - log::line(format!("hook id={id} answered {d}")); + log::line(format!("hook id={id} answered {}", loggable(&d))); Some(d) } Ok(Some(Reply::Decline)) => { @@ -260,6 +262,11 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option &str { + if decision.starts_with('{') { "a question" } else { decision } +} + fn send(app: &AppHandle, request_id: &str, reply: Reply, keep: bool) { let sender = { let pending = app.state::(); @@ -295,3 +302,13 @@ pub fn answer(app: &AppHandle, request_id: &str, decision: &str) { log::line(format!("decision id={request_id} {word}")); send(app, request_id, Reply::Decision(word.to_string()), false); } + +/// Called when an option is picked for a question Claude Code asked. `answers` +/// maps each question's text to the chosen label, which is the shape +/// AskUserQuestion takes them in. +pub fn answer_question(app: &AppHandle, request_id: &str, answers: &HashMap) { + log::line(format!("decision id={request_id} answered a question")); + // One line: the relay reads up to the first newline. + let line = json!({ "answers": answers }).to_string(); + send(app, request_id, Reply::Decision(line), false); +} diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..18fd1b59b 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -77,6 +77,10 @@ export const Bridge = { call("approval_decision", { requestId, decision }), /** "The card is up" — until this lands the relay only waits a moment. */ approvalAck: (requestId: string) => call("approval_ack", { requestId }), + /** Answers a question Claude Code asked: question text → chosen label. */ + approvalAnswer: (requestId: string, answers: Record) => + call("approval_answer", { requestId, answers }), + /** "Nobody can act on this" — Claude Code asks in the terminal right away. */ approvalDecline: (requestId: string) => call("approval_decline", { requestId }), diff --git a/windows/src/core/layout.ts b/windows/src/core/layout.ts index 6f70eea4c..2b450a48f 100644 --- a/windows/src/core/layout.ts +++ b/windows/src/core/layout.ts @@ -92,6 +92,9 @@ export const VIEW_LAYOUTS: Record = { // dropped the whole sequence — Mochi included — is drawn by src/upload, which // owns its own constants (USC) straight from UploadSequenceEngine.swift. +/** The question view with options to pick from: room for two rows of them. */ +export const QUESTION_PICKER_H = 200; + /** Chat view grows with the conversation — IslandContainer.chatPromptHeight. */ export function chatPromptHeight(messageCount: number): number { return Math.min(300, 240 + messageCount * 40); diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..b6060e197 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -26,6 +26,15 @@ export interface ApprovalInfo { sessionId: string; tool: string; command: string; + /** Set when Claude Code is asking a question rather than for a permission. */ + questions?: AskedQuestion[]; +} + +/** One question of an AskUserQuestion call. */ +export interface AskedQuestion { + question: string; + options: { label: string; description: string }[]; + multiSelect: boolean; } export interface ChatMessage { diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index d90a78f09..037091b60 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -5,7 +5,7 @@ import { Bridge, onEvent } from "../core/bridge"; import { Sound } from "../core/sound"; -import { State } from "../core/state"; +import { State, type AskedQuestion } from "../core/state"; import type { Island } from "./island"; const CLAUDE_ID = "integration_claude"; @@ -120,6 +120,28 @@ function approvalTarget(tool: string, input: Record): string { return tool; } +/** + * The questions of an AskUserQuestion call, if the island can show all of them + * as options to pick from. Anything it cannot is left to the terminal. + */ +function askedQuestions(tool: string, input: Record): AskedQuestion[] | null { + if (tool !== "AskUserQuestion" || !Array.isArray(input.questions)) return null; + const out: AskedQuestion[] = []; + for (const raw of input.questions as Record[]) { + const question = typeof raw?.question === "string" ? raw.question : ""; + const options = (Array.isArray(raw?.options) ? (raw.options as Record[]) : []) + .filter((o) => typeof o?.label === "string" && o.label) + .map((o) => ({ + label: o.label as string, + description: typeof o.description === "string" ? o.description : "", + })); + // A question cut short by the relay would be answered under the wrong text. + if (!question || question.endsWith("…") || options.length < 2) return null; + out.push({ question, options, multiSelect: raw.multiSelect === true }); + } + return out.length > 0 ? out : null; +} + function upsert(projectName: string, cwd: string) { const t = State.tasks.find((x) => x.id === CLAUDE_ID); if (!t) return; @@ -291,20 +313,25 @@ function handleHook(island: Island, payload: HookPayload) { if (pendingTimeout != null) window.clearTimeout(pendingTimeout); const tool = payload.tool_name ?? "Tool"; const input = payload.tool_input ?? {}; + // Claude Code asking a question is not a permission to grant: the island + // shows the options and sends back the one that was picked. + const questions = askedQuestions(tool, input); + const view = questions ? "question" : "approval"; State.pendingApproval = { requestId, sessionId: payload.session_id ?? "", tool, command: approvalTarget(tool, input), + ...(questions ? { questions } : {}), }; // 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(CLAUDE_ID, view); State.isPinned = true; - Sound.play("approval"); + Sound.play(view); if (focused) { - island.alert("approval"); + island.alert(view); } else { // 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 @@ -322,7 +349,9 @@ function handleHook(island: Island, payload: HookPayload) { island.dropPin(); State.updateTask(CLAUDE_ID, "working"); State.setPillBadge(CLAUDE_ID, null); - if (State.view === "approval") island.setView(State.defaultView()); + if (State.view === "approval" || State.view === "question") { + island.setView(State.defaultView()); + } State.notify(); }, 110_000); break; diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb61802..7aee49bad 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -7,6 +7,7 @@ import { EXPANDED_CORNER, EXPANDED_W, NOTCH_W, PANEL_H, PANEL_W, ROUNDED_CORNER, VIEW_LAYOUTS, botGlowColor, botGlowOpacity, botPosition, chatPromptHeight, islandSize, + QUESTION_PICKER_H, type IslandMode, type IslandViewName, } from "../core/layout"; import { Sound } from "../core/sound"; @@ -101,6 +102,16 @@ export class Island { }); } + /** The request has its answer: the card goes and the session carries on. */ + private closeApproval() { + State.pendingApproval = null; + State.isPinned = false; + this.fsm.pinned = false; + State.updateTask("integration_claude", "working"); + State.setPillBadge("integration_claude", null); + this.setView(State.defaultView()); + } + // ── DOM ───────────────────────────────────────────────────────────────────── private build() { @@ -140,12 +151,21 @@ export class Island { if (!req) return; Sound.play(d === "deny" ? "blip" : "approve"); void Bridge.approvalDecision(req.requestId, d); - State.pendingApproval = null; - State.isPinned = false; - this.fsm.pinned = false; - State.updateTask("integration_claude", "working"); - State.setPillBadge("integration_claude", null); - this.setView(State.defaultView()); + this.closeApproval(); + }, + answer: (answers) => { + const req = State.pendingApproval; + if (!req) return; + Sound.play("approve"); + void Bridge.approvalAnswer(req.requestId, answers); + this.closeApproval(); + }, + answerInTerminal: () => { + const req = State.pendingApproval; + if (!req) return; + Sound.play("blip"); + void Bridge.approvalDecline(req.requestId); + this.closeApproval(); }, toggleSound: () => { State.settings.soundEnabled = !State.settings.soundEnabled; @@ -450,7 +470,10 @@ export class Island { // ── Geometry ──────────────────────────────────────────────────────────────── private targetSize(): { w: number; h: number; r: number } { - const { w, h } = islandSize(State.mode, State.view, State.chatHistory.length); + let { w, h } = islandSize(State.mode, State.view, State.chatHistory.length); + if (State.mode === "expanded" && State.view === "question" && State.pendingApproval?.questions) { + h = QUESTION_PICKER_H; + } const r = State.mode === "expanded" ? EXPANDED_CORNER : ROUNDED_CORNER; return { w, h, r }; } diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c..a93b2713f 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -640,6 +640,24 @@ body { gap: 8px; } +/* Options to pick from when Claude Code asks a question. */ +.actions.options { + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.question-text { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.btn.off { + opacity: 0.4; +} + .status-badge { display: inline-flex; align-items: center; diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f..8c42af7e5 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -21,6 +21,10 @@ export interface ViewActions { openTarget(): void; openUrl(url: string): void; decide(d: "allow" | "deny"): void; + /** Answers the question Claude Code asked: question text → chosen label. */ + answer(answers: Record): void; + /** Hands the pending request back to the terminal. */ + answerInTerminal(): void; toggleSound(): void; setVolume(v: number): void; setAutoClose(seconds: number): void; @@ -319,20 +323,93 @@ function buildApproval(actions: ViewActions): ViewHost { // ── Question ────────────────────────────────────────────────────────────────── -function buildQuestion(): ViewHost { +function buildQuestion(actions: ViewActions): ViewHost { const who = h("div"); - const title = h("div", { class: "title" }); - const row = h("div", { class: "actions" }); + const title = h("div", { class: "title question-text" }); + const row = h("div", { class: "actions options" }); const el = h("div", { class: "view" }, card("cyan", stack(116, 16, who, title, row))); + + // Where we are in the request on screen: which question, what is answered so + // far, and what is ticked in a pick-several question. + let requestId = ""; + let index = 0; + let answers: Record = {}; + let picked = new Set(); + // The buttons are only rebuilt when what they show changes: rebuilding them + // between a mouse-down and a mouse-up would swallow the click. + let rowKey = ""; + + const next = (question: string, answer: string, total: number) => { + answers[question] = answer; + picked = new Set(); + index += 1; + if (index >= total) actions.answer(answers); + else State.notify(); + }; + return { el, sync() { + const questions = State.pendingApproval?.questions; clear(who); - who.append(agentWho(State.focusTask, "Claude Code is asking a question")); - const task = State.focusTask; - title.textContent = task?.steps.at(-1) ?? "Claude needs an answer."; + + // A question that arrived as a notification has nothing to pick from. + if (!questions) { + who.append(agentWho(State.focusTask, "is asking a question")); + title.textContent = State.focusTask?.steps.at(-1) ?? "Claude needs an answer."; + if (rowKey !== "terminal") { + rowKey = "terminal"; + clear(row); + row.append(h("div", { class: "sub", text: "Answer it in your terminal." })); + } + return; + } + + if (State.pendingApproval!.requestId !== requestId) { + requestId = State.pendingApproval!.requestId; + index = 0; + answers = {}; + picked = new Set(); + } + const q = questions[Math.min(index, questions.length - 1)]; + const step = questions.length > 1 ? ` (${index + 1} of ${questions.length})` : ""; + who.append(agentWho(State.focusTask, `is asking${step}`)); + title.textContent = q.question; + title.title = q.question; + + const key = `${requestId}:${index}:${[...picked].join("|")}`; + if (rowKey === key) return; + rowKey = key; clear(row); - row.append(h("div", { class: "sub", text: "Answer in your terminal — Coucou can't reply for you yet." })); + for (const option of q.options) { + const on = picked.has(option.label); + const button = btn(option.label, on ? "primary" : "secondary", () => { + if (!q.multiSelect) { + next(q.question, option.label, questions.length); + return; + } + if (on) picked.delete(option.label); + else picked.add(option.label); + State.notify(); + }); + if (option.description) button.title = option.description; + row.append(button); + } + if (q.multiSelect) { + const done = btn("Done", "primary", () => { + if (picked.size > 0) next(q.question, [...picked].join(", "), questions.length); + }); + if (picked.size === 0) done.classList.add("off"); + row.append(done); + } + row.append( + h("button", { + class: "link-btn", + style: "color:#8e939c", + text: "Answer in terminal", + onclick: () => actions.answerInTerminal(), + }), + ); }, }; } @@ -491,7 +568,7 @@ export function buildViews( map.set("overview", buildOverview(actions)); map.set("empty", buildEmpty(actions)); map.set("approval", buildApproval(actions)); - map.set("question", buildQuestion()); + map.set("question", buildQuestion(actions)); map.set("error", buildError(actions)); map.set("finished", buildFinished(actions)); map.set("confused", buildConfused()); From 83b30879afcafef4efeb7b6be996f87b7292f044 Mon Sep 17 00:00:00 2001 From: chux Date: Sun, 4 Oct 2026 23:06:48 -0400 Subject: [PATCH 05/77] Linux: pin the layer-shell island to its display A layer surface ignores set_position, so the island landed on whichever output had focus when it was mapped. Pin it with gtk_layer_set_monitor to the display target_monitor picked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BNUKEFycKsFCP5fKkhY1Uf --- windows/src-tauri/src/island.rs | 9 ++++++++ windows/src-tauri/src/platform/linux.rs | 25 +++++++++++++++++++++++ windows/src-tauri/src/platform/windows.rs | 3 +++ 3 files changed, 37 insertions(+) diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 92c9cd4cb..ad184e763 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -114,6 +114,13 @@ fn monitor_contains(m: &Monitor, x: f64, y: f64) -> bool { && y < (p.y + s.height as i32) as f64 } +/// A display's logical origin: GDK monitor geometry is in logical pixels. +fn logical_origin(m: &Monitor) -> (i32, i32) { + let scale = m.scale_factor(); + let p = m.position(); + ((p.x as f64 / scale).round() as i32, (p.y as f64 / scale).round() as i32) +} + /// The display the island lives on: the primary one, or the one under the cursor. fn target_monitor(app: &AppHandle, pref: &str) -> Option { let monitors = app.available_monitors().ok()?; @@ -172,6 +179,8 @@ pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { let _ = win.set_resizable(true); let _ = win.set_size(PhysicalSize::new(pw, ph)); let _ = win.set_position(PhysicalPosition::new(x, y)); + let (lx, ly) = logical_origin(&m); + platform::pin_to_monitor(&win, lx, ly); // Moving across displays can rescale the window: re-assert the physical size. let _ = win.set_size(PhysicalSize::new(pw, ph)); let _ = win.set_always_on_top(true); diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e748a336a..eeab97360 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -181,6 +181,7 @@ mod layer { pub fn gtk_layer_set_anchor(window: *mut GtkWindow, edge: c_int, anchor: c_int); pub fn gtk_layer_set_exclusive_zone(window: *mut GtkWindow, zone: c_int); pub fn gtk_layer_set_keyboard_mode(window: *mut GtkWindow, mode: c_int); + pub fn gtk_layer_set_monitor(window: *mut GtkWindow, monitor: *mut gtk::gdk::ffi::GdkMonitor); } } @@ -263,6 +264,30 @@ pub fn make_non_activating(win: &WebviewWindow) { crate::log::line("island is a layer-shell overlay"); } +/// Puts the layer surface on the display whose logical origin is (`x`, `y`). +/// +/// A layer surface ignores `set_position`: without an explicit output the +/// compositor maps it on whichever display has focus at that moment, so on a +/// multi-monitor Hyprland or Sway desktop the island wandered from one screen +/// to the other every time it was remapped. A no-op for a regular window, which +/// `set_position` already places. +pub fn pin_to_monitor(win: &WebviewWindow, x: i32, y: i32) { + if !LAYER_SURFACE.load(Ordering::Relaxed) { + return; + } + let Ok(gw) = win.gtk_window() else { return }; + let display = gw.display(); + let monitor = (0..display.n_monitors()) + .filter_map(|i| display.monitor(i)) + .find(|m| { + let g = m.geometry(); + g.x() == x && g.y() == y + }); + let Some(monitor) = monitor else { return }; + let mon_ptr: *mut gtk::gdk::ffi::GdkMonitor = monitor.to_glib_none().0; + unsafe { layer::gtk_layer_set_monitor(gtk_window_ptr(&gw), mon_ptr) }; +} + /// Temporarily allow keyboard focus so a text field inside the island can be /// typed in. pub fn set_activating(win: &WebviewWindow, activating: bool) { diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 6e1200a8a..fe4bcae21 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -233,3 +233,6 @@ pub fn set_activating(win: &WebviewWindow, activating: bool) { /// Click-through here is the poll's WS_EX_TRANSPARENT toggle, not a region. pub fn set_input_region(_win: &WebviewWindow, _rect: Option<(f64, f64, f64, f64)>) {} + +/// `set_position` already places a Win32 window on the right display. +pub fn pin_to_monitor(_win: &WebviewWindow, _x: i32, _y: i32) {} From 4207be9e82f8f9404aa05b2f58dcc04a81592cbc Mon Sep 17 00:00:00 2001 From: chux Date: Sun, 4 Oct 2026 23:07:03 -0400 Subject: [PATCH 06/77] Settings: choose which display the island lives on "Main display" means little on Wayland: GDK has no primary monitor, so it is whichever output GDK lists first. List every display in "Island lives on" and store the choice as at:, (logical origin). Names cannot be the key: two monitors of the same model report the same one. A display that is gone falls back to the primary one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BNUKEFycKsFCP5fKkhY1Uf --- windows/src-tauri/src/island.rs | 39 +++++++++++++++++++++++++++++++-- windows/src-tauri/src/lib.rs | 7 ++++++ windows/src/core/bridge.ts | 3 +++ windows/src/core/state.ts | 3 ++- windows/src/settings/main.ts | 8 ++++++- 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index ad184e763..0ce036da5 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -114,16 +114,51 @@ fn monitor_contains(m: &Monitor, x: f64, y: f64) -> bool { && y < (p.y + s.height as i32) as f64 } -/// A display's logical origin: GDK monitor geometry is in logical pixels. +/// A display's logical origin, the key `at:,` preferences are matched on. +/// Names are no good for that: two monitors of the same model share one. fn logical_origin(m: &Monitor) -> (i32, i32) { let scale = m.scale_factor(); let p = m.position(); ((p.x as f64 / scale).round() as i32, (p.y as f64 / scale).round() as i32) } -/// The display the island lives on: the primary one, or the one under the cursor. +/// One entry of the "Island lives on" list in Settings. +#[derive(Serialize, Clone)] +pub struct MonitorChoice { + pub key: String, + pub label: String, +} + +pub fn monitor_choices(app: &AppHandle) -> Vec { + let Ok(monitors) = app.available_monitors() else { return Vec::new() }; + monitors + .iter() + .map(|m| { + let (x, y) = logical_origin(m); + let scale = m.scale_factor(); + let s = m.size(); + let w = (s.width as f64 / scale).round(); + let h = (s.height as f64 / scale).round(); + let name = m.name().cloned().unwrap_or_else(|| "Display".into()); + MonitorChoice { key: format!("at:{x},{y}"), label: format!("{name} — {w}×{h} at {x},{y}") } + }) + .collect() +} + +/// The display the island lives on: a chosen one, the primary one, or the one +/// under the cursor. fn target_monitor(app: &AppHandle, pref: &str) -> Option { let monitors = app.available_monitors().ok()?; + if let Some((x, y)) = pref + .strip_prefix("at:") + .and_then(|r| r.split_once(',')) + .and_then(|(x, y)| Some((x.trim().parse::().ok()?, y.trim().parse::().ok()?))) + { + // Unplugged or rearranged since: fall back to the primary display. + if let Some(m) = monitors.iter().find(|m| logical_origin(m) == (x, y)) { + return Some(m.clone()); + } + } if pref == "cursor" { if let Some((cx, cy)) = cursor_physical() { if let Some(m) = monitors.iter().find(|m| monitor_contains(m, cx, cy)) { diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..2c5233700 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -124,6 +124,12 @@ fn reposition(app: AppHandle, shared: State) { island::apply_geometry(&app, &pref, collapsed); } +/// The displays the island can be pinned to, for Settings. +#[tauri::command] +fn list_monitors(app: AppHandle) -> Vec { + island::monitor_choices(&app) +} + #[tauri::command] fn open_url(url: String) { if !(url.starts_with("http://") || url.starts_with("https://")) { @@ -381,6 +387,7 @@ pub fn run() { set_island_rect, focus_window, reposition, + list_monitors, open_url, open_in_vscode, quit_app, diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..32775da81 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -50,6 +50,9 @@ export const Bridge = { reposition: () => call("reposition"), + /** Displays the island can be pinned to: `key` is what `settings.screen` stores. */ + listMonitors: () => call<{ key: string; label: string }[]>("list_monitors"), + openUrl: (url: string) => call("open_url", { url }), /** "Open terminal" → opens the folder in VS Code when `code` is on PATH. */ diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..4b1b2335b 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -87,7 +87,8 @@ export interface Settings { autoCloseInterval: number; absenceInterval: number; activeIntegrations: string[]; - screen: "primary" | "cursor"; + /** "primary", "cursor", or `at:,` for one display (logical origin). */ + screen: string; autostart: boolean; hooksInstalled: boolean; /** Claude model used by the chat. */ diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..2e8a02a3a 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -387,8 +387,14 @@ function generalSection(): HTMLElement { h("option", { value: "cursor", text: "Display under the cursor" }), ); screen.value = settings.screen; + void Bridge.listMonitors().then((list) => { + for (const m of list ?? []) screen.append(h("option", { value: m.key, text: m.label })); + // Set again now the option exists; a display that is gone shows as the main one. + screen.value = settings.screen; + if (!screen.value) screen.value = "primary"; + }); screen.addEventListener("change", () => { - settings.screen = screen.value as Settings["screen"]; + settings.screen = screen.value; void save(); }); From 5b06e1c828797d0ced61cc0d268bfb86f189f056 Mon Sep 17 00:00:00 2001 From: chux Date: Sun, 4 Oct 2026 23:07:03 -0400 Subject: [PATCH 07/77] Island: ignore clicks on a fresh permission card for 600 ms The card opens under a cursor that was busy with the window underneath, so a click meant for that window could land on Allow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BNUKEFycKsFCP5fKkhY1Uf --- windows/src/views/views.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f..73489e0db 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -288,15 +288,32 @@ function buildEmpty(actions: ViewActions): ViewHost { // ── Approval ────────────────────────────────────────────────────────────────── +/** How long a fresh permission card ignores clicks on its buttons. */ +const CLICK_GUARD_MS = 600; + function buildApproval(actions: ViewActions): ViewHost { const who = h("div"); const code = h("div", { class: "code" }); const row = h("div", { class: "actions" }); const el = h("div", { class: "view" }, card("amber", stack(116, 16, who, code, row))); let rowKey = ""; + // The card pops up under a cursor that was busy with something else: a click + // meant for the window underneath must not land on Allow. Clicks in the first + // moments after a new request appears are ignored. + let shownFor: string | null = null; + let shownAt = 0; + const guarded = (d: "allow" | "deny") => () => { + if (performance.now() - shownAt < CLICK_GUARD_MS) return; + actions.decide(d); + }; return { el, sync() { + const req = State.pendingApproval?.requestId ?? null; + if (req !== shownFor) { + shownFor = req; + shownAt = performance.now(); + } clear(who); who.append(agentWho(State.focusTask, "needs permission")); // The whole point of approving here rather than in the terminal: this line @@ -310,8 +327,8 @@ function buildApproval(actions: ViewActions): ViewHost { rowKey = "built"; clear(row); row.append( - btn("Deny", "secondary", () => actions.decide("deny"), "N"), - btn("Allow", "primary", () => actions.decide("allow"), "Y"), + btn("Deny", "secondary", guarded("deny"), "N"), + btn("Allow", "primary", guarded("allow"), "Y"), ); }, }; From 0bd0eb8d4b90c9cd3b32f504d5462d6ae75680c9 Mon Sep 17 00:00:00 2001 From: chux Date: Sun, 4 Oct 2026 23:07:03 -0400 Subject: [PATCH 08/77] Settings: name the Secret Service as the key store on Linux Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BNUKEFycKsFCP5fKkhY1Uf --- windows/src/settings/main.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 2e8a02a3a..df9a881fb 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -7,6 +7,11 @@ import { Bridge, onEvent, type HookStatus } from "../core/bridge"; import { DEFAULT_SETTINGS, type Settings } from "../core/state"; import { h, clear } from "../views/dom"; +/** Where secrets.rs keeps the keys on this OS. */ +const KEY_STORE = navigator.userAgent.includes("Windows") + ? "Windows Credential Manager" + : "Secret Service (GNOME Keyring, KWallet)"; + let settings: Settings = { ...DEFAULT_SETTINGS }; let version = ""; @@ -181,7 +186,7 @@ const MODELS: [string, string][] = [ function apiSection(hasKey: boolean): HTMLElement { const dot = statusDot(hasKey); - const state = h("span", { class: "hint", text: hasKey ? "Key saved in the Windows Credential Manager." : "No key yet — the chat needs one." }); + const state = h("span", { class: "hint", text: hasKey ? `Key saved in the ${KEY_STORE}.` : "No key yet — the chat needs one." }); const field = h("input", { type: "password", @@ -199,7 +204,7 @@ function apiSection(hasKey: boolean): HTMLElement { const present = (await Bridge.secretPresent("anthropic-api-key")) ?? false; dot.style.background = present ? "#22c55e" : "#f4505e"; state.textContent = present - ? "Key saved in the Windows Credential Manager." + ? `Key saved in the ${KEY_STORE}.` : "No key yet — the chat needs one."; field.placeholder = present ? "•••••••••••• (stored)" : "sk-ant-..."; clearBtn.style.display = present ? "" : "none"; @@ -292,7 +297,7 @@ function integrationsSection(present: Record): HTMLElement { function updateNote() { const used = settings.activeIntegrations.length; - note.textContent = `Pick up to ${MAX_ACTIVE} pills to show next to Mochi — ${used}/${MAX_ACTIVE} in use. Keys are stored in the Windows Credential Manager, never on disk.`; + note.textContent = `Pick up to ${MAX_ACTIVE} pills to show next to Mochi — ${used}/${MAX_ACTIVE} in use. Keys are stored in the ${KEY_STORE}, never on disk.`; } for (const def of INTEGRATIONS) { From 5f7dfacae085c9b968c4ffe95125a8b86b467595 Mon Sep 17 00:00:00 2001 From: KauaDc Date: Mon, 5 Oct 2026 08:37:11 -0300 Subject: [PATCH 09/77] Windows: take file drops from every Explorer view, and make Cancel work Files dragged from the classic Explorer folder view (Downloads, Documents...) got the "no drop" cursor: the island's innermost windows belong to msedgewebview2.exe and those drags never reached wry's drop target. Only the WinUI views (Home, Recent) got through. On Windows the island now lets WebView2 take the drop, as Edge does, and reads the real paths back through postMessageWithAdditionalObjects (webview_drop.rs). Linux keeps Tauri's drag events (tauri.linux.conf.json still enables them). Cancel on the dropped-file card never received the click: the hidden view and #content sat above the canvas buttons. It now also forgets the file, so the chat no longer picks it up, and a copy finishing late cannot bring it back. Co-Authored-By: Claude Opus 5.5 --- windows/Cargo.lock | 2 + windows/src-tauri/Cargo.toml | 4 ++ windows/src-tauri/src/lib.rs | 4 ++ windows/src-tauri/src/platform/windows.rs | 21 +++---- windows/src-tauri/src/webview_drop.rs | 69 +++++++++++++++++++++++ windows/src-tauri/tauri.conf.json | 2 +- windows/src/core/bridge.ts | 66 ++++++++++++++++++++-- windows/src/island/island.ts | 21 ++++++- windows/src/style.css | 8 ++- windows/src/views/upload.ts | 2 +- windows/src/views/views.ts | 2 + 11 files changed, 179 insertions(+), 22 deletions(-) create mode 100644 windows/src-tauri/src/webview_drop.rs diff --git a/windows/Cargo.lock b/windows/Cargo.lock index 6278c7525..489bfbf82 100644 --- a/windows/Cargo.lock +++ b/windows/Cargo.lock @@ -623,7 +623,9 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-single-instance", "tokio", + "webview2-com", "windows 0.61.3", + "windows-core 0.62.2", ] [[package]] diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml index f581867d9..3b9bcc91f 100644 --- a/windows/src-tauri/Cargo.toml +++ b/windows/src-tauri/Cargo.toml @@ -24,6 +24,10 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus [target.'cfg(windows)'.dependencies] keyring = { version = "3", features = ["windows-native"] } +# The island's file drop reads paths straight from WebView2 (webview_drop.rs). +# Same versions as wry, so nothing new is compiled. +webview2-com = "0.39" +wv2_core = { package = "windows-core", version = "0.62" } windows = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..b75833833 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -11,6 +11,8 @@ mod platform; mod secrets; mod settings; mod tray; +#[cfg(windows)] +mod webview_drop; use std::process::Command; use std::sync::atomic::Ordering; @@ -410,6 +412,8 @@ pub fn run() { if let Some(win) = island::window(&handle) { platform::make_non_activating(&win); + #[cfg(windows)] + webview_drop::install(&handle); island::apply_geometry(&handle, &loaded.screen, false); let _ = win.show(); } diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 6e1200a8a..fca91ed1c 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -20,7 +20,6 @@ use ::windows::Win32::UI::WindowsAndMessaging::{ }; use super::LocalTime; -use crate::island::WINDOW_LABEL; /// File name of the Claude Code relay. pub const HOOK_EXE: &str = "coucou-hook.exe"; @@ -173,24 +172,22 @@ fn hwnd_of(win: &WebviewWindow) -> Option { Some(HWND(raw as *mut _)) } -/// Lets dropped files reach the app again. +/// Keeps the settings window from showing the "no drop" cursor. /// /// wry installs its drop target by walking the webview's child windows **once**, /// when the webview is created. WebView2 creates `Chrome_RenderWidgetHostHWND` /// later and registers its own target on it; being the innermost window, that one -/// wins, and since the page has no HTML5 drop handler it refuses everything — the -/// "no drop" cursor, with nothing reaching Tauri. Revoking it makes OLE fall -/// through to the target wry registered on the parent widget, which is the one -/// that feeds Tauri's drag events. +/// wins, and since the page has no HTML5 drop handler it refuses everything. +/// Revoking it makes OLE fall through to wry's target. The island is the other +/// way round: it takes drops through WebView2's own target (see webview_drop.rs), +/// so its windows are left alone. /// /// Cheap and idempotent, so it is simply re-run whenever a drag might be starting. pub fn unblock_webview_drops(app: &AppHandle) { - for label in [WINDOW_LABEL, "settings"] { - let Some(win) = app.get_webview_window(label) else { continue }; - let Some(hwnd) = hwnd_of(&win) else { continue }; - unsafe { - let _ = EnumChildWindows(Some(hwnd), Some(revoke_render_widget), LPARAM(0)); - } + let Some(win) = app.get_webview_window("settings") else { return }; + let Some(hwnd) = hwnd_of(&win) else { return }; + unsafe { + let _ = EnumChildWindows(Some(hwnd), Some(revoke_render_widget), LPARAM(0)); } } diff --git a/windows/src-tauri/src/webview_drop.rs b/windows/src-tauri/src/webview_drop.rs new file mode 100644 index 000000000..4a729c8b4 --- /dev/null +++ b/windows/src-tauri/src/webview_drop.rs @@ -0,0 +1,69 @@ +// Files dropped on the island, taken by WebView2 itself. +// +// Tauri's drag-drop goes through drop targets wry registers on the webview's +// child windows. The innermost windows, though, belong to msedgewebview2.exe, +// and drags from the classic Explorer folder view (Downloads, Documents…) never +// walked up to wry's target: "no drop" cursor, nothing reached the app. Only the +// newer WinUI views (Home, Recent) got through. +// +// So `dragDropEnabled` is off for the island and WebView2 takes drops the way +// Edge does. The page accepts the HTML5 drag (src/core/bridge.ts) and hands the +// dropped File objects back with `chrome.webview.postMessageWithAdditionalObjects`; +// here they turn back into real paths and reach the island as a `file-drag` drop. + +use tauri::{AppHandle, Emitter}; +use webview2_com::Microsoft::Web::WebView2::Win32::{ + ICoreWebView2File, ICoreWebView2WebMessageReceivedEventArgs2, +}; +use webview2_com::{take_pwstr, WebMessageReceivedEventHandler}; +use wv2_core::{Interface, PWSTR}; + +use crate::island::{self, WINDOW_LABEL}; + +#[derive(serde::Serialize, Clone)] +struct FileDrop { + #[serde(rename = "type")] + kind: &'static str, + paths: Vec, +} + +pub fn install(app: &AppHandle) { + let Some(win) = island::window(app) else { return }; + let handle = app.clone(); + let result = win.with_webview(move |webview| unsafe { + let core = match webview.controller().CoreWebView2() { + Ok(core) => core, + Err(err) => { + crate::log::line(format!("file drop: no webview: {err}")); + return; + } + }; + let handler = WebMessageReceivedEventHandler::create(Box::new(move |_, args| { + let Some(args) = args else { return Ok(()) }; + // Tauri's own IPC messages come through here too; they carry no objects. + let Ok(args) = args.cast::() else { return Ok(()) }; + let Ok(objects) = args.AdditionalObjects() else { return Ok(()) }; + let mut count = 0u32; + objects.Count(&mut count)?; + if count == 0 { + return Ok(()); + } + let mut paths = Vec::new(); + for i in 0..count { + let Ok(file) = objects.GetValueAtIndex(i)?.cast::() else { continue }; + let mut path = PWSTR::null(); + file.Path(&mut path)?; + paths.push(take_pwstr(path)); + } + let _ = handle.emit_to(WINDOW_LABEL, "file-drag", FileDrop { kind: "drop", paths }); + Ok(()) + })); + let mut token = 0i64; + if let Err(err) = core.add_WebMessageReceived(&handler, &mut token) { + crate::log::line(format!("file drop: cannot listen: {err}")); + } + }); + if let Err(err) = result { + crate::log::line(format!("file drop: {err}")); + } +} diff --git a/windows/src-tauri/tauri.conf.json b/windows/src-tauri/tauri.conf.json index 2196b8159..5d2f0282c 100644 --- a/windows/src-tauri/tauri.conf.json +++ b/windows/src-tauri/tauri.conf.json @@ -34,7 +34,7 @@ "maximizable": false, "minimizable": false, "closable": false, - "dragDropEnabled": true, + "dragDropEnabled": false, "additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required" } ] diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..774ab1b51 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -150,12 +150,70 @@ export interface DragDropPayload { paths?: string[]; } -/** Files dragged onto the island. Only reaches us when the window takes the mouse. */ +interface WebView2Bridge { + postMessageWithAdditionalObjects(message: unknown, objects: ArrayLike): void; +} + +/** + * Files dragged onto the island. Only reaches us when the window takes the mouse. + * + * On Windows WebView2 takes the drop itself, as in Edge — Tauri's drop handling + * never sees drags from the classic Explorer folder view (see + * src-tauri/src/webview_drop.rs). Enter/over/leave come straight from the page; + * the drop hands the File objects to Rust, which answers with their real paths + * as a `file-drag` event. Elsewhere (Linux) Tauri's own drag events still do it. + */ export async function onDragDrop(handler: (e: DragDropPayload) => void) { if (!IS_TAURI) return () => {}; - return getCurrentWebview().onDragDropEvent((event) => { - handler(event.payload as DragDropPayload); - }); + const webview = (window as unknown as { chrome?: { webview?: WebView2Bridge } }).chrome?.webview; + if (!webview) { + return getCurrentWebview().onDragDropEvent((event) => { + handler(event.payload as DragDropPayload); + }); + } + const hasFiles = (e: DragEvent) => e.dataTransfer?.types.includes("Files") ?? false; + // dragenter/dragleave fire for every element crossed; only the outermost pair counts. + let depth = 0; + + const onEnter = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + if (depth++ === 0) handler({ type: "enter" }); + }; + const onOver = (e: DragEvent) => { + if (!hasFiles(e)) return; + // Without this WebView2 refuses the drop — or navigates to the file. + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; + handler({ type: "over" }); + }; + const onLeave = (e: DragEvent) => { + if (!hasFiles(e) || depth === 0) return; + if (--depth === 0) handler({ type: "leave" }); + }; + const onDrop = (e: DragEvent) => { + e.preventDefault(); + depth = 0; + const files = e.dataTransfer?.files; + if (!files || files.length === 0) { + handler({ type: "drop", paths: [] }); + return; + } + webview.postMessageWithAdditionalObjects("coucou-file-drop", files); + }; + + window.addEventListener("dragenter", onEnter); + window.addEventListener("dragover", onOver); + window.addEventListener("dragleave", onLeave); + window.addEventListener("drop", onDrop); + const unlisten = await listen("file-drag", (e) => handler(e.payload)); + return () => { + window.removeEventListener("dragenter", onEnter); + window.removeEventListener("dragover", onOver); + window.removeEventListener("dragleave", onLeave); + window.removeEventListener("drop", onDrop); + unlisten(); + }; } export async function onEvent(name: string, handler: (payload: T) => void) { diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb61802..a942435fd 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -106,6 +106,7 @@ export class Island { private build() { const actions: ViewActions = { setView: (v) => this.setView(v), + cancelDrop: () => this.discardDrop(), collapse: () => this.collapse(), setFocus: (id) => { State.setFocus(id); @@ -191,7 +192,7 @@ export class Island { : null; this.setView("prompt"); }, - cancel: () => this.setView(State.defaultView()), + cancel: () => this.discardDrop(), }); this.clipEl = h( @@ -406,11 +407,14 @@ export class Island { void Bridge.ingestFile(path) .then((file) => { + // Cancelled (or replaced by another drop) while the copy was running. + if (State.droppedFile?.path !== path) return; State.droppedFile = { name: file.name, path: file.path }; State.promptContext = { kind: "file", name: file.name, path: file.path }; State.notify(); }) .catch((err) => { + if (State.droppedFile?.path !== path) return; UploadSeq.deactivate(); State.noteMessage = String(err).replace(/^Error:\s*/, ""); this.engine.animateMorph(0); @@ -420,6 +424,13 @@ export class Island { }); } + /** "Cancel" on the dropped file: forget it, so the chat does not pick it up. */ + private discardDrop() { + State.droppedFile = null; + State.promptContext = null; + this.setView(State.defaultView()); + } + /** * Sounds and view changes hung off the canvas timeline: a `tick` every 10 %, * the ✓ chime when the bar completes, then `choose` once Mochi has grown back. @@ -836,8 +847,12 @@ export class Island { const expanded = State.mode === "expanded"; const greetingActive = expanded && State.view === "greeting"; - this.contentEl.style.opacity = expanded && !greetingActive ? "1" : "0"; - this.contentEl.style.pointerEvents = expanded && !greetingActive ? "auto" : "none"; + const live = expanded && !greetingActive; + this.contentEl.style.opacity = live ? "1" : "0"; + // While the drop sequence owns the body its buttons are painted on the canvas + // underneath, so only the header may keep taking clicks up here. + this.contentEl.style.pointerEvents = live && !this.uploadActive ? "auto" : "none"; + this.header.el.style.pointerEvents = live ? "auto" : "none"; this.greetingCanvas.style.display = greetingActive ? "block" : "none"; this.header.sync(); diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c..a4168add0 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -126,13 +126,19 @@ body { cursor: pointer; } -/* The ordinary views step aside while the sequence owns the body. */ +/* The ordinary views step aside while the sequence owns the body. The active + view sets pointer-events back to auto, which beats the parent's none: it has + to be switched off too, or its invisible card swallows the canvas buttons. */ #views.hidden-by-upload { opacity: 0; pointer-events: none; transition: opacity 0.22s ease-in-out; } +#views.hidden-by-upload .view.on { + pointer-events: none; +} + #bot-glow { position: absolute; pointer-events: none; diff --git a/windows/src/views/upload.ts b/windows/src/views/upload.ts index 714060ff7..bb8e1fac3 100644 --- a/windows/src/views/upload.ts +++ b/windows/src/views/upload.ts @@ -97,7 +97,7 @@ export function buildChoose(actions: ViewActions): ViewHost { h("button", { class: "btn secondary", text: "Cancel", - onclick: () => actions.setView(State.defaultView()), + onclick: () => actions.cancelDrop(), }), ); const el = h( diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f..e4a175812 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -14,6 +14,8 @@ import { renderIntegrationCard, type IntegrationCardHooks } from "./integrations export interface ViewActions { setView(v: IslandViewName): void; + /** "Cancel" on a dropped file: forgets it and goes back home. */ + cancelDrop(): void; collapse(): void; setFocus(id: string): void; openTerminal(): void; From 0b700de42be8376a83de595c9d25f10f8b8084ae Mon Sep 17 00:00:00 2001 From: PythonTilk Date: Tue, 6 Oct 2026 11:40:55 +0200 Subject: [PATCH 10/77] fix(windows): the step ticker no longer stops at a session's twentieth step The ticker found new steps by their index in task.steps. That list is capped at 20: once it is full, every new step shifts the oldest out and the index stays at 19, so the ticker saw nothing new and showed steps 18 and 19 for the rest of the session, while the header went on reading 20/20. Tasks now carry stepSeq, the position of the newest step in the whole session, which keeps counting past the cap. The ticker scrolls by that, and takes the texts from the end of the list. Co-Authored-By: Claude Opus 5.5 --- windows/src/core/state.ts | 7 +++++++ windows/src/island/hooks.ts | 1 + windows/src/views/ticker.ts | 34 +++++++++++++++------------------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..71b4f59cb 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -13,6 +13,11 @@ export interface AgentTask { state: BotStateName; stepIndex: number; steps: string[]; + /** + * Position of the newest step in the whole session. `steps` is capped, so + * `stepIndex` stops moving once it is full; this keeps counting. + */ + stepSeq?: number; source: AgentSource; isIntegration: boolean; emote?: BotEmoteName | null; @@ -186,9 +191,11 @@ class AppState { appendStep(id: string, step: string) { const t = this.tasks.find((x) => x.id === id); if (!t) return; + const newest = t.stepSeq ?? t.steps.length - 1; t.steps.push(step); if (t.steps.length > 20) t.steps.shift(); t.stepIndex = t.steps.length - 1; + t.stepSeq = newest + 1; this.notify(); } diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index d90a78f09..246c02ea3 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -132,6 +132,7 @@ function clearSession() { if (!t) return; t.steps = []; t.stepIndex = 0; + delete t.stepSeq; t.name = "VS Code"; t.pillBadge = null; } diff --git a/windows/src/views/ticker.ts b/windows/src/views/ticker.ts index 72b18023e..6d03f2cdb 100644 --- a/windows/src/views/ticker.ts +++ b/windows/src/views/ticker.ts @@ -96,31 +96,27 @@ export class Ticker { } sync(task: AgentTask | null) { - const steps = task && task.steps.length > 0 ? task.steps : ["…"]; - const idx = task ? Math.min(task.stepIndex, steps.length - 1) : -1; - - // First render: drop straight into place, no animation. - if (this.displayIndex < 0) { - this.displayIndex = idx; - setText(this.a, idx > 0 ? steps[idx - 1] : "…"); - setText(this.b, steps[Math.max(idx, 0)]); - this.rest(); - return; - } - - // The session restarted (steps were cleared): re-seed rather than scroll. - if (idx < this.displayIndex) { + const steps = task?.steps ?? []; + // Where the newest step sits in the whole session, not in `steps`: that + // list is capped at 20, and counting inside it made the ticker stop for + // good at the twentieth step of a session. + const newest = steps.length === 0 ? -1 : (task?.stepSeq ?? steps.length - 1); + + // First render, or the session restarted (steps were cleared): drop + // straight into place rather than scroll. + if (this.displayIndex < 0 || newest < this.displayIndex) { this.queue = []; this.startMs = null; - this.displayIndex = idx; - setText(this.a, idx > 0 ? steps[idx - 1] : "…"); - setText(this.b, steps[Math.max(idx, 0)]); + this.displayIndex = newest; + setText(this.a, steps.at(-2) ?? "…"); + setText(this.b, steps.at(-1) ?? "…"); this.rest(); return; } - for (let i = this.displayIndex + 1; i <= idx; i++) this.queue.push(steps[i]); - this.displayIndex = idx; + const fresh = Math.min(newest - this.displayIndex, steps.length); + if (fresh > 0) this.queue.push(...steps.slice(-fresh)); + this.displayIndex = newest; if (this.queue.length > MAX_QUEUE) { this.queue = this.queue.slice(-MAX_QUEUE); } From 777d06f91194f0fd3101cce2285c7fa71fdb8e30 Mon Sep 17 00:00:00 2001 From: Totopo27 <35828647+Totopo27@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:01:08 -0600 Subject: [PATCH 11/77] feat(windows): support Gemini CLI hooks and session island view --- windows/hook/src/main.rs | 142 +++++++++++++++- windows/src-tauri/src/hooks.rs | 274 ++++++++++++++++++++++++++++++ windows/src-tauri/src/lib.rs | 36 +++- windows/src-tauri/src/settings.rs | 3 + windows/src/core/bridge.ts | 7 + windows/src/core/state.ts | 2 + windows/src/island/hooks.ts | 23 ++- windows/src/settings/main.ts | 130 ++++++++++++++ windows/src/views/views.ts | 20 ++- 9 files changed, 620 insertions(+), 17 deletions(-) diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs index 5bb363781..ea6de1b51 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -44,8 +44,82 @@ mod unix; #[cfg(target_os = "linux")] use unix::connect; +/// Normalize event names across agents (Claude Code, Gemini CLI, Antigravity) +fn normalize_event(name: &str) -> String { + match name { + "BeforeTool" | "BeforeToolSelection" => "PreToolUse".to_string(), + "AfterTool" | "AfterModel" | "PostInvocation" => "PostToolUse".to_string(), + "BeforeAgent" | "PreInvocation" => "UserPromptSubmit".to_string(), + "AfterAgent" => "Stop".to_string(), + "startup" => "SessionStart".to_string(), + "exit" => "SessionEnd".to_string(), + other => other.to_string(), + } +} + +/// Normalize tool fields from Gemini CLI format (`toolCall: {name, args: {...}}`) +/// into canonical `tool_name` and `tool_input`. +fn normalize_tool_fields(map: &mut serde_json::Map) { + if !map.contains_key("tool_name") { + let (extracted_name, extracted_input) = if let Some(tool) = map.get("toolCall").and_then(|t| t.as_object()) { + let name = tool.get("name").and_then(|n| n.as_str()).map(|n| n.to_string()); + let input = if let Some(args) = tool.get("args").and_then(|a| a.as_object()) { + let mut input_map = serde_json::Map::new(); + for (k, v) in args { + let target_key = match k.as_str() { + "CommandLine" => "command", + "FilePath" => "file_path", + "Path" => "path", + "Url" => "url", + "Query" => "query", + "Pattern" => "pattern", + other => other, + }; + input_map.insert(target_key.into(), v.clone()); + } + Some(input_map) + } else { + None + }; + (name, input) + } else if let Some(name) = map.get("tool").and_then(|t| t.as_str()) { + (Some(name.to_string()), None) + } else { + (None, None) + }; + + if let Some(name) = extracted_name { + map.insert("tool_name".into(), serde_json::Value::String(name)); + } + if let Some(input) = extracted_input { + if !map.contains_key("tool_input") { + map.insert("tool_input".into(), serde_json::Value::Object(input)); + } + } + } + + if !map.contains_key("session_id") { + let mut found_sid = None; + for key in &["conversationId", "conversation_id", "sessionId", "GEMINI_SESSION_ID"] { + if let Some(val) = map.get(*key).and_then(|v| v.as_str()) { + if !val.is_empty() { + found_sid = Some(val.to_string()); + break; + } + } + } + if let Some(sid) = found_sid { + map.insert("session_id".into(), serde_json::Value::String(sid)); + } else if let Ok(sid) = std::env::var("GEMINI_SESSION_ID") { + if !sid.is_empty() { + map.insert("session_id".into(), serde_json::Value::String(sid)); + } + } + } +} + fn main() { - let Some((payload, event)) = read_event() else { std::process::exit(0) }; + let Some((payload, event, agent)) = read_event() else { std::process::exit(0) }; let waits_for_answer = event == "PermissionRequest"; let budget = if waits_for_answer { DECISION_BUDGET } else { FIRE_AND_FORGET_BUDGET }; @@ -65,6 +139,11 @@ fn main() { let _ = writeln!(out, "{json}"); let _ = out.flush(); } + } else if !waits_for_answer && (agent == "gemini" || agent == "antigravity") { + // Gemini CLI and Antigravity expect a JSON response on stdout ({}) + let mut out = std::io::stdout(); + let _ = writeln!(out, "{{}}"); + let _ = out.flush(); } // Nothing printed: Claude Code asks in the terminal, as if we were not here. std::process::exit(0); @@ -86,8 +165,8 @@ fn decision_json(decision: &str) -> Option { )) } -/// Reads stdin and returns the payload to forward plus the event name. -fn read_event() -> Option<(String, String)> { +/// Reads stdin and returns the payload to forward plus the event name and agent. +fn read_event() -> Option<(String, String, String)> { let mut raw = Vec::new(); if std::io::stdin().read_to_end(&mut raw).is_err() || raw.is_empty() { return None; @@ -118,14 +197,18 @@ fn read_event() -> Option<(String, String)> { // Which agent this hook was installed for. Absent means Claude Code, // so existing hook commands keep working unchanged. if !agent.is_empty() { - map.insert("coucou_agent".into(), serde_json::Value::String(agent)); + map.insert("coucou_agent".into(), serde_json::Value::String(agent.clone())); } - let event = map + + normalize_tool_fields(map); + + let raw_event = map .get("hook_event_name") .and_then(|v| v.as_str()) .map(str::to_string) .filter(|s| !s.is_empty()) .unwrap_or(arg_event); + let event = normalize_event(&raw_event); map.insert("hook_event_name".into(), serde_json::Value::String(event.clone())); for field in DROPPED_FIELDS { @@ -138,7 +221,21 @@ fn read_event() -> Option<(String, String)> { .map(str::is_empty) .unwrap_or(true); if cwd_missing { - if let Ok(cwd) = std::env::current_dir() { + // Also check workspacePaths or workspace_roots (Gemini CLI) + let mut found_path = None; + for key in &["workspacePaths", "workspace_roots"] { + if let Some(arr) = map.get(*key).and_then(|v| v.as_array()) { + if let Some(first) = arr.first().and_then(|v| v.as_str()) { + if !first.is_empty() { + found_path = Some(first.to_string()); + break; + } + } + } + } + if let Some(p) = found_path { + map.insert("cwd".into(), serde_json::Value::String(p)); + } else if let Ok(cwd) = std::env::current_dir() { map.insert( "cwd".into(), serde_json::Value::String(cwd.to_string_lossy().to_string()), @@ -165,7 +262,7 @@ fn read_event() -> Option<(String, String)> { let mut line = payload.to_string(); line.push('\n'); - Some((line, event)) + Some((line, event, agent)) } /// Caps every string in the payload. A single Write can carry a whole file. @@ -253,4 +350,35 @@ mod tests { assert!(s.len() <= MAX_FIELD_LEN + 4); assert!(s.ends_with('…')); } + + #[test] + fn normalize_event_maps_gemini_events_to_canonical() { + assert_eq!(normalize_event("BeforeTool"), "PreToolUse"); + assert_eq!(normalize_event("AfterTool"), "PostToolUse"); + assert_eq!(normalize_event("BeforeAgent"), "UserPromptSubmit"); + assert_eq!(normalize_event("AfterAgent"), "Stop"); + assert_eq!(normalize_event("startup"), "SessionStart"); + assert_eq!(normalize_event("exit"), "SessionEnd"); + assert_eq!(normalize_event("PreToolUse"), "PreToolUse"); + } + + #[test] + fn normalize_tool_fields_extracts_gemini_tool_call() { + let mut map = serde_json::Map::new(); + map.insert( + "toolCall".into(), + serde_json::json!({ + "name": "Bash", + "args": { + "CommandLine": "ls -la" + } + }), + ); + map.insert("conversationId".into(), serde_json::json!("gemini-conv-123")); + normalize_tool_fields(&mut map); + + assert_eq!(map.get("tool_name").unwrap(), "Bash"); + assert_eq!(map.get("tool_input").unwrap()["command"], "ls -la"); + assert_eq!(map.get("session_id").unwrap(), "gemini-conv-123"); + } } diff --git a/windows/src-tauri/src/hooks.rs b/windows/src-tauri/src/hooks.rs index 3da1d0e5a..03708ed9e 100644 --- a/windows/src-tauri/src/hooks.rs +++ b/windows/src-tauri/src/hooks.rs @@ -237,6 +237,258 @@ fn current_fingerprint() -> String { // ── Public API ──────────────────────────────────────────────────────────────── +pub fn gemini_settings_path() -> PathBuf { + platform::home_dir().join(".gemini").join("settings.json") +} + +const GEMINI_EVENTS: &[(&str, &str, u64)] = &[ + ("SessionStart", "SessionStart", 10000), + ("SessionEnd", "SessionEnd", 10000), + ("BeforeTool", "PreToolUse", 5000), + ("AfterTool", "PostToolUse", 5000), + ("BeforeAgent", "UserPromptSubmit", 5000), + ("AfterAgent", "Stop", 5000), +]; + +#[cfg(windows)] +fn gemini_hook_command(event: &str) -> String { + let exe = settings::hook_exe_path().to_string_lossy().replace('\\', "/"); + format!("\"{exe}\" --agent gemini {event}") +} + +#[cfg(unix)] +fn gemini_hook_command(event: &str) -> String { + format!( + "{} --agent gemini {event}", + sh_quote(&settings::hook_exe_path().to_string_lossy()) + ) +} + +fn read_gemini_settings() -> Result { + let path = gemini_settings_path(); + match std::fs::read(&path) { + Ok(bytes) => parse_settings(&bytes, &path.display().to_string()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), + Err(err) => Err(format!("Can't read {}: {err}", path.display())), + } +} + +fn read_gemini_settings_lossy() -> Value { + read_gemini_settings().unwrap_or_else(|_| json!({})) +} + +fn gemini_entry_is_ours(entry: &Value) -> bool { + if let Some(cmd) = entry.get("command").and_then(Value::as_str) { + if cmd.contains(MARKER) && cmd.contains("--agent gemini") { + return true; + } + } + if let Some(inner) = entry.get("hooks").and_then(Value::as_array) { + if inner.iter().any(|h| { + h.get("command") + .and_then(Value::as_str) + .map(|c| c.contains(MARKER) && c.contains("--agent gemini")) + .unwrap_or(false) + }) { + return true; + } + } + false +} + +fn remove_gemini_nb_hook_entries(groups: &[Value]) -> Vec { + let mut out = Vec::new(); + for group in groups { + if let Some(cmd) = group.get("command").and_then(Value::as_str) { + if cmd.contains(MARKER) && cmd.contains("--agent gemini") { + continue; + } + } + if let Some(inner) = group.get("hooks").and_then(Value::as_array) { + let filtered: Vec = inner + .iter() + .filter(|h| { + !h.get("command") + .and_then(Value::as_str) + .map(|c| c.contains(MARKER) && c.contains("--agent gemini")) + .unwrap_or(false) + }) + .cloned() + .collect(); + if !filtered.is_empty() { + let mut updated = group.as_object().cloned().unwrap_or_default(); + updated.insert("hooks".into(), Value::Array(filtered)); + out.push(Value::Object(updated)); + } + } else { + out.push(group.clone()); + } + } + out +} + +fn merged_gemini(existing: &Value) -> Result { + let mut root = existing.as_object().cloned().unwrap_or_default(); + if let Some(raw) = root.get("hooks") { + if !raw.is_object() { + return Err("~/.gemini/settings.json: \"hooks\" has an unexpected type — Coucou won't touch it.".into()); + } + } + let mut hooks = root + .get("hooks") + .and_then(Value::as_object) + .cloned() + .unwrap_or_else(Map::new); + + for (gemini_event, normalized_event, timeout) in GEMINI_EVENTS { + if let Some(raw) = hooks.get(*gemini_event) { + if !raw.is_array() { + return Err(format!( + "~/.gemini/settings.json: \"hooks\"[\"{gemini_event}\"] has an unexpected type — Coucou won't touch it." + )); + } + } + let list = hooks + .get(*gemini_event) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut cleaned = remove_gemini_nb_hook_entries(&list); + cleaned.push(json!({ + "matcher": "*", + "hooks": [{ + "type": "command", + "command": gemini_hook_command(normalized_event), + "timeout": timeout, + }] + })); + hooks.insert((*gemini_event).to_string(), Value::Array(cleaned)); + } + + root.insert("hooks".into(), Value::Object(hooks)); + Ok(Value::Object(root)) +} + +fn without_gemini_ours(existing: &Value) -> Result { + let mut root = existing.as_object().cloned().unwrap_or_default(); + let Some(raw_hooks) = root.get("hooks") else { + return Ok(Value::Object(root)); + }; + let Some(hooks) = raw_hooks.as_object() else { + return Err("~/.gemini/settings.json: \"hooks\" has an unexpected type — Coucou won't touch it.".into()); + }; + let mut out = Map::new(); + for (event, value) in hooks { + match value.as_array() { + Some(list) => { + let kept = remove_gemini_nb_hook_entries(list); + if !kept.is_empty() { + out.insert(event.clone(), Value::Array(kept)); + } + } + None => { + out.insert(event.clone(), value.clone()); + } + } + } + if out.is_empty() { + root.remove("hooks"); + } else { + root.insert("hooks".into(), Value::Object(out)); + } + Ok(Value::Object(root)) +} + +fn gemini_backup_path() -> PathBuf { + let p = gemini_settings_path(); + p.with_file_name(format!("settings.json.bak-{}", stamp())) +} + +fn gemini_current_fingerprint() -> String { + match std::fs::read(gemini_settings_path()) { + Ok(bytes) => fingerprint(&bytes), + Err(_) => fingerprint(b""), + } +} + +pub fn gemini_status() -> HookStatus { + let current = read_gemini_settings_lossy(); + let installed = current + .get("hooks") + .and_then(Value::as_object) + .map(|hooks| { + hooks + .values() + .filter_map(Value::as_array) + .flatten() + .any(gemini_entry_is_ours) + }) + .unwrap_or(false); + let hook_path = settings::hook_exe_path(); + HookStatus { + installed, + settings_path: gemini_settings_path().to_string_lossy().to_string(), + hook_ready: hook_path.exists(), + hook_path: hook_path.to_string_lossy().to_string(), + } +} + +pub fn gemini_preview(install: bool) -> Result { + let current = read_gemini_settings()?; + let next = if install { + merged_gemini(¤t)? + } else { + without_gemini_ours(¤t)? + }; + Ok(HookPreview { + diff: unified_diff(&pretty(¤t), &pretty(&next)), + backup: gemini_backup_path().to_string_lossy().to_string(), + settings_path: gemini_settings_path().to_string_lossy().to_string(), + fingerprint: gemini_current_fingerprint(), + }) +} + +pub fn gemini_write(install: bool, fingerprint: &str) -> Result { + let path = gemini_settings_path(); + let dir = path.parent().unwrap_or(Path::new(".")); + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + + let current = read_gemini_settings()?; + if gemini_current_fingerprint() != fingerprint { + return Err(format!( + "{} changed since the preview. Nothing was written — review the new diff.", + path.display() + )); + } + + let backup = gemini_backup_path(); + if path.exists() { + std::fs::copy(&path, &backup).map_err(|e| format!("backup failed: {e}"))?; + } + + let next = if install { + merged_gemini(¤t)? + } else { + without_gemini_ours(¤t)? + }; + let mut text = pretty(&next); + text.push('\n'); + + #[cfg(unix)] + let path = std::fs::canonicalize(&path).unwrap_or(path); + + let temp = path.with_extension(format!("json.coucou-{}", std::process::id())); + if let Err(err) = write_like(&temp, &path, text.as_bytes()) { + let _ = std::fs::remove_file(&temp); + return Err(format!("write failed: {err}")); + } + if let Err(err) = std::fs::rename(&temp, &path) { + let _ = std::fs::remove_file(&temp); + return Err(format!("write failed: {err}")); + } + Ok(backup.to_string_lossy().to_string()) +} + pub fn status() -> HookStatus { let current = read_settings_lossy(); let installed = current @@ -574,6 +826,28 @@ mod tests { assert_eq!(cleaned, existing); } + #[test] + fn merging_gemini_creates_groups_and_preserves_others() { + let existing = serde_json::json!({ + "theme": "dark", + "hooks": { + "BeforeTool": [ + { "matcher": "other", "hooks": [{ "type": "command", "command": "custom.exe" }] } + ] + } + }); + + let after = merged_gemini(&existing).unwrap(); + assert_eq!(after["theme"], "dark"); + let before_tool = after["hooks"]["BeforeTool"].as_array().unwrap(); + assert_eq!(before_tool.len(), 2); + assert!(before_tool[0]["matcher"] == "other"); + assert!(gemini_entry_is_ours(&before_tool[1])); + + let cleaned = without_gemini_ours(&after).unwrap(); + assert_eq!(cleaned, existing); + } + #[test] fn a_fingerprint_notices_any_change() { assert_eq!(fingerprint(b"{}"), fingerprint(b"{}")); diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..15aa450a9 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -47,8 +47,9 @@ pub struct BootInfo { #[tauri::command] 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. + // The real state of ~/.claude/settings.json and ~/.gemini/settings.json wins over whatever we stored. settings.hooks_installed = hooks::status().installed; + settings.gemini_hooks_installed = hooks::gemini_status().installed; let screen = island::screen_info(&app, &settings.screen); BootInfo { settings, @@ -211,6 +212,36 @@ fn hooks_apply( Ok(backup) } +// ── Gemini CLI hooks ────────────────────────────────────────────────────────── + +#[tauri::command] +fn gemini_hooks_status() -> HookStatus { + hooks::gemini_status() +} + +#[tauri::command] +fn gemini_hooks_preview(install: bool) -> Result { + hooks::gemini_preview(install) +} + +#[tauri::command] +fn gemini_hooks_apply( + app: AppHandle, + shared: State, + install: bool, + fingerprint: String, +) -> Result { + let backup = hooks::gemini_write(install, &fingerprint)?; + let updated = { + let mut current = shared.settings.lock().unwrap(); + current.gemini_hooks_installed = install; + let _ = settings::save(¤t); + current.clone() + }; + let _ = app.emit("settings-changed", updated); + Ok(backup) +} + #[tauri::command] fn approval_decision(app: AppHandle, request_id: String, decision: String) { pipe::answer(&app, &request_id, &decision); @@ -387,6 +418,9 @@ pub fn run() { hooks_status, hooks_preview, hooks_apply, + gemini_hooks_status, + gemini_hooks_preview, + gemini_hooks_apply, approval_decision, approval_ack, approval_decline, diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 2a8d7d359..a90fa85f4 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -16,6 +16,8 @@ pub struct Settings { pub screen: String, pub autostart: bool, pub hooks_installed: bool, + #[serde(default)] + pub gemini_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")] @@ -42,6 +44,7 @@ impl Default for Settings { screen: "primary".into(), autostart: false, hooks_installed: false, + gemini_hooks_installed: false, model: default_model(), } } diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..12cec1a46 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -73,6 +73,13 @@ export const Bridge = { hooksApply: (install: boolean, fingerprint: string) => callOrThrow("hooks_apply", { install, fingerprint }), + // ── Gemini CLI hooks ─────────────────────────────────────────────────────── + geminiHooksStatus: () => call("gemini_hooks_status"), + geminiHooksPreview: (install: boolean) => + callOrThrow("gemini_hooks_preview", { install }), + geminiHooksApply: (install: boolean, fingerprint: string) => + callOrThrow("gemini_hooks_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. */ diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..085882670 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -90,6 +90,7 @@ export interface Settings { screen: "primary" | "cursor"; autostart: boolean; hooksInstalled: boolean; + geminiHooksInstalled: boolean; /** Claude model used by the chat. */ model: string; } @@ -105,6 +106,7 @@ export const DEFAULT_SETTINGS: Settings = { screen: "primary", autostart: false, hooksInstalled: false, + geminiHooksInstalled: false, model: "claude-opus-5", }; diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index d90a78f09..f1c57628d 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -36,7 +36,24 @@ function validateAgent(raw: string | undefined): string | null { const FALLBACK_COLORS = ["#22C55E", "#EAB308", "#60A5FA", "#E879F9"]; +const KNOWN_AGENT_COLORS: Record = { + gemini: "#8AB4F8", + antigravity: "#E879F9", + cursor: "#C0C4CC", + codex: "#2DD4BF", + opencode: "#38BDF8", +}; + +const KNOWN_AGENT_NAMES: Record = { + gemini: "Gemini CLI", + antigravity: "Antigravity", + cursor: "Cursor", + codex: "Codex", + opencode: "OpenCode", +}; + function agentColor(name: string): string { + if (KNOWN_AGENT_COLORS[name]) return KNOWN_AGENT_COLORS[name]; let h = 0; for (let i = 0; i < name.length; i++) { h = (Math.imul(31, h) + name.charCodeAt(i)) | 0; @@ -44,6 +61,10 @@ function agentColor(name: string): string { return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; } +function agentDisplayName(name: string): string { + return KNOWN_AGENT_NAMES[name] ?? name; +} + const PROJECT_ALIASES: Record = { "notch-buddy": "Notch Buddy", notchbuddy: "Notch Buddy", @@ -176,7 +197,7 @@ function handleHook(island: Island, payload: HookPayload) { /** Ensure the agent pill exists (no-op for Claude Code). */ const ensurePill = () => { if (isExternalAgent) { - State.upsertExternalAgent(agentId, validAgent!, agentColor(validAgent!)); + State.upsertExternalAgent(agentId, agentDisplayName(validAgent!), agentColor(validAgent!)); } else { upsert(projectName, cwd); } diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..354e4ea86 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -171,6 +171,132 @@ function claudeSection(status: HookStatus): HTMLElement { return section; } +// ── Gemini CLI section ──────────────────────────────────────────────────────── + +function geminiSection(status: HookStatus): 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: "Gemini CLI" })), + body, + ); + + const rebuild = async () => { + const fresh = await Bridge.geminiHooksStatus(); + if (fresh) Object.assign(status, fresh); + clear(body); + draw(); + const head = section.querySelector("h2")!; + clear(head); + head.append(statusDot(status.installed), h("span", { text: "Gemini CLI" })); + }; + + function draw() { + body.append( + h("div", { + class: "hint", + text: status.installed + ? "Coucou is hooked into your Gemini CLI sessions (~/.gemini/settings.json). Tool calls and steps show up in the island." + : "Install the hooks in ~/.gemini/settings.json to see your Gemini CLI sessions and tool calls in the island.", + }), + h("div", { class: "row" }, + h("label", { text: "settings.json" }), + h("span", { class: "path", text: status.settingsPath }), + ), + h("div", { class: "row" }, + h("label", { text: "Relay" }), + h("span", { class: "path", text: status.hookPath }), + statusDot(status.hookReady), + ), + ); + + if (!status.hookReady) { + 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 hooks…" : "Install hooks…", + onclick: () => showPreview(true), + }); + if (!status.hookReady) { + 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 hooks…", + onclick: () => showPreview(false), + })); + } + body.append(actions); + } + + async function showPreview(install: boolean) { + let preview; + try { + preview = await Bridge.geminiHooksPreview(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 is exactly what will change in ~/.gemini/settings.json. Your own hooks are left untouched." + : "This removes Coucou's entries only. Your own hooks are left untouched.", + }), + renderDiff(preview.diff), + h("div", { class: "row" }, + h("span", { class: "path", text: `Backup → ${preview.backup}` }), + ), + ); + 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.geminiHooksApply(install, preview.fingerprint); + clear(body); + body.append(h("div", { + class: "notice ok", + text: `Done. Previous settings saved as ${backup}. Open a new Gemini CLI session to pick the hooks 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; +} + // ── Claude API section ──────────────────────────────────────────────────────── const MODELS: [string, string][] = [ @@ -428,6 +554,9 @@ async function main() { const status = (await Bridge.hooksStatus()) ?? { installed: false, settingsPath: "", hookPath: "", hookReady: false, }; + const geminiStatus = (await Bridge.geminiHooksStatus()) ?? { + installed: false, settingsPath: "", hookPath: "", hookReady: false, + }; const hasKey = (await Bridge.secretPresent("anthropic-api-key")) ?? false; @@ -442,6 +571,7 @@ async function main() { root.append( h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), + geminiSection(geminiStatus), apiSection(hasKey), integrationsSection(present), generalSection(), diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f..16ed82681 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -172,10 +172,10 @@ function buildOverview(actions: ViewActions): ViewHost { mode = null; } - // VS Code with a live Claude Code session keeps the ticker; every other - // pill shows its own card, exactly like IntegrationCardView. + // Tasks with live steps or non-idle state keep the ticker; every other + // integration pill shows its own card, exactly like IntegrationCardView. const sessionActive = - task?.id === "integration_claude" && (task.state !== "idle" || task.steps.length > 0); + task && (!task.isIntegration || task.id === "integration_claude") && (task.state !== "idle" || task.steps.length > 0); if (task && sessionActive) { if (mode !== "ticker") { @@ -185,10 +185,11 @@ function buildOverview(actions: ViewActions): ViewHost { cardKey = ""; } clear(who); + const toolLabel = task.source === "claudeCode" ? "Claude Code" : task.source === "agent" ? task.name : "n8n"; 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: toolLabel }), ); if (task.steps.length > 1) { who.append(h("span", { @@ -328,9 +329,10 @@ function buildQuestion(): ViewHost { el, sync() { clear(who); - who.append(agentWho(State.focusTask, "Claude Code is asking a question")); + const name = State.focusTask?.name ?? "Agent"; + who.append(agentWho(State.focusTask, `${name} is asking a question`)); const task = State.focusTask; - title.textContent = task?.steps.at(-1) ?? "Claude needs an answer."; + title.textContent = task?.steps.at(-1) ?? `${name} needs an answer.`; clear(row); row.append(h("div", { class: "sub", text: "Answer in your terminal — Coucou can't reply for you yet." })); }, @@ -353,7 +355,8 @@ function buildError(actions: ViewActions): ViewHost { sync() { const task = State.focusTask; clear(who); - who.append(agentWho(task, task?.source === "n8n" ? "n8n" : "Claude Code")); + const whoLabel = task?.source === "n8n" ? "n8n" : (task?.name ?? "Agent"); + who.append(agentWho(task, whoLabel)); title.textContent = task?.source === "n8n" ? "Workflow stopped." : "Session stopped on an error."; detail.textContent = task?.steps.at(-1) ?? "No detail available."; }, @@ -374,7 +377,8 @@ function buildFinished(actions: ViewActions): ViewHost { el, sync() { clear(who); - who.append(agentWho(State.focusTask, "Claude Code finished")); + const name = State.focusTask?.name ?? "Agent"; + who.append(agentWho(State.focusTask, `${name} finished`)); title.textContent = State.focusTask?.steps.at(-1) ?? "Session finished"; }, }; From c7db6abfc83cb057d58cda5762a2379c39dc0945 Mon Sep 17 00:00:00 2001 From: FabioLukas123 <146683844+FabioLukas123@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:12:50 -0400 Subject: [PATCH 12/77] Linux: Arch Linux package (PKGBUILD, desktop entry, README) Builds the existing Tauri app from windows/ with makepkg and installs it with its relay, icons and licenses. No change to the app itself. --- linux/arch/PKGBUILD | 69 +++++++++++++++++++++++++++++++++++++++ linux/arch/coucou.desktop | 9 +++++ windows/README.md | 11 +++++++ 3 files changed, 89 insertions(+) create mode 100644 linux/arch/PKGBUILD create mode 100644 linux/arch/coucou.desktop diff --git a/linux/arch/PKGBUILD b/linux/arch/PKGBUILD new file mode 100644 index 000000000..05b1daf16 --- /dev/null +++ b/linux/arch/PKGBUILD @@ -0,0 +1,69 @@ +# Maintainer: FabioLukas123 +# Coucou for Arch Linux — built from the Tauri app in windows/, which runs on +# Windows and Linux alike. + +pkgname=coucou-git +_pkgname=coucou +pkgver=0.2.0 +pkgrel=1 +pkgdesc="Mochi lives at the top of your screen and keeps an eye on your Claude Code sessions" +arch=('x86_64' 'aarch64') +url="https://github.com/Louis-CFM/coucou" +license=('MIT') +depends=( + 'webkit2gtk-4.1' + 'gtk3' + 'gtk-layer-shell' + 'libayatana-appindicator' + 'gst-plugins-base' + 'gst-plugins-good' + 'dbus' + 'xdg-utils' + 'hicolor-icon-theme' +) +optdepends=( + 'gnome-keyring: store API keys (or any other Secret Service provider)' + 'kwallet: store API keys (or any other Secret Service provider)' +) +makedepends=('git' 'cargo' 'nodejs' 'npm') +provides=("$_pkgname") +conflicts=("$_pkgname") +# makepkg's default LTO and debug flags make the Rust build very slow. +options=('!lto' '!debug') +source=("$_pkgname::git+https://github.com/Louis-CFM/coucou.git") +sha256sums=('SKIP') + +pkgver() { + cd "$_pkgname" + local v + v=$(sed -n 's/^version = "\(.*\)"/\1/p' windows/Cargo.toml | head -1) + printf "%s.r%s.%s" "$v" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" +} + +prepare() { + cd "$_pkgname/windows" + export RUSTUP_TOOLCHAIN=stable + cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" + npm ci +} + +build() { + cd "$_pkgname/windows" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR="$PWD/target" + # beforeBuildCommand builds the front end and the coucou-hook relay. + npx tauri build --no-bundle +} + +package() { + cd "$_pkgname" + install -Dm755 windows/target/release/coucou "$pkgdir/usr/bin/coucou" + # Found next to the app, then copied into ~/.local/share/coucou/bin at launch. + install -Dm755 windows/target/release/coucou-hook "$pkgdir/usr/bin/coucou-hook" + install -Dm644 linux/arch/coucou.desktop "$pkgdir/usr/share/applications/coucou.desktop" + install -Dm644 windows/src-tauri/icons/32x32.png "$pkgdir/usr/share/icons/hicolor/32x32/apps/coucou.png" + install -Dm644 windows/src-tauri/icons/128x128.png "$pkgdir/usr/share/icons/hicolor/128x128/apps/coucou.png" + install -Dm644 windows/src-tauri/icons/128x128@2x.png "$pkgdir/usr/share/icons/hicolor/256x256/apps/coucou.png" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + install -Dm644 LICENSE-ASSETS.md "$pkgdir/usr/share/licenses/$pkgname/LICENSE-ASSETS.md" +} diff --git a/linux/arch/coucou.desktop b/linux/arch/coucou.desktop new file mode 100644 index 000000000..c38778d96 --- /dev/null +++ b/linux/arch/coucou.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Coucou +Comment=Mochi lives at the top of your screen and keeps an eye on your Claude Code sessions +Exec=coucou +Icon=coucou +Terminal=false +Categories=Development;Utility; +StartupNotify=false diff --git a/windows/README.md b/windows/README.md index 2b897bd3d..0a2b8a63f 100644 --- a/windows/README.md +++ b/windows/README.md @@ -179,6 +179,17 @@ npm run tauri dev # live-reloading development build npm run pack # AppImage, .deb and .rpm in windows/release/ ``` +On Arch Linux, build and install the package from `linux/arch/`: + +```bash +git clone https://github.com/Louis-CFM/coucou.git +cd coucou/linux/arch +makepkg -si +``` + +It needs `webkit2gtk-4.1`, `gtk-layer-shell` and `libayatana-appindicator` +(pulled in as dependencies); store API keys with GNOME Keyring or KWallet. + What changes on Linux: - **The island** is a gtk-layer-shell overlay anchored to the top edge, over any From c4e330f5a6e54cbdad44d0648ff35f98cebe7548 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:08:24 +0200 Subject: [PATCH 13/77] Windows/Linux: GitHub pulse and contribution pollers Port of GithubPoller.swift, GitHubPulse.swift and GitHubActivity.swift: a GraphQL pulse (my PRs with CI, PRs waiting for my review, default-branch CI of recent repos) on the Mac cadence (10 s, then 60 s while CI runs, 5 min otherwise) and the contribution calendar (15 s, then 30 min). headSha-aware alert detection is emitted as github-alerts. Nothing polls while the pill is off or Coucou is paused; the island can ask for a refresh of stale data, and a token change drops in-flight answers. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/github.rs | 799 ++++++++++++++++++++++++++ windows/src-tauri/src/integrations.rs | 271 ++++++++- windows/src-tauri/src/lib.rs | 30 +- 3 files changed, 1089 insertions(+), 11 deletions(-) create mode 100644 windows/src-tauri/src/github.rs diff --git a/windows/src-tauri/src/github.rs b/windows/src-tauri/src/github.rs new file mode 100644 index 000000000..f1b2907c5 --- /dev/null +++ b/windows/src-tauri/src/github.rs @@ -0,0 +1,799 @@ +// GitHub pulse and contribution calendar — the pure half of GithubPoller.swift, +// GitHubPulse.swift and GitHubActivity.swift. Parsing, alert detection and the +// staleness rule live here, away from the network, so they can be tested. +// +// The pollers that use them are in integrations.rs. + +use serde::Serialize; +use serde_json::Value; + +// ── CI and review states ────────────────────────────────────────────────────── + +#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum CiState { + Pending, + Success, + Failure, + Unknown, +} + +impl CiState { + /// GitHub's `StatusState` (statusCheckRollup.state) → our four states. + pub fn from_github(raw: Option<&str>) -> Self { + match raw.map(str::to_ascii_uppercase).as_deref() { + Some("PENDING") | Some("EXPECTED") => CiState::Pending, + Some("SUCCESS") => CiState::Success, + Some("ERROR") | Some("FAILURE") => CiState::Failure, + _ => CiState::Unknown, + } + } +} + +#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ReviewState { + Approved, + ChangesRequested, + Pending, + Unknown, +} + +impl ReviewState { + pub fn from_github(raw: Option<&str>) -> Self { + match raw.map(str::to_ascii_uppercase).as_deref() { + Some("APPROVED") => ReviewState::Approved, + Some("CHANGES_REQUESTED") => ReviewState::ChangesRequested, + Some("REVIEW_REQUIRED") => ReviewState::Pending, + _ => ReviewState::Unknown, + } + } +} + +// ── Pulse ───────────────────────────────────────────────────────────────────── + +#[derive(Serialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GitHubPr { + /// "owner/repo#number" + pub id: String, + pub title: String, + pub url: String, + pub repo: String, + pub number: i64, + pub is_draft: bool, + pub ci: CiState, + pub review: ReviewState, + /// Oid of the PR's last commit, when GitHub returned it. + pub head_sha: Option, +} + +#[derive(Serialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GitHubRepoCi { + pub repo: String, + pub url: String, + pub branch: String, + pub ci: CiState, + /// Oid of the default branch's head commit, when GitHub returned it. + pub head_sha: Option, +} + +#[derive(Serialize, Clone, Debug, PartialEq)] +pub struct GitHubPulse { + pub login: String, + #[serde(rename = "myPRs")] + pub my_prs: Vec, + #[serde(rename = "toReview")] + pub to_review: Vec, + #[serde(rename = "mainCI")] + pub main_ci: Vec, + /// Unix time in milliseconds. + #[serde(rename = "fetchedAt")] + pub fetched_at: u64, +} + +/// What a pulse poll can raise: the island turns these into a badge and a sound. +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(tag = "kind")] +pub enum GitHubEvent { + #[serde(rename = "ciFailed")] + CiFailed { + #[serde(rename = "prId")] + pr_id: String, + }, + #[serde(rename = "ciPassed")] + CiPassed { + #[serde(rename = "prId")] + pr_id: String, + }, + #[serde(rename = "mainFailed")] + MainFailed { repo: String }, + #[serde(rename = "reviewRequested")] + ReviewRequested { + #[serde(rename = "prId")] + pr_id: String, + }, +} + +fn str_at<'a>(v: &'a Value, key: &str) -> Option<&'a str> { + v.get(key).and_then(Value::as_str) +} + +/// Pull request fields shared by "my PRs" and "to review"; None when a required +/// field is missing (that node is skipped, as on macOS). +fn pr_basics(node: &Value) -> Option<(String, String, String, String, i64, bool)> { + let number = node.get("number")?.as_i64()?; + let title = str_at(node, "title")?.to_string(); + let url = str_at(node, "url")?.to_string(); + let repo = node.get("repository").and_then(|r| str_at(r, "nameWithOwner"))?.to_string(); + let is_draft = node.get("isDraft").and_then(Value::as_bool).unwrap_or(false); + Some((format!("{repo}#{number}"), title, url, repo, number, is_draft)) +} + +fn nodes<'a>(parent: Option<&'a Value>) -> &'a [Value] { + parent + .and_then(|p| p.get("nodes")) + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]) +} + +impl GitHubPulse { + /// Parses the pulse GraphQL response. None when there is no `data.viewer`. + pub fn parse(root: &Value, now_ms: u64) -> Option { + let data = root.get("data")?.as_object()?; + let viewer = data.get("viewer")?.as_object()?; + let login = viewer.get("login").and_then(Value::as_str).unwrap_or("").to_string(); + + // My pull requests, first occurrence of an id wins. + let mut my_prs: Vec = Vec::new(); + for node in nodes(viewer.get("pullRequests")) { + let Some((id, title, url, repo, number, is_draft)) = pr_basics(node) else { continue }; + if my_prs.iter().any(|p| p.id == id) { + continue; + } + let review = ReviewState::from_github(str_at(node, "reviewDecision")); + let commit = node + .get("commits") + .and_then(|c| c.get("nodes")) + .and_then(Value::as_array) + .and_then(|a| a.last()) + .and_then(|n| n.get("commit")); + let ci_raw = commit + .and_then(|c| c.get("statusCheckRollup")) + .and_then(|r| str_at(r, "state")); + let head_sha = commit.and_then(|c| str_at(c, "oid")).map(str::to_string); + my_prs.push(GitHubPr { + id, + title, + url, + repo, + number, + is_draft, + ci: CiState::from_github(ci_raw), + review, + head_sha, + }); + } + + // Default-branch CI of the recently pushed repos, archived ones left out. + let mut main_ci: Vec = Vec::new(); + for node in nodes(viewer.get("repositories")) { + if node.get("isArchived").and_then(Value::as_bool).unwrap_or(false) { + continue; + } + let (Some(repo), Some(url)) = (str_at(node, "nameWithOwner"), str_at(node, "url")) else { + continue; + }; + let Some(branch_ref) = node.get("defaultBranchRef").filter(|b| b.is_object()) else { + continue; + }; + let Some(branch) = str_at(branch_ref, "name") else { continue }; + let target = branch_ref.get("target"); + let ci_raw = target + .and_then(|t| t.get("statusCheckRollup")) + .and_then(|r| str_at(r, "state")); + let head_sha = target.and_then(|t| str_at(t, "oid")).map(str::to_string); + main_ci.push(GitHubRepoCi { + repo: repo.to_string(), + url: url.to_string(), + branch: branch.to_string(), + ci: CiState::from_github(ci_raw), + head_sha, + }); + } + + // Pull requests waiting for my review. + let mut to_review: Vec = Vec::new(); + for node in nodes(data.get("reviewRequested")) { + let Some((id, title, url, repo, number, is_draft)) = pr_basics(node) else { continue }; + if to_review.iter().any(|p| p.id == id) { + continue; + } + to_review.push(GitHubPr { + id, + title, + url, + repo, + number, + is_draft, + ci: CiState::Unknown, + review: ReviewState::Pending, + head_sha: None, + }); + } + + Some(GitHubPulse { login, my_prs, to_review, main_ci, fetched_at: now_ms }) + } + + /// True when a PR's CI or a default branch is still running — the next poll + /// then comes after 60 s instead of 5 min. + pub fn has_pending(&self) -> bool { + self.my_prs.iter().any(|p| p.ci == CiState::Pending) + || self.main_ci.iter().any(|r| r.ci == CiState::Pending) + } + + /// Alerts between two polls. The first poll after launch (or after the token + /// changed) has no `old` and stays silent. + /// + /// headSha rule, which catches CI runs faster than the poll interval: + /// - same commit (or both unknown): plain state transitions; + /// - new commit or new PR: alert at once if its CI is already done; if it is + /// still running, the next poll on the same commit will catch the result; + /// - default branches only ever raise "failed", never "passed". + pub fn events(old: Option<&GitHubPulse>, new: &GitHubPulse) -> Vec { + let Some(old) = old else { return Vec::new() }; + let mut out = Vec::new(); + + for pr in &new.my_prs { + // First occurrence wins, should the old list hold a duplicate. + let prev = old.my_prs.iter().find(|p| p.id == pr.id); + match prev { + Some(prev) if prev.head_sha == pr.head_sha => { + if pr.ci == CiState::Failure && prev.ci != CiState::Failure { + out.push(GitHubEvent::CiFailed { pr_id: pr.id.clone() }); + } else if pr.ci == CiState::Success && prev.ci == CiState::Pending { + out.push(GitHubEvent::CiPassed { pr_id: pr.id.clone() }); + } + } + _ => match pr.ci { + CiState::Success => out.push(GitHubEvent::CiPassed { pr_id: pr.id.clone() }), + CiState::Failure => out.push(GitHubEvent::CiFailed { pr_id: pr.id.clone() }), + _ => {} + }, + } + } + + for repo in &new.main_ci { + let prev = old.main_ci.iter().find(|r| r.repo == repo.repo); + let failed = match prev { + Some(prev) if prev.head_sha == repo.head_sha => { + repo.ci == CiState::Failure && prev.ci != CiState::Failure + } + _ => repo.ci == CiState::Failure, + }; + if failed { + out.push(GitHubEvent::MainFailed { repo: repo.repo.clone() }); + } + } + + for pr in &new.to_review { + if !old.to_review.iter().any(|p| p.id == pr.id) { + out.push(GitHubEvent::ReviewRequested { pr_id: pr.id.clone() }); + } + } + + out + } +} + +/// True when nothing was fetched yet or the last fetch is older than `max_age_secs`. +pub fn is_stale(fetched_at_ms: Option, now_ms: u64, max_age_secs: u64) -> bool { + match fetched_at_ms { + None => true, + Some(t) => now_ms.saturating_sub(t) > max_age_secs * 1000, + } +} + +// ── Contribution calendar ───────────────────────────────────────────────────── + +#[derive(Serialize, Clone, Debug, PartialEq)] +pub struct ContributionDay { + /// YYYY-MM-DD + pub date: String, + pub count: i64, + /// 0…4, from NONE … FOURTH_QUARTILE. + pub level: u8, + /// 0 = Sunday … 6 = Saturday. + pub weekday: u8, +} + +#[derive(Serialize, Clone, Debug, PartialEq)] +pub struct GitHubActivity { + pub total: i64, + /// Oldest week first; the last one is usually the current, incomplete week. + pub weeks: Vec>, + #[serde(rename = "fetchedAt")] + pub fetched_at: u64, +} + +fn contribution_level(raw: &str) -> u8 { + match raw { + "FIRST_QUARTILE" => 1, + "SECOND_QUARTILE" => 2, + "THIRD_QUARTILE" => 3, + "FOURTH_QUARTILE" => 4, + _ => 0, + } +} + +impl GitHubActivity { + pub fn parse(root: &Value, now_ms: u64) -> Option { + let calendar = root + .get("data")? + .get("viewer")? + .get("contributionsCollection")? + .get("contributionCalendar")?; + let total = calendar.get("totalContributions")?.as_i64()?; + let weeks_raw = calendar.get("weeks")?.as_array()?; + + let mut weeks = Vec::new(); + for week in weeks_raw { + let Some(days_raw) = week.get("contributionDays").and_then(Value::as_array) else { continue }; + let days: Vec = days_raw + .iter() + .filter_map(|d| { + Some(ContributionDay { + date: str_at(d, "date")?.to_string(), + count: d.get("contributionCount")?.as_i64()?, + level: contribution_level(str_at(d, "contributionLevel")?), + weekday: u8::try_from(d.get("weekday")?.as_i64()?).ok()?, + }) + }) + .collect(); + if !days.is_empty() { + weeks.push(days); + } + } + Some(GitHubActivity { total, weeks, fetched_at: now_ms }) + } +} + +// ── GraphQL ─────────────────────────────────────────────────────────────────── + +/// Same query as GithubPoller.swift. +pub const PULSE_QUERY: &str = r#" +query { + viewer { + login + pullRequests(states: OPEN, first: 20, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + number title url isDraft reviewDecision + repository { nameWithOwner url } + commits(last: 1) { + nodes { commit { oid statusCheckRollup { state } } } + } + } + } + repositories(first: 10, ownerAffiliations: [OWNER], orderBy: {field: PUSHED_AT, direction: DESC}) { + nodes { + nameWithOwner url isArchived + defaultBranchRef { + name + target { ... on Commit { oid statusCheckRollup { state } } } + } + } + } + } + reviewRequested: search(query: "is:pr is:open review-requested:@me archived:false", type: ISSUE, first: 20) { + issueCount + nodes { + ... on PullRequest { + number title url isDraft + author { login } + repository { nameWithOwner url } + } + } + } +} +"#; + +pub const ACTIVITY_QUERY: &str = r#" +query { + viewer { + login + contributionsCollection { + contributionCalendar { + totalContributions + weeks { + contributionDays { + date contributionCount contributionLevel weekday + } + } + } + } + } +} +"#; + +/// Number of GraphQL errors in a response, for the log (never their text: it +/// can quote the query, not secrets, but there is no need for it). +pub fn graphql_error_count(root: &Value) -> usize { + root.get("errors").and_then(Value::as_array).map(Vec::len).unwrap_or(0) +} + +// ── Tests (GitHubPulseTests.swift, GitHubActivityTests.swift) ──────────────── + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn valid_pulse() -> Value { + json!({ + "data": { + "viewer": { + "login": "testuser", + "pullRequests": { "nodes": [ + { + "number": 42, "title": "Add feature", + "url": "https://github.com/testuser/myrepo/pull/42", + "isDraft": false, "reviewDecision": "APPROVED", + "repository": {"nameWithOwner": "testuser/myrepo", "url": "https://github.com/testuser/myrepo"}, + "commits": {"nodes": [{"commit": {"oid": "aaa", "statusCheckRollup": {"state": "SUCCESS"}}}]} + }, + { + "number": 43, "title": "Fix bug", + "url": "https://github.com/testuser/myrepo/pull/43", + "isDraft": true, "reviewDecision": null, + "repository": {"nameWithOwner": "testuser/myrepo", "url": "https://github.com/testuser/myrepo"}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": null}}]} + } + ]}, + "repositories": { "nodes": [ + { + "nameWithOwner": "testuser/myrepo", "url": "https://github.com/testuser/myrepo", + "isArchived": false, + "defaultBranchRef": {"name": "main", "target": {"oid": "bbb", "statusCheckRollup": {"state": "PENDING"}}} + }, + { + "nameWithOwner": "testuser/archived", "url": "https://github.com/testuser/archived", + "isArchived": true, + "defaultBranchRef": {"name": "main", "target": {"statusCheckRollup": {"state": "SUCCESS"}}} + } + ]} + }, + "reviewRequested": { + "issueCount": 1, + "nodes": [{ + "number": 7, "title": "Review this", + "url": "https://github.com/other/repo/pull/7", + "isDraft": false, "author": {"login": "otheruser"}, + "repository": {"nameWithOwner": "other/repo", "url": "https://github.com/other/repo"} + }] + } + } + }) + } + + fn empty_pulse() -> GitHubPulse { + let root = json!({ + "data": { + "viewer": {"login": "testuser", "pullRequests": {"nodes": []}, "repositories": {"nodes": []}}, + "reviewRequested": {"issueCount": 0, "nodes": []} + } + }); + GitHubPulse::parse(&root, 0).unwrap() + } + + fn pr(id: &str, ci: CiState, sha: Option<&str>) -> GitHubPr { + GitHubPr { + id: id.into(), + title: "T".into(), + url: String::new(), + repo: id.split('#').next().unwrap().into(), + number: 1, + is_draft: false, + ci, + review: ReviewState::Unknown, + head_sha: sha.map(str::to_string), + } + } + + fn repo(name: &str, ci: CiState, sha: Option<&str>) -> GitHubRepoCi { + GitHubRepoCi { repo: name.into(), url: String::new(), branch: "main".into(), ci, head_sha: sha.map(str::to_string) } + } + + #[test] + fn parses_a_full_pulse() { + let p = GitHubPulse::parse(&valid_pulse(), 5).unwrap(); + assert_eq!(p.login, "testuser"); + assert_eq!(p.my_prs.len(), 2); + assert_eq!(p.my_prs[0].id, "testuser/myrepo#42"); + assert_eq!(p.my_prs[0].ci, CiState::Success); + assert_eq!(p.my_prs[0].review, ReviewState::Approved); + assert_eq!(p.my_prs[0].head_sha.as_deref(), Some("aaa")); + assert_eq!(p.my_prs[1].ci, CiState::Unknown, "null rollup"); + assert_eq!(p.my_prs[1].review, ReviewState::Unknown, "null review"); + assert!(p.my_prs[1].is_draft); + assert_eq!(p.main_ci.len(), 1, "archived repo filtered out"); + assert_eq!(p.main_ci[0].repo, "testuser/myrepo"); + assert_eq!(p.main_ci[0].ci, CiState::Pending); + assert_eq!(p.main_ci[0].branch, "main"); + assert_eq!(p.main_ci[0].head_sha.as_deref(), Some("bbb")); + assert_eq!(p.to_review.len(), 1); + assert_eq!(p.to_review[0].id, "other/repo#7"); + assert_eq!(p.to_review[0].review, ReviewState::Pending); + assert!(p.has_pending(), "default branch pending"); + assert_eq!(p.fetched_at, 5); + } + + #[test] + fn parses_an_empty_pulse() { + let p = empty_pulse(); + assert!(p.my_prs.is_empty() && p.main_ci.is_empty() && p.to_review.is_empty()); + assert!(!p.has_pending()); + } + + #[test] + fn rejects_bad_pulse_data() { + assert!(GitHubPulse::parse(&json!("garbage"), 0).is_none()); + assert!(GitHubPulse::parse(&json!({}), 0).is_none()); + assert!(GitHubPulse::parse(&json!({"data": null}), 0).is_none()); + } + + #[test] + fn keeps_the_first_of_duplicate_prs() { + let root = json!({ + "data": { + "viewer": { + "login": "testuser", + "pullRequests": {"nodes": [ + {"number": 42, "title": "Add feature", "url": "u", "isDraft": false, "reviewDecision": "APPROVED", + "repository": {"nameWithOwner": "testuser/myrepo"}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]}}, + {"number": 42, "title": "Duplicate entry", "url": "u", "isDraft": true, "reviewDecision": null, + "repository": {"nameWithOwner": "testuser/myrepo"}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "FAILURE"}}}]}} + ]}, + "repositories": {"nodes": []} + }, + "reviewRequested": {"issueCount": 0, "nodes": []} + } + }); + let p = GitHubPulse::parse(&root, 0).unwrap(); + assert_eq!(p.my_prs.len(), 1); + assert_eq!(p.my_prs[0].title, "Add feature"); + assert_eq!(p.my_prs[0].ci, CiState::Success); + } + + #[test] + fn maps_ci_states() { + assert_eq!(CiState::from_github(None), CiState::Unknown); + assert_eq!(CiState::from_github(Some("PENDING")), CiState::Pending); + assert_eq!(CiState::from_github(Some("EXPECTED")), CiState::Pending); + assert_eq!(CiState::from_github(Some("SUCCESS")), CiState::Success); + assert_eq!(CiState::from_github(Some("FAILURE")), CiState::Failure); + assert_eq!(CiState::from_github(Some("ERROR")), CiState::Failure); + assert_eq!(CiState::from_github(Some("WAITING")), CiState::Unknown); + } + + #[test] + fn first_poll_is_silent() { + let p = GitHubPulse::parse(&valid_pulse(), 0).unwrap(); + assert!(GitHubPulse::events(None, &p).is_empty()); + } + + #[test] + fn pending_to_success_passes() { + let mut old = empty_pulse(); + old.my_prs = vec![pr("r/p#1", CiState::Pending, None)]; + let mut new = old.clone(); + new.my_prs[0].ci = CiState::Success; + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::CiPassed { pr_id: "r/p#1".into() }]); + } + + #[test] + fn success_to_failure_fails() { + let mut old = empty_pulse(); + old.my_prs = vec![pr("r/p#2", CiState::Success, None)]; + let mut new = old.clone(); + new.my_prs[0].ci = CiState::Failure; + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::CiFailed { pr_id: "r/p#2".into() }]); + } + + #[test] + fn default_branch_breaking_alerts() { + let mut old = empty_pulse(); + old.main_ci = vec![repo("a/b", CiState::Success, None)]; + let mut new = old.clone(); + new.main_ci[0].ci = CiState::Failure; + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::MainFailed { repo: "a/b".into() }]); + } + + #[test] + fn default_branch_turning_green_is_quiet() { + let mut old = empty_pulse(); + old.main_ci = vec![repo("a/b", CiState::Pending, Some("x"))]; + let mut new = old.clone(); + new.main_ci[0].ci = CiState::Success; + assert!(GitHubPulse::events(Some(&old), &new).is_empty()); + new.main_ci[0].head_sha = Some("y".into()); + assert!(GitHubPulse::events(Some(&old), &new).is_empty()); + } + + #[test] + fn new_review_request_alerts_once() { + let mut review = pr("o/r#7", CiState::Unknown, None); + review.review = ReviewState::Pending; + let mut old = empty_pulse(); + let mut new = old.clone(); + new.to_review = vec![review.clone()]; + assert_eq!( + GitHubPulse::events(Some(&old), &new), + vec![GitHubEvent::ReviewRequested { pr_id: "o/r#7".into() }] + ); + old.to_review = vec![review]; + assert!(GitHubPulse::events(Some(&old), &new).is_empty(), "already known"); + } + + #[test] + fn duplicate_ids_in_old_fire_once() { + let mut old = empty_pulse(); + let p = pr("r/p#1", CiState::Pending, None); + old.my_prs = vec![p.clone(), p.clone()]; + let mut new = empty_pulse(); + let mut passed = p; + passed.ci = CiState::Success; + new.my_prs = vec![passed]; + let events = GitHubPulse::events(Some(&old), &new); + assert_eq!(events.iter().filter(|e| matches!(e, GitHubEvent::CiPassed { .. })).count(), 1); + } + + #[test] + fn new_pr_already_green_passes() { + let old = empty_pulse(); + let mut new = old.clone(); + new.my_prs = vec![pr("r/p#10", CiState::Success, Some("abc111"))]; + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::CiPassed { pr_id: "r/p#10".into() }]); + } + + #[test] + fn new_pr_still_pending_is_quiet() { + let old = empty_pulse(); + let mut new = old.clone(); + new.my_prs = vec![pr("r/p#11", CiState::Pending, Some("abc222"))]; + assert!(GitHubPulse::events(Some(&old), &new).is_empty()); + } + + #[test] + fn new_commit_already_red_fails() { + let mut old = empty_pulse(); + old.my_prs = vec![pr("r/p#12", CiState::Success, Some("sha-old"))]; + let mut new = old.clone(); + new.my_prs[0].ci = CiState::Failure; + new.my_prs[0].head_sha = Some("sha-new".into()); + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::CiFailed { pr_id: "r/p#12".into() }]); + } + + #[test] + fn new_commit_already_green_passes_even_from_green() { + // A fast CI ran entirely between two polls: success → (new sha) success. + let mut old = empty_pulse(); + old.my_prs = vec![pr("r/p#14", CiState::Success, Some("one"))]; + let mut new = old.clone(); + new.my_prs[0].head_sha = Some("two".into()); + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::CiPassed { pr_id: "r/p#14".into() }]); + } + + #[test] + fn same_sha_success_stays_quiet() { + let mut old = empty_pulse(); + old.my_prs = vec![pr("r/p#13", CiState::Success, Some("same"))]; + let new = old.clone(); + assert!(GitHubPulse::events(Some(&old), &new).is_empty()); + } + + #[test] + fn new_commit_on_main_red_fails() { + let mut old = empty_pulse(); + old.main_ci = vec![repo("a/b", CiState::Success, Some("sha-old"))]; + let mut new = old.clone(); + new.main_ci[0].ci = CiState::Failure; + new.main_ci[0].head_sha = Some("sha-new".into()); + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::MainFailed { repo: "a/b".into() }]); + } + + #[test] + fn staleness() { + let now = 1_000_000; + assert!(is_stale(None, now, 60)); + assert!(!is_stale(Some(now), now, 60)); + assert!(!is_stale(Some(now - 60_000), now, 60)); + assert!(is_stale(Some(now - 61_000), now, 60)); + assert!(!is_stale(Some(now + 5_000), now, 60), "clock skew is not stale"); + } + + #[test] + fn events_serialise_for_the_island() { + let v = serde_json::to_value(GitHubEvent::CiFailed { pr_id: "a/b#1".into() }).unwrap(); + assert_eq!(v, json!({"kind": "ciFailed", "prId": "a/b#1"})); + let v = serde_json::to_value(GitHubEvent::MainFailed { repo: "a/b".into() }).unwrap(); + assert_eq!(v, json!({"kind": "mainFailed", "repo": "a/b"})); + let p = serde_json::to_value(GitHubPulse::parse(&valid_pulse(), 7).unwrap()).unwrap(); + assert!(p.get("myPRs").is_some() && p.get("toReview").is_some() && p.get("mainCI").is_some()); + assert_eq!(p["myPRs"][0]["isDraft"], json!(false)); + assert_eq!(p["myPRs"][0]["headSha"], json!("aaa")); + assert_eq!(p["myPRs"][0]["ci"], json!("success")); + assert_eq!(p["myPRs"][0]["review"], json!("approved")); + assert_eq!(p["fetchedAt"], json!(7)); + } + + // ── Activity ────────────────────────────────────────────────────────────── + + fn day(date: &str, count: i64, level: &str, weekday: i64) -> Value { + json!({"date": date, "contributionCount": count, "contributionLevel": level, "weekday": weekday}) + } + + fn valid_activity() -> Value { + json!({ + "data": { "viewer": { "login": "testuser", "contributionsCollection": { "contributionCalendar": { + "totalContributions": 42, + "weeks": [ + {"contributionDays": [ + day("2026-01-05", 0, "NONE", 0), + day("2026-01-06", 1, "FIRST_QUARTILE", 1), + day("2026-01-07", 4, "SECOND_QUARTILE", 2), + day("2026-01-08", 8, "THIRD_QUARTILE", 3), + day("2026-01-09", 12, "FOURTH_QUARTILE", 4), + day("2026-01-10", 2, "FIRST_QUARTILE", 5), + day("2026-01-11", 0, "NONE", 6) + ]}, + {"contributionDays": [ + day("2026-01-12", 5, "SECOND_QUARTILE", 0), + day("2026-01-13", 10, "THIRD_QUARTILE", 1) + ]} + ] + }}}} + }) + } + + #[test] + fn parses_the_contribution_calendar() { + let a = GitHubActivity::parse(&valid_activity(), 3).unwrap(); + assert_eq!(a.total, 42); + assert_eq!(a.weeks.len(), 2); + assert_eq!(a.weeks[0].len(), 7); + assert_eq!(a.weeks[1].len(), 2, "incomplete current week"); + let levels: Vec = a.weeks[0][..5].iter().map(|d| d.level).collect(); + assert_eq!(levels, vec![0, 1, 2, 3, 4]); + assert_eq!(a.weeks[0][0].date, "2026-01-05"); + assert_eq!(a.weeks[0][0].count, 0); + assert_eq!(a.weeks[0][0].weekday, 0); + assert_eq!(a.weeks[1][1].date, "2026-01-13"); + assert_eq!(a.weeks[1][1].count, 10); + assert_eq!(a.fetched_at, 3); + } + + #[test] + fn unknown_contribution_level_is_zero() { + let root = json!({"data": {"viewer": {"contributionsCollection": {"contributionCalendar": { + "totalContributions": 1, + "weeks": [{"contributionDays": [day("2026-03-01", 1, "EXTRA_SPECIAL", 0)]}] + }}}}}); + assert_eq!(GitHubActivity::parse(&root, 0).unwrap().weeks[0][0].level, 0); + } + + #[test] + fn rejects_bad_activity_data() { + assert!(GitHubActivity::parse(&json!("garbage"), 0).is_none()); + assert!(GitHubActivity::parse(&json!({}), 0).is_none()); + } + + #[test] + fn counts_graphql_errors() { + assert_eq!(graphql_error_count(&json!({"data": {}})), 0); + assert_eq!(graphql_error_count(&json!({"errors": [{}, {}]})), 2); + } +} diff --git a/windows/src-tauri/src/integrations.rs b/windows/src-tauri/src/integrations.rs index d666b9977..a67e03a01 100644 --- a/windows/src-tauri/src/integrations.rs +++ b/windows/src-tauri/src/integrations.rs @@ -8,14 +8,16 @@ // Nothing is polled until its key exists in the Credential Manager, and no // request goes anywhere the user has not configured. -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Mutex; use std::time::Duration; use serde::Serialize; use serde_json::{json, Value}; use tauri::{AppHandle, Emitter, Manager}; +use tokio::sync::Notify; +use crate::github::{self, GitHubActivity, GitHubPulse}; use crate::island::WINDOW_LABEL; use crate::log; use crate::secrets; @@ -67,6 +69,7 @@ pub fn start(app: AppHandle) { spawn(app.clone(), "integration_stripe", 6, 30, poll_stripe); spawn(app.clone(), "integration_resend", 6, 60, poll_resend); spawn(app.clone(), "integration_github", 7, 300, poll_github); + spawn_github_loops(app.clone()); spawn(app.clone(), "integration_calcom", 8, 300, poll_calcom); spawn(app, "integration_notion", 9, 300, poll_notion); } @@ -107,7 +110,11 @@ where pub async fn poll_once(app: AppHandle, id: &str) { match id { "integration_stripe" => poll_stripe(app).await, - "integration_github" => poll_github(app).await, + "integration_github" => { + wake_github_pulse(); + github_refresh_if_stale("activity"); + poll_github(app).await + } "integration_vercel" => poll_vercel(app).await, "integration_n8n" => poll_n8n(app).await, "integration_resend" => poll_resend(app).await, @@ -315,14 +322,246 @@ async fn poll_github(app: AppHandle) { _ => 0, }; - emit(&app, IntegrationUpdate { - id: "integration_github", - data: json!({ "totalRepos": public + private, "totalStars": stars }), - error: None, - event: None, + GITHUB.cache.lock().unwrap().stats = Some((public + private, stars)); + emit_github(&app); +} + +// ── GitHub pulse and activity (GithubPoller.swift) ──────────────────────────── +// +// Two more loops next to the stats above, with the Mac cadence: the pulse (my +// pull requests and their CI, reviews waiting for me, default-branch CI) 10 s +// after launch, then every 60 s while some CI is running and every 5 min +// otherwise; the contribution calendar 15 s after launch, then every 30 min. +// Neither touches the network while the pill is off or Coucou is paused, and +// the island wakes them when the card is opened on stale data. +// +// All three results are kept here and always sent together, so one poll never +// wipes what another one reported. + +const GITHUB_ID: &str = "integration_github"; + +#[derive(Default)] +struct GitHubCache { + /// (repositories, stars) + stats: Option<(i64, i64)>, + pulse: Option, + activity: Option, +} + +struct GitHubLoops { + cache: Mutex, + /// Bumped when the token changes: a response for the old token is dropped. + generation: AtomicU64, + pulse_wake: Notify, + activity_wake: Notify, + pulse_busy: AtomicBool, + activity_busy: AtomicBool, +} + +static GITHUB: std::sync::LazyLock = std::sync::LazyLock::new(|| GitHubLoops { + cache: Mutex::new(GitHubCache::default()), + generation: AtomicU64::new(0), + pulse_wake: Notify::new(), + activity_wake: Notify::new(), + pulse_busy: AtomicBool::new(false), + activity_busy: AtomicBool::new(false), +}); + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// What the island's GitHub card receives: whichever of the three is known. +fn github_card_data(cache: &GitHubCache) -> Value { + let mut data = serde_json::Map::new(); + if let Some((repos, stars)) = cache.stats { + data.insert("totalRepos".into(), json!(repos)); + data.insert("totalStars".into(), json!(stars)); + } + if let Some(pulse) = &cache.pulse { + data.insert("pulse".into(), serde_json::to_value(pulse).unwrap_or(Value::Null)); + } + if let Some(activity) = &cache.activity { + data.insert("activity".into(), serde_json::to_value(activity).unwrap_or(Value::Null)); + } + Value::Object(data) +} + +fn emit_github(app: &AppHandle) { + let data = github_card_data(&GITHUB.cache.lock().unwrap()); + emit(app, IntegrationUpdate { id: GITHUB_ID, data, error: None, event: None }); +} + +fn spawn_github_loops(app: AppHandle) { + let pulse_app = app.clone(); + tauri::async_runtime::spawn(async move { + let mut wait = Duration::from_secs(10); + loop { + // Sleeps for `wait`, or less when the island asks for fresh data. + let _ = tokio::time::timeout(wait, GITHUB.pulse_wake.notified()).await; + let pending = run_github_pulse(&pulse_app).await; + wait = Duration::from_secs(if pending { 60 } else { 300 }); + } + }); + tauri::async_runtime::spawn(async move { + let mut wait = Duration::from_secs(15); + loop { + let _ = tokio::time::timeout(wait, GITHUB.activity_wake.notified()).await; + run_github_activity(&app).await; + wait = Duration::from_secs(1800); + } }); } +/// One authenticated GraphQL call. Partial errors are logged (as a count) and the +/// data parsed anyway; only a response without `data` is dropped. +async fn github_graphql(token: &str, query: &str, what: &str) -> Option { + let response = client() + .post("https://api.github.com/graphql") + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .header("User-Agent", "Coucou") + .json(&json!({ "query": query })) + .send() + .await; + let Ok(response) = response else { + log::line(format!("github {what}: no connection")); + return None; + }; + if !response.status().is_success() { + log::line(format!("github {what} HTTP {}", response.status().as_u16())); + return None; + } + let root: Value = response.json().await.ok()?; + let errors = github::graphql_error_count(&root); + if errors > 0 { + log::line(format!("github {what} GraphQL errors: {errors}")); + } + root.get("data").filter(|d| d.is_object())?; + Some(root) +} + +/// Returns whether some CI is still running, which sets the next interval. +async fn run_github_pulse(app: &AppHandle) -> bool { + if PAUSED.load(Ordering::Relaxed) || !enabled(app, GITHUB_ID) { + return false; + } + let Some(token) = secrets::get("github-token") else { return false }; + let generation = GITHUB.generation.load(Ordering::SeqCst); + GITHUB.pulse_busy.store(true, Ordering::SeqCst); + let root = github_graphql(&token, github::PULSE_QUERY, "pulse").await; + GITHUB.pulse_busy.store(false, Ordering::SeqCst); + let Some(pulse) = root.and_then(|r| GitHubPulse::parse(&r, now_ms())) else { return false }; + let pending = pulse.has_pending(); + + let events = { + let mut cache = GITHUB.cache.lock().unwrap(); + // The token changed while this was in flight: it answers for someone else. + if GITHUB.generation.load(Ordering::SeqCst) != generation { + return pending; + } + let events = GitHubPulse::events(cache.pulse.as_ref(), &pulse); + cache.pulse = Some(pulse); + events + }; + emit_github(app); + if !events.is_empty() { + let _ = app.emit_to(WINDOW_LABEL, "github-alerts", &events); + } + pending +} + +async fn run_github_activity(app: &AppHandle) { + if PAUSED.load(Ordering::Relaxed) || !enabled(app, GITHUB_ID) { + return; + } + let Some(token) = secrets::get("github-token") else { return }; + let generation = GITHUB.generation.load(Ordering::SeqCst); + GITHUB.activity_busy.store(true, Ordering::SeqCst); + let root = github_graphql(&token, github::ACTIVITY_QUERY, "activity").await; + GITHUB.activity_busy.store(false, Ordering::SeqCst); + let Some(activity) = root.and_then(|r| GitHubActivity::parse(&r, now_ms())) else { return }; + { + let mut cache = GITHUB.cache.lock().unwrap(); + if GITHUB.generation.load(Ordering::SeqCst) != generation { + return; + } + cache.activity = Some(activity); + } + emit_github(app); +} + +fn wake_github_pulse() { + if !GITHUB.pulse_busy.load(Ordering::SeqCst) { + GITHUB.pulse_wake.notify_one(); + } +} + +/// The card was opened: fetch now if what it shows is older than the Mac's +/// limits (1 min for the pulse, 5 min for the activity grid). No-op while a +/// request is already in flight. +pub fn github_refresh_if_stale(section: &str) { + let now = now_ms(); + let cache = GITHUB.cache.lock().unwrap(); + match section { + "pulse" => { + let fetched = cache.pulse.as_ref().map(|p| p.fetched_at); + if !GITHUB.pulse_busy.load(Ordering::SeqCst) && github::is_stale(fetched, now, 60) { + GITHUB.pulse_wake.notify_one(); + } + } + "activity" => { + let fetched = cache.activity.as_ref().map(|a| a.fetched_at); + if !GITHUB.activity_busy.load(Ordering::SeqCst) && github::is_stale(fetched, now, 300) { + GITHUB.activity_wake.notify_one(); + } + } + _ => {} + } +} + +/// The GitHub token was saved or removed: forget what the old one fetched, drop +/// its requests still in flight, and fetch again right away. +pub fn github_token_changed(app: &AppHandle) { + GITHUB.generation.fetch_add(1, Ordering::SeqCst); + let has_token = secrets::get("github-token").is_some(); + { + let mut cache = GITHUB.cache.lock().unwrap(); + cache.pulse = None; + cache.activity = None; + if !has_token { + cache.stats = None; + } + } + emit_github(app); + GITHUB.pulse_wake.notify_one(); + GITHUB.activity_wake.notify_one(); + if has_token && !PAUSED.load(Ordering::Relaxed) && enabled(app, GITHUB_ID) { + tauri::async_runtime::spawn(poll_github(app.clone())); + } +} + +/// Switching the pill off forgets the pulse, so switching it back on starts +/// silent instead of alerting on everything that changed in between. +pub fn settings_saved(app: &AppHandle, active_integrations: &[String]) { + if active_integrations.iter().any(|id| id == GITHUB_ID) { + return; + } + let had_data = { + let mut cache = GITHUB.cache.lock().unwrap(); + let had = cache.pulse.is_some() || cache.activity.is_some(); + cache.pulse = None; + cache.activity = None; + had + }; + if had_data { + emit_github(app); + } +} + // ── Vercel ──────────────────────────────────────────────────────────────────── async fn poll_vercel(app: AppHandle) { @@ -763,3 +1002,21 @@ fn fmt_value(v: &Value) -> String { other => other.to_string(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_card_data_sends_only_what_is_known() { + let mut cache = GitHubCache::default(); + assert_eq!(github_card_data(&cache), json!({})); + cache.stats = Some((12, 3400)); + assert_eq!(github_card_data(&cache), json!({ "totalRepos": 12, "totalStars": 3400 })); + cache.activity = Some(GitHubActivity { total: 5, weeks: vec![], fetched_at: 9 }); + let data = github_card_data(&cache); + assert_eq!(data["totalRepos"], json!(12)); + assert_eq!(data["activity"], json!({ "total": 5, "weeks": [], "fetchedAt": 9 })); + assert!(data.get("pulse").is_none()); + } +} diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index d9ec3353e..967492062 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod claude; mod files; +mod github; mod hooks; mod integrations; mod island; @@ -82,6 +83,7 @@ fn save_settings(app: AppHandle, shared: State, settings: Settings) { let collapsed = shared.gate.collapsed.load(Ordering::Relaxed); island::apply_geometry(&app, &settings.screen, collapsed); } + integrations::settings_saved(&app, &settings.active_integrations); // Keep the other window in step (island ⇄ settings window). let _ = app.emit("settings-changed", settings); } @@ -265,13 +267,25 @@ fn secret_present(key: String) -> bool { } #[tauri::command] -fn secret_set(key: String, value: String) -> Result<(), String> { - secrets::set(&key, &value) +fn secret_set(app: AppHandle, key: String, value: String) -> Result<(), String> { + let before = (key == "github-token").then(|| secrets::get(&key)); + secrets::set(&key, &value)?; + if let Some(before) = before { + if secrets::get(&key) != before { + integrations::github_token_changed(&app); + } + } + Ok(()) } #[tauri::command] -fn secret_clear(key: String) -> Result<(), String> { - secrets::clear(&key) +fn secret_clear(app: AppHandle, key: String) -> Result<(), String> { + let had = key == "github-token" && secrets::present(&key); + secrets::clear(&key)?; + if had { + integrations::github_token_changed(&app); + } + Ok(()) } /// Opens the configured n8n instance — the URL lives in the Credential Manager. @@ -288,6 +302,13 @@ async fn refresh_integration(app: AppHandle, id: String) { integrations::poll_once(app, &id).await; } +/// The GitHub card was opened: refetch its `section` ("pulse" or "activity") +/// if it is stale. The pollers still decline while the pill is off or paused. +#[tauri::command] +fn github_refresh(section: String) { + integrations::github_refresh_if_stale(§ion); +} + /// Lets the island write to the same log as the Rust side. #[tauri::command] fn log_line(message: String) { @@ -400,6 +421,7 @@ pub fn run() { secret_set, secret_clear, refresh_integration, + github_refresh, open_n8n, open_settings_window, set_paused, From 8d47969f511997241595e342d2849075307d7e1b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:17 +0200 Subject: [PATCH 14/77] Windows/Linux: record a weekly recap history from hook events recap.rs keeps per-turn counts (time, files and lines changed, commands, questions) and permission decisions in recap.json under the app data dir, with a 12-week window, a hard size cap and atomic writes. Only the agent's pill ID and the project folder's name are stored, never commands, paths or prompts. Also saves the shared PNG into Pictures/Downloads without ever replacing a file, and adds "Weekly recap" to the tray menu. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/lib.rs | 12 + windows/src-tauri/src/pipe.rs | 4 + windows/src-tauri/src/platform/linux.rs | 45 + windows/src-tauri/src/platform/windows.rs | 20 + windows/src-tauri/src/recap.rs | 1024 +++++++++++++++++++++ windows/src-tauri/src/tray.rs | 5 +- 6 files changed, 1108 insertions(+), 2 deletions(-) create mode 100644 windows/src-tauri/src/recap.rs diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index d9ec3353e..69520e5a3 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod island; mod log; mod pipe; mod platform; +mod recap; mod secrets; mod settings; mod tray; @@ -215,6 +216,7 @@ fn hooks_apply( #[tauri::command] fn approval_decision(app: AppHandle, request_id: String, decision: String) { + recap::record_decision(&app, &request_id, &decision); pipe::answer(&app, &request_id, &decision); } @@ -230,6 +232,7 @@ fn approval_ack(app: AppHandle, request_id: String) { /// already up. Claude Code falls back to asking in the terminal immediately. #[tauri::command] fn approval_decline(app: AppHandle, request_id: String) { + recap::forget_request(&app, &request_id); pipe::decline(&app, &request_id); } @@ -376,6 +379,7 @@ pub fn run() { }) .manage(Pending::default()) .manage(Chat::default()) + .manage(recap::load()) .invoke_handler(tauri::generate_handler![ boot, save_settings, @@ -403,6 +407,14 @@ pub fn run() { open_n8n, open_settings_window, set_paused, + recap::recap_history, + recap::recap_prefs, + recap::recap_set_enabled, + recap::recap_set_hide_projects, + recap::recap_mark_shown, + recap::recap_clear, + recap::recap_save_png, + recap::recap_reveal_saved, ]) .setup(move |app| { let handle = app.handle().clone(); diff --git a/windows/src-tauri/src/pipe.rs b/windows/src-tauri/src/pipe.rs index 36b6316c2..b8c60d394 100644 --- a/windows/src-tauri/src/pipe.rs +++ b/windows/src-tauri/src/pipe.rs @@ -195,6 +195,9 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) { .unwrap_or_default() .to_string(); + // Counts for the weekly recap — never the command, path or prompt itself. + crate::recap::observe(&app, &payload); + if event != "PermissionRequest" { log::line(format!("hook {event}")); let _ = app.emit_to(WINDOW_LABEL, "hook", payload); @@ -208,6 +211,7 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) { let pending = app.state::(); pending.0.lock().unwrap().insert(id.clone(), tx); } + crate::recap::note_request(&app, &id, &payload); payload["request_id"] = json!(id); log::line(format!("hook PermissionRequest id={id}")); let _ = app.emit_to(WINDOW_LABEL, "hook", payload); diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e748a336a..eb44860cd 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -51,6 +51,38 @@ pub fn local_dir() -> PathBuf { xdg("XDG_DATA_HOME", ".local/share").join("coucou") } +/// Where a saved image goes, best first: the XDG pictures folder named in +/// ~/.config/user-dirs.dirs, then ~/Pictures, ~/Downloads and the home folder. +/// The caller takes the first one that exists. +pub fn picture_dirs() -> Vec { + let home = home_dir(); + let mut dirs = Vec::new(); + let user_dirs = xdg("XDG_CONFIG_HOME", ".config").join("user-dirs.dirs"); + if let Ok(text) = std::fs::read_to_string(user_dirs) { + if let Some(dir) = xdg_user_dir(&text, "XDG_PICTURES_DIR", &home) { + dirs.push(dir); + } + } + dirs.push(home.join("Pictures")); + dirs.push(home.join("Downloads")); + dirs.push(home); + dirs +} + +/// `XDG_PICTURES_DIR="$HOME/Pictures"` → /home/me/Pictures. Only the two forms +/// xdg-user-dirs writes are understood: "$HOME/…" and an absolute path. +fn xdg_user_dir(text: &str, key: &str, home: &Path) -> Option { + let line = text.lines().map(str::trim).find(|l| l.starts_with(key))?; + let value = line.strip_prefix(key)?.trim_start().strip_prefix('=')?.trim(); + let value = value.strip_prefix('"')?.strip_suffix('"')?; + let path = match value.strip_prefix("$HOME") { + Some(rest) => home.join(rest.trim_start_matches('/')), + None => PathBuf::from(value), + }; + // "$HOME/" alone means "no pictures folder", by the spec. + (path.is_absolute() && path != home).then_some(path) +} + /// Environment the webview must inherit, set before any thread or process /// starts. /// @@ -344,4 +376,17 @@ mod tests { assert_eq!(std::fs::metadata(&dir).unwrap().mode() & 0o777, 0o700); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn the_pictures_folder_comes_from_user_dirs() { + let home = Path::new("/home/me"); + let text = "# written by xdg-user-dirs-update\nXDG_DESKTOP_DIR=\"$HOME/Desktop\"\nXDG_PICTURES_DIR=\"$HOME/Images\"\n"; + assert_eq!(xdg_user_dir(text, "XDG_PICTURES_DIR", home), Some(PathBuf::from("/home/me/Images"))); + let absolute = "XDG_PICTURES_DIR=\"/data/pics\""; + assert_eq!(xdg_user_dir(absolute, "XDG_PICTURES_DIR", home), Some(PathBuf::from("/data/pics"))); + // "$HOME/" means the folder is disabled; relative paths are not paths. + assert_eq!(xdg_user_dir("XDG_PICTURES_DIR=\"$HOME/\"", "XDG_PICTURES_DIR", home), None); + assert_eq!(xdg_user_dir("XDG_PICTURES_DIR=\"pics\"", "XDG_PICTURES_DIR", home), None); + assert_eq!(xdg_user_dir("", "XDG_PICTURES_DIR", home), None); + } } diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 6e1200a8a..4eaf0c432 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -49,6 +49,26 @@ pub fn local_dir() -> PathBuf { base.join("Coucou") } +/// Where a saved image goes, best first: Pictures (also where OneDrive moves +/// it), then Downloads and the profile folder. The caller takes the first one +/// that exists. Read from the environment rather than the Known Folders API: a +/// relocated Pictures folder falls back to Downloads, which is fine for a PNG. +pub fn picture_dirs() -> Vec { + let mut dirs = Vec::new(); + let home = std::env::var_os("USERPROFILE").map(PathBuf::from); + if let Some(home) = &home { + dirs.push(home.join("Pictures")); + } + if let Some(onedrive) = std::env::var_os("OneDrive").map(PathBuf::from) { + dirs.push(onedrive.join("Pictures")); + } + if let Some(home) = home { + dirs.push(home.join("Downloads")); + dirs.push(home); + } + dirs +} + /// %APPDATA% and %LOCALAPPDATA% are already private to the user. pub fn ensure_private_dir(dir: &std::path::Path) -> std::io::Result<()> { std::fs::create_dir_all(dir) diff --git a/windows/src-tauri/src/recap.rs b/windows/src-tauri/src/recap.rs new file mode 100644 index 000000000..e6b0d7cd6 --- /dev/null +++ b/windows/src-tauri/src/recap.rs @@ -0,0 +1,1024 @@ +// Weekly recap history — port of RecapStore.swift. +// +// Every hook event the relay forwards also passes through here. A "turn" runs +// from a prompt (or the first tool call) to Stop, and only its counts and two +// names are kept: the agent's pill ID and the project folder's last path +// component. Never a command, a file path, file contents or a prompt. +// +// The history lives in recap.json under platform::local_dir() +// (%LOCALAPPDATA%\Coucou on Windows, ~/.local/share/coucou on Linux), keeps a +// 12-week rolling window and a hard cap on its length, and is written whole to +// a temporary file then renamed over the old one, so a crash never leaves half +// a file. Nothing in it ever leaves the machine. +// +// Aggregation (weeks, top agent, busiest day…) happens in the island +// (src/recap/summary.ts): it needs the user's time zone, which JavaScript has +// and plain Rust does not. + +use std::collections::{HashMap, HashSet}; +use std::io::{ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, PoisonError}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::{AppHandle, Emitter, Manager, State}; + +use crate::island::WINDOW_LABEL; +use crate::platform; + +/// 12 weeks, like the Mac. +pub const WINDOW_SECS: i64 = 12 * 7 * 86_400; +/// A turn with no event for two hours is closed at its last event (agent +/// crashed, or never sent Stop) — RecapStore.pruneStale. +const STALE_SECS: i64 = 2 * 3600; +/// Hard caps, whatever the window holds: about 2 MB of JSON at most. +const MAX_TURNS: usize = 10_000; +const MAX_DECISIONS: usize = 10_000; +const MAX_DRAFTS: usize = 256; +const MAX_REQUESTS: usize = 64; +const MAX_NAME_CHARS: usize = 64; +/// A recap.json larger than this was not written by us: set it aside. +const MAX_FILE_BYTES: u64 = 16 << 20; +/// A 1080 × 1920 PNG is ~1 MB; anything far larger is not our image. +const MAX_PNG_BYTES: usize = 24 << 20; + +/// Same limits as DiffEngine.swift: beyond them, lines are counted, not diffed. +const DIFF_MAX_BYTES: usize = 200 * 1024; +const DIFF_MAX_LINES: usize = 4000; +const DIFF_MAX_CELLS: usize = 1_000_000; + +/// Tool names that run a shell command, across the agents the relay serves. +const COMMAND_TOOLS: &[&str] = &[ + "Bash", + "PowerShell", + "Execute", + "mcp__ide__executeCode", + "run_shell_command", + "shell", + "local_shell", + "exec_command", +]; + +// ── Persisted model ─────────────────────────────────────────────────────────── + +/// One finished turn. Times are Unix seconds. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Turn { + pub agent: String, + pub project: String, + pub start: i64, + pub end: i64, + pub files_changed: u32, + pub lines_added: u32, + pub lines_removed: u32, + pub commands_run: u32, + pub questions: u32, +} + +/// A click on Allow or Deny in the island. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Decision { + pub agent: String, + pub date: i64, + /// "allow" or "deny". + pub decision: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Prefs { + /// "Keep a history of my coding sessions" — on by default, like the Mac. + pub enabled: bool, + /// Leave the project name out of the shared image. + pub hide_projects: bool, + /// Monday (YYYY-MM-DD) of the week the recap was last shown on its own. + pub last_shown_week: String, +} + +impl Default for Prefs { + fn default() -> Self { + Self { enabled: true, hide_projects: false, last_shown_week: String::new() } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct History { + schema_version: u32, + turns: Vec, + decisions: Vec, + prefs: Prefs, +} + +impl Default for History { + fn default() -> Self { + Self { schema_version: 1, turns: Vec::new(), decisions: Vec::new(), prefs: Prefs::default() } + } +} + +/// What the island reads: the part of the history it asked for. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HistoryView { + pub turns: Vec, + pub decisions: Vec, + pub prefs: Prefs, +} + +/// A turn in progress, in memory only. The paths are only there to count +/// distinct files and are never written anywhere. +#[derive(Debug)] +struct Draft { + agent: String, + project: String, + start: i64, + last_event: i64, + paths: HashSet, + lines_added: u32, + lines_removed: u32, + commands_run: u32, + questions: u32, +} + +impl Draft { + fn into_turn(self, end: i64) -> Turn { + Turn { + agent: self.agent, + project: self.project, + start: self.start, + end: end.max(self.start), + files_changed: self.paths.len().min(u32::MAX as usize) as u32, + lines_added: self.lines_added, + lines_removed: self.lines_removed, + commands_run: self.commands_run, + questions: self.questions, + } + } +} + +// ── Store ───────────────────────────────────────────────────────────────────── + +pub struct Recap { + path: PathBuf, + history: History, + drafts: HashMap, + /// Permission request ID → agent, until the island answers it. + requests: HashMap, + /// The last image saved, so "Show in folder" never takes a path from the page. + last_saved: Option, +} + +impl Recap { + /// Reads `path`. A file that cannot be used is renamed aside, never lost. + pub fn load(path: PathBuf, now: i64) -> Self { + let history = read_history(&path); + let mut recap = Self { + path, + history, + drafts: HashMap::new(), + requests: HashMap::new(), + last_saved: None, + }; + recap.prune(now); + recap + } + + /// One hook event, as the relay delivered it. + pub fn observe(&mut self, payload: &Value, now: i64) { + if !self.history.prefs.enabled { + return; + } + self.close_stale(now); + let event = text(payload, "hook_event_name"); + let agent = agent_id(payload); + let key = session_key(payload, &agent); + + match event { + "UserPromptSubmit" => { + self.draft(&key, &agent, payload, now); + } + "PreToolUse" => { + let tool = text(payload, "tool_name"); + let draft = self.draft(&key, &agent, payload, now); + if COMMAND_TOOLS.contains(&tool) { + draft.commands_run = draft.commands_run.saturating_add(1); + } + if tool == "AskUserQuestion" { + draft.questions = draft.questions.saturating_add(1); + } + } + "PostToolUse" => { + let tool = text(payload, "tool_name"); + let input = payload.get("tool_input").unwrap_or(&Value::Null); + let change = file_change(tool, input); + let draft = self.draft(&key, &agent, payload, now); + if let Some((path, added, removed)) = change { + draft.paths.insert(path); + draft.lines_added = draft.lines_added.saturating_add(added); + draft.lines_removed = draft.lines_removed.saturating_add(removed); + } + } + // Unlike the Mac, SessionEnd closes the turn instead of dropping it: + // a session quit mid-turn still worked. + "Stop" | "StopFailure" | "Interrupt" | "SessionEnd" => { + if let Some(draft) = self.drafts.remove(&key) { + self.history.turns.push(draft.into_turn(now)); + self.prune(now); + self.save(); + } + } + _ => {} + } + } + + /// The draft for `key`, created on the first event of a turn. + fn draft(&mut self, key: &str, agent: &str, payload: &Value, now: i64) -> &mut Draft { + if !self.drafts.contains_key(key) && self.drafts.len() >= MAX_DRAFTS { + self.close_oldest_draft(); + } + let draft = self.drafts.entry(key.to_string()).or_insert_with(|| Draft { + agent: agent.to_string(), + project: project_name(text(payload, "cwd")), + start: now, + last_event: now, + paths: HashSet::new(), + lines_added: 0, + lines_removed: 0, + commands_run: 0, + questions: 0, + }); + draft.last_event = now; + draft + } + + fn close_oldest_draft(&mut self) { + let Some(key) = self + .drafts + .iter() + .min_by_key(|(_, d)| d.last_event) + .map(|(k, _)| k.clone()) + else { + return; + }; + if let Some(draft) = self.drafts.remove(&key) { + let end = draft.last_event; + self.history.turns.push(draft.into_turn(end)); + } + } + + /// RecapStore.pruneStale: turns silent for two hours count up to their + /// last event instead of being lost. + fn close_stale(&mut self, now: i64) { + let stale: Vec = self + .drafts + .iter() + .filter(|(_, d)| d.last_event < now - STALE_SECS) + .map(|(k, _)| k.clone()) + .collect(); + if stale.is_empty() { + return; + } + for key in stale { + if let Some(draft) = self.drafts.remove(&key) { + let end = draft.last_event; + self.history.turns.push(draft.into_turn(end)); + } + } + self.prune(now); + self.save(); + } + + /// A permission request reached the island: remember whose it is. + pub fn note_request(&mut self, request_id: &str, payload: &Value) { + if self.requests.len() >= MAX_REQUESTS { + self.requests.clear(); + } + self.requests.insert(request_id.to_string(), agent_id(payload)); + } + + /// The request was handed back to the terminal: nothing to record. + pub fn forget_request(&mut self, request_id: &str) { + self.requests.remove(request_id); + } + + /// A click on Allow or Deny. + pub fn record_decision(&mut self, request_id: &str, decision: &str, now: i64) { + let agent = self + .requests + .remove(request_id) + .unwrap_or_else(|| "integration_claude".to_string()); + if !self.history.prefs.enabled { + return; + } + let word = match decision { + "allow" | "always" => "allow", + "deny" => "deny", + _ => return, + }; + self.history.decisions.push(Decision { agent, date: now, decision: word.to_string() }); + self.prune(now); + self.save(); + } + + /// Everything from `since` on (Unix seconds). + pub fn view(&self, since: i64) -> HistoryView { + HistoryView { + turns: self.history.turns.iter().filter(|t| t.start >= since).cloned().collect(), + decisions: self.history.decisions.iter().filter(|d| d.date >= since).cloned().collect(), + prefs: self.history.prefs.clone(), + } + } + + pub fn prefs(&self) -> &Prefs { + &self.history.prefs + } + + pub fn set_enabled(&mut self, enabled: bool) { + self.history.prefs.enabled = enabled; + if !enabled { + self.drafts.clear(); + self.requests.clear(); + } + self.save(); + } + + pub fn set_hide_projects(&mut self, hide: bool) { + self.history.prefs.hide_projects = hide; + self.save(); + } + + /// `week` is the Monday the recap was shown for, as YYYY-MM-DD. + pub fn mark_shown(&mut self, week: &str) { + if !is_week_key(week) { + return; + } + self.history.prefs.last_shown_week = week.to_string(); + self.save(); + } + + /// Settings → General → Clear history. The preferences stay. + pub fn clear(&mut self) { + self.history.turns.clear(); + self.history.decisions.clear(); + self.drafts.clear(); + self.save(); + } + + /// 12-week window, then the hard caps (oldest first out). + fn prune(&mut self, now: i64) { + let cutoff = now - WINDOW_SECS; + self.history.turns.retain(|t| t.start >= cutoff); + self.history.decisions.retain(|d| d.date >= cutoff); + if self.history.turns.len() > MAX_TURNS { + self.history.turns.sort_by_key(|t| t.start); + let excess = self.history.turns.len() - MAX_TURNS; + self.history.turns.drain(..excess); + } + if self.history.decisions.len() > MAX_DECISIONS { + self.history.decisions.sort_by_key(|d| d.date); + let excess = self.history.decisions.len() - MAX_DECISIONS; + self.history.decisions.drain(..excess); + } + } + + fn save(&self) { + if let Err(err) = write_history(&self.path, &self.history) { + note(format!("could not save the weekly recap history: {err}")); + } + } +} + +// ── Reading and writing recap.json ──────────────────────────────────────────── + +fn read_history(path: &Path) -> History { + let too_big = std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_FILE_BYTES); + let bytes = if too_big { + None + } else { + match std::fs::read(path) { + Ok(bytes) => Some(bytes), + Err(err) if err.kind() == ErrorKind::NotFound => return History::default(), + Err(err) => { + note(format!("can't read {}: {err}", path.display())); + // Unreadable is not corrupt: leave it, and start over in memory. + // The first save will replace it, which only costs old counts. + return History::default(); + } + } + }; + if let Some(history) = bytes.and_then(|b| serde_json::from_slice::(&b).ok()) { + return history; + } + // Keep the file as recap.json.corrupt-YYYYMMDD-HHMMSS, like the Mac. + let t = platform::local_time(); + let aside = path.with_extension(format!( + "json.corrupt-{:04}{:02}{:02}-{:02}{:02}{:02}", + t.year, t.month, t.day, t.hour, t.minute, t.second + )); + match std::fs::rename(path, &aside) { + Ok(()) => note(format!("{} was not usable — kept as {}", path.display(), aside.display())), + Err(err) => note(format!("{} was not usable and could not be set aside: {err}", path.display())), + } + History::default() +} + +/// Whole file to a temporary sibling, flushed, then renamed over the old one. +fn write_history(path: &Path, history: &History) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + platform::ensure_private_dir(dir)?; + } + let json = serde_json::to_vec(history) + .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e))?; + let temp = path.with_extension(format!("json.coucou-{}", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let written = options + .open(&temp) + .and_then(|mut file| { + file.write_all(&json)?; + file.sync_all() + }) + .and_then(|()| std::fs::rename(&temp, path)); + if written.is_err() { + let _ = std::fs::remove_file(&temp); + } + written +} + +/// One line in coucou.log. Tests must never write to the real one. +fn note(message: String) { + #[cfg(not(test))] + crate::log::line(message); + #[cfg(test)] + eprintln!("{message}"); +} + +// ── Reading a hook payload ──────────────────────────────────────────────────── + +fn text<'a>(payload: &'a Value, key: &str) -> &'a str { + payload.get(key).and_then(Value::as_str).unwrap_or_default() +} + +/// Same routing as hooks.ts: a valid `coucou_agent` → `agent_`, anything +/// else → the Claude Code pill. "claude" is reserved. +fn agent_id(payload: &Value) -> String { + let raw = text(payload, "coucou_agent"); + let valid = !raw.is_empty() + && raw.len() <= 24 + && raw != "claude" + && raw.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + if valid { format!("agent_{raw}") } else { "integration_claude".to_string() } +} + +/// Concurrent sessions are tracked apart; one without an ID is keyed by its +/// agent and folder, as HookServer.swift does. +fn session_key(payload: &Value, agent: &str) -> String { + let id = text(payload, "session_id"); + if id.is_empty() || id == "unknown" { + format!("{agent}+{}", text(payload, "cwd")) + } else { + id.to_string() + } +} + +/// The working folder's last component — the only part of a path ever kept. +fn project_name(cwd: &str) -> String { + let trimmed = cwd.trim_end_matches(['/', '\\']); + let last = trimmed.rsplit(['/', '\\']).next().unwrap_or_default(); + clean_name(last) +} + +fn clean_name(raw: &str) -> String { + raw.chars().filter(|c| !c.is_control()).take(MAX_NAME_CHARS).collect() +} + +/// The file an Edit, MultiEdit or Write touched, with lines added and removed. +fn file_change(tool: &str, input: &Value) -> Option<(String, u32, u32)> { + let path = input.get("file_path").and_then(Value::as_str)?; + let (added, removed) = match tool { + "Edit" => { + let old = input.get("old_string").and_then(Value::as_str)?; + let new = input.get("new_string").and_then(Value::as_str)?; + line_diff(old, new) + } + "MultiEdit" => { + let edits = input.get("edits").and_then(Value::as_array)?; + edits.iter().fold((0u32, 0u32), |(a, r), edit| { + let old = edit.get("old_string").and_then(Value::as_str).unwrap_or_default(); + let new = edit.get("new_string").and_then(Value::as_str).unwrap_or_default(); + let (da, dr) = line_diff(old, new); + (a.saturating_add(da), r.saturating_add(dr)) + }) + } + "Write" => { + let content = input.get("content").and_then(Value::as_str)?; + (count(split_lines(content).len()), 0) + } + _ => return None, + }; + (added > 0 || removed > 0).then(|| (path.to_string(), added, removed)) +} + +fn count(n: usize) -> u32 { + n.min(u32::MAX as usize) as u32 +} + +/// DiffEngine.splitLines: CRLF → LF, no trailing empty line. +fn split_lines(text: &str) -> Vec<&str> { + if text.is_empty() { + return Vec::new(); + } + let mut lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect(); + if lines.last() == Some(&"") { + lines.pop(); + } + lines +} + +/// Lines added and removed between `old` and `new`, from their longest common +/// subsequence — the counts DiffEngine.fromEdit gives. Past its size limits +/// every old line counts as removed and every new one as added. +pub fn line_diff(old: &str, new: &str) -> (u32, u32) { + let a = split_lines(old); + let b = split_lines(new); + let fallback = (count(b.len()), count(a.len())); + if old.len() + new.len() > DIFF_MAX_BYTES + || a.len() + b.len() > DIFF_MAX_LINES + || a.len().saturating_mul(b.len()) > DIFF_MAX_CELLS + { + return fallback; + } + let mut prev = vec![0usize; b.len() + 1]; + let mut row = vec![0usize; b.len() + 1]; + for x in &a { + for (j, y) in b.iter().enumerate() { + row[j + 1] = if x == y { prev[j] + 1 } else { prev[j + 1].max(row[j]) }; + } + std::mem::swap(&mut prev, &mut row); + } + let lcs = prev[b.len()]; + (count(b.len() - lcs), count(a.len() - lcs)) +} + +fn is_week_key(week: &str) -> bool { + week.len() == 10 + && week.bytes().enumerate().all(|(i, b)| if i == 4 || i == 7 { b == b'-' } else { b.is_ascii_digit() }) +} + +// ── Saving the shared image ─────────────────────────────────────────────────── + +/// Standard base64, with or without a `data:…;base64,` prefix. +fn decode_base64(data: &str) -> Option> { + let body = match data.split_once(',') { + Some((head, rest)) if head.starts_with("data:") => rest, + _ => data, + }; + let mut out = Vec::with_capacity(body.len() / 4 * 3); + let mut acc = 0u32; + let mut bits = 0u32; + let mut padding = false; + for b in body.bytes() { + let v = match b { + b'A'..=b'Z' => b - b'A', + b'a'..=b'z' => b - b'a' + 26, + b'0'..=b'9' => b - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' => { + padding = true; + continue; + } + b'\r' | b'\n' => continue, + _ => return None, + }; + if padding { + return None; + } + acc = (acc << 6) | u32::from(v); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) +} + +const PNG_SIGNATURE: &[u8] = &[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1A, b'\n']; + +/// Writes `bytes` as `.png` in `dir`, or ` (2).png`… when that name +/// is taken. An existing file is never replaced. +fn write_unique(dir: &Path, stem: &str, bytes: &[u8]) -> std::io::Result { + for n in 1..1000 { + let name = if n == 1 { format!("{stem}.png") } else { format!("{stem} ({n}).png") }; + let path = dir.join(name); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(mut file) => { + let written = file.write_all(bytes).and_then(|()| file.sync_all()); + drop(file); + if let Err(err) = written { + let _ = std::fs::remove_file(&path); + return Err(err); + } + return Ok(path); + } + Err(err) if err.kind() == ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(std::io::Error::new(ErrorKind::AlreadyExists, "too many recap images with this name")) +} + +/// The PNG the page rendered, checked and written to the first pictures folder +/// that exists. +fn save_png(dirs: &[PathBuf], data: &str, week: &str) -> Result { + if data.len() > MAX_PNG_BYTES / 3 * 4 + 64 { + return Err("The image is too large.".into()); + } + let bytes = decode_base64(data).ok_or("The image could not be read.")?; + if !bytes.starts_with(PNG_SIGNATURE) { + return Err("The image could not be read.".into()); + } + let dir = dirs.iter().find(|d| d.is_dir()).ok_or("No Pictures or Downloads folder to save into.")?; + let stem = if is_week_key(week) { + format!("Coucou weekly recap {week}") + } else { + "Coucou weekly recap".to_string() + }; + write_unique(dir, &stem, &bytes).map_err(|err| format!("Could not save the image: {err}")) +} + +// ── Tauri glue ──────────────────────────────────────────────────────────────── + +pub struct Store(Mutex); + +impl Store { + fn lock(&self) -> MutexGuard<'_, Recap> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +fn now() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0) +} + +pub fn load() -> Store { + Store(Mutex::new(Recap::load(platform::local_dir().join("recap.json"), now()))) +} + +/// Called by the relay server for every hook event. An agent starting work is +/// also one of the moments the Monday recap may show up. +pub fn observe(app: &AppHandle, payload: &Value) { + if let Some(store) = app.try_state::() { + store.lock().observe(payload, now()); + } + if matches!(text(payload, "hook_event_name"), "SessionStart" | "UserPromptSubmit") { + let _ = app.emit_to(WINDOW_LABEL, "recap-check", ()); + } +} + +pub fn note_request(app: &AppHandle, request_id: &str, payload: &Value) { + if let Some(store) = app.try_state::() { + store.lock().note_request(request_id, payload); + } +} + +pub fn forget_request(app: &AppHandle, request_id: &str) { + if let Some(store) = app.try_state::() { + store.lock().forget_request(request_id); + } +} + +pub fn record_decision(app: &AppHandle, request_id: &str, decision: &str) { + if let Some(store) = app.try_state::() { + store.lock().record_decision(request_id, decision, now()); + } +} + +#[tauri::command] +pub fn recap_history(store: State, since: i64) -> HistoryView { + store.lock().view(since) +} + +#[tauri::command] +pub fn recap_prefs(store: State) -> Prefs { + store.lock().prefs().clone() +} + +#[tauri::command] +pub fn recap_set_enabled(store: State, enabled: bool) { + store.lock().set_enabled(enabled); +} + +#[tauri::command] +pub fn recap_set_hide_projects(store: State, hide: bool) { + store.lock().set_hide_projects(hide); +} + +#[tauri::command] +pub fn recap_mark_shown(store: State, week: String) { + store.lock().mark_shown(&week); +} + +#[tauri::command] +pub fn recap_clear(store: State) { + store.lock().clear(); +} + +/// `data` is the canvas's PNG as a data URL. Returns where it was written. +#[tauri::command] +pub fn recap_save_png(store: State, data: String, week: String) -> Result { + let path = save_png(&platform::picture_dirs(), &data, &week)?; + let shown = path.to_string_lossy().to_string(); + store.lock().last_saved = Some(path); + Ok(shown) +} + +/// Opens the folder of the image saved last — never a path from the page. +#[tauri::command] +pub fn recap_reveal_saved(store: State) { + let dir = store.lock().last_saved.as_ref().and_then(|p| p.parent().map(Path::to_path_buf)); + if let Some(dir) = dir { + platform::reveal_folder(&dir.to_string_lossy()); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const HOUR: i64 = 3600; + /// Monday 5 October 2026, 00:00 UTC — the exact zone does not matter here. + const T0: i64 = 1_791_158_400; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("coucou-recap-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn event(name: &str, session: &str, extra: Value) -> Value { + let mut v = json!({ "hook_event_name": name, "session_id": session, "cwd": "/home/me/code/coucou" }); + if let (Some(obj), Value::Object(more)) = (v.as_object_mut(), extra) { + obj.extend(more); + } + v + } + + fn on_disk(path: &Path) -> Value { + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() + } + + #[test] + fn a_turn_is_counted_from_prompt_to_stop() { + let dir = scratch("turn"); + let file = dir.join("recap.json"); + let mut r = Recap::load(file.clone(), T0); + r.observe(&event("UserPromptSubmit", "s1", json!({ "prompt": "secret plan" })), T0); + r.observe(&event("PreToolUse", "s1", json!({ "tool_name": "Bash", "tool_input": { "command": "rm -rf /tmp/x" } })), T0 + 10); + r.observe(&event("PreToolUse", "s1", json!({ "tool_name": "Read" })), T0 + 20); + r.observe(&event("PreToolUse", "s1", json!({ "tool_name": "AskUserQuestion" })), T0 + 25); + r.observe(&event("PostToolUse", "s1", json!({ "tool_name": "Edit", "tool_input": { + "file_path": "/home/me/code/coucou/a.rs", "old_string": "a\nb\nc", "new_string": "a\nB\nc\nd" } })), T0 + 30); + r.observe(&event("PostToolUse", "s1", json!({ "tool_name": "Write", "tool_input": { + "file_path": "/home/me/code/coucou/b.rs", "content": "one\ntwo\nthree\n" } })), T0 + 40); + r.observe(&event("PostToolUse", "s1", json!({ "tool_name": "Edit", "tool_input": { + "file_path": "/home/me/code/coucou/a.rs", "old_string": "x", "new_string": "y" } })), T0 + 50); + r.observe(&event("Stop", "s1", json!({})), T0 + HOUR); + + let view = r.view(0); + assert_eq!(view.turns, vec![Turn { + agent: "integration_claude".into(), + project: "coucou".into(), + start: T0, + end: T0 + HOUR, + files_changed: 2, + lines_added: 2 + 3 + 1, + lines_removed: 1 + 1, + commands_run: 1, + questions: 1, + }]); + + // Only counts and names reach the disk. + let written = std::fs::read_to_string(&file).unwrap(); + for secret in ["secret plan", "rm -rf", "a.rs", "/home/me", "one\\ntwo"] { + assert!(!written.contains(secret), "{secret} must not be stored"); + } + assert_eq!(on_disk(&file)["turns"][0]["project"], "coucou"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn concurrent_sessions_and_agents_are_kept_apart() { + let dir = scratch("sessions"); + let mut r = Recap::load(dir.join("recap.json"), T0); + r.observe(&event("UserPromptSubmit", "a", json!({})), T0); + r.observe(&event("UserPromptSubmit", "b", json!({ "coucou_agent": "gemini", "cwd": "C:\\Users\\me\\side-project\\" })), T0 + 60); + r.observe(&event("PreToolUse", "b", json!({ "coucou_agent": "gemini", "tool_name": "run_shell_command" })), T0 + 70); + r.observe(&event("Stop", "a", json!({})), T0 + 120); + r.observe(&event("Stop", "b", json!({ "coucou_agent": "gemini" })), T0 + 180); + let turns = r.view(0).turns; + assert_eq!(turns.len(), 2); + assert_eq!((turns[0].agent.as_str(), turns[0].commands_run), ("integration_claude", 0)); + assert_eq!(turns[1].agent, "agent_gemini"); + assert_eq!(turns[1].project, "side-project"); + assert_eq!(turns[1].commands_run, 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn invalid_or_reserved_agent_tags_fall_back_to_claude_code() { + for tag in ["claude", "Gemini", "has space", "a-very-long-agent-name-over-24"] { + assert_eq!(agent_id(&json!({ "coucou_agent": tag })), "integration_claude", "{tag}"); + } + assert_eq!(agent_id(&json!({ "coucou_agent": "codex" })), "agent_codex"); + } + + #[test] + fn a_turn_silent_for_two_hours_ends_at_its_last_event() { + let dir = scratch("stale"); + let mut r = Recap::load(dir.join("recap.json"), T0); + r.observe(&event("UserPromptSubmit", "s1", json!({})), T0); + r.observe(&event("PreToolUse", "s1", json!({ "tool_name": "Bash" })), T0 + 600); + // Next event, from another session, three hours later. + r.observe(&event("UserPromptSubmit", "s2", json!({})), T0 + 3 * HOUR); + let turns = r.view(0).turns; + assert_eq!(turns.len(), 1); + assert_eq!((turns[0].start, turns[0].end), (T0, T0 + 600)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn decisions_are_recorded_with_the_agent_that_asked() { + let dir = scratch("decisions"); + let mut r = Recap::load(dir.join("recap.json"), T0); + r.note_request("r1", &json!({ "coucou_agent": "codex" })); + r.record_decision("r1", "always", T0); + r.record_decision("unknown", "deny", T0 + 1); + r.record_decision("r3", "maybe", T0 + 2); + r.note_request("r4", &json!({})); + r.forget_request("r4"); + let d = r.view(0).decisions; + assert_eq!(d.len(), 2); + assert_eq!((d[0].agent.as_str(), d[0].decision.as_str()), ("agent_codex", "allow")); + assert_eq!((d[1].agent.as_str(), d[1].decision.as_str()), ("integration_claude", "deny")); + assert!(r.requests.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn twelve_weeks_are_kept_and_older_entries_pruned() { + let dir = scratch("prune"); + let file = dir.join("recap.json"); + let mut r = Recap::load(file.clone(), T0); + let old = T0 - WINDOW_SECS - 1; + let edge = T0 - WINDOW_SECS; + for start in [old, edge, T0 - HOUR] { + r.history.turns.push(Turn { + agent: "integration_claude".into(), project: "p".into(), start, end: start + 60, + files_changed: 0, lines_added: 0, lines_removed: 0, commands_run: 0, questions: 0, + }); + } + r.history.decisions.push(Decision { agent: "integration_claude".into(), date: old, decision: "allow".into() }); + r.prune(T0); + r.save(); + assert_eq!(r.view(0).turns.iter().map(|t| t.start).collect::>(), [edge, T0 - HOUR]); + assert!(r.view(0).decisions.is_empty()); + // Loading later prunes again. + let later = Recap::load(file, T0 + 2 * HOUR); + assert_eq!(later.view(0).turns.len(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_history_never_grows_past_its_cap() { + let dir = scratch("cap"); + let mut r = Recap::load(dir.join("recap.json"), T0); + for i in 0..(MAX_TURNS as i64 + 5) { + r.history.turns.push(Turn { + agent: "a".into(), project: "p".into(), start: T0 - i, end: T0, + files_changed: 0, lines_added: 0, lines_removed: 0, commands_run: 0, questions: 0, + }); + } + r.prune(T0); + assert_eq!(r.history.turns.len(), MAX_TURNS); + // The oldest went. + assert_eq!(r.history.turns.first().unwrap().start, T0 - MAX_TURNS as i64 + 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn names_are_cut_short_and_cleaned() { + let long = "x".repeat(200); + assert_eq!(project_name(&format!("/a/{long}")).chars().count(), MAX_NAME_CHARS); + assert_eq!(project_name("/a/b\u{7}c/"), "bc"); + assert_eq!(project_name(""), ""); + assert_eq!(project_name("C:\\work\\korus"), "korus"); + } + + #[test] + fn line_counts_match_the_diff_engine() { + assert_eq!(line_diff("a\nb\nc", "a\nb\nc"), (0, 0)); + assert_eq!(line_diff("a\nb\nc", "a\nx\nc"), (1, 1)); + assert_eq!(line_diff("", "a\nb\n"), (2, 0)); + assert_eq!(line_diff("a\r\nb\r\n", "a\nb\nc\n"), (1, 0)); + // Too big to diff: everything counts. + let big_old = "l\n".repeat(3000); + let big_new = "l\n".repeat(1500); + assert_eq!(line_diff(&big_old, &big_new), (1500, 3000)); + assert_eq!(file_change("Read", &json!({ "file_path": "/x" })), None); + assert_eq!(file_change("Edit", &json!({ "file_path": "/x", "old_string": "a", "new_string": "a" })), None); + let multi = json!({ "file_path": "/x", "edits": [ + { "old_string": "a", "new_string": "b" }, { "old_string": "", "new_string": "c\nd" } ] }); + assert_eq!(file_change("MultiEdit", &multi), Some(("/x".into(), 3, 1))); + } + + #[test] + fn a_disabled_history_records_nothing_and_clear_keeps_the_prefs() { + let dir = scratch("disabled"); + let file = dir.join("recap.json"); + let mut r = Recap::load(file.clone(), T0); + r.observe(&event("UserPromptSubmit", "s", json!({})), T0); + r.observe(&event("Stop", "s", json!({})), T0 + 60); + r.set_hide_projects(true); + r.mark_shown("2026-10-05"); + r.mark_shown("not a week"); + r.clear(); + assert!(r.view(0).turns.is_empty()); + let reloaded = Recap::load(file.clone(), T0); + assert_eq!(reloaded.prefs(), &Prefs { enabled: true, hide_projects: true, last_shown_week: "2026-10-05".into() }); + + r.set_enabled(false); + r.observe(&event("UserPromptSubmit", "s", json!({})), T0); + r.observe(&event("Stop", "s", json!({})), T0 + 60); + r.note_request("r", &json!({})); + r.record_decision("r", "allow", T0); + assert!(r.view(0).turns.is_empty() && r.view(0).decisions.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_corrupt_file_is_set_aside_and_writes_leave_nothing_behind() { + let dir = scratch("corrupt"); + let file = dir.join("recap.json"); + std::fs::write(&file, b"{ not json").unwrap(); + let mut r = Recap::load(file.clone(), T0); + assert!(r.view(0).turns.is_empty()); + let names = |d: &Path| -> Vec { + let mut n: Vec = std::fs::read_dir(d).unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); + n.sort(); + n + }; + let aside = names(&dir); + assert_eq!(aside.len(), 1); + assert!(aside[0].starts_with("recap.json.corrupt-"), "{aside:?}"); + assert_eq!(std::fs::read(dir.join(&aside[0])).unwrap(), b"{ not json"); + + r.observe(&event("UserPromptSubmit", "s", json!({})), T0); + r.observe(&event("Stop", "s", json!({})), T0 + 60); + let mut expected = vec!["recap.json".to_string(), aside[0].clone()]; + expected.sort(); + assert_eq!(names(&dir), expected); + assert_eq!(on_disk(&file)["schemaVersion"], 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn base64_round_trips_and_rejects_junk() { + assert_eq!(decode_base64("data:image/png;base64,aGVsbG8=").unwrap(), b"hello"); + assert_eq!(decode_base64("aGk=").unwrap(), b"hi"); + assert_eq!(decode_base64("YWJj").unwrap(), b"abc"); + assert!(decode_base64("a$b").is_none()); + assert!(decode_base64("aGk=aGk=").is_none()); + } + + #[test] + fn a_saved_image_never_replaces_an_existing_file() { + let dir = scratch("png"); + let missing = dir.join("missing"); + let png = "data:image/png;base64,iVBORw0KGgo="; + let first = save_png(&[missing.clone(), dir.clone()], png, "2026-10-05").unwrap(); + let second = save_png(&[dir.clone()], png, "2026-10-05").unwrap(); + assert_eq!(first, dir.join("Coucou weekly recap 2026-10-05.png")); + assert_eq!(second, dir.join("Coucou weekly recap 2026-10-05 (2).png")); + // The week is a date or nothing: no path tricks through it. + let third = save_png(&[dir.clone()], png, "../../x").unwrap(); + assert_eq!(third, dir.join("Coucou weekly recap.png")); + // Not a PNG: refused. + assert!(save_png(&[dir.clone()], "data:image/png;base64,aGVsbG8=", "").is_err()); + assert!(save_png(&[missing], png, "").is_err()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/windows/src-tauri/src/tray.rs b/windows/src-tauri/src/tray.rs index f6aa0c8fc..5be80b189 100644 --- a/windows/src-tauri/src/tray.rs +++ b/windows/src-tauri/src/tray.rs @@ -1,4 +1,4 @@ -// Notification-area icon: Open, Settings, Pause, Quit. +// Notification-area icon: Open, Weekly recap, Settings, Pause, Quit. use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIconBuilder; @@ -8,13 +8,14 @@ use crate::island::WINDOW_LABEL; pub fn build(app: &AppHandle) -> tauri::Result<()> { let open = MenuItem::with_id(app, "open", "Open Coucou", true, None::<&str>)?; + let recap = MenuItem::with_id(app, "recap", "Weekly recap", true, None::<&str>)?; let settings = MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?; let pause = MenuItem::with_id(app, "pause", "Pause", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let sep1 = PredefinedMenuItem::separator(app)?; let sep2 = PredefinedMenuItem::separator(app)?; - let menu = Menu::with_items(app, &[&open, &sep1, &settings, &pause, &sep2, &quit])?; + let menu = Menu::with_items(app, &[&open, &recap, &sep1, &settings, &pause, &sep2, &quit])?; let mut builder = TrayIconBuilder::with_id("coucou") .tooltip("Coucou") From 0f4edf1f9600aab071a15dde5c94b7dd3fc31cd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:42 +0200 Subject: [PATCH 15/77] Windows/Linux: GitHub pulse card, alerts and contribution grid The GitHub card follows GitHubPulseCardView once the pulse is in: stars and the last 7 days of contributions in the header, then My PRs, To review and Default branch CI. Each row opens its list; an item opens on github.com (http/https to github.com only). The header opens the past 23 weeks, and a day shows its count on hover or click. Without a pulse the card stays the existing stars + repositories overview. github-alerts from Rust badge the pill (when it isn't on screen) and play the Mac's sound: error for red CI, question for a review request, finish for green CI. Opening the card asks Rust to refresh stale data. The open button now goes to github.com/pulls, as on macOS. Co-Authored-By: Claude Opus 5.5 --- windows/src/core/bridge.ts | 2 + windows/src/core/github.ts | 312 +++++++++++++++++++++++++++++ windows/src/island/integrations.ts | 31 +++ windows/src/island/island.ts | 2 +- windows/src/style.css | 172 ++++++++++++++++ windows/src/views/dom.ts | 8 +- windows/src/views/github.ts | 208 +++++++++++++++++++ windows/src/views/icons.ts | 8 + windows/src/views/integrations.ts | 15 +- windows/tests/github.test.mjs | 212 ++++++++++++++++++++ 10 files changed, 967 insertions(+), 3 deletions(-) create mode 100644 windows/src/core/github.ts create mode 100644 windows/src/views/github.ts create mode 100644 windows/tests/github.test.mjs diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..4831060f0 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -94,6 +94,8 @@ export const Bridge = { // ── Integrations ────────────────────────────────────────────────────────── refreshIntegration: (id: string) => call("refresh_integration", { id }), + /** The GitHub card is on screen: refetch that part if it is stale. */ + githubRefresh: (section: "pulse" | "activity") => call("github_refresh", { section }), /** Opens the configured n8n instance in the browser. */ openN8n: () => call("open_n8n"), diff --git a/windows/src/core/github.ts b/windows/src/core/github.ts new file mode 100644 index 000000000..4e362dada --- /dev/null +++ b/windows/src/core/github.ts @@ -0,0 +1,312 @@ +// GitHub pulse and contribution grid — the pure half of the GitHub card. +// Port of the helpers around GitHubPulseCardView, GitHubDetailView and +// GitHubActivityDetailContent (IslandViewContent.swift), GitHubActivity.swift and +// AppState.handleGitHubEvents. No DOM here, so all of it is tested in Node. +// +// Parsing and alert detection happen in Rust (src-tauri/src/github.rs); this +// reads what Rust sends and turns it into what the card shows. + +export type CIState = "pending" | "success" | "failure" | "unknown"; +export type ReviewState = "approved" | "changesRequested" | "pending" | "unknown"; +export type GitHubSection = "myPRs" | "toReview" | "mainCI" | "activity"; + +export interface GitHubPR { + /** "owner/repo#number" */ + id: string; + title: string; + url: string; + repo: string; + number: number; + isDraft: boolean; + ci: CIState; + review: ReviewState; + headSha: string | null; +} + +export interface GitHubRepoCI { + repo: string; + url: string; + branch: string; + ci: CIState; + headSha: string | null; +} + +export interface GitHubPulse { + login: string; + myPRs: GitHubPR[]; + toReview: GitHubPR[]; + mainCI: GitHubRepoCI[]; + fetchedAt: number; +} + +export interface ContributionDay { + /** YYYY-MM-DD */ + date: string; + count: number; + /** 0…4 */ + level: number; + /** 0 = Sunday … 6 = Saturday */ + weekday: number; +} + +export interface GitHubActivity { + total: number; + /** Oldest week first; the last one is usually incomplete. */ + weeks: ContributionDay[][]; + fetchedAt: number; +} + +export interface GitHubStats { + totalRepos: number; + totalStars: number; +} + +export type GitHubEvent = + | { kind: "ciFailed"; prId: string } + | { kind: "ciPassed"; prId: string } + | { kind: "mainFailed"; repo: string } + | { kind: "reviewRequested"; prId: string }; + +/** Every word the GitHub card shows, in one place for the translation layer. */ +export const GH_STRINGS = { + title: "GitHub", + overview: "Overview", + totalStars: "Total stars", + repositories: "Repositories", + myPRs: "My PRs", + toReview: "To review", + mainCI: "Default branch CI", + activity: "Activity", + nothingHere: "Nothing here", + loading: "Loading…", + draft: "Draft", + failing: "failing", + running: "running", + passing: "passing", + allGreen: "all green", + noRepos: "no repos", + unknown: "unknown", + noContributions: "No contributions", + oneContribution: "1 contribution", + contributions: (n: number) => `${n} contributions`, + pastYear: (total: string) => `${total} past year`, + repos: (n: number) => `${n} repos`, +} as const; + +export const CI_COLORS: Record = { + failure: "#F4505E", + pending: "#F5A524", + success: "#22C55E", + unknown: "#6B7079", +}; + +/** contributionColor() — GitHub's dark-theme greens, empty days barely visible. */ +export function contributionColor(level: number): string { + switch (level) { + case 1: return "#0E4429"; + case 2: return "#006D32"; + case 3: return "#26A641"; + case 4: return "#39D353"; + default: return "rgba(255,255,255,0.06)"; + } +} + +// ── Reading what Rust sent ──────────────────────────────────────────────────── + +const isObj = (v: unknown): v is Record => typeof v === "object" && v != null; + +export function readPulse(data: Record): GitHubPulse | null { + const p = data.pulse; + if (!isObj(p) || !Array.isArray(p.myPRs) || !Array.isArray(p.toReview) || !Array.isArray(p.mainCI)) { + return null; + } + return p as unknown as GitHubPulse; +} + +export function readActivity(data: Record): GitHubActivity | null { + const a = data.activity; + if (!isObj(a) || !Array.isArray(a.weeks) || typeof a.total !== "number") return null; + return a as unknown as GitHubActivity; +} + +export function readStats(data: Record): GitHubStats | null { + if (data.totalRepos == null) return null; + return { totalRepos: Number(data.totalRepos ?? 0), totalStars: Number(data.totalStars ?? 0) }; +} + +// ── Card summaries ──────────────────────────────────────────────────────────── + +/** ciWorstState / mainCIWorst: failure > pending > success > unknown. */ +export function worstCI(states: CIState[]): CIState { + if (states.includes("failure")) return "failure"; + if (states.includes("pending")) return "pending"; + if (states.includes("success")) return "success"; + return "unknown"; +} + +/** "My PRs" value: count, plus how many fail or that some are still running. */ +export function myPRsValue(prs: GitHubPR[]): string { + const n = prs.length; + if (n === 0) return "0"; + const failing = prs.filter((p) => p.ci === "failure").length; + if (failing > 0) return `${n} · ${failing} ${GH_STRINGS.failing}`; + if (prs.some((p) => p.ci === "pending")) return `${n} · ${GH_STRINGS.running}`; + return String(n); +} + +/** The "Default branch CI" row: which icon, its colour and the value. */ +export function mainCISummary(repos: GitHubRepoCI[]): { failing: boolean; color: string; value: string } { + const worst = worstCI(repos.map((r) => r.ci)); + switch (worst) { + case "failure": { + const n = repos.filter((r) => r.ci === "failure").length; + return { failing: true, color: CI_COLORS.failure, value: `${n} ${GH_STRINGS.failing}` }; + } + case "pending": + return { failing: false, color: CI_COLORS.pending, value: GH_STRINGS.running }; + case "success": + return { failing: false, color: CI_COLORS.success, value: GH_STRINGS.allGreen }; + default: + return { + failing: false, + color: CI_COLORS.unknown, + value: repos.length === 0 ? GH_STRINGS.noRepos : GH_STRINGS.unknown, + }; + } +} + +/** The word at the end of a default-branch row; nothing when unknown. */ +export function ciWord(ci: CIState): string | null { + switch (ci) { + case "failure": return GH_STRINGS.failing; + case "pending": return GH_STRINGS.running; + case "success": return GH_STRINGS.passing; + default: return null; + } +} + +export function sectionTitle(section: GitHubSection): string { + return GH_STRINGS[section]; +} + +/** "1.2k" above a thousand, like the Mac's formatCount. */ +export function formatCount(n: number): string { + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); +} + +/** "owner/repo" → "repo". */ +export function shortRepo(repo: string): string { + return repo.split("/").pop() || repo; +} + +// ── Links ───────────────────────────────────────────────────────────────────── + +/** Only http(s) links to github.com open, like safeWebURL + the host check on macOS. */ +export function safeGitHubUrl(raw: string): string | null { + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + if (url.hostname !== "github.com") return null; + return url.toString(); +} + +/** A repository's Actions page, where a red default branch is explained. */ +export function actionsUrl(repoUrl: string): string { + return repoUrl.endsWith("/") ? `${repoUrl}actions` : `${repoUrl}/actions`; +} + +export function profileUrl(login: string): string { + return `https://github.com/${encodeURIComponent(login)}`; +} + +// ── Contribution grid ───────────────────────────────────────────────────────── + +/** 7 pt squares, 1.5 pt apart, in the 202 pt the card has: 23 weeks. */ +export const GRID_SQUARE = 7; +export const GRID_GAP = 1.5; +export const GRID_WEEKS = Math.floor((202 + GRID_GAP) / (GRID_SQUARE + GRID_GAP)); + +/** The last `n` weeks, oldest first; the last may be incomplete. */ +export function lastWeeks(activity: GitHubActivity, n: number): ContributionDay[][] { + if (n <= 0) return []; + return activity.weeks.slice(Math.max(0, activity.weeks.length - n)); +} + +/** The last `n` days, oldest first. */ +export function lastDays(activity: GitHubActivity, n: number): ContributionDay[] { + if (n <= 0) return []; + const all = activity.weeks.flat(); + return all.slice(Math.max(0, all.length - n)); +} + +/** One column of the grid: seven slots, Sunday first, null where a day is missing. */ +export function weekColumn(week: ContributionDay[]): (ContributionDay | null)[] { + return Array.from({ length: 7 }, (_, dow) => week.find((d) => d.weekday === dow) ?? null); +} + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** "2026-01-05" → "Jan 5"; anything else is returned as is. */ +export function dayLabel(date: string): string { + const parts = date.split("-"); + if (parts.length !== 3) return date; + const month = Number(parts[1]); + const day = Number(parts[2]); + if (!Number.isInteger(month) || month < 1 || month > 12 || !Number.isInteger(day)) return date; + return `${MONTHS[month - 1]} ${day}`; +} + +export function contributionsLabel(count: number): string { + if (count === 0) return GH_STRINGS.noContributions; + if (count === 1) return GH_STRINGS.oneContribution; + return GH_STRINGS.contributions(count); +} + +/** 1234 → "1,234", whatever the system locale. */ +export function totalLabel(n: number): string { + return n.toLocaleString("en-US"); +} + +/** Right side of the Activity header: the picked day, or the year's total. */ +export function activityHeader( + activity: GitHubActivity | null, + stats: GitHubStats | null, + picked: ContributionDay | null, +): string { + if (picked) return `${dayLabel(picked.date)} · ${contributionsLabel(picked.count)}`; + if (!activity) return ""; + const total = GH_STRINGS.pastYear(totalLabel(activity.total)); + return stats ? `${total} · ${GH_STRINGS.repos(stats.totalRepos)}` : total; +} + +// ── Alerts ──────────────────────────────────────────────────────────────────── + +export interface GitHubAlert { + badge: "error" | "finished"; + sound: "error" | "question" | "finish"; +} + +/** handleGitHubEvents: the loudest event wins — red CI > review request > green CI. */ +export function gitHubAlert(events: GitHubEvent[]): GitHubAlert | null { + let level = 0; + let alert: GitHubAlert | null = null; + for (const e of events) { + switch (e.kind) { + case "ciFailed": + case "mainFailed": + if (level < 3) { level = 3; alert = { badge: "error", sound: "error" }; } + break; + case "reviewRequested": + if (level < 2) { level = 2; alert = { badge: "finished", sound: "question" }; } + break; + case "ciPassed": + if (level < 1) { level = 1; alert = { badge: "finished", sound: "finish" }; } + break; + } + } + return alert; +} diff --git a/windows/src/island/integrations.ts b/windows/src/island/integrations.ts index 862ffb14b..3e59dde7e 100644 --- a/windows/src/island/integrations.ts +++ b/windows/src/island/integrations.ts @@ -3,10 +3,13 @@ // the pill isn't focused, plays a sound, and clears itself after 60 s. import { onEvent, Bridge, type IntegrationUpdate } from "../core/bridge"; +import { gitHubAlert, type GitHubEvent } from "../core/github"; import { Sound } from "../core/sound"; import { State } from "../core/state"; import type { Island } from "./island"; +const GITHUB = "integration_github"; + /** Which Credential Manager key backs each pill. */ const KEY_FOR: Record = { integration_stripe: "stripe-api-key", @@ -22,7 +25,35 @@ const clearTimers = new Map(); export function registerIntegrationHandlers(island: Island) { void onEvent("integration", (update) => handle(island, update)); + void onEvent("github-alerts", handleGitHubAlerts); void refreshConfigured(); + State.subscribe(refreshGitHubWhenShown); +} + +/** + * AppState.handleGitHubEvents: the loudest event sets the badge — only while the + * GitHub pill isn't the one on screen — and plays its sound. Unlike the other + * integrations it leaves Mochi's state alone and the badge stays until the pill + * is focused, as on macOS. + */ +export function handleGitHubAlerts(events: GitHubEvent[]) { + if (State.paused) return; + const alert = gitHubAlert(events); + if (!alert) return; + const task = State.tasks.find((t) => t.id === GITHUB); + if (!task) return; + if (State.focusId !== GITHUB) task.pillBadge = alert.badge; + Sound.play(alert.sound); + State.notify(); +} + +let gitHubShown = false; + +/** The GitHub card just came on screen (focused, island opened): refresh it if stale. */ +function refreshGitHubWhenShown() { + const shown = State.mode === "expanded" && State.focusTask?.id === GITHUB; + if (shown && !gitHubShown) void Bridge.githubRefresh("pulse"); + gitHubShown = shown; } /** Asks Rust which keys exist so the idle cards can say so. */ diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb61802..0bc7f58b5 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -122,7 +122,7 @@ export class Island { const urls: Record = { integration_resend: "https://resend.com/emails", integration_vercel: "https://vercel.com/dashboard", - integration_github: "https://github.com", + integration_github: "https://github.com/pulls", integration_stripe: "https://dashboard.stripe.com/payments", integration_notion: "https://notion.so", integration_calcom: "https://app.cal.com/bookings", diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c..7e4134f81 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -1365,3 +1365,175 @@ input[type="range"]::-webkit-slider-thumb { .int-card.detail { padding-top: 8px; } + +/* ── GitHub pulse (GitHubPulseCardView / GitHubDetailView) ─────────────── */ + +.gh-spark { + display: inline-flex; + align-items: center; + gap: 4px; + border: 0; + padding: 0; + background: transparent; + font: 400 11px var(--font); + color: var(--dim-2); + white-space: nowrap; + cursor: default; + min-width: 0; +} + +.gh-mini { + display: inline-flex; + gap: 2px; +} + +.gh-mini i, +.gh-cell { + width: 7px; + height: 7px; + border-radius: 1.5px; + flex: 0 0 auto; +} + +.int-stats.gh-rows { + gap: 4px; + padding-top: 6px; +} + +.gh-stat { + border: 0; + padding: 0; + background: transparent; + width: 100%; + text-align: left; + cursor: default; +} + +.gh-detail-head { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.gh-back { + display: inline-flex; + align-items: center; + gap: 3px; + border: 0; + padding: 0; + background: transparent; + font: 600 12px var(--font); + color: var(--ink); + white-space: nowrap; + cursor: default; + flex: 0 0 auto; +} + +.gh-head-right { + margin-left: auto; + border: 0; + padding: 0; + background: transparent; + font: 400 11px var(--font); + color: var(--dim-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + cursor: default; +} + +.gh-list { + display: flex; + flex-direction: column; + max-height: 60px; /* three 20 px rows; the rest scrolls */ + overflow-y: auto; + margin-top: 4px; + scrollbar-width: none; +} + +.gh-list::-webkit-scrollbar { + display: none; +} + +.gh-list.fade { + -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 80%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, #000 80%, transparent 100%); +} + +.gh-row { + display: flex; + align-items: center; + gap: 5px; + height: 20px; + flex: 0 0 20px; + padding: 0 4px 0 0; + border: 0; + border-radius: 4px; + background: transparent; + text-align: left; + cursor: default; + min-width: 0; +} + +.gh-row:hover { + background: rgba(255, 255, 255, 0.05); +} + +.gh-gap { + width: 5px; + flex: 0 0 5px; +} + +.gh-ref { + font: 400 10.5px var(--font); + color: #9398a1; + white-space: nowrap; + flex: 0 0 auto; +} + +.gh-title { + font: 400 11px var(--font); + color: #c5c8cd; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.gh-draft { + font: 400 9.5px var(--font); + color: var(--dim-3); + flex: 0 0 auto; +} + +.gh-word { + margin-left: auto; + padding-left: 4px; + font: 400 10px var(--font); + white-space: nowrap; + flex: 0 0 auto; +} + +.gh-empty { + font: 400 10.5px var(--font); + color: var(--dim-3); + padding-top: 8px; +} + +.gh-grid { + display: flex; + gap: 1.5px; + padding-top: 5px; +} + +.gh-week { + display: flex; + flex-direction: column; + gap: 1.5px; +} + +.gh-cell.empty { + background: transparent; +} diff --git a/windows/src/views/dom.ts b/windows/src/views/dom.ts index 665751157..125660391 100644 --- a/windows/src/views/dom.ts +++ b/windows/src/views/dom.ts @@ -26,7 +26,11 @@ export function h( return el; } -export function svg(path: string, size = 14, opts: { fill?: string; stroke?: number } = {}): SVGSVGElement { +export function svg( + path: string, + size = 14, + opts: { fill?: string; stroke?: number; evenOdd?: boolean } = {}, +): SVGSVGElement { const el = document.createElementNS("http://www.w3.org/2000/svg", "svg"); el.setAttribute("viewBox", "0 0 24 24"); el.setAttribute("width", String(size)); @@ -42,6 +46,8 @@ export function svg(path: string, size = 14, opts: { fill?: string; stroke?: num p.setAttribute("stroke-linejoin", "round"); } else { p.setAttribute("fill", opts.fill ?? "currentColor"); + // Lets a filled glyph carry a cut-out (checkmark.seal.fill, xmark.octagon.fill). + if (opts.evenOdd) p.setAttribute("fill-rule", "evenodd"); } el.append(p); return el; diff --git a/windows/src/views/github.ts b/windows/src/views/github.ts new file mode 100644 index 000000000..1aa71c77e --- /dev/null +++ b/windows/src/views/github.ts @@ -0,0 +1,208 @@ +// The GitHub card once the pulse is in — DOM ports of GitHubPulseCardView, +// GitHubDetailView, GitHubPRRowView, GitHubRepoCIRowView and +// GitHubActivityDetailContent (IslandViewContent.swift). Sizes, colours and +// wording follow the Swift views; the logic lives in core/github.ts. +// +// Without a pulse (no token scope for it yet, or the first poll still to come) +// the card stays the stars + repositories overview in integrations.ts. + +import { h, svg, dot } from "./dom"; +import { ICONS } from "./icons"; +import { Bridge } from "../core/bridge"; +import { + CI_COLORS, GH_STRINGS, GRID_WEEKS, activityHeader, actionsUrl, ciWord, contributionColor, + formatCount, lastDays, lastWeeks, mainCISummary, myPRsValue, profileUrl, safeGitHubUrl, + sectionTitle, shortRepo, weekColumn, worstCI, + type ContributionDay, type GitHubActivity, type GitHubPR, type GitHubPulse, type GitHubRepoCI, + type GitHubSection, type GitHubStats, +} from "../core/github"; + +const GITHUB_RED = "#F4505E"; + +/** Which list the detail view shows; kept across re-renders of the card. */ +let openSection: GitHubSection = "myPRs"; + +function open(url: string) { + const safe = safeGitHubUrl(url); + if (safe) void Bridge.openUrl(safe); +} + +/** Seven small squares: the last week of contributions. */ +function miniRow(activity: GitHubActivity): HTMLElement { + const row = h("span", { class: "gh-mini" }); + for (const day of lastDays(activity, 7)) { + row.append(h("i", { style: `background:${contributionColor(day.level)}` })); + } + return row; +} + +function statButton(icon: SVGSVGElement, color: string, label: string, value: string, onClick: () => void): HTMLElement { + return h( + "button", + { class: "int-stat gh-stat", onclick: onClick }, + h("i", { class: "int-stat-icon", style: `color:${color}` }, icon), + h("span", { class: "int-stat-label", text: label }), + h("span", { class: "int-stat-value", text: value }), + ); +} + +// ── Card ────────────────────────────────────────────────────────────────────── + +export function githubPulseCard( + pulse: GitHubPulse, + stats: GitHubStats | null, + activity: GitHubActivity | null, + openDetail: () => void, +): HTMLElement { + const show = (section: GitHubSection) => { + openSection = section; + void Bridge.githubRefresh(section === "activity" ? "activity" : "pulse"); + openDetail(); + }; + + const head = h("div", { class: "int-head" }, dot(GITHUB_RED, 7), h("b", { text: GH_STRINGS.title })); + if (stats || activity) { + const spark = h("button", { class: "gh-spark", onclick: () => show("activity") }); + if (stats) spark.append(h("span", { text: `★ ${formatCount(stats.totalStars)}` })); + if (activity) spark.append(miniRow(activity)); + head.append(spark); + } else { + head.append(h("span", { text: GH_STRINGS.overview })); + } + + const prColor = CI_COLORS[worstCI(pulse.myPRs.map((p) => p.ci))]; + const reviews = pulse.toReview.length; + const main = mainCISummary(pulse.mainCI); + + return h( + "div", + { class: "int-card" }, + head, + h( + "div", + { class: "int-stats gh-rows" }, + statButton(svg(ICONS.pull, 10, { stroke: 2.2 }), prColor, GH_STRINGS.myPRs, myPRsValue(pulse.myPRs), + () => show("myPRs")), + statButton(svg(ICONS.eye, 10, { stroke: 2.2 }), reviews > 0 ? "#8AB4F8" : "#6B7079", GH_STRINGS.toReview, + String(reviews), () => show("toReview")), + statButton(svg(main.failing ? ICONS.octagonX : ICONS.seal, 10, { evenOdd: true }), main.color, + GH_STRINGS.mainCI, main.value, () => show("mainCI")), + ), + ); +} + +// ── Detail: lists ───────────────────────────────────────────────────────────── + +function backButton(title: string, onBack: () => void): HTMLElement { + return h( + "button", + { class: "gh-back", onclick: onBack }, + svg(ICONS.chevronLeft, 9, { stroke: 2.6 }), + h("span", { text: title }), + ); +} + +function ciDot(ci: GitHubPR["ci"]): HTMLElement { + const el = dot(CI_COLORS[ci], 5); + if (ci === "unknown") el.style.opacity = "0"; + return el; +} + +function prRow(pr: GitHubPR, showCI: boolean): HTMLElement { + return h( + "button", + { class: "gh-row", onclick: () => open(pr.url) }, + showCI ? ciDot(pr.ci) : h("i", { class: "gh-gap" }), + h("span", { class: "gh-ref", text: `${shortRepo(pr.repo)}#${pr.number}` }), + h("span", { class: "gh-title", text: pr.title }), + pr.isDraft ? h("span", { class: "gh-draft", text: GH_STRINGS.draft }) : null, + ); +} + +function repoRow(repo: GitHubRepoCI): HTMLElement { + const word = ciWord(repo.ci); + return h( + "button", + { class: "gh-row", onclick: () => open(actionsUrl(repo.url)) }, + ciDot(repo.ci), + h("span", { class: "gh-ref", text: shortRepo(repo.repo) }), + h("span", { class: "gh-title", text: repo.branch }), + word ? h("span", { class: "gh-word", style: `color:${CI_COLORS[repo.ci]}`, text: word }) : null, + ); +} + +function listDetail(section: Exclude, pulse: GitHubPulse, onBack: () => void): HTMLElement { + const rows: HTMLElement[] = + section === "mainCI" + ? pulse.mainCI.map(repoRow) + : (section === "myPRs" ? pulse.myPRs : pulse.toReview).map((pr) => prRow(pr, section === "myPRs")); + + const body = + rows.length === 0 + ? h("div", { class: "gh-empty", text: GH_STRINGS.nothingHere }) + : h("div", { class: rows.length > 3 ? "gh-list fade" : "gh-list" }, ...rows); + + return h( + "div", + { class: "int-card" }, + h("div", { class: "gh-detail-head" }, backButton(sectionTitle(section), onBack)), + body, + ); +} + +// ── Detail: contribution grid ───────────────────────────────────────────────── + +function activityDetail( + activity: GitHubActivity | null, + stats: GitHubStats | null, + login: string, + onBack: () => void, +): HTMLElement { + const right = h("button", { + class: "gh-head-right", + text: activityHeader(activity, stats, null), + onclick: () => open(profileUrl(login)), + }); + + let picked: ContributionDay | null = null; + const show = (day: ContributionDay | null) => { + picked = day; + right.textContent = activityHeader(activity, stats, day); + }; + + const head = h("div", { class: "gh-detail-head" }, backButton(GH_STRINGS.activity, onBack)); + if (activity) head.append(right); + + if (!activity) { + return h("div", { class: "int-card" }, head, h("div", { class: "gh-empty", text: GH_STRINGS.loading })); + } + + const grid = h("div", { class: "gh-grid" }); + for (const week of lastWeeks(activity, GRID_WEEKS)) { + const column = h("div", { class: "gh-week" }); + for (const day of weekColumn(week)) { + if (!day) { + column.append(h("i", { class: "gh-cell empty" })); + continue; + } + const cell = h("i", { class: "gh-cell", style: `background:${contributionColor(day.level)}` }); + cell.addEventListener("mouseenter", () => show(day)); + cell.addEventListener("mouseleave", () => show(null)); + cell.addEventListener("click", () => show(picked?.date === day.date ? null : day)); + column.append(cell); + } + grid.append(column); + } + return h("div", { class: "int-card" }, head, grid); +} + +export function githubDetail( + pulse: GitHubPulse, + stats: GitHubStats | null, + activity: GitHubActivity | null, + onBack: () => void, +): HTMLElement { + return openSection === "activity" + ? activityDetail(activity, stats, pulse.login, onBack) + : listDetail(openSection, pulse, onBack); +} diff --git a/windows/src/views/icons.ts b/windows/src/views/icons.ts index d2ff82475..7c99e9690 100644 --- a/windows/src/views/icons.ts +++ b/windows/src/views/icons.ts @@ -36,6 +36,14 @@ export const ICONS = { star: "M12 3.2l2.6 5.55 5.9.82-4.3 4.3 1.05 6.13L12 17.1l-5.25 2.9L7.8 13.87 3.5 9.57l5.9-.82L12 3.2z", // square.stack.fill stack: "M5 8h14v11.5H5V8zm1.8-3h10.4v1.6H6.8V5zm1.6-2.6h7.2V4H8.4V2.4z", + // arrow.triangle.pull (stroked) + pull: "M6 3a2.2 2.2 0 1 1 0 4.4A2.2 2.2 0 0 1 6 3zM6 7.4v9.2M6 16.6a2.2 2.2 0 1 1 0 4.4 2.2 2.2 0 0 1 0-4.4zM18 16.6a2.2 2.2 0 1 1 0 4.4 2.2 2.2 0 0 1 0-4.4zM18 16.6V9a3 3 0 0 0-3-3h-4.5M13 3.5 10.5 6 13 8.5", + // eye (stroked) + eye: "M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z", + // checkmark.seal.fill (even-odd) + seal: "M12 1.8 14.23 3.69 17.1 3.17 18.08 5.92 20.83 6.9 20.31 9.77 22.2 12 20.31 14.23 20.83 17.1 18.08 18.08 17.1 20.83 14.23 20.31 12 22.2 9.77 20.31 6.9 20.83 5.92 18.08 3.17 17.1 3.69 14.23 1.8 12 3.69 9.77 3.17 6.9 5.92 5.92 6.9 3.17 9.77 3.69zM7.4 12.3 8.8 10.9 10.7 12.8 15.2 8.3 16.6 9.7 10.7 15.6z", + // xmark.octagon.fill (even-odd) + octagonX: "M21.61 15.98 15.98 21.61 8.02 21.61 2.39 15.98 2.39 8.02 8.02 2.39 15.98 2.39 21.61 8.02zM8.53 7.66 12 11.13 15.47 7.66 16.34 8.53 12.87 12 16.34 15.47 15.47 16.34 12 12.87 8.53 16.34 7.66 15.47 11.13 12 7.66 8.53z", // doc.text doc: "M6.5 2.6h7l4 4v14.8h-11V2.6zm6.6 1.6v3.3h3.3l-3.3-3.3zM8.6 11h6.8v1.5H8.6V11zm0 3.4h6.8v1.5H8.6v-1.5z", } as const; diff --git a/windows/src/views/integrations.ts b/windows/src/views/integrations.ts index b8ad73c8f..f170ce2a3 100644 --- a/windows/src/views/integrations.ts +++ b/windows/src/views/integrations.ts @@ -8,6 +8,8 @@ import { h, svg, clear, dot } from "./dom"; import { ICONS } from "./icons"; import { State, type AgentTask } from "../core/state"; import { Bridge } from "../core/bridge"; +import { readActivity, readPulse, readStats } from "../core/github"; +import { githubDetail, githubPulseCard } from "./github"; /** Same shape as the Swift `timeAgo` computed properties. */ export function timeAgo(value: unknown): string { @@ -391,7 +393,7 @@ export function hasIntegrationData(id: string): boolean { case "integration_resend": return arr(id, "emails").length > 0; case "integration_github": - return get(id).totalRepos != null; + return get(id).totalRepos != null || readPulse(get(id)) != null; case "integration_stripe": return info.loaded; case "integration_notion": @@ -415,6 +417,17 @@ export function renderIntegrationCard(task: AgentTask, hooks: IntegrationCardHoo } if (!hasIntegrationData(task.id)) return idleCard(task, hooks.openSettings); + // With the pulse in, GitHub gets the Mac's richer card and its lists. + if (task.id === "integration_github") { + const d = get(task.id); + const pulse = readPulse(d); + if (pulse) { + return hooks.detailOpen + ? githubDetail(pulse, readStats(d), readActivity(d), hooks.closeDetail) + : githubPulseCard(pulse, readStats(d), readActivity(d), hooks.openDetail); + } + } + switch (task.id) { case "integration_resend": return resendCard(); diff --git a/windows/tests/github.test.mjs b/windows/tests/github.test.mjs new file mode 100644 index 000000000..71ee32c96 --- /dev/null +++ b/windows/tests/github.test.mjs @@ -0,0 +1,212 @@ +// GitHub pulse, alerts and contribution grid (src/core/github.ts and the alert +// handling in src/island/integrations.ts). The Rust half — parsing and alert +// detection — is tested in src-tauri/src/github.rs, like GitHubPulseTests.swift. + +import { beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { calls, emit, sent } from "./tauri.mjs"; +import { + GRID_WEEKS, activityHeader, actionsUrl, ciWord, contributionColor, contributionsLabel, dayLabel, + formatCount, gitHubAlert, lastDays, lastWeeks, mainCISummary, myPRsValue, profileUrl, readActivity, + readPulse, readStats, safeGitHubUrl, shortRepo, totalLabel, weekColumn, worstCI, +} from "../src/core/github.ts"; +import { registerIntegrationHandlers } from "../src/island/integrations.ts"; +import { DEFAULT_SETTINGS, State } from "../src/core/state.ts"; + +const GITHUB = "integration_github"; + +// ── Fixtures (GitHubActivityTests.swift) ────────────────────────────────────── + +const day = (date, count, level, weekday) => ({ date, count, level, weekday }); +const activity = { + total: 42, + fetchedAt: 0, + weeks: [ + [ + day("2026-01-05", 0, 0, 0), day("2026-01-06", 1, 1, 1), day("2026-01-07", 4, 2, 2), + day("2026-01-08", 8, 3, 3), day("2026-01-09", 12, 4, 4), day("2026-01-10", 2, 1, 5), + day("2026-01-11", 0, 0, 6), + ], + [day("2026-01-12", 5, 2, 0), day("2026-01-13", 10, 3, 1)], + ], +}; + +const pr = (id, ci) => ({ + id, title: "T", url: `https://github.com/${id.replace("#", "/pull/")}`, repo: id.split("#")[0], + number: Number(id.split("#")[1]), isDraft: false, ci, review: "unknown", headSha: null, +}); +const repo = (name, ci) => ({ repo: name, url: `https://github.com/${name}`, branch: "main", ci, headSha: null }); + +// ── Contribution grid ───────────────────────────────────────────────────────── + +test("lastWeeks returns the newest weeks, oldest first, clamped", () => { + assert.equal(lastWeeks(activity, 2).length, 2); + assert.equal(lastWeeks(activity, 1).length, 1); + assert.equal(lastWeeks(activity, 1)[0].length, 2, "the current week is incomplete"); + assert.equal(lastWeeks(activity, 1)[0][0].date, "2026-01-12"); + assert.equal(lastWeeks(activity, 99).length, 2); + assert.deepEqual(lastWeeks(activity, 0), []); +}); + +test("lastDays returns the newest days across weeks, oldest first, clamped", () => { + assert.deepEqual(lastDays(activity, 3).map((d) => d.date), ["2026-01-11", "2026-01-12", "2026-01-13"]); + assert.equal(lastDays(activity, 9).length, 9); + assert.equal(lastDays(activity, 99).length, 9); + assert.deepEqual(lastDays(activity, 0), []); +}); + +test("the grid shows 23 weeks, Sunday on top, gaps where a day is missing", () => { + assert.equal(GRID_WEEKS, 23); + const column = weekColumn(activity.weeks[1]); + assert.equal(column.length, 7); + assert.equal(column[0].date, "2026-01-12"); + assert.equal(column[1].date, "2026-01-13"); + assert.deepEqual(column.slice(2), [null, null, null, null, null]); + assert.deepEqual(weekColumn([day("2026-01-14", 1, 1, 3)]).map((d) => d?.weekday ?? null), + [null, null, null, 3, null, null, null]); +}); + +test("contribution levels map to GitHub's greens", () => { + assert.equal(contributionColor(0), "rgba(255,255,255,0.06)"); + assert.equal(contributionColor(1), "#0E4429"); + assert.equal(contributionColor(4), "#39D353"); + assert.equal(contributionColor(9), "rgba(255,255,255,0.06)"); +}); + +test("a picked day shows its date and count, otherwise the year", () => { + assert.equal(dayLabel("2026-01-05"), "Jan 5"); + assert.equal(dayLabel("2026-12-31"), "Dec 31"); + assert.equal(dayLabel("garbage"), "garbage"); + assert.equal(dayLabel("2026-13-01"), "2026-13-01"); + assert.equal(contributionsLabel(0), "No contributions"); + assert.equal(contributionsLabel(1), "1 contribution"); + assert.equal(contributionsLabel(12), "12 contributions"); + assert.equal(totalLabel(1234), "1,234"); + const stats = { totalRepos: 8, totalStars: 3 }; + assert.equal(activityHeader(activity, stats, activity.weeks[0][4]), "Jan 9 · 12 contributions"); + assert.equal(activityHeader(activity, stats, null), "42 past year · 8 repos"); + assert.equal(activityHeader(activity, null, null), "42 past year"); + assert.equal(activityHeader(null, stats, null), ""); +}); + +// ── Card summaries ──────────────────────────────────────────────────────────── + +test("the worst CI wins: failure, then pending, then success", () => { + assert.equal(worstCI([]), "unknown"); + assert.equal(worstCI(["success", "unknown"]), "success"); + assert.equal(worstCI(["success", "pending"]), "pending"); + assert.equal(worstCI(["pending", "failure", "success"]), "failure"); +}); + +test("My PRs counts, then says how many fail or that some still run", () => { + assert.equal(myPRsValue([]), "0"); + assert.equal(myPRsValue([pr("a/b#1", "success"), pr("a/b#2", "unknown")]), "2"); + assert.equal(myPRsValue([pr("a/b#1", "success"), pr("a/b#2", "pending")]), "2 · running"); + assert.equal(myPRsValue([pr("a/b#1", "failure"), pr("a/b#2", "pending")]), "2 · 1 failing"); +}); + +test("default branch CI sums up the recent repos", () => { + assert.deepEqual(mainCISummary([]), { failing: false, color: "#6B7079", value: "no repos" }); + assert.deepEqual(mainCISummary([repo("a/b", "unknown")]), { failing: false, color: "#6B7079", value: "unknown" }); + assert.deepEqual(mainCISummary([repo("a/b", "success")]), { failing: false, color: "#22C55E", value: "all green" }); + assert.deepEqual(mainCISummary([repo("a/b", "success"), repo("a/c", "pending")]), + { failing: false, color: "#F5A524", value: "running" }); + assert.deepEqual(mainCISummary([repo("a/b", "failure"), repo("a/c", "failure"), repo("a/d", "pending")]), + { failing: true, color: "#F4505E", value: "2 failing" }); + assert.equal(ciWord("success"), "passing"); + assert.equal(ciWord("unknown"), null); +}); + +test("small formatting helpers", () => { + assert.equal(formatCount(999), "999"); + assert.equal(formatCount(1234), "1.2k"); + assert.equal(shortRepo("owner/repo"), "repo"); + assert.equal(shortRepo("repo"), "repo"); +}); + +// ── Links ───────────────────────────────────────────────────────────────────── + +test("only http(s) links to github.com open", () => { + assert.equal(safeGitHubUrl("https://github.com/a/b/pull/1"), "https://github.com/a/b/pull/1"); + assert.equal(safeGitHubUrl("http://github.com/a"), "http://github.com/a"); + assert.equal(safeGitHubUrl("javascript:alert(1)"), null); + assert.equal(safeGitHubUrl("file:///etc/passwd"), null); + assert.equal(safeGitHubUrl("https://github.com.evil.example/a"), null); + assert.equal(safeGitHubUrl("https://evil.example/?github.com"), null); + assert.equal(safeGitHubUrl("not a url"), null); + assert.equal(actionsUrl("https://github.com/a/b"), "https://github.com/a/b/actions"); + assert.equal(actionsUrl("https://github.com/a/b/"), "https://github.com/a/b/actions"); + assert.equal(profileUrl("octo cat"), "https://github.com/octo%20cat"); +}); + +// ── Reading what Rust sends ─────────────────────────────────────────────────── + +test("card data is read defensively", () => { + assert.equal(readPulse({}), null); + assert.equal(readPulse({ pulse: { myPRs: [] } }), null); + const pulse = { login: "me", myPRs: [], toReview: [], mainCI: [], fetchedAt: 1 }; + assert.equal(readPulse({ pulse }), pulse); + assert.equal(readActivity({ activity: { weeks: "x", total: 1 } }), null); + assert.equal(readActivity({ activity }), activity); + assert.equal(readStats({}), null); + assert.deepEqual(readStats({ totalRepos: 3, totalStars: 10 }), { totalRepos: 3, totalStars: 10 }); +}); + +// ── Alerts (AppState.handleGitHubEvents) ────────────────────────────────────── + +test("the loudest event decides the badge and the sound", () => { + assert.equal(gitHubAlert([]), null); + assert.deepEqual(gitHubAlert([{ kind: "ciPassed", prId: "a" }]), { badge: "finished", sound: "finish" }); + assert.deepEqual(gitHubAlert([{ kind: "ciPassed", prId: "a" }, { kind: "reviewRequested", prId: "b" }]), + { badge: "finished", sound: "question" }); + assert.deepEqual( + gitHubAlert([{ kind: "reviewRequested", prId: "b" }, { kind: "mainFailed", repo: "r" }, { kind: "ciPassed", prId: "a" }]), + { badge: "error", sound: "error" }, + ); + assert.deepEqual(gitHubAlert([{ kind: "ciFailed", prId: "a" }]), { badge: "error", sound: "error" }); +}); + +const island = { reveal: () => {} }; +registerIntegrationHandlers(island); + +const github = () => State.tasks.find((t) => t.id === GITHUB); + +beforeEach(() => { + calls.length = 0; + State.tasks = []; + State.focusId = null; + State.mode = "hidden"; + State.paused = false; + State.settings = { ...DEFAULT_SETTINGS }; + State.loadIntegrationTasks(); +}); + +test("an alert badges the GitHub pill when another pill is on screen", () => { + emit("github-alerts", [{ kind: "mainFailed", repo: "a/b" }]); + assert.equal(github().pillBadge, "error"); + assert.equal(github().state, "idle", "Mochi's state is left alone, as on macOS"); +}); + +test("no badge while the GitHub pill is the one on screen", () => { + State.setFocus(GITHUB); + emit("github-alerts", [{ kind: "reviewRequested", prId: "a/b#1" }]); + assert.equal(github().pillBadge, null); +}); + +test("nothing happens while paused", () => { + State.paused = true; + emit("github-alerts", [{ kind: "ciFailed", prId: "a/b#1" }]); + assert.equal(github().pillBadge ?? null, null); +}); + +test("showing the GitHub card asks Rust for a refresh, once per showing", () => { + State.setFocus(GITHUB); + assert.deepEqual(sent("github_refresh"), [], "not while the island is closed"); + State.mode = "expanded"; + State.notify(); + State.notify(); + assert.deepEqual(sent("github_refresh"), [{ section: "pulse" }]); + State.setFocus("integration_claude"); + State.setFocus(GITHUB); + assert.deepEqual(sent("github_refresh"), [{ section: "pulse" }, { section: "pulse" }]); +}); From c2cfbca5553def628914fcee9cf5e26b8acfd1c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:43 +0200 Subject: [PATCH 16/77] Windows/Linux settings: say what the GitHub token needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classic token with the repo scope, or fine-grained with read access to Pull requests, Commit statuses and Actions — the Mac's hint, shown with the existing hint style. Co-Authored-By: Claude Opus 5.5 --- windows/src/settings/main.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..81db34ac9 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -262,13 +262,16 @@ interface IntegrationDef { color: string; /** Credential Manager keys, in the order they are shown. */ fields: { key: string; label: string; placeholder: string; secret: boolean }[]; + /** What the key needs, shown under its field. */ + hint?: string; } const INTEGRATIONS: IntegrationDef[] = [ { id: "integration_stripe", name: "Stripe", color: "#0570DE", fields: [{ key: "stripe-api-key", label: "Secret key", placeholder: "sk_live_…", secret: true }] }, { id: "integration_github", name: "GitHub", color: "#F4505E", - fields: [{ key: "github-token", label: "Token", placeholder: "ghp_…", secret: true }] }, + fields: [{ key: "github-token", label: "Token", placeholder: "ghp_…", secret: true }], + hint: "Classic token with the repo scope, or fine-grained with read access to Pull requests, Commit statuses and Actions." }, { id: "integration_vercel", name: "Vercel", color: "#7C5CFF", fields: [{ key: "vercel-token", label: "Token", placeholder: "…", secret: true }] }, { id: "integration_n8n", name: "n8n", color: "#F29B38", @@ -342,6 +345,8 @@ function integrationsSection(present: Record): HTMLElement { ); } + if (def.hint) rows.append(h("div", { class: "hint", text: def.hint })); + list.append( h("div", { style: "display:flex;gap:12px;align-items:flex-start" }, h("div", { style: "display:flex;align-items:center;gap:8px;min-width:132px;padding-top:4px" }, From 3bb9657e73632d58f3498c70a4dd69bc812fc91c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:56 +0200 Subject: [PATCH 17/77] Windows README: the GitHub pulse, alerts and contribution grid Co-Authored-By: Claude Opus 5.5 --- windows/README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/windows/README.md b/windows/README.md index 2b897bd3d..924b91ec7 100644 --- a/windows/README.md +++ b/windows/README.md @@ -77,6 +77,27 @@ only ask whether a key exists. Same for every integration key. No telemetry. The only network requests Coucou makes are to the services you configure yourself. +## GitHub + +With a token in **Settings… → Integrations → GitHub** — a classic token with +the `repo` scope, or a fine-grained one with read access to Pull requests, +Commit statuses and Actions — the GitHub pill shows: + +- **My PRs**: your open pull requests and their CI status. +- **To review**: the pull requests waiting for your review. +- **Default branch CI**: the CI of the default branch of your 10 most recently + pushed repositories. +- Your stars and the **last 7 days of contributions** in the card header; click + them for the past 23 weeks, and hover or click a day for its count. + +Click a row for the list, then an item to open it on github.com. The pill gets +a badge and a sound when the CI of one of your pull requests turns red or green +(fast runs between two checks included), when a default branch breaks, or when +someone requests your review. Pull requests are checked every 5 minutes, every +minute while a CI is running, and as soon as you open the card on data older +than a minute; contributions every 30 minutes. Nothing is fetched while the pill +is off or Coucou is paused. + ## Build it yourself You need [Rust](https://rustup.rs), [Node 20+](https://nodejs.org), and the @@ -163,6 +184,8 @@ OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integr attach it as context, and jumping to a specific terminal window — "Open terminal" opens the working folder in VS Code when `code` is on your `PATH`. - Cal.com shows the next bookings as a list rather than the Mac's calendar. +- The GitHub lists are clicked, not walked with the arrow keys, and there is no + iPhone to keep fetching them while the pill is off. ## Linux From 4b0a411a4ca7fc7c3f24e3d66a8b9ed588180881 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:17:32 +0200 Subject: [PATCH 18/77] Windows: port the live diff engine (DiffEngine.swift) to TypeScript LCS line diff with 3-line context hunks, the same 200 KB / 4 000-line / 1M-cell guards, toOneLine and the diff step encoding, plus buildFileDiff for Edit / MultiEdit / Write tool inputs. Tests mirror tests/DiffEngineTests.swift. Co-Authored-By: Claude Opus 5.5 --- windows/src/core/diff.ts | 326 ++++++++++++++++++++++++++++++++++++ windows/tests/diff.test.mjs | 192 +++++++++++++++++++++ 2 files changed, 518 insertions(+) create mode 100644 windows/src/core/diff.ts create mode 100644 windows/tests/diff.test.mjs diff --git a/windows/src/core/diff.ts b/windows/src/core/diff.ts new file mode 100644 index 000000000..477471de0 --- /dev/null +++ b/windows/src/core/diff.ts @@ -0,0 +1,326 @@ +// Live diff — port of DiffEngine.swift (NotchBuddy/Sources/CoucouKit). +// +// Line diff (LCS) with 3-line context hunks, used for the Edit / MultiEdit / +// Write tool calls the hooks see. Same size guards as macOS: beyond 200 KB or +// 4 000 lines, or when the LCS table would pass a million cells, only the +// +N −M counts are kept and the diff is marked `tooLarge`. +// +// Also home to `toOneLine` (Claude's final message → one line of plain text) +// and the diff step encoding the ticker reads. + +export type DiffKind = "context" | "added" | "removed"; + +export interface DiffLine { + kind: DiffKind; + text: string; + /** 1-based; -1 for pure adds. */ + origLine: number; + /** 1-based; -1 for pure removes. */ + newLine: number; +} + +export interface DiffHunk { + origStart: number; + newStart: number; + lines: DiffLine[]; +} + +export interface FileDiff { + /** Stable identifier assigned by State.appendSessionDiff. */ + id: number; + path: string; + added: number; + removed: number; + hunks: DiffHunk[]; + tooLarge: boolean; + /** True when produced by fromNew (Write tool). */ + isNewFile: boolean; +} + +/** FileDiff.maxBytes / maxLines on macOS. */ +export const DIFF_MAX_BYTES = 200 * 1024; +export const DIFF_MAX_LINES = 4000; +/** LCS is O(m·n): bail out before the quadratic blowup. */ +const DIFF_MAX_CELLS = 1_000_000; +const CONTEXT = 3; + +const encoder = new TextEncoder(); +const utf8Length = (s: string) => encoder.encode(s).length; + +/** Last path component, Windows or POSIX separators. */ +export function fileName(path: string): string { + const cleaned = path.replace(/[\\/]+$/, ""); + const idx = Math.max(cleaned.lastIndexOf("\\"), cleaned.lastIndexOf("/")); + return idx >= 0 ? cleaned.slice(idx + 1) : cleaned; +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +export function fromEdit(oldText: string, newText: string, path: string): FileDiff { + if (utf8Length(oldText) + utf8Length(newText) > DIFF_MAX_BYTES) { + return countFallback(oldText, newText, path); + } + const oldLines = splitLines(oldText); + const newLines = splitLines(newText); + if (oldLines.length + newLines.length > DIFF_MAX_LINES) { + return countFallback(oldText, newText, path); + } + if (oldLines.length * newLines.length > DIFF_MAX_CELLS) { + return countFallback(oldText, newText, path); + } + const flat = buildDiffLines(oldLines, newLines); + let added = 0; + let removed = 0; + for (const l of flat) { + if (l.kind === "added") added++; + else if (l.kind === "removed") removed++; + } + return { + id: 0, path, added, removed, hunks: buildHunks(flat, CONTEXT), tooLarge: false, isNewFile: false, + }; +} + +export function fromNew(content: string, path: string): FileDiff { + if (utf8Length(content) > DIFF_MAX_BYTES) { + const lineCount = content.split("\n").length; + return { id: 0, path, added: lineCount, removed: 0, hunks: [], tooLarge: true, isNewFile: true }; + } + const lines = splitLines(content); + if (lines.length > DIFF_MAX_LINES) { + return { id: 0, path, added: lines.length, removed: 0, hunks: [], tooLarge: true, isNewFile: true }; + } + const diffLines: DiffLine[] = lines.map((text, i) => ({ + kind: "added", text, origLine: -1, newLine: i + 1, + })); + return { + id: 0, + path, + added: diffLines.length, + removed: 0, + hunks: diffLines.length ? [{ origStart: 0, newStart: 1, lines: diffLines }] : [], + tooLarge: false, + isNewFile: true, + }; +} + +/** + * HookServer.buildFileDiff — the diff of one Edit, MultiEdit or Write tool call, + * or null when the call changes nothing (or is not a file edit at all). + */ +export function buildFileDiff(tool: string, input: Record): FileDiff | null { + const str = (v: unknown): string | null => (typeof v === "string" ? v : null); + const path = str(input.file_path); + switch (tool) { + case "Edit": { + const oldText = str(input.old_string); + const newText = str(input.new_string); + if (oldText == null || newText == null || path == null) return null; + if (!oldText && !newText) return null; + const d = fromEdit(oldText, newText, path); + return d.added > 0 || d.removed > 0 ? d : null; + } + case "MultiEdit": { + const edits = input.edits; + if (path == null || !Array.isArray(edits) || edits.length === 0) return null; + let added = 0; + let removed = 0; + let tooLarge = false; + const hunks: DiffHunk[] = []; + for (const edit of edits) { + if (!edit || typeof edit !== "object") continue; + const e = edit as Record; + const oldText = str(e.old_string); + const newText = str(e.new_string); + if (oldText == null || newText == null) continue; + const d = fromEdit(oldText, newText, path); + added += d.added; + removed += d.removed; + hunks.push(...d.hunks); + if (d.tooLarge) tooLarge = true; + } + if (added === 0 && removed === 0) return null; + return { id: 0, path, added, removed, hunks, tooLarge, isNewFile: false }; + } + case "Write": { + const content = str(input.content); + if (path == null || !content) return null; + const d = fromNew(content, path); + return d.added > 0 || d.removed > 0 ? d : null; + } + default: + return null; + } +} + +// ── Line splitting ──────────────────────────────────────────────────────────── + +function splitLines(text: string): string[] { + const parts = text.replace(/\r\n/g, "\n").split("\n"); + // A trailing newline leaves one empty element behind. + if (parts[parts.length - 1] === "") parts.pop(); + return parts; +} + +// ── LCS diff ────────────────────────────────────────────────────────────────── + +function buildDiffLines(oldLines: string[], newLines: string[]): DiffLine[] { + const m = oldLines.length; + const n = newLines.length; + const w = n + 1; + // dp[i*w + j] = LCS length of oldLines[0.. 0 && j > 0) { + if (oldLines[i - 1] === newLines[j - 1]) { + matches.push([i - 1, j - 1]); + i--; + j--; + } else if (dp[(i - 1) * w + j] >= dp[i * w + j - 1]) { + i--; + } else { + j--; + } + } + matches.reverse(); + + const result: DiffLine[] = []; + let prevOld = -1; + let prevNew = -1; + const removedLine = (k: number): DiffLine => ({ kind: "removed", text: oldLines[k], origLine: k + 1, newLine: -1 }); + const addedLine = (k: number): DiffLine => ({ kind: "added", text: newLines[k], origLine: -1, newLine: k + 1 }); + for (const [oi, ni] of matches) { + for (let k = prevOld + 1; k < oi; k++) result.push(removedLine(k)); + for (let k = prevNew + 1; k < ni; k++) result.push(addedLine(k)); + result.push({ kind: "context", text: oldLines[oi], origLine: oi + 1, newLine: ni + 1 }); + prevOld = oi; + prevNew = ni; + } + for (let k = prevOld + 1; k < m; k++) result.push(removedLine(k)); + for (let k = prevNew + 1; k < n; k++) result.push(addedLine(k)); + return result; +} + +// ── Hunks ───────────────────────────────────────────────────────────────────── + +function buildHunks(lines: DiffLine[], context: number): DiffHunk[] { + const merged: [number, number][] = []; + lines.forEach((line, i) => { + if (line.kind === "context") return; + const start = Math.max(0, i - context); + const end = Math.min(lines.length - 1, i + context); + const last = merged[merged.length - 1]; + if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end); + else merged.push([start, end]); + }); + return merged.map(([start, end]) => { + const hunkLines = lines.slice(start, end + 1); + return { + origStart: hunkLines.find((l) => l.origLine > 0)?.origLine ?? 1, + newStart: hunkLines.find((l) => l.newLine > 0)?.newLine ?? 1, + lines: hunkLines, + }; + }); +} + +// ── Count fallback (too large) ──────────────────────────────────────────────── + +function countFallback(oldText: string, newText: string, path: string): FileDiff { + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + const oldSet = new Set(oldLines); + const newSet = new Set(newLines); + const added = newLines.filter((l) => l !== "" && !oldSet.has(l)).length; + const removed = oldLines.filter((l) => l !== "" && !newSet.has(l)).length; + return { id: 0, path, added, removed, hunks: [], tooLarge: true, isNewFile: false }; +} + +// ── toOneLine ───────────────────────────────────────────────────────────────── + +/** + * A possibly multi-line Markdown message as one line of plain text: only the + * first non-empty paragraph (a blank line, a horizontal rule or a table row ends + * it), without `**`, `__`, backticks, leading `#` or list markers. + */ +export function toOneLine(text: string, maxChars = 200): string { + const trimWS = (s: string) => s.replace(/^[ \t]+|[ \t]+$/g, ""); + const paragraphs: string[][] = []; + let current: string[] = []; + for (const line of text.replace(/\r\n/g, "\n").split("\n")) { + const t = trimWS(line); + const isHR = t.length >= 3 && (/^-+$/.test(t) || /^\*+$/.test(t) || /^_+$/.test(t)); + if (t === "" || isHR || t.startsWith("|")) { + if (current.length) paragraphs.push(current); + current = []; + } else { + current.push(line); + } + } + if (current.length) paragraphs.push(current); + + for (const para of paragraphs) { + const s = para.join("\n").replaceAll("**", "").replaceAll("__", "").replaceAll("`", ""); + const processed: string[] = []; + for (const line of s.split("\n")) { + let l = trimWS(line.replace(/^#+/, "")); + if (l.startsWith("- ") || l.startsWith("* ") || l.startsWith("• ")) l = l.slice(2); + else l = l.replace(/^\d+\.\s+/, ""); + l = trimWS(l); + if (l) processed.push(l); + } + const collapsed = processed.join(" ").split(/\s+/).filter(Boolean).join(" "); + if (collapsed) return Array.from(collapsed).slice(0, maxChars).join(""); + } + return ""; +} + +// ── Diff steps ──────────────────────────────────────────────────────────────── +// A diff shows up in the ticker as a step string the user never sees raw: +// `"\t::"` — same format as macOS. + +export const DIFF_STEP_MARKER = ""; + +export interface DiffStep { + filename: string; + added: number; + removed: number; + diffId: number; +} + +export function isDiffStep(step: string): boolean { + return step.startsWith(DIFF_STEP_MARKER); +} + +export function makeDiffStep(filename: string, added: number, removed: number, diffId: number): string { + return `${DIFF_STEP_MARKER}${filename}\t${added}:${removed}:${diffId}`; +} + +export function parseDiffStep(step: string): DiffStep | null { + if (!isDiffStep(step)) return null; + const body = step.slice(DIFF_STEP_MARKER.length); + const tab = body.indexOf("\t"); + if (tab < 0) return null; + const parts = body.slice(tab + 1).split(":"); + if (parts.length !== 3 || !parts.every((p) => /^-?\d+$/.test(p))) return null; + const [added, removed, diffId] = parts.map(Number); + return { filename: body.slice(0, tab), added, removed, diffId }; +} + +/** The last step that is text, never a diff marker. */ +export function lastTextStep(steps: readonly string[]): string | undefined { + for (let i = steps.length - 1; i >= 0; i--) { + if (!isDiffStep(steps[i])) return steps[i]; + } + return undefined; +} diff --git a/windows/tests/diff.test.mjs b/windows/tests/diff.test.mjs new file mode 100644 index 000000000..fc558e1b6 --- /dev/null +++ b/windows/tests/diff.test.mjs @@ -0,0 +1,192 @@ +// Live diff engine (src/core/diff.ts) — port of tests/DiffEngineTests.swift, +// plus the tool-input glue (buildFileDiff) HookServer.swift does on macOS. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + DIFF_MAX_LINES, buildFileDiff, fileName, fromEdit, fromNew, isDiffStep, lastTextStep, + makeDiffStep, parseDiffStep, toOneLine, +} from "../src/core/diff.ts"; + +const allLines = (d) => d.hunks.flatMap((h) => h.lines); + +test("fromEdit — additions", () => { + const d = fromEdit("", "hello\nworld\n", "/a/b.swift"); + assert.equal(d.added, 2); + assert.equal(d.removed, 0); + assert.equal(d.tooLarge, false); + assert.equal(d.hunks.length, 1); + assert.equal(fileName(d.path), "b.swift"); +}); + +test("fromEdit — removals", () => { + const d = fromEdit("hello\nworld\n", "", "/x.py"); + assert.equal(d.added, 0); + assert.equal(d.removed, 2); + assert.equal(d.hunks.length, 1); +}); + +test("fromEdit — replacement keeps context lines", () => { + const d = fromEdit("foo\nbar\nbaz\n", "foo\nqux\nbaz\n", "/f.ts"); + assert.equal(d.added, 1); + assert.equal(d.removed, 1); + assert.ok(d.hunks[0].lines.some((l) => l.kind === "context")); + assert.deepEqual( + d.hunks[0].lines.map((l) => [l.kind, l.text, l.origLine, l.newLine]), + [ + ["context", "foo", 1, 1], + ["removed", "bar", 2, -1], + ["added", "qux", -1, 2], + ["context", "baz", 3, 3], + ], + ); +}); + +test("fromNew — new file, every line added", () => { + const d = fromNew("line1\nline2\nline3\n", "/new.rs"); + assert.equal(d.added, 3); + assert.equal(d.removed, 0); + assert.equal(d.isNewFile, true); + assert.ok(allLines(d).every((l) => l.kind === "added")); +}); + +test("fromEdit — two separate edits of a MultiEdit", () => { + const d1 = fromEdit("aaa\n", "bbb\n", "/m.kt"); + const d2 = fromEdit("ccc\n", "ddd\n", "/m.kt"); + assert.deepEqual([d1.added, d1.removed, d2.added, d2.removed], [1, 1, 1, 1]); +}); + +test("tooLarge past 200 KB: counts only, no hunks", () => { + const d = fromEdit("x".repeat(150 * 1024), "y".repeat(60 * 1024), "/big.swift"); + assert.equal(d.tooLarge, true); + assert.deepEqual(d.hunks, []); + assert.equal(d.added, 1); + assert.equal(d.removed, 1); +}); + +test("the 200 KB guard counts UTF-8 bytes, not characters", () => { + const d = fromEdit("é".repeat(60 * 1024), "è".repeat(60 * 1024), "/accents.txt"); + assert.equal(d.tooLarge, true); +}); + +test("CRLF is normalised", () => { + const d = fromEdit("a\r\nb\r\n", "a\r\nc\r\n", "/win.txt"); + assert.equal(d.added, 1); + assert.equal(d.removed, 1); +}); + +test("no trailing newline", () => { + const d = fromEdit("hello", "hello\nworld", "/t.txt"); + assert.equal(d.added, 1); + assert.equal(d.removed, 0); +}); + +test("3 lines of context around a change", () => { + const lines = Array.from({ length: 10 }, (_, i) => `line${i + 1}`); + const changed = lines.map((l, i) => (i === 4 ? "changed" : l)); + const d = fromEdit(`${lines.join("\n")}\n`, `${changed.join("\n")}\n`, "/ctx.swift"); + assert.equal(d.added, 1); + assert.equal(d.removed, 1); + assert.equal(d.hunks.length, 1); + const context = d.hunks[0].lines.filter((l) => l.kind === "context"); + assert.equal(context.length, 6); + assert.equal(d.hunks[0].origStart, 2); + assert.equal(d.hunks[0].newStart, 2); +}); + +test("distant changes make separate hunks", () => { + const lines = Array.from({ length: 30 }, (_, i) => `l${i}`); + const changed = lines.map((l, i) => (i === 2 || i === 25 ? `${l}!` : l)); + const d = fromEdit(lines.join("\n"), changed.join("\n"), "/two.ts"); + assert.equal(d.hunks.length, 2); + assert.equal(d.added, 2); + assert.equal(d.removed, 2); +}); + +test("m·n > 1 000 000 → tooLarge before the quadratic table", () => { + const many = Array.from({ length: 1001 }, (_, i) => `line${i}`).join("\n"); + const d = fromEdit(many, `${many}\nextra`, "/big.swift"); + assert.equal(d.tooLarge, true); + assert.deepEqual(d.hunks, []); +}); + +test("fromNew — too large keeps the line count", () => { + const d = fromNew("x\n".repeat(DIFF_MAX_LINES + 1), "/new.swift"); + assert.equal(d.tooLarge, true); + assert.equal(d.isNewFile, true); + assert.deepEqual(d.hunks, []); + assert.ok(d.added > 0); +}); + +test("makeDiffStep / parseDiffStep round-trip", () => { + const s = makeDiffStep("foo.swift", 3, 1, 7); + assert.ok(isDiffStep(s)); + assert.deepEqual(parseDiffStep(s), { filename: "foo.swift", added: 3, removed: 1, diffId: 7 }); + assert.equal(parseDiffStep(makeDiffStep("bar.ts", 0, 2, 42))?.diffId, 42); + assert.equal(isDiffStep("Edit foo.swift"), false); + assert.equal(parseDiffStep("Edit foo.swift"), null); + assert.equal(parseDiffStep("\uE001no-tab"), null); + assert.equal(parseDiffStep("\uE001f\t1:2"), null); + assert.equal(parseDiffStep("\uE001f\t1:2:x"), null); +}); + +test("lastTextStep skips diff markers", () => { + assert.equal(lastTextStep(["Lit · a.ts", makeDiffStep("a.ts", 1, 0, 1)]), "Lit · a.ts"); + assert.equal(lastTextStep([makeDiffStep("a.ts", 1, 0, 1)]), undefined); + assert.equal(lastTextStep([]), undefined); +}); + +test("toOneLine", () => { + assert.equal(toOneLine("line one\nline two\nline three"), "line one line two line three"); + assert.equal(toOneLine("**hello** world"), "hello world"); + assert.equal(toOneLine("## My Title\nsome text"), "My Title some text"); + assert.equal(toOneLine(""), ""); + assert.ok(toOneLine("x ".repeat(200), 10).length <= 10); + assert.equal(toOneLine("First para.\n\nSecond para."), "First para."); + assert.equal( + toOneLine("Done. Single commit 450a657 on github-pulse.\n\n---\n\nFiles touched (7)…"), + "Done. Single commit 450a657 on github-pulse.", + ); + assert.equal(toOneLine("Summary line.\n***\nMore details."), "Summary line."); + assert.equal(toOneLine("Result:\n| Col1 | Col2 |\n|---|---|\n| A | B |"), "Result:"); + assert.equal(toOneLine("- item one\n- item two"), "item one item two"); + assert.equal(toOneLine("* first\n* second"), "first second"); + assert.equal(toOneLine("1. step one\n2. step two"), "step one step two"); + assert.equal(toOneLine("\n\nActual content."), "Actual content."); + // Windows line endings split paragraphs too. + assert.equal(toOneLine("Fixed `it`.\r\n\r\nDetails"), "Fixed it."); +}); + +test("buildFileDiff — Edit", () => { + const d = buildFileDiff("Edit", { file_path: "C:\\p\\a.ts", old_string: "a\nb", new_string: "a\nc\nd" }); + assert.equal(fileName(d.path), "a.ts"); + assert.deepEqual([d.added, d.removed, d.isNewFile], [2, 1, false]); + // Nothing changed, nothing given, or not a string: no diff. + assert.equal(buildFileDiff("Edit", { file_path: "/a", old_string: "x", new_string: "x" }), null); + assert.equal(buildFileDiff("Edit", { file_path: "/a", old_string: "", new_string: "" }), null); + assert.equal(buildFileDiff("Edit", { old_string: "x", new_string: "y" }), null); + assert.equal(buildFileDiff("Edit", { file_path: "/a", old_string: 1, new_string: "y" }), null); +}); + +test("buildFileDiff — MultiEdit sums its edits", () => { + const d = buildFileDiff("MultiEdit", { + file_path: "/p/m.kt", + edits: [ + { old_string: "aaa", new_string: "bbb" }, + { old_string: "ccc", new_string: "ddd\neee" }, + { old_string: 3 }, + null, + ], + }); + assert.deepEqual([d.added, d.removed, d.hunks.length], [3, 2, 2]); + assert.equal(buildFileDiff("MultiEdit", { file_path: "/p/m.kt", edits: [] }), null); + assert.equal(buildFileDiff("MultiEdit", { file_path: "/p/m.kt" }), null); +}); + +test("buildFileDiff — Write and other tools", () => { + const d = buildFileDiff("Write", { file_path: "/p/n.md", content: "# Hi\n\nThere\n" }); + assert.deepEqual([d.added, d.removed, d.isNewFile], [3, 0, true]); + assert.equal(buildFileDiff("Write", { file_path: "/p/n.md", content: "" }), null); + assert.equal(buildFileDiff("Bash", { command: "ls" }), null); + assert.equal(buildFileDiff("Read", { file_path: "/p/n.md" }), null); +}); From fbc460513d45ba69d41578a7e9cf374be79b0f33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:17:32 +0200 Subject: [PATCH 19/77] Windows relay: forward a finished edit's text whole for the live diff PostToolUse of Edit / MultiEdit / Write keeps old_string / new_string / content up to 256 KB each and 512 KB per event (other fields and events keep the 2 000-byte cap). Anything cut sets coucou_diff_truncated so the island shows no counts it cannot trust. Co-Authored-By: Claude Opus 5.5 --- windows/hook/src/main.rs | 154 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 144 insertions(+), 10 deletions(-) diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs index 5bb363781..52ef9c7ae 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -34,6 +34,17 @@ const DROPPED_FIELDS: &[&str] = &["tool_response", "transcript_path"]; /// less than this anyway. const MAX_FIELD_LEN: usize = 2_000; +/// The live diff needs the whole text of a file edit, once it has happened: +/// PostToolUse of these tools keeps its edit strings far longer than the rest. +const DIFF_TOOLS: &[&str] = &["Edit", "MultiEdit", "Write"]; +/// The `tool_input` keys holding the text being replaced or written. +const DIFF_FIELDS: &[&str] = &["old_string", "new_string", "content"]; +/// Per edit string. The island stops diffing at 200 KB anyway (DiffEngine). +const MAX_DIFF_FIELD_LEN: usize = 256 * 1024; +/// For all edit strings of one event together, so the line stays well under the +/// 1 MiB the app reads from the pipe even once JSON-escaped. +const MAX_DIFF_TOTAL: usize = 512 * 1024; + #[cfg(windows)] mod win; #[cfg(windows)] @@ -161,26 +172,79 @@ fn read_event() -> Option<(String, String)> { } } - truncate_strings(&mut payload); + truncate_payload(&mut payload, &event); let mut line = payload.to_string(); line.push('\n'); Some((line, event)) } +/// Caps the strings of a payload: every field to MAX_FIELD_LEN, except the edit +/// strings of a finished Edit / MultiEdit / Write, which the live diff needs +/// whole. If even those had to be cut, `coucou_diff_truncated` tells the island +/// not to show counts it cannot trust. +fn truncate_payload(payload: &mut serde_json::Value, event: &str) { + let keeps_diff = event == "PostToolUse" + && payload + .get("tool_name") + .and_then(|v| v.as_str()) + .is_some_and(|tool| DIFF_TOOLS.contains(&tool)); + let input = if keeps_diff { + payload.as_object_mut().and_then(|map| map.remove("tool_input")) + } else { + None + }; + + truncate_strings(payload); + + if let Some(mut input) = input { + let mut budget = MAX_DIFF_TOTAL; + let mut cut_any = false; + cap_diff_strings(&mut input, &mut budget, &mut cut_any); + if let Some(map) = payload.as_object_mut() { + map.insert("tool_input".into(), input); + if cut_any { + map.insert("coucou_diff_truncated".into(), serde_json::Value::Bool(true)); + } + } + } +} + +/// `tool_input` of a diff tool: edit strings share MAX_DIFF_TOTAL, each capped at +/// MAX_DIFF_FIELD_LEN; any other string gets the ordinary cap. +fn cap_diff_strings(value: &mut serde_json::Value, budget: &mut usize, cut_any: &mut bool) { + match value { + serde_json::Value::Object(map) => { + for (key, v) in map.iter_mut() { + match v { + serde_json::Value::String(s) if DIFF_FIELDS.contains(&key.as_str()) => { + let limit = MAX_DIFF_FIELD_LEN.min(*budget); + if cut(s, limit) { + *cut_any = true; + } + *budget = budget.saturating_sub(s.len()); + } + _ => cap_diff_strings(v, budget, cut_any), + } + } + } + serde_json::Value::Array(items) => { + for item in items { + cap_diff_strings(item, budget, cut_any); + } + } + serde_json::Value::String(s) => { + cut(s, MAX_FIELD_LEN); + } + _ => {} + } +} + /// Caps every string in the payload. A single Write can carry a whole file. fn truncate_strings(value: &mut serde_json::Value) { match value { serde_json::Value::String(s) => { - if s.len() > MAX_FIELD_LEN { - // Cut on a char boundary; a lone byte index can split UTF-8. - let mut end = MAX_FIELD_LEN; - while end > 0 && !s.is_char_boundary(end) { - end -= 1; - } - s.truncate(end); - s.push('…'); - } + cut(s, MAX_FIELD_LEN); } serde_json::Value::Array(items) => items.iter_mut().for_each(truncate_strings), serde_json::Value::Object(map) => map.values_mut().for_each(truncate_strings), @@ -188,6 +252,21 @@ fn truncate_strings(value: &mut serde_json::Value) { } } +/// Shortens `s` to at most `max` bytes plus an ellipsis; true if it was cut. +fn cut(s: &mut String, max: usize) -> bool { + if s.len() <= max { + return false; + } + // Cut on a char boundary; a lone byte index can split UTF-8. + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + s.truncate(end); + s.push('…'); + true +} + /// Connect, send, and — for a permission request — wait for the island's word. fn talk(payload: &str, waits_for_answer: bool) -> Option { let mut pipe = connect()?; @@ -253,4 +332,59 @@ mod tests { assert!(s.len() <= MAX_FIELD_LEN + 4); assert!(s.ends_with('…')); } + + #[test] + fn a_finished_edit_keeps_its_text_whole_for_the_live_diff() { + let big = "line\n".repeat(4_000); // 20 KB, well past MAX_FIELD_LEN + let mut v = serde_json::json!({ + "tool_name": "Edit", + "tool_input": { "file_path": "/p/a.ts", "old_string": big, "new_string": big }, + "cwd": "x".repeat(4_000), + }); + truncate_payload(&mut v, "PostToolUse"); + assert_eq!(v["tool_input"]["old_string"].as_str().unwrap().len(), big.len()); + assert_eq!(v["tool_input"]["new_string"].as_str().unwrap().len(), big.len()); + // Everything else keeps the ordinary cap, and nothing says "cut". + assert!(v["cwd"].as_str().unwrap().len() <= MAX_FIELD_LEN + 4); + assert!(v.get("coucou_diff_truncated").is_none()); + + let mut multi = serde_json::json!({ + "tool_name": "MultiEdit", + "tool_input": { "edits": [{ "old_string": big, "new_string": "x" }] }, + }); + truncate_payload(&mut multi, "PostToolUse"); + assert_eq!(multi["tool_input"]["edits"][0]["old_string"].as_str().unwrap().len(), big.len()); + } + + #[test] + fn edits_are_still_capped_before_they_happen_and_for_other_tools() { + let big = "é".repeat(4_000); + for (event, tool) in [("PreToolUse", "Edit"), ("PermissionRequest", "Write"), ("PostToolUse", "Bash")] { + let mut v = serde_json::json!({ "tool_name": tool, "tool_input": { "content": big } }); + truncate_payload(&mut v, event); + assert!(v["tool_input"]["content"].as_str().unwrap().len() <= MAX_FIELD_LEN + 4, "{event} {tool}"); + } + } + + #[test] + fn an_edit_beyond_the_budget_is_cut_and_flagged() { + let huge = "x".repeat(MAX_DIFF_FIELD_LEN + 10); + let mut v = serde_json::json!({ + "tool_name": "Write", + "tool_input": { "file_path": "/p/big.txt", "content": huge }, + }); + truncate_payload(&mut v, "PostToolUse"); + assert!(v["tool_input"]["content"].as_str().unwrap().len() <= MAX_DIFF_FIELD_LEN + 4); + assert_eq!(v["coucou_diff_truncated"], serde_json::Value::Bool(true)); + + // Together, the edit strings never pass the shared budget. + let half = "y".repeat(MAX_DIFF_FIELD_LEN - 1); + let edits: Vec<_> = (0..4) + .map(|_| serde_json::json!({ "old_string": half, "new_string": half })) + .collect(); + let mut multi = serde_json::json!({ "tool_name": "MultiEdit", "tool_input": { "edits": edits } }); + truncate_payload(&mut multi, "PostToolUse"); + assert!(multi.to_string().len() < MAX_DIFF_TOTAL + 64 * 1024); + assert_eq!(multi["coucou_diff_truncated"], serde_json::Value::Bool(true)); + } } From 122e130bf6a9529cbe622476dfd383078ae62a06 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:17:39 +0200 Subject: [PATCH 20/77] Windows: live diff in the ticker, the diff card, and Claude's final line - PostToolUse of a file edit stores its diff (50 per pill, forgotten after an hour or at SessionEnd / pill removal) and adds a ticker step: file name +N -M. - Clicking a diff row opens the diff in the overview's left card (DiffCardView); < or Esc goes back, leaving the overview closes it, and the arrow opens the file in VS Code through open_file_in_vscode (existing file by full path only; the fallback shows its folder, never launches the file). - Stop reads last_assistant_message (fallback message) through toOneLine: the first paragraph becomes the last step and finalLine. The ticker then holds still (no shimmer, check mark, #C9CDD4) until the next turn, and the finished card shows it on one line with an ellipsis. - Error and question views skip diff steps when showing the last step. - The ticker's dim text is pinned to top:0, where a one-line row already put it, so a text wider than the row no longer drops below it. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/lib.rs | 51 ++++++++++ windows/src/core/bridge.ts | 3 + windows/src/core/state.ts | 43 +++++++++ windows/src/island/hooks.ts | 44 ++++++++- windows/src/style.css | 139 +++++++++++++++++++++++++++ windows/src/views/diff.ts | 66 +++++++++++++ windows/src/views/ticker.ts | 105 ++++++++++++++++---- windows/src/views/views.ts | 68 +++++++++++-- windows/tests/livediff.test.mjs | 163 ++++++++++++++++++++++++++++++++ 9 files changed, 654 insertions(+), 28 deletions(-) create mode 100644 windows/src/views/diff.ts create mode 100644 windows/tests/livediff.test.mjs diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index d9ec3353e..0cd30c1a7 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -165,6 +165,33 @@ fn open_in_vscode(path: Option) -> bool { false } +/// The file behind a live diff, if it may be handed to the editor: an existing +/// regular file given by its full path. Anything else — a relative path, a +/// folder, a path `code` could read as an option — goes no further. +fn diff_file(path: &str) -> Option<&std::path::Path> { + let p = std::path::Path::new(path); + (p.is_absolute() && p.is_file()).then_some(p) +} + +/// The diff card's ↗: opens the edited file in VS Code when `code` is on PATH, +/// otherwise shows its folder. The file itself is never opened by its type — +/// xdg-open or Explorer would run a script that Claude just wrote. +#[tauri::command] +fn open_file_in_vscode(path: String) -> bool { + let Some(file) = diff_file(&path) else { return false }; + if let Some(code) = platform::find_on_path("code") { + let mut cmd = Command::new(code); + cmd.arg(file); + if platform::no_console(&mut cmd).spawn().is_ok() { + return true; + } + } + if let Some(folder) = file.parent().filter(|d| d.is_dir()) { + platform::reveal_folder(&folder.to_string_lossy()); + } + false +} + #[tauri::command] fn quit_app(app: AppHandle) { app.exit(0); @@ -385,6 +412,7 @@ pub fn run() { reposition, open_url, open_in_vscode, + open_file_in_vscode, quit_app, hooks_status, hooks_preview, @@ -433,3 +461,26 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running Coucou"); } + +#[cfg(test)] +mod tests { + use super::diff_file; + + #[test] + fn only_an_existing_file_by_its_full_path_reaches_the_editor() { + let dir = std::env::temp_dir().join(format!("coucou-diff-file-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("edited.ts"); + std::fs::write(&file, "x").unwrap(); + + assert!(diff_file(&file.to_string_lossy()).is_some()); + // A folder, a missing file, a relative path or an option never pass. + assert!(diff_file(&dir.to_string_lossy()).is_none()); + assert!(diff_file(&dir.join("missing.ts").to_string_lossy()).is_none()); + assert!(diff_file("edited.ts").is_none()); + assert!(diff_file("--help").is_none()); + assert!(diff_file("").is_none()); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..1e2c298fc 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -55,6 +55,9 @@ export const Bridge = { /** "Open terminal" → opens the folder in VS Code when `code` is on PATH. */ openInVSCode: (path: string | null) => call("open_in_vscode", { path }), + /** The diff card's ↗: an existing file, in VS Code; never launched by its type. */ + openFileInVSCode: (path: string) => call("open_file_in_vscode", { path }), + quit: () => call("quit_app"), openSettingsWindow: () => call("open_settings_window"), diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..bd801c4ba 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -2,6 +2,7 @@ import type { BotEmoteName, BotStateName, IslandMode, IslandViewName } from "./layout"; import type { EyeShape } from "../mochi/engine"; +import type { FileDiff } from "./diff"; export type AgentSource = "claudeCode" | "n8n" | "agent"; export type PillBadge = "approval" | "finished" | "error"; @@ -19,6 +20,8 @@ export interface AgentTask { miniEye?: EyeShape | null; pillBadge?: PillBadge | null; sessionCwd?: string | null; + /** Claude's final message after Stop, one line; cleared when a new turn starts. */ + finalLine?: string | null; } export interface ApprovalInfo { @@ -110,6 +113,11 @@ export const DEFAULT_SETTINGS: Settings = { type Listener = () => void; +/** Live diffs kept per pill (oldest dropped first) — same cap as macOS. */ +export const MAX_DIFFS_PER_PILL = 50; +/** A pill's diffs are forgotten after an hour without a new one, as on macOS. */ +export const DIFF_TTL_MS = 3_600_000; + class AppState { mode: IslandMode = "hidden"; view: IslandViewName = "overview"; @@ -140,6 +148,12 @@ class AppState { integrations: Record = {}; + /** Per-pill file diffs, in order of reception. Steps carry their ids. */ + sessionDiffs = new Map(); + private sessionDiffTimers = new Map(); + /** Never reset, so an id can never point at a newer diff than the one tapped. */ + private nextDiffId = 0; + lastActivity = performance.now(); settings: Settings = { ...DEFAULT_SETTINGS }; @@ -199,6 +213,34 @@ class AppState { this.notify(); } + /** Stores a diff for a pill and returns its id (for the ticker step). */ + appendSessionDiff(pillId: string, diff: FileDiff): number { + const id = this.nextDiffId++; + const list = this.sessionDiffs.get(pillId) ?? []; + list.push({ ...diff, id }); + while (list.length > MAX_DIFFS_PER_PILL) list.shift(); + this.sessionDiffs.set(pillId, list); + // One timer per pill, re-armed on every diff — nothing polls. + const prev = this.sessionDiffTimers.get(pillId); + if (prev != null) window.clearTimeout(prev); + this.sessionDiffTimers.set( + pillId, + window.setTimeout(() => this.clearSessionDiffs(pillId), DIFF_TTL_MS), + ); + return id; + } + + findDiff(pillId: string, id: number): FileDiff | null { + return this.sessionDiffs.get(pillId)?.find((d) => d.id === id) ?? null; + } + + clearSessionDiffs(pillId: string) { + const timer = this.sessionDiffTimers.get(pillId); + if (timer != null) window.clearTimeout(timer); + this.sessionDiffTimers.delete(pillId); + this.sessionDiffs.delete(pillId); + } + /** loadIntegrationTasks() — VS Code always on, the rest opt-in (max 4). */ loadIntegrationTasks() { for (const proto of INTEGRATION_AGENTS) { @@ -232,6 +274,7 @@ class AppState { const idx = this.tasks.findIndex((t) => t.id === id); if (idx < 0) return; this.tasks.splice(idx, 1); + this.clearSessionDiffs(id); if (this.focusId === id) this.focusId = this.tasks[0]?.id ?? "integration_claude"; this.notify(); } diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index 573e1f631..7b471446a 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -4,6 +4,7 @@ // terminal (Windows Terminal, VS Code, PowerShell…) and all of them are handled. import { Bridge, onEvent } from "../core/bridge"; +import { buildFileDiff, fileName, makeDiffStep, toOneLine } from "../core/diff"; import { Sound } from "../core/sound"; import { State } from "../core/state"; import type { Island } from "./island"; @@ -32,8 +33,12 @@ interface HookPayload { message?: string; /** UserPromptSubmit carries `prompt`; `message` belongs to Notification/Stop. */ prompt?: string; + /** Stop: Claude's final answer of the turn (Markdown). */ + last_assistant_message?: string; tool_name?: string; tool_input?: Record; + /** Set by the relay when an edit was too big to forward whole (> 256 KB). */ + coucou_diff_truncated?: boolean; /** Optional agent tag: lowercase, digits and hyphens, ≤ 24 chars. */ coucou_agent?: string; } @@ -145,6 +150,28 @@ function clearSession() { t.stepIndex = 0; t.name = "VS Code"; t.pillBadge = null; + t.finalLine = null; +} + +/** The final message stays on the card until the next turn starts. */ +function clearFinalLine(id: string) { + const t = State.tasks.find((x) => x.id === id); + if (t) t.finalLine = null; +} + +/** + * PostToolUse of Edit / MultiEdit / Write → a diff stored for the pill and a + * ticker step with its +N −M counts. Nothing is kept for a pill that does not + * exist, so a stray event cannot grow memory. An edit the relay had to cut + * would give wrong counts: the PreToolUse step ("Modifie · file") stands alone. + */ +function recordDiff(agentId: string, payload: HookPayload) { + if (payload.coucou_diff_truncated) return; + if (!State.tasks.some((t) => t.id === agentId)) return; + const diff = buildFileDiff(payload.tool_name ?? "", payload.tool_input ?? {}); + if (!diff) return; + const id = State.appendSessionDiff(agentId, diff); + State.appendStep(agentId, makeDiffStep(fileName(diff.path), diff.added, diff.removed, id)); } export function registerHookHandlers(island: Island) { @@ -205,6 +232,7 @@ function handleHook(island: Island, payload: HookPayload) { switch (name) { case "SessionStart": ensurePill(); + clearFinalLine(agentId); surface("overview", false); Sound.play("work"); break; @@ -212,6 +240,7 @@ function handleHook(island: Island, payload: HookPayload) { case "UserPromptSubmit": { ensurePill(); supersedeStop(); + clearFinalLine(agentId); State.updateTask(agentId, "thinking"); // The field is `prompt`; reading `message` meant this step was always blank. const asked = payload.prompt ?? payload.message; @@ -223,6 +252,7 @@ function handleHook(island: Island, payload: HookPayload) { case "PreToolUse": { ensurePill(); supersedeStop(); + clearFinalLine(agentId); State.updateTask(agentId, "working"); const tool = payload.tool_name ?? "Tool"; State.appendStep(agentId, stepLabel(tool, payload.tool_input ?? {})); @@ -233,6 +263,7 @@ function handleHook(island: Island, payload: HookPayload) { case "PostToolUse": supersedeStop(); State.updateTask(agentId, "working"); + recordDiff(agentId, payload); break; case "PostToolUseFailure": @@ -256,9 +287,16 @@ function handleHook(island: Island, payload: HookPayload) { break; } - case "Stop": + case "Stop": { State.updateTask(agentId, "finished"); - if (payload.message) State.appendStep(agentId, payload.message.slice(0, 60)); + // Claude Code puts the turn's answer in the Stop payload itself, so there is + // no transcript to read (the relay does not even forward its path). + const finalText = toOneLine(payload.last_assistant_message ?? payload.message ?? ""); + if (finalText) { + State.appendStep(agentId, finalText); + const t = State.tasks.find((x) => x.id === agentId); + if (t) t.finalLine = finalText; + } Sound.play("finish"); if (focused) surface("finished", true); else State.setPillBadge(agentId, "finished"); @@ -276,6 +314,7 @@ function handleHook(island: Island, payload: HookPayload) { }, 5200), ); break; + } case "StopFailure": supersedeStop(); @@ -289,6 +328,7 @@ function handleHook(island: Island, payload: HookPayload) { // Nothing left for the timer to do, and it must not outlive the session: a // pill recreated within 5.2 s would be removed by it. cancelStopTimer(agentId); + State.clearSessionDiffs(agentId); if (isExternalAgent) { State.removeTask(agentId); } else { diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c..6fd4a3b2f 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -482,6 +482,137 @@ body { text-overflow: ellipsis; } +/* Live diff step: file name, then +N −M right after it (TickerRowView). */ +.ticker-row .tick-count { + flex: 0 0 auto; + margin-left: -6px; /* no row gap: the counts sit right after the name */ + font: 500 10px var(--mono); + white-space: pre; +} + +.ticker-row .tick-count .plus { + color: var(--green); +} + +.ticker-row .tick-count .minus { + color: var(--red); +} + +/* ── Diff card (overview, left card) — DiffCardView ─────────────────────── */ + +.diff-card { + position: relative; + z-index: 1; + height: 100%; + display: flex; + flex-direction: column; + padding: 4px 10px 4px 108px; +} + +.diff-head { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 0 3px; + min-width: 0; +} + +.diff-back { + display: flex; + align-items: center; + gap: 3px; + min-width: 0; + border: 0; + background: transparent; + color: var(--ink); + cursor: default; +} + +.diff-back b { + font: 600 12px var(--font); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.diff-head .grow { + flex: 1 1 auto; +} + +.diff-head .tick-count { + flex: 0 0 auto; + font: 500 10px var(--mono); +} + +.diff-head .plus { + color: var(--green); +} + +.diff-head .minus { + color: var(--red); +} + +.diff-head .icon-btn { + width: 14px; + height: 14px; + flex: 0 0 auto; +} + +.diff-note { + font: 400 10.5px var(--font); + color: var(--dim-3); +} + +.diff-lines { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + scrollbar-width: none; + user-select: text; +} + +.diff-lines::-webkit-scrollbar { + display: none; +} + +.diff-line { + display: flex; + min-height: 14px; /* an empty line still takes a row */ + font: 400 10.5px/1.35 var(--mono); + color: var(--dim-3); +} + +.diff-line .sym { + flex: 0 0 12px; + color: #454850; +} + +.diff-line .txt { + flex: 1 1 auto; + min-width: 0; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; +} + +.diff-line.added { + background: rgba(34, 197, 94, 0.12); + color: #86efac; +} + +.diff-line.added .sym { + color: var(--green); +} + +.diff-line.removed { + background: rgba(244, 80, 94, 0.12); + color: #fca5a5; +} + +.diff-line.removed .sym { + color: var(--red); +} + /* Shimmer sweep, 2.2 s — same as TickerShimmerText / ShimmeringText */ .shimmer { background: linear-gradient( @@ -588,6 +719,14 @@ body { font: 600 15px var(--font); } +/* Claude's final message on the finished card: one line, never past the card. */ +.title.one-line { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + .sub { font: 400 13px var(--font); color: var(--dim); diff --git a/windows/src/views/diff.ts b/windows/src/views/diff.ts new file mode 100644 index 000000000..9fface6eb --- /dev/null +++ b/windows/src/views/diff.ts @@ -0,0 +1,66 @@ +// Diff card — port of DiffCardView / DiffLineRowView (IslandViewContent.swift). +// Opens in the overview's left card when a diff step of the ticker is clicked. + +import { h, svg } from "./dom"; +import { ICONS } from "./icons"; +import { fileName, type DiffKind, type FileDiff } from "../core/diff"; + +const STRINGS = { + back: "Back", + open: "Open in VS Code", + tooLarge: "Diff too large", + noChanges: "No changes", +}; + +const SYMBOLS: Record = { added: "+", removed: "−", context: " " }; + +export interface DiffCardHooks { + dismiss(): void; + /** ↗ — opens the file in the editor. */ + open(path: string): void; +} + +export function buildDiffCard(diff: FileDiff, hooks: DiffCardHooks): HTMLElement { + const head = h( + "div", + { class: "diff-head" }, + h( + "button", + { class: "diff-back", title: STRINGS.back, onclick: () => hooks.dismiss() }, + svg(ICONS.chevronLeft, 9, { stroke: 2.4 }), + h("b", { text: fileName(diff.path) }), + ), + h("span", { class: "grow" }), + diff.added > 0 ? h("span", { class: "tick-count plus", text: `+${diff.added}` }) : null, + diff.removed > 0 ? h("span", { class: "tick-count minus", text: `−${diff.removed}` }) : null, + h( + "button", + { class: "icon-btn", title: STRINGS.open, onclick: () => hooks.open(diff.path) }, + svg(ICONS.arrowUpRight, 8), + ), + ); + + const lines = diff.hunks.flatMap((hunk) => hunk.lines); + let content: HTMLElement; + if (diff.tooLarge) { + content = h("div", { class: "diff-note", text: STRINGS.tooLarge }); + } else if (lines.length === 0) { + content = h("div", { class: "diff-note", text: STRINGS.noChanges }); + } else { + content = h("div", { class: "diff-lines" }); + const rows = document.createDocumentFragment(); + for (const line of lines) { + rows.append( + h( + "div", + { class: `diff-line ${line.kind}` }, + h("span", { class: "sym", text: SYMBOLS[line.kind] }), + h("span", { class: "txt", text: line.text }), + ), + ); + } + content.append(rows); + } + + return h("div", { class: "diff-card" }, head, content); +} diff --git a/windows/src/views/ticker.ts b/windows/src/views/ticker.ts index 72b18023e..3b0bf3f2e 100644 --- a/windows/src/views/ticker.ts +++ b/windows/src/views/ticker.ts @@ -7,9 +7,16 @@ // and any step that arrived mid-animation was dropped outright. Steps are now // queued instead, so a burst scrolls past rather than vanishing. +// +// Diff steps (see core/diff.ts) render as the file name followed by +N −M, and +// a click on one opens its diff. After Stop the card holds Claude's final +// message: no shimmer, no chevron, the ✓ on every row (TickerRowView isActive +// = false on macOS). + import { h, svg } from "./dom"; import { ICONS } from "./icons"; import { cubicBezier, clamp, lerp } from "../core/anim"; +import { parseDiffStep, type DiffStep } from "../core/diff"; import type { AgentTask } from "../core/state"; const ROW_H = 22; @@ -20,13 +27,24 @@ const MAX_QUEUE = 4; const COMPLETED_SCALE = 11.5 / 13; // 0.885 — the completed font size const EASE = cubicBezier(0.4, 0, 0.2, 1); +/** The current row's text once the turn is over (TickerRowView staticColor). */ +const STILL_CURRENT = "#c9cdd4"; +const DIM = "#6b7079"; + interface Row { el: HTMLElement; chevron: SVGElement; check: SVGElement; + body: HTMLElement; shimmer: HTMLElement; dim: HTMLElement; + /** +N −M, attached to the row only while it shows a diff. */ + counts: HTMLElement; + plus: HTMLElement; + minus: HTMLElement; text: string; + /** Set when the row shows a file diff. */ + diff: DiffStep | null; } function makeRow(): Row { @@ -38,36 +56,70 @@ function makeRow(): Row { const shimmer = h("span", { class: "tick-text shimmer" }); const dim = h("span", { class: "tick-text", - style: "position:absolute;left:0;right:0;color:#6b7079", + // top:0 is where a one-line row already put it; it also keeps a text wider + // than the row on that line instead of dropping it below the row. + style: `position:absolute;top:0;left:0;right:0;color:${DIM}`, }); + const body = h("span", { style: "position:relative;flex:1 1 auto;min-width:0" }, shimmer, dim); + const plus = h("span", { class: "plus" }); + const minus = h("span", { class: "minus" }); + const counts = h("span", { class: "tick-count" }, plus, minus); const el = h( "div", { class: "ticker-row" }, h("span", { class: "tick-icon", style: "position:relative" }, chevron, check), - h("span", { style: "position:relative;flex:1 1 auto;min-width:0" }, shimmer, dim), + body, ); - return { el, chevron, check, shimmer, dim, text: "" }; + return { el, chevron, check, body, shimmer, dim, counts, plus, minus, text: "", diff: null }; } function setText(row: Row, text: string) { if (row.text === text) return; row.text = text; - row.shimmer.textContent = text; - row.dim.textContent = text; + const diff = parseDiffStep(text); + row.diff = diff; + const shown = diff ? diff.filename : text; + row.shimmer.textContent = shown; + row.dim.textContent = shown; + if (diff) { + // Filename, then the counts right after it; the name truncates first. + row.body.style.flex = "0 1 auto"; + row.plus.textContent = diff.added > 0 ? ` +${diff.added}` : ""; + row.minus.textContent = diff.removed > 0 ? ` −${diff.removed}` : ""; + if (row.counts.parentNode !== row.el) row.el.append(row.counts); + row.el.classList.add("diff"); + } else if (row.el.classList.contains("diff")) { + // Back to an ordinary step: exactly the row it always was. + row.body.style.flex = "1 1 auto"; + row.counts.remove(); + row.el.classList.remove("diff"); + } } /** * Places a row. `phase` 0 = current (shimmering, full size), 1 = completed * (dim, shifted up-left and scaled down) — same crossfades as the Swift view. + * `still` is the finished look: static text, ✓ only, nothing animating. */ -function place(row: Row, y: number, phase: number, opacity: number) { +function place(row: Row, y: number, phase: number, opacity: number, still: boolean) { const scale = 1 - phase * (1 - COMPLETED_SCALE); row.el.style.transform = `translate(${-phase * 10}px, ${y}px) scale(${scale})`; row.el.style.opacity = String(opacity); - row.chevron.style.opacity = String(clamp(1 - phase * 2, 0, 1)); - row.check.style.opacity = String(clamp(phase * 2 - 1, 0, 1)); - row.shimmer.style.opacity = String(clamp(1 - phase * 1.6, 0, 1)); - row.dim.style.opacity = String(clamp(phase * 2 - 0.4, 0, 1)); + if (still) { + row.chevron.style.opacity = "0"; + row.check.style.opacity = "1"; + row.shimmer.style.opacity = "0"; + row.dim.style.opacity = "1"; + row.dim.style.color = phase < 0.5 ? STILL_CURRENT : DIM; + } else { + row.chevron.style.opacity = String(clamp(1 - phase * 2, 0, 1)); + row.check.style.opacity = String(clamp(phase * 2 - 1, 0, 1)); + row.shimmer.style.opacity = String(clamp(1 - phase * 1.6, 0, 1)); + row.dim.style.opacity = String(clamp(phase * 2 - 0.4, 0, 1)); + row.dim.style.color = DIM; + } + // An invisible shimmer must not keep the webview repainting. + row.shimmer.style.animationPlayState = still ? "paused" : ""; } export class Ticker { @@ -78,17 +130,27 @@ export class Ticker { private queue: string[] = []; private startMs: number | null = null; private displayIndex = -1; + /** True once Claude has finished: the final message holds still. */ + private still = false; - constructor() { + /** `onDiffTap` receives the diff id of a clicked diff row. */ + constructor(onDiffTap?: (diffId: number) => void) { this.el = h("div", { class: "ticker" }, this.a.el, this.b.el, this.c.el); + // The completed and the current row answer a click, as on macOS; the + // incoming row is only ever seen mid-slide. + for (const row of [this.a, this.b]) { + row.el.addEventListener("click", () => { + if (row.diff && onDiffTap) onDiffTap(row.diff.diffId); + }); + } this.rest(); } /** The state between transitions: completed on top, current below. */ private rest() { - place(this.a, 0, 1, 1); - place(this.b, ROW_H, 0, 1); - place(this.c, ROW_H * 2, 0, 0); + place(this.a, 0, 1, 1, this.still); + place(this.b, ROW_H, 0, 1, this.still); + place(this.c, ROW_H * 2, 0, 0, this.still); } get animating(): boolean { @@ -99,6 +161,13 @@ export class Ticker { const steps = task && task.steps.length > 0 ? task.steps : ["…"]; const idx = task ? Math.min(task.stepIndex, steps.length - 1) : -1; + const still = !!task?.finalLine; + if (still !== this.still) { + this.still = still; + // Mid-slide, the next frame places every row with the new look anyway. + if (this.startMs == null) this.rest(); + } + // First render: drop straight into place, no animation. if (this.displayIndex < 0) { this.displayIndex = idx; @@ -131,7 +200,7 @@ export class Ticker { if (this.startMs == null) { if (this.queue.length === 0) return; setText(this.c, this.queue[0]); - place(this.c, ROW_H * 2, 0, 0); + place(this.c, ROW_H * 2, 0, 0, this.still); this.startMs = nowMs; } @@ -139,9 +208,9 @@ export class Ticker { const e = EASE(p); // A leaves upwards and fades a little faster than it moves, as on macOS. - place(this.a, lerp(0, -ROW_H, e), 1, clamp(1 - p * 1.35, 0, 1)); - place(this.b, lerp(ROW_H, 0, e), e, 1); - place(this.c, lerp(ROW_H * 2, ROW_H, e), 0, e); + place(this.a, lerp(0, -ROW_H, e), 1, clamp(1 - p * 1.35, 0, 1), this.still); + place(this.b, lerp(ROW_H, 0, e), e, 1, this.still); + place(this.c, lerp(ROW_H * 2, ROW_H, e), 0, e, this.still); if (p < 1) return; diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f..96104c515 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -11,6 +11,9 @@ import { createMiniBot, pruneMiniBots } from "../mochi/minibots"; import { buildPrompt } from "./chat"; import { buildChoose, buildUpload, buildUploading } from "./upload"; import { renderIntegrationCard, type IntegrationCardHooks } from "./integrations"; +import { buildDiffCard } from "./diff"; +import { lastTextStep } from "../core/diff"; +import { Bridge } from "../core/bridge"; export interface ViewActions { setView(v: IslandViewName): void; @@ -117,7 +120,18 @@ export function buildHeader(actions: ViewActions): ViewHost { // ── Overview ────────────────────────────────────────────────────────────────── function buildOverview(actions: ViewActions): ViewHost { - const ticker = new Ticker(); + /** The diff open in the left card (a FileDiff id), as activeDiffId on macOS. */ + let activeDiffId: number | null = null; + const closeDiff = () => { + if (activeDiffId == null) return; + activeDiffId = null; + State.notify(); + }; + const ticker = new Ticker((diffId) => { + actions.blip(); + activeDiffId = diffId; + State.notify(); + }); const who = h("div", { class: "who" }); const tickerBody = h("div", { class: "card-body" }, who, ticker.el); const leftBody = h("div", { class: "left-body" }); @@ -138,9 +152,26 @@ function buildOverview(actions: ViewActions): ViewHost { let pillIds = ""; let detailOpen = false; let lastFocus: string | null = null; - let mode: "ticker" | "card" | null = null; + let mode: "ticker" | "card" | "diff" | null = null; let cardKey = ""; + // Leaving the overview or folding the island closes the diff, as on macOS. + State.subscribe(() => { + if (activeDiffId != null && (State.view !== "overview" || State.mode !== "expanded")) { + activeDiffId = null; + } + }); + // Escape steps back out of the diff before it closes the island. + window.addEventListener( + "keydown", + (e) => { + if (e.key !== "Escape" || activeDiffId == null || State.view !== "overview") return; + e.stopImmediatePropagation(); + closeDiff(); + }, + true, + ); + const hooks: IntegrationCardHooks = { get detailOpen() { return detailOpen; @@ -168,6 +199,7 @@ function buildOverview(actions: ViewActions): ViewHost { if (task?.id !== lastFocus) { lastFocus = task?.id ?? null; detailOpen = false; + activeDiffId = null; cardKey = ""; mode = null; } @@ -177,7 +209,25 @@ function buildOverview(actions: ViewActions): ViewHost { const sessionActive = task?.id === "integration_claude" && (task.state !== "idle" || task.steps.length > 0); - if (task && sessionActive) { + // A diff that has since been dropped (cap, expiry, session end) just closes. + const diff = task && activeDiffId != null ? State.findDiff(task.id, activeDiffId) : null; + if (!diff) activeDiffId = null; + + if (task && diff) { + const key = `diff~${task.id}~${diff.id}`; + if (key !== cardKey) { + cardKey = key; + mode = "diff"; + clear(leftBody); + leftBody.append(buildDiffCard(diff, { + dismiss: () => { + actions.blip(); + closeDiff(); + }, + open: (path) => void Bridge.openFileInVSCode(path), + })); + } + } else if (task && sessionActive) { if (mode !== "ticker") { clear(leftBody); leftBody.append(tickerBody); @@ -212,7 +262,7 @@ function buildOverview(actions: ViewActions): ViewHost { } } - jump.style.display = detailOpen ? "none" : ""; + jump.style.display = detailOpen || mode === "diff" ? "none" : ""; const others = State.otherTasks.slice(0, 4); const pillKey = others.map((t) => `${t.id}:${t.pillBadge ?? ""}`).join("|"); @@ -330,7 +380,7 @@ function buildQuestion(): ViewHost { clear(who); who.append(agentWho(State.focusTask, "Claude Code is asking a question")); const task = State.focusTask; - title.textContent = task?.steps.at(-1) ?? "Claude needs an answer."; + title.textContent = (task && lastTextStep(task.steps)) ?? "Claude needs an answer."; clear(row); row.append(h("div", { class: "sub", text: "Answer in your terminal — Coucou can't reply for you yet." })); }, @@ -355,7 +405,7 @@ function buildError(actions: ViewActions): ViewHost { clear(who); who.append(agentWho(task, task?.source === "n8n" ? "n8n" : "Claude Code")); title.textContent = task?.source === "n8n" ? "Workflow stopped." : "Session stopped on an error."; - detail.textContent = task?.steps.at(-1) ?? "No detail available."; + detail.textContent = (task && lastTextStep(task.steps)) ?? "No detail available."; }, }; } @@ -364,7 +414,7 @@ function buildError(actions: ViewActions): ViewHost { function buildFinished(actions: ViewActions): ViewHost { const who = h("div"); - const title = h("div", { class: "title" }); + const title = h("div", { class: "title one-line" }); const row = h("div", { class: "actions" }, btn("Open terminal", "primary", () => actions.openTerminal()), btn("OK", "secondary", () => actions.collapse()), @@ -375,7 +425,9 @@ function buildFinished(actions: ViewActions): ViewHost { sync() { clear(who); who.append(agentWho(State.focusTask, "Claude Code finished")); - title.textContent = State.focusTask?.steps.at(-1) ?? "Session finished"; + // Claude's final message, else the last step that is not a diff (FinishedView). + const task = State.focusTask; + title.textContent = task?.finalLine || (task && lastTextStep(task.steps)) || "Session finished"; }, }; } diff --git a/windows/tests/livediff.test.mjs b/windows/tests/livediff.test.mjs new file mode 100644 index 000000000..4c330ceff --- /dev/null +++ b/windows/tests/livediff.test.mjs @@ -0,0 +1,163 @@ +// Live diff and the finished line, end to end through the hook handler: file +// edits become ticker steps with their diff stored (and bounded), and Stop leaves +// Claude's final message on the card until the next turn. + +import { afterEach, beforeEach, mock, test } from "node:test"; +import assert from "node:assert/strict"; +import { emit } from "./tauri.mjs"; +import { registerHookHandlers } from "../src/island/hooks.ts"; +import { DEFAULT_SETTINGS, DIFF_TTL_MS, MAX_DIFFS_PER_PILL, State } from "../src/core/state.ts"; +import { parseDiffStep } from "../src/core/diff.ts"; + +const CLAUDE = "integration_claude"; + +const island = { alert() {}, setView() {}, reveal() {}, dropPin() {} }; +registerHookHandlers(island); + +const hook = (payload) => emit("hook", payload); +const task = (id = CLAUDE) => State.tasks.find((t) => t.id === id); +const diffs = (id = CLAUDE) => State.sessionDiffs.get(id) ?? []; + +const edit = (extra = {}) => + hook({ + hook_event_name: "PostToolUse", + cwd: "/p/proj", + tool_name: "Edit", + tool_input: { file_path: "/p/proj/src/app.ts", old_string: "a\nb\n", new_string: "a\nc\nd\n" }, + ...extra, + }); + +beforeEach(() => { + mock.timers.enable({ apis: ["setTimeout"] }); + for (const id of [...State.sessionDiffs.keys()]) State.clearSessionDiffs(id); + State.tasks = []; + State.focusId = null; + State.mode = "hidden"; + State.view = "overview"; + State.paused = false; + State.isPinned = false; + State.pendingApproval = null; + State.settings = { ...DEFAULT_SETTINGS }; + State.loadIntegrationTasks(); +}); + +afterEach(() => { + mock.timers.runAll(); + mock.timers.reset(); +}); + +// ── Live diff ───────────────────────────────────────────────────────────────── + +test("a finished Edit adds a diff step with its counts, and keeps the diff", () => { + edit(); + const step = parseDiffStep(task().steps.at(-1)); + assert.deepEqual({ ...step, diffId: undefined }, { filename: "app.ts", added: 2, removed: 1, diffId: undefined }); + const diff = State.findDiff(CLAUDE, step.diffId); + assert.equal(diff.path, "/p/proj/src/app.ts"); + assert.ok(diff.hunks.length > 0); +}); + +test("Write and MultiEdit are diffed too; other tools and PreToolUse are not", () => { + hook({ hook_event_name: "PostToolUse", tool_name: "Write", tool_input: { file_path: "C:\\p\\new.md", content: "x\ny\n" } }); + assert.deepEqual(parseDiffStep(task().steps.at(-1)).filename, "new.md"); + hook({ + hook_event_name: "PostToolUse", + tool_name: "MultiEdit", + tool_input: { file_path: "/p/m.ts", edits: [{ old_string: "a", new_string: "b" }, { old_string: "c", new_string: "d" }] }, + }); + const multi = parseDiffStep(task().steps.at(-1)); + assert.deepEqual([multi.added, multi.removed], [2, 2]); + + const before = task().steps.length; + hook({ hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "ls" } }); + hook({ hook_event_name: "PreToolUse", tool_name: "Edit", tool_input: { file_path: "/p/a.ts", old_string: "a", new_string: "b" } }); + assert.equal(task().steps.length, before + 1); // only the PreToolUse label + assert.equal(parseDiffStep(task().steps.at(-1)), null); + assert.equal(diffs().length, 2); +}); + +test("an edit with no change, or one the relay had to cut, adds no diff", () => { + hook({ hook_event_name: "PostToolUse", tool_name: "Edit", tool_input: { file_path: "/p/a.ts", old_string: "same", new_string: "same" } }); + edit({ coucou_diff_truncated: true }); + assert.deepEqual(task().steps, []); + assert.equal(diffs().length, 0); +}); + +test("diffs are capped per pill, oldest first, and ids stay unique", () => { + for (let i = 0; i < MAX_DIFFS_PER_PILL + 5; i++) edit(); + const kept = diffs(); + assert.equal(kept.length, MAX_DIFFS_PER_PILL); + assert.equal(new Set(kept.map((d) => d.id)).size, MAX_DIFFS_PER_PILL); + // The ticker keeps fewer steps than diffs; every step still finds its diff. + for (const s of task().steps) assert.ok(State.findDiff(CLAUDE, parseDiffStep(s).diffId)); + // The first ones are gone: tapping their step would be a no-op. + assert.equal(State.findDiff(CLAUDE, kept[0].id - 1), null); +}); + +test("diffs are forgotten an hour after the last one, and at the end of the session", () => { + edit(); + mock.timers.tick(DIFF_TTL_MS - 1000); + edit(); // re-arms the hour + mock.timers.tick(2000); + assert.equal(diffs().length, 2); + mock.timers.tick(DIFF_TTL_MS); + assert.equal(diffs().length, 0); + + edit(); + hook({ hook_event_name: "SessionEnd", cwd: "/p/proj" }); + assert.equal(diffs().length, 0); +}); + +test("an agent pill's diffs go with the pill", () => { + hook({ hook_event_name: "PreToolUse", coucou_agent: "gemini", tool_name: "Edit", tool_input: { file_path: "/p/a.ts" } }); + edit({ coucou_agent: "gemini" }); + assert.equal(diffs("agent_gemini").length, 1); + hook({ hook_event_name: "SessionEnd", coucou_agent: "gemini" }); + assert.equal(task("agent_gemini"), undefined); + assert.equal(State.sessionDiffs.has("agent_gemini"), false); + // An event for a pill that does not exist stores nothing. + edit({ coucou_agent: "ghost" }); + assert.equal(State.sessionDiffs.has("agent_ghost"), false); +}); + +// ── Final message ───────────────────────────────────────────────────────────── + +test("Stop shows the first paragraph of Claude's final message, on one line", () => { + hook({ + hook_event_name: "Stop", + last_assistant_message: "**Done.** Fixed the `parser` and\nadded tests.\n\n## Details\n- a\n- b", + }); + const line = "Done. Fixed the parser and added tests."; + assert.equal(task().finalLine, line); + assert.equal(task().steps.at(-1), line); +}); + +test("Stop falls back to `message`, and adds nothing when both are empty", () => { + hook({ hook_event_name: "Stop", message: "All good." }); + assert.equal(task().finalLine, "All good."); + hook({ hook_event_name: "UserPromptSubmit", prompt: "again" }); + hook({ hook_event_name: "Stop", last_assistant_message: "\n\n---\n" }); + assert.equal(task().finalLine, null); + assert.equal(task().steps.at(-1), "again"); +}); + +test("a long final message is kept to 200 characters", () => { + hook({ hook_event_name: "Stop", last_assistant_message: "word ".repeat(100) }); + assert.ok(task().finalLine.length <= 200); +}); + +for (const [what, event] of [ + ["a new prompt", { hook_event_name: "UserPromptSubmit", prompt: "next" }], + ["a tool starting", { hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "ls" } }], + ["a new session", { hook_event_name: "SessionStart" }], + ["the end of the session", { hook_event_name: "SessionEnd" }], +]) { + test(`the final message stays until ${what}`, () => { + hook({ hook_event_name: "Stop", last_assistant_message: "Done." }); + mock.timers.tick(5200); // back to idle: the line is still there + assert.equal(task().state, "idle"); + assert.equal(task().finalLine, "Done."); + hook({ cwd: "/p/proj", ...event }); + assert.equal(task().finalLine, null); + }); +} From b93bfa4e8a64fccb70dee41e2ee0e288e407dcb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:17:39 +0200 Subject: [PATCH 21/77] Windows README: live diff and the final line Co-Authored-By: Claude Opus 5.5 --- windows/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/windows/README.md b/windows/README.md index 2b897bd3d..cf0bca824 100644 --- a/windows/README.md +++ b/windows/README.md @@ -44,6 +44,7 @@ installs for the current user only — no admin prompt. | Click Mochi | It gets annoyed. Three times in a row and it goes dizzy | | Rest the pointer on Mochi for two seconds | Hearts | | Drag a file onto the island | Mochi turns into a box, swallows it, then offers to answer questions about it | +| Click a file in the session ticker | Its diff opens in the island; ↗ opens the file in VS Code, ‹ or `Esc` goes back | | `Esc` | Closes the island | | Tray icon | Open, Settings…, Pause, Quit | @@ -51,6 +52,13 @@ Everything else happens on its own: a Claude Code permission request opens the island with **Deny / Allow**, a finished session shows what it did, and your integrations sit in the coloured pills next to Mochi. +**Live diff.** Every file Claude edits (Edit, MultiEdit, Write) shows up in the +session ticker with its **+N −M** lines; click it for the diff. Same limits as the +Mac: past 200 KB or 4 000 lines only the counts are kept, at most 50 diffs per +session, and they are forgotten an hour after the last edit or when the session +ends. When Claude finishes, the card keeps the first paragraph of its final +answer on one line, still, until the next prompt. + ## Claude Code The settings window @@ -163,6 +171,12 @@ OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integr attach it as context, and jumping to a specific terminal window — "Open terminal" opens the working folder in VS Code when `code` is on your `PATH`. - Cal.com shows the next bookings as a list rather than the Mac's calendar. +- Live diff: the relay forwards an edit's text whole only once the edit is done + (PostToolUse), up to 256 KB per string and 512 KB per event. A bigger edit + shows its "Modifie · file" step without counts rather than wrong ones. The + diff's ↗ needs `code` on your `PATH`; without it, it opens the file's folder — + never the file itself. The diff shows for the Claude Code pill only, since + other agents' pills have no ticker here yet; there is no ⌘E-style shortcut. ## Linux From 23c09b90b805c137fc70074b53e78d549a537aec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:19:28 +0200 Subject: [PATCH 22/77] Mochi's outfits, drawn in code Port of the Mac's wardrobe (0.1.5, #195): party hat, beanie, crown, witch hat, Santa hat, bunny ears, bow, sunglasses, round glasses, scarf and pumpkin, from design/outfits/mochi-outfits.js and MochiOutfitDrawing.swift. Outfits follow the head in 3D, glasses sit on the eyes, pompoms and hat tips lag on a spring that reacts to taps and head turns, and outfits drop in and fade out. With an outfit on, a roll turns the whole character. Only the main Mochi is ever dressed. Auto mode picks the outfit from the season. Co-Authored-By: Claude Opus 5.5 --- windows/src/mochi/engine.ts | 130 +++- windows/src/mochi/outfits.ts | 1151 +++++++++++++++++++++++++++++++++ windows/src/mochi/wardrobe.ts | 137 ++++ 3 files changed, 1409 insertions(+), 9 deletions(-) create mode 100644 windows/src/mochi/outfits.ts create mode 100644 windows/src/mochi/wardrobe.ts diff --git a/windows/src/mochi/engine.ts b/windows/src/mochi/engine.ts index f1856b28d..02a47612b 100644 --- a/windows/src/mochi/engine.ts +++ b/windows/src/mochi/engine.ts @@ -7,6 +7,8 @@ import { Ease, lerp, type EaseFn } from "../core/anim"; import { Sound } from "../core/sound"; import type { BotEmoteName, BotStateName } from "../core/layout"; +import { PUMPKIN_BODY, drawOutfitBehind, drawOutfitFront, makeHead } from "./outfits"; +import type { Outfit } from "./wardrobe"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -36,7 +38,8 @@ interface Tween { type PropKey = | "yaw" | "pitch" | "roll" | "tilt" | "open" | "sx" | "sy" - | "oy" | "ox" | "tint" | "morph" | "hands" | "blush" | "es" | "badgeS"; + | "oy" | "ox" | "tint" | "morph" | "hands" | "blush" | "es" | "badgeS" + | "outfitPresence"; interface BotStateCfg { color: RGB; @@ -174,6 +177,22 @@ export class BotEngine { sx = 1; sy = 1; oy = 0; ox = 0; tint = 0; morph = 0; hands = 0; blush = 0; es = 1; badgeS = 0; + // Outfit (the main Mochi only — minis never wear one). `outfit` is what is + // drawn; it changes only once the previous one has left. + outfit: Outfit = "none"; + /** 0 = gone, 1 = fully on. */ + outfitPresence = 0; + private outfitTarget: Outfit = "none"; + + // Spring lag of the soft parts (pompoms, hat tips, scarf end), −1…1. + physDx = 0; + physDy = 0; + private physVx = 0; + private physVy = 0; + private prevYaw = 0; + private prevOy = 0; + private prevRoll = 0; + // Targets tgYaw = 0; tgPitch = 0; tgTilt = 0; tgSy = 1; tgSx = 1; tgEs = 1; @@ -279,6 +298,7 @@ export class BotEngine { } squash() { + this.physVy += 0.6; this.anim("sy", [[0.78, 70, Ease.out], [1.1, 130, Ease.out], [1, 170, Ease.inOut]]); this.anim("sx", [[1.16, 70, Ease.out], [0.95, 130, Ease.out], [1, 170, Ease.inOut]]); } @@ -304,6 +324,8 @@ export class BotEngine { this.slapTimes.push(t); Sound.play("slap"); this.squash(); + this.physVy -= 1.2; + this.physVx += Math.random() < 0.5 ? 0.7 : -0.7; if (this.slapTimes.length >= 3) { this.slapTimes = []; this.onDizzy?.(); @@ -325,6 +347,7 @@ export class BotEngine { const tok = ++this.greetToken; this.waveStart = t + 0.45; this.waveUntil = t + 1.55; + this.physVx += 0.2; this.eyeOverride = "happy"; this.eyeOverrideUntil = t + 2.0; @@ -452,6 +475,37 @@ export class BotEngine { this.morph = 0; } + /** + * Dresses Mochi. Animated: the old outfit leaves (180 ms), the new one drops + * in (350 ms) and Mochi does a little squash — BotEngine.setOutfit on macOS. + */ + setOutfit(next: Outfit, animated = true) { + if (next === this.outfitTarget) return; + this.outfitTarget = next; + this.tweens.delete("outfitPresence"); + this.locks.delete("outfitPresence"); + const enter = () => { + this.outfit = next; + this.anim("outfitPresence", [[1, 350, Ease.inOut]], () => this.squash()); + }; + if (!animated) { + this.outfit = next; + this.outfitPresence = next !== "none" ? 1 : 0; + } else if (next === "none") { + this.anim("outfitPresence", [[0, 180, Ease.inOut]], () => { this.outfit = "none"; }); + } else if (this.outfit === "none") { + this.outfitPresence = 0; + enter(); + } else { + this.anim("outfitPresence", [[0, 180, Ease.inOut]], enter); + } + } + + /** Wearing something visible: the body then turns as one piece when it rolls. */ + private get rigidRoll(): boolean { + return !this.isMini && this.outfit !== "none" && this.outfitPresence > 0.05; + } + /** True while anything is still moving — lets the island stop its RAF loop. */ get busy(): boolean { return ( @@ -468,7 +522,8 @@ export class BotEngine { this.slotH > 0.001 || Math.abs(this.slotHVel) > 0.001 || Math.abs(this.col[0] - this.colT[0]) > 0.003 || Math.abs(this.col[1] - this.colT[1]) > 0.003 || - Math.abs(this.col[2] - this.colT[2]) > 0.003 + Math.abs(this.col[2] - this.colT[2]) > 0.003 || + (this.outfit !== "none" && (Math.abs(this.physVx) > 0.01 || Math.abs(this.physVy) > 0.01)) ); } @@ -597,6 +652,25 @@ export class BotEngine { this.slotHVel += acc * dt; this.slotH = Math.max(0, this.slotH + this.slotHVel * dt); + // Soft-part spring: lags behind head turns, hops and rolls (stiffness 60, damping 9). + if (dt > 0) { + const yawVel = (this.yaw - this.prevYaw) / dt; + const oyVel = (this.oy - this.prevOy) / dt; + // A finished roll snaps from 2π·turns back to 0: that jump is not motion. + const dRoll = this.roll - this.prevRoll; + const rollVel = Math.abs(dRoll) > Math.PI ? 0 : dRoll / dt; + const centrifugal = this.rigidRoll ? rollVel * 0.18 : 0; + const tDx = Math.max(-1, Math.min(1, -yawVel * 0.35 - this.tilt * 2 + centrifugal)); + const tDy = Math.max(-1, Math.min(1, oyVel * 0.5)); + this.physVx += (60 * (tDx - this.physDx) - 9 * this.physVx) * dt; + this.physVy += (60 * (tDy - this.physDy) - 9 * this.physVy) * dt; + this.physDx += this.physVx * dt; + this.physDy += this.physVy * dt; + } + this.prevYaw = this.yaw; + this.prevOy = this.oy; + this.prevRoll = this.roll; + this.lastTime = n; } @@ -647,6 +721,15 @@ export class BotEngine { const cx = W / 2 + this.ox * R; const cy = H / 2 + this.particleOverhang / 2 + this.oy * R + R * 0.06; + // With an outfit on, a roll turns the whole character — hat included — as + // one piece instead of rolling the eyes over the body (BotCanvasView, macOS). + x.save(); + if (this.rigidRoll && Math.abs(this.roll) > 0.001) { + x.translate(cx, cy); + x.rotate(this.roll); + x.translate(-cx, -cy); + } + this.drawHandsBehind(x, R, rx, ry, cx, cy); x.save(); @@ -654,6 +737,11 @@ export class BotEngine { if (this.tilt !== 0) x.rotate(this.tilt); x.scale(this.sx, this.sy); + const dressed = !this.isMini && this.outfit !== "none"; + const head = dressed ? makeHead(R, this.yaw, this.pitch, this.physDx, this.physDy) : null; + const outfitState = { presence: this.outfitPresence, morph: this.morph }; + if (head) drawOutfitBehind(x, this.outfit, head, outfitState); + const body = this.bodyPath(rx, ry, R); this.drawBody(x, body, R, rx, ry); @@ -674,6 +762,9 @@ export class BotEngine { this.drawEyes(x, body, R, rx, ry); if (this.morph > 0.05) this.drawMouth(x, body, R); + if (head) drawOutfitFront(x, this.outfit, head, outfitState); + + x.restore(); x.restore(); if (this.badge && this.badgeS > 0.01 && this.morph < 0.25) { @@ -710,18 +801,37 @@ export class BotEngine { return p; } + /** How much of the pumpkin's orange shows on the body (it comes and goes with the outfit). */ + private get pumpkinAlpha(): number { + if (this.isMini || this.outfit !== "pumpkin") return 0; + return Math.min(1, this.outfitPresence * 2.5) * (1 - this.morph); + } + private drawBody(x: CanvasRenderingContext2D, body: Path2D, R: number, rx: number, ry: number) { + const pumpkin = this.pumpkinAlpha; if (this.bodyColor) { // Mini bots: flat solid fill — no gradient, no reflection, no highlight x.fillStyle = rgba(this.bodyColor, 1); x.fill(body); - return; + if (pumpkin <= 0.001) return; + } else { + const g = x.createLinearGradient(rx * 0.7, -ry * 0.85, -rx * 0.8, ry * 0.9); + g.addColorStop(0, rgba(BASE_TOP)); + g.addColorStop(1, rgba(BASE_BOTTOM)); + x.fillStyle = g; + x.fill(body); + } + x.save(); + if (pumpkin > 0.001) { + const pg = x.createLinearGradient(rx * 0.7, -ry * 0.85, -rx * 0.8, ry * 0.9); + pg.addColorStop(0, PUMPKIN_BODY[0]); + pg.addColorStop(1, PUMPKIN_BODY[1]); + x.globalAlpha = pumpkin; + x.fillStyle = pg; + x.fill(body); + // A flat-coloured body only gets the shading while it is a pumpkin. + x.globalAlpha = this.bodyColor ? pumpkin : 1; } - const g = x.createLinearGradient(rx * 0.7, -ry * 0.85, -rx * 0.8, ry * 0.9); - g.addColorStop(0, rgba(BASE_TOP)); - g.addColorStop(1, rgba(BASE_BOTTOM)); - x.fillStyle = g; - x.fill(body); const effectiveTint = this.tint * (1 - this.morph); if (effectiveTint > 0.01) { @@ -744,6 +854,7 @@ export class BotEngine { hl.addColorStop(1, "rgba(255,255,255,0)"); x.fillStyle = hl; x.fill(body); + x.restore(); } private drawEyes(x: CanvasRenderingContext2D, body: Path2D, R: number, rx: number, ry: number) { @@ -761,7 +872,8 @@ export class BotEngine { for (const sd of [-1, 1]) { const eyeYaw = sd * EYE_SP + this.yaw; - let eyePitch = EYE_P + this.pitch + this.roll; + // Rolling with an outfit on, the whole body turns: the eyes must not roll again. + let eyePitch = EYE_P + this.pitch + (this.rigidRoll ? 0 : this.roll); eyePitch = (((eyePitch + Math.PI) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) - Math.PI; const cp = Math.cos(eyePitch); if (Math.cos(eyeYaw) * cp <= 0.04) continue; diff --git a/windows/src/mochi/outfits.ts b/windows/src/mochi/outfits.ts new file mode 100644 index 000000000..3087cd532 --- /dev/null +++ b/windows/src/mochi/outfits.ts @@ -0,0 +1,1151 @@ +// Mochi's outfits, drawn in code — port of design/outfits/mochi-outfits.js (the +// Canvas reference the Mac version was built from) and of the Mac's +// NotchBuddy/Sources/CoucouKit/MochiOutfitDrawing.swift, whose fixes win where +// the two differ (front arcs found by silhouette, simplified drawing below +// R = 16, the enter/leave transitions, bunny ears). +// +// Coordinates are BotEngine's body space: origin at the body centre, y down, +// R = W × 0.3, rx = 1.14 R, ry = 0.88 R. The head is a superellipsoid whose +// horizontal radius at height y (y up, −1…1) is (1 − |y|^2.7)^(1/2.7), so its +// silhouette matches Mochi's body at yaw = pitch = 0. Accessories are seen +// slightly from above and follow the head pitch only partly, so a hat never +// flips to a top-down view. + +import { Ease } from "../core/anim"; +import type { Outfit, OutfitSelection } from "./wardrobe"; + +const EXP = 2.7; +const VIEW_TILT = -0.3; +const ACC_PITCH = 0.4; +const EYE_W = 0.25; +const EYE_H = 0.27; +const EYE_SP = 0.37; +const EYE_P = -0.12; + +/** Below this radius the small details (ribs, dots, gems) are left out. */ +const SIMPLIFY_BELOW_R = 16; + +type Ctx = CanvasRenderingContext2D; +type Vec3 = readonly [number, number, number]; +interface P3 { x: number; y: number; z: number } +interface Pt { x: number; y: number } + +/** Head geometry + the spring lag of the soft parts (−1…1, in head units). */ +export interface Head { + R: number; + rx: number; + ry: number; + yaw: number; + pitch: number; + physDx: number; + physDy: number; +} + +export function makeHead(R: number, yaw = 0, pitch = 0, physDx = 0, physDy = 0): Head { + return { R, rx: R * 1.14, ry: R * 0.88, yaw, pitch, physDx, physDy }; +} + +// ── 3D helpers ──────────────────────────────────────────────────────────────── + +/** Radius of the horizontal ring of the head at height y. */ +function ringR(y: number): number { + const a = Math.min(1, Math.abs(y)); + return Math.pow(1 - Math.pow(a, EXP), 1 / EXP); +} + +/** Head-local point (x right, y up, z toward the viewer) → body space, with depth. */ +function proj(H: Head, p: Vec3): P3 { + const [x, y, z] = p; + const cy = Math.cos(H.yaw); + const sy = Math.sin(H.yaw); + const x1 = x * cy + z * sy; + const z1 = -x * sy + z * cy; + const pitch = VIEW_TILT + H.pitch * ACC_PITCH; + const cp = Math.cos(pitch); + const sp = Math.sin(pitch); + const y2 = y * cp + z1 * sp; + const z2 = -y * sp + z1 * cp; + return { x: x1 * H.rx, y: -y2 * H.ry, z: z2 }; +} + +/** Point on the head surface at height y, longitude lon (0 = facing the viewer), scaled by s. */ +function surf(y: number, lon: number, s = 1): Vec3 { + const r = ringR(y) * s; + return [r * Math.sin(lon), y, r * Math.cos(lon)]; +} + +/** The visible half of a projected closed ring, left → right, cut at its silhouette. */ +function frontSilhouette(pts: P3[]): P3[] { + const n = pts.length; + if (n < 2) return pts; + let minI = 0; + let maxI = 0; + for (let i = 1; i < n; i++) { + if (pts[i].x < pts[minI].x) minI = i; + if (pts[i].x > pts[maxI].x) maxI = i; + } + if (minI === maxI) return [pts[minI]]; + const walk = (step: number) => { + const out: P3[] = []; + let i = minI; + for (;;) { + out.push(pts[i]); + if (i === maxI || out.length > n) break; + i = (i + step + n) % n; + } + return out; + }; + const a = walk(1); + const b = walk(-1); + const za = a.reduce((s, q) => s + q.z, 0) / a.length; + const zb = b.reduce((s, q) => s + q.z, 0) / b.length; + return za >= zb ? a : b; +} + +function ring(H: Head, y: number, s: number, n = 120): P3[] { + const pts: P3[] = []; + for (let i = 0; i < n; i++) pts.push(proj(H, surf(y, -Math.PI + (i / n) * 2 * Math.PI, s))); + return pts; +} + +/** Front arc of the ring at height y, ordered left → right. */ +function frontArc(H: Head, y: number, s: number): P3[] { + return frontSilhouette(ring(H, y, s)); +} + +/** The part of the head above the front arc of ring y — what a cap covers. */ +function capClip(H: Head, y: number, s: number, extraTop = 3): Path2D { + const arc = frontArc(H, y, s); + const p = new Path2D(); + if (arc.length === 0) return p; + p.moveTo(arc[0].x - H.rx, arc[0].y); + for (const q of arc) p.lineTo(q.x, q.y); + const last = arc[arc.length - 1]; + p.lineTo(last.x + H.rx, last.y); + p.lineTo(H.rx * 2, -H.ry * extraTop); + p.lineTo(-H.rx * 2, -H.ry * extraTop); + p.closePath(); + return p; +} + +/** Everything but `p`, for an even-odd clip. */ +function invert(p: Path2D, H: Head): Path2D { + const q = new Path2D(); + q.rect(-H.rx * 4, -H.ry * 4, H.rx * 8, H.ry * 8); + q.addPath(p); + return q; +} + +/** A big rectangle: fills whatever the current clip lets through. */ +function fillAll(ctx: Ctx, H: Head) { + ctx.fillRect(-H.rx * 4, -H.ry * 4, H.rx * 8, H.ry * 8); +} + +/** Mochi's body outline, same superellipse as the engine. */ +export function bodyOutline(rx: number, ry: number): Path2D { + const p = new Path2D(); + const n = 96; + const e = 2 / EXP; + for (let i = 0; i <= n; i++) { + const a = (i / n) * Math.PI * 2; + const ca = Math.cos(a); + const sa = Math.sin(a); + const x = rx * Math.sign(ca) * Math.pow(Math.abs(ca), e); + const y = ry * Math.sign(sa) * Math.pow(Math.abs(sa), e); + if (i === 0) p.moveTo(x, y); + else p.lineTo(x, y); + } + p.closePath(); + return p; +} + +function lin(ctx: Ctx, x0: number, y0: number, x1: number, y1: number, stops: [number, string][]) { + const g = ctx.createLinearGradient(x0, y0, x1, y1); + for (const [o, c] of stops) g.addColorStop(o, c); + return g; +} + +function rad(ctx: Ctx, x: number, y: number, r0: number, r1: number, stops: [number, string][]) { + const g = ctx.createRadialGradient(x, y, r0, x, y, Math.max(0.001, r1)); + for (const [o, c] of stops) g.addColorStop(o, c); + return g; +} + +function polyline(ctx: Ctx, pts: Pt[]) { + ctx.beginPath(); + pts.forEach((q, i) => (i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y))); +} + +function roundRect(ctx: Ctx, x: number, y: number, w: number, h: number, r: number) { + const rr = Math.max(0, Math.min(r, w / 2, h / 2)); + ctx.beginPath(); + ctx.moveTo(x + rr, y); + ctx.arcTo(x + w, y, x + w, y + h, rr); + ctx.arcTo(x + w, y + h, x, y + h, rr); + ctx.arcTo(x, y + h, x, y, rr); + ctx.arcTo(x, y, x + w, y, rr); + ctx.closePath(); +} + +// ── Eyes ────────────────────────────────────────────────────────────────────── + +export interface EyeFrame { + sd: number; + x: number; + y: number; + fx: number; + fy: number; + visible: boolean; + w: number; + h: number; +} + +/** Where the engine draws the eyes — the glasses sit on these. */ +export function eyeFrames(H: Head): EyeFrame[] { + return [-1, 1].map((sd) => { + const eyeYaw = sd * EYE_SP + H.yaw; + const eyePitch = EYE_P + H.pitch; + const cp = Math.cos(eyePitch); + return { + sd, + visible: Math.cos(eyeYaw) * cp > 0.04, + x: Math.sin(eyeYaw) * cp * H.rx, + y: -Math.sin(eyePitch) * H.ry, + fx: Math.max(0.18, Math.cos(eyeYaw)), + fy: Math.max(0.18, cp), + w: H.R * EYE_W, + h: H.R * EYE_H, + }; + }); +} + +// ── Soft bits ───────────────────────────────────────────────────────────────── + +function pompom(ctx: Ctx, x: number, y: number, r: number, base = "#FFFFFF", shade = "#D5D9E2") { + ctx.save(); + ctx.translate(x, y); + const n = 11; + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2; + const br = r * (0.34 + 0.06 * Math.sin(i * 2.3)); + const bx = Math.cos(a) * r * 0.78; + const by = Math.sin(a) * r * 0.78; + ctx.fillStyle = rad(ctx, bx - br * 0.4, by - br * 0.5, 0, br * 1.3, [[0, base], [1, shade]]); + ctx.beginPath(); + ctx.arc(bx, by, br, 0, Math.PI * 2); + ctx.fill(); + } + ctx.fillStyle = rad(ctx, -r * 0.3, -r * 0.35, 0, r * 1.05, [[0, base], [0.7, base], [1, shade]]); + ctx.beginPath(); + ctx.arc(0, 0, r * 0.86, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +/** Fuzzy band along a polyline (the Santa hat's trim). */ +function fuzzyBand(ctx: Ctx, arc: Pt[], thick: number, base = "#FFFFFF", shade = "#DADDE4") { + if (arc.length < 2) return; + ctx.save(); + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + polyline(ctx, arc); + ctx.strokeStyle = shade; + ctx.lineWidth = thick; + ctx.stroke(); + polyline(ctx, arc); + ctx.strokeStyle = base; + ctx.lineWidth = thick * 0.78; + ctx.stroke(); + const step = Math.max(2, Math.floor(arc.length / 16)); + for (let i = 0; i < arc.length; i += step) { + const q = arc[i]; + const r = thick * (0.32 + 0.1 * Math.sin(i * 1.7)); + ctx.fillStyle = rad(ctx, q.x - r * 0.3, q.y - thick * 0.35 - r * 0.3, 0, r * 1.2, [[0, base], [1, shade]]); + ctx.beginPath(); + ctx.arc(q.x, q.y - thick * 0.32, r, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); +} + +// ── Beanie ──────────────────────────────────────────────────────────────────── + +function beanie(ctx: Ctx, H: Head, body: Path2D, simple: boolean) { + const s = 1.035; + const yEdge = 0.42; + const yCuff = 0.58; + const head = bodyOutline(H.rx * s, H.ry * s); + + // shadow on the head under the cuff + ctx.save(); + ctx.clip(body); + ctx.clip(capClip(H, yEdge - 0.12, 1)); + ctx.fillStyle = "rgba(30,40,70,0.10)"; + fillAll(ctx, H); + ctx.restore(); + + // knit body + ctx.save(); + ctx.clip(capClip(H, yCuff, s)); + ctx.fillStyle = lin(ctx, H.rx * 0.5, -H.ry * 1.1, -H.rx * 0.6, H.ry * 0.2, [[0, "#7DB6FF"], [1, "#2F6FE0"]]); + ctx.fill(head); + if (!simple) { + ctx.clip(head); + ctx.strokeStyle = "rgba(20,50,140,0.16)"; + ctx.lineWidth = H.R * 0.045; + ctx.lineCap = "round"; + for (let k = -6; k <= 6; k++) { + const lon = k * 0.24; + const pts: P3[] = []; + for (let i = 0; i <= 16; i++) { + const q = proj(H, surf(yCuff + ((1.05 - yCuff) * i) / 16, lon, s)); + if (q.z > 0) pts.push(q); + } + if (pts.length < 2) continue; + polyline(ctx, pts); + ctx.stroke(); + } + } + ctx.restore(); + + // cuff — the band between yEdge and yCuff + const cuffHead = bodyOutline(H.rx * s * 1.04, H.ry * s * 1.04); + ctx.save(); + ctx.clip(capClip(H, yEdge, s * 1.04)); + ctx.clip(invert(capClip(H, yCuff, s * 1.04), H), "evenodd"); + ctx.fillStyle = lin(ctx, 0, -H.ry * 0.6, 0, -H.ry * 0.2, [[0, "#3C7BEA"], [1, "#2257C4"]]); + ctx.fill(cuffHead); + ctx.clip(cuffHead); + ctx.strokeStyle = "rgba(10,30,100,0.22)"; + ctx.lineWidth = H.R * 0.035; + for (let k = -14; k <= 14; k++) { + const lon = k * 0.115; + const a = proj(H, surf(yEdge, lon, s * 1.04)); + const b = proj(H, surf(yCuff, lon, s * 1.04)); + if (a.z < 0) continue; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + ctx.restore(); + + // top highlight + ctx.save(); + ctx.clip(capClip(H, yCuff, s)); + ctx.clip(head); + ctx.fillStyle = rad(ctx, H.rx * 0.3, -H.ry * 0.85, 0, H.R * 0.45, [[0, "rgba(255,255,255,0.35)"], [1, "rgba(255,255,255,0)"]]); + ctx.fill(head); + ctx.restore(); + + // pompom on a short spring + const top = proj(H, [0, 1.08 * s, 0]); + pompom(ctx, top.x + H.physDx * H.rx * 0.25, top.y - H.R * 0.12 + H.physDy * H.ry * 0.15, H.R * 0.24); +} + +// ── Santa hat ───────────────────────────────────────────────────────────────── + +function santaHat(ctx: Ctx, H: Head, body: Path2D) { + const s = 1.05; + const yEdge = 0.52; + const arc = frontArc(H, yEdge, s); + if (arc.length === 0) return; + const L = arc[0]; + const Rt = arc[arc.length - 1]; + const crown = proj(H, [0, 1.05, 0]); + // the tip flops to the right and down, plus the spring lag + const tip = { x: crown.x + H.rx * (0.95 + H.physDx * 0.35), y: crown.y + H.ry * (0.05 + H.physDy * 0.2) }; + const peak = { x: crown.x + H.rx * 0.25, y: crown.y - H.ry * 0.62 }; + const bag = new Path2D(); + bag.moveTo(L.x, L.y); + bag.bezierCurveTo(L.x - H.rx * 0.05, L.y - H.ry * 0.7, peak.x - H.rx * 0.55, peak.y - H.ry * 0.05, peak.x, peak.y); + bag.quadraticCurveTo(tip.x - H.rx * 0.05, peak.y - H.ry * 0.02, tip.x, tip.y); + bag.quadraticCurveTo(tip.x - H.rx * 0.12, tip.y - H.ry * 0.22, peak.x + H.rx * 0.18, peak.y + H.ry * 0.32); + bag.bezierCurveTo(Rt.x + H.rx * 0.05, peak.y + H.ry * 0.45, Rt.x + H.rx * 0.08, Rt.y - H.ry * 0.35, Rt.x, Rt.y); + for (let i = arc.length - 1; i >= 0; i--) bag.lineTo(arc[i].x, arc[i].y); + bag.closePath(); + + ctx.save(); + ctx.clip(body); + ctx.clip(capClip(H, yEdge - 0.14, 1)); + ctx.fillStyle = "rgba(120,10,10,0.10)"; + fillAll(ctx, H); + ctx.restore(); + + ctx.fillStyle = lin(ctx, -H.rx * 0.6, -H.ry * 1.6, H.rx * 0.7, -H.ry * 0.3, [[0, "#FF6B6B"], [0.55, "#E53935"], [1, "#B71C1C"]]); + ctx.fill(bag); + + // folds following the flop + ctx.save(); + ctx.clip(bag); + ctx.lineCap = "round"; + ctx.strokeStyle = "rgba(90,0,0,0.20)"; + for (const [a, b, w] of [[0.15, 0.55, 0.1], [0.45, 0.85, 0.08]]) { + ctx.beginPath(); + ctx.moveTo(peak.x - H.rx * 0.1 + (Rt.x - L.x) * a * 0.3, peak.y + H.ry * 0.15); + ctx.quadraticCurveTo(peak.x + H.rx * 0.35, peak.y + H.ry * (0.05 + a * 0.3), tip.x - H.rx * (0.45 - b * 0.3), tip.y - H.ry * 0.12); + ctx.lineWidth = H.R * w; + ctx.stroke(); + } + ctx.fillStyle = rad(ctx, peak.x - H.rx * 0.25, peak.y + H.ry * 0.05, 0, H.R * 0.5, [[0, "rgba(255,255,255,0.32)"], [1, "rgba(255,255,255,0)"]]); + ctx.fill(bag); + ctx.restore(); + + fuzzyBand(ctx, arc, H.R * 0.3); + pompom(ctx, tip.x, tip.y + H.R * 0.04, H.R * 0.22); +} + +// ── Party hat ───────────────────────────────────────────────────────────────── + +function partyHat(ctx: Ctx, H: Head, simple: boolean) { + const baseY = 0.82; + const baseR = 0.42; + const lean = -0.24 + H.physDx * 0.12; + const c = proj(H, [0.16, baseY + 0.06, 0]); + const rim: P3[] = []; + for (let i = 0; i <= 48; i++) { + const a = (i / 48) * Math.PI * 2; + rim.push(proj(H, [0.16 + baseR * Math.sin(a), baseY + 0.06, baseR * Math.cos(a)])); + } + const left = rim.reduce((m, q) => (q.x < m.x ? q : m)); + const right = rim.reduce((m, q) => (q.x > m.x ? q : m)); + const h = H.ry * 1.6; + const apex = { x: c.x + Math.sin(lean) * h, y: c.y - Math.cos(lean) * h }; + const front = frontSilhouette(rim); + + const cone = new Path2D(); + cone.moveTo(left.x, left.y); + cone.quadraticCurveTo((left.x + apex.x) / 2 - H.rx * 0.06, (left.y + apex.y) / 2, apex.x - H.R * 0.05, apex.y + H.R * 0.06); + cone.quadraticCurveTo(apex.x, apex.y - H.R * 0.03, apex.x + H.R * 0.05, apex.y + H.R * 0.06); + cone.quadraticCurveTo((right.x + apex.x) / 2 + H.rx * 0.06, (right.y + apex.y) / 2, right.x, right.y); + for (let i = front.length - 1; i >= 0; i--) cone.lineTo(front[i].x, front[i].y); + cone.closePath(); + ctx.fillStyle = lin(ctx, left.x, apex.y, right.x, left.y, [[0, "#FF9BD0"], [0.5, "#F15BAE"], [1, "#C2187A"]]); + ctx.fill(cone); + + ctx.save(); + ctx.clip(cone); + if (!simple) { + ctx.fillStyle = "rgba(255,255,255,0.92)"; + const dots = [[0.25, -0.35], [0.3, 0.3], [0.55, -0.05], [0.72, 0.28], [0.8, -0.3], [0.45, 0.6], [0.48, -0.65]]; + for (const [t, u] of dots) { + const bx = left.x + (right.x - left.x) * (0.5 + u * 0.5); + const by = left.y + (right.y - left.y) * (0.5 + u * 0.5); + const x = bx + (apex.x - bx) * (1 - t); + const y = by + (apex.y - by) * (1 - t); + const r = H.R * 0.075 * (0.6 + t * 0.5); + ctx.beginPath(); + ctx.ellipse(x, y, r, r * 0.9, 0, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.fillStyle = lin(ctx, left.x, 0, right.x, 0, [[0, "rgba(255,255,255,0.28)"], [0.35, "rgba(255,255,255,0)"], [1, "rgba(80,0,40,0.18)"]]); + ctx.fill(cone); + ctx.restore(); + + if (front.length > 1) { + polyline(ctx, front); + ctx.strokeStyle = "#FFD84D"; + ctx.lineWidth = H.R * 0.07; + ctx.lineCap = "round"; + ctx.stroke(); + } + pompom(ctx, apex.x, apex.y - H.R * 0.04, H.R * 0.16, "#FFE27A", "#F2B705"); +} + +// ── Crown ───────────────────────────────────────────────────────────────────── + +const CROWN_YB = 0.46; + +/** side −1: the back half, behind the head; +1: the front half. */ +function crownPart(ctx: Ctx, H: Head, side: number, simple: boolean) { + const s = 1.06; + const yb = CROWN_YB; + const yt = 0.66; + const n = 8; + const spikeH = 0.42; + const N = 120; + const seg: { b: P3; tt: P3; z: number }[] = []; + for (let i = 0; i <= N; i++) { + const lon = -Math.PI + (i / N) * 2 * Math.PI; + const b = proj(H, surf(yb, lon, s)); + const phase = ((lon + Math.PI) / (2 * Math.PI)) * n; + const f = phase - Math.floor(phase); + const spike = Math.pow(Math.max(0, 1 - Math.abs(f - 0.5) * 2), 1.6); + const sp = surf(yt, lon, s); + const tt = proj(H, [sp[0] * (1 - 0.08 * spike), yt + spikeH * spike, sp[2] * (1 - 0.08 * spike)]); + seg.push({ b, tt, z: b.z }); + } + const keep = seg.filter((q) => (side > 0 ? q.z >= 0 : q.z < 0.02)); + if (keep.length < 2) return; + keep.sort((a, b) => a.b.x - b.b.x); + const shape = new Path2D(); + keep.forEach((q, i) => (i ? shape.lineTo(q.tt.x, q.tt.y) : shape.moveTo(q.tt.x, q.tt.y))); + for (let i = keep.length - 1; i >= 0; i--) shape.lineTo(keep[i].b.x, keep[i].b.y); + shape.closePath(); + + const dark = side < 0; + ctx.fillStyle = lin(ctx, 0, -H.ry * 1.05, 0, -H.ry * 0.45, dark + ? [[0, "#C98A12"], [1, "#8A5A06"]] + : [[0, "#FFE58A"], [0.5, "#FBBF24"], [1, "#D08A0B"]]); + ctx.fill(shape); + if (dark) return; + + ctx.save(); + ctx.clip(shape); + ctx.fillStyle = lin(ctx, -H.rx, 0, H.rx, 0, [ + [0, "rgba(120,70,0,0.25)"], [0.45, "rgba(255,255,255,0)"], + [0.62, "rgba(255,255,255,0.35)"], [1, "rgba(120,70,0,0.25)"], + ]); + ctx.fill(shape); + ctx.restore(); + if (simple) return; + + const gems = ["#EF4444", "#3B82F6", "#22C55E", "#A855F7"]; + for (let k = 0; k < n; k++) { + const lon = -Math.PI + ((k + 0.5) / n) * 2 * Math.PI; + const sp = surf(yt, lon, s); + const tipP = proj(H, [sp[0] * 0.92, yt + spikeH, sp[2] * 0.92]); + const mid = proj(H, surf((yb + yt) / 2, lon, s * 1.01)); + if (mid.z <= 0.12) continue; + const r = H.R * 0.055; + ctx.beginPath(); + ctx.arc(tipP.x, tipP.y - r * 0.5, r, 0, Math.PI * 2); + ctx.fillStyle = rad(ctx, tipP.x - r * 0.3, tipP.y - r, 0, r * 1.2, [[0, "#FFF6CC"], [1, "#E0A21A"]]); + ctx.fill(); + const gr = H.R * 0.075; + ctx.beginPath(); + ctx.ellipse(mid.x, mid.y, gr * Math.max(0.35, mid.z), gr, 0, 0, Math.PI * 2); + ctx.fillStyle = gems[k % gems.length]; + ctx.fill(); + ctx.beginPath(); + ctx.arc(mid.x - gr * 0.25 * mid.z, mid.y - gr * 0.35, gr * 0.28, 0, Math.PI * 2); + ctx.fillStyle = "rgba(255,255,255,0.75)"; + ctx.fill(); + } +} + +function crownFront(ctx: Ctx, H: Head, body: Path2D, simple: boolean) { + // shadow of the band on the head + ctx.save(); + ctx.clip(body); + ctx.clip(capClip(H, CROWN_YB - 0.1, 1)); + ctx.clip(invert(capClip(H, CROWN_YB, 1), H), "evenodd"); + ctx.fillStyle = "rgba(80,50,0,0.12)"; + fillAll(ctx, H); + ctx.restore(); + crownPart(ctx, H, 1, simple); +} + +// ── Witch hat ───────────────────────────────────────────────────────────────── + +function witchBrim(H: Head): P3[] { + const y = 0.7; + const rr = 1.42; + const pts: P3[] = []; + for (let i = 0; i <= 120; i++) { + const a = -Math.PI + (i / 120) * 2 * Math.PI; + const wob = 1 + 0.035 * Math.sin(a * 3 + 0.6); + const droop = -0.1 * Math.pow(Math.abs(Math.sin(a)), 2); // the edges droop a little + pts.push(proj(H, [rr * wob * Math.sin(a), y + droop, rr * wob * Math.cos(a)])); + } + return pts; +} + +function closedPath(pts: Pt[]): Path2D { + const p = new Path2D(); + pts.forEach((q, i) => (i ? p.lineTo(q.x, q.y) : p.moveTo(q.x, q.y))); + p.closePath(); + return p; +} + +/** The whole brim, behind the head; front() paints its front half again. */ +function witchHatBack(ctx: Ctx, H: Head) { + ctx.fillStyle = lin(ctx, 0, -H.ry, 0, -H.ry * 0.4, [[0, "#2A0A4F"], [1, "#3B0F6B"]]); + ctx.fill(closedPath(witchBrim(H))); +} + +function witchHatFront(ctx: Ctx, H: Head, body: Path2D) { + const all = witchBrim(H); + const brim = closedPath(all); + const fr = all.filter((p) => p.z >= 0).sort((a, b) => a.x - b.x); + + ctx.save(); + ctx.clip(body); + ctx.clip(capClip(H, 0.5, 1)); + ctx.fillStyle = "rgba(40,0,70,0.10)"; + fillAll(ctx, H); + ctx.restore(); + + ctx.fillStyle = lin(ctx, 0, -H.ry * 0.9, 0, -H.ry * 0.3, [[0, "#5B21B6"], [1, "#3B0764"]]); + ctx.fill(brim); + if (fr.length > 1) { + polyline(ctx, fr); + ctx.strokeStyle = "rgba(190,150,255,0.35)"; + ctx.lineWidth = H.R * 0.035; + ctx.lineCap = "round"; + ctx.stroke(); + } + + // cone: base ring r = 0.62 at y = 0.74, tall apex, the tip bends over + const baseR = 0.62; + const by = 0.74; + const bl = proj(H, [-baseR, by, 0]); + const br = proj(H, [baseR, by, 0]); + const c = proj(H, [0, by, 0]); + const lean = 0.1 + H.physDx * 0.15; + const top = { x: c.x + H.rx * 0.18 + Math.sin(lean) * H.ry * 0.3, y: c.y - H.ry * 1.25 }; + const tip = { x: top.x + H.rx * (0.45 + H.physDx * 0.25), y: top.y + H.ry * (0.22 + H.physDy * 0.1) }; + const capFront = frontArc(H, by, baseR / ringR(by)).filter((q) => q.x >= bl.x - 1 && q.x <= br.x + 1); + const cone = new Path2D(); + cone.moveTo(bl.x, bl.y); + cone.bezierCurveTo(bl.x + H.rx * 0.12, bl.y - H.ry * 0.5, top.x - H.rx * 0.28, top.y + H.ry * 0.25, top.x - H.rx * 0.02, top.y - H.ry * 0.02); + cone.quadraticCurveTo(top.x + H.rx * 0.25, top.y - H.ry * 0.08, tip.x, tip.y); + cone.quadraticCurveTo(top.x + H.rx * 0.22, top.y + H.ry * 0.08, top.x + H.rx * 0.14, top.y + H.ry * 0.22); + cone.bezierCurveTo(br.x - H.rx * 0.18, c.y - H.ry * 0.45, br.x - H.rx * 0.02, br.y - H.ry * 0.2, br.x, br.y); + for (let i = capFront.length - 1; i >= 0; i--) cone.lineTo(capFront[i].x, capFront[i].y); + cone.closePath(); + ctx.fillStyle = lin(ctx, bl.x, top.y, br.x, bl.y, [[0, "#7C3AED"], [0.55, "#4C1D95"], [1, "#2E1065"]]); + ctx.fill(cone); + + ctx.save(); + ctx.clip(cone); + ctx.fillStyle = lin(ctx, bl.x, 0, br.x, 0, [[0, "rgba(255,255,255,0.22)"], [0.4, "rgba(255,255,255,0)"], [1, "rgba(0,0,0,0.15)"]]); + ctx.fill(cone); + // crease where the tip bends + ctx.beginPath(); + ctx.moveTo(top.x - H.rx * 0.05, top.y + H.ry * 0.05); + ctx.quadraticCurveTo(top.x + H.rx * 0.1, top.y + H.ry * 0.12, top.x + H.rx * 0.2, top.y + H.ry * 0.06); + ctx.strokeStyle = "rgba(20,0,40,0.35)"; + ctx.lineWidth = H.R * 0.05; + ctx.lineCap = "round"; + ctx.stroke(); + // orange band, just above the base + const fc = proj(H, [0, by, baseR]); + const lift = H.ry * 0.11; + ctx.beginPath(); + ctx.moveTo(bl.x - 2, bl.y - lift); + ctx.quadraticCurveTo(fc.x, 2 * (fc.y - lift) - (bl.y + br.y) / 2, br.x + 2, br.y - lift); + ctx.strokeStyle = "#F97316"; + ctx.lineWidth = H.ry * 0.17; + ctx.lineCap = "butt"; + ctx.stroke(); + ctx.restore(); + + // buckle + const bw = H.R * 0.2; + const bh = H.R * 0.16; + ctx.save(); + ctx.translate(fc.x, fc.y - H.ry * 0.11); + roundRect(ctx, -bw / 2, -bh / 2, bw, bh, bh * 0.25); + ctx.fillStyle = "#FCD34D"; + ctx.fill(); + roundRect(ctx, -bw / 2 + bw * 0.24, -bh / 2 + bh * 0.28, bw * 0.52, bh * 0.44, bh * 0.1); + ctx.fillStyle = "#C2410C"; + ctx.fill(); + ctx.restore(); +} + +// ── Glasses (pinned to the real eye positions) ──────────────────────────────── + +function sunglasses(ctx: Ctx, H: Head, body: Path2D) { + const eyes = eyeFrames(H); + const w = H.R * 0.62; + const h = H.R * 0.46; + ctx.save(); + ctx.clip(body); + ctx.strokeStyle = "#111317"; + ctx.lineCap = "round"; + const [l, r] = eyes; + if (l.visible && r.visible) { + ctx.beginPath(); + ctx.moveTo(l.x + (w / 2) * l.fx * 0.9, l.y - h * 0.18); + ctx.quadraticCurveTo((l.x + r.x) / 2, (l.y + r.y) / 2 - h * 0.42, r.x - (w / 2) * r.fx * 0.9, r.y - h * 0.18); + ctx.lineWidth = H.R * 0.07; + ctx.stroke(); + } + ctx.lineWidth = H.R * 0.06; + for (const e of eyes) { + if (!e.visible) continue; + ctx.beginPath(); + ctx.moveTo(e.x + (e.sd * w) / 2 * e.fx, e.y - h * 0.2); + ctx.lineTo(e.sd * H.rx * 1.05, e.y - h * 0.35); + ctx.stroke(); + } + for (const e of eyes) { + if (!e.visible) continue; + ctx.save(); + ctx.translate(e.x, e.y); + ctx.scale(e.fx, e.fy); + roundRect(ctx, -w / 2, -h / 2, w, h, h * 0.42); + ctx.fillStyle = "rgba(17,19,23,0.82)"; + ctx.fill(); + ctx.lineWidth = H.R * 0.05; + ctx.strokeStyle = "#0B0C0F"; + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(-w * 0.28, -h * 0.05); + ctx.lineTo(-w * 0.05, -h * 0.3); + ctx.strokeStyle = "rgba(255,255,255,0.45)"; + ctx.stroke(); + ctx.restore(); + } + ctx.restore(); +} + +function roundGlasses(ctx: Ctx, H: Head, body: Path2D) { + const eyes = eyeFrames(H); + const d = H.R * 0.56; + ctx.save(); + ctx.clip(body); + ctx.strokeStyle = "#8A4B12"; + ctx.lineCap = "round"; + const [l, r] = eyes; + if (l.visible && r.visible) { + ctx.beginPath(); + ctx.moveTo(l.x + (d / 2) * l.fx, l.y - d * 0.08); + ctx.quadraticCurveTo((l.x + r.x) / 2, (l.y + r.y) / 2 - d * 0.3, r.x - (d / 2) * r.fx, r.y - d * 0.08); + ctx.lineWidth = H.R * 0.055; + ctx.stroke(); + } + ctx.lineWidth = H.R * 0.05; + for (const e of eyes) { + if (!e.visible) continue; + ctx.beginPath(); + ctx.moveTo(e.x + (e.sd * d) / 2 * e.fx, e.y - d * 0.1); + ctx.lineTo(e.sd * H.rx * 1.05, e.y - d * 0.25); + ctx.stroke(); + } + for (const e of eyes) { + if (!e.visible) continue; + ctx.save(); + ctx.translate(e.x, e.y); + ctx.scale(e.fx, e.fy); + ctx.beginPath(); + ctx.arc(0, 0, d / 2, 0, Math.PI * 2); + ctx.fillStyle = "rgba(190,225,255,0.18)"; + ctx.fill(); + ctx.lineWidth = H.R * 0.065; + ctx.strokeStyle = "#9A5A1A"; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(0, 0, d / 2 - H.R * 0.03, Math.PI * 1.1, Math.PI * 1.45); + ctx.strokeStyle = "rgba(255,255,255,0.55)"; + ctx.lineWidth = H.R * 0.03; + ctx.stroke(); + ctx.restore(); + } + ctx.restore(); +} + +// ── Scarf ───────────────────────────────────────────────────────────────────── + +function scarf(ctx: Ctx, H: Head) { + const s = 1.05; + const y0 = -0.34; + const y1 = -0.66; + const top = frontArc(H, y0, s); + const bot = frontArc(H, y1, s); + if (top.length === 0 || bot.length === 0) return; + const band = new Path2D(); + top.forEach((q, i) => (i ? band.lineTo(q.x, q.y) : band.moveTo(q.x, q.y))); + for (let i = bot.length - 1; i >= 0; i--) band.lineTo(bot[i].x, bot[i].y); + band.closePath(); + + ctx.save(); + ctx.clip(bodyOutline(H.rx * s, H.ry * s)); + ctx.fillStyle = lin(ctx, 0, -H.ry * 0.2, 0, H.ry * 0.7, [[0, "#F87171"], [1, "#B91C1C"]]); + ctx.fill(band); + ctx.save(); + ctx.clip(band); + ctx.strokeStyle = "rgba(255,255,255,0.85)"; + ctx.lineCap = "round"; + for (const lon of [-1.0, -0.45, 0.1, 0.65, 1.2]) { + const a = proj(H, surf(y0, lon, s)); + const b = proj(H, surf(y1, lon, s)); + if (a.z < 0) continue; + ctx.beginPath(); + ctx.moveTo(a.x, a.y - 4); + ctx.lineTo(b.x, b.y + 4); + ctx.lineWidth = H.R * 0.09 * Math.max(0.3, a.z); + ctx.stroke(); + } + ctx.restore(); + ctx.fillStyle = lin(ctx, 0, -H.ry * 0.5, 0, H.ry * 0.3, [[0, "rgba(255,255,255,0.18)"], [1, "rgba(0,0,0,0.1)"]]); + ctx.fill(band); + ctx.restore(); + + // the hanging end, from the knot + const k = proj(H, surf((y0 + y1) / 2, -0.55, s * 1.03)); + if (k.z <= 0) return; + const sw = H.physDx * H.rx * 0.12; + const end = new Path2D(); + end.moveTo(k.x - H.R * 0.16, k.y); + end.quadraticCurveTo(k.x - H.R * 0.24 + sw, k.y + H.ry * 0.35, k.x - H.R * 0.2 + sw * 1.4, k.y + H.ry * 0.62); + end.lineTo(k.x + H.R * 0.06 + sw * 1.4, k.y + H.ry * 0.6); + end.quadraticCurveTo(k.x + H.R * 0.02 + sw, k.y + H.ry * 0.3, k.x + H.R * 0.12, k.y); + end.closePath(); + ctx.fillStyle = lin(ctx, 0, k.y, 0, k.y + H.ry * 0.6, [[0, "#EF4444"], [1, "#B91C1C"]]); + ctx.fill(end); + ctx.save(); + ctx.clip(end); + ctx.fillStyle = "rgba(255,255,255,0.85)"; + for (const t of [0.35, 0.7]) ctx.fillRect(k.x - H.R * 0.4 + sw, k.y + H.ry * 0.62 * t, H.R * 0.8, H.R * 0.07); + ctx.restore(); + // fringe + ctx.strokeStyle = "#DC2626"; + ctx.lineWidth = H.R * 0.035; + ctx.lineCap = "round"; + for (let i = 0; i < 4; i++) { + const fx = k.x - H.R * 0.17 + sw * 1.4 + i * H.R * 0.075; + ctx.beginPath(); + ctx.moveTo(fx, k.y + H.ry * 0.6); + ctx.lineTo(fx, k.y + H.ry * 0.72); + ctx.stroke(); + } + // knot + ctx.beginPath(); + ctx.ellipse(k.x, k.y, H.R * 0.17, H.R * 0.14, 0.2, 0, Math.PI * 2); + ctx.fillStyle = rad(ctx, k.x - H.R * 0.05, k.y - H.R * 0.05, 0, H.R * 0.2, [[0, "#F87171"], [1, "#B91C1C"]]); + ctx.fill(); +} + +// ── Pumpkin (the body colours come from the engine) ─────────────────────────── + +export const PUMPKIN_BODY: readonly [string, string] = ["#FFA94D", "#E8590C"]; + +function pumpkin(ctx: Ctx, H: Head, body: Path2D, simple: boolean) { + if (!simple) { + ctx.save(); + ctx.clip(body); + ctx.lineCap = "round"; + for (const lon of [-1.15, -0.55, 0.0, 0.55, 1.15]) { + const pts: P3[] = []; + for (let i = 0; i <= 30; i++) { + const q = proj(H, surf(-0.98 + (1.96 * i) / 30, lon, 1)); + if (q.z > 0) pts.push(q); + } + if (pts.length < 2) continue; + const zz = pts[Math.floor(pts.length / 2)].z; + polyline(ctx, pts); + ctx.strokeStyle = `rgba(150,50,0,${0.22 * zz})`; + ctx.lineWidth = H.R * 0.12; + ctx.stroke(); + polyline(ctx, pts.map((q) => ({ x: q.x + H.R * 0.07, y: q.y }))); + ctx.strokeStyle = `rgba(255,220,170,${0.18 * zz})`; + ctx.lineWidth = H.R * 0.04; + ctx.stroke(); + } + ctx.restore(); + } + const t = proj(H, [0.02, 1.0, 0]); + // stem + ctx.beginPath(); + ctx.moveTo(t.x - H.R * 0.09, t.y + H.R * 0.04); + ctx.quadraticCurveTo(t.x - H.R * 0.08, t.y - H.R * 0.22, t.x + H.R * 0.08, t.y - H.R * 0.3); + ctx.lineTo(t.x + H.R * 0.13, t.y - H.R * 0.22); + ctx.quadraticCurveTo(t.x + H.R * 0.04, t.y - H.R * 0.15, t.x + H.R * 0.08, t.y + H.R * 0.04); + ctx.closePath(); + ctx.fillStyle = lin(ctx, t.x - H.R * 0.1, 0, t.x + H.R * 0.1, 0, [[0, "#65A30D"], [1, "#3F6212"]]); + ctx.fill(); + // leaf + ctx.save(); + ctx.translate(t.x - H.R * 0.06, t.y - H.R * 0.02); + ctx.rotate(-0.5); + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.quadraticCurveTo(-H.R * 0.18, -H.R * 0.2, -H.R * 0.38, -H.R * 0.02); + ctx.quadraticCurveTo(-H.R * 0.18, H.R * 0.1, 0, 0); + ctx.fillStyle = lin(ctx, 0, -H.R * 0.15, -H.R * 0.3, 0, [[0, "#84CC16"], [1, "#4D7C0F"]]); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(-H.R * 0.02, -H.R * 0.01); + ctx.quadraticCurveTo(-H.R * 0.18, -H.R * 0.08, -H.R * 0.32, -H.R * 0.03); + ctx.strokeStyle = "rgba(30,60,0,0.4)"; + ctx.lineWidth = H.R * 0.02; + ctx.lineCap = "round"; + ctx.stroke(); + ctx.restore(); + if (simple) return; + // curly tendril + ctx.beginPath(); + ctx.moveTo(t.x + H.R * 0.1, t.y - H.R * 0.12); + ctx.bezierCurveTo(t.x + H.R * 0.3, t.y - H.R * 0.25, t.x + H.R * 0.35, t.y - H.R * 0.02, t.x + H.R * 0.22, t.y - H.R * 0.06); + ctx.strokeStyle = "#4D7C0F"; + ctx.lineWidth = H.R * 0.03; + ctx.lineCap = "round"; + ctx.stroke(); +} + +// ── Bow (anchored in 3D, turns with the head) ───────────────────────────────── + +function bow(ctx: Ctx, H: Head) { + const a = proj(H, surf(0.86, 0.55, 1.02)); + if (a.z < -0.2) return; + const s = H.R * 0.26; + const sq = Math.max(0.45, Math.cos(0.55 + H.yaw)); + ctx.save(); + ctx.translate(a.x, a.y); + ctx.rotate(0.35 + H.yaw * 0.3); + ctx.scale(sq, 1); + for (const sd of [-1, 1]) { + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.bezierCurveTo(sd * s * 0.6, -s * 0.85, sd * s * 1.35, -s * 0.55, sd * s * 1.15, 0); + ctx.bezierCurveTo(sd * s * 1.35, s * 0.55, sd * s * 0.6, s * 0.85, 0, 0); + ctx.fillStyle = lin(ctx, 0, -s, 0, s, [[0, "#FF8CC6"], [1, "#DB2777"]]); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(sd * s * 0.25, -s * 0.05); + ctx.quadraticCurveTo(sd * s * 0.7, -s * 0.15, sd * s * 0.95, -s * 0.05); + ctx.strokeStyle = "rgba(140,10,70,0.35)"; + ctx.lineWidth = s * 0.08; + ctx.lineCap = "round"; + ctx.stroke(); + } + ctx.beginPath(); + ctx.ellipse(0, 0, s * 0.24, s * 0.3, 0, 0, Math.PI * 2); + ctx.fillStyle = rad(ctx, -s * 0.06, -s * 0.1, 0, s * 0.35, [[0, "#FFB3D9"], [1, "#C2185B"]]); + ctx.fill(); + ctx.restore(); +} + +// ── Bunny ears (always behind the head) ─────────────────────────────────────── + +function bunnyEars(ctx: Ctx, H: Head) { + const R = H.R; + const earH = R * 0.85; + for (const sd of [-1, 1]) { + const root = proj(H, [sd * 0.45, 0.92, 0]); + const rootL = proj(H, [sd * 0.45 - 0.22, 0.92, 0]); + const rootR = proj(H, [sd * 0.45 + 0.22, 0.92, 0]); + const hw = Math.max(R * 0.04, Math.abs(rootR.x - rootL.x) / 2); + ctx.save(); + ctx.translate(root.x, root.y - earH * 0.15); + ctx.beginPath(); + ctx.ellipse(0, 0, hw, earH / 2, 0, 0, Math.PI * 2); + ctx.fillStyle = "#F9F0F0"; + ctx.fill(); + ctx.strokeStyle = "rgba(0,0,0,0.06)"; + ctx.lineWidth = 0.8; + ctx.stroke(); + ctx.beginPath(); + ctx.ellipse(0, -earH / 2 + R * 0.1 + earH * 0.325, hw * 0.5, earH * 0.325, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(252,165,165,0.7)"; + ctx.fill(); + ctx.restore(); + } +} + +// ── Layers and transitions ──────────────────────────────────────────────────── + +let scratch: HTMLCanvasElement | null = null; + +/** + * Draws `fn` at `alpha` as one layer, so overlapping parts don't show through + * each other while an outfit fades (SwiftUI's drawLayer on macOS). + */ +function withLayer(ctx: Ctx, alpha: number, fn: (c: Ctx) => void) { + if (alpha >= 0.999) { + ctx.save(); + fn(ctx); + ctx.restore(); + return; + } + const W = ctx.canvas.width; + const Hh = ctx.canvas.height; + scratch ??= document.createElement("canvas"); + if (scratch.width < W || scratch.height < Hh) { + scratch.width = Math.max(scratch.width, W); + scratch.height = Math.max(scratch.height, Hh); + } + const s = scratch.getContext("2d"); + if (!s) return; + s.setTransform(1, 0, 0, 1, 0, 0); + s.clearRect(0, 0, W, Hh); + s.setTransform(ctx.getTransform()); + s.save(); + fn(s); + s.restore(); + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalAlpha *= alpha; + ctx.drawImage(scratch, 0, 0, W, Hh, 0, 0, W, Hh); + ctx.restore(); +} + +/** Glasses, bow, scarf and pumpkin are on the face: behind the head once it has turned away. */ +const ON_FACE: ReadonlySet = new Set(["sunglasses", "roundGlasses", "bow", "scarf", "pumpkin"]); +const HATS: ReadonlySet = new Set(["beanie", "santaHat", "partyHat", "crown", "witchHat"]); + +export interface OutfitState { + /** 0 = gone, 1 = fully on. Animated by the engine. */ + presence: number; + /** Mochi's mailbox morph: outfits fade away as he turns into a box. */ + morph: number; +} + +function faceTurnedAway(H: Head): boolean { + return proj(H, [0, 0, 1]).z < 0; +} + +function drawFace(ctx: Ctx, outfit: Outfit, H: Head, body: Path2D, simple: boolean) { + switch (outfit) { + case "sunglasses": sunglasses(ctx, H, body); break; + case "roundGlasses": roundGlasses(ctx, H, body); break; + case "scarf": scarf(ctx, H); break; + case "pumpkin": pumpkin(ctx, H, body, simple); break; + case "bow": bow(ctx, H); break; + default: break; + } +} + +function layerAlpha(st: OutfitState): number { + const morphFade = 1 - Math.min(1, Math.max(0, (st.morph - 0.3) / 0.2)); + return morphFade * Math.min(1, st.presence * 2.5); +} + +/** + * The parts behind Mochi's body. `ctx` is in body space (translated to the body + * centre, tilted and squashed exactly like the body). + */ +export function drawOutfitBehind(ctx: Ctx, outfit: Outfit, H: Head, st: OutfitState) { + if (outfit === "none") return; + const alpha = layerAlpha(st); + if (alpha <= 0.005) return; + const simple = H.R < SIMPLIFY_BELOW_R; + const body = bodyOutline(H.rx, H.ry); + + if (ON_FACE.has(outfit)) { + if (faceTurnedAway(H)) withLayer(ctx, alpha, (l) => drawFace(l, outfit, H, body, simple)); + return; + } + const posP = Ease.back(st.presence); + const hatScale = 0.85 + 0.15 * posP; + ctx.save(); + ctx.translate(0, -(1 - posP) * H.ry); + ctx.scale(hatScale, hatScale); + withLayer(ctx, alpha, (l) => { + if (outfit === "bunnyEars") bunnyEars(l, H); + else if (outfit === "crown") crownPart(l, H, -1, simple); + else if (outfit === "witchHat") witchHatBack(l, H); + }); + ctx.restore(); +} + +/** The parts in front of Mochi, drawn after the body and the eyes. */ +export function drawOutfitFront(ctx: Ctx, outfit: Outfit, H: Head, st: OutfitState) { + if (outfit === "none" || outfit === "bunnyEars") return; + if (ON_FACE.has(outfit) && faceTurnedAway(H)) return; + const alpha = layerAlpha(st); + if (alpha <= 0.005) return; + const simple = H.R < SIMPLIFY_BELOW_R; + const body = bodyOutline(H.rx, H.ry); + const p = st.presence; + const posP = Ease.back(p); + + ctx.save(); + if (HATS.has(outfit)) { + // hats drop onto the head and settle + const hatScale = 0.85 + 0.15 * posP; + ctx.translate(0, -(1 - posP) * H.ry); + ctx.scale(hatScale, hatScale); + } else if (outfit === "sunglasses" || outfit === "roundGlasses") { + ctx.translate(0, (1 - p) * 0.25 * H.ry); + } else if (outfit === "scarf") { + ctx.translate(0, (1 - p) * 0.3 * H.ry); + } else if (outfit === "bow") { + ctx.scale(Math.max(0.001, posP), Math.max(0.001, posP)); + } + withLayer(ctx, alpha, (l) => { + switch (outfit) { + case "beanie": beanie(l, H, body, simple); break; + case "santaHat": santaHat(l, H, body); break; + case "partyHat": partyHat(l, H, simple); break; + case "crown": crownFront(l, H, body, simple); break; + case "witchHat": witchHatFront(l, H, body); break; + default: drawFace(l, outfit, H, body, simple); + } + }); + ctx.restore(); +} + +// ── Wardrobe icons ──────────────────────────────────────────────────────────── + +const INK = "rgb(26,20,18)"; + +/** A little Mochi wearing `outfit`, centred in a `size`×`size` icon. */ +function iconMochi(ctx: Ctx, size: number, outfit: Outfit) { + const R = 10; + const H = makeHead(R); + const cx = size / 2; + const cy = size / 2 + R * 0.62; + const st: OutfitState = { presence: 1, morph: 0 }; + ctx.save(); + ctx.translate(cx, cy); + drawOutfitBehind(ctx, outfit, H, st); + const body = bodyOutline(H.rx, H.ry); + const [top, bottom] = outfit === "pumpkin" ? PUMPKIN_BODY : ["rgb(237,237,239)", "rgb(196,197,202)"]; + ctx.fillStyle = lin(ctx, H.rx * 0.7, -H.ry * 0.85, -H.rx * 0.8, H.ry * 0.9, [[0, top], [1, bottom]]); + ctx.fill(body); + ctx.fillStyle = rad(ctx, 0, 0, R * 0.15, R * 1.25, [[0, "rgba(0,0,0,0)"], [0.6, "rgba(0,0,0,0)"], [1, "rgba(0,0,0,0.2)"]]); + ctx.fill(body); + ctx.fillStyle = rad(ctx, H.rx * 0.34, -H.ry * 0.46, 0, R * 0.42, [[0, "rgba(255,255,255,0.55)"], [1, "rgba(255,255,255,0)"]]); + ctx.fill(body); + ctx.save(); + ctx.clip(body); + ctx.fillStyle = INK; + for (const e of eyeFrames(H)) { + if (!e.visible) continue; + ctx.save(); + ctx.translate(e.x, e.y); + ctx.scale(e.fx, e.fy); + const hh = Math.max(e.h, e.w * 0.3); + roundRect(ctx, -e.w / 2, -hh / 2, e.w, hh, Math.min(e.w / 2, hh / 2)); + ctx.fill(); + ctx.restore(); + } + ctx.restore(); + drawOutfitFront(ctx, outfit, H, st); + ctx.restore(); +} + +/** + * One wardrobe button's picture — drawOutfitIcon on macOS. "auto" shows the + * season's outfit with an AUTO tag, "none" a crossed-out circle. + */ +export function drawWardrobeIcon(ctx: Ctx, size: number, selection: OutfitSelection, seasonal: Outfit, autoLabel: string) { + ctx.clearRect(0, 0, size, size); + if (selection === "none") { + const r = 6.5; + ctx.save(); + ctx.translate(size / 2, size / 2); + ctx.strokeStyle = "#454850"; + ctx.lineWidth = 1.4; + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.arc(0, 0, r * 0.82, 0, Math.PI * 2); + ctx.moveTo(-r * 0.56, r * 0.56); + ctx.lineTo(r * 0.56, -r * 0.56); + ctx.stroke(); + ctx.restore(); + return; + } + iconMochi(ctx, size, selection === "auto" ? seasonal : selection); + if (selection !== "auto") return; + const R = 10; + const by = size / 2 + R * 0.62 + R * 0.88 * 0.72; + const bw = 14; + const bh = 6.5; + ctx.save(); + ctx.translate(size / 2, by); + roundRect(ctx, -bw / 2, -bh / 2, bw, bh, bh / 2); + ctx.fillStyle = "rgba(0,0,0,0.6)"; + ctx.fill(); + ctx.fillStyle = "#FFFFFF"; + ctx.font = `600 4.2px system-ui, "Segoe UI", sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(autoLabel, 0, 0.2); + ctx.restore(); +} diff --git a/windows/src/mochi/wardrobe.ts b/windows/src/mochi/wardrobe.ts new file mode 100644 index 000000000..976c34e3c --- /dev/null +++ b/windows/src/mochi/wardrobe.ts @@ -0,0 +1,137 @@ +// Mochi's wardrobe — the pure logic, port of NotchBuddy/Sources/CoucouKit/MochiWardrobe.swift. +// What Mochi wears is picked in the wardrobe view (right-click on Mochi, or the +// tray menu) and stored in the preferences as `mochiOutfit`. The drawing lives +// in ./outfits.ts. + +/** Every outfit, plus "auto" (dress for the season) and "none". Order of the wardrobe. */ +export const OUTFIT_SELECTIONS = [ + "auto", "none", "partyHat", "beanie", "crown", "sunglasses", "roundGlasses", + "bow", "scarf", "witchHat", "pumpkin", "santaHat", "bunnyEars", +] as const; + +/** What can be stored in the preferences. The raw values are the Mac's, keep them stable. */ +export type OutfitSelection = (typeof OUTFIT_SELECTIONS)[number]; + +/** What Mochi actually wears: a selection with "auto" resolved. */ +export type Outfit = Exclude; + +export const DEFAULT_OUTFIT: OutfitSelection = "auto"; + +// ── User-visible strings ────────────────────────────────────────────────────── + +export const OUTFIT_LABELS: Record = { + auto: "Auto (seasons)", + none: "None", + partyHat: "Party hat", + beanie: "Beanie", + crown: "Crown", + sunglasses: "Sunglasses", + roundGlasses: "Round glasses", + bow: "Bow", + scarf: "Scarf", + witchHat: "Witch hat", + pumpkin: "Pumpkin", + santaHat: "Santa hat", + bunnyEars: "Bunny ears", +}; + +export const WARDROBE_STRINGS = { + title: "Wardrobe", + autoBadge: "AUTO", + autoNow: (current: string) => `Auto · ${current}`, + autoHover: (current: string) => `Auto · follows the seasons (now: ${current})`, +}; + +// ── Logic ───────────────────────────────────────────────────────────────────── + +const SELECTION_SET: ReadonlySet = new Set(OUTFIT_SELECTIONS); + +/** + * A stored preference → a selection. Anything unknown (a value from a newer or + * older build, such as the Mac's removed "topHat", or garbage) means "auto", + * exactly like `Outfit.stored` on macOS. + */ +export function parseOutfit(raw: unknown): OutfitSelection { + return typeof raw === "string" && SELECTION_SET.has(raw) ? (raw as OutfitSelection) : DEFAULT_OUTFIT; +} + +/** Easter Sunday of `year` (Meeus/Jones/Butcher), as [month 1–12, day]. */ +export function easterDate(year: number): [number, number] { + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31); + const day = ((h + l - 7 * m + 114) % 31) + 1; + return [month, day]; +} + +const DAY_MS = 86_400_000; + +/** + * The seasonal outfit for `date`, read in the user's local calendar. + * Priority: party hat > Santa hat > witch hat > bunny ears > sunglasses > none. + */ +export function seasonalOutfit(date: Date): Outfit { + const day = date.getDate(); + const month = date.getMonth() + 1; + const year = date.getFullYear(); + + // Dec 31 – Jan 2 + if ((month === 12 && day === 31) || (month === 1 && day <= 2)) return "partyHat"; + // Dec 1–26 + if (month === 12 && day <= 26) return "santaHat"; + // Oct 1 – Nov 1 + if (month === 10 || (month === 11 && day === 1)) return "witchHat"; + // Two days before Easter to the day after. Calendar days counted in UTC so a + // daylight-saving change in between can't shift the count. + const [em, ed] = easterDate(year); + const delta = Math.round((Date.UTC(year, month - 1, day) - Date.UTC(year, em - 1, ed)) / DAY_MS); + if (delta >= -2 && delta <= 1) return "bunnyEars"; + // Jun 21 – Aug 31 + if ((month === 6 && day >= 21) || month === 7 || month === 8) return "sunglasses"; + return "none"; +} + +/** "auto" → the season's outfit; anything else is worn as chosen. */ +export function resolveOutfit(selection: OutfitSelection, date: Date): Outfit { + return selection === "auto" ? seasonalOutfit(date) : selection; +} + +/** + * The grey text at the right of the wardrobe header: the hovered outfit, + * otherwise the current choice — WardrobeView.headerRight on macOS. + */ +export function wardrobeHeader( + hovered: OutfitSelection | null, + selection: OutfitSelection, + date: Date, +): string { + const season = OUTFIT_LABELS[seasonalOutfit(date)]; + if (hovered) return hovered === "auto" ? WARDROBE_STRINGS.autoHover(season) : OUTFIT_LABELS[hovered]; + return selection === "auto" ? WARDROBE_STRINGS.autoNow(season) : OUTFIT_LABELS[selection]; +} + +/** Same as resolveOutfit, remembered for the day — it is asked every frame. */ +export class SeasonCache { + private key = ""; + private value: Outfit = "none"; + + get(selection: OutfitSelection, date = new Date()): Outfit { + if (selection !== "auto") return selection; + const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; + if (key !== this.key) { + this.key = key; + this.value = seasonalOutfit(date); + } + return this.value; + } +} From 396cf291303f57962ca8703459f85d833fc1868d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:19:28 +0200 Subject: [PATCH 23/77] Remember Mochi's outfit in the preferences New mochiOutfit field (default "auto", the Mac's raw values). The struct's serde default keeps settings.json files from older builds loading; the island reads anything unknown as "auto". Node tests mirror MochiWardrobeTests.swift. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/settings.rs | 25 +++++- windows/src/core/state.ts | 10 +++ windows/tests/wardrobe.test.mjs | 131 ++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 windows/tests/wardrobe.test.mjs diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 17f63e1c1..a95abfd74 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -23,6 +23,10 @@ pub struct Settings { pub hooks_installed: bool, /// Claude model used by the chat. Changeable in the settings window. pub model: String, + /// Mochi's outfit, picked in the wardrobe: "auto" (dresses for the + /// season), "none" or an outfit id — the Mac's raw values. The island reads + /// anything it doesn't know as "auto", so the value is stored as it comes. + pub mochi_outfit: String, } fn default_model() -> String { @@ -46,6 +50,7 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + mochi_outfit: "auto".into(), } } } @@ -306,7 +311,8 @@ mod tests { "screen": "cursor", "autostart": true, "hooksInstalled": true, - "model": "some-model" + "model": "some-model", + "mochiOutfit": "witchHat" }"#; fn custom() -> Value { @@ -396,6 +402,22 @@ mod tests { assert_eq!(loaded, expected); } + #[test] + fn a_file_from_before_the_wardrobe_dresses_mochi_for_the_seasons() { + let loaded = parse(&custom_with("mochiOutfit", None)).unwrap(); + assert_eq!(loaded.mochi_outfit, "auto"); + assert_eq!(loaded.model, "some-model"); + assert!(!loaded.sound_enabled); + } + + #[test] + fn an_outfit_this_build_does_not_know_is_kept_as_written() { + // A newer build may add outfits: the island shows "auto" for it, but + // the choice must survive a save made by this one. + let loaded = parse(&custom_with("mochiOutfit", Some(json!("topHat")))).unwrap(); + assert_eq!(loaded.mochi_outfit, "topHat"); + } + #[test] fn a_missing_field_takes_its_default_and_the_rest_are_kept() { for key in custom().as_object().unwrap().keys() { @@ -632,6 +654,7 @@ mod tests { "autostart", "hooksInstalled", "model", + "mochiOutfit", ] ); let _ = std::fs::remove_dir_all(&dir); diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..16acf8c4a 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -2,6 +2,7 @@ import type { BotEmoteName, BotStateName, IslandMode, IslandViewName } from "./layout"; import type { EyeShape } from "../mochi/engine"; +import { DEFAULT_OUTFIT, type Outfit } from "../mochi/wardrobe"; export type AgentSource = "claudeCode" | "n8n" | "agent"; export type PillBadge = "approval" | "finished" | "error"; @@ -92,6 +93,11 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + /** + * Mochi's outfit: "auto" (dresses for the season), "none" or an outfit id. + * Same raw values as the Mac's "mochiOutfit"; read it through parseOutfit. + */ + mochiOutfit: string; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +112,7 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + mochiOutfit: DEFAULT_OUTFIT, }; type Listener = () => void; @@ -140,6 +147,9 @@ class AppState { integrations: Record = {}; + /** Outfit shown on Mochi while the pointer rests on a wardrobe button. */ + wardrobePreview: Outfit | null = null; + lastActivity = performance.now(); settings: Settings = { ...DEFAULT_SETTINGS }; diff --git a/windows/tests/wardrobe.test.mjs b/windows/tests/wardrobe.test.mjs new file mode 100644 index 000000000..f6310d941 --- /dev/null +++ b/windows/tests/wardrobe.test.mjs @@ -0,0 +1,131 @@ +// Mochi's wardrobe: season → outfit, stored preference → outfit (src/mochi/wardrobe.ts). +// Mirrors tests/MochiWardrobeTests.swift so both platforms dress Mochi alike. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + OUTFIT_SELECTIONS, OUTFIT_LABELS, SeasonCache, easterDate, parseOutfit, resolveOutfit, + seasonalOutfit, wardrobeHeader, +} from "../src/mochi/wardrobe.ts"; +import { DEFAULT_SETTINGS } from "../src/core/state.ts"; + +/** A local calendar day, at noon so no time zone can push it into the next one. */ +const day = (y, m, d) => new Date(y, m - 1, d, 12); +const season = (y, m, d) => seasonalOutfit(day(y, m, d)); + +test("witch hat from October 1 to November 1", () => { + assert.equal(season(2026, 9, 30), "none"); + assert.equal(season(2026, 10, 1), "witchHat"); + assert.equal(season(2026, 10, 31), "witchHat"); + assert.equal(season(2026, 11, 1), "witchHat"); + assert.equal(season(2026, 11, 2), "none"); +}); + +test("Santa hat from December 1 to 26", () => { + assert.equal(season(2026, 11, 30), "none"); + assert.equal(season(2026, 12, 1), "santaHat"); + assert.equal(season(2026, 12, 26), "santaHat"); + assert.equal(season(2026, 12, 27), "none"); +}); + +test("party hat from December 31 to January 2", () => { + assert.equal(season(2026, 12, 30), "none"); + assert.equal(season(2026, 12, 31), "partyHat"); + assert.equal(season(2027, 1, 1), "partyHat"); + assert.equal(season(2027, 1, 2), "partyHat"); + assert.equal(season(2027, 1, 3), "none"); +}); + +test("nothing around Valentine's day (the hearts headband is gone)", () => { + for (const d of [12, 13, 15, 16]) assert.equal(season(2026, 2, d), "none"); +}); + +test("Easter falls where the calendar says", () => { + assert.deepEqual(easterDate(2026), [4, 5]); + assert.deepEqual(easterDate(2027), [3, 28]); + assert.deepEqual(easterDate(2028), [4, 16]); + assert.deepEqual(easterDate(2019), [4, 21]); +}); + +test("bunny ears from two days before Easter to the day after", () => { + // Easter 2026 = April 5 + assert.equal(season(2026, 4, 2), "none"); + for (const d of [3, 4, 5, 6]) assert.equal(season(2026, 4, d), "bunnyEars", `April ${d}`); + assert.equal(season(2026, 4, 7), "none"); + // Easter 2027 = March 28 + assert.equal(season(2027, 3, 26), "bunnyEars"); + assert.equal(season(2027, 3, 29), "bunnyEars"); + assert.equal(season(2027, 3, 30), "none"); + // Easter 2028 = April 16 + assert.equal(season(2028, 4, 14), "bunnyEars"); + assert.equal(season(2028, 4, 17), "bunnyEars"); + assert.equal(season(2028, 4, 18), "none"); +}); + +test("a daylight-saving change just before Easter doesn't shift the count", () => { + // Europe moves its clocks on March 29 2026, a week before Easter: midnight + // and late-evening dates must count the same days as noon. + assert.equal(seasonalOutfit(new Date(2026, 3, 3, 0, 0)), "bunnyEars"); + assert.equal(seasonalOutfit(new Date(2026, 3, 6, 23, 59)), "bunnyEars"); + assert.equal(seasonalOutfit(new Date(2026, 3, 7, 0, 0)), "none"); +}); + +test("sunglasses from June 21 to August 31", () => { + assert.equal(season(2026, 6, 20), "none"); + assert.equal(season(2026, 6, 21), "sunglasses"); + assert.equal(season(2026, 8, 31), "sunglasses"); + assert.equal(season(2026, 9, 1), "none"); +}); + +test("auto dresses for the season, any other choice is worn as is", () => { + const d = day(2026, 10, 15); + assert.equal(resolveOutfit("auto", d), "witchHat"); + assert.equal(resolveOutfit("none", d), "none"); + assert.equal(resolveOutfit("beanie", d), "beanie"); +}); + +test("the stored values are the Mac's, in the wardrobe's order", () => { + assert.deepEqual([...OUTFIT_SELECTIONS], [ + "auto", "none", "partyHat", "beanie", "crown", "sunglasses", "roundGlasses", + "bow", "scarf", "witchHat", "pumpkin", "santaHat", "bunnyEars", + ]); + for (const sel of OUTFIT_SELECTIONS) assert.ok(OUTFIT_LABELS[sel], `${sel} has a label`); +}); + +test("a stored preference is read back as it was saved", () => { + for (const sel of OUTFIT_SELECTIONS) assert.equal(parseOutfit(sel), sel); +}); + +test("removed, unknown or missing preferences mean auto", () => { + for (const raw of ["topHat", "cap", "heartsHeadband", "strawHat", "totallyUnknown", "", "Beanie"]) { + assert.equal(parseOutfit(raw), "auto", JSON.stringify(raw)); + } + for (const raw of [undefined, null, 42, true, {}, ["beanie"]]) { + assert.equal(parseOutfit(raw), "auto", String(raw)); + } +}); + +test("preferences from before the wardrobe start on auto", () => { + assert.equal(DEFAULT_SETTINGS.mochiOutfit, "auto"); + // What main.ts does with a settings.json that has no mochiOutfit key. + const old = { soundEnabled: false, model: "x" }; + const merged = { ...DEFAULT_SETTINGS, ...old }; + assert.equal(parseOutfit(merged.mochiOutfit), "auto"); +}); + +test("the wardrobe header names the hovered outfit, else the current choice", () => { + const d = day(2026, 10, 15); + assert.equal(wardrobeHeader(null, "auto", d), "Auto · Witch hat"); + assert.equal(wardrobeHeader(null, "crown", d), "Crown"); + assert.equal(wardrobeHeader("auto", "crown", d), "Auto · follows the seasons (now: Witch hat)"); + assert.equal(wardrobeHeader("scarf", "auto", d), "Scarf"); + assert.equal(wardrobeHeader(null, "auto", day(2026, 5, 1)), "Auto · None"); +}); + +test("the per-frame season lookup follows the calendar day", () => { + const cache = new SeasonCache(); + assert.equal(cache.get("auto", day(2026, 9, 30)), "none"); + assert.equal(cache.get("auto", new Date(2026, 9, 1, 0, 0, 1)), "witchHat"); + assert.equal(cache.get("bow", day(2026, 10, 1)), "bow"); + assert.equal(cache.get("auto", day(2026, 12, 2)), "santaHat"); +}); From d02f82031c62e0a424498bf2a19a74ce38f639f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:19:28 +0200 Subject: [PATCH 24/77] Greeting v2: drop in, bounce, slide aside and wave Port of GreetingCanvasView.swift (0.1.5, #196): Mochi falls into the island through warp streaks, bounces, slides to the side and waves with a quick little hand, then comes back. Plays the Mac's new greeting.wav, faded out when the greeting is left (SoundEngine.fadeOut). The collapse into the compact island now uses the island's own 340 ms close curve: with the Mac's ease the island shrank ahead of Mochi and cut his body off when the greeting was left early. A Node test runs the island geometry frame by frame and checks he always fits. Co-Authored-By: Claude Opus 5.5 --- windows/src/core/sound.ts | 36 ++- windows/src/mochi/greeting.ts | 392 ++++++++++++++++++++------------ windows/tests/greeting.test.mjs | 72 ++++++ windows/vite.config.ts | 2 +- 4 files changed, 357 insertions(+), 145 deletions(-) create mode 100644 windows/tests/greeting.test.mjs diff --git a/windows/src/core/sound.ts b/windows/src/core/sound.ts index b5178554f..afe9342a3 100644 --- a/windows/src/core/sound.ts +++ b/windows/src/core/sound.ts @@ -1,5 +1,5 @@ // SoundEngine — port of SoundEngine.swift. -// The 28 WAVs are the macOS app's own files (see SOUNDS_DIR in vite.config.ts); +// The 29 WAVs are the macOS app's own files (see SOUNDS_DIR in vite.config.ts); // they are served at /sounds/.wav. Default volume 0.12, slider range 0–0.2, // exactly like the Mac player, and several sounds may overlap. @@ -7,7 +7,7 @@ export const SOUND_NAMES = [ "peek", "open", "close", "hover", "blip", "slap", "annoyed", "dizzy", "greet", "work", "finish", "error", "approval", "question", "approve", "gulp", "tick", "send", "love", "pop", "proud", "wink", "yawn", "attach", "think", "search", - "rate", "sleep", + "rate", "sleep", "greeting", ] as const; export type SoundName = (typeof SOUND_NAMES)[number]; @@ -21,6 +21,8 @@ class SoundEngine { private buffers = new Map(); private loading: Promise | null = null; private idleTimer: number | null = null; + /** Sounds still playing, by name, so one can be faded out (the greeting). */ + private playing = new Map>(); /** Creates the context and decodes every WAV. Safe to call more than once. */ preload(): Promise { @@ -97,9 +99,37 @@ class SoundEngine { if (ctx.state === "suspended") void ctx.resume(); const src = ctx.createBufferSource(); src.buffer = buf; - src.connect(master); + const gain = ctx.createGain(); + src.connect(gain); + gain.connect(master); + const voice = { src, gain }; + let voices = this.playing.get(name); + if (!voices) this.playing.set(name, (voices = new Set())); + voices.add(voice); + src.onended = () => { + voices.delete(voice); + gain.disconnect(); + }; src.start(); } + + /** Fades every playing copy of `name` to silence over `seconds`, then stops it. */ + fadeOut(name: string, seconds: number) { + const ctx = this.ctx; + const voices = this.playing.get(name); + if (!ctx || !voices) return; + const t = ctx.currentTime; + for (const { src, gain } of voices) { + gain.gain.cancelScheduledValues(t); + gain.gain.setValueAtTime(gain.gain.value, t); + gain.gain.linearRampToValueAtTime(0, t + seconds); + try { + src.stop(t + seconds); + } catch { + /* already stopped */ + } + } + } } export const Sound = new SoundEngine(); diff --git a/windows/src/mochi/greeting.ts b/windows/src/mochi/greeting.ts index 682f60a2c..c0f74a82b 100644 --- a/windows/src/mochi/greeting.ts +++ b/windows/src/mochi/greeting.ts @@ -1,27 +1,24 @@ -// The launch "coucou" — port of GreetingCanvasView.swift. +// The launch "coucou" (greeting v2) — port of GreetingCanvasView.swift. +// Mochi drops into the island, bounces, slides to the side and waves hello with +// a quick little hand, then comes back and settles in the compact island. // Everything is laid out in the same 640×150 reference space as on macOS. +import { closeCurve } from "../core/anim"; import { Sound } from "../core/sound"; import { COMPACT_W, NOTCH_H, NOTCH_W } from "../core/layout"; -// ── Timing (mirrors greeting-v2.html `T`) ───────────────────────────────────── +// ── Timing (GT in the Swift file) ───────────────────────────────────────────── const T = { - grow: 0.45, - squint0: 0.6, - squint1: 0.82, - dip0: 1.25, - dip1: 1.4, - pop0: 1.36, - pop1: 1.52, - content0: 2.45, - content1: 2.58, - tuck0: 2.58, - tuck1: 2.8, + pop0: 1.3, + pop1: 1.45, + content0: 2.4, + tuck0: 2.45, + tuck1: 2.7, badge: 2.72, down0: 2.85, - down1: 3.2, - blink2: 3.8, + down1: 3.45, + blink2: 3.7, tint0: 3.85, tint1: 4.15, end: 4.6, @@ -31,18 +28,30 @@ const T = { export const GREETING_END = T.end; +/** The greeting's own sound, played from the start (greeting.wav, the Mac's file). */ +const GREETING_SOUND = "greeting"; + // ── Geometry (640×150) ──────────────────────────────────────────────────────── +export const GREETING_W = 640; +export const GREETING_H = 150; const C0 = { x: 320, y: 90 }; const HB = 58; -const ASP = 1.34; +export const ASP = 1.34; const EAR_X = 40; -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; + +/** IslandRestingLayout for the compact island the greeting lands in. */ +const COMPACT = { + width: COMPACT_W, + height: NOTCH_H, + botDiameter: Math.min(20, Math.max(0, NOTCH_H - 6)), + botCenterY: NOTCH_H / 2, + miniGridScale: Math.min(1, Math.max(0, NOTCH_H - 4) / 28), + miniGridCenterX: COMPACT_W - 40, +}; // ── Easing ──────────────────────────────────────────────────────────────────── @@ -65,7 +74,7 @@ const seg = (t: number, a: number, b: number) => clamp((t - a) / (b - a), 0, 1); type EyeType = "dot" | "happy" | "content"; -interface Pose { +export interface Pose { hb: number; x: number; y: number; sx: number; sy: number; tilt: number; eye: EyeType; open: number; eyeRoll: number; lookX: number; lookY: number; @@ -76,59 +85,100 @@ interface Pose { } function greetPose(t: number): Pose { + // Island size — only the spread of the warp streaks uses it. 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); - - const gg = E.back(seg(t, 0.02, T.grow)); - const hb = lerp(3, HB, gg); - let x = C0.x; - let y = lerp(16, C0.y, E.out(seg(t, 0.02, T.grow))); - let sx = 1; - let sy = 1; - let tilt = 0; - - if (t >= T.dip0 && t < T.pop1) { - const k = Math.sin(Math.PI * seg(t, T.dip0, T.pop1)); - y += hb * 0.22 * k; - sy = 1 - 0.06 * k; - sx = 1 + 0.04 * k; - } - if (t >= T.pop1 && t < T.tuck1) { + const iw = lerp(NOTCH_W, GREETING_W, g); + const ih = lerp(COMPACT.height, GREETING_H, g); + + const cx = C0.x; + const cy = C0.y; + // Body height: invisible before 0.20, grows 0.15 → 1 with a back ease. + const hb = t < 0.2 ? 0 : lerp(HB * 0.15, HB, E.back(seg(t, 0.2, 0.6))); + + // Landmarks, in multiples of the body height + const landY = cy + 0.12 * HB; + const peakY = cy - 0.15 * HB; + const dipY = cy + 0.36 * HB; + const springY = cy - 0.1 * HB; + const sinkY = cy + 0.3 * HB; + const drift1 = cx - 0.16 * HB; + const drift2 = cx - 0.45 * HB; + const drift3 = cx - 0.57 * HB; + const drift4 = cx - 0.85 * HB; + + // Lateral travel: slides to the side, waves there, comes back. + let x: number; + if (t < 0.85) x = cx; + else if (t < 1.2) x = lerp(cx, drift1, E.inOut(seg(t, 0.85, 1.2))); + else if (t < 1.3) x = lerp(drift1, drift2, E.easeIn(seg(t, 1.2, 1.3))); + else if (t < 1.45) x = lerp(drift2, drift3, E.inOut(seg(t, 1.3, 1.45))); + else if (t < 2.4) x = lerp(drift3, drift4, E.inOut(seg(t, 1.45, 2.4))); + else if (t < 2.85) x = drift4; + else x = lerp(drift4, cx, E.inOut(seg(t, 2.85, 3.45))); + + // Vertical travel: drop in, bounce, plunge and spring for the wave, sink to tuck. + let y: number; + if (t < 0.2) y = COMPACT.botCenterY; + else if (t < 0.6) y = lerp(COMPACT.botCenterY, landY, E.easeIn(seg(t, 0.2, 0.6))); + else if (t < 0.73) y = lerp(landY, peakY, E.out(seg(t, 0.6, 0.73))); + else if (t < 0.9) y = lerp(peakY, cy, E.inOut(seg(t, 0.73, 0.9))); + else if (t < 1.2) y = cy; + else if (t < 1.3) y = lerp(cy, dipY, E.easeIn(seg(t, 1.2, 1.3))); + else if (t < 1.45) y = lerp(dipY, springY, E.out(seg(t, 1.3, 1.45))); + else if (t < 1.6) y = lerp(springY, cy, E.inOut(seg(t, 1.45, 1.6))); + else if (t < 2.4) y = cy; + else if (t < 2.7) y = lerp(cy, sinkY, E.inOut(seg(t, 2.4, 2.7))); + else if (t < 2.85) y = sinkY; + else y = lerp(sinkY, cy, E.inOut(seg(t, 2.85, 3.45))); + + // Body bob in phase with the waving hand + if (t >= T.pop1 && t < T.tuck0) { const w = t - T.pop1; - const fade = 1 - seg(t, T.tuck0, T.tuck1); - x += Math.sin(w * 2 * Math.PI * 0.9) * hb * ASP * 0.05 * fade; - tilt = Math.sin(w * 2 * Math.PI * 0.9 + 0.6) * 0.05 * fade; - y += Math.sin(w * 2 * Math.PI * 1.8) * 0.8 * fade; - } - if (t >= T.tuck0 && t < T.down1) { - y += hb * 0.12 * Math.sin(Math.PI * seg(t, T.tuck0, T.down1)); + y += Math.sin(w * 2 * Math.PI * 5) * 0.02 * HB * clamp(w / 0.08, 0, 1); } + // Squash and stretch + const landSqK = t >= 0.52 && t < 0.68 ? Math.sin(Math.PI * seg(t, 0.52, 0.68)) : 0; + const bounceK = t >= 0.62 && t < 0.84 ? Math.sin(Math.PI * seg(t, 0.62, 0.84)) : 0; + let sx = 1 + 0.14 * landSqK - 0.1 * bounceK; + let sy = 1 - 0.14 * landSqK + 0.18 * bounceK; + const plungeK = t >= 1.18 && t < 1.42 ? Math.sin(Math.PI * seg(t, 1.18, 1.42)) : 0; + const springK = t >= 1.3 && t < 1.46 ? Math.sin(Math.PI * seg(t, 1.3, 1.46)) : 0; + sx += 0.12 * plungeK - 0.18 * springK; + sy -= 0.12 * plungeK - 0.25 * springK; + let sinkK = 0; + if (t >= 2.38 && t < 2.7) sinkK = E.inOut(seg(t, 2.38, 2.7)); + else if (t >= 2.7 && t < 2.85) sinkK = 1 - E.inOut(seg(t, 2.7, 2.85)); + sx += 0.18 * sinkK; + sy -= 0.14 * sinkK; + const microK = t >= 3.7 && t < 3.82 ? Math.sin(Math.PI * seg(t, 3.7, 3.82)) : 0; + sx += 0.08 * microK; + sy -= 0.07 * microK; + + // Eyes let eye: EyeType = "dot"; - if (t >= T.squint0 && t < T.squint1) eye = "happy"; - if (t >= T.content0 && t < T.content1) eye = "content"; - if (t >= T.down0 && t < T.down1) eye = "content"; - let eyeRoll = 0; - if (t >= T.dip0 && t < T.pop1) eyeRoll = Math.sin(Math.PI * seg(t, T.dip0, T.pop1)); + if (t >= 0.55 && t < 0.8) eye = "happy"; + if (t >= T.content0 && t < T.tuck1) eye = "content"; const blink = (tb: number) => { const k = seg(t, tb, tb + 0.12); return k > 0 && k < 1 ? 1 - Math.sin(Math.PI * k) * 0.94 : 1; }; - const open = Math.min(blink(1.95), blink(T.blink2)); + const open = Math.min(blink(1.95), blink(3.05), blink(T.blink2)); + // Look let lookX = 0; let lookY = 0; - if (t >= T.squint1 && t < T.dip0) lookY = -0.2; if (t >= T.pop1 && t < T.content0) { lookX = 0.55; lookY = -0.45; } - if (t >= T.content0 && t < T.down1) { lookX = -0.3; lookY = 0.6; } - if (t >= T.down1) { + else if (t >= T.content0 && t < T.down0) { lookX = -0.3; lookY = 0.6; } + else if (t >= T.down0 && t < T.down1) { lookX = 0.3; lookY = 0.6; } + else if (t >= T.down1) { const k = E.inOut(seg(t, T.down1, T.down1 + 0.35)); - lookX = lerp(-0.3, 0, k); + lookX = lerp(0.3, 0, k); lookY = lerp(0.6, 0, k); } + // Hands const handL = t < T.tuck0 ? E.back(seg(t, T.pop0, T.pop0 + 0.14)) : 1 - E.easeIn(seg(t, T.tuck0, T.tuck1 - 0.03)); @@ -138,8 +188,8 @@ function greetPose(t: number): Pose { const wave = t >= T.pop1 && t < T.tuck0 ? t - T.pop1 : -1; return { - hb, x, y, sx, sy, tilt, - eye, open, eyeRoll, + hb, x, y, sx, sy, tilt: 0, + eye, open, eyeRoll: 0, lookX, lookY, handL, handR, wave, badge: E.back(seg(t, T.badge, T.badge + 0.28)), @@ -156,9 +206,9 @@ function greetPose(t: number): Pose { function smallPose(): Pose { return { - hb: EAR_HB, - x: 320 - SMALL_W / 2 + EAR_X, - y: EAR_Y, + hb: (EAR_HB * COMPACT.botDiameter) / 20, + x: GREETING_W / 2 - COMPACT.width / 2 + EAR_X, + y: COMPACT.botCenterY, sx: 1, sy: 1, tilt: 0, eye: "dot", open: 1, eyeRoll: 0, lookX: 0, lookY: 0, @@ -166,15 +216,20 @@ function smallPose(): Pose { badge: 1, tint: 0.6, halo: 0.6, haloBlue: 1, minis: 1, fx: 1, header: 0, card: 0, - iw: SMALL_W, ih: SMALL_H, + iw: COMPACT.width, ih: COMPACT.height, }; } -function pose(t: number, tc: number): Pose { +/** Mochi's pose `t` seconds in; `tc` is when the collapse began (∞ if not yet). */ +export function greetingPose(t: number, tc = Number.POSITIVE_INFINITY): Pose { if (t < tc) return greetPose(Math.min(t, T.end + 10)); const a = greetPose(tc); const b = smallPose(); - const e = E.inOut(seg(t, tc, tc + T.COLLAPSE)); + // Same curve and duration (340 ms) as the island shrinking around him: with + // the Mac's inOut ease the island got ahead of Mochi and cut his body off + // when the greeting was left early. + const k = seg(t, tc, tc + T.COLLAPSE); + const e = k >= 1 ? 1 : closeCurve(k); const p: Pose = { ...a }; p.iw = lerp(a.iw, b.iw, e); p.ih = lerp(a.ih, b.ih, e); @@ -203,11 +258,74 @@ function pose(t: number, tc: number): Pose { return p; } -// ── Particles (seeded LCG, seed = 7, identical sequence to the Swift version) ── +/** Where the waving (left, round) hand is, in body space before the body's scale. */ +function handLPos(hw: number, hh: number, p: Pose) { + const k = p.handL; + const hb = hh * 2; + const r = hb * 0.15 * k; + const x = lerp(-hw * 0.35, -hw - hb * 0.22, k); + let y = lerp(hh * 0.85, hh * 0.62, k); + if (p.wave >= 0) { + // ramp in 0.08 s after pop1, ramp out over the tuck + const w = p.wave; + const rampIn = clamp(w / 0.08, 0, 1); + const rampOut = 1 - clamp((w - (T.tuck0 - T.pop1)) / (T.tuck1 - T.tuck0), 0, 1); + y += Math.sin(w * 2 * Math.PI * 5) * hb * 0.14 * rampIn * rampOut; + } + return { x, y, r }; +} + +/** The resting (right, capsule) hand. */ +function handRPos(hw: number, hh: number, p: Pose) { + const k = p.handR; + const hb = hh * 2; + return { + x: lerp(hw * 0.35, hw + hb * 0.2, k), + y: lerp(hh * 0.85, hh * 0.2, k), + L: hb * 0.4 * k, + T: hb * 0.22 * k, + // breathing rotation ±0.04 rad at 2.5 Hz while the other hand waves + ang: p.wave >= 0 ? -0.61 + Math.sin(p.wave * 2 * Math.PI * 2.5) * 0.04 : -0.61, + }; +} + +/** + * Bounding box of Mochi's body and hands in the 640×150 space — what must stay + * inside the island so nothing of him is cut off. + */ +export function mochiBounds(p: Pose): { left: number; right: number; top: number; bottom: number } | null { + const hh = p.hb / 2; + const hw = hh * ASP; + if (hh <= 0.4) return null; + let left = -hw; + let right = hw; + let top = -hh; + let bottom = hh; + if (p.handL > 0.01) { + const l = handLPos(hw, hh, p); + left = Math.min(left, l.x - l.r); + top = Math.min(top, l.y - l.r); + bottom = Math.max(bottom, l.y + l.r); + } + if (p.handR > 0.01) { + const r = handRPos(hw, hh, p); + const ext = r.L / 2 + r.T / 2; + right = Math.max(right, r.x + ext); + top = Math.min(top, r.y - ext); + bottom = Math.max(bottom, r.y + ext); + } + return { + left: p.x + left * p.sx, + right: p.x + right * p.sx, + top: p.y + top * p.sy, + bottom: p.y + bottom * p.sy, + }; +} + +// ── Particles (seeded LCG, seed = 7, same sequence as the Swift version) ────── +interface WarpStreak { xNorm: number; speed: number; len: number; thick: number; alpha: number; t0: number } interface RingDot { a: number; j: number; s: number; al: number } -interface Ring { t0: number; dots: RingDot[] } -interface Streak { a: number; sp: number; len: number; t0: number; col: string } const PARTICLES = (() => { let seed = 7; @@ -215,24 +333,23 @@ const PARTICLES = (() => { seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; - const rings: Ring[] = [0.1, 0.2, 0.3, 0.45, 0.6].map((t0) => ({ - t0, - dots: Array.from({ length: 170 }, () => ({ - a: rnd() * Math.PI * 2, - j: (rnd() - 0.5) * 0.22, - s: 0.7 + rnd() * 0.9, - al: 0.45 + rnd() * 0.55, - })), + // ~70 white warp streaks for the fall-in (0 → 0.55 s) + const warps: WarpStreak[] = Array.from({ length: 70 }, () => ({ + xNorm: rnd(), + speed: 400 + rnd() * 300, + len: 6 + rnd() * 16, + thick: 1 + rnd() * 0.5, + alpha: 0.25 + rnd() * 0.55, + t0: rnd() * 0.35, })); - const cols = ["#3B9EFF", "#F29B38", "#FF5A4E", "#2EC4A0", "#A78BFA"]; - const streaks: Streak[] = Array.from({ length: 16 }, (_, i) => ({ - a: (i / 16) * Math.PI * 2 + (rnd() - 0.5) * 0.3, - sp: 230 + rnd() * 260, - len: 6 + rnd() * 9, - t0: 0.08 + rnd() * 0.14, - col: cols[i % 5], + // one white burst ring at 0.45 s + const ring: RingDot[] = Array.from({ length: 90 }, () => ({ + a: rnd() * Math.PI * 2, + j: (rnd() - 0.5) * 0.22, + s: 0.7 + rnd() * 0.9, + al: 0.45 + rnd() * 0.55, })); - return { rings, streaks }; + return { warps, ring }; })(); // ── Drawing ─────────────────────────────────────────────────────────────────── @@ -265,6 +382,7 @@ function mochiPath(hw: number, hh: number): Path2D { return p; } +/** White body gradient. A canvas gradient pads past its ends, so the corners are never left unpainted. */ function whiteFill( x: CanvasRenderingContext2D, path: Path2D, x0: number, y0: number, x1: number, y1: number, @@ -272,22 +390,15 @@ function whiteFill( const g = x.createLinearGradient(x0, y0, x1, y1); g.addColorStop(0, "rgb(251,251,252)"); g.addColorStop(1, "rgb(231,233,236)"); - x.save(); x.fillStyle = g; x.fill(path); - x.restore(); } function drawHandL(x: CanvasRenderingContext2D, hw: number, hh: number, p: Pose) { - const k = p.handL; - if (k <= 0.01) return; - const hb = hh * 2; - const r = hb * 0.15 * k; - const rx = lerp(-hw * 0.35, -hw - hb * 0.22, k); - let ry = lerp(hh * 0.85, hh * 0.62, k); - if (p.wave >= 0) ry += Math.sin(p.wave * 6) * hb * 0.02; + if (p.handL <= 0.01) return; + const { x: hx, y: hy, r } = handLPos(hw, hh, p); x.save(); - x.translate(rx, ry); + x.translate(hx, hy); const circ = new Path2D(); circ.ellipse(0, 0, r, r, 0, 0, Math.PI * 2); whiteFill(x, circ, r, -r, -r, r); @@ -298,27 +409,15 @@ function drawHandL(x: CanvasRenderingContext2D, hw: number, hh: number, p: Pose) } function drawHandR(x: CanvasRenderingContext2D, hw: number, hh: number, p: Pose) { - const k = p.handR; - if (k <= 0.01) return; - const hb = hh * 2; - const L = hb * 0.4 * k; - const T2 = hb * 0.22 * k; - let rx = lerp(hw * 0.35, hw + hb * 0.2, k); - let ry = lerp(hh * 0.85, hh * 0.2, k); - let ang = -0.61; - if (p.wave >= 0) { - const w = p.wave * 2 * Math.PI * 2.5; - ang += Math.sin(w) * 0.21; - ry += Math.sin(w + 0.8) * hb * 0.04; - rx += Math.cos(w) * hb * 0.015; - } + if (p.handR <= 0.01) return; + const h = handRPos(hw, hh, p); x.save(); - x.translate(rx, ry); - x.rotate(ang); - const g = x.createLinearGradient(L / 2, -T2 / 2, -L / 2, T2 / 2); + x.translate(h.x, h.y); + x.rotate(h.ang); + rr(x, -h.L / 2, -h.T / 2, h.L, h.T, h.T / 2); + const g = x.createLinearGradient(h.L / 2, -h.T / 2, -h.L / 2, h.T / 2); g.addColorStop(0, "rgb(251,251,252)"); g.addColorStop(1, "rgb(231,233,236)"); - rr(x, -L / 2, -T2 / 2, L, T2, T2 / 2); x.fillStyle = g; x.fill(); x.strokeStyle = "rgba(0,0,0,0.08)"; @@ -364,11 +463,8 @@ function drawMochi(x: CanvasRenderingContext2D, p: Pose) { const g = x.createLinearGradient(0, hh, 0, -hh * 0.1); g.addColorStop(0, `rgba(127,180,234,${p.tint})`); g.addColorStop(1, "rgba(127,180,234,0)"); - x.save(); - x.clip(body); x.fillStyle = g; x.fill(body); - x.restore(); } // Eyes @@ -433,45 +529,53 @@ function drawMochi(x: CanvasRenderingContext2D, p: Pose) { function drawParticles(x: CanvasRenderingContext2D, t: number, p: Pose) { if (!(p.card > 0 || p.fx < 1)) return; - for (const ring of PARTICLES.rings) { - const k = seg(t, ring.t0, ring.t0 + 1.35); - if (k <= 0 || k >= 1) continue; + + // Warp streaks: white vertical lines while Mochi falls in + if (t < 0.55) { + const fadeOut = 1 - seg(t, 0.4, 0.55); + x.lineCap = "butt"; + for (const s of PARTICLES.warps) { + if (t < s.t0) continue; + const yBot = (t - s.t0) * s.speed; + const yTop = yBot - s.len; + if (yBot <= 0) continue; + const sx = GREETING_W / 2 - p.iw / 2 + s.xNorm * p.iw; + x.strokeStyle = `rgba(255,255,255,${s.alpha * p.fx * fadeOut})`; + x.lineWidth = s.thick; + x.beginPath(); + x.moveTo(sx, Math.max(0, yTop)); + x.lineTo(sx, Math.min(GREETING_H, yBot)); + x.stroke(); + } + } + + // Ring: one white burst at 0.45 s + const k = seg(t, 0.45, 0.45 + 1.35); + if (k > 0 && k < 1) { const rx = lerp(14, 380, E.out(k)); const ry = rx * 0.34; const fade = (1 - k) * (k < 0.08 ? k / 0.08 : 1) * p.fx * p.card; - for (const dot of ring.dots) { + for (const dot of PARTICLES.ring) { const r = 1 + dot.j; x.fillStyle = `rgba(255,255,255,${dot.al * fade})`; x.fillRect(C0.x + Math.cos(dot.a) * rx * r, C0.y + Math.sin(dot.a) * ry * r, dot.s, dot.s); } } - for (const s of PARTICLES.streaks) { - const k = seg(t, s.t0, s.t0 + 0.6); - if (k <= 0 || k >= 1) continue; - const dist = s.sp * E.out(k) * 0.9 + 10; - const alpha = (1 - k) * p.fx; - x.strokeStyle = s.col + Math.round(alpha * 255).toString(16).padStart(2, "0"); - x.lineWidth = 1.6; - x.lineCap = "round"; - x.beginPath(); - x.moveTo(C0.x + Math.cos(s.a) * (dist - s.len), C0.y + Math.sin(s.a) * (dist - s.len) * 0.42); - x.lineTo(C0.x + Math.cos(s.a) * dist, C0.y + Math.sin(s.a) * dist * 0.42); - x.stroke(); - } } const MINI_COLORS = ["#E86A6A", "#3E86E0", "#EFAE5A", "#8C73F2"]; function drawMinis(x: CanvasRenderingContext2D, alpha: number) { if (alpha <= 0.01) return; - const cx = 320 + SMALL_W / 2 - 27; - const cy = 16; - const sp = 6; + const cx = GREETING_W / 2 - COMPACT.width / 2 + COMPACT.miniGridCenterX; + const cy = COMPACT.botCenterY; + const sp = 6 * COMPACT.miniGridScale; const offsets: [number, number][] = [[-sp, -sp], [sp, -sp], [-sp, sp], [sp, sp]]; offsets.forEach(([dx, dy], i) => { x.save(); x.translate(cx + dx, cy + dy); - x.scale(alpha, alpha); + const scale = alpha * COMPACT.miniGridScale; + x.scale(scale, scale); x.fillStyle = MINI_COLORS[i]; x.fill(mochiPath(5.3, 4)); x.restore(); @@ -497,11 +601,10 @@ export class Greeting { this.tc = Number.POSITIVE_INFINITY; this.fired = false; this.cancelTimers(); - this.timers.push( - window.setTimeout(() => Sound.play("greet"), T.pop0 * 1000), - window.setTimeout(() => Sound.play("blip"), T.badge * 1000), - window.setTimeout(() => this.fire(), (T.end + 0.05) * 1000), - ); + // The score plays from the start; it outlives greetComplete a little on + // purpose and fades when the greeting view goes away (see leave()). + Sound.play(GREETING_SOUND); + this.timers.push(window.setTimeout(() => this.fire(), (T.end + 0.05) * 1000)); } /** Mouse entered the island during the greeting — hold it open. */ @@ -514,6 +617,13 @@ export class Greeting { const t = (performance.now() - this.startMs) / 1000; if (!Number.isFinite(this.tc) || this.tc > t) this.tc = t; this.cancelTimers(); + Sound.fadeOut(GREETING_SOUND, 0.25); + } + + /** The greeting view is gone: let what is left of its sound fade away. */ + leave() { + this.cancelTimers(); + Sound.fadeOut(GREETING_SOUND, 0.2); } get elapsed(): number { @@ -540,8 +650,8 @@ export class Greeting { const t = this.elapsed; if (!this.fired && t >= T.end && this.tc >= T.autoLeave) this.fire(); - const p = pose(t, this.tc); - x.clearRect(0, 0, 640, 150); + const p = greetingPose(t, this.tc); + x.clearRect(0, 0, GREETING_W, GREETING_H); if (p.card > 0) { x.save(); diff --git a/windows/tests/greeting.test.mjs b/windows/tests/greeting.test.mjs new file mode 100644 index 000000000..f26bb486b --- /dev/null +++ b/windows/tests/greeting.test.mjs @@ -0,0 +1,72 @@ +// The launch greeting (src/mochi/greeting.ts): Mochi must never be cut off by +// the island while it grows, while he bounces and waves, or while it shrinks +// back to the compact island. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { GREETING_END, GREETING_W, greetingPose, mochiBounds } from "../src/mochi/greeting.ts"; +import { Tracked } from "../src/core/anim.ts"; +import { COMPACT_W, NOTCH_H, NOTCH_W, VIEW_LAYOUTS } from "../src/core/layout.ts"; + +const FRAME = 1 / 60; +const SLACK = 0.5; // anti-aliasing + +/** Mochi's box against the island's, which is centred in the 640 px greeting canvas. */ +function assertInside(t, tc, w, h) { + const b = mochiBounds(greetingPose(t, tc)); + if (!b) return; + const left = GREETING_W / 2 - w / 2; + const right = GREETING_W / 2 + w / 2; + const at = `t=${t.toFixed(3)} tc=${tc}`; + // No check on the top: he drops in from the edge of the screen, like out of + // the notch on a Mac, so his head is meant to come out of it. + assert.ok(b.bottom <= h + SLACK, `${at}: bottom ${b.bottom.toFixed(1)} below the island (${h.toFixed(1)})`); + assert.ok(b.left >= left - SLACK, `${at}: left ${b.left.toFixed(1)} outside ${left.toFixed(1)}`); + assert.ok(b.right <= right + SLACK, `${at}: right ${b.right.toFixed(1)} outside ${right.toFixed(1)}`); +} + +/** Runs the island geometry the way Island does: spring open, then the 340 ms close curve. */ +function run(tc) { + const width = new Tracked(NOTCH_W); + const height = new Tracked(0); + width.springTo(GREETING_W); + height.springTo(VIEW_LAYOUTS.greeting.height); + let collapsed = false; + const stop = Number.isFinite(tc) ? tc + 0.6 : GREETING_END + 0.5; + for (let t = 0; t <= stop; t += FRAME) { + const now = t * 1000; + if (!collapsed && t >= tc) { + collapsed = true; + width.curveTowards(COMPACT_W, 340, now); + height.curveTowards(NOTCH_H, 340, now); + } + width.step(FRAME, now); + height.step(FRAME, now); + assertInside(t, tc, width.value, height.value); + } +} + +test("Mochi stays inside the island for the whole greeting", () => { + run(Number.POSITIVE_INFINITY); +}); + +test("Mochi stays inside the island when the greeting is cut short", () => { + for (let tc = 0.3; tc < GREETING_END + 0.4; tc += 0.1) run(tc); +}); + +test("he slides to the side to wave, then comes back to the middle", () => { + const atWave = greetingPose(2.0); + assert.ok(atWave.x < 290, `waving at x=${atWave.x}`); + assert.ok(atWave.handL > 0.9 && atWave.handR > 0.9, "both hands out"); + assert.ok(atWave.wave >= 0, "waving"); + const back = greetingPose(3.6); + assert.equal(back.x, 320); + assert.equal(back.handL, 0); +}); + +test("he lands where the compact island's Mochi sits", () => { + const end = greetingPose(10, 5); + assert.equal(end.y, NOTCH_H / 2); + assert.equal(end.x, GREETING_W / 2 - COMPACT_W / 2 + 40); + assert.equal(end.minis, 1); +}); diff --git a/windows/vite.config.ts b/windows/vite.config.ts index 1ff3b6565..3cf942c5a 100644 --- a/windows/vite.config.ts +++ b/windows/vite.config.ts @@ -4,7 +4,7 @@ import { resolve, join, extname } from "node:path"; // ─────────────────────────────────────────────────────────────────────────────── // THE one and only place the shared sound folder is declared. -// The 28 WAVs live in the macOS app and are NOT duplicated in the repo; when they +// The 29 WAVs live in the macOS app and are NOT duplicated in the repo; when they // move to `shared/sounds/`, change this single line. export const SOUNDS_DIR = resolve(__dirname, "../NotchBuddy/Resources/sounds"); // ─────────────────────────────────────────────────────────────────────────────── From c97dec18cec5f341d1936cbb10b29970747de443 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:19:29 +0200 Subject: [PATCH 25/77] =?UTF-8?q?Wardrobe=20view:=20right-click=20Mochi,?= =?UTF-8?q?=20or=20Wardrobe=E2=80=A6=20in=20the=20tray?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New island view styled like the others: hover an outfit to try it on Mochi, click to keep it (pop + proud). Right-click on Mochi toggles it, from the compact island too. The bot canvas gets side room for wide hats, and leaving the greeting view fades its sound. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/tray.rs | 5 +- windows/src/core/layout.ts | 4 +- windows/src/island/island.ts | 66 ++++++++++++++++++++++-- windows/src/main.ts | 4 ++ windows/src/style.css | 62 +++++++++++++++++++++++ windows/src/views/views.ts | 7 +++ windows/src/views/wardrobe.ts | 94 +++++++++++++++++++++++++++++++++++ 7 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 windows/src/views/wardrobe.ts diff --git a/windows/src-tauri/src/tray.rs b/windows/src-tauri/src/tray.rs index f6aa0c8fc..559f6b4e8 100644 --- a/windows/src-tauri/src/tray.rs +++ b/windows/src-tauri/src/tray.rs @@ -1,4 +1,4 @@ -// Notification-area icon: Open, Settings, Pause, Quit. +// Notification-area icon: Open, Wardrobe, Settings, Pause, Quit. use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIconBuilder; @@ -8,13 +8,14 @@ use crate::island::WINDOW_LABEL; pub fn build(app: &AppHandle) -> tauri::Result<()> { let open = MenuItem::with_id(app, "open", "Open Coucou", true, None::<&str>)?; + let wardrobe = MenuItem::with_id(app, "wardrobe", "Wardrobe…", true, None::<&str>)?; let settings = MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?; let pause = MenuItem::with_id(app, "pause", "Pause", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let sep1 = PredefinedMenuItem::separator(app)?; let sep2 = PredefinedMenuItem::separator(app)?; - let menu = Menu::with_items(app, &[&open, &sep1, &settings, &pause, &sep2, &quit])?; + let menu = Menu::with_items(app, &[&open, &sep1, &wardrobe, &settings, &pause, &sep2, &quit])?; let mut builder = TrayIconBuilder::with_id("coucou") .tooltip("Coucou") diff --git a/windows/src/core/layout.ts b/windows/src/core/layout.ts index 6f70eea4c..0f0b29975 100644 --- a/windows/src/core/layout.ts +++ b/windows/src/core/layout.ts @@ -21,7 +21,8 @@ export type IslandViewName = | "result" | "note" | "settings" - | "greeting"; + | "greeting" + | "wardrobe"; export type BotStateName = | "idle" @@ -86,6 +87,7 @@ export const VIEW_LAYOUTS: Record = { note: { height: 160, botX: 60, botY: null, botDiameter: 50, agentMode: "column" }, settings: { height: 160, botX: 54, botY: null, botDiameter: 46, agentMode: "none" }, greeting: { height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: "none" }, + wardrobe: { height: 160, botX: 68, botY: null, botDiameter: 58, agentMode: "none" }, }; // The upload views above are only the fallback geometry. Once a file is actually diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb61802..18aabfa6c 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -14,6 +14,7 @@ import { State } from "../core/state"; import { BotEngine, hexToRGB } from "../mochi/engine"; import { Greeting } from "../mochi/greeting"; import { createMiniBot, pruneMiniBots, syncMiniBotStates, tickMiniBots } from "../mochi/minibots"; +import { SeasonCache, parseOutfit } from "../mochi/wardrobe"; import { UploadCanvas } from "../upload/canvas"; import { USC, UploadSeq } from "../upload/sequence"; import { buildHeader, buildViews, type ViewActions, type ViewHost } from "../views/views"; @@ -21,6 +22,10 @@ import { h } from "../views/dom"; import { IslandStateMachine } from "./fsm"; const BOT_OVERHANG = 40; +/** Extra canvas on each side of Mochi, for the witch hat's brim and the Santa hat's tip. */ +const BOT_SIDE = 24; +/** The pill whose Mochi wears the outfit; any other focused pill takes its own look. */ +const MAIN_PILL = "integration_claude"; /** Same margin as the Rust hit test (src-tauri/src/island.rs). */ const HIT_MARGIN = 14; @@ -60,6 +65,8 @@ export class Island { private engine = new BotEngine(); private greeting = new Greeting(); + private greetingShown = false; + private seasons = new SeasonCache(); private running = false; private lastFrame = 0; @@ -167,6 +174,18 @@ export class Island { }, openSettingsWindow: () => void Bridge.openSettingsWindow(), blip: () => Sound.play("blip"), + chooseOutfit: (selection) => { + if (parseOutfit(State.settings.mochiOutfit) === selection) return; + State.settings.mochiOutfit = selection; + void Bridge.saveSettings(State.settings); + Sound.play("pop"); + this.engine.triggerEmote("proud"); + State.notify(); + }, + previewOutfit: (outfit) => { + State.wardrobePreview = outfit; + State.notify(); + }, }; this.wakeStrip = h("div", { id: "wake-strip" }); @@ -334,6 +353,15 @@ export class Island { this.fsm.reveal(); } + /** Right-click on Mochi: wardrobe open ↔ back to the usual view. */ + toggleWardrobe() { + if (State.paused || State.mode === "hidden") return; + // The greeting and the drop sequence draw a Mochi of their own. + if (State.mode === "expanded" && (State.view === "greeting" || this.uploadActive)) return; + if (State.mode === "expanded" && State.view === "wardrobe") this.setView(State.defaultView()); + else this.setView("wardrobe"); + } + /** An alert stopped waiting for an answer: let the island auto-close again. */ dropPin() { this.fsm.pinned = false; @@ -534,6 +562,12 @@ export class Island { this.islandEl.addEventListener("mousedown", (e) => { Sound.resume(); State.lastActivity = performance.now(); + // Right-click on Mochi opens the wardrobe, and closes it again. + if (e.button === 2 && this.isBotHit(e.clientX, e.clientY)) { + this.cancelBotHover(); + this.toggleWardrobe(); + return; + } if (State.mode !== "expanded") { this.fsm.click(); return; @@ -544,6 +578,12 @@ export class Island { } }); + // No browser menu over Mochi: his right-click is the wardrobe. Everywhere + // else (the chat field) the webview keeps its own menu. + this.islandEl.addEventListener("contextmenu", (e) => { + if (this.isBotHit(e.clientX, e.clientY)) e.preventDefault(); + }); + window.addEventListener("keydown", (e) => { if (e.key === "Escape" && State.mode === "expanded" && !State.isPinned) this.collapse(); State.lastActivity = performance.now(); @@ -773,15 +813,16 @@ export class Island { const size = this.botSize.value; const w = Math.max(1, Math.round(size)); const hCss = w + BOT_OVERHANG; + const wCss = w + BOT_SIDE * 2; const dpr = Math.min(2, window.devicePixelRatio || 1); if (this.canvasPx !== w) { this.canvasPx = w; - this.botCanvas.width = Math.round(w * dpr); + this.botCanvas.width = Math.round(wCss * dpr); this.botCanvas.height = Math.round(hCss * dpr); - this.botCanvas.style.width = `${w}px`; + this.botCanvas.style.width = `${wCss}px`; this.botCanvas.style.height = `${hCss}px`; } - this.botCanvas.style.left = `${this.botCx.value - w / 2}px`; + this.botCanvas.style.left = `${this.botCx.value - wCss / 2}px`; this.botCanvas.style.top = `${this.botCy.value - BOT_OVERHANG / 2 - hCss / 2}px`; const ctx = this.botCanvas.getContext("2d"); @@ -801,9 +842,18 @@ export class Island { this.engine.slotHVel = 0; } } + // Only the main Mochi is dressed: a focused integration pill shows its own + // colours, unless the wardrobe is open (BotCanvasView.showOutfit, macOS). + // In the wardrobe the hovered outfit swaps in at once, without the drop-in. + const inWardrobe = State.mode === "expanded" && State.view === "wardrobe"; + const mainFocused = State.focusId == null || State.focusId === MAIN_PILL; + const showOutfit = mainFocused || State.mode !== "expanded" || inWardrobe; + const outfit = State.wardrobePreview ?? this.seasons.get(parseOutfit(State.settings.mochiOutfit)); + this.engine.setOutfit(showOutfit ? outfit : "none", !inWardrobe); + this.engine.update(dt); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, w, hCss); + ctx.setTransform(dpr, 0, 0, dpr, BOT_SIDE * dpr, 0); + ctx.clearRect(-BOT_SIDE, 0, wCss, hCss); this.engine.draw(ctx, w, hCss); } @@ -840,6 +890,12 @@ export class Island { this.contentEl.style.pointerEvents = expanded && !greetingActive ? "auto" : "none"; this.greetingCanvas.style.display = greetingActive ? "block" : "none"; + // Leaving the greeting, however it ends, lets its sound fade out. + if (this.greetingShown && !greetingActive) this.greeting.leave(); + this.greetingShown = greetingActive; + // A wardrobe try-on never outlives the wardrobe. + if (State.wardrobePreview && !(expanded && State.view === "wardrobe")) State.wardrobePreview = null; + this.header.sync(); for (const [name, view] of this.views) { const on = name === State.view; diff --git a/windows/src/main.ts b/windows/src/main.ts index f65d5e757..21b2195f9 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -43,6 +43,10 @@ async function main() { setPaused(false); island.alert(State.defaultView()); break; + case "wardrobe": + setPaused(false); + island.alert("wardrobe"); + break; case "pause": setPaused(!State.paused); if (State.paused) island.fsm.forceHidden(); diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c..28c7c392d 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -1365,3 +1365,65 @@ input[type="range"]::-webkit-slider-thumb { .int-card.detail { padding-top: 8px; } + +/* ── Wardrobe (right-click on Mochi) — WardrobeView on macOS ─────────────── */ + +.wardrobe-stack { + padding: 12px 16px 12px 104px; + gap: 10px; +} + +.wardrobe-head { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +.wardrobe-title { + font: 600 12px var(--font); + color: var(--ink); + flex: 0 0 auto; +} + +.wardrobe-note { + margin-left: auto; + font: 400 11px var(--font); + color: var(--dim-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wardrobe-grid { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.wardrobe-item { + width: 30px; + height: 30px; + padding: 0; + border-radius: 7px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.06); + display: grid; + place-items: center; + cursor: default; + transition: background 0.12s ease-out, border-color 0.12s ease-out; +} + +.wardrobe-item:hover { + background: rgba(255, 255, 255, 0.1); +} + +.wardrobe-item.on { + border-color: rgba(255, 255, 255, 0.4); +} + +.wardrobe-item canvas { + width: 28px; + height: 28px; + pointer-events: none; +} diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f..484909b78 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -11,6 +11,8 @@ import { createMiniBot, pruneMiniBots } from "../mochi/minibots"; import { buildPrompt } from "./chat"; import { buildChoose, buildUpload, buildUploading } from "./upload"; import { renderIntegrationCard, type IntegrationCardHooks } from "./integrations"; +import { buildWardrobe } from "./wardrobe"; +import type { Outfit, OutfitSelection } from "../mochi/wardrobe"; export interface ViewActions { setView(v: IslandViewName): void; @@ -26,6 +28,10 @@ export interface ViewActions { setAutoClose(seconds: number): void; openSettingsWindow(): void; blip(): void; + /** Wardrobe click: keeps the outfit ("auto" and "none" included). */ + chooseOutfit(selection: OutfitSelection): void; + /** Wardrobe hover: shows an outfit on Mochi without keeping it; null ends it. */ + previewOutfit(outfit: Outfit | null): void; } export interface ViewHost { @@ -501,6 +507,7 @@ export function buildViews( map.set("upload", buildUpload()); map.set("uploading", buildUploading()); map.set("choose", buildChoose(actions)); + map.set("wardrobe", buildWardrobe(actions)); // Not in the Windows v1: sending a file by email, window attach + web result. map.set("mail", buildPlaceholder("Sending by email isn't in this version.", "")); map.set("searching", buildPlaceholder("Claude is searching…", "")); diff --git a/windows/src/views/wardrobe.ts b/windows/src/views/wardrobe.ts new file mode 100644 index 000000000..4c9a61e54 --- /dev/null +++ b/windows/src/views/wardrobe.ts @@ -0,0 +1,94 @@ +// Wardrobe view — port of WardrobeView (IslandViewContent.swift). Opened with a +// right-click on Mochi or from the tray menu. Resting the pointer on a button +// tries the outfit on Mochi; a click keeps it. + +import { h } from "./dom"; +import { State } from "../core/state"; +import { drawWardrobeIcon } from "../mochi/outfits"; +import { + OUTFIT_LABELS, OUTFIT_SELECTIONS, WARDROBE_STRINGS, parseOutfit, resolveOutfit, seasonalOutfit, + wardrobeHeader, type Outfit, type OutfitSelection, +} from "../mochi/wardrobe"; +import type { ViewActions, ViewHost } from "./views"; + +const ICON = 28; + +export function buildWardrobe(actions: ViewActions): ViewHost { + const note = h("span", { class: "wardrobe-note" }); + const grid = h("div", { class: "wardrobe-grid" }); + const el = h( + "div", + { class: "view wardrobe" }, + h( + "div", + { class: "card" }, + h( + "div", + { class: "stack wardrobe-stack" }, + h("div", { class: "wardrobe-head" }, h("span", { class: "wardrobe-title", text: WARDROBE_STRINGS.title }), note), + grid, + ), + ), + ); + + let hovered: OutfitSelection | null = null; + let drawnSeason: Outfit | null = null; + const items = new Map(); + + const updateNote = () => { + note.textContent = wardrobeHeader(hovered, parseOutfit(State.settings.mochiOutfit), new Date()); + }; + + for (const sel of OUTFIT_SELECTIONS) { + const canvas = h("canvas"); + const button = h( + "button", + { class: "wardrobe-item", title: OUTFIT_LABELS[sel], "aria-label": OUTFIT_LABELS[sel] }, + canvas, + ); + button.addEventListener("mouseenter", () => { + hovered = sel; + actions.previewOutfit(resolveOutfit(sel, new Date())); + updateNote(); + }); + button.addEventListener("mouseleave", () => { + if (hovered !== sel) return; + hovered = null; + actions.previewOutfit(null); + updateNote(); + }); + button.addEventListener("click", () => actions.chooseOutfit(sel)); + items.set(sel, { button, canvas }); + grid.append(button); + } + + /** Icons are drawn once; only "auto" changes, with the season. */ + const drawIcons = () => { + const season = seasonalOutfit(new Date()); + if (season === drawnSeason) return; + const first = drawnSeason == null; + drawnSeason = season; + const dpr = Math.min(2, window.devicePixelRatio || 1); + for (const [sel, { canvas }] of items) { + if (!first && sel !== "auto") continue; + canvas.width = Math.round(ICON * dpr); + canvas.height = Math.round(ICON * dpr); + const ctx = canvas.getContext("2d"); + if (!ctx) continue; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + drawWardrobeIcon(ctx, ICON, sel, season, WARDROBE_STRINGS.autoBadge); + } + }; + + return { + el, + sync() { + drawIcons(); + // The island drops the preview when the view closes: so does the hover. + if (State.wardrobePreview == null) hovered = null; + const current = parseOutfit(State.settings.mochiOutfit); + for (const [sel, { button }] of items) button.classList.toggle("on", sel === current); + updateNote(); + }, + }; +} From 2e34a5e49bc27424a625703b1d2e827458bcdc5b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:19:29 +0200 Subject: [PATCH 26/77] README: wardrobe, tray entry, 29 sounds Co-Authored-By: Claude Opus 5.5 --- windows/README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/windows/README.md b/windows/README.md index 2b897bd3d..d6da9f5a6 100644 --- a/windows/README.md +++ b/windows/README.md @@ -43,9 +43,10 @@ installs for the current user only — no admin prompt. | Click the small island | It opens | | Click Mochi | It gets annoyed. Three times in a row and it goes dizzy | | Rest the pointer on Mochi for two seconds | Hearts | +| Right-click Mochi | The wardrobe: rest the pointer on an outfit to try it on, click to keep it. **Auto** dresses him for the season (witch hat in October, Santa hat in December…) | | Drag a file onto the island | Mochi turns into a box, swallows it, then offers to answer questions about it | | `Esc` | Closes the island | -| Tray icon | Open, Settings…, Pause, Quit | +| Tray icon | Open, Wardrobe…, Settings…, Pause, Quit | Everything else happens on its own: a Claude Code permission request opens the island with **Deny / Allow**, a finished session shows what it did, and @@ -107,7 +108,7 @@ Installing is optional — `target/release/coucou.exe` runs on its own. There is window in the taskbar and no console: the island at the top of the screen and the Mochi in the notification area are the whole app, and Quit lives in its menu. -The 28 sounds are the macOS app's own files; they are never duplicated in this +The 29 sounds are the macOS app's own files; they are never duplicated in this folder. The path is declared once, in `SOUNDS_DIR` at the top of `vite.config.ts` — when they move to `shared/sounds/`, change that one line. @@ -163,6 +164,9 @@ OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integr attach it as context, and jumping to a specific terminal window — "Open terminal" opens the working folder in VS Code when `code` is on your `PATH`. - Cal.com shows the next bookings as a list rather than the Mac's calendar. +- The wardrobe opens with a right-click on Mochi or from the tray menu; the + Mac's keyboard shortcut for it isn't there. In the compact island a tall hat + is cut by the top edge of the screen, as it is by the notch on a Mac. ## Linux From deab632cbd56936c1e0554dc3a6ff1663f27fca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:20:09 +0200 Subject: [PATCH 27/77] Windows/Linux: register global shortcuts from Rust tauri-plugin-global-shortcut registers the Mac's global actions with AltGr-safe Ctrl+Alt defaults, stored in settings.shortcuts with serde defaults. Each press reaches the island as a `shortcut` event (the wardrobe as `open-wardrobe`). On Windows a Ctrl+Alt combination that types a character on an installed layout is left unregistered. On Wayland nothing is registered and `coucou --shortcut ` runs an action through the single-instance relay instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DMzyoPKtGVNBUemdZhnWRG --- windows/Cargo.lock | 80 +++- windows/src-tauri/Cargo.toml | 1 + windows/src-tauri/src/lib.rs | 54 ++- windows/src-tauri/src/platform/linux.rs | 27 ++ windows/src-tauri/src/platform/windows.rs | 46 +- windows/src-tauri/src/settings.rs | 9 +- windows/src-tauri/src/shortcuts.rs | 551 ++++++++++++++++++++++ 7 files changed, 759 insertions(+), 9 deletions(-) create mode 100644 windows/src-tauri/src/shortcuts.rs diff --git a/windows/Cargo.lock b/windows/Cargo.lock index 6278c7525..b6a09d346 100644 --- a/windows/Cargo.lock +++ b/windows/Cargo.lock @@ -621,6 +621,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-global-shortcut", "tauri-plugin-single-instance", "tokio", "windows 0.61.3", @@ -1366,6 +1367,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1490,6 +1501,24 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types 0.7.0", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.21", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2068,6 +2097,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.2", + "serde", + "unicode-segmentation", +] + [[package]] name = "keyboard-types" version = "0.8.3" @@ -2255,7 +2295,7 @@ dependencies = [ "crossbeam-channel", "dpi", "gtk", - "keyboard-types", + "keyboard-types 0.8.3", "objc2", "objc2-app-kit", "objc2-core-foundation", @@ -4048,6 +4088,21 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd2e1f725891a1613af8f25bf918ba3357654b0fd3f2d794c0ce76cbdd92ed2" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.5.0" @@ -5447,6 +5502,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xdg-home" version = "1.3.0" @@ -5457,6 +5529,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.3" diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml index f581867d9..901b5665f 100644 --- a/windows/src-tauri/Cargo.toml +++ b/windows/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-single-instance = "2" tauri-plugin-autostart = "2" +tauri-plugin-global-shortcut = "2" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } tokio = { version = "1", features = ["net", "io-util", "sync", "time", "rt"] } diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index d9ec3353e..5f06cca60 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod pipe; mod platform; mod secrets; mod settings; +mod shortcuts; mod tray; use std::process::Command; @@ -61,12 +62,13 @@ 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, shortcuts_changed) = { let mut current = shared.settings.lock().unwrap(); let screen_changed = current.screen != settings.screen; let autostart_changed = current.autostart != settings.autostart; + let shortcuts_changed = current.shortcuts != settings.shortcuts; *current = settings.clone(); - (screen_changed, autostart_changed) + (screen_changed, autostart_changed, shortcuts_changed) }; if let Err(err) = settings::save(&settings) { log::line(format!("could not save settings: {err}")); @@ -82,6 +84,9 @@ fn save_settings(app: AppHandle, shared: State, settings: Settings) { let collapsed = shared.gate.collapsed.load(Ordering::Relaxed); island::apply_geometry(&app, &settings.screen, collapsed); } + if shortcuts_changed { + shortcuts::apply(&app, &settings.shortcuts); + } // Keep the other window in step (island ⇄ settings window). let _ = app.emit("settings-changed", settings); } @@ -294,6 +299,26 @@ fn log_line(message: String) { log::line(format!("ui {message}")); } +// ── Global shortcuts ────────────────────────────────────────────────────────── + +/// How each global shortcut went: registered, taken by another app, and so on. +#[tauri::command] +fn shortcuts_status(app: AppHandle) -> shortcuts::Report { + shortcuts::status(&app) +} + +/// Settings is recording a new combination: let go of ours meanwhile, so the +/// keys reach the recorder instead of running an action. `false` takes them back. +#[tauri::command] +fn shortcuts_suspend(app: AppHandle, shared: State, suspended: bool) { + if suspended { + shortcuts::suspend(&app); + } else { + let stored = shared.settings.lock().unwrap().shortcuts.clone(); + shortcuts::apply(&app, &stored); + } +} + // ── Settings window ─────────────────────────────────────────────────────────── /// WebView2 allows exactly one browser environment per app, and its options are @@ -365,17 +390,31 @@ pub fn run() { let loaded = settings::load(); let gate = Arc::new(PollGate::new()); - tauri::Builder::default() - .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { - let _ = app.emit_to(island::WINDOW_LABEL, "tray", "open".to_string()); + let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + // `coucou --shortcut `: what a desktop's own keyboard + // settings run where we can't listen for keys ourselves (Wayland). + match shortcuts::from_args(&argv) { + Some(action) => shortcuts::dispatch(app, action), + None => { + let _ = app.emit_to(island::WINDOW_LABEL, "tray", "open".to_string()); + } + } })) - .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None)) + .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None)); + // Where no global shortcut can work, the plugin isn't even started. + if platform::global_shortcuts_blocked().is_none() { + builder = builder.plugin(shortcuts::plugin()); + } + + builder .manage(Shared { settings: Mutex::new(loaded.clone()), gate: gate.clone(), }) .manage(Pending::default()) .manage(Chat::default()) + .manage(shortcuts::Registry::default()) .invoke_handler(tauri::generate_handler![ boot, save_settings, @@ -403,6 +442,8 @@ pub fn run() { open_n8n, open_settings_window, set_paused, + shortcuts_status, + shortcuts_suspend, ]) .setup(move |app| { let handle = app.handle().clone(); @@ -428,6 +469,7 @@ pub fn run() { hooks::ensure_hook_exe(&handle); pipe::start(handle.clone()); integrations::start(handle.clone()); + shortcuts::apply(&handle, &loaded.shortcuts); Ok(()) }) .run(tauri::generate_context!()) diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e748a336a..262033133 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -300,6 +300,33 @@ fn apply_input_region(gw: &impl IsA, rect: Region) { } } +// ── Global shortcuts ────────────────────────────────────────────────────────── + +/// Global shortcuts are X11 key grabs. A Wayland session has no such thing: a +/// grab made through XWayland only sees keys typed into other X11 windows, so +/// it would look registered and never fire. The XDG GlobalShortcuts portal is +/// the Wayland way and isn't wired up yet, so on Wayland nothing is registered +/// and Settings explains how to bind `coucou --shortcut ` in the desktop's +/// own keyboard settings instead. +pub fn global_shortcuts_blocked() -> Option<&'static str> { + let set = |var: &str| std::env::var_os(var).is_some_and(|v| !v.is_empty()); + let wayland = set("WAYLAND_DISPLAY") + || std::env::var("XDG_SESSION_TYPE").is_ok_and(|v| v.eq_ignore_ascii_case("wayland")); + if wayland { + Some("wayland") + } else if !set("DISPLAY") { + Some("no-display") + } else { + None + } +} + +/// On X11, AltGr is a modifier of its own (ISO_Level3_Shift): Ctrl+Alt+key +/// never stands for it, so no combination takes a character away. +pub fn ctrl_alt_types(_vk: u16, _shift: bool) -> Option { + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 6e1200a8a..6707a2d65 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -13,7 +13,10 @@ use ::windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TO use ::windows::Win32::System::Ole::RevokeDragDrop; use ::windows::Win32::System::SystemInformation::GetLocalTime; use ::windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; -use ::windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON}; +use ::windows::Win32::UI::Input::KeyboardAndMouse::{ + GetAsyncKeyState, GetKeyboardLayoutList, MapVirtualKeyExW, ToUnicodeEx, HKL, MAPVK_VK_TO_VSC, + VK_CONTROL, VK_LBUTTON, VK_MENU, VK_SHIFT, +}; use ::windows::Win32::UI::WindowsAndMessaging::{ EnumChildWindows, GetClassNameW, GetCursorPos, GetWindowLongPtrW, SetWindowLongPtrW, GWL_EXSTYLE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, @@ -233,3 +236,44 @@ pub fn set_activating(win: &WebviewWindow, activating: bool) { /// Click-through here is the poll's WS_EX_TRANSPARENT toggle, not a region. pub fn set_input_region(_win: &WebviewWindow, _rect: Option<(f64, f64, f64, f64)>) {} + +// ── Global shortcuts ────────────────────────────────────────────────────────── + +/// RegisterHotKey works in every Windows session. +pub fn global_shortcuts_blocked() -> Option<&'static str> { + None +} + +/// The character Ctrl+Alt(+Shift)+`vk` types on one of the installed keyboard +/// layouts, if any. Windows reads Ctrl+Alt as AltGr, so registering such a +/// combination as a global shortcut would stop the user typing that character +/// (AltGr+E is € on most European layouts, AltGr+A is ą in Polish). +pub fn ctrl_alt_types(vk: u16, shift: bool) -> Option { + let mut layouts = [HKL::default(); 32]; + let count = unsafe { GetKeyboardLayoutList(Some(&mut layouts[..])) }; + let count = usize::try_from(count).unwrap_or(0).min(layouts.len()); + + let mut state = [0u8; 256]; + state[VK_CONTROL.0 as usize] = 0x80; + state[VK_MENU.0 as usize] = 0x80; + if shift { + state[VK_SHIFT.0 as usize] = 0x80; + } + for &layout in &layouts[..count] { + let scan = unsafe { MapVirtualKeyExW(u32::from(vk), MAPVK_VK_TO_VSC, Some(layout)) }; + let mut buf = [0u16; 8]; + // Flag 0x4: leave the keyboard state alone, so a dead key met here + // doesn't change what the user types next (Windows 10 1607 and later). + let n = unsafe { ToUnicodeEx(u32::from(vk), scan, &state, &mut buf, 0x4, Some(layout)) }; + let typed = match n { + // A dead key (´ ^ ¨…) is still something the user types with it. + n if n < 0 => String::from_utf16_lossy(&buf[..1]), + 0 => continue, + n => String::from_utf16_lossy(&buf[..(n as usize).min(buf.len())]), + }; + if typed.chars().any(|c| !c.is_control()) { + return Some(typed); + } + } + None +} diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 17f63e1c1..003b4c28d 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -23,6 +23,9 @@ pub struct Settings { pub hooks_installed: bool, /// Claude model used by the chat. Changeable in the settings window. pub model: String, + /// Global shortcuts the user changed, by action id; the others keep their + /// default (see shortcuts.rs). + pub shortcuts: crate::shortcuts::Bindings, } fn default_model() -> String { @@ -46,6 +49,7 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + shortcuts: Default::default(), } } } @@ -306,7 +310,8 @@ mod tests { "screen": "cursor", "autostart": true, "hooksInstalled": true, - "model": "some-model" + "model": "some-model", + "shortcuts": { "openChat": { "keys": "Ctrl+Shift+K", "enabled": false } } }"#; fn custom() -> Value { @@ -414,6 +419,7 @@ mod tests { ("soundEnabled", json!(1)), ("activeIntegrations", json!("integration_n8n")), ("screen", Value::Null), + ("shortcuts", json!("Ctrl+Alt+A")), ] { let loaded = parse(&custom_with(key, Some(wrong))) .unwrap_or_else(|| panic!("a file with a bad {key} was thrown away")); @@ -632,6 +638,7 @@ mod tests { "autostart", "hooksInstalled", "model", + "shortcuts", ] ); let _ = std::fs::remove_dir_all(&dir); diff --git a/windows/src-tauri/src/shortcuts.rs b/windows/src-tauri/src/shortcuts.rs new file mode 100644 index 000000000..a47c7c4e6 --- /dev/null +++ b/windows/src-tauri/src/shortcuts.rs @@ -0,0 +1,551 @@ +// Global keyboard shortcuts — port of HotKeyCenter.swift + ShortcutLogic.swift. +// +// The shortcuts are registered from Rust through tauri-plugin-global-shortcut +// (RegisterHotKey on Windows, XGrabKey on X11). A press is handed to the island +// as a `shortcut` event carrying the action id; the island does the rest, the +// same way it handles the tray menu. The wardrobe is the exception: it goes out +// as its own `open-wardrobe` event, which the wardrobe view listens to. +// +// Default keys. The Mac uses ⌃⌥ + a letter. On Windows Ctrl+Alt *is* AltGr on +// most European layouts, so a global Ctrl+Alt+E would swallow every € typed on +// a French or German keyboard. The defaults below were checked against the +// AltGr layer of the French (AZERTY), German (QWERTZ), Spanish, Italian, +// Portuguese and Brazilian (ABNT2) layouts, which between them put a character +// on E, Q, M, W, C and on every digit and most punctuation keys: +// +// Ctrl+Alt+Space open the chat Ctrl+Alt+→ / ← next / previous pill +// Ctrl+Alt+A waiting permission Ctrl+Alt+S mute Mochi +// Ctrl+Alt+T open the terminal Ctrl+Alt+G wardrobe +// Ctrl+Alt+N open / close the island (off by default, as on the Mac) +// +// ⌃⌥[ and ⌃⌥] became the arrows (brackets are AltGr characters almost +// everywhere) and ⌃⌥M became S (AltGr+M is µ in German). Layouts outside that +// list can still clash — Polish puts ą on AltGr+A and ś on AltGr+S — so on +// Windows every Ctrl+Alt combination is also checked against the keyboard +// layouts actually installed (platform::ctrl_alt_types) and left unregistered, +// flagged in Settings, when it types a character. The same table lives in +// src/core/shortcuts.ts; tests/shortcuts.test.mjs keeps the two in step. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::str::FromStr; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Manager, Runtime}; +use tauri_plugin_global_shortcut::{Code, GlobalShortcut, Modifiers, Shortcut, ShortcutState}; + +use crate::island::WINDOW_LABEL; + +/// One global action, as on the Mac (`ShortcutAction`). The ids are the Mac's +/// raw values and are stored in settings.json: never rename one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ActionDef { + pub id: &'static str, + pub default_keys: &'static str, + pub enabled_by_default: bool, + /// False for the Mac actions this version cannot do yet. Their ids and + /// defaults stay reserved so a later port lands on the same keys. + pub ported: bool, +} + +const fn action(id: &'static str, keys: &'static str, on: bool, ported: bool) -> ActionDef { + ActionDef { id, default_keys: keys, enabled_by_default: on, ported } +} + +/// Same order as `ShortcutAction.allCases`. +pub const ACTIONS: &[ActionDef] = &[ + action("toggleIsland", "Ctrl+Alt+N", false, true), + action("openChat", "Ctrl+Alt+Space", true, true), + action("goToAlert", "Ctrl+Alt+A", true, true), + action("jumpToTerminal", "Ctrl+Alt+T", true, true), + // Dragging Mochi onto a window is not in this version. + action("attachFrontWindow", "Ctrl+Alt+F", true, false), + action("nextPill", "Ctrl+Alt+Right", true, true), + action("prevPill", "Ctrl+Alt+Left", true, true), + action("muteToggle", "Ctrl+Alt+S", true, true), + // Mochi on the desktop is not in this version. + action("desktopToggle", "Ctrl+Alt+D", true, false), + action("wardrobeToggle", "Ctrl+Alt+G", true, true), +]; + +pub fn find(id: &str) -> Option<&'static ActionDef> { + ACTIONS.iter().find(|a| a.id == id) +} + +/// What the user chose for one action. An action missing from settings.json +/// uses its default; `keys` empty means no key at all. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Binding { + pub keys: String, + pub enabled: bool, +} + +impl Default for Binding { + fn default() -> Self { + Self { keys: String::new(), enabled: true } + } +} + +/// `settings.shortcuts`: only the actions the user changed. +pub type Bindings = BTreeMap; + +/// The binding in force for `def`: the stored one, or the default. +pub fn effective(def: &ActionDef, stored: &Bindings) -> Binding { + stored.get(def.id).cloned().unwrap_or_else(|| Binding { + keys: def.default_keys.to_string(), + enabled: def.enabled_by_default, + }) +} + +// ── Status shown in Settings ────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Status { + /// Registered and listening. + Active, + /// Turned off, or no key. + Off, + /// Another app already holds this combination. + InUse, + /// Another Coucou shortcut has the same combination. + Duplicate, + /// Not a combination the OS can register. + Invalid, + /// Ctrl+Alt+key types a character on one of the keyboard layouts. + TypesCharacter, + /// Global shortcuts can't be registered in this session (Wayland). + Unsupported, + /// The action itself isn't in this version. + NotPorted, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionStatus { + pub id: &'static str, + pub status: Status, + /// The character a `TypesCharacter` combination types. + #[serde(skip_serializing_if = "Option::is_none")] + pub typed: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Report { + pub actions: Vec, + /// Why nothing could be registered at all, when that is the case. + pub blocked: Option, + /// The command a desktop's own shortcut settings can run instead: + /// ` --shortcut `. + pub command: String, +} + +/// What is registered right now: hot-key id → action id, plus the last report. +#[derive(Default)] +pub struct Registry { + by_id: Mutex>, + report: Mutex>, +} + +// ── Pure helpers ────────────────────────────────────────────────────────────── + +/// Parses an accelerator the way the plugin will. `None` for an empty or +/// unknown one, and for a bare or Shift-only key: a global shortcut needs +/// Ctrl, Alt or the Windows key, or it would take that key away from every +/// other app (same rule as parseKeys in src/core/shortcuts.ts). +pub fn parse(keys: &str) -> Option { + if keys.trim().is_empty() { + return None; + } + let shortcut = Shortcut::from_str(keys).ok()?; + let has_modifier = shortcut + .mods + .intersects(Modifiers::CONTROL | Modifiers::ALT | Modifiers::SUPER | Modifiers::META); + (has_modifier && shortcut.key != Code::Escape).then_some(shortcut) +} + +/// True when Windows reads the combination as AltGr: Ctrl and Alt held, no +/// Windows key. +pub fn is_altgr_like(shortcut: &Shortcut) -> bool { + shortcut.mods.contains(Modifiers::CONTROL | Modifiers::ALT) + && !shortcut.mods.intersects(Modifiers::SUPER | Modifiers::META) +} + +/// The Windows virtual-key code global-hotkey registers for `code`, for the +/// keys that can type a character. Others (arrows, F-keys…) never do: `None`. +pub fn character_vk(code: Code) -> Option { + use Code::*; + let letters = [ + KeyA, KeyB, KeyC, KeyD, KeyE, KeyF, KeyG, KeyH, KeyI, KeyJ, KeyK, KeyL, KeyM, KeyN, + KeyO, KeyP, KeyQ, KeyR, KeyS, KeyT, KeyU, KeyV, KeyW, KeyX, KeyY, KeyZ, + ]; + if let Some(i) = letters.iter().position(|c| *c == code) { + return Some(0x41 + i as u16); + } + let digits = [Digit0, Digit1, Digit2, Digit3, Digit4, Digit5, Digit6, Digit7, Digit8, Digit9]; + if let Some(i) = digits.iter().position(|c| *c == code) { + return Some(0x30 + i as u16); + } + Some(match code { + Space => 0x20, + Semicolon => 0xBA, + Equal => 0xBB, + Comma => 0xBC, + Minus => 0xBD, + Period => 0xBE, + Slash => 0xBF, + Backquote => 0xC0, + BracketLeft => 0xDB, + Backslash => 0xDC, + BracketRight => 0xDD, + Quote => 0xDE, + IntlBackslash => 0xE2, + _ => return None, + }) +} + +/// What each action should do, before the OS is asked anything: the shortcut +/// to register, or the reason it won't be. `types` answers whether a Ctrl+Alt +/// combination types a character (see platform::ctrl_alt_types). +pub fn plan( + stored: &Bindings, + types: impl Fn(&Shortcut) -> Option, +) -> Vec<(&'static ActionDef, Result)> { + let mut seen = HashSet::new(); + ACTIONS + .iter() + .map(|def| { + let refuse = |status, typed| Err(ActionStatus { id: def.id, status, typed }); + let binding = effective(def, stored); + let outcome = if !def.ported { + refuse(Status::NotPorted, None) + } else if !binding.enabled || binding.keys.trim().is_empty() { + refuse(Status::Off, None) + } else { + match parse(&binding.keys) { + None => refuse(Status::Invalid, None), + Some(shortcut) => { + if let Some(typed) = types(&shortcut) { + refuse(Status::TypesCharacter, Some(typed)) + } else if !seen.insert(shortcut.id()) { + refuse(Status::Duplicate, None) + } else { + Ok(shortcut) + } + } + } + }; + (def, outcome) + }) + .collect() +} + +/// `coucou --shortcut ` → the action id, when it names a ported one. +pub fn from_args(args: &[String]) -> Option<&'static str> { + let at = args.iter().position(|a| a == "--shortcut")?; + let def = find(args.get(at + 1)?)?; + def.ported.then_some(def.id) +} + +// ── Registration ────────────────────────────────────────────────────────────── + +/// The plugin, with every press routed through `dispatch`. +pub fn plugin() -> tauri::plugin::TauriPlugin { + tauri_plugin_global_shortcut::Builder::new() + .with_handler(|app, shortcut, event| { + if event.state != ShortcutState::Pressed { + return; + } + let Some(registry) = app.try_state::() else { return }; + let action = registry.by_id.lock().unwrap().get(&shortcut.id()).copied(); + if let Some(action) = action { + dispatch(app, action); + } + }) + .build() +} + +/// Hands an action to the island. +pub fn dispatch(app: &AppHandle, action: &str) { + crate::log::line(format!("shortcut {action}")); + if action == "wardrobeToggle" { + let _ = app.emit_to(WINDOW_LABEL, "open-wardrobe", ()); + } else { + let _ = app.emit_to(WINDOW_LABEL, "shortcut", action.to_string()); + } +} + +/// Unregisters everything Coucou holds. +fn release(app: &AppHandle) { + if let Some(gs) = app.try_state::>() { + if let Err(err) = gs.unregister_all() { + crate::log::line(format!("shortcuts: unregister failed: {err}")); + } + } + if let Some(registry) = app.try_state::() { + registry.by_id.lock().unwrap().clear(); + } +} + +/// Registers the shortcuts in `stored` in place of the current ones, and +/// tells the settings window how each one went. Never fails: a shortcut that +/// can't be had is reported, not fatal. +pub fn apply(app: &AppHandle, stored: &Bindings) { + release(app); + let blocked = crate::platform::global_shortcuts_blocked(); + let gs = app.try_state::>(); + + let mut by_id = HashMap::new(); + let mut report = Vec::new(); + for (def, outcome) in plan(stored, typed_character) { + let status = match outcome { + Err(status) => status, + Ok(_) if blocked.is_some() || gs.is_none() => { + ActionStatus { id: def.id, status: Status::Unsupported, typed: None } + } + Ok(shortcut) => { + // No lock of ours is held here: the plugin calls our handler + // with its own lock taken, on the thread that registers. + let result = gs.as_ref().map(|gs| gs.register(shortcut)); + let status = match result { + Some(Ok(())) => { + by_id.insert(shortcut.id(), def.id); + Status::Active + } + Some(Err(err)) => { + crate::log::line(format!("shortcuts: {} not registered: {err}", def.id)); + Status::InUse + } + None => Status::Unsupported, + }; + ActionStatus { id: def.id, status, typed: None } + } + }; + report.push(status); + } + + if let Some(registry) = app.try_state::() { + *registry.by_id.lock().unwrap() = by_id; + *registry.report.lock().unwrap() = report; + } + let _ = app.emit("shortcuts-status", status(app)); +} + +/// Lets go of every shortcut while a new one is being recorded in Settings, +/// so pressing a combination Coucou already holds records it instead of +/// running it. +pub fn suspend(app: &AppHandle) { + release(app); +} + +pub fn status(app: &AppHandle) -> Report { + let actions = app + .try_state::() + .map(|r| r.report.lock().unwrap().clone()) + .unwrap_or_default(); + Report { + actions, + blocked: crate::platform::global_shortcuts_blocked().map(str::to_string), + command: format!("{} --shortcut", launch_command()), + } +} + +/// How a desktop shortcut should start us: the AppImage when we run from one +/// (the mounted executable moves on every launch), this executable otherwise. +fn launch_command() -> String { + let exe = std::env::var_os("APPIMAGE") + .map(std::path::PathBuf::from) + .or_else(|| std::env::current_exe().ok()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "coucou".to_string()); + if exe.contains(' ') { + format!("\"{exe}\"") + } else { + exe + } +} + +fn typed_character(shortcut: &Shortcut) -> Option { + if !is_altgr_like(shortcut) { + return None; + } + let vk = character_vk(shortcut.key)?; + crate::platform::ctrl_alt_types(vk, shortcut.mods.contains(Modifiers::SHIFT)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAC_IDS: [&str; 10] = [ + "toggleIsland", "openChat", "goToAlert", "jumpToTerminal", "attachFrontWindow", + "nextPill", "prevPill", "muteToggle", "desktopToggle", "wardrobeToggle", + ]; + + fn never(_: &Shortcut) -> Option { + None + } + + // testDefaultsExhaustive + #[test] + fn every_mac_action_has_a_default_and_keeps_its_id() { + let ids: Vec<_> = ACTIONS.iter().map(|a| a.id).collect(); + assert_eq!(ids, MAC_IDS); + } + + // testAllDefaultsHaveModifier + #[test] + fn every_default_parses_and_has_a_modifier() { + for def in ACTIONS { + let shortcut = parse(def.default_keys) + .unwrap_or_else(|| panic!("{} default does not parse", def.id)); + assert!(is_altgr_like(&shortcut), "{} default is not Ctrl+Alt", def.id); + } + } + + // testNoDefaultDuplicates + #[test] + fn no_two_defaults_share_a_combination() { + let mut seen = HashSet::new(); + for def in ACTIONS { + assert!(seen.insert(parse(def.default_keys).unwrap().id()), "{} duplicates", def.id); + } + } + + // testEnabledByDefault + #[test] + fn only_the_island_toggle_is_off_by_default() { + for def in ACTIONS { + assert_eq!(def.enabled_by_default, def.id != "toggleIsland", "{}", def.id); + } + } + + #[test] + fn the_actions_not_ported_yet_are_reserved_not_registered() { + let plan = plan(&Bindings::new(), never); + for (def, outcome) in plan { + let reserved = matches!(def.id, "attachFrontWindow" | "desktopToggle"); + assert_eq!(def.ported, !reserved); + match outcome { + Ok(_) => assert!(def.ported && def.enabled_by_default, "{}", def.id), + Err(s) if reserved => assert_eq!(s.status, Status::NotPorted), + Err(s) => assert_eq!((def.id, s.status), ("toggleIsland", Status::Off)), + } + } + } + + #[test] + fn parsing_needs_a_modifier_and_a_known_key() { + assert!(parse("").is_none()); + assert!(parse("A").is_none(), "a bare key would take A from every app"); + assert!(parse("Shift+A").is_none(), "so would Shift+A"); + assert!(parse("Ctrl+Escape").is_none()); + assert!(parse("Ctrl+Alt+Nope").is_none()); + assert!(parse("Ctrl+Alt").is_none()); + let s = parse("Ctrl+Alt+Space").unwrap(); + assert_eq!(s.key, Code::Space); + assert!(s.mods.contains(Modifiers::CONTROL | Modifiers::ALT)); + assert!(!s.mods.contains(Modifiers::SHIFT)); + let all = parse("Ctrl+Alt+Shift+Super+K").unwrap(); + assert!(all.mods.contains(Modifiers::CONTROL | Modifiers::ALT | Modifiers::SHIFT | Modifiers::SUPER)); + // Case and spelling don't change the combination. + assert_eq!(parse("ctrl+alt+right").unwrap().id(), parse("Control+Alt+ArrowRight").unwrap().id()); + } + + #[test] + fn only_ctrl_alt_without_the_windows_key_reads_as_altgr() { + assert!(is_altgr_like(&parse("Ctrl+Alt+E").unwrap())); + assert!(is_altgr_like(&parse("Ctrl+Alt+Shift+E").unwrap())); + assert!(!is_altgr_like(&parse("Ctrl+Shift+E").unwrap())); + assert!(!is_altgr_like(&parse("Ctrl+Alt+Super+E").unwrap())); + } + + #[test] + fn virtual_keys_match_the_ones_registered() { + assert_eq!(character_vk(Code::KeyA), Some(0x41)); + assert_eq!(character_vk(Code::KeyZ), Some(0x5A)); + assert_eq!(character_vk(Code::Digit0), Some(0x30)); + assert_eq!(character_vk(Code::Digit9), Some(0x39)); + assert_eq!(character_vk(Code::BracketRight), Some(0xDD)); + assert_eq!(character_vk(Code::Space), Some(0x20)); + assert_eq!(character_vk(Code::ArrowLeft), None); + assert_eq!(character_vk(Code::F5), None); + } + + // testDuplicateDetection + #[test] + fn a_combination_used_twice_is_registered_once() { + let mut stored = Bindings::new(); + let same = Binding { keys: "Ctrl+Alt+A".into(), enabled: true }; + stored.insert("jumpToTerminal".into(), same.clone()); + let plan = plan(&stored, never); + let alert = plan.iter().find(|(d, _)| d.id == "goToAlert").unwrap(); + let term = plan.iter().find(|(d, _)| d.id == "jumpToTerminal").unwrap(); + assert!(alert.1.is_ok()); + assert_eq!(term.1.as_ref().unwrap_err().status, Status::Duplicate); + + // Same key, other modifiers: not a duplicate. + stored.insert("jumpToTerminal".into(), Binding { keys: "Ctrl+Shift+A".into(), enabled: true }); + let plan = super::plan(&stored, never); + assert!(plan.iter().all(|(d, o)| o.is_ok() || !d.ported || d.id == "toggleIsland")); + + // A disabled action doesn't hold its keys. + stored.insert("goToAlert".into(), Binding { keys: "Ctrl+Alt+A".into(), enabled: false }); + stored.insert("jumpToTerminal".into(), same); + let plan = super::plan(&stored, never); + let term = plan.iter().find(|(d, _)| d.id == "jumpToTerminal").unwrap(); + assert!(term.1.is_ok()); + } + + #[test] + fn a_combination_that_types_a_character_is_not_registered() { + let euro = |s: &Shortcut| (s.key == Code::KeyA).then(|| "ą".to_string()); + let plan = plan(&Bindings::new(), euro); + let alert = plan.iter().find(|(d, _)| d.id == "goToAlert").unwrap(); + let refused = alert.1.as_ref().unwrap_err(); + assert_eq!(refused.status, Status::TypesCharacter); + assert_eq!(refused.typed.as_deref(), Some("ą")); + } + + #[test] + fn stored_bindings_override_the_defaults_and_the_rest_fall_back() { + let mut stored = Bindings::new(); + stored.insert("openChat".into(), Binding { keys: "Ctrl+Shift+K".into(), enabled: true }); + stored.insert("muteToggle".into(), Binding { keys: String::new(), enabled: true }); + let chat = effective(find("openChat").unwrap(), &stored); + assert_eq!(chat.keys, "Ctrl+Shift+K"); + let alert = effective(find("goToAlert").unwrap(), &stored); + assert_eq!(alert, Binding { keys: "Ctrl+Alt+A".into(), enabled: true }); + let toggle = effective(find("toggleIsland").unwrap(), &stored); + assert!(!toggle.enabled); + let plan = plan(&stored, never); + let mute = plan.iter().find(|(d, _)| d.id == "muteToggle").unwrap(); + assert_eq!(mute.1.as_ref().unwrap_err().status, Status::Off); + } + + // testLoadSaveRoundTrip + #[test] + fn bindings_round_trip_through_json() { + let mut stored = Bindings::new(); + stored.insert("openChat".into(), Binding { keys: "Ctrl+Alt+K".into(), enabled: false }); + let json = serde_json::to_string(&stored).unwrap(); + assert_eq!(json, r#"{"openChat":{"keys":"Ctrl+Alt+K","enabled":false}}"#); + assert_eq!(serde_json::from_str::(&json).unwrap(), stored); + // A hand-edited entry without `enabled` stays on. + let partial: Bindings = serde_json::from_str(r#"{"openChat":{"keys":"Ctrl+Alt+K"}}"#).unwrap(); + assert!(partial["openChat"].enabled); + } + + #[test] + fn the_command_line_names_an_action() { + let args = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(from_args(&args(&["coucou", "--shortcut", "openChat"])), Some("openChat")); + assert_eq!(from_args(&args(&["coucou", "--shortcut", "wardrobeToggle"])), Some("wardrobeToggle")); + assert_eq!(from_args(&args(&["coucou", "--shortcut"])), None); + assert_eq!(from_args(&args(&["coucou", "--shortcut", "rm -rf"])), None); + assert_eq!(from_args(&args(&["coucou", "--shortcut", "desktopToggle"])), None); + assert_eq!(from_args(&args(&["coucou"])), None); + } +} From 23635500c892664047eebbbadd53aed0abaaf991 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:20:16 +0200 Subject: [PATCH 28/77] Windows/Linux: shortcut logic and what the island does with them core/shortcuts.ts ports ShortcutLogic: accelerator parsing and display, the key recorder, duplicates, the AltGr table the defaults are checked against, card and pill navigation. island/shortcuts.ts runs the global actions and the in-island keys (Ctrl instead of Cmd). Tests mirror tests/ShortcutTests.swift and keep the Rust and TS defaults in step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DMzyoPKtGVNBUemdZhnWRG --- windows/src/core/bridge.ts | 18 ++ windows/src/core/shortcuts.ts | 388 ++++++++++++++++++++++++++++++ windows/src/core/state.ts | 4 + windows/src/island/island.ts | 29 ++- windows/src/island/shortcuts.ts | 162 +++++++++++++ windows/src/main.ts | 2 + windows/tests/shortcuts.test.mjs | 395 +++++++++++++++++++++++++++++++ 7 files changed, 997 insertions(+), 1 deletion(-) create mode 100644 windows/src/core/shortcuts.ts create mode 100644 windows/src/island/shortcuts.ts create mode 100644 windows/tests/shortcuts.test.mjs diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..d1eda669b 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -99,8 +99,26 @@ export const Bridge = { /** Tray → Pause. Stops the integration pollers, not just the island. */ setPaused: (paused: boolean) => call("set_paused", { paused }), + + // ── Global shortcuts ────────────────────────────────────────────────────── + /** How each global shortcut went when Rust last registered them. */ + shortcutsStatus: () => call("shortcuts_status"), + /** Lets go of every global shortcut while Settings records a new one. */ + shortcutsSuspend: (suspended: boolean) => call("shortcuts_suspend", { suspended }), }; +export type ShortcutStatus = + | "active" | "off" | "inUse" | "duplicate" | "invalid" + | "typesCharacter" | "unsupported" | "notPorted"; + +export interface ShortcutsReport { + actions: { id: string; status: ShortcutStatus; typed?: string }[]; + /** "wayland" or "no-display" when no global shortcut can be registered. */ + blocked: string | null; + /** ` --shortcut`: append an action id for a desktop shortcut. */ + command: string; +} + export interface IntegrationUpdate { id: string; data: Record; diff --git a/windows/src/core/shortcuts.ts b/windows/src/core/shortcuts.ts new file mode 100644 index 000000000..25216f886 --- /dev/null +++ b/windows/src/core/shortcuts.ts @@ -0,0 +1,388 @@ +// Keyboard shortcuts — the pure part, port of ShortcutLogic.swift. +// +// Global shortcuts are registered by Rust (src-tauri/src/shortcuts.rs), which +// owns the same table of defaults; tests/shortcuts.test.mjs keeps the two in +// step. This file parses and formats accelerators, turns a key press into one +// (the recorder in Settings), spots duplicates, and maps the keys pressed +// inside the open island to what they do. +// +// Why Ctrl+Alt+Space / A / T / S / G / ← → rather than the Mac's letters: see +// the top of src-tauri/src/shortcuts.rs. In short, Windows reads Ctrl+Alt as +// AltGr, and AltGr+E, Q, M, W, C, the digits and most punctuation type a +// character on at least one of the French, German, Spanish, Italian, +// Portuguese or Brazilian layouts (ALTGR_CHARACTERS below). + +// ── Strings shown to the user ───────────────────────────────────────────────── + +export const SHORTCUT_TEXT = { + toggleIsland: "Open or close the island", + openChat: "Open the chat", + goToAlert: "Go to the waiting permission or question", + jumpToTerminal: "Open the terminal", + attachFrontWindow: "Attach the front window to the chat", + nextPill: "Next pill", + prevPill: "Previous pill", + muteToggle: "Mute or unmute Mochi", + desktopToggle: "Send Mochi to the desktop", + wardrobeToggle: "Open the wardrobe", + island: { + nextPrev: "Next or previous pill", + byNumber: "Go to pill 1 to 9", + send: "Send the message", + newChat: "Start a new chat", + settings: "Open Settings", + pin: "Keep the island open", + close: "Close the island", + }, +} as const; + +// ── Actions ─────────────────────────────────────────────────────────────────── + +/** Mac `ShortcutAction` raw values. Stored in settings.json: never rename one. */ +export type ShortcutId = + | "toggleIsland" + | "openChat" + | "goToAlert" + | "jumpToTerminal" + | "attachFrontWindow" + | "nextPill" + | "prevPill" + | "muteToggle" + | "desktopToggle" + | "wardrobeToggle"; + +export interface ShortcutDef { + id: ShortcutId; + defaultKeys: string; + enabledByDefault: boolean; + /** False for the Mac actions this version can't do yet: reserved, never registered. */ + ported: boolean; +} + +const def = (id: ShortcutId, defaultKeys: string, enabledByDefault: boolean, ported: boolean): ShortcutDef => + ({ id, defaultKeys, enabledByDefault, ported }); + +/** Same order and defaults as ACTIONS in src-tauri/src/shortcuts.rs. */ +export const SHORTCUTS: readonly ShortcutDef[] = [ + def("toggleIsland", "Ctrl+Alt+N", false, true), + def("openChat", "Ctrl+Alt+Space", true, true), + def("goToAlert", "Ctrl+Alt+A", true, true), + def("jumpToTerminal", "Ctrl+Alt+T", true, true), + def("attachFrontWindow", "Ctrl+Alt+F", true, false), + def("nextPill", "Ctrl+Alt+Right", true, true), + def("prevPill", "Ctrl+Alt+Left", true, true), + def("muteToggle", "Ctrl+Alt+S", true, true), + def("desktopToggle", "Ctrl+Alt+D", true, false), + def("wardrobeToggle", "Ctrl+Alt+G", true, true), +]; + +export interface Binding { + /** Canonical accelerator, e.g. "Ctrl+Alt+Space"; empty for no key. */ + keys: string; + enabled: boolean; +} + +/** `settings.shortcuts`: only the actions the user changed. */ +export type Bindings = Record; + +/** The binding in force: what the user stored, or the default. */ +export function effective(d: ShortcutDef, stored: Bindings | undefined): Binding { + const own = stored?.[d.id]; + return own ? { keys: own.keys, enabled: own.enabled } : { keys: d.defaultKeys, enabled: d.enabledByDefault }; +} + +// ── Accelerators ────────────────────────────────────────────────────────────── + +export interface Combo { + ctrl: boolean; + alt: boolean; + shift: boolean; + /** The Windows key / Super. */ + meta: boolean; + /** Canonical key name (see KEY_NAMES). */ + key: string; +} + +const PUNCTUATION: Record = { + Comma: ",", Period: ".", Slash: "/", Semicolon: ";", Quote: "'", Backquote: "`", + BracketLeft: "[", BracketRight: "]", Backslash: "\\", Minus: "-", Equal: "=", +}; + +const NAMED = [ + "Space", "Enter", "Tab", "Backspace", "Delete", "Insert", "Home", "End", + "PageUp", "PageDown", "Up", "Down", "Left", "Right", +]; + +/** Every main key an accelerator may name — all of them parse in Rust too. */ +export const KEY_NAMES: readonly string[] = [ + ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + ...Array.from({ length: 24 }, (_, i) => `F${i + 1}`), + ...NAMED, + ...Object.keys(PUNCTUATION), +]; + +const KEY_ALIASES: Record = { + ARROWUP: "Up", ARROWDOWN: "Down", ARROWLEFT: "Left", ARROWRIGHT: "Right", + RETURN: "Enter", ESC: "Escape", DEL: "Delete", " ": "Space", + ...Object.fromEntries(Object.entries(PUNCTUATION).map(([name, ch]) => [ch, name])), +}; + +function canonicalKey(token: string): string | null { + const up = token.toUpperCase(); + if (KEY_ALIASES[token]) return KEY_ALIASES[token]; + if (KEY_ALIASES[up]) return KEY_ALIASES[up]; + if (/^KEY[A-Z]$/.test(up)) return up.slice(3); + if (/^DIGIT[0-9]$/.test(up)) return up.slice(5); + return KEY_NAMES.find((k) => k.toUpperCase() === up) ?? null; +} + +/** + * "Ctrl+Alt+Space" → a Combo. Accepts the spellings Rust accepts (Control, + * Option, Super/Cmd, ArrowLeft…). `null` for anything else, and for a bare key: + * a global shortcut always needs Ctrl, Alt or the Windows key. + */ +export function parseKeys(text: string): Combo | null { + if (!text.trim()) return null; + // "Ctrl+Alt++" doesn't happen: the plus key is spelled Equal. + const tokens = text.split("+").map((t) => t.trim()); + if (tokens.some((t) => !t)) return null; + const combo: Combo = { ctrl: false, alt: false, shift: false, meta: false, key: "" }; + for (const [i, token] of tokens.entries()) { + const last = i === tokens.length - 1; + switch (token.toUpperCase()) { + case "CTRL": case "CONTROL": case "COMMANDORCONTROL": case "CMDORCTRL": + if (last) return null; + combo.ctrl = true; + continue; + case "ALT": case "OPTION": + if (last) return null; + combo.alt = true; + continue; + case "SHIFT": + if (last) return null; + combo.shift = true; + continue; + case "SUPER": case "META": case "WIN": case "CMD": case "COMMAND": + if (last) return null; + combo.meta = true; + continue; + } + if (!last) return null; + const key = canonicalKey(token); + if (!key || key === "Escape") return null; + combo.key = key; + } + if (!combo.key || !(combo.ctrl || combo.alt || combo.meta)) return null; + return combo; +} + +/** Combo → "Ctrl+Alt+Shift+Super+Key", the form stored and handed to Rust. */ +export function formatKeys(c: Combo): string { + const parts: string[] = []; + if (c.ctrl) parts.push("Ctrl"); + if (c.alt) parts.push("Alt"); + if (c.shift) parts.push("Shift"); + if (c.meta) parts.push("Super"); + parts.push(c.key); + return parts.join("+"); +} + +/** Canonical form of an accelerator, or null when it isn't one. */ +export function normalizeKeys(text: string): string | null { + const c = parseKeys(text); + return c ? formatKeys(c) : null; +} + +const KEY_GLYPHS: Record = { Up: "↑", Down: "↓", Left: "←", Right: "→", ...PUNCTUATION }; + +/** What a person reads: "Ctrl+Alt+→", "Ctrl+Shift+K", "Win+Space". */ +export function displayKeys(text: string): string { + const c = parseKeys(text); + if (!c) return text.trim() ? text : "—"; + const parts: string[] = []; + if (c.ctrl) parts.push("Ctrl"); + if (c.alt) parts.push("Alt"); + if (c.shift) parts.push("Shift"); + if (c.meta) parts.push("Win"); + parts.push(KEY_GLYPHS[c.key] ?? c.key); + return parts.join("+"); +} + +// ── Recording a key press ───────────────────────────────────────────────────── + +/** The parts of a KeyboardEvent the recorder reads. */ +export interface KeyPress { + key: string; + code: string; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + metaKey: boolean; +} + +export type Recorded = + /** Only modifiers so far: keep listening. */ + | { kind: "pending" } + | { kind: "cancel" } + | { kind: "clear" } + | { kind: "keys"; keys: string } + /** Needs Ctrl, Alt or the Windows key. */ + | { kind: "needsModifier" } + /** On this keyboard, Ctrl+Alt+key is AltGr and types `typed`. */ + | { kind: "typesCharacter"; typed: string; keys: string } + | { kind: "unsupported" }; + +const MODIFIER_KEYS = new Set(["Control", "Alt", "Shift", "Meta", "AltGraph", "OS", "Super", "Hyper"]); + +/** The main key of a press, by name. Letters follow the layout, so Ctrl+Alt+A + * is the key labelled A on AZERTY too — which is what RegisterHotKey and X11 + * grab. Everything else goes by its position. */ +function pressedKey(e: KeyPress): string | null { + if (/^[a-z]$/i.test(e.key)) return e.key.toUpperCase(); + if (/^Key[A-Z]$/.test(e.code)) return e.code.slice(3); + if (/^Digit[0-9]$/.test(e.code)) return e.code.slice(5); + if (/^Arrow(Up|Down|Left|Right)$/.test(e.code)) return e.code.slice(5); + if (/^F([1-9]|1[0-9]|2[0-4])$/.test(e.code)) return e.code; + if (NAMED.includes(e.code) || e.code in PUNCTUATION) return e.code; + if (e.code === "NumpadEnter") return "Enter"; + return null; +} + +/** Turns a key press in the recorder into what to do with it. */ +export function recordPress(e: KeyPress): Recorded { + if (MODIFIER_KEYS.has(e.key)) return { kind: "pending" }; + const bare = !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey; + if (e.key === "Escape" && bare) return { kind: "cancel" }; + if ((e.key === "Backspace" || e.key === "Delete") && bare) return { kind: "clear" }; + const key = pressedKey(e); + if (!key) return { kind: "unsupported" }; + if (!(e.ctrlKey || e.altKey || e.metaKey)) return { kind: "needsModifier" }; + const keys = formatKeys({ ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey, key }); + // Ctrl+Alt is AltGr on Windows. When the press typed something other than a + // plain letter, digit or space, this combination is how the user types it. + if (e.ctrlKey && e.altKey && !e.metaKey) { + if (e.key === "Dead") return { kind: "typesCharacter", typed: "´", keys }; + if ([...e.key].length === 1 && !/^[a-z0-9 ]$/i.test(e.key)) { + return { kind: "typesCharacter", typed: e.key, keys }; + } + } + return { kind: "keys", keys }; +} + +// ── Conflicts ───────────────────────────────────────────────────────────────── + +/** Actions whose combination another enabled action also uses. */ +export function duplicates(entries: Iterable<[string, string]>): Set { + const seen = new Map(); + const dups = new Set(); + for (const [id, keys] of entries) { + const norm = normalizeKeys(keys); + if (!norm) continue; + const other = seen.get(norm); + if (other != null) { + dups.add(id); + dups.add(other); + } else { + seen.set(norm, id); + } + } + return dups; +} + +/** The (id, keys) pairs Coucou would register with `stored`. */ +export function activeKeys(stored: Bindings | undefined): [string, string][] { + return SHORTCUTS.filter((d) => d.ported) + .map((d) => [d.id, effective(d, stored)] as const) + .filter(([, b]) => b.enabled && b.keys) + .map(([id, b]) => [id, b.keys]); +} + +/** + * What AltGr (= Ctrl+Alt on Windows) types on the keys of the layouts the + * defaults were checked against: letters and digits only, the keys a shortcut + * is likely to use. Standard Windows layouts. + */ +export const ALTGR_CHARACTERS: Record> = { + "French (AZERTY)": { + E: "€", "2": "~", "3": "#", "4": "{", "5": "[", "6": "|", "7": "`", "8": "\\", "9": "^", "0": "@", + }, + "German (QWERTZ)": { Q: "@", E: "€", M: "µ", "2": "²", "3": "³", "7": "{", "8": "[", "9": "]", "0": "}" }, + Spanish: { E: "€", "1": "|", "2": "@", "3": "#", "4": "~", "5": "€", "6": "¬" }, + Italian: { E: "€", "5": "€" }, + Portuguese: { E: "€", "2": "@", "3": "£", "4": "§", "5": "€", "7": "{", "8": "[", "9": "]", "0": "}" }, + "Brazilian (ABNT2)": { + Q: "/", W: "?", E: "°", C: "₢", "1": "¹", "2": "²", "3": "³", "4": "£", "5": "¢", "6": "¬", + }, +}; + +/** The layouts on which `keys` would type a character instead of running. */ +export function altGrClashes(keys: string): string[] { + const c = parseKeys(keys); + if (!c || !c.ctrl || !c.alt || c.meta) return []; + return Object.entries(ALTGR_CHARACTERS) + .filter(([, chars]) => chars[c.key] != null) + .map(([layout]) => layout); +} + +// ── Inside the open island ──────────────────────────────────────────────────── + +/** Same as ShortcutLogic.navigate: next card-list selection, clamped. */ +export function navigate(selection: number | null, delta: number, itemCount: number): number | null { + if (itemCount <= 0) return null; + if (selection != null) return Math.max(0, Math.min(itemCount - 1, selection + delta)); + return delta > 0 ? 0 : itemCount - 1; +} + +/** The pill `delta` steps from `current`, wrapping around (cyclePill). */ +export function cyclePill(ids: readonly string[], current: string | null, delta: number): string | null { + if (ids.length === 0) return null; + const at = Math.max(0, current == null ? 0 : ids.indexOf(current)); + return ids[(((at + delta) % ids.length) + ids.length) % ids.length]; +} + +/** Pill number `n`, counting from 1 (switchToPill). */ +export function pillByNumber(ids: readonly string[], n: number): string | null { + return n >= 1 && n <= ids.length ? ids[n - 1] : null; +} + +/** Shown read-only in Settings, like ShortcutLogic.islandShortcuts. The Mac's + * ⌘↑ ⌘↓ ⌘O (card lists) and ⌘E (diff) have no counterpart here yet. */ +export const ISLAND_SHORTCUTS: readonly { keys: string; description: string }[] = [ + { keys: "Ctrl+→ / Ctrl+←", description: SHORTCUT_TEXT.island.nextPrev }, + { keys: "Ctrl+1 – Ctrl+9", description: SHORTCUT_TEXT.island.byNumber }, + { keys: "Ctrl+Enter", description: SHORTCUT_TEXT.island.send }, + { keys: "Ctrl+K", description: SHORTCUT_TEXT.island.newChat }, + { keys: "Ctrl+,", description: SHORTCUT_TEXT.island.settings }, + { keys: "Ctrl+P", description: SHORTCUT_TEXT.island.pin }, + { keys: "Esc", description: SHORTCUT_TEXT.island.close }, +]; + +export type IslandKeyAction = + | { kind: "cycle"; delta: 1 | -1 } + | { kind: "pill"; number: number } + | { kind: "newChat" } + | { kind: "settings" } + | { kind: "pin" }; + +/** + * A key pressed while the island has the keyboard → what it does, or null to + * leave it alone. Ctrl stands in for ⌘. In a text field Ctrl+← and Ctrl+→ + * keep moving by word. + */ +export function islandKeyAction( + e: KeyPress, + ctx: { view: string; inTextField: boolean }, +): IslandKeyAction | null { + if (!e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return null; + if (e.key === "ArrowRight" || e.key === "ArrowLeft") { + if (ctx.inTextField) return null; + return { kind: "cycle", delta: e.key === "ArrowRight" ? 1 : -1 }; + } + const digit = /^Digit([1-9])$/.exec(e.code); + if (digit) return { kind: "pill", number: Number(digit[1]) }; + const letter = /^[a-z]$/i.test(e.key) ? e.key.toLowerCase() : /^Key([A-Z])$/.exec(e.code)?.[1].toLowerCase(); + if (letter === "k") return ctx.view === "prompt" ? { kind: "newChat" } : null; + if (letter === "p") return { kind: "pin" }; + if (e.key === "," || e.code === "Comma") return { kind: "settings" }; + return null; +} diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..d0102ece2 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -2,6 +2,7 @@ import type { BotEmoteName, BotStateName, IslandMode, IslandViewName } from "./layout"; import type { EyeShape } from "../mochi/engine"; +import type { Bindings } from "./shortcuts"; export type AgentSource = "claudeCode" | "n8n" | "agent"; export type PillBadge = "approval" | "finished" | "error"; @@ -92,6 +93,8 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + /** Global shortcuts the user changed, by action id (see core/shortcuts.ts). */ + shortcuts: Bindings; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +109,7 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + shortcuts: {}, }; type Listener = () => void; diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb61802..ceafc8283 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -7,7 +7,7 @@ import { EXPANDED_CORNER, EXPANDED_W, NOTCH_W, PANEL_H, PANEL_W, ROUNDED_CORNER, VIEW_LAYOUTS, botGlowColor, botGlowOpacity, botPosition, chatPromptHeight, islandSize, - type IslandMode, type IslandViewName, + type BotEmoteName, type IslandMode, type IslandViewName, } from "../core/layout"; import { Sound } from "../core/sound"; import { State } from "../core/state"; @@ -339,6 +339,33 @@ export class Island { this.fsm.pinned = false; } + // ── Keyboard shortcuts (island/shortcuts.ts) ──────────────────────────────── + + emote(name: BotEmoteName) { + this.engine.triggerEmote(name); + this.ensureRunning(); + } + + /** Ctrl+P: keep the open island from folding away, or let it fold again. */ + setPinned(on: boolean) { + State.isPinned = on; + this.fsm.pinned = on; + if (on) { + this.fsm.cancelTimers(); + this.homeCollapseAt = null; + } else if (!this.wasInIsland && this.fsm.state === "home") { + this.fsm.mouseLeft(); + this.homeCollapseAt = performance.now() + State.settings.autoCloseInterval * 1000; + } + State.notify(); + } + + /** The island takes the keyboard, so its own shortcuts work (Mac: makeKey). + * It gives it back when it closes, or when the chat is left. */ + takeKeyboard() { + void Bridge.focusWindow(true); + } + // ── File drop ─────────────────────────────────────────────────────────────── private onDragDrop(e: { type: string; paths?: string[] }) { diff --git a/windows/src/island/shortcuts.ts b/windows/src/island/shortcuts.ts new file mode 100644 index 000000000..e9411a041 --- /dev/null +++ b/windows/src/island/shortcuts.ts @@ -0,0 +1,162 @@ +// Keyboard shortcuts → island. Port of IslandWindowController.handleHotKey and +// handleIslandKey from the macOS app. +// +// Global shortcuts are caught by Rust and arrive as a `shortcut` event with +// the action id (the wardrobe goes out as `open-wardrobe` instead, for the +// wardrobe view to pick up). The in-island keys are read here, while the +// island has the keyboard: it takes it for the chat, and when a global +// shortcut opens it. + +import { Bridge, onEvent } from "../core/bridge"; +import type { BotEmoteName, IslandViewName } from "../core/layout"; +import { cyclePill, islandKeyAction, pillByNumber, type IslandKeyAction } from "../core/shortcuts"; +import { Sound } from "../core/sound"; +import { State } from "../core/state"; + +const CLAUDE_ID = "integration_claude"; + +/** What the shortcuts need from the island. */ +export interface ShortcutHost { + alert(view: IslandViewName): void; + setView(view: IslandViewName): void; + collapse(): void; + emote(name: BotEmoteName): void; + setPinned(on: boolean): void; + /** Gives the island the keyboard, so the in-island keys work (Mac: makeKey). */ + takeKeyboard(): void; +} + +function focusPill(host: ShortcutHost, id: string | null, open: boolean) { + if (!id) return; + State.setFocus(id); + Sound.play("blip"); + if (open) host.alert("overview"); + else host.setView("overview"); +} + +/** A global shortcut was pressed. `resume` lifts Pause, as the tray's Open does. */ +export function runGlobalShortcut(host: ShortcutHost, action: string, resume: () => void) { + switch (action) { + case "toggleIsland": + if (State.mode === "expanded") { + host.collapse(); + } else { + resume(); + host.alert(State.pendingApproval ? "approval" : State.defaultView()); + host.takeKeyboard(); + } + break; + + case "openChat": + resume(); + host.alert("prompt"); + break; + + case "goToAlert": { + const asking = State.tasks.find((t) => t.state === "question"); + if (State.pendingApproval) { + resume(); + State.setFocus(CLAUDE_ID); + host.alert("approval"); + host.takeKeyboard(); + } else if (asking) { + resume(); + State.setFocus(asking.id); + host.alert("question"); + host.takeKeyboard(); + } else { + // Nothing is waiting: Mochi says so. + host.emote("annoyed"); + Sound.play("error"); + } + break; + } + + // The Mac brings the terminal app forward; here it is the existing "Open + // terminal": the session's folder in VS Code. + case "jumpToTerminal": + void Bridge.openInVSCode(State.focusTask?.sessionCwd ?? null); + if (State.mode === "expanded") host.collapse(); + break; + + case "nextPill": + case "prevPill": + resume(); + focusPill( + host, + cyclePill(State.tasks.map((t) => t.id), State.focusTask?.id ?? null, action === "nextPill" ? 1 : -1), + true, + ); + break; + + case "muteToggle": { + const on = !State.settings.soundEnabled; + State.settings.soundEnabled = on; + Sound.setEnabled(on); + void Bridge.saveSettings(State.settings); + if (on) Sound.play("tick"); + host.emote(on ? "happy" : "annoyed"); + State.notify(); + break; + } + + // wardrobeToggle never comes this way (Rust sends `open-wardrobe`), and + // attachFrontWindow / desktopToggle aren't in this version. + default: + break; + } +} + +/** A key the island acts on while it has the keyboard. */ +export function runIslandKey(host: ShortcutHost, action: IslandKeyAction) { + const ids = State.tasks.map((t) => t.id); + switch (action.kind) { + case "cycle": + focusPill(host, cyclePill(ids, State.focusTask?.id ?? null, action.delta), false); + break; + case "pill": + focusPill(host, pillByNumber(ids, action.number), false); + break; + case "newChat": + // Not while an answer is on its way: it would land in the new chat. + if (State.stateOverride === "thinking") return; + State.chatHistory = []; + State.droppedFile = null; + State.promptContext = null; + void Bridge.chatReset(); + Sound.play("blip"); + host.setView("prompt"); + break; + case "settings": + void Bridge.openSettingsWindow(); + break; + case "pin": + // A permission card keeps the island pinned until it is answered. + if (State.pendingApproval) return; + host.setPinned(!State.isPinned); + break; + } +} + +function inTextField(target: EventTarget | null): boolean { + const el = target as { tagName?: string; isContentEditable?: boolean } | null; + return el?.tagName === "INPUT" || el?.tagName === "TEXTAREA" || el?.isContentEditable === true; +} + +export function registerShortcutHandlers(host: ShortcutHost, resume: () => void) { + void onEvent("shortcut", (action) => runGlobalShortcut(host, action, resume)); + + // Capture phase: the chat field stops its own key events from bubbling. + window.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (State.mode !== "expanded") return; + const action = islandKeyAction(e, { view: State.view, inTextField: inTextField(e.target) }); + if (!action) return; + e.preventDefault(); + e.stopPropagation(); + runIslandKey(host, action); + }, + true, + ); +} diff --git a/windows/src/main.ts b/windows/src/main.ts index f65d5e757..708c3da47 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -7,6 +7,7 @@ import { State, type Settings } from "./core/state"; import { Island } from "./island/island"; import { registerHookHandlers } from "./island/hooks"; import { registerIntegrationHandlers, refreshConfigured } from "./island/integrations"; +import { registerShortcutHandlers } from "./island/shortcuts"; async function main() { const root = document.getElementById("root"); @@ -63,6 +64,7 @@ async function main() { registerHookHandlers(island); registerIntegrationHandlers(island); + registerShortcutHandlers(island, () => setPaused(false)); island.launch(); diff --git a/windows/tests/shortcuts.test.mjs b/windows/tests/shortcuts.test.mjs new file mode 100644 index 000000000..896748a9d --- /dev/null +++ b/windows/tests/shortcuts.test.mjs @@ -0,0 +1,395 @@ +// Keyboard shortcuts: the pure logic (src/core/shortcuts.ts, mirroring the +// Mac's tests/ShortcutTests.swift) and what the island does with them +// (src/island/shortcuts.ts), driven through the real bridge. + +import { beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { calls, emit, sent } from "./tauri.mjs"; +import { + ALTGR_CHARACTERS, ISLAND_SHORTCUTS, KEY_NAMES, SHORTCUTS, SHORTCUT_TEXT, activeKeys, altGrClashes, + cyclePill, displayKeys, duplicates, effective, formatKeys, islandKeyAction, navigate, normalizeKeys, + parseKeys, pillByNumber, recordPress, +} from "../src/core/shortcuts.ts"; +import { registerShortcutHandlers, runGlobalShortcut, runIslandKey } from "../src/island/shortcuts.ts"; +import { DEFAULT_SETTINGS, State } from "../src/core/state.ts"; + +const MAC_IDS = [ + "toggleIsland", "openChat", "goToAlert", "jumpToTerminal", "attachFrontWindow", + "nextPill", "prevPill", "muteToggle", "desktopToggle", "wardrobeToggle", +]; + +// ── Defaults (testDefaultsExhaustive, testAllDefaultsHaveModifier, testNoDefaultDuplicates) ── + +test("every Mac action has a default, in the Mac's order, with its Mac id", () => { + assert.deepEqual(SHORTCUTS.map((d) => d.id), MAC_IDS); + for (const d of SHORTCUTS) assert.ok(SHORTCUT_TEXT[d.id], `${d.id} has no label`); +}); + +test("every default parses, is canonical and holds Ctrl+Alt", () => { + for (const d of SHORTCUTS) { + const c = parseKeys(d.defaultKeys); + assert.ok(c, `${d.id} default does not parse`); + assert.ok(c.ctrl && c.alt && !c.meta, `${d.id} default is not Ctrl+Alt`); + assert.equal(formatKeys(c), d.defaultKeys, `${d.id} default is not in canonical form`); + } +}); + +test("no two defaults share a combination", () => { + assert.deepEqual(duplicates(SHORTCUTS.map((d) => [d.id, d.defaultKeys])), new Set()); +}); + +test("no default types a character with AltGr on the checked European layouts", () => { + for (const layout of ["French (AZERTY)", "German (QWERTZ)", "Spanish", "Italian", "Portuguese", "Brazilian (ABNT2)"]) { + assert.ok(ALTGR_CHARACTERS[layout], layout); + } + for (const d of SHORTCUTS) { + assert.deepEqual(altGrClashes(d.defaultKeys), [], `${d.id} = ${d.defaultKeys}`); + } + // The Mac's own letters would not have been safe. + assert.deepEqual(altGrClashes("Ctrl+Alt+M"), ["German (QWERTZ)"]); + assert.ok(altGrClashes("Ctrl+Alt+E").length >= 5); + assert.ok(altGrClashes("Ctrl+Alt+0").includes("French (AZERTY)")); + // Not Ctrl+Alt: not AltGr. + assert.deepEqual(altGrClashes("Ctrl+Shift+E"), []); +}); + +test("the defaults are the same on both sides of the bridge", () => { + const rust = readFileSync(new URL("../src-tauri/src/shortcuts.rs", import.meta.url), "utf8"); + const rows = [...rust.matchAll(/^\s*action\("(\w+)", "([^"]+)", (true|false), (true|false)\),/gm)] + .map(([, id, keys, on, ported]) => ({ id, keys, on: on === "true", ported: ported === "true" })); + assert.deepEqual( + rows, + SHORTCUTS.map((d) => ({ id: d.id, keys: d.defaultKeys, on: d.enabledByDefault, ported: d.ported })), + ); +}); + +// testEnabledByDefault +test("only the island toggle is off by default; the two not ported yet are reserved", () => { + for (const d of SHORTCUTS) { + assert.equal(d.enabledByDefault, d.id !== "toggleIsland", d.id); + assert.equal(d.ported, !["attachFrontWindow", "desktopToggle"].includes(d.id), d.id); + } + assert.deepEqual(activeKeys({}).map(([id]) => id), [ + "openChat", "goToAlert", "jumpToTerminal", "nextPill", "prevPill", "muteToggle", "wardrobeToggle", + ]); +}); + +// ── Parsing and formatting (testCarbonModifiers, testDisplayString, testKeyCodeToString) ── + +test("modifiers parse one by one and all together", () => { + assert.deepEqual(parseKeys("Ctrl+Shift+N"), { ctrl: true, alt: false, shift: true, meta: false, key: "N" }); + assert.deepEqual(parseKeys("Ctrl+Alt+A"), { ctrl: true, alt: true, shift: false, meta: false, key: "A" }); + assert.deepEqual(parseKeys("Super+Shift+Alt+Ctrl+k"), { ctrl: true, alt: true, shift: true, meta: true, key: "K" }); + // The spellings Rust accepts. + assert.equal(normalizeKeys("control+option+arrowright"), "Ctrl+Alt+Right"); + assert.equal(normalizeKeys("CmdOrCtrl+Alt+KeyQ"), "Ctrl+Alt+Q"); + assert.equal(normalizeKeys("Ctrl+Alt+Digit7"), "Ctrl+Alt+7"); + assert.equal(normalizeKeys("Ctrl+Alt+]"), "Ctrl+Alt+BracketRight"); +}); + +test("a bare key, Shift alone, a modifier with no key, or nonsense is not a shortcut", () => { + for (const bad of ["", "A", "Shift+A", "Ctrl+Alt", "Ctrl+Alt+", "Ctrl+Alt+Nope", "A+Ctrl", "Ctrl+A+B", "Ctrl+Escape"]) { + assert.equal(parseKeys(bad), null, JSON.stringify(bad)); + } +}); + +test("what a person reads", () => { + assert.equal(displayKeys("Ctrl+Shift+N"), "Ctrl+Shift+N"); + assert.equal(displayKeys("Ctrl+Alt+Space"), "Ctrl+Alt+Space"); + assert.equal(displayKeys("Ctrl+Alt+Right"), "Ctrl+Alt+→"); + assert.equal(displayKeys("Ctrl+Alt+Left"), "Ctrl+Alt+←"); + assert.equal(displayKeys("Super+Comma"), "Win+,"); + assert.equal(displayKeys("Ctrl+Alt+BracketLeft"), "Ctrl+Alt+["); + assert.equal(displayKeys(""), "—"); + // Each modifier once. + assert.equal(displayKeys("Ctrl+Ctrl+Alt+A").match(/Ctrl/g).length, 1); +}); + +test("every key name survives a round trip", () => { + for (const key of KEY_NAMES) { + assert.equal(normalizeKeys(`Ctrl+Alt+${key}`), `Ctrl+Alt+${key}`, key); + } +}); + +// ── Conflicts (testDuplicateDetection) ─────────────────────────────────────── + +test("duplicates flag both actions, and only the same modifiers and key", () => { + assert.deepEqual(duplicates([]), new Set()); + assert.deepEqual(duplicates([["goToAlert", "Ctrl+Alt+A"]]), new Set()); + assert.deepEqual(duplicates([["goToAlert", "Ctrl+Alt+A"], ["jumpToTerminal", "Ctrl+Alt+T"]]), new Set()); + assert.deepEqual( + duplicates([["goToAlert", "Ctrl+Alt+A"], ["jumpToTerminal", "ctrl+alt+a"]]), + new Set(["goToAlert", "jumpToTerminal"]), + ); + assert.deepEqual(duplicates([["goToAlert", "Ctrl+Alt+A"], ["jumpToTerminal", "Ctrl+Shift+A"]]), new Set()); +}); + +// ── Stored bindings (testLoadSaveRoundTrip) ─────────────────────────────────── + +test("a stored binding wins, the rest keep their default, and off ones hold no keys", () => { + const stored = { + openChat: { keys: "Ctrl+Alt+K", enabled: true }, + goToAlert: { keys: "Ctrl+Alt+A", enabled: false }, + toggleIsland: { keys: "Ctrl+Alt+N", enabled: true }, + }; + const chat = SHORTCUTS.find((d) => d.id === "openChat"); + assert.deepEqual(effective(chat, stored), { keys: "Ctrl+Alt+K", enabled: true }); + assert.deepEqual(effective(chat, undefined), { keys: "Ctrl+Alt+Space", enabled: true }); + const ids = activeKeys(stored).map(([id]) => id); + assert.ok(ids.includes("toggleIsland")); + assert.ok(!ids.includes("goToAlert")); + // Same JSON shape as Rust's Bindings. + assert.deepEqual(JSON.parse(JSON.stringify(stored)).openChat, { keys: "Ctrl+Alt+K", enabled: true }); + assert.deepEqual(DEFAULT_SETTINGS.shortcuts, {}); +}); + +// ── Recording a press ───────────────────────────────────────────────────────── + +const press = (over) => ({ + key: "", code: "", ctrlKey: false, altKey: false, shiftKey: false, metaKey: false, ...over, +}); + +test("the recorder turns a press into canonical keys", () => { + assert.deepEqual(recordPress(press({ key: " ", code: "Space", ctrlKey: true, altKey: true })), + { kind: "keys", keys: "Ctrl+Alt+Space" }); + assert.deepEqual(recordPress(press({ key: "ArrowRight", code: "ArrowRight", ctrlKey: true, altKey: true })), + { kind: "keys", keys: "Ctrl+Alt+Right" }); + // AZERTY: the key labelled A sits where QWERTY has Q. The label wins, as it + // does for RegisterHotKey. + assert.deepEqual(recordPress(press({ key: "a", code: "KeyQ", ctrlKey: true, altKey: true })), + { kind: "keys", keys: "Ctrl+Alt+A" }); + assert.deepEqual(recordPress(press({ key: "K", code: "KeyK", ctrlKey: true, shiftKey: true })), + { kind: "keys", keys: "Ctrl+Shift+K" }); + assert.deepEqual(recordPress(press({ key: "F9", code: "F9", metaKey: true })), + { kind: "keys", keys: "Super+F9" }); +}); + +test("the recorder waits for a key, cancels, clears, and refuses what can't be a shortcut", () => { + assert.deepEqual(recordPress(press({ key: "Control", code: "ControlLeft", ctrlKey: true })), { kind: "pending" }); + assert.deepEqual(recordPress(press({ key: "AltGraph", code: "AltRight", ctrlKey: true, altKey: true })), { kind: "pending" }); + assert.deepEqual(recordPress(press({ key: "Escape", code: "Escape" })), { kind: "cancel" }); + assert.deepEqual(recordPress(press({ key: "Backspace", code: "Backspace" })), { kind: "clear" }); + assert.deepEqual(recordPress(press({ key: "a", code: "KeyA" })), { kind: "needsModifier" }); + assert.deepEqual(recordPress(press({ key: "A", code: "KeyA", shiftKey: true })), { kind: "needsModifier" }); + assert.deepEqual(recordPress(press({ key: "Unidentified", code: "Lang1", ctrlKey: true })), { kind: "unsupported" }); +}); + +test("a Ctrl+Alt press that types a character on this keyboard is refused", () => { + // German: AltGr+E = €, AltGr+Q = @. French: AltGr+0 = @. + assert.deepEqual(recordPress(press({ key: "€", code: "KeyE", ctrlKey: true, altKey: true })), + { kind: "typesCharacter", typed: "€", keys: "Ctrl+Alt+E" }); + assert.deepEqual(recordPress(press({ key: "@", code: "KeyQ", ctrlKey: true, altKey: true })), + { kind: "typesCharacter", typed: "@", keys: "Ctrl+Alt+Q" }); + assert.deepEqual(recordPress(press({ key: "@", code: "Digit0", ctrlKey: true, altKey: true })), + { kind: "typesCharacter", typed: "@", keys: "Ctrl+Alt+0" }); + assert.equal(recordPress(press({ key: "Dead", code: "BracketLeft", ctrlKey: true, altKey: true })).kind, "typesCharacter"); + // Ctrl+Shift is never AltGr. + assert.deepEqual(recordPress(press({ key: "€", code: "KeyE", ctrlKey: true, shiftKey: true })), + { kind: "keys", keys: "Ctrl+Shift+E" }); +}); + +// ── In the island (testCardNavigation and the island keys) ─────────────────── + +test("card navigation clamps, and starts from either end", () => { + assert.equal(navigate(null, +1, 3), 0); + assert.equal(navigate(null, -1, 3), 2); + assert.equal(navigate(0, -1, 3), 0); + assert.equal(navigate(2, +1, 3), 2); + assert.equal(navigate(1, +1, 3), 2); + assert.equal(navigate(1, -1, 3), 0); + assert.equal(navigate(1, 0, 3), 1); + assert.equal(navigate(null, +1, 0), null); + assert.equal(navigate(0, +1, 0), null); +}); + +test("pills cycle around and are picked by number", () => { + const ids = ["a", "b", "c"]; + assert.equal(cyclePill(ids, "a", 1), "b"); + assert.equal(cyclePill(ids, "c", 1), "a"); + assert.equal(cyclePill(ids, "a", -1), "c"); + assert.equal(cyclePill(ids, "gone", 1), "b"); + assert.equal(cyclePill([], "a", 1), null); + assert.equal(pillByNumber(ids, 1), "a"); + assert.equal(pillByNumber(ids, 3), "c"); + assert.equal(pillByNumber(ids, 4), null); + assert.equal(pillByNumber(ids, 0), null); +}); + +test("Ctrl stands in for ⌘ inside the island", () => { + const ctx = { view: "overview", inTextField: false }; + const ctrl = (key, code = "") => press({ key, code, ctrlKey: true }); + assert.deepEqual(islandKeyAction(ctrl("ArrowRight"), ctx), { kind: "cycle", delta: 1 }); + assert.deepEqual(islandKeyAction(ctrl("ArrowLeft"), ctx), { kind: "cycle", delta: -1 }); + assert.deepEqual(islandKeyAction(ctrl("&", "Digit1"), ctx), { kind: "pill", number: 1 }); + assert.deepEqual(islandKeyAction(ctrl("9", "Digit9"), ctx), { kind: "pill", number: 9 }); + assert.equal(islandKeyAction(ctrl("0", "Digit0"), ctx), null); + assert.deepEqual(islandKeyAction(ctrl("p", "KeyP"), ctx), { kind: "pin" }); + assert.deepEqual(islandKeyAction(ctrl(",", "Comma"), ctx), { kind: "settings" }); + // Ctrl+K only means something in the chat. + assert.equal(islandKeyAction(ctrl("k", "KeyK"), ctx), null); + assert.deepEqual(islandKeyAction(ctrl("k", "KeyK"), { view: "prompt", inTextField: true }), { kind: "newChat" }); + // In a text field Ctrl+← → move by word. + assert.equal(islandKeyAction(ctrl("ArrowRight"), { view: "prompt", inTextField: true }), null); + // Anything with another modifier, or no Ctrl, is left alone. + assert.equal(islandKeyAction(press({ key: "ArrowRight", ctrlKey: true, altKey: true }), ctx), null); + assert.equal(islandKeyAction(press({ key: "p", code: "KeyP" }), ctx), null); + assert.ok(ISLAND_SHORTCUTS.length >= 6); +}); + +// ── What the island does ────────────────────────────────────────────────────── + +let did; +const host = { + alert: (v) => did.push(`alert:${v}`), + setView: (v) => did.push(`setView:${v}`), + collapse: () => did.push("collapse"), + emote: (e) => did.push(`emote:${e}`), + setPinned: (on) => did.push(`pin:${on}`), + takeKeyboard: () => did.push("keyboard"), +}; +const resume = () => did.push("resume"); +// The island listens on `window`, which here is the bare global object. +const listeners = []; +globalThis.addEventListener = (type, fn, capture) => listeners.push({ type, fn, capture }); +registerShortcutHandlers(host, resume); + +beforeEach(() => { + did = []; + calls.length = 0; + State.tasks = []; + State.focusId = null; + State.mode = "hidden"; + State.view = "overview"; + State.isPinned = false; + State.pendingApproval = null; + State.stateOverride = null; + State.chatHistory = []; + State.settings = { ...DEFAULT_SETTINGS }; + State.loadIntegrationTasks(); +}); + +test("a global shortcut arrives as an event and opens the chat", () => { + emit("shortcut", "openChat"); + assert.deepEqual(did, ["resume", "alert:prompt"]); +}); + +test("island keys are read before the chat field sees them, and only while open", () => { + const keydown = listeners.find((l) => l.type === "keydown"); + assert.equal(keydown.capture, true); + const ev = (over) => { + const e = { ...press(over), target: null, stopped: false, prevented: false }; + e.preventDefault = () => { e.prevented = true; }; + e.stopPropagation = () => { e.stopped = true; }; + return e; + }; + const hidden = ev({ key: "p", code: "KeyP", ctrlKey: true }); + keydown.fn(hidden); + assert.equal(hidden.prevented, false); + + State.mode = "expanded"; + const pin = ev({ key: "p", code: "KeyP", ctrlKey: true }); + keydown.fn(pin); + assert.ok(pin.prevented && pin.stopped); + assert.deepEqual(did, ["pin:true"]); + + // Typing in the chat is left alone, word jumps included. + const word = ev({ key: "ArrowLeft", ctrlKey: true }); + word.target = { tagName: "INPUT" }; + keydown.fn(word); + assert.equal(word.prevented, false); + const plain = ev({ key: "k", code: "KeyK" }); + keydown.fn(plain); + assert.equal(plain.prevented, false); +}); + +test("the island toggle opens with the keyboard, and closes an open island", () => { + runGlobalShortcut(host, "toggleIsland", resume); + assert.deepEqual(did, ["resume", "alert:overview", "keyboard"]); + did = []; + State.mode = "expanded"; + runGlobalShortcut(host, "toggleIsland", resume); + assert.deepEqual(did, ["collapse"]); +}); + +test("go to alert: the permission first, then a question, else Mochi is annoyed", () => { + runGlobalShortcut(host, "goToAlert", resume); + assert.deepEqual(did, ["emote:annoyed"]); + + did = []; + State.upsertExternalAgent("agent_gemini", "Gemini", "#fff"); + State.updateTask("agent_gemini", "question"); + runGlobalShortcut(host, "goToAlert", resume); + assert.deepEqual(did, ["resume", "alert:question", "keyboard"]); + assert.equal(State.focusId, "agent_gemini"); + + did = []; + State.pendingApproval = { requestId: "r1", sessionId: "s", tool: "Bash", command: "ls" }; + runGlobalShortcut(host, "goToAlert", resume); + assert.deepEqual(did, ["resume", "alert:approval", "keyboard"]); + assert.equal(State.focusId, "integration_claude"); + // Nothing is ever decided from a shortcut. + assert.deepEqual(sent("approval_decision"), []); +}); + +test("the terminal shortcut is the existing Open terminal, and folds the island", () => { + State.tasks[0].sessionCwd = "C:\\work\\proj"; + State.mode = "expanded"; + runGlobalShortcut(host, "jumpToTerminal", resume); + assert.deepEqual(sent("open_in_vscode"), [{ path: "C:\\work\\proj" }]); + assert.deepEqual(did, ["collapse"]); +}); + +test("next and previous pill wrap around and open the overview", () => { + const ids = State.tasks.map((t) => t.id); + State.focusId = ids[0]; + runGlobalShortcut(host, "prevPill", resume); + assert.equal(State.focusId, ids[ids.length - 1]); + runGlobalShortcut(host, "nextPill", resume); + assert.equal(State.focusId, ids[0]); + assert.deepEqual(did, ["resume", "alert:overview", "resume", "alert:overview"]); +}); + +test("mute flips the sound, saves it, and Mochi reacts", () => { + runGlobalShortcut(host, "muteToggle", resume); + assert.equal(State.settings.soundEnabled, false); + assert.equal(sent("save_settings").at(-1).settings.soundEnabled, false); + runGlobalShortcut(host, "muteToggle", resume); + assert.equal(State.settings.soundEnabled, true); + assert.deepEqual(did, ["emote:annoyed", "emote:happy"]); +}); + +test("actions that aren't the island's do nothing here", () => { + for (const id of ["wardrobeToggle", "attachFrontWindow", "desktopToggle", "nonsense"]) { + runGlobalShortcut(host, id, resume); + } + assert.deepEqual(did, []); + assert.deepEqual(calls, []); +}); + +test("island keys: pills by number, new chat, settings, pin", () => { + const ids = State.tasks.map((t) => t.id); + runIslandKey(host, { kind: "pill", number: 2 }); + assert.equal(State.focusId, ids[1]); + runIslandKey(host, { kind: "pill", number: 9 }); + assert.equal(State.focusId, ids[1]); + runIslandKey(host, { kind: "cycle", delta: 1 }); + assert.equal(State.focusId, ids[2 % ids.length]); + + State.chatHistory = [{ id: 1, role: "user", content: "hi" }]; + State.stateOverride = "thinking"; + runIslandKey(host, { kind: "newChat" }); + assert.equal(State.chatHistory.length, 1, "not while an answer is on its way"); + State.stateOverride = null; + runIslandKey(host, { kind: "newChat" }); + assert.deepEqual(State.chatHistory, []); + assert.equal(sent("chat_reset").length, 1); + + runIslandKey(host, { kind: "settings" }); + assert.equal(sent("open_settings_window").length, 1); + + runIslandKey(host, { kind: "pin" }); + State.pendingApproval = { requestId: "r1", sessionId: "s", tool: "Bash", command: "ls" }; + runIslandKey(host, { kind: "pin" }); + assert.deepEqual(did, ["setView:overview", "setView:overview", "setView:prompt", "pin:true"]); +}); From 21a8a8608e0f747b915a8717f439ddf3a9ba40a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:20:16 +0200 Subject: [PATCH 29/77] =?UTF-8?q?Windows/Linux:=20Settings=20=E2=86=92=20S?= =?UTF-8?q?hortcuts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change, record or turn off each global shortcut, see the ones another app holds, the duplicates and the combinations that type a character, and the Wayland commands to bind instead. The island's own keys are listed read-only, as on the Mac. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DMzyoPKtGVNBUemdZhnWRG --- windows/src/settings/main.ts | 191 +++++++++++++++++++++++++++++- windows/src/settings/settings.css | 73 ++++++++++++ 2 files changed, 263 insertions(+), 1 deletion(-) diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..50874400e 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -3,7 +3,11 @@ // 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 ShortcutsReport } from "../core/bridge"; +import { + ISLAND_SHORTCUTS, SHORTCUTS, SHORTCUT_TEXT, activeKeys, displayKeys, duplicates, effective, + recordPress, type Binding, +} from "../core/shortcuts"; import { DEFAULT_SETTINGS, type Settings } from "../core/state"; import { h, clear } from "../views/dom"; @@ -417,6 +421,189 @@ function generalSection(): HTMLElement { ); } +// ── Shortcuts section ───────────────────────────────────────────────────────── + +const SHORTCUTS_UI = { + title: "Shortcuts", + hint: "Work from any app. Click a shortcut to change it, then press the new keys — Esc cancels, Backspace removes it.", + global: "From anywhere", + island: "In the open island", + recording: "Press keys…", + none: "None", + reset: "Reset to defaults", + inUse: "In use by another app", + duplicate: "Used twice", + invalid: "Not a valid shortcut", + unavailable: "Not available", + types: (ch: string) => `Types “${ch}”`, + typesNote: (keys: string, ch: string) => + `${keys} types “${ch}” on your keyboard, so it can't be a shortcut. Pick another key.`, + needsModifier: "Hold Ctrl, Alt or the Windows key with it.", + unsupportedKey: "That key can't be used in a shortcut.", + wayland: + "Your Wayland desktop doesn't let apps listen for keys outside their own windows. Add the shortcuts in your system's keyboard settings instead, with these commands:", + noDisplay: "No display server was found, so global shortcuts are off.", +} as const; + +function shortcutsSection(initial: ShortcutsReport | null): HTMLElement { + let report = initial; + const list = h("div", { class: "shortcut-list" }); + const feedback = h("div", {}); + const blockedNote = h("div", {}); + + let stopRecording: (() => void) | null = null; + + function store(id: string, binding: Binding) { + settings.shortcuts = { ...settings.shortcuts, [id]: binding }; + void save(); + } + + function tagFor(id: string, dups: Set): HTMLElement | null { + if (dups.has(id)) return h("span", { class: "tag err", text: SHORTCUTS_UI.duplicate }); + const st = report?.actions.find((a) => a.id === id); + switch (st?.status) { + case "inUse": return h("span", { class: "tag warn", text: SHORTCUTS_UI.inUse }); + case "duplicate": return h("span", { class: "tag err", text: SHORTCUTS_UI.duplicate }); + case "invalid": return h("span", { class: "tag err", text: SHORTCUTS_UI.invalid }); + case "typesCharacter": return h("span", { class: "tag warn", text: SHORTCUTS_UI.types(st.typed ?? "?") }); + case "unsupported": return h("span", { class: "tag", text: SHORTCUTS_UI.unavailable }); + default: return null; + } + } + + function record(id: string, binding: Binding, button: HTMLButtonElement) { + stopRecording?.(); + clear(feedback); + button.classList.add("recording"); + button.textContent = SHORTCUTS_UI.recording; + void Bridge.shortcutsSuspend(true); + + const onKey = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + const result = recordPress(e); + switch (result.kind) { + case "pending": + return; + case "keys": + finish(); + store(id, { keys: result.keys, enabled: true }); + return; + case "clear": + finish(); + store(id, { keys: "", enabled: binding.enabled }); + return; + case "typesCharacter": + finish(); + feedback.append(h("div", { + class: "notice warn", + text: SHORTCUTS_UI.typesNote(displayKeys(result.keys), result.typed), + })); + return; + case "needsModifier": + feedback.replaceChildren(h("div", { class: "notice warn", text: SHORTCUTS_UI.needsModifier })); + return; + case "unsupported": + feedback.replaceChildren(h("div", { class: "notice warn", text: SHORTCUTS_UI.unsupportedKey })); + return; + case "cancel": + finish(); + return; + } + }; + const onBlur = () => finish(); + + function finish() { + window.removeEventListener("keydown", onKey, true); + window.removeEventListener("blur", onBlur); + stopRecording = null; + // Takes the global shortcuts back, from what is saved by now. + void Bridge.shortcutsSuspend(false); + draw(); + } + stopRecording = finish; + window.addEventListener("keydown", onKey, true); + window.addEventListener("blur", onBlur); + } + + function draw() { + clear(list); + const dups = duplicates(activeKeys(settings.shortcuts)); + for (const d of SHORTCUTS) { + if (!d.ported) continue; + const binding = effective(d, settings.shortcuts); + const keycap = h("button", { + class: "keycap", + text: binding.keys ? displayKeys(binding.keys) : SHORTCUTS_UI.none, + }) as HTMLButtonElement; + keycap.disabled = !binding.enabled; + keycap.addEventListener("click", () => record(d.id, binding, keycap)); + const sw = toggle(binding.enabled, (on) => store(d.id, { keys: binding.keys, enabled: on })); + const tag = binding.enabled ? tagFor(d.id, dups) : null; + list.append(h("div", { class: binding.enabled ? "row shortcut" : "row shortcut off" }, + sw, + h("span", { class: "shortcut-name", text: SHORTCUT_TEXT[d.id] }), + ...(tag ? [tag] : []), + keycap, + )); + } + + clear(blockedNote); + if (report?.blocked === "wayland") { + const commands = h("div", { class: "diff" }); + for (const d of SHORTCUTS) { + if (d.ported) commands.append(h("div", { class: "ctx", text: `${report.command} ${d.id}` })); + } + blockedNote.append(h("div", { class: "notice warn", text: SHORTCUTS_UI.wayland }), commands); + } else if (report?.blocked) { + blockedNote.append(h("div", { class: "notice warn", text: SHORTCUTS_UI.noDisplay })); + } + } + + const islandList = h("div", { class: "shortcut-list" }); + for (const row of ISLAND_SHORTCUTS) { + islandList.append(h("div", { class: "row shortcut" }, + h("span", { class: "shortcut-name", text: row.description }), + h("span", { class: "keycap static", text: row.keys }), + )); + } + + const reset = h("button", { + text: SHORTCUTS_UI.reset, + onclick: () => { + stopRecording?.(); + settings.shortcuts = {}; + clear(feedback); + void save(); + draw(); + }, + }); + + void onEvent("shortcuts-status", (fresh) => { + report = fresh; + if (!stopRecording) draw(); + }); + void onEvent("settings-changed", (s) => { + settings = { ...settings, ...s }; + if (!stopRecording) draw(); + }); + + draw(); + return h( + "section", + {}, + h("h2", {}, h("span", { text: SHORTCUTS_UI.title })), + h("div", { class: "hint", text: SHORTCUTS_UI.hint }), + h("div", { class: "subhead", text: SHORTCUTS_UI.global }), + list, + blockedNote, + feedback, + h("div", { class: "row" }, reset), + h("div", { class: "subhead", text: SHORTCUTS_UI.island }), + islandList, + ); +} + // ── Boot ────────────────────────────────────────────────────────────────────── async function main() { @@ -430,6 +617,7 @@ async function main() { }; const hasKey = (await Bridge.secretPresent("anthropic-api-key")) ?? false; + const shortcutReport = await Bridge.shortcutsStatus(); const keys = [ "stripe-api-key", "github-token", "vercel-token", @@ -445,6 +633,7 @@ async function main() { apiSection(hasKey), integrationsSection(present), generalSection(), + shortcutsSection(shortcutReport), h("div", { class: "hint", text: "No telemetry. Network requests only go to the services you configure yourself.", diff --git a/windows/src/settings/settings.css b/windows/src/settings/settings.css index 605b82025..c17d018cd 100644 --- a/windows/src/settings/settings.css +++ b/windows/src/settings/settings.css @@ -265,3 +265,76 @@ select option { background: rgba(245, 165, 36, 0.13); color: #fbc06a; } + +/* Shortcuts section */ + +.subhead { + font: 600 11px var(--font); + color: var(--dim-2); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-top: 4px; +} + +.shortcut-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.row.shortcut { + flex-wrap: nowrap; + gap: 10px; +} + +.row.shortcut.off .shortcut-name { + color: var(--dim-2); +} + +.shortcut-name { + flex: 1 1 auto; + min-width: 0; + font-size: 12.5px; +} + +.keycap { + font: 500 11.5px var(--mono); + min-width: 118px; + padding: 5px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.07); + border: 1px solid var(--hairline); + color: var(--ink); + text-align: center; + flex: 0 0 auto; +} + +.keycap.recording { + border-color: rgba(255, 255, 255, 0.4); + color: var(--dim); +} + +.keycap.static { + background: transparent; + color: var(--dim); +} + +.tag { + font: 500 10.5px var(--font); + padding: 2px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: var(--dim); + white-space: nowrap; + flex: 0 0 auto; +} + +.tag.warn { + background: rgba(245, 165, 36, 0.13); + color: #fbc06a; +} + +.tag.err { + background: rgba(244, 80, 94, 0.14); + color: #ff8d97; +} From 239155cc00cf249a6093b7d80d47d65bf187b0d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:20:16 +0200 Subject: [PATCH 30/77] Windows/Linux README: keyboard shortcuts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DMzyoPKtGVNBUemdZhnWRG --- windows/README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/windows/README.md b/windows/README.md index 2b897bd3d..616fca78f 100644 --- a/windows/README.md +++ b/windows/README.md @@ -46,6 +46,38 @@ installs for the current user only — no admin prompt. | Drag a file onto the island | Mochi turns into a box, swallows it, then offers to answer questions about it | | `Esc` | Closes the island | | Tray icon | Open, Settings…, Pause, Quit | +| `Ctrl+Alt+Space` | Opens the chat, from any app | +| `Ctrl+Alt+A` | Jumps to the waiting permission or question | +| `Ctrl+Alt+T` | Opens the session's folder in VS Code ("Open terminal") | +| `Ctrl+Alt+→` / `Ctrl+Alt+←` | Next / previous pill | +| `Ctrl+Alt+S` | Mutes or unmutes Mochi | +| `Ctrl+Alt+G` | Opens the wardrobe | +| `Ctrl+Alt+N` | Opens and closes the island (off until you turn it on) | +| In the open island: `Ctrl+→` `Ctrl+←`, `Ctrl+1`–`Ctrl+9` | Switch pills | +| In the open island: `Ctrl+Enter`, `Ctrl+K` | Send, start a new chat | +| In the open island: `Ctrl+,`, `Ctrl+P` | Settings, keep the island open | + +### Keyboard shortcuts + +Every global shortcut can be changed or turned off in **Settings… → Shortcuts**: +click it and press the new keys. A combination another app already holds is +flagged *In use*, and two Coucou shortcuts on the same keys are flagged *Used +twice*. While you record a new one, Coucou lets go of its own so the keys reach +the recorder. + +The defaults are not the Mac's `⌃⌥` letters. On Windows, `Ctrl+Alt` is `AltGr`, +so a global `Ctrl+Alt+E` would swallow every `€` typed on a French or German +keyboard. The defaults were checked against the AltGr layer of the French, +German, Spanish, Italian, Portuguese and Brazilian (ABNT2) layouts — that is why +pill switching uses the arrows rather than `[` `]`, and mute is `S` rather than +`M` (`AltGr+M` is `µ` in German). On top of that, Coucou asks Windows what each +`Ctrl+Alt` combination types on the layouts you have installed and leaves any +that types a character unregistered, flagged *Types “ą”* in Settings: Polish, +for one, puts `ą` on `AltGr+A` and `ś` on `AltGr+S`. The recorder refuses such +a combination too. + +Older Intel graphics drivers rotate the screen on `Ctrl+Alt+←` / `→`; if yours +still does, those two show up as *In use*. Everything else happens on its own: a Claude Code permission request opens the island with **Deny / Allow**, a finished session shows what it did, and @@ -163,6 +195,14 @@ OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integr attach it as context, and jumping to a specific terminal window — "Open terminal" opens the working folder in VS Code when `code` is on your `PATH`. - Cal.com shows the next bookings as a list rather than the Mac's calendar. +- Keyboard shortcuts use `Ctrl+Alt` where the Mac uses `⌃⌥`, with different + keys (see [Keyboard shortcuts](#keyboard-shortcuts)), and `Ctrl` where the + Mac uses `⌘` inside the island. "Bring the terminal forward" is "Open + terminal" here. Not in this version: sending Mochi to the desktop, attaching + the front window (their ids are kept for later), moving through a card's + list (`⌘↑` `⌘↓` `⌘O`) and the diff (`⌘E`). The island only reads its own + shortcuts while it has the keyboard: in the chat, or after a global shortcut + opened it. ## Linux @@ -192,6 +232,17 @@ What changes on Linux: - **Claude Code hooks** go through `~/.local/share/coucou/bin/coucou-hook` and a Unix socket at `$XDG_RUNTIME_DIR/coucou.sock`. Both ends check that the other runs as the same user. +- **Global shortcuts** are X11 key grabs, so they work in an X11 session. + On X11, AltGr is a modifier of its own and never clashes with `Ctrl+Alt`; + combinations the desktop already uses (GNOME's `Ctrl+Alt+T` terminal and + `Ctrl+Alt+←`/`→` workspace switching) show up as *In use*. Wayland has no + key grabs — the GlobalShortcuts portal isn't supported yet — so nothing is + registered there, and **Settings → Shortcuts** lists commands to bind in your + desktop's own keyboard settings instead: + `coucou --shortcut openChat` (or the AppImage's path) runs the action in the + Coucou that is already open. The ids are `toggleIsland`, `openChat`, + `goToAlert`, `jumpToTerminal`, `nextPill`, `prevPill`, `muteToggle` and + `wardrobeToggle`. - **Keys** live in the Secret Service (GNOME Keyring, KWallet). - **Files**: preferences in `~/.config/coucou/`, the log at `~/.local/share/coucou/coucou.log`. From 43c13ed2de9a590c28b2bd5e7331c5801f2f937b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:20:25 +0200 Subject: [PATCH 31/77] Windows/Linux: weekly recap aggregation in local time summary.ts turns the recorded turns into last week's numbers (merged time, sessions, files and lines, commands, questions, permissions, top agent, top project, busiest day, longest session), with ISO weeks in the user's time zone. Tests mirror the Mac's test-weekly-recap.swift sample week. Co-Authored-By: Claude Opus 5.5 --- windows/src/recap/summary.ts | 251 +++++++++++++++++++++++++++++++++++ windows/tests/recap.test.mjs | 168 +++++++++++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 windows/src/recap/summary.ts create mode 100644 windows/tests/recap.test.mjs diff --git a/windows/src/recap/summary.ts b/windows/src/recap/summary.ts new file mode 100644 index 000000000..b62cc8d05 --- /dev/null +++ b/windows/src/recap/summary.ts @@ -0,0 +1,251 @@ +// Weekly recap — pure aggregation, port of RecapStore.weeklySummary. +// Rust (src-tauri/src/recap.rs) records the turns; this file turns them into +// one week's numbers. It lives on the JavaScript side because weeks, days and +// "Monday 8 am" are local time, and the webview knows the user's time zone. +// No DOM, no Tauri: tests/recap.test.mjs runs it as it is. + +/** One finished turn, as recap.rs stores it. Times are Unix seconds. */ +export interface RecapTurn { + agent: string; + project: string; + start: number; + end: number; + filesChanged: number; + linesAdded: number; + linesRemoved: number; + commandsRun: number; + questions: number; +} + +export interface RecapDecision { + agent: string; + date: number; + decision: string; +} + +export interface RecapPrefs { + enabled: boolean; + hideProjects: boolean; + /** Monday (YYYY-MM-DD) of the week the recap last opened on its own. */ + lastShownWeek: string; +} + +export interface RecapHistory { + turns: RecapTurn[]; + decisions: RecapDecision[]; + prefs: RecapPrefs; +} + +export interface WeeklySummary { + weekStart: Date; + /** Last second of the week (Sunday 23:59:59). */ + weekEnd: Date; + totalMinutes: number; + sessionCount: number; + filesChanged: number; + linesAdded: number; + linesRemoved: number; + commandsRun: number; + questions: number; + permissionsAllowed: number; + permissionsDenied: number; + /** Display name. */ + topAgent: string | null; + topProject: string | null; + busiestDay: string | null; + longestSessionMinutes: number; +} + +export const DEFAULT_PREFS: RecapPrefs = { enabled: true, hideProjects: false, lastShownWeek: "" }; + +// ── Weeks (ISO 8601: Monday to Sunday, local time) ──────────────────────────── + +/** Local midnight `n` days after `d`'s date — DST-safe, unlike adding ms. */ +export function addDays(d: Date, n: number): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); +} + +/** Monday 00:00 (local) of the week holding `d`. */ +export function mondayOf(d: Date): Date { + const sinceMonday = (d.getDay() + 6) % 7; + return addDays(d, -sinceMonday); +} + +/** Monday 00:00 of the last completed week. */ +export function previousWeekStart(now: Date): Date { + return addDays(mondayOf(now), -7); +} + +/** YYYY-MM-DD of a local date. */ +export function dayKey(d: Date): string { + const p = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} + +/** The Monday recap opens on its own once per week, from 8 am on. */ +export const RECAP_HOUR = 8; + +export function shouldAutoShow(now: Date, lastShownWeek: string): boolean { + return now.getDay() === 1 && now.getHours() >= RECAP_HOUR && dayKey(mondayOf(now)) !== lastShownWeek; +} + +// ── Aggregation ─────────────────────────────────────────────────────────────── + +const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/** Pill IDs → names, from PillCatalog.swift. Claude Code hooks come from any + * terminal here, so its pill reads "Claude Code" rather than "VS Code". */ +const AGENT_NAMES: Record = { + integration_claude: "Claude Code", + agent_cursor: "Cursor", + agent_antigravity: "Antigravity", + agent_codex: "Codex", + agent_gemini: "Gemini CLI", + agent_copilot: "Copilot CLI", + agent_muse: "Muse Code", + agent_opencode: "OpenCode", + agent_amp: "Amp", + agent_hermes: "Hermes", +}; + +export function agentName(id: string): string { + const known = AGENT_NAMES[id]; + if (known) return known; + const raw = id.replace(/^agent_/, ""); + return raw ? raw.charAt(0).toUpperCase() + raw.slice(1) : id; +} + +/** The most frequent key; ties go to whatever `order` ranks first. */ +function topKey(counts: Map, order: (a: K, b: K) => number): K | null { + let best: K | null = null; + let bestCount = 0; + for (const [key, n] of counts) { + if (n > bestCount || (n === bestCount && best !== null && order(key, best) < 0)) { + best = key; + bestCount = n; + } + } + return best; +} + +/** Wall time covered by the turns, overlapping ones counted once. Seconds. */ +export function mergedSeconds(turns: RecapTurn[]): number { + const sorted = turns.filter((t) => t.end > t.start).sort((a, b) => a.start - b.start); + let total = 0; + let segStart = 0; + let segEnd = -Infinity; + for (const t of sorted) { + if (t.start <= segEnd) { + segEnd = Math.max(segEnd, t.end); + } else { + if (segEnd > segStart) total += segEnd - segStart; + segStart = t.start; + segEnd = t.end; + } + } + if (segEnd > segStart) total += segEnd - segStart; + return total; +} + +/** The week starting at `weekStart` (a local Monday 00:00), or null when nothing ran. */ +export function summarize(history: RecapHistory, weekStart: Date): WeeklySummary | null { + const end = addDays(weekStart, 7); + const from = weekStart.getTime() / 1000; + const to = end.getTime() / 1000; + const turns = history.turns.filter((t) => t.start >= from && t.start < to); + if (turns.length === 0) return null; + const decisions = history.decisions.filter((d) => d.date >= from && d.date < to); + const sum = (f: (t: RecapTurn) => number) => turns.reduce((n, t) => n + f(t), 0); + + const byAgent = new Map(); + const byProject = new Map(); + const byDay = new Map(); + for (const t of turns) { + byAgent.set(t.agent, (byAgent.get(t.agent) ?? 0) + 1); + if (t.project) byProject.set(t.project, (byProject.get(t.project) ?? 0) + 1); + const day = new Date(t.start * 1000).getDay(); + byDay.set(day, (byDay.get(day) ?? 0) + 1); + } + const byName = (a: string, b: string) => a.localeCompare(b); + // Ties go to the earlier day of the (Monday-first) week. + const byWeekOrder = (a: number, b: number) => ((a + 6) % 7) - ((b + 6) % 7); + const topAgent = topKey(byAgent, byName); + const busiest = topKey(byDay, byWeekOrder); + const longest = Math.max(0, ...turns.map((t) => t.end - t.start)); + + return { + weekStart, + weekEnd: new Date(end.getTime() - 1000), + totalMinutes: Math.floor(mergedSeconds(turns) / 60), + sessionCount: turns.length, + filesChanged: sum((t) => t.filesChanged), + linesAdded: sum((t) => t.linesAdded), + linesRemoved: sum((t) => t.linesRemoved), + commandsRun: sum((t) => t.commandsRun), + questions: sum((t) => t.questions), + permissionsAllowed: decisions.filter((d) => d.decision === "allow" || d.decision === "always").length, + permissionsDenied: decisions.filter((d) => d.decision === "deny").length, + topAgent: topAgent === null ? null : agentName(topAgent), + topProject: topKey(byProject, byName), + busiestDay: busiest === null ? null : DAY_NAMES[busiest], + longestSessionMinutes: Math.floor(longest / 60), + }; +} + +// ── Formatting ──────────────────────────────────────────────────────────────── + +export function formatDuration(minutes: number): string { + if (minutes < 60) return `${minutes}m`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m === 0 ? `${h}h` : `${h}h ${m}m`; +} + +/** 1234 → "1,234"; 12345 → "12.3k" — keeps a stat chip narrow. */ +export function formatCount(n: number): string { + if (n >= 10_000) return `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k`; + return n.toLocaleString("en-US"); +} + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** "Sep 28 – Oct 4". */ +export function weekRangeLabel(s: Pick): string { + const f = (d: Date) => `${MONTHS[d.getMonth()]} ${d.getDate()}`; + return `${f(s.weekStart)} – ${f(s.weekEnd)}`; +} + +// ── Sample week ─────────────────────────────────────────────────────────────── + +/** + * The fake week of scripts/test-weekly-recap.swift, starting on `monday`. + * Used by the tests and by `npm run dev` in a plain browser, where there is + * no Rust side to read a history from. + */ +export function sampleHistory(monday: Date): RecapHistory { + const at = (day: number, hour: number, minute = 0) => + Math.floor(new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + day, hour, minute).getTime() / 1000); + const turn = ( + agent: string, project: string, start: number, end: number, + filesChanged: number, linesAdded: number, linesRemoved: number, commandsRun: number, questions: number, + ): RecapTurn => ({ agent, project, start, end, filesChanged, linesAdded, linesRemoved, commandsRun, questions }); + const claude = "integration_claude"; + return { + turns: [ + turn(claude, "coucou", at(0, 9), at(0, 11, 30), 8, 312, 87, 14, 2), + turn(claude, "coucou", at(0, 14), at(0, 15, 45), 3, 95, 20, 5, 0), + turn("agent_gemini", "side-project", at(1, 10), at(1, 11), 2, 50, 10, 3, 1), + turn(claude, "coucou", at(2, 9, 30), at(2, 12), 5, 180, 60, 8, 3), + turn(claude, "coucou", at(3, 16), at(3, 17), 1, 40, 5, 2, 0), + turn(claude, "coucou", at(4, 8), at(4, 13), 12, 540, 130, 22, 5), + ], + decisions: [ + { agent: claude, date: at(0, 9, 30), decision: "allow" }, + { agent: claude, date: at(0, 10), decision: "allow" }, + { agent: claude, date: at(2, 10), decision: "deny" }, + { agent: claude, date: at(4, 9), decision: "always" }, + { agent: claude, date: at(4, 10), decision: "allow" }, + ], + prefs: { ...DEFAULT_PREFS }, + }; +} diff --git a/windows/tests/recap.test.mjs b/windows/tests/recap.test.mjs new file mode 100644 index 000000000..1570ffb77 --- /dev/null +++ b/windows/tests/recap.test.mjs @@ -0,0 +1,168 @@ +// Weekly recap aggregation (src/recap/summary.ts) — the numbers the card and +// the shared image show. Mirrors the fake week of scripts/test-weekly-recap.swift. +// Dates are built with local constructors, so the tests hold in any time zone. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + addDays, dayKey, formatCount, formatDuration, mergedSeconds, mondayOf, + previousWeekStart, sampleHistory, shouldAutoShow, summarize, weekRangeLabel, +} from "../src/recap/summary.ts"; + +const secs = (d) => Math.floor(d.getTime() / 1000); +const turn = (start, end, extra = {}) => ({ + agent: "integration_claude", project: "coucou", + start: secs(start), end: secs(end), + filesChanged: 0, linesAdded: 0, linesRemoved: 0, commandsRun: 0, questions: 0, + ...extra, +}); +const history = (turns, decisions = []) => ({ + turns, decisions, prefs: { enabled: true, hideProjects: false, lastShownWeek: "" }, +}); + +// Monday 28 September 2026 → Sunday 4 October; "now" is Monday 5 October. +const LAST_MONDAY = new Date(2026, 8, 28); +const NOW = new Date(2026, 9, 5, 9, 15); + +// ── Week boundaries ─────────────────────────────────────────────────────────── + +test("the last completed week runs from the previous Monday to Sunday, local time", () => { + assert.equal(dayKey(previousWeekStart(NOW)), "2026-09-28"); + // Any moment of the current week points at the same previous week. + assert.equal(dayKey(previousWeekStart(new Date(2026, 9, 5, 0, 0, 1))), "2026-09-28"); + assert.equal(dayKey(previousWeekStart(new Date(2026, 9, 11, 23, 59))), "2026-09-28"); + // Sunday still belongs to the week that began six days earlier. + assert.equal(dayKey(mondayOf(new Date(2026, 9, 4, 22))), "2026-09-28"); + // Across a month and a year. + assert.equal(dayKey(mondayOf(new Date(2027, 0, 1))), "2026-12-28"); + assert.equal(previousWeekStart(NOW).getHours(), 0); +}); + +test("only turns that start inside the week count", () => { + const s = summarize(history([ + turn(new Date(2026, 8, 27, 23, 0), new Date(2026, 8, 27, 23, 59)), // Sunday before + turn(new Date(2026, 8, 28, 0, 0), new Date(2026, 8, 28, 0, 30)), // Monday 00:00 + turn(new Date(2026, 9, 4, 23, 50), new Date(2026, 9, 5, 0, 40)), // Sunday night, ends Monday + turn(new Date(2026, 9, 5, 0, 0), new Date(2026, 9, 5, 1, 0)), // this Monday + ]), LAST_MONDAY); + assert.equal(s.sessionCount, 2); + assert.equal(s.totalMinutes, 30 + 50); + assert.equal(dayKey(s.weekStart), "2026-09-28"); + assert.equal(dayKey(s.weekEnd), "2026-10-04"); + assert.equal(weekRangeLabel(s), "Sep 28 – Oct 4"); +}); + +test("a week with nothing in it has no summary", () => { + assert.equal(summarize(history([]), LAST_MONDAY), null); + const lastMonth = turn(new Date(2026, 8, 1, 9), new Date(2026, 8, 1, 10)); + assert.equal(summarize(history([lastMonth]), LAST_MONDAY), null); +}); + +// ── The Mac's fake week ─────────────────────────────────────────────────────── + +test("the sample week adds up like the Mac's", () => { + const s = summarize(sampleHistory(LAST_MONDAY), LAST_MONDAY); + assert.equal(s.totalMinutes, 150 + 105 + 60 + 150 + 60 + 300); + assert.equal(formatDuration(s.totalMinutes), "13h 45m"); + assert.equal(s.sessionCount, 6); + assert.equal(s.filesChanged, 31); + assert.equal(s.linesAdded, 1217); + assert.equal(s.linesRemoved, 312); + assert.equal(s.commandsRun, 54); + assert.equal(s.questions, 11); + assert.equal(s.permissionsAllowed, 4); + assert.equal(s.permissionsDenied, 1); + assert.equal(s.topAgent, "Claude Code"); + assert.equal(s.topProject, "coucou"); + assert.equal(s.busiestDay, "Monday"); + assert.equal(s.longestSessionMinutes, 300); +}); + +// ── Details ─────────────────────────────────────────────────────────────────── + +test("parallel sessions are not counted twice in the time spent", () => { + const d = (h, m = 0) => new Date(2026, 8, 29, h, m); + const turns = [ + turn(d(9), d(10)), + turn(d(9, 30), d(11)), // overlaps the first + turn(d(10, 15), d(10, 45)), // inside both + turn(d(14), d(14, 20)), + ]; + assert.equal(mergedSeconds(turns), (120 + 20) * 60); + const s = summarize(history(turns), LAST_MONDAY); + assert.equal(s.totalMinutes, 140); + assert.equal(s.longestSessionMinutes, 90); + assert.equal(s.sessionCount, 4); +}); + +test("top agent and top project go by number of sessions", () => { + const d = (day, h) => new Date(2026, 8, 28 + day, h); + const s = summarize(history([ + turn(d(0, 9), d(0, 15), { agent: "integration_claude", project: "big-but-once" }), + turn(d(1, 9), d(1, 10), { agent: "agent_gemini", project: "korus" }), + turn(d(1, 11), d(1, 12), { agent: "agent_gemini", project: "korus" }), + turn(d(2, 9), d(2, 10), { agent: "agent_my-tool", project: "" }), + turn(d(2, 11), d(2, 12), { agent: "agent_my-tool", project: "" }), + turn(d(2, 13), d(2, 14), { agent: "agent_my-tool", project: "" }), + ]), LAST_MONDAY); + assert.equal(s.topAgent, "My-tool"); + // Sessions without a folder never make a top project. + assert.equal(s.topProject, "korus"); + assert.equal(s.busiestDay, "Wednesday"); + assert.equal(s.longestSessionMinutes, 360); +}); + +test("ties are settled the same way every time", () => { + const d = (day, h) => new Date(2026, 8, 28 + day, h); + const s = summarize(history([ + turn(d(6, 9), d(6, 10), { agent: "agent_codex", project: "zeta" }), // Sunday + turn(d(3, 9), d(3, 10), { agent: "integration_claude", project: "alpha" }), // Thursday + ]), LAST_MONDAY); + assert.equal(s.topAgent, "Codex"); // agent_codex < integration_claude + assert.equal(s.topProject, "alpha"); + assert.equal(s.busiestDay, "Thursday"); // earlier in the week than Sunday +}); + +test("decisions outside the week do not count", () => { + const inWeek = secs(new Date(2026, 8, 30, 10)); + const s = summarize(history( + [turn(new Date(2026, 8, 30, 9), new Date(2026, 8, 30, 11))], + [ + { agent: "integration_claude", date: inWeek, decision: "allow" }, + { agent: "integration_claude", date: inWeek, decision: "deny" }, + { agent: "integration_claude", date: secs(new Date(2026, 9, 5, 10)), decision: "allow" }, + { agent: "integration_claude", date: secs(new Date(2026, 8, 20, 10)), decision: "deny" }, + ], + ), LAST_MONDAY); + assert.equal(s.permissionsAllowed, 1); + assert.equal(s.permissionsDenied, 1); +}); + +test("a history older than the window shows nothing for last week", () => { + // Rust prunes past 12 weeks; whatever it keeps from earlier weeks stays out. + const twelveWeeksAgo = addDays(LAST_MONDAY, -7 * 12); + const s = summarize(history([turn(twelveWeeksAgo, addDays(twelveWeeksAgo, 0.01))]), LAST_MONDAY); + assert.equal(s, null); + const old = summarize(history([turn(addDays(twelveWeeksAgo, 1), new Date(addDays(twelveWeeksAgo, 1).getTime() + 3600_000))]), twelveWeeksAgo); + assert.equal(old.sessionCount, 1); +}); + +// ── When it opens on its own ────────────────────────────────────────────────── + +test("the recap opens on Monday from 8 am, once a week", () => { + assert.equal(shouldAutoShow(new Date(2026, 9, 5, 7, 59), ""), false); + assert.equal(shouldAutoShow(new Date(2026, 9, 5, 8, 0), ""), true); + assert.equal(shouldAutoShow(new Date(2026, 9, 5, 18, 0), "2026-09-28"), true); + assert.equal(shouldAutoShow(new Date(2026, 9, 5, 18, 0), "2026-10-05"), false); + assert.equal(shouldAutoShow(new Date(2026, 9, 6, 9, 0), ""), false); // Tuesday + assert.equal(shouldAutoShow(new Date(2026, 9, 4, 9, 0), ""), false); // Sunday +}); + +test("numbers stay short enough for a stat chip", () => { + assert.equal(formatDuration(0), "0m"); + assert.equal(formatDuration(59), "59m"); + assert.equal(formatDuration(120), "2h"); + assert.equal(formatCount(1217), "1,217"); + assert.equal(formatCount(12_345), "12.3k"); + assert.equal(formatCount(123_456), "123k"); +}); From 9806b66fa891b3a1d227855424feb57da55118b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:20:25 +0200 Subject: [PATCH 32/77] Windows/Linux: weekly recap card, shared image and settings The island gets a recap view that opens on Monday from 8 am, once a week, when the app starts, an agent starts working or the island wakes, and any time from the tray. "Share image" flips the card to a share panel: a 1080 x 1920 PNG with Mochi drawn by the island's engine, saved into Pictures or Downloads, or copied through the web clipboard; project names can be hidden. Settings -> General gets the history toggle and Clear history. dev/recap-preview.html shows it all on the sample week. Co-Authored-By: Claude Opus 5.5 --- windows/dev/recap-preview.html | 20 ++ windows/dev/recap-preview.ts | 23 +++ windows/src/core/bridge.ts | 15 ++ windows/src/core/layout.ts | 7 +- windows/src/island/island.ts | 11 +- windows/src/main.ts | 11 ++ windows/src/recap/recap.ts | 87 +++++++++ windows/src/recap/share.ts | 261 +++++++++++++++++++++++++++ windows/src/settings/main.ts | 46 +++++ windows/src/style.css | 106 +++++++++++ windows/src/views/recap.ts | 236 ++++++++++++++++++++++++ windows/src/views/views.ts | 2 + windows/tests/recap-trigger.test.mjs | 103 +++++++++++ 13 files changed, 926 insertions(+), 2 deletions(-) create mode 100644 windows/dev/recap-preview.html create mode 100644 windows/dev/recap-preview.ts create mode 100644 windows/src/recap/recap.ts create mode 100644 windows/src/recap/share.ts create mode 100644 windows/src/views/recap.ts create mode 100644 windows/tests/recap-trigger.test.mjs diff --git a/windows/dev/recap-preview.html b/windows/dev/recap-preview.html new file mode 100644 index 000000000..15266e2f1 --- /dev/null +++ b/windows/dev/recap-preview.html @@ -0,0 +1,20 @@ + + + + + Coucou — weekly recap preview + + + +
+
+ + + diff --git a/windows/dev/recap-preview.ts b/windows/dev/recap-preview.ts new file mode 100644 index 000000000..62eda1119 --- /dev/null +++ b/windows/dev/recap-preview.ts @@ -0,0 +1,23 @@ +// The weekly recap card in a real island, and the shared image with and +// without project names, on the sample week of scripts/test-weekly-recap.swift. +// `npm run dev`, then open /dev/recap-preview.html. Not shipped in the app. + +import "../src/style.css"; +import { Island } from "../src/island/island"; +import { Recap } from "../src/recap/recap"; +import { renderShareImage } from "../src/recap/share"; + +const island = new Island(document.getElementById("root")!); +await Recap.open(island); + +const images = document.getElementById("images")!; +const summary = Recap.summary; +if (summary) { + for (const [hide, caption] of [[false, "Shared image"], [true, "Project names hidden"]] as const) { + const img = document.createElement("img"); + img.src = renderShareImage(summary, hide).toDataURL("image/png"); + const figure = document.createElement("figure"); + figure.append(img, caption); + images.append(figure); + } +} diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..1054998f7 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -6,6 +6,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import type { Settings } from "./state"; +import type { RecapHistory, RecapPrefs } from "../recap/summary"; export const IS_TAURI = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; @@ -99,6 +100,20 @@ export const Bridge = { /** Tray → Pause. Stops the integration pollers, not just the island. */ setPaused: (paused: boolean) => call("set_paused", { paused }), + + // ── Weekly recap ────────────────────────────────────────────────────────── + /** Turns and decisions from `since` (Unix seconds) on, plus the recap prefs. */ + recapHistory: (since: number) => call("recap_history", { since: Math.floor(since) }), + recapPrefs: () => call("recap_prefs"), + recapSetEnabled: (enabled: boolean) => call("recap_set_enabled", { enabled }), + recapSetHideProjects: (hide: boolean) => call("recap_set_hide_projects", { hide }), + /** `week` is the Monday (YYYY-MM-DD) the recap opened on its own for. */ + recapMarkShown: (week: string) => call("recap_mark_shown", { week }), + recapClear: () => call("recap_clear"), + /** Writes the PNG (a data URL) into Pictures or Downloads; returns its path. */ + recapSavePng: (data: string, week: string) => callOrThrow("recap_save_png", { data, week }), + /** Opens the folder of the image saved last. */ + recapRevealSaved: () => call("recap_reveal_saved"), }; export interface IntegrationUpdate { diff --git a/windows/src/core/layout.ts b/windows/src/core/layout.ts index 6f70eea4c..6821eff7a 100644 --- a/windows/src/core/layout.ts +++ b/windows/src/core/layout.ts @@ -21,7 +21,8 @@ export type IslandViewName = | "result" | "note" | "settings" - | "greeting"; + | "greeting" + | "recap"; export type BotStateName = | "idle" @@ -86,6 +87,10 @@ export const VIEW_LAYOUTS: Record = { note: { height: 160, botX: 60, botY: null, botDiameter: 50, agentMode: "column" }, settings: { height: 160, botX: 54, botY: null, botDiameter: 46, agentMode: "none" }, greeting: { height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: "none" }, + // Mac: 160. The extra 24 hold the two lines with top agent, project, busiest + // day, longest session, permissions and questions, which the Mac card leaves + // to the shared image. + recap: { height: 184, botX: 62, botY: null, botDiameter: 58, agentMode: "column" }, }; // The upload views above are only the fallback geometry. Once a file is actually diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb61802..e85123065 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -84,6 +84,11 @@ export class Island { private prevViewBeforeConfused: IslandViewName = "overview"; private lastSyncedView: IslandViewName | null = null; + /** The launch greeting ended, or the island came out of hidden — two of the + * moments the Monday recap may open (see src/recap/recap.ts). */ + onGreetingDone: (() => void) | null = null; + onWake: (() => void) | null = null; + /** Drop sequence bookkeeping: last tick played, and whether the ✓ has fired. */ private uploadTens = 0; private uploadDone = false; @@ -94,7 +99,10 @@ export class Island { this.wireFsm(); this.wireInput(); this.engine.onDizzy = () => this.handleDizzy(); - this.greeting.onComplete = () => this.fsm.greetComplete(); + this.greeting.onComplete = () => { + this.fsm.greetComplete(); + this.onGreetingDone?.(); + }; State.subscribe(() => { this.dirty = true; this.ensureRunning(); @@ -247,6 +255,7 @@ export class Island { break; } State.notify(); + if (from === "hidden") this.onWake?.(); }; } diff --git a/windows/src/main.ts b/windows/src/main.ts index f65d5e757..acdbf922a 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -7,6 +7,7 @@ import { State, type Settings } from "./core/state"; import { Island } from "./island/island"; import { registerHookHandlers } from "./island/hooks"; import { registerIntegrationHandlers, refreshConfigured } from "./island/integrations"; +import { Recap } from "./recap/recap"; async function main() { const root = document.getElementById("root"); @@ -43,6 +44,10 @@ async function main() { setPaused(false); island.alert(State.defaultView()); break; + case "recap": + setPaused(false); + void Recap.open(island); + break; case "pause": setPaused(!State.paused); if (State.paused) island.fsm.forceHidden(); @@ -64,6 +69,12 @@ async function main() { registerHookHandlers(island); registerIntegrationHandlers(island); + // Monday recap: app start (greeting over), an agent starting work, waking up. + const checkRecap = () => void Recap.check(island); + island.onGreetingDone = checkRecap; + island.onWake = checkRecap; + await onEvent("recap-check", checkRecap); + island.launch(); // In a plain browser there is no wake strip behind the cursor: make the whole diff --git a/windows/src/recap/recap.ts b/windows/src/recap/recap.ts new file mode 100644 index 000000000..9b38df115 --- /dev/null +++ b/windows/src/recap/recap.ts @@ -0,0 +1,87 @@ +// Weekly recap — when the card opens, and what it shows. Port of +// AppDelegate.checkMondayRecap / openWeeklyRecap. +// +// It opens on its own once a week, on Monday from 8 am, at the first of: +// * the launch greeting ending (the app starting that day), +// * an agent starting work (SessionStart / UserPromptSubmit — Rust sends +// `recap-check` when it sees one), +// * the island waking from hidden (the user coming back to the machine). +// And any time from the tray: "Weekly recap". Nothing here runs on a timer. + +import { Bridge, IS_TAURI } from "../core/bridge"; +import type { IslandViewName } from "../core/layout"; +import { State } from "../core/state"; +import { + DEFAULT_PREFS, dayKey, mondayOf, previousWeekStart, sampleHistory, shouldAutoShow, summarize, + type RecapPrefs, type WeeklySummary, +} from "./summary"; + +/** What the recap needs from the island. */ +export interface RecapHost { + alert(view: IslandViewName): void; +} + +/** Views the Monday card may replace; anything else is the user busy with something. */ +const QUIET_VIEWS: ReadonlySet = new Set(["overview", "empty", "settings", "greeting"]); + +/** Same 1.5 s as the Mac, so it never lands on top of the event that triggered it. */ +const SHOW_DELAY_MS = 1500; + +class RecapController { + summary: WeeklySummary | null = null; + prefs: RecapPrefs = { ...DEFAULT_PREFS }; + /** Bumped on every load, so the view knows to rebuild (and leave share mode). */ + version = 0; + private checking = false; + + /** Reads last week from Rust. A plain browser (`npm run dev`) gets the sample week. */ + async load(now = new Date()): Promise { + const start = previousWeekStart(now); + const history = IS_TAURI + ? await Bridge.recapHistory(start.getTime() / 1000) + : sampleHistory(start); + this.prefs = { ...DEFAULT_PREFS, ...(history?.prefs ?? {}) }; + this.summary = history ? summarize(history, start) : null; + this.version++; + return this.summary; + } + + /** Tray → Weekly recap. Shows the card even for an empty week. */ + async open(host: RecapHost) { + await this.load(); + host.alert("recap"); + } + + /** Monday ≥ 8 am, not shown yet this week, something to show, nobody interrupted. */ + async check(host: RecapHost, now = new Date()) { + // The clock first: six days out of seven this is all that runs. + if (this.checking || !shouldAutoShow(now, "") || !this.quiet()) return; + this.checking = true; + try { + const prefs = IS_TAURI ? await Bridge.recapPrefs() : null; + if (!prefs?.enabled || !shouldAutoShow(now, prefs.lastShownWeek)) return; + if (!(await this.load(now))) return; + const week = dayKey(mondayOf(now)); + window.setTimeout(() => { + if (!this.quiet()) return; + host.alert("recap"); + void Bridge.recapMarkShown(week); + this.prefs.lastShownWeek = week; + }, SHOW_DELAY_MS); + } finally { + this.checking = false; + } + } + + private quiet(): boolean { + if (State.paused || State.pendingApproval) return false; + return State.mode !== "expanded" || QUIET_VIEWS.has(State.view); + } + + async setHideProjects(hide: boolean) { + this.prefs.hideProjects = hide; + await Bridge.recapSetHideProjects(hide); + } +} + +export const Recap = new RecapController(); diff --git a/windows/src/recap/share.ts b/windows/src/recap/share.ts new file mode 100644 index 000000000..7086a4cf3 --- /dev/null +++ b/windows/src/recap/share.ts @@ -0,0 +1,261 @@ +// The 1080 × 1920 image of the week, drawn once into an offscreen canvas — +// port of RecapShareImageView in WeeklyRecapView.swift. Same colours, sizes +// and order; Mochi comes from the island's own engine. + +import { BotEngine } from "../mochi/engine"; +import { formatCount, formatDuration, weekRangeLabel, type WeeklySummary } from "./summary"; + +export const SHARE_W = 1080; +export const SHARE_H = 1920; + +const FONT = `system-ui, "Segoe UI Variable Display", "Segoe UI", "Cantarell", "Ubuntu", sans-serif`; +const MONO = `"Cascadia Mono", "Consolas", "DejaVu Sans Mono", ui-monospace, monospace`; + +const INK = "#F1F2F4"; +const DIM = "#8E939C"; +const INDIGO_TEXT = "#818CF8"; + +/** Strings drawn into the image, in one place for the translation layer. */ +const T = { + title: "Weekly recap", + timeCoding: "TIME CODING", + sessions: "SESSIONS", + files: "FILES", + commands: "COMMANDS", + topAgent: "Top agent", + topProject: "Top project", + busiestDay: "Busiest day", + longestSession: "Longest session", + approved: "Approved", + denied: "Denied", + footer: "Coucou · github.com/Louis-CFM/coucou", +}; + +type Ctx = CanvasRenderingContext2D; + +function font(x: Ctx, weight: number, size: number, family = FONT) { + x.font = `${weight} ${size}px ${family}`; +} + +/** Shrinks the font until `text` fits in `maxW` (minimumScaleFactor). */ +function fitFont(x: Ctx, text: string, weight: number, size: number, maxW: number, minScale: number) { + let s = size; + font(x, weight, s); + while (x.measureText(text).width > maxW && s > size * minScale) { + s -= 2; + font(x, weight, s); + } +} + +/** Text with letter spacing, centred on `cx` — `letterSpacing` is not everywhere yet. */ +function spaced(x: Ctx, text: string, cx: number, y: number, spacing: number) { + const chars = [...text]; + const widths = chars.map((c) => x.measureText(c).width); + const total = widths.reduce((a, b) => a + b, 0) + spacing * (chars.length - 1); + let at = cx - total / 2; + x.textAlign = "left"; + chars.forEach((c, i) => { + x.fillText(c, at, y); + at += widths[i] + spacing; + }); +} + +/** Cuts `text` with an ellipsis so it fits `maxW` in the current font. */ +function ellipsize(x: Ctx, text: string, maxW: number): string { + if (x.measureText(text).width <= maxW) return text; + let t = text; + while (t.length > 1 && x.measureText(`${t}…`).width > maxW) t = t.slice(0, -1); + return `${t}…`; +} + +function roundRect(x: Ctx, left: number, top: number, w: number, h: number, r: number) { + x.beginPath(); + x.moveTo(left + r, top); + x.arcTo(left + w, top, left + w, top + h, r); + x.arcTo(left + w, top + h, left, top + h, r); + x.arcTo(left, top + h, left, top, r); + x.arcTo(left, top, left + w, top, r); + x.closePath(); +} + +const BADGE_H = 66; +const BADGE_GAP = 16; + +function badge(x: Ctx, left: number, top: number, w: number, label: string, value: string) { + x.fillStyle = "rgba(255,255,255,0.05)"; + roundRect(x, left, top, w, BADGE_H, 18); + x.fill(); + const mid = top + BADGE_H / 2; + x.textBaseline = "middle"; + font(x, 400, 24); + x.fillStyle = DIM; + x.textAlign = "left"; + x.fillText(label, left + 32, mid); + const labelW = x.measureText(label).width; + font(x, 600, 24); + x.fillStyle = INK; + x.textAlign = "right"; + x.fillText(ellipsize(x, value, w - 64 - labelW - 16), left + w - 32, mid); +} + +/** A still Mochi, drawn by the same engine as the island's. */ +function drawMochi(x: Ctx, cx: number, top: number, size: number) { + const engine = new BotEngine(); + engine.setState("idle", true); + engine.update(1 / 60); + x.save(); + x.translate(cx - size / 2, top); + engine.draw(x, size, size); + x.restore(); +} + +export function renderShareImage(s: WeeklySummary, hideProjects: boolean): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.width = SHARE_W; + canvas.height = SHARE_H; + const x = canvas.getContext("2d"); + if (!x) return canvas; + const W = SHARE_W; + const cx = W / 2; + + // Background: near-black with an indigo glow low in the frame. + x.fillStyle = "#0B0C0E"; + x.fillRect(0, 0, W, SHARE_H); + const glow = x.createRadialGradient(cx, SHARE_H * 0.75, 0, cx, SHARE_H * 0.75, 900); + glow.addColorStop(0, "rgba(99,102,241,0.30)"); + glow.addColorStop(0.65, "rgba(99,102,241,0)"); + x.fillStyle = glow; + x.fillRect(0, 0, W, SHARE_H); + + // What goes in the badge list. + const badges: [string, string][] = []; + if (s.topAgent) badges.push([T.topAgent, s.topAgent]); + if (!hideProjects && s.topProject) badges.push([T.topProject, s.topProject]); + if (s.busiestDay) badges.push([T.busiestDay, s.busiestDay]); + if (s.longestSessionMinutes > 1) badges.push([T.longestSession, formatDuration(s.longestSessionMinutes)]); + const decisions = s.permissionsAllowed + s.permissionsDenied > 0; + const badgeRows = badges.length + (decisions ? 1 : 0); + const hasLines = s.linesAdded + s.linesRemoved > 0; + + // Heights of each block, so the whole stack can be centred like the VStack. + // Mochi's body fills ~60 % of the square the engine draws into. + const MOCHI = 220; + const MOCHI_DRAW = 320; + const blockH = + MOCHI + 20 + 62 + 6 + 36 + 14 + 29 + 80 + // Mochi, Coucou, title, range + 110 + 6 + 26 + // time + caption + 56 + 94 + // stat row + (hasLines ? 24 + 34 : 0) + + (badgeRows > 0 ? 56 + badgeRows * BADGE_H + (badgeRows - 1) * BADGE_GAP : 0); + const footerTop = SHARE_H - 60 - 24; + let y = Math.max(40, (footerTop - blockH) / 2); + + x.textBaseline = "top"; + + drawMochi(x, cx, y + MOCHI / 2 - MOCHI_DRAW / 2 - MOCHI_DRAW * 0.02, MOCHI_DRAW); + y += MOCHI + 20; + + x.fillStyle = INK; + x.textAlign = "center"; + font(x, 900, 52); + x.fillText("Coucou", cx, y); + y += 62 + 6; + + x.fillStyle = DIM; + font(x, 500, 30); + x.fillText(T.title, cx, y); + y += 36 + 14; + + x.fillStyle = INDIGO_TEXT; + font(x, 400, 24); + x.fillText(weekRangeLabel(s), cx, y); + y += 29 + 80; + + // Time — the headline number. + const time = formatDuration(s.totalMinutes); + x.fillStyle = INK; + fitFont(x, time, 900, 100, W - 120, 0.4); + x.textAlign = "center"; + x.fillText(time, cx, y); + y += 110 + 6; + x.fillStyle = DIM; + font(x, 600, 22); + spaced(x, T.timeCoding, cx, y, 3); + y += 26 + 56; + + // Sessions | files | commands + const stats: [string, string][] = [ + [formatCount(s.sessionCount), T.sessions], + [formatCount(s.filesChanged), T.files], + ]; + if (s.commandsRun > 0) stats.push([formatCount(s.commandsRun), T.commands]); + const rowLeft = 40; + const colW = (W - rowLeft * 2) / stats.length; + stats.forEach(([value, label], i) => { + const colCx = rowLeft + colW * (i + 0.5); + if (i > 0) { + x.fillStyle = "rgba(255,255,255,0.08)"; + x.fillRect(rowLeft + colW * i, y + 7, 1, 80); + } + x.fillStyle = INK; + fitFont(x, value, 900, 64, colW - 24, 0.5); + x.textAlign = "center"; + x.fillText(value, colCx, y); + x.fillStyle = DIM; + font(x, 600, 18); + spaced(x, label, colCx, y + 72, 2); + }); + y += 94; + + // Lines added / removed + if (hasLines) { + y += 24; + const plus = `+${formatCount(s.linesAdded)}`; + const minus = `−${formatCount(s.linesRemoved)}`; + font(x, 600, 28, MONO); + const pw = x.measureText(plus).width; + const mw = x.measureText(minus).width; + const start = cx - (pw + 24 + mw) / 2; + x.textAlign = "left"; + x.fillStyle = "#4ADE80"; + x.fillText(plus, start, y); + x.fillStyle = "#F87171"; + x.fillText(minus, start + pw + 24, y); + y += 34; + } + + // Badges + if (badgeRows > 0) { + y += 56; + const left = 60; + const w = W - left * 2; + for (const [label, value] of badges) { + badge(x, left, y, w, label, value); + y += BADGE_H + BADGE_GAP; + } + if (decisions) { + const half = (w - BADGE_GAP) / 2; + badge(x, left, y, half, T.approved, formatCount(s.permissionsAllowed)); + badge(x, left + half + BADGE_GAP, y, half, T.denied, formatCount(s.permissionsDenied)); + } + } + + // Footer + x.textBaseline = "top"; + x.textAlign = "center"; + x.fillStyle = "rgba(142,147,156,0.6)"; + font(x, 500, 20, MONO); + x.fillText(T.footer, cx, footerTop); + + return canvas; +} + +/** The canvas as a PNG data URL, for Rust to write to disk. */ +export function toPngDataUrl(canvas: HTMLCanvasElement): string { + return canvas.toDataURL("image/png"); +} + +/** The canvas as a PNG blob, for the clipboard. */ +export function toPngBlob(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve) => canvas.toBlob(resolve, "image/png")); +} diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..36b239dce 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -414,9 +414,55 @@ function generalSection(): HTMLElement { h("label", { text: "Launch at startup" }), toggle(settings.autostart, (v) => { settings.autostart = v; void save(); }), ), + ...recapRows(), ); } +/** Settings → General → Weekly recap. The prefs live with the history in Rust. */ +function recapRows(): HTMLElement[] { + const T = { + label: "Weekly recap", + keep: "Keep a history of my coding sessions", + clear: "Clear history", + cleared: "History cleared.", + about: "Counts and project names only — never commands, files or prompts. Kept on this computer for 12 weeks.", + }; + const feedback = h("span", { class: "hint" }); + const sw = toggle(true, (v) => { void Bridge.recapSetEnabled(v); }); + void Bridge.recapPrefs().then((prefs) => { + if (prefs) sw.classList.toggle("on", prefs.enabled); + }); + const clearBtn = h("button", { + class: "danger", + text: T.clear, + onclick: async () => { + clearBtn.disabled = true; + await Bridge.recapClear(); + feedback.textContent = T.cleared; + window.setTimeout(() => { + clearBtn.disabled = false; + feedback.textContent = ""; + }, 2400); + }, + }) as HTMLButtonElement; + return [ + h("div", { class: "row" }, + h("label", { text: T.label }), + sw, + h("span", { class: "hint", text: T.keep }), + ), + h("div", { class: "row" }, + h("label", {}), + clearBtn, + feedback, + ), + h("div", { class: "row" }, + h("label", {}), + h("span", { class: "hint", style: "flex:1 1 0;min-width:0", text: T.about }), + ), + ]; +} + // ── Boot ────────────────────────────────────────────────────────────────────── async function main() { diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c..76c95fb41 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -1365,3 +1365,109 @@ input[type="range"]::-webkit-slider-thumb { .int-card.detail { padding-top: 8px; } + +/* ── Weekly recap ───────────────────────────────────────────────────────── */ +/* WeeklyRecapCardView: text from x = 116, indigo wash, chips of 17 px values. */ + +.recap-stack { + padding: 4px 16px 4px 116px; + gap: 4px; +} + +.recap-head { + display: flex; + align-items: baseline; + gap: 6px; +} + +.recap-title { + font: 600 11px var(--font); + color: #818cf8; +} + +.recap-range { + font: 400 10px var(--font); + color: var(--dim-2); + white-space: nowrap; +} + +.recap-chips { + display: flex; + gap: 14px; + white-space: nowrap; +} + +.recap-chip .v { + font: 700 17px var(--font); + color: var(--ink-2); +} + +.recap-chip .l { + font: 400 9px var(--font); + color: var(--dim-2); +} + +.recap-meta { + display: flex; + gap: 5px; + font: 400 11px var(--font); + color: var(--dim-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.recap-meta .n { + color: var(--ink-2); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; +} + +.recap-meta .sep { + color: var(--dim-4); +} + +.recap-stack .actions { + margin-top: 3px; +} + +.recap-share { + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 16px; + padding: 8px 16px 8px 116px; +} + +.recap-thumb { + flex: 0 0 64px; + width: 64px; + height: 114px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.recap-share-side { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.recap-status { + display: flex; + align-items: baseline; + gap: 8px; + min-height: 14px; + font: 400 11px var(--font); + color: var(--dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.recap-link { + color: #818cf8; + padding: 0; +} diff --git a/windows/src/views/recap.ts b/windows/src/views/recap.ts new file mode 100644 index 000000000..7f0a8f168 --- /dev/null +++ b/windows/src/views/recap.ts @@ -0,0 +1,236 @@ +// Weekly recap card and its share panel — port of WeeklyRecapCardView and +// RecapSharePanelView (WeeklyRecapView.swift). The Mac shares from a separate +// panel; here the card flips to a share mode inside the island, because a +// window created after launch comes up blank in WebView2 (see lib.rs). +// +// Nothing animates in this view: the image is drawn once, when share mode +// opens or the privacy toggle changes. + +import { h, clear } from "./dom"; +import { Bridge } from "../core/bridge"; +import { washRGBA } from "../core/layout"; +import { Recap } from "../recap/recap"; +import { renderShareImage, toPngBlob, toPngDataUrl } from "../recap/share"; +import { + dayKey, formatCount, formatDuration, weekRangeLabel, type WeeklySummary, +} from "../recap/summary"; +import type { ViewActions, ViewHost } from "./views"; + +/** User-visible strings, in one place for the translation layer. */ +const T = { + title: "Weekly recap", + noActivity: "No activity last week", + noActivitySub: "Coucou counts your agent sessions as they happen — check back next Monday.", + coding: "coding", + session: "session", + sessions: "sessions", + file: "file", + files: "files", + lines: "lines", + commands: "commands", + topAgent: "Top agent", + topProject: "Top project", + busiestDay: "Busiest day", + longest: "Longest session", + permissions: "Permissions", + allowedDenied: (a: number, d: number) => `${a} allowed, ${d} denied`, + questions: "Questions", + shareImage: "Share image", + ok: "OK", + shareTitle: "Share your week", + hideProjects: "Hide project names", + save: "Save image", + copy: "Copy", + back: "Back", + saving: "Saving…", + savedAs: (name: string) => `Saved as ${name}`, + showInFolder: "Show in folder", + copied: "Copied — paste it anywhere.", + copyUnavailable: "Copying images isn't available here — use Save image.", +}; + +function btn(label: string, kind: "primary" | "secondary", onClick: () => void): HTMLButtonElement { + return h("button", { class: `btn ${kind}`, onclick: onClick }, h("span", { text: label })); +} + +function chip(value: string, label: string): HTMLElement { + return h("div", { class: "recap-chip" }, + h("div", { class: "v", text: value }), + h("div", { class: "l", text: label }), + ); +} + +/** "Label value · Label value" — values in white, like the who-row. */ +function metaLine(pairs: [string, string][]): HTMLElement | null { + if (pairs.length === 0) return null; + const el = h("div", { class: "recap-meta" }); + pairs.forEach(([label, value], i) => { + if (i > 0) el.append(h("span", { class: "sep", text: "·" })); + el.append(h("span", { text: label }), h("span", { class: "n", text: value })); + }); + return el; +} + +const plural = (n: number, one: string, many: string) => (n === 1 ? one : many); + +export function buildRecap(actions: ViewActions): ViewHost { + const card = h("div", { class: "card wash recap-card" }); + card.style.setProperty("--wash", washRGBA("indigo")); + const el = h("div", { class: "view" }, card); + + let built = -1; + let shareCanvas: HTMLCanvasElement | null = null; + + function summaryBody(s: WeeklySummary): HTMLElement { + const chips = h("div", { class: "recap-chips" }, + chip(formatDuration(s.totalMinutes), T.coding), + chip(formatCount(s.sessionCount), plural(s.sessionCount, T.session, T.sessions)), + chip(formatCount(s.filesChanged), plural(s.filesChanged, T.file, T.files)), + ); + if (s.linesAdded + s.linesRemoved > 0) { + chips.append(chip(`+${formatCount(s.linesAdded)} / −${formatCount(s.linesRemoved)}`, T.lines)); + } + if (s.commandsRun > 0) chips.append(chip(formatCount(s.commandsRun), T.commands)); + + const first: [string, string][] = []; + if (s.topAgent) first.push([T.topAgent, s.topAgent]); + if (s.topProject) first.push([T.topProject, s.topProject]); + if (s.busiestDay) first.push([T.busiestDay, s.busiestDay]); + const second: [string, string][] = []; + if (s.longestSessionMinutes > 1) second.push([T.longest, formatDuration(s.longestSessionMinutes)]); + if (s.permissionsAllowed + s.permissionsDenied > 0) { + second.push([T.permissions, T.allowedDenied(s.permissionsAllowed, s.permissionsDenied)]); + } + if (s.questions > 0) second.push([T.questions, formatCount(s.questions)]); + + return h("div", { class: "stack recap-stack" }, + h("div", { class: "recap-head" }, + h("span", { class: "recap-title", text: T.title }), + h("span", { class: "grow" }), + h("span", { class: "recap-range", text: weekRangeLabel(s) }), + ), + chips, + metaLine(first), + metaLine(second), + h("div", { class: "actions" }, + btn(T.shareImage, "primary", () => showShare(s)), + btn(T.ok, "secondary", () => actions.collapse()), + ), + ); + } + + function emptyBody(): HTMLElement { + return h("div", { class: "stack recap-stack" }, + h("div", { class: "title", text: T.noActivity }), + h("div", { class: "sub", text: T.noActivitySub }), + h("div", { class: "actions" }, btn(T.ok, "secondary", () => actions.collapse())), + ); + } + + function showSummary() { + clear(card); + card.append(Recap.summary ? summaryBody(Recap.summary) : emptyBody()); + } + + function showShare(s: WeeklySummary) { + const thumb = h("canvas", { class: "recap-thumb" }); + const status = h("div", { class: "recap-status" }); + const sw = h("button", { class: Recap.prefs.hideProjects ? "switch on" : "switch" }); + + const draw = () => { + shareCanvas = renderShareImage(s, Recap.prefs.hideProjects); + const dpr = Math.min(2, window.devicePixelRatio || 1); + thumb.width = Math.round(64 * dpr); + thumb.height = Math.round(114 * dpr); + const ctx = thumb.getContext("2d"); + if (ctx) { + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(shareCanvas, 0, 0, thumb.width, thumb.height); + } + }; + + const say = (text: string, extra?: HTMLElement) => { + clear(status); + status.append(h("span", { text })); + if (extra) status.append(extra); + }; + + sw.addEventListener("click", () => { + const next = !Recap.prefs.hideProjects; + sw.classList.toggle("on", next); + void Recap.setHideProjects(next); + draw(); + clear(status); + }); + + const save = btn(T.save, "primary", async () => { + if (!shareCanvas) return; + save.disabled = true; + say(T.saving); + try { + const path = await Bridge.recapSavePng(toPngDataUrl(shareCanvas), dayKey(s.weekStart)); + const name = path.split(/[\\/]/).pop() ?? path; + say(T.savedAs(name), h("button", { + class: "link-btn recap-link", + text: T.showInFolder, + onclick: () => void Bridge.recapRevealSaved(), + })); + } catch (err) { + say(String(err).replace(/^Error:\s*/, "")); + } finally { + save.disabled = false; + } + }); + + const copy = btn(T.copy, "secondary", async () => { + if (!shareCanvas) return; + say(await copyImage(shareCanvas) ? T.copied : T.copyUnavailable); + }); + + clear(card); + card.append(h("div", { class: "stack recap-share" }, + thumb, + h("div", { class: "recap-share-side" }, + h("div", { class: "recap-title", text: T.shareTitle }), + h("div", { class: "settings-row" }, sw, h("span", { text: T.hideProjects })), + h("div", { class: "actions" }, save, copy, btn(T.back, "secondary", showSummary)), + status, + ), + )); + draw(); + } + + return { + el, + sync() { + // Rebuilt only when the recap was (re)loaded, never on every state change: + // that would reset share mode under the user's cursor. + if (built === Recap.version) return; + built = Recap.version; + shareCanvas = null; + showSummary(); + }, + }; +} + +/** + * Puts the PNG on the clipboard through the web Clipboard API — no new + * dependency. It needs the document focused, and the island never takes focus + * on its own, so it is lent focus for the duration. WebKitGTK builds without + * image clipboard support say no, and the caller offers Save instead. + */ +async function copyImage(canvas: HTMLCanvasElement): Promise { + const Item = (window as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem; + if (!Item || !navigator.clipboard?.write) return false; + await Bridge.focusWindow(true); + try { + const blob = await toPngBlob(canvas); + if (!blob) return false; + await navigator.clipboard.write([new Item({ "image/png": blob })]); + return true; + } catch { + return false; + } finally { + void Bridge.focusWindow(false); + } +} diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f..8fdaa1177 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -11,6 +11,7 @@ import { createMiniBot, pruneMiniBots } from "../mochi/minibots"; import { buildPrompt } from "./chat"; import { buildChoose, buildUpload, buildUploading } from "./upload"; import { renderIntegrationCard, type IntegrationCardHooks } from "./integrations"; +import { buildRecap } from "./recap"; export interface ViewActions { setView(v: IslandViewName): void; @@ -501,6 +502,7 @@ export function buildViews( map.set("upload", buildUpload()); map.set("uploading", buildUploading()); map.set("choose", buildChoose(actions)); + map.set("recap", buildRecap(actions)); // Not in the Windows v1: sending a file by email, window attach + web result. map.set("mail", buildPlaceholder("Sending by email isn't in this version.", "")); map.set("searching", buildPlaceholder("Claude is searching…", "")); diff --git a/windows/tests/recap-trigger.test.mjs b/windows/tests/recap-trigger.test.mjs new file mode 100644 index 000000000..efa3eff76 --- /dev/null +++ b/windows/tests/recap-trigger.test.mjs @@ -0,0 +1,103 @@ +// When the weekly recap opens on its own (src/recap/recap.ts), driven through +// the real bridge with Rust's answers stubbed. + +import { afterEach, beforeEach, mock, test } from "node:test"; +import assert from "node:assert/strict"; +import { calls, internals, sent } from "./tauri.mjs"; +import { Recap } from "../src/recap/recap.ts"; +import { sampleHistory, previousWeekStart } from "../src/recap/summary.ts"; +import { State } from "../src/core/state.ts"; + +const MONDAY_9AM = new Date(2026, 9, 5, 9, 0); +const original = internals.invoke; + +let prefs; +let history; +let shown; +const host = { alert: (view) => shown.push(view) }; + +beforeEach(() => { + mock.timers.enable({ apis: ["setTimeout"] }); + calls.length = 0; + shown = []; + prefs = { enabled: true, hideProjects: false, lastShownWeek: "" }; + history = sampleHistory(previousWeekStart(MONDAY_9AM)); + internals.invoke = async (cmd, args) => { + if (cmd.startsWith("recap_") && cmd !== "recap_mark_shown") calls.push([cmd, args]); + if (cmd === "recap_prefs") return prefs; + if (cmd === "recap_history") return { ...history, prefs }; + return original(cmd, args); + }; + State.mode = "hidden"; + State.view = "overview"; + State.paused = false; + State.pendingApproval = null; +}); + +afterEach(() => { + internals.invoke = original; + mock.timers.reset(); +}); + +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +test("on Monday morning the recap opens once, after a short delay", async () => { + await Recap.check(host, MONDAY_9AM); + assert.deepEqual(shown, []); + mock.timers.tick(1500); + assert.deepEqual(shown, ["recap"]); + assert.deepEqual(sent("recap_mark_shown"), [{ week: "2026-10-05" }]); + assert.equal(Recap.summary.sessionCount, 6); + // The history was asked from last Monday on. + assert.equal(sent("recap_history")[0].since, previousWeekStart(MONDAY_9AM).getTime() / 1000); +}); + +test("not twice in the same week, not on Tuesday, not before 8", async () => { + prefs.lastShownWeek = "2026-10-05"; + await Recap.check(host, MONDAY_9AM); + prefs.lastShownWeek = ""; + await Recap.check(host, new Date(2026, 9, 6, 9)); + await Recap.check(host, new Date(2026, 9, 5, 7, 30)); + mock.timers.tick(5000); + assert.deepEqual(shown, []); +}); + +test("an empty week or a disabled history shows nothing", async () => { + history = { turns: [], decisions: [], prefs }; + await Recap.check(host, MONDAY_9AM); + prefs = { ...prefs, enabled: false }; + history = sampleHistory(previousWeekStart(MONDAY_9AM)); + await Recap.check(host, MONDAY_9AM); + mock.timers.tick(5000); + assert.deepEqual(shown, []); + assert.deepEqual(sent("recap_mark_shown"), []); +}); + +test("it never covers a permission request or a chat in progress", async () => { + State.pendingApproval = { requestId: "r", sessionId: "s", tool: "Bash", command: "ls" }; + await Recap.check(host, MONDAY_9AM); + State.pendingApproval = null; + State.mode = "expanded"; + State.view = "prompt"; + await Recap.check(host, MONDAY_9AM); + mock.timers.tick(5000); + assert.deepEqual(shown, []); + + // A request arriving during the delay wins too. + State.mode = "hidden"; + await Recap.check(host, MONDAY_9AM); + State.pendingApproval = { requestId: "r", sessionId: "s", tool: "Bash", command: "ls" }; + mock.timers.tick(1500); + assert.deepEqual(shown, []); + assert.deepEqual(sent("recap_mark_shown"), []); +}); + +test("the tray opens it any day, even for an empty week", async () => { + history = { turns: [], decisions: [], prefs }; + const before = Recap.version; + await Recap.open(host); + await settle(); + assert.deepEqual(shown, ["recap"]); + assert.equal(Recap.summary, null); + assert.equal(Recap.version, before + 1); +}); From dd16085e6240dd44019b238cf191b958a493f7d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:20:49 +0200 Subject: [PATCH 33/77] Windows README: the weekly recap, and how it differs from the Mac Co-Authored-By: Claude Opus 5.5 --- windows/README.md | 47 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/windows/README.md b/windows/README.md index 2b897bd3d..02f7ffacf 100644 --- a/windows/README.md +++ b/windows/README.md @@ -45,7 +45,7 @@ installs for the current user only — no admin prompt. | Rest the pointer on Mochi for two seconds | Hearts | | Drag a file onto the island | Mochi turns into a box, swallows it, then offers to answer questions about it | | `Esc` | Closes the island | -| Tray icon | Open, Settings…, Pause, Quit | +| Tray icon | Open, Weekly recap, Settings…, Pause, Quit | Everything else happens on its own: a Claude Code permission request opens the island with **Deny / Allow**, a finished session shows what it did, and @@ -68,6 +68,24 @@ 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. +## Weekly recap + +On Monday from 8 am, the first time Coucou starts, an agent starts working or +you wake the island, a card sums up the past week: time spent, sessions, files +and lines changed, commands run, permissions and questions, your top agent, +top project, busiest day and longest session. **Tray → Weekly recap** opens it +any day. + +**Share image** turns it into a 1080 × 1920 picture with Mochi. **Save image** +writes it to your Pictures folder (Downloads if there is none) as +`Coucou weekly recap YYYY-MM-DD.png`, never over an existing file; **Copy** puts +it on the clipboard. **Hide project names** leaves the project out of the image. + +The history is `recap.json` next to the log, a 12-week rolling window: counts, +the agent and the project folder's name — never a command, a file path, file +contents or a prompt. It never leaves your machine. **Settings → General → +Weekly recap** turns it off or clears it. + ## Chat and keys **Settings… → Claude** takes your Anthropic API key. Keys live in the **Windows @@ -93,7 +111,9 @@ npm run pack # builds the installer and drops it in windows/release/ `npm run dev` alone serves the front end in an ordinary browser, which is enough to work on the island's looks. It also serves `dev/upload-preview.html`, which replays the whole file-drop choreography on a loop — the one part of the UI that -otherwise needs a real drag from Explorer to see. Neither page ships in the app. +otherwise needs a real drag from Explorer to see — and `dev/recap-preview.html`, +the weekly recap card and its shared image on a sample week. None of these pages +ships in the app. `npm run pack` leaves two files in `windows/release/`, the same names the release workflow publishes: @@ -134,7 +154,8 @@ windows/ ### Log `%LOCALAPPDATA%\Coucou\coucou.log` — hook events, permission decisions, poller -problems. It stays on your machine. +problems. It stays on your machine. The weekly recap's history sits beside it in +`recap.json`. ## Supported agents @@ -163,6 +184,20 @@ OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integr attach it as context, and jumping to a specific terminal window — "Open terminal" opens the working folder in VS Code when `code` is on your `PATH`. - Cal.com shows the next bookings as a list rather than the Mac's calendar. +- Weekly recap: + - Sharing happens inside the island instead of a separate panel, and Save + writes straight into Pictures (or Downloads) instead of asking where. There + is no system Share sheet; Copy uses the web clipboard. + - Questions are counted when an agent asks one (`AskUserQuestion`), not when + it is answered from the island, which can't answer questions yet. + - There is no sleep/wake notification to listen to without a background + loop, so after the machine wakes the Monday card waits for the first agent + to start or for you to hover the island. + - The card is 24 px taller than the Mac's: it also lists top agent, project, + busiest day, longest session, permissions and questions, which the Mac + leaves to the image. A turn cut short by the session ending still counts. + - Tray → Pause doesn't stop the history (it stays on the machine anyway); + switch it off in Settings → General. ## Linux @@ -194,7 +229,11 @@ What changes on Linux: runs as the same user. - **Keys** live in the Secret Service (GNOME Keyring, KWallet). - **Files**: preferences in `~/.config/coucou/`, the log at - `~/.local/share/coucou/coucou.log`. + `~/.local/share/coucou/coucou.log`, the weekly recap history beside it in + `recap.json`. A saved recap image goes to the pictures folder named in + `~/.config/user-dirs.dirs`, else `~/Pictures`, else `~/Downloads`. +- **Copying the recap image** needs a WebKitGTK with image clipboard support; + where it is missing, the island says so and Save still works. - What the Windows build leaves out, this one does too: sending a file by email, dragging Mochi onto a window, and jumping to a specific terminal window — "Open terminal" opens the folder in VS Code. From b2ad2130fdfb64c60ba7c41ede10240c2d8cbfe1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:23:41 +0200 Subject: [PATCH 34/77] Chat: one conversation for every provider, full Claude answers, first name Groundwork for the Mac's chat providers on Windows and Linux, with the Claude chat brought to parity first: - chat.rs holds the conversation for every provider: in the wire format of the provider that answered last, and as plain text turns. A turn with another provider rebuilds its history from the plain turns, so Claude's web search blocks are never sent anywhere else. Only a successful turn is recorded, and an answer that lands after "New chat" is dropped. - Claude's answer keeps every text block, joined as is (Mac #67, commit 0a633f9): citations no longer get a line break in mid-sentence. - The system prompt asks for light Markdown, as on the Mac, and greets the user by their first name when the account has one worth using (Mac #154, same rules as UserIdentity.swift): the Windows display name or the Linux GECOS full name; login handles are ignored. - COUCOU_ANTHROPIC_BASE_URL sends the Claude chat through an Anthropic-compatible gateway (issue #180). It is a base URL (/v1/messages is added when missing), must be https (http only to this computer), and only that variable is read: Claude Code's ANTHROPIC_BASE_URL could hand the user's key to a proxy they never chose. The gateway's host, never the key, is logged once. - Every answer is read with a size ceiling (net.rs), and requests to this computer skip any system proxy. - chat_models lists the models on the Anthropic account, for the picker. Based on #206 by @Totopo27 (custom Anthropic base URL), reduced to COUCOU_ANTHROPIC_BASE_URL and tested without touching the environment. Co-authored-by: Totopo27 <35828647+Totopo27@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/chat.rs | 289 +++++++++++++++++ windows/src-tauri/src/claude.rs | 360 ++++++++++++++-------- windows/src-tauri/src/identity.rs | 67 ++++ windows/src-tauri/src/lib.rs | 18 +- windows/src-tauri/src/net.rs | 283 +++++++++++++++++ windows/src-tauri/src/platform/linux.rs | 19 ++ windows/src-tauri/src/platform/windows.rs | 60 ++++ 7 files changed, 968 insertions(+), 128 deletions(-) create mode 100644 windows/src-tauri/src/chat.rs create mode 100644 windows/src-tauri/src/identity.rs create mode 100644 windows/src-tauri/src/net.rs diff --git a/windows/src-tauri/src/chat.rs b/windows/src-tauri/src/chat.rs new file mode 100644 index 000000000..9f1be2b72 --- /dev/null +++ b/windows/src-tauri/src/chat.rs @@ -0,0 +1,289 @@ +// The chat, whoever answers it: the conversation, the system prompt, and which +// provider a turn goes to. Each provider's own wire format lives in its module +// (claude.rs for Anthropic). +// +// API keys never leave the credential store and file bytes never cross the IPC +// boundary: the island sends the question and gets the answer's text back. + +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tauri::AppHandle; + +use crate::settings::Settings; +use crate::{claude, secrets}; + +pub const ANTHROPIC: &str = "anthropic"; + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum ChatContext { + File { name: String, path: String }, + Window { app_name: String, title: String, url: Option }, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatReply { + pub text: String, +} + +/// A model a provider offers, for the picker in the chat view. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelInfo { + pub id: String, + pub label: String, +} + +// ── Conversation ────────────────────────────────────────────────────────────── + +/// The conversation is kept twice: in the wire format of the provider that +/// answered last (Claude's carries web search blocks no other provider would +/// understand), and as plain text turns. Switching provider mid-conversation +/// rebuilds the history from the plain turns, so nothing in one provider's +/// format is ever sent to another. +#[derive(Default)] +pub struct Chat { + inner: Mutex, +} + +#[derive(Default)] +struct Conversation { + /// Bumped by every reset, so an answer that lands after "New chat" is dropped. + epoch: u64, + /// The provider `native` belongs to. + owner: Option, + native: Vec, + /// `{"role", "content": text}` turns, the same whoever answered. + plain: Vec, +} + +/// What a provider needs to build one turn. +pub struct Turn { + epoch: u64, + provider: String, + /// No earlier turn: the file or window context rides along with this one. + pub first: bool, + /// The earlier turns, in this provider's format. + pub history: Vec, +} + +impl Chat { + pub fn reset(&self) { + let mut c = self.inner.lock().unwrap(); + let epoch = c.epoch + 1; + *c = Conversation { epoch, ..Default::default() }; + } + + /// Starts a turn with `provider`, converting the history if another + /// provider answered the previous turns. + pub fn begin(&self, provider: &str) -> Turn { + let mut c = self.inner.lock().unwrap(); + if c.owner.as_deref() != Some(provider) { + c.native = c.plain.clone(); + c.owner = Some(provider.to_string()); + } + Turn { + epoch: c.epoch, + provider: provider.to_string(), + first: c.plain.is_empty(), + history: c.native.clone(), + } + } + + /// Records a finished turn: the user message and the answer in the + /// provider's format, and their plain text. Only a successful turn is + /// recorded, so the history always matches what the model saw. + pub fn commit(&self, turn: &Turn, user: Value, assistant: Value, user_text: &str, answer: &str) { + let mut c = self.inner.lock().unwrap(); + if c.epoch != turn.epoch || c.owner.as_deref() != Some(turn.provider.as_str()) { + return; + } + c.native.push(user); + c.native.push(assistant); + c.plain.push(json!({ "role": "user", "content": user_text })); + c.plain.push(json!({ "role": "assistant", "content": answer })); + } +} + +// ── System prompt ───────────────────────────────────────────────────────────── + +/// Mochi's instructions. Greets the user by their first name when the account +/// has one worth using (identity.rs), and only claims web search where the +/// provider runs it (Claude). +pub fn system_prompt(web_search: bool) -> String { + system_prompt_for(crate::identity::first_name(), web_search) +} + +fn system_prompt_for(first_name: Option<&str>, web_search: bool) -> String { + let opening = match first_name { + Some(name) => format!("You are Mochi, {name}'s personal AI assistant living at the top of their screen."), + None => "You are Mochi, a personal AI assistant living at the top of the user's screen.".to_string(), + }; + let abilities = if web_search { + "You have web search access and can help with absolutely anything — research, coding, finding places, recommendations, tasks, questions." + } else { + "You can help with absolutely anything — research, coding, recommendations, tasks, questions. You have no web access: say so when something needs current information." + }; + format!( + "{opening} {abilities} \ +Respond in the user's language. Be thorough and complete — use as much detail as the task requires. \ +Use light Markdown when it helps: short paragraphs, bullet lists, **bold**, `inline code` and fenced code blocks. Avoid tables and big headings: the chat window is small." + ) +} + +/// The window context line, as ClaudeService.chat() writes it. +pub fn window_line(app_name: &str, title: &str, url: Option<&str>) -> String { + let mut text = format!("Context — App: {app_name}, Window: {title}"); + if let Some(url) = url { + text.push_str(&format!(", URL: {url}")); + } + text +} + +/// The plain-text record of what the user asked, context included. +pub fn plain_question(first: bool, context: Option<&ChatContext>, query: &str) -> String { + match context.filter(|_| first) { + Some(ChatContext::File { name, .. }) => format!("File: {name}\n\n{query}"), + Some(ChatContext::Window { app_name, title, url }) => { + format!("{}\n\n{query}", window_line(app_name, title, url.as_deref())) + } + None => query.to_string(), + } +} + +// ── Which provider ──────────────────────────────────────────────────────────── + +/// The model chosen for `provider`, or its default. +pub fn model_for(settings: &Settings, _provider: &str) -> String { + let m = settings.model.trim(); + if m.is_empty() { claude::DEFAULT_MODEL.to_string() } else { m.to_string() } +} + +/// One chat turn with the provider chosen in the settings. +pub async fn send( + app: &AppHandle, + chat: &Chat, + settings: &Settings, + query: String, + context: Option, +) -> Result { + let _ = app; + let model = model_for(settings, ANTHROPIC); + claude::send(chat, &model, query, context).await +} + +/// The models `provider` offers. Asked only when the user opens the picker on +/// that provider, and only once it has a key (or, for a local server, an +/// address): nothing is sent anywhere before that. +pub async fn models(settings: &Settings, provider: &str) -> Result, String> { + const NO_KEY: &str = "No API key — add it in Settings."; + let _ = settings; + if provider == ANTHROPIC { + let key = secrets::get(claude::KEY).ok_or(NO_KEY)?; + return claude::models(&key).await; + } + Err(format!("Unknown chat provider: {provider}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn turn_texts(history: &[Value]) -> Vec<(String, Value)> { + history + .iter() + .map(|m| (m["role"].as_str().unwrap().to_string(), m["content"].clone())) + .collect() + } + + #[test] + fn a_turn_is_recorded_only_once_it_succeeds() { + let chat = Chat::default(); + let t = chat.begin("anthropic"); + assert!(t.first); + assert!(t.history.is_empty()); + // A failed turn records nothing: the next one is still the first. + let t = chat.begin("anthropic"); + assert!(t.first); + chat.commit(&t, json!({"role":"user","content":[{"type":"text","text":"hi"}]}), json!({"role":"assistant","content":[{"type":"text","text":"hello"}]}), "hi", "hello"); + let t = chat.begin("anthropic"); + assert!(!t.first); + assert_eq!(t.history.len(), 2); + assert!(t.history[0]["content"].is_array()); + } + + #[test] + fn switching_provider_never_sends_claude_blocks_to_another_one() { + let chat = Chat::default(); + let t = chat.begin("anthropic"); + let blocks = json!([ + {"type":"server_tool_use","id":"srvtoolu_1","name":"web_search","input":{"query":"x"}}, + {"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":[]}, + {"type":"text","text":"Found it."} + ]); + chat.commit(&t, json!({"role":"user","content":[{"type":"text","text":"look"}]}), json!({"role":"assistant","content":blocks}), "look", "Found it."); + + let t = chat.begin("openai"); + assert!(!t.first); + assert_eq!( + turn_texts(&t.history), + vec![("user".into(), json!("look")), ("assistant".into(), json!("Found it."))] + ); + let raw = serde_json::to_string(&t.history).unwrap(); + assert!(!raw.contains("web_search"), "{raw}"); + + // And back: Claude gets the plain turns too, including OpenAI's answer. + chat.commit(&t, json!({"role":"user","content":"more"}), json!({"role":"assistant","content":"Sure."}), "more", "Sure."); + let t = chat.begin("anthropic"); + assert_eq!(t.history.len(), 4); + assert_eq!(t.history[3], json!({"role":"assistant","content":"Sure."})); + assert!(!serde_json::to_string(&t.history).unwrap().contains("web_search")); + } + + #[test] + fn an_answer_that_lands_after_a_reset_or_a_switch_is_dropped() { + let chat = Chat::default(); + let t = chat.begin("openai"); + chat.reset(); + chat.commit(&t, json!({"role":"user","content":"q"}), json!({"role":"assistant","content":"a"}), "q", "a"); + assert!(chat.begin("openai").first); + + let t = chat.begin("openai"); + let _other = chat.begin("google"); + chat.commit(&t, json!({"role":"user","content":"q"}), json!({"role":"assistant","content":"a"}), "q", "a"); + assert!(chat.begin("google").first); + } + + #[test] + fn the_prompt_greets_by_first_name_and_claims_web_search_only_for_claude() { + let p = system_prompt_for(Some("Louis"), true); + assert!(p.starts_with("You are Mochi, Louis's personal AI assistant living at the top of their screen.")); + assert!(p.contains("web search access")); + assert!(p.contains("light Markdown")); + let p = system_prompt_for(None, false); + assert!(p.starts_with("You are Mochi, a personal AI assistant living at the top of the user's screen.")); + assert!(!p.contains("web search")); + assert!(p.contains("no web access")); + } + + #[test] + fn context_goes_with_the_first_question_only() { + let file = ChatContext::File { name: "a.txt".into(), path: "/x/a.txt".into() }; + assert_eq!(plain_question(true, Some(&file), "why?"), "File: a.txt\n\nwhy?"); + assert_eq!(plain_question(false, Some(&file), "why?"), "why?"); + let win = ChatContext::Window { app_name: "Code".into(), title: "main.rs".into(), url: None }; + assert_eq!(plain_question(true, Some(&win), "q"), "Context — App: Code, Window: main.rs\n\nq"); + assert_eq!(window_line("Edge", "Docs", Some("https://x.dev")), "Context — App: Edge, Window: Docs, URL: https://x.dev"); + } + + #[test] + fn the_model_comes_from_the_settings_or_the_default() { + let mut s = Settings::default(); + assert_eq!(model_for(&s, "anthropic"), claude::DEFAULT_MODEL); + s.model = "claude-haiku-4-5".into(); + assert_eq!(model_for(&s, "anthropic"), "claude-haiku-4-5"); + } +} diff --git a/windows/src-tauri/src/claude.rs b/windows/src-tauri/src/claude.rs index 060a57c64..2080347a6 100644 --- a/windows/src-tauri/src/claude.rs +++ b/windows/src-tauri/src/claude.rs @@ -2,16 +2,25 @@ // chat with web search, and files sent as document/image/text blocks. // // Everything happens here rather than in the island: the API key never leaves -// the Credential Manager, and file bytes never cross the IPC boundary. +// the credential store, and file bytes never cross the IPC boundary. +// +// COUCOU_ANTHROPIC_BASE_URL points the chat at an Anthropic-compatible gateway +// instead (issue #180). Only that variable is read: Claude Code's own +// ANTHROPIC_BASE_URL may name a proxy the user never meant to hand this key to. -use std::sync::Mutex; +use std::sync::OnceLock; +use std::time::Duration; -use serde::{Deserialize, Serialize}; +use reqwest::Url; use serde_json::{json, Value}; -use crate::secrets; +use crate::chat::{self, Chat, ChatContext, ChatReply, ModelInfo}; +use crate::{net, secrets}; -const ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; +/// Credential store entry of the Anthropic API key. +pub const KEY: &str = "anthropic-api-key"; +const DEFAULT_ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; +const BASE_URL_VAR: &str = "COUCOU_ANTHROPIC_BASE_URL"; const ANTHROPIC_VERSION: &str = "2023-06-01"; /// Server-side fallback: on a policy decline the API retries the same request on /// a fallback model inside the same call, so the island never shows a dead end. @@ -22,69 +31,45 @@ const MAX_INLINE_TEXT: u64 = 200_000; pub const DEFAULT_MODEL: &str = "claude-opus-5"; -const SYSTEM_PROMPT: &str = "You are Mochi, a personal AI assistant living at the top of the user's screen. \ -You have web search access and can help with absolutely anything — research, coding, finding places, recommendations, tasks, questions. \ -Respond in the user's language. Be thorough and complete — use as much detail as the task requires. \ -No markdown formatting (no **, no ##, no bullet dashes). Use plain text with line breaks."; - -#[derive(Default)] -pub struct Chat { - /// Full multi-turn history, including tool_use / tool_result blocks. - messages: Mutex>, -} - -impl Chat { - pub fn reset(&self) { - self.messages.lock().unwrap().clear(); - } - - fn is_empty(&self) -> bool { - self.messages.lock().unwrap().is_empty() - } - - fn push(&self, message: Value) { - self.messages.lock().unwrap().push(message); - } - - fn pop(&self) { - self.messages.lock().unwrap().pop(); - } - - fn snapshot(&self) -> Vec { - self.messages.lock().unwrap().clone() - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum ChatContext { - File { name: String, path: String }, - Window { app_name: String, title: String, url: Option }, +/// The Messages endpoint: Anthropic's, or the gateway in COUCOU_ANTHROPIC_BASE_URL. +/// Read once; the gateway's host (never the key) goes to the log once. +fn endpoint() -> Result { + static ENDPOINT: OnceLock> = OnceLock::new(); + ENDPOINT + .get_or_init(|| { + let raw = std::env::var(BASE_URL_VAR).ok().filter(|v| !v.trim().is_empty()); + let resolved = match raw { + None => Ok(Url::parse(DEFAULT_ENDPOINT).expect("valid default endpoint")), + Some(raw) => net::anthropic_endpoint(&raw), + }; + match &resolved { + Ok(url) if url.as_str() != DEFAULT_ENDPOINT => crate::log::line(format!( + "chat: Claude requests go to {} ({BASE_URL_VAR})", + net::host_for_log(url) + )), + Err(err) => crate::log::line(format!("chat: {err}")), + _ => {} + } + resolved + }) + .clone() } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ChatReply { - pub text: String, +/// The model list endpoint next to the Messages one. +fn models_endpoint(messages: &Url) -> Url { + let mut url = messages.clone(); + let path = url.path().trim_end_matches("/messages").to_string(); + url.set_path(&format!("{path}/models")); + url.set_query(Some("limit=100")); + url } -/// One chat turn. Returns the assistant's text, or a message the island shows -/// in the note view. -pub async fn send( - chat: &Chat, - model: &str, - query: String, - context: Option, -) -> Result { - let key = secrets::get("anthropic-api-key") - .ok_or_else(|| "API key missing. Open settings.".to_string())?; - +/// The user's content blocks for one turn. File / window context rides along +/// with the first message only, exactly like ClaudeService.chat(). +fn user_content(first: bool, context: Option<&ChatContext>, query: &str) -> Vec { let mut content: Vec = Vec::new(); - - // File / window context rides along with the first message only, exactly - // like ClaudeService.chat(). - if chat.is_empty() { - match &context { + if first { + match context { Some(ChatContext::File { name, path }) => { if let Some(block) = file_block(path) { content.push(block); @@ -92,79 +77,90 @@ pub async fn send( content.push(json!({ "type": "text", "text": format!("File: {name}") })); } Some(ChatContext::Window { app_name, title, url }) => { - let mut text = format!("Context — App: {app_name}, Window: {title}"); - if let Some(url) = url { - text.push_str(&format!(", URL: {url}")); - } - content.push(json!({ "type": "text", "text": text })); + let line = chat::window_line(app_name, title, url.as_deref()); + content.push(json!({ "type": "text", "text": line })); } None => {} } } content.push(json!({ "type": "text", "text": query })); + content +} - chat.push(json!({ "role": "user", "content": content })); - - let body = json!({ +fn request_body(model: &str, system: &str, history: &[Value], user: &Value) -> Value { + let mut messages = history.to_vec(); + messages.push(user.clone()); + json!({ "model": model, "max_tokens": MAX_TOKENS, - "system": SYSTEM_PROMPT, + "system": system, "tools": [{ "type": "web_search_20260209", "name": "web_search", "max_uses": 5 }], "fallbacks": "default", - "messages": chat.snapshot(), - }); - - let response = match call(&key, &body).await { - Ok(v) => v, - Err(err) => { - chat.pop(); // keep the history consistent with what the model saw - return Err(err); - } - }; + "messages": messages, + }) +} +/// The assistant's full text from a Messages API content array — the same as +/// claudeResponseText() on the Mac (#67). Web search answers interleave text +/// with tool blocks, and citations split a sentence across adjacent text +/// blocks: every text block is kept, joined as is, and only the whole trimmed. +pub fn response_text(content: &[Value]) -> Option { + let text: String = content + .iter() + .filter(|b| b.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .collect(); + let text = text.trim(); + (!text.is_empty()).then(|| text.to_string()) +} + +/// The content blocks to keep in the history and the text to show, or why there are none. +fn interpret(response: &Value) -> Result<(Vec, String), String> { // A policy decline comes back as HTTP 200 with stop_reason "refusal". if response.get("stop_reason").and_then(Value::as_str) == Some("refusal") { - chat.pop(); let why = response - .get("stop_details") - .and_then(|d| d.get("explanation")) + .pointer("/stop_details/explanation") .and_then(Value::as_str) .unwrap_or("Claude declined this one."); return Err(why.to_string()); } + let blocks = response + .get("content") + .and_then(Value::as_array) + .cloned() + .ok_or("Unexpected API response.")?; + let text = response_text(&blocks).ok_or("No response text.")?; + Ok((blocks, text)) +} - let Some(blocks) = response.get("content").and_then(Value::as_array).cloned() else { - chat.pop(); - return Err("Unexpected API response.".into()); - }; +/// One chat turn. Returns the assistant's text, or a message the island shows +/// in the note view. +pub async fn send( + chat: &Chat, + model: &str, + query: String, + context: Option, +) -> Result { + let key = secrets::get(KEY).ok_or_else(|| "API key missing. Open settings.".to_string())?; + let endpoint = endpoint()?; - // Store the whole content — tool_use / tool_result blocks included — so the - // next turn has the right context. - chat.push(json!({ "role": "assistant", "content": blocks.clone() })); + let turn = chat.begin(chat::ANTHROPIC); + let user = json!({ "role": "user", "content": user_content(turn.first, context.as_ref(), &query) }); + let body = request_body(model, &chat::system_prompt(true), &turn.history, &user); - let text = blocks - .iter() - .filter(|b| b.get("type").and_then(Value::as_str) == Some("text")) - .filter_map(|b| b.get("text").and_then(Value::as_str)) - .collect::>() - .join("\n") - .trim() - .to_string(); + let response = call(&endpoint, &key, &body).await?; + let (blocks, text) = interpret(&response)?; - if text.is_empty() { - return Err("No response text.".into()); - } + // Store the whole content — tool_use / tool_result blocks included — so the + // next turn has the right context. + let plain = chat::plain_question(turn.first, context.as_ref(), &query); + chat.commit(&turn, user, json!({ "role": "assistant", "content": blocks }), &plain, &text); Ok(ChatReply { text }) } -async fn call(key: &str, body: &Value) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(90)) - .build() - .map_err(|e| e.to_string())?; - - let response = client - .post(ENDPOINT) +async fn call(endpoint: &Url, key: &str, body: &Value) -> Result { + let response = net::client(endpoint, Duration::from_secs(90))? + .post(endpoint.clone()) .header("x-api-key", key) .header("anthropic-version", ANTHROPIC_VERSION) .header("anthropic-beta", FALLBACK_BETA) @@ -175,21 +171,46 @@ async fn call(key: &str, body: &Value) -> Result { .map_err(|e| format!("Network error: {e}"))?; let status = response.status(); - let text = response.text().await.map_err(|e| e.to_string())?; if !status.is_success() { // Surface the API's own message, which is what makes a bad key obvious. - let detail = serde_json::from_str::(&text) - .ok() - .and_then(|v| { - v.get("error") - .and_then(|e| e.get("message")) - .and_then(Value::as_str) - .map(str::to_string) - }) - .unwrap_or_else(|| text.chars().take(200).collect()); - return Err(format!("Claude API {status}: {detail}")); + let body = net::read_capped(response, net::MAX_ERROR_BODY).await.unwrap_or_default(); + return Err(format!("Claude API {status}: {}", net::error_detail(&body))); + } + let bytes = net::read_capped(response, net::MAX_BODY).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("Bad API response: {e}")) +} + +/// The models on the user's Anthropic account, newest first, as the API lists them. +pub async fn models(key: &str) -> Result, String> { + let url = models_endpoint(&endpoint()?); + let response = net::client(&url, Duration::from_secs(10))? + .get(url.clone()) + .header("x-api-key", key) + .header("anthropic-version", ANTHROPIC_VERSION) + .send() + .await + .map_err(|e| format!("Network error: {e}"))?; + let status = response.status(); + if !status.is_success() { + let body = net::read_capped(response, net::MAX_ERROR_BODY).await.unwrap_or_default(); + return Err(format!("Claude API {status}: {}", net::error_detail(&body))); } - serde_json::from_str(&text).map_err(|e| format!("Bad API response: {e}")) + let bytes = net::read_capped(response, net::MAX_BODY).await?; + let json: Value = serde_json::from_slice(&bytes).map_err(|_| "Unexpected API response.".to_string())?; + Ok(parse_models(&json)) +} + +fn parse_models(json: &Value) -> Vec { + json.get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|m| { + let id = m.get("id")?.as_str()?.to_string(); + let label = m.get("display_name").and_then(Value::as_str).unwrap_or(&id).to_string(); + Some(ModelInfo { id, label }) + }) + .collect() } /// PDF → document block, image → image block, text/code → inline text. @@ -227,7 +248,7 @@ fn file_block(path: &str) -> Option { } /// Small standalone base64 encoder — not worth another dependency. -/// Also used for Stripe's basic auth. +/// Also used for Stripe's basic auth and the images sent to other providers. pub(crate) fn base64_for(bytes: &[u8]) -> String { base64(bytes) } @@ -248,7 +269,7 @@ fn base64(bytes: &[u8]) -> String { #[cfg(test)] mod tests { - use super::base64; + use super::*; #[test] fn base64_matches_rfc4648_vectors() { @@ -260,4 +281,93 @@ mod tests { assert_eq!(base64(b"fooba"), "Zm9vYmE="); assert_eq!(base64(b"foobar"), "Zm9vYmFy"); } + + fn texts(parts: &[&str]) -> Vec { + parts.iter().map(|t| json!({ "type": "text", "text": t })).collect() + } + + #[test] + fn every_text_block_is_kept_in_order_without_added_separators() { + assert_eq!(response_text(&texts(&["Simple answer."])).as_deref(), Some("Simple answer.")); + let content = vec![ + json!({"type":"text","text":"I'll look that up.\n"}), + json!({"type":"server_tool_use","id":"srvtoolu_01","name":"web_search","input":{"query":"test"}}), + json!({"type":"web_search_tool_result","tool_use_id":"srvtoolu_01","content":[]}), + json!({"type":"text","text":"The answer is 42."}), + ]; + assert_eq!(response_text(&content).as_deref(), Some("I'll look that up.\nThe answer is 42.")); + // An empty leading block does not hide the answer. + let content = vec![ + json!({"type":"text","text":""}), + json!({"type":"web_search_tool_result","tool_use_id":"b","content":[]}), + json!({"type":"text","text":"Here is the actual answer."}), + ]; + assert_eq!(response_text(&content).as_deref(), Some("Here is the actual answer.")); + // Citations split a sentence across blocks: no newline is inserted. + assert_eq!( + response_text(&texts(&["Paris is the ", "capital", " of France."])).as_deref(), + Some("Paris is the capital of France.") + ); + // Search result text never leaks into the answer. + let content = vec![ + json!({"type":"text","text":"Answer."}), + json!({"type":"web_search_tool_result","tool_use_id":"a","content":[{"type":"web_search_result","title":"Page","text":"Leak"}]}), + ]; + assert_eq!(response_text(&content).as_deref(), Some("Answer.")); + assert_eq!(response_text(&[json!({"type":"server_tool_use","id":"x"})]), None); + assert_eq!(response_text(&texts(&["", " \n"])), None); + } + + #[test] + fn a_refusal_or_an_empty_answer_is_an_error() { + let refusal = json!({"stop_reason":"refusal","stop_details":{"explanation":"Not this."},"content":[]}); + assert_eq!(interpret(&refusal).unwrap_err(), "Not this."); + assert_eq!(interpret(&json!({"stop_reason":"refusal"})).unwrap_err(), "Claude declined this one."); + assert_eq!(interpret(&json!({"id":"x"})).unwrap_err(), "Unexpected API response."); + assert_eq!(interpret(&json!({"content":[]})).unwrap_err(), "No response text."); + let (blocks, text) = interpret(&json!({"content":[{"type":"text","text":" Hi "}]})).unwrap(); + assert_eq!(text, "Hi"); + assert_eq!(blocks.len(), 1); + } + + #[test] + fn the_request_carries_history_web_search_and_the_new_turn_last() { + let history = vec![json!({"role":"user","content":"a"}), json!({"role":"assistant","content":"b"})]; + let user = json!({"role":"user","content":[{"type":"text","text":"c"}]}); + let body = request_body("claude-x", "sys", &history, &user); + assert_eq!(body["model"], "claude-x"); + assert_eq!(body["system"], "sys"); + assert_eq!(body["max_tokens"], MAX_TOKENS); + assert_eq!(body["tools"][0]["name"], "web_search"); + assert_eq!(body["messages"].as_array().unwrap().len(), 3); + assert_eq!(body["messages"][2], user); + } + + #[test] + fn context_is_sent_with_the_first_turn_only() { + let ctx = ChatContext::Window { app_name: "Edge".into(), title: "Docs".into(), url: Some("https://x.dev".into()) }; + let first = user_content(true, Some(&ctx), "q"); + assert_eq!(first.len(), 2); + assert_eq!(first[0]["text"], "Context — App: Edge, Window: Docs, URL: https://x.dev"); + assert_eq!(user_content(false, Some(&ctx), "q"), vec![json!({"type":"text","text":"q"})]); + // A file that cannot be read still names itself. + let ctx = ChatContext::File { name: "gone.pdf".into(), path: "/no/such/gone.pdf".into() }; + assert_eq!(user_content(true, Some(&ctx), "q")[0]["text"], "File: gone.pdf"); + } + + #[test] + fn the_model_list_sits_next_to_the_messages_endpoint() { + let url = Url::parse(DEFAULT_ENDPOINT).unwrap(); + assert_eq!(models_endpoint(&url).as_str(), "https://api.anthropic.com/v1/models?limit=100"); + let url = net::anthropic_endpoint("https://gw.example.com/anthropic").unwrap(); + assert_eq!(models_endpoint(&url).as_str(), "https://gw.example.com/anthropic/v1/models?limit=100"); + let list = json!({"data":[{"id":"claude-opus-5","display_name":"Claude Opus 5"},{"id":"claude-x"},{"nope":1}]}); + assert_eq!( + parse_models(&list), + vec![ + ModelInfo { id: "claude-opus-5".into(), label: "Claude Opus 5".into() }, + ModelInfo { id: "claude-x".into(), label: "claude-x".into() }, + ] + ); + } } diff --git a/windows/src-tauri/src/identity.rs b/windows/src-tauri/src/identity.rs new file mode 100644 index 000000000..9fdac3414 --- /dev/null +++ b/windows/src-tauri/src/identity.rs @@ -0,0 +1,67 @@ +// The user's first name, for Mochi's system prompt — the same rules as +// UserIdentity.swift on the Mac. The full name comes from the OS account +// (platform::user_full_name); this file only decides whether it is something +// you would greet someone by. + +use std::sync::OnceLock; + +const MAX_FULL_NAME_CHARS: usize = 32; + +/// Resolved once: the account name cannot change while the app runs. +pub fn first_name() -> Option<&'static str> { + static NAME: OnceLock> = OnceLock::new(); + NAME.get_or_init(|| crate::platform::user_full_name().as_deref().and_then(first_name_from)) + .as_deref() +} + +/// "Théodore Riant" gives "Théodore"; a login handle such as "theodoreriant" +/// or "t.riant2" gives nothing, because "Hey theodoreriant!" reads worse than +/// a greeting with no name at all. +pub fn first_name_from(full_name: &str) -> Option { + let full = full_name.trim(); + if full.is_empty() || full.chars().count() > MAX_FULL_NAME_CHARS || looks_like_login_handle(full) { + return None; + } + let first = full.split(' ').find(|p| !p.is_empty())?; + first.chars().all(is_name_part).then(|| first.to_string()) +} + +/// Where no full name is set the OS hands back the account name, so a single +/// lowercase word, or one carrying digits or separators, is taken as a handle. +fn looks_like_login_handle(name: &str) -> bool { + if name.contains(' ') { + return false; + } + name.to_lowercase() == name || name.chars().any(|c| c.is_numeric() || "._-@".contains(c)) +} + +fn is_name_part(c: char) -> bool { + c.is_alphabetic() || c == '\'' || c == '-' +} + +#[cfg(test)] +mod tests { + use super::first_name_from; + + #[test] + fn a_real_full_name_gives_its_first_word() { + assert_eq!(first_name_from("Théodore Riant").as_deref(), Some("Théodore")); + assert_eq!(first_name_from(" Louis Raille ").as_deref(), Some("Louis")); + assert_eq!(first_name_from("Jean-Luc Picard").as_deref(), Some("Jean-Luc")); + assert_eq!(first_name_from("O'Brien Miles").as_deref(), Some("O'Brien")); + assert_eq!(first_name_from("Louis").as_deref(), Some("Louis")); + assert_eq!(first_name_from("Zoë").as_deref(), Some("Zoë")); + } + + #[test] + fn login_handles_and_odd_names_give_nothing() { + for name in ["", " ", "theodoreriant", "t.riant2", "Louis2", "louis_r", "me@example", "Admin_01"] { + assert_eq!(first_name_from(name), None, "{name}"); + } + // A first word that is not a name. + assert_eq!(first_name_from("R2-D2 Unit"), None); + assert_eq!(first_name_from("Dr. Who"), None); + // Too long to be a person's name. + assert_eq!(first_name_from(&format!("Louis {}", "a".repeat(40))), None); + } +} diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index d9ec3353e..eec354d0d 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -1,11 +1,14 @@ // Coucou for Windows — app wiring and the commands the island calls. +mod chat; mod claude; mod files; mod hooks; +mod identity; mod integrations; mod island; mod log; +mod net; mod pipe; mod platform; mod secrets; @@ -20,7 +23,7 @@ use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; use tauri_plugin_autostart::{ManagerExt, MacosLauncher}; -use claude::{Chat, ChatContext, ChatReply}; +use chat::{Chat, ChatContext, ChatReply, ModelInfo}; use files::DroppedFile; use hooks::{HookPreview, HookStatus}; use island::{PollGate, ScreenInfo}; @@ -238,13 +241,21 @@ fn approval_decline(app: AppHandle, request_id: String) { /// One chat turn. The API key and any file bytes stay on the Rust side. #[tauri::command] async fn chat_send( + app: AppHandle, shared: State<'_, Shared>, chat: State<'_, Chat>, query: String, context: Option, ) -> Result { - let model = shared.settings.lock().unwrap().model.clone(); - claude::send(&chat, &model, query, context).await + let settings = shared.settings.lock().unwrap().clone(); + chat::send(&app, &chat, &settings, query, context).await +} + +/// The models a provider offers. Only asked with the provider's key. +#[tauri::command] +async fn chat_models(shared: State<'_, Shared>, provider: String) -> Result, String> { + let settings = shared.settings.lock().unwrap().clone(); + chat::models(&settings, &provider).await } #[tauri::command] @@ -394,6 +405,7 @@ pub fn run() { approval_decline, log_line, chat_send, + chat_models, chat_reset, ingest_file, secret_present, diff --git a/windows/src-tauri/src/net.rs b/windows/src-tauri/src/net.rs new file mode 100644 index 000000000..ef31c7fdd --- /dev/null +++ b/windows/src-tauri/src/net.rs @@ -0,0 +1,283 @@ +// Network helpers shared by the chat providers: address checks, bounded reads +// and HTTP clients. +// +// Every answer a server sends is read with a ceiling, so a misbehaving or +// hostile server (a local model address can point anywhere) cannot make the +// app hold an unbounded amount of memory. + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use reqwest::Url; + +/// Ceiling for a JSON answer: a chat reply or a model list. +pub const MAX_BODY: usize = 8 * 1024 * 1024; +/// Ceiling for an error body: only its message is shown. +pub const MAX_ERROR_BODY: usize = 64 * 1024; + +/// True for an address that is this machine: `localhost`, 127.0.0.0/8, ::1, +/// and the unspecified addresses (0.0.0.0, ::), which connect to this machine. +pub fn is_loopback_host(host: &str) -> bool { + let host = host.trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase(); + if host == "localhost" || host.ends_with(".localhost") { + return true; + } + match host.parse::() { + Ok(IpAddr::V4(ip)) => ip.is_loopback() || ip.is_unspecified(), + Ok(IpAddr::V6(ip)) => { + ip.is_loopback() + || ip.is_unspecified() + || ip.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) + } + Err(_) => false, + } +} + +pub fn is_loopback_url(url: &Url) -> bool { + url.host_str().is_some_and(is_loopback_host) +} + +/// The address of a model server as the user typed or pasted it, cleaned up: +/// a missing scheme becomes `http://`, trailing slashes and the documentation +/// sub-paths people paste (`/api`, `/v1`) go, and the loopback names become +/// 127.0.0.1 — on Windows `localhost` may resolve to ::1 first while Ollama +/// only listens on IPv4. Only http and https, and no `user:password@`. +pub fn normalise_server_url(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Err("Enter the server address first.".into()); + } + let with_scheme = if raw.contains("://") { raw.to_string() } else { format!("http://{raw}") }; + let mut url = Url::parse(&with_scheme).map_err(|_| format!("Not a valid address: {raw}"))?; + if !matches!(url.scheme(), "http" | "https") { + return Err("The address must start with http:// or https://.".into()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("Leave the user name and password out of the address.".into()); + } + if url.host_str().is_none_or(str::is_empty) { + return Err(format!("Not a valid address: {raw}")); + } + if is_loopback_url(&url) { + let _ = url.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST)); + } + let mut path = url.path().trim_end_matches('/').to_string(); + for suffix in ["/api", "/v1"] { + if let Some(rest) = path.strip_suffix(suffix) { + path = rest.trim_end_matches('/').to_string(); + } + } + url.set_path(&path); + url.set_query(None); + url.set_fragment(None); + Ok(url) +} + +/// `base` + `tail`, with exactly one slash between them. +pub fn join(base: &Url, tail: &str) -> String { + format!("{}/{}", base.as_str().trim_end_matches('/'), tail.trim_start_matches('/')) +} + +/// The Messages endpoint for an Anthropic-compatible gateway given in +/// COUCOU_ANTHROPIC_BASE_URL. A base URL (`https://gw.example.com`, +/// `…/v1`) gets `/v1/messages` added; a full endpoint is kept. The key goes +/// wherever this points, so it must be https — plain http only to this machine. +pub fn anthropic_endpoint(raw: &str) -> Result { + const VAR: &str = "COUCOU_ANTHROPIC_BASE_URL"; + let mut url = Url::parse(raw.trim()).map_err(|_| format!("{VAR} is not a valid URL."))?; + match url.scheme() { + "https" => {} + "http" if is_loopback_url(&url) => {} + "http" => return Err(format!("{VAR} must use https:// (plain http only to this computer).")), + _ => return Err(format!("{VAR} must start with https://.")), + } + if !url.username().is_empty() || url.password().is_some() || url.host_str().is_none() { + return Err(format!("{VAR} must not carry a user name or password.")); + } + let path = url.path().trim_end_matches('/').to_string(); + let path = if path.ends_with("/v1/messages") { + path + } else if path.ends_with("/v1") { + format!("{path}/messages") + } else { + format!("{path}/v1/messages") + }; + url.set_path(&path); + url.set_fragment(None); + Ok(url) +} + +/// A client for `url`: requests to this machine skip any system proxy, which +/// would otherwise see (and usually fail) a loopback address. +pub fn client(url: &Url, timeout: Duration) -> Result { + let mut builder = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10).min(timeout)) + .timeout(timeout); + if is_loopback_url(url) { + builder = builder.no_proxy(); + } + builder.build().map_err(|e| e.to_string()) +} + +/// The body of `response`, refused past `limit` bytes. +pub async fn read_capped(mut response: reqwest::Response, limit: usize) -> Result, String> { + if response.content_length().is_some_and(|n| n > limit as u64) { + return Err("The server's answer is too large.".into()); + } + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|e| format!("Network error: {e}"))? { + if body.len() + chunk.len() > limit { + return Err("The server's answer is too large.".into()); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +/// The text of an error body, cut down for a message: `error.message` +/// (OpenAI, Anthropic), a bare `error` string (Ollama), or the first characters. +pub fn error_detail(body: &[u8]) -> String { + let text = String::from_utf8_lossy(body); + if let Ok(v) = serde_json::from_str::(&text) { + if let Some(m) = v.pointer("/error/message").and_then(|m| m.as_str()) { + return m.to_string(); + } + if let Some(m) = v.get("error").and_then(|m| m.as_str()) { + return m.to_string(); + } + if let Some(m) = v.get("message").and_then(|m| m.as_str()) { + return m.to_string(); + } + // Gemini answers some errors as a one-element array. + if let Some(m) = v.pointer("/0/error/message").and_then(|m| m.as_str()) { + return m.to_string(); + } + } + text.trim().chars().take(200).collect() +} + +/// Only the host of an address, for the log: never a path, a query or a key. +pub fn host_for_log(url: &Url) -> String { + match (url.host_str(), url.port()) { + (Some(h), Some(p)) => format!("{h}:{p}"), + (Some(h), None) => h.to_string(), + _ => "?".into(), + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + #[test] + fn loopback_hosts_are_recognised_and_others_are_not() { + for host in ["localhost", "LOCALHOST", "app.localhost", "127.0.0.1", "127.8.9.1", "::1", "[::1]", "0.0.0.0", "[::]", "::ffff:127.0.0.1"] { + assert!(is_loopback_host(host), "{host}"); + } + for host in ["example.com", "192.168.1.10", "10.0.0.2", "localhost.example.com", "[2001:db8::1]", "128.0.0.1", ""] { + assert!(!is_loopback_host(host), "{host}"); + } + } + + #[test] + fn a_pasted_server_address_is_cleaned_up() { + let n = |s: &str| normalise_server_url(s).map(|u| u.to_string()); + assert_eq!(n(" http://localhost:11434/ ").unwrap(), "http://127.0.0.1:11434/"); + assert_eq!(n("http://localhost:11434/v1").unwrap(), "http://127.0.0.1:11434/"); + assert_eq!(n("http://127.0.0.1:1234/v1/").unwrap(), "http://127.0.0.1:1234/"); + assert_eq!(n("localhost:11434/api").unwrap(), "http://127.0.0.1:11434/"); + assert_eq!(n("0.0.0.0:11434").unwrap(), "http://127.0.0.1:11434/"); + assert_eq!(n("http://[::1]:8000").unwrap(), "http://127.0.0.1:8000/"); + assert_eq!(n("https://llm.example.com/proxy/v1?x=1#y").unwrap(), "https://llm.example.com/proxy"); + assert_eq!(n("gpu-box.lan:8000").unwrap(), "http://gpu-box.lan:8000/"); + } + + #[test] + fn a_server_address_that_is_not_http_is_refused() { + assert!(normalise_server_url("").is_err()); + assert!(normalise_server_url(" ").is_err()); + assert!(normalise_server_url("file:///etc/passwd").is_err()); + assert!(normalise_server_url("javascript://alert(1)").is_err()); + assert!(normalise_server_url("http://user:pw@example.com").is_err()); + assert!(normalise_server_url("http://").is_err()); + } + + #[test] + fn join_puts_one_slash_between_base_and_path() { + let base = normalise_server_url("http://127.0.0.1:11434").unwrap(); + assert_eq!(join(&base, "/v1/models"), "http://127.0.0.1:11434/v1/models"); + let base = Url::parse("https://api.openai.com/v1").unwrap(); + assert_eq!(join(&base, "chat/completions"), "https://api.openai.com/v1/chat/completions"); + } + + #[test] + fn the_anthropic_gateway_is_taken_as_a_base_url_and_must_be_https() { + let e = |s: &str| anthropic_endpoint(s).map(|u| u.to_string()); + assert_eq!(e("https://gw.example.com").unwrap(), "https://gw.example.com/v1/messages"); + assert_eq!(e("https://gw.example.com/").unwrap(), "https://gw.example.com/v1/messages"); + assert_eq!(e("https://gw.example.com/anthropic").unwrap(), "https://gw.example.com/anthropic/v1/messages"); + assert_eq!(e("https://gw.example.com/v1").unwrap(), "https://gw.example.com/v1/messages"); + assert_eq!(e("https://gw.example.com/v1/messages/").unwrap(), "https://gw.example.com/v1/messages"); + assert_eq!(e(" http://localhost:4000 ").unwrap(), "http://localhost:4000/v1/messages"); + assert_eq!(e("http://127.0.0.1:4000/v1").unwrap(), "http://127.0.0.1:4000/v1/messages"); + assert!(e("http://gw.example.com").is_err()); + assert!(e("ftp://gw.example.com").is_err()); + assert!(e("https://me:secret@gw.example.com").is_err()); + assert!(e("not a url").is_err()); + } + + #[test] + fn error_details_come_from_the_usual_places() { + assert_eq!(error_detail(br#"{"error":{"message":"bad key"}}"#), "bad key"); + assert_eq!(error_detail(br#"{"error":"model 'x' not found"}"#), "model 'x' not found"); + assert_eq!(error_detail(br#"[{"error":{"message":"quota"}}]"#), "quota"); + assert_eq!(error_detail(b" plain text "), "plain text"); + assert_eq!(error_detail("x".repeat(500).as_bytes()).len(), 200); + } + + #[test] + fn only_the_host_goes_to_the_log() { + let url = Url::parse("https://gw.example.com:8443/secret/path?key=abc").unwrap(); + assert_eq!(host_for_log(&url), "gw.example.com:8443"); + let url = Url::parse("https://gw.example.com/v1/messages").unwrap(); + assert_eq!(host_for_log(&url), "gw.example.com"); + } + + /// One-shot HTTP server on a free port answering the next request with `body`. + pub(crate) fn serve_once(status: &str, headers: &str, body: Vec) -> String { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let head = format!("HTTP/1.1 {status}\r\n{headers}Connection: close\r\n\r\n"); + std::thread::spawn(move || { + let (mut conn, _) = listener.accept().unwrap(); + let mut buf = [0u8; 8192]; + let _ = conn.read(&mut buf); + let _ = conn.write_all(head.as_bytes()); + let _ = conn.write_all(&body); + }); + url + } + + fn block_on(f: impl std::future::Future) -> T { + tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(f) + } + + #[test] + fn a_body_past_the_ceiling_is_refused_with_or_without_a_length() { + let get = |url: String| async move { + let url = Url::parse(&url).unwrap(); + let response = client(&url, Duration::from_secs(5)).unwrap().get(url).send().await.unwrap(); + read_capped(response, 1000).await + }; + // Declared length too large. + let url = serve_once("200 OK", "Content-Length: 5000\r\n", vec![b'x'; 5000]); + assert!(block_on(get(url)).is_err()); + // No length (read to the end of the connection): stopped while reading. + let url = serve_once("200 OK", "", vec![b'x'; 5000]); + assert!(block_on(get(url)).is_err()); + // Under the ceiling: read whole. + let url = serve_once("200 OK", "Content-Length: 3\r\n", b"abc".to_vec()); + assert_eq!(block_on(get(url)).unwrap(), b"abc"); + } +} diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e748a336a..c2feb283d 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -119,6 +119,25 @@ pub fn relay_socket_path() -> Option { is_private_dir(&dir).then(|| dir.join("coucou.sock")) } +// ── Who we are ──────────────────────────────────────────────────────────────── + +/// The account's full name: the first field of the passwd GECOS entry +/// ("Louis Raille,,,"), or nothing when it is unset. +pub fn user_full_name() -> Option { + unsafe { + let mut pwd: libc::passwd = std::mem::zeroed(); + let mut result: *mut libc::passwd = std::ptr::null_mut(); + let mut buf = vec![0 as libc::c_char; 16 * 1024]; + let rc = libc::getpwuid_r(libc::getuid(), &mut pwd, buf.as_mut_ptr(), buf.len(), &mut result); + if rc != 0 || result.is_null() || pwd.pw_gecos.is_null() { + return None; + } + let gecos = std::ffi::CStr::from_ptr(pwd.pw_gecos).to_string_lossy().into_owned(); + let name = gecos.split(',').next().unwrap_or("").trim().to_string(); + (!name.is_empty()).then_some(name) + } +} + // ── Processes ───────────────────────────────────────────────────────────────── /// Nothing to hide: a spawned process only gets a terminal if it asks for one. diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 6e1200a8a..1b2f62e7f 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -145,6 +145,66 @@ pub fn current_user_sid() -> Option { } } +// The display name only feeds Mochi's greeting (identity.rs). The calls are +// declared here by hand, with their documented C signatures, so they need no +// extra `windows` crate features. +#[link(name = "secur32")] +extern "system" { + fn GetUserNameExW(name_format: i32, name_buffer: *mut u16, size: *mut u32) -> u8; +} + +#[link(name = "netapi32")] +extern "system" { + fn NetUserGetInfo(server: *const u16, user: *const u16, level: u32, buffer: *mut *mut u8) -> u32; + fn NetApiBufferFree(buffer: *mut core::ffi::c_void) -> u32; +} + +/// EXTENDED_NAME_FORMAT::NameDisplay. +const NAME_DISPLAY: i32 = 3; + +/// The account's display name ("Louis Raille"): the directory's for a domain +/// or Entra account, else the local account's "Full name", else nothing. +pub fn user_full_name() -> Option { + directory_display_name().or_else(local_full_name).filter(|n| !n.trim().is_empty()) +} + +fn directory_display_name() -> Option { + let mut buf = [0u16; 256]; + let mut size = buf.len() as u32; + // On success `size` is the length copied, without the terminating null. + let ok = unsafe { GetUserNameExW(NAME_DISPLAY, buf.as_mut_ptr(), &mut size) } != 0; + if !ok || size as usize > buf.len() { + return None; + } + String::from_utf16(&buf[..size as usize]).ok() +} + +fn local_full_name() -> Option { + let user = std::env::var("USERNAME").ok().filter(|u| !u.is_empty())?; + let wide: Vec = user.encode_utf16().chain(std::iter::once(0)).collect(); + let mut info: *mut u8 = std::ptr::null_mut(); + // Level 10 is USER_INFO_10 { name, comment, usr_comment, full_name }, four + // wide-string pointers; any user may read it about themselves. + let status = unsafe { NetUserGetInfo(std::ptr::null(), wide.as_ptr(), 10, &mut info) }; + if status != 0 || info.is_null() { + return None; + } + unsafe { + let full = *(info as *const *const u16).add(3); + let text = if full.is_null() { + None + } else { + let mut len = 0usize; + while len < 256 && *full.add(len) != 0 { + len += 1; + } + String::from_utf16(std::slice::from_raw_parts(full, len)).ok() + }; + NetApiBufferFree(info.cast()); + text + } +} + // ── Cursor ──────────────────────────────────────────────────────────────────── /// The 60 Hz poll reads the cursor and flips click-through from it. From cab5e744f70780005dbe9f30813100f4e899e0fb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:24:31 +0200 Subject: [PATCH 35/77] Chat: Google AI, OpenAI and OpenRouter as chat providers The Mac's chat talks to Google AI (Gemini) and OpenAI since 0.1.1; this adds them on Windows and Linux, plus OpenRouter, behind one table in openai_compat.rs: id, base URL, credential store key, model list path and default model. - One client for the three OpenAI-compatible APIs. Chat only: no tools are sent, and the system prompt does not claim web search. - A dropped text file goes along inline, an image as a data URL (up to 5 MB); PDFs by name only. - Model lists are fetched only for the provider picked in the chat, and only once it has a key (chat_models answers "No API key" without a request). Non-chat models are left out (the Mac's lists), Gemini's "models/" prefix is dropped, OpenAI is sorted newest first, OpenRouter free models first. - OpenAI gets max_completion_tokens, which its reasoning models require. - No X-Title / HTTP-Referer attribution headers: nothing about the app is sent that the request does not need. - Settings: chat_provider and chat_models (the model per provider); keys google-api-key and openai-api-key (the Mac's names) and openrouter-api-key. Based on #161 by @4rchila (OpenRouter, openai_compat.rs) and #166 by YourAlpha (Google Gemini provider: key name and endpoint). Co-authored-by: German <106097211+4rchila@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/chat.rs | 46 ++- windows/src-tauri/src/lib.rs | 7 +- windows/src-tauri/src/openai_compat.rs | 413 +++++++++++++++++++++++++ windows/src-tauri/src/secrets.rs | 3 + windows/src-tauri/src/settings.rs | 15 +- 5 files changed, 469 insertions(+), 15 deletions(-) create mode 100644 windows/src-tauri/src/openai_compat.rs diff --git a/windows/src-tauri/src/chat.rs b/windows/src-tauri/src/chat.rs index 9f1be2b72..f9a918c44 100644 --- a/windows/src-tauri/src/chat.rs +++ b/windows/src-tauri/src/chat.rs @@ -1,6 +1,6 @@ // The chat, whoever answers it: the conversation, the system prompt, and which -// provider a turn goes to. Each provider's own wire format lives in its module -// (claude.rs for Anthropic). +// provider a turn goes to. Each provider's own wire format lives in its module: +// claude.rs (Anthropic) and openai_compat.rs (OpenAI, Google AI, OpenRouter). // // API keys never leave the credential store and file bytes never cross the IPC // boundary: the island sends the question and gets the answer's text back. @@ -12,7 +12,7 @@ use serde_json::{json, Value}; use tauri::AppHandle; use crate::settings::Settings; -use crate::{claude, secrets}; +use crate::{claude, openai_compat, secrets}; pub const ANTHROPIC: &str = "anthropic"; @@ -157,9 +157,18 @@ pub fn plain_question(first: bool, context: Option<&ChatContext>, query: &str) - // ── Which provider ──────────────────────────────────────────────────────────── /// The model chosen for `provider`, or its default. -pub fn model_for(settings: &Settings, _provider: &str) -> String { - let m = settings.model.trim(); - if m.is_empty() { claude::DEFAULT_MODEL.to_string() } else { m.to_string() } +pub fn model_for(settings: &Settings, provider: &str) -> String { + if provider == ANTHROPIC { + let m = settings.model.trim(); + return if m.is_empty() { claude::DEFAULT_MODEL.to_string() } else { m.to_string() }; + } + settings + .chat_models + .get(provider) + .map(|m| m.trim().to_string()) + .filter(|m| !m.is_empty()) + .or_else(|| openai_compat::provider(provider).map(|p| p.default_model.to_string())) + .unwrap_or_default() } /// One chat turn with the provider chosen in the settings. @@ -170,21 +179,31 @@ pub async fn send( query: String, context: Option, ) -> Result { + let provider = settings.chat_provider.as_str(); + let model = model_for(settings, provider); + if provider == ANTHROPIC || provider.is_empty() { + return claude::send(chat, &model, query, context).await; + } + if let Some(p) = openai_compat::provider(provider) { + return openai_compat::send(chat, p, &model, query, context).await; + } let _ = app; - let model = model_for(settings, ANTHROPIC); - claude::send(chat, &model, query, context).await + Err(format!("Unknown chat provider: {provider}")) } /// The models `provider` offers. Asked only when the user opens the picker on -/// that provider, and only once it has a key (or, for a local server, an -/// address): nothing is sent anywhere before that. +/// that provider, and only once it has a key: nothing is sent anywhere before that. pub async fn models(settings: &Settings, provider: &str) -> Result, String> { const NO_KEY: &str = "No API key — add it in Settings."; - let _ = settings; if provider == ANTHROPIC { let key = secrets::get(claude::KEY).ok_or(NO_KEY)?; return claude::models(&key).await; } + if let Some(p) = openai_compat::provider(provider) { + let key = secrets::get(p.key).ok_or(NO_KEY)?; + return openai_compat::models(p, &key).await; + } + let _ = settings; Err(format!("Unknown chat provider: {provider}")) } @@ -280,10 +299,13 @@ mod tests { } #[test] - fn the_model_comes_from_the_settings_or_the_default() { + fn the_model_comes_from_the_settings_or_the_provider_default() { let mut s = Settings::default(); assert_eq!(model_for(&s, "anthropic"), claude::DEFAULT_MODEL); + assert_eq!(model_for(&s, "openai"), openai_compat::provider("openai").unwrap().default_model); + s.chat_models.insert("openai".into(), " gpt-x ".into()); s.model = "claude-haiku-4-5".into(); + assert_eq!(model_for(&s, "openai"), "gpt-x"); assert_eq!(model_for(&s, "anthropic"), "claude-haiku-4-5"); } } diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index eec354d0d..bb261466b 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod integrations; mod island; mod log; mod net; +mod openai_compat; mod pipe; mod platform; mod secrets; @@ -238,7 +239,8 @@ 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. +/// One chat turn with the provider picked in the chat view. API keys and any +/// file bytes stay on the Rust side. #[tauri::command] async fn chat_send( app: AppHandle, @@ -251,7 +253,8 @@ async fn chat_send( chat::send(&app, &chat, &settings, query, context).await } -/// The models a provider offers. Only asked with the provider's key. +/// The models a provider offers, for the picker in the chat view. Only asked +/// once the user picked that provider, and only with its key. #[tauri::command] async fn chat_models(shared: State<'_, Shared>, provider: String) -> Result, String> { let settings = shared.settings.lock().unwrap().clone(); diff --git a/windows/src-tauri/src/openai_compat.rs b/windows/src-tauri/src/openai_compat.rs new file mode 100644 index 000000000..02a49d962 --- /dev/null +++ b/windows/src-tauri/src/openai_compat.rs @@ -0,0 +1,413 @@ +// Cloud providers that speak the OpenAI chat completions API: OpenAI, Google AI +// (Gemini's OpenAI-compatible endpoint, as ClaudeService.swift uses it) and +// OpenRouter. One table describes them; one client talks to all three. +// +// Same contract as claude.rs: the key never leaves the credential store and +// file bytes never cross the IPC boundary. Chat only — no tools are sent, so +// the model can answer but never act on the machine. + +use std::time::Duration; + +use reqwest::Url; +use serde_json::{json, Value}; + +use crate::chat::{self, Chat, ChatContext, ChatReply, ModelInfo}; +use crate::{net, secrets}; + +pub struct Provider { + pub id: &'static str, + pub name: &'static str, + pub base_url: &'static str, + /// Credential store entry of its API key. + pub key: &'static str, + /// Relative to `base_url`. + pub models_path: &'static str, + pub default_model: &'static str, + /// Model ids containing any of these are not chat models. + pub not_chat: &'static [&'static str], + /// OpenAI's reasoning models refuse `max_tokens` and want + /// `max_completion_tokens`; the compatible APIs take the older name. + pub max_tokens_field: &'static str, +} + +pub const PROVIDERS: &[Provider] = &[ + Provider { + id: "openai", + name: "OpenAI", + base_url: "https://api.openai.com/v1", + key: "openai-api-key", + models_path: "models", + default_model: "gpt-4o", + not_chat: &[ + "embed", "tts", "whisper", "dall-e", "audio", "realtime", "moderat", "codex", + "computer-use", "transcribe", "image", "sora", "babbage", "davinci", "instruct", + ], + max_tokens_field: "max_completion_tokens", + }, + Provider { + id: "google", + name: "Google AI", + base_url: "https://generativelanguage.googleapis.com/v1beta/openai", + key: "google-api-key", + models_path: "models", + default_model: "gemini-2.0-flash", + not_chat: &["embed", "imagen", "veo", "aqa", "tts", "audio", "live"], + max_tokens_field: "max_tokens", + }, + Provider { + id: "openrouter", + name: "OpenRouter", + base_url: "https://openrouter.ai/api/v1", + key: "openrouter-api-key", + models_path: "models", + default_model: "openrouter/auto", + not_chat: &[], + max_tokens_field: "max_tokens", + }, +]; + +pub fn provider(id: &str) -> Option<&'static Provider> { + PROVIDERS.iter().find(|p| p.id == id) +} + +const MAX_TOKENS: u32 = 4096; +/// Text and code files are inlined; anything larger is skipped. +const MAX_INLINE_TEXT: u64 = 200_000; +/// Images are sent inline as data URLs; past this size they are skipped. +const MAX_IMAGE: u64 = 5_000_000; + +fn url(p: &Provider, tail: &str) -> Result { + let base = Url::parse(p.base_url).map_err(|e| e.to_string())?; + Url::parse(&net::join(&base, tail)).map_err(|e| e.to_string()) +} + +/// The user's message for one turn: plain text, or text and an image part +/// when the first turn carries a dropped picture. +fn user_message(first: bool, context: Option<&ChatContext>, query: &str) -> Value { + let mut prefix = String::new(); + let mut image = None; + match context.filter(|_| first) { + Some(ChatContext::File { name, path }) => match file_part(path) { + Some(FilePart::Image(part)) => { + image = Some(part); + prefix = format!("File: {name}\n\n"); + } + Some(FilePart::Text(body)) => prefix = format!("File: {name}\nFile contents:\n{body}\n\n"), + None => prefix = format!("File: {name}\n\n"), + }, + Some(ChatContext::Window { app_name, title, url }) => { + prefix = format!("{}\n\n", chat::window_line(app_name, title, url.as_deref())); + } + None => {} + } + let text = format!("{prefix}{query}"); + match image { + None => json!({ "role": "user", "content": text }), + Some(part) => json!({ "role": "user", "content": [part, { "type": "text", "text": text }] }), + } +} + +fn request_body(p: &Provider, model: &str, system: &str, history: &[Value], user: &Value) -> Value { + let mut messages = vec![json!({ "role": "system", "content": system })]; + messages.extend(history.iter().cloned()); + messages.push(user.clone()); + let mut body = json!({ "model": model, "messages": messages }); + body[p.max_tokens_field] = json!(MAX_TOKENS); + body +} + +/// The answer's text, or the error the provider put in a 200 reply +/// (OpenRouter does when the upstream model fails). +fn reply_text(p: &Provider, response: &Value) -> Result { + let text = response + .pointer("/choices/0/message/content") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + if !text.is_empty() { + return Ok(text); + } + if let Some(msg) = response.pointer("/error/message").and_then(Value::as_str) { + return Err(format!("{}: {msg}", p.name)); + } + Err("No response text.".into()) +} + +fn status_error(p: &Provider, status: u16, detail: &str) -> String { + match status { + 401 | 403 => format!("{} rejected the API key ({status}). Check it in Settings.", p.name), + 402 => format!("{}: not enough credits (402). {detail}", p.name), + 404 => format!("{}: model not found (404). Pick another one above the chat box. {detail}", p.name), + 429 => format!("{} rate limit reached (429): {detail}", p.name), + _ => format!("{} {status}: {detail}", p.name), + } +} + +/// One chat turn with a cloud provider. +pub async fn send( + chat: &Chat, + p: &'static Provider, + model: &str, + query: String, + context: Option, +) -> Result { + let key = secrets::get(p.key).ok_or_else(|| format!("{} API key missing. Add it in Settings.", p.name))?; + if model.is_empty() { + return Err(format!("Pick a {} model above the chat box.", p.name)); + } + let turn = chat.begin(p.id); + let user = user_message(turn.first, context.as_ref(), &query); + let body = request_body(p, model, &chat::system_prompt(false), &turn.history, &user); + + let endpoint = url(p, "chat/completions")?; + let response = net::client(&endpoint, Duration::from_secs(90))? + .post(endpoint) + .bearer_auth(&key) + .json(&body) + .send() + .await + .map_err(|e| format!("Network error: {e}"))?; + let status = response.status(); + if !status.is_success() { + let body = net::read_capped(response, net::MAX_ERROR_BODY).await.unwrap_or_default(); + return Err(status_error(p, status.as_u16(), &net::error_detail(&body))); + } + let bytes = net::read_capped(response, net::MAX_BODY).await?; + let json: Value = serde_json::from_slice(&bytes).map_err(|e| format!("Bad API response: {e}"))?; + let text = reply_text(p, &json)?; + + let plain = chat::plain_question(turn.first, context.as_ref(), &query); + chat.commit(&turn, user, json!({ "role": "assistant", "content": text }), &plain, &text); + Ok(ChatReply { text }) +} + +/// The provider's chat models. Only ever asked with the user's key, once they +/// picked this provider in the chat. +pub async fn models(p: &Provider, key: &str) -> Result, String> { + let endpoint = url(p, p.models_path)?; + let response = net::client(&endpoint, Duration::from_secs(15))? + .get(endpoint) + .bearer_auth(key) + .send() + .await + .map_err(|e| format!("Network error: {e}"))?; + let status = response.status(); + if !status.is_success() { + let body = net::read_capped(response, net::MAX_ERROR_BODY).await.unwrap_or_default(); + return Err(status_error(p, status.as_u16(), &net::error_detail(&body))); + } + let bytes = net::read_capped(response, net::MAX_BODY).await?; + let json: Value = serde_json::from_slice(&bytes).map_err(|_| "Unexpected API response.".to_string())?; + Ok(parse_models(p, &json)) +} + +fn parse_models(p: &Provider, json: &Value) -> Vec { + let items = json.get("data").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut models: Vec<(ModelInfo, i64, bool)> = items + .iter() + .filter(|m| { + // OpenRouter lists image and audio generators too. + m.pointer("/architecture/output_modalities") + .and_then(Value::as_array) + .is_none_or(|mods| mods.iter().any(|x| x.as_str() == Some("text"))) + }) + .filter_map(|m| { + let raw = m.get("id")?.as_str()?; + // Gemini sometimes answers "models/gemini-…". + let id = raw.strip_prefix("models/").unwrap_or(raw).to_string(); + let lower = id.to_lowercase(); + if id.is_empty() || p.not_chat.iter().any(|x| lower.contains(x)) { + return None; + } + let created = m.get("created").and_then(Value::as_i64).unwrap_or(0); + let free = p.id == "openrouter" && is_free(m, &id); + let name = m.get("name").and_then(Value::as_str).unwrap_or(&id); + let label = if free && !name.to_lowercase().contains("(free)") { format!("{name} (free)") } else { name.to_string() }; + Some((ModelInfo { id, label }, created, free)) + }) + .collect(); + match p.id { + // Newest first, as on the Mac. + "openai" => models.sort_by_key(|m| std::cmp::Reverse(m.1)), + // Free models first, then by name. + "openrouter" => models.sort_by(|a, b| { + b.2.cmp(&a.2).then_with(|| a.0.label.to_lowercase().cmp(&b.0.label.to_lowercase())) + }), + _ => {} + } + models.into_iter().map(|(m, _, _)| m).collect() +} + +fn is_free(m: &Value, id: &str) -> bool { + let zero = |ptr: &str| { + m.pointer(ptr) + .and_then(Value::as_str) + .is_some_and(|v| v.parse::().ok() == Some(0.0)) + }; + id.ends_with(":free") || (zero("/pricing/prompt") && zero("/pricing/completion")) +} + +enum FilePart { + Image(Value), + Text(String), +} + +/// Images → image_url data URL, text/code → inline text. PDFs and other binary +/// files go by name only (there is no portable document part). +fn file_part(path: &str) -> Option { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + let len = std::fs::metadata(path).ok()?.len(); + let image = match ext.as_str() { + "jpg" | "jpeg" => Some("image/jpeg"), + "png" => Some("image/png"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + _ => None, + }; + if let Some(media) = image { + if len > MAX_IMAGE { + return None; + } + let bytes = std::fs::read(path).ok()?; + let url = format!("data:{media};base64,{}", crate::claude::base64_for(&bytes)); + return Some(FilePart::Image(json!({ "type": "image_url", "image_url": { "url": url } }))); + } + if ext == "pdf" || len > MAX_INLINE_TEXT { + return None; + } + std::fs::read_to_string(path).ok().map(FilePart::Text) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(id: &str) -> &'static Provider { + provider(id).unwrap() + } + + #[test] + fn the_table_points_at_each_providers_own_api_and_key() { + assert_eq!(url(p("openai"), "chat/completions").unwrap().as_str(), "https://api.openai.com/v1/chat/completions"); + assert_eq!( + url(p("google"), "chat/completions").unwrap().as_str(), + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" + ); + assert_eq!(url(p("openrouter"), "models").unwrap().as_str(), "https://openrouter.ai/api/v1/models"); + for prov in PROVIDERS { + assert!(crate::secrets::KNOWN_KEYS.contains(&prov.key), "{}", prov.key); + assert!(prov.base_url.starts_with("https://")); + } + assert!(provider("anthropic").is_none()); + assert!(provider("ollama").is_none()); + } + + #[test] + fn the_request_is_system_then_history_then_the_new_turn() { + let history = vec![json!({"role":"user","content":"a"}), json!({"role":"assistant","content":"b"})]; + let user = user_message(false, None, "c"); + assert_eq!(user, json!({"role":"user","content":"c"})); + let body = request_body(p("google"), "gemini-x", "sys", &history, &user); + assert_eq!(body["model"], "gemini-x"); + assert_eq!(body["max_tokens"], MAX_TOKENS); + let msgs = body["messages"].as_array().unwrap(); + assert_eq!(msgs.len(), 4); + assert_eq!(msgs[0], json!({"role":"system","content":"sys"})); + assert_eq!(msgs[3], user); + assert!(body.get("tools").is_none(), "chat only: no tools"); + // OpenAI's reasoning models refuse the old field name. + let body = request_body(p("openai"), "gpt-5", "sys", &[], &user); + assert_eq!(body["max_completion_tokens"], MAX_TOKENS); + assert!(body.get("max_tokens").is_none()); + } + + #[test] + fn context_rides_with_the_first_turn_as_text_or_an_image() { + let dir = std::env::temp_dir().join(format!("coucou-oai-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let txt = dir.join("notes.txt"); + std::fs::write(&txt, "hello").unwrap(); + let ctx = ChatContext::File { name: "notes.txt".into(), path: txt.to_string_lossy().into() }; + assert_eq!( + user_message(true, Some(&ctx), "sum up")["content"], + "File: notes.txt\nFile contents:\nhello\n\nsum up" + ); + assert_eq!(user_message(false, Some(&ctx), "more")["content"], "more"); + + let png = dir.join("pic.png"); + std::fs::write(&png, [0x89, b'P', b'N', b'G']).unwrap(); + let ctx = ChatContext::File { name: "pic.png".into(), path: png.to_string_lossy().into() }; + let msg = user_message(true, Some(&ctx), "what is it?"); + let parts = msg["content"].as_array().unwrap(); + assert_eq!(parts[0]["type"], "image_url"); + assert!(parts[0]["image_url"]["url"].as_str().unwrap().starts_with("data:image/png;base64,")); + assert_eq!(parts[1], json!({"type":"text","text":"File: pic.png\n\nwhat is it?"})); + + let pdf = dir.join("doc.pdf"); + std::fs::write(&pdf, "%PDF").unwrap(); + let ctx = ChatContext::File { name: "doc.pdf".into(), path: pdf.to_string_lossy().into() }; + assert_eq!(user_message(true, Some(&ctx), "q")["content"], "File: doc.pdf\n\nq"); + let _ = std::fs::remove_dir_all(dir); + + let ctx = ChatContext::Window { app_name: "Code".into(), title: "x".into(), url: None }; + assert_eq!(user_message(true, Some(&ctx), "q")["content"], "Context — App: Code, Window: x\n\nq"); + } + + #[test] + fn the_reply_text_or_the_error_inside_a_200_is_read() { + let ok = json!({"choices":[{"message":{"role":"assistant","content":" Hi! "}}]}); + assert_eq!(reply_text(p("openai"), &ok).unwrap(), "Hi!"); + let failed = json!({"error":{"message":"upstream down"}}); + assert_eq!(reply_text(p("openrouter"), &failed).unwrap_err(), "OpenRouter: upstream down"); + assert_eq!(reply_text(p("google"), &json!({"choices":[]})).unwrap_err(), "No response text."); + } + + #[test] + fn status_errors_say_what_to_do() { + assert!(status_error(p("openai"), 401, "x").contains("rejected the API key")); + assert!(status_error(p("openrouter"), 402, "x").contains("credits")); + assert_eq!(status_error(p("google"), 500, "boom"), "Google AI 500: boom"); + } + + #[test] + fn model_lists_keep_chat_models_only() { + let openai = json!({"data":[ + {"id":"gpt-4o","created":100}, + {"id":"text-embedding-3-small","created":300}, + {"id":"gpt-5-mini","created":200}, + {"id":"whisper-1","created":400}, + {"id":"dall-e-3","created":500} + ]}); + let ids: Vec<_> = parse_models(p("openai"), &openai).into_iter().map(|m| m.id).collect(); + assert_eq!(ids, vec!["gpt-5-mini", "gpt-4o"]); + + let google = json!({"data":[ + {"id":"models/gemini-2.0-flash"}, + {"id":"models/text-embedding-004"}, + {"id":"gemini-2.5-pro"}, + {"id":"models/imagen-3.0"} + ]}); + let ids: Vec<_> = parse_models(p("google"), &google).into_iter().map(|m| m.id).collect(); + assert_eq!(ids, vec!["gemini-2.0-flash", "gemini-2.5-pro"]); + + let openrouter = json!({"data":[ + {"id":"b/paid","name":"B Paid","pricing":{"prompt":"0.000001","completion":"0.000002"}}, + {"id":"a/free:free","name":"A (free)"}, + {"id":"c/zero","name":"C Zero","pricing":{"prompt":"0","completion":"0"}}, + {"id":"d/image","name":"D Image","architecture":{"output_modalities":["image"]}} + ]}); + let models = parse_models(p("openrouter"), &openrouter); + let labels: Vec<_> = models.iter().map(|m| m.label.as_str()).collect(); + assert_eq!(labels, vec!["A (free)", "C Zero (free)", "B Paid"]); + } + + #[test] + fn an_unknown_answer_gives_an_empty_list() { + assert!(parse_models(p("openai"), &json!({"nope":true})).is_empty()); + } +} diff --git a/windows/src-tauri/src/secrets.rs b/windows/src-tauri/src/secrets.rs index 5c37b6529..46b94b09e 100644 --- a/windows/src-tauri/src/secrets.rs +++ b/windows/src-tauri/src/secrets.rs @@ -8,6 +8,9 @@ const SERVICE: &str = "fr.louisraille.coucou"; /// Every key Coucou may store. Anything outside this list is refused. pub const KNOWN_KEYS: &[&str] = &[ "anthropic-api-key", + "openai-api-key", + "google-api-key", + "openrouter-api-key", "n8n-url", "n8n-api-key", "vercel-token", diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 17f63e1c1..6abe0948f 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use std::collections::BTreeMap; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard, PoisonError}; @@ -23,6 +24,12 @@ pub struct Settings { pub hooks_installed: bool, /// Claude model used by the chat. Changeable in the settings window. pub model: String, + /// Who the chat talks to: "anthropic", or a cloud provider of + /// openai_compat.rs ("openai", "google", "openrouter"). Picked in the chat view. + pub chat_provider: String, + /// The model picked for each provider other than Anthropic (whose model is + /// `model`), by provider id. + pub chat_models: BTreeMap, } fn default_model() -> String { @@ -46,6 +53,8 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + chat_provider: crate::chat::ANTHROPIC.into(), + chat_models: BTreeMap::new(), } } } @@ -306,7 +315,9 @@ mod tests { "screen": "cursor", "autostart": true, "hooksInstalled": true, - "model": "some-model" + "model": "some-model", + "chatProvider": "openai", + "chatModels": { "openai": "gpt-x" } }"#; fn custom() -> Value { @@ -632,6 +643,8 @@ mod tests { "autostart", "hooksInstalled", "model", + "chatProvider", + "chatModels", ] ); let _ = std::fs::remove_dir_all(&dir); From 6fd7743d4afe9358d7d4b151b46b563806ed5021 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:25:11 +0200 Subject: [PATCH 36/77] Chat: local models through Ollama, LM Studio or any OpenAI-compatible server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As the Mac does since 0.1.3 (#156, LocalChat.swift), plus a server of the user's own that speaks the OpenAI API (vLLM, llama.cpp…), with an optional key kept in the credential store (openai-compatible-key). - local_connect checks that a server answers and lists its chat models (embedding and rerank models are left out). An empty address means the usual one on this computer; Ollama's OLLAMA_HOST is honoured. - Addresses are validated and cleaned: http or https only, no user:password@, trailing /v1 and /api dropped, and the loopback names pinned to 127.0.0.1 (on Windows "localhost" can resolve to ::1 first while Ollama listens on IPv4 only). Requests to this computer skip any proxy. - The answer streams: each step goes to the island as a chat-delta event with the text visible so far, at most 15 times a second. blocks stay hidden while open and are dropped from the finished answer; reasoning-only deltas are ignored and an error event ends the turn. - Every read is bounded: model lists and error bodies, one event line (1 MB) and the whole streamed answer (4 MB). - A dropped text file goes along inline, 24 000 characters at most, read without loading more of the file than that; images and PDFs by name only. - Settings: ollamaUrl, lmstudioUrl, customUrl (empty: not connected). Based on #173 by Justin Minkmar (local_chat.rs), re-applied on main without the branches it was stacked on; ideas from #262 by bagpotato94 (loopback pinning, no proxy for loopback, OLLAMA_HOST). Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/chat.rs | 19 +- windows/src-tauri/src/lib.rs | 10 +- windows/src-tauri/src/local_chat.rs | 502 ++++++++++++++++++++++++++++ windows/src-tauri/src/secrets.rs | 1 + windows/src-tauri/src/settings.rs | 23 +- 5 files changed, 545 insertions(+), 10 deletions(-) create mode 100644 windows/src-tauri/src/local_chat.rs diff --git a/windows/src-tauri/src/chat.rs b/windows/src-tauri/src/chat.rs index f9a918c44..834d5b48d 100644 --- a/windows/src-tauri/src/chat.rs +++ b/windows/src-tauri/src/chat.rs @@ -1,6 +1,7 @@ // The chat, whoever answers it: the conversation, the system prompt, and which // provider a turn goes to. Each provider's own wire format lives in its module: -// claude.rs (Anthropic) and openai_compat.rs (OpenAI, Google AI, OpenRouter). +// claude.rs (Anthropic), openai_compat.rs (OpenAI, Google AI, OpenRouter) and +// local_chat.rs (Ollama, LM Studio, any OpenAI-compatible server). // // API keys never leave the credential store and file bytes never cross the IPC // boundary: the island sends the question and gets the answer's text back. @@ -12,7 +13,7 @@ use serde_json::{json, Value}; use tauri::AppHandle; use crate::settings::Settings; -use crate::{claude, openai_compat, secrets}; +use crate::{claude, local_chat, openai_compat, secrets}; pub const ANTHROPIC: &str = "anthropic"; @@ -187,12 +188,15 @@ pub async fn send( if let Some(p) = openai_compat::provider(provider) { return openai_compat::send(chat, p, &model, query, context).await; } - let _ = app; + if let Some(server) = local_chat::server(settings, provider) { + return local_chat::send(app, chat, &server, &model, query, context).await; + } Err(format!("Unknown chat provider: {provider}")) } /// The models `provider` offers. Asked only when the user opens the picker on -/// that provider, and only once it has a key: nothing is sent anywhere before that. +/// that provider, and only once it has a key (or, for a local server, an +/// address): nothing is sent anywhere before that. pub async fn models(settings: &Settings, provider: &str) -> Result, String> { const NO_KEY: &str = "No API key — add it in Settings."; if provider == ANTHROPIC { @@ -203,7 +207,9 @@ pub async fn models(settings: &Settings, provider: &str) -> Result, provider: String) -> Result, String> { let settings = shared.settings.lock().unwrap().clone(); chat::models(&settings, &provider).await } +/// Settings → Local models → Connect: does the server answer, and with which models? +#[tauri::command] +async fn local_connect(provider: String, url: String) -> Result { + local_chat::connect(&provider, &url).await +} + #[tauri::command] fn chat_reset(chat: State) { chat.reset(); @@ -409,6 +416,7 @@ pub fn run() { log_line, chat_send, chat_models, + local_connect, chat_reset, ingest_file, secret_present, diff --git a/windows/src-tauri/src/local_chat.rs b/windows/src-tauri/src/local_chat.rs new file mode 100644 index 000000000..07a13d9dc --- /dev/null +++ b/windows/src-tauri/src/local_chat.rs @@ -0,0 +1,502 @@ +// Chat with a model server the user runs: Ollama, LM Studio, or any other +// server that speaks the OpenAI API (vLLM, llama.cpp, Unsloth…) — the same +// integration as LocalChat.swift, plus that last one. +// +// The answer is streamed token by token. Each step goes to the island as a +// `chat-delta` event carrying the text visible so far, and the island shows it +// growing; the reply of the command is the finished text. `` blocks of +// reasoning models stay hidden while open and are dropped from the answer. +// +// Nothing leaves the machine unless the user pointed the server address +// somewhere else: that address is the only place this module talks to. + +use std::time::{Duration, Instant}; + +use reqwest::Url; +use serde::Serialize; +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter}; + +use crate::chat::{self, Chat, ChatContext, ChatReply, ModelInfo}; +use crate::island::WINDOW_LABEL; +use crate::settings::Settings; +use crate::{net, secrets}; + +/// Credential store entry of the key of the user's own OpenAI-compatible server. +pub const CUSTOM_KEY: &str = "openai-compatible-key"; + +const MAX_TOKENS: u32 = 4096; +/// A text file is sent inline up to this many characters; the rest is cut. +const MAX_INLINE_CHARS: usize = 24_000; +/// The island is told about new text at most this often. +const DELTA_INTERVAL: Duration = Duration::from_millis(1000 / 15); +/// Ceilings for a streamed answer: one event line, and the whole answer. +const MAX_LINE: usize = 1024 * 1024; +const MAX_ANSWER: usize = 4 * 1024 * 1024; +/// Ollama and LM Studio ignore the key, but some clients insist on sending one. +const NO_KEY: &str = "ollama"; +/// Models that embed or rank rather than chat are left out of the list. +const NOT_CHAT: &[&str] = &["embed", "bge-", "all-minilm", "clip", "rerank"]; + +/// A model server as the settings describe it. +pub struct Server { + pub id: &'static str, + pub name: &'static str, + /// As stored: empty until the user connects it. + pub url: String, + pub key: Option, +} + +/// Ollama, LM Studio or "custom", from the settings; None for any other id. +pub fn server(settings: &Settings, id: &str) -> Option { + let (id, name, url, key) = match id { + "ollama" => ("ollama", "Ollama", &settings.ollama_url, None), + "lmstudio" => ("lmstudio", "LM Studio", &settings.lmstudio_url, None), + "custom" => ("custom", "OpenAI-compatible server", &settings.custom_url, secrets::get(CUSTOM_KEY)), + _ => return None, + }; + Some(Server { id, name, url: url.clone(), key }) +} + +/// The usual address of a server on this machine; none for "custom". Ollama's +/// own OLLAMA_HOST wins when it is set, as the Ollama CLI does. +fn usual_address(id: &str) -> Option { + match id { + "ollama" => Some( + std::env::var("OLLAMA_HOST") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "http://127.0.0.1:11434".into()), + ), + "lmstudio" => Some("http://127.0.0.1:1234".into()), + _ => None, + } +} + +fn bearer(key: Option<&str>) -> String { + format!("Bearer {}", key.filter(|k| !k.is_empty()).unwrap_or(NO_KEY)) +} + +fn unreachable(url: &Url) -> String { + format!("Cannot reach {}. Is the server running?", url.as_str().trim_end_matches('/')) +} + +fn base_url(server: &Server) -> Result { + if server.url.trim().is_empty() { + return Err(format!("Connect {} in Settings → Local models first.", server.name)); + } + net::normalise_server_url(&server.url) +} + +/// What the "Connect" button reports: the address as it will be stored, the +/// chat models the server has, and whether the address is this machine. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Connected { + pub url: String, + pub models: Vec, + pub loopback: bool, +} + +/// Settings → Local models → Connect: does the server answer, and which models +/// does it have? An empty address means the usual one on this machine. +pub async fn connect(id: &str, typed: &str) -> Result { + let raw = if typed.trim().is_empty() { usual_address(id).unwrap_or_default() } else { typed.to_string() }; + let url = net::normalise_server_url(&raw)?; + let key = if id == "custom" { secrets::get(CUSTOM_KEY) } else { None }; + let models = list(&url, key.as_deref()).await?; + Ok(Connected { + url: url.as_str().trim_end_matches('/').to_string(), + models, + loopback: net::is_loopback_url(&url), + }) +} + +/// The models of a connected server, for the picker in the chat view. +pub async fn models(server: &Server) -> Result, String> { + let url = base_url(server)?; + let models = list(&url, server.key.as_deref()).await?; + if models.is_empty() { + return Err(format!("No models yet. Download one in {} first.", server.name)); + } + Ok(models.into_iter().map(|id| ModelInfo { label: id.clone(), id }).collect()) +} + +/// `GET /v1/models`, chat models only, or why the server can't be reached. +async fn list(base: &Url, key: Option<&str>) -> Result, String> { + let response = net::client(base, Duration::from_secs(5))? + .get(net::join(base, "v1/models")) + .header("Authorization", bearer(key)) + .send() + .await + .map_err(|_| unreachable(base))?; + if matches!(response.status().as_u16(), 401 | 403) { + return Err("The server refused the key. Check it, then connect again.".into()); + } + if !response.status().is_success() { + return Err(unreachable(base)); + } + let bytes = net::read_capped(response, net::MAX_BODY).await?; + let body: Value = serde_json::from_slice(&bytes).map_err(|_| unreachable(base))?; + parse_models(&body).ok_or_else(|| unreachable(base)) +} + +fn parse_models(body: &Value) -> Option> { + let items = body.get("data")?.as_array()?; + Some( + items + .iter() + .filter_map(|m| m.get("id").and_then(Value::as_str)) + .filter(|id| !NOT_CHAT.iter().any(|n| id.to_lowercase().contains(n))) + .map(str::to_string) + .collect(), + ) +} + +// ── Messages ────────────────────────────────────────────────────────────────── + +/// What a dropped file adds to the first message: text inline (cut), anything +/// else by name only. +fn file_note(name: &str, path: &str) -> String { + let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + let binary = matches!(ext.as_str(), "pdf" | "jpg" | "jpeg" | "png" | "gif" | "webp"); + // Read no more than the inline limit can use (4 bytes per character at most). + let text = if binary { None } else { read_prefix(path, MAX_INLINE_CHARS * 4) }; + match text { + Some(text) if !text.is_empty() => { + let mut body: String = text.chars().take(MAX_INLINE_CHARS).collect(); + if body.len() < text.len() { + body.push_str("\n[truncated]"); + } + format!("File: {name}\nFile contents:\n{body}") + } + _ => format!("File: {name}"), + } +} + +/// The first `limit` bytes of a file as UTF-8 text (a character cut in half at +/// the end is dropped), or None for a file that is not text. +fn read_prefix(path: &str, limit: usize) -> Option { + use std::io::Read; + let mut bytes = Vec::new(); + std::fs::File::open(path).ok()?.take(limit as u64).read_to_end(&mut bytes).ok()?; + match String::from_utf8(bytes) { + Ok(text) => Some(text), + Err(e) if e.utf8_error().error_len().is_none() => { + let valid = e.utf8_error().valid_up_to(); + let mut bytes = e.into_bytes(); + bytes.truncate(valid); + String::from_utf8(bytes).ok() + } + Err(_) => None, + } +} + +fn user_text(first: bool, context: Option<&ChatContext>, query: &str) -> String { + match context.filter(|_| first) { + Some(ChatContext::File { name, path }) => format!("{}\n\n{query}", file_note(name, path)), + Some(ChatContext::Window { app_name, title, url }) => { + format!("{}\n\n{query}", chat::window_line(app_name, title, url.as_deref())) + } + None => query.to_string(), + } +} + +fn request_body(model: &str, system: &str, history: &[Value], user: &Value) -> Value { + let mut messages = vec![json!({ "role": "system", "content": system })]; + messages.extend(history.iter().cloned()); + messages.push(user.clone()); + json!({ "model": model, "messages": messages, "stream": true, "max_tokens": MAX_TOKENS }) +} + +/// One chat turn with a model server. +pub async fn send( + app: &AppHandle, + chat: &Chat, + server: &Server, + model: &str, + query: String, + context: Option, +) -> Result { + let base = base_url(server)?; + if model.is_empty() { + return Err("Pick a model above the chat box first.".into()); + } + let turn = chat.begin(server.id); + let user = json!({ "role": "user", "content": user_text(turn.first, context.as_ref(), &query) }); + let body = request_body(model, &chat::system_prompt(false), &turn.history, &user); + + let answer = stream(&base, server.key.as_deref(), model, &body, |visible| { + let _ = app.emit_to(WINDOW_LABEL, "chat-delta", visible); + }) + .await?; + if answer.is_empty() { + return Err("No response text.".into()); + } + let plain = chat::plain_question(turn.first, context.as_ref(), &query); + chat.commit(&turn, user, json!({ "role": "assistant", "content": answer }), &plain, &answer); + Ok(ChatReply { text: answer }) +} + +// ── Streaming ───────────────────────────────────────────────────────────────── + +/// One server-sent event line: `Some(Ok(text))` for a content delta, +/// `Some(Err(message))` for an error event, None for anything else (other +/// lines, `[DONE]`, a delta without content, such as reasoning-only ones). +fn parse_sse_line(line: &str) -> Option> { + let payload = line.strip_prefix("data:")?.trim_start(); + if payload == "[DONE]" { + return None; + } + let json: Value = serde_json::from_str(payload).ok()?; + if let Some(msg) = json.pointer("/error/message").and_then(Value::as_str) { + return Some(Err(msg.to_string())); + } + json.pointer("/choices/0/delta/content")?.as_str().map(|s| Ok(s.to_string())) +} + +/// Removes finished `…` blocks (reasoning models such as DeepSeek-R1). +fn filter_thinking_blocks(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find("") { + let Some(len) = rest[start..].find("") else { break }; + out.push_str(&rest[..start]); + rest = &rest[start + len + "".len()..]; + } + out.push_str(rest); + out.trim().to_string() +} + +/// For the text shown while streaming: finished blocks go, and so does +/// everything after a block that has not been closed yet. +fn progressive_filter(text: &str) -> String { + let cleaned = filter_thinking_blocks(text); + match cleaned.find("") { + Some(at) => cleaned[..at].trim().to_string(), + None => cleaned, + } +} + +/// Posts the request and reads the event stream. `on_delta` gets the visible +/// text at most 15 times a second, and once more at the end; the result is the +/// whole answer without its thinking. +async fn stream( + base: &Url, + key: Option<&str>, + model: &str, + body: &Value, + mut on_delta: impl FnMut(String), +) -> Result { + let mut reply = net::client(base, Duration::from_secs(300))? + .post(net::join(base, "v1/chat/completions")) + .header("Authorization", bearer(key)) + .json(body) + .send() + .await + .map_err(|_| unreachable(base))?; + + let status = reply.status(); + if status.as_u16() == 404 { + return Err(format!("{model} isn't installed. Pick another model above the chat box.")); + } + if !status.is_success() { + let body = net::read_capped(reply, net::MAX_ERROR_BODY).await.unwrap_or_default(); + let detail = net::error_detail(&body); + return Err(if detail.is_empty() { format!("HTTP {}", status.as_u16()) } else { detail }); + } + + let mut accumulated = String::new(); + let mut pending = Vec::::new(); + let mut last = Instant::now().checked_sub(DELTA_INTERVAL).unwrap_or_else(Instant::now); + while let Some(chunk) = reply.chunk().await.map_err(|_| unreachable(base))? { + pending.extend_from_slice(&chunk); + // Whole lines only: a chunk may end in the middle of one, or of a character. + while let Some(end) = pending.iter().position(|b| *b == b'\n') { + let line: Vec = pending.drain(..=end).collect(); + let line = String::from_utf8_lossy(&line); + match parse_sse_line(line.trim_end()) { + Some(Ok(delta)) => accumulated.push_str(&delta), + Some(Err(message)) => return Err(message), + None => {} + } + } + if pending.len() > MAX_LINE || accumulated.len() > MAX_ANSWER { + return Err("The server's answer is too large.".into()); + } + if last.elapsed() >= DELTA_INTERVAL { + last = Instant::now(); + on_delta(progressive_filter(&accumulated)); + } + } + // A last line without its newline. + if let Some(Ok(delta)) = parse_sse_line(String::from_utf8_lossy(&pending).trim_end()) { + accumulated.push_str(&delta); + } + // The last state always goes out, whatever the throttle skipped. + on_delta(progressive_filter(&accumulated)); + Ok(filter_thinking_blocks(&accumulated)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::net::tests::serve_once; + + fn block_on(f: impl std::future::Future) -> T { + tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(f) + } + + fn url(s: &str) -> Url { + net::normalise_server_url(s).unwrap() + } + + #[test] + fn only_content_deltas_are_taken_from_the_event_stream() { + let line = r#"data: {"choices":[{"delta":{"content":"Hel"}}]}"#; + assert_eq!(parse_sse_line(line), Some(Ok("Hel".into()))); + let line = r#"data:{"choices":[{"delta":{"content":"lo"}}]}"#; + assert_eq!(parse_sse_line(line), Some(Ok("lo".into()))); + assert_eq!(parse_sse_line("data: [DONE]"), None); + assert_eq!(parse_sse_line(""), None); + assert_eq!(parse_sse_line(": keep-alive"), None); + assert_eq!(parse_sse_line(r#"data: {"choices":[{"delta":{"role":"assistant"}}]}"#), None); + assert_eq!(parse_sse_line(r#"data: {"choices":[{"delta":{"content":null,"reasoning_content":"x"}}]}"#), None); + assert_eq!(parse_sse_line("data: not json"), None); + assert_eq!(parse_sse_line(r#"data: {"error":{"message":"out of memory"}}"#), Some(Err("out of memory".into()))); + } + + #[test] + fn finished_thinking_blocks_go_and_an_open_one_hides_what_follows() { + assert_eq!(filter_thinking_blocks("hmm\n\nThe answer."), "The answer."); + assert_eq!(filter_thinking_blocks("axbyc"), "abc"); + assert_eq!(filter_thinking_blocks("plain"), "plain"); + // Streaming: nothing of an unfinished block shows. + assert_eq!(progressive_filter("Sure. let me think"), "Sure."); + assert_eq!(progressive_filter("still thinking"), ""); + assert_eq!(progressive_filter("doneVisible again"), "Visible"); + // A tag that is only half written is not a block yet. + assert_eq!(progressive_filter("text \nstep "}}]}"#, + r#"{"choices":[{"delta":{"content":"one\n"}}]}"#, + r#"{"choices":[{"delta":{"content":null,"reasoning_content":"internal"}}]}"#, + r###"{"choices":[{"delta":{"content":"## Answer\n\n"}}]}"###, + r#"{"choices":[{"delta":{"content":"- **item 1**\n"}}]}"#, + r#"{"choices":[{"delta":{"content":"```python\nprint('hello')\n```"}}]}"#, + ]; + let mut body = String::new(); + for e in events { + body.push_str(&format!("data: {e}\n\n")); + } + body.push_str("data: [DONE]\n\n"); + let u = serve_once("200 OK", "Content-Type: text/event-stream\r\n", body.into_bytes()); + let mut seen = Vec::new(); + let answer = block_on(stream(&url(&u), None, "m", &json!({}), |v| seen.push(v))).unwrap(); + assert_eq!(answer, "## Answer\n\n- **item 1**\n```python\nprint('hello')\n```"); + assert_eq!(seen.last().unwrap(), &answer); + assert!(seen.iter().all(|v| !v.contains("step") && !v.contains("internal"))); + } + + #[test] + fn stream_errors_say_what_happened() { + let u = serve_once("404 Not Found", "Content-Length: 2\r\n", b"{}".to_vec()); + let err = block_on(stream(&url(&u), None, "unknown-model", &json!({}), |_| {})).unwrap_err(); + assert_eq!(err, "unknown-model isn't installed. Pick another model above the chat box."); + let body = br#"{"error":{"message":"context too long"}}"#; + let u = serve_once("400 Bad Request", &format!("Content-Length: {}\r\n", body.len()), body.to_vec()); + assert_eq!(block_on(stream(&url(&u), None, "m", &json!({}), |_| {})).unwrap_err(), "context too long"); + let u = serve_once("200 OK", "", b"data: {\"error\":{\"message\":\"crashed\"}}\n".to_vec()); + assert_eq!(block_on(stream(&url(&u), None, "m", &json!({}), |_| {})).unwrap_err(), "crashed"); + } + + #[test] + fn an_endless_line_is_cut_off() { + let u = serve_once("200 OK", "", vec![b'x'; MAX_LINE + 10]); + let err = block_on(stream(&url(&u), None, "m", &json!({}), |_| {})).unwrap_err(); + assert_eq!(err, "The server's answer is too large."); + } + + #[test] + fn text_files_go_inline_cut_and_everything_else_by_name() { + let dir = std::env::temp_dir().join(format!("coucou-local-chat-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let txt = dir.join("notes.txt"); + std::fs::write(&txt, "é".repeat(MAX_INLINE_CHARS + 500)).unwrap(); + let note = file_note("notes.txt", txt.to_str().unwrap()); + let body = note.strip_prefix("File: notes.txt\nFile contents:\n").expect("inline text"); + let body = body.strip_suffix("\n[truncated]").expect("marked as cut"); + assert_eq!(body.chars().count(), MAX_INLINE_CHARS); + + let small = dir.join("small.md"); + std::fs::write(&small, "# hi").unwrap(); + assert_eq!(file_note("small.md", small.to_str().unwrap()), "File: small.md\nFile contents:\n# hi"); + + let png = dir.join("pic.png"); + std::fs::write(&png, "not really a picture").unwrap(); + assert_eq!(file_note("pic.png", png.to_str().unwrap()), "File: pic.png"); + assert_eq!(file_note("gone.txt", "/no/such/file"), "File: gone.txt"); + let bin = dir.join("blob.bin"); + std::fs::write(&bin, [0xff, 0xfe, 0x00, 0x80]).unwrap(); + assert_eq!(file_note("blob.bin", bin.to_str().unwrap()), "File: blob.bin"); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/windows/src-tauri/src/secrets.rs b/windows/src-tauri/src/secrets.rs index 46b94b09e..7b7a76717 100644 --- a/windows/src-tauri/src/secrets.rs +++ b/windows/src-tauri/src/secrets.rs @@ -11,6 +11,7 @@ pub const KNOWN_KEYS: &[&str] = &[ "openai-api-key", "google-api-key", "openrouter-api-key", + "openai-compatible-key", "n8n-url", "n8n-api-key", "vercel-token", diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 6abe0948f..25445da3b 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -24,12 +24,18 @@ pub struct Settings { pub hooks_installed: bool, /// Claude model used by the chat. Changeable in the settings window. pub model: String, - /// Who the chat talks to: "anthropic", or a cloud provider of - /// openai_compat.rs ("openai", "google", "openrouter"). Picked in the chat view. + /// Who the chat talks to: "anthropic", a cloud provider of + /// openai_compat.rs ("openai", "google", "openrouter"), or a model server + /// of local_chat.rs ("ollama", "lmstudio", "custom"). Picked in the chat view. pub chat_provider: String, /// The model picked for each provider other than Anthropic (whose model is /// `model`), by provider id. pub chat_models: BTreeMap, + /// Addresses of the model servers once connected; empty means not connected. + pub ollama_url: String, + pub lmstudio_url: String, + /// Any other OpenAI-compatible server; its key, if any, is in the keychain. + pub custom_url: String, } fn default_model() -> String { @@ -55,6 +61,9 @@ impl Default for Settings { model: default_model(), chat_provider: crate::chat::ANTHROPIC.into(), chat_models: BTreeMap::new(), + ollama_url: String::new(), + lmstudio_url: String::new(), + custom_url: String::new(), } } } @@ -316,8 +325,11 @@ mod tests { "autostart": true, "hooksInstalled": true, "model": "some-model", - "chatProvider": "openai", - "chatModels": { "openai": "gpt-x" } + "chatProvider": "ollama", + "chatModels": { "ollama": "llama3.2", "openai": "gpt-x" }, + "ollamaUrl": "http://127.0.0.1:11434", + "lmstudioUrl": "http://127.0.0.1:1234", + "customUrl": "https://llm.example.com" }"#; fn custom() -> Value { @@ -645,6 +657,9 @@ mod tests { "model", "chatProvider", "chatModels", + "ollamaUrl", + "lmstudioUrl", + "customUrl", ] ); let _ = std::fs::remove_dir_all(&dir); From 5b4ee6188e2c3b59dd308540a874750afbf8f54b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:25:39 +0200 Subject: [PATCH 37/77] Chat: Markdown in the answers, with a copy button on code blocks As on the Mac since #156 (ChatMarkdown.swift, ChatMarkdownView.swift): headings, paragraphs, bullet and numbered lists, quotes, rules, and fenced code blocks with a copy button; bold, italic, inline code and links inside the text. Every provider's answer goes through it. - Built as DOM nodes from text only, never as HTML: nothing a model writes can inject markup. The tests run the renderer on a fake DOM whose innerHTML throws. - A link opens only when it is http or https, and through Rust's open_url, which checks the scheme again; any other target stays plain text. - Inline syntax is parsed into a tree first (parseInline), so it can be tested without a webview. No lookbehind in the regex: older WebKitGTK builds reject it, and one bad literal would take the whole island down. snake_case words are not italics. Based on #173 by Justin Minkmar (markdown.ts and its styles). Co-Authored-By: Claude Opus 5.5 --- windows/src/style.css | 121 +++++++++++++++ windows/src/views/chat.ts | 7 +- windows/src/views/icons.ts | 4 + windows/src/views/markdown.ts | 265 ++++++++++++++++++++++++++++++++ windows/tests/fakedom.mjs | 133 ++++++++++++++++ windows/tests/markdown.test.mjs | 137 +++++++++++++++++ 6 files changed, 665 insertions(+), 2 deletions(-) create mode 100644 windows/src/views/markdown.ts create mode 100644 windows/tests/fakedom.mjs create mode 100644 windows/tests/markdown.test.mjs diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c..12f190668 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -827,6 +827,127 @@ input[type="range"]::-webkit-slider-thumb { white-space: pre-wrap; padding-right: 8px; user-select: text; + /* Answers are Markdown blocks (views/markdown.ts), spaced as ChatMarkdownView. */ + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +/* ── Markdown in the answers ─────────────────────────────────────────────── */ + +.md-p { + font: 400 12.5px var(--font); + color: #b0b5be; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.md-h { + font: 600 13px var(--font); + color: var(--ink-2); + overflow-wrap: anywhere; +} + +.md-h1 { + font-weight: 700; + font-size: 14px; +} + +.reply strong { + color: #e8e9ec; + font-weight: 600; +} + +.md-code { + padding: 1px 4px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.08); + font: 400 11.5px var(--mono); + color: #c8cdd4; +} + +.md-link { + color: #60a5fa; + text-decoration: underline; + cursor: pointer; +} + +.md-pre { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: #0d0e12; +} + +.md-pre pre { + flex: 1 1 auto; + min-width: 0; + font: 400 11px var(--mono); + color: #c8cdd4; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.md-copy { + flex: 0 0 22px; + display: grid; + place-items: center; + width: 22px; + height: 22px; + padding: 0; + border: 0; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + color: var(--dim-3); + cursor: default; +} + +.md-copy:hover { + color: var(--dim); +} + +.md-copy.done { + color: var(--green); +} + +.md-item { + display: flex; + align-items: flex-start; + gap: 6px; +} + +.md-bullet { + flex: 0 0 auto; + min-width: 10px; + font: 400 12.5px var(--font); + color: var(--dim-3); +} + +.md-quote { + display: flex; + align-items: stretch; + gap: 8px; +} + +.md-quote i { + flex: 0 0 2px; + border-radius: 1px; + background: var(--dim-5); +} + +.md-quote .md-p { + color: #8a8f98; +} + +.md-rule { + width: 100%; + border: 0; + border-top: 1px solid rgba(255, 255, 255, 0.3); + opacity: 0.3; } .typing { diff --git a/windows/src/views/chat.ts b/windows/src/views/chat.ts index 2cd253f7b..6b28965d6 100644 --- a/windows/src/views/chat.ts +++ b/windows/src/views/chat.ts @@ -1,8 +1,9 @@ // Chat view — DOM port of PromptView / ChatBubble / TypingDotsView from -// IslandViewContent.swift. +// IslandViewContent.swift. Answers are rendered as Markdown (markdown.ts). import { h, svg, clear } from "./dom"; import { ICONS } from "./icons"; +import { renderMarkdown } from "./markdown"; import { Bridge, type ChatContext } from "../core/bridge"; import { Sound } from "../core/sound"; import { State, type ChatMessage } from "../core/state"; @@ -18,7 +19,9 @@ function bubble(message: ChatMessage): HTMLElement { h("div", { class: "bubble", text: message.content }), ); } - return h("div", { class: "chat-row" }, h("div", { class: "reply", text: message.content })); + const reply = h("div", { class: "reply" }); + renderMarkdown(reply, message.content); + return h("div", { class: "chat-row" }, reply); } function typingDots(): HTMLElement { diff --git a/windows/src/views/icons.ts b/windows/src/views/icons.ts index d2ff82475..e2fc64e55 100644 --- a/windows/src/views/icons.ts +++ b/windows/src/views/icons.ts @@ -36,6 +36,10 @@ export const ICONS = { star: "M12 3.2l2.6 5.55 5.9.82-4.3 4.3 1.05 6.13L12 17.1l-5.25 2.9L7.8 13.87 3.5 9.57l5.9-.82L12 3.2z", // square.stack.fill stack: "M5 8h14v11.5H5V8zm1.8-3h10.4v1.6H6.8V5zm1.6-2.6h7.2V4H8.4V2.4z", + // doc.on.doc (copy) + copy: "M9 3.5h8.5L21 7v10.5H9v-14zm1.8 1.8v10.4h8.4V7.8l-2.5-2.5h-5.9zM3 7.5h4.2v1.8H4.8v10.4h8.4v-2.2H15v4H3v-14z", + // chevron.up.chevron.down + chevronUpDown: "M8 9.5 12 5.5l4 4M8 14.5l4 4 4-4", // doc.text doc: "M6.5 2.6h7l4 4v14.8h-11V2.6zm6.6 1.6v3.3h3.3l-3.3-3.3zM8.6 11h6.8v1.5H8.6V11zm0 3.4h6.8v1.5H8.6v-1.5z", } as const; diff --git a/windows/src/views/markdown.ts b/windows/src/views/markdown.ts new file mode 100644 index 000000000..0501af197 --- /dev/null +++ b/windows/src/views/markdown.ts @@ -0,0 +1,265 @@ +// Markdown in the chat's answers, as on the Mac (ChatMarkdown.swift and +// ChatMarkdownView.swift): headings, paragraphs, fenced code with a copy button, +// bullet and numbered lists, quotes and rules, with bold, italic, `code` and +// links inside the text. +// +// Everything is built as DOM nodes with textContent, never as HTML, so nothing +// a model writes can inject markup. Links open only when they are http(s), and +// even then through Rust, which checks the scheme again. + +import { Bridge } from "../core/bridge"; +import { h, svg } from "./dom"; +import { ICONS } from "./icons"; + +const STRINGS = { + copy: "Copy", + copied: "Copied", +}; + +export type Block = + | { kind: "heading"; level: number; text: string } + | { kind: "paragraph"; text: string } + | { kind: "code"; lang: string; code: string } + /** `prefix` is "•" or "1."; `indent` the nesting level, from 0. */ + | { kind: "item"; prefix: string; text: string; indent: number } + | { kind: "quote"; text: string } + | { kind: "rule" }; + +const isRule = (s: string) => s === "---" || s === "***" || s === "___"; +const isBullet = (s: string) => /^[-*+] /.test(s); +const ordered = (s: string) => /^(\d+)\.\s+/.exec(s); +const isQuote = (s: string) => s.startsWith("> ") || s === ">"; + +/** Same rules as the Mac's parser, line by line. */ +export function parseMarkdown(input: string): Block[] { + const blocks: Block[] = []; + const lines = input.replace(/\r\n?/g, "\n").split("\n"); + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + if (line.startsWith("```")) { + const lang = line.slice(3).trim(); + const code: string[] = []; + for (i++; i < lines.length && !lines[i].startsWith("```"); i++) code.push(lines[i]); + blocks.push({ kind: "code", lang, code: code.join("\n") }); + i++; + continue; + } + + // A heading needs a space after the #s; "#hashtag" stays a paragraph. + const hashes = /^#+/.exec(line)?.[0].length ?? 0; + if (hashes && (line.length === hashes || line[hashes] === " ")) { + const text = line.slice(hashes).trim(); + if (text) blocks.push({ kind: "heading", level: Math.min(hashes, 6), text }); + i++; + continue; + } + + const stripped = line.trim(); + if (isRule(stripped)) { + blocks.push({ kind: "rule" }); + i++; + continue; + } + if (isQuote(stripped)) { + blocks.push({ kind: "quote", text: stripped.startsWith("> ") ? stripped.slice(2) : "" }); + i++; + continue; + } + + const indent = Math.floor((line.length - line.trimStart().length) / 2); + if (isBullet(stripped)) { + blocks.push({ kind: "item", prefix: "•", text: stripped.slice(2), indent }); + i++; + continue; + } + const number = ordered(stripped); + if (number) { + blocks.push({ kind: "item", prefix: `${number[1]}.`, text: stripped.slice(number[0].length), indent }); + i++; + continue; + } + if (!stripped) { + i++; + continue; + } + + // A paragraph runs until a blank line or the start of another block. + const para = [line]; + for (i++; i < lines.length; i++) { + const next = lines[i]; + const s = next.trim(); + if (!s || next.startsWith("#") || next.startsWith("```") || isQuote(s) || isBullet(s) || isRule(s) || ordered(s)) break; + para.push(next); + } + blocks.push({ kind: "paragraph", text: para.join("\n") }); + } + return blocks; +} + +// ── Inline ──────────────────────────────────────────────────────────────────── + +export type Inline = + | { kind: "text"; text: string } + | { kind: "code"; text: string } + | { kind: "strong"; children: Inline[] } + | { kind: "em"; children: Inline[] } + /** `url` is null when the target is not a web address: the text stays, the link goes. */ + | { kind: "link"; url: string | null; children: Inline[] }; + +/** + * `code`, **bold**, __bold__, *italic*, _italic_ and [text](url), whichever + * comes first. No lookbehind: older WebKitGTK builds reject it, and one bad + * regex literal would take the whole island down with it. + */ +const INLINE = /`([^`\n]+)`|\*\*([^*]+?)\*\*|__([^_]+?)__|\*([^*\s][^*]*?)\*|_([^_\s][^_]*?)_(?!\w)|\[([^\]\n]+)\]\(([^)\s]+)\)/g; + +const isWordChar = (c: string | undefined) => c != null && /\w/.test(c); + +/** The address if it is an http(s) one; anything else (javascript:, file:, data:…) is not a link. */ +export function safeWebUrl(raw: string): string | null { + try { + const url = new URL(raw.trim()); + return url.protocol === "http:" || url.protocol === "https:" ? url.href : null; + } catch { + return null; + } +} + +export function parseInline(text: string): Inline[] { + const out: Inline[] = []; + const pushText = (t: string) => { + if (!t) return; + const last = out[out.length - 1]; + if (last?.kind === "text") last.text += t; + else out.push({ kind: "text", text: t }); + }; + const re = new RegExp(INLINE.source, "g"); + let at = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text))) { + // snake_case_words are not italics: an opening _ must not follow a letter. + if (m[5] != null && isWordChar(text[m.index - 1])) { + re.lastIndex = m.index + 1; + continue; + } + pushText(text.slice(at, m.index)); + at = m.index + m[0].length; + if (m[1] != null) out.push({ kind: "code", text: m[1] }); + else if (m[6] != null) out.push({ kind: "link", url: safeWebUrl(m[7]), children: parseInline(m[6]) }); + else if (m[2] != null || m[3] != null) out.push({ kind: "strong", children: parseInline(m[2] ?? m[3]) }); + else out.push({ kind: "em", children: parseInline(m[4] ?? m[5]) }); + } + pushText(text.slice(at)); + return out; +} + +function appendInline(into: HTMLElement, nodes: Inline[]) { + for (const node of nodes) { + switch (node.kind) { + case "text": + into.append(document.createTextNode(node.text)); + break; + case "code": + into.append(h("code", { class: "md-code", text: node.text })); + break; + case "strong": + case "em": { + const el = h(node.kind === "strong" ? "strong" : "em"); + appendInline(el, node.children); + into.append(el); + break; + } + case "link": { + if (!node.url) { + appendInline(into, node.children); + break; + } + const url = node.url; + const a = h("a", { class: "md-link", href: url, title: url }); + a.addEventListener("click", (e) => { + e.preventDefault(); + void Bridge.openUrl(url); + }); + appendInline(a, node.children); + into.append(a); + break; + } + } + } +} + +function inline(text: string, into: HTMLElement) { + appendInline(into, parseInline(text)); +} + +// ── Blocks ──────────────────────────────────────────────────────────────────── + +async function writeClipboard(text: string) { + try { + await navigator.clipboard.writeText(text); + } catch { + // No clipboard API (or no permission): the old way, through a hidden field. + const area = h("textarea", { style: "position:fixed;opacity:0" }) as HTMLTextAreaElement; + area.value = text; + document.body.append(area); + area.select(); + document.execCommand("copy"); + area.remove(); + } +} + +/** Puts the code on the clipboard; the check mark shows for a moment. */ +function copyButton(text: string): HTMLElement { + const btn = h("button", { class: "md-copy", title: STRINGS.copy, "aria-label": STRINGS.copy }, svg(ICONS.copy, 11)); + btn.addEventListener("click", async (e) => { + e.stopPropagation(); + await writeClipboard(text); + btn.replaceChildren(svg(ICONS.check, 11, { stroke: 2.2 })); + btn.classList.add("done"); + btn.title = STRINGS.copied; + window.setTimeout(() => { + btn.replaceChildren(svg(ICONS.copy, 11)); + btn.classList.remove("done"); + btn.title = STRINGS.copy; + }, 1500); + }); + return btn; +} + +function render(block: Block): HTMLElement { + switch (block.kind) { + case "heading": { + const el = h("div", { class: block.level <= 2 ? "md-h md-h1" : "md-h" }); + inline(block.text, el); + return el; + } + case "paragraph": { + const el = h("div", { class: "md-p" }); + inline(block.text, el); + return el; + } + case "code": + return h("div", { class: "md-pre" }, h("pre", { text: block.code }), copyButton(block.code)); + case "item": { + const text = h("span", { class: "md-p" }); + inline(block.text, text); + const item = h("div", { class: "md-item" }, h("span", { class: "md-bullet", text: block.prefix }), text); + if (block.indent) item.style.paddingLeft = `${block.indent * 12}px`; + return item; + } + case "quote": { + const text = h("span", { class: "md-p" }); + inline(block.text, text); + return h("div", { class: "md-quote" }, h("i"), text); + } + case "rule": + return h("hr", { class: "md-rule" }); + } +} + +/** Replaces what `into` shows with the Markdown rendered. */ +export function renderMarkdown(into: HTMLElement, markdown: string) { + into.replaceChildren(...parseMarkdown(markdown).map(render)); +} diff --git a/windows/tests/fakedom.mjs b/windows/tests/fakedom.mjs new file mode 100644 index 000000000..a8869b44f --- /dev/null +++ b/windows/tests/fakedom.mjs @@ -0,0 +1,133 @@ +// Just enough of a DOM for the views' helpers (views/dom.ts) to build nodes in +// Node. Writing `innerHTML` throws: a view that renders untrusted text must +// never parse it as markup, and a test that reaches for it fails loudly. + +class FakeText { + constructor(text) { + this.nodeType = 3; + this.data = String(text); + this.parentNode = null; + } + get textContent() { + return this.data; + } +} + +class FakeElement { + constructor(tag, ns = null) { + this.nodeType = 1; + this.tagName = String(tag).toUpperCase(); + this.namespaceURI = ns; + this.childNodes = []; + this.parentNode = null; + this.attributes = new Map(); + this.listeners = new Map(); + this.className = ""; + this.title = ""; + this.dataset = {}; + this.scrollTop = 0; + this.scrollHeight = 0; + this.style = { setProperty: (k, v) => (this.style[k] = v) }; + const el = this; + this.classList = { + add: (c) => (el.className = [...new Set([...el.className.split(" ").filter(Boolean), c])].join(" ")), + remove: (c) => (el.className = el.className.split(" ").filter((x) => x && x !== c).join(" ")), + contains: (c) => el.className.split(" ").includes(c), + toggle: (c, on) => ((on ?? !el.classList.contains(c)) ? el.classList.add(c) : el.classList.remove(c)), + }; + } + set innerHTML(_) { + throw new Error("innerHTML must never be used"); + } + get innerHTML() { + throw new Error("innerHTML must never be used"); + } + set outerHTML(_) { + throw new Error("outerHTML must never be used"); + } + insertAdjacentHTML() { + throw new Error("insertAdjacentHTML must never be used"); + } + get parentElement() { + return this.parentNode?.nodeType === 1 ? this.parentNode : null; + } + get children() { + return this.childNodes.filter((n) => n.nodeType === 1); + } + get firstChild() { + return this.childNodes[0] ?? null; + } + get textContent() { + return this.childNodes.map((n) => n.textContent).join(""); + } + set textContent(text) { + this.childNodes = []; + if (text !== "") this.append(new FakeText(text)); + } + setAttribute(k, v) { + this.attributes.set(k, String(v)); + } + getAttribute(k) { + return this.attributes.get(k) ?? null; + } + addEventListener(type, fn) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), fn]); + } + /** Calls the listeners of `type` with a minimal event. */ + fire(type) { + const event = { type, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }, stopPropagation() {} }; + for (const fn of this.listeners.get(type) ?? []) fn(event); + return event; + } + append(...nodes) { + for (const n of nodes) { + const node = typeof n === "string" ? new FakeText(n) : n; + node.parentNode = this; + this.childNodes.push(node); + } + } + replaceChildren(...nodes) { + this.childNodes = []; + this.append(...nodes); + } + removeChild(node) { + this.childNodes = this.childNodes.filter((n) => n !== node); + return node; + } + remove() { + this.parentNode?.removeChild(this); + } + /** `.a.b` class selectors only: the first descendant carrying every class. */ + querySelector(selector) { + const wanted = selector.split(".").filter(Boolean); + return [...this.walk()].find((e) => wanted.every((c) => e.classList.contains(c))) ?? null; + } + scrollIntoView() {} + focus() {} + select() {} + /** Every descendant element, depth first. */ + *walk() { + for (const c of this.children) { + yield c; + yield* c.walk(); + } + } + /** Descendants by tag name ("A") or class (".md-p"). */ + find(selector) { + const match = selector.startsWith(".") + ? (e) => e.classList.contains(selector.slice(1)) + : (e) => e.tagName === selector.toUpperCase(); + return [...this.walk()].filter(match); + } +} + +export function installFakeDom() { + globalThis.requestAnimationFrame = (fn) => fn(); + globalThis.document = { + createElement: (tag) => new FakeElement(tag), + createElementNS: (ns, tag) => new FakeElement(tag, ns), + createTextNode: (text) => new FakeText(text), + body: new FakeElement("body"), + }; + return { root: () => new FakeElement("div") }; +} diff --git a/windows/tests/markdown.test.mjs b/windows/tests/markdown.test.mjs new file mode 100644 index 000000000..5fc3d08e1 --- /dev/null +++ b/windows/tests/markdown.test.mjs @@ -0,0 +1,137 @@ +// Markdown in the chat's answers (src/views/markdown.ts): the block parser +// follows ChatMarkdown.swift, inline syntax is parsed to a tree, and rendering +// builds DOM nodes from text only — never HTML — with http(s)-only links. + +import { beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { installFakeDom } from "./fakedom.mjs"; +import { calls, sent } from "./tauri.mjs"; + +const dom = installFakeDom(); +const { parseMarkdown, parseInline, renderMarkdown, safeWebUrl } = await import("../src/views/markdown.ts"); + +beforeEach(() => { + calls.length = 0; +}); + +// ── Blocks ──────────────────────────────────────────────────────────────────── + +test("headings need a space after the hashes", () => { + assert.deepEqual(parseMarkdown("# Title\n## Sub\n####### Deep"), [ + { kind: "heading", level: 1, text: "Title" }, + { kind: "heading", level: 2, text: "Sub" }, + { kind: "heading", level: 6, text: "Deep" }, + ]); + assert.deepEqual(parseMarkdown("#hashtag"), [{ kind: "paragraph", text: "#hashtag" }]); +}); + +test("a fenced code block keeps its lines as they are, language apart", () => { + const md = "Before\n```python\nprint('hi')\n indented\n\n# not a heading\n```\nAfter"; + assert.deepEqual(parseMarkdown(md), [ + { kind: "paragraph", text: "Before" }, + { kind: "code", lang: "python", code: "print('hi')\n indented\n\n# not a heading" }, + { kind: "paragraph", text: "After" }, + ]); + // An unclosed fence runs to the end, as while an answer is still streaming. + assert.deepEqual(parseMarkdown("```\nlet x"), [{ kind: "code", lang: "", code: "let x" }]); +}); + +test("lists keep their markers and nesting", () => { + assert.deepEqual(parseMarkdown("- one\n* two\n + nested\n1. first\n12. twelfth"), [ + { kind: "item", prefix: "•", text: "one", indent: 0 }, + { kind: "item", prefix: "•", text: "two", indent: 0 }, + { kind: "item", prefix: "•", text: "nested", indent: 1 }, + { kind: "item", prefix: "1.", text: "first", indent: 0 }, + { kind: "item", prefix: "12.", text: "twelfth", indent: 0 }, + ]); +}); + +test("quotes, rules and paragraphs that stop at the next block", () => { + assert.deepEqual(parseMarkdown("> quoted\n>\n---\nline one\nline two\n- item\n\npara"), [ + { kind: "quote", text: "quoted" }, + { kind: "quote", text: "" }, + { kind: "rule" }, + { kind: "paragraph", text: "line one\nline two" }, + { kind: "item", prefix: "•", text: "item", indent: 0 }, + { kind: "paragraph", text: "para" }, + ]); + assert.deepEqual(parseMarkdown("a\r\nb"), [{ kind: "paragraph", text: "a\nb" }]); + assert.deepEqual(parseMarkdown(""), []); +}); + +// ── Inline ──────────────────────────────────────────────────────────────────── + +test("inline code, bold, italic and links nest as a tree", () => { + assert.deepEqual(parseInline("Use `npm ci` **now**, *really* or __not__ _ever_."), [ + { kind: "text", text: "Use " }, + { kind: "code", text: "npm ci" }, + { kind: "text", text: " " }, + { kind: "strong", children: [{ kind: "text", text: "now" }] }, + { kind: "text", text: ", " }, + { kind: "em", children: [{ kind: "text", text: "really" }] }, + { kind: "text", text: " or " }, + { kind: "strong", children: [{ kind: "text", text: "not" }] }, + { kind: "text", text: " " }, + { kind: "em", children: [{ kind: "text", text: "ever" }] }, + { kind: "text", text: "." }, + ]); + assert.deepEqual(parseInline("[**Docs**](https://example.com/a?b=1)"), [ + { kind: "link", url: "https://example.com/a?b=1", children: [{ kind: "strong", children: [{ kind: "text", text: "Docs" }] }] }, + ]); +}); + +test("snake_case words and code spans are not italics", () => { + assert.deepEqual(parseInline("call my_long_name now"), [{ kind: "text", text: "call my_long_name now" }]); + assert.deepEqual(parseInline("`a *b* c`"), [{ kind: "code", text: "a *b* c" }]); + assert.deepEqual(parseInline("2 * 3 * 4"), [{ kind: "text", text: "2 * 3 * 4" }]); +}); + +test("only http and https addresses become links", () => { + assert.equal(safeWebUrl("https://example.com"), "https://example.com/"); + assert.equal(safeWebUrl("http://localhost:3000/x"), "http://localhost:3000/x"); + for (const bad of ["javascript:alert(1)", "JaVaScRiPt:alert(1)", "file:///etc/passwd", "data:text/html,x", "vbscript:x", "/relative", "mailto:a@b.c", "not a url"]) { + assert.equal(safeWebUrl(bad), null, bad); + } + assert.deepEqual(parseInline("[click](javascript:alert(1))"), [ + { kind: "link", url: null, children: [{ kind: "text", text: "click" }] }, + { kind: "text", text: ")" }, + ]); +}); + +// ── Rendering ───────────────────────────────────────────────────────────────── + +test("markup in an answer is shown as text, never parsed", () => { + const root = dom.root(); + const evil = ' ****\n```\ncode\n```'; + renderMarkdown(root, evil); // fakedom throws if anything touches innerHTML + assert.equal(root.find("IMG").length, 0); + assert.equal(root.find("SCRIPT").length, 0); + assert.equal(root.find("B").length, 0); + assert.match(root.textContent, //); + assert.equal(root.find("STRONG")[0].textContent, ""); + assert.equal(root.find("PRE")[0].textContent, "code"); +}); + +test("each block becomes its own element", () => { + const root = dom.root(); + renderMarkdown(root, "## Answer\n\nHere:\n\n- **item 1**\n- item 2\n\n> note\n\n---\n\n```js\nx()\n```"); + const classes = root.children.map((c) => c.className); + assert.deepEqual(classes, ["md-h md-h1", "md-p", "md-item", "md-item", "md-quote", "md-rule", "md-pre"]); + assert.equal(root.find(".md-bullet")[0].textContent, "•"); + assert.equal(root.find(".md-copy").length, 1); + // Rendering again replaces, it never appends. + renderMarkdown(root, "plain"); + assert.equal(root.children.length, 1); +}); + +test("a web link opens through Rust; anything else is plain text", () => { + const root = dom.root(); + renderMarkdown(root, "See [the docs](https://example.com/docs) or [this](file:///etc/passwd)."); + const links = root.find("A"); + assert.equal(links.length, 1); + assert.equal(links[0].getAttribute("href"), "https://example.com/docs"); + const event = links[0].fire("click"); + assert.equal(event.defaultPrevented, true); + assert.deepEqual(sent("open_url"), [{ url: "https://example.com/docs" }]); + assert.match(root.textContent, /or this\.$/); +}); From 741aab2a9a70de9c250ea528c5e1b635f0fe0394 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:26:00 +0200 Subject: [PATCH 38/77] Chat view: switch provider and model by clicking the model name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As on the Mac (PromptView's model button and ModelPickerView): the model in use sits above the text field with its provider's colour; a click opens the picker inside the chat card — provider chips, then that provider's models — and Escape or a second click folds it. - Cloud providers are always offered; one without a key says so, with a button to Settings, and is never asked for its models. A model server shows once connected, or while it is the one in use. - The models are asked for only when a provider is picked (cached for the session; a model server is asked again each time, as on the Mac). If the saved model is not offered, a sensible one is kept and saved (Gemini flash, OpenAI mini, else the first). - Picking saves chatProvider / chatModels; switching mid-conversation keeps the conversation (Rust converts it). No switching while an answer comes. - A local model's answer grows in place from the chat-delta events, the typing dots staying until the first visible text; the finished answer then replaces it. - core/providers.ts is the island's provider table (ids, colours, keys, defaults) and the address check the Settings window uses, mirroring net.rs. Strings are grouped at the top of chat.ts. Co-Authored-By: Claude Opus 5.5 --- windows/src/core/bridge.ts | 21 +++ windows/src/core/providers.ts | 105 ++++++++++++++ windows/src/core/state.ts | 14 ++ windows/src/style.css | 171 ++++++++++++++++++++++ windows/src/views/chat.ts | 239 +++++++++++++++++++++++++++++-- windows/tests/chat.test.mjs | 124 ++++++++++++++++ windows/tests/providers.test.mjs | 74 ++++++++++ 7 files changed, 736 insertions(+), 12 deletions(-) create mode 100644 windows/src/core/providers.ts create mode 100644 windows/tests/chat.test.mjs create mode 100644 windows/tests/providers.test.mjs diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..185ffe75c 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -85,6 +85,14 @@ export const Bridge = { chatSend: (query: string, context: ChatContext | null) => callOrThrow<{ text: string }>("chat_send", { query, context }), chatReset: () => call("chat_reset"), + /** + * The models a provider offers, for the picker in the chat view. Rust asks + * the provider only when it has a key (or a server address). + */ + chatModels: (provider: string) => callOrThrow("chat_models", { provider }), + /** Settings → Local models → Connect: does the server answer, and with which models? */ + localConnect: (provider: "ollama" | "lmstudio" | "custom", url: string) => + callOrThrow("local_connect", { provider, 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. */ @@ -112,6 +120,19 @@ export type ChatContext = | { kind: "file"; name: string; path: string } | { kind: "window"; appName: string; title: string; url?: string }; +export interface ModelInfo { + id: string; + label: string; +} + +export interface LocalServer { + /** The address as it is stored. */ + url: string; + models: string[]; + /** The address is this machine. */ + loopback: boolean; +} + export interface DroppedFile { name: string; path: string; diff --git a/windows/src/core/providers.ts b/windows/src/core/providers.ts new file mode 100644 index 000000000..346c32c63 --- /dev/null +++ b/windows/src/core/providers.ts @@ -0,0 +1,105 @@ +// Who the chat can talk to — the island's side of chat.rs. The Mac's +// ChatProvider (IslandTypes.swift) plus OpenRouter and any OpenAI-compatible +// server. Pure data and helpers, so they can be tested without a webview. + +import type { Settings } from "./state"; + +export type ProviderId = + | "anthropic" | "openai" | "google" | "openrouter" + | "ollama" | "lmstudio" | "custom"; + +export interface ProviderDef { + id: ProviderId; + /** Shown on the chip in the chat's model picker. */ + name: string; + accent: string; + /** Credential store entry of its key; null for the model servers. */ + key: string | null; + /** Settings field holding a model server's address. */ + urlField: "ollamaUrl" | "lmstudioUrl" | "customUrl" | null; + defaultModel: string; + /** When the saved model is not offered, the first one containing this is picked. */ + prefer: string | null; +} + +export const PROVIDERS: readonly ProviderDef[] = [ + { id: "anthropic", name: "Anthropic", accent: "#E07950", key: "anthropic-api-key", urlField: null, defaultModel: "claude-opus-5", prefer: "opus" }, + { id: "google", name: "Google", accent: "#4285F4", key: "google-api-key", urlField: null, defaultModel: "gemini-2.0-flash", prefer: "flash" }, + { id: "openai", name: "OpenAI", accent: "#10A37F", key: "openai-api-key", urlField: null, defaultModel: "gpt-4o", prefer: "mini" }, + { id: "openrouter", name: "OpenRouter", accent: "#6467F2", key: "openrouter-api-key", urlField: null, defaultModel: "openrouter/auto", prefer: null }, + { id: "ollama", name: "Ollama", accent: "#FACC15", key: null, urlField: "ollamaUrl", defaultModel: "", prefer: null }, + { id: "lmstudio", name: "LM Studio", accent: "#A3E635", key: null, urlField: "lmstudioUrl", defaultModel: "", prefer: null }, + { id: "custom", name: "Custom server", accent: "#C0C4CC", key: null, urlField: "customUrl", defaultModel: "", prefer: null }, +]; + +/** Credential store entry of the custom server's optional key. */ +export const CUSTOM_SERVER_KEY = "openai-compatible-key"; + +export function providerDef(id: string): ProviderDef { + return PROVIDERS.find((p) => p.id === id) ?? PROVIDERS[0]; +} + +/** The model the chat uses for the active provider. */ +export function activeModel(settings: Settings): string { + const p = providerDef(settings.chatProvider); + if (p.id === "anthropic") return settings.model || p.defaultModel; + return settings.chatModels[p.id] || p.defaultModel; +} + +/** `settings` with `model` picked for `provider`. */ +export function withModel(settings: Settings, provider: ProviderId, model: string): Settings { + if (provider === "anthropic") return { ...settings, model }; + return { ...settings, chatModels: { ...settings.chatModels, [provider]: model } }; +} + +/** + * The chips of the picker: every cloud provider (one without a key says so + * when picked), and a model server once it is connected — or while it is the + * active one, so the picker never hides where the chat goes. + */ +export function visibleProviders(settings: Settings): ProviderDef[] { + return PROVIDERS.filter( + (p) => !p.urlField || settings[p.urlField] !== "" || settings.chatProvider === p.id, + ); +} + +/** The model to keep once the list arrives: the saved one if offered, else a sensible one. */ +export function pickModel(provider: ProviderDef, offered: string[], current: string): string | null { + if (offered.length === 0) return null; + if (offered.includes(current)) return current; + const preferred = provider.prefer ? offered.find((id) => id.includes(provider.prefer!)) : undefined; + if (preferred) return preferred; + return offered.includes(provider.defaultModel) ? provider.defaultModel : offered[0]; +} + +// ── Where an address points ─────────────────────────────────────────────────── + +/** Mirrors net.rs: this machine, by name or address. */ +export function isLoopbackHost(host: string): boolean { + const h = host.replace(/^\[|\]$/g, "").toLowerCase(); + if (h === "localhost" || h.endsWith(".localhost")) return true; + if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true; + return h === "0.0.0.0" || h === "::1" || h === "::" || h === "::ffff:127.0.0.1" || h === "::ffff:7f00:1"; +} + +/** + * Where a server address sends what you type: this machine, another one over + * https, another one in clear text (http), or nowhere valid. An empty field + * means the usual address on this machine. + */ +export type Exposure = "local" | "remote" | "remote-http" | "invalid"; + +export function urlExposure(raw: string): Exposure { + const text = raw.trim(); + if (!text) return "local"; + let url: URL; + try { + url = new URL(text.includes("://") ? text : `http://${text}`); + } catch { + return "invalid"; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return "invalid"; + if (!url.hostname || url.username || url.password) return "invalid"; + if (isLoopbackHost(url.hostname)) return "local"; + return url.protocol === "https:" ? "remote" : "remote-http"; +} diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..955e43784 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -2,6 +2,7 @@ import type { BotEmoteName, BotStateName, IslandMode, IslandViewName } from "./layout"; import type { EyeShape } from "../mochi/engine"; +import type { ProviderId } from "./providers"; export type AgentSource = "claudeCode" | "n8n" | "agent"; export type PillBadge = "approval" | "finished" | "error"; @@ -92,6 +93,14 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + /** Who the chat talks to (see core/providers.ts); picked in the chat view. */ + chatProvider: ProviderId; + /** The model picked for each provider other than Anthropic, by provider id. */ + chatModels: Record; + /** Model server addresses once connected; empty means not connected. */ + ollamaUrl: string; + lmstudioUrl: string; + customUrl: string; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +115,11 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + chatProvider: "anthropic", + chatModels: {}, + ollamaUrl: "", + lmstudioUrl: "", + customUrl: "", }; type Listener = () => void; diff --git a/windows/src/style.css b/windows/src/style.css index 12f190668..1935591a3 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -977,6 +977,177 @@ input[type="range"]::-webkit-slider-thumb { 50% { transform: scale(1.2); } } +/* ── Provider and model switcher (PromptView's model button) ─────────────── */ + +.model-row { + display: flex; + justify-content: flex-end; + padding: 0 10px; +} + +.model-btn { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: 100%; + padding: 4px 8px; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + color: #5c6370; + cursor: default; +} + +.model-btn:hover:not(:disabled), +.model-btn.open { + background: rgba(255, 255, 255, 0.1); +} + +.model-btn:disabled { + opacity: 0.6; +} + +.model-dot { + flex: 0 0 6px; + width: 6px; + height: 6px; + border-radius: 50%; +} + +.model-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 500 10.5px var(--font); + color: #7b8089; +} + +.picker { + display: none; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; + gap: 8px; + padding: 10px; + border-radius: 12px; + background: #16171b; + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.chat-body.picking .picker { + display: flex; +} + +.chat-body.picking .chat-log, +.chat-body.picking .chip-row { + display: none; +} + +.picker-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.picker-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.06); + font: 400 11.5px var(--font); + color: #c8cdd4; + cursor: default; +} + +.picker-chip.on { + font-weight: 600; + color: var(--ink-2); + /* Plain fallbacks for WebKitGTK builds without color-mix(). */ + border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.12); + border-color: color-mix(in srgb, var(--accent) 50%, transparent); + background: color-mix(in srgb, var(--accent) 18%, transparent); +} + +.picker-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); +} + +.picker-rule { + flex: 0 0 1px; + background: rgba(255, 255, 255, 0.2); + opacity: 0.4; +} + +.picker-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.18) transparent; +} + +.picker-model { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: #c8cdd4; + font: 400 12px var(--font); + text-align: left; + cursor: default; +} + +.picker-model:hover { + background: rgba(255, 255, 255, 0.05); +} + +.picker-model.on { + color: var(--accent); + background: rgba(255, 255, 255, 0.06); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.picker-model-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.picker-status { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 4px 0; + font: 400 11px var(--font); + color: #8a8f98; +} + +.picker-link { + padding: 0; + border: 0; + background: none; + color: #60a5fa; + font: inherit; + cursor: default; +} + .chat-bar { display: flex; align-items: center; diff --git a/windows/src/views/chat.ts b/windows/src/views/chat.ts index 6b28965d6..9b46b6e92 100644 --- a/windows/src/views/chat.ts +++ b/windows/src/views/chat.ts @@ -1,14 +1,33 @@ -// Chat view — DOM port of PromptView / ChatBubble / TypingDotsView from -// IslandViewContent.swift. Answers are rendered as Markdown (markdown.ts). +// Chat view — DOM port of PromptView / ChatBubble / TypingDotsView / +// ModelPickerView from IslandViewContent.swift. +// +// Answers are rendered as Markdown (markdown.ts). A local model streams its +// answer: Rust sends `chat-delta` events with the text visible so far. The +// model name above the text field opens the picker: provider chips, then the +// models of the chosen provider, asked for only once it is picked. import { h, svg, clear } from "./dom"; import { ICONS } from "./icons"; import { renderMarkdown } from "./markdown"; -import { Bridge, type ChatContext } from "../core/bridge"; +import { Bridge, onEvent, type ChatContext, type ModelInfo } from "../core/bridge"; +import { + activeModel, pickModel, providerDef, visibleProviders, withModel, type ProviderDef, +} from "../core/providers"; import { Sound } from "../core/sound"; import { State, type ChatMessage } from "../core/state"; import type { ViewHost } from "./views"; +const STRINGS = { + placeholderFirst: "Ask me anything…", + placeholderNext: "Continue…", + send: "Send", + switchModel: "Switch provider or model", + noModel: "Choose a model", + loading: "Loading models…", + noKey: "No API key — add it in Settings.", + openSettings: "Open Settings", +}; + let nextId = 1; function bubble(message: ChatMessage): HTMLElement { @@ -39,33 +58,218 @@ function contextChip(label: string): HTMLElement { return chip; } +function saveSettings() { + void Bridge.saveSettings(State.settings); +} + +// ── Model picker ────────────────────────────────────────────────────────────── + +interface Picker { + el: HTMLElement; + open(): void; + close(): void; + readonly isOpen: boolean; +} + +/** Provider chips, then the chosen provider's models — ModelPickerView. */ +function buildPicker(onChange: () => void): Picker { + const chips = h("div", { class: "picker-chips" }); + const list = h("div", { class: "picker-list" }); + const el = h("div", { class: "picker" }, chips, h("div", { class: "picker-rule" }), list); + + /** Models already asked for, by provider; a model server is asked again each time. */ + const cache = new Map(); + let isOpen = false; + let request = 0; + + function drawChips() { + clear(chips); + for (const p of visibleProviders(State.settings)) { + const on = p.id === State.settings.chatProvider; + const chip = h( + "button", + { class: on ? "picker-chip on" : "picker-chip", style: `--accent:${p.accent}` }, + h("i", { class: "picker-dot" }), + h("span", { text: p.name }), + ); + chip.addEventListener("click", () => { + if (p.id === State.settings.chatProvider) return; + State.settings = { ...State.settings, chatProvider: p.id }; + saveSettings(); + Sound.play("pop"); + drawChips(); + void loadModels(); + onChange(); + }); + chips.append(chip); + } + } + + function status(text: string, withSettings = false) { + clear(list); + const line = h("div", { class: "picker-status", text }); + if (withSettings) { + line.append( + h("button", { + class: "picker-link", + text: STRINGS.openSettings, + onclick: () => void Bridge.openSettingsWindow(), + }), + ); + } + list.append(line); + } + + function drawModels(p: ProviderDef, models: ModelInfo[]) { + clear(list); + const current = activeModel(State.settings); + for (const m of models) { + const on = m.id === current; + const row = h( + "button", + { class: on ? "picker-model on" : "picker-model", style: `--accent:${p.accent}`, title: m.id }, + h("span", { class: "picker-model-name", text: m.label }), + on ? svg(ICONS.check, 11, { stroke: 2.2 }) : null, + ); + row.addEventListener("click", () => { + State.settings = withModel(State.settings, p.id, m.id); + saveSettings(); + Sound.play("blip"); + close(); + }); + list.append(row); + } + list.querySelector(".picker-model.on")?.scrollIntoView({ block: "nearest" }); + } + + async function loadModels() { + const p = providerDef(State.settings.chatProvider); + const ticket = ++request; + const cached = p.urlField ? undefined : cache.get(p.id); + if (cached) { + drawModels(p, cached); + return; + } + // Nothing is asked of a provider that has no key yet. + if (p.key && !(await Bridge.secretPresent(p.key))) { + if (ticket === request) status(STRINGS.noKey, true); + return; + } + if (ticket !== request) return; + status(STRINGS.loading); + try { + const models = await Bridge.chatModels(p.id); + if (ticket !== request) return; + if (!p.urlField) cache.set(p.id, models); + const keep = pickModel(p, models.map((m) => m.id), activeModel(State.settings)); + if (keep && keep !== activeModel(State.settings)) { + State.settings = withModel(State.settings, p.id, keep); + saveSettings(); + onChange(); + } + drawModels(p, models); + } catch (err) { + if (ticket === request) status(String(err).replace(/^Error:\s*/, ""), Boolean(p.key)); + } + } + + function open() { + isOpen = true; + el.classList.add("on"); + drawChips(); + onChange(); + void loadModels(); + } + + function close() { + isOpen = false; + request++; + el.classList.remove("on"); + onChange(); + } + + return { + el, + open, + close, + get isOpen() { + return isOpen; + }, + }; +} + +// ── View ────────────────────────────────────────────────────────────────────── + export function buildPrompt(onHeightChange: () => void): ViewHost { const chipRow = h("div", { class: "chip-row" }); const log = h("div", { class: "chat-log" }); const input = h("input", { type: "text", class: "chat-input", - placeholder: "Ask me anything…", + placeholder: STRINGS.placeholderFirst, spellcheck: "false", }) as HTMLInputElement; - const send = h("button", { class: "send-btn", title: "Send" }, svg(ICONS.arrowUp, 11)); + const send = h("button", { class: "send-btn", title: STRINGS.send }, svg(ICONS.arrowUp, 11)); const bar = h("div", { class: "chat-bar" }, input, send); - const el = h( - "div", - { class: "view" }, - h("div", { class: "card wash chat-card" }, h("div", { class: "chat-body" }, chipRow, log, bar)), + const modelDot = h("i", { class: "model-dot" }); + const modelName = h("span", { class: "model-name" }); + const modelBtn = h( + "button", + { class: "model-btn", title: STRINGS.switchModel }, + modelDot, + modelName, + svg(ICONS.chevronUpDown, 9, { stroke: 2 }), ); + const modelRow = h("div", { class: "model-row" }, modelBtn); + + const body = h("div", { class: "chat-body" }); + const picker = buildPicker(() => { + body.classList.toggle("picking", picker.isOpen); + drawModelButton(); + }); + body.append(chipRow, log, picker.el, modelRow, bar); + + const el = h("div", { class: "view" }, h("div", { class: "card wash chat-card" }, body)); (el.querySelector(".card") as HTMLElement).style.setProperty("--wash", "rgba(99,102,241,0.5)"); let sending = false; let renderedCount = -1; + // A local model answers token by token: where its text so far is shown. + let live: HTMLElement | null = null; + + function drawModelButton() { + const p = providerDef(State.settings.chatProvider); + modelDot.style.background = p.accent; + modelName.textContent = activeModel(State.settings) || STRINGS.noModel; + modelBtn.classList.toggle("open", picker.isOpen); + modelBtn.disabled = sending; + } + + modelBtn.addEventListener("click", () => { + if (sending) return; + if (picker.isOpen) picker.close(); + else picker.open(); + }); + + void onEvent("chat-delta", (text) => { + if (!sending || !text) return; // nothing visible yet: the dots stay + if (!live) { + live = h("div", { class: "reply" }); + log.querySelector(".typing")?.parentElement?.remove(); + log.append(h("div", { class: "chat-row" }, live)); + } + renderMarkdown(live, text); + log.scrollTop = log.scrollHeight; + }); async function submit() { const query = input.value.trim(); if (!query || sending) return; + if (picker.isOpen) picker.close(); input.value = ""; sending = true; + drawModelButton(); Sound.play("send"); State.chatHistory.push({ id: nextId++, role: "user", content: query }); @@ -89,6 +293,9 @@ export function buildPrompt(onHeightChange: () => void): ViewHost { Sound.play("error"); } finally { sending = false; + live = null; + renderedCount = -1; // the finished answer replaces the streamed one + drawModelButton(); State.notify(); onHeightChange(); input.focus(); @@ -97,9 +304,13 @@ export function buildPrompt(onHeightChange: () => void): ViewHost { send.addEventListener("click", () => void submit()); input.addEventListener("keydown", (e) => { - if ((e as KeyboardEvent).key === "Enter") { + const key = (e as KeyboardEvent).key; + if (key === "Enter") { e.preventDefault(); void submit(); + } else if (key === "Escape" && picker.isOpen) { + e.preventDefault(); + picker.close(); } e.stopPropagation(); // Escape closes the island, not the chat }); @@ -117,7 +328,7 @@ export function buildPrompt(onHeightChange: () => void): ViewHost { const thinking = State.stateOverride === "thinking"; const count = State.chatHistory.length + (thinking ? 0.5 : 0); - if (count !== renderedCount) { + if (count !== renderedCount && !live) { renderedCount = count; clear(log); for (const m of State.chatHistory) log.append(bubble(m)); @@ -125,7 +336,11 @@ export function buildPrompt(onHeightChange: () => void): ViewHost { log.scrollTop = log.scrollHeight; } - input.placeholder = State.chatHistory.length === 0 ? "Ask me anything…" : "Continue…"; + // Leaving the chat folds the picker away. + if (State.view !== "prompt" && picker.isOpen) picker.close(); + drawModelButton(); + + input.placeholder = State.chatHistory.length === 0 ? STRINGS.placeholderFirst : STRINGS.placeholderNext; input.disabled = sending; }, focus() { diff --git a/windows/tests/chat.test.mjs b/windows/tests/chat.test.mjs new file mode 100644 index 000000000..ff03d4a7c --- /dev/null +++ b/windows/tests/chat.test.mjs @@ -0,0 +1,124 @@ +// The chat view (src/views/chat.ts) on a fake DOM, through the real bridge: +// the model switcher asks for a provider's models only once it is picked and +// has a key, picking saves the settings, and a streamed answer grows in place. + +import { beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { installFakeDom } from "./fakedom.mjs"; +import { calls, emit, internals, sent } from "./tauri.mjs"; + +installFakeDom(); +const { buildPrompt } = await import("../src/views/chat.ts"); +const { DEFAULT_SETTINGS, State } = await import("../src/core/state.ts"); + +/** What the mocked Rust side answers, by command. */ +let answers; +const plainInvoke = internals.invoke; +internals.invoke = async (cmd, args) => { + const result = await plainInvoke(cmd, args); + if (cmd in answers) return typeof answers[cmd] === "function" ? answers[cmd](args) : answers[cmd]; + return result; +}; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +let view; +beforeEach(() => { + calls.length = 0; + answers = {}; + State.settings = { ...DEFAULT_SETTINGS, chatModels: {} }; + State.chatHistory = []; + State.stateOverride = null; + State.view = "prompt"; + State.droppedFile = null; + view = buildPrompt(() => {}); + view.sync(); +}); + +const $ = (cls) => view.el.querySelector(cls); +const chips = () => view.el.find(".picker-chip").map((c) => c.textContent); +const models = () => view.el.find(".picker-model").map((m) => m.textContent); + +test("the model button shows the active provider's model", () => { + assert.equal($(".model-name").textContent, "claude-opus-5"); + State.settings = { ...State.settings, chatProvider: "google" }; + view.sync(); + assert.equal($(".model-name").textContent, "gemini-2.0-flash"); + State.settings = { ...State.settings, chatProvider: "ollama" }; + view.sync(); + assert.equal($(".model-name").textContent, "Choose a model"); +}); + +test("a provider without a key is never asked for its models", async () => { + answers.secret_present = false; + $(".model-btn").fire("click"); + await flush(); + assert.ok($(".chat-body").classList.contains("picking")); + assert.deepEqual(chips(), ["Anthropic", "Google", "OpenAI", "OpenRouter"]); + assert.deepEqual(sent("secret_present"), [{ key: "anthropic-api-key" }]); + assert.deepEqual(sent("chat_models"), []); + assert.match($(".picker-status").textContent, /No API key/); +}); + +test("with a key, the models are listed and picking one saves it", async () => { + answers.secret_present = true; + answers.chat_models = [ + { id: "claude-opus-5", label: "Claude Opus 5" }, + { id: "claude-sonnet-5", label: "Claude Sonnet 5" }, + ]; + $(".model-btn").fire("click"); + await flush(); + assert.deepEqual(sent("chat_models"), [{ provider: "anthropic" }]); + assert.deepEqual(models(), ["Claude Opus 5", "Claude Sonnet 5"]); + view.el.find(".picker-model")[1].fire("click"); + assert.equal(State.settings.model, "claude-sonnet-5"); + assert.equal(sent("save_settings").at(-1).settings.model, "claude-sonnet-5"); + assert.ok(!$(".chat-body").classList.contains("picking")); + assert.equal($(".model-name").textContent, "claude-sonnet-5"); +}); + +test("switching provider saves it and asks the new provider only", async () => { + answers.secret_present = (args) => args.key === "google-api-key"; + answers.chat_models = (args) => (args.provider === "google" ? [{ id: "gemini-2.5-flash", label: "gemini-2.5-flash" }] : []); + $(".model-btn").fire("click"); + await flush(); + assert.deepEqual(sent("chat_models"), []); + view.el.find(".picker-chip")[1].fire("click"); + await flush(); + assert.equal(State.settings.chatProvider, "google"); + assert.deepEqual(sent("chat_models"), [{ provider: "google" }]); + // The saved model was not offered: the flash one is kept instead, and saved. + assert.equal(State.settings.chatModels.google, "gemini-2.5-flash"); + assert.equal(sent("save_settings").at(-1).settings.chatModels.google, "gemini-2.5-flash"); + assert.deepEqual(models(), ["gemini-2.5-flash"]); +}); + +test("a local answer streams into one reply, then the finished text replaces it", async () => { + let finish; + answers.chat_send = () => new Promise((resolve) => (finish = resolve)); + const input = $(".chat-input"); + input.value = "hello"; + $(".send-btn").fire("click"); + await flush(); + view.sync(); // what State.notify() does in the island + assert.ok($(".model-btn").disabled, "no switching mid-answer"); + assert.ok($(".typing"), "dots until the first visible text"); + + emit("chat-delta", ""); // still thinking + assert.ok($(".typing")); + emit("chat-delta", "Hel"); + emit("chat-delta", "Hello **there**"); + view.sync(); // another view update mid-stream must not wipe the live answer + assert.equal($(".typing"), null); + const replies = view.el.find(".reply"); + assert.equal(replies.length, 1); + assert.equal(replies[0].find("STRONG")[0].textContent, "there"); + + finish({ text: "Hello **there**!" }); + await flush(); + view.sync(); + assert.equal(view.el.find(".reply").length, 1); + assert.equal(view.el.find(".reply")[0].textContent, "Hello there!"); + assert.deepEqual(State.chatHistory.map((m) => m.role), ["user", "assistant"]); + assert.ok(!$(".model-btn").disabled); +}); diff --git a/windows/tests/providers.test.mjs b/windows/tests/providers.test.mjs new file mode 100644 index 000000000..1b2b9471c --- /dev/null +++ b/windows/tests/providers.test.mjs @@ -0,0 +1,74 @@ +// The island's provider table (src/core/providers.ts): which model the chat +// uses, which chips the picker shows, and what a server address exposes. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + PROVIDERS, activeModel, isLoopbackHost, pickModel, providerDef, urlExposure, visibleProviders, withModel, +} from "../src/core/providers.ts"; +import { DEFAULT_SETTINGS } from "../src/core/state.ts"; + +const settings = (over = {}) => ({ ...DEFAULT_SETTINGS, ...over }); + +test("the ids and key names match the Rust side and the Mac", () => { + assert.deepEqual(PROVIDERS.map((p) => p.id), ["anthropic", "google", "openai", "openrouter", "ollama", "lmstudio", "custom"]); + assert.equal(providerDef("google").key, "google-api-key"); + assert.equal(providerDef("openai").key, "openai-api-key"); + assert.equal(providerDef("openrouter").key, "openrouter-api-key"); + assert.equal(providerDef("ollama").key, null); + // An unknown id (an older or newer settings.json) falls back to Claude. + assert.equal(providerDef("nope").id, "anthropic"); +}); + +test("Claude's model is the existing setting; the others are kept per provider", () => { + assert.equal(activeModel(settings()), "claude-opus-5"); + assert.equal(activeModel(settings({ chatProvider: "google" })), "gemini-2.0-flash"); + assert.equal(activeModel(settings({ chatProvider: "ollama" })), ""); + let s = withModel(settings({ chatProvider: "openai" }), "openai", "gpt-5-mini"); + assert.equal(activeModel(s), "gpt-5-mini"); + assert.equal(s.model, "claude-opus-5"); + s = withModel(s, "anthropic", "claude-haiku-4-5"); + assert.equal(s.model, "claude-haiku-4-5"); + assert.equal(s.chatModels.openai, "gpt-5-mini"); + // withModel never changes the object it was given. + assert.deepEqual(DEFAULT_SETTINGS.chatModels, {}); +}); + +test("model servers show in the picker once connected, or while in use", () => { + const ids = (s) => visibleProviders(s).map((p) => p.id); + assert.deepEqual(ids(settings()), ["anthropic", "google", "openai", "openrouter"]); + assert.deepEqual(ids(settings({ ollamaUrl: "http://127.0.0.1:11434" })), ["anthropic", "google", "openai", "openrouter", "ollama"]); + assert.deepEqual(ids(settings({ chatProvider: "custom" })), ["anthropic", "google", "openai", "openrouter", "custom"]); +}); + +test("the saved model is kept when offered, else a sensible one is picked", () => { + const google = providerDef("google"); + assert.equal(pickModel(google, ["gemini-2.5-pro", "gemini-2.0-flash"], "gemini-2.5-pro"), "gemini-2.5-pro"); + assert.equal(pickModel(google, ["gemini-2.5-pro", "gemini-2.5-flash"], "gone"), "gemini-2.5-flash"); + assert.equal(pickModel(providerDef("openai"), ["gpt-4o", "gpt-5-mini"], "gone"), "gpt-5-mini"); + assert.equal(pickModel(providerDef("ollama"), ["llama3.2", "qwen"], ""), "llama3.2"); + assert.equal(pickModel(providerDef("openrouter"), ["a/b", "openrouter/auto"], "gone"), "openrouter/auto"); + assert.equal(pickModel(google, [], "x"), null); +}); + +test("loopback hosts match net.rs", () => { + for (const host of ["localhost", "app.localhost", "127.0.0.1", "127.9.9.9", "[::1]", "::1", "0.0.0.0", "[::]"]) { + assert.ok(isLoopbackHost(host), host); + } + for (const host of ["example.com", "192.168.1.2", "localhost.example.com", "128.0.0.1", "[2001:db8::1]"]) { + assert.ok(!isLoopbackHost(host), host); + } +}); + +test("an address says whether what you type leaves this computer", () => { + assert.equal(urlExposure(""), "local"); + assert.equal(urlExposure("http://localhost:11434"), "local"); + assert.equal(urlExposure("127.0.0.1:1234/v1"), "local"); + assert.equal(urlExposure("http://[::1]:8000"), "local"); + assert.equal(urlExposure("https://llm.example.com"), "remote"); + assert.equal(urlExposure("http://gpu-box.lan:8000"), "remote-http"); + assert.equal(urlExposure("192.168.1.20:8000"), "remote-http"); + assert.equal(urlExposure("ftp://example.com"), "invalid"); + assert.equal(urlExposure("http://user:pw@example.com"), "invalid"); + assert.equal(urlExposure("http://"), "invalid"); +}); From e7a4831df7d274623e6fbfec3c744c67ad080fa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:26:16 +0200 Subject: [PATCH 39/77] Settings: Chat providers and Local models sections Two new sections after "Claude", in the existing style; the Claude section is untouched. - Chat providers: a key each for Google AI, OpenAI and OpenRouter, saved to the credential store (the window only learns whether one exists), with where to get it. Provider and model are then picked in the chat view. - Local models: Connect Ollama, LM Studio (empty address = the usual one on this computer) or an OpenAI-compatible server with an optional key. Connected servers show their address, "Use in chat" and Disconnect, which forgets the address (and the custom server's key) and moves the chat back to Claude if it was in use. - Where the address points is spelled out as you type: "Nothing leaves your PC" only for an address on this computer; another machine is named as such, and a warning shows before a key would be sent over plain http:// to another machine. - The section follows provider changes made from the island. Co-Authored-By: Claude Opus 5.5 --- windows/src/settings/main.ts | 268 +++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..cce0d9266 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -4,6 +4,7 @@ import "./settings.css"; import { Bridge, onEvent, type HookStatus } from "../core/bridge"; +import { CUSTOM_SERVER_KEY, providerDef, urlExposure } from "../core/providers"; import { DEFAULT_SETTINGS, type Settings } from "../core/state"; import { h, clear } from "../views/dom"; @@ -254,6 +255,261 @@ function apiSection(hasKey: boolean): HTMLElement { ); } +// ── Chat providers section ──────────────────────────────────────────────────── + +const CHAT_STRINGS = { + providersTitle: "Chat providers", + providersHint: "Chat with Google AI, OpenAI or OpenRouter instead of Claude: add a key here, then click the model name above the chat box to switch provider and model. Keys stay in the system keychain. These providers get no web search and no tools: they can answer, never act on this computer.", + stored: "•••••••• (stored)", + save: "Save", + remove: "Remove", + localTitle: "Local models", + localHint: "Chat with a model you run yourself: Ollama or LM Studio (leave the address empty for the usual one on this computer), or any server that speaks the OpenAI API, such as vLLM or llama.cpp. Once connected, pick it above the chat box.", + connect: "Connect", + connecting: "Connecting…", + disconnect: "Disconnect", + useInChat: "Use in chat", + inUse: "In use", + keyOptional: "API key (optional)", + localOnly: "Nothing leaves your PC: the server runs on this computer.", + remote: "This address is another machine: what you ask is sent to it.", + remoteHttp: "This address is another machine, over plain http: what you ask travels unencrypted.", + keyOverHttp: "Warning: the key would be sent unencrypted (http://) to another machine. Use https://, or a server on this computer.", + invalid: "Not a valid http:// or https:// address.", + noModels: (name: string) => `No models yet. Download one in ${name} first.`, + models: (n: number) => (n === 1 ? "1 model" : `${n} models`), +}; + +interface CloudDef { + id: "google" | "openai" | "openrouter"; + name: string; + placeholder: string; + where: string; +} + +const CLOUD: CloudDef[] = [ + { id: "google", name: "Google AI", placeholder: "AIza…", where: "aistudio.google.com" }, + { id: "openai", name: "OpenAI", placeholder: "sk-…", where: "platform.openai.com" }, + { id: "openrouter", name: "OpenRouter", placeholder: "sk-or-…", where: "openrouter.ai/keys" }, +]; + +function chatProvidersSection(present: Record): HTMLElement { + const list = h("div", { style: "display:flex;flex-direction:column;gap:8px" }); + for (const def of CLOUD) { + const p = providerDef(def.id); + const key = p.key!; + const input = h("input", { + type: "password", + placeholder: present[key] ? CHAT_STRINGS.stored : def.placeholder, + autocomplete: "off", + spellcheck: "false", + style: "flex:1 1 auto;min-width:0", + }) as HTMLInputElement; + const dotEl = statusDot(present[key] ?? false); + const saveBtn = h("button", { text: CHAT_STRINGS.save }); + const removeBtn = h("button", { class: "danger", text: CHAT_STRINGS.remove }); + const refresh = () => { + input.placeholder = present[key] ? CHAT_STRINGS.stored : def.placeholder; + dotEl.style.background = present[key] ? "#22c55e" : "#f4505e"; + removeBtn.style.display = present[key] ? "" : "none"; + }; + saveBtn.addEventListener("click", async () => { + const value = input.value.trim(); + if (!value) return; + try { + await Bridge.secretSet(key, value); + present[key] = true; + input.value = ""; + } catch { + dotEl.style.background = "#f5a524"; + return; + } + refresh(); + }); + removeBtn.addEventListener("click", async () => { + try { + await Bridge.secretClear(key); + present[key] = false; + } catch { + dotEl.style.background = "#f5a524"; + return; + } + refresh(); + }); + refresh(); + list.append( + h("div", { class: "row" }, + h("label", {}, + h("i", { class: "dot", style: `background:${p.accent};margin-right:8px` }), + h("span", { text: def.name }), + ), + input, saveBtn, removeBtn, dotEl, + ), + h("div", { class: "hint", style: "margin:-4px 0 0 144px", text: `Key from ${def.where}` }), + ); + } + return h( + "section", + {}, + h("h2", {}, h("span", { text: CHAT_STRINGS.providersTitle })), + h("div", { class: "hint", text: CHAT_STRINGS.providersHint }), + list, + ); +} + +// ── Local models section ────────────────────────────────────────────────────── + +type LocalId = "ollama" | "lmstudio" | "custom"; + +/** Redraws the local models section after a change made elsewhere (the island). */ +let localRedraw: (() => void) | null = null; + +const LOCAL: Record = { + ollama: { name: "Ollama", usual: "http://127.0.0.1:11434" }, + lmstudio: { name: "LM Studio", usual: "http://127.0.0.1:1234" }, + // No usual address: any server that speaks the OpenAI API. + custom: { name: "OpenAI-compatible", usual: "" }, +}; + +/** What an address means for the user's data, as a hint line. */ +function exposureNotice(url: string, withKey: boolean): HTMLElement | null { + switch (urlExposure(url)) { + case "local": + return h("div", { class: "hint", text: CHAT_STRINGS.localOnly }); + case "remote": + return h("div", { class: "hint", text: CHAT_STRINGS.remote }); + case "remote-http": + return withKey + ? h("div", { class: "notice warn", text: CHAT_STRINGS.keyOverHttp }) + : h("div", { class: "hint", text: CHAT_STRINGS.remoteHttp }); + case "invalid": + return url.trim() ? h("div", { class: "notice err", text: CHAT_STRINGS.invalid }) : null; + } +} + +function localSection(customKey: boolean): HTMLElement { + const body = h("div", { style: "display:flex;flex-direction:column;gap:14px" }); + const section = h( + "section", + {}, + h("h2", {}, h("span", { text: CHAT_STRINGS.localTitle })), + h("div", { class: "hint", text: CHAT_STRINGS.localHint }), + body, + ); + const redraw = () => { + clear(body); + for (const id of Object.keys(LOCAL) as LocalId[]) body.append(serverBlock(id)); + }; + + function serverBlock(id: LocalId): HTMLElement { + const def = LOCAL[id]; + const p = providerDef(id); + const field = p.urlField!; + const connected = settings[field] !== ""; + const status = h("div", {}); + const exposure = h("div", {}); + const label = h("label", {}, + h("i", { class: "dot", style: `background:${p.accent};margin-right:8px` }), + h("span", { text: def.name }), + ); + const block = h("div", { style: "display:flex;flex-direction:column;gap:6px" }); + + if (connected) { + const inUse = settings.chatProvider === id; + const use = h("button", { class: inUse ? "" : "primary", text: inUse ? CHAT_STRINGS.inUse : CHAT_STRINGS.useInChat }); + use.disabled = inUse; + use.addEventListener("click", () => { + settings.chatProvider = id; + void save().then(redraw); + }); + const disconnect = h("button", { class: "danger", text: CHAT_STRINGS.disconnect }); + disconnect.addEventListener("click", async () => { + settings[field] = ""; + if (settings.chatProvider === id) settings.chatProvider = "anthropic"; + if (id === "custom") { + await Bridge.secretClear(CUSTOM_SERVER_KEY).catch(() => {}); + customKey = false; + } + await save(); + redraw(); + }); + block.append( + h("div", { class: "row" }, label, h("span", { class: "path", text: settings[field] }), statusDot(true), use, disconnect), + status, + ); + exposure.append(exposureNotice(settings[field], id === "custom" && customKey) ?? ""); + block.append(exposure); + return block; + } + + const input = h("input", { + type: "text", + placeholder: def.usual || "https://llm.example.com", + style: "flex:1 1 auto;min-width:0", + spellcheck: "false", + autocomplete: "off", + }) as HTMLInputElement; + // A custom server may want a key; it goes to the keychain, never to settings.json. + const key = h("input", { + type: "password", + placeholder: customKey ? CHAT_STRINGS.stored : CHAT_STRINGS.keyOptional, + style: "flex:1 1 auto;min-width:0", + autocomplete: "off", + spellcheck: "false", + }) as HTMLInputElement; + const connect = h("button", { class: "primary", text: CHAT_STRINGS.connect }); + + const showExposure = () => { + clear(exposure); + const withKey = id === "custom" && (customKey || key.value.trim() !== ""); + const notice = exposureNotice(input.value || def.usual, withKey); + if (notice) exposure.append(notice); + }; + input.addEventListener("input", showExposure); + key.addEventListener("input", showExposure); + + connect.addEventListener("click", async () => { + connect.disabled = true; + clear(status); + status.append(h("div", { class: "hint", text: CHAT_STRINGS.connecting })); + try { + if (id === "custom" && key.value.trim()) { + await Bridge.secretSet(CUSTOM_SERVER_KEY, key.value.trim()); + key.value = ""; + customKey = true; + } + const server = await Bridge.localConnect(id, input.value); + if (!server.models.length) { + clear(status); + status.append(h("div", { class: "notice err", text: CHAT_STRINGS.noModels(def.name) })); + } else { + settings[field] = server.url; + if (!server.models.includes(settings.chatModels[id] ?? "")) { + settings.chatModels = { ...settings.chatModels, [id]: server.models[0] }; + } + await save(); + redraw(); + return; + } + } catch (err) { + clear(status); + status.append(h("div", { class: "notice err", text: String(err).replace(/^Error:\s*/, "") })); + } + connect.disabled = false; + }); + + block.append(h("div", { class: "row" }, label, input, connect)); + if (id === "custom") block.append(h("div", { class: "row" }, h("label", { text: "" }), key)); + block.append(status, exposure); + showExposure(); + return block; + } + + redraw(); + localRedraw = redraw; + return section; +} + // ── Integrations section ────────────────────────────────────────────────────── interface IntegrationDef { @@ -438,11 +694,20 @@ async function main() { const present: Record = {}; for (const k of keys) present[k] = (await Bridge.secretPresent(k)) ?? false; + const chatKeys: Record = {}; + for (const def of CLOUD) { + const key = providerDef(def.id).key!; + chatKeys[key] = (await Bridge.secretPresent(key)) ?? false; + } + const customKey = (await Bridge.secretPresent(CUSTOM_SERVER_KEY)) ?? false; + clear(root); root.append( h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), apiSection(hasKey), + chatProvidersSection(chatKeys), + localSection(customKey), integrationsSection(present), generalSection(), h("div", { @@ -452,7 +717,10 @@ async function main() { ); void onEvent("settings-changed", (s) => { + const before = `${settings.chatProvider}|${settings.ollamaUrl}|${settings.lmstudioUrl}|${settings.customUrl}`; settings = { ...settings, ...s }; + const after = `${settings.chatProvider}|${settings.ollamaUrl}|${settings.lmstudioUrl}|${settings.customUrl}`; + if (before !== after) localRedraw?.(); }); } From fde3e31770be8de0f3f9f8d422a498e46a0875c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:26:20 +0200 Subject: [PATCH 40/77] README: chat providers, local models, Markdown and the Anthropic gateway Co-Authored-By: Claude Opus 5.5 --- windows/README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/windows/README.md b/windows/README.md index 2b897bd3d..171fb867c 100644 --- a/windows/README.md +++ b/windows/README.md @@ -74,6 +74,37 @@ It works from any terminal — Windows Terminal, PowerShell, VS Code, Git Bash. Credential Manager**, never on disk and never in the interface — the island can only ask whether a key exists. Same for every integration key. +The chat also talks to **Google AI (Gemini)**, **OpenAI** and **OpenRouter**: +add their keys in **Settings… → Chat providers**, then click the model name +above the chat box to switch provider and model, as on the Mac. The model list +is fetched from the provider only once you pick it and it has a key. Switching +mid-conversation carries the conversation over as plain text, so nothing in one +provider's format is ever sent to another. These providers get no web search +and no tools — they answer, they never act on your PC. + +**Local models**: **Settings… → Local models** connects **Ollama** or **LM +Studio** (leave the address empty for the usual one on this PC; Ollama's +`OLLAMA_HOST` is honoured) or any server that speaks the OpenAI API (vLLM, +llama.cpp…), with an optional key kept in the credential store. Answers stream +in as they are written, and the `` blocks of reasoning models stay +hidden. A text file you dropped goes along inline (24 000 characters at most); +images and PDFs by name only. Settings tells you whether the address is this +PC — nothing leaves it then — and warns before a key would travel over plain +`http://` to another machine. + +Answers from every provider are shown as **Markdown**: headings, lists, bold, +inline code, quotes, and code blocks with a copy button. It is built from text +nodes, never parsed as HTML, and only `http`/`https` links open. Mochi greets +you by your first name when your account has one (the Windows display name or +the Linux GECOS full name; a bare login name is not used). + +To send the Claude chat through an Anthropic-compatible gateway, set +`COUCOU_ANTHROPIC_BASE_URL` (for example `https://gateway.example.com`; +`/v1/messages` is added). It must be `https://`, or `http://` to this PC only. +Claude Code's own `ANTHROPIC_BASE_URL` is deliberately ignored: your key only +goes where you told Coucou to send it. The gateway's host is written to the log +once; the key never is. + No telemetry. The only network requests Coucou makes are to the services you configure yourself. @@ -163,6 +194,10 @@ OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integr attach it as context, and jumping to a specific terminal window — "Open terminal" opens the working folder in VS Code when `code` is on your `PATH`. - Cal.com shows the next bookings as a list rather than the Mac's calendar. +- The chat's model picker opens inside the chat card instead of a popover, and + it also offers OpenRouter and any OpenAI-compatible server, which the Mac + does not. Google AI, OpenAI and OpenRouter can see an image you dropped (sent + inline), where the Mac sends its name only. ## Linux @@ -193,6 +228,8 @@ What changes on Linux: Unix socket at `$XDG_RUNTIME_DIR/coucou.sock`. Both ends check that the other runs as the same user. - **Keys** live in the Secret Service (GNOME Keyring, KWallet). +- **Mochi's greeting** uses the full name in your account's GECOS field + (`chfn` sets it); without one the chat stays neutral. - **Files**: preferences in `~/.config/coucou/`, the log at `~/.local/share/coucou/coucou.log`. - What the Windows build leaves out, this one does too: sending a file by From 8efad4bdad185d0b5920831f360ee5e5a7f4f77d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:28:53 +0200 Subject: [PATCH 41/77] Fixes on top of #160, #149, #227, #299 - Linux pointer watch (#160) now also runs for a regular window (GNOME, X11), not only on layer-shell, so auto-close works there too (#136). - GNOME: force GDK_BACKEND=x11 (#149) only when XWayland is there (DISPLAY set). - GNOME large text (#122): undo text-scaling-factor for the island through GDK_DPI_SCALE, unless the user set it. - Display choice (#227): remember a display by place, name and size, so it is found again after the layout is rearranged or the resolution changes; keys saved before still match. - PKGBUILD (#299): license matches LICENSE-ASSETS.md. Co-Authored-By: Claude Opus 5.5 --- linux/arch/PKGBUILD | 2 +- windows/src-tauri/src/island.rs | 131 +++++++++++++++++++++--- windows/src-tauri/src/platform/linux.rs | 73 +++++++++++-- windows/src/settings/main.ts | 13 ++- 4 files changed, 192 insertions(+), 27 deletions(-) diff --git a/linux/arch/PKGBUILD b/linux/arch/PKGBUILD index 05b1daf16..f02432474 100644 --- a/linux/arch/PKGBUILD +++ b/linux/arch/PKGBUILD @@ -9,7 +9,7 @@ pkgrel=1 pkgdesc="Mochi lives at the top of your screen and keeps an eye on your Claude Code sessions" arch=('x86_64' 'aarch64') url="https://github.com/Louis-CFM/coucou" -license=('MIT') +license=("MIT" "LicenseRef-coucou-assets") depends=( 'webkit2gtk-4.1' 'gtk3' diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 0ce036da5..7ba6d0eb3 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -134,30 +134,87 @@ pub fn monitor_choices(app: &AppHandle) -> Vec { monitors .iter() .map(|m| { - let (x, y) = logical_origin(m); - let scale = m.scale_factor(); - let s = m.size(); - let w = (s.width as f64 / scale).round(); - let h = (s.height as f64 / scale).round(); - let name = m.name().cloned().unwrap_or_else(|| "Display".into()); - MonitorChoice { key: format!("at:{x},{y}"), label: format!("{name} — {w}×{h} at {x},{y}") } + let d = describe(m); + MonitorChoice { + key: d.key(), + label: format!("{} — {}×{} at {},{}", d.name, d.w, d.h, d.x, d.y), + } }) .collect() } +/// What a display is remembered by: its logical origin, plus its name and +/// logical size, so it is still found after the layout is rearranged or the +/// resolution changes (the Mac keeps the display's UUID for the same reason; +/// Tauri has no stable ID). +#[derive(Debug, Clone, PartialEq)] +struct DisplayId { + name: String, + x: i32, + y: i32, + w: i32, + h: i32, +} + +impl DisplayId { + /// `at:,` stays first, so a preference saved before still matches. + fn key(&self) -> String { + format!("at:{},{}|{}|{}x{}", self.x, self.y, self.name.replace('|', " "), self.w, self.h) + } +} + +fn describe(m: &Monitor) -> DisplayId { + let (x, y) = logical_origin(m); + let scale = m.scale_factor(); + let s = m.size(); + DisplayId { + name: m.name().cloned().unwrap_or_else(|| "Display".into()), + x, + y, + w: (s.width as f64 / scale).round() as i32, + h: (s.height as f64 / scale).round() as i32, + } +} + +/// Which display a saved `at:` preference points at, best match first: same +/// place and name; the same name and size elsewhere (layout rearranged); the +/// same name alone when unique (resolution changed); the same place. None +/// means unplugged, and the caller falls back to the primary display. +fn pick_display(pref: &str, displays: &[DisplayId]) -> Option { + let rest = pref.strip_prefix("at:")?; + let mut parts = rest.split('|'); + let (x, y) = parts.next()?.split_once(',')?; + let (x, y) = (x.trim().parse::().ok()?, y.trim().parse::().ok()?); + let name = parts.next(); + let size = parts.next().and_then(|s| { + let (w, h) = s.split_once('x')?; + Some((w.parse::().ok()?, h.parse::().ok()?)) + }); + let at = |d: &DisplayId| d.x == x && d.y == y; + if let Some(name) = name { + if let Some(i) = displays.iter().position(|d| at(d) && d.name == name) { + return Some(i); + } + if let Some((w, h)) = size { + if let Some(i) = displays.iter().position(|d| d.name == name && d.w == w && d.h == h) { + return Some(i); + } + } + let mut same_name = displays.iter().enumerate().filter(|(_, d)| d.name == name); + if let (Some((i, _)), None) = (same_name.next(), same_name.next()) { + return Some(i); + } + } + displays.iter().position(at) +} + /// The display the island lives on: a chosen one, the primary one, or the one /// under the cursor. fn target_monitor(app: &AppHandle, pref: &str) -> Option { let monitors = app.available_monitors().ok()?; - if let Some((x, y)) = pref - .strip_prefix("at:") - .and_then(|r| r.split_once(',')) - .and_then(|(x, y)| Some((x.trim().parse::().ok()?, y.trim().parse::().ok()?))) - { - // Unplugged or rearranged since: fall back to the primary display. - if let Some(m) = monitors.iter().find(|m| logical_origin(m) == (x, y)) { - return Some(m.clone()); - } + let ids: Vec = monitors.iter().map(describe).collect(); + if let Some(i) = pick_display(pref, &ids) { + return Some(monitors[i].clone()); } if pref == "cursor" { if let Some((cx, cy)) = cursor_physical() { @@ -172,6 +229,48 @@ fn target_monitor(app: &AppHandle, pref: &str) -> Option { .or_else(|| monitors.into_iter().next()) } +#[cfg(test)] +mod display_tests { + use super::*; + + fn d(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayId { + DisplayId { name: name.into(), x, y, w, h } + } + + #[test] + fn a_display_is_found_again_after_changes() { + let dell = d("DELL U2720Q", 1920, 0, 2560, 1440); + let lap = d("eDP-1", 0, 0, 1920, 1200); + let key = dell.key(); + assert_eq!(pick_display(&key, &[lap.clone(), dell.clone()]), Some(1)); + // Rearranged: the Dell moved to the left of the laptop. + let moved = [d("eDP-1", 2560, 0, 1920, 1200), d("DELL U2720Q", 0, 0, 2560, 1440)]; + assert_eq!(pick_display(&key, &moved), Some(1)); + // Resolution changed, still the only Dell. + let rescaled = [lap.clone(), d("DELL U2720Q", 1920, 0, 1920, 1080)]; + assert_eq!(pick_display(&key, &rescaled), Some(1)); + // Unplugged: nothing, so the caller falls back to the primary display. + assert_eq!(pick_display(&key, &[lap.clone()]), None); + } + + #[test] + fn two_identical_monitors_are_told_apart_by_place() { + let a = d("LG 27UL500", 0, 0, 1920, 1080); + let b = d("LG 27UL500", 1920, 0, 1920, 1080); + assert_eq!(pick_display(&b.key(), &[a.clone(), b.clone()]), Some(1)); + assert_eq!(pick_display(&a.key(), &[a, b]), Some(0)); + } + + #[test] + fn preferences_saved_before_still_match() { + let lap = d("eDP-1", 0, 0, 1920, 1200); + let ext = d("HDMI-1", 1920, 0, 1920, 1080); + assert_eq!(pick_display("at:1920,0", &[lap.clone(), ext.clone()]), Some(1)); + assert_eq!(pick_display("primary", &[lap, ext]), None); + assert_eq!(pick_display("at:nonsense", &[]), None); + } +} + pub fn screen_info(app: &AppHandle, pref: &str) -> ScreenInfo { match target_monitor(app, pref) { Some(m) => { diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index 53543715d..8253961d4 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -61,6 +61,7 @@ pub fn local_dir() -> PathBuf { /// plugin paths that vanish once Coucou quits. Give ours its own file. pub fn prepare_environment() { prefer_x11_on_gnome(); + follow_gnome_text_scaling(); if std::env::var_os("APPIMAGE").is_none() || std::env::var_os("GST_REGISTRY").is_some() { return; } @@ -77,15 +78,57 @@ pub fn prepare_environment() { /// pass theirs down to every child. COUCOU_X11=0 keeps the Wayland window. fn prefer_x11_on_gnome() { let env = |k: &str| std::env::var(k).unwrap_or_default(); - if should_prefer_x11(&env("XDG_SESSION_TYPE"), &env("XDG_CURRENT_DESKTOP"), &env("COUCOU_X11")) { + if should_prefer_x11( + &env("XDG_SESSION_TYPE"), + &env("XDG_CURRENT_DESKTOP"), + &env("COUCOU_X11"), + &env("DISPLAY"), + ) { std::env::set_var("GDK_BACKEND", "x11"); } } -fn should_prefer_x11(session: &str, desktop: &str, opt: &str) -> bool { +/// Only with XWayland actually there (`DISPLAY` set): forcing x11 without it +/// would leave GTK with no display at all. +fn should_prefer_x11(session: &str, desktop: &str, opt: &str, display: &str) -> bool { session.eq_ignore_ascii_case("wayland") && desktop.split(':').any(|d| d.eq_ignore_ascii_case("gnome")) && opt != "0" + && !display.trim().is_empty() +} + +fn is_gnome(desktop: &str) -> bool { + desktop.split(':').any(|d| d.eq_ignore_ascii_case("gnome")) +} + +/// GNOME's "Large text" (text-scaling-factor > 1) makes WebKitGTK draw every +/// font larger inside a window sized in fixed pixels, so the island's content +/// was cut off (#122). The island's layout is its own, so it asks GDK to undo +/// that factor for this process only. An explicit GDK_DPI_SCALE always wins. +fn follow_gnome_text_scaling() { + if std::env::var_os("GDK_DPI_SCALE").is_some() + || !is_gnome(&std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default()) + { + return; + } + let Ok(out) = Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "text-scaling-factor"]) + .output() + else { + return; + }; + if let Some(scale) = dpi_scale_for(&String::from_utf8_lossy(&out.stdout)) { + std::env::set_var("GDK_DPI_SCALE", scale); + } +} + +/// `"1.25\n"` → `Some("0.8")`; `None` for 1.0 or anything unreadable. +fn dpi_scale_for(gsettings_output: &str) -> Option { + let factor: f64 = gsettings_output.trim().parse().ok()?; + if !(0.5..=3.0).contains(&factor) || (factor - 1.0).abs() < 0.01 { + return None; + } + Some(format!("{:.4}", 1.0 / factor).trim_end_matches('0').trim_end_matches('.').to_string()) } pub fn local_time() -> LocalTime { @@ -230,6 +273,10 @@ pub fn unblock_webview_drops(_app: &AppHandle) {} /// up to the window manager. pub fn make_non_activating(win: &WebviewWindow) { let Ok(gw) = win.gtk_window() else { return }; + // The pointer watch reports the pointer leaving on every kind of window: + // a regular one (GNOME, X11) misses `mouseout` just as a layer surface does. + ISLAND_GTK.with(|cell| *cell.borrow_mut() = Some(gw.clone())); + watch_pointer_leave(win); // COUCOU_LAYER_SHELL=0 is the way out on a compositor where it misbehaves. let wanted = std::env::var("COUCOU_LAYER_SHELL").map(|v| v != "0").unwrap_or(true); let supported = unsafe { layer::gtk_layer_is_supported() } != 0; @@ -291,8 +338,6 @@ pub fn make_non_activating(win: &WebviewWindow) { } gtk::glib::Propagation::Proceed }); - ISLAND_GTK.with(|cell| *cell.borrow_mut() = Some(gw.clone())); - watch_pointer_leave(win); LAYER_SURFACE.store(true, Ordering::Relaxed); crate::log::line("island is a layer-shell overlay"); } @@ -465,10 +510,22 @@ mod tests { #[test] fn x11_is_only_preferred_on_gnome_wayland_unless_opted_out() { - assert!(should_prefer_x11("wayland", "ubuntu:GNOME", "")); - assert!(!should_prefer_x11("x11", "ubuntu:GNOME", "")); - assert!(!should_prefer_x11("wayland", "KDE", "")); - assert!(!should_prefer_x11("wayland", "GNOME", "0")); + assert!(should_prefer_x11("wayland", "ubuntu:GNOME", "", ":0")); + assert!(!should_prefer_x11("x11", "ubuntu:GNOME", "", ":0")); + assert!(!should_prefer_x11("wayland", "KDE", "", ":0")); + assert!(!should_prefer_x11("wayland", "GNOME", "0", ":0")); + // No XWayland: keep the Wayland window rather than no display at all. + assert!(!should_prefer_x11("wayland", "GNOME", "", "")); + } + + #[test] + fn text_scaling_is_undone_for_the_island() { + assert_eq!(dpi_scale_for("1.25\n").as_deref(), Some("0.8")); + assert_eq!(dpi_scale_for("1.5").as_deref(), Some("0.6667")); + assert_eq!(dpi_scale_for("1.0").as_deref(), None); + assert_eq!(dpi_scale_for("").as_deref(), None); + assert_eq!(dpi_scale_for("nonsense").as_deref(), None); + assert_eq!(dpi_scale_for("40").as_deref(), None); } #[test] diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index df9a881fb..ccc1b77fd 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -394,8 +394,17 @@ function generalSection(): HTMLElement { screen.value = settings.screen; void Bridge.listMonitors().then((list) => { for (const m of list ?? []) screen.append(h("option", { value: m.key, text: m.label })); - // Set again now the option exists; a display that is gone shows as the main one. - screen.value = settings.screen; + // Set again now the option exists. A display saved under an older key (moved, + // resized, or saved before names were kept) is shown by its place or its + // name; one that is gone shows as the main one. + const saved = settings.screen; + const [place, name] = saved.split("|"); + const keys = (list ?? []).map((m) => m.key); + screen.value = + keys.find((k) => k === saved) ?? + (saved.startsWith("at:") ? keys.find((k) => k.split("|")[0] === place) : undefined) ?? + (name ? keys.find((k) => k.split("|")[1] === name) : undefined) ?? + saved; if (!screen.value) screen.value = "primary"; }); screen.addEventListener("change", () => { From 452df97570cdab545db55e6c4ae6b581a5acb019 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:30:02 +0200 Subject: [PATCH 42/77] File drop (#240): copy only paths a real drop delivered ingest_file is callable from the page; on its own it copied any file the user can read. It now accepts only paths the OS just delivered through a drop (WebView2's drop objects on Windows, Tauri's drag-drop event on Linux), each once, within two minutes. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/files.rs | 67 +++++++++++++++++++++++++++ windows/src-tauri/src/lib.rs | 7 +++ windows/src-tauri/src/webview_drop.rs | 1 + 3 files changed, 75 insertions(+) diff --git a/windows/src-tauri/src/files.rs b/windows/src-tauri/src/files.rs index 144907152..07cce8e6a 100644 --- a/windows/src-tauri/src/files.rs +++ b/windows/src-tauri/src/files.rs @@ -23,7 +23,49 @@ pub fn inbox_dir() -> PathBuf { settings::local_dir().join("inbox") } +// ── Paths a real drop delivered ───────────────────────────────────────────── +// +// `ingest_file` is callable from the page, so on its own it would copy any file +// the user can read (a script injected into the page could pull in ~/.ssh). +// Only paths the OS just delivered through a real drop are accepted: WebView2's +// drop objects on Windows (webview_drop.rs), Tauri's drag-drop window event +// elsewhere (lib.rs). Each is good once, for a couple of minutes. + +const DROP_VALID_FOR: Duration = Duration::from_secs(120); +const DROP_MAX_PENDING: usize = 64; + +static DROPPED: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// Records paths that came from a real drop. +pub fn allow_dropped>(paths: I) { + let mut list = DROPPED.lock().unwrap_or_else(|e| e.into_inner()); + let now = std::time::Instant::now(); + list.retain(|(_, at)| now.duration_since(*at) < DROP_VALID_FOR); + for p in paths { + list.push((p, now)); + } + let excess = list.len().saturating_sub(DROP_MAX_PENDING); + list.drain(..excess); +} + +/// True (once) when `path` was delivered by a drop in the last couple of minutes. +fn take_dropped(path: &str) -> bool { + let mut list = DROPPED.lock().unwrap_or_else(|e| e.into_inner()); + let now = std::time::Instant::now(); + list.retain(|(_, at)| now.duration_since(*at) < DROP_VALID_FOR); + match list.iter().position(|(p, _)| p == path) { + Some(i) => { + list.remove(i); + true + } + None => false, + } +} + pub fn ingest(source: &str) -> Result { + if !take_dropped(source) { + return Err("Only files dropped on the island can be added.".into()); + } let src = Path::new(source); let meta = std::fs::metadata(src).map_err(|e| format!("cannot read {source}: {e}"))?; if meta.is_dir() { @@ -94,18 +136,22 @@ mod tests { let source = tmp.join("note.txt"); std::fs::write(&source, b"hello").unwrap(); + let drop = |p: &Path| allow_dropped([p.to_string_lossy().to_string()]); + drop(&source); let first = ingest(source.to_str().unwrap()).unwrap(); assert_eq!(first.name, "note.txt"); assert_eq!(std::fs::read(&first.path).unwrap(), b"hello"); // A second drop of the same name must not clobber the first copy. std::fs::write(&source, b"second").unwrap(); + drop(&source); let second = ingest(source.to_str().unwrap()).unwrap(); assert_ne!(first.path, second.path); assert_eq!(std::fs::read(&first.path).unwrap(), b"hello"); assert_eq!(std::fs::read(&second.path).unwrap(), b"second"); // Folders are refused rather than silently ignored. + drop(&tmp); assert!(ingest(tmp.to_str().unwrap()).is_err()); // An ancient source must not arrive already older than the sweep window. @@ -118,6 +164,7 @@ mod tests { .unwrap() .set_modified(long_ago) .unwrap(); + drop(&old_source); let aged = ingest(old_source.to_str().unwrap()).unwrap(); assert!( Path::new(&aged.path).exists(), @@ -130,3 +177,23 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } } + +#[cfg(test)] +mod drop_tests { + use super::*; + + // One test: the list is process-wide and tests run in parallel. + #[test] + fn only_a_dropped_path_is_ingested_once_and_the_list_stays_bounded() { + let p = "/tmp/coucou-test-not-dropped.txt".to_string(); + assert!(ingest(&p).is_err(), "never dropped"); + allow_dropped([p.clone()]); + assert!(take_dropped(&p)); + assert!(!take_dropped(&p), "good once"); + + allow_dropped((0..200).map(|i| format!("/tmp/bounded-{i}"))); + assert!(DROPPED.lock().unwrap().len() <= DROP_MAX_PENDING); + assert!(take_dropped("/tmp/bounded-199")); + assert!(!take_dropped("/tmp/bounded-0")); + } +} diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 006b8cb1e..3b77ef0aa 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -421,6 +421,13 @@ pub fn run() { create_settings_window(&handle); if let Some(win) = island::window(&handle) { + // Where Tauri takes the drop itself (Linux), its paths are the + // ones ingest_file may copy (files.rs). + win.on_window_event(|event| { + if let tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event { + files::allow_dropped(paths.iter().map(|p| p.to_string_lossy().to_string())); + } + }); platform::make_non_activating(&win); #[cfg(windows)] webview_drop::install(&handle); diff --git a/windows/src-tauri/src/webview_drop.rs b/windows/src-tauri/src/webview_drop.rs index 4a729c8b4..93775d9a2 100644 --- a/windows/src-tauri/src/webview_drop.rs +++ b/windows/src-tauri/src/webview_drop.rs @@ -55,6 +55,7 @@ pub fn install(app: &AppHandle) { file.Path(&mut path)?; paths.push(take_pwstr(path)); } + crate::files::allow_dropped(paths.iter().cloned()); let _ = handle.emit_to(WINDOW_LABEL, "file-drag", FileDrop { kind: "drop", paths }); Ok(()) })); From 5c3169c2ec3d8c86d08d00219acd59b2c467fbc5 Mon Sep 17 00:00:00 2001 From: Shakib Bin Kabir Date: Sun, 4 Oct 2026 18:59:54 +0600 Subject: [PATCH 43/77] Windows: keep ticker steps on their own line The completed step's dim text was absolutely positioned with no `top`, so its static position fell one line below the shimmer text and it was drawn over the current step. Pin it to `top: 0`. The overview also never told the frame loop that the ticker was mid-scroll, so a step arriving while everything else was settled could freeze half-way. The view's tick() now reports whether it needs another frame. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 200f7f77e49dcea26f2e6d87cb13e0e0d5d10e77) --- windows/src/island/island.ts | 5 +++-- windows/src/views/ticker.ts | 4 +++- windows/src/views/views.ts | 8 +++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 476dc3e5f..c6c253bb9 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -744,7 +744,8 @@ export class Island { this.viewsEl.classList.toggle("hidden-by-upload", uploadActive); tickMiniBots(dt); - this.views.get(State.view)?.tick?.(nowMs); + // A ticker scroll that loses its frames freezes mid-way, rows overlapping. + const viewAnimating = this.views.get(State.view)?.tick?.(nowMs) === true; if (UploadSeq.isActive) this.stepSequence(); this.updateCountdown(nowMs); @@ -760,7 +761,7 @@ export class Island { ? settling : settling || !this.botCx.settled || !this.botCy.settled || !this.botSize.settled || - greetingActive || this.engine.busy || UploadSeq.isActive; + greetingActive || this.engine.busy || UploadSeq.isActive || viewAnimating; if (busy) { requestAnimationFrame(this.frame); diff --git a/windows/src/views/ticker.ts b/windows/src/views/ticker.ts index 6d03f2cdb..1d14922cd 100644 --- a/windows/src/views/ticker.ts +++ b/windows/src/views/ticker.ts @@ -38,7 +38,9 @@ function makeRow(): Row { const shimmer = h("span", { class: "tick-text shimmer" }); const dim = h("span", { class: "tick-text", - style: "position:absolute;left:0;right:0;color:#6b7079", + // top:0 — without it the static position falls below the shimmer text, + // drawing the completed step over the current one. + style: "position:absolute;top:0;left:0;right:0;color:#6b7079", }); const el = h( "div", diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index d8efd3bc9..aad4d09f4 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -35,8 +35,8 @@ export interface ViewHost { sync(): void; /** Called when the view becomes active, for views with a text field. */ focus?(): void; - /** Called every frame while the view is on screen. */ - tick?(nowMs: number): void; + /** Called every frame while the view is on screen. True = needs another frame. */ + tick?(nowMs: number): boolean | void; } // ── Shared pieces ───────────────────────────────────────────────────────────── @@ -163,7 +163,9 @@ function buildOverview(actions: ViewActions): ViewHost { return { el, tick(nowMs: number) { - if (mode === "ticker") ticker.tick(nowMs); + if (mode !== "ticker") return false; + ticker.tick(nowMs); + return ticker.animating; }, sync() { const task = State.focusTask; From 771ea3b318cb82d20f2338e036f3bc73e6b3db15 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:31:35 +0200 Subject: [PATCH 44/77] Questions (#216): check answers against the questions, lists for multi-select - The relay only sends answers back when they answer exactly the questions Claude Code asked, with one of each question's labels (same rule as the Mac's QuestionPayload.accepts). - Multi-select answers go back as a list of labels, as on the Mac (Claude Code 2.1.136+), instead of one comma-joined string. - A question answered in the terminal takes the card down (PostToolUse of AskUserQuestion for the same session), as in #172. Co-Authored-By: Claude Opus 5.5 --- windows/hook/src/main.rs | 58 +++++++++++++++++++++++++++++++++-- windows/src-tauri/src/lib.rs | 2 +- windows/src-tauri/src/pipe.rs | 2 +- windows/src/core/bridge.ts | 2 +- windows/src/island/hooks.ts | 35 +++++++++++++++------ windows/src/views/views.ts | 8 ++--- 6 files changed, 88 insertions(+), 19 deletions(-) diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs index a4eb45653..4cd62465c 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -82,10 +82,11 @@ fn decision_json(decision: &str, question: Option<&serde_json::Value>) -> Option if decision.trim_start().starts_with('{') { let reply = serde_json::from_str::(decision).ok()?; let answers = reply.get("answers")?.as_object()?; - if answers.is_empty() || !answers.values().all(|v| v.is_string()) { + let question = question?; + if !answers_fit(question, answers) { return None; } - let mut input = question?.as_object()?.clone(); + let mut input = question.as_object()?.clone(); input.insert("answers".into(), serde_json::Value::Object(answers.clone())); return Some( serde_json::json!({ @@ -109,6 +110,38 @@ fn decision_json(decision: &str, question: Option<&serde_json::Value>) -> Option )) } +/// True when `answers` answers exactly the questions Claude Code asked: one entry +/// per question, keyed by its text; a single-select answer is one of its option +/// labels (a string), a multi-select answer a non-empty list of distinct labels +/// (Claude Code 2.1.136+ takes the list, as the Mac sends it). Same rule as +/// `QuestionPayload.accepts` on macOS. +fn answers_fit(question: &serde_json::Value, answers: &serde_json::Map) -> bool { + let Some(items) = question.get("questions").and_then(|q| q.as_array()) else { return false }; + if items.is_empty() || items.len() != answers.len() { + return false; + } + items.iter().all(|item| { + let Some(text) = item.get("question").and_then(|q| q.as_str()) else { return false }; + let labels: Vec<&str> = item + .get("options") + .and_then(|o| o.as_array()) + .map(|opts| opts.iter().filter_map(|o| o.get("label").and_then(|l| l.as_str())).collect()) + .unwrap_or_default(); + let multi = item.get("multiSelect").and_then(|m| m.as_bool()).unwrap_or(false); + match answers.get(text) { + Some(serde_json::Value::String(pick)) if !multi => labels.contains(&pick.as_str()), + Some(serde_json::Value::Array(picks)) if multi => { + let picks: Vec<&str> = picks.iter().filter_map(|p| p.as_str()).collect(); + let mut seen = picks.clone(); + seen.sort_unstable(); + seen.dedup(); + !picks.is_empty() && seen.len() == picks.len() && picks.iter().all(|p| labels.contains(p)) + } + _ => false, + } + }) +} + /// Reads stdin and returns the payload to forward, the event name, and — for an /// AskUserQuestion permission request — the question as Claude Code sent it. fn read_event() -> Option<(String, String, Option)> { @@ -298,6 +331,27 @@ mod tests { assert!(decision_json(r#"{"command":"rm -rf /"}"#, Some(&question)).is_none()); } + #[test] + fn answers_must_match_the_questions_asked() { + let q = serde_json::json!({ "questions": [ + { "question": "Which one?", "options": [{ "label": "A" }, { "label": "B" }] }, + { "question": "Extras?", "multiSelect": true, + "options": [{ "label": "Tests" }, { "label": "Docs" }, { "label": "Lint" }] } + ]}); + let ok = |a: &str| decision_json(a, Some(&q)).is_some(); + assert!(ok(r#"{"answers":{"Which one?":"A","Extras?":["Tests","Docs"]}}"#)); + // Not one of the labels, or an answer to a question nobody asked. + assert!(!ok(r#"{"answers":{"Which one?":"C","Extras?":["Tests"]}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":["Tests"],"Other?":"x"}}"#)); + // A question left out. + assert!(!ok(r#"{"answers":{"Which one?":"A"}}"#)); + // Shapes: single-select is one string, multi-select a non-empty list. + assert!(!ok(r#"{"answers":{"Which one?":["A"],"Extras?":["Tests"]}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":"Tests"}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":[]}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":["Tests","Tests"]}}"#)); + } + #[test] fn long_strings_are_cut_on_a_char_boundary() { let mut v = serde_json::json!({ "tool_input": { "content": "é".repeat(4000) } }); diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index e341ebbd8..264e4c3a8 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -232,7 +232,7 @@ fn approval_decision(app: AppHandle, request_id: String, decision: String) { fn approval_answer( app: AppHandle, request_id: String, - answers: std::collections::HashMap, + answers: std::collections::HashMap, ) { pipe::answer_question(&app, &request_id, &answers); } diff --git a/windows/src-tauri/src/pipe.rs b/windows/src-tauri/src/pipe.rs index 8e0bbcada..8d3566b3f 100644 --- a/windows/src-tauri/src/pipe.rs +++ b/windows/src-tauri/src/pipe.rs @@ -306,7 +306,7 @@ pub fn answer(app: &AppHandle, request_id: &str, decision: &str) { /// Called when an option is picked for a question Claude Code asked. `answers` /// maps each question's text to the chosen label, which is the shape /// AskUserQuestion takes them in. -pub fn answer_question(app: &AppHandle, request_id: &str, answers: &HashMap) { +pub fn answer_question(app: &AppHandle, request_id: &str, answers: &HashMap) { log::line(format!("decision id={request_id} answered a question")); // One line: the relay reads up to the first newline. let line = json!({ "answers": answers }).to_string(); diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 64573a213..c6121c159 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -81,7 +81,7 @@ export const Bridge = { /** "The card is up" — until this lands the relay only waits a moment. */ approvalAck: (requestId: string) => call("approval_ack", { requestId }), /** Answers a question Claude Code asked: question text → chosen label. */ - approvalAnswer: (requestId: string, answers: Record) => + approvalAnswer: (requestId: string, answers: Record) => call("approval_answer", { requestId, answers }), /** "Nobody can act on this" — Claude Code asks in the terminal right away. */ diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index 27e310cd9..ff03a72fb 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -13,6 +13,20 @@ const CLAUDE_ID = "integration_claude"; /** Clears the approval card if no decision was made before the hook gave up. */ let pendingTimeout: number | null = null; +/** Takes the approval or question card down and gives the island back. */ +function dropPendingCard(island: Island): void { + if (!State.pendingApproval) return; + State.pendingApproval = null; + State.isPinned = false; + island.dropPin(); + State.updateTask(CLAUDE_ID, "working"); + State.setPillBadge(CLAUDE_ID, null); + if (State.view === "approval" || State.view === "question") { + island.setView(State.defaultView()); + } + State.notify(); +} + /** The return to idle that Stop arms, per pill, so the next turn can cancel it. */ const stopTimers = new Map(); @@ -255,6 +269,16 @@ function handleHook(island: Island, payload: HookPayload) { case "PostToolUse": supersedeStop(); + // The question was answered in the terminal: the card would be lying. + if ( + payload.tool_name === "AskUserQuestion" && + State.pendingApproval?.questions && + State.pendingApproval.sessionId === (payload.session_id ?? "") + ) { + if (pendingTimeout != null) window.clearTimeout(pendingTimeout); + pendingTimeout = null; + dropPendingCard(island); + } State.updateTask(agentId, "working"); break; @@ -380,16 +404,7 @@ function handleHook(island: Island, payload: HookPayload) { // taken over and the card would be lying. pendingTimeout = window.setTimeout(() => { pendingTimeout = null; - if (!State.pendingApproval) return; - State.pendingApproval = null; - State.isPinned = false; - island.dropPin(); - State.updateTask(CLAUDE_ID, "working"); - State.setPillBadge(CLAUDE_ID, null); - if (State.view === "approval" || State.view === "question") { - island.setView(State.defaultView()); - } - State.notify(); + dropPendingCard(island); }, 110_000); break; } diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index f4a990b48..e433651da 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -24,7 +24,7 @@ export interface ViewActions { openUrl(url: string): void; decide(d: "allow" | "deny"): void; /** Answers the question Claude Code asked: question text → chosen label. */ - answer(answers: Record): void; + answer(answers: Record): void; /** Hands the pending request back to the terminal. */ answerInTerminal(): void; toggleSound(): void; @@ -354,13 +354,13 @@ function buildQuestion(actions: ViewActions): ViewHost { // far, and what is ticked in a pick-several question. let requestId = ""; let index = 0; - let answers: Record = {}; + let answers: Record = {}; let picked = new Set(); // The buttons are only rebuilt when what they show changes: rebuilding them // between a mouse-down and a mouse-up would swallow the click. let rowKey = ""; - const next = (question: string, answer: string, total: number) => { + const next = (question: string, answer: string | string[], total: number) => { answers[question] = answer; picked = new Set(); index += 1; @@ -418,7 +418,7 @@ function buildQuestion(actions: ViewActions): ViewHost { } if (q.multiSelect) { const done = btn("Done", "primary", () => { - if (picked.size > 0) next(q.question, [...picked].join(", "), questions.length); + if (picked.size > 0) next(q.question, [...picked], questions.length); }); if (picked.size === 0) done.classList.add("off"); row.append(done); From 62e43c6d8b0c9b55e4699b7386b7752647162664 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:38:39 +0200 Subject: [PATCH 45/77] Windows: the island follows the auto-close delay live (Mac #25) Changing the auto-close delay in Settings while the open island counts down now restarts the countdown with the new delay, as on macOS. The countdown bar reads the state machine's own deadline, and a pin that arrives during a countdown keeps the island open. Co-Authored-By: Claude Opus 5.5 --- windows/src/island/fsm.ts | 32 ++++++++-- windows/src/island/island.ts | 15 ++--- windows/tests/fsm.test.mjs | 120 +++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 13 deletions(-) diff --git a/windows/src/island/fsm.ts b/windows/src/island/fsm.ts index 8a24545ed..8cd46d11f 100644 --- a/windows/src/island/fsm.ts +++ b/windows/src/island/fsm.ts @@ -8,8 +8,19 @@ export class IslandStateMachine { onTransition: ((from: FsmState, to: FsmState) => void) | null = null; - /** home → petit delay, seconds. */ - homeToPetitDelay = 15; + /** + * home → petit delay, seconds: the auto-close preference. Changing it while a + * countdown runs starts that countdown again with the new delay, so an edit in + * Settings applies at once (IslandStateMachine.homeToPetitDelay on macOS). + */ + get homeToPetitDelay(): number { + return this.homeDelay; + } + set homeToPetitDelay(seconds: number) { + if (!Number.isFinite(seconds) || seconds < 0 || seconds === this.homeDelay) return; + this.homeDelay = seconds; + if (this.state === "home" && this.homeCollapse != null) this.scheduleHomeCollapse(); + } /** petit → hidden delay, seconds. */ petitToHiddenDelay = 60; /** coucou → petit once the greeting animation ends (no hover). */ @@ -19,6 +30,14 @@ export class IslandStateMachine { /** An alert waiting for an answer stays open, even when the mouse leaves. */ pinned = false; + /** + * When the open island will fold, on the performance.now() clock, while the + * mouse-leave countdown runs; null otherwise. The island draws its countdown + * bar from it. + */ + homeCollapseDueAt: number | null = null; + + private homeDelay = 15; private petitHide: number | null = null; private homeCollapse: number | null = null; private greetCollapse: number | null = null; @@ -115,10 +134,14 @@ export class IslandStateMachine { private scheduleHomeCollapse() { this.clear("homeCollapse"); if (this.pinned) return; + const ms = this.homeDelay * 1000; + this.homeCollapseDueAt = performance.now() + ms; this.homeCollapse = window.setTimeout(() => { this.homeCollapse = null; - if (this.state === "home") this.transition("petit"); - }, this.homeToPetitDelay * 1000); + this.homeCollapseDueAt = null; + // An alert pinned while the countdown ran keeps the island open. + if (this.state === "home" && !this.pinned) this.transition("petit"); + }, ms); } private scheduleGreetCollapse(delay: number) { @@ -133,6 +156,7 @@ export class IslandStateMachine { const id = this[which]; if (id != null) window.clearTimeout(id); this[which] = null; + if (which === "homeCollapse") this.homeCollapseDueAt = null; } cancelTimers() { diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 54dbf5f09..07bd41830 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -73,7 +73,6 @@ export class Island { private wasInIsland = false; /** Last shape handed to Rust for the click-through test. */ private pushedRect = { x: -1, y: -1, w: -1, h: -1 }; - private homeCollapseAt: number | null = null; // Bot hover → love (IslandWindowController.botHoverIn) private botHovering = false; @@ -315,7 +314,6 @@ export class Island { if (State.mode !== "expanded") this.setMode("expanded"); else this.animateGeometry(false); State.lastActivity = performance.now(); - this.homeCollapseAt = null; State.notify(); } @@ -638,13 +636,9 @@ export class Island { if (inIsland && !this.wasInIsland) { if (this.fsm.state === "coucou") this.greeting.hover(); this.fsm.mouseEntered(); - this.homeCollapseAt = null; } if (!inIsland && this.wasInIsland) { this.fsm.mouseLeft(); - if (this.fsm.state === "home" && !State.isPinned) { - this.homeCollapseAt = performance.now() + State.settings.autoCloseInterval * 1000; - } } this.wasInIsland = inIsland; @@ -870,13 +864,16 @@ export class Island { } private updateCountdown(nowMs: number) { - if (State.mode !== "expanded" || State.isPinned || this.homeCollapseAt == null) { + // The state machine's own deadline, so the bar follows an auto-close delay + // edited while the countdown runs. + const dueAt = this.fsm.homeCollapseDueAt; + if (State.mode !== "expanded" || State.isPinned || dueAt == null) { this.countdown.style.width = "0px"; return; } - const autoClose = State.settings.autoCloseInterval; + const autoClose = this.fsm.homeToPetitDelay; const windowS = Math.min(10, autoClose * 0.6); - const remaining = (this.homeCollapseAt - nowMs) / 1000; + const remaining = (dueAt - nowMs) / 1000; this.countdown.style.width = remaining < windowS ? `${Math.max(0, clamp(remaining / windowS, 0, 1) * 160)}px` : "0px"; } diff --git a/windows/tests/fsm.test.mjs b/windows/tests/fsm.test.mjs index efb50267f..06156a9d0 100644 --- a/windows/tests/fsm.test.mjs +++ b/windows/tests/fsm.test.mjs @@ -162,3 +162,123 @@ test("a transition to the current state is not reported", () => { fsm.forceHome(); assert.deepEqual(transitions, ["hidden>home"]); }); + +// ── Auto-close delay from Settings (IslandAutoCloseTests.swift) ────────────── + +/** Opened by a click, the way the Mac tests open theirs. */ +function opened(delay) { + fsm.homeToPetitDelay = delay; + fsm.mouseEntered(); + fsm.click(); + assert.equal(fsm.state, "home"); +} + +test("a configured 2-second delay replaces the default 15 seconds", () => { + opened(2); + fsm.mouseLeft(); + seconds(1.9); + assert.equal(fsm.state, "home"); + seconds(0.1); + assert.equal(fsm.state, "petit"); +}); + +test("editing the delay during a countdown replaces its timer", () => { + opened(15); + fsm.mouseLeft(); + fsm.homeToPetitDelay = 0.05; + seconds(0.05); + assert.equal(fsm.state, "petit"); +}); + +test("a longer delay also cancels the shorter timer that was running", () => { + opened(0.05); + fsm.mouseLeft(); + fsm.homeToPetitDelay = 0.25; + seconds(0.1); + assert.equal(fsm.state, "home"); + seconds(0.15); + assert.equal(fsm.state, "petit"); +}); + +test("coming back cancels the countdown, and leaving again starts it with the delay", () => { + opened(0.05); + fsm.mouseLeft(); + fsm.mouseEntered(); + seconds(0.1); + assert.equal(fsm.state, "home"); + fsm.mouseLeft(); + seconds(0.05); + assert.equal(fsm.state, "petit"); +}); + +test("a delay edit while the island is hovered starts no countdown", () => { + opened(15); + fsm.homeToPetitDelay = 0.05; + seconds(600); + assert.equal(fsm.state, "home"); + fsm.mouseLeft(); + seconds(0.05); + assert.equal(fsm.state, "petit"); +}); + +test("the greeting keeps its own timing whatever the auto-close delay", () => { + fsm.greetAutoCollapseDelay = 0.15; + fsm.launch(); + fsm.greetComplete(); + fsm.homeToPetitDelay = 0.01; + seconds(0.05); + assert.equal(fsm.state, "coucou"); + seconds(0.1); + assert.equal(fsm.state, "petit"); +}); + +test("an alert waiting for an answer stays open through a delay edit", () => { + opened(0.05); + fsm.pinned = true; + fsm.mouseLeft(); + fsm.homeToPetitDelay = 0.01; + seconds(600); + assert.equal(fsm.state, "home"); + fsm.pinned = false; + fsm.mouseLeft(); + seconds(0.01); + assert.equal(fsm.state, "petit"); +}); + +test("an alert pinned during a countdown blocks the old timer and its replacement", () => { + opened(0.2); + fsm.mouseLeft(); + fsm.pinned = true; + fsm.homeToPetitDelay = 0.01; + seconds(600); + assert.equal(fsm.state, "home"); + fsm.pinned = false; + fsm.mouseLeft(); + seconds(0.01); + assert.equal(fsm.state, "petit"); +}); + +test("a pin that arrives after the timer was armed still holds the island", () => { + opened(1); + fsm.mouseLeft(); + fsm.pinned = true; + seconds(600); + assert.equal(fsm.state, "home"); +}); + +test("the deadline the countdown bar reads follows the delay and clears with the timer", () => { + opened(15); + assert.equal(fsm.homeCollapseDueAt, null); + fsm.mouseLeft(); + const first = fsm.homeCollapseDueAt; + assert.ok(first != null); + fsm.homeToPetitDelay = 5; + assert.ok(fsm.homeCollapseDueAt < first); + fsm.mouseEntered(); + assert.equal(fsm.homeCollapseDueAt, null); +}); + +test("an unusable delay is ignored", () => { + for (const bad of [NaN, -1, Infinity]) fsm.homeToPetitDelay = bad; + assert.equal(fsm.homeToPetitDelay, 15); +}); From 1190fab3e697b8db844c1bfae7439a08acdbeae6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:43:18 +0200 Subject: [PATCH 46/77] Plan usage relay: coucou-hook --statusline and its installer Claude Code's status line hands its command the plan limits (rate_limits). `coucou-hook --statusline` forwards only those and the session id to the app (300 ms at most, in the background), then runs the status line the user had before with the same input and prints its output untouched, so it keeps working. That command is kept in statusline-previous.json beside the relay. Running the previous status line the way Claude Code runs it: /bin/sh -c on Linux, Git Bash's bash -c on Windows (CLAUDE_CODE_GIT_BASH_PATH, then the Git for Windows that git.exe on PATH belongs to, the usual install folders, then bash.exe on PATH except the WSL launcher), never cmd /C. It gets 10 s, 64 KB of output are kept and the rest drained, and a background child holding the pipe open cannot hold the relay. hooks.rs gains the status line install/remove, apart from the hooks: diff of the `statusLine` key only, dated backup, fingerprint check and a write after a click through the same hardened writer. Only `command` is swapped; removing puts the previous status line back, or drops the key, and leaves a status line the user changed since alone. Based on #171 by Justin Minkmar (273efa0), re-applied on this branch. Co-authored-by: Justin Minkmar Co-Authored-By: Claude Opus 5.5 --- windows/hook/src/main.rs | 10 +- windows/hook/src/statusline.rs | 338 +++++++++++++++++++++++++++++++++ windows/src-tauri/src/hooks.rs | 164 +++++++++++++++- 3 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 windows/hook/src/statusline.rs diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs index 4cd62465c..3fa87d1b6 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -14,7 +14,10 @@ //! island is the whole point. No answer means empty stdout, and Claude Code //! asks in the terminal exactly as if Coucou were not installed. //! -//! Usage: `coucou-hook ` (the name is also read from the JSON). +//! Usage: `coucou-hook ` (the name is also read from the JSON), or +//! `coucou-hook --statusline` as Claude Code's status line command (plan usage, +//! see statusline.rs): it passes the plan limits on and runs the status line the +//! user had before, so that keeps working. use std::io::{Read, Write}; use std::sync::mpsc; @@ -34,6 +37,8 @@ const DROPPED_FIELDS: &[&str] = &["tool_response", "transcript_path"]; /// less than this anyway. const MAX_FIELD_LEN: usize = 2_000; +mod statusline; + #[cfg(windows)] mod win; #[cfg(windows)] @@ -45,6 +50,9 @@ mod unix; use unix::connect; fn main() { + if std::env::args().skip(1).any(|a| a == "--statusline") { + statusline::run(); + } let Some((payload, event, question)) = read_event() else { std::process::exit(0) }; let waits_for_answer = event == "PermissionRequest"; diff --git a/windows/hook/src/statusline.rs b/windows/hook/src/statusline.rs new file mode 100644 index 000000000..2d135eb64 --- /dev/null +++ b/windows/hook/src/statusline.rs @@ -0,0 +1,338 @@ +//! `coucou-hook --statusline`: Claude Code's status line command, for the plan +//! usage pill. +//! +//! Claude Code hands its status line command the session's JSON on stdin, and +//! whatever that command prints becomes the status line. Coucou only wants the +//! plan limits (`rate_limits`) out of it: they go on to the app in the +//! background and nothing else of the input does. If the user had a status line +//! of their own, it is run the way Claude Code runs it — `/bin/sh -c` on Linux, +//! Git Bash's `bash -c` on Windows — with the same input, and what it prints is +//! passed on untouched, so it keeps working. +//! +//! Never blocking Claude Code: the app gets [`APP_BUDGET`] at most, the user's +//! own status line [`PREVIOUS_BUDGET`] (then it is killed and prints nothing), and +//! no more than [`MAX_OUTPUT`] bytes of what it prints are kept. + +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant}; + +use crate::{talk, truncate_strings, CONNECT_TIMEOUT}; + +/// How long the app may take to take the limits. Same 300 ms as a hook connect. +const APP_BUDGET: Duration = CONNECT_TIMEOUT; +/// How long the user's own status line may take before we give up on it (the +/// Mac's relay allows the same). +const PREVIOUS_BUDGET: Duration = Duration::from_secs(10); +/// What is kept of the user's status line output. A status line is a line or +/// two; anything past this is drained and dropped so the command never stalls +/// on a full pipe. +const MAX_OUTPUT: usize = 64 * 1024; +/// After the command has exited, how long its output may still take to arrive +/// (a background child of it can hold the pipe open forever). +const DRAIN_GRACE: Duration = Duration::from_millis(200); + +/// Saved by the app next to this program when the relay took the status line. +const PREVIOUS_FILE: &str = "statusline-previous.json"; + +pub fn run() -> ! { + let mut raw = Vec::new(); + let _ = std::io::stdin().read_to_end(&mut raw); + if raw.starts_with(&[0xEF, 0xBB, 0xBF]) { + raw.drain(..3); + } + + // The limits go on in the background, never delaying the status line. + let sent = serde_json::from_slice::(&raw) + .ok() + .and_then(|v| payload(v.as_object()?)) + .map(|line| { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(talk(&line, false)); + }); + rx + }); + + if let Some(command) = previous_command() { + if let Some(out) = run_previous(&command, &raw, PREVIOUS_BUDGET) { + let mut stdout = std::io::stdout(); + let _ = stdout.write_all(&out); + let _ = stdout.flush(); + } + } + if let Some(rx) = sent { + let _ = rx.recv_timeout(APP_BUDGET); + } + std::process::exit(0); +} + +/// The one line the app gets from a status line call: the limits and which +/// session they came from, nothing else (no cwd, no model, no transcript). +/// Without limits — API-key users — there is nothing to send. +pub fn payload(map: &serde_json::Map) -> Option { + let limits = map.get("rate_limits").filter(|v| v.is_object())?; + let mut value = serde_json::json!({ + "hook_event_name": "StatusLine", + "session_id": map.get("session_id").cloned().unwrap_or(serde_json::Value::Null), + "rate_limits": limits, + }); + truncate_strings(&mut value); + let mut line = value.to_string(); + line.push('\n'); + Some(line) +} + +/// The command of the status line the user had before the relay took its place. +fn previous_command() -> Option { + let path = std::env::current_exe().ok()?.with_file_name(PREVIOUS_FILE); + let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(path).ok()?).ok()?; + saved.get("command")?.as_str().filter(|c| !c.trim().is_empty()).map(str::to_string) +} + +/// The shell Claude Code runs a status line command with. +#[cfg(unix)] +fn shell(command: &str) -> Option { + let mut c = Command::new("/bin/sh"); + c.args(["-c", command]); + Some(c) +} + +/// On Windows Claude Code runs status line commands through Git Bash, so a +/// command written for it (`~/bin/line.sh`, `$HOME`, pipes into `jq`…) only +/// works there. No Git Bash found: nothing is run rather than guessing with +/// `cmd`, which would mangle such a command. +#[cfg(windows)] +fn shell(command: &str) -> Option { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let bash = git_bash_candidates(|k| std::env::var_os(k), |p| p.is_file()).into_iter().next()?; + let mut c = Command::new(bash); + c.args(["-c", command]).creation_flags(CREATE_NO_WINDOW); + Some(c) +} + +/// Where Git Bash may be, best first, keeping only those `exists` accepts: +/// Claude Code's own override (`CLAUDE_CODE_GIT_BASH_PATH`), the Git for Windows +/// that `git.exe` on PATH belongs to, the usual install folders, then any +/// `bash.exe` on PATH — except Windows' own WSL launcher (System32, WindowsApps), +/// which would run the command in Linux instead. +#[cfg_attr(not(windows), allow(dead_code))] +fn git_bash_candidates( + var: impl Fn(&str) -> Option, + exists: impl Fn(&Path) -> bool, +) -> Vec { + let mut out: Vec = Vec::new(); + if let Some(p) = var("CLAUDE_CODE_GIT_BASH_PATH").filter(|p| !p.is_empty()) { + out.push(PathBuf::from(p)); + } + let path_dirs: Vec = var("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); + // …\Git\cmd\git.exe, …\Git\bin\git.exe or …\Git\mingw64\bin\git.exe. + for dir in path_dirs.iter().filter(|d| exists(&d.join("git.exe"))) { + if let Some(root) = dir.parent() { + out.push(root.join("bin").join("bash.exe")); + if let Some(up) = root.parent() { + out.push(up.join("bin").join("bash.exe")); + } + } + } + for base in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] { + if let Some(dir) = var(base).filter(|p| !p.is_empty()) { + out.push(PathBuf::from(dir).join("Git").join("bin").join("bash.exe")); + } + } + if let Some(dir) = var("LOCALAPPDATA").filter(|p| !p.is_empty()) { + out.push(PathBuf::from(dir).join("Programs").join("Git").join("bin").join("bash.exe")); + } + for dir in &path_dirs { + let windows_own = dir.components().any(|c| { + let c = c.as_os_str().to_string_lossy(); + c.eq_ignore_ascii_case("system32") || c.eq_ignore_ascii_case("windowsapps") + }); + if !windows_own { + out.push(dir.join("bash.exe")); + } + } + let mut seen = Vec::new(); + out.retain(|p| { + let fresh = !seen.contains(p) && exists(p); + seen.push(p.clone()); + fresh + }); + out +} + +/// Runs `command` through the status line shell with `input` on stdin and +/// returns what it printed (at most [`MAX_OUTPUT`] bytes), or nothing if it could +/// not start or ran past `budget` (then it is killed). +pub fn run_previous(command: &str, input: &[u8], budget: Duration) -> Option> { + let mut child = shell(command)? + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + // Feed and drain on their own threads: a long input or output cannot wedge us. + let mut stdin = child.stdin.take()?; + let data = input.to_vec(); + std::thread::spawn(move || { + let _ = stdin.write_all(&data); + // Dropped here: the command sees the end of its input. + }); + let mut stdout = child.stdout.take()?; + let kept = Arc::new(Mutex::new(Vec::new())); + let (done_tx, done_rx) = mpsc::channel::<()>(); + { + let kept = Arc::clone(&kept); + std::thread::spawn(move || { + let mut chunk = [0u8; 8192]; + loop { + match stdout.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => { + let mut buf = kept.lock().unwrap_or_else(|e| e.into_inner()); + let room = MAX_OUTPUT.saturating_sub(buf.len()); + buf.extend_from_slice(&chunk[..n.min(room)]); + } + } + } + let _ = done_tx.send(()); + }); + } + + let deadline = Instant::now() + budget; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + // Whatever arrived; a child process of the command holding the pipe open + // does not get to hold us too. + let _ = done_rx.recv_timeout(DRAIN_GRACE); + let out = std::mem::take(&mut *kept.lock().unwrap_or_else(|e| e.into_inner())); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::ffi::OsString; + + #[test] + fn only_the_limits_and_the_session_go_to_the_app() { + let input = serde_json::json!({ + "session_id": "s1", "cwd": "/secret", "model": { "id": "x" }, + "transcript_path": "/home/me/.claude/x.jsonl", + "rate_limits": { "five_hour": { "used_percentage": 42, "resets_at": 1 } } + }); + let line = payload(input.as_object().unwrap()).unwrap(); + assert!(line.ends_with('\n')); + let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap(); + assert_eq!(v["hook_event_name"], "StatusLine"); + assert_eq!(v["session_id"], "s1"); + assert_eq!(v["rate_limits"]["five_hour"]["used_percentage"], 42); + assert_eq!(v.as_object().unwrap().len(), 3); + } + + #[test] + fn without_limits_nothing_is_sent() { + // API-key users get no rate_limits. + assert!(payload(serde_json::json!({ "session_id": "s1" }).as_object().unwrap()).is_none()); + assert!(payload(serde_json::json!({ "rate_limits": "x" }).as_object().unwrap()).is_none()); + } + + #[cfg(unix)] + #[test] + fn the_users_own_status_line_gets_the_same_input_and_its_output_comes_back() { + let out = run_previous("cat; echo ' tail'", b"{\"a\":1}", Duration::from_secs(5)).unwrap(); + assert_eq!(String::from_utf8(out).unwrap(), "{\"a\":1} tail\n"); + } + + #[cfg(unix)] + #[test] + fn a_status_line_that_hangs_is_killed_and_prints_nothing() { + let started = Instant::now(); + assert!(run_previous("sleep 5", b"{}", Duration::from_millis(200)).is_none()); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + #[cfg(unix)] + #[test] + fn a_flood_of_output_is_cut_and_never_stalls_the_command() { + let started = Instant::now(); + let out = run_previous("head -c 1000000 /dev/zero", b"", Duration::from_secs(5)).unwrap(); + assert_eq!(out.len(), MAX_OUTPUT); + assert!(started.elapsed() < Duration::from_secs(3)); + } + + #[cfg(unix)] + #[test] + fn a_background_child_holding_the_pipe_does_not_hold_us() { + let started = Instant::now(); + let out = run_previous("echo hi; (sleep 5 &) ", b"", Duration::from_secs(5)).unwrap(); + assert_eq!(String::from_utf8(out).unwrap(), "hi\n"); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + fn env(pairs: &[(&str, OsString)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(); + move |k| map.get(k).cloned() + } + + fn path_var(dirs: &[&str]) -> OsString { + std::env::join_paths(dirs.iter().map(PathBuf::from)).unwrap() + } + + #[test] + fn git_bash_is_found_the_way_claude_code_finds_it() { + let files = [ + "/override/bash.exe", + "/git/cmd/git.exe", + "/git/bin/bash.exe", + "/pf/Git/bin/bash.exe", + "/wsl/System32/bash.exe", + "/msys/usr/bin/bash.exe", + ]; + let exists = |p: &Path| files.iter().any(|f| Path::new(f) == p); + + // Claude Code's own override wins. + let all = git_bash_candidates( + env(&[ + ("CLAUDE_CODE_GIT_BASH_PATH", "/override/bash.exe".into()), + ("PATH", path_var(&["/wsl/System32", "/git/cmd", "/msys/usr/bin"])), + ("ProgramFiles", "/pf".into()), + ]), + exists, + ); + assert_eq!( + all, + vec![ + PathBuf::from("/override/bash.exe"), + PathBuf::from("/git/bin/bash.exe"), + PathBuf::from("/pf/Git/bin/bash.exe"), + PathBuf::from("/msys/usr/bin/bash.exe"), + ] + ); + + // The WSL launcher in System32 is never taken for Git Bash. + let none = git_bash_candidates(env(&[("PATH", path_var(&["/wsl/System32"]))]), exists); + assert!(none.is_empty()); + + // A missing override falls through to git.exe's own install. + let found = git_bash_candidates( + env(&[("CLAUDE_CODE_GIT_BASH_PATH", "/nope/bash.exe".into()), ("PATH", path_var(&["/git/cmd"]))]), + exists, + ); + assert_eq!(found, vec![PathBuf::from("/git/bin/bash.exe")]); + } +} diff --git a/windows/src-tauri/src/hooks.rs b/windows/src-tauri/src/hooks.rs index 3da1d0e5a..31ee2cd58 100644 --- a/windows/src-tauri/src/hooks.rs +++ b/windows/src-tauri/src/hooks.rs @@ -40,6 +40,8 @@ const MARKER: &str = "coucou-hook"; #[serde(rename_all = "camelCase")] pub struct HookStatus { pub installed: bool, + /// Coucou's status line relay (plan usage) is the one in settings.json. + pub plan_relay_installed: bool, pub settings_path: String, pub hook_path: String, pub hook_ready: bool, @@ -139,6 +141,12 @@ fn entry_is_ours(entry: &Value) -> bool { .unwrap_or(false) } +/// The status line in settings.json is Coucou's relay (old installs wrote +/// `coucou-hook StatusLine`, new ones `coucou-hook --statusline`; both match). +fn status_line_is_ours(v: &Value) -> bool { + v.get("command").and_then(Value::as_str).is_some_and(|c| c.contains(MARKER)) +} + /// Settings with Coucou's hooks added; everything else is left untouched. fn merged(existing: &Value) -> Value { let mut root = existing.as_object().cloned().unwrap_or_default(); @@ -253,6 +261,7 @@ pub fn status() -> HookStatus { let hook_path = settings::hook_exe_path(); HookStatus { installed, + plan_relay_installed: plan_relay_installed(¤t), settings_path: settings_path().to_string_lossy().to_string(), hook_ready: hook_path.exists(), hook_path: hook_path.to_string_lossy().to_string(), @@ -260,10 +269,19 @@ pub fn status() -> HookStatus { } pub fn preview(install: bool) -> Result { + preview_with(|current| if install { merged(current) } else { without_ours(current) }, pretty) +} + +/// The diff of what `next` makes of settings.json, shown with `view` (the whole +/// file, or just the key that changes). +fn preview_with( + next: impl Fn(&Value) -> Value, + view: fn(&Value) -> String, +) -> Result { let current = read_settings()?; - let next = if install { merged(¤t) } else { without_ours(¤t) }; + let next = next(¤t); Ok(HookPreview { - diff: unified_diff(&pretty(¤t), &pretty(&next)), + diff: unified_diff(&view(¤t), &view(&next)), backup: backup_path().to_string_lossy().to_string(), settings_path: settings_path().to_string_lossy().to_string(), fingerprint: current_fingerprint(), @@ -277,6 +295,10 @@ pub fn preview(install: bool) -> Result { /// and make them look at a fresh diff, because the only thing worse than not /// installing the hooks is silently reverting somebody else's edit. pub fn write(install: bool, fingerprint: &str) -> Result { + write_with(|current| if install { merged(current) } else { without_ours(current) }, fingerprint) +} + +fn write_with(next: impl Fn(&Value) -> Value, fingerprint: &str) -> Result { let path = settings_path(); let dir = path.parent().unwrap_or(Path::new(".")); std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; @@ -296,7 +318,7 @@ pub fn write(install: bool, fingerprint: &str) -> Result { std::fs::copy(&path, &backup).map_err(|e| format!("backup failed: {e}"))?; } - let next = if install { merged(¤t) } else { without_ours(¤t) }; + let next = next(¤t); let mut text = pretty(&next); text.push('\n'); @@ -319,6 +341,99 @@ pub fn write(install: bool, fingerprint: &str) -> Result { Ok(backup.to_string_lossy().to_string()) } +// ── Status line (plan usage) ────────────────────────────────────────────────── +// +// Claude Code runs one `statusLine` command and hands it the plan limits. Coucou +// puts its relay there; a status line the user already had is kept in +// statusline-previous.json beside the relay, and the relay still runs it, so it +// keeps working. Installing and removing it is separate from the hooks. + +/// Where the user's own status line waits while the relay stands in for it. +pub fn status_line_previous_path() -> PathBuf { + settings::hook_exe_path().with_file_name("statusline-previous.json") +} + +fn read_status_line_previous() -> Option { + let bytes = std::fs::read(status_line_previous_path()).ok()?; + serde_json::from_slice::(&bytes).ok().filter(Value::is_object) +} + +/// True when the `statusLine` in settings.json is Coucou's relay. +pub fn plan_relay_installed(settings: &Value) -> bool { + settings.get("statusLine").is_some_and(status_line_is_ours) +} + +/// `statusLine` as it reads after installing or removing the relay. `None`: the +/// key goes. Installing swaps only `command`, so `padding`, `refreshInterval` +/// and the rest of the user's status line stay as they were. +fn status_line_after(existing: Option<&Value>, install: bool, previous: Option<&Value>) -> Option { + if install { + let mut sl = existing.filter(|v| v.is_object()).cloned().unwrap_or_else(|| json!({})); + let obj = sl.as_object_mut().expect("an object"); + obj.entry("type").or_insert_with(|| json!("command")); + obj.insert("command".into(), json!(hook_command("--statusline"))); + Some(sl) + } else if existing.is_some_and(status_line_is_ours) { + previous.cloned() + } else { + existing.cloned() // not ours any more (the user changed it): leave it alone + } +} + +fn status_line_settings(current: &Value, install: bool, previous: Option<&Value>) -> Value { + let mut root = current.as_object().cloned().unwrap_or_default(); + match status_line_after(root.get("statusLine"), install, previous) { + Some(sl) => root.insert("statusLine".into(), sl), + None => root.remove("statusLine"), + }; + Value::Object(root) +} + +/// Only the `statusLine` key, which is all the diff of this change is about. +fn status_line_view(settings: &Value) -> String { + pretty(&json!({ "statusLine": settings.get("statusLine").cloned().unwrap_or(Value::Null) })) +} + +/// The diff the user has to look at before the relay goes in or out. +pub fn status_line_preview(install: bool) -> Result { + let previous = read_status_line_previous(); + preview_with(|current| status_line_settings(current, install, previous.as_ref()), status_line_view) +} + +/// Installs or removes the relay, after the same backup and fingerprint checks as +/// the hooks. Installing first saves a status line of the user's own, removing +/// puts it back (or removes the key if there was none). +pub fn status_line_write(install: bool, fingerprint: &str) -> Result { + let before = read_settings()?; + let previous = read_status_line_previous(); + let own = before.get("statusLine").filter(|v| !status_line_is_ours(v)).cloned(); + let saved = install && own.is_some() && save_status_line_previous(own.as_ref().expect("some")).is_ok(); + if install && own.is_some() && !saved { + return Err("could not save your current status line next to the relay; nothing was changed".into()); + } + let backup = write_with(|current| status_line_settings(current, install, previous.as_ref()), fingerprint); + match &backup { + // Back in settings.json: the saved copy has done its job. + Ok(_) if !install => { + let _ = std::fs::remove_file(status_line_previous_path()); + } + // Nothing was written: do not leave a stale copy behind. + Err(_) if saved => { + let _ = std::fs::remove_file(status_line_previous_path()); + } + _ => {} + } + backup +} + +fn save_status_line_previous(status_line: &Value) -> std::io::Result<()> { + let path = status_line_previous_path(); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + write_like(&path, &path, pretty(status_line).as_bytes()) +} + /// Writes `bytes` to `temp`, which is about to replace `original`. /// /// On Linux a fresh file would get the umask's 0644, and settings.json can hold @@ -511,6 +626,49 @@ mod tests { const WHERE: &str = "settings.json"; + #[test] + fn the_hooks_leave_the_status_line_alone() { + let theirs = json!({ "statusLine": { "type": "command", "command": "~/bin/my-line" } }); + assert_eq!(merged(&theirs)["statusLine"], theirs["statusLine"]); + assert_eq!(without_ours(&theirs)["statusLine"], theirs["statusLine"]); + assert!(merged(&json!({})).get("statusLine").is_none()); + } + + #[test] + fn the_relay_takes_the_status_line_and_keeps_the_users_other_fields() { + // None yet: ours is added. + let fresh = status_line_settings(&json!({ "model": "opus" }), true, None); + assert!(status_line_is_ours(&fresh["statusLine"])); + assert_eq!(fresh["statusLine"]["type"], "command"); + assert_eq!(fresh["model"], "opus"); + + // Their own: only the command is swapped; padding and refresh stay. + let own = json!({ "statusLine": { "type": "command", "command": "~/bin/my-line", "padding": 2, "refreshInterval": 5 } }); + let taken = status_line_settings(&own, true, None); + assert!(status_line_is_ours(&taken["statusLine"])); + assert_eq!(taken["statusLine"]["padding"], 2); + assert_eq!(taken["statusLine"]["refreshInterval"], 5); + + // Installing again keeps ours and its extra fields. + let again = status_line_settings(&taken, true, None); + assert_eq!(again["statusLine"]["padding"], 2); + assert!(status_line_is_ours(&again["statusLine"])); + } + + #[test] + fn removing_the_relay_restores_what_was_there_and_never_touches_anything_else() { + let previous = json!({ "type": "command", "command": "~/bin/my-line", "padding": 2 }); + let ours = status_line_settings(&json!({}), true, None); + + // There was one before: it comes back exactly. + assert_eq!(status_line_settings(&ours, false, Some(&previous))["statusLine"], previous); + // There was none: the key goes. + assert!(status_line_settings(&ours, false, None).get("statusLine").is_none()); + // The user changed it since: not ours, so untouched. + let theirs = json!({ "statusLine": { "type": "command", "command": "~/bin/other" } }); + assert_eq!(status_line_settings(&theirs, false, Some(&previous))["statusLine"], theirs["statusLine"]); + } + #[test] fn a_utf8_bom_is_stripped_not_treated_as_corruption() { // PowerShell 5's `Set-Content -Encoding utf8` produces exactly this. From c02923e27c3a6a0fa8303e57ed6f63b36fdc0d30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:43:55 +0200 Subject: [PATCH 47/77] Plan usage pills in the island: Claude and Codex, as on the Mac The island's header gets the Mac's plan pills (ClaudePlanHeaderPill), before the gear, Claude first, on the overview only, both off by default: "Claude 73%" / "Codex 12%", green below 50 %, orange up to 80 %, red above, grey without numbers. Two pills tighten the right side like the Mac. Clicking one puts its card in place of the overview's left card (5 hours and week, reset times, how old the numbers are; Codex adds its plan and free resets), Mochi wears the plan's colour while it is open, and it closes when the view, the mode or the focused pill changes. Countdowns move every 30 s, only while a card is open. Claude: the numbers come from the status line relay ("StatusLine" events), checked like ClaudePlanGauge.parse, and the last ones survive a restart. Settings -> Plan usage -> Show in notch installs the relay first (diff, backup, click), Uninstall relay puts the user's status line back and turns the pill off. Codex (CodexPlanGauge, #244): nothing is installed. When its pill shows or is clicked, with numbers older than a minute and Coucou not paused, the app runs `codex app-server` and asks `account/rateLimits/read`, as Codex's /status does, with a 15 s limit and bounded reads; only the limits and free resets reach the webview. The Codex CLI is looked for on PATH and in the usual per-user folders (platform::codex_candidates). Status line calls are not logged and wake a hidden island only when the numbers moved. Island and settings parts based on #171 by Justin Minkmar (273efa0). Co-authored-by: Justin Minkmar Co-Authored-By: Claude Opus 5.5 --- windows/README.md | 32 +++ windows/src-tauri/src/codex_plan.rs | 210 +++++++++++++++++ windows/src-tauri/src/lib.rs | 47 +++- windows/src-tauri/src/pipe.rs | 5 +- windows/src-tauri/src/platform/linux.rs | 26 +++ windows/src-tauri/src/platform/windows.rs | 21 ++ windows/src-tauri/src/settings.rs | 20 +- windows/src/core/bridge.ts | 15 ++ windows/src/core/plan.ts | 225 +++++++++++++++++++ windows/src/core/state.ts | 20 ++ windows/src/island/hooks.ts | 14 ++ windows/src/island/island.ts | 11 +- windows/src/settings/main.ts | 224 ++++++++++++++----- windows/src/style.css | 152 +++++++++++++ windows/src/views/icons.ts | 2 + windows/src/views/usage.ts | 213 ++++++++++++++++++ windows/src/views/views.ts | 65 +++++- windows/tests/plan.test.mjs | 261 ++++++++++++++++++++++ 18 files changed, 1503 insertions(+), 60 deletions(-) create mode 100644 windows/src-tauri/src/codex_plan.rs create mode 100644 windows/src/core/plan.ts create mode 100644 windows/src/views/usage.ts create mode 100644 windows/tests/plan.test.mjs diff --git a/windows/README.md b/windows/README.md index 081142cb3..3fa856f8c 100644 --- a/windows/README.md +++ b/windows/README.md @@ -69,6 +69,29 @@ 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. +### Plan usage + +As on the Mac, the island's header can show your plan limits: a small pill +("Claude 73%", green below 50 %, orange up to 80 %, red above) for the 5-hour and +weekly Claude limits, and another for Codex. Click one for the details and the +reset times. Both are off by default; turn them on in **Settings… → Plan usage**. + +- **Claude** (Pro and Max plans): the numbers come from Claude Code's own status + line. **Show in notch** first shows you the diff of the `statusLine` key in + `%USERPROFILE%\.claude\settings.json`, takes a dated backup and writes only + after your click: the status line becomes `coucou-hook --statusline`, which + passes only the limits on (300 ms at most) and runs the status line you had + before — kept in `statusline-previous.json` next to the relay — with the same + input, printing what it prints. On Windows that one runs through Git Bash, as + Claude Code runs it (`CLAUDE_CODE_GIT_BASH_PATH`, then the Git for Windows that + `git.exe` on `PATH` belongs to, then the usual install folders); it gets 10 s + and 64 KB of output. **Uninstall relay** puts your status line back. The + numbers arrive with Claude Code's replies. +- **Codex**: nothing is installed. When the pill shows (or is clicked, at most + once a minute) Coucou starts `codex app-server` and asks it + `account/rateLimits/read`, as Codex's `/status` does, then stops it (15 s at + most, never while paused). Codex must be signed in with ChatGPT. + ## Chat and keys **Settings… → Claude** takes your Anthropic API key. Keys live in the **Windows @@ -164,6 +187,10 @@ OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integr attach it as context, and jumping to a specific terminal window — "Open terminal" opens the working folder in VS Code when `code` is on your `PATH`. - Cal.com shows the next bookings as a list rather than the Mac's calendar. +- Plan usage: the user's previous status line runs through Git Bash on Windows + (`/bin/sh` on Linux and Mac); without Git Bash it is not run, rather than + guessed at with `cmd`. The Codex CLI is looked for on `PATH` and in npm's, + Volta's, Bun's and pnpm's folders (and nvm's on Linux). ## Linux @@ -209,6 +236,11 @@ What changes on Linux: Unix socket at `$XDG_RUNTIME_DIR/coucou.sock`. Both ends check that the other runs as the same user. - **Keys** live in the Secret Service (GNOME Keyring, KWallet). +- **Plan usage**: the status line relay is `~/.local/share/coucou/bin/coucou-hook + --statusline` and runs your previous status line with `/bin/sh -c`, like Claude + Code. Codex is found on `$PATH`, in `~/.local/bin`, npm's global prefix, Volta, + Bun, pnpm, or nvm (newest Node first), since a desktop launch often has a + shorter `$PATH` than your shell. - **Files**: preferences in `~/.config/coucou/`, the log at `~/.local/share/coucou/coucou.log`. - What the Windows build leaves out, this one does too: sending a file by diff --git a/windows/src-tauri/src/codex_plan.rs b/windows/src-tauri/src/codex_plan.rs new file mode 100644 index 000000000..734ce7bf6 --- /dev/null +++ b/windows/src-tauri/src/codex_plan.rs @@ -0,0 +1,210 @@ +// Codex plan usage — the same source as CodexPlanGauge.swift on the Mac. +// +// Coucou asks the Codex CLI itself: `codex app-server` (the JSON-RPC server +// behind Codex's /status) is started, asked `account/rateLimits/read`, and +// stopped. Coucou makes no network call of its own and reads no credential: +// Codex answers with its own sign-in, exactly as /status does, and nothing is +// installed or written. It runs only when the pill shows or is clicked, at most +// once at a time, for 15 s at most, and is skipped while Coucou is paused. + +use std::cmp::Ordering; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{self, AtomicBool}; +use std::sync::mpsc; +use std::time::Duration; + +use serde_json::{json, Value}; + +use crate::platform; + +/// The Mac gives Codex the same 15 s. +const TIMEOUT: Duration = Duration::from_secs(15); +/// One JSON-RPC line we are willing to read; the answer is a few hundred bytes. +const MAX_LINE: usize = 256 * 1024; +/// Everything we are willing to read before the answer shows up. +const MAX_TOTAL: usize = 2 * 1024 * 1024; + +/// What the Mac sends: initialize, initialized, then the one question. +const REQUESTS: &str = concat!( + r#"{"id":1,"method":"initialize","params":{"clientInfo":{"name":"coucou","version":"1"}}}"#, + "\n", + r#"{"method":"initialized"}"#, + "\n", + r#"{"id":2,"method":"account/rateLimits/read"}"#, + "\n", +); + +static BUSY: AtomicBool = AtomicBool::new(false); + +struct NotBusy; +impl Drop for NotBusy { + fn drop(&mut self) { + BUSY.store(false, atomic::Ordering::Release); + } +} + +/// The limits part of Codex's `account/rateLimits/read` answer, or `None` when +/// Codex is not installed, not signed in, slow, already being asked, or Coucou +/// is paused. Blocking: call it off the main thread. +pub fn read() -> Option { + if crate::integrations::PAUSED.load(atomic::Ordering::Relaxed) { + return None; + } + if BUSY.swap(true, atomic::Ordering::AcqRel) { + return None; + } + let _busy = NotBusy; + + let exe = platform::codex_candidates().into_iter().next()?; + let mut cmd = Command::new(&exe); + cmd.arg("app-server").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()); + // An npm install is a `#!/usr/bin/env node` script (or a .cmd calling node): + // its own folder first on PATH, as the Mac does, so it finds its Node. + if let Some(dir) = exe.parent() { + let mut dirs = vec![dir.to_path_buf()]; + if let Some(path) = std::env::var_os("PATH") { + dirs.extend(std::env::split_paths(&path)); + } + if let Ok(joined) = std::env::join_paths(dirs) { + cmd.env("PATH", joined); + } + } + platform::no_console(&mut cmd); + let mut child = cmd.spawn().ok()?; + + let answer = (|| { + let mut stdin = child.stdin.take()?; + let stdout = child.stdout.take()?; + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(first_answer(BufReader::new(stdout))); + }); + stdin.write_all(REQUESTS.as_bytes()).ok()?; + stdin.flush().ok()?; + let answer = rx.recv_timeout(TIMEOUT).ok().flatten(); + // Closing its input is how app-server learns we are done. + drop(stdin); + answer + })(); + let _ = child.kill(); + let _ = child.wait(); + answer.map(|result| limits_only(&result)) +} + +/// Reads JSON-RPC lines until the answer to request 2 and returns its `result`. +/// Gives up on a line or a stream that is far too long for what we expect. +pub fn first_answer(reader: impl BufRead) -> Option { + let mut reader = reader; + let mut total = 0usize; + let mut line = Vec::new(); + loop { + line.clear(); + let n = (&mut reader).take(MAX_LINE as u64 + 1).read_until(b'\n', &mut line).ok()?; + if n == 0 { + return None; + } + total += n; + if total > MAX_TOTAL || line.len() > MAX_LINE { + return None; + } + let Ok(message) = serde_json::from_slice::(&line) else { continue }; + if message.get("id").and_then(Value::as_i64) == Some(2) { + return message.get("result").filter(|r| r.is_object()).cloned(); + } + } +} + +/// Only what the card shows goes to the webview: the limits and the free resets. +fn limits_only(result: &Value) -> Value { + json!({ + "rateLimits": result.get("rateLimits").cloned().unwrap_or(Value::Null), + "rateLimitResetCredits": result.get("rateLimitResetCredits").cloned().unwrap_or(Value::Null), + }) +} + +/// Compares version-like names with their numbers as numbers ("v20.1" > "v9.12"), +/// like Foundation's `.numeric` comparison the Mac sorts nvm's folders with. +#[cfg_attr(windows, allow(dead_code))] +pub fn compare_versions(a: &str, b: &str) -> Ordering { + fn parts(s: &str) -> Vec> { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut digits = false; + for c in s.chars() { + if c.is_ascii_digit() != digits && !cur.is_empty() { + out.push(if digits { Ok(cur.parse().unwrap_or(u64::MAX)) } else { Err(cur.clone()) }); + cur.clear(); + } + digits = c.is_ascii_digit(); + cur.push(c); + } + if !cur.is_empty() { + out.push(if digits { Ok(cur.parse().unwrap_or(u64::MAX)) } else { Err(cur) }); + } + out + } + let (pa, pb) = (parts(a), parts(b)); + for (x, y) in pa.iter().zip(pb.iter()) { + let ord = match (x, y) { + (Ok(x), Ok(y)) => x.cmp(y), + (Err(x), Err(y)) => x.cmp(y), + (Ok(_), Err(_)) => Ordering::Less, + (Err(_), Ok(_)) => Ordering::Greater, + }; + if ord != Ordering::Equal { + return ord; + } + } + pa.len().cmp(&pb.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_answer_to_the_question_is_picked_out_of_the_conversation() { + let stream = concat!( + r#"{"id":1,"result":{"userAgent":"codex"}}"#, "\n", + "not json at all\n", + r#"{"method":"account/updated","params":{}}"#, "\n", + r#"{"id":2,"result":{"rateLimits":{"primary":{"usedPercent":12.5,"resetsAt":1900000000}}}}"#, "\n", + r#"{"id":3,"result":{}}"#, "\n", + ); + let result = first_answer(stream.as_bytes()).unwrap(); + assert_eq!(result["rateLimits"]["primary"]["usedPercent"], 12.5); + } + + #[test] + fn an_error_a_closed_stream_or_a_flood_is_no_answer() { + assert!(first_answer(r#"{"id":2,"error":{"message":"not signed in"}}"#.as_bytes()).is_none()); + assert!(first_answer(r#"{"id":1,"result":{}}"#.as_bytes()).is_none()); + assert!(first_answer(&b""[..]).is_none()); + let huge = "x".repeat(MAX_LINE + 10); + assert!(first_answer(format!("{huge}\n{}\n", r#"{"id":2,"result":{}}"#).as_bytes()).is_none()); + // Many short lines add up too. + let chatter = format!("{}\n", "y".repeat(1000)).repeat(MAX_TOTAL / 1000 + 10); + assert!(first_answer(chatter.as_bytes()).is_none()); + } + + #[test] + fn only_the_limits_reach_the_webview() { + let result = json!({ + "rateLimits": { "planType": "plus" }, + "rateLimitResetCredits": { "availableCount": 1 }, + "account": { "email": "me@example.com" } + }); + let kept = limits_only(&result); + assert_eq!(kept["rateLimits"]["planType"], "plus"); + assert_eq!(kept["rateLimitResetCredits"]["availableCount"], 1); + assert!(kept.get("account").is_none()); + } + + #[test] + fn newest_node_first_like_the_mac() { + let mut v = vec!["v9.12.0", "v20.1.0", "v18.20.4", "v20.11.1"]; + v.sort_by(|a, b| compare_versions(b, a)); + assert_eq!(v, ["v20.11.1", "v20.1.0", "v18.20.4", "v9.12.0"]); + } +} diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 264e4c3a8..ef6cb424c 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ // Coucou for Windows — app wiring and the commands the island calls. mod claude; +mod codex_plan; mod files; mod hooks; mod integrations; @@ -50,7 +51,9 @@ pub struct BootInfo { 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; + let hooks_status = hooks::status(); + settings.hooks_installed = hooks_status.installed; + settings.plan_relay_installed = hooks_status.plan_relay_installed; let screen = island::screen_info(&app, &settings.screen); BootInfo { settings, @@ -222,6 +225,45 @@ fn hooks_apply( Ok(backup) } +/// The diff of putting the plan usage relay into (or taking it out of) the +/// status line: just the `statusLine` key, before and after. +#[tauri::command] +fn status_line_preview(install: bool) -> Result { + hooks::status_line_preview(install) +} + +/// Only ever called from an explicit click in the settings window. +#[tauri::command] +fn status_line_apply( + app: AppHandle, + shared: State, + install: bool, + fingerprint: String, +) -> Result { + let backup = hooks::status_line_write(install, &fingerprint)?; + let updated = { + let mut current = shared.settings.lock().unwrap(); + current.plan_relay_installed = install; + // As on the Mac: taking the relay out turns the pill off with it. + if !install { + current.show_plan_in_notch = false; + } + if let Err(err) = settings::save(¤t) { + log::line(format!("could not save settings: {err}")); + } + current.clone() + }; + let _ = app.emit("settings-changed", updated); + Ok(backup) +} + +/// Codex plan usage, asked of the Codex CLI (`codex app-server`) when its pill +/// shows. Off the main thread: it can take a few seconds. +#[tauri::command] +async fn codex_plan_usage() -> Option { + tauri::async_runtime::spawn_blocking(codex_plan::read).await.ok().flatten() +} + #[tauri::command] fn approval_decision(app: AppHandle, request_id: String, decision: String) { pipe::answer(&app, &request_id, &decision); @@ -409,6 +451,9 @@ pub fn run() { hooks_status, hooks_preview, hooks_apply, + status_line_preview, + status_line_apply, + codex_plan_usage, approval_decision, approval_answer, approval_ack, diff --git a/windows/src-tauri/src/pipe.rs b/windows/src-tauri/src/pipe.rs index 8d3566b3f..8c04fa2b1 100644 --- a/windows/src-tauri/src/pipe.rs +++ b/windows/src-tauri/src/pipe.rs @@ -198,7 +198,10 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) { .to_string(); if event != "PermissionRequest" { - log::line(format!("hook {event}")); + // The status line relay calls in with every Claude Code update: not log-worthy. + if event != "StatusLine" { + log::line(format!("hook {event}")); + } let _ = app.emit_to(WINDOW_LABEL, "hook", payload); pipe.finish(); return; diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index 8253961d4..4d1be4713 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -208,6 +208,32 @@ pub fn find_on_path(stem: &str) -> Option { }) } +/// Where the Codex CLI may be, best first: $PATH, then the usual per-user +/// install folders, which a desktop launch often leaves out of $PATH (npm's +/// global prefix, Volta, Bun, pnpm, and nvm with its newest Node first). +pub fn codex_candidates() -> Vec { + let home = home_dir(); + let mut out: Vec = find_on_path("codex").into_iter().collect(); + for dir in [".local/bin", ".npm-global/bin", ".volta/bin", ".bun/bin", ".local/share/pnpm"] { + out.push(home.join(dir).join("codex")); + } + out.push(PathBuf::from("/usr/local/bin/codex")); + out.push(PathBuf::from("/usr/bin/codex")); + let nvm = home.join(".nvm/versions/node"); + if let Ok(entries) = std::fs::read_dir(&nvm) { + let mut versions: Vec = + entries.filter_map(|e| e.ok()?.file_name().into_string().ok()).collect(); + versions.sort_by(|a, b| crate::codex_plan::compare_versions(b, a)); + out.extend(versions.iter().map(|v| nvm.join(v).join("bin/codex"))); + } + out.retain(|p| { + std::fs::metadata(p) + .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + }); + out +} + // ── Cursor ──────────────────────────────────────────────────────────────────── /// Nothing polls the cursor here: the page reports it over the island, and the diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 79bc6a124..dfc1dc49f 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -101,6 +101,27 @@ pub fn find_on_path(stem: &str) -> Option { None } +/// Where the Codex CLI may be, best first: %PATH% (codex.exe or npm's +/// codex.cmd), then npm's global folder and the Volta / Bun / pnpm ones. +/// Rust quotes the one fixed argument safely for a `.cmd` (see find_on_path). +pub fn codex_candidates() -> Vec { + let mut out: Vec = find_on_path("codex").into_iter().collect(); + let var = |k: &str| std::env::var_os(k).map(PathBuf::from).filter(|p| p.is_absolute()); + if let Some(appdata) = var("APPDATA") { + out.push(appdata.join("npm").join("codex.cmd")); + } + if let Some(local) = var("LOCALAPPDATA") { + out.push(local.join("Volta").join("bin").join("codex.exe")); + out.push(local.join("pnpm").join("codex.cmd")); + } + if let Some(home) = var("USERPROFILE") { + out.push(home.join(".bun").join("bin").join("codex.exe")); + out.push(home.join(".local").join("bin").join("codex.exe")); + } + out.retain(|p| p.is_file()); + out +} + // ── Who we are ──────────────────────────────────────────────────────────────── // // Named pipes share one machine-wide namespace, so the SID in the name is what diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 17f63e1c1..205c579ce 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -23,6 +23,15 @@ pub struct Settings { pub hooks_installed: bool, /// Claude model used by the chat. Changeable in the settings window. pub model: String, + /// Show the Claude plan pill (5 h and weekly limits) in the island's header. + /// Off until the user turns it on, so the header stays as it shipped. + pub show_plan_in_notch: bool, + /// Coucou's status line relay is the one in Claude Code's settings.json. + /// Like `hooks_installed`, the real state wins at launch over what was stored. + pub plan_relay_installed: bool, + /// Show the Codex plan pill (5 h / weekly limits from `codex app-server`). + /// Off by default; nothing is installed for it. + pub show_codex_plan_in_notch: bool, } fn default_model() -> String { @@ -46,6 +55,9 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + show_plan_in_notch: false, + plan_relay_installed: false, + show_codex_plan_in_notch: false, } } } @@ -306,7 +318,10 @@ mod tests { "screen": "cursor", "autostart": true, "hooksInstalled": true, - "model": "some-model" + "model": "some-model", + "showPlanInNotch": true, + "planRelayInstalled": true, + "showCodexPlanInNotch": true }"#; fn custom() -> Value { @@ -632,6 +647,9 @@ mod tests { "autostart", "hooksInstalled", "model", + "showPlanInNotch", + "planRelayInstalled", + "showCodexPlanInNotch", ] ); let _ = std::fs::remove_dir_all(&dir); diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index c6121c159..b87d67439 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -76,6 +76,19 @@ export const Bridge = { hooksApply: (install: boolean, fingerprint: string) => callOrThrow("hooks_apply", { install, fingerprint }), + // ── Plan usage: the status line relay, installed apart from the hooks ────── + /** Diff of the `statusLine` key only. `install: false` previews taking the relay out. */ + statusLinePreview: (install: boolean) => callOrThrow("status_line_preview", { install }), + /** Same rules as hooksApply: an explicit click, and only for the diff that was shown. */ + statusLineApply: (install: boolean, fingerprint: string) => + callOrThrow("status_line_apply", { install, fingerprint }), + /** + * Asks the Codex CLI (`codex app-server`) for its plan limits, as Codex's + * /status does. The raw `account/rateLimits/read` result, or null when Codex + * is missing, not signed in or slow (15 s). + */ + codexPlanUsage: () => call("codex_plan_usage"), + approvalDecision: (requestId: string, decision: "allow" | "deny") => call("approval_decision", { requestId, decision }), /** "The card is up" — until this lands the relay only waits a moment. */ @@ -127,6 +140,8 @@ export interface DroppedFile { export interface HookStatus { installed: boolean; + /** Coucou's status line relay (plan usage) is the status line in settings.json. */ + planRelayInstalled: boolean; settingsPath: string; hookPath: string; hookReady: boolean; diff --git a/windows/src/core/plan.ts b/windows/src/core/plan.ts new file mode 100644 index 000000000..7b4f85b2b --- /dev/null +++ b/windows/src/core/plan.ts @@ -0,0 +1,225 @@ +// Plan usage gauges — the logic of ClaudePlanGauge.swift and CodexPlanGauge.swift +// on the Mac, with no DOM so it can be tested on its own. The pills and cards +// that show it are in views/usage.ts. +// +// Claude: the numbers come from Claude Code's own status line (`rate_limits` in +// what it hands its status line command), through the relay. Pro and Max only. +// Codex: the numbers come from the Codex CLI itself (`codex app-server`, +// `account/rateLimits/read`, the source of Codex's /status), asked by the app +// when the pill shows. Nothing is read from any credentials, nothing is fetched +// by Coucou itself. + +export interface PlanWindow { + /** 0–100, clamped. */ + usedPct: number; + /** Epoch milliseconds. */ + resetsAt: number; +} + +export interface PlanUsage { + fiveHour?: PlanWindow; + sevenDay?: PlanWindow; + /** When the numbers arrived (epoch ms). */ + updatedAt: number; +} + +export interface CodexPlanUsage extends PlanUsage { + /** Free full resets available. */ + resetCredits?: number; + /** Soonest expiry among the available ones (epoch ms). */ + resetCreditExpiresAt?: number; + planType?: string; +} + +const DAY_MS = 86_400_000; + +/** Every user-visible string of the gauges, in one place. */ +export const PLAN_TEXT = { + claudeTitle: "Claude plan", + codexTitle: "Codex plan", + claudePillTitle: "Claude plan usage", + codexPillTitle: "Codex plan usage", + waiting: "Waiting for a response from Claude Code", + askingCodex: "Asking Codex…", + justNow: "just now", + minAgo: (n: number) => `${n} min ago`, + hAgo: (n: number) => `${n} h ago`, + fiveHours: "5 hours", + week: "Week", + resets: "Resets", + resetting: "Resetting…", + inHM: (h: number, m: number) => `in ${h} h ${m}`, + inM: (m: number) => `in ${m} min`, + available: (n: number) => `${n} available`, + until: (date: string) => ` · until ${date}`, + none: "—", +}; + +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); +const asObj = (v: unknown): Record | null => + v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : null; + +// ── Claude ──────────────────────────────────────────────────────────────────── + +function parseClaudeWindow(raw: unknown, now: number): PlanWindow | undefined { + const w = asObj(raw); + const pct = w?.used_percentage; + const epoch = w?.resets_at; + if (!isNum(pct) || !isNum(epoch)) return undefined; + // Anything outside 0–200 is not a percentage; 100–200 is a plan over its limit, shown full. + if (pct < 0 || pct > 200) return undefined; + // resets_at is epoch seconds. A date more than 400 days away is milliseconds in disguise. + if (epoch <= 0 || epoch * 1000 > now + 400 * DAY_MS) return undefined; + return { usedPct: Math.min(100, pct), resetsAt: epoch * 1000 }; +} + +/** ClaudePlanGauge.parse: `rate_limits` of a status line call, or null when it holds neither window. */ +export function parseClaudePlan(rateLimits: unknown, now = Date.now()): PlanUsage | null { + const rl = asObj(rateLimits); + const fiveHour = parseClaudeWindow(rl?.five_hour, now); + const sevenDay = parseClaudeWindow(rl?.seven_day, now); + if (!fiveHour && !sevenDay) return null; + const usage: PlanUsage = { updatedAt: now }; + if (fiveHour) usage.fiveHour = fiveHour; + if (sevenDay) usage.sevenDay = sevenDay; + return usage; +} + +// ── Codex ───────────────────────────────────────────────────────────────────── + +/** + * CodexPlanGauge.parse: the result of `account/rateLimits/read`. `primary` and + * `secondary` are not tied to a window, so they are sorted by duration: a day or + * less is the 5-hour window, anything else (or no duration) the week. + */ +export function parseCodexPlan(result: unknown, now = Date.now()): CodexPlanUsage | null { + const r = asObj(result); + const limits = asObj(r?.rateLimits); + if (!limits) return null; + const usage: CodexPlanUsage = { updatedAt: now }; + if (typeof limits.planType === "string" && limits.planType) usage.planType = limits.planType; + for (const key of ["primary", "secondary"]) { + const w = asObj(limits[key]); + if (!w || !isNum(w.usedPercent) || !isNum(w.resetsAt)) continue; + const window: PlanWindow = { + usedPct: Math.min(100, Math.max(0, w.usedPercent)), + resetsAt: w.resetsAt * 1000, + }; + if (Number.isInteger(w.windowDurationMins) && (w.windowDurationMins as number) <= 24 * 60) { + usage.fiveHour = window; + } else { + usage.sevenDay = window; + } + } + const credits = asObj(r?.rateLimitResetCredits); + if (credits) { + if (Number.isInteger(credits.availableCount)) usage.resetCredits = credits.availableCount as number; + const expiries = (Array.isArray(credits.credits) ? credits.credits : []) + .map(asObj) + .filter((c) => c?.status === "available" && isNum(c.expiresAt)) + .map((c) => (c!.expiresAt as number) * 1000); + if (expiries.length) usage.resetCreditExpiresAt = Math.min(...expiries); + } + return usage.fiveHour || usage.sevenDay ? usage : null; +} + +// ── Shared ──────────────────────────────────────────────────────────────────── + +/** What to show for a window: 0 once its reset time has passed. */ +export const effectivePct = (w: PlanWindow, now = Date.now()): number => (w.resetsAt <= now ? 0 : w.usedPct); + +/** The higher of the two effective percentages; null when there are no windows. */ +export function dominantPct(u: PlanUsage | null | undefined, now = Date.now()): number | null { + const pcts = [u?.fiveHour, u?.sevenDay].filter((w): w is PlanWindow => !!w).map((w) => effectivePct(w, now)); + return pcts.length ? Math.max(...pcts) : null; +} + +/** Green below 50 %, orange up to 80 %, red above, grey without data. */ +export function planColor(pct: number | null): string { + if (pct == null) return "#6B7079"; + if (pct < 50) return "#22C55E"; + if (pct < 80) return "#F59E0B"; + return "#F4505E"; +} + +/** "Claude 73%" / "Codex 12%" on the pill; "Claude —" while there are no numbers. */ +export function pillLabel(name: "Claude" | "Codex", u: PlanUsage | null | undefined, now = Date.now()): string { + const pct = dominantPct(u, now); + return pct == null ? `${name} ${PLAN_TEXT.none}` : `${name} ${Math.round(pct)}%`; +} + +/** "just now", "12 min ago", "3 h ago". */ +export function ageLabel(updatedAt: number, now = Date.now()): string { + const secs = (now - updatedAt) / 1000; + if (secs < 60) return PLAN_TEXT.justNow; + const mins = Math.floor(secs / 60); + return mins < 60 ? PLAN_TEXT.minAgo(mins) : PLAN_TEXT.hAgo(Math.floor(mins / 60)); +} + +/** "in 1 h 20" / "in 5 min" for the 5-hour window, "Mon 9:00" for the week. */ +export function resetLabel(w: PlanWindow, weekly: boolean, now = Date.now()): string { + const secs = (w.resetsAt - now) / 1000; + if (secs <= 0) return PLAN_TEXT.resetting; + if (weekly) { + const d = new Date(w.resetsAt); + return `${WEEKDAYS[d.getDay()]} ${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; + } + const hours = Math.floor(secs / 3600); + const mins = Math.floor((secs % 3600) / 60); + return hours > 0 ? PLAN_TEXT.inHM(hours, mins) : PLAN_TEXT.inM(mins); +} + +/** The Claude card's subtitle. */ +export function claudeSubtitle(u: PlanUsage | null, now = Date.now()): string { + return u ? ageLabel(u.updatedAt, now) : PLAN_TEXT.waiting; +} + +/** The Codex card's subtitle: "plus · 3 min ago". */ +export function codexSubtitle(u: CodexPlanUsage | null, now = Date.now()): string { + if (!u) return PLAN_TEXT.askingCodex; + return (u.planType ? `${u.planType} · ` : "") + ageLabel(u.updatedAt, now); +} + +/** "2 available · until Oct 9", or "—" when Codex said nothing about resets. */ +export function codexResetsLabel(u: CodexPlanUsage | null): string { + if (u?.resetCredits == null) return PLAN_TEXT.none; + let text = PLAN_TEXT.available(u.resetCredits); + if (u.resetCredits > 0 && u.resetCreditExpiresAt != null) { + const d = new Date(u.resetCreditExpiresAt); + text += PLAN_TEXT.until(`${MONTHS[d.getMonth()]} ${d.getDate()}`); + } + return text; +} + +/** How long a Codex answer stays good before the pill asks again (as on the Mac). */ +export const CODEX_STALE_MS = 60_000; + +/** True when the Codex numbers are missing or older than a minute. */ +export const codexIsStale = (u: CodexPlanUsage | null, now = Date.now()): boolean => + !u || now - u.updatedAt >= CODEX_STALE_MS; + +/** A stored Claude usage, if it still looks like one (the last numbers survive a restart). */ +export function restorePlanUsage(raw: string | null): PlanUsage | null { + if (!raw) return null; + try { + const v = asObj(JSON.parse(raw)); + if (!v || !isNum(v.updatedAt)) return null; + const win = (w: unknown): PlanWindow | undefined => { + const o = asObj(w); + return o && isNum(o.usedPct) && isNum(o.resetsAt) && o.usedPct >= 0 && o.usedPct <= 100 + ? { usedPct: o.usedPct, resetsAt: o.resetsAt } + : undefined; + }; + const usage: PlanUsage = { updatedAt: v.updatedAt }; + const fh = win(v.fiveHour); + const sd = win(v.sevenDay); + if (fh) usage.fiveHour = fh; + if (sd) usage.sevenDay = sd; + return fh || sd ? usage : null; + } catch { + return null; + } +} diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 4785897aa..f79c162ec 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -2,6 +2,7 @@ import type { BotEmoteName, BotStateName, IslandMode, IslandViewName } from "./layout"; import type { EyeShape } from "../mochi/engine"; +import type { CodexPlanUsage, PlanUsage } from "./plan"; export type AgentSource = "claudeCode" | "n8n" | "agent"; export type PillBadge = "approval" | "finished" | "error"; @@ -107,6 +108,12 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + /** Show the Claude plan pill (5 h and weekly limits) in the island's header. */ + showPlanInNotch: boolean; + /** Coucou's status line relay is installed in Claude Code's settings. */ + planRelayInstalled: boolean; + /** Show the Codex plan pill in the island's header. */ + showCodexPlanInNotch: boolean; } export const DEFAULT_SETTINGS: Settings = { @@ -121,6 +128,9 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + showPlanInNotch: false, + planRelayInstalled: false, + showCodexPlanInNotch: false, }; type Listener = () => void; @@ -155,6 +165,15 @@ class AppState { integrations: Record = {}; + /** Claude's 5 h / weekly limits, from the status line (null until the first call). */ + planUsage: PlanUsage | null = null; + /** Codex's limits, from `codex app-server` (null until it has answered). */ + codexPlanUsage: CodexPlanUsage | null = null; + /** A plan card is open in place of the overview's left card. */ + showingPlanDetail = false; + /** Which one: the Codex card rather than Claude's. */ + planDetailIsCodex = false; + lastActivity = performance.now(); settings: Settings = { ...DEFAULT_SETTINGS }; @@ -187,6 +206,7 @@ class AppState { const t = this.tasks.find((x) => x.id === id); if (!t) return; this.focusId = id; + this.showingPlanDetail = false; t.pillBadge = null; this.notify(); } diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index ff03a72fb..146a80d32 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -7,6 +7,8 @@ import { Bridge, onEvent } from "../core/bridge"; import { Sound } from "../core/sound"; import { State, type AskedQuestion } from "../core/state"; import type { Island } from "./island"; +import { parseClaudePlan, restorePlanUsage } from "../core/plan"; +import { setClaudePlanUsage, storedClaudePlanUsage } from "../views/usage"; const CLAUDE_ID = "integration_claude"; @@ -50,6 +52,8 @@ interface HookPayload { tool_input?: Record; /** Optional agent tag: lowercase, digits and hyphens, ≤ 24 chars. */ coucou_agent?: string; + /** StatusLine (the plan usage relay): Claude Code's 5-hour and weekly limits. */ + rate_limits?: unknown; } /** Same rule as HookServer.validateAgent on macOS. "claude" is reserved. */ @@ -185,10 +189,20 @@ function clearSession() { } export function registerHookHandlers(island: Island) { + // The last plan numbers seen survive a restart, as on the Mac. + State.planUsage ??= restorePlanUsage(storedClaudePlanUsage()); void onEvent("hook", (payload) => handleHook(island, payload)); } function handleHook(island: Island, payload: HookPayload) { + // Account-wide numbers from the status line relay, not part of any session: + // keep the latest, nothing else (no reveal, no sound), paused or not. + if (payload.hook_event_name === "StatusLine") { + const usage = parseClaudePlan(payload.rate_limits); + if (usage) setClaudePlanUsage(usage); + return; + } + if (State.paused) { // Silence here used to cost Claude Code nearly two minutes: the relay waited // for a decision from an island that had already decided not to look. Say so, diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 54dbf5f09..ede219b13 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -17,6 +17,7 @@ import { Greeting } from "../mochi/greeting"; import { createMiniBot, pruneMiniBots, syncMiniBotStates, tickMiniBots } from "../mochi/minibots"; import { UploadCanvas } from "../upload/canvas"; import { USC, UploadSeq } from "../upload/sequence"; +import { closePlanCard, openPlanColor, planCardOpen } from "../views/usage"; import { buildHeader, buildViews, type ViewActions, type ViewHost } from "../views/views"; import { h } from "../views/dom"; import { IslandStateMachine } from "./fsm"; @@ -288,6 +289,7 @@ export class Island { void Bridge.focusWindow(false); } if (mode !== "expanded") { + closePlanCard(); this.engine.resetMorph(); // Nothing can be seen of the sequence once the island is shut, and leaving // it running would keep the frame loop awake — the island must cost @@ -311,6 +313,7 @@ export class Island { expand(view: IslandViewName) { this.stopSequenceIfLeaving(view); + if (view !== "overview") closePlanCard(); State.view = view; if (State.mode !== "expanded") this.setMode("expanded"); else this.animateGeometry(false); @@ -321,6 +324,7 @@ export class Island { setView(view: IslandViewName) { this.stopSequenceIfLeaving(view); + if (view !== "overview") closePlanCard(); if (State.mode !== "expanded") { this.fsm.forceHome(); State.view = view; @@ -839,7 +843,12 @@ export class Island { if (!ctx) return; const focus = State.focusTask; - this.engine.bodyColor = focus?.isIntegration ? hexToRGB(focus.color) : null; + // While a plan card is open Mochi wears the plan's colour, like its pill. + this.engine.bodyColor = planCardOpen() + ? hexToRGB(openPlanColor()) + : focus?.isIntegration + ? hexToRGB(focus.color) + : null; this.engine.particleOverhang = BOT_OVERHANG; this.engine.lookX = this.lookX(); this.engine.lookY = this.lookY(); diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index ccc1b77fd..4369c5978 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 HookPreview, type HookStatus } from "../core/bridge"; import { DEFAULT_SETTINGS, type Settings } from "../core/state"; import { h, clear } from "../views/dom"; @@ -46,6 +46,90 @@ function renderDiff(text: string): HTMLElement { return box; } +// ── Changes to Claude Code's settings.json ──────────────────────────────────── + +/** One kind of change to ~/.claude/settings.json, with the words that go with it. */ +interface Change { + preview: (install: boolean) => Promise; + apply: (install: boolean, fingerprint: string) => Promise; + installText: string; + removeText: string; + installButton: string; + removeButton: string; + done: (backup: string) => string; +} + +const HOOKS_CHANGE: Change = { + preview: Bridge.hooksPreview, + apply: Bridge.hooksApply, + installText: "This is exactly what will change in your settings.json. Your own hooks are left untouched.", + removeText: "This removes Coucou's entries only. Your own hooks are left untouched.", + installButton: "Back up and write", + removeButton: "Back up and remove", + done: (backup) => `Done. Previous settings saved as ${backup}. Open a new Claude Code session to pick the hooks up.`, +}; + +const STATUS_LINE_CHANGE: Change = { + preview: Bridge.statusLinePreview, + apply: Bridge.statusLineApply, + installText: "This is exactly what will change: only the status line. If you already have one it keeps working, Coucou's relay runs it for you.", + removeText: "This puts your previous status line back, or removes the entry if there was none.", + installButton: "Back up and write", + removeButton: "Back up and remove", + done: (backup) => `Done. Previous settings saved as ${backup}. The numbers appear after the next reply of a Claude Code session.`, +}; + +/** + * Shows the diff of a change in `body` and writes it only after an explicit + * click, and only if settings.json still matches the diff that was shown. + * `back` redraws the section; `applied` runs a moment after a successful write. + */ +async function reviewChange( + body: HTMLElement, + change: Change, + install: boolean, + back: () => void, + applied: () => void, +) { + let preview; + try { + preview = await change.preview(install); + } catch (err) { + // An unreadable or invalid settings.json stops here rather than being + // treated as empty and written over. + clear(body); + body.append( + h("div", { class: "notice err", text: String(err).replace(/^Error:\s*/, "") }), + h("div", { class: "row" }, h("button", { text: "Back", onclick: back })), + ); + return; + } + if (!preview) return; + clear(body); + body.append( + h("div", { class: "hint", text: install ? change.installText : change.removeText }), + renderDiff(preview.diff), + h("div", { class: "row" }, h("span", { class: "path", text: `Backup → ${preview.backup}` })), + ); + const confirm = h("button", { + class: install ? "primary" : "danger", + text: install ? change.installButton : change.removeButton, + }); + confirm.addEventListener("click", async () => { + confirm.disabled = true; + try { + const backup = await change.apply(install, preview.fingerprint); + clear(body); + body.append(h("div", { class: "notice ok", text: change.done(String(backup)) })); + window.setTimeout(applied, 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: back }))); +} + // ── Claude Code section ─────────────────────────────────────────────────────── function claudeSection(status: HookStatus): HTMLElement { @@ -57,11 +141,14 @@ function claudeSection(status: HookStatus): HTMLElement { body, ); + const redraw = () => { + clear(body); + draw(); + }; const rebuild = async () => { const fresh = await Bridge.hooksStatus(); if (fresh) Object.assign(status, fresh); - clear(body); - draw(); + redraw(); const head = section.querySelector("h2")!; clear(head); head.append(statusDot(status.installed), h("span", { text: "Claude Code" })); @@ -97,7 +184,7 @@ function claudeSection(status: HookStatus): HTMLElement { const install = h("button", { class: "primary", text: status.installed ? "Reinstall hooks…" : "Install hooks…", - onclick: () => showPreview(true), + onclick: () => void reviewChange(body, HOOKS_CHANGE, true, redraw, () => void rebuild()), }); // Writing hook commands that point at a relay which isn't there would give // every Claude Code session a broken hook and nothing to show for it. @@ -110,66 +197,98 @@ function claudeSection(status: HookStatus): HTMLElement { actions.append(h("button", { class: "danger", text: "Uninstall hooks…", - onclick: () => showPreview(false), + onclick: () => void reviewChange(body, HOOKS_CHANGE, false, redraw, () => void rebuild()), })); } body.append(actions); } - async function showPreview(install: boolean) { - let preview; - try { - preview = await Bridge.hooksPreview(install); - } catch (err) { - // An unreadable or invalid settings.json stops here rather than being - // treated as empty and written over. - 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; + draw(); + return section; +} + +// ── Plan usage section ──────────────────────────────────────────────────────── + +/** + * The 5-hour and weekly limits in the island's header. They come from Claude + * Code's status line, so the relay has to be the status line first: turning the + * switch on without it starts the install, and the switch only stays on once + * that has been confirmed. A status line the user had keeps working. + */ +const PLAN_SETTINGS_TEXT = { + claude: "Shows your Claude plan usage (5-hour and weekly limits) in the island's header. Coucou adds a status line relay in ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.", + showClaude: "Show in notch", + codex: "Shows your Codex plan usage (weekly limit and free resets left) in the island's header. Coucou asks the Codex CLI (codex app-server) when the pill shows; nothing is installed. Codex must be signed in with ChatGPT.", + showCodex: "Show Codex plan in the notch", +}; + +function planSection(status: HookStatus): HTMLElement { + const body = h("div", { style: "display:flex;flex-direction:column;gap:12px" }); + const section = h("section", {}, h("h2", {}, h("span", { text: "Plan usage" })), body); + + const redraw = () => { clear(body); + draw(); + }; + const rebuild = async () => { + const fresh = await Bridge.hooksStatus(); + if (fresh) Object.assign(status, fresh); + settings.planRelayInstalled = status.planRelayInstalled; + // Cancelled or failed: a switch that was waiting for the install falls back. + if (!status.planRelayInstalled) settings.showPlanInNotch = false; + redraw(); + }; + + function draw() { + // The switch shows "on" while the install it asked for is being reviewed. + const sw = toggle(settings.showPlanInNotch, (on) => { + if (!on) { + settings.showPlanInNotch = false; + void save(); + } else if (status.planRelayInstalled) { + settings.showPlanInNotch = true; + void save(); + } else { + // Turned on before the relay is in: install it first; it stays on once confirmed. + void reviewChange(body, STATUS_LINE_CHANGE, true, () => void rebuild(), () => { + settings.planRelayInstalled = true; + settings.showPlanInNotch = true; + void save().then(rebuild); + }); + } + }); body.append( h("div", { class: "hint", - text: install - ? "This is exactly what will change in your settings.json. Your own hooks are left untouched." - : "This removes Coucou's entries only. Your own hooks are left untouched.", + text: PLAN_SETTINGS_TEXT.claude, }), - renderDiff(preview.diff), + h("div", { class: "row" }, h("label", { text: PLAN_SETTINGS_TEXT.showClaude }), sw), h("div", { class: "row" }, - h("span", { class: "path", text: `Backup → ${preview.backup}` }), + h("label", { text: "Relay" }), + statusDot(status.planRelayInstalled), + h("span", { class: "hint", text: status.planRelayInstalled ? "installed" : "not installed" }), + status.planRelayInstalled + ? h("button", { + class: "danger", + text: "Uninstall relay…", + onclick: () => void reviewChange(body, STATUS_LINE_CHANGE, false, redraw, () => void rebuild()), + }) + : h("button", { + class: "primary", + text: "Install relay…", + onclick: () => void reviewChange(body, STATUS_LINE_CHANGE, true, redraw, () => void rebuild()), + }), + ), + // Codex: nothing to install, Coucou asks the Codex CLI when the pill shows. + h("div", { class: "hint", text: PLAN_SETTINGS_TEXT.codex }), + h("div", { class: "row" }, + h("label", { text: PLAN_SETTINGS_TEXT.showCodex }), + toggle(settings.showCodexPlanInNotch, (on) => { + settings.showCodexPlanInNotch = on; + void save(); + }), ), ); - 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.hooksApply(install, preview.fingerprint); - clear(body); - body.append(h("div", { - class: "notice ok", - text: `Done. Previous settings saved as ${backup}. Open a new Claude Code session to pick the hooks 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(); @@ -446,7 +565,7 @@ async function main() { version = boot.version; } const status = (await Bridge.hooksStatus()) ?? { - installed: false, settingsPath: "", hookPath: "", hookReady: false, + installed: false, planRelayInstalled: false, settingsPath: "", hookPath: "", hookReady: false, }; const hasKey = (await Bridge.secretPresent("anthropic-api-key")) ?? false; @@ -462,6 +581,7 @@ async function main() { root.append( h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), + planSection(status), apiSection(hasKey), integrationsSection(present), generalSection(), diff --git a/windows/src/style.css b/windows/src/style.css index 335ed1593..59817c47d 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -1389,3 +1389,155 @@ input[type="range"]::-webkit-slider-thumb { .int-card.detail { padding-top: 8px; } + +/* ── Plan usage: the pills in the header and the card behind them ──────────── + ClaudePlanHeaderPill / ClaudePlanCardView / CodexPlanCardView on the Mac. + Nothing here applies until a pill is turned on in Settings. */ + +/* Off: takes no room, so the header is exactly as it shipped. */ +.plan-pills { + display: none; + align-items: center; + gap: 5px; + /* .header-actions spaces its items 14px apart; the Mac puts 8px between + the pills and the gear. */ + margin-right: -6px; +} + +.plan-pills.on { + display: flex; +} + +/* Both pills: the Mac tightens the right side (icons 10px apart, pills 5px + from the gear, 8px from the edge). */ +.header-actions.both-plans { + gap: 10px; + padding-right: 8px; +} + +.header-actions.both-plans .plan-pills { + margin-right: -5px; +} + +.header-actions .plan-pill { + --plan: #6b7079; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border: 1px solid color-mix(in srgb, var(--plan) 14%, transparent); + border-radius: 999px; + background: #0e0f11; + color: var(--dim-3); + font: 600 10px var(--font); + line-height: 12px; + white-space: nowrap; + cursor: default; + transition: background 0.18s, border-color 0.18s, color 0.18s; +} + +.header-actions .plan-pill:hover, +.header-actions .plan-pill.active { + background: color-mix(in srgb, var(--plan) 18%, transparent); + border-color: color-mix(in srgb, var(--plan) 55%, transparent); + color: color-mix(in srgb, var(--plan) 70%, white); +} + +.plan-pill-dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.plan-card { + position: relative; + z-index: 1; + padding-top: 10px; +} + +.plan-head { + display: flex; + align-items: center; + gap: 6px; + padding: 0 36px 0 108px; + white-space: nowrap; +} + +.plan-head > * { + flex: 0 0 auto; +} + +.plan-title { + font: 600 12px var(--font); + color: var(--ink); +} + +.plan-head > .plan-sub { + flex: 0 1 auto; /* the one part that gives way, with an ellipsis */ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font: 400 11px var(--font); + color: var(--dim-2); +} + +.plan-rows { + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px 12px 0 108px; +} + +.plan-row { + display: flex; + align-items: center; + gap: 5px; + font: 400 11px var(--font); + white-space: nowrap; +} + +.plan-label { + flex: 0 0 40px; + color: var(--dim-3); +} + +.plan-bar { + flex: 0 0 50px; + height: 4px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.plan-fill { + display: block; + height: 100%; + border-radius: 2px; +} + +.plan-pct { + flex: 0 0 30px; + text-align: right; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: #c5c8cd; +} + +.plan-reset-icon { + display: inline-flex; + color: var(--dim-3); +} + +.plan-reset { + font-size: 10px; + color: var(--dim-3); +} + +.plan-none { + color: var(--dim-3); +} + +.plan-credits { + font-weight: 600; + color: #c5c8cd; +} diff --git a/windows/src/views/icons.ts b/windows/src/views/icons.ts index d2ff82475..ecda77f1a 100644 --- a/windows/src/views/icons.ts +++ b/windows/src/views/icons.ts @@ -17,6 +17,8 @@ export const ICONS = { speakerOff: "M11 4.5 6.5 8.2H3.4v7.6h3.1L11 19.5v-15zm3.6 4.1 1.27-1.27 2.33 2.33 2.33-2.33 1.27 1.27L19.47 11l2.33 2.33-1.27 1.27-2.33-2.33-2.33 2.33-1.27-1.27L16.93 11 14.6 8.6z", // arrow.up.right arrowUpRight: "M8.5 7h8.5v8.5h-2V10.4l-7.1 7.1-1.4-1.4 7.1-7.1H8.5V7z", + // arrow.clockwise (stroke) + arrowClockwise: "M19 12a7 7 0 1 1-2.05-4.95M19 4.5V8h-3.5", // chevron.right chevronRight: "M9 5.5 15.5 12 9 18.5", chevronLeft: "M15 5.5 8.5 12 15 18.5", diff --git a/windows/src/views/usage.ts b/windows/src/views/usage.ts new file mode 100644 index 000000000..1813f98a7 --- /dev/null +++ b/windows/src/views/usage.ts @@ -0,0 +1,213 @@ +// Plan usage in the island: the Claude and Codex pills in the header and the +// card behind each. Same behaviour as ClaudePlanHeaderPill, ClaudePlanCardView +// and CodexPlanCardView on the Mac; the numbers and labels come from +// core/plan.ts. +// +// The pills sit in the header's right side, before the gear, on the overview +// only, Claude first. Clicking one puts its card in place of the overview's +// left card (clicking it again, or the other pill, closes or swaps it), and +// Mochi wears the plan's colour while it is open. It closes when the view, the +// mode or the focused pill changes. + +import { Bridge } from "../core/bridge"; +import { + PLAN_TEXT, claudeSubtitle, codexIsStale, codexResetsLabel, codexSubtitle, dominantPct, + effectivePct, parseCodexPlan, pillLabel, planColor, resetLabel, + type CodexPlanUsage, type PlanUsage, type PlanWindow, +} from "../core/plan"; +import { State } from "../core/state"; +import { clear, dot, h, svg } from "./dom"; +import { ICONS } from "./icons"; + +/** The Claude pill is in the header: overview, turned on, relay in. */ +export function claudePillVisible(): boolean { + const s = State.settings; + return State.view === "overview" && s.showPlanInNotch && s.planRelayInstalled; +} + +/** The Codex pill is in the header: overview, turned on (nothing to install). */ +export function codexPillVisible(): boolean { + return State.view === "overview" && State.settings.showCodexPlanInNotch; +} + +/** A plan card is open and its pill is still there. */ +export function planCardOpen(): boolean { + if (!State.showingPlanDetail) return false; + return State.planDetailIsCodex ? codexPillVisible() : claudePillVisible(); +} + +const claudeColor = (now = Date.now()) => planColor(dominantPct(State.planUsage, now)); +const codexColor = (now = Date.now()) => planColor(dominantPct(State.codexPlanUsage, now)); + +/** The colour of the open card's plan, which Mochi wears while it is open. */ +export function openPlanColor(): string { + return State.planDetailIsCodex ? codexColor() : claudeColor(); +} + +/** Closes the plan card (view, mode or focus changed). */ +export function closePlanCard(): void { + State.showingPlanDetail = false; +} + +// ── Claude numbers ──────────────────────────────────────────────────────────── + +const STORE_KEY = "coucou.claudePlanUsage"; + +/** New numbers from the status line; kept so they survive a restart, as on the Mac. */ +export function setClaudePlanUsage(usage: PlanUsage): void { + const prev = State.planUsage; + const same = JSON.stringify([prev?.fiveHour, prev?.sevenDay]) === JSON.stringify([usage.fiveHour, usage.sevenDay]); + State.planUsage = usage; + if (!same) { + try { + window.localStorage?.setItem(STORE_KEY, JSON.stringify(usage)); + } catch { + // Storage off or full: the numbers just will not outlive this run. + } + } + // The relay calls in with every Claude Code update: a hidden island is only + // woken when the numbers actually moved. + if (!same || State.mode === "expanded") State.notify(); +} + +/** The last numbers seen, if any were kept. */ +export function storedClaudePlanUsage(): string | null { + try { + return window.localStorage?.getItem(STORE_KEY) ?? null; + } catch { + return null; + } +} + +// ── Codex numbers ───────────────────────────────────────────────────────────── + +let codexInFlight = false; + +/** + * Asks Codex again when the numbers are missing or older than a minute. Only + * from the pill (shown or clicked), never on a timer, and never while paused: + * `codex app-server` talks to Codex's own service. + */ +export function refreshCodexPlanUsage(): void { + if (codexInFlight || State.paused || !codexIsStale(State.codexPlanUsage)) return; + codexInFlight = true; + void Bridge.codexPlanUsage() + .then((result) => { + const usage = parseCodexPlan(result); + if (usage) { + State.codexPlanUsage = usage; + State.notify(); + } + }) + .finally(() => { + codexInFlight = false; + }); +} + +// ── Pill ────────────────────────────────────────────────────────────────────── + +export interface PlanPill { + el: HTMLElement; + sync(): void; +} + +/** One pill: colour dot and label, lit while hovered or while its card is open. */ +export function buildPlanPill(codex: boolean): PlanPill { + const label = h("span", { class: "plan-pill-label" }); + const dotEl = h("i", { class: "plan-pill-dot" }); + const isOpen = () => State.showingPlanDetail && State.planDetailIsCodex === codex; + const el = h("button", { + class: "plan-pill", + title: codex ? PLAN_TEXT.codexPillTitle : PLAN_TEXT.claudePillTitle, + onclick: () => { + const open = isOpen(); + State.planDetailIsCodex = codex; + State.showingPlanDetail = !open; + if (codex) refreshCodexPlanUsage(); + State.notify(); + }, + }, dotEl, label); + return { + el, + sync() { + const color = codex ? codexColor() : claudeColor(); + el.style.setProperty("--plan", color); + el.classList.toggle("active", isOpen()); + dotEl.style.background = color; + label.textContent = codex + ? pillLabel("Codex", State.codexPlanUsage) + : pillLabel("Claude", State.planUsage); + }, + }; +} + +// ── Card ────────────────────────────────────────────────────────────────────── + +function gaugeRow(label: string, w: PlanWindow | undefined, weekly: boolean, now: number): HTMLElement { + const row = h("div", { class: "plan-row" }, h("span", { class: "plan-label", text: label })); + if (!w) { + row.append(h("span", { class: "plan-none", text: PLAN_TEXT.none })); + return row; + } + const pct = effectivePct(w, now); + const fill = h("i", { class: "plan-fill" }); + fill.style.width = `${pct}%`; + fill.style.background = planColor(pct); + row.append( + h("span", { class: "plan-bar" }, fill), + h("span", { class: "plan-pct", text: `${Math.round(pct)}%` }), + h("span", { class: "plan-reset-icon" }, svg(ICONS.arrowClockwise, 8, { stroke: 2.6 })), + h("span", { class: "plan-reset", text: resetLabel(w, weekly, now) }), + ); + return row; +} + +function head(color: string, title: string, subtitle: string): HTMLElement { + return h("div", { class: "plan-head" }, + dot(color, 7), + h("span", { class: "plan-title", text: title }), + h("span", { class: "plan-sub", text: subtitle }), + ); +} + +/** The card that stands in for the overview's left card while a pill is open. */ +export class PlanCard { + readonly el = h("div", { class: "plan-card" }); + private key = ""; + + /** Re-renders when the numbers change, or every 30 s for the countdowns. */ + sync(now = Date.now()) { + const codex = State.planDetailIsCodex; + const u = codex ? State.codexPlanUsage : State.planUsage; + const key = `${codex}|${JSON.stringify(u)}|${Math.floor(now / 30_000)}`; + if (key === this.key) return; + this.key = key; + clear(this.el); + if (codex) this.drawCodex(State.codexPlanUsage, now); + else this.drawClaude(State.planUsage, now); + } + + private drawClaude(u: PlanUsage | null, now: number) { + this.el.append( + head(claudeColor(now), PLAN_TEXT.claudeTitle, claudeSubtitle(u, now)), + h("div", { class: "plan-rows" }, + gaugeRow(PLAN_TEXT.fiveHours, u?.fiveHour, false, now), + gaugeRow(PLAN_TEXT.week, u?.sevenDay, true, now), + ), + ); + } + + private drawCodex(u: CodexPlanUsage | null, now: number) { + const rows = h("div", { class: "plan-rows" }); + // Codex plans without a 5-hour window show the week alone, as on the Mac. + if (u?.fiveHour) rows.append(gaugeRow(PLAN_TEXT.fiveHours, u.fiveHour, false, now)); + rows.append( + gaugeRow(PLAN_TEXT.week, u?.sevenDay, true, now), + h("div", { class: "plan-row" }, + h("span", { class: "plan-label", text: PLAN_TEXT.resets }), + h("span", { class: "plan-credits", text: codexResetsLabel(u) }), + ), + ); + this.el.append(head(codexColor(now), PLAN_TEXT.codexTitle, codexSubtitle(u, now)), rows); + } +} diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index e433651da..b1174a9bc 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -11,6 +11,9 @@ import { createMiniBot, pruneMiniBots } from "../mochi/minibots"; import { buildPrompt } from "./chat"; import { buildChoose, buildUpload, buildUploading } from "./upload"; import { renderIntegrationCard, type IntegrationCardHooks } from "./integrations"; +import { + PlanCard, buildPlanPill, claudePillVisible, codexPillVisible, planCardOpen, refreshCodexPlanUsage, +} from "./usage"; export interface ViewActions { setView(v: IslandViewName): void; @@ -90,6 +93,11 @@ 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)); + // Plan usage pills (off by default): before the gear, Claude first, as on the Mac. + const claudePill = buildPlanPill(false); + const codexPill = buildPlanPill(true); + const planPills = h("div", { class: "plan-pills" }, claudePill.el, codexPill.el); + let codexShown = false; function go(v: IslandViewName) { actions.blip(); @@ -100,8 +108,9 @@ 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" }, planPills, gearBtn, soundBtn), ); + const headerActions = el.lastElementChild as HTMLElement; return { el, @@ -115,9 +124,26 @@ 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)); + syncPlanPills(); el.style.opacity = v === "confused" ? "0" : "1"; }, }; + + function syncPlanPills() { + const claudeOn = claudePillVisible(); + const codexOn = codexPillVisible(); + claudePill.el.style.display = claudeOn ? "" : "none"; + codexPill.el.style.display = codexOn ? "" : "none"; + planPills.classList.toggle("on", claudeOn || codexOn); + // Both pills: the right side tightens so it still clears the screen edge. + headerActions.classList.toggle("both-plans", claudeOn && codexOn); + if (claudeOn) claudePill.sync(); + if (codexOn) codexPill.sync(); + // Codex is asked when its pill comes into view (stale answers only). + const shown = codexOn && State.mode === "expanded"; + if (shown && !codexShown) refreshCodexPlanUsage(); + codexShown = shown; + } } // ── Overview ────────────────────────────────────────────────────────────────── @@ -135,6 +161,9 @@ function buildOverview(actions: ViewActions): ViewHost { const left = card(null, leftBody, jump); const pills = h("div", { class: "pills" }); const right = card(null, pills); + // Opened from a plan pill in the header: stands in for the left card. + const plan = new PlanCard(); + let planTimer: number | null = null; const el = h("div", { class: "view overview" }, h("div", { class: "left" }, left), @@ -144,7 +173,7 @@ function buildOverview(actions: ViewActions): ViewHost { let pillIds = ""; let detailOpen = false; let lastFocus: string | null = null; - let mode: "ticker" | "card" | null = null; + let mode: "ticker" | "card" | "plan" | null = null; let cardKey = ""; const hooks: IntegrationCardHooks = { @@ -164,6 +193,20 @@ function buildOverview(actions: ViewActions): ViewHost { openSettings: () => actions.openSettingsWindow(), }; + /** The countdowns move every 30 s while a card is open, and only then. */ + function syncPlanTimer(open: boolean) { + const stop = () => { + if (planTimer != null) window.clearInterval(planTimer); + planTimer = null; + }; + if (!open) return stop(); + if (planTimer != null) return; + planTimer = window.setInterval(() => { + if (planCardOpen() && State.mode === "expanded") State.notify(); + else stop(); + }, 30_000); + } + return { el, tick(nowMs: number) { @@ -185,7 +228,21 @@ function buildOverview(actions: ViewActions): ViewHost { const sessionActive = task?.id === "integration_claude" && (task.state !== "idle" || task.steps.length > 0); - if (task && sessionActive) { + const planOpen = planCardOpen(); + if (mode === "plan" && !planOpen) { + mode = null; + cardKey = ""; + } + syncPlanTimer(planOpen); + + if (planOpen) { + if (mode !== "plan") { + clear(leftBody); + leftBody.append(plan.el); + mode = "plan"; + } + plan.sync(); + } else if (task && sessionActive) { if (mode !== "ticker") { clear(leftBody); leftBody.append(tickerBody); @@ -220,7 +277,7 @@ function buildOverview(actions: ViewActions): ViewHost { } } - jump.style.display = detailOpen ? "none" : ""; + jump.style.display = detailOpen || mode === "plan" ? "none" : ""; const others = State.otherTasks.slice(0, 4); const pillKey = others.map((t) => `${t.id}:${t.pillBadge ?? ""}`).join("|"); diff --git a/windows/tests/plan.test.mjs b/windows/tests/plan.test.mjs new file mode 100644 index 000000000..29b5f763c --- /dev/null +++ b/windows/tests/plan.test.mjs @@ -0,0 +1,261 @@ +// Plan usage gauges (src/core/plan.ts, src/views/usage.ts): a port of the Mac's +// tests/ClaudePlanGaugeTests.swift, the Codex parsing of CodexPlanGauge.swift, +// and the pills' rules (where they show, what a click opens, what closes it). + +import { beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { + claudeSubtitle, codexIsStale, codexResetsLabel, codexSubtitle, dominantPct, effectivePct, + parseClaudePlan, parseCodexPlan, pillLabel, planColor, resetLabel, restorePlanUsage, +} from "../src/core/plan.ts"; +import { emit } from "./tauri.mjs"; +import { registerHookHandlers } from "../src/island/hooks.ts"; +import { + claudePillVisible, codexPillVisible, openPlanColor, planCardOpen, +} from "../src/views/usage.ts"; +import { DEFAULT_SETTINGS, State } from "../src/core/state.ts"; + +const NOW = Date.UTC(2026, 9, 7, 12, 0, 0); +const future = NOW / 1000 + 7200; // 2 hours from now, epoch seconds +const past = NOW / 1000 - 100; +const claude = (rl) => parseClaudePlan(rl, NOW); +const win = (pct, epochS) => ({ usedPct: pct, resetsAt: epochS * 1000 }); + +// ── ClaudePlanGauge.parse ───────────────────────────────────────────────────── + +test("a full payload gives both windows", () => { + const u = claude({ + five_hour: { used_percentage: 23.5, resets_at: future }, + seven_day: { used_percentage: 67.0, resets_at: future }, + }); + assert.equal(u.fiveHour.usedPct, 23.5); + assert.equal(u.sevenDay.usedPct, 67); + assert.equal(u.fiveHour.resetsAt, future * 1000); + assert.equal(u.updatedAt, NOW); +}); + +test("missing or empty rate_limits is nothing", () => { + assert.equal(claude(undefined), null); + assert.equal(claude({}), null); + assert.equal(claude("x"), null); +}); + +test("percentages: 150 is shown full, above 200 or below 0 is dropped, ints are fine", () => { + const one = (pct) => claude({ five_hour: { used_percentage: pct, resets_at: future } })?.fiveHour; + assert.equal(one(250), undefined); + assert.equal(one(201), undefined); + assert.equal(one(-5), undefined); + assert.equal(one(150).usedPct, 100); + assert.equal(one(42).usedPct, 42); + assert.equal(one("42"), undefined); +}); + +test("a reset more than 400 days away is milliseconds in disguise", () => { + const far = NOW / 1000 + 401 * 86400; + assert.equal(claude({ five_hour: { used_percentage: 50, resets_at: far } }), null); + assert.equal(claude({ five_hour: { used_percentage: 50, resets_at: NOW } }), null); + assert.equal(claude({ five_hour: { used_percentage: 50, resets_at: 0 } }), null); +}); + +test("one good window is enough", () => { + const u = claude({ + five_hour: { used_percentage: 250, resets_at: future }, + seven_day: { used_percentage: 10, resets_at: future }, + }); + assert.equal(u.fiveHour, undefined); + assert.equal(u.sevenDay.usedPct, 10); +}); + +// ── effectivePct / dominantPct ──────────────────────────────────────────────── + +test("a window whose reset has passed counts as 0", () => { + assert.equal(effectivePct(win(80, past), NOW), 0); + assert.equal(effectivePct(win(80, future), NOW), 80); +}); + +test("the dominant percentage is the higher window", () => { + const both = { fiveHour: win(30, future), sevenDay: win(70, future), updatedAt: NOW }; + const fhOnly = { fiveHour: win(55, future), updatedAt: NOW }; + const empty = { updatedAt: NOW }; + assert.equal(dominantPct(both, NOW), 70); + assert.equal(dominantPct(fhOnly, NOW), 55); + assert.equal(dominantPct(empty, NOW), null); + assert.equal(dominantPct(null, NOW), null); + // An expired week no longer dominates. + assert.equal(dominantPct({ fiveHour: win(30, future), sevenDay: win(90, past), updatedAt: NOW }, NOW), 30); +}); + +// ── color / labels ──────────────────────────────────────────────────────────── + +test("colours: grey, then green below 50, orange below 80, red", () => { + assert.equal(planColor(null), "#6B7079"); + assert.equal(planColor(0), "#22C55E"); + assert.equal(planColor(49), "#22C55E"); + assert.equal(planColor(50), "#F59E0B"); + assert.equal(planColor(79), "#F59E0B"); + assert.equal(planColor(80), "#F4505E"); + assert.equal(planColor(100), "#F4505E"); +}); + +test("pill labels read like the Mac's header pill", () => { + const both = { fiveHour: win(30, future), sevenDay: win(70, future), updatedAt: NOW }; + assert.equal(pillLabel("Claude", null, NOW), "Claude —"); + assert.equal(pillLabel("Claude", { updatedAt: NOW }, NOW), "Claude —"); + assert.equal(pillLabel("Claude", both, NOW), "Claude 70%"); + assert.equal(pillLabel("Codex", { sevenDay: win(12.4, future), updatedAt: NOW }, NOW), "Codex 12%"); + assert.equal(pillLabel("Codex", null, NOW), "Codex —"); +}); + +test("reset times: a countdown for 5 hours, a weekday for the week", () => { + assert.equal(resetLabel(win(10, NOW / 1000 + 4800), false, NOW), "in 1 h 20"); + assert.equal(resetLabel(win(10, NOW / 1000 + 300), false, NOW), "in 5 min"); + assert.equal(resetLabel(win(10, past), false, NOW), "Resetting…"); + const at = new Date(2026, 9, 12, 9, 5); // a Monday, local time + assert.equal(resetLabel({ usedPct: 1, resetsAt: at.getTime() }, true, at.getTime() - 3600_000), "Mon 9:05"); +}); + +test("subtitles say how old the numbers are", () => { + assert.equal(claudeSubtitle(null, NOW), "Waiting for a response from Claude Code"); + assert.equal(claudeSubtitle({ updatedAt: NOW - 30_000 }, NOW), "just now"); + assert.equal(claudeSubtitle({ updatedAt: NOW - 12 * 60_000 }, NOW), "12 min ago"); + assert.equal(claudeSubtitle({ updatedAt: NOW - 3 * 3600_000 }, NOW), "3 h ago"); + assert.equal(codexSubtitle(null, NOW), "Asking Codex…"); + assert.equal(codexSubtitle({ planType: "plus", updatedAt: NOW - 120_000 }, NOW), "plus · 2 min ago"); +}); + +test("the last Claude numbers survive a restart, and junk does not", () => { + const u = { fiveHour: win(30, future), updatedAt: NOW }; + assert.deepEqual(restorePlanUsage(JSON.stringify(u)), u); + assert.equal(restorePlanUsage(null), null); + assert.equal(restorePlanUsage("{"), null); + assert.equal(restorePlanUsage(JSON.stringify({ updatedAt: NOW, fiveHour: { usedPct: 900, resetsAt: 1 } })), null); +}); + +// ── CodexPlanGauge.parse ────────────────────────────────────────────────────── + +test("Codex windows are sorted by duration, not by primary/secondary", () => { + const u = parseCodexPlan({ + rateLimits: { + planType: "plus", + primary: { usedPercent: 64, resetsAt: future + 86400, windowDurationMins: 10080 }, + secondary: { usedPercent: 12.5, resetsAt: future, windowDurationMins: 300 }, + }, + }, NOW); + assert.equal(u.planType, "plus"); + assert.equal(u.fiveHour.usedPct, 12.5); + assert.equal(u.sevenDay.usedPct, 64); + assert.equal(u.sevenDay.resetsAt, (future + 86400) * 1000); +}); + +test("a Codex window without a duration is the week; percentages are clamped", () => { + const u = parseCodexPlan({ rateLimits: { primary: { usedPercent: 140, resetsAt: future } } }, NOW); + assert.equal(u.fiveHour, undefined); + assert.equal(u.sevenDay.usedPct, 100); + const low = parseCodexPlan({ rateLimits: { primary: { usedPercent: -3, resetsAt: future } } }, NOW); + assert.equal(low.sevenDay.usedPct, 0); +}); + +test("free resets: the count and the soonest available expiry", () => { + const u = parseCodexPlan({ + rateLimits: { primary: { usedPercent: 5, resetsAt: future, windowDurationMins: 10080 } }, + rateLimitResetCredits: { + availableCount: 2, + credits: [ + { status: "available", expiresAt: Date.UTC(2026, 10, 20, 12) / 1000 }, + { status: "used", expiresAt: Date.UTC(2026, 9, 1, 12) / 1000 }, + { status: "available", expiresAt: Date.UTC(2026, 9, 9, 12) / 1000 }, + ], + }, + }, NOW); + assert.equal(u.resetCredits, 2); + assert.equal(u.resetCreditExpiresAt, Date.UTC(2026, 9, 9, 12)); + assert.equal(codexResetsLabel(u), "2 available · until Oct 9"); + assert.equal(codexResetsLabel({ ...u, resetCredits: 0 }), "0 available"); + assert.equal(codexResetsLabel({ updatedAt: NOW }), "—"); + assert.equal(codexResetsLabel(null), "—"); +}); + +test("no Codex limits, or only broken ones, is nothing", () => { + assert.equal(parseCodexPlan(null, NOW), null); + assert.equal(parseCodexPlan({}, NOW), null); + assert.equal(parseCodexPlan({ rateLimits: { primary: { usedPercent: "5", resetsAt: future } } }, NOW), null); + assert.equal(parseCodexPlan({ rateLimits: { primary: { usedPercent: 5 } } }, NOW), null); +}); + +test("Codex is asked again only once its numbers are a minute old", () => { + assert.equal(codexIsStale(null, NOW), true); + assert.equal(codexIsStale({ updatedAt: NOW - 59_000 }, NOW), false); + assert.equal(codexIsStale({ updatedAt: NOW - 60_000 }, NOW), true); +}); + +// ── The pills in the island ─────────────────────────────────────────────────── + +registerHookHandlers({ alert() {}, setView() {}, reveal() {}, dropPin() {} }); + +beforeEach(() => { + State.settings = { ...DEFAULT_SETTINGS }; + State.view = "overview"; + State.paused = false; + State.planUsage = null; + State.codexPlanUsage = null; + State.showingPlanDetail = false; + State.planDetailIsCodex = false; +}); + +test("both pills are off by default, so the header is as it shipped", () => { + assert.equal(claudePillVisible(), false); + assert.equal(codexPillVisible(), false); +}); + +test("the Claude pill needs the switch and the relay, and the overview", () => { + State.settings.showPlanInNotch = true; + assert.equal(claudePillVisible(), false); + State.settings.planRelayInstalled = true; + assert.equal(claudePillVisible(), true); + State.view = "prompt"; + assert.equal(claudePillVisible(), false); +}); + +test("the Codex pill needs only its switch, on the overview", () => { + State.settings.showCodexPlanInNotch = true; + assert.equal(codexPillVisible(), true); + State.view = "settings"; + assert.equal(codexPillVisible(), false); +}); + +test("an open card follows its pill, and Mochi wears that plan's colour", () => { + State.settings.showPlanInNotch = true; + State.settings.planRelayInstalled = true; + State.showingPlanDetail = true; + assert.equal(planCardOpen(), true); + assert.equal(openPlanColor(), "#6B7079"); + // The Codex card cannot be open without its pill. + State.planDetailIsCodex = true; + assert.equal(planCardOpen(), false); + State.settings.showCodexPlanInNotch = true; + assert.equal(planCardOpen(), true); + State.codexPlanUsage = { sevenDay: win(85, Date.now() / 1000 + 3600), updatedAt: Date.now() }; + assert.equal(openPlanColor(), "#F4505E"); +}); + +test("focusing another pill closes the plan card", () => { + State.loadIntegrationTasks(); + State.showingPlanDetail = true; + State.setFocus(State.tasks[0].id); + assert.equal(State.showingPlanDetail, false); +}); + +test("a status line call updates the numbers and nothing else, even when paused", () => { + State.paused = true; + const tasks = JSON.stringify(State.tasks); + emit("hook", { + hook_event_name: "StatusLine", + session_id: "s1", + rate_limits: { five_hour: { used_percentage: 42, resets_at: Date.now() / 1000 + 3600 } }, + }); + assert.equal(State.planUsage.fiveHour.usedPct, 42); + assert.equal(JSON.stringify(State.tasks), tasks); + // Without limits (API-key users) the last numbers stay. + emit("hook", { hook_event_name: "StatusLine", session_id: "s1" }); + assert.equal(State.planUsage.fiveHour.usedPct, 42); +}); From a40ec1d8f8abcd613daabc4326726ba1c92adb45 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:48:35 +0200 Subject: [PATCH 48/77] Agents: one hardened writer and one settings section for every agent config_file.rs is now the only code that changes a config Coucou does not own: strict read (BOM stripped, unreadable or non-object refused), edits that refuse unexpected types, a diff, a fingerprint check against the exact bytes previewed (a missing file and an empty one are told apart), a dated backup of those bytes that must succeed and never overwrites an earlier one, then an atomic write that keeps permissions and follows symlinks. Several files can be planned and checked together. hooks.rs (Claude Code) goes through it, and now refuses a "hooks" value of an unexpected type instead of replacing it. The Gemini CLI installer from #278 moves to agents.rs on the same writer; its duplicated preview/write and the geminiHooksInstalled setting are gone. On Windows Gemini CLI runs hooks through PowerShell, so its command is `& ''` instead of a bare quoted path, which PowerShell rejects. Settings gets one Agents section (preview diff, click to confirm, install or uninstall) used by every agent. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/agents.rs | 523 ++++++++++++++++++++ windows/src-tauri/src/config_file.rs | 628 ++++++++++++++++++++++++ windows/src-tauri/src/hooks.rs | 683 +++------------------------ windows/src-tauri/src/lib.rs | 44 +- windows/src-tauri/src/settings.rs | 3 - windows/src/core/bridge.ts | 40 +- windows/src/core/state.ts | 2 - windows/src/settings/agents.ts | 137 ++++++ windows/src/settings/main.ts | 159 +------ windows/src/settings/parts.ts | 16 + windows/tests/hooks.test.mjs | 2 +- 11 files changed, 1444 insertions(+), 793 deletions(-) create mode 100644 windows/src-tauri/src/agents.rs create mode 100644 windows/src-tauri/src/config_file.rs create mode 100644 windows/src/settings/agents.ts create mode 100644 windows/src/settings/parts.ts diff --git a/windows/src-tauri/src/agents.rs b/windows/src-tauri/src/agents.rs new file mode 100644 index 000000000..9451f8d3f --- /dev/null +++ b/windows/src-tauri/src/agents.rs @@ -0,0 +1,523 @@ +// Hook and plugin installers for every agent other than Claude Code (hooks.rs). +// +// Each agent is a list of file edits handed to config_file.rs, which applies the +// CLAUDE.md rules once for all of them: strict read, refuse unexpected types, +// diff, dated backup that must succeed, fingerprint check, atomic write. What +// lives here is only *what* changes in each agent's config, and the command +// line that starts the relay in the shell that agent uses. +// +// Formats and paths follow the Mac app (NotchBuddy/Sources/App/HookServer.swift) +// so a config shared between machines (dotfiles) looks the same everywhere. + +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde_json::{json, Map, Value}; + +use crate::config_file::{self, FileEdit, Plan}; +use crate::{platform, settings}; + +/// Marker that identifies a Coucou entry: the relay's file name. +const MARKER: &str = "coucou-hook"; + +// ── The relay command line ──────────────────────────────────────────────────── + +/// The shell an agent runs its hook commands through on Windows. On Linux every +/// agent uses `sh` (or bash), so there is only one way to quote. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Shell { + /// Git Bash on Windows (Claude Code), `sh` on Linux. + Sh, + /// `powershell -Command` (Gemini CLI, Copilot CLI's `powershell` field). + PowerShell, + /// `cmd /C`, or a process started directly (Codex, Cursor, Antigravity, Muse). + Cmd, +} + +/// Where the relay is and which OS the command is for. Kept apart from the +/// real install so tests can build both the Windows and the Linux command. +pub struct Relay { + pub exe: String, + pub windows: bool, +} + +impl Relay { + pub fn current() -> Self { + Self { + exe: settings::hook_exe_path().to_string_lossy().to_string(), + windows: cfg!(windows), + } + } + + /// The relay followed by `args`, as one command line for `shell`. + pub fn command(&self, shell: Shell, args: &str) -> String { + if !self.windows { + return format!("{} {args}", sh_quote(&self.exe)); + } + let exe = match shell { + // Git Bash reads backslashes as escapes: forward slashes, quoted. + Shell::Sh => format!("\"{}\"", self.exe.replace('\\', "/")), + // A quoted string alone is an expression in PowerShell: `&` runs it. + // Inside single quotes only `'` is special, and it doubles. + Shell::PowerShell => format!("& '{}'", self.exe.replace('\'', "''")), + // cmd /C keeps the quotes of a lone quoted program name; a plain path + // goes unquoted so it also works if the agent turns out to use + // PowerShell. A Windows path cannot contain `"`. + Shell::Cmd if is_plain(&self.exe) => self.exe.clone(), + Shell::Cmd => format!("\"{}\"", self.exe), + }; + format!("{exe} {args}") + } +} + +/// The relay command for the running OS, for `args`. +pub fn relay_command(shell: Shell, args: &str) -> String { + Relay::current().command(shell, args) +} + +/// `s` as one single-quoted POSIX shell word: `'` becomes `'\''`, nothing else +/// is special inside single quotes. +fn sh_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} + +/// A path no Windows shell would split or interpret. +fn is_plain(path: &str) -> bool { + path.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '\\' | ':' | '.' | '_' | '-')) +} + +// ── Agents ──────────────────────────────────────────────────────────────────── + +type JsonChange = Box Result, String>>; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Agent { + Gemini, +} + +impl Agent { + pub const ALL: &'static [Agent] = &[Agent::Gemini]; + + /// The `--agent` name; the pill is `agent_` (PillCatalog.swift). + pub fn id(self) -> &'static str { + match self { + Agent::Gemini => "gemini", + } + } + + pub fn from_id(id: &str) -> Option { + Self::ALL.iter().copied().find(|a| a.id() == id) + } + + /// The files this agent's install touches, under `home`. + fn files(self, home: &Path) -> Vec { + match self { + Agent::Gemini => vec![home.join(".gemini").join("settings.json")], + } + } + + /// The edits that install (or remove) Coucou for this agent. + fn edits(self, home: &Path, relay: &Relay, install: bool) -> Vec> { + let files = self.files(home); + let path = files[0].clone(); + let label = path.display().to_string(); + let change = self.json_change(relay, install); + vec![FileEdit { path, edit: config_file::json_edit(label, change) }] + } + + /// The change to this agent's JSON config: the new object, or `None` to + /// remove a file that is Coucou's alone. Command lines are built here, up + /// front, so the change itself is pure. + fn json_change(self, relay: &Relay, install: bool) -> JsonChange { + let agent = self.id(); + if !install { + return Box::new(move |v| groups_uninstall(v, agent).map(Some)); + } + match self { + Agent::Gemini => { + let commands: Vec<(String, String, u64)> = GEMINI_EVENTS + .iter() + .map(|(event, said, timeout)| { + let command = relay.command(Shell::PowerShell, &format!("--agent gemini {said}")); + (event.to_string(), command, *timeout) + }) + .collect(); + Box::new(move |v| gemini_install(v, &commands).map(Some)) + } + } + } + + /// True when the agent's config already routes to Coucou. Never fails: a + /// file we cannot read just reads as "not installed". + fn installed(self, home: &Path) -> bool { + let files = self.files(home); + let json = || { + config_file::read(&files[0]) + .ok() + .flatten() + .and_then(|b| config_file::parse_json(Some(&b), "").ok()) + .unwrap_or_else(|| json!({})) + }; + match self { + Agent::Gemini => groups_have_ours(&json(), "gemini"), + } + } + + /// Whether the island can answer this agent's permission requests. + fn approvals(self) -> bool { + false + } +} + +// ── Strings shown in Settings ───────────────────────────────────────────────── + +const TEXT_UNEXPECTED: &str = "has an unexpected type — Coucou has not touched it."; + +impl Agent { + pub fn name(self) -> &'static str { + match self { + Agent::Gemini => "Gemini CLI", + } + } + + /// What to do once the file is written, shown with the result. + fn note(self) -> &'static str { + match self { + Agent::Gemini => "Start a new Gemini CLI session to pick the hooks up.", + } + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentStatus { + pub id: &'static str, + pub name: &'static str, + pub installed: bool, + /// The file (or files, one per line) Coucou writes. + pub path: String, + pub hook_ready: bool, + /// The island can allow or deny this agent's permission requests. + pub approvals: bool, + pub note: &'static str, +} + +fn find(id: &str) -> Result { + Agent::from_id(id).ok_or_else(|| format!("Unknown agent \"{id}\".")) +} + +pub fn list() -> Vec { + let home = platform::home_dir(); + let hook_ready = settings::hook_exe_path().exists(); + Agent::ALL + .iter() + .map(|&a| AgentStatus { + id: a.id(), + name: a.name(), + installed: a.installed(&home), + path: a.files(&home).iter().map(|p| p.display().to_string()).collect::>().join("\n"), + hook_ready, + approvals: a.approvals(), + note: a.note(), + }) + .collect() +} + +pub fn preview(id: &str, install: bool) -> Result { + let agent = find(id)?; + config_file::preview(&agent.edits(&platform::home_dir(), &Relay::current(), install)) +} + +/// Only ever called from an explicit click. Returns the backups taken, one per line. +pub fn apply(id: &str, install: bool, fingerprint: &str) -> Result { + let agent = find(id)?; + apply_in(agent, &platform::home_dir(), &Relay::current(), install, fingerprint) +} + +fn apply_in(agent: Agent, home: &Path, relay: &Relay, install: bool, fingerprint: &str) -> Result { + let backups = config_file::apply(&agent.edits(home, relay, install), fingerprint)?; + Ok(backups.iter().map(|p| p.display().to_string()).collect::>().join("\n")) +} + +// ── Shared JSON helpers ─────────────────────────────────────────────────────── + +fn unexpected(what: &str) -> String { + format!("{what} {TEXT_UNEXPECTED}") +} + +/// `root[key]` as an object to edit: absent is empty, anything else is refused. +fn object_at(root: &Map, key: &str) -> Result, String> { + match root.get(key) { + None => Ok(Map::new()), + Some(Value::Object(m)) => Ok(m.clone()), + Some(_) => Err(unexpected(&format!("\"{key}\""))), + } +} + +/// `hooks[event]` as a list to edit: absent is empty, anything else is refused. +fn list_at(hooks: &Map, event: &str) -> Result, String> { + match hooks.get(event) { + None => Ok(Vec::new()), + Some(Value::Array(list)) => Ok(list.clone()), + Some(_) => Err(unexpected(&format!("\"hooks\".\"{event}\""))), + } +} + +/// A command line written by Coucou for `agent`. +fn is_our_command(command: Option<&Value>, agent: &str) -> bool { + command + .and_then(Value::as_str) + .is_some_and(|c| c.contains(MARKER) && c.contains(&format!("--agent {agent}"))) +} + +/// Claude-style groups (`{"matcher"?, "hooks": [{"command"}]}`, or a legacy +/// flat `{"command"}`) without Coucou's entries for `agent`. A group left +/// empty goes; everything else stays exactly as it was. +fn without_ours_in_groups(groups: &[Value], agent: &str) -> Vec { + groups + .iter() + .filter_map(|group| { + if is_our_command(group.get("command"), agent) { + return None; + } + let Some(inner) = group.get("hooks").and_then(Value::as_array) else { + return Some(group.clone()); + }; + let kept: Vec = + inner.iter().filter(|h| !is_our_command(h.get("command"), agent)).cloned().collect(); + if kept.len() == inner.len() { + return Some(group.clone()); + } + if kept.is_empty() { + return None; + } + let mut group = group.clone(); + group["hooks"] = Value::Array(kept); + Some(group) + }) + .collect() +} + +/// `root` with one Coucou group per event, built by `group(event)`. Earlier +/// Coucou entries for `agent` are replaced; nobody else's are touched. +fn groups_install( + root: &Value, + agent: &str, + events: impl IntoIterator, +) -> Result, String> { + let mut root = root.as_object().cloned().unwrap_or_default(); + let mut hooks = object_at(&root, "hooks")?; + for (event, group) in events { + let mut list = without_ours_in_groups(&list_at(&hooks, &event)?, agent); + list.push(group); + hooks.insert(event, Value::Array(list)); + } + root.insert("hooks".into(), Value::Object(hooks)); + Ok(root) +} + +/// `root` without any Coucou group for `agent`; an event left empty goes, and +/// so does `hooks` when nothing is left in it. +fn groups_uninstall(root: &Value, agent: &str) -> Result { + let mut root = root.as_object().cloned().unwrap_or_default(); + if !root.contains_key("hooks") { + return Ok(Value::Object(root)); + } + let hooks = object_at(&root, "hooks")?; + let mut out = Map::new(); + for (event, value) in hooks { + match value.as_array() { + Some(list) => { + let kept = without_ours_in_groups(list, agent); + if !kept.is_empty() { + out.insert(event, Value::Array(kept)); + } + } + None => { + out.insert(event, value); + } + } + } + if out.is_empty() { + root.remove("hooks"); + } else { + root.insert("hooks".into(), Value::Object(out)); + } + Ok(Value::Object(root)) +} + +fn groups_have_ours(root: &Value, agent: &str) -> bool { + let ours = |group: &Value| { + is_our_command(group.get("command"), agent) + || group + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|inner| inner.iter().any(|h| is_our_command(h.get("command"), agent))) + }; + root.get("hooks") + .and_then(Value::as_object) + .is_some_and(|hooks| hooks.values().filter_map(Value::as_array).flatten().any(ours)) +} + +// ── Gemini CLI — ~/.gemini/settings.json ────────────────────────────────────── +// +// Claude-style groups with a `*` matcher and timeouts in milliseconds. The +// event name the relay should report goes on the command line. On Windows, +// Gemini CLI runs hooks with `powershell -Command`. AfterModel is left out: it +// fires on every response chunk and would flood the island. + +const GEMINI_EVENTS: &[(&str, &str, u64)] = &[ + ("SessionStart", "SessionStart", 10000), + ("SessionEnd", "SessionEnd", 10000), + ("BeforeTool", "PreToolUse", 5000), + ("AfterTool", "PostToolUse", 5000), + ("BeforeAgent", "UserPromptSubmit", 5000), + ("AfterAgent", "Stop", 5000), +]; + +fn gemini_install(root: &Value, commands: &[(String, String, u64)]) -> Result { + let events = commands.iter().map(|(event, command, timeout)| { + ( + event.clone(), + json!({ "matcher": "*", "hooks": [{ "type": "command", "command": command, "timeout": timeout }] }), + ) + }); + groups_install(root, "gemini", events).map(Value::Object) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config_file::tests::scratch; + + fn linux() -> Relay { + Relay { exe: "/home/me/.local/share/coucou/bin/coucou-hook".into(), windows: false } + } + + fn windows(exe: &str) -> Relay { + Relay { exe: exe.into(), windows: true } + } + + const WIN: &str = r"C:\Users\me\AppData\Local\Coucou\bin\coucou-hook.exe"; + const WIN_SPACE: &str = r"C:\Users\Jane O'Neil\AppData\Local\Coucou\bin\coucou-hook.exe"; + + /// Installs then uninstalls `agent` in a fresh home holding `existing` in + /// its first file, and returns (installed file, uninstalled file). + fn round_trip(agent: Agent, existing: Option<&str>) -> (PathBuf, Value, Option) { + let home = scratch(&format!("agent-{}", agent.id())); + let file = agent.files(&home)[0].clone(); + if let Some(text) = existing { + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, text).unwrap(); + } + let relay = linux(); + let plan = config_file::preview(&agent.edits(&home, &relay, true)).unwrap(); + assert!(plan.diff.contains(MARKER), "{}", plan.diff); + apply_in(agent, &home, &relay, true, &plan.fingerprint).unwrap(); + assert!(agent.installed(&home), "{agent:?} should read as installed"); + let installed = read_json(&file); + + let plan = config_file::preview(&agent.edits(&home, &relay, false)).unwrap(); + apply_in(agent, &home, &relay, false, &plan.fingerprint).unwrap(); + assert!(!agent.installed(&home), "{agent:?} should read as removed"); + let removed = file.exists().then(|| read_json(&file)); + (home, installed, removed) + } + + fn read_json(path: &Path) -> Value { + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() + } + + #[test] + fn the_command_is_quoted_for_the_shell_that_runs_it() { + assert_eq!( + linux().command(Shell::Cmd, "--agent codex"), + "'/home/me/.local/share/coucou/bin/coucou-hook' --agent codex" + ); + let w = windows(WIN); + assert_eq!( + w.command(Shell::Sh, "Stop"), + "\"C:/Users/me/AppData/Local/Coucou/bin/coucou-hook.exe\" Stop" + ); + assert_eq!( + w.command(Shell::PowerShell, "--agent gemini Stop"), + format!("& '{WIN}' --agent gemini Stop") + ); + // A plain path is left bare, so cmd and PowerShell both run it. + assert_eq!(w.command(Shell::Cmd, "--agent cursor"), format!("{WIN} --agent cursor")); + + let spaced = windows(WIN_SPACE); + assert_eq!(spaced.command(Shell::Cmd, "x"), format!("\"{WIN_SPACE}\" x")); + assert_eq!( + spaced.command(Shell::PowerShell, "x"), + r"& 'C:\Users\Jane O''Neil\AppData\Local\Coucou\bin\coucou-hook.exe' x" + ); + } + + #[test] + fn the_hook_path_is_one_shell_word_whatever_it_contains() { + assert_eq!(sh_quote("/home/a b/x"), "'/home/a b/x'"); + assert_eq!(sh_quote(r#"/h/$(id)`x`\"y"#), r#"'/h/$(id)`x`\"y'"#); + assert_eq!(sh_quote("/h/it's"), r"'/h/it'\''s'"); + } + + #[test] + fn every_agent_id_is_a_valid_pill_name() { + for agent in Agent::ALL { + let id = agent.id(); + assert!(id.len() <= 24 && id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')); + assert_eq!(Agent::from_id(id), Some(*agent)); + } + assert!(find("claude").is_err()); + } + + #[test] + fn gemini_keeps_foreign_hooks_and_removes_only_its_own() { + let existing = r#"{"theme":"dark","hooks":{"BeforeTool":[{"matcher":"other","hooks":[{"type":"command","command":"custom.exe"}]}]}}"#; + let (home, installed, removed) = round_trip(Agent::Gemini, Some(existing)); + assert_eq!(installed["theme"], "dark"); + let before_tool = installed["hooks"]["BeforeTool"].as_array().unwrap(); + assert_eq!(before_tool.len(), 2); + assert_eq!(before_tool[0]["matcher"], "other"); + assert_eq!(before_tool[1]["matcher"], "*"); + assert_eq!(before_tool[1]["hooks"][0]["timeout"], 5000); + let cmd = before_tool[1]["hooks"][0]["command"].as_str().unwrap(); + assert!(cmd.ends_with("--agent gemini PreToolUse"), "{cmd}"); + assert!(installed["hooks"]["AfterModel"].is_null()); + assert_eq!(removed.unwrap(), serde_json::from_str::(existing).unwrap()); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn installing_twice_leaves_one_entry_per_event() { + let once = gemini_install(&json!({}), &[("BeforeTool".into(), "'x/coucou-hook' --agent gemini PreToolUse".into(), 5000)]).unwrap(); + let twice = gemini_install(&once, &[("BeforeTool".into(), "'y/coucou-hook' --agent gemini PreToolUse".into(), 5000)]).unwrap(); + assert_eq!(twice["hooks"]["BeforeTool"].as_array().unwrap().len(), 1); + } + + #[test] + fn unexpected_types_are_refused_for_every_json_agent() { + for agent in Agent::ALL { + let home = scratch(&format!("odd-{}", agent.id())); + let file = agent.files(&home)[0].clone(); + if file.extension().is_some_and(|e| e == "json") { + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + for odd in [r#"{"hooks":"nope"}"#, r#"{"hooks":[1]}"#, "[1,2]", "{ broken"] { + std::fs::write(&file, odd).unwrap(); + let edits = agent.edits(&home, &linux(), true); + assert!(config_file::preview(&edits).is_err(), "{agent:?} accepted {odd}"); + assert_eq!(std::fs::read_to_string(&file).unwrap(), odd); + } + } + let _ = std::fs::remove_dir_all(home); + } + } + + #[test] + fn another_tools_entry_for_an_event_with_the_wrong_shape_is_refused() { + let odd = json!({ "hooks": { "BeforeTool": { "matcher": "x" } } }); + assert!(gemini_install(&odd, &[("BeforeTool".into(), "c".into(), 1)]).is_err()); + } +} diff --git a/windows/src-tauri/src/config_file.rs b/windows/src-tauri/src/config_file.rs new file mode 100644 index 000000000..d6ca769e5 --- /dev/null +++ b/windows/src-tauri/src/config_file.rs @@ -0,0 +1,628 @@ +// The one careful way Coucou changes a file that belongs to somebody else: +// Claude Code's settings.json, every other agent's hook config, and the plugin +// files a few agents load. +// +// The rules (CLAUDE.md) are applied here once, for everyone: +// * read strictly — a file we cannot read or parse is an error, never "empty"; +// * the change is computed from the exact bytes the user was shown, identified +// by a fingerprint, and refused if the file moved since; +// * an edit refuses values of an unexpected type instead of replacing them; +// * a dated backup of the current bytes is taken first and must succeed; +// * the new contents are written beside the target and renamed over it, +// keeping its permissions, and through a symlink to the file it points at. +// +// Nothing here decides *what* to change: callers hand in an `Edit` per file. + +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde_json::{json, Value}; + +/// What an edit wants a file to become. +pub struct Change { + /// The current contents as the diff shows them. + pub before: String, + /// The new contents, or `None` to remove the file. + pub after: Option, +} + +/// Computes the change for one file from its current bytes (`None`: no file). +pub type Edit<'a> = Box) -> Result + 'a>; + +/// One file and what to do with it. +pub struct FileEdit<'a> { + pub path: PathBuf, + pub edit: Edit<'a>, +} + +/// What the user looks at before anything is written. +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Plan { + pub diff: String, + /// Where each existing file will be copied first, one per line; empty when + /// there is nothing to back up. + pub backup: String, + /// The file (or files, one per line) that change. + pub path: String, + /// Identifies the exact bytes the diff was computed from; handed back to + /// `apply` so only what the user actually looked at is ever written. + pub fingerprint: String, +} + +// ── Reading ─────────────────────────────────────────────────────────────────── + +/// The bytes of `path`, `None` when there is no such file. Anything else — a +/// lock, a permission problem, a directory in its place — is an error: not +/// knowing what is in a file is not the same as it being empty. +pub fn read(path: &Path) -> Result>, String> { + match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) => Err(format!("Can't read {}: {err}", path.display())), + } +} + +/// A JSON config as an object. A missing, empty or whitespace-only file is an +/// empty object; a UTF-8 BOM (PowerShell 5, Notepad) is stripped. Anything +/// else that is not a JSON object is refused. +pub fn parse_json(bytes: Option<&[u8]>, label: &str) -> Result { + let Some(bytes) = bytes else { return Ok(json!({})) }; + let text = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + if text.iter().all(u8::is_ascii_whitespace) { + return Ok(json!({})); + } + match serde_json::from_slice::(text) { + Ok(v) if v.is_object() => Ok(v), + Ok(_) => Err(format!("{label} isn't a JSON object — Coucou won't touch it.")), + Err(err) => Err(format!( + "{label} isn't valid JSON ({err}). Fix or move it, then try again — Coucou won't overwrite it." + )), + } +} + +pub fn pretty(v: &Value) -> String { + serde_json::to_string_pretty(v).unwrap_or_default() +} + +/// An `Edit` for a JSON file: `change` gets the parsed object and returns the +/// new one, or `None` to remove the file. The diff compares both pretty-printed. +pub fn json_edit<'a>( + label: String, + change: impl Fn(&Value) -> Result, String> + 'a, +) -> Edit<'a> { + Box::new(move |bytes| { + let current = parse_json(bytes, &label)?; + let after = change(¤t)?.map(|next| { + let mut text = pretty(&next); + text.push('\n'); + text + }); + Ok(Change { before: pretty(¤t), after }) + }) +} + +/// An `Edit` for a text file Coucou generates whole (a plugin): `change` gets +/// the current text and returns the new one, or `None` to remove the file. +pub fn text_edit<'a>( + label: String, + change: impl Fn(Option<&str>) -> Result, String> + 'a, +) -> Edit<'a> { + Box::new(move |bytes| { + let current = match bytes { + None => None, + Some(b) => Some( + std::str::from_utf8(b) + .map_err(|_| format!("{label} isn't UTF-8 text — Coucou won't touch it."))?, + ), + }; + let after = change(current)?; + Ok(Change { before: current.unwrap_or_default().to_string(), after }) + }) +} + +// ── Preview and apply ───────────────────────────────────────────────────────── + +/// Identifies the exact bytes a preview was computed from. FNV-1a is plenty: +/// the question is only "is this still the file I showed the user?". +pub 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}") +} + +/// A missing file and an empty one are told apart: creating a file someone +/// else just created must be refused too. +fn fingerprint_of(bytes: Option<&[u8]>) -> String { + match bytes { + Some(b) => fingerprint(b), + None => "none".into(), + } +} + +/// The diff, backups and fingerprint for a set of edits. Nothing is written. +pub fn preview(edits: &[FileEdit]) -> Result { + let mut diffs = Vec::new(); + let mut backups = Vec::new(); + let mut paths = Vec::new(); + let mut prints = Vec::new(); + for file in edits { + let current = read(&file.path)?; + let change = (file.edit)(current.as_deref())?; + let after = change.after.clone().unwrap_or_default(); + let mut diff = unified_diff(&change.before, after.trim_end_matches('\n')); + if change.after.is_none() && current.is_some() { + diff = format!("The file is removed.\n{diff}"); + } + if edits.len() > 1 { + diff = format!("── {}\n{diff}", file.path.display()); + } + diffs.push(diff); + if current.is_some() { + backups.push(backup_name(&file.path, &stamp(), 0).display().to_string()); + } + paths.push(file.path.display().to_string()); + prints.push(fingerprint_of(current.as_deref())); + } + Ok(Plan { + diff: diffs.join("\n"), + backup: backups.join("\n"), + path: paths.join("\n"), + fingerprint: prints.join(":"), + }) +} + +/// Applies the edits the user confirmed. Returns the backups taken. +/// +/// Every file is read, checked against `expected` and edited before anything +/// is touched; then every existing file is backed up; only then is anything +/// written. A file that changed since the preview — another tool, another +/// window, the user's own editor — stops everything: the only thing worse than +/// not installing is silently reverting somebody else's edit. +pub fn apply(edits: &[FileEdit], expected: &str) -> Result, String> { + let wanted: Vec<&str> = expected.split(':').collect(); + if wanted.len() != edits.len() { + return Err("The preview is out of date. Nothing was written — review the new diff.".into()); + } + let mut planned = Vec::new(); + for (file, want) in edits.iter().zip(wanted) { + let current = read(&file.path)?; + if fingerprint_of(current.as_deref()) != want { + return Err(format!( + "{} changed since the preview. Nothing was written — review the new diff.", + file.path.display() + )); + } + let change = (file.edit)(current.as_deref())?; + planned.push((file, current, change.after)); + } + + let mut backups = Vec::new(); + for (file, current, _) in &planned { + if let Some(bytes) = current { + let copy = back_up(&file.path, bytes) + .map_err(|e| format!("Backup of {} failed, nothing was written: {e}", file.path.display()))?; + backups.push(copy); + } + } + + for (file, current, after) in planned { + match after { + Some(text) => replace(&file.path, text.as_bytes()) + .map_err(|e| format!("Write to {} failed: {e}", file.path.display()))?, + None if current.is_some() => std::fs::remove_file(&file.path) + .map_err(|e| format!("Could not remove {}: {e}", file.path.display()))?, + None => {} + } + } + Ok(backups) +} + +// ── Writing ─────────────────────────────────────────────────────────────────── + +/// Down to the second: installing then uninstalling in the same minute must not +/// quietly overwrite the first backup. +fn 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 + ) +} + +/// `settings.json.bak-20260102-030405`, then `-1`, `-2`… while that is taken. +fn backup_name(path: &Path, stamp: &str, n: u32) -> PathBuf { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + match n { + 0 => path.with_file_name(format!("{name}.bak-{stamp}")), + n => path.with_file_name(format!("{name}.bak-{stamp}-{n}")), + } +} + +/// Copies `bytes` — the very bytes the change was computed from — beside +/// `path`. A backup that already exists is never written over, and one that +/// could not be written whole is removed rather than left looking faithful. +fn back_up(path: &Path, bytes: &[u8]) -> std::io::Result { + let stamp = stamp(); + for n in 0..100 { + let copy = backup_name(path, &stamp, n); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + match options.open(©) { + Ok(mut file) => { + let written = write_whole(&mut file, bytes).and_then(|()| keep_mode(&file, path)); + drop(file); + if let Err(err) = written { + let _ = std::fs::remove_file(©); + return Err(err); + } + return Ok(copy); + } + Err(err) if err.kind() == ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(std::io::Error::new(ErrorKind::AlreadyExists, "too many backups this second")) +} + +fn write_whole(file: &mut std::fs::File, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + file.write_all(bytes)?; + file.sync_all() +} + +/// Gives `file` the permissions of `original` (ours only when there is none). +/// A config can hold API keys: a rewrite or a backup must never widen access. +fn keep_mode(file: &std::fs::File, original: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(original) + .map(|m| m.permissions().mode() & 0o777) + .unwrap_or(0o600); + file.set_permissions(std::fs::Permissions::from_mode(mode))?; + } + #[cfg(not(unix))] + let _ = (file, original); + Ok(()) +} + +/// Writes `bytes` to `temp`, which is about to replace `original`: created +/// readable by us only, then given the original's permissions. +pub fn write_like(temp: &Path, original: &Path, bytes: &[u8]) -> std::io::Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let mut file = options.open(temp)?; + write_whole(&mut file, bytes)?; + keep_mode(&file, original) +} + +/// Replaces `path` with `bytes`: written beside it and renamed over it, so a +/// crash or a full disk leaves the original intact rather than half a file. +fn replace(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + // A dotfiles setup often makes a config a symlink: write to the file it + // points at, so the link survives the rename. + #[cfg(unix)] + let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + #[cfg(unix)] + let path = resolved.as_path(); + + let name = path.file_name().unwrap_or_default().to_string_lossy(); + let temp = path.with_file_name(format!("{name}.coucou-{}", std::process::id())); + let result = write_like(&temp, path, bytes).and_then(|()| std::fs::rename(&temp, path)); + if result.is_err() { + let _ = std::fs::remove_file(&temp); + } + result +} + +// ── Minimal unified diff (LCS) ──────────────────────────────────────────────── + +/// Config files are short, so a plain O(n·m) LCS is the simplest honest diff. +pub fn unified_diff(before: &str, after: &str) -> String { + let a: Vec<&str> = before.lines().collect(); + let b: Vec<&str> = after.lines().collect(); + let (n, m) = (a.len(), b.len()); + + let mut lcs = vec![vec![0usize; m + 1]; n + 1]; + for i in (0..n).rev() { + for j in (0..m).rev() { + lcs[i][j] = if a[i] == b[j] { + lcs[i + 1][j + 1] + 1 + } else { + lcs[i + 1][j].max(lcs[i][j + 1]) + }; + } + } + + let mut out: Vec = Vec::new(); + let (mut i, mut j) = (0usize, 0usize); + while i < n && j < m { + if a[i] == b[j] { + out.push(format!(" {}", a[i])); + i += 1; + j += 1; + } else if lcs[i + 1][j] >= lcs[i][j + 1] { + out.push(format!("- {}", a[i])); + i += 1; + } else { + out.push(format!("+ {}", b[j])); + j += 1; + } + } + while i < n { + out.push(format!("- {}", a[i])); + i += 1; + } + while j < m { + out.push(format!("+ {}", b[j])); + j += 1; + } + + // Keep three lines of context around each change so the panel stays readable. + let changed: Vec = out + .iter() + .enumerate() + .filter(|(_, l)| l.starts_with('+') || l.starts_with('-')) + .map(|(i, _)| i) + .collect(); + if changed.is_empty() { + return "No change.".into(); + } + let mut keep = vec![false; out.len()]; + for idx in changed { + let lo = idx.saturating_sub(3); + let hi = (idx + 4).min(out.len()); + for k in lo..hi { + keep[k] = true; + } + } + let mut result = String::new(); + let mut gap = false; + for (idx, line) in out.iter().enumerate() { + if keep[idx] { + result.push_str(line); + result.push('\n'); + gap = false; + } else if !gap { + result.push_str(" …\n"); + gap = true; + } + } + result +} + +#[cfg(test)] +pub mod tests { + use super::*; + + /// A fresh directory of our own. + pub fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("coucou-cfg-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn names(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) + .collect(); + names.sort(); + names + } + + /// Adds `"ours": true` and refuses a `hooks` that is not an object. + fn add_ours(path: &Path) -> Vec> { + vec![FileEdit { + path: path.to_path_buf(), + edit: json_edit("test.json".into(), |v| { + if v.get("hooks").is_some_and(|h| !h.is_object()) { + return Err("\"hooks\" has an unexpected type — Coucou has not touched it.".into()); + } + let mut next = v.clone(); + next["ours"] = json!(true); + Ok(Some(next)) + }), + }] + } + + fn remove_file_edit(path: &Path) -> Vec> { + vec![FileEdit { path: path.to_path_buf(), edit: json_edit("test.json".into(), |_| Ok(None)) }] + } + + #[test] + fn unreadable_content_is_an_error_never_an_empty_object() { + for bad in [&b"{ not json"[..], &b"[1,2,3]"[..], &b"\"a string\""[..]] { + assert!(parse_json(Some(bad), "x").is_err()); + } + assert_eq!(parse_json(None, "x").unwrap(), json!({})); + assert_eq!(parse_json(Some(b" \n\t"), "x").unwrap(), json!({})); + let mut bom = vec![0xEF, 0xBB, 0xBF]; + bom.extend_from_slice(br#"{"a":1}"#); + assert_eq!(parse_json(Some(&bom), "x").unwrap()["a"], 1); + } + + #[test] + fn a_fingerprint_notices_any_change_and_tells_missing_from_empty() { + assert_eq!(fingerprint(b"{}"), fingerprint(b"{}")); + assert_ne!(fingerprint(b"{}"), fingerprint(b"{ }")); + assert_ne!(fingerprint_of(None), fingerprint_of(Some(b""))); + } + + #[test] + fn the_backup_holds_the_original_bytes_and_the_rest_is_kept() { + let dir = scratch("backup"); + let path = dir.join("settings.json"); + let mut original = vec![0xEF, 0xBB, 0xBF]; + original.extend_from_slice(br#"{"model":"opus","keep":{"x":[1,2]}}"#); + std::fs::write(&path, &original).unwrap(); + + let plan = preview(&add_ours(&path)).unwrap(); + assert!(plan.diff.contains("\"ours\": true"), "{}", plan.diff); + assert!(plan.backup.contains("settings.json.bak-")); + let backups = apply(&add_ours(&path), &plan.fingerprint).unwrap(); + assert_eq!(backups.len(), 1); + assert_eq!(std::fs::read(&backups[0]).unwrap(), original); + + let after: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(after["model"], "opus"); + assert_eq!(after["keep"]["x"], json!([1, 2])); + assert_eq!(after["ours"], true); + // Nothing but the file and its backup: no temporary left behind. + assert_eq!(names(&dir).len(), 2); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn two_backups_in_the_same_second_never_overwrite_each_other() { + let dir = scratch("twice"); + let path = dir.join("hooks.json"); + std::fs::write(&path, b"{}").unwrap(); + let first = apply(&add_ours(&path), &fingerprint(b"{}")).unwrap(); + let now = std::fs::read(&path).unwrap(); + let second = apply(&add_ours(&path), &fingerprint(&now)).unwrap(); + assert_ne!(first, second); + assert_eq!(std::fs::read(&first[0]).unwrap(), b"{}"); + assert_eq!(std::fs::read(&second[0]).unwrap(), now); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_backup_that_cannot_be_taken_stops_everything() { + let dir = scratch("nobackup"); + // A name the file system accepts, but too long for ".bak-" on top. + let path = dir.join(format!("{}.json", "s".repeat(240))); + std::fs::write(&path, b"{}").unwrap(); + let err = apply(&add_ours(&path), &fingerprint(b"{}")).unwrap_err(); + assert!(err.contains("Backup"), "{err}"); + assert_eq!(std::fs::read(&path).unwrap(), b"{}"); + assert_eq!(names(&dir).len(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_file_that_changed_since_the_preview_is_refused_and_left_alone() { + let dir = scratch("changed"); + let path = dir.join("settings.json"); + std::fs::write(&path, b"{}").unwrap(); + let plan = preview(&add_ours(&path)).unwrap(); + std::fs::write(&path, br#"{"someone":"else"}"#).unwrap(); + let err = apply(&add_ours(&path), &plan.fingerprint).unwrap_err(); + assert!(err.contains("changed since the preview"), "{err}"); + assert_eq!(std::fs::read(&path).unwrap(), br#"{"someone":"else"}"#); + + // A file created since a preview of "no file" is refused too. + let fresh = dir.join("new.json"); + let plan = preview(&add_ours(&fresh)).unwrap(); + std::fs::write(&fresh, b"").unwrap(); + assert!(apply(&add_ours(&fresh), &plan.fingerprint).is_err()); + assert_eq!(std::fs::read(&fresh).unwrap(), b""); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unexpected_types_and_invalid_json_are_refused_before_anything_is_written() { + let dir = scratch("types"); + let path = dir.join("settings.json"); + for bad in [&br#"{"hooks":"a string"}"#[..], &b"{ broken"[..]] { + std::fs::write(&path, bad).unwrap(); + assert!(preview(&add_ours(&path)).is_err()); + assert!(apply(&add_ours(&path), &fingerprint(bad)).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), bad); + } + assert_eq!(names(&dir), ["settings.json"]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_new_file_is_created_with_its_folder_and_nothing_is_backed_up() { + let dir = scratch("create"); + let path = dir.join("deep").join("er").join("hooks.json"); + let plan = preview(&add_ours(&path)).unwrap(); + assert_eq!(plan.backup, ""); + assert!(apply(&add_ours(&path), &plan.fingerprint).unwrap().is_empty()); + let after: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(after, json!({ "ours": true })); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn removing_a_file_backs_it_up_first() { + let dir = scratch("remove"); + let path = dir.join("coucou.json"); + std::fs::write(&path, b"{\"a\":1}").unwrap(); + let plan = preview(&remove_file_edit(&path)).unwrap(); + assert!(plan.diff.starts_with("The file is removed.")); + let backups = apply(&remove_file_edit(&path), &plan.fingerprint).unwrap(); + assert!(!path.exists()); + assert_eq!(std::fs::read(&backups[0]).unwrap(), b"{\"a\":1}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn several_files_are_checked_together() { + let dir = scratch("many"); + let (a, b) = (dir.join("a.json"), dir.join("b.json")); + std::fs::write(&a, b"{}").unwrap(); + let edits = |a: &Path, b: &Path| { + let mut v = add_ours(a); + v.extend(add_ours(b)); + v + }; + let plan = preview(&edits(&a, &b)).unwrap(); + // The second one appears in between: neither is written. + std::fs::write(&b, b"{\"x\":1}").unwrap(); + assert!(apply(&edits(&a, &b), &plan.fingerprint).is_err()); + assert_eq!(std::fs::read(&a).unwrap(), b"{}"); + let plan = preview(&edits(&a, &b)).unwrap(); + apply(&edits(&a, &b), &plan.fingerprint).unwrap(); + for p in [&a, &b] { + let v: Value = serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap(); + assert_eq!(v["ours"], true); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn rewriting_keeps_permissions_and_symlinks() { + use std::os::unix::fs::PermissionsExt; + let dir = scratch("perm"); + let real = dir.join("dotfiles-settings.json"); + let link = dir.join("settings.json"); + std::fs::write(&real, b"{}").unwrap(); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o640)).unwrap(); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + let backups = apply(&add_ours(&link), &fingerprint(b"{}")).unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + let v: Value = serde_json::from_slice(&std::fs::read(&real).unwrap()).unwrap(); + assert_eq!(v["ours"], true); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&real), 0o640); + assert_eq!(mode(&backups[0]), 0o640); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_text_file_that_is_not_utf8_is_refused() { + let edit = text_edit("plugin.js".into(), |_| Ok(Some("x".into()))); + assert!(edit(Some(&[0xff, 0xfe, 0x00])).is_err()); + let change = edit(None).unwrap(); + assert_eq!(change.before, ""); + assert_eq!(change.after.as_deref(), Some("x")); + } +} diff --git a/windows/src-tauri/src/hooks.rs b/windows/src-tauri/src/hooks.rs index 03708ed9e..1b44561ff 100644 --- a/windows/src-tauri/src/hooks.rs +++ b/windows/src-tauri/src/hooks.rs @@ -8,12 +8,17 @@ // The command is only the quoted exe path in forward slashes plus the event name: // on Windows Claude Code runs hook commands through Git Bash, and anything with // PowerShell or cmd in it breaks. +// +// Reading, the diff, the backup and the write itself live in config_file.rs, +// shared with every other agent's installer (agents.rs). use std::path::{Path, PathBuf}; use serde::Serialize; use serde_json::{json, Map, Value}; use tauri::{AppHandle, Manager}; +use crate::agents::{self, Shell}; +use crate::config_file::{self, FileEdit}; use crate::{platform, settings}; /// Every event the island reacts to, with the hook timeout written to settings.json. @@ -60,68 +65,20 @@ pub fn settings_path() -> PathBuf { platform::home_dir().join(".claude").join("settings.json") } -/// Reads `~/.claude/settings.json`. -/// -/// The only error that means "start from nothing" is the file not being there. -/// Everything else — a lock held by another process, a permission problem, JSON -/// we cannot parse — is reported, because the alternative is treating somebody's -/// unreadable settings as an empty object and then writing that back over them. -fn read_settings() -> Result { - let path = settings_path(); - match std::fs::read(&path) { - Ok(bytes) => parse_settings(&bytes, &path.display().to_string()), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), - // A lock, a permission problem, a bad drive: all of them mean we do not - // know what is in there, and not knowing is not the same as empty. - Err(err) => Err(format!("Can't read {}: {err}", path.display())), - } -} - -/// The parsing half of `read_settings`, split out so it can be tested without a -/// home directory. -fn parse_settings(bytes: &[u8], path: &str) -> Result { - // PowerShell writes a UTF-8 BOM with `Set-Content -Encoding utf8`, and - // serde_json refuses it. Stripping it is safe and well defined; guessing at - // anything else is not. - let text = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); - if text.iter().all(u8::is_ascii_whitespace) { - return Ok(json!({})); - } - match serde_json::from_slice::(text) { - Ok(v) if v.is_object() => Ok(v), - Ok(_) => Err(format!("{path} isn't a JSON object — Coucou won't touch it.")), - Err(err) => Err(format!( - "{path} isn't valid JSON ({err}). Fix or move it, then try again — Coucou won't overwrite it." - )), - } -} - /// The settings as they are, or an empty object when we cannot tell. Only for /// read-only paths like `status()`, which must never fail loudly; anything that -/// writes uses `read_settings()` and surfaces the error instead. +/// writes goes through `config_file`, which surfaces the error instead. fn read_settings_lossy() -> Value { - read_settings().unwrap_or_else(|_| json!({})) + config_file::read(&settings_path()) + .ok() + .and_then(|bytes| config_file::parse_json(bytes.as_deref(), "settings.json").ok()) + .unwrap_or_else(|| json!({})) } -#[cfg(windows)] -fn hook_command(event: &str) -> String { - let exe = settings::hook_exe_path().to_string_lossy().replace('\\', "/"); - format!("\"{exe}\" {event}") -} - -/// Claude Code runs the command through `sh`, which still reads `$`, `` ` `` -/// and `\` inside double quotes. Single quotes keep the path a path, whatever -/// the home directory is called. -#[cfg(unix)] +/// On Windows Claude Code runs hook commands through Git Bash; on Linux through +/// `sh`. Either way the relay path is one quoted shell word. fn hook_command(event: &str) -> String { - format!("{} {event}", sh_quote(&settings::hook_exe_path().to_string_lossy())) -} - -/// `s` as one single-quoted shell word: `'` becomes `'\''`, nothing else is -/// special inside single quotes. -#[cfg(unix)] -fn sh_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', r"'\''")) + agents::relay_command(Shell::Sh, event) } fn entry_is_ours(entry: &Value) -> bool { @@ -139,21 +96,23 @@ fn entry_is_ours(entry: &Value) -> bool { .unwrap_or(false) } -/// Settings with Coucou's hooks added; everything else is left untouched. -fn merged(existing: &Value) -> Value { +/// Settings with Coucou's hooks added; everything else is left untouched. A +/// `hooks` (or one of its events) that is not what Claude Code documents is +/// refused rather than replaced. +fn merged(existing: &Value) -> Result { let mut root = existing.as_object().cloned().unwrap_or_default(); - let mut hooks = root - .get("hooks") - .and_then(Value::as_object) - .cloned() - .unwrap_or_else(Map::new); + let mut hooks = match root.get("hooks") { + None => Map::new(), + Some(Value::Object(h)) => h.clone(), + Some(_) => return Err(unexpected("\"hooks\"")), + }; for (event, timeout) in HOOK_EVENTS { - let mut list = hooks - .get(*event) - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); + let mut list = match hooks.get(*event) { + None => Vec::new(), + Some(Value::Array(list)) => list.clone(), + Some(_) => return Err(unexpected(&format!("\"hooks\".\"{event}\""))), + }; list.retain(|entry| !entry_is_ours(entry)); list.push(json!({ "hooks": [{ @@ -166,14 +125,20 @@ fn merged(existing: &Value) -> Value { } root.insert("hooks".into(), Value::Object(hooks)); - Value::Object(root) + Ok(Value::Object(root)) +} + +fn unexpected(what: &str) -> String { + format!("settings.json: {what} has an unexpected type — Coucou has not touched it.") } /// Settings with every Coucou entry removed, and nothing else changed. -fn without_ours(existing: &Value) -> Value { +fn without_ours(existing: &Value) -> Result { let mut root = existing.as_object().cloned().unwrap_or_default(); - let Some(hooks) = root.get("hooks").and_then(Value::as_object).cloned() else { - return Value::Object(root); + let hooks = match root.get("hooks") { + None => return Ok(Value::Object(root)), + Some(Value::Object(h)) => h.clone(), + Some(_) => return Err(unexpected("\"hooks\"")), }; let mut out = Map::new(); for (event, value) in hooks { @@ -195,299 +160,19 @@ fn without_ours(existing: &Value) -> Value { } else { root.insert("hooks".into(), Value::Object(out)); } - Value::Object(root) -} - -fn pretty(v: &Value) -> String { - serde_json::to_string_pretty(v).unwrap_or_default() -} - -/// Down to the second: installing then uninstalling in the same minute must not -/// quietly overwrite the first backup. -fn stamp() -> String { - let t = 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 { - let p = settings_path(); - p.with_file_name(format!("settings.json.bak-{}", stamp())) -} - -/// Identifies the exact bytes a preview was computed from. FNV-1a is plenty: -/// the question is only "is this still the file I showed the user?". -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_fingerprint() -> String { - match std::fs::read(settings_path()) { - Ok(bytes) => fingerprint(&bytes), - Err(_) => fingerprint(b""), - } -} - -// ── Public API ──────────────────────────────────────────────────────────────── - -pub fn gemini_settings_path() -> PathBuf { - platform::home_dir().join(".gemini").join("settings.json") -} - -const GEMINI_EVENTS: &[(&str, &str, u64)] = &[ - ("SessionStart", "SessionStart", 10000), - ("SessionEnd", "SessionEnd", 10000), - ("BeforeTool", "PreToolUse", 5000), - ("AfterTool", "PostToolUse", 5000), - ("BeforeAgent", "UserPromptSubmit", 5000), - ("AfterAgent", "Stop", 5000), -]; - -#[cfg(windows)] -fn gemini_hook_command(event: &str) -> String { - let exe = settings::hook_exe_path().to_string_lossy().replace('\\', "/"); - format!("\"{exe}\" --agent gemini {event}") -} - -#[cfg(unix)] -fn gemini_hook_command(event: &str) -> String { - format!( - "{} --agent gemini {event}", - sh_quote(&settings::hook_exe_path().to_string_lossy()) - ) -} - -fn read_gemini_settings() -> Result { - let path = gemini_settings_path(); - match std::fs::read(&path) { - Ok(bytes) => parse_settings(&bytes, &path.display().to_string()), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), - Err(err) => Err(format!("Can't read {}: {err}", path.display())), - } -} - -fn read_gemini_settings_lossy() -> Value { - read_gemini_settings().unwrap_or_else(|_| json!({})) -} - -fn gemini_entry_is_ours(entry: &Value) -> bool { - if let Some(cmd) = entry.get("command").and_then(Value::as_str) { - if cmd.contains(MARKER) && cmd.contains("--agent gemini") { - return true; - } - } - if let Some(inner) = entry.get("hooks").and_then(Value::as_array) { - if inner.iter().any(|h| { - h.get("command") - .and_then(Value::as_str) - .map(|c| c.contains(MARKER) && c.contains("--agent gemini")) - .unwrap_or(false) - }) { - return true; - } - } - false -} - -fn remove_gemini_nb_hook_entries(groups: &[Value]) -> Vec { - let mut out = Vec::new(); - for group in groups { - if let Some(cmd) = group.get("command").and_then(Value::as_str) { - if cmd.contains(MARKER) && cmd.contains("--agent gemini") { - continue; - } - } - if let Some(inner) = group.get("hooks").and_then(Value::as_array) { - let filtered: Vec = inner - .iter() - .filter(|h| { - !h.get("command") - .and_then(Value::as_str) - .map(|c| c.contains(MARKER) && c.contains("--agent gemini")) - .unwrap_or(false) - }) - .cloned() - .collect(); - if !filtered.is_empty() { - let mut updated = group.as_object().cloned().unwrap_or_default(); - updated.insert("hooks".into(), Value::Array(filtered)); - out.push(Value::Object(updated)); - } - } else { - out.push(group.clone()); - } - } - out -} - -fn merged_gemini(existing: &Value) -> Result { - let mut root = existing.as_object().cloned().unwrap_or_default(); - if let Some(raw) = root.get("hooks") { - if !raw.is_object() { - return Err("~/.gemini/settings.json: \"hooks\" has an unexpected type — Coucou won't touch it.".into()); - } - } - let mut hooks = root - .get("hooks") - .and_then(Value::as_object) - .cloned() - .unwrap_or_else(Map::new); - - for (gemini_event, normalized_event, timeout) in GEMINI_EVENTS { - if let Some(raw) = hooks.get(*gemini_event) { - if !raw.is_array() { - return Err(format!( - "~/.gemini/settings.json: \"hooks\"[\"{gemini_event}\"] has an unexpected type — Coucou won't touch it." - )); - } - } - let list = hooks - .get(*gemini_event) - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let mut cleaned = remove_gemini_nb_hook_entries(&list); - cleaned.push(json!({ - "matcher": "*", - "hooks": [{ - "type": "command", - "command": gemini_hook_command(normalized_event), - "timeout": timeout, - }] - })); - hooks.insert((*gemini_event).to_string(), Value::Array(cleaned)); - } - - root.insert("hooks".into(), Value::Object(hooks)); - Ok(Value::Object(root)) -} - -fn without_gemini_ours(existing: &Value) -> Result { - let mut root = existing.as_object().cloned().unwrap_or_default(); - let Some(raw_hooks) = root.get("hooks") else { - return Ok(Value::Object(root)); - }; - let Some(hooks) = raw_hooks.as_object() else { - return Err("~/.gemini/settings.json: \"hooks\" has an unexpected type — Coucou won't touch it.".into()); - }; - let mut out = Map::new(); - for (event, value) in hooks { - match value.as_array() { - Some(list) => { - let kept = remove_gemini_nb_hook_entries(list); - if !kept.is_empty() { - out.insert(event.clone(), Value::Array(kept)); - } - } - None => { - out.insert(event.clone(), value.clone()); - } - } - } - if out.is_empty() { - root.remove("hooks"); - } else { - root.insert("hooks".into(), Value::Object(out)); - } Ok(Value::Object(root)) } -fn gemini_backup_path() -> PathBuf { - let p = gemini_settings_path(); - p.with_file_name(format!("settings.json.bak-{}", stamp())) -} - -fn gemini_current_fingerprint() -> String { - match std::fs::read(gemini_settings_path()) { - Ok(bytes) => fingerprint(&bytes), - Err(_) => fingerprint(b""), - } -} - -pub fn gemini_status() -> HookStatus { - let current = read_gemini_settings_lossy(); - let installed = current - .get("hooks") - .and_then(Value::as_object) - .map(|hooks| { - hooks - .values() - .filter_map(Value::as_array) - .flatten() - .any(gemini_entry_is_ours) - }) - .unwrap_or(false); - let hook_path = settings::hook_exe_path(); - HookStatus { - installed, - settings_path: gemini_settings_path().to_string_lossy().to_string(), - hook_ready: hook_path.exists(), - hook_path: hook_path.to_string_lossy().to_string(), - } +fn edits(install: bool) -> Vec> { + vec![FileEdit { + path: settings_path(), + edit: config_file::json_edit("settings.json".into(), move |current| { + if install { merged(current).map(Some) } else { without_ours(current).map(Some) } + }), + }] } -pub fn gemini_preview(install: bool) -> Result { - let current = read_gemini_settings()?; - let next = if install { - merged_gemini(¤t)? - } else { - without_gemini_ours(¤t)? - }; - Ok(HookPreview { - diff: unified_diff(&pretty(¤t), &pretty(&next)), - backup: gemini_backup_path().to_string_lossy().to_string(), - settings_path: gemini_settings_path().to_string_lossy().to_string(), - fingerprint: gemini_current_fingerprint(), - }) -} - -pub fn gemini_write(install: bool, fingerprint: &str) -> Result { - let path = gemini_settings_path(); - let dir = path.parent().unwrap_or(Path::new(".")); - std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; - - let current = read_gemini_settings()?; - if gemini_current_fingerprint() != fingerprint { - return Err(format!( - "{} changed since the preview. Nothing was written — review the new diff.", - path.display() - )); - } - - let backup = gemini_backup_path(); - if path.exists() { - std::fs::copy(&path, &backup).map_err(|e| format!("backup failed: {e}"))?; - } - - let next = if install { - merged_gemini(¤t)? - } else { - without_gemini_ours(¤t)? - }; - let mut text = pretty(&next); - text.push('\n'); - - #[cfg(unix)] - let path = std::fs::canonicalize(&path).unwrap_or(path); - - let temp = path.with_extension(format!("json.coucou-{}", std::process::id())); - if let Err(err) = write_like(&temp, &path, text.as_bytes()) { - let _ = std::fs::remove_file(&temp); - return Err(format!("write failed: {err}")); - } - if let Err(err) = std::fs::rename(&temp, &path) { - let _ = std::fs::remove_file(&temp); - return Err(format!("write failed: {err}")); - } - Ok(backup.to_string_lossy().to_string()) -} +// ── Public API ──────────────────────────────────────────────────────────────── pub fn status() -> HookStatus { let current = read_settings_lossy(); @@ -512,89 +197,23 @@ pub fn status() -> HookStatus { } pub fn preview(install: bool) -> Result { - let current = read_settings()?; - let next = if install { merged(¤t) } else { without_ours(¤t) }; + let plan = config_file::preview(&edits(install))?; Ok(HookPreview { - diff: unified_diff(&pretty(¤t), &pretty(&next)), - backup: backup_path().to_string_lossy().to_string(), - settings_path: settings_path().to_string_lossy().to_string(), - fingerprint: current_fingerprint(), + diff: plan.diff, + backup: plan.backup, + settings_path: plan.path, + fingerprint: plan.fingerprint, }) } -/// Writes the merged (or cleaned) settings after taking a dated backup. +/// Writes the merged (or cleaned) settings after taking a dated backup, and +/// returns where the backup went ("" when there was no file to back up). /// -/// `fingerprint` is the one the preview was computed from. If the file changed -/// in between — another tool, another window, the user's own editor — we stop -/// and make them look at a fresh diff, because the only thing worse than not -/// installing the hooks is silently reverting somebody else's edit. +/// `fingerprint` is the one the preview was computed from: a settings.json that +/// changed in between is refused rather than overwritten (see config_file). pub fn write(install: bool, fingerprint: &str) -> Result { - let path = settings_path(); - let dir = path.parent().unwrap_or(Path::new(".")); - std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; - - // Read before the backup: an unreadable file must abort before we touch - // anything at all. - let current = read_settings()?; - if current_fingerprint() != fingerprint { - return Err(format!( - "{} changed since the preview. Nothing was written — review the new diff.", - path.display() - )); - } - - let backup = backup_path(); - if path.exists() { - std::fs::copy(&path, &backup).map_err(|e| format!("backup failed: {e}"))?; - } - - let next = if install { merged(¤t) } else { without_ours(¤t) }; - let mut text = pretty(&next); - text.push('\n'); - - // A dotfiles setup often makes settings.json a symlink: write to the file it - // points at, so the link survives the rename below. - #[cfg(unix)] - let path = std::fs::canonicalize(&path).unwrap_or(path); - - // Write beside the target and rename over it: a crash or a full disk leaves - // the original settings.json intact rather than half a file. - let temp = path.with_extension(format!("json.coucou-{}", std::process::id())); - if let Err(err) = write_like(&temp, &path, text.as_bytes()) { - let _ = std::fs::remove_file(&temp); - return Err(format!("write failed: {err}")); - } - if let Err(err) = std::fs::rename(&temp, &path) { - let _ = std::fs::remove_file(&temp); - return Err(format!("write failed: {err}")); - } - Ok(backup.to_string_lossy().to_string()) -} - -/// Writes `bytes` to `temp`, which is about to replace `original`. -/// -/// On Linux a fresh file would get the umask's 0644, and settings.json can hold -/// API keys in its `env` block: the new file is created readable by us only, -/// then given the original's permissions, so the rename never widens them. -fn write_like(temp: &Path, original: &Path, bytes: &[u8]) -> std::io::Result<()> { - use std::io::Write; - let mut options = std::fs::OpenOptions::new(); - options.write(true).create(true).truncate(true); - #[cfg(unix)] - std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); - let mut file = options.open(temp)?; - file.write_all(bytes)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(original) - .map(|m| m.permissions().mode() & 0o777) - .unwrap_or(0o600); - file.set_permissions(std::fs::Permissions::from_mode(mode))?; - } - #[cfg(not(unix))] - let _ = original; - Ok(()) + let backups = config_file::apply(&edits(install), fingerprint)?; + Ok(backups.first().map(|p| p.to_string_lossy().to_string()).unwrap_or_default()) } /// Copies the relay (coucou-hook.exe / coucou-hook) into the local data dir's @@ -681,117 +300,10 @@ fn install_relay(src: &Path, dest: &Path) { } } -// ── Minimal unified diff (LCS) ──────────────────────────────────────────────── - -/// settings.json is short, so a plain O(n·m) LCS is the simplest honest diff. -fn unified_diff(before: &str, after: &str) -> String { - let a: Vec<&str> = before.lines().collect(); - let b: Vec<&str> = after.lines().collect(); - let (n, m) = (a.len(), b.len()); - - let mut lcs = vec![vec![0usize; m + 1]; n + 1]; - for i in (0..n).rev() { - for j in (0..m).rev() { - lcs[i][j] = if a[i] == b[j] { - lcs[i + 1][j + 1] + 1 - } else { - lcs[i + 1][j].max(lcs[i][j + 1]) - }; - } - } - - let mut out: Vec = Vec::new(); - let (mut i, mut j) = (0usize, 0usize); - while i < n && j < m { - if a[i] == b[j] { - out.push(format!(" {}", a[i])); - i += 1; - j += 1; - } else if lcs[i + 1][j] >= lcs[i][j + 1] { - out.push(format!("- {}", a[i])); - i += 1; - } else { - out.push(format!("+ {}", b[j])); - j += 1; - } - } - while i < n { - out.push(format!("- {}", a[i])); - i += 1; - } - while j < m { - out.push(format!("+ {}", b[j])); - j += 1; - } - - // Keep three lines of context around each change so the panel stays readable. - let changed: Vec = out - .iter() - .enumerate() - .filter(|(_, l)| l.starts_with('+') || l.starts_with('-')) - .map(|(i, _)| i) - .collect(); - if changed.is_empty() { - return "No change.".into(); - } - let mut keep = vec![false; out.len()]; - for idx in changed { - let lo = idx.saturating_sub(3); - let hi = (idx + 4).min(out.len()); - for k in lo..hi { - keep[k] = true; - } - } - let mut result = String::new(); - let mut gap = false; - for (idx, line) in out.iter().enumerate() { - if keep[idx] { - result.push_str(line); - result.push('\n'); - gap = false; - } else if !gap { - result.push_str(" …\n"); - gap = true; - } - } - result -} - #[cfg(test)] mod tests { use super::*; - const WHERE: &str = "settings.json"; - - #[test] - fn a_utf8_bom_is_stripped_not_treated_as_corruption() { - // PowerShell 5's `Set-Content -Encoding utf8` produces exactly this. - let mut bytes = vec![0xEF, 0xBB, 0xBF]; - bytes.extend_from_slice(br#"{"model":"opus","hooks":{}}"#); - let parsed = parse_settings(&bytes, WHERE).expect("a BOM must not defeat the parser"); - assert_eq!(parsed["model"], "opus"); - } - - #[test] - fn unreadable_content_is_an_error_never_an_empty_object() { - // This is the whole bug: returning {} here meant `merged()` produced a - // file containing nothing but Coucou's hooks, and the write replaced - // everything the user had. - for bad in [&b"{ not json"[..], &b"[1,2,3]"[..], &b"\"a string\""[..]] { - assert!( - parse_settings(bad, WHERE).is_err(), - "content we cannot use must refuse, not come back empty" - ); - } - } - - #[test] - fn empty_and_whitespace_files_start_from_nothing() { - assert_eq!(parse_settings(b"", WHERE).unwrap(), json!({})); - assert_eq!(parse_settings(b" - ", WHERE).unwrap(), json!({})); - } - #[test] fn merging_keeps_every_other_setting_and_every_foreign_hook() { let existing = serde_json::json!({ @@ -808,7 +320,7 @@ mod tests { } }); - let after = merged(&existing); + let after = merged(&existing).unwrap(); assert_eq!(after["model"], "claude-opus-5"); assert_eq!(after["theme"], "dark"); assert_eq!(after["enabledPlugins"], serde_json::json!(["a", "b"])); @@ -821,78 +333,27 @@ mod tests { assert!(pre.iter().any(entry_is_ours), "our own hook was not added"); assert!(after["hooks"]["SomeEventWeDoNotTouch"].is_array()); - // And removing ours puts it back exactly as it was. - let cleaned = without_ours(&after); - assert_eq!(cleaned, existing); - } - - #[test] - fn merging_gemini_creates_groups_and_preserves_others() { - let existing = serde_json::json!({ - "theme": "dark", - "hooks": { - "BeforeTool": [ - { "matcher": "other", "hooks": [{ "type": "command", "command": "custom.exe" }] } - ] - } - }); - - let after = merged_gemini(&existing).unwrap(); - assert_eq!(after["theme"], "dark"); - let before_tool = after["hooks"]["BeforeTool"].as_array().unwrap(); - assert_eq!(before_tool.len(), 2); - assert!(before_tool[0]["matcher"] == "other"); - assert!(gemini_entry_is_ours(&before_tool[1])); + // Installing twice adds nothing more. + assert_eq!(merged(&after).unwrap(), after); - let cleaned = without_gemini_ours(&after).unwrap(); + // And removing ours puts it back exactly as it was. + let cleaned = without_ours(&after).unwrap(); assert_eq!(cleaned, existing); } #[test] - fn a_fingerprint_notices_any_change() { - assert_eq!(fingerprint(b"{}"), fingerprint(b"{}")); - assert_ne!(fingerprint(b"{}"), fingerprint(b"{ }")); - assert_ne!(fingerprint(b""), fingerprint(b"{}")); - } - - #[cfg(unix)] - #[test] - fn the_hook_path_is_one_shell_word_whatever_it_contains() { - assert_eq!(sh_quote("/home/a b/x"), "'/home/a b/x'"); - // $, backticks, backslashes and double quotes stay literal in single quotes. - assert_eq!(sh_quote(r#"/h/$(id)`x`\"y"#), r#"'/h/$(id)`x`\"y'"#); - // A single quote closes, escapes and reopens. - assert_eq!(sh_quote("/h/it's"), r"'/h/it'\''s'"); - } - - /// settings.json can carry API keys in its `env` block: rewriting it must - /// never make it readable by more people than before. - #[cfg(unix)] - #[test] - fn rewriting_settings_never_widens_its_permissions() { - use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join(format!("coucou-perm-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let original = dir.join("settings.json"); - let temp = dir.join("settings.json.new"); - let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; - - for wanted in [0o600, 0o640, 0o644] { - std::fs::write(&original, b"{}").unwrap(); - std::fs::set_permissions(&original, std::fs::Permissions::from_mode(wanted)).unwrap(); - let _ = std::fs::remove_file(&temp); - write_like(&temp, &original, b"{\"a\":1}").unwrap(); - assert_eq!(mode(&temp), wanted, "the rewrite must keep {wanted:o}"); + fn values_of_an_unexpected_type_are_refused_not_replaced() { + for odd in [ + json!({ "hooks": "a string" }), + json!({ "hooks": [1, 2] }), + json!({ "hooks": { "PreToolUse": { "not": "a list" } } }), + ] { + assert!(merged(&odd).is_err(), "{odd}"); } - - // No original: ours only. - std::fs::remove_file(&original).unwrap(); - let _ = std::fs::remove_file(&temp); - write_like(&temp, &original, b"{}").unwrap(); - assert_eq!(mode(&temp), 0o600); - - let _ = std::fs::remove_dir_all(&dir); + assert!(without_ours(&json!({ "hooks": 3 })).is_err()); + // An event we do not install stays as it is, whatever its shape. + let kept = json!({ "hooks": { "Custom": "anything" } }); + assert_eq!(without_ours(&kept).unwrap(), kept); } /// Everything filesystem-shaped lives in one test on purpose: it points diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index d0fb31bd7..06ef66023 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -1,6 +1,8 @@ // Coucou for Windows — app wiring and the commands the island calls. +mod agents; mod claude; +mod config_file; mod files; mod hooks; mod integrations; @@ -49,9 +51,8 @@ pub struct BootInfo { #[tauri::command] fn boot(app: AppHandle, shared: State) -> BootInfo { let mut settings = shared.settings.lock().unwrap().clone(); - // The real state of ~/.claude/settings.json and ~/.gemini/settings.json wins over whatever we stored. + // The real state of ~/.claude/settings.json wins over whatever we stored. settings.hooks_installed = hooks::status().installed; - settings.gemini_hooks_installed = hooks::gemini_status().installed; let screen = island::screen_info(&app, &settings.screen); BootInfo { settings, @@ -223,34 +224,27 @@ fn hooks_apply( Ok(backup) } -// ── Gemini CLI hooks ────────────────────────────────────────────────────────── +// ── Other agents' hooks and plugins ────────────────────────────────────────── #[tauri::command] -fn gemini_hooks_status() -> HookStatus { - hooks::gemini_status() +fn agent_hooks_list() -> Vec { + agents::list() } +/// The diff the user has to look at before anything is written. #[tauri::command] -fn gemini_hooks_preview(install: bool) -> Result { - hooks::gemini_preview(install) +fn agent_hooks_preview(agent: String, install: bool) -> Result { + agents::preview(&agent, install) } +/// Only ever called from an explicit click in the settings window, with the +/// fingerprint of the preview the user looked at. #[tauri::command] -fn gemini_hooks_apply( - app: AppHandle, - shared: State, - install: bool, - fingerprint: String, -) -> Result { - let backup = hooks::gemini_write(install, &fingerprint)?; - let updated = { - let mut current = shared.settings.lock().unwrap(); - current.gemini_hooks_installed = install; - let _ = settings::save(¤t); - current.clone() - }; - let _ = app.emit("settings-changed", updated); - Ok(backup) +fn agent_hooks_apply(agent: String, install: bool, fingerprint: String) -> Result { + let backups = agents::apply(&agent, install, &fingerprint)?; + let done = if install { "installed" } else { "removed" }; + log::line(format!("agent hooks {done} for {agent}")); + Ok(backups) } #[tauri::command] @@ -440,9 +434,9 @@ pub fn run() { hooks_status, hooks_preview, hooks_apply, - gemini_hooks_status, - gemini_hooks_preview, - gemini_hooks_apply, + agent_hooks_list, + agent_hooks_preview, + agent_hooks_apply, approval_decision, approval_answer, approval_ack, diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 3a4e4fe28..17f63e1c1 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -21,8 +21,6 @@ pub struct Settings { pub screen: String, pub autostart: bool, pub hooks_installed: bool, - #[serde(default)] - pub gemini_hooks_installed: bool, /// Claude model used by the chat. Changeable in the settings window. pub model: String, } @@ -47,7 +45,6 @@ impl Default for Settings { screen: "primary".into(), autostart: false, hooks_installed: false, - gemini_hooks_installed: false, model: default_model(), } } diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index b9c6b5181..6ce020534 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -76,12 +76,17 @@ export const Bridge = { hooksApply: (install: boolean, fingerprint: string) => callOrThrow("hooks_apply", { install, fingerprint }), - // ── Gemini CLI hooks ─────────────────────────────────────────────────────── - geminiHooksStatus: () => call("gemini_hooks_status"), - geminiHooksPreview: (install: boolean) => - callOrThrow("gemini_hooks_preview", { install }), - geminiHooksApply: (install: boolean, fingerprint: string) => - callOrThrow("gemini_hooks_apply", { install, fingerprint }), + // ── Other agents (Gemini CLI, Codex, Cursor…) ───────────────────────────── + agentHooksList: () => call("agent_hooks_list"), + /** Diff to show before anything is written. `install: false` previews removal. */ + agentHooksPreview: (agent: string, install: boolean) => + callOrThrow("agent_hooks_preview", { agent, install }), + /** + * Writes the agent's config — only ever after an explicit click, and only when + * it still matches the preview the user looked at. Returns the backups taken. + */ + agentHooksApply: (agent: string, install: boolean, fingerprint: string) => + callOrThrow("agent_hooks_apply", { agent, install, fingerprint }), approvalDecision: (requestId: string, decision: "allow" | "deny") => call("approval_decision", { requestId, decision }), @@ -139,6 +144,29 @@ export interface HookStatus { hookReady: boolean; } +/** One agent other than Claude Code, as agents.rs reports it. */ +export interface AgentHookStatus { + /** The `--agent` name; its pill is `agent_`. */ + id: string; + name: string; + installed: boolean; + /** The file (or files, one per line) Coucou writes. */ + path: string; + hookReady: boolean; + /** The island can allow or deny this agent's permission requests. */ + approvals: boolean; + /** What to do once it is written. */ + note: string; +} + +export interface AgentHookPlan { + diff: string; + /** Where each existing file is copied first, one per line; "" when none. */ + backup: string; + path: string; + fingerprint: string; +} + export interface HookPreview { diff: string; backup: string; diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index e60d5d2b4..4785897aa 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -105,7 +105,6 @@ export interface Settings { screen: string; autostart: boolean; hooksInstalled: boolean; - geminiHooksInstalled: boolean; /** Claude model used by the chat. */ model: string; } @@ -121,7 +120,6 @@ export const DEFAULT_SETTINGS: Settings = { screen: "primary", autostart: false, hooksInstalled: false, - geminiHooksInstalled: false, model: "claude-opus-5", }; diff --git a/windows/src/settings/agents.ts b/windows/src/settings/agents.ts new file mode 100644 index 000000000..82f7f198a --- /dev/null +++ b/windows/src/settings/agents.ts @@ -0,0 +1,137 @@ +// Settings → Agents: one section for every agent other than Claude Code, all +// going through the same steps — preview the exact diff, click to confirm, and +// only then does agents.rs back the file up and write it. + +import { Bridge, type AgentHookStatus } from "../core/bridge"; +import { clear, h } from "../views/dom"; +import { renderDiff, statusDot } from "./parts"; + +const TEXT = { + title: "Agents", + intro: + "Show other coding agents in the island. Coucou adds its entries to each agent's own config: you see the exact change and where the backup goes before anything is written, and uninstalling removes only what Coucou added.", + none: "Coucou could not list the agents.", + approvals: "Sessions, and Allow / Deny from the island", + displayOnly: "Sessions — approvals stay in the agent", + install: "Install…", + reinstall: "Reinstall…", + uninstall: "Uninstall…", + relayMissing: "The relay isn't installed yet. Restart Coucou.", + previewInstall: (name: string) => + `This is exactly what changes for ${name}. Nothing else is touched.`, + previewRemove: "This removes Coucou's entries only. Everything else stays.", + backup: (to: string) => (to ? `Backup → ${to}` : "No existing file — nothing to back up."), + confirmInstall: "Back up and write", + confirmRemove: "Back up and remove", + cancel: "Cancel", + back: "Back", + done: (backups: string, note: string) => + `Done.${backups ? ` Previous version saved as ${backups.split("\n").join(", ")}.` : ""} ${note}`, + failed: (err: unknown) => `Could not write: ${errorText(err)}`, +}; + +function errorText(err: unknown): string { + return String(err).replace(/^Error:\s*/, ""); +} + +export function agentsSection(list: AgentHookStatus[] | null): HTMLElement { + const blocks = h("div", { style: "display:flex;flex-direction:column;gap:14px" }); + if (!list || list.length === 0) { + blocks.append(h("div", { class: "hint", text: TEXT.none })); + } + for (const status of list ?? []) blocks.append(agentBlock(status)); + return h( + "section", + {}, + h("h2", {}, h("span", { text: TEXT.title })), + h("div", { class: "hint", text: TEXT.intro }), + blocks, + ); +} + +function agentBlock(initial: AgentHookStatus): HTMLElement { + let status = initial; + const head = h("div", { class: "row" }); + const body = h("div", { style: "display:flex;flex-direction:column;gap:10px" }); + const block = h("div", { style: "display:flex;flex-direction:column;gap:8px" }, head, body); + + async function refresh() { + const fresh = (await Bridge.agentHooksList())?.find((a) => a.id === status.id); + if (fresh) status = fresh; + draw(); + } + + function draw() { + clear(head); + clear(body); + const install = h("button", { + class: "primary", + text: status.installed ? TEXT.reinstall : TEXT.install, + onclick: () => void showPreview(true), + }); + // A hook pointing at a relay that isn't there would break the agent's hooks. + if (!status.hookReady) { + install.disabled = true; + install.title = TEXT.relayMissing; + } + head.append( + h("div", { style: "display:flex;align-items:center;gap:8px;min-width:132px" }, + statusDot(status.installed), + h("span", { style: "font-size:12.5px", text: status.name }), + ), + h("span", { class: "hint", text: status.approvals ? TEXT.approvals : TEXT.displayOnly }), + h("span", { class: "spacer" }), + install, + ); + if (status.installed) { + head.append(h("button", { + class: "danger", + text: TEXT.uninstall, + onclick: () => void showPreview(false), + })); + } + body.append(h("span", { class: "path", text: status.path })); + } + + async function showPreview(install: boolean) { + let plan; + try { + plan = await Bridge.agentHooksPreview(status.id, install); + } catch (err) { + // An unreadable or unexpected config stops here rather than being + // treated as empty and written over. + clear(body); + body.append( + h("div", { class: "notice err", text: errorText(err) }), + h("div", { class: "row" }, h("button", { text: TEXT.back, onclick: draw })), + ); + return; + } + clear(body); + body.append( + h("div", { class: "hint", text: install ? TEXT.previewInstall(status.name) : TEXT.previewRemove }), + renderDiff(plan.diff), + h("div", { class: "row" }, h("span", { class: "path", text: TEXT.backup(plan.backup) })), + ); + const confirm = h("button", { + class: install ? "primary" : "danger", + text: install ? TEXT.confirmInstall : TEXT.confirmRemove, + }); + confirm.addEventListener("click", async () => { + confirm.disabled = true; + try { + const backups = await Bridge.agentHooksApply(status.id, install, plan.fingerprint); + clear(body); + body.append(h("div", { class: "notice ok", text: TEXT.done(backups, install ? status.note : "") })); + window.setTimeout(() => void refresh(), 2600); + } catch (err) { + confirm.disabled = false; + body.append(h("div", { class: "notice err", text: TEXT.failed(err) })); + } + }); + body.append(h("div", { class: "row" }, confirm, h("button", { text: TEXT.cancel, onclick: draw }))); + } + + draw(); + return block; +} diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 7f9b06900..8009de7e1 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -6,6 +6,8 @@ import "./settings.css"; import { Bridge, onEvent, type HookStatus } from "../core/bridge"; import { DEFAULT_SETTINGS, type Settings } from "../core/state"; import { h, clear } from "../views/dom"; +import { agentsSection } from "./agents"; +import { renderDiff, statusDot } from "./parts"; /** Where secrets.rs keeps the keys on this OS. */ const KEY_STORE = navigator.userAgent.includes("Windows") @@ -33,19 +35,6 @@ function toggle(on: boolean, onChange: (v: boolean) => void): HTMLElement { return el; } -function statusDot(ok: boolean): HTMLElement { - return h("i", { class: "dot", style: `background:${ok ? "#22c55e" : "#f4505e"}` }); -} - -function renderDiff(text: string): HTMLElement { - const box = h("div", { class: "diff" }); - for (const line of text.split("\n")) { - const cls = line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : "ctx"; - box.append(h("div", { class: cls, text: line })); - } - return box; -} - // ── Claude Code section ─────────────────────────────────────────────────────── function claudeSection(status: HookStatus): HTMLElement { @@ -144,7 +133,12 @@ function claudeSection(status: HookStatus): HTMLElement { }), renderDiff(preview.diff), h("div", { class: "row" }, - h("span", { class: "path", text: `Backup → ${preview.backup}` }), + h("span", { + class: "path", + text: preview.backup + ? `Backup → ${preview.backup}` + : "No settings.json yet — nothing to back up.", + }), ), ); const confirm = h("button", { @@ -158,133 +152,10 @@ function claudeSection(status: HookStatus): HTMLElement { clear(body); body.append(h("div", { class: "notice ok", - text: `Done. Previous settings saved as ${backup}. Open a new Claude Code session to pick the hooks 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; -} - -// ── Gemini CLI section ──────────────────────────────────────────────────────── - -function geminiSection(status: HookStatus): 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: "Gemini CLI" })), - body, - ); - - const rebuild = async () => { - const fresh = await Bridge.geminiHooksStatus(); - if (fresh) Object.assign(status, fresh); - clear(body); - draw(); - const head = section.querySelector("h2")!; - clear(head); - head.append(statusDot(status.installed), h("span", { text: "Gemini CLI" })); - }; - - function draw() { - body.append( - h("div", { - class: "hint", - text: status.installed - ? "Coucou is hooked into your Gemini CLI sessions (~/.gemini/settings.json). Tool calls and steps show up in the island." - : "Install the hooks in ~/.gemini/settings.json to see your Gemini CLI sessions and tool calls in the island.", - }), - h("div", { class: "row" }, - h("label", { text: "settings.json" }), - h("span", { class: "path", text: status.settingsPath }), - ), - h("div", { class: "row" }, - h("label", { text: "Relay" }), - h("span", { class: "path", text: status.hookPath }), - statusDot(status.hookReady), - ), - ); - - if (!status.hookReady) { - 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 hooks…" : "Install hooks…", - onclick: () => showPreview(true), - }); - if (!status.hookReady) { - 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 hooks…", - onclick: () => showPreview(false), - })); - } - body.append(actions); - } - - async function showPreview(install: boolean) { - let preview; - try { - preview = await Bridge.geminiHooksPreview(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 is exactly what will change in ~/.gemini/settings.json. Your own hooks are left untouched." - : "This removes Coucou's entries only. Your own hooks are left untouched.", - }), - renderDiff(preview.diff), - h("div", { class: "row" }, - h("span", { class: "path", text: `Backup → ${preview.backup}` }), - ), - ); - 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.geminiHooksApply(install, preview.fingerprint); - clear(body); - body.append(h("div", { - class: "notice ok", - text: `Done. Previous settings saved as ${backup}. Open a new Gemini CLI session to pick the hooks up.`, + // No backup when there was no settings.json to back up. + text: backup + ? `Done. Previous settings saved as ${backup}. Open a new Claude Code session to pick the hooks up.` + : "Done. Open a new Claude Code session to pick the hooks up.", })); window.setTimeout(() => void rebuild(), 2600); } catch (err) { @@ -574,9 +445,7 @@ async function main() { const status = (await Bridge.hooksStatus()) ?? { installed: false, settingsPath: "", hookPath: "", hookReady: false, }; - const geminiStatus = (await Bridge.geminiHooksStatus()) ?? { - installed: false, settingsPath: "", hookPath: "", hookReady: false, - }; + const agents = await Bridge.agentHooksList(); const hasKey = (await Bridge.secretPresent("anthropic-api-key")) ?? false; @@ -591,7 +460,7 @@ async function main() { root.append( h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), - geminiSection(geminiStatus), + agentsSection(agents), apiSection(hasKey), integrationsSection(present), generalSection(), diff --git a/windows/src/settings/parts.ts b/windows/src/settings/parts.ts new file mode 100644 index 000000000..7704ee3e4 --- /dev/null +++ b/windows/src/settings/parts.ts @@ -0,0 +1,16 @@ +// Small pieces every settings section draws with. + +import { h } from "../views/dom"; + +export function statusDot(ok: boolean): HTMLElement { + return h("i", { class: "dot", style: `background:${ok ? "#22c55e" : "#f4505e"}` }); +} + +export function renderDiff(text: string): HTMLElement { + const box = h("div", { class: "diff" }); + for (const line of text.split("\n")) { + const cls = line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : "ctx"; + box.append(h("div", { class: cls, text: line })); + } + return box; +} diff --git a/windows/tests/hooks.test.mjs b/windows/tests/hooks.test.mjs index 93eaa2fb0..d99862fb5 100644 --- a/windows/tests/hooks.test.mjs +++ b/windows/tests/hooks.test.mjs @@ -211,7 +211,7 @@ test("a tagged agent gets its own pill next to Claude Code's", () => { assert.equal(State.tasks[0].id, CLAUDE); assert.equal(State.tasks[1].id, "agent_gemini"); const agent = task("agent_gemini"); - assert.equal(agent.name, "gemini"); + assert.equal(agent.name, "Gemini CLI"); assert.equal(agent.source, "agent"); assert.equal(agent.state, "working"); assert.deepEqual(agent.steps, ["Exécute · ls"]); From a8a1a57e405c3166a4d7d85ef49f4eae56fb3cbe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:50:06 +0200 Subject: [PATCH 49/77] Windows/Linux: tell whether each hook-driven agent is connected (Mac #183) A pill fed by hook events has no key: it is connected once the agent's config routes its events to coucou-hook. agent_hooks.rs ports the Mac's coucouHooksPresent (ClaudeHookDetection.swift) with its ten fixture cases, and finds the relay with each agent's --agent tag in the Gemini, Antigravity, Codex, Copilot and Muse config files. Read-only. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/agent_hooks.rs | 230 +++++++++++++++++++++++++++ windows/src-tauri/src/lib.rs | 8 + windows/src/core/bridge.ts | 2 + 3 files changed, 240 insertions(+) create mode 100644 windows/src-tauri/src/agent_hooks.rs diff --git a/windows/src-tauri/src/agent_hooks.rs b/windows/src-tauri/src/agent_hooks.rs new file mode 100644 index 000000000..aa64f8135 --- /dev/null +++ b/windows/src-tauri/src/agent_hooks.rs @@ -0,0 +1,230 @@ +// Whether each hook-driven pill is connected. +// +// A pill fed by hook events has no key: it is connected once the agent's config +// routes its events to coucou-hook (Mac #183 — the idle card used to say "Key +// not configured" for these). Only reads, and only the agents' own config files; +// nothing here ever writes. +// +// The checks themselves are pure functions over parsed JSON, so they are tested +// on fixtures rather than on the real files. + +use std::collections::HashMap; +use std::path::PathBuf; + +use serde_json::Value; + +use crate::platform::home_dir; + +/// Port of `coucouHooksPresent(inSettings:)` (ClaudeHookDetection.swift): true +/// when a parsed `~/.claude/settings.json` routes Claude Code's SessionStart +/// events to Coucou. The command text is what tells: Coucou's relay here is +/// `coucou-hook`, the Mac's is `~/.claude/coucou/nb-hook` (or NotchBuddy in the +/// App Store build), so a settings file shared between machines reads the same. +pub fn claude_hooks_present(settings: &Value) -> bool { + let Some(groups) = settings + .get("hooks") + .and_then(|h| h.get("SessionStart")) + .and_then(Value::as_array) + else { + return false; + }; + groups.iter().any(|group| { + group.get("hooks").and_then(Value::as_array).is_some_and(|hooks| { + hooks.iter().any(|hook| { + hook.get("command") + .and_then(Value::as_str) + .is_some_and(|c| c.contains("NotchBuddy") || c.contains("coucou")) + }) + }) + }) +} + +/// True when some string in `config` runs the relay for `agent`: it names +/// `coucou-hook` (or the Mac's `nb-hook`) and passes `--agent `. +/// +/// Every agent nests its hooks differently (Gemini and Codex under +/// `hooks.[].hooks[].command`, Copilot under `hooks.[].bash` or +/// `.powershell`, Antigravity under its own key), and the README tells people to +/// write these files by hand, so the whole document is searched rather than one +/// layout trusted. +pub fn agent_hooks_present(config: &Value, agent: &str) -> bool { + match config { + Value::String(s) => runs_relay_for(s, agent), + Value::Array(items) => items.iter().any(|v| agent_hooks_present(v, agent)), + Value::Object(map) => map.values().any(|v| agent_hooks_present(v, agent)), + _ => false, + } +} + +fn runs_relay_for(command: &str, agent: &str) -> bool { + if !(command.contains("coucou-hook") || command.contains("nb-hook")) { + return false; + } + // `--agent gemini` must not also match `--agent gemini-beta`. + let flag = format!("--agent {agent}"); + command.match_indices(&flag).any(|(at, _)| { + command[at + flag.len()..] + .chars() + .next() + .is_none_or(|c| c.is_whitespace() || c == '"' || c == '\'') + }) +} + +/// Agents configured through their own hook file, with the `--agent` tag the +/// relay is given and where that file lives. Antigravity has two places: the +/// one this README names, and the Mac's. +fn agent_configs() -> Vec<(&'static str, &'static str, Vec)> { + let home = home_dir(); + vec![ + ("agent_gemini", "gemini", vec![home.join(".gemini").join("settings.json")]), + ( + "agent_antigravity", + "antigravity", + vec![ + home.join(".config").join("antigravity").join("hooks.json"), + home.join(".gemini").join("config").join("hooks.json"), + ], + ), + ("agent_codex", "codex", vec![home.join(".codex").join("hooks.json")]), + ("agent_copilot", "copilot", vec![home.join(".copilot").join("hooks").join("coucou.json")]), + ("agent_muse", "muse", vec![home.join(".config").join("muse").join("settings.json")]), + ] +} + +/// A JSON file as a value; anything missing or unreadable is "nothing there". +fn read_json(path: &PathBuf) -> Value { + std::fs::read(path) + .ok() + .and_then(|bytes| { + let text = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(&bytes).to_vec(); + serde_json::from_slice(&text).ok() + }) + .unwrap_or(Value::Null) +} + +/// Pill ID → connected, for every hook-driven pill. +pub fn status() -> HashMap { + let claude = claude_hooks_present(&read_json(&crate::hooks::settings_path())); + let mut out = HashMap::new(); + // Cursor sessions are Claude Code in Cursor's terminal: the same hooks. + out.insert("integration_claude".to_string(), claude); + out.insert("agent_cursor".to_string(), claude); + for (pill, agent, paths) in agent_configs() { + let present = paths.iter().any(|p| agent_hooks_present(&read_json(p), agent)); + out.insert(pill.to_string(), present); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(json: &str) -> Value { + serde_json::from_str(json).unwrap_or(Value::Null) + } + + // The ten cases of tests/ClaudeHookDetectionTests.swift, plus this build's relay. + + #[test] + fn the_hook_coucou_writes_is_installed() { + assert!(claude_hooks_present(&settings( + r#"{"hooks":{"SessionStart":[{"hooks":[ + {"type":"command","command":"\"C:/Users/me/AppData/Local/Coucou/bin/coucou-hook.exe\" SessionStart"}]}]}}"# + ))); + assert!(claude_hooks_present(&settings( + r#"{"hooks":{"SessionStart":[{"hooks":[ + {"type":"command","command":"'/home/me/.local/share/coucou/bin/coucou-hook' SessionStart"}]}]}}"# + ))); + } + + #[test] + fn the_mac_builds_hooks_count_too() { + assert!(claude_hooks_present(&settings( + r#"{"hooks":{"SessionStart":[{"hooks":[ + {"type":"command","command":"$HOME/.claude/coucou/nb-hook"}]}]}}"# + ))); + assert!(claude_hooks_present(&settings( + r#"{"hooks":{"SessionStart":[{"hooks":[ + {"type":"command","command":"/Applications/NotchBuddy.app/.../nb-hook"}]}]}}"# + ))); + } + + #[test] + fn our_hook_next_to_somebody_elses_is_installed() { + assert!(claude_hooks_present(&settings( + r#"{"hooks":{"SessionStart":[ + {"hooks":[{"type":"command","command":"/usr/local/bin/other-tool"}]}, + {"hooks":[{"type":"command","command":"$HOME/.claude/coucou/nb-hook"}]}]}}"# + ))); + } + + #[test] + fn other_tools_only_is_not_installed() { + // The case that showed "Key not configured" on macOS. + assert!(!claude_hooks_present(&settings( + r#"{"hooks":{"SessionStart":[{"hooks":[ + {"type":"command","command":"$HOME/.vibe-island/bin/vibe-island-bridge"}, + {"type":"command","command":"python3 /Users/me/.claude/skills/harness/hook.py"}]}]}}"# + ))); + } + + #[test] + fn hooks_for_other_events_do_not_count() { + assert!(!claude_hooks_present(&settings( + r#"{"hooks":{"PreToolUse":[{"hooks":[ + {"type":"command","command":"$HOME/.claude/coucou/nb-hook"}]}]}}"# + ))); + } + + #[test] + fn malformed_or_empty_settings_never_read_as_installed() { + for json in [ + "{}", + r#"{"hooks":{"SessionStart":[]}}"#, + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command"}]}]}}"#, + r#"{"hooks":{"SessionStart":"not-an-array"}}"#, + r#"{"hooks":"not-an-object"}"#, + "not json", + ] { + assert!(!claude_hooks_present(&settings(json)), "{json}"); + } + } + + // Other agents: any place in their config that runs the relay with their tag. + + #[test] + fn an_agent_is_connected_when_its_config_runs_the_relay_with_its_tag() { + let gemini = settings( + r#"{"hooks":{"BeforeTool":[{"hooks":[ + {"type":"command","command":"\"C:/Users/me/AppData/Local/Coucou/bin/coucou-hook.exe\" --agent gemini BeforeTool"}]}]}}"#, + ); + assert!(agent_hooks_present(&gemini, "gemini")); + assert!(!agent_hooks_present(&gemini, "codex")); + + // Copilot keeps the command under `bash` / `powershell`. + let copilot = settings( + r#"{"version":1,"hooks":{"preToolUse":[{"type":"command", + "powershell":"& 'C:\\Users\\me\\AppData\\Local\\Coucou\\bin\\coucou-hook.exe' --agent copilot preToolUse"}]}}"#, + ); + assert!(agent_hooks_present(&copilot, "copilot")); + + // The Mac's relay, in a config synced from a Mac. + let codex = settings(r#"{"hooks":{"Stop":[{"hooks":[{"command":"~/.claude/coucou/nb-hook --agent codex"}]}]}}"#); + assert!(agent_hooks_present(&codex, "codex")); + } + + #[test] + fn a_longer_tag_or_another_program_is_not_the_agent() { + let beta = settings(r#"{"hooks":{"x":[{"command":"coucou-hook --agent gemini-beta x"}]}}"#); + assert!(!agent_hooks_present(&beta, "gemini")); + let other = settings(r#"{"hooks":{"x":[{"command":"other-relay --agent gemini x"}]}}"#); + assert!(!agent_hooks_present(&other, "gemini")); + let quoted = settings(r#"{"cmd":["coucou-hook", "--agent gemini"]}"#); + // Split over two strings: no single command runs the relay with the tag. + assert!(!agent_hooks_present("ed, "gemini")); + assert!(!agent_hooks_present(&Value::Null, "gemini")); + let at_end = settings(r#"{"c":"coucou-hook --agent muse"}"#); + assert!(agent_hooks_present(&at_end, "muse")); + } +} diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 264e4c3a8..b553ac1e1 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ // Coucou for Windows — app wiring and the commands the island calls. +mod agent_hooks; mod claude; mod files; mod hooks; @@ -193,6 +194,12 @@ fn hooks_status() -> HookStatus { hooks::status() } +/// Pill ID → whether that agent's hooks reach Coucou. Read-only. +#[tauri::command] +fn agent_hooks_status() -> std::collections::HashMap { + agent_hooks::status() +} + /// Returns the diff the user has to look at before anything is written. #[tauri::command] fn hooks_preview(install: bool) -> Result { @@ -407,6 +414,7 @@ pub fn run() { open_in_vscode, quit_app, hooks_status, + agent_hooks_status, hooks_preview, hooks_apply, approval_decision, diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index c6121c159..35c50dc8a 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -67,6 +67,8 @@ export const Bridge = { // ── Claude Code hooks ───────────────────────────────────────────────────── hooksStatus: () => call("hooks_status"), + /** Pill ID → whether that agent's hooks reach Coucou (read-only, Mac #183). */ + agentHooksStatus: () => call>("agent_hooks_status"), /** Diff to show before anything is written. `install: false` previews removal. */ hooksPreview: (install: boolean) => callOrThrow("hooks_preview", { install }), /** From ea861d5d05fd75db29bd0d141d7ae741bfd8862a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:50:52 +0200 Subject: [PATCH 50/77] Relay: every agent's events onto one set, and each agent's own reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalize.rs ports the Mac relay's full table: Gemini CLI, Antigravity, snake_case and Copilot's camelCase event names (preToolUse, sessionStart, permissionRequest…), Cursor's (beforeSubmitPrompt, a stop with status "error" becomes StopFailure), and the fields: toolName / toolArgs (object or JSON text), toolCall.name / args with Antigravity's key aliases, conversationId / sessionId / GEMINI_SESSION_ID, workdir, workspacePaths / workspace_roots. A field already present is kept. reply.rs is the only place that knows what each agent reads on stdout: hookSpecificOutput for Claude Code and Codex, a bare permissionDecision for Copilot CLI and Muse Code, "{}" for Gemini CLI, Antigravity, Copilot and Muse, silence for the rest. A decision is only ever relayed for a PermissionRequest from an agent the island can answer (Claude Code, Codex, Copilot, Muse); with no click there is never an allow, and Copilot (fail-closed) gets an explicit "ask". When stdin cannot be read the agent still gets its "no opinion" reply. Co-Authored-By: Claude Opus 5.5 --- windows/hook/src/main.rs | 493 +++++++++++----------------------- windows/hook/src/normalize.rs | 267 ++++++++++++++++++ windows/hook/src/reply.rs | 269 +++++++++++++++++++ 3 files changed, 693 insertions(+), 336 deletions(-) create mode 100644 windows/hook/src/normalize.rs create mode 100644 windows/hook/src/reply.rs diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs index 59099c3f6..e02f4803c 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -1,26 +1,35 @@ -//! coucou-hook — the relay Claude Code runs on every hook event. +//! coucou-hook — the relay Claude Code (and every other agent) runs on each hook +//! event. //! -//! Reads the hook JSON on stdin, adds a little terminal context, and hands it to +//! Reads the hook JSON on stdin, maps the agent's event and field names onto +//! Claude Code's (normalize.rs), adds a little terminal context, and hands it to //! Coucou over the named pipe `\\.\pipe\coucou-` (Windows) or the Unix //! socket `$XDG_RUNTIME_DIR/coucou.sock` (Linux). //! -//! Hard rule (docs/CLAUDE.md): **never block Claude Code.** -//! * If the pipe does not exist — Coucou is closed — we exit 0 immediately with -//! nothing on stdout, and the session carries on untouched. +//! Hard rule (docs/CLAUDE.md): **never block the agent.** +//! * If the pipe does not exist — Coucou is closed — we exit 0 immediately, with +//! only the "no opinion" reply the agent expects (reply.rs), and the session +//! carries on untouched. //! * Every step runs under a deadline enforced by the main thread, so a pipe that //! accepts the connection and then stops reading cannot wedge the session //! either: we abandon the worker and exit. //! * Only `PermissionRequest` waits for an answer, because approving from the -//! island is the whole point. No answer means empty stdout, and Claude Code -//! asks in the terminal exactly as if Coucou were not installed. +//! island is the whole point. No answer means no decision, and the agent asks +//! in its terminal exactly as if Coucou were not installed. //! -//! Usage: `coucou-hook ` (the name is also read from the JSON). +//! Usage: `coucou-hook [--agent ] []` (the event name is also +//! read from the JSON; `--agent` is absent for Claude Code). use std::io::{Read, Write}; use std::sync::mpsc; use std::time::Duration; -/// Budget for getting a pipe connection. Beyond this Claude Code wins, always. +use serde_json::{Map, Value}; + +mod normalize; +mod reply; + +/// Budget for getting a pipe connection. Beyond this the agent wins, always. const CONNECT_TIMEOUT: Duration = Duration::from_millis(300); /// Whole-run budget for an event nobody waits on: connect and write, no more. const FIRE_AND_FORGET_BUDGET: Duration = Duration::from_secs(2); @@ -29,7 +38,7 @@ const DECISION_BUDGET: Duration = Duration::from_secs(110); /// Fields that are pointless to forward and can be enormous (a whole file read, /// a full command output). The island never shows them. -const DROPPED_FIELDS: &[&str] = &["tool_response", "transcript_path"]; +const DROPPED_FIELDS: &[&str] = &["tool_response", "tool_output", "transcript_path"]; /// Longest string forwarded for any single field; the island truncates to far /// less than this anyway. const MAX_FIELD_LEN: usize = 2_000; @@ -44,84 +53,52 @@ mod unix; #[cfg(target_os = "linux")] use unix::connect; -/// Normalize event names across agents (Claude Code, Gemini CLI, Antigravity) -fn normalize_event(name: &str) -> String { - match name { - "BeforeTool" | "BeforeToolSelection" => "PreToolUse".to_string(), - "AfterTool" | "AfterModel" | "PostInvocation" => "PostToolUse".to_string(), - "BeforeAgent" | "PreInvocation" => "UserPromptSubmit".to_string(), - "AfterAgent" => "Stop".to_string(), - "startup" => "SessionStart".to_string(), - "exit" => "SessionEnd".to_string(), - other => other.to_string(), - } +/// What the command line says: `--agent ` and the event name. +struct Args { + agent: String, + event: String, } -/// Normalize tool fields from Gemini CLI format (`toolCall: {name, args: {...}}`) -/// into canonical `tool_name` and `tool_input`. -fn normalize_tool_fields(map: &mut serde_json::Map) { - if !map.contains_key("tool_name") { - let (extracted_name, extracted_input) = if let Some(tool) = map.get("toolCall").and_then(|t| t.as_object()) { - let name = tool.get("name").and_then(|n| n.as_str()).map(|n| n.to_string()); - let input = if let Some(args) = tool.get("args").and_then(|a| a.as_object()) { - let mut input_map = serde_json::Map::new(); - for (k, v) in args { - let target_key = match k.as_str() { - "CommandLine" => "command", - "FilePath" => "file_path", - "Path" => "path", - "Url" => "url", - "Query" => "query", - "Pattern" => "pattern", - other => other, - }; - input_map.insert(target_key.into(), v.clone()); - } - Some(input_map) - } else { - None - }; - (name, input) - } else if let Some(name) = map.get("tool").and_then(|t| t.as_str()) { - (Some(name.to_string()), None) - } else { - (None, None) - }; - - if let Some(name) = extracted_name { - map.insert("tool_name".into(), serde_json::Value::String(name)); - } - if let Some(input) = extracted_input { - if !map.contains_key("tool_input") { - map.insert("tool_input".into(), serde_json::Value::Object(input)); - } +fn args() -> Args { + let mut agent = String::new(); + let mut event = String::new(); + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + if arg == "--agent" { + agent = it.next().unwrap_or_default(); + } else if event.is_empty() { + event = arg; } } + Args { agent, event } +} - if !map.contains_key("session_id") { - let mut found_sid = None; - for key in &["conversationId", "conversation_id", "sessionId", "GEMINI_SESSION_ID"] { - if let Some(val) = map.get(*key).and_then(|v| v.as_str()) { - if !val.is_empty() { - found_sid = Some(val.to_string()); - break; - } - } - } - if let Some(sid) = found_sid { - map.insert("session_id".into(), serde_json::Value::String(sid)); - } else if let Ok(sid) = std::env::var("GEMINI_SESSION_ID") { - if !sid.is_empty() { - map.insert("session_id".into(), serde_json::Value::String(sid)); - } - } - } +/// One event, ready to forward. +struct Event { + /// The payload as one line of JSON. + line: String, + /// The canonical event name. + name: String, + /// For Claude Code's AskUserQuestion, the question as it was asked. + question: Option, } fn main() { - let Some((payload, event, question, agent)) = read_event() else { std::process::exit(0) }; + let args = args(); + let mut raw = Vec::new(); + let _ = std::io::stdin().read_to_end(&mut raw); + let env = |key: &str| std::env::var(key).ok(); + let cwd = std::env::current_dir().map(|p| p.to_string_lossy().to_string()).unwrap_or_default(); + + let Some(event) = prepare(&raw, &args, &env, &cwd) else { + // Nothing we could forward. An agent that needs JSON still gets its + // "no opinion" — Copilot is fail-closed and would deny without it. + let name = normalize::event(&args.event); + print(reply::stdout(&args.agent, name, None, None)); + std::process::exit(0); + }; - let waits_for_answer = event == "PermissionRequest"; + let waits_for_answer = event.name == "PermissionRequest"; let budget = if waits_for_answer { DECISION_BUDGET } else { FIRE_AND_FORGET_BUDGET }; // The worker owns every blocking call. If it overruns the budget we simply @@ -129,196 +106,62 @@ fn main() { // (No catch_unwind here — the release profile is panic = "abort", so it would // be dead code. `talk` is written to have nothing to panic on instead.) let (tx, rx) = mpsc::channel::>(); + let line = event.line.clone(); std::thread::spawn(move || { - let _ = tx.send(talk(&payload, waits_for_answer)); + let _ = tx.send(talk(&line, waits_for_answer)); }); - if let Ok(Some(decision)) = rx.recv_timeout(budget) { - if let Some(json) = decision_json(&decision, question.as_ref()) { - let mut out = std::io::stdout(); - let _ = writeln!(out, "{json}"); - let _ = out.flush(); - } - } else if !waits_for_answer && (agent == "gemini" || agent == "antigravity") { - // Gemini CLI and Antigravity expect a JSON response on stdout ({}) - let mut out = std::io::stdout(); - let _ = writeln!(out, "{{}}"); - let _ = out.flush(); - } - // Nothing printed: Claude Code asks in the terminal, as if we were not here. + let decision = rx.recv_timeout(budget).ok().flatten(); + print(reply::stdout(&args.agent, &event.name, decision.as_deref(), event.question.as_ref())); std::process::exit(0); } -/// The documented PermissionRequest output. Anything we do not recognise prints -/// nothing at all rather than guessing — silence is the safe answer. -/// See https://code.claude.com/docs/en/hooks -/// -/// `question` is the AskUserQuestion input, when that is what is being asked: -/// the island may then answer it, which Claude Code takes as the same input -/// with an `answers` map added. Nothing else of the input can be changed from -/// the island, and no other tool's input can be changed at all. -fn decision_json(decision: &str, question: Option<&serde_json::Value>) -> Option { - if decision.trim_start().starts_with('{') { - let reply = serde_json::from_str::(decision).ok()?; - let answers = reply.get("answers")?.as_object()?; - let question = question?; - if !answers_fit(question, answers) { - return None; - } - let mut input = question.as_object()?.clone(); - input.insert("answers".into(), serde_json::Value::Object(answers.clone())); - return Some( - serde_json::json!({ - "hookSpecificOutput": { - "hookEventName": "PermissionRequest", - "decision": { "behavior": "allow", "updatedInput": input }, - } - }) - .to_string(), - ); +fn print(line: Option) { + if let Some(line) = line { + let mut out = std::io::stdout(); + let _ = writeln!(out, "{line}"); + let _ = out.flush(); } - let behavior = match decision.trim() { - // "always" still answers a plain allow; remembering it is the island's - // business, not Claude Code's. - "allow" | "always" => r#"{"behavior":"allow"}"#.to_string(), - "deny" => r#"{"behavior":"deny","message":"Denied from Coucou"}"#.to_string(), - _ => return None, - }; - Some(format!( - r#"{{"hookSpecificOutput":{{"hookEventName":"PermissionRequest","decision":{behavior}}}}}"# - )) } -/// True when `answers` answers exactly the questions Claude Code asked: one entry -/// per question, keyed by its text; a single-select answer is one of its option -/// labels (a string), a multi-select answer a non-empty list of distinct labels -/// (Claude Code 2.1.136+ takes the list, as the Mac sends it). Same rule as -/// `QuestionPayload.accepts` on macOS. -fn answers_fit(question: &serde_json::Value, answers: &serde_json::Map) -> bool { - let Some(items) = question.get("questions").and_then(|q| q.as_array()) else { return false }; - if items.is_empty() || items.len() != answers.len() { - return false; - } - items.iter().all(|item| { - let Some(text) = item.get("question").and_then(|q| q.as_str()) else { return false }; - let labels: Vec<&str> = item - .get("options") - .and_then(|o| o.as_array()) - .map(|opts| opts.iter().filter_map(|o| o.get("label").and_then(|l| l.as_str())).collect()) - .unwrap_or_default(); - let multi = item.get("multiSelect").and_then(|m| m.as_bool()).unwrap_or(false); - match answers.get(text) { - Some(serde_json::Value::String(pick)) if !multi => labels.contains(&pick.as_str()), - Some(serde_json::Value::Array(picks)) if multi => { - let picks: Vec<&str> = picks.iter().filter_map(|p| p.as_str()).collect(); - let mut seen = picks.clone(); - seen.sort_unstable(); - seen.dedup(); - !picks.is_empty() && seen.len() == picks.len() && picks.iter().all(|p| labels.contains(p)) - } - _ => false, - } - }) -} - -/// Reads stdin and returns the payload to forward, the event name, — for an -/// AskUserQuestion permission request — the question as Claude Code sent it, -/// and the agent the hook was installed for. -fn read_event() -> Option<(String, String, Option, String)> { - let mut raw = Vec::new(); - if std::io::stdin().read_to_end(&mut raw).is_err() || raw.is_empty() { - return None; - } +/// The payload to forward, from the raw stdin bytes. `env` reads an environment +/// variable and `cwd` is the working directory, so tests stay pure. +fn prepare(raw: &[u8], args: &Args, env: &dyn Fn(&str) -> Option, cwd: &str) -> Option { // Some shells hand us a UTF-8 BOM; serde_json would choke on it. - if raw.starts_with(&[0xEF, 0xBB, 0xBF]) { - raw.drain(..3); - } - - let mut payload = serde_json::from_slice::(&raw).ok()?; + let raw = raw.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(raw); + let mut payload = serde_json::from_slice::(raw).ok()?; let map = payload.as_object_mut()?; - // Parse argv: "coucou-hook.exe [--agent ] []" - // --agent tags the payload with coucou_agent so the app routes to the right pill. - // Absent or invalid names are validated and discarded by the app, not here. - let mut agent = String::new(); - let mut arg_event = String::new(); - { - let mut it = std::env::args().skip(1); - while let Some(arg) = it.next() { - if arg == "--agent" { - agent = it.next().unwrap_or_default(); - } else if arg_event.is_empty() { - arg_event = arg; - } - } - } - // Which agent this hook was installed for. Absent means Claude Code, - // so existing hook commands keep working unchanged. - if !agent.is_empty() { - map.insert("coucou_agent".into(), serde_json::Value::String(agent.clone())); + // Which agent this hook was installed for, so the app routes it to the right + // pill. Absent means Claude Code, so existing hook commands keep working + // unchanged; invalid names are discarded by the app, not here. + if !args.agent.is_empty() { + map.insert("coucou_agent".into(), Value::String(args.agent.clone())); } - normalize_tool_fields(map); - let raw_event = map .get("hook_event_name") - .and_then(|v| v.as_str()) - .map(str::to_string) + .and_then(Value::as_str) .filter(|s| !s.is_empty()) - .unwrap_or(arg_event); - let event = normalize_event(&raw_event); - map.insert("hook_event_name".into(), serde_json::Value::String(event.clone())); + .map(str::to_string) + .unwrap_or_else(|| args.event.clone()); + normalize::fields(map, env); + let name = normalize::refine(normalize::event(&raw_event), map); + map.insert("hook_event_name".into(), Value::String(name.clone())); for field in DROPPED_FIELDS { map.remove(*field); } - let cwd_missing = map - .get("cwd") - .and_then(|v| v.as_str()) - .map(str::is_empty) - .unwrap_or(true); - if cwd_missing { - // Also check workspacePaths or workspace_roots (Gemini CLI) - let mut found_path = None; - for key in &["workspacePaths", "workspace_roots"] { - if let Some(arr) = map.get(*key).and_then(|v| v.as_array()) { - if let Some(first) = arr.first().and_then(|v| v.as_str()) { - if !first.is_empty() { - found_path = Some(first.to_string()); - break; - } - } - } - } - if let Some(p) = found_path { - map.insert("cwd".into(), serde_json::Value::String(p)); - } else if let Ok(cwd) = std::env::current_dir() { - map.insert( - "cwd".into(), - serde_json::Value::String(cwd.to_string_lossy().to_string()), - ); - } + if map.get("cwd").and_then(Value::as_str).map(str::is_empty).unwrap_or(true) && !cwd.is_empty() { + map.insert("cwd".into(), Value::String(cwd.to_string())); } - // Which terminal the session runs in. Unlike macOS, Coucou here accepts - // events from every terminal, so this is context only — never a filter. - for (key, var) in [ - ("term_program", "TERM_PROGRAM"), - ("wt_session", "WT_SESSION"), - ("term_session_id", "TERM_SESSION_ID"), - ("vscode_pid", "VSCODE_PID"), - ("session_pid", "CLAUDE_CODE_SSE_PORT"), - ] { - if !map.contains_key(key) { - let value = std::env::var(var).unwrap_or_default(); - map.insert(key.into(), serde_json::Value::String(value)); - } - } + add_terminal_context(map, env); // Kept whole: what goes back to Claude Code must be its own input, not the // shortened copy the island is shown. - let question = (map.get("tool_name").and_then(|v| v.as_str()) == Some("AskUserQuestion")) + let question = (map.get("tool_name").and_then(Value::as_str) == Some("AskUserQuestion")) .then(|| map.get("tool_input").cloned()) .flatten(); @@ -326,13 +169,29 @@ fn read_event() -> Option<(String, String, Option, String)> { let mut line = payload.to_string(); line.push('\n'); - Some((line, event, question, agent)) + Some(Event { line, name, question }) +} + +/// Which terminal the session runs in. Unlike macOS, Coucou here accepts events +/// from every terminal, so this is context only — never a filter. +fn add_terminal_context(map: &mut Map, env: &dyn Fn(&str) -> Option) { + for (key, var) in [ + ("term_program", "TERM_PROGRAM"), + ("wt_session", "WT_SESSION"), + ("term_session_id", "TERM_SESSION_ID"), + ("vscode_pid", "VSCODE_PID"), + ("session_pid", "CLAUDE_CODE_SSE_PORT"), + ] { + if !map.contains_key(key) { + map.insert(key.into(), Value::String(env(var).unwrap_or_default())); + } + } } /// Caps every string in the payload. A single Write can carry a whole file. -fn truncate_strings(value: &mut serde_json::Value) { +fn truncate_strings(value: &mut Value) { match value { - serde_json::Value::String(s) => { + Value::String(s) => { if s.len() > MAX_FIELD_LEN { // Cut on a char boundary; a lone byte index can split UTF-8. let mut end = MAX_FIELD_LEN; @@ -343,8 +202,8 @@ fn truncate_strings(value: &mut serde_json::Value) { s.push('…'); } } - serde_json::Value::Array(items) => items.iter_mut().for_each(truncate_strings), - serde_json::Value::Object(map) => map.values_mut().for_each(truncate_strings), + Value::Array(items) => items.iter_mut().for_each(truncate_strings), + Value::Object(map) => map.values_mut().for_each(truncate_strings), _ => {} } } @@ -384,70 +243,11 @@ fn talk(payload: &str, waits_for_answer: bool) -> Option { mod tests { use super::*; - #[test] - fn decision_json_matches_the_documented_shape() { - assert_eq!( - decision_json("allow", None).unwrap(), - r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}"# - ); - assert_eq!( - decision_json("deny", None).unwrap(), - r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied from Coucou"}}}"# - ); - // "always" is an island concept; Claude Code just gets an allow. - assert!(decision_json("always", None).unwrap().contains(r#""behavior":"allow""#)); - } - - #[test] - fn anything_unrecognised_prints_nothing() { - assert!(decision_json("", None).is_none()); - assert!(decision_json("maybe", None).is_none()); - // The shape the app used to send must not be mistaken for a decision. - assert!(decision_json(r#"{"permissionDecision":"allow"}"#, None).is_none()); - } - - #[test] - fn an_answered_question_goes_back_as_the_same_input_plus_answers() { - let question = serde_json::json!({ - "questions": [{ "question": "Which one?", "options": [{ "label": "A" }, { "label": "B" }] }] - }); - let out = decision_json(r#"{"answers":{"Which one?":"B"}}"#, Some(&question)).unwrap(); - let v: serde_json::Value = serde_json::from_str(&out).unwrap(); - let decision = &v["hookSpecificOutput"]["decision"]; - assert_eq!(decision["behavior"], "allow"); - assert_eq!(decision["updatedInput"]["questions"], question["questions"]); - assert_eq!(decision["updatedInput"]["answers"]["Which one?"], "B"); - } - - #[test] - fn answers_are_only_accepted_for_a_question() { - // Any other tool: the island cannot rewrite its input. - assert!(decision_json(r#"{"answers":{"q":"a"}}"#, None).is_none()); - let question = serde_json::json!({ "questions": [] }); - assert!(decision_json(r#"{"answers":{}}"#, Some(&question)).is_none()); - assert!(decision_json(r#"{"answers":{"q":1}}"#, Some(&question)).is_none()); - assert!(decision_json(r#"{"command":"rm -rf /"}"#, Some(&question)).is_none()); - } - - #[test] - fn answers_must_match_the_questions_asked() { - let q = serde_json::json!({ "questions": [ - { "question": "Which one?", "options": [{ "label": "A" }, { "label": "B" }] }, - { "question": "Extras?", "multiSelect": true, - "options": [{ "label": "Tests" }, { "label": "Docs" }, { "label": "Lint" }] } - ]}); - let ok = |a: &str| decision_json(a, Some(&q)).is_some(); - assert!(ok(r#"{"answers":{"Which one?":"A","Extras?":["Tests","Docs"]}}"#)); - // Not one of the labels, or an answer to a question nobody asked. - assert!(!ok(r#"{"answers":{"Which one?":"C","Extras?":["Tests"]}}"#)); - assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":["Tests"],"Other?":"x"}}"#)); - // A question left out. - assert!(!ok(r#"{"answers":{"Which one?":"A"}}"#)); - // Shapes: single-select is one string, multi-select a non-empty list. - assert!(!ok(r#"{"answers":{"Which one?":["A"],"Extras?":["Tests"]}}"#)); - assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":"Tests"}}"#)); - assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":[]}}"#)); - assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":["Tests","Tests"]}}"#)); + fn run(raw: &str, agent: &str, event: &str) -> (Value, Event) { + let args = Args { agent: agent.into(), event: event.into() }; + let ev = prepare(raw.as_bytes(), &args, &|_| None, "/home/me/here").expect("forwarded"); + let v = serde_json::from_str(ev.line.trim_end()).unwrap(); + (v, ev) } #[test] @@ -460,33 +260,54 @@ mod tests { } #[test] - fn normalize_event_maps_gemini_events_to_canonical() { - assert_eq!(normalize_event("BeforeTool"), "PreToolUse"); - assert_eq!(normalize_event("AfterTool"), "PostToolUse"); - assert_eq!(normalize_event("BeforeAgent"), "UserPromptSubmit"); - assert_eq!(normalize_event("AfterAgent"), "Stop"); - assert_eq!(normalize_event("startup"), "SessionStart"); - assert_eq!(normalize_event("exit"), "SessionEnd"); - assert_eq!(normalize_event("PreToolUse"), "PreToolUse"); + fn claude_code_payloads_are_forwarded_as_they_are() { + let (v, ev) = run(r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"/p"}"#, "", "PreToolUse"); + assert_eq!(ev.name, "PreToolUse"); + assert!(v.get("coucou_agent").is_none()); + assert_eq!(v["cwd"], "/p"); + assert_eq!(v["tool_name"], "Bash"); } #[test] - fn normalize_tool_fields_extracts_gemini_tool_call() { - let mut map = serde_json::Map::new(); - map.insert( - "toolCall".into(), - serde_json::json!({ - "name": "Bash", - "args": { - "CommandLine": "ls -la" - } - }), - ); - map.insert("conversationId".into(), serde_json::json!("gemini-conv-123")); - normalize_tool_fields(&mut map); - - assert_eq!(map.get("tool_name").unwrap(), "Bash"); - assert_eq!(map.get("tool_input").unwrap()["command"], "ls -la"); - assert_eq!(map.get("session_id").unwrap(), "gemini-conv-123"); + fn an_agents_event_is_tagged_and_renamed() { + // Gemini CLI: its own name in the payload, ours on the command line. + let (v, ev) = run(r#"{"hook_event_name":"BeforeTool","toolCall":{"name":"shell","args":{"CommandLine":"ls"}}}"#, "gemini", "PreToolUse"); + assert_eq!(ev.name, "PreToolUse"); + assert_eq!(v["hook_event_name"], "PreToolUse"); + assert_eq!(v["coucou_agent"], "gemini"); + assert_eq!(v["tool_input"]["command"], "ls"); + assert_eq!(v["cwd"], "/home/me/here"); + + // Copilot CLI sends no event name: the command line's camelCase one is used. + let (_, ev) = run(r#"{"toolName":"bash"}"#, "copilot", "permissionRequest"); + assert_eq!(ev.name, "PermissionRequest"); + + // Cursor: a stop that failed, and its tool output left behind. + let (v, ev) = run(r#"{"hook_event_name":"stop","status":"error","tool_output":"huge"}"#, "cursor", ""); + assert_eq!(ev.name, "StopFailure"); + assert!(v.get("tool_output").is_none()); + } + + #[test] + fn a_question_is_kept_whole_and_only_for_ask_user_question() { + let long = "x".repeat(3000); + let raw = format!(r#"{{"hook_event_name":"PermissionRequest","tool_name":"AskUserQuestion","tool_input":{{"questions":[{{"question":"{long}"}}]}}}}"#); + let (v, ev) = run(&raw, "", ""); + assert_eq!(ev.question.unwrap()["questions"][0]["question"].as_str().unwrap().len(), 3000); + assert!(v["tool_input"]["questions"][0]["question"].as_str().unwrap().ends_with('…')); + let (_, ev) = run(r#"{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"ls"}}"#, "", ""); + assert!(ev.question.is_none()); + } + + #[test] + fn what_cannot_be_read_is_not_forwarded() { + let args = Args { agent: "copilot".into(), event: "preToolUse".into() }; + for raw in ["", "not json", "[1,2]"] { + assert!(prepare(raw.as_bytes(), &args, &|_| None, "/").is_none()); + } + // A BOM is not a reason to drop the event. + let mut bom = vec![0xEF, 0xBB, 0xBF]; + bom.extend_from_slice(br#"{"hook_event_name":"Stop"}"#); + assert!(prepare(&bom, &args, &|_| None, "/").is_some()); } } diff --git a/windows/hook/src/normalize.rs b/windows/hook/src/normalize.rs new file mode 100644 index 000000000..7d0c30525 --- /dev/null +++ b/windows/hook/src/normalize.rs @@ -0,0 +1,267 @@ +//! Every agent's hook payload, mapped onto the events and fields the island +//! knows (Claude Code's). Same table as `normalize_event` / +//! `normalize_tool_fields` in the Mac relay (HookServer.swift), plus Cursor's +//! events. + +use serde_json::{Map, Value}; + +/// The canonical name of an agent's event. Unknown names pass through. +pub fn event(name: &str) -> &str { + match name { + // Gemini CLI + "BeforeTool" | "BeforeToolSelection" => "PreToolUse", + "AfterTool" | "AfterModel" => "PostToolUse", + "BeforeAgent" => "UserPromptSubmit", + "AfterAgent" => "Stop", + "startup" => "SessionStart", + "exit" => "SessionEnd", + // Antigravity + "PreInvocation" => "UserPromptSubmit", + "PostInvocation" => "PostToolUse", + // snake_case relays + "pre_tool_use" => "PreToolUse", + "post_tool_use" => "PostToolUse", + "user_prompt_submit" => "UserPromptSubmit", + "session_start" => "SessionStart", + "session_end" => "SessionEnd", + "stop" => "Stop", + // Copilot CLI (camelCase) — Cursor shares most of these + "sessionStart" => "SessionStart", + "userPromptSubmitted" => "UserPromptSubmit", + "agentStop" => "Stop", + "notification" => "Notification", + "preToolUse" => "PreToolUse", + "postToolUse" => "PostToolUse", + "permissionRequest" => "PermissionRequest", + "sessionEnd" => "SessionEnd", + // Cursor + "beforeSubmitPrompt" => "UserPromptSubmit", + "postToolUseFailure" => "PostToolUseFailure", + "subagentStart" => "SubagentStart", + "subagentStop" => "SubagentStop", + other => other, + } +} + +/// Keys of Antigravity's `toolCall.args` the island reads under another name. +const ARG_ALIASES: &[(&str, &str)] = &[ + ("CommandLine", "command"), + ("FilePath", "file_path"), + ("Path", "path"), + ("Url", "url"), + ("Query", "query"), + ("Pattern", "pattern"), +]; + +fn non_empty_str<'a>(map: &'a Map, key: &str) -> Option<&'a str> { + map.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()) +} + +/// Fills `tool_name`, `tool_input`, `session_id` and `cwd` from wherever the +/// agent put them. A field the payload already has is never overwritten. +/// `env` reads an environment variable (a parameter so tests stay pure). +pub fn fields(map: &mut Map, env: &dyn Fn(&str) -> Option) { + let tool_call = map.get("toolCall").and_then(Value::as_object).cloned(); + + if !map.contains_key("tool_name") { + // Copilot: toolName. Gemini CLI / Antigravity: toolCall.name. Others: tool. + let name = non_empty_str(map, "toolName") + .or_else(|| tool_call.as_ref().and_then(|t| non_empty_str(t, "name"))) + .or_else(|| non_empty_str(map, "tool")) + .map(str::to_string); + if let Some(name) = name { + map.insert("tool_name".into(), Value::String(name)); + } + } + + if !map.contains_key("tool_input") { + // Copilot sends toolArgs as an object, or as the JSON text of one. + let args = match map.get("toolArgs") { + Some(Value::Object(o)) => Some(o.clone()), + Some(Value::String(s)) => serde_json::from_str::(s) + .ok() + .and_then(|v| v.as_object().cloned()), + _ => None, + }; + let input = args.or_else(|| { + let mut flat = tool_call.as_ref()?.get("args")?.as_object()?.clone(); + for (from, to) in ARG_ALIASES { + if let Some(v) = flat.get(*from).cloned() { + flat.insert((*to).to_string(), v); + } + } + Some(flat) + }); + if let Some(input) = input { + map.insert("tool_input".into(), Value::Object(input)); + } + } + + if non_empty_str(map, "session_id").is_none() { + let sid = ["conversationId", "conversation_id", "sessionId", "GEMINI_SESSION_ID"] + .iter() + .find_map(|k| non_empty_str(map, k)) + .map(str::to_string) + .or_else(|| env("GEMINI_SESSION_ID").filter(|s| !s.is_empty())); + if let Some(sid) = sid { + map.insert("session_id".into(), Value::String(sid)); + } + } + + if non_empty_str(map, "cwd").is_none() { + // Copilot: workdir. Gemini CLI / Antigravity: workspacePaths. Cursor: workspace_roots. + let cwd = non_empty_str(map, "workdir").map(str::to_string).or_else(|| { + ["workspacePaths", "workspace_roots"].iter().find_map(|k| { + map.get(*k)?.as_array()?.first()?.as_str().filter(|s| !s.is_empty()).map(str::to_string) + }) + }); + if let Some(cwd) = cwd { + map.insert("cwd".into(), Value::String(cwd)); + } + } +} + +/// The event once the payload is known: a stop that reports an error (Cursor's +/// `status: "error"`) is a StopFailure. +pub fn refine(event: &str, map: &Map) -> String { + if event == "Stop" && map.get("status").and_then(Value::as_str) == Some("error") { + return "StopFailure".into(); + } + event.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn obj(v: Value) -> Map { + v.as_object().unwrap().clone() + } + + fn no_env(_: &str) -> Option { + None + } + + #[test] + fn every_agents_event_names_map_onto_claude_codes() { + for (raw, canonical) in [ + ("BeforeTool", "PreToolUse"), + ("BeforeToolSelection", "PreToolUse"), + ("AfterTool", "PostToolUse"), + ("AfterModel", "PostToolUse"), + ("BeforeAgent", "UserPromptSubmit"), + ("AfterAgent", "Stop"), + ("startup", "SessionStart"), + ("exit", "SessionEnd"), + ("PreInvocation", "UserPromptSubmit"), + ("PostInvocation", "PostToolUse"), + ("pre_tool_use", "PreToolUse"), + ("post_tool_use", "PostToolUse"), + ("user_prompt_submit", "UserPromptSubmit"), + ("session_start", "SessionStart"), + ("session_end", "SessionEnd"), + ("stop", "Stop"), + ("sessionStart", "SessionStart"), + ("userPromptSubmitted", "UserPromptSubmit"), + ("agentStop", "Stop"), + ("notification", "Notification"), + ("preToolUse", "PreToolUse"), + ("postToolUse", "PostToolUse"), + ("permissionRequest", "PermissionRequest"), + ("sessionEnd", "SessionEnd"), + ("beforeSubmitPrompt", "UserPromptSubmit"), + ("postToolUseFailure", "PostToolUseFailure"), + ("subagentStart", "SubagentStart"), + ("subagentStop", "SubagentStop"), + ] { + assert_eq!(event(raw), canonical, "{raw}"); + } + // Claude Code's own names, and anything unknown, pass through. + for same in ["PreToolUse", "PermissionRequest", "Stop", "Interrupt", "somethingNew"] { + assert_eq!(event(same), same); + } + } + + #[test] + fn antigravity_and_gemini_tool_calls_become_tool_name_and_input() { + let mut m = obj(json!({ + "conversationId": "conv-1", + "workspacePaths": ["C:\\Projects\\test"], + "toolCall": { "name": "run_command", "args": { "CommandLine": "cargo check" } } + })); + fields(&mut m, &no_env); + assert_eq!(m["tool_name"], "run_command"); + assert_eq!(m["tool_input"]["command"], "cargo check"); + assert_eq!(m["tool_input"]["CommandLine"], "cargo check"); + assert_eq!(m["session_id"], "conv-1"); + assert_eq!(m["cwd"], "C:\\Projects\\test"); + } + + #[test] + fn copilot_tool_name_args_and_workdir_are_read() { + let mut m = obj(json!({ + "toolName": "bash", + "toolArgs": "{\"command\":\"npm test\"}", + "sessionId": "s-9", + "workdir": "/home/me/app" + })); + fields(&mut m, &no_env); + assert_eq!(m["tool_name"], "bash"); + assert_eq!(m["tool_input"]["command"], "npm test"); + assert_eq!(m["session_id"], "s-9"); + assert_eq!(m["cwd"], "/home/me/app"); + + let mut m = obj(json!({ "toolName": "edit", "toolArgs": { "path": "a.rs" } })); + fields(&mut m, &no_env); + assert_eq!(m["tool_input"]["path"], "a.rs"); + } + + #[test] + fn cursor_conversation_and_workspace_become_session_and_cwd() { + let mut m = obj(json!({ "conversation_id": "c-1", "workspace_roots": ["/w/proj"] })); + fields(&mut m, &no_env); + assert_eq!(m["session_id"], "c-1"); + assert_eq!(m["cwd"], "/w/proj"); + } + + #[test] + fn what_the_payload_already_says_is_never_overwritten() { + let mut m = obj(json!({ + "tool_name": "Bash", "tool_input": { "command": "ls" }, + "session_id": "keep", "cwd": "/keep", + "toolName": "other", "toolArgs": { "command": "rm" }, + "conversationId": "no", "workdir": "/no" + })); + fields(&mut m, &|_| Some("env".into())); + assert_eq!(m["tool_name"], "Bash"); + assert_eq!(m["tool_input"]["command"], "ls"); + assert_eq!(m["session_id"], "keep"); + assert_eq!(m["cwd"], "/keep"); + } + + #[test] + fn gemini_session_comes_from_the_environment_last() { + let mut m = obj(json!({})); + fields(&mut m, &|k| (k == "GEMINI_SESSION_ID").then(|| "g-7".to_string())); + assert_eq!(m["session_id"], "g-7"); + } + + #[test] + fn odd_shapes_are_ignored_not_trusted() { + let mut m = obj(json!({ "toolCall": "nope", "toolArgs": "not json", "workspacePaths": "x", "tool": 3 })); + fields(&mut m, &no_env); + assert!(!m.contains_key("tool_name")); + assert!(!m.contains_key("tool_input")); + assert!(!m.contains_key("cwd")); + } + + #[test] + fn a_stop_with_an_error_status_is_a_failure() { + assert_eq!(refine("Stop", &obj(json!({ "status": "error" }))), "StopFailure"); + for status in ["completed", "aborted"] { + assert_eq!(refine("Stop", &obj(json!({ "status": status }))), "Stop"); + } + assert_eq!(refine("PreToolUse", &obj(json!({ "status": "error" }))), "PreToolUse"); + } +} diff --git a/windows/hook/src/reply.rs b/windows/hook/src/reply.rs new file mode 100644 index 000000000..9da86a714 --- /dev/null +++ b/windows/hook/src/reply.rs @@ -0,0 +1,269 @@ +//! What the relay prints back to the agent — the only place that knows each +//! agent's stdout contract. Same replies as the Mac relay (HookServer.swift). +//! +//! The one rule that matters: **nothing that allows anything is ever printed +//! without a decision a human clicked.** With no decision the reply is silence, +//! an empty `{}`, or Copilot's explicit "ask", and every agent then asks in its +//! own terminal exactly as if Coucou were not installed. + +use serde_json::{json, Map, Value}; + +/// The agents whose permission requests the island can answer: Claude Code +/// (no `--agent`), Codex, Copilot CLI and Muse Code. Any decision for another +/// agent is ignored here too, whatever the app sent. +pub fn takes_decisions(agent: &str) -> bool { + matches!(agent, "" | "codex" | "copilot" | "muse") +} + +/// Agents that read a JSON object on stdout after every hook and get `{}` — +/// "no opinion" — when there is nothing to say. +fn wants_json(agent: &str) -> bool { + matches!(agent, "gemini" | "antigravity" | "copilot" | "muse") +} + +/// The line to print for `event` from `agent`, given the island's `decision` +/// (`None`: nobody clicked) and, for Claude Code's AskUserQuestion, the +/// question as it was asked. +pub fn stdout(agent: &str, event: &str, decision: Option<&str>, question: Option<&Value>) -> Option { + if event != "PermissionRequest" { + return wants_json(agent).then(|| "{}".to_string()); + } + let decision = decision.filter(|_| takes_decisions(agent)); + match agent { + // Copilot CLI and Muse Code read a bare permissionDecision. Copilot is + // fail-closed: it must always get valid JSON, so silence is an "ask". + "copilot" | "muse" => { + let word = match decision.map(str::trim) { + Some("allow" | "always") => Some("allow"), + Some("deny") => Some("deny"), + _ => None, + }; + match word { + Some(word) => Some(json!({ "permissionDecision": word }).to_string()), + None if agent == "copilot" => Some(json!({ "permissionDecision": "ask" }).to_string()), + None => None, + } + } + // Claude Code and Codex share the documented hookSpecificOutput. Only + // Claude Code asks questions. + "" => decision.and_then(|d| decision_json(d, question)), + _ => decision.and_then(|d| decision_json(d, None)), + } +} + +/// The documented PermissionRequest output. Anything we do not recognise prints +/// nothing at all rather than guessing — silence is the safe answer. +/// See https://code.claude.com/docs/en/hooks +/// +/// `question` is the AskUserQuestion input, when that is what is being asked: +/// the island may then answer it, which Claude Code takes as the same input +/// with an `answers` map added. Nothing else of the input can be changed from +/// the island, and no other tool's input can be changed at all. +pub fn decision_json(decision: &str, question: Option<&Value>) -> Option { + if decision.trim_start().starts_with('{') { + let reply = serde_json::from_str::(decision).ok()?; + let answers = reply.get("answers")?.as_object()?; + let question = question?; + if !answers_fit(question, answers) { + return None; + } + let mut input = question.as_object()?.clone(); + input.insert("answers".into(), Value::Object(answers.clone())); + return Some( + json!({ + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { "behavior": "allow", "updatedInput": input }, + } + }) + .to_string(), + ); + } + let behavior = match decision.trim() { + // "always" still answers a plain allow; remembering it is the island's + // business, not the agent's. + "allow" | "always" => r#"{"behavior":"allow"}"#.to_string(), + "deny" => r#"{"behavior":"deny","message":"Denied from Coucou"}"#.to_string(), + _ => return None, + }; + Some(format!( + r#"{{"hookSpecificOutput":{{"hookEventName":"PermissionRequest","decision":{behavior}}}}}"# + )) +} + +/// True when `answers` answers exactly the questions Claude Code asked: one entry +/// per question, keyed by its text; a single-select answer is one of its option +/// labels (a string), a multi-select answer a non-empty list of distinct labels +/// (Claude Code 2.1.136+ takes the list, as the Mac sends it). Same rule as +/// `QuestionPayload.accepts` on macOS. +fn answers_fit(question: &Value, answers: &Map) -> bool { + let Some(items) = question.get("questions").and_then(|q| q.as_array()) else { return false }; + if items.is_empty() || items.len() != answers.len() { + return false; + } + items.iter().all(|item| { + let Some(text) = item.get("question").and_then(|q| q.as_str()) else { return false }; + let labels: Vec<&str> = item + .get("options") + .and_then(|o| o.as_array()) + .map(|opts| opts.iter().filter_map(|o| o.get("label").and_then(|l| l.as_str())).collect()) + .unwrap_or_default(); + let multi = item.get("multiSelect").and_then(|m| m.as_bool()).unwrap_or(false); + match answers.get(text) { + Some(Value::String(pick)) if !multi => labels.contains(&pick.as_str()), + Some(Value::Array(picks)) if multi => { + let picks: Vec<&str> = picks.iter().filter_map(|p| p.as_str()).collect(); + let mut seen = picks.clone(); + seen.sort_unstable(); + seen.dedup(); + !picks.is_empty() && seen.len() == picks.len() && picks.iter().all(|p| labels.contains(p)) + } + _ => false, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const AGENTS: &[&str] = &[ + "", "gemini", "antigravity", "cursor", "codex", "copilot", "muse", "opencode", "amp", + "hermes", "claude-desktop", "my-tool", + ]; + const EVENTS: &[&str] = &[ + "SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", + "PermissionRequest", "Notification", "Stop", "StopFailure", "SessionEnd", "Interrupt", + "SubagentStart", "SubagentStop", "", + ]; + + /// Whatever a reply says, does it let anything through? + fn allows(out: &str) -> bool { + let v: Value = serde_json::from_str(out).expect("every reply is JSON"); + let text = v.to_string(); + text.contains("allow") || v.get("decision").is_some() || v.get("permission").is_some() || v.get("continue").is_some() + } + + #[test] + fn nothing_is_ever_allowed_without_a_decision() { + for agent in AGENTS { + for event in EVENTS { + if let Some(out) = stdout(agent, event, None, None) { + assert!(!allows(&out), "{agent:?} {event} printed {out} with nobody clicking"); + } + // A decline, a timeout or garbage from the app is no decision either. + for not_a_decision in ["", "ask", "maybe", "{\"permissionDecision\":\"allow\"}"] { + if let Some(out) = stdout(agent, event, Some(not_a_decision), None) { + assert!(!allows(&out), "{agent:?} {event} {not_a_decision:?} printed {out}"); + } + } + } + } + } + + #[test] + fn a_decision_only_counts_on_a_permission_request_from_an_agent_that_takes_one() { + for agent in AGENTS { + for event in EVENTS.iter().filter(|e| **e != "PermissionRequest") { + if let Some(out) = stdout(agent, event, Some("allow"), None) { + assert!(!allows(&out), "{agent:?} {event}: {out}"); + } + } + let out = stdout(agent, "PermissionRequest", Some("allow"), None); + assert_eq!(out.as_deref().is_some_and(allows), takes_decisions(agent), "{agent:?}: {out:?}"); + } + } + + #[test] + fn each_agent_gets_its_own_reply_shape() { + let allow = r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}"#; + let deny = r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied from Coucou"}}}"#; + for agent in ["", "codex"] { + assert_eq!(stdout(agent, "PermissionRequest", Some("allow"), None).unwrap(), allow); + assert_eq!(stdout(agent, "PermissionRequest", Some("always"), None).unwrap(), allow); + assert_eq!(stdout(agent, "PermissionRequest", Some("deny"), None).unwrap(), deny); + assert_eq!(stdout(agent, "PermissionRequest", None, None), None); + assert_eq!(stdout(agent, "PreToolUse", None, None), None); + } + for agent in ["copilot", "muse"] { + assert_eq!(stdout(agent, "PermissionRequest", Some("allow"), None).unwrap(), r#"{"permissionDecision":"allow"}"#); + assert_eq!(stdout(agent, "PermissionRequest", Some("deny"), None).unwrap(), r#"{"permissionDecision":"deny"}"#); + assert_eq!(stdout(agent, "PreToolUse", None, None).unwrap(), "{}"); + } + // Copilot is fail-closed: no decision is an explicit "ask", never silence. + assert_eq!(stdout("copilot", "PermissionRequest", None, None).unwrap(), r#"{"permissionDecision":"ask"}"#); + assert_eq!(stdout("muse", "PermissionRequest", None, None), None); + // Gemini CLI and Antigravity read "{}" as "no opinion" — Antigravity's + // PreToolUse included: the tool is never allowed on Coucou's say-so. + for agent in ["gemini", "antigravity"] { + for event in ["PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"] { + assert_eq!(stdout(agent, event, None, None).unwrap(), "{}"); + } + } + // Cursor: silence, which Cursor reads as "carry on as usual". + for event in ["PreToolUse", "UserPromptSubmit", "Stop"] { + assert_eq!(stdout("cursor", event, None, None), None); + } + // Hermes approvals are not supported: its decisions are never relayed. + assert_eq!(stdout("hermes", "PermissionRequest", Some("allow"), None), None); + } + + #[test] + fn decision_json_matches_the_documented_shape() { + assert_eq!( + decision_json("allow", None).unwrap(), + r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}"# + ); + assert!(decision_json("always", None).unwrap().contains(r#""behavior":"allow""#)); + } + + #[test] + fn anything_unrecognised_prints_nothing() { + assert!(decision_json("", None).is_none()); + assert!(decision_json("maybe", None).is_none()); + // The shape the app used to send must not be mistaken for a decision. + assert!(decision_json(r#"{"permissionDecision":"allow"}"#, None).is_none()); + } + + #[test] + fn an_answered_question_goes_back_as_the_same_input_plus_answers() { + let question = json!({ + "questions": [{ "question": "Which one?", "options": [{ "label": "A" }, { "label": "B" }] }] + }); + let out = stdout("", "PermissionRequest", Some(r#"{"answers":{"Which one?":"B"}}"#), Some(&question)).unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + let decision = &v["hookSpecificOutput"]["decision"]; + assert_eq!(decision["behavior"], "allow"); + assert_eq!(decision["updatedInput"]["questions"], question["questions"]); + assert_eq!(decision["updatedInput"]["answers"]["Which one?"], "B"); + // Only Claude Code asks questions: an answer for anyone else is nothing. + assert!(stdout("codex", "PermissionRequest", Some(r#"{"answers":{"Which one?":"B"}}"#), Some(&question)).is_none()); + } + + #[test] + fn answers_are_only_accepted_for_a_question() { + assert!(decision_json(r#"{"answers":{"q":"a"}}"#, None).is_none()); + let question = json!({ "questions": [] }); + assert!(decision_json(r#"{"answers":{}}"#, Some(&question)).is_none()); + assert!(decision_json(r#"{"answers":{"q":1}}"#, Some(&question)).is_none()); + assert!(decision_json(r#"{"command":"rm -rf /"}"#, Some(&question)).is_none()); + } + + #[test] + fn answers_must_match_the_questions_asked() { + let q = json!({ "questions": [ + { "question": "Which one?", "options": [{ "label": "A" }, { "label": "B" }] }, + { "question": "Extras?", "multiSelect": true, + "options": [{ "label": "Tests" }, { "label": "Docs" }, { "label": "Lint" }] } + ]}); + let ok = |a: &str| decision_json(a, Some(&q)).is_some(); + assert!(ok(r#"{"answers":{"Which one?":"A","Extras?":["Tests","Docs"]}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"C","Extras?":["Tests"]}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":["Tests"],"Other?":"x"}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A"}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":["A"],"Extras?":["Tests"]}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":"Tests"}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":[]}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":["Tests","Tests"]}}"#)); + } +} From a9c57d7b89ba0146f6552cbed30be5985d41135f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:51:26 +0200 Subject: [PATCH 51/77] Windows/Linux: the pill catalog, your main tool and declared pills (Mac 0.1.1, #120, #183) src/core/pills.ts ports PillCatalog.swift as the one source of truth: the Mac's IDs, names, colours, categories and subtitles in the Mac's order, plus what each pill needs here (hooks, a key, nothing) and whether this build runs it. The eight shipped Windows pills keep their IDs. OpenCode, Amp, Hermes, Ollama, LM Studio and Apple Music are left out; Google AI and OpenAI show as coming soon (the chat here only talks to Anthropic); Claude Desktop is Windows only. Settings gets an Active pills section: pick the main tool (VS Code, Cursor, Codex or Antigravity), which is always on and takes no slot, and declare agents and chat providers next to the services, four at most. A session on a pill that is neither main nor declared gets the pill for its duration. Claude Code in Cursor's terminal goes on the Cursor pill. Hook-driven pills say Hooks installed / not installed instead of asking for a key, Claude Desktop says no setup is needed, and agent sessions show their live ticker like Claude Code's. Hook status is read again each time the island opens, never polled. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/settings.rs | 6 + windows/src/core/pills.ts | 237 ++++++++++++++++++++++++++ windows/src/core/state.ts | 166 +++++++++++------- windows/src/island/hooks.ts | 42 +++-- windows/src/island/integrations.ts | 53 +++--- windows/src/island/island.ts | 4 + windows/src/main.ts | 3 + windows/src/settings/main.ts | 110 +++++++++++- windows/src/settings/settings.css | 40 +++++ windows/src/views/integrations.ts | 51 ++++-- windows/src/views/views.ts | 21 ++- windows/tests/hooks.test.mjs | 33 ++++ windows/tests/pills.test.mjs | 260 +++++++++++++++++++++++++++++ 13 files changed, 913 insertions(+), 113 deletions(-) create mode 100644 windows/src/core/pills.ts create mode 100644 windows/tests/pills.test.mjs diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 17f63e1c1..05a09cf47 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -17,6 +17,8 @@ pub struct Settings { pub auto_close_interval: f64, pub absence_interval: f64, pub active_integrations: Vec, + /// The always-on workspace pill (src/core/pills.ts checks it is one). + pub main_pill: String, /// "primary" = the main display, "cursor" = whichever display the mouse is on. pub screen: String, pub autostart: bool, @@ -42,6 +44,7 @@ impl Default for Settings { "integration_vercel".into(), "integration_github".into(), ], + main_pill: "integration_claude".into(), screen: "primary".into(), autostart: false, hooks_installed: false, @@ -303,6 +306,7 @@ mod tests { "autoCloseInterval": 30.0, "absenceInterval": 60.0, "activeIntegrations": ["integration_notion"], + "mainPill": "agent_cursor", "screen": "cursor", "autostart": true, "hooksInstalled": true, @@ -413,6 +417,7 @@ mod tests { ("soundVolume", json!("loud")), ("soundEnabled", json!(1)), ("activeIntegrations", json!("integration_n8n")), + ("mainPill", json!(["agent_cursor"])), ("screen", Value::Null), ] { let loaded = parse(&custom_with(key, Some(wrong))) @@ -628,6 +633,7 @@ mod tests { "autoCloseInterval", "absenceInterval", "activeIntegrations", + "mainPill", "screen", "autostart", "hooksInstalled", diff --git a/windows/src/core/pills.ts b/windows/src/core/pills.ts new file mode 100644 index 000000000..efb797cf4 --- /dev/null +++ b/windows/src/core/pills.ts @@ -0,0 +1,237 @@ +// Every pill the island can show — port of PillCatalog.swift, the single source +// of truth on macOS. IDs, names, colours, categories and subtitles are the Mac's, +// in the Mac's order: they are contract values (settings, hook routing), so an +// ID here never changes once shipped. +// +// What is added for Windows and Linux is `support` — not every Mac pill has +// something behind it here — and `connect`, which says what makes the pill +// "connected": its hooks being installed, a key in the OS credential store, or +// nothing at all. + +import type { AgentSource } from "./state"; + +export type PillCategory = "workspace" | "agent" | "ai" | "service"; + +/** Section titles, in display order. */ +export const PILL_CATEGORIES: { id: PillCategory; title: string }[] = [ + { id: "workspace", title: "Where you code" }, + { id: "agent", title: "Agents" }, + { id: "ai", title: "AI for the chat" }, + { id: "service", title: "Services" }, +]; + +/** + * Whether the pill does anything on this build: + * - `yes`: works on Windows and Linux; + * - `windows`: Windows only (the app behind it has no Linux build); + * - `soon`: can be declared, shows "Coming soon" (macOS has it, this build not yet); + * - `no`: macOS only, never offered here. + */ +export type PillSupport = "yes" | "windows" | "soon" | "no"; + +/** What makes the pill connected. */ +export type PillConnect = + /** Hook events: connected once the hooks are installed (Mac #183). */ + | { kind: "hooks" } + /** A key in the credential store. */ + | { kind: "key"; key: string } + /** Nothing to set up. */ + | { kind: "none" }; + +export interface PillDefinition { + id: string; + name: string; + color: string; + category: PillCategory; + /** Label next to the pill name in the idle card header. */ + subtitle: string; + source: AgentSource; + support: PillSupport; + connect: PillConnect; +} + +export type HostOs = "windows" | "linux"; + +const hooks: PillConnect = { kind: "hooks" }; +const key = (k: string): PillConnect => ({ kind: "key", key: k }); +const none: PillConnect = { kind: "none" }; + +/** ChatProvider.accentHex on macOS. */ +const ACCENT = { + anthropic: "#E07950", + google: "#4285F4", + openai: "#10A37F", + ollama: "#FACC15", + lmstudio: "#A3E635", +}; + +export const PILL_CATALOG: readonly PillDefinition[] = [ + // ── Where you code ───────────────────────────────────────────────────────── + { id: "integration_claude", name: "VS Code", color: "#F5F6F8", category: "workspace", + subtitle: "Integration", source: "claudeCode", support: "yes", connect: hooks }, + // Claude Code in Cursor's terminal: the same hooks as Claude Code. + { id: "agent_cursor", name: "Cursor", color: "#C0C4CC", category: "workspace", + subtitle: "Integration", source: "agent", support: "yes", connect: hooks }, + { id: "agent_antigravity", name: "Antigravity", color: "#E879F9", category: "workspace", + subtitle: "Integration", source: "agent", support: "yes", connect: hooks }, + { id: "agent_codex", name: "Codex", color: "#2DD4BF", category: "workspace", + subtitle: "Integration", source: "agent", support: "yes", connect: hooks }, + // ── Agents ───────────────────────────────────────────────────────────────── + { id: "agent_gemini", name: "Gemini CLI", color: "#8AB4F8", category: "agent", + subtitle: "Agent", source: "agent", support: "yes", connect: hooks }, + { id: "agent_copilot", name: "Copilot CLI", color: "#818CF8", category: "agent", + subtitle: "Agent", source: "agent", support: "yes", connect: hooks }, + { id: "agent_muse", name: "Muse Code", color: "#38BDF8", category: "agent", + subtitle: "Agent", source: "agent", support: "yes", connect: hooks }, + // Their integration is a plugin with macOS paths, installed by the Mac app only. + { id: "agent_opencode", name: "OpenCode", color: "#4ADE80", category: "agent", + subtitle: "Agent", source: "agent", support: "no", connect: hooks }, + { id: "agent_amp", name: "Amp", color: "#F59E0B", category: "agent", + subtitle: "Agent", source: "agent", support: "no", connect: hooks }, + { id: "agent_hermes", name: "Hermes", color: "#C084FC", category: "agent", + subtitle: "Agent", source: "agent", support: "no", connect: hooks }, + // Claude Code sessions run from the Claude desktop app: the relay tags them + // from CLAUDE_CODE_ENTRYPOINT, so there is nothing to install. The app has no + // Linux build. + { id: "agent_claude-desktop", name: "Claude Desktop", color: "#D97757", category: "agent", + subtitle: "Agent", source: "agent", support: "windows", connect: none }, + // ── AI for the chat ──────────────────────────────────────────────────────── + { id: "ai_anthropic", name: "Anthropic", color: ACCENT.anthropic, category: "ai", + subtitle: "Chat", source: "n8n", support: "yes", connect: key("anthropic-api-key") }, + // The chat here only talks to Anthropic so far. + { id: "ai_google", name: "Google AI", color: ACCENT.google, category: "ai", + subtitle: "Chat", source: "n8n", support: "soon", connect: key("google-api-key") }, + { id: "ai_openai", name: "OpenAI", color: ACCENT.openai, category: "ai", + subtitle: "Chat", source: "n8n", support: "soon", connect: key("openai-api-key") }, + { id: "ai_ollama", name: "Ollama", color: ACCENT.ollama, category: "ai", + subtitle: "Chat", source: "n8n", support: "no", connect: none }, + { id: "ai_lmstudio", name: "LM Studio", color: ACCENT.lmstudio, category: "ai", + subtitle: "Chat", source: "n8n", support: "no", connect: none }, + // ── Services ─────────────────────────────────────────────────────────────── + { id: "integration_resend", name: "Resend", color: "#22C55E", category: "service", + subtitle: "Integration", source: "n8n", support: "yes", connect: key("resend-api-key") }, + { id: "integration_n8n", name: "n8n", color: "#F29B38", category: "service", + subtitle: "Integration", source: "n8n", support: "yes", connect: key("n8n-api-key") }, + { id: "integration_vercel", name: "Vercel", color: "#7C5CFF", category: "service", + subtitle: "Integration", source: "n8n", support: "yes", connect: key("vercel-token") }, + { id: "integration_github", name: "GitHub", color: "#F4505E", category: "service", + subtitle: "Integration", source: "n8n", support: "yes", connect: key("github-token") }, + { id: "integration_notion", name: "Notion", color: "#8C8C8C", category: "service", + subtitle: "Integration", source: "n8n", support: "yes", connect: key("notion-api-key") }, + { id: "integration_calcom", name: "Cal.com", color: "#C9956A", category: "service", + subtitle: "Integration", source: "n8n", support: "yes", connect: key("calcom-api-key") }, + { id: "integration_stripe", name: "Stripe", color: "#0570DE", category: "service", + subtitle: "Integration", source: "n8n", support: "yes", connect: key("stripe-api-key") }, + { id: "integration_music", name: "Apple Music", color: "#FA2D48", category: "service", + subtitle: "Integration", source: "n8n", support: "no", connect: none }, +]; + +/** The always-on pill unless the user picks another workspace tool. */ +export const DEFAULT_MAIN_PILL = "integration_claude"; + +/** How many declared pills may sit next to the main one. */ +export const MAX_DECLARED = 4; + +/** The OS this page runs on. Only the Windows webview says "Windows". */ +export const HOST_OS: HostOs = + typeof navigator !== "undefined" && /Windows/.test(navigator.userAgent ?? "") ? "windows" : "linux"; + +export function pillDefinition(id: string): PillDefinition | undefined { + return PILL_CATALOG.find((p) => p.id === id); +} + +function offeredOn(def: PillDefinition, os: HostOs): boolean { + return def.support === "yes" || def.support === "soon" || (def.support === "windows" && os === "windows"); +} + +/** The pills this build offers, in catalog order. */ +export function availablePills(os: HostOs = HOST_OS): PillDefinition[] { + return PILL_CATALOG.filter((p) => offeredOn(p, os)); +} + +export function isComingSoon(id: string): boolean { + return pillDefinition(id)?.support === "soon"; +} + +/** True for pills whose sessions arrive as hook events (Mac #183). */ +export function isHookPill(id: string): boolean { + return pillDefinition(id)?.connect.kind === "hooks"; +} + +/** Workspace tools that can be the main pill. */ +export function mainPillChoices(os: HostOs = HOST_OS): PillDefinition[] { + return availablePills(os).filter((p) => p.category === "workspace" && p.support !== "soon"); +} + +/** PillDefinition.sessionSubtitle — next to the name in a live session's card. */ +export function sessionSubtitle(id: string): string { + switch (id) { + case "integration_claude": return "Claude Code"; + case "agent_cursor": return "Cursor"; + case "agent_codex": return "Codex"; + case "agent_hermes": return "Hermes"; + case "agent_claude-desktop": return "Claude Desktop"; + default: return "Agent"; + } +} + +export interface Declared { + mainPill: string; + activeIntegrations: string[]; +} + +/** + * The declaration as it may be used: a main pill this build can run (else the + * default), and declared pills this build offers, without duplicates and + * without the main pill, which never takes a slot. Same rules as + * AppState.loadIntegrationTasks on macOS. + */ +export function sanitizeDeclared(d: Declared, os: HostOs = HOST_OS): Declared { + const mains = new Set(mainPillChoices(os).map((p) => p.id)); + const mainPill = mains.has(d.mainPill) ? d.mainPill : DEFAULT_MAIN_PILL; + const offered = new Set(availablePills(os).map((p) => p.id)); + const activeIntegrations: string[] = []; + for (const id of d.activeIntegrations ?? []) { + if (id !== mainPill && offered.has(id) && !activeIntegrations.includes(id)) { + activeIntegrations.push(id); + } + } + return { mainPill, activeIntegrations }; +} + +/** + * Declares or undeclares a pill. Returns the new list, or null when the click + * changes nothing: the main pill is never toggled, an unknown pill never + * declared, and a fifth pill never added. + */ +export function toggleDeclared(d: Declared, id: string, os: HostOs = HOST_OS): string[] | null { + if (id === d.mainPill) return null; + if (!availablePills(os).some((p) => p.id === id)) return null; + if (d.activeIntegrations.includes(id)) return d.activeIntegrations.filter((x) => x !== id); + if (d.activeIntegrations.length >= MAX_DECLARED) return null; + return [...d.activeIntegrations, id]; +} + +/** + * Picks a new main pill. The old main pill is not declared on its own, and the + * new one leaves the declared list so it does not take a slot twice. + */ +export function chooseMainPill(d: Declared, id: string, os: HostOs = HOST_OS): Declared | null { + if (!mainPillChoices(os).some((p) => p.id === id)) return null; + return { mainPill: id, activeIntegrations: d.activeIntegrations.filter((x) => x !== id) }; +} + +/** + * Pill order: the main pill first, then pills from outside the catalog (any + * other tagged agent), then the catalog's pills in catalog order. Pills of equal + * rank keep their order. Mirrors sortTasksByCatalog on macOS, with the main + * pill (not always VS Code here) leading. + */ +export function orderPills(tasks: T[], mainPill: string): T[] { + const index = new Map(PILL_CATALOG.map((p, i) => [p.id, i])); + const rank = (t: T) => (t.id === mainPill ? -2 : index.has(t.id) ? index.get(t.id)! : -1); + return tasks + .map((t, i) => ({ t, i })) + .sort((a, b) => rank(a.t) - rank(b.t) || a.i - b.i) + .map(({ t }) => t); +} diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 4785897aa..628d1ef4f 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -2,6 +2,10 @@ import type { BotEmoteName, BotStateName, IslandMode, IslandViewName } from "./layout"; import type { EyeShape } from "../mochi/engine"; +import { + DEFAULT_MAIN_PILL, HOST_OS, availablePills, orderPills, pillDefinition, sanitizeDeclared, + toggleDeclared, type HostOs, type PillDefinition, +} from "./pills"; export type AgentSource = "claudeCode" | "n8n" | "agent"; export type PillBadge = "approval" | "finished" | "error"; @@ -24,6 +28,8 @@ export interface AgentTask { miniEye?: EyeShape | null; pillBadge?: PillBadge | null; sessionCwd?: string | null; + /** Claude Code's session, so "Open terminal" can find the window it runs in. */ + sessionId?: string | null; } export interface ApprovalInfo { @@ -64,28 +70,13 @@ export interface SearchResult { note?: string; } -const task = ( - id: string, name: string, color: string, source: AgentSource, -): AgentTask => ({ - id, name, color, state: "idle", stepIndex: 0, steps: [], source, isIntegration: true, -}); - -/** AgentTask.integrationAgents — same ids, names and colours as macOS. */ -export const INTEGRATION_AGENTS: AgentTask[] = [ - task("integration_claude", "VS Code", "#F5F6F8", "claudeCode"), - task("integration_resend", "Resend", "#22C55E", "n8n"), - task("integration_n8n", "n8n", "#F29B38", "n8n"), - task("integration_vercel", "Vercel", "#7C5CFF", "n8n"), - task("integration_github", "GitHub", "#F4505E", "n8n"), - task("integration_notion", "Notion", "#8C8C8C", "n8n"), - task("integration_calcom", "Cal.com", "#C9956A", "n8n"), - task("integration_stripe", "Stripe", "#0570DE", "n8n"), -]; - -export const TOGGLEABLE_INTEGRATION_IDS = [ - "integration_resend", "integration_n8n", "integration_vercel", "integration_github", - "integration_notion", "integration_calcom", "integration_stripe", -]; +/** A fresh, idle task for a catalog pill. */ +function taskFor(def: PillDefinition, name = def.name): AgentTask { + return { + id: def.id, name, color: def.color, state: "idle", stepIndex: 0, steps: [], + source: def.source, isIntegration: true, + }; +} /** What an integration poller last reported. */ export interface IntegrationInfo { @@ -100,7 +91,10 @@ export interface Settings { soundVolume: number; autoCloseInterval: number; absenceInterval: number; + /** Declared pills next to the main one (at most 4), in the order they were added. */ activeIntegrations: string[]; + /** The always-on workspace pill: VS Code, Cursor, Codex or Antigravity. */ + mainPill: string; /** "primary", "cursor", or `at:,` for one display (logical origin). */ screen: string; autostart: boolean; @@ -117,6 +111,7 @@ export const DEFAULT_SETTINGS: Settings = { activeIntegrations: [ "integration_resend", "integration_n8n", "integration_vercel", "integration_github", ], + mainPill: DEFAULT_MAIN_PILL, screen: "primary", autostart: false, hooksInstalled: false, @@ -159,6 +154,9 @@ class AppState { settings: Settings = { ...DEFAULT_SETTINGS }; + /** Which pills this build offers depends on it (Claude Desktop is Windows only). */ + os: HostOs = HOST_OS; + private listeners = new Set(); subscribe(fn: Listener): () => void { @@ -216,67 +214,111 @@ class AppState { this.notify(); } - /** loadIntegrationTasks() — VS Code always on, the rest opt-in (max 4). */ + /** The always-on workspace pill, once the setting has been checked. */ + get mainPillId(): string { + return sanitizeDeclared(this.settings, this.os).mainPill; + } + + /** + * True for a pill that stays when its session ends: the main pill and the + * declared ones go back to idle instead of going away. + */ + isKept(id: string): boolean { + const d = sanitizeDeclared(this.settings, this.os); + return id === d.mainPill || d.activeIntegrations.includes(id); + } + + /** + * Loads the catalog pills: the main pill always, the declared ones, and none + * of the others — a pill that is mid-session stays until its session ends. + * Safe to call any number of times. AppState.loadIntegrationTasks on macOS. + */ loadIntegrationTasks() { - for (const proto of INTEGRATION_AGENTS) { - const shouldLoad = - proto.id === "integration_claude" || 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); + const d = sanitizeDeclared(this.settings, this.os); + this.settings.mainPill = d.mainPill; + this.settings.activeIntegrations = d.activeIntegrations; + for (const def of availablePills(this.os)) { + const shouldLoad = def.id === d.mainPill || d.activeIntegrations.includes(def.id); + const idx = this.tasks.findIndex((t) => t.id === def.id); + if (shouldLoad && idx < 0) this.tasks.push(taskFor(def)); + const busy = idx >= 0 && (this.tasks[idx].state !== "idle" || this.tasks[idx].steps.length > 0); + if (!shouldLoad && idx >= 0 && !busy) this.tasks.splice(idx, 1); } - // Order: integration_claude first, then agent_* pills (visible in slice(0,4)), - // then other integrations in declaration order. - const order = INTEGRATION_AGENTS.map((t) => t.id); - this.tasks.sort((a, b) => { - const isAgentA = a.id.startsWith("agent_"); - const isAgentB = b.id.startsWith("agent_"); - // integration_claude always first - if (a.id === "integration_claude") return -1; - if (b.id === "integration_claude") return 1; - // agent_* before other integrations; preserve insertion order among themselves - if (isAgentA && !isAgentB) return -1; - if (isAgentB && !isAgentA) return 1; - if (isAgentA && isAgentB) return 0; - // both known integrations → declaration order - return order.indexOf(a.id) - order.indexOf(b.id); - }); - if (!this.focusId) this.focusId = "integration_claude"; + this.tasks = orderPills(this.tasks, d.mainPill); + if (!this.focusId || !this.tasks.some((t) => t.id === this.focusId)) this.focusId = d.mainPill; this.notify(); } + /** + * A session is over. The main and declared pills are put back as they were; + * any other pill goes away (AppState.removeTask on macOS). + */ removeTask(id: string) { const idx = this.tasks.findIndex((t) => t.id === id); if (idx < 0) return; + if (this.isKept(id)) { + const t = this.tasks[idx]; + t.state = "idle"; + t.steps = []; + t.stepIndex = 0; + delete t.stepSeq; + t.pillBadge = null; + const def = pillDefinition(id); + if (def) t.name = def.name; + this.notify(); + return; + } this.tasks.splice(idx, 1); - if (this.focusId === id) this.focusId = this.tasks[0]?.id ?? "integration_claude"; + if (this.focusId === id) this.focusId = this.tasks[0]?.id ?? this.mainPillId; this.notify(); } - /** Creates a dynamic agent_ pill on first event; no-ops if it already exists. - * Inserted right after integration_claude so it appears in the visible slice(0,4). */ + /** + * Creates the pill of a tagged agent on its first event; no-op if it exists. + * Inserted right after the main pill so it is in the visible slice(0,4). A + * catalog agent wears its catalog colour, as on macOS. + */ upsertExternalAgent(id: string, name: string, color: string) { if (this.tasks.some((t) => t.id === id)) return; - const at = this.tasks.findIndex((t) => t.id === "integration_claude") + 1; - this.tasks.splice(at, 0, { - id, name, color, + const def = pillDefinition(id); + this.insertAfterMain({ + id, name, color: def?.color ?? color, state: "idle", stepIndex: 0, steps: [], source: "agent", isIntegration: false, }); - if (!this.focusId) this.focusId = id; + } + + /** + * The pill a Claude Code session belongs to (VS Code or Cursor). It is made + * for the session when it is neither the main pill nor declared, as + * upsertWorkspaceTask does on macOS. + */ + upsertWorkspacePill(id: string, name: string, cwd: string): AgentTask | null { + let t = this.tasks.find((x) => x.id === id); + if (!t) { + const def = pillDefinition(id); + if (!def) return null; + t = taskFor(def, name); + this.insertAfterMain(t); + } + t.name = name; + if (cwd) t.sessionCwd = cwd; + return t; + } + + private insertAfterMain(t: AgentTask) { + const at = this.tasks.findIndex((x) => x.id === this.mainPillId) + 1; + this.tasks.splice(at, 0, t); + if (!this.focusId) this.focusId = t.id; this.notify(); } + /** Declares or undeclares a pill (max 4 next to the main one). */ toggleIntegration(id: string) { - if (id === "integration_claude") return; - const active = this.settings.activeIntegrations; - if (active.includes(id)) { - this.settings.activeIntegrations = active.filter((x) => x !== id); - if (this.focusId === id) this.focusId = "integration_claude"; - } else { - if (active.length >= 4) return; - this.settings.activeIntegrations = [...active, id]; - } + const next = toggleDeclared(sanitizeDeclared(this.settings, this.os), id, this.os); + if (!next) return; + this.settings.activeIntegrations = next; + if (!next.includes(id) && this.focusId === id) this.focusId = this.mainPillId; this.loadIntegrationTasks(); } diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index ff03a72fb..cb9ef14fa 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -6,9 +6,11 @@ import { Bridge, onEvent } from "../core/bridge"; import { Sound } from "../core/sound"; import { State, type AskedQuestion } from "../core/state"; +import { pillDefinition } from "../core/pills"; import type { Island } from "./island"; const CLAUDE_ID = "integration_claude"; +const CURSOR_ID = "agent_cursor"; /** Clears the approval card if no decision was made before the hook gave up. */ let pendingTimeout: number | null = null; @@ -50,6 +52,8 @@ interface HookPayload { tool_input?: Record; /** Optional agent tag: lowercase, digits and hyphens, ≤ 24 chars. */ coucou_agent?: string; + /** "cursor" when Claude Code runs in Cursor's terminal (set by the relay). */ + term_editor?: string; } /** Same rule as HookServer.validateAgent on macOS. "claude" is reserved. */ @@ -167,21 +171,27 @@ function askedQuestions(tool: string, input: Record): AskedQues return out.length > 0 ? out : null; } -function upsert(projectName: string, cwd: string) { - const t = State.tasks.find((x) => x.id === CLAUDE_ID); - if (!t) return; - t.name = projectName; - if (cwd) t.sessionCwd = cwd; +/** The Claude Code session's pill, named after its project for the session. */ +function upsert(id: string, projectName: string, cwd: string, sessionId: string) { + const t = State.upsertWorkspacePill(id, projectName, cwd); + if (t && sessionId) t.sessionId = sessionId; } -function clearSession() { - const t = State.tasks.find((x) => x.id === CLAUDE_ID); +/** The session is over: the pill goes back as it was, or away if it was only there for it. */ +function clearSession(id: string) { + const t = State.tasks.find((x) => x.id === id); if (!t) return; + if (!State.isKept(id)) { + State.removeTask(id); + return; + } + t.state = "idle"; t.steps = []; t.stepIndex = 0; delete t.stepSeq; - t.name = "VS Code"; + t.name = pillDefinition(id)?.name ?? t.name; t.pillBadge = null; + t.sessionId = null; } export function registerHookHandlers(island: Island) { @@ -203,10 +213,13 @@ function handleHook(island: Island, payload: HookPayload) { 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. + // "claude" is reserved; absent or invalid → Claude Code's own pill: Cursor's + // when it runs in Cursor's terminal (Mac #120), VS Code's otherwise. const validAgent = validateAgent(payload.coucou_agent); - const agentId = validAgent ? `agent_${validAgent}` : CLAUDE_ID; + const workspaceId = payload.term_editor === "cursor" ? CURSOR_ID : CLAUDE_ID; + const agentId = validAgent ? `agent_${validAgent}` : workspaceId; const isExternalAgent = validAgent !== null; + const sessionId = payload.session_id ?? ""; const focused = State.focusId === agentId; @@ -225,8 +238,11 @@ function handleHook(island: Island, payload: HookPayload) { const ensurePill = () => { if (isExternalAgent) { State.upsertExternalAgent(agentId, validAgent!, agentColor(validAgent!)); + const t = State.tasks.find((x) => x.id === agentId); + if (t && cwd) t.sessionCwd = cwd; + if (t && sessionId) t.sessionId = sessionId; } else { - upsert(projectName, cwd); + upsert(agentId, projectName, cwd, sessionId); } }; @@ -340,7 +356,7 @@ function handleHook(island: Island, payload: HookPayload) { State.removeTask(agentId); } else { State.updateTask(agentId, "idle"); - clearSession(); + clearSession(agentId); } break; @@ -369,7 +385,7 @@ function handleHook(island: Island, payload: HookPayload) { if (requestId) void Bridge.approvalDecline(requestId); break; } - upsert(projectName, cwd); + upsert(agentId, projectName, cwd, sessionId); supersedeStop(); if (pendingTimeout != null) window.clearTimeout(pendingTimeout); const tool = payload.tool_name ?? "Tool"; diff --git a/windows/src/island/integrations.ts b/windows/src/island/integrations.ts index 862ffb14b..108172915 100644 --- a/windows/src/island/integrations.ts +++ b/windows/src/island/integrations.ts @@ -3,21 +3,11 @@ // the pill isn't focused, plays a sound, and clears itself after 60 s. import { onEvent, Bridge, type IntegrationUpdate } from "../core/bridge"; +import { availablePills } from "../core/pills"; import { Sound } from "../core/sound"; import { State } from "../core/state"; import type { Island } from "./island"; -/** Which Credential Manager key backs each pill. */ -const KEY_FOR: Record = { - integration_stripe: "stripe-api-key", - integration_github: "github-token", - integration_vercel: "vercel-token", - integration_n8n: "n8n-api-key", - integration_resend: "resend-api-key", - integration_notion: "notion-api-key", - integration_calcom: "calcom-api-key", -}; - const clearTimers = new Map(); export function registerIntegrationHandlers(island: Island) { @@ -25,18 +15,43 @@ export function registerIntegrationHandlers(island: Island) { void refreshConfigured(); } -/** Asks Rust which keys exist so the idle cards can say so. */ +/** + * Whether each pill is connected, so its idle card can say so: a key in the + * credential store for a service or a chat provider, the hooks in place for a + * pill fed by hook events (Mac #183), nothing at all for Claude Desktop. + */ export async function refreshConfigured() { - for (const [id, key] of Object.entries(KEY_FOR)) { - const present = (await Bridge.secretPresent(key)) ?? false; + const hooks = (await Bridge.agentHooksStatus()) ?? null; + for (const def of availablePills(State.os)) { + let configured: boolean; + switch (def.connect.kind) { + case "key": + configured = (await Bridge.secretPresent(def.connect.key)) ?? false; + break; + case "hooks": + // Without an answer from Rust (a plain browser), the Claude Code pill + // falls back to what the settings say about its hooks. + configured = hooks?.[def.id] ?? + (def.id === "integration_claude" ? State.settings.hooksInstalled : false); + break; + case "none": + configured = true; + break; + } + const info = State.integrations[def.id] ?? { data: {}, error: null, loaded: false, configured: false }; + State.integrations[def.id] = { ...info, configured }; + } + State.notify(); +} + +/** Only the hook-driven pills, for when the island opens: a few small file reads. */ +export async function refreshHookPills() { + const hooks = await Bridge.agentHooksStatus(); + if (!hooks) return; + for (const [id, present] of Object.entries(hooks)) { const info = State.integrations[id] ?? { data: {}, error: null, loaded: false, configured: false }; State.integrations[id] = { ...info, configured: present }; } - const hooks = State.settings.hooksInstalled; - const claude = State.integrations.integration_claude ?? { - data: {}, error: null, loaded: false, configured: false, - }; - State.integrations.integration_claude = { ...claude, configured: hooks }; State.notify(); } diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 07bd41830..322a4a7f6 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -20,6 +20,7 @@ import { USC, UploadSeq } from "../upload/sequence"; import { buildHeader, buildViews, type ViewActions, type ViewHost } from "../views/views"; import { h } from "../views/dom"; import { IslandStateMachine } from "./fsm"; +import { refreshHookPills } from "./integrations"; const BOT_OVERHANG = 40; /** Same margin as the Rust hit test (src-tauri/src/island.rs). */ @@ -260,6 +261,9 @@ export class Island { case "home": this.expand(State.defaultView()); if (!this.wasInIsland) this.fsm.mouseLeft(); + // Hooks may have been installed in a terminal since: the idle cards + // say so on the next open, without polling while the island is shut. + void refreshHookPills(); break; case "coucou": this.expand("greeting"); diff --git a/windows/src/main.ts b/windows/src/main.ts index d71525e65..98d297b2e 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -56,9 +56,12 @@ async function main() { // The settings window writes preferences; apply them here without a restart. await onEvent("settings-changed", (s) => { + const previousMain = State.mainPillId; State.settings = { ...State.settings, ...s }; island.applySettings(); State.loadIntegrationTasks(); + // A new main tool comes to the front, as on macOS. + if (State.mainPillId !== previousMain) State.setFocus(State.mainPillId); void refreshConfigured(); }); diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index ccc1b77fd..9f3cd188f 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -5,6 +5,10 @@ import "./settings.css"; import { Bridge, onEvent, type HookStatus } from "../core/bridge"; import { DEFAULT_SETTINGS, type Settings } from "../core/state"; +import { + MAX_DECLARED, PILL_CATEGORIES, availablePills, chooseMainPill, isComingSoon, mainPillChoices, + sanitizeDeclared, toggleDeclared, type PillDefinition, +} from "../core/pills"; import { h, clear } from "../views/dom"; /** Where secrets.rs keeps the keys on this OS. */ @@ -259,6 +263,87 @@ function apiSection(hasKey: boolean): HTMLElement { ); } +// ── Active pills section ────────────────────────────────────────────────────── + +/** + * The tools you use (Mac 0.1.1–0.1.2): pick the main workspace tool, which is + * always on and takes no slot, and declare the agents and chat providers you + * want as pills. Services are declared in Integrations below, next to their keys. + */ +function activePillsSection(connected: Record): HTMLElement { + const slots = h("div", { class: "hint" }); + const main = h("select", {}) as HTMLSelectElement; + for (const def of mainPillChoices()) main.append(h("option", { value: def.id, text: def.name })); + main.addEventListener("change", () => { + const next = chooseMainPill(settings, main.value); + if (!next) return; + settings.mainPill = next.mainPill; + settings.activeIntegrations = next.activeIntegrations; + declaredChanged(); + }); + const groups = h("div", { style: "display:flex;flex-direction:column;gap:12px" }); + + /** Why a pill would show nothing yet, as on the Mac's row. */ + function hint(def: PillDefinition): string | null { + if (isComingSoon(def.id)) return "Coming soon"; + if (def.connect.kind === "hooks" && !connected[def.id]) return "Hooks not installed"; + if (def.connect.kind === "key" && !connected[def.id]) return "Key not configured"; + return null; + } + + function row(def: PillDefinition): HTMLElement { + const isMain = def.id === settings.mainPill; + const on = settings.activeIntegrations.includes(def.id); + const full = !isMain && !on && settings.activeIntegrations.length >= MAX_ACTIVE; + const el = h("div", { class: full ? "pill-row full" : "pill-row" }, + h("i", { class: "dot", style: `background:${def.color};width:10px;height:10px` }), + h("span", { class: "name", text: def.name }), + ); + if (isMain) { + el.append(h("span", { class: "state", text: "Main" })); + return el; + } + const why = hint(def); + el.append(h("span", { class: "state", text: why ?? "" })); + const sw = h("button", { class: on ? "switch on" : "switch" }) as HTMLButtonElement; + sw.disabled = full; + sw.addEventListener("click", () => { + const next = toggleDeclared(settings, def.id); + if (!next) return; + settings.activeIntegrations = next; + declaredChanged(); + }); + el.append(sw); + return el; + } + + function draw() { + const used = settings.activeIntegrations.length; + slots.textContent = `${used}/${MAX_ACTIVE} slots in use — the main tool doesn't take one.`; + slots.classList.toggle("full", used >= MAX_ACTIVE); + main.value = settings.mainPill; + clear(groups); + for (const cat of PILL_CATEGORIES) { + if (cat.id === "service") continue; + const pills = availablePills().filter((p) => p.category === cat.id); + if (pills.length === 0) continue; + groups.append(h("div", { class: "pill-group" }, h("h3", { text: cat.title }), ...pills.map(row))); + } + } + declaredViews.push(draw); + draw(); + + return h( + "section", + {}, + h("h2", {}, h("span", { text: "Active pills" })), + h("div", { class: "hint", text: "Choose the tools you use. Coucou only shows what you declare here." }), + slots, + h("div", { class: "row" }, h("label", { text: "Main tool" }), main), + groups, + ); +} + // ── Integrations section ────────────────────────────────────────────────────── interface IntegrationDef { @@ -289,7 +374,15 @@ const INTEGRATIONS: IntegrationDef[] = [ fields: [{ key: "calcom-api-key", label: "API key", placeholder: "cal_…", secret: true }] }, ]; -const MAX_ACTIVE = 4; +const MAX_ACTIVE = MAX_DECLARED; + +/** Everything that shows the declared pills, redrawn when any of them changes. */ +const declaredViews: (() => void)[] = []; + +function declaredChanged() { + for (const redraw of declaredViews) redraw(); + void save(); +} function integrationsSection(present: Record): HTMLElement { const note = h("div", { class: "hint" }); @@ -299,6 +392,7 @@ function integrationsSection(present: Record): HTMLElement { const used = settings.activeIntegrations.length; note.textContent = `Pick up to ${MAX_ACTIVE} pills to show next to Mochi — ${used}/${MAX_ACTIVE} in use. Keys are stored in the ${KEY_STORE}, never on disk.`; } + declaredViews.push(updateNote); for (const def of INTEGRATIONS) { const active = settings.activeIntegrations.includes(def.id); @@ -312,8 +406,7 @@ function integrationsSection(present: Record): HTMLElement { settings.activeIntegrations = [...settings.activeIntegrations, def.id]; } sw.classList.toggle("on", !on); - updateNote(); - void save(); + declaredChanged(); }); const rows = h("div", { style: "display:flex;flex-direction:column;gap:6px;flex:1 1 auto;min-width:0" }); @@ -458,11 +551,21 @@ async function main() { const present: Record = {}; for (const k of keys) present[k] = (await Bridge.secretPresent(k)) ?? false; + // A main pill this build can run, and no pill declared twice. + settings = { ...settings, ...sanitizeDeclared(settings) }; + const connected: Record = { ...((await Bridge.agentHooksStatus()) ?? {}) }; + for (const def of availablePills()) { + if (def.connect.kind === "key") { + connected[def.id] = present[def.connect.key] ?? (await Bridge.secretPresent(def.connect.key)) ?? false; + } + } + clear(root); root.append( h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), apiSection(hasKey), + activePillsSection(connected), integrationsSection(present), generalSection(), h("div", { @@ -473,6 +576,7 @@ async function main() { void onEvent("settings-changed", (s) => { settings = { ...settings, ...s }; + for (const redraw of declaredViews) redraw(); }); } diff --git a/windows/src/settings/settings.css b/windows/src/settings/settings.css index 605b82025..cc2517f12 100644 --- a/windows/src/settings/settings.css +++ b/windows/src/settings/settings.css @@ -265,3 +265,43 @@ select option { background: rgba(245, 165, 36, 0.13); color: #fbc06a; } + +/* Active pills — one group per catalog category, one row per pill. */ + +.pill-group { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 10px; + border-top: 1px solid var(--hairline); +} + +.pill-group > h3 { + font: 600 11px var(--font); + color: var(--dim); +} + +.pill-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; +} + +.pill-row .name { + font-size: 12.5px; +} + +.pill-row.full .name { + color: var(--dim-2); +} + +.pill-row .state { + margin-left: auto; + font: 400 11.5px var(--font); + color: var(--dim); +} + +.hint.full { + color: var(--amber); +} diff --git a/windows/src/views/integrations.ts b/windows/src/views/integrations.ts index b8ad73c8f..cf2564a3d 100644 --- a/windows/src/views/integrations.ts +++ b/windows/src/views/integrations.ts @@ -8,6 +8,8 @@ import { h, svg, clear, dot } from "./dom"; import { ICONS } from "./icons"; import { State, type AgentTask } from "../core/state"; import { Bridge } from "../core/bridge"; +import { isComingSoon, pillDefinition } from "../core/pills"; +import { refreshHookPills } from "../island/integrations"; /** Same shape as the Swift `timeAgo` computed properties. */ export function timeAgo(value: unknown): string { @@ -53,15 +55,34 @@ const OPEN_URLS: Record = { integration_calcom: "https://app.cal.com/bookings", }; +/** IntegrationCardView.statusLabel on macOS. */ +export function idleStatus( + id: string, + info: { configured: boolean; error: string | null } | undefined, + chatModel: string, +): { label: string; color: string } { + if (isComingSoon(id)) return { label: "Coming soon", color: "#6B7079" }; + if (info?.error) return { label: info.error, color: "#F4505E" }; + const configured = info?.configured ?? false; + const def = pillDefinition(id); + const ok = (label: string) => ({ label, color: "#22C55E" }); + const missing = (label: string) => ({ label, color: "#F4505E" }); + // Pills driven by hooks never have a key: they are connected once the hooks + // are in place (Mac #183). A session replaces this card; nothing is loading. + if (def?.connect.kind === "hooks") return configured ? ok("Hooks installed") : missing("Hooks not installed"); + if (def?.connect.kind === "none") return ok("Ready · no setup needed"); + if (def?.category === "ai") { + if (!configured) return missing("Key not configured"); + return ok(id === "ai_anthropic" ? `Key configured · ${chatModel}` : "Key configured"); + } + return configured ? ok("Connected · loading…") : missing("Key not configured"); +} + function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { const info = State.integrations[task.id]; 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"; - const label = error ?? (configured ? "Connected · loading…" : missing); - const statusColor = error || !configured ? "#F4505E" : "#22C55E"; + const def = pillDefinition(task.id); + const status = idleStatus(task.id, info, State.settings.model); const actions = h("div", { class: "int-actions" }); if (task.id === "integration_claude") { @@ -92,16 +113,20 @@ function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { }), ); } - if (configured) { + const hookPill = def?.connect.kind === "hooks"; + if (isComingSoon(task.id) || def?.connect.kind === "none") { + // Nothing to set up, and nothing to refresh. + } else if (configured && (hookPill || def?.category !== "ai")) { actions.append( h("button", { class: "link-btn", style: `color:${task.color}d9`, text: "Refresh", - onclick: () => void Bridge.refreshIntegration(task.id), + // A hook pill has nothing to poll: look at its hooks again instead. + onclick: () => void (hookPill ? refreshHookPills() : Bridge.refreshIntegration(task.id)), }), ); - } else { + } else if (!configured) { actions.append( h("button", { class: "link-btn", style: "color:#8e939c", text: "Settings…", onclick: openSettings }), ); @@ -110,8 +135,12 @@ 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, + def?.subtitle ?? "Integration", + ), + h("div", { class: "int-status" }, dot(status.color, 5), h("span", { text: status.label })), actions, ); } diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index e433651da..4a7186c3f 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -11,6 +11,7 @@ import { createMiniBot, pruneMiniBots } from "../mochi/minibots"; import { buildPrompt } from "./chat"; import { buildChoose, buildUpload, buildUploading } from "./upload"; import { renderIntegrationCard, type IntegrationCardHooks } from "./integrations"; +import { pillDefinition, sessionSubtitle } from "../core/pills"; export interface ViewActions { setView(v: IslandViewName): void; @@ -180,10 +181,9 @@ function buildOverview(actions: ViewActions): ViewHost { mode = null; } - // VS Code with a live Claude Code 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); + // A workspace or agent pill with a live session keeps the ticker; every + // other pill shows its own card, exactly like IntegrationCardView. + const sessionActive = task != null && hasSessionTicker(task); if (task && sessionActive) { if (mode !== "ticker") { @@ -196,7 +196,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: sessionSubtitle(task.id) }), ); if (task.steps.length > 1) { who.append(h("span", { @@ -234,6 +234,17 @@ function buildOverview(actions: ViewActions): ViewHost { }; } +/** + * IntegrationCardView.agentSessionActive: a workspace tool or an agent — or + * any other tagged agent — with something going on. + */ +export function hasSessionTicker(task: AgentTask): boolean { + const category = pillDefinition(task.id)?.category; + const isSession = category === "workspace" || category === "agent" || + (category == null && task.id.startsWith("agent_")); + return isSession && (task.state !== "idle" || task.steps.length > 0); +} + function buildPill(task: AgentTask, actions: ViewActions): HTMLElement { const label = task.id === "integration_claude" ? "VS Code" : task.name; const canvas = createMiniBot(task, 24); diff --git a/windows/tests/hooks.test.mjs b/windows/tests/hooks.test.mjs index 93eaa2fb0..ffa7a5129 100644 --- a/windows/tests/hooks.test.mjs +++ b/windows/tests/hooks.test.mjs @@ -248,6 +248,39 @@ test("an agent's permission request is declined, never shown as Claude Code's", assert.equal(State.pendingApproval, null); }); +// ── Main tool and Cursor ────────────────────────────────────────────────────── + +test("Claude Code in Cursor's terminal works on the Cursor pill, made for the session", () => { + hook({ hook_event_name: "SessionStart", cwd: "/p/proj", session_id: "s9", term_editor: "cursor" }); + hook({ hook_event_name: "PreToolUse", cwd: "/p/proj", term_editor: "cursor", tool_name: "Bash", tool_input: { command: "ls" } }); + assert.equal(task("agent_cursor").state, "working"); + assert.equal(task("agent_cursor").sessionId, "s9"); + assert.equal(task().state, "idle"); + hook({ hook_event_name: "SessionEnd", term_editor: "cursor" }); + assert.equal(task("agent_cursor"), undefined); +}); + +test("with another main tool, Claude Code's pill comes for the session and goes after", () => { + State.settings.mainPill = "agent_codex"; + State.loadIntegrationTasks(); + assert.equal(task(), undefined); + hook({ hook_event_name: "SessionStart", cwd: "/p/proj" }); + assert.equal(task().name, "proj"); + assert.equal(State.tasks[0].id, "agent_codex"); + hook({ hook_event_name: "SessionEnd" }); + assert.equal(task(), undefined); +}); + +test("the main tool's agent sessions put it back as it was, never take it away", () => { + State.settings.mainPill = "agent_codex"; + State.loadIntegrationTasks(); + hook({ hook_event_name: "SessionStart", coucou_agent: "codex" }); + hook({ hook_event_name: "Stop", coucou_agent: "codex" }); + seconds(5.2); + assert.equal(task("agent_codex").state, "idle"); + assert.equal(task("agent_codex").name, "Codex"); +}); + // ── Permission requests ─────────────────────────────────────────────────────── const ask = (request_id, extra = {}) => diff --git a/windows/tests/pills.test.mjs b/windows/tests/pills.test.mjs new file mode 100644 index 000000000..143347b10 --- /dev/null +++ b/windows/tests/pills.test.mjs @@ -0,0 +1,260 @@ +// The pill catalog (src/core/pills.ts) and the declared pills in the app state. + +import { beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { + DEFAULT_MAIN_PILL, MAX_DECLARED, PILL_CATALOG, PILL_CATEGORIES, availablePills, chooseMainPill, + isHookPill, mainPillChoices, orderPills, pillDefinition, sanitizeDeclared, sessionSubtitle, + toggleDeclared, +} from "../src/core/pills.ts"; +import { DEFAULT_SETTINGS, State } from "../src/core/state.ts"; + +// ── The catalog itself ──────────────────────────────────────────────────────── + +test("the catalog holds the Mac's pills, in the Mac's order, with the Mac's values", () => { + // Copied from NotchBuddy/Sources/CoucouKit/PillCatalog.swift: an ID is a + // contract value, and a colour or subtitle that drifts is a visible bug. + const mac = [ + ["integration_claude", "VS Code", "#F5F6F8", "workspace", "Integration"], + ["agent_cursor", "Cursor", "#C0C4CC", "workspace", "Integration"], + ["agent_antigravity", "Antigravity", "#E879F9", "workspace", "Integration"], + ["agent_codex", "Codex", "#2DD4BF", "workspace", "Integration"], + ["agent_gemini", "Gemini CLI", "#8AB4F8", "agent", "Agent"], + ["agent_copilot", "Copilot CLI", "#818CF8", "agent", "Agent"], + ["agent_muse", "Muse Code", "#38BDF8", "agent", "Agent"], + ["agent_opencode", "OpenCode", "#4ADE80", "agent", "Agent"], + ["agent_amp", "Amp", "#F59E0B", "agent", "Agent"], + ["agent_hermes", "Hermes", "#C084FC", "agent", "Agent"], + ["agent_claude-desktop", "Claude Desktop", "#D97757", "agent", "Agent"], + ["ai_anthropic", "Anthropic", "#E07950", "ai", "Chat"], + ["ai_google", "Google AI", "#4285F4", "ai", "Chat"], + ["ai_openai", "OpenAI", "#10A37F", "ai", "Chat"], + ["ai_ollama", "Ollama", "#FACC15", "ai", "Chat"], + ["ai_lmstudio", "LM Studio", "#A3E635", "ai", "Chat"], + ["integration_resend", "Resend", "#22C55E", "service", "Integration"], + ["integration_n8n", "n8n", "#F29B38", "service", "Integration"], + ["integration_vercel", "Vercel", "#7C5CFF", "service", "Integration"], + ["integration_github", "GitHub", "#F4505E", "service", "Integration"], + ["integration_notion", "Notion", "#8C8C8C", "service", "Integration"], + ["integration_calcom", "Cal.com", "#C9956A", "service", "Integration"], + ["integration_stripe", "Stripe", "#0570DE", "service", "Integration"], + ["integration_music", "Apple Music", "#FA2D48", "service", "Integration"], + ]; + assert.deepEqual( + PILL_CATALOG.map((p) => [p.id, p.name, p.color, p.category, p.subtitle]), + mac, + ); + assert.deepEqual(PILL_CATEGORIES.map((c) => c.title), [ + "Where you code", "Agents", "AI for the chat", "Services", + ]); +}); + +test("the pills Windows always had keep their IDs, names, colours and sources", () => { + const shipped = [ + ["integration_claude", "VS Code", "#F5F6F8", "claudeCode"], + ["integration_resend", "Resend", "#22C55E", "n8n"], + ["integration_n8n", "n8n", "#F29B38", "n8n"], + ["integration_vercel", "Vercel", "#7C5CFF", "n8n"], + ["integration_github", "GitHub", "#F4505E", "n8n"], + ["integration_notion", "Notion", "#8C8C8C", "n8n"], + ["integration_calcom", "Cal.com", "#C9956A", "n8n"], + ["integration_stripe", "Stripe", "#0570DE", "n8n"], + ]; + for (const [id, name, color, source] of shipped) { + const def = pillDefinition(id); + assert.deepEqual([def.id, def.name, def.color, def.source], [id, name, color, source]); + assert.equal(def.support, "yes"); + } +}); + +test("IDs are unique", () => { + const ids = PILL_CATALOG.map((p) => p.id); + assert.equal(new Set(ids).size, ids.length); +}); + +test("this build leaves out what only macOS has, and Claude Desktop on Linux", () => { + const windows = availablePills("windows").map((p) => p.id); + const linux = availablePills("linux").map((p) => p.id); + for (const id of ["agent_opencode", "agent_amp", "agent_hermes", "ai_ollama", "ai_lmstudio", "integration_music"]) { + assert.ok(!windows.includes(id), id); + assert.ok(!linux.includes(id), id); + } + assert.ok(windows.includes("agent_claude-desktop")); + assert.ok(!linux.includes("agent_claude-desktop")); + assert.deepEqual(linux, windows.filter((id) => id !== "agent_claude-desktop")); +}); + +test("the main pill is a workspace tool that works here", () => { + assert.deepEqual(mainPillChoices("linux").map((p) => p.id), [ + "integration_claude", "agent_cursor", "agent_antigravity", "agent_codex", + ]); + assert.equal(DEFAULT_MAIN_PILL, "integration_claude"); +}); + +test("hook-driven pills are the workspace tools and the agents with hooks", () => { + for (const id of ["integration_claude", "agent_cursor", "agent_codex", "agent_gemini", "agent_copilot", "agent_muse", "agent_antigravity"]) { + assert.ok(isHookPill(id), id); + } + for (const id of ["agent_claude-desktop", "ai_anthropic", "integration_stripe", "agent_unknown"]) { + assert.ok(!isHookPill(id), id); + } +}); + +test("a live session names its tool next to the project", () => { + assert.equal(sessionSubtitle("integration_claude"), "Claude Code"); + assert.equal(sessionSubtitle("agent_cursor"), "Cursor"); + assert.equal(sessionSubtitle("agent_claude-desktop"), "Claude Desktop"); + assert.equal(sessionSubtitle("agent_gemini"), "Agent"); + assert.equal(sessionSubtitle("agent_whatever"), "Agent"); +}); + +// ── Declaring pills ────────────────────────────────────────────────────────── + +test("a declaration from an older or edited settings file is made usable", () => { + assert.deepEqual( + sanitizeDeclared({ mainPill: "integration_stripe", activeIntegrations: ["integration_claude", "x"] }, "linux"), + { mainPill: "integration_claude", activeIntegrations: [] }, + ); + assert.deepEqual( + sanitizeDeclared({ mainPill: "agent_cursor", activeIntegrations: ["agent_cursor", "integration_n8n", "integration_n8n", "agent_hermes"] }, "linux"), + { mainPill: "agent_cursor", activeIntegrations: ["integration_n8n"] }, + ); + // No mainPill at all: a settings.json from before this version. + assert.deepEqual( + sanitizeDeclared({ activeIntegrations: ["integration_github"] }, "windows"), + { mainPill: "integration_claude", activeIntegrations: ["integration_github"] }, + ); + assert.equal(sanitizeDeclared({ mainPill: "agent_claude-desktop", activeIntegrations: [] }, "windows").mainPill, + "integration_claude"); +}); + +test("up to four pills next to the main one, never the main one itself", () => { + const d = { + mainPill: "integration_claude", + activeIntegrations: ["integration_n8n", "integration_github", "integration_stripe"], + }; + const four = toggleDeclared(d, "agent_gemini", "linux"); + assert.equal(four.length, MAX_DECLARED); + assert.equal(toggleDeclared({ ...d, activeIntegrations: four }, "ai_anthropic", "linux"), null); + assert.equal(toggleDeclared(d, "integration_claude", "linux"), null); + assert.equal(toggleDeclared(d, "agent_hermes", "linux"), null); + assert.equal(toggleDeclared(d, "agent_claude-desktop", "linux"), null); + assert.ok(toggleDeclared(d, "agent_claude-desktop", "windows").includes("agent_claude-desktop")); + assert.deepEqual(toggleDeclared(d, "integration_github", "linux"), ["integration_n8n", "integration_stripe"]); + // Coming soon can still be declared, as on macOS. + assert.ok(toggleDeclared(d, "ai_google", "linux").includes("ai_google")); +}); + +test("picking a main pill takes it out of the declared ones, and only workspace tools qualify", () => { + const d = { mainPill: "integration_claude", activeIntegrations: ["agent_cursor", "integration_n8n"] }; + assert.deepEqual(chooseMainPill(d, "agent_cursor", "linux"), { + mainPill: "agent_cursor", activeIntegrations: ["integration_n8n"], + }); + assert.equal(chooseMainPill(d, "integration_n8n", "linux"), null); + assert.equal(chooseMainPill(d, "agent_gemini", "linux"), null); +}); + +test("pills are ordered main first, then other agents, then the catalog's order", () => { + const ids = (list) => list.map((t) => t.id); + const tasks = ["integration_stripe", "agent_mine", "integration_n8n", "agent_cursor", "integration_claude", "agent_gemini"] + .map((id) => ({ id })); + assert.deepEqual(ids(orderPills(tasks, "integration_claude")), [ + "integration_claude", "agent_mine", "agent_cursor", "agent_gemini", "integration_n8n", "integration_stripe", + ]); + assert.deepEqual(ids(orderPills(tasks, "agent_cursor")).slice(0, 3), [ + "agent_cursor", "agent_mine", "integration_claude", + ]); +}); + +// ── In the app state ───────────────────────────────────────────────────────── + +beforeEach(() => { + State.tasks = []; + State.focusId = null; + State.os = "windows"; + State.settings = { ...DEFAULT_SETTINGS }; +}); + +const ids = () => State.tasks.map((t) => t.id); + +test("the main pill always loads, the declared ones join it, and focus starts on it", () => { + State.settings.mainPill = "agent_cursor"; + State.settings.activeIntegrations = ["integration_github", "agent_gemini"]; + State.loadIntegrationTasks(); + assert.deepEqual(ids(), ["agent_cursor", "agent_gemini", "integration_github"]); + assert.equal(State.focusId, "agent_cursor"); + assert.equal(State.tasks[1].name, "Gemini CLI"); + assert.equal(State.tasks[1].color, "#8AB4F8"); +}); + +test("an old settings file gets VS Code as its main pill and keeps its integrations", () => { + State.settings = { ...DEFAULT_SETTINGS, mainPill: undefined }; + State.loadIntegrationTasks(); + assert.deepEqual(ids(), [ + "integration_claude", "integration_resend", "integration_n8n", "integration_vercel", "integration_github", + ]); + assert.equal(State.settings.mainPill, "integration_claude"); +}); + +test("switching the main pill frees its slot and the old main pill goes when idle", () => { + State.loadIntegrationTasks(); + State.settings.mainPill = "agent_codex"; + State.loadIntegrationTasks(); + assert.equal(ids()[0], "agent_codex"); + assert.ok(!ids().includes("integration_claude")); + assert.equal(State.focusId, "agent_codex"); +}); + +test("a pill in the middle of a session stays until the session ends", () => { + State.loadIntegrationTasks(); + State.tasks[0].state = "working"; + State.settings.mainPill = "agent_cursor"; + State.loadIntegrationTasks(); + assert.ok(ids().includes("integration_claude")); + State.removeTask("integration_claude"); + assert.ok(!ids().includes("integration_claude")); +}); + +test("the end of a session puts a kept pill back as it was, and removes any other", () => { + State.settings.activeIntegrations = ["agent_gemini"]; + State.loadIntegrationTasks(); + const gemini = State.tasks.find((t) => t.id === "agent_gemini"); + Object.assign(gemini, { name: "proj", state: "finished", steps: ["a"], pillBadge: "finished" }); + State.removeTask("agent_gemini"); + assert.deepEqual( + [gemini.name, gemini.state, gemini.steps, gemini.pillBadge], + ["Gemini CLI", "idle", [], null], + ); + State.upsertExternalAgent("agent_mine", "mine", "#22C55E"); + State.setFocus("agent_mine"); + State.removeTask("agent_mine"); + assert.ok(!ids().includes("agent_mine")); + assert.equal(State.focusId, "integration_claude"); +}); + +test("a Claude Code session gets its pill even when it is not loaded", () => { + State.settings.mainPill = "agent_codex"; + State.loadIntegrationTasks(); + const t = State.upsertWorkspacePill("integration_claude", "proj", "/p"); + assert.equal(ids()[1], "integration_claude"); + assert.equal(t.name, "proj"); + assert.equal(t.sessionCwd, "/p"); + assert.equal(State.upsertWorkspacePill("not_a_pill", "x", ""), null); +}); + +test("toggling declares up to four pills and never the main one", () => { + State.settings.activeIntegrations = []; + State.loadIntegrationTasks(); + for (const id of ["integration_n8n", "agent_gemini", "ai_anthropic", "integration_stripe", "integration_github"]) { + State.toggleIntegration(id); + } + assert.deepEqual(State.settings.activeIntegrations, [ + "integration_n8n", "agent_gemini", "ai_anthropic", "integration_stripe", + ]); + State.toggleIntegration("integration_claude"); + assert.ok(ids().includes("integration_claude")); + State.setFocus("agent_gemini"); + State.toggleIntegration("agent_gemini"); + assert.ok(!ids().includes("agent_gemini")); + assert.equal(State.focusId, "integration_claude"); +}); From ec56e2a34b291e61a8e3724a21a043f70111489c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:51:59 +0200 Subject: [PATCH 52/77] Windows/Linux: the permission card waits for you, and can be folded away (Mac #117, #120, #290) - A permission request or a question always brings its card up, even over another pill or an island that is already open; its pill comes to the front and the pill you were on comes back once you answer, or once the card is withdrawn. Before, a request behind another pill only badged it. - While the card waits, no other alert covers it, the mouse never folds it, and opening the island shows it again. - New: fold the waiting card to the compact island without answering, with the chevron in its corner or Escape in the island. The request keeps waiting, the compact island stays on screen, and nothing is ever decided for you. Co-Authored-By: Claude Opus 5.5 --- windows/src/core/state.ts | 39 ++++++++++++++++++++ windows/src/island/fsm.ts | 5 ++- windows/src/island/hooks.ts | 33 +++++++---------- windows/src/island/island.ts | 36 +++++++++++++++--- windows/src/style.css | 8 ++++ windows/src/views/icons.ts | 2 + windows/src/views/views.ts | 23 +++++++++++- windows/tests/fsm.test.mjs | 25 +++++++++++++ windows/tests/hooks.test.mjs | 71 +++++++++++++++++++++++++++++++++--- 9 files changed, 208 insertions(+), 34 deletions(-) diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 628d1ef4f..5409ba83a 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -35,6 +35,8 @@ export interface AgentTask { export interface ApprovalInfo { requestId: string; sessionId: string; + /** The pill the request belongs to: VS Code's, or Cursor's. */ + pillId: string; tool: string; command: string; /** Set when Claude Code is asking a question rather than for a permission. */ @@ -147,6 +149,8 @@ class AppState { searchResult: SearchResult | null = null; chatHistory: ChatMessage[] = []; pendingApproval: ApprovalInfo | null = null; + /** The pill that was in front when the card came up; it comes back after. */ + focusBeforeApproval: string | null = null; integrations: Record = {}; @@ -189,6 +193,36 @@ class AppState { this.notify(); } + /** + * A permission card or a question comes up: its pill comes to the front, and + * the pill that was there is remembered (HookServer.focusBeforeApproval). + */ + beginApproval(info: ApprovalInfo) { + this.pendingApproval = info; + this.isPinned = true; + if (this.focusBeforeApproval == null) this.focusBeforeApproval = this.focusId; + this.setFocus(info.pillId); + } + + /** + * The card has its answer, or is withdrawn: the session carries on, and the + * pill you were on comes back — unless you moved to another one meanwhile. + */ + endApproval() { + const req = this.pendingApproval; + if (!req) return; + this.pendingApproval = null; + this.isPinned = false; + this.updateTask(req.pillId, "working"); + this.setPillBadge(req.pillId, null); + const previous = this.focusBeforeApproval; + this.focusBeforeApproval = null; + if (previous && this.focusId === req.pillId && this.tasks.some((t) => t.id === previous)) { + this.focusId = previous; + } + this.notify(); + } + updateTask(id: string, state: BotStateName) { const t = this.tasks.find((x) => x.id === id); if (!t) return; @@ -322,7 +356,12 @@ class AppState { this.loadIntegrationTasks(); } + /** + * What the island opens on. A card waiting for an answer comes first, so + * reopening a folded island shows it again (Mac #117, #290). + */ defaultView(): IslandViewName { + if (this.pendingApproval) return this.pendingApproval.questions ? "question" : "approval"; return this.tasks.length === 0 ? "empty" : "overview"; } } diff --git a/windows/src/island/fsm.ts b/windows/src/island/fsm.ts index 8cd46d11f..64ef42856 100644 --- a/windows/src/island/fsm.ts +++ b/windows/src/island/fsm.ts @@ -125,9 +125,12 @@ export class IslandStateMachine { private schedulePetitHide() { this.clear("petitHide"); + // A card folded away while it waits for an answer keeps the compact island + // on screen, so it can be reopened (isHeldOpen on macOS). + if (this.pinned) return; this.petitHide = window.setTimeout(() => { this.petitHide = null; - if (this.state === "petit") this.transition("hidden"); + if (this.state === "petit" && !this.pinned) this.transition("hidden"); }, this.petitToHiddenDelay * 1000); } diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index cb9ef14fa..53a1962ca 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -18,11 +18,8 @@ let pendingTimeout: number | null = null; /** Takes the approval or question card down and gives the island back. */ function dropPendingCard(island: Island): void { if (!State.pendingApproval) return; - State.pendingApproval = null; - State.isPinned = false; + State.endApproval(); island.dropPin(); - State.updateTask(CLAUDE_ID, "working"); - State.setPillBadge(CLAUDE_ID, null); if (State.view === "approval" || State.view === "question") { island.setView(State.defaultView()); } @@ -323,7 +320,8 @@ function handleHook(island: Island, payload: HookPayload) { State.updateTask(agentId, "finished"); if (payload.message) State.appendStep(agentId, payload.message.slice(0, 60)); Sound.play("finish"); - if (focused) surface("finished", true); + // A card waiting for an answer is never covered by another alert. + if (focused && !State.pendingApproval) surface("finished", true); else State.setPillBadge(agentId, "finished"); cancelStopTimer(agentId); stopTimers.set( @@ -344,7 +342,7 @@ function handleHook(island: Island, payload: HookPayload) { supersedeStop(); State.updateTask(agentId, "error"); Sound.play("error"); - if (focused) surface("error", true); + if (focused && !State.pendingApproval) surface("error", true); else State.setPillBadge(agentId, "error"); break; @@ -394,28 +392,23 @@ function handleHook(island: Island, payload: HookPayload) { // shows the options and sends back the one that was picked. const questions = askedQuestions(tool, input); const view = questions ? "question" : "approval"; - State.pendingApproval = { + // The card always comes up, even over another pill or an island that is + // already open: its pill comes to the front, and the one you were on + // comes back once you answer (Mac #117, #120). + State.beginApproval({ requestId, - sessionId: payload.session_id ?? "", + sessionId, + pillId: agentId, tool, command: approvalTarget(tool, input), ...(questions ? { questions } : {}), - }; + }); // 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, view); - State.isPinned = true; + State.updateTask(agentId, view); Sound.play(view); - if (focused) { - island.alert(view); - } else { - // 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"); - island.reveal(); - } + island.alert(view); // Coucou answers within 108 s or not at all; after that the terminal has // taken over and the card would be lying. pendingTimeout = window.setTimeout(() => { diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 322a4a7f6..384cf4080 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -104,14 +104,23 @@ export class Island { /** The request has its answer: the card goes and the session carries on. */ private closeApproval() { - State.pendingApproval = null; - State.isPinned = false; + State.endApproval(); this.fsm.pinned = false; - State.updateTask("integration_claude", "working"); - State.setPillBadge("integration_claude", null); this.setView(State.defaultView()); } + /** + * Folds a card that is waiting for an answer down to the compact island, + * without answering it (Mac #290). Nothing is decided: the request keeps + * waiting, the island stays on screen, and opening it shows the card again. + */ + foldApproval() { + if (!State.pendingApproval || State.mode !== "expanded") return; + State.isPinned = true; + this.fsm.pinned = true; + this.fsm.forcePetit(); + } + // ── DOM ───────────────────────────────────────────────────────────────────── private build() { @@ -119,6 +128,7 @@ export class Island { setView: (v) => this.setView(v), cancelDrop: () => this.discardDrop(), collapse: () => this.collapse(), + foldApproval: () => this.foldApproval(), setFocus: (id) => { State.setFocus(id); Sound.play("blip"); @@ -287,7 +297,8 @@ export class Island { if (mode === "expanded") Sound.play("open"); if (prev === "expanded") { Sound.play("close"); - State.isPinned = false; + // A folded card is still waiting: it keeps the island pinned. + if (!State.pendingApproval) State.isPinned = false; void Bridge.focusWindow(false); } if (mode !== "expanded") { @@ -338,6 +349,11 @@ export class Island { } collapse() { + // A waiting card is only ever folded, never dropped by a close. + if (State.pendingApproval) { + this.foldApproval(); + return; + } State.isPinned = false; this.fsm.pinned = false; // Drive the state machine rather than the mode: setting the mode behind its @@ -360,6 +376,8 @@ export class Island { /** An alert stopped waiting for an answer: let the island auto-close again. */ dropPin() { this.fsm.pinned = false; + // The countdown the pin held back starts now, if the mouse is elsewhere. + if (!this.wasInIsland) this.fsm.mouseLeft(); } // ── File drop ─────────────────────────────────────────────────────────────── @@ -580,8 +598,14 @@ export class Island { } }); + // Only keys typed into the island itself land here, never Escape typed in + // a terminal — so it may fold a waiting card away, as Escape in the notch + // does on macOS. window.addEventListener("keydown", (e) => { - if (e.key === "Escape" && State.mode === "expanded" && !State.isPinned) this.collapse(); + if (e.key === "Escape" && State.mode === "expanded") { + if (State.pendingApproval) this.foldApproval(); + else if (!State.isPinned) this.collapse(); + } State.lastActivity = performance.now(); }); diff --git a/windows/src/style.css b/windows/src/style.css index 335ed1593..07f6bae6c 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -444,6 +444,14 @@ body { z-index: 2; } +/* Folds a waiting permission card or question away without answering it. */ +.fold { + position: absolute; + top: 8px; + right: 10px; + z-index: 2; +} + /* Ticker */ .ticker { diff --git a/windows/src/views/icons.ts b/windows/src/views/icons.ts index d2ff82475..464e99129 100644 --- a/windows/src/views/icons.ts +++ b/windows/src/views/icons.ts @@ -20,6 +20,8 @@ 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", + // chevron.up + chevronUp: "M5.5 15 12 8.5 18.5 15", // checkmark check: "M5 12.5 9.5 17 19 7.5", // arrow.up (send) diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index 4a7186c3f..d77610362 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -18,6 +18,8 @@ export interface ViewActions { /** "Cancel" on a dropped file: forgets it and goes back home. */ cancelDrop(): void; collapse(): void; + /** Folds a waiting card to the compact island without answering it. */ + foldApproval(): void; setFocus(id: string): void; openTerminal(): void; /** The ↗ button: opens whatever the focused pill points at. */ @@ -310,11 +312,25 @@ function buildEmpty(actions: ViewActions): ViewHost { /** How long a fresh permission card ignores clicks on its buttons. */ const CLICK_GUARD_MS = 600; +/** + * The ⌃ in the corner of a waiting card: folds the island to its compact size + * and leaves the request waiting — nothing is answered (Mac #290). Opening the + * island again brings the card back. + */ +function foldButton(actions: ViewActions): HTMLElement { + return h( + "button", + { class: "icon-btn fold", title: "Later — keep it waiting", onclick: () => actions.foldApproval() }, + svg(ICONS.chevronUp, 8, { stroke: 2.4 }), + ); +} + function buildApproval(actions: ViewActions): ViewHost { const who = h("div"); const code = h("div", { class: "code" }); const row = h("div", { class: "actions" }); - const el = h("div", { class: "view" }, card("amber", stack(116, 16, who, code, row))); + const el = h("div", { class: "view" }, + card("amber", stack(116, 16, who, code, row), foldButton(actions))); let rowKey = ""; // The card pops up under a cursor that was busy with something else: a click // meant for the window underneath must not land on Allow. Clicks in the first @@ -359,7 +375,8 @@ function buildQuestion(actions: ViewActions): ViewHost { const who = h("div"); const title = h("div", { class: "title question-text" }); const row = h("div", { class: "actions options" }); - const el = h("div", { class: "view" }, card("cyan", stack(116, 16, who, title, row))); + const fold = foldButton(actions); + const el = h("div", { class: "view" }, card("cyan", stack(116, 16, who, title, row), fold)); // Where we are in the request on screen: which question, what is answered so // far, and what is ticked in a pick-several question. @@ -384,6 +401,8 @@ function buildQuestion(actions: ViewActions): ViewHost { sync() { const questions = State.pendingApproval?.questions; clear(who); + // Only a request that is waiting can be folded away and come back. + fold.style.display = State.pendingApproval ? "" : "none"; // A question that arrived as a notification has nothing to pick from. if (!questions) { diff --git a/windows/tests/fsm.test.mjs b/windows/tests/fsm.test.mjs index 06156a9d0..9b51ae2f1 100644 --- a/windows/tests/fsm.test.mjs +++ b/windows/tests/fsm.test.mjs @@ -278,6 +278,31 @@ test("the deadline the countdown bar reads follows the delay and clears with the assert.equal(fsm.homeCollapseDueAt, null); }); +// ── A card folded away while it waits (Mac #290) ───────────────────────────── + +test("a folded card keeps the compact island on screen until it is answered", () => { + fsm.forceHome(); + fsm.pinned = true; + fsm.forcePetit(); + fsm.mouseLeft(); + seconds(600); + assert.equal(fsm.state, "petit"); + // Reopening brings it back open, and the mouse leaving does not fold it. + fsm.mouseEntered(); + fsm.click(); + fsm.mouseLeft(); + seconds(600); + assert.equal(fsm.state, "home"); + // Answered: the usual timers again. + fsm.pinned = false; + fsm.mouseLeft(); + seconds(15); + assert.equal(fsm.state, "petit"); + fsm.mouseLeft(); + seconds(60); + assert.equal(fsm.state, "hidden"); +}); + test("an unusable delay is ignored", () => { for (const bad of [NaN, -1, Infinity]) fsm.homeToPetitDelay = bad; assert.equal(fsm.homeToPetitDelay, 15); diff --git a/windows/tests/hooks.test.mjs b/windows/tests/hooks.test.mjs index ffa7a5129..b5e2f7b55 100644 --- a/windows/tests/hooks.test.mjs +++ b/windows/tests/hooks.test.mjs @@ -299,6 +299,7 @@ test("a permission request puts the card up, says exactly what it authorises, an assert.deepEqual(State.pendingApproval, { requestId: "r1", sessionId: "s1", + pillId: CLAUDE, tool: "Write", command: "Write · C:\\Users\\me\\proj\\.env", }); @@ -323,12 +324,71 @@ test("the card names the most specific thing the tool carries", () => { assert.equal(target(undefined, undefined), "Tool"); }); -test("a request behind another pill badges and reveals instead of taking the view", () => { +test("a request behind another pill comes to the front, and that pill comes back after (Mac #120)", () => { State.setFocus("integration_n8n"); ask("r1"); - assert.deepEqual(asked, ["reveal"]); - assert.equal(task().pillBadge, "approval"); + assert.deepEqual(asked, ["alert:approval"]); + assert.equal(State.focusId, CLAUDE); assert.deepEqual(sent("approval_ack"), [{ requestId: "r1" }]); + State.endApproval(); + assert.equal(State.focusId, "integration_n8n"); + assert.equal(State.isPinned, false); + assert.equal(task().state, "working"); +}); + +test("the pill you were on comes back after a withdrawn card too", () => { + State.setFocus("integration_n8n"); + ask("r1"); + seconds(110); + assert.equal(State.pendingApproval, null); + assert.equal(State.focusId, "integration_n8n"); +}); + +test("a pill picked while the card was up keeps the front after the answer", () => { + State.setFocus("integration_n8n"); + ask("r1"); + State.setFocus("integration_github"); + State.endApproval(); + assert.equal(State.focusId, "integration_github"); +}); + +test("the card shows when the island is already open, and is what it reopens on", () => { + State.mode = "expanded"; + ask("r1"); + assert.deepEqual(asked, ["alert:approval"]); + assert.equal(State.defaultView(), "approval"); + State.endApproval(); + assert.equal(State.defaultView(), "overview"); +}); + +test("a question is what the island reopens on while it waits", () => { + ask("r1", { + tool_name: "AskUserQuestion", + tool_input: { questions: [{ question: "Which?", options: [{ label: "A" }, { label: "B" }] }] }, + }); + assert.equal(State.defaultView(), "question"); +}); + +test("a finished or failed session behind a waiting card only badges its pill", () => { + State.settings.activeIntegrations = ["agent_gemini"]; + State.loadIntegrationTasks(); + hook({ hook_event_name: "SessionStart", coucou_agent: "gemini" }); + ask("r1"); + asked = []; + State.focusId = "agent_gemini"; + hook({ hook_event_name: "Stop", coucou_agent: "gemini" }); + hook({ hook_event_name: "StopFailure", coucou_agent: "gemini" }); + assert.deepEqual(asked, []); + assert.equal(task("agent_gemini").pillBadge, "error"); +}); + +test("a request from Claude Code in Cursor's terminal goes on the Cursor pill", () => { + ask("r1", { term_editor: "cursor" }); + assert.equal(State.pendingApproval.pillId, "agent_cursor"); + assert.equal(task("agent_cursor").state, "approval"); + assert.equal(task("agent_cursor").name, "proj"); + assert.equal(State.focusId, "agent_cursor"); + assert.equal(task().state, "idle"); }); test("a second request never replaces the card: it goes back to the terminal", () => { @@ -407,11 +467,12 @@ test("a permission request within 5.2 s of a stop keeps its card", () => { assert.equal(State.pendingApproval.requestId, "r1"); }); -test("a permission request behind another pill keeps its badge past the stop timer", () => { +test("a permission request behind another pill keeps the front past the stop timer", () => { State.setFocus("integration_n8n"); afterStop(() => ask("r1")); assert.equal(task().state, "approval"); - assert.equal(task().pillBadge, "approval"); + assert.equal(State.focusId, CLAUDE); + assert.equal(State.pendingApproval.requestId, "r1"); }); test("a rate limit within 5.2 s of a stop stays a rate limit", () => { From 7deafb10e80bd9bf7c2142b901869ff0be586e9e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:53:34 +0200 Subject: [PATCH 53/77] Island: agents' approvals on their own pill, and the wording fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex, Copilot CLI and Muse Code permission requests now get the Allow / Deny card on their own pill (agent_codex…), as on the Mac; every other agent's request is still declined at once so it asks in its terminal. The card remembers its pill, so answering it and its timeout put that pill back, not Claude Code's; clicking a pill badged with a waiting request opens its card. Only Claude Code's AskUserQuestion becomes a question card. A pending card whose turn is over (Stop, StopFailure, UserPromptSubmit, SessionEnd, Interrupt from the same session) is taken down and the relay released without a decision. Codex's Interrupt puts its pill to idle; an agent's Stop shows last_assistant_message. Names and colours of the known agents come from PillCatalog.swift (src/island/agents.ts). Wording: Claude Code keeps "Claude Code finished"; an agent's card says "finished" / "Agent" next to the agent's name instead of repeating it, and its pill shows the ticker. Co-Authored-By: Claude Opus 5.5 --- windows/src/core/state.ts | 2 + windows/src/island/agents.ts | 52 ++++++++++++++++ windows/src/island/hooks.ts | 111 +++++++++++++++++----------------- windows/src/island/island.ts | 9 ++- windows/src/views/views.ts | 19 +++--- windows/tests/agents.test.mjs | 38 ++++++++++++ windows/tests/hooks.test.mjs | 84 +++++++++++++++++++++++-- 7 files changed, 246 insertions(+), 69 deletions(-) create mode 100644 windows/src/island/agents.ts create mode 100644 windows/tests/agents.test.mjs diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 4785897aa..cb0d15579 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -29,6 +29,8 @@ export interface AgentTask { export interface ApprovalInfo { requestId: string; sessionId: string; + /** The pill the request belongs to: Claude Code's, or an agent's (Codex…). */ + pillId: string; tool: string; command: string; /** Set when Claude Code is asking a question rather than for a permission. */ diff --git a/windows/src/island/agents.ts b/windows/src/island/agents.ts new file mode 100644 index 000000000..68ccc6cea --- /dev/null +++ b/windows/src/island/agents.ts @@ -0,0 +1,52 @@ +// Agents other than Claude Code, as the island shows them. Ids, names and colours +// are PillCatalog.swift's (pill `agent_`), so a pill looks the same on every +// platform. + +export interface KnownAgent { + name: string; + color: string; +} + +export const KNOWN_AGENTS: Record = { + cursor: { name: "Cursor", color: "#C0C4CC" }, + antigravity: { name: "Antigravity", color: "#E879F9" }, + codex: { name: "Codex", color: "#2DD4BF" }, + gemini: { name: "Gemini CLI", color: "#8AB4F8" }, + copilot: { name: "Copilot CLI", color: "#818CF8" }, + muse: { name: "Muse Code", color: "#38BDF8" }, + opencode: { name: "OpenCode", color: "#4ADE80" }, + amp: { name: "Amp", color: "#F59E0B" }, + hermes: { name: "Hermes", color: "#C084FC" }, + "claude-desktop": { name: "Claude Desktop", color: "#D97757" }, +}; + +/** + * Agents whose permission requests get an Allow / Deny card, as on the Mac. + * Must match `takes_decisions` in the relay (hook/src/reply.rs): any other + * agent's request is handed straight back to its terminal. + */ +export const APPROVAL_AGENTS = new Set(["codex", "copilot", "muse"]); + +/** Same rule as HookServer.validateAgent on macOS. "claude" is reserved. */ +export function validateAgent(raw: string | undefined): string | null { + if (!raw || raw.length > 24 || raw === "claude") return null; + if (!/^[a-z0-9-]+$/.test(raw)) return null; + return raw; +} + +const FALLBACK_COLORS = ["#22C55E", "#EAB308", "#60A5FA", "#E879F9"]; + +export function agentColor(id: string): string { + const known = KNOWN_AGENTS[id]; + if (known) return known.color; + let h = 0; + for (let i = 0; i < id.length; i++) { + h = (Math.imul(31, h) + id.charCodeAt(i)) | 0; + } + return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; +} + +/** The pill label: the agent's own name when Coucou knows it, else its id. */ +export function agentName(id: string): string { + return KNOWN_AGENTS[id]?.name ?? id; +} diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index bd87e3a69..fe580f881 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -1,11 +1,14 @@ -// Claude Code hook events → island state. +// Hook events from Claude Code and every other agent → 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. +// The relay has already mapped every agent's events and fields onto Claude +// Code's (hook/src/normalize.rs), so one handler serves them all. import { Bridge, onEvent } from "../core/bridge"; import { Sound } from "../core/sound"; import { State, type AskedQuestion } from "../core/state"; +import { APPROVAL_AGENTS, agentColor, agentName, validateAgent } from "./agents"; import type { Island } from "./island"; const CLAUDE_ID = "integration_claude"; @@ -16,11 +19,12 @@ let pendingTimeout: number | null = null; /** Takes the approval or question card down and gives the island back. */ function dropPendingCard(island: Island): void { if (!State.pendingApproval) return; + const pill = State.pendingApproval.pillId; State.pendingApproval = null; State.isPinned = false; island.dropPin(); - State.updateTask(CLAUDE_ID, "working"); - State.setPillBadge(CLAUDE_ID, null); + State.updateTask(pill, "working"); + State.setPillBadge(pill, null); if (State.view === "approval" || State.view === "question") { island.setView(State.defaultView()); } @@ -38,12 +42,17 @@ function cancelStopTimer(id: string): boolean { return true; } +/** Events after which a pending permission request of the same session is moot. */ +const TURN_OVER = new Set(["Stop", "StopFailure", "UserPromptSubmit", "SessionEnd", "Interrupt"]); + interface HookPayload { hook_event_name?: string; request_id?: string; session_id?: string; cwd?: string; message?: string; + /** What an agent said last, on Stop (Hermes, Codex). */ + last_assistant_message?: string; /** UserPromptSubmit carries `prompt`; `message` belongs to Notification/Stop. */ prompt?: string; tool_name?: string; @@ -52,44 +61,6 @@ interface HookPayload { coucou_agent?: string; } -/** Same rule as HookServer.validateAgent on macOS. "claude" is reserved. */ -function validateAgent(raw: string | undefined): string | null { - if (!raw || raw.length > 24 || raw === "claude") return null; - if (!/^[a-z0-9-]+$/.test(raw)) return null; - return raw; -} - -const FALLBACK_COLORS = ["#22C55E", "#EAB308", "#60A5FA", "#E879F9"]; - -const KNOWN_AGENT_COLORS: Record = { - gemini: "#8AB4F8", - antigravity: "#E879F9", - cursor: "#C0C4CC", - codex: "#2DD4BF", - opencode: "#38BDF8", -}; - -const KNOWN_AGENT_NAMES: Record = { - gemini: "Gemini CLI", - antigravity: "Antigravity", - cursor: "Cursor", - codex: "Codex", - opencode: "OpenCode", -}; - -function agentColor(name: string): string { - if (KNOWN_AGENT_COLORS[name]) return KNOWN_AGENT_COLORS[name]; - let h = 0; - for (let i = 0; i < name.length; i++) { - h = (Math.imul(31, h) + name.charCodeAt(i)) | 0; - } - return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; -} - -function agentDisplayName(name: string): string { - return KNOWN_AGENT_NAMES[name] ?? name; -} - const PROJECT_ALIASES: Record = { "notch-buddy": "Notch Buddy", notchbuddy: "Notch Buddy", @@ -245,7 +216,7 @@ function handleHook(island: Island, payload: HookPayload) { /** Ensure the agent pill exists (no-op for Claude Code). */ const ensurePill = () => { if (isExternalAgent) { - State.upsertExternalAgent(agentId, agentDisplayName(validAgent!), agentColor(validAgent!)); + State.upsertExternalAgent(agentId, agentName(validAgent!), agentColor(validAgent!)); } else { upsert(projectName, cwd); } @@ -260,6 +231,22 @@ function handleHook(island: Island, payload: HookPayload) { if (cancelStopTimer(agentId)) State.setPillBadge(agentId, null); }; + // The turn that asked for a permission is over — answered in the terminal, + // interrupted, or a new prompt — so the card would be lying. It goes, and the + // relay is released without a decision. Same rule as the Mac. + const pending = State.pendingApproval; + if ( + pending && + TURN_OVER.has(name) && + pending.pillId === agentId && + pending.sessionId === (payload.session_id ?? "") + ) { + if (pendingTimeout != null) window.clearTimeout(pendingTimeout); + pendingTimeout = null; + if (pending.requestId) void Bridge.approvalDecline(pending.requestId); + dropPendingCard(island); + } + switch (name) { case "SessionStart": ensurePill(); @@ -324,9 +311,11 @@ function handleHook(island: Island, payload: HookPayload) { break; } - case "Stop": + case "Stop": { State.updateTask(agentId, "finished"); - if (payload.message) State.appendStep(agentId, payload.message.slice(0, 60)); + // Agents report their last words as `last_assistant_message` (Hermes, Codex). + const said = payload.message ?? (isExternalAgent ? payload.last_assistant_message : undefined); + if (said) State.appendStep(agentId, said.slice(0, 60)); Sound.play("finish"); if (focused) surface("finished", true); else State.setPillBadge(agentId, "finished"); @@ -344,6 +333,14 @@ function handleHook(island: Island, payload: HookPayload) { }, 5200), ); break; + } + + case "Interrupt": + // Codex: the user stopped the turn. Back to idle, nothing to celebrate. + supersedeStop(); + State.updateTask(agentId, "idle"); + State.setPillBadge(agentId, null); + break; case "StopFailure": supersedeStop(); @@ -374,10 +371,10 @@ function handleHook(island: Island, payload: HookPayload) { break; case "PermissionRequest": { - // External agents do not get an approval card — showing one would look like - // a Claude Code request. Decline immediately so the agent re-asks in its - // terminal. Approval support for other agents will come with Codex support. - if (isExternalAgent) { + // Only Claude Code and the agents the relay can answer for (Codex, Copilot + // CLI, Muse Code — same as the Mac) get a card. Anyone else's request is + // declined at once, so the agent asks in its own terminal. + if (isExternalAgent && !APPROVAL_AGENTS.has(validAgent!)) { if (payload.request_id) void Bridge.approvalDecline(payload.request_id); break; } @@ -390,18 +387,20 @@ function handleHook(island: Island, payload: HookPayload) { if (requestId) void Bridge.approvalDecline(requestId); break; } - upsert(projectName, cwd); + ensurePill(); supersedeStop(); if (pendingTimeout != null) window.clearTimeout(pendingTimeout); const tool = payload.tool_name ?? "Tool"; const input = payload.tool_input ?? {}; // Claude Code asking a question is not a permission to grant: the island - // shows the options and sends back the one that was picked. - const questions = askedQuestions(tool, input); + // shows the options and sends back the one that was picked. Only Claude + // Code asks questions this way. + const questions = isExternalAgent ? null : askedQuestions(tool, input); const view = questions ? "question" : "approval"; State.pendingApproval = { requestId, sessionId: payload.session_id ?? "", + pillId: agentId, tool, command: approvalTarget(tool, input), ...(questions ? { questions } : {}), @@ -409,16 +408,16 @@ function handleHook(island: Island, payload: HookPayload) { // 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, view); + State.updateTask(agentId, view); State.isPinned = true; Sound.play(view); - if (focused) { + if (State.focusId === agentId) { island.alert(view); } else { - // Another agent holds the view, so the card would yank it away. The badge + // Another pill 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"); + // anything, hence the reveal. Clicking the pill brings the card up. + State.setPillBadge(agentId, "approval"); island.reveal(); } // Coucou answers within 108 s or not at all; after that the terminal has diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 54dbf5f09..4f547d40a 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -104,11 +104,12 @@ export class Island { /** The request has its answer: the card goes and the session carries on. */ private closeApproval() { + const pill = State.pendingApproval?.pillId ?? "integration_claude"; State.pendingApproval = null; State.isPinned = false; this.fsm.pinned = false; - State.updateTask("integration_claude", "working"); - State.setPillBadge("integration_claude", null); + State.updateTask(pill, "working"); + State.setPillBadge(pill, null); this.setView(State.defaultView()); } @@ -122,6 +123,10 @@ export class Island { setFocus: (id) => { State.setFocus(id); Sound.play("blip"); + // A pill badged with a waiting request opens on its card: that is how a + // request that arrived behind another pill gets answered. + const req = State.pendingApproval; + if (req?.pillId === id) this.setView(req.questions ? "question" : "approval"); }, openTerminal: () => { const cwd = State.focusTask?.sessionCwd ?? null; diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index d87172f7b..f49f714e2 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -180,10 +180,12 @@ function buildOverview(actions: ViewActions): ViewHost { mode = null; } - // Tasks with live steps or non-idle state keep the ticker; every other - // integration pill shows its own card, exactly like IntegrationCardView. + // VS Code with a live Claude Code session keeps the ticker, and so does an + // agent's pill (Gemini CLI, Codex…) — it only exists while its session + // does. Every other pill shows its own card, exactly like IntegrationCardView. const sessionActive = - task && (!task.isIntegration || task.id === "integration_claude") && (task.state !== "idle" || task.steps.length > 0); + (task?.id === "integration_claude" && (task.state !== "idle" || task.steps.length > 0)) || + (task != null && task.source === "agent" && !task.isIntegration); if (task && sessionActive) { if (mode !== "ticker") { @@ -193,7 +195,9 @@ function buildOverview(actions: ViewActions): ViewHost { cardKey = ""; } clear(who); - const toolLabel = task.source === "claudeCode" ? "Claude Code" : task.source === "agent" ? task.name : "n8n"; + // The agent's name is already the pill's: the label says what kind of + // pill it is, as on the Mac. + const toolLabel = task.source === "claudeCode" ? "Claude Code" : task.source === "agent" ? "Agent" : "n8n"; who.append( dot(task.color, 7), h("span", { class: "name", text: task.name }), @@ -452,7 +456,8 @@ function buildError(actions: ViewActions): ViewHost { sync() { const task = State.focusTask; clear(who); - const whoLabel = task?.source === "n8n" ? "n8n" : (task?.name ?? "Agent"); + // agentWho already shows an agent's name: its label is just the kind. + const whoLabel = task?.source === "n8n" ? "n8n" : task?.source === "agent" ? "Agent" : "Claude Code"; who.append(agentWho(task, whoLabel)); title.textContent = task?.source === "n8n" ? "Workflow stopped." : "Session stopped on an error."; detail.textContent = task?.steps.at(-1) ?? "No detail available."; @@ -474,8 +479,8 @@ function buildFinished(actions: ViewActions): ViewHost { el, sync() { clear(who); - const name = State.focusTask?.name ?? "Agent"; - who.append(agentWho(State.focusTask, `${name} finished`)); + const agent = State.focusTask?.source === "agent"; + who.append(agentWho(State.focusTask, agent ? "finished" : "Claude Code finished")); title.textContent = State.focusTask?.steps.at(-1) ?? "Session finished"; }, }; diff --git a/windows/tests/agents.test.mjs b/windows/tests/agents.test.mjs new file mode 100644 index 000000000..3b210cabd --- /dev/null +++ b/windows/tests/agents.test.mjs @@ -0,0 +1,38 @@ +// Agents other than Claude Code (src/island/agents.ts): the pill names and +// colours of PillCatalog.swift, and who may get an approval card. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + APPROVAL_AGENTS, KNOWN_AGENTS, agentColor, agentName, validateAgent, +} from "../src/island/agents.ts"; + +test("known agents carry the Mac's names and colours", () => { + assert.equal(agentName("gemini"), "Gemini CLI"); + assert.equal(agentColor("gemini"), "#8AB4F8"); + assert.equal(agentName("copilot"), "Copilot CLI"); + assert.equal(agentColor("codex"), "#2DD4BF"); + assert.equal(agentColor("cursor"), "#C0C4CC"); + for (const [id, { color }] of Object.entries(KNOWN_AGENTS)) { + assert.equal(validateAgent(id), id, id); + assert.match(color, /^#[0-9A-F]{6}$/, id); + } +}); + +test("an unknown agent keeps its id and gets a stable colour", () => { + assert.equal(agentName("my-tool"), "my-tool"); + assert.equal(agentColor("my-tool"), agentColor("my-tool")); + assert.match(agentColor("my-tool"), /^#[0-9A-F]{6}$/); +}); + +test("the agent tag is checked the Mac's way, and claude is reserved", () => { + for (const bad of ["claude", "Gemini", "has space", "a".repeat(25), "", undefined, "x_y"]) { + assert.equal(validateAgent(bad), null, String(bad)); + } + assert.equal(validateAgent("a".repeat(24)), "a".repeat(24)); +}); + +test("approval cards are for the agents the relay answers, no one else", () => { + // Must match takes_decisions() in hook/src/reply.rs. + assert.deepEqual([...APPROVAL_AGENTS].sort(), ["codex", "copilot", "muse"]); +}); diff --git a/windows/tests/hooks.test.mjs b/windows/tests/hooks.test.mjs index d99862fb5..e8d5356d1 100644 --- a/windows/tests/hooks.test.mjs +++ b/windows/tests/hooks.test.mjs @@ -242,10 +242,85 @@ test("an agent's pill goes away when its session ends, or 5.2 s after it stops", }); test("an agent's permission request is declined, never shown as Claude Code's", () => { - hook({ hook_event_name: "PermissionRequest", request_id: "r1", coucou_agent: "gemini", tool_name: "Bash" }); - assert.deepEqual(sent("approval_decline"), [{ requestId: "r1" }]); - assert.deepEqual(sent("approval_ack"), []); - assert.equal(State.pendingApproval, null); + for (const agent of ["gemini", "antigravity", "cursor", "opencode", "amp", "hermes", "my-tool"]) { + calls.length = 0; + hook({ hook_event_name: "PermissionRequest", request_id: "r1", coucou_agent: agent, tool_name: "Bash" }); + assert.deepEqual(sent("approval_decline"), [{ requestId: "r1" }], agent); + assert.deepEqual(sent("approval_ack"), [], agent); + assert.equal(State.pendingApproval, null, agent); + } +}); + +test("Codex, Copilot CLI and Muse Code get the card on their own pill", () => { + for (const agent of ["codex", "copilot", "muse"]) { + State.pendingApproval = null; + State.focusId = CLAUDE; + calls.length = 0; + asked = []; + hook({ + hook_event_name: "PermissionRequest", request_id: `r-${agent}`, session_id: "s1", + coucou_agent: agent, tool_name: "Bash", tool_input: { command: "npm publish" }, + }); + const id = `agent_${agent}`; + assert.deepEqual(State.pendingApproval, { + requestId: `r-${agent}`, sessionId: "s1", pillId: id, tool: "Bash", command: "Bash · npm publish", + }); + assert.deepEqual(sent("approval_ack"), [{ requestId: `r-${agent}` }]); + assert.deepEqual(sent("approval_decline"), []); + // Behind Claude Code's pill: badged and revealed, the view is not taken. + assert.equal(task(id).state, "approval"); + assert.equal(task(id).pillBadge, "approval"); + assert.deepEqual(asked, ["reveal"]); + // Claude Code's pill is untouched. + assert.equal(task().state, "idle"); + assert.equal(task().pillBadge ?? null, null); + } +}); + +test("an agent's card comes up at once when its pill has the focus", () => { + hook({ hook_event_name: "SessionStart", session_id: "s1", coucou_agent: "codex" }); + State.setFocus("agent_codex"); + asked = []; + hook({ hook_event_name: "PermissionRequest", request_id: "r1", session_id: "s1", coucou_agent: "codex", tool_name: "Bash" }); + assert.deepEqual(asked, ["alert:approval"]); +}); + +test("only Claude Code's questions become a question card", () => { + hook({ + hook_event_name: "PermissionRequest", request_id: "r1", session_id: "s1", coucou_agent: "codex", + tool_name: "AskUserQuestion", + tool_input: { questions: [{ question: "Which?", options: [{ label: "A" }, { label: "B" }] }] }, + }); + assert.equal(State.pendingApproval.questions, undefined); + assert.equal(task("agent_codex").state, "approval"); +}); + +test("the end of the turn takes a waiting card down and releases the relay", () => { + for (const end of ["Stop", "StopFailure", "UserPromptSubmit", "SessionEnd", "Interrupt"]) { + State.pendingApproval = null; + calls.length = 0; + hook({ hook_event_name: "PermissionRequest", request_id: "r1", session_id: "s1", coucou_agent: "codex", tool_name: "Bash" }); + // Another session's turn ending changes nothing. + hook({ hook_event_name: end, session_id: "other", coucou_agent: "codex" }); + assert.equal(State.pendingApproval?.requestId, "r1", end); + hook({ hook_event_name: end, session_id: "s1", coucou_agent: "codex" }); + assert.equal(State.pendingApproval, null, end); + assert.deepEqual(sent("approval_decline"), [{ requestId: "r1" }], end); + mock.timers.runAll(); + } +}); + +test("Codex's Interrupt puts its pill back to idle", () => { + hook({ hook_event_name: "UserPromptSubmit", session_id: "s1", coucou_agent: "codex", prompt: "go" }); + assert.equal(task("agent_codex").state, "thinking"); + hook({ hook_event_name: "Interrupt", session_id: "s1", coucou_agent: "codex" }); + assert.equal(task("agent_codex").state, "idle"); +}); + +test("an agent's last words show when it stops", () => { + hook({ hook_event_name: "SessionStart", coucou_agent: "hermes" }); + hook({ hook_event_name: "Stop", coucou_agent: "hermes", last_assistant_message: "All done, tests pass." }); + assert.deepEqual(task("agent_hermes").steps, ["All done, tests pass."]); }); // ── Permission requests ─────────────────────────────────────────────────────── @@ -266,6 +341,7 @@ test("a permission request puts the card up, says exactly what it authorises, an assert.deepEqual(State.pendingApproval, { requestId: "r1", sessionId: "s1", + pillId: CLAUDE, tool: "Write", command: "Write · C:\\Users\\me\\proj\\.env", }); From 600fe705b086afe0d5ac994da52cfd01e5d44608 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:55:11 +0200 Subject: [PATCH 54/77] Antigravity: install its hooks from Settings, never auto-allow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-applies the useful half of #298 on the shared writer: Coucou's hooks go in a `coucou` named group of ~/.gemini/config/hooks.json (tool events as matcher groups, lifecycle events as plain handlers, timeouts in seconds), and uninstalling removes that group only. A `coucou` group someone else wrote is neither replaced nor removed. What is left out of #298: the relay no longer prints {"decision":"allow"} on PreToolUse — it prints "{}", no decision, like the Mac, so Antigravity's own permission rules stay in charge even with Coucou closed. The relay path is quoted whenever it needs to be (#298 wrote it bare, which breaks on a user name with a space), the write goes through the hardened writer, and the Claude Code idle card and package-lock.json are left as they were. Antigravity's tool names get their ticker labels. Co-authored-by: kobaltgit Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/agents.rs | 116 +++++++++++++++++++++++++++++--- windows/src/island/hooks.ts | 7 ++ 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/windows/src-tauri/src/agents.rs b/windows/src-tauri/src/agents.rs index 9451f8d3f..b50234191 100644 --- a/windows/src-tauri/src/agents.rs +++ b/windows/src-tauri/src/agents.rs @@ -93,15 +93,17 @@ type JsonChange = Box Result, String>>; #[derive(Clone, Copy, Debug, PartialEq)] pub enum Agent { Gemini, + Antigravity, } impl Agent { - pub const ALL: &'static [Agent] = &[Agent::Gemini]; + pub const ALL: &'static [Agent] = &[Agent::Gemini, Agent::Antigravity]; /// The `--agent` name; the pill is `agent_` (PillCatalog.swift). pub fn id(self) -> &'static str { match self { Agent::Gemini => "gemini", + Agent::Antigravity => "antigravity", } } @@ -113,6 +115,7 @@ impl Agent { fn files(self, home: &Path) -> Vec { match self { Agent::Gemini => vec![home.join(".gemini").join("settings.json")], + Agent::Antigravity => vec![home.join(".gemini").join("config").join("hooks.json")], } } @@ -129,12 +132,9 @@ impl Agent { /// remove a file that is Coucou's alone. Command lines are built here, up /// front, so the change itself is pure. fn json_change(self, relay: &Relay, install: bool) -> JsonChange { - let agent = self.id(); - if !install { - return Box::new(move |v| groups_uninstall(v, agent).map(Some)); - } - match self { - Agent::Gemini => { + match (self, install) { + (Agent::Gemini, false) => Box::new(|v| groups_uninstall(v, "gemini").map(Some)), + (Agent::Gemini, true) => { let commands: Vec<(String, String, u64)> = GEMINI_EVENTS .iter() .map(|(event, said, timeout)| { @@ -144,6 +144,11 @@ impl Agent { .collect(); Box::new(move |v| gemini_install(v, &commands).map(Some)) } + (Agent::Antigravity, false) => Box::new(|v| antigravity_uninstall(v).map(Some)), + (Agent::Antigravity, true) => { + let block = antigravity_block(relay); + Box::new(move |v| antigravity_install(v, &block).map(Some)) + } } } @@ -160,6 +165,7 @@ impl Agent { }; match self { Agent::Gemini => groups_have_ours(&json(), "gemini"), + Agent::Antigravity => json().get("coucou").is_some_and(antigravity_is_ours), } } @@ -171,12 +177,14 @@ impl Agent { // ── Strings shown in Settings ───────────────────────────────────────────────── +const TEXT_UNTOUCHED: &str = "Coucou has not touched it."; const TEXT_UNEXPECTED: &str = "has an unexpected type — Coucou has not touched it."; impl Agent { pub fn name(self) -> &'static str { match self { Agent::Gemini => "Gemini CLI", + Agent::Antigravity => "Antigravity", } } @@ -184,6 +192,7 @@ impl Agent { fn note(self) -> &'static str { match self { Agent::Gemini => "Start a new Gemini CLI session to pick the hooks up.", + Agent::Antigravity => "Start a new Antigravity conversation to pick the hooks up.", } } } @@ -387,6 +396,59 @@ fn gemini_install(root: &Value, commands: &[(String, String, u64)]) -> Result Value { + let handler = |event: &str| { + json!({ + "type": "command", + "command": relay.command(Shell::Cmd, &format!("--agent antigravity {event}")), + "timeout": 10, + }) + }; + let mut block = Map::new(); + for event in ANTIGRAVITY_TOOL_EVENTS { + block.insert((*event).into(), json!([{ "matcher": "*", "hooks": [handler(event)] }])); + } + for event in ANTIGRAVITY_LIFECYCLE_EVENTS { + block.insert((*event).into(), json!([handler(event)])); + } + Value::Object(block) +} + +/// A `coucou` group Coucou wrote (it runs the relay as `--agent antigravity`). +fn antigravity_is_ours(group: &Value) -> bool { + let text = group.to_string(); + text.contains(MARKER) && text.contains("--agent antigravity") +} + +fn antigravity_install(root: &Value, block: &Value) -> Result { + let mut root = root.as_object().cloned().unwrap_or_default(); + if root.get("coucou").is_some_and(|g| !antigravity_is_ours(g)) { + return Err(format!("A hook group named \"coucou\" that Coucou did not write is already there — {TEXT_UNTOUCHED}")); + } + root.insert("coucou".into(), block.clone()); + Ok(Value::Object(root)) +} + +/// Removes Coucou's group, and only if it is Coucou's. +fn antigravity_uninstall(root: &Value) -> Result { + let mut root = root.as_object().cloned().unwrap_or_default(); + if root.get("coucou").is_some_and(antigravity_is_ours) { + root.remove("coucou"); + } + Ok(Value::Object(root)) +} + #[cfg(test)] mod tests { use super::*; @@ -504,7 +566,11 @@ mod tests { let file = agent.files(&home)[0].clone(); if file.extension().is_some_and(|e| e == "json") { std::fs::create_dir_all(file.parent().unwrap()).unwrap(); - for odd in [r#"{"hooks":"nope"}"#, r#"{"hooks":[1]}"#, "[1,2]", "{ broken"] { + let shaped: &[&str] = match agent { + Agent::Antigravity => &[r#"{"coucou":"nope"}"#], + _ => &[r#"{"hooks":"nope"}"#, r#"{"hooks":[1]}"#], + }; + for odd in shaped.iter().copied().chain(["[1,2]", "{ broken", "\"text\""]) { std::fs::write(&file, odd).unwrap(); let edits = agent.edits(&home, &linux(), true); assert!(config_file::preview(&edits).is_err(), "{agent:?} accepted {odd}"); @@ -515,6 +581,40 @@ mod tests { } } + #[test] + fn antigravity_gets_its_own_named_group_and_nothing_else_changes() { + let existing = r#"{"my-guard":{"PreToolUse":[{"matcher":"run_command","hooks":[{"command":"/bin/guard"}]}]}}"#; + let (home, installed, removed) = round_trip(Agent::Antigravity, Some(existing)); + assert_eq!(installed["my-guard"]["PreToolUse"][0]["matcher"], "run_command"); + let ours = &installed["coucou"]; + for event in ANTIGRAVITY_TOOL_EVENTS { + assert_eq!(ours[event][0]["matcher"], "*"); + let cmd = ours[event][0]["hooks"][0]["command"].as_str().unwrap(); + assert!(cmd.ends_with(&format!("--agent antigravity {event}")), "{cmd}"); + } + for event in ANTIGRAVITY_LIFECYCLE_EVENTS { + assert_eq!(ours[event][0]["timeout"], 10); + assert!(ours[event][0]["command"].as_str().unwrap().contains(MARKER)); + } + assert_eq!(removed.unwrap(), serde_json::from_str::(existing).unwrap()); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn a_coucou_group_someone_else_wrote_is_neither_replaced_nor_removed() { + let theirs = json!({ "coucou": { "Stop": [{ "command": "/bin/notify" }] } }); + assert!(antigravity_install(&theirs, &antigravity_block(&linux())).is_err()); + assert_eq!(antigravity_uninstall(&theirs).unwrap(), theirs); + } + + #[test] + fn on_windows_antigravity_runs_the_relay_path_quoted_only_when_needed() { + let block = antigravity_block(&windows(WIN)); + assert_eq!(block["Stop"][0]["command"], format!("{WIN} --agent antigravity Stop")); + let block = antigravity_block(&windows(WIN_SPACE)); + assert_eq!(block["Stop"][0]["command"], format!("\"{WIN_SPACE}\" --agent antigravity Stop")); + } + #[test] fn another_tools_entry_for_an_event_with_the_wrong_shape_is_refused() { let odd = json!({ "hooks": { "BeforeTool": { "matcher": "x" } } }); diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index fe580f881..d53fe0f72 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -93,6 +93,13 @@ const TOOL_LABELS: Record = { MultiEdit: "Modifie", NotebookEdit: "Notebook", PowerShell: "Exécute", + // Antigravity's tools (#298). + run_command: "Exécute", + view_file: "Lit", + write_to_file: "Écrit", + replace_file_content: "Modifie", + read_url_content: "Récupère", + search_web: "Recherche web", }; function stepLabel(tool: string, input: Record): string { From 092bb6dccafb1c825dc8ee5a14891eede1185d94 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:55:59 +0200 Subject: [PATCH 55/77] Cursor Agent: install its hooks from Settings, one pill, no auto-allow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Takes #231's Cursor support onto the shared pieces: its native ~/.cursor/hooks.json format and event list (installed and removed by the hardened writer, which refuses a "hooks" of an unexpected type instead of replacing it), and its event mapping and stop status → StopFailure, which now live in the relay's single normaliser. Left out of #231: the relay answered every preToolUse with an allow and every prompt with continue — it now prints nothing, so Cursor's own permission rules decide; the hook command was Windows-only in a module every platform builds (the shared relay command works on both, and is quoted for cmd); and each session got its own agent_cursor_ pill — every Cursor session is on agent_cursor, the Mac's pill id. Co-authored-by: BeyondBirthday07 Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/agents.rs | 62 ++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/windows/src-tauri/src/agents.rs b/windows/src-tauri/src/agents.rs index b50234191..38e0375b0 100644 --- a/windows/src-tauri/src/agents.rs +++ b/windows/src-tauri/src/agents.rs @@ -94,16 +94,18 @@ type JsonChange = Box Result, String>>; pub enum Agent { Gemini, Antigravity, + Cursor, } impl Agent { - pub const ALL: &'static [Agent] = &[Agent::Gemini, Agent::Antigravity]; + pub const ALL: &'static [Agent] = &[Agent::Gemini, Agent::Antigravity, Agent::Cursor]; /// The `--agent` name; the pill is `agent_` (PillCatalog.swift). pub fn id(self) -> &'static str { match self { Agent::Gemini => "gemini", Agent::Antigravity => "antigravity", + Agent::Cursor => "cursor", } } @@ -116,6 +118,7 @@ impl Agent { match self { Agent::Gemini => vec![home.join(".gemini").join("settings.json")], Agent::Antigravity => vec![home.join(".gemini").join("config").join("hooks.json")], + Agent::Cursor => vec![home.join(".cursor").join("hooks.json")], } } @@ -149,6 +152,11 @@ impl Agent { let block = antigravity_block(relay); Box::new(move |v| antigravity_install(v, &block).map(Some)) } + (Agent::Cursor, false) => Box::new(|v| groups_uninstall(v, "cursor").map(Some)), + (Agent::Cursor, true) => { + let command = relay.command(Shell::Cmd, "--agent cursor"); + Box::new(move |v| cursor_install(v, &command).map(Some)) + } } } @@ -166,6 +174,7 @@ impl Agent { match self { Agent::Gemini => groups_have_ours(&json(), "gemini"), Agent::Antigravity => json().get("coucou").is_some_and(antigravity_is_ours), + Agent::Cursor => groups_have_ours(&json(), "cursor"), } } @@ -185,6 +194,7 @@ impl Agent { match self { Agent::Gemini => "Gemini CLI", Agent::Antigravity => "Antigravity", + Agent::Cursor => "Cursor Agent", } } @@ -193,6 +203,7 @@ impl Agent { match self { Agent::Gemini => "Start a new Gemini CLI session to pick the hooks up.", Agent::Antigravity => "Start a new Antigravity conversation to pick the hooks up.", + Agent::Cursor => "Restart Cursor to pick the hooks up.", } } } @@ -449,6 +460,31 @@ fn antigravity_uninstall(root: &Value) -> Result { Ok(Value::Object(root)) } +// ── Cursor Agent — ~/.cursor/hooks.json ─────────────────────────────────────── +// +// `{"version": 1, "hooks": {"": [{"command": …}]}}`, camelCase events +// (from #231, BeyondBirthday07). The relay maps them, turns a stop with +// `status: "error"` into StopFailure, and prints nothing back: no permission is +// ever given on Cursor's behalf. Every session lands on the one `agent_cursor` +// pill. + +const CURSOR_EVENTS: &[&str] = &[ + "sessionStart", + "sessionEnd", + "beforeSubmitPrompt", + "preToolUse", + "postToolUse", + "postToolUseFailure", + "stop", +]; + +fn cursor_install(root: &Value, command: &str) -> Result { + let events = CURSOR_EVENTS.iter().map(|e| (e.to_string(), json!({ "command": command }))); + let mut root = groups_install(root, "cursor", events)?; + root.entry("version").or_insert(json!(1)); + Ok(Value::Object(root)) +} + #[cfg(test)] mod tests { use super::*; @@ -615,6 +651,30 @@ mod tests { assert_eq!(block["Stop"][0]["command"], format!("\"{WIN_SPACE}\" --agent antigravity Stop")); } + #[test] + fn cursor_uses_its_native_format_and_keeps_the_users_hooks() { + let existing = r#"{"version":1,"hooks":{"afterFileEdit":[{"command":"./format.sh"}],"stop":[{"command":"./notify.sh"}]}}"#; + let (home, installed, removed) = round_trip(Agent::Cursor, Some(existing)); + assert_eq!(installed["version"], 1); + for event in CURSOR_EVENTS { + let last = installed["hooks"][event].as_array().unwrap().last().unwrap().clone(); + assert!(last["command"].as_str().unwrap().ends_with("--agent cursor"), "{event}"); + } + assert_eq!(installed["hooks"]["stop"][0]["command"], "./notify.sh"); + assert_eq!(installed["hooks"]["afterFileEdit"][0]["command"], "./format.sh"); + assert_eq!(removed.unwrap(), serde_json::from_str::(existing).unwrap()); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn a_new_cursor_file_gets_its_version() { + let after = cursor_install(&json!({}), "'x/coucou-hook' --agent cursor").unwrap(); + assert_eq!(after["version"], 1); + // A version the user set is theirs. + let after = cursor_install(&json!({ "version": 2 }), "c").unwrap(); + assert_eq!(after["version"], 2); + } + #[test] fn another_tools_entry_for_an_event_with_the_wrong_shape_is_refused() { let odd = json!({ "hooks": { "BeforeTool": { "matcher": "x" } } }); From c68ed17389aafcd5deb172e1aa9bfd48db5a0199 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:57:33 +0200 Subject: [PATCH 56/77] Codex: install its hooks from Settings, Allow / Deny from the island Same hooks as the Mac (0.1.2, #130) in ~/.codex/hooks.json: session, prompt, tool, subagent, Interrupt and SessionEnd events, and a PermissionRequest that waits up to 120 s with a status line in Codex. On Windows Codex runs hook commands with cmd /C, so the relay path is quoted for cmd. Settings says that Codex only runs new hooks once they are trusted with /hooks. A Codex permission request gets the card on the Codex pill; Allow and Deny go back as Claude Code's hookSpecificOutput, which Codex documents. Nothing is answered without a click: no click, no output, and Codex asks as usual. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/agents.rs | 76 +++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/windows/src-tauri/src/agents.rs b/windows/src-tauri/src/agents.rs index 38e0375b0..1864d5486 100644 --- a/windows/src-tauri/src/agents.rs +++ b/windows/src-tauri/src/agents.rs @@ -95,10 +95,11 @@ pub enum Agent { Gemini, Antigravity, Cursor, + Codex, } impl Agent { - pub const ALL: &'static [Agent] = &[Agent::Gemini, Agent::Antigravity, Agent::Cursor]; + pub const ALL: &'static [Agent] = &[Agent::Codex, Agent::Gemini, Agent::Antigravity, Agent::Cursor]; /// The `--agent` name; the pill is `agent_` (PillCatalog.swift). pub fn id(self) -> &'static str { @@ -106,6 +107,7 @@ impl Agent { Agent::Gemini => "gemini", Agent::Antigravity => "antigravity", Agent::Cursor => "cursor", + Agent::Codex => "codex", } } @@ -119,6 +121,7 @@ impl Agent { Agent::Gemini => vec![home.join(".gemini").join("settings.json")], Agent::Antigravity => vec![home.join(".gemini").join("config").join("hooks.json")], Agent::Cursor => vec![home.join(".cursor").join("hooks.json")], + Agent::Codex => vec![home.join(".codex").join("hooks.json")], } } @@ -157,6 +160,11 @@ impl Agent { let command = relay.command(Shell::Cmd, "--agent cursor"); Box::new(move |v| cursor_install(v, &command).map(Some)) } + (Agent::Codex, false) => Box::new(|v| groups_uninstall(v, "codex").map(Some)), + (Agent::Codex, true) => { + let command = relay.command(Shell::Cmd, "--agent codex"); + Box::new(move |v| codex_install(v, &command).map(Some)) + } } } @@ -175,12 +183,14 @@ impl Agent { Agent::Gemini => groups_have_ours(&json(), "gemini"), Agent::Antigravity => json().get("coucou").is_some_and(antigravity_is_ours), Agent::Cursor => groups_have_ours(&json(), "cursor"), + Agent::Codex => groups_have_ours(&json(), "codex"), } } - /// Whether the island can answer this agent's permission requests. + /// Whether the island can answer this agent's permission requests. Must + /// match `takes_decisions` in the relay (hook/src/reply.rs). fn approvals(self) -> bool { - false + matches!(self, Agent::Codex) } } @@ -195,6 +205,7 @@ impl Agent { Agent::Gemini => "Gemini CLI", Agent::Antigravity => "Antigravity", Agent::Cursor => "Cursor Agent", + Agent::Codex => "Codex", } } @@ -204,6 +215,7 @@ impl Agent { Agent::Gemini => "Start a new Gemini CLI session to pick the hooks up.", Agent::Antigravity => "Start a new Antigravity conversation to pick the hooks up.", Agent::Cursor => "Restart Cursor to pick the hooks up.", + Agent::Codex => "Codex runs new hooks only once you trust them: start Codex and review them once with /hooks.", } } } @@ -485,6 +497,41 @@ fn cursor_install(root: &Value, command: &str) -> Result { Ok(Value::Object(root)) } +// ── Codex — ~/.codex/hooks.json ─────────────────────────────────────────────── +// +// Claude-style groups without a matcher, timeouts in seconds; Codex sends the +// event name in the payload. PermissionRequest waits for the island (120 s, +// with a status line shown in Codex meanwhile) and is answered with Claude +// Code's hookSpecificOutput — only after a click. Codex runs hook commands with +// `cmd /C` on Windows and `$SHELL -lc` elsewhere, and only once the user has +// trusted them with /hooks. + +const CODEX_EVENTS: &[(&str, u64)] = &[ + ("SessionStart", 10), + ("UserPromptSubmit", 10), + ("PreToolUse", 10), + ("PermissionRequest", 120), + ("PostToolUse", 10), + ("Stop", 10), + ("SubagentStart", 10), + ("SubagentStop", 10), + ("Interrupt", 3), + ("SessionEnd", 3), +]; + +const CODEX_WAITING: &str = "Waiting for your answer in the island (Coucou)"; + +fn codex_install(root: &Value, command: &str) -> Result { + let events = CODEX_EVENTS.iter().map(|(event, timeout)| { + let mut hook = json!({ "type": "command", "command": command, "timeout": timeout }); + if *event == "PermissionRequest" { + hook["statusMessage"] = json!(CODEX_WAITING); + } + (event.to_string(), json!({ "hooks": [hook] })) + }); + groups_install(root, "codex", events).map(Value::Object) +} + #[cfg(test)] mod tests { use super::*; @@ -675,6 +722,29 @@ mod tests { assert_eq!(after["version"], 2); } + #[test] + fn codex_hooks_match_the_macs_and_leave_the_rest_alone() { + let existing = r#"{"description":"mine","hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"guard.sh"}]}]}}"#; + let (home, installed, removed) = round_trip(Agent::Codex, Some(existing)); + assert_eq!(installed["description"], "mine"); + for (event, timeout) in CODEX_EVENTS { + let ours = installed["hooks"][event].as_array().unwrap().last().unwrap()["hooks"][0].clone(); + assert_eq!(ours["timeout"], *timeout, "{event}"); + assert!(ours["command"].as_str().unwrap().ends_with("--agent codex"), "{event}"); + assert_eq!(ours.get("statusMessage").is_some(), *event == "PermissionRequest"); + } + assert_eq!(installed["hooks"]["PreToolUse"][0]["matcher"], "Bash"); + assert_eq!(removed.unwrap(), serde_json::from_str::(existing).unwrap()); + assert!(Agent::Codex.approvals()); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn on_windows_codex_gets_a_command_cmd_can_run() { + let after = codex_install(&json!({}), &windows(WIN_SPACE).command(Shell::Cmd, "--agent codex")).unwrap(); + assert_eq!(after["hooks"]["Stop"][0]["hooks"][0]["command"], format!("\"{WIN_SPACE}\" --agent codex")); + } + #[test] fn another_tools_entry_for_an_event_with_the_wrong_shape_is_refused() { let odd = json!({ "hooks": { "BeforeTool": { "matcher": "x" } } }); From 7262361cfe14b65b293e585952e6b7e99a35f5d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:58:59 +0200 Subject: [PATCH 57/77] Relay: tag Claude Desktop sessions and Claude Code in Cursor (Mac #191, #120) A Claude Code session started from the Claude desktop app carries CLAUDE_CODE_ENTRYPOINT=claude-desktop: the relay tags it coucou_agent: claude-desktop, as the Mac's does, so it lands on the Claude Desktop pill (an explicit --agent still wins). Claude Code in Cursor's terminal is told apart from VS Code by CURSOR_TRACE_ID, or by Cursor's executable behind VS Code's git helper, and reported as term_editor: cursor. Co-Authored-By: Claude Opus 5.5 --- windows/hook/src/main.rs | 69 ++++++++++++++++++++++++++++++++++-- windows/tests/hooks.test.mjs | 11 ++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs index 4cd62465c..7ceac1012 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -174,8 +174,15 @@ fn read_event() -> Option<(String, String, Option)> { } // Which agent this hook was installed for. Absent means Claude Code, // so existing hook commands keep working unchanged. - if !agent.is_empty() { - map.insert("coucou_agent".into(), serde_json::Value::String(agent)); + let env = |name: &str| std::env::var(name).ok(); + if let Some(tag) = agent_tag(&agent, &env) { + map.insert("coucou_agent".into(), serde_json::Value::String(tag)); + } + // Claude Code in Cursor's terminal goes on the Cursor pill (Mac #120). + if !map.contains_key("term_editor") { + if let Some(editor) = term_editor(&env) { + map.insert("term_editor".into(), serde_json::Value::String(editor.into())); + } } let event = map .get("hook_event_name") @@ -231,6 +238,30 @@ fn read_event() -> Option<(String, String, Option)> { Some((line, event, question)) } +/// The `coucou_agent` tag: `--agent` when given, otherwise `claude-desktop` for +/// a Claude Code session started from the Claude desktop app, which says so in +/// CLAUDE_CODE_ENTRYPOINT — the same rule as the Mac's relay (#191). Nothing +/// for a plain Claude Code session. +fn agent_tag(arg: &str, env: &dyn Fn(&str) -> Option) -> Option { + if !arg.is_empty() { + return Some(arg.to_string()); + } + (env("CLAUDE_CODE_ENTRYPOINT").as_deref() == Some("claude-desktop")) + .then(|| "claude-desktop".to_string()) +} + +/// `cursor` when the session runs in Cursor's integrated terminal. Cursor sets +/// TERM_PROGRAM=vscode like VS Code does, so it is told apart by its own trace +/// variable, or by its executable behind VS Code's git helper. +fn term_editor(env: &dyn Fn(&str) -> Option) -> Option<&'static str> { + if env("CURSOR_TRACE_ID").is_some_and(|v| !v.is_empty()) { + return Some("cursor"); + } + let helper = env("VSCODE_GIT_ASKPASS_NODE").unwrap_or_default(); + let exe = helper.rsplit(['/', '\\']).next().unwrap_or_default().to_ascii_lowercase(); + exe.starts_with("cursor").then_some("cursor") +} + /// Caps every string in the payload. A single Write can carry a whole file. fn truncate_strings(value: &mut serde_json::Value) { match value { @@ -352,6 +383,40 @@ mod tests { assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":["Tests","Tests"]}}"#)); } + fn env_of(vars: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { + move |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| v.to_string()) + } + + #[test] + fn claude_desktop_sessions_are_tagged_and_an_explicit_agent_wins() { + let desktop = env_of(&[("CLAUDE_CODE_ENTRYPOINT", "claude-desktop")]); + assert_eq!(agent_tag("", &desktop).as_deref(), Some("claude-desktop")); + assert_eq!(agent_tag("gemini", &desktop).as_deref(), Some("gemini")); + assert_eq!(agent_tag("", &env_of(&[("CLAUDE_CODE_ENTRYPOINT", "cli")])), None); + assert_eq!(agent_tag("", &env_of(&[])), None); + } + + #[test] + fn cursor_is_told_apart_from_vs_code() { + assert_eq!(term_editor(&env_of(&[("CURSOR_TRACE_ID", "abc")])), Some("cursor")); + assert_eq!( + term_editor(&env_of(&[( + "VSCODE_GIT_ASKPASS_NODE", + r"C:\Users\me\AppData\Local\Programs\cursor\Cursor.exe" + )])), + Some("cursor") + ); + assert_eq!( + term_editor(&env_of(&[( + "VSCODE_GIT_ASKPASS_NODE", + r"C:\Users\me\AppData\Local\Programs\Microsoft VS Code\Code.exe" + )])), + None + ); + assert_eq!(term_editor(&env_of(&[("CURSOR_TRACE_ID", "")])), None); + assert_eq!(term_editor(&env_of(&[("TERM_PROGRAM", "vscode")])), None); + } + #[test] fn long_strings_are_cut_on_a_char_boundary() { let mut v = serde_json::json!({ "tool_input": { "content": "é".repeat(4000) } }); diff --git a/windows/tests/hooks.test.mjs b/windows/tests/hooks.test.mjs index b5e2f7b55..a3bd45ff7 100644 --- a/windows/tests/hooks.test.mjs +++ b/windows/tests/hooks.test.mjs @@ -248,6 +248,17 @@ test("an agent's permission request is declined, never shown as Claude Code's", assert.equal(State.pendingApproval, null); }); +test("a Claude Desktop session gets the Claude Desktop pill, in its colour (Mac #191)", () => { + hook({ hook_event_name: "SessionStart", cwd: "C:\\p\\proj", session_id: "d1", coucou_agent: "claude-desktop" }); + const desktop = task("agent_claude-desktop"); + assert.equal(desktop.color, "#D97757"); + assert.equal(desktop.sessionId, "d1"); + assert.equal(task().state, "idle"); + // Its permission requests are answered in the app, as on macOS. + hook({ hook_event_name: "PermissionRequest", request_id: "r1", coucou_agent: "claude-desktop", tool_name: "Bash" }); + assert.deepEqual(sent("approval_decline"), [{ requestId: "r1" }]); +}); + // ── Main tool and Cursor ────────────────────────────────────────────────────── test("Claude Code in Cursor's terminal works on the Cursor pill, made for the session", () => { From e68f8375ff2d16d2942d72a8e7a4f9e36ad409f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:59:01 +0200 Subject: [PATCH 58/77] Copilot CLI and Muse Code: install from Settings, approvals from the island MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same hooks as the Mac (0.2.0, #263): - Copilot CLI gets a file of Coucou's own, ~/.copilot/hooks/coucou.json, with camelCase events and `bash` entries — plus a `powershell` line on Windows, where Copilot runs that one. Removing Coucou's last entry removes the file; anything someone else added keeps it. - Muse Code: Claude-style groups merged into ~/.config/muse/settings.json (timeouts in milliseconds, schema_version for a new file). Both get the Allow / Deny card on their own pill and a bare permissionDecision back; Copilot, which is fail-closed, gets "ask" when nobody clicked. A config file that does not exist yet now previews as all additions, and removing nothing from nothing says "No change.". Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/agents.rs | 209 ++++++++++++++++++++++++++- windows/src-tauri/src/config_file.rs | 10 +- 2 files changed, 213 insertions(+), 6 deletions(-) diff --git a/windows/src-tauri/src/agents.rs b/windows/src-tauri/src/agents.rs index 1864d5486..f41273c42 100644 --- a/windows/src-tauri/src/agents.rs +++ b/windows/src-tauri/src/agents.rs @@ -96,10 +96,19 @@ pub enum Agent { Antigravity, Cursor, Codex, + Copilot, + Muse, } impl Agent { - pub const ALL: &'static [Agent] = &[Agent::Codex, Agent::Gemini, Agent::Antigravity, Agent::Cursor]; + pub const ALL: &'static [Agent] = &[ + Agent::Codex, + Agent::Copilot, + Agent::Muse, + Agent::Gemini, + Agent::Antigravity, + Agent::Cursor, + ]; /// The `--agent` name; the pill is `agent_` (PillCatalog.swift). pub fn id(self) -> &'static str { @@ -108,6 +117,8 @@ impl Agent { Agent::Antigravity => "antigravity", Agent::Cursor => "cursor", Agent::Codex => "codex", + Agent::Copilot => "copilot", + Agent::Muse => "muse", } } @@ -122,6 +133,8 @@ impl Agent { Agent::Antigravity => vec![home.join(".gemini").join("config").join("hooks.json")], Agent::Cursor => vec![home.join(".cursor").join("hooks.json")], Agent::Codex => vec![home.join(".codex").join("hooks.json")], + Agent::Copilot => vec![home.join(".copilot").join("hooks").join("coucou.json")], + Agent::Muse => vec![home.join(".config").join("muse").join("settings.json")], } } @@ -165,6 +178,25 @@ impl Agent { let command = relay.command(Shell::Cmd, "--agent codex"); Box::new(move |v| codex_install(v, &command).map(Some)) } + (Agent::Copilot, false) => Box::new(copilot_uninstall), + (Agent::Copilot, true) => { + let entries: Vec<(String, Value)> = COPILOT_EVENTS + .iter() + .map(|(event, timeout)| (event.to_string(), copilot_entry(relay, event, *timeout))) + .collect(); + Box::new(move |v| copilot_install(v, &entries).map(Some)) + } + (Agent::Muse, false) => Box::new(|v| groups_uninstall(v, "muse").map(Some)), + (Agent::Muse, true) => { + let commands: Vec<(String, String, u64)> = MUSE_EVENTS + .iter() + .map(|(event, seconds)| { + let command = relay.command(Shell::Cmd, &format!("--agent muse {event}")); + (event.to_string(), command, seconds * 1000) + }) + .collect(); + Box::new(move |v| muse_install(v, &commands).map(Some)) + } } } @@ -184,13 +216,18 @@ impl Agent { Agent::Antigravity => json().get("coucou").is_some_and(antigravity_is_ours), Agent::Cursor => groups_have_ours(&json(), "cursor"), Agent::Codex => groups_have_ours(&json(), "codex"), + Agent::Copilot => json() + .get("hooks") + .and_then(Value::as_object) + .is_some_and(|h| h.values().filter_map(Value::as_array).flatten().any(copilot_entry_is_ours)), + Agent::Muse => groups_have_ours(&json(), "muse"), } } /// Whether the island can answer this agent's permission requests. Must /// match `takes_decisions` in the relay (hook/src/reply.rs). fn approvals(self) -> bool { - matches!(self, Agent::Codex) + matches!(self, Agent::Codex | Agent::Copilot | Agent::Muse) } } @@ -206,6 +243,8 @@ impl Agent { Agent::Antigravity => "Antigravity", Agent::Cursor => "Cursor Agent", Agent::Codex => "Codex", + Agent::Copilot => "GitHub Copilot CLI", + Agent::Muse => "Muse Code", } } @@ -216,6 +255,8 @@ impl Agent { Agent::Antigravity => "Start a new Antigravity conversation to pick the hooks up.", Agent::Cursor => "Restart Cursor to pick the hooks up.", Agent::Codex => "Codex runs new hooks only once you trust them: start Codex and review them once with /hooks.", + Agent::Copilot => "Start a new Copilot CLI session to pick the hooks up.", + Agent::Muse => "Start a new Muse Code session to pick the hooks up.", } } } @@ -532,6 +573,107 @@ fn codex_install(root: &Value, command: &str) -> Result { groups_install(root, "codex", events).map(Value::Object) } +// ── GitHub Copilot CLI — ~/.copilot/hooks/coucou.json ───────────────────────── +// +// A file of Coucou's own in Copilot's hooks folder: camelCase events, each +// entry `{"type": "command", "bash": …, "timeoutSec": N}`, plus `powershell` +// on Windows, where Copilot runs that one. The event goes on the command line +// because Copilot does not put it in the payload. Copilot is fail-closed on +// permissionRequest: the relay always answers it with valid JSON — "ask" when +// nobody clicked. Removing the last of Coucou's entries removes the file. + +const COPILOT_EVENTS: &[(&str, u64)] = &[ + ("sessionStart", 10), + ("userPromptSubmitted", 10), + ("preToolUse", 10), + ("permissionRequest", 120), + ("postToolUse", 10), + ("agentStop", 10), + ("sessionEnd", 3), + ("notification", 10), +]; + +fn copilot_entry(relay: &Relay, event: &str, timeout: u64) -> Value { + let args = format!("--agent copilot {event}"); + let mut entry = json!({ "type": "command", "bash": relay.command(Shell::Sh, &args), "timeoutSec": timeout }); + if relay.windows { + entry["powershell"] = json!(relay.command(Shell::PowerShell, &args)); + } + entry +} + +fn copilot_entry_is_ours(entry: &Value) -> bool { + ["bash", "powershell", "command"].iter().any(|k| is_our_command(entry.get(*k), "copilot")) +} + +fn copilot_install(root: &Value, entries: &[(String, Value)]) -> Result { + let mut root = root.as_object().cloned().unwrap_or_default(); + let mut hooks = object_at(&root, "hooks")?; + for (event, entry) in entries { + let mut list = list_at(&hooks, event)?; + list.retain(|e| !copilot_entry_is_ours(e)); + list.push(entry.clone()); + hooks.insert(event.clone(), Value::Array(list)); + } + root.insert("hooks".into(), Value::Object(hooks)); + root.insert("version".into(), json!(1)); + Ok(Value::Object(root)) +} + +fn copilot_uninstall(root: &Value) -> Result, String> { + let mut root = root.as_object().cloned().unwrap_or_default(); + if root.contains_key("hooks") { + let mut hooks = object_at(&root, "hooks")?; + for list in hooks.values_mut() { + if let Value::Array(entries) = list { + entries.retain(|e| !copilot_entry_is_ours(e)); + } + } + hooks.retain(|_, v| !v.as_array().is_some_and(Vec::is_empty)); + if hooks.is_empty() { + root.remove("hooks"); + } else { + root.insert("hooks".into(), Value::Object(hooks)); + } + } + // Nothing of anyone else's left: the file was Coucou's, and goes. + if root.keys().all(|k| k == "version") { + return Ok(None); + } + Ok(Some(Value::Object(root))) +} + +// ── Muse Code — ~/.config/muse/settings.json ────────────────────────────────── +// +// Claude-style groups with a `*` matcher, PascalCase events, timeouts in +// milliseconds. Permission requests get the island's card and a bare +// permissionDecision back. + +const MUSE_EVENTS: &[(&str, u64)] = &[ + ("SessionStart", 10), + ("UserPromptSubmit", 5), + ("PreToolUse", 5), + ("PermissionRequest", 120), + ("PostToolUse", 5), + ("Stop", 5), + ("SessionEnd", 3), +]; + +fn muse_install(root: &Value, commands: &[(String, String, u64)]) -> Result { + let fresh = root.as_object().is_none_or(Map::is_empty); + let events = commands.iter().map(|(event, command, timeout)| { + ( + event.clone(), + json!({ "matcher": "*", "hooks": [{ "type": "command", "command": command, "timeout": timeout }] }), + ) + }); + let mut root = groups_install(root, "muse", events)?; + if fresh { + root.insert("schema_version".into(), json!(1)); + } + Ok(Value::Object(root)) +} + #[cfg(test)] mod tests { use super::*; @@ -551,7 +693,10 @@ mod tests { /// Installs then uninstalls `agent` in a fresh home holding `existing` in /// its first file, and returns (installed file, uninstalled file). fn round_trip(agent: Agent, existing: Option<&str>) -> (PathBuf, Value, Option) { - let home = scratch(&format!("agent-{}", agent.id())); + // Tests run in parallel: every round trip gets a home of its own. + static RUN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let run = RUN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let home = scratch(&format!("agent-{}-{run}", agent.id())); let file = agent.files(&home)[0].clone(); if let Some(text) = existing { std::fs::create_dir_all(file.parent().unwrap()).unwrap(); @@ -745,6 +890,64 @@ mod tests { assert_eq!(after["hooks"]["Stop"][0]["hooks"][0]["command"], format!("\"{WIN_SPACE}\" --agent codex")); } + #[test] + fn copilot_gets_a_file_of_its_own_which_goes_when_coucou_leaves() { + let (home, installed, removed) = round_trip(Agent::Copilot, None); + assert_eq!(installed["version"], 1); + for (event, timeout) in COPILOT_EVENTS { + let entry = &installed["hooks"][event][0]; + assert_eq!(entry["timeoutSec"], *timeout); + assert!(entry["bash"].as_str().unwrap().ends_with(&format!("--agent copilot {event}"))); + // No PowerShell line on Linux. + assert!(entry.get("powershell").is_none()); + } + assert!(removed.is_none(), "the file should be gone"); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn copilot_keeps_what_someone_else_added_to_the_file() { + let existing = r#"{"version":1,"hooks":{"sessionStart":[{"type":"command","bash":"echo hi","timeoutSec":5}]}}"#; + let (home, installed, removed) = round_trip(Agent::Copilot, Some(existing)); + assert_eq!(installed["hooks"]["sessionStart"].as_array().unwrap().len(), 2); + assert_eq!(removed.unwrap(), serde_json::from_str::(existing).unwrap()); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn on_windows_copilot_gets_a_powershell_line_too() { + let entry = copilot_entry(&windows(WIN), "preToolUse", 10); + assert_eq!(entry["powershell"], format!("& '{WIN}' --agent copilot preToolUse")); + assert_eq!( + entry["bash"], + "\"C:/Users/me/AppData/Local/Coucou/bin/coucou-hook.exe\" --agent copilot preToolUse" + ); + } + + #[test] + fn muse_hooks_use_milliseconds_and_a_schema_version_for_a_new_file() { + let (home, installed, removed) = round_trip(Agent::Muse, None); + assert_eq!(installed["schema_version"], 1); + assert_eq!(installed["hooks"]["PermissionRequest"][0]["hooks"][0]["timeout"], 120_000); + assert_eq!(installed["hooks"]["PreToolUse"][0]["matcher"], "*"); + assert!(installed["hooks"]["Stop"][0]["hooks"][0]["command"].as_str().unwrap().ends_with("--agent muse Stop")); + // Only Coucou's hooks go; the file and its schema_version stay. + assert_eq!(removed.unwrap(), json!({ "schema_version": 1 })); + let _ = std::fs::remove_dir_all(home); + + let existing = r#"{"model":"m","hooks":{"Stop":[{"matcher":"*","hooks":[{"type":"command","command":"say done"}]}]}}"#; + let (home, installed, removed) = round_trip(Agent::Muse, Some(existing)); + assert!(installed.get("schema_version").is_none()); + assert_eq!(removed.unwrap(), serde_json::from_str::(existing).unwrap()); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn only_codex_copilot_and_muse_take_approvals() { + let with: Vec<&str> = Agent::ALL.iter().filter(|a| a.approvals()).map(|a| a.id()).collect(); + assert_eq!(with, ["codex", "copilot", "muse"]); + } + #[test] fn another_tools_entry_for_an_event_with_the_wrong_shape_is_refused() { let odd = json!({ "hooks": { "BeforeTool": { "matcher": "x" } } }); diff --git a/windows/src-tauri/src/config_file.rs b/windows/src-tauri/src/config_file.rs index d6ca769e5..25ec52b65 100644 --- a/windows/src-tauri/src/config_file.rs +++ b/windows/src-tauri/src/config_file.rs @@ -99,7 +99,9 @@ pub fn json_edit<'a>( text.push('\n'); text }); - Ok(Change { before: pretty(¤t), after }) + // A file that is not there yet shows as all additions. + let before = if bytes.is_some() { pretty(¤t) } else { String::new() }; + Ok(Change { before, after }) }) } @@ -155,8 +157,10 @@ pub fn preview(edits: &[FileEdit]) -> Result { let change = (file.edit)(current.as_deref())?; let after = change.after.clone().unwrap_or_default(); let mut diff = unified_diff(&change.before, after.trim_end_matches('\n')); - if change.after.is_none() && current.is_some() { - diff = format!("The file is removed.\n{diff}"); + match (¤t, &change.after) { + (Some(_), None) => diff = format!("The file is removed.\n{diff}"), + (None, None) => diff = "No change.".into(), + _ => {} } if edits.len() > 1 { diff = format!("── {}\n{diff}", file.path.display()); From c90d30b536a9e38dcd5b14a63b496d19101d5792 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:59:05 +0200 Subject: [PATCH 59/77] Windows: Open terminal brings the session's own window forward; Claude Desktop opens the app (Mac 0.1.0, #191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the relay connects, its process tree leads to the terminal or editor window the Claude Code session runs in (Windows Terminal, VS Code, Cursor...). It is looked for once per session, kept in memory by session ID, and Open terminal (and the pill's arrow) brings that window forward — the one titled after the project when an editor has several. Where no window is found (a classic console, whose window belongs to conhost) and on Linux, the folder opens in VS Code as before. The Claude Desktop pill's arrow, its idle card and its finished card open the Claude app instead: brought forward when it runs, started from its install folder otherwise. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/Cargo.toml | 3 + windows/src-tauri/src/lib.rs | 24 +++ windows/src-tauri/src/pipe.rs | 36 ++++ windows/src-tauri/src/platform/linux.rs | 23 +++ windows/src-tauri/src/platform/windows.rs | 134 ++++++++++++- windows/src-tauri/src/session_window.rs | 230 ++++++++++++++++++++++ windows/src/core/bridge.ts | 10 + windows/src/island/island.ts | 13 +- windows/src/views/integrations.ts | 9 + windows/src/views/views.ts | 7 +- 10 files changed, 482 insertions(+), 7 deletions(-) create mode 100644 windows/src-tauri/src/session_window.rs diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml index 3b9bcc91f..bf773caaa 100644 --- a/windows/src-tauri/Cargo.toml +++ b/windows/src-tauri/Cargo.toml @@ -32,7 +32,10 @@ windows = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", + # "Open terminal": the relay's process tree leads to the session's window. + "Win32_System_Diagnostics_ToolHelp", "Win32_System_Ole", + "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index b553ac1e1..681113da0 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod log; mod pipe; mod platform; mod secrets; +mod session_window; mod settings; mod tray; #[cfg(windows)] @@ -175,6 +176,27 @@ fn open_in_vscode(path: Option) -> bool { false } +/// "Open terminal": brings forward the terminal or editor window the session +/// runs in, when it was found (Windows, see session_window.rs); otherwise opens +/// the folder in VS Code, as before. +#[tauri::command] +fn open_session(session_id: Option, path: Option) -> bool { + if let Some(owner) = session_id.as_deref().and_then(session_window::lookup) { + let folder = path.as_deref().map(session_window::folder_name).unwrap_or_default(); + if platform::focus_process_window(owner, folder) { + return true; + } + } + open_in_vscode(path) +} + +/// The Claude Desktop pill's target: the Claude app (Windows only — it has no +/// Linux build). +#[tauri::command] +fn open_claude_desktop() -> bool { + platform::open_claude_desktop() +} + #[tauri::command] fn quit_app(app: AppHandle) { app.exit(0); @@ -412,6 +434,8 @@ pub fn run() { list_monitors, open_url, open_in_vscode, + open_session, + open_claude_desktop, quit_app, hooks_status, agent_hooks_status, diff --git a/windows/src-tauri/src/pipe.rs b/windows/src-tauri/src/pipe.rs index 8d3566b3f..da10762bb 100644 --- a/windows/src-tauri/src/pipe.rs +++ b/windows/src-tauri/src/pipe.rs @@ -34,6 +34,7 @@ use tokio::sync::mpsc; use crate::island::WINDOW_LABEL; use crate::log; +use crate::session_window; /// Slightly under coucou-hook's own 110 s wait, so we always answer first. const DECISION_TIMEOUT: Duration = Duration::from_secs(108); @@ -154,6 +155,10 @@ pub fn start(app: AppHandle) { trait Relay: AsyncRead + AsyncWrite + Unpin { /// Ends the conversation once everything has been written. fn finish(&mut self) {} + /// The relay process on the other end, where the OS says. + fn client_pid(&self) -> Option { + None + } } #[cfg(windows)] @@ -161,6 +166,10 @@ impl Relay for NamedPipeServer { fn finish(&mut self) { let _ = self.disconnect(); } + fn client_pid(&self) -> Option { + use std::os::windows::io::AsRawHandle; + crate::platform::pipe_client_pid(self.as_raw_handle()) + } } /// Dropping the stream closes it; the relay reads up to our newline first. @@ -197,6 +206,8 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) { .unwrap_or_default() .to_string(); + note_session_window(&pipe, &payload, &event); + if event != "PermissionRequest" { log::line(format!("hook {event}")); let _ = app.emit_to(WINDOW_LABEL, "hook", payload); @@ -226,6 +237,31 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) { pipe.finish(); } +/// Finds, once per session, the window it runs in — see session_window.rs. +/// +/// Only while the session is unknown, so the process snapshot is not taken on +/// every event. The relay must still be running for its parents to be found: +/// a permission request always is (it waits for us), a quick event may already +/// have exited, and then a later event of the session tries again. +fn note_session_window(pipe: &impl Relay, payload: &Value, event: &str) { + let Some(session) = payload.get("session_id").and_then(Value::as_str) else { return }; + if event == "SessionEnd" { + session_window::forget(session); + return; + } + if session_window::known(session) { + return; + } + let Some(relay) = pipe.client_pid() else { return }; + let ancestors = crate::platform::process_ancestors(relay); + // No ancestors: the relay was already gone, so try again next time. Some, + // but none with a window (a classic console): settled, VS Code it is. + if !ancestors.is_empty() { + let owner = crate::platform::first_with_window(&ancestors); + session_window::remember(session, owner.unwrap_or(session_window::NO_WINDOW)); + } +} + /// Two waits: a short one for "the card is up", then the long one for a human. async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option { match tokio::time::timeout(ACK_TIMEOUT, rx.recv()).await { diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index 8253961d4..7333da345 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -208,6 +208,29 @@ pub fn find_on_path(stem: &str) -> Option { }) } +// ── Session windows ("Open terminal") ───────────────────────────────────────── +// +// Wayland lets no app raise another app's window, and X11 would need a window +// manager protocol client this build does not carry, so "Open terminal" opens +// the folder in VS Code here, as before. There is no Claude desktop app for +// Linux either. + +pub fn process_ancestors(_pid: u32) -> Vec { + Vec::new() +} + +pub fn first_with_window(_pids: &[u32]) -> Option { + None +} + +pub fn focus_process_window(_pid: u32, _folder: &str) -> bool { + false +} + +pub fn open_claude_desktop() -> bool { + false +} + // ── Cursor ──────────────────────────────────────────────────────────────────── /// Nothing polls the cursor here: the page reports it over the island, and the diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 79bc6a124..b9432ad24 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -1,5 +1,7 @@ // Windows: Win32 for the island window and the cursor, %APPDATA% for files. +use std::collections::HashMap; +use std::os::windows::io::RawHandle; use std::os::windows::process::CommandExt; use std::path::PathBuf; use std::process::Command; @@ -10,16 +12,23 @@ use ::windows::core::{BOOL, PWSTR}; use ::windows::Win32::Foundation::{CloseHandle, HANDLE, HLOCAL, HWND, LPARAM, LocalFree, POINT}; use ::windows::Win32::Security::Authorization::ConvertSidToStringSidW; use ::windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER}; +use ::windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, +}; use ::windows::Win32::System::Ole::RevokeDragDrop; +use ::windows::Win32::System::Pipes::GetNamedPipeClientProcessId; use ::windows::Win32::System::SystemInformation::GetLocalTime; use ::windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; use ::windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON}; use ::windows::Win32::UI::WindowsAndMessaging::{ - EnumChildWindows, GetClassNameW, GetCursorPos, GetWindowLongPtrW, SetWindowLongPtrW, - GWL_EXSTYLE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, + EnumChildWindows, EnumWindows, GetClassNameW, GetCursorPos, GetWindow, GetWindowLongPtrW, + GetWindowTextW, GetWindowThreadProcessId, IsIconic, IsWindowVisible, SetForegroundWindow, + SetWindowLongPtrW, ShowWindow, GWL_EXSTYLE, GW_OWNER, SW_RESTORE, WS_EX_NOACTIVATE, + WS_EX_TOOLWINDOW, }; use super::LocalTime; +use crate::session_window::{self, Proc}; /// File name of the Claude Code relay. pub const HOOK_EXE: &str = "coucou-hook.exe"; @@ -236,3 +245,124 @@ pub fn set_pointer_watch(_active: bool) {} /// `set_position` already places a Win32 window on the right display. pub fn pin_to_monitor(_win: &WebviewWindow, _x: i32, _y: i32) {} + +// ── Session windows ("Open terminal") ───────────────────────────────────────── +// +// See session_window.rs: the relay's ancestors lead to the terminal or editor +// window a Claude Code session runs in. + +/// The process on the other end of a relay connection. +pub fn pipe_client_pid(handle: RawHandle) -> Option { + let mut pid = 0u32; + unsafe { GetNamedPipeClientProcessId(HANDLE(handle), &mut pid).ok()? }; + (pid != 0).then_some(pid) +} + +/// Every process, by ID: its parent and its executable's name. +fn process_table() -> HashMap { + let mut out = HashMap::new(); + unsafe { + let Ok(snapshot) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else { return out }; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut more = Process32FirstW(snapshot, &mut entry).is_ok(); + while more { + let len = entry.szExeFile.iter().position(|c| *c == 0).unwrap_or(entry.szExeFile.len()); + out.insert( + entry.th32ProcessID, + Proc { + parent: entry.th32ParentProcessID, + exe: String::from_utf16_lossy(&entry.szExeFile[..len]), + }, + ); + more = Process32NextW(snapshot, &mut entry).is_ok(); + } + let _ = CloseHandle(snapshot); + } + out +} + +/// The ancestors of a process, nearest first, below the desktop shell. +pub fn process_ancestors(pid: u32) -> Vec { + session_window::ancestors(&process_table(), pid) +} + +/// Visible top-level windows that are nobody's dialog and have a title: +/// (handle, process ID, title). +fn top_windows() -> Vec<(isize, u32, String)> { + unsafe extern "system" fn collect(hwnd: HWND, lparam: LPARAM) -> BOOL { + let list = unsafe { &mut *(lparam.0 as *mut Vec<(isize, u32, String)>) }; + unsafe { + if !IsWindowVisible(hwnd).as_bool() || GetWindow(hwnd, GW_OWNER).is_ok() { + return true.into(); + } + let mut pid = 0u32; + GetWindowThreadProcessId(hwnd, Some(&mut pid as *mut u32)); + let mut text = [0u16; 512]; + let len = GetWindowTextW(hwnd, &mut text); + if len > 0 { + list.push((hwnd.0 as isize, pid, String::from_utf16_lossy(&text[..len as usize]))); + } + } + true.into() + } + let mut list: Vec<(isize, u32, String)> = Vec::new(); + unsafe { + let _ = EnumWindows(Some(collect), LPARAM(&mut list as *mut _ as isize)); + } + list +} + +/// The first of `pids` that owns a window. +pub fn first_with_window(pids: &[u32]) -> Option { + let windows = top_windows(); + pids.iter().copied().find(|pid| windows.iter().any(|(_, owner, _)| owner == pid)) +} + +fn bring_forward(raw: isize) -> bool { + let hwnd = HWND(raw as *mut _); + unsafe { + if IsIconic(hwnd).as_bool() { + let _ = ShowWindow(hwnd, SW_RESTORE); + } + // Allowed: the click on the island that asked for this was the last input. + SetForegroundWindow(hwnd).as_bool() + } +} + +/// Brings `pid`'s window forward — the one titled after `folder` if there are several. +pub fn focus_process_window(pid: u32, folder: &str) -> bool { + let candidates: Vec<(isize, String)> = top_windows() + .into_iter() + .filter(|(_, owner, _)| *owner == pid) + .map(|(hwnd, _, title)| (hwnd, title)) + .collect(); + session_window::pick_window(&candidates, folder).is_some_and(|hwnd| bring_forward(*hwnd)) +} + +/// Brings forward the window of a running app, by executable name. +fn focus_app(exe: &str) -> bool { + let pids: Vec = process_table() + .into_iter() + .filter(|(_, p)| p.exe.eq_ignore_ascii_case(exe)) + .map(|(pid, _)| pid) + .collect(); + top_windows() + .into_iter() + .find(|(_, owner, _)| pids.contains(owner)) + .is_some_and(|(hwnd, _, _)| bring_forward(hwnd)) +} + +/// The Claude desktop app: brought forward when it runs (Claude Code's own +/// `claude.exe` has no window of its own, so it is never the one picked), +/// started from where its installer puts it otherwise. +pub fn open_claude_desktop() -> bool { + if focus_app("claude.exe") { + return true; + } + let Some(base) = std::env::var_os("LOCALAPPDATA") else { return false }; + let exe = PathBuf::from(base).join("AnthropicClaude").join("claude.exe"); + exe.is_file() && Command::new(exe).spawn().is_ok() +} diff --git a/windows/src-tauri/src/session_window.rs b/windows/src-tauri/src/session_window.rs new file mode 100644 index 000000000..d1521b2d4 --- /dev/null +++ b/windows/src-tauri/src/session_window.rs @@ -0,0 +1,230 @@ +// "Open terminal" brings the window a Claude Code session runs in to the front, +// instead of opening its folder in VS Code (Mac 0.1.0: jump to the terminal). +// +// The relay is a grandchild of Claude Code, which runs in a shell, which runs +// in the terminal or editor whose window we want. When the relay connects, its +// process is still alive, so walking up from it finds that window's process. +// What is found is kept per session ID, here in memory only, and looked up when +// the button is clicked. Where nothing is found — a classic console, whose +// window belongs to a conhost that is no ancestor, or Linux — the island falls +// back to VS Code as before. +// +// The OS calls live in platform/; this file is the logic, tested on fixtures. + +// The process-tree half only runs on Windows; Linux keeps the fallback. +#![cfg_attr(not(windows), allow(dead_code))] + +use std::collections::HashMap; +use std::sync::Mutex; + +/// One process as a snapshot lists it. +#[derive(Debug, Clone)] +pub struct Proc { + pub parent: u32, + /// The executable's file name, as the snapshot gives it (`Code.exe`). + pub exe: String, +} + +/// Never the window of a session: the desktop shell and the services that sit +/// at the top of every process tree. Reaching one of them means the terminal +/// window was not an ancestor (a classic console window belongs to conhost). +const TREE_TOPS: &[&str] = &[ + "explorer.exe", "services.exe", "wininit.exe", "winlogon.exe", "svchost.exe", + "smss.exe", "csrss.exe", "system", "sihost.exe", "userinit.exe", +]; + +/// How far up we look. A session sits a handful of levels below its window. +const MAX_DEPTH: usize = 16; + +/// The ancestors of `start`, nearest first, stopping before the top of the +/// tree. `start` itself — the relay — is not included. A loop in the parent +/// links (a parent ID reused by a newer process) ends the walk. +pub fn ancestors(procs: &HashMap, start: u32) -> Vec { + let mut out = Vec::new(); + let mut seen = vec![start]; + let mut current = start; + while out.len() < MAX_DEPTH { + let Some(proc) = procs.get(¤t) else { break }; + let parent = proc.parent; + if parent == 0 || seen.contains(&parent) { + break; + } + let Some(up) = procs.get(&parent) else { break }; + if TREE_TOPS.contains(&up.exe.to_ascii_lowercase().as_str()) { + break; + } + out.push(parent); + seen.push(parent); + current = parent; + } + out +} + +/// The nearest ancestor that owns a window: the terminal or the editor. +pub fn window_owner(procs: &HashMap, start: u32, has_window: impl Fn(u32) -> bool) -> Option { + ancestors(procs, start).into_iter().find(|pid| has_window(*pid)) +} + +/// Which of a process's windows to bring forward: the one whose title names +/// the session's folder (an editor with several projects open), else the first. +pub fn pick_window<'a, W>(windows: &'a [(W, String)], folder: &str) -> Option<&'a W> { + let folder = folder.to_lowercase(); + let named = (!folder.is_empty()) + .then(|| windows.iter().find(|(_, title)| title.to_lowercase().contains(&folder))) + .flatten(); + named.or_else(|| windows.first()).map(|(w, _)| w) +} + +/// The last folder name of a path, either separator. +pub fn folder_name(path: &str) -> &str { + path.trim_end_matches(['/', '\\']).rsplit(['/', '\\']).next().unwrap_or_default() +} + +// ── Sessions seen so far ────────────────────────────────────────────────────── + +/// Session ID → the process that owns its window. Small: an old session is +/// dropped once there are more than this many. +const MAX_SESSIONS: usize = 64; + +static SESSIONS: Mutex> = Mutex::new(Vec::new()); + +/// Remembered for a session whose window could not be found, so it is not +/// looked for on every event. No process has this ID. +pub const NO_WINDOW: u32 = 0; + +/// A session ID arrives in a hook payload: only what Claude Code sends (a +/// UUID) is kept, at most 128 characters. +fn valid_session(id: &str) -> bool { + !id.is_empty() && id.len() <= 128 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +pub fn remember(session: &str, owner: u32) { + if !valid_session(session) { + return; + } + let mut list = SESSIONS.lock().unwrap_or_else(|e| e.into_inner()); + list.retain(|(s, _)| s != session); + list.push((session.to_string(), owner)); + let excess = list.len().saturating_sub(MAX_SESSIONS); + list.drain(..excess); +} + +/// True once the session has been looked at, window found or not. +pub fn known(session: &str) -> bool { + let list = SESSIONS.lock().unwrap_or_else(|e| e.into_inner()); + list.iter().any(|(s, _)| s == session) +} + +/// The process owning the session's window, if one was found. +pub fn lookup(session: &str) -> Option { + let list = SESSIONS.lock().unwrap_or_else(|e| e.into_inner()); + list.iter().find(|(s, _)| s == session).map(|(_, pid)| *pid).filter(|pid| *pid != NO_WINDOW) +} + +pub fn forget(session: &str) { + SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).retain(|(s, _)| s != session); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tree(entries: &[(u32, u32, &str)]) -> HashMap { + entries + .iter() + .map(|(pid, parent, exe)| (*pid, Proc { parent: *parent, exe: exe.to_string() })) + .collect() + } + + #[test] + fn windows_terminal_is_found_above_the_shells() { + // WindowsTerminal → pwsh → claude (node) → bash → coucou-hook + let procs = tree(&[ + (4, 0, "System"), + (100, 4, "explorer.exe"), + (200, 100, "WindowsTerminal.exe"), + (300, 200, "pwsh.exe"), + (400, 300, "node.exe"), + (500, 400, "bash.exe"), + (600, 500, "coucou-hook.exe"), + ]); + assert_eq!(ancestors(&procs, 600), [500, 400, 300, 200]); + assert_eq!(window_owner(&procs, 600, |pid| pid == 200 || pid == 100), Some(200)); + } + + #[test] + fn vs_code_is_found_through_its_pty_host() { + let procs = tree(&[ + (100, 1, "explorer.exe"), + (210, 100, "Code.exe"), + (220, 210, "Code.exe"), + (300, 220, "powershell.exe"), + (400, 300, "node.exe"), + (600, 400, "coucou-hook.exe"), + ]); + assert_eq!(window_owner(&procs, 600, |pid| pid == 210), Some(210)); + } + + #[test] + fn a_classic_console_finds_nothing_rather_than_the_desktop() { + // cmd.exe's window belongs to conhost, which is not an ancestor; the + // walk must stop at explorer instead of picking the taskbar. + let procs = tree(&[ + (100, 1, "explorer.exe"), + (300, 100, "cmd.exe"), + (400, 300, "node.exe"), + (600, 400, "coucou-hook.exe"), + ]); + assert_eq!(window_owner(&procs, 600, |pid| pid == 100), None); + } + + #[test] + fn a_relay_already_gone_or_a_loop_ends_the_walk() { + assert!(ancestors(&tree(&[]), 600).is_empty()); + let looped = tree(&[(1, 2, "a.exe"), (2, 1, "b.exe"), (3, 1, "coucou-hook.exe")]); + assert_eq!(ancestors(&looped, 3), [1, 2]); + let deep: Vec<(u32, u32, &str)> = (1..100).map(|i| (i, i + 1, "x.exe")).collect(); + assert_eq!(ancestors(&tree(&deep), 1).len(), MAX_DEPTH); + } + + #[test] + fn the_window_named_after_the_project_wins() { + let windows = vec![ + (1, "notes — Visual Studio Code".to_string()), + (2, "app.ts — coucou — Visual Studio Code".to_string()), + ]; + assert_eq!(pick_window(&windows, "Coucou"), Some(&2)); + assert_eq!(pick_window(&windows, "other"), Some(&1)); + assert_eq!(pick_window(&windows, ""), Some(&1)); + assert_eq!(pick_window::(&[], "x"), None); + assert_eq!(folder_name(r"C:\Users\me\coucou\"), "coucou"); + assert_eq!(folder_name("/home/me/proj"), "proj"); + } + + #[test] + fn sessions_are_remembered_by_id_and_only_plausible_ids_are_kept() { + remember("s-test-1", 42); + remember("s-test-1", 43); + assert_eq!(lookup("s-test-1"), Some(43)); + remember("not a session/../x", 7); + assert!(!known("not a session/../x")); + forget("s-test-1"); + assert!(!known("s-test-1")); + + // A session without a window is settled, with nothing to show. + remember("s-test-2", NO_WINDOW); + assert!(known("s-test-2")); + assert_eq!(lookup("s-test-2"), None); + forget("s-test-2"); + + // Only the latest sessions are kept. (One test: the list is shared.) + for i in 0..(MAX_SESSIONS + 5) { + remember(&format!("s-cap-{i}"), 1); + } + assert!(!known("s-cap-0")); + assert!(known(&format!("s-cap-{}", MAX_SESSIONS + 4))); + for i in 0..(MAX_SESSIONS + 5) { + forget(&format!("s-cap-{i}")); + } + } +} diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 35c50dc8a..4eb29d774 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -58,6 +58,16 @@ export const Bridge = { /** "Open terminal" → opens the folder in VS Code when `code` is on PATH. */ openInVSCode: (path: string | null) => call("open_in_vscode", { path }), + /** + * "Open terminal": the window the session runs in when Rust found it + * (Windows), else the folder in VS Code. + */ + openSession: (sessionId: string | null, path: string | null) => + call("open_session", { sessionId, path }), + + /** The Claude desktop app, for the Claude Desktop pill (Windows only). */ + openClaudeDesktop: () => call("open_claude_desktop"), + quit: () => call("quit_app"), openSettingsWindow: () => call("open_settings_window"), diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 384cf4080..39939e4e3 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -23,6 +23,7 @@ import { IslandStateMachine } from "./fsm"; import { refreshHookPills } from "./integrations"; const BOT_OVERHANG = 40; +const CLAUDE_DESKTOP_ID = "agent_claude-desktop"; /** Same margin as the Rust hit test (src-tauri/src/island.rs). */ const HIT_MARGIN = 14; @@ -134,8 +135,10 @@ export class Island { Sound.play("blip"); }, openTerminal: () => { - const cwd = State.focusTask?.sessionCwd ?? null; - void Bridge.openInVSCode(cwd); + const task = State.focusTask; + // Sessions from the Claude desktop app live there, not in a terminal. + if (task?.id === CLAUDE_DESKTOP_ID) void Bridge.openClaudeDesktop(); + else void Bridge.openSession(task?.sessionId ?? null, task?.sessionCwd ?? null); }, // The ↗ button — same targets as openAgentTarget() on macOS. openTarget: () => { @@ -149,8 +152,10 @@ export class Island { integration_notion: "https://notion.so", integration_calcom: "https://app.cal.com/bookings", }; - if (task.id === "integration_claude") void Bridge.openInVSCode(task.sessionCwd ?? null); - else if (task.id === "integration_n8n") void Bridge.openN8n(); + if (task.id === CLAUDE_DESKTOP_ID) void Bridge.openClaudeDesktop(); + else if (task.id === "integration_claude" || task.sessionId) { + void Bridge.openSession(task.sessionId ?? null, task.sessionCwd ?? null); + } else if (task.id === "integration_n8n") void Bridge.openN8n(); else if (urls[task.id]) void Bridge.openUrl(urls[task.id]); }, openUrl: (url) => { diff --git a/windows/src/views/integrations.ts b/windows/src/views/integrations.ts index cf2564a3d..75bc42a67 100644 --- a/windows/src/views/integrations.ts +++ b/windows/src/views/integrations.ts @@ -94,6 +94,15 @@ function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { onclick: () => void Bridge.openInVSCode(task.sessionCwd ?? null), }), ); + } else if (task.id === "agent_claude-desktop") { + actions.append( + h("button", { + class: "link-btn", + style: `color:${task.color}d9`, + text: "Open Claude", + onclick: () => void Bridge.openClaudeDesktop(), + }), + ); } else if (task.id === "integration_n8n") { actions.append( h("button", { diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index d77610362..a7bb9f221 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -493,8 +493,9 @@ function buildError(actions: ViewActions): ViewHost { function buildFinished(actions: ViewActions): ViewHost { const who = h("div"); const title = h("div", { class: "title" }); + const open = btn("Open terminal", "primary", () => actions.openTerminal()); const row = h("div", { class: "actions" }, - btn("Open terminal", "primary", () => actions.openTerminal()), + open, btn("OK", "secondary", () => actions.collapse()), ); const el = h("div", { class: "view" }, card("green", stack(116, 16, who, title, row))); @@ -504,6 +505,10 @@ function buildFinished(actions: ViewActions): ViewHost { clear(who); who.append(agentWho(State.focusTask, "Claude Code finished")); title.textContent = State.focusTask?.steps.at(-1) ?? "Session finished"; + // Sessions from the Claude desktop app live there, not in a terminal. + const label = State.focusTask?.id === "agent_claude-desktop" ? "Open Claude" : "Open terminal"; + const span = open.firstElementChild as HTMLElement; + if (span.textContent !== label) span.textContent = label; }, }; } From dde859eefa77b89a9fbccc98d90194be6bd46a97 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:59:08 +0200 Subject: [PATCH 60/77] Desktop Mochi: the pure logic and his life cycle, with tests Port of DesktopMochiLogic.swift (sleep rule, body hit test, look origin, retract on landing) and of DesktopMochiController's phases (fly out, back to the island for an alert, home), with the world injected so it can be tested. The Node tests mirror tests/DesktopMochiTests.swift. Co-Authored-By: Claude Opus 5.5 --- windows/src/mochi/desktop-logic.ts | 307 +++++++++++++++++++++++++ windows/tests/desktop.test.mjs | 349 +++++++++++++++++++++++++++++ 2 files changed, 656 insertions(+) create mode 100644 windows/src/mochi/desktop-logic.ts create mode 100644 windows/tests/desktop.test.mjs diff --git a/windows/src/mochi/desktop-logic.ts b/windows/src/mochi/desktop-logic.ts new file mode 100644 index 000000000..fc8d5e6a6 --- /dev/null +++ b/windows/src/mochi/desktop-logic.ts @@ -0,0 +1,307 @@ +// Mochi on the desktop — the pure logic, port of +// NotchBuddy/Sources/App/DesktopMochiLogic.swift and of the life cycle in +// DesktopMochiController (DesktopMochi.swift). No DOM, no Tauri: the island +// wires it up in src/island/desktop.ts, the desktop window draws him in +// src/desktop/main.ts, and Rust owns the window (src-tauri/src/desktop.rs). +// +// Coordinates are y-down everywhere on a PC, so the Mac's DesktopSpace flip is +// not needed: a point on the desktop is used as the OS reports it. + +import type { BotEmoteName, BotStateName } from "../core/layout"; +import type { Outfit } from "./wardrobe"; + +/** What the island tells the desktop window, whenever it changes. */ +export interface DesktopSnapshot { + state: BotStateName; + /** He is always the main Mochi, so always dressed (try-ons included). */ + outfit: Outfit; + soundEnabled: boolean; + soundVolume: number; + /** Tray → Pause: he dozes off and stays asleep. */ + paused: boolean; +} + +/** Events between the two windows. Rust adds `desktop-mochi-dropped`. */ +export const DESKTOP_EVENTS = { + /** island → desktop window */ + state: "desktop-mochi-state", + emote: "desktop-mochi-emote", + /** desktop window → island */ + ready: "desktop-mochi-ready", + home: "desktop-mochi-home", + wardrobe: "desktop-mochi-wardrobe", + dizzy: "desktop-mochi-dizzy", + /** Rust → island: a drag ended, `{ from: "island" | "desktop", home }`. */ + dropped: "desktop-mochi-dropped", + /** Rust → desktop window */ + cursor: "desktop-cursor", + visible: "desktop-visible", + flight: "desktop-flight", +} as const; + +// ── Constants (DesktopMochiLogic) ──────────────────────────────────────────── + +/** Side of the desktop window, logical pixels. */ +export const PANEL_SIZE = 120; +/** Seconds without agent activity before he may fall asleep. */ +export const SLEEP_TIMEOUT = 120; +/** He only dozes off once the pointer is at least this far from him. */ +export const SLEEP_MOUSE_DISTANCE = 150; +/** Clickable body radius, as a fraction of the window side. */ +export const BODY_RADIUS_FRACTION = 0.24; + +/** Surprised, then off to the island (DesktopMochi.swift: 0.45 s). */ +export const RETRACT_DELAY_MS = 450; +/** Pause in the island after the alert is answered, before flying back (0.6 s). */ +export const RETURN_DELAY_MS = 600; +/** Single click → poke, unless it turns out to be a double click. */ +export const DOUBLE_CLICK_MS = 300; +/** A press that moves farther than this is a drag, not a click. */ +export const DRAG_THRESHOLD = 3; + +// ── Pure helpers ───────────────────────────────────────────────────────────── + +export interface Point { + x: number; + y: number; +} + +/** + * Whether Mochi should be asleep: no agent activity for longer than + * SLEEP_TIMEOUT (strictly), and the pointer not close to him. + */ +export function shouldSleep(sinceAgentActive: number, mouseDistanceToCenter: number): boolean { + return sinceAgentActive > SLEEP_TIMEOUT && mouseDistanceToCenter >= SLEEP_MOUSE_DISTANCE; +} + +/** Hit test of the round body inside the square window (window-local). */ +export function isOverBody(local: Point, panelSize = PANEL_SIZE): boolean { + const c = panelSize / 2; + const r = panelSize * BODY_RADIUS_FRACTION; + const dx = local.x - c; + const dy = local.y - c; + return dx * dx + dy * dy <= r * r; +} + +/** Eye-tracking origin: the window centre, in the same space as the cursor. */ +export function lookOrigin(panelMinX: number, panelMinY: number, panelSize = PANEL_SIZE): Point { + return { x: panelMinX + panelSize / 2, y: panelMinY + panelSize / 2 }; +} + +/** BotCanvasView.lookX / lookY: x right +, y up +. */ +export function gaze(bot: Point, mouse: Point): { lookX: number; lookY: number } { + return { + lookX: Math.tanh((mouse.x - bot.x) / 260), + lookY: -Math.tanh((mouse.y - bot.y) / 200), + }; +} + +/** An alert went up during a flight: once landed, he turns right back. */ +export function shouldRetractOnLanding(alertActive: boolean): boolean { + return alertActive; +} + +/** Agent work keeps him awake; idle and sleeping don't. */ +export function agentActive(state: BotStateName): boolean { + return state !== "idle" && state !== "sleeping"; +} + +/** + * What sends him back to the island: a permission waiting for a click, or an + * agent asking a question. (The Mac's pendingApproval / pendingQuestion.) + */ +export function alertActive(s: { + pendingApproval: unknown; + tasks: ReadonlyArray<{ state: BotStateName }>; +}): boolean { + return s.pendingApproval != null || s.tasks.some((t) => t.state === "question"); +} + +/** + * Linux has no global cursor (Wayland), so his window only hears the pointer + * while it is over his body. That counts as "near" for this long afterwards. + */ +export const POINTER_MEMORY_MS = 4000; + +/** + * Distance from the pointer to his centre, for shouldSleep. With a cursor + * poll (Windows) it is measured; without one, the pointer is near while it was + * over him a moment ago, and far otherwise. + */ +export function pointerDistance( + cursorPoll: boolean, + cursor: Point | null, + sincePointerMs: number, + panelSize = PANEL_SIZE, +): number { + if (cursorPoll) { + return cursor ? Math.hypot(cursor.x - panelSize / 2, cursor.y - panelSize / 2) : Infinity; + } + return sincePointerMs < POINTER_MEMORY_MS ? 0 : Infinity; +} + +/** + * Layer-shell drag: the surface is stretched over the display, so the pointer + * reads in display coordinates — once the compositor has applied it. Until + * then the pointer is still relative to the small surface at `origin`. + */ +export function layerDragTopLeft(origin: Point, client: Point, grab: Point, overlayApplied: boolean): Point { + const base = overlayApplied ? { x: 0, y: 0 } : origin; + return { x: base.x + client.x - grab.x, y: base.y + client.y - grab.y }; +} + +/** X11 drag: screen coordinates are global there; the window moves in physical pixels. */ +export function windowDragTopLeft(origin: Point, screen: Point, screenAtPress: Point, dpr: number): Point { + return { + x: origin.x + (screen.x - screenAtPress.x) * dpr, + y: origin.y + (screen.y - screenAtPress.y) * dpr, + }; +} + +// ── Life cycle (DesktopMochiController) ────────────────────────────────────── + +export type DesktopPhase = + /** No window on screen. */ + | "home" + /** Window flying from the island to his spot. */ + | "flyingOut" + /** On the desktop: the normal state. */ + | "onDesktop" + /** An alert just went up: surprised, about to fly to the island. */ + | "retracting" + /** The alert was answered while he was flying to the island. */ + | "alertResolvedDuringRetract" + /** In the island, showing the alert; back out once it is answered. */ + | "atNotchForAlert"; + +/** Everything the life cycle does to the world, injected so it can be tested. */ +export interface DesktopPorts { + /** Shows the window at the island and flies it to his spot. False: no spot. */ + flyOut(): Promise; + /** Flies the window to the island and hides it. `forget`: he lives there again. */ + flyHome(forget: boolean): Promise; + /** The island's own Mochi hides while he is out. */ + setAway(away: boolean): void; + /** An emote on the desktop Mochi. */ + emote(emote: BotEmoteName, duration?: number): void; + play(sound: string): void; + alertActive(): boolean; + /** The island must be on screen for him to show the alert there. */ + revealIsland(): void; + later(fn: () => void, ms: number): void; +} + +export class DesktopMochiController { + phase: DesktopPhase = "home"; + /** He lives on the desktop (the `onDesktop` preference). */ + enabled = false; + + private lastState: BotStateName | null = null; + private port: DesktopPorts; + + constructor(port: DesktopPorts) { + this.port = port; + } + + /** Launch, and back from an alert: fly out to his spot if he lives there. */ + async launchFlyIfNeeded(): Promise { + if (!this.enabled || this.phase !== "home") return; + // An alert is up: wait in the island, the alert's end flies him out. + if (this.port.alertActive()) { + this.phase = "atNotchForAlert"; + return; + } + this.phase = "flyingOut"; + this.port.setAway(true); + const ok = await this.port.flyOut(); + if (this.phase !== "flyingOut") return; + if (!ok) { + // His spot is on a display that is gone: he stays home. + this.phase = "home"; + this.enabled = false; + this.port.setAway(false); + return; + } + this.landed(); + } + + /** Dropped on the desktop after a drag out of the island (Rust placed him). */ + installed() { + if (this.phase !== "home") return; + this.enabled = true; + this.port.setAway(true); + this.port.emote("happy", 0.6); + this.port.play("pop"); + this.landed(); + } + + /** Double click, or dropped on the island: home for good. */ + async flyHome(): Promise { + // Also in the instant between an alert and his flight to the island: the + // user's "home" wins, and the pending retract finds nothing left to do. + const out = ["onDesktop", "retracting", "alertResolvedDuringRetract"]; + if (!out.includes(this.phase)) return; + this.phase = "home"; + this.enabled = false; + await this.port.flyHome(true); + this.port.play("peek"); + this.port.setAway(false); + } + + /** Call whenever the alert condition may have changed; only edges count. */ + private alertWas = false; + updateAlert(active: boolean) { + if (active === this.alertWas) return; + this.alertWas = active; + if (active) { + if (this.phase === "onDesktop") this.beginRetract(); + return; + } + if (this.phase === "atNotchForAlert") { + this.phase = "home"; + this.port.later(() => void this.launchFlyIfNeeded(), RETURN_DELAY_MS); + } else if (this.phase === "retracting") { + this.phase = "alertResolvedDuringRetract"; + } + } + + /** Call with the effective state; a task finishing gets a happy jump. */ + updateState(state: BotStateName) { + const prev = this.lastState; + this.lastState = state; + if (prev == null || prev === state) return; + if (state === "finished" && this.phase === "onDesktop") this.port.emote("happy", 1.2); + } + + private landed() { + this.phase = "onDesktop"; + // An alert may have gone up during the flight, when nothing reacts to it. + this.alertWas = this.port.alertActive(); + if (shouldRetractOnLanding(this.alertWas)) this.beginRetract(); + } + + private beginRetract() { + this.port.emote("surprised"); + this.phase = "retracting"; + this.port.later(() => { + if (this.phase === "retracting") { + void this.retractForAlert(); + } else if (this.phase === "alertResolvedDuringRetract") { + // Answered before he even took off: he never left his spot. + this.phase = "onDesktop"; + } + }, RETRACT_DELAY_MS); + } + + private async retractForAlert() { + await this.port.flyHome(false); + this.port.setAway(false); + if (this.phase === "alertResolvedDuringRetract") { + this.phase = "home"; + await this.launchFlyIfNeeded(); + } else if (this.phase === "retracting") { + this.phase = "atNotchForAlert"; + this.port.revealIsland(); + } + } +} diff --git a/windows/tests/desktop.test.mjs b/windows/tests/desktop.test.mjs new file mode 100644 index 000000000..2a34b319d --- /dev/null +++ b/windows/tests/desktop.test.mjs @@ -0,0 +1,349 @@ +// Mochi on the desktop: the pure logic and the life cycle (src/mochi/desktop-logic.ts). +// The first half mirrors tests/DesktopMochiTests.swift; the geometry of the +// window itself (clamping, saved spots, the island's home zone) is tested in +// src-tauri/src/desktop.rs, where it lives. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + BODY_RADIUS_FRACTION, DesktopMochiController, PANEL_SIZE, RETRACT_DELAY_MS, RETURN_DELAY_MS, + agentActive, alertActive, gaze, isOverBody, layerDragTopLeft, lookOrigin, pointerDistance, + shouldRetractOnLanding, shouldSleep, windowDragTopLeft, +} from "../src/mochi/desktop-logic.ts"; + +// ── shouldSleep ─────────────────────────────────────────────────────────────── + +test("shouldSleep: idle long enough and the pointer far away", () => { + assert.equal(shouldSleep(5, 300), false, "active agent must not sleep"); + assert.equal(shouldSleep(200, 50), false, "mouse near panel must not sleep"); + assert.equal(shouldSleep(200, 200), true, "long idle + far mouse must sleep"); + assert.equal(shouldSleep(120, 300), false, "exactly at timeout must not sleep"); + assert.equal(shouldSleep(120.1, 300), true, "just over timeout must sleep"); + assert.equal(shouldSleep(200, 150), true, "at distance threshold must sleep"); +}); + +test("agent work keeps him awake, idle and sleeping don't", () => { + for (const s of ["working", "thinking", "searching", "approval", "question", "finished", "error"]) { + assert.equal(agentActive(s), true, s); + } + assert.equal(agentActive("idle"), false); + assert.equal(agentActive("sleeping"), false); +}); + +test("pointer distance: measured with a cursor poll, remembered without one", () => { + assert.equal(pointerDistance(true, { x: 60, y: 60 }, 0), 0); + assert.equal(pointerDistance(true, { x: 60 + 150, y: 60 }, 0), 150); + assert.equal(pointerDistance(true, null, 0), Infinity, "no cursor yet: far"); + // Linux: near while the pointer was over him a moment ago. + assert.equal(pointerDistance(false, null, 100), 0); + assert.equal(pointerDistance(false, { x: 60, y: 60 }, 60_000), Infinity); +}); + +// ── isOverBody ──────────────────────────────────────────────────────────────── + +test("isOverBody: the round body, not the square window", () => { + const s = 120; + const r = s * BODY_RADIUS_FRACTION; // 28.8 + assert.equal(isOverBody({ x: 60, y: 60 }, s), true, "center must be inside body"); + assert.equal(isOverBody({ x: 60 + r - 0.5, y: 60 }, s), true, "inside radius must hit"); + assert.equal(isOverBody({ x: 60 + r + 0.5, y: 60 }, s), false, "outside radius must miss"); + assert.equal(isOverBody({ x: 0, y: 0 }, s), false, "corner must miss"); + const diag = r / Math.SQRT2 - 0.5; + assert.equal(isOverBody({ x: 60 + diag, y: 60 + diag }, s), true, "diagonal inside must hit"); + assert.equal(PANEL_SIZE, 120); +}); + +// ── lookOrigin / gaze ───────────────────────────────────────────────────────── + +const sign = (v) => (v === 0 ? 0 : v < 0 ? -1 : 1); + +/** The Mac test's AppKit points (y up from the menu-bar screen's bottom) → y down. */ +const topDown = (p, desktopTop) => ({ x: p.x, y: desktopTop - p.y }); + +test("lookOrigin: the window centre, in desktop coordinates", () => { + // Panel at (200, 300) from the top-left of the desktop. + assert.deepEqual(lookOrigin(200, 300, 120), { x: 260, y: 360 }); + // On a secondary screen to the right: x stays global, not screen-relative. + assert.deepEqual(lookOrigin(1540, 100, 120), { x: 1600, y: 160 }); +}); + +test("gaze signs across a real arrangement of screens", () => { + // Menu-bar MacBook 1512×982 at (0,0); external 2560×1440 above it at + // (-500, 982); portrait 1080×1920 on the left at (-1080, -600). AppKit + // coordinates as in the Swift test, turned y-down like a PC reports them. + const top = 982; + const look = (bot, mouse) => { + const g = gaze(topDown(bot, top), topDown(mouse, top)); + return [sign(g.lookX), sign(g.lookY)]; + }; + // Mochi in the island at the top of the external screen above. + const islandOnTop = { x: 780, y: 2422 - 16 }; + assert.deepEqual(look(islandOnTop, { x: 1400, y: 100 }), [1, -1], "cursor on the MacBook below-right"); + assert.deepEqual(look(islandOnTop, { x: -1000, y: 0 }), [-1, -1], "cursor on the portrait screen"); + assert.deepEqual(look(islandOnTop, { x: 780, y: 2500 }), [0, 1], "cursor above Mochi"); + // Mochi on the desktop of the portrait screen, cursor on the external screen above. + const panelMin = topDown({ x: -700, y: -200 + 120 }, top); // AppKit minY is the bottom edge + const desktopOnLeft = lookOrigin(panelMin.x, panelMin.y, 120); + const mouseAbove = topDown({ x: 1000, y: 2000 }, top); + assert.ok(mouseAbove.x > desktopOnLeft.x && mouseAbove.y < desktopOnLeft.y, + "desktop Mochi looks right and up at a cursor on another screen"); + const g = gaze(desktopOnLeft, mouseAbove); + assert.ok(g.lookX > 0 && g.lookY > 0); +}); + +// ── shouldRetractOnLanding / alerts ─────────────────────────────────────────── + +test("shouldRetractOnLanding", () => { + assert.equal(shouldRetractOnLanding(true), true, "must retract when alert is active on landing"); + assert.equal(shouldRetractOnLanding(false), false, "must not retract when no alert on landing"); +}); + +test("a permission or a question sends him back to the island", () => { + assert.equal(alertActive({ pendingApproval: null, tasks: [{ state: "working" }] }), false); + assert.equal(alertActive({ pendingApproval: { requestId: "r" }, tasks: [] }), true); + assert.equal(alertActive({ pendingApproval: null, tasks: [{ state: "idle" }, { state: "question" }] }), true); +}); + +// ── Linux drags ─────────────────────────────────────────────────────────────── + +test("layer-shell drag: display coordinates once the overlay is up", () => { + const origin = { x: 500, y: 300 }; + const grab = { x: 60, y: 62 }; + // Not applied yet: the pointer is still relative to the small surface. + assert.deepEqual(layerDragTopLeft(origin, { x: 70, y: 62 }, grab, false), { x: 510, y: 300 }); + // Applied: the pointer reads in display coordinates. + assert.deepEqual(layerDragTopLeft(origin, { x: 900, y: 462 }, grab, true), { x: 840, y: 400 }); +}); + +test("X11 drag: screen delta, in physical pixels", () => { + assert.deepEqual( + windowDragTopLeft({ x: 1000, y: 600 }, { x: 520, y: 310 }, { x: 500, y: 300 }, 2), + { x: 1040, y: 620 }, + ); +}); + +// ── Life cycle ──────────────────────────────────────────────────────────────── + +/** A world the controller acts on, with manual flights and timers. */ +function world({ alert = false, flyOutOk = true } = {}) { + const log = []; + const timers = []; + const flights = []; + const w = { + alert, + away: false, + log, + /** Lands the oldest pending flight. */ + async land(result) { + const f = flights.shift(); + assert.ok(f, "a flight should be in the air"); + f.resolve(result ?? f.defaultResult); + await new Promise((r) => setImmediate(r)); + }, + get flying() { + return flights.map((f) => f.kind); + }, + /** Runs the timers due within `ms`. */ + async tick(ms) { + const due = timers.filter((t) => t.ms <= ms); + for (const t of due) timers.splice(timers.indexOf(t), 1); + for (const t of due) t.fn(); + await new Promise((r) => setImmediate(r)); + }, + port: { + flyOut: () => new Promise((resolve) => flights.push({ kind: "out", resolve, defaultResult: flyOutOk })), + flyHome: (forget) => { + log.push(forget ? "home-forget" : "home-keep"); + return new Promise((resolve) => flights.push({ kind: "home", resolve, defaultResult: true })); + }, + setAway: (a) => { + w.away = a; + }, + emote: (e) => log.push(`emote:${e}`), + play: (s) => log.push(`sound:${s}`), + alertActive: () => w.alert, + revealIsland: () => log.push("reveal"), + later: (fn, ms) => timers.push({ fn, ms }), + }, + }; + w.c = new DesktopMochiController(w.port); + return w; +} + +test("launch: flies out to his spot only if he lives on the desktop", async () => { + const w = world(); + await w.c.launchFlyIfNeeded(); + assert.equal(w.c.phase, "home", "not enabled: stays home"); + assert.deepEqual(w.flying, []); + + w.c.enabled = true; + void w.c.launchFlyIfNeeded(); + assert.equal(w.c.phase, "flyingOut"); + assert.equal(w.away, true, "the island's Mochi hides as he leaves"); + await w.land(); + assert.equal(w.c.phase, "onDesktop"); +}); + +test("launch: a spot on a display that is gone keeps him home", async () => { + const w = world({ flyOutOk: false }); + w.c.enabled = true; + void w.c.launchFlyIfNeeded(); + await w.land(); + assert.equal(w.c.phase, "home"); + assert.equal(w.c.enabled, false); + assert.equal(w.away, false); +}); + +test("launch during an alert waits in the island, then flies out once answered", async () => { + const w = world({ alert: true }); + w.c.enabled = true; + w.c.updateAlert(true); + await w.c.launchFlyIfNeeded(); + assert.equal(w.c.phase, "atNotchForAlert"); + assert.deepEqual(w.flying, []); + + w.alert = false; + w.c.updateAlert(false); + assert.equal(w.c.phase, "home"); + await w.tick(RETURN_DELAY_MS); + assert.deepEqual(w.flying, ["out"]); + await w.land(); + assert.equal(w.c.phase, "onDesktop"); +}); + +test("alert on the desktop: surprised, flies to the island, comes back once answered", async () => { + const w = world(); + w.c.enabled = true; + void w.c.launchFlyIfNeeded(); + await w.land(); + + w.alert = true; + w.c.updateAlert(true); + assert.equal(w.c.phase, "retracting"); + assert.ok(w.log.includes("emote:surprised")); + await w.tick(RETRACT_DELAY_MS); + assert.deepEqual(w.flying, ["home"]); + assert.ok(w.log.includes("home-keep"), "he keeps his spot"); + await w.land(); + assert.equal(w.c.phase, "atNotchForAlert"); + assert.equal(w.away, false, "the island's Mochi shows the alert"); + assert.ok(w.log.includes("reveal")); + assert.equal(w.c.enabled, true); + + w.alert = false; + w.c.updateAlert(false); + await w.tick(RETURN_DELAY_MS); + await w.land(); + assert.equal(w.c.phase, "onDesktop"); +}); + +test("alert answered before he takes off: he never leaves his spot", async () => { + const w = world(); + w.c.enabled = true; + void w.c.launchFlyIfNeeded(); + await w.land(); + + w.alert = true; + w.c.updateAlert(true); + w.alert = false; + w.c.updateAlert(false); + assert.equal(w.c.phase, "alertResolvedDuringRetract"); + await w.tick(RETRACT_DELAY_MS); + assert.equal(w.c.phase, "onDesktop"); + assert.deepEqual(w.flying, []); +}); + +test("alert answered during the flight to the island: straight back out", async () => { + const w = world(); + w.c.enabled = true; + void w.c.launchFlyIfNeeded(); + await w.land(); + + w.alert = true; + w.c.updateAlert(true); + await w.tick(RETRACT_DELAY_MS); + assert.deepEqual(w.flying, ["home"]); + w.alert = false; + w.c.updateAlert(false); + assert.equal(w.c.phase, "alertResolvedDuringRetract"); + await w.land(); + assert.deepEqual(w.flying, ["out"], "relaunched as soon as he reached the island"); + await w.land(); + assert.equal(w.c.phase, "onDesktop"); +}); + +test("an alert that went up during the flight out sends him right back", async () => { + const w = world(); + w.c.enabled = true; + void w.c.launchFlyIfNeeded(); + w.alert = true; + w.c.updateAlert(true); // ignored mid-flight + assert.equal(w.c.phase, "flyingOut"); + await w.land(); + assert.equal(w.c.phase, "retracting"); + await w.tick(RETRACT_DELAY_MS); + assert.deepEqual(w.flying, ["home"]); +}); + +test("dropped on the desktop from the island: lands with a pop", () => { + const w = world(); + w.c.installed(); + assert.equal(w.c.phase, "onDesktop"); + assert.equal(w.c.enabled, true); + assert.equal(w.away, true); + assert.ok(w.log.includes("sound:pop")); + assert.ok(w.log.includes("emote:happy")); +}); + +test("dropped on the desktop while an alert is up: turns right back", () => { + const w = world({ alert: true }); + w.c.installed(); + assert.equal(w.c.phase, "retracting"); +}); + +test("double click or dropped on the island: home for good", async () => { + const w = world(); + w.c.installed(); + void w.c.flyHome(); + assert.equal(w.c.phase, "home"); + assert.equal(w.c.enabled, false); + assert.ok(w.log.includes("home-forget")); + await w.land(); + assert.equal(w.away, false); + assert.ok(w.log.includes("sound:peek")); + + // An alert now leaves him in the island. + w.alert = true; + w.c.updateAlert(true); + assert.equal(w.c.phase, "home"); +}); + +test("sent home in the instant before an alert takes him: home wins", async () => { + const w = world(); + w.c.installed(); + w.alert = true; + w.c.updateAlert(true); + assert.equal(w.c.phase, "retracting"); + void w.c.flyHome(); + assert.equal(w.c.phase, "home"); + await w.land(); + await w.tick(RETRACT_DELAY_MS); + assert.deepEqual(w.flying, [], "the retract has nothing left to do"); + w.alert = false; + w.c.updateAlert(false); + await w.tick(RETURN_DELAY_MS); + assert.deepEqual(w.flying, [], "and he doesn't come back out"); +}); + +test("a finished task gets a happy jump, only on the desktop", () => { + const w = world(); + w.c.updateState("working"); + w.c.updateState("finished"); + assert.ok(!w.log.includes("emote:happy"), "home: the island's Mochi celebrates"); + w.c.installed(); + w.log.length = 0; + w.c.updateState("working"); + w.c.updateState("finished"); + w.c.updateState("finished"); + assert.deepEqual(w.log, ["emote:happy"]); +}); From 64bb85e2d216b4e45fa07225b32a078f245e8448 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:59:13 +0200 Subject: [PATCH 61/77] Desktop Mochi: his own window, placed and polled from Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small transparent, always-on-top window with no taskbar button, created hidden at launch like the settings window. desktop.rs owns it: flights to and from the island, the drop (home over the island's panel, otherwise settled into the work area), and his spot in the preferences, validated against the displays connected at launch. Windows: a cursor poll — only while he is awake or carried — makes the window click-through except over his body, feeds his eyes and carries him during a drag; the window is parked off-screen rather than hidden so showing it never takes the focus. Linux: the input region is his body; X11 places an ordinary window, layer-shell compositors get a layer surface on the island's display placed with margins; GNOME on Wayland has neither and the feature is off. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/capabilities/default.json | 2 +- windows/src-tauri/src/desktop.rs | 1007 +++++++++++++++++++ windows/src-tauri/src/island.rs | 4 +- windows/src-tauri/src/lib.rs | 21 +- windows/src-tauri/src/platform/linux.rs | 182 +++- windows/src-tauri/src/platform/mod.rs | 52 + windows/src-tauri/src/platform/windows.rs | 28 + windows/src-tauri/src/settings.rs | 49 +- 8 files changed, 1337 insertions(+), 8 deletions(-) create mode 100644 windows/src-tauri/src/desktop.rs diff --git a/windows/src-tauri/capabilities/default.json b/windows/src-tauri/capabilities/default.json index 38b8f5dfe..c869302e7 100644 --- a/windows/src-tauri/capabilities/default.json +++ b/windows/src-tauri/capabilities/default.json @@ -2,6 +2,6 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "What Coucou's windows are allowed to do. Everything that touches the OS goes through Coucou's own commands.", - "windows": ["island", "settings"], + "windows": ["island", "settings", "mochi"], "permissions": ["core:default", "autostart:default"] } diff --git a/windows/src-tauri/src/desktop.rs b/windows/src-tauri/src/desktop.rs new file mode 100644 index 000000000..5b7e6509e --- /dev/null +++ b/windows/src-tauri/src/desktop.rs @@ -0,0 +1,1007 @@ +// Mochi on the desktop: his own small window, wherever the user dropped him. +// Window side of DesktopMochi.swift, plus the geometry half of +// DesktopMochiLogic.swift (`logic` below, with its tests). +// +// The island page runs his life cycle (src/island/desktop.ts): fly out, back to +// the island for an alert, home. This file owns the window — where it is, which +// part of it takes the mouse, the flights, the drag, and the saved spot. +// +// How the window is placed depends on the system (platform::DesktopMode): +// * Windows: anywhere, in physical desktop pixels. A cursor poll — running +// only while he is awake on the desktop — makes the window click-through +// except over his body, feeds his eyes, and carries him during a drag. +// * Linux X11: the same coordinates; the input region is his body, and the +// page drives the drag. +// * Linux layer-shell: a layer surface on the island's display, placed with +// margins in logical pixels. +// * GNOME on Wayland: off, nothing is created. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, State, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + +use crate::island::{self, PollGate}; +use crate::platform::{self, DesktopMode, MouseShape}; +use crate::settings::{self, DesktopSpot}; + +pub const LABEL: &str = "mochi"; + +/// Side of the square window, logical pixels (DesktopMochiLogic.panelSize). +pub const SIZE: f64 = 120.0; +/// Kept between him and the edges of the work area (DesktopMochiLogic.clampMargin). +const MARGIN: f64 = 24.0; +/// To and from the island (DesktopMochi.swift: 0.45 s, ease in-out). +const FLIGHT_MS: u64 = 450; +/// Settling into his spot after a drop (0.25 s, with a little overshoot). +const SNAP_MS: u64 = 250; +const TICK_MS: u64 = 16; + +/// Windows never hides his window: it parks it off-screen. tao shows a hidden +/// window again with SW_SHOW, which may take the keyboard focus from whatever +/// the user is typing in each time he flies out; a window created visible +/// (shown without activation) and only ever moved never is. Same as the +/// island, which is never re-shown either. +const PARK: bool = cfg!(windows); +const PARKED: (f64, f64) = (-32000.0, -32000.0); + +// ── Pure geometry ───────────────────────────────────────────────────────────── + +pub mod logic { + /// A rectangle, y down, in whatever space the caller works in. + #[derive(Clone, Copy, Debug, PartialEq)] + pub struct Rect { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, + } + + impl Rect { + pub fn contains(&self, p: (f64, f64)) -> bool { + p.0 >= self.x && p.0 < self.x + self.w && p.1 >= self.y && p.1 < self.y + self.h + } + + pub fn center(&self) -> (f64, f64) { + (self.x + self.w / 2.0, self.y + self.h / 2.0) + } + } + + /// A connected display: its whole area, the part windows may use (no + /// taskbar), and how many of the space's units make one logical pixel. + #[derive(Clone, Copy, Debug, PartialEq)] + pub struct Display { + pub frame: Rect, + pub work: Rect, + pub scale: f64, + } + + /// Radius of the clickable body, as a fraction of the window side. + pub const BODY_RADIUS_FRACTION: f64 = 0.24; + + /// Island panel, logical pixels: dropping Mochi on it brings him home. + pub const HOME_ZONE_W: f64 = crate::island::PANEL_W; + pub const HOME_ZONE_H: f64 = crate::island::PANEL_H; + + /// Hit test of the round body inside the square window (window-local). + pub fn is_over_body(local: (f64, f64), size: f64) -> bool { + let c = size / 2.0; + let r = size * BODY_RADIUS_FRACTION; + let (dx, dy) = (local.0 - c, local.1 - c); + dx * dx + dy * dy <= r * r + } + + /// Keeps a window of side `size` inside `work`, `margin` from each edge. + pub fn clamp_origin(origin: (f64, f64), size: f64, work: Rect, margin: f64) -> (f64, f64) { + ( + origin.0.max(work.x + margin).min(work.x + work.w - size - margin), + origin.1.max(work.y + margin).min(work.y + work.h - size - margin), + ) + } + + /// The display whose frame holds `p`, or else the one whose centre is closest. + pub fn display_near(p: (f64, f64), displays: &[Display]) -> Option { + displays.iter().copied().find(|d| d.frame.contains(p)).or_else(|| { + displays.iter().copied().min_by(|a, b| { + let da = dist2(p, a.work.center()); + let db = dist2(p, b.work.center()); + da.total_cmp(&db) + }) + }) + } + + fn dist2(a: (f64, f64), b: (f64, f64)) -> f64 { + (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2) + } + + /// Where a window dropped with its top-left at `origin` settles: inside the + /// work area of the display under its centre (or the nearest one). + pub fn settle(origin: (f64, f64), displays: &[Display], size: f64, margin: f64) -> (f64, f64) { + let Some(first) = displays.first() else { return origin }; + let half = size * first.scale / 2.0; + let Some(d) = display_near((origin.0 + half, origin.1 + half), displays) else { + return origin; + }; + clamp_origin(origin, size * d.scale, d.work, margin * d.scale) + } + + /// A spot saved at an earlier launch, checked against the displays + /// connected now: `None` when none of them holds its centre any more (the + /// monitor was unplugged) — he then stays in the island. A spot that is + /// still on a display is pulled back inside its work area. + pub fn restore_spot( + spot: (f64, f64), + displays: &[Display], + size: f64, + margin: f64, + ) -> Option<(f64, f64)> { + displays.iter().find_map(|d| { + let s = size * d.scale; + let center = (spot.0 + s / 2.0, spot.1 + s / 2.0); + d.frame + .contains(center) + .then(|| clamp_origin(spot, s, d.work, margin * d.scale)) + }) + } + + /// First visit: the bottom-right corner of the work area, like the Mac. + pub fn default_spot(d: &Display, size: f64, margin: f64) -> (f64, f64) { + let s = size * d.scale; + let m = margin * d.scale; + (d.work.x + d.work.w - s - m, d.work.y + d.work.h - s - m) + } + + /// The island's panel: centred on the island, from the top of its display. + pub fn home_zone(island_center_x: f64, island_top: f64, scale: f64) -> Rect { + Rect { + x: island_center_x - HOME_ZONE_W * scale / 2.0, + y: island_top, + w: HOME_ZONE_W * scale, + h: HOME_ZONE_H * scale, + } + } + + /// Top-left corner of the window when Mochi sits in the island: centred + /// under the top edge, where the flights start and end. + pub fn island_spot(island_center_x: f64, island_top: f64, size: f64, scale: f64) -> (f64, f64) { + (island_center_x - size * scale / 2.0, island_top) + } + + pub fn ease_in_out(t: f64) -> f64 { + if t < 0.5 { + 4.0 * t * t * t + } else { + 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 + } + } + + /// Ease-out with a small overshoot: the landing squash. + pub fn ease_out_back(t: f64) -> f64 { + let c1 = 1.3; + let c3 = c1 + 1.0; + 1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2) + } + + pub fn lerp(a: (f64, f64), b: (f64, f64), t: f64) -> (f64, f64) { + (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) + } +} + +use logic::{Display, Rect}; + +// ── State ───────────────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Source { + /// Dragged out of the island. + Island, + /// Picked up where he sat on the desktop. + Desktop, +} + +#[derive(Clone, Copy)] +struct Carry { + /// Cursor minus the window's top-left at pick-up, physical (Poll mode). + grab: (f64, f64), + from: Source, +} + +#[derive(Default)] +struct Inner { + /// The window is on screen. + shown: bool, + /// Sitting on the desktop, taking pokes (not flying, not carried). + landed: bool, + asleep: bool, + carry: Option, + /// Layer mode: the surface covers the display while it is dragged. + overlay: bool, + /// Top-left corner, in the mode's space. + pos: (f64, f64), + /// Layer mode: logical size of the display he lives on. + display: Option<(f64, f64)>, + /// Poll mode: the click-through state last applied. + ignoring: Option, +} + +pub struct Desktop { + pub mode: DesktopMode, + gate: PollGate, + inner: Mutex, + /// Bumped by every flight: an older one still running stops moving him. + flight: AtomicU64, +} + +impl Desktop { + fn new(mode: DesktopMode) -> Self { + Self { mode, gate: PollGate::new(), inner: Mutex::new(Inner::default()), flight: AtomicU64::new(0) } + } +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct Dropped { + from: Source, + home: bool, +} + +#[derive(Serialize, Clone)] +struct Cursor { + x: f64, + y: f64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopInfo { + /// "poll" | "window" | "layer" | "off". + mode: &'static str, + /// He was on the desktop when the app last quit. + on_desktop: bool, +} + +pub fn window(app: &AppHandle) -> Option { + app.get_webview_window(LABEL) +} + +fn body_disc() -> MouseShape { + MouseShape::Disc { cx: SIZE / 2.0, cy: SIZE / 2.0, r: SIZE * logic::BODY_RADIUS_FRACTION } +} + +// ── Setup ───────────────────────────────────────────────────────────────────── + +/// Same reasoning as the settings window: created hidden at launch, before the +/// island's webview, then only shown and hidden. Nothing is created where the +/// feature is off. +pub fn setup(app: &AppHandle) { + let mut mode = platform::desktop_mode(); + if mode != DesktopMode::Off { + match create_window(app, mode) { + Some(win) if platform::prepare_desktop_window(&win, mode) => {} + Some(win) => { + let _ = win.destroy(); + mode = DesktopMode::Off; + } + None => mode = DesktopMode::Off, + } + } + crate::log::line(format!("desktop Mochi: {}", mode.as_str())); + let desktop = Arc::new(Desktop::new(mode)); + app.manage(desktop.clone()); + // Nothing takes the mouse until he is out. + refresh(app, &desktop); + if mode == DesktopMode::Poll { + spawn_poll(app.clone(), desktop); + } +} + +fn page_url(app: &AppHandle) -> WebviewUrl { + #[cfg(dev)] + if let Some(mut base) = app.config().build.dev_url.clone() { + base.set_path("/mochi.html"); + return WebviewUrl::External(base); + } + let _ = app; + WebviewUrl::App("mochi.html".into()) +} + +fn create_window(app: &AppHandle, _mode: DesktopMode) -> Option { + let mut builder = WebviewWindowBuilder::new(app, LABEL, page_url(app)) + .additional_browser_args(crate::BROWSER_ARGS) + .title("Mochi") + .inner_size(SIZE, SIZE) + // GTK won't size a non-resizable window below its natural size (see + // island::apply_geometry). Windows would grow resize borders instead. + .resizable(cfg!(target_os = "linux")) + .decorations(false) + .transparent(true) + .shadow(false) + .always_on_top(true) + .skip_taskbar(true) + .visible_on_all_workspaces(true) + .focused(false) + .maximizable(false) + .minimizable(false) + .closable(false) + .disable_drag_drop_handler() + .focusable(false) + .visible(PARK); + if PARK { + builder = builder.position(PARKED.0, PARKED.1); + } + let built = builder.build(); + match built { + Ok(win) => Some(win), + Err(err) => { + crate::log::line(format!("desktop Mochi window failed: {err}")); + None + } + } +} + +// ── Coordinates ─────────────────────────────────────────────────────────────── + +/// Connected displays in the mode's space. +fn displays(app: &AppHandle, d: &Desktop) -> Vec { + if d.mode == DesktopMode::Layer { + let (w, h) = d.inner.lock().unwrap().display.unwrap_or((1920.0, 1080.0)); + let frame = Rect { x: 0.0, y: 0.0, w, h }; + return vec![Display { frame, work: frame, scale: 1.0 }]; + } + let Ok(monitors) = app.available_monitors() else { return Vec::new() }; + monitors + .iter() + .map(|m| { + let (p, s, wa) = (m.position(), m.size(), m.work_area()); + Display { + frame: Rect { x: p.x as f64, y: p.y as f64, w: s.width as f64, h: s.height as f64 }, + work: Rect { + x: wa.position.x as f64, + y: wa.position.y as f64, + w: wa.size.width as f64, + h: wa.size.height as f64, + }, + scale: m.scale_factor(), + } + }) + .collect() +} + +/// The island's top-centre point and the scale there, in the mode's space. +fn island_anchor(app: &AppHandle, d: &Desktop) -> Option<(f64, f64, f64)> { + if d.mode == DesktopMode::Layer { + let (w, _) = d.inner.lock().unwrap().display?; + return Some((w / 2.0, 0.0, 1.0)); + } + let win = island::window(app)?; + let pos = win.outer_position().ok()?; + let size = win.outer_size().ok()?; + let scale = win.scale_factor().unwrap_or(1.0); + Some((pos.x as f64 + size.width as f64 / 2.0, pos.y as f64, scale)) +} + +/// The window side in the mode's space. +fn side(app: &AppHandle, d: &Desktop) -> f64 { + if d.mode == DesktopMode::Layer { + return SIZE; + } + SIZE * window(app).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0) +} + +/// Asks the GTK main thread for the island's display (layer mode), and waits. +/// Never called from the main thread itself: every caller is a command that +/// runs on Tauri's async pool. +fn refresh_layer_display(app: &AppHandle, d: &Desktop) { + if d.mode != DesktopMode::Layer { + return; + } + let (Some(island), Some(mochi)) = (island::window(app), window(app)) else { return }; + let (tx, rx) = std::sync::mpsc::channel(); + let _ = app.run_on_main_thread(move || { + let _ = tx.send(platform::layer_display(&island, &mochi)); + }); + if let Ok(Some(size)) = rx.recv_timeout(Duration::from_secs(1)) { + d.inner.lock().unwrap().display = Some(size); + } +} + +/// Island-window client coordinates (logical) → the mode's space. +fn from_island_client(app: &AppHandle, d: &Desktop, x: f64, y: f64) -> Option<(f64, f64)> { + let island = island::window(app)?; + match d.mode { + DesktopMode::Layer => { + // The island is anchored to the top edge and centred. + let (dw, _) = d.inner.lock().unwrap().display?; + let scale = island.scale_factor().unwrap_or(1.0); + let iw = island.inner_size().ok()?.width as f64 / scale; + Some(((dw - iw) / 2.0 + x, y)) + } + DesktopMode::Window => { + let pos = island.outer_position().ok()?; + let scale = island.scale_factor().unwrap_or(1.0); + Some((pos.x as f64 + x * scale, pos.y as f64 + y * scale)) + } + _ => None, + } +} + +// ── Window operations ───────────────────────────────────────────────────────── + +fn place(app: &AppHandle, d: &Desktop, pos: (f64, f64)) { + d.inner.lock().unwrap().pos = pos; + let Some(win) = window(app) else { return }; + match d.mode { + DesktopMode::Poll | DesktopMode::Window => { + let _ = win.set_position(PhysicalPosition::new(pos.0.round() as i32, pos.1.round() as i32)); + } + DesktopMode::Layer => { + let _ = app.run_on_main_thread(move || platform::set_layer_margins(&win, pos.0, pos.1)); + } + DesktopMode::Off => {} + } +} + +fn show(app: &AppHandle, d: &Desktop, at: (f64, f64)) { + place(app, d, at); + d.inner.lock().unwrap().shown = true; + if let Some(win) = window(app) { + if !PARK { + let _ = win.show(); + } + let _ = win.set_always_on_top(true); + } + refresh(app, d); + // The page draws only while he is on screen. + let _ = app.emit_to(LABEL, "desktop-visible", true); +} + +fn hide(app: &AppHandle, d: &Desktop) { + { + let mut i = d.inner.lock().unwrap(); + i.shown = false; + i.landed = false; + i.carry = None; + i.asleep = false; + } + refresh(app, d); + if PARK { + place(app, d, PARKED); + } else if let Some(win) = window(app) { + let _ = win.hide(); + } + let _ = app.emit_to(LABEL, "desktop-visible", false); +} + +/// Re-derives the poll gate and what takes the mouse from the current state. +fn refresh(app: &AppHandle, d: &Desktop) { + let (active, ignore, shape) = { + let mut i = d.inner.lock().unwrap(); + let active = i.shown && (i.carry.is_some() || (i.landed && !i.asleep)); + // Poll mode: Some(flag) is decided here; None leaves it to the poll. + let ignore = if !i.shown || (!i.landed && i.carry.is_none()) { + Some(true) + } else if let Some(c) = i.carry { + Some(c.from == Source::Island) + } else if i.asleep { + // Asleep, nothing polls: the whole little window takes the mouse + // and acts as his own wake area — the first move over it wakes + // him, and the poll gives the rest back right away. + Some(false) + } else { + None + }; + if ignore.is_none() { + i.ignoring = None; + } + let shape = if !i.shown || (!i.landed && i.carry.is_none()) { + MouseShape::Empty + } else if i.overlay { + MouseShape::Whole + } else if matches!(i.carry, Some(Carry { from: Source::Island, .. })) { + MouseShape::Empty + } else { + body_disc() + }; + (active, ignore, shape) + }; + let Some(win) = window(app) else { return }; + match d.mode { + DesktopMode::Poll => { + if let Some(flag) = ignore { + d.inner.lock().unwrap().ignoring = Some(flag); + let _ = win.set_ignore_cursor_events(flag); + } + d.gate.set_active(active); + } + DesktopMode::Window | DesktopMode::Layer => { + let _ = app.run_on_main_thread(move || platform::set_desktop_shape(&win, shape)); + } + DesktopMode::Off => {} + } +} + +/// Moves him from where he is to `to`, then lands him. False when another +/// flight took over before the end. +async fn fly(app: &AppHandle, d: &Desktop, to: (f64, f64), ms: u64, ease: fn(f64) -> f64) -> bool { + let token = d.flight.fetch_add(1, Ordering::SeqCst) + 1; + let from = d.inner.lock().unwrap().pos; + let (tx, rx) = tokio::sync::oneshot::channel(); + let app2 = app.clone(); + std::thread::spawn(move || { + let d = app2.state::>(); + let steps = (ms / TICK_MS).max(1); + for k in 1..=steps { + if d.flight.load(Ordering::SeqCst) != token { + let _ = tx.send(false); + return; + } + let t = k as f64 / steps as f64; + place(&app2, &d, logic::lerp(from, to, ease(t))); + std::thread::sleep(Duration::from_millis(TICK_MS)); + } + let _ = tx.send(d.flight.load(Ordering::SeqCst) == token); + }); + rx.await.unwrap_or(false) +} + +/// Saves what changed about him in the preferences. +fn remember(app: &AppHandle, change: impl FnOnce(&mut settings::DesktopMochiPref)) { + let Some(shared) = app.try_state::() else { return }; + let snapshot = { + let mut s = shared.settings.lock().unwrap(); + change(&mut s.desktop_mochi); + s.clone() + }; + if let Err(err) = settings::save(&snapshot) { + crate::log::line(format!("could not save settings: {err}")); + } +} + +fn remember_spot(app: &AppHandle, d: &Desktop, pos: (f64, f64), on_desktop: bool) { + let space = d.mode.space().to_string(); + remember(app, |p| { + p.on_desktop = on_desktop; + p.spot = Some(DesktopSpot { x: pos.0, y: pos.1, space }); + }); +} + +/// Where he lands when he flies out, or `None` when his spot is on a display +/// that is no longer connected. +fn target_spot(app: &AppHandle, d: &Desktop) -> Option<(f64, f64)> { + let all = displays(app, d); + let saved = app.try_state::().and_then(|s| s.settings.lock().unwrap().desktop_mochi.spot.clone()); + match saved { + Some(spot) if spot.space == d.mode.space() => { + logic::restore_spot((spot.x, spot.y), &all, SIZE, MARGIN) + } + // Never placed, or placed under another kind of session: a fresh start + // in the corner of the island's display. + _ => { + let (cx, top, _) = island_anchor(app, d)?; + let home = logic::display_near((cx, top + 1.0), &all)?; + Some(logic::default_spot(&home, SIZE, MARGIN)) + } + } +} + +/// The drop, wherever it came from: home if it is over the island's panel, +/// otherwise his new spot. +async fn finish_drop(app: &AppHandle, d: &Desktop, pos: (f64, f64), from: Source) { + let s = side(app, d); + let center = (pos.0 + s / 2.0, pos.1 + s / 2.0); + let home = island_anchor(app, d) + .map(|(cx, top, scale)| logic::home_zone(cx, top, scale).contains(center)) + .unwrap_or(false); + { + let mut i = d.inner.lock().unwrap(); + i.carry = None; + i.landed = !home; + } + if home { + if from == Source::Island { + // Back where he came from: he simply reappears in the island. + hide(app, d); + } else { + // The island page flies him home (and forgets the spot). + refresh(app, d); + } + } else { + let spot = logic::settle(pos, &displays(app, d), SIZE, MARGIN); + refresh(app, d); + if spot != pos { + fly(app, d, spot, SNAP_MS, logic::ease_out_back).await; + } + remember_spot(app, d, spot, true); + } + let _ = app.emit_to(island::WINDOW_LABEL, "desktop-mochi-dropped", Dropped { from, home }); +} + +// ── Poll (Windows) ──────────────────────────────────────────────────────────── + +/// Runs only while he is awake on the desktop or being carried: click-through +/// from the body hit test, the cursor for his eyes, and the drag. +fn spawn_poll(app: AppHandle, d: Arc) { + std::thread::spawn(move || loop { + d.gate.wait_until_active(); + let mut last = (f64::MIN, f64::MIN); + while d.gate.is_active() { + std::thread::sleep(Duration::from_millis(TICK_MS)); + let Some(win) = window(&app) else { continue }; + let Some((cx, cy)) = platform::cursor_physical() else { continue }; + let carry = d.inner.lock().unwrap().carry; + + if let Some(c) = carry { + let pos = (cx - c.grab.0, cy - c.grab.1); + if platform::left_button_down() { + if pos != d.inner.lock().unwrap().pos { + place(&app, &d, pos); + } + } else { + // Released: hand the drop to the async pool, where the + // snap animation may wait. + d.inner.lock().unwrap().carry = None; + let app2 = app.clone(); + let d2 = d.clone(); + tauri::async_runtime::spawn(async move { finish_drop(&app2, &d2, pos, c.from).await }); + } + continue; + } + // Between a release and the drop being settled, nothing to decide. + if !d.inner.lock().unwrap().landed { + continue; + } + + let Ok(origin) = win.outer_position() else { continue }; + let scale = win.scale_factor().unwrap_or(1.0); + let local = ((cx - origin.x as f64) / scale, (cy - origin.y as f64) / scale); + let accept = logic::is_over_body(local, SIZE); + { + let mut i = d.inner.lock().unwrap(); + if i.ignoring != Some(!accept) { + i.ignoring = Some(!accept); + let _ = win.set_ignore_cursor_events(!accept); + } + } + if (local.0 - last.0).abs() >= 1.0 || (local.1 - last.1).abs() >= 1.0 { + last = local; + let _ = win.emit("desktop-cursor", Cursor { x: local.0, y: local.1 }); + } + } + }); +} + +// ── Commands ────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub fn desktop_mochi_info(app: AppHandle, desktop: State>) -> DesktopInfo { + let on_desktop = app + .try_state::() + .map(|s| s.settings.lock().unwrap().desktop_mochi.on_desktop) + .unwrap_or(false); + DesktopInfo { mode: desktop.mode.as_str(), on_desktop: on_desktop && desktop.mode != DesktopMode::Off } +} + +/// Dragging Mochi out of the island: the window appears under the pointer +/// (`x`, `y`: island-window client coordinates) and follows it — the cursor +/// poll carries it on Windows, `desktop_mochi_carry` elsewhere. +#[tauri::command] +pub async fn desktop_mochi_pick_up(app: AppHandle, x: f64, y: f64) -> bool { + let d = app.state::>().inner().clone(); + if d.mode == DesktopMode::Off || window(&app).is_none() || d.inner.lock().unwrap().shown { + return false; + } + refresh_layer_display(&app, &d); + let s = side(&app, &d); + let center = match d.mode { + DesktopMode::Poll => platform::cursor_physical(), + _ => from_island_client(&app, &d, x, y), + }; + let Some(center) = center else { return false }; + d.flight.fetch_add(1, Ordering::SeqCst); + d.inner.lock().unwrap().carry = Some(Carry { grab: (s / 2.0, s / 2.0), from: Source::Island }); + show(&app, &d, (center.0 - s / 2.0, center.1 - s / 2.0)); + true +} + +/// Linux: the pointer moved while Mochi is being dragged out of the island. +#[tauri::command] +pub async fn desktop_mochi_carry(app: AppHandle, x: f64, y: f64) { + let d = app.state::>().inner().clone(); + if d.mode == DesktopMode::Poll || d.inner.lock().unwrap().carry.is_none() { + return; + } + let s = side(&app, &d); + if let Some(c) = from_island_client(&app, &d, x, y) { + place(&app, &d, (c.0 - s / 2.0, c.1 - s / 2.0)); + } +} + +/// Linux: the button went up while Mochi was being dragged out of the island. +#[tauri::command] +pub async fn desktop_mochi_carry_end(app: AppHandle, x: f64, y: f64) { + let d = app.state::>().inner().clone(); + if d.mode == DesktopMode::Poll || d.inner.lock().unwrap().carry.is_none() { + return; + } + let s = side(&app, &d); + let pos = from_island_client(&app, &d, x, y) + .map(|c| (c.0 - s / 2.0, c.1 - s / 2.0)) + .unwrap_or_else(|| d.inner.lock().unwrap().pos); + finish_drop(&app, &d, pos, Source::Island).await; +} + +/// The page saw a drag start on Mochi. Returns his top-left corner in the +/// mode's space, which the page needs to draw him in the overlay (layer mode). +#[tauri::command] +pub async fn desktop_mochi_drag_begin(app: AppHandle) -> Option<(f64, f64)> { + let d = app.state::>().inner().clone(); + let pos = { + let i = d.inner.lock().unwrap(); + if !i.landed || i.carry.is_some() { + return None; + } + i.pos + }; + // A drag takes over from a snap still settling him. + d.flight.fetch_add(1, Ordering::SeqCst); + match d.mode { + DesktopMode::Poll => { + let win = window(&app)?; + let (cx, cy) = platform::cursor_physical()?; + let origin = win.outer_position().ok()?; + let grab = (cx - origin.x as f64, cy - origin.y as f64); + d.inner.lock().unwrap().carry = Some(Carry { grab, from: Source::Desktop }); + refresh(&app, &d); + } + DesktopMode::Layer => { + d.inner.lock().unwrap().overlay = true; + let win = window(&app)?; + let _ = app.run_on_main_thread(move || { + platform::set_layer_overlay(&win, true); + platform::set_desktop_shape(&win, MouseShape::Whole); + }); + } + _ => {} + } + Some(pos) +} + +/// X11: the page moves the window during its drag (top-left, physical pixels). +#[tauri::command] +pub async fn desktop_mochi_drag_move(app: AppHandle, x: f64, y: f64) { + let d = app.state::>().inner().clone(); + if d.mode == DesktopMode::Window && d.inner.lock().unwrap().landed { + place(&app, &d, (x, y)); + } +} + +/// Linux: the page's drag ended with the top-left corner at (`x`, `y`), in the +/// mode's space. On Windows the poll sees the release itself. +#[tauri::command] +pub async fn desktop_mochi_drag_end(app: AppHandle, x: f64, y: f64) { + let d = app.state::>().inner().clone(); + if d.mode == DesktopMode::Poll || !d.inner.lock().unwrap().landed { + return; + } + if d.mode == DesktopMode::Layer { + d.inner.lock().unwrap().overlay = false; + if let Some(win) = window(&app) { + let _ = app.run_on_main_thread(move || { + platform::set_layer_overlay(&win, false); + platform::set_layer_margins(&win, x, y); + }); + } + if let Some(win) = window(&app) { + let _ = win.set_size(tauri::LogicalSize::new(SIZE, SIZE)); + } + } + place(&app, &d, (x, y)); + finish_drop(&app, &d, (x, y), Source::Desktop).await; +} + +/// Launch or the end of an alert: from the island to his spot. False when the +/// spot is on a display that is gone — he then stays home, and is forgotten +/// there so the next launch doesn't try again. +#[tauri::command] +pub async fn desktop_mochi_fly_out(app: AppHandle) -> bool { + let d = app.state::>().inner().clone(); + if d.mode == DesktopMode::Off || window(&app).is_none() { + return false; + } + refresh_layer_display(&app, &d); + let Some(target) = target_spot(&app, &d) else { + crate::log::line("desktop Mochi: his spot is on a display that is gone — he stays home"); + remember(&app, |p| p.on_desktop = false); + return false; + }; + let Some((cx, top, scale)) = island_anchor(&app, &d) else { return false }; + let start = logic::island_spot(cx, top, SIZE, scale); + { + let mut i = d.inner.lock().unwrap(); + i.landed = false; + i.carry = None; + i.asleep = false; + } + show(&app, &d, start); + let _ = app.emit_to(LABEL, "desktop-flight", "out"); + if !fly(&app, &d, target, FLIGHT_MS, logic::ease_in_out).await { + return false; + } + d.inner.lock().unwrap().landed = true; + refresh(&app, &d); + remember_spot(&app, &d, target, true); + true +} + +/// To the island, then hidden. `forget` is the user bringing him home (double +/// click, drop on the island); without it he is only away for an alert and +/// flies back out once it is answered. +#[tauri::command] +pub async fn desktop_mochi_fly_home(app: AppHandle, forget: bool) -> bool { + let d = app.state::>().inner().clone(); + if !d.inner.lock().unwrap().shown { + if forget { + remember(&app, |p| p.on_desktop = false); + } + return true; + } + { + let mut i = d.inner.lock().unwrap(); + i.landed = false; + i.carry = None; + i.asleep = false; + } + refresh(&app, &d); + let _ = app.emit_to(LABEL, "desktop-flight", "home"); + let done = match island_anchor(&app, &d) { + Some((cx, top, scale)) => { + let to = logic::island_spot(cx, top, SIZE, scale); + fly(&app, &d, to, FLIGHT_MS, logic::ease_in_out).await + } + None => true, + }; + if done { + hide(&app, &d); + } + if forget { + remember(&app, |p| p.on_desktop = false); + } + done +} + +/// The page dozed off (or woke up). Asleep, nothing polls. +#[tauri::command] +pub fn desktop_mochi_set_asleep(app: AppHandle, desktop: State>, asleep: bool) { + let changed = { + let mut i = desktop.inner.lock().unwrap(); + let changed = i.asleep != asleep; + i.asleep = asleep; + changed + }; + if changed { + refresh(&app, &desktop); + } +} + +#[cfg(test)] +mod tests { + use super::logic::*; + + // Mirrors tests/DesktopMochiTests.swift (isOverBody, clampOrigin), in the + // y-down coordinates every PC uses. + + #[test] + fn is_over_body() { + let s = 120.0; + let r = s * BODY_RADIUS_FRACTION; // 28.8 + assert!(super::logic::is_over_body((60.0, 60.0), s), "center must be inside body"); + assert!(super::logic::is_over_body((60.0 + r - 0.5, 60.0), s), "inside radius must hit"); + assert!(!super::logic::is_over_body((60.0 + r + 0.5, 60.0), s), "outside radius must miss"); + assert!(!super::logic::is_over_body((0.0, 0.0), s), "corner must miss"); + let diag = r / 2f64.sqrt() - 0.5; + assert!(super::logic::is_over_body((60.0 + diag, 60.0 + diag), s), "diagonal inside must hit"); + assert!(super::logic::is_over_body((60.0 - diag, 60.0 + diag), s), "every quadrant"); + } + + #[test] + fn clamp_origin_keeps_him_inside_the_work_area() { + // A 1440×900 display with a 40 px taskbar at the bottom. + let work = Rect { x: 0.0, y: 0.0, w: 1440.0, h: 860.0 }; + let (s, m) = (120.0, 24.0); + assert_eq!(clamp_origin((600.0, 400.0), s, work, m), (600.0, 400.0), "in-bounds origin must be unchanged"); + assert_eq!(clamp_origin((-50.0, 400.0), s, work, m).0, work.x + m, "too-left"); + assert_eq!(clamp_origin((2000.0, 400.0), s, work, m).0, work.x + work.w - s - m, "too-right"); + assert_eq!(clamp_origin((400.0, -50.0), s, work, m).1, work.y + m, "too-high"); + assert_eq!(clamp_origin((400.0, 2000.0), s, work, m).1, work.y + work.h - s - m, "too-low: above the taskbar"); + } + + fn two_displays() -> Vec { + // A 1920×1080 laptop at 100 %, and a 4K monitor at 200 % on its right, + // physical pixels; both with a 48 px taskbar at the bottom. + let a = Rect { x: 0.0, y: 0.0, w: 1920.0, h: 1080.0 }; + let b = Rect { x: 1920.0, y: -200.0, w: 3840.0, h: 2160.0 }; + vec![ + Display { frame: a, work: Rect { h: 1032.0, ..a }, scale: 1.0 }, + Display { frame: b, work: Rect { h: 2064.0, ..b }, scale: 2.0 }, + ] + } + + #[test] + fn a_spot_on_a_connected_display_is_kept() { + let d = two_displays(); + assert_eq!(restore_spot((800.0, 600.0), &d, 120.0, 24.0), Some((800.0, 600.0))); + // On the second display, sized in its own scale. + assert_eq!(restore_spot((3000.0, 1000.0), &d, 120.0, 24.0), Some((3000.0, 1000.0))); + } + + #[test] + fn a_spot_on_a_display_that_is_gone_brings_him_home() { + let d = two_displays(); + // Left of the laptop: a monitor that has been unplugged. + assert_eq!(restore_spot((-1500.0, 300.0), &d, 120.0, 24.0), None); + // Nothing connected at all. + assert_eq!(restore_spot((800.0, 600.0), &[], 120.0, 24.0), None); + // Only the first display left: the spot on the second is gone. + assert_eq!(restore_spot((3000.0, 1000.0), &d[..1], 120.0, 24.0), None); + } + + #[test] + fn a_spot_that_a_taller_taskbar_now_covers_moves_up() { + let d = two_displays(); + let (x, y) = restore_spot((800.0, 1000.0), &d, 120.0, 24.0).unwrap(); + assert_eq!(x, 800.0); + assert_eq!(y, 1032.0 - 120.0 - 24.0); + // The 200 % display clamps with its own scale. + let (_, y) = restore_spot((3000.0, 1700.0), &d, 120.0, 24.0).unwrap(); + assert_eq!(y, -200.0 + 2064.0 - 240.0 - 48.0); + } + + #[test] + fn a_drop_settles_on_the_display_under_him() { + let d = two_displays(); + // Dropped against the right edge of the laptop: stays on the laptop. + assert_eq!(settle((1800.0, 500.0), &d, 120.0, 24.0), (1920.0 - 120.0 - 24.0, 500.0)); + // His centre already over the next display: he moves onto it. + assert_eq!(settle((1870.0, 500.0), &d, 120.0, 24.0), (1920.0 + 48.0, 500.0)); + // Dropped in the gap above the laptop, next to the taller monitor: + // nearest display. + let (x, y) = settle((1000.0, -150.0), &d, 120.0, 24.0); + assert_eq!((x, y), (1000.0, 24.0)); + } + + #[test] + fn first_visit_is_the_bottom_right_corner() { + let d = two_displays(); + assert_eq!(default_spot(&d[0], 120.0, 24.0), (1920.0 - 144.0, 1032.0 - 144.0)); + assert_eq!(default_spot(&d[1], 120.0, 24.0), (1920.0 + 3840.0 - 288.0, -200.0 + 2064.0 - 288.0)); + } + + #[test] + fn the_island_panel_is_home() { + // Island centred on a 1920 px display at 150 %. + let zone = home_zone(960.0, 0.0, 1.5); + assert!(zone.contains((960.0, 10.0)), "on the island"); + assert!(zone.contains((960.0 - 359.0 * 1.5, 300.0 * 1.5)), "inside the panel"); + assert!(!zone.contains((960.0, 330.0 * 1.5)), "below the panel"); + assert!(!zone.contains((100.0, 10.0)), "top-left corner of the screen"); + } + + #[test] + fn he_leaves_and_returns_under_the_top_centre() { + assert_eq!(island_spot(960.0, 0.0, 120.0, 1.0), (900.0, 0.0)); + assert_eq!(island_spot(2880.0, -200.0, 120.0, 2.0), (2760.0, -200.0)); + } + + #[test] + fn flights_start_and_end_where_asked() { + for ease in [ease_in_out as fn(f64) -> f64, ease_out_back] { + assert!(ease(0.0).abs() < 1e-9); + assert!((ease(1.0) - 1.0).abs() < 1e-9); + } + assert!(ease_out_back(0.7) > 1.0, "the landing overshoots a little"); + assert_eq!(lerp((0.0, 10.0), (100.0, 20.0), 0.5), (50.0, 15.0)); + } +} diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 92c9cd4cb..00536adb8 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -89,14 +89,14 @@ impl PollGate { self.cv.notify_all(); } - fn wait_until_active(&self) { + pub(crate) fn wait_until_active(&self) { let mut guard = self.active.lock().unwrap(); while !*guard { guard = self.cv.wait(guard).unwrap(); } } - fn is_active(&self) -> bool { + pub(crate) fn is_active(&self) -> bool { *self.active.lock().unwrap() } } diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index d9ec3353e..948d76856 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ // Coucou for Windows — app wiring and the commands the island calls. mod claude; +mod desktop; mod files; mod hooks; mod integrations; @@ -65,9 +66,13 @@ fn save_settings(app: AppHandle, shared: State, settings: Settings) { let mut current = shared.settings.lock().unwrap(); let screen_changed = current.screen != settings.screen; let autostart_changed = current.autostart != settings.autostart; - *current = settings.clone(); + // Where Mochi sits on the desktop is desktop.rs's to say, not a webview's. + let mut settings = settings.clone(); + settings.desktop_mochi = current.desktop_mochi.clone(); + *current = settings; (screen_changed, autostart_changed) }; + let settings = shared.settings.lock().unwrap().clone(); if let Err(err) = settings::save(&settings) { log::line(format!("could not save settings: {err}")); } @@ -301,7 +306,7 @@ fn log_line(message: String) { /// for the *same* arguments as the island (see `additionalBrowserArgs` in /// tauri.conf.json) — a mismatch makes the second window come up blank, with no /// error anywhere. -const BROWSER_ARGS: &str = "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required"; +pub(crate) const BROWSER_ARGS: &str = "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required"; /// In a dev build the pages are served by Vite, so the second window needs the /// absolute dev URL; a bundled build resolves it inside the app bundle. @@ -403,12 +408,24 @@ pub fn run() { open_n8n, open_settings_window, set_paused, + desktop::desktop_mochi_info, + desktop::desktop_mochi_pick_up, + desktop::desktop_mochi_carry, + desktop::desktop_mochi_carry_end, + desktop::desktop_mochi_drag_begin, + desktop::desktop_mochi_drag_move, + desktop::desktop_mochi_drag_end, + desktop::desktop_mochi_fly_out, + desktop::desktop_mochi_fly_home, + desktop::desktop_mochi_set_asleep, ]) .setup(move |app| { let handle = app.handle().clone(); tray::build(&handle)?; // Before the island: see create_settings_window. create_settings_window(&handle); + // Same rule for Mochi's desktop window. + desktop::setup(&handle); if let Some(win) = island::window(&handle) { platform::make_non_activating(&win); diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e748a336a..d4e4581dc 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -167,8 +167,12 @@ mod layer { use gtk::ffi::GtkWindow; use std::os::raw::{c_char, c_int}; + pub const LAYER_TOP: c_int = 2; pub const LAYER_OVERLAY: c_int = 3; + pub const EDGE_LEFT: c_int = 0; + pub const EDGE_RIGHT: c_int = 1; pub const EDGE_TOP: c_int = 2; + pub const EDGE_BOTTOM: c_int = 3; pub const KEYBOARD_NONE: c_int = 0; pub const KEYBOARD_ON_DEMAND: c_int = 2; @@ -179,11 +183,18 @@ mod layer { pub fn gtk_layer_set_namespace(window: *mut GtkWindow, name_space: *const c_char); pub fn gtk_layer_set_layer(window: *mut GtkWindow, layer: c_int); pub fn gtk_layer_set_anchor(window: *mut GtkWindow, edge: c_int, anchor: c_int); + pub fn gtk_layer_set_margin(window: *mut GtkWindow, edge: c_int, margin: c_int); + pub fn gtk_layer_set_monitor(window: *mut GtkWindow, monitor: *mut gtk::gdk::ffi::GdkMonitor); pub fn gtk_layer_set_exclusive_zone(window: *mut GtkWindow, zone: c_int); pub fn gtk_layer_set_keyboard_mode(window: *mut GtkWindow, mode: c_int); } } +/// COUCOU_LAYER_SHELL=0 is the way out on a compositor where it misbehaves. +fn layer_shell_wanted() -> bool { + std::env::var("COUCOU_LAYER_SHELL").map(|v| v != "0").unwrap_or(true) +} + /// True once the island window is a layer-shell surface. static LAYER_SURFACE: AtomicBool = AtomicBool::new(false); @@ -210,8 +221,7 @@ pub fn unblock_webview_drops(_app: &AppHandle) {} /// up to the window manager. pub fn make_non_activating(win: &WebviewWindow) { let Ok(gw) = win.gtk_window() else { return }; - // COUCOU_LAYER_SHELL=0 is the way out on a compositor where it misbehaves. - let wanted = std::env::var("COUCOU_LAYER_SHELL").map(|v| v != "0").unwrap_or(true); + let wanted = layer_shell_wanted(); let supported = unsafe { layer::gtk_layer_is_supported() } != 0; if !wanted || !supported || gw.is_realized() { let why = if !wanted { @@ -300,6 +310,174 @@ fn apply_input_region(gw: &impl IsA, rect: Region) { } } +// ── Desktop Mochi window ────────────────────────────────────────────────────── +// +// Where the window may go decides everything here: +// * X11 places any window where it is asked: an ordinary always-on-top +// window, moved with set_position; +// * a layer-shell compositor won't move a toplevel, but anchors a layer +// surface wherever its margins say, on the island's display; +// * GNOME on Wayland has neither, so the feature is off there. +// In every case the input region is Mochi's body, so clicks anywhere else go +// to the desktop underneath without any cursor polling. + +/// Which of the three applies. Must run on the GTK main thread. +pub fn desktop_mode() -> super::DesktopMode { + use gtk::glib::prelude::ObjectExt; + let wayland = gtk::gdk::Display::default() + .map(|d| d.type_().name().contains("Wayland")) + .unwrap_or(false); + let layer = layer_shell_wanted() && unsafe { layer::gtk_layer_is_supported() } != 0; + if layer { + super::DesktopMode::Layer + } else if !wayland { + super::DesktopMode::Window + } else { + super::DesktopMode::Off + } +} + +/// The input shape last asked for the desktop Mochi, re-applied on every map. +static MOCHI_SHAPE: Mutex = Mutex::new(super::MouseShape::Empty); + +/// Sets the window up for `mode` before it is ever shown. False means it can't +/// be used (a layer surface can't be made from a window already shown). +pub fn prepare_desktop_window(win: &WebviewWindow, mode: super::DesktopMode) -> bool { + let Ok(gw) = win.gtk_window() else { return false }; + gw.set_accept_focus(false); + match mode { + super::DesktopMode::Layer => { + if gw.is_realized() { + crate::log::line("desktop Mochi: window already shown, no layer surface"); + return false; + } + gw.set_titlebar(None::<>k::Widget>); + let ptr = gtk_window_ptr(&gw); + unsafe { + layer::gtk_layer_init_for_window(ptr); + layer::gtk_layer_set_namespace(ptr, c"coucou-mochi".as_ptr()); + // Above windows, below fullscreen video — like a floating panel. + layer::gtk_layer_set_layer(ptr, layer::LAYER_TOP); + layer::gtk_layer_set_anchor(ptr, layer::EDGE_TOP, 1); + layer::gtk_layer_set_anchor(ptr, layer::EDGE_LEFT, 1); + // -1: margins count from the display's corner, panels or not, + // the same origin the island uses. + layer::gtk_layer_set_exclusive_zone(ptr, -1); + layer::gtk_layer_set_keyboard_mode(ptr, layer::KEYBOARD_NONE); + } + // Same first-frame problem as the island (see make_non_activating). + let remapped = std::cell::Cell::new(false); + gw.connect_map_event(move |w, _| { + apply_mouse_shape(w, *MOCHI_SHAPE.lock().unwrap()); + if !remapped.replace(true) { + let w = w.clone(); + gtk::glib::idle_add_local_once(move || { + w.hide(); + w.show_all(); + apply_mouse_shape(&w, *MOCHI_SHAPE.lock().unwrap()); + }); + } + gtk::glib::Propagation::Proceed + }); + true + } + super::DesktopMode::Window => { + gw.connect_map_event(|w, _| { + apply_mouse_shape(w, *MOCHI_SHAPE.lock().unwrap()); + gtk::glib::Propagation::Proceed + }); + true + } + _ => false, + } +} + +/// Which part of the desktop Mochi takes the mouse. Main thread. +pub fn set_desktop_shape(win: &WebviewWindow, shape: super::MouseShape) { + *MOCHI_SHAPE.lock().unwrap() = shape; + let Ok(gw) = win.gtk_window() else { return }; + apply_mouse_shape(&gw, shape); +} + +fn apply_mouse_shape(gw: &impl IsA, shape: super::MouseShape) { + use gtk::cairo::{RectangleInt, Region}; + if shape == super::MouseShape::Whole { + gw.input_shape_combine_region(None); + return; + } + let Some(gdk_window) = gw.window() else { return }; + match shape { + super::MouseShape::Whole => {} + super::MouseShape::Empty => { + let region = Region::create_rectangle(&RectangleInt::new(0, 0, 0, 0)); + gdk_window.input_shape_combine_region(®ion, 0, 0); + } + super::MouseShape::Disc { cx, cy, r } => { + // A region is made of rectangles: stack 2 px rows into a disc. + let region = Region::create_rectangle(&RectangleInt::new(0, 0, 0, 0)); + let mut y = (cy - r).floor(); + while y < cy + r { + let mid = y + 1.0 - cy; + let half = (r * r - mid * mid).max(0.0).sqrt(); + let row = RectangleInt::new( + (cx - half).floor() as i32, + y as i32, + (2.0 * half).ceil() as i32, + 2, + ); + let _ = region.union_rectangle(&row); + y += 2.0; + } + gdk_window.input_shape_combine_region(®ion, 0, 0); + } + } +} + +/// Layer surface only: puts the top-left corner at (x, y), logical pixels from +/// the display's corner. Main thread. +pub fn set_layer_margins(win: &WebviewWindow, x: f64, y: f64) { + let Ok(gw) = win.gtk_window() else { return }; + let ptr = gtk_window_ptr(&gw); + unsafe { + layer::gtk_layer_set_margin(ptr, layer::EDGE_LEFT, x.round() as i32); + layer::gtk_layer_set_margin(ptr, layer::EDGE_TOP, y.round() as i32); + } +} + +/// Layer surface only: `on` stretches it over the whole display — how a drag +/// gets exact pointer positions on Wayland, where a surface that moves under +/// the pointer can't tell where it is — and `off` folds it back to Mochi's +/// size in the corner. Main thread. +pub fn set_layer_overlay(win: &WebviewWindow, on: bool) { + let Ok(gw) = win.gtk_window() else { return }; + let ptr = gtk_window_ptr(&gw); + unsafe { + layer::gtk_layer_set_anchor(ptr, layer::EDGE_RIGHT, on as i32); + layer::gtk_layer_set_anchor(ptr, layer::EDGE_BOTTOM, on as i32); + if on { + layer::gtk_layer_set_margin(ptr, layer::EDGE_LEFT, 0); + layer::gtk_layer_set_margin(ptr, layer::EDGE_TOP, 0); + } + } +} + +/// Layer surface only: puts the desktop Mochi on the island's display and +/// returns that display's logical size. Main thread. +pub fn layer_display(island: &WebviewWindow, mochi: &WebviewWindow) -> Option<(f64, f64)> { + let island_gw = island.gtk_window().ok()?; + let display = gtk::gdk::Display::default()?; + let monitor = island_gw + .window() + .and_then(|w| display.monitor_at_window(&w)) + .or_else(|| display.primary_monitor()) + .or_else(|| display.monitor(0))?; + if let Ok(gw) = mochi.gtk_window() { + unsafe { layer::gtk_layer_set_monitor(gtk_window_ptr(&gw), monitor.to_glib_none().0) }; + } + let g = monitor.geometry(); + Some((g.width() as f64, g.height() as f64)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/windows/src-tauri/src/platform/mod.rs b/windows/src-tauri/src/platform/mod.rs index 5eb032dc7..73ce61f9f 100644 --- a/windows/src-tauri/src/platform/mod.rs +++ b/windows/src-tauri/src/platform/mod.rs @@ -31,3 +31,55 @@ pub fn home_dir() -> PathBuf { .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) } + +/// How the desktop Mochi's window can be placed and clicked on this system. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum DesktopMode { + /// Windows: placed anywhere, and a cursor poll drives click-through, the + /// eyes and the drag, exactly like the island. + Poll, + /// Linux on X11: an ordinary window the window manager lets us place; the + /// input region is the click-through and the page drives the drag. + Window, + /// Linux on a layer-shell compositor (KDE, COSMIC, wlroots): a layer + /// surface anchored to the top-left corner of the island's display, placed + /// with margins. + Layer, + /// Wayland without layer-shell (GNOME): no way to put a window where the + /// user dropped it, so Mochi stays in the island. + Off, +} + +impl DesktopMode { + pub fn as_str(self) -> &'static str { + match self { + DesktopMode::Poll => "poll", + DesktopMode::Window => "window", + DesktopMode::Layer => "layer", + DesktopMode::Off => "off", + } + } + + /// The space a saved spot is measured in. A spot from another space (the + /// same machine once on X11, once on Wayland) is not trusted. + pub fn space(self) -> &'static str { + match self { + // Physical pixels of the whole desktop, as the OS reports monitors. + DesktopMode::Poll | DesktopMode::Window => "screen", + // Logical pixels from the top-left corner of the island's display. + DesktopMode::Layer | DesktopMode::Off => "layer", + } + } +} + +/// Which part of the desktop Mochi's window takes the mouse. +#[derive(Clone, Copy, PartialEq, Debug)] +#[cfg_attr(windows, allow(dead_code))] +pub enum MouseShape { + /// Nothing: every click goes to what is underneath (during a flight). + Empty, + /// The whole window (while he is being dragged across the screen). + Whole, + /// A disc, in window-logical pixels: Mochi's body. + Disc { cx: f64, cy: f64, r: f64 }, +} diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 6e1200a8a..604ed2dc9 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -233,3 +233,31 @@ pub fn set_activating(win: &WebviewWindow, activating: bool) { /// Click-through here is the poll's WS_EX_TRANSPARENT toggle, not a region. pub fn set_input_region(_win: &WebviewWindow, _rect: Option<(f64, f64, f64, f64)>) {} + +// ── Desktop Mochi window ────────────────────────────────────────────────────── + +/// Windows places a window wherever it is asked, and the cursor poll is there. +pub fn desktop_mode() -> super::DesktopMode { + super::DesktopMode::Poll +} + +/// Same window styles as the island: never takes focus, never in Alt-Tab (it +/// is created without a taskbar button). +pub fn prepare_desktop_window(win: &WebviewWindow, _mode: super::DesktopMode) -> bool { + make_non_activating(win); + true +} + +/// The cursor poll toggles WS_EX_TRANSPARENT from the body hit test instead. +pub fn set_desktop_shape(_win: &WebviewWindow, _shape: super::MouseShape) {} + +/// Layer-shell only (Linux). +pub fn set_layer_margins(_win: &WebviewWindow, _x: f64, _y: f64) {} + +/// Layer-shell only (Linux). +pub fn set_layer_overlay(_win: &WebviewWindow, _on: bool) {} + +/// Layer-shell only (Linux): the logical size of the display the island is on. +pub fn layer_display(_island: &WebviewWindow, _mochi: &WebviewWindow) -> Option<(f64, f64)> { + None +} diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index a95abfd74..0cf69499f 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -27,6 +27,26 @@ pub struct Settings { /// season), "none" or an outfit id — the Mac's raw values. The island reads /// anything it doesn't know as "auto", so the value is stored as it comes. pub mochi_outfit: String, + /// Mochi on the desktop: whether he lives there, and his spot. Owned by + /// the Rust side (desktop.rs) — what a webview sends back is ignored. + pub desktop_mochi: DesktopMochiPref, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DesktopMochiPref { + /// He was on the desktop when the app quit: he flies back out at launch. + pub on_desktop: bool, + /// Top-left corner of his window where the user last left him. + pub spot: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DesktopSpot { + pub x: f64, + pub y: f64, + /// What x and y are measured in (`DesktopMode::space`). + pub space: String, } fn default_model() -> String { @@ -51,6 +71,7 @@ impl Default for Settings { hooks_installed: false, model: default_model(), mochi_outfit: "auto".into(), + desktop_mochi: DesktopMochiPref::default(), } } } @@ -312,7 +333,8 @@ mod tests { "autostart": true, "hooksInstalled": true, "model": "some-model", - "mochiOutfit": "witchHat" + "mochiOutfit": "witchHat", + "desktopMochi": { "onDesktop": true, "spot": { "x": 1500.5, "y": -300.0, "space": "screen" } } }"#; fn custom() -> Value { @@ -410,6 +432,30 @@ mod tests { assert!(!loaded.sound_enabled); } + #[test] + fn a_file_from_before_the_desktop_mochi_keeps_him_in_the_island() { + let loaded = parse(&custom_with("desktopMochi", None)).unwrap(); + assert_eq!(loaded.desktop_mochi, DesktopMochiPref::default()); + assert!(!loaded.desktop_mochi.on_desktop); + assert_eq!(loaded.mochi_outfit, "witchHat"); + } + + #[test] + fn a_half_written_desktop_spot_costs_only_the_spot() { + let loaded = parse(&custom_with( + "desktopMochi", + Some(json!({ "onDesktop": true, "spot": { "x": "left" } })), + )) + .unwrap(); + // The whole field falls back, and nothing else does. + assert_eq!(loaded.desktop_mochi, DesktopMochiPref::default()); + assert_eq!(loaded.model, "some-model"); + + let loaded = parse(&custom_with("desktopMochi", Some(json!({ "onDesktop": true })))).unwrap(); + assert!(loaded.desktop_mochi.on_desktop); + assert_eq!(loaded.desktop_mochi.spot, None); + } + #[test] fn an_outfit_this_build_does_not_know_is_kept_as_written() { // A newer build may add outfits: the island shows "auto" for it, but @@ -655,6 +701,7 @@ mod tests { "hooksInstalled", "model", "mochiOutfit", + "desktopMochi", ] ); let _ = std::fs::remove_dir_all(&dir); From b52bbe354a58ba581760c2f849bc0acd877d60b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:59:17 +0200 Subject: [PATCH 62/77] Desktop Mochi: the page that draws him mochi.html draws him with the island's engine, dressed in his outfit: 30 fps awake like the Mac, 4 fps asleep, nothing at all while he is not out. Click to poke, double-click to send him home, right-click for the wardrobe, drag to move him. He dozes off after two minutes without agent activity once the pointer is away, and wakes on activity or when the pointer comes over him. Co-Authored-By: Claude Opus 5.5 --- windows/mochi.html | 33 ++++ windows/src/core/bridge.ts | 40 +++- windows/src/desktop/main.ts | 354 ++++++++++++++++++++++++++++++++++++ windows/vite.config.ts | 1 + 4 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 windows/mochi.html create mode 100644 windows/src/desktop/main.ts diff --git a/windows/mochi.html b/windows/mochi.html new file mode 100644 index 000000000..659dd8d50 --- /dev/null +++ b/windows/mochi.html @@ -0,0 +1,33 @@ + + + + + + Mochi + + + + + + + diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..faa26d406 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -3,7 +3,7 @@ // `npm run dev` alone. import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { emitTo, listen } from "@tauri-apps/api/event"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import type { Settings } from "./state"; @@ -99,8 +99,46 @@ export const Bridge = { /** Tray → Pause. Stops the integration pollers, not just the island. */ setPaused: (paused: boolean) => call("set_paused", { paused }), + + // ── Mochi on the desktop (src-tauri/src/desktop.rs) ─────────────────────── + desktopInfo: () => call("desktop_mochi_info"), + /** Dragged out of the island: (x, y) is the pointer in island-window coordinates. */ + desktopPickUp: (x: number, y: number) => call("desktop_mochi_pick_up", { x, y }), + /** Linux: the pointer moved during that drag (Windows carries him from Rust). */ + desktopCarry: (x: number, y: number) => call("desktop_mochi_carry", { x, y }), + desktopCarryEnd: (x: number, y: number) => call("desktop_mochi_carry_end", { x, y }), + /** A drag started on the desktop Mochi; resolves to his top-left corner. */ + desktopDragBegin: () => call<[number, number] | null>("desktop_mochi_drag_begin"), + /** X11: top-left corner, physical pixels. */ + desktopDragMove: (x: number, y: number) => call("desktop_mochi_drag_move", { x, y }), + desktopDragEnd: (x: number, y: number) => call("desktop_mochi_drag_end", { x, y }), + /** From the island to his spot. False: no spot on any connected display. */ + desktopFlyOut: () => call("desktop_mochi_fly_out"), + /** To the island, then hidden. `forget`: he lives in the island again. */ + desktopFlyHome: (forget: boolean) => call("desktop_mochi_fly_home", { forget }), + /** Asleep, the cursor poll stops. */ + desktopSetAsleep: (asleep: boolean) => call("desktop_mochi_set_asleep", { asleep }), }; +/** How the desktop Mochi's window works here (platform::DesktopMode). */ +export type DesktopMode = "poll" | "window" | "layer" | "off"; + +export interface DesktopInfo { + mode: DesktopMode; + /** He was on the desktop when the app last quit. */ + onDesktop: boolean; +} + +/** An event for one window only (island ⇄ desktop Mochi). Never throws. */ +export async function emitToWindow(label: string, event: string, payload?: unknown) { + if (!IS_TAURI) return; + try { + await emitTo(label, event, payload); + } catch (err) { + console.error(`[coucou] emit ${event} failed`, err); + } +} + export interface IntegrationUpdate { id: string; data: Record; diff --git a/windows/src/desktop/main.ts b/windows/src/desktop/main.ts new file mode 100644 index 000000000..34273e145 --- /dev/null +++ b/windows/src/desktop/main.ts @@ -0,0 +1,354 @@ +// The desktop Mochi's own window (mochi.html): draws him, and turns clicks and +// drags on him into pokes, flights home, the wardrobe and a new spot. Port of +// DesktopBotView + the mouse half of DesktopMochiController (DesktopMochi.swift). +// +// What he shows comes from the island (DESKTOP_EVENTS.state), where he is from +// Rust. The page draws only while the window is on screen, at 30 fps awake +// like the Mac, and a few frames a second asleep — with no cursor polling at +// all while he sleeps. + +import { Bridge, emitToWindow, onEvent, type DesktopMode } from "../core/bridge"; +import type { BotEmoteName } from "../core/layout"; +import { Sound } from "../core/sound"; +import { BotEngine } from "../mochi/engine"; +import { + DESKTOP_EVENTS, DOUBLE_CLICK_MS, DRAG_THRESHOLD, PANEL_SIZE, agentActive, gaze, isOverBody, + layerDragTopLeft, lookOrigin, pointerDistance, shouldSleep, windowDragTopLeft, + type DesktopSnapshot, type Point, +} from "../mochi/desktop-logic"; + +const ISLAND = "island"; + +/** + * Width Mochi is drawn at. The engine's body radius is 0.3 × width, so this + * gives the 28.8 px body the hit test uses, and leaves room around him for + * hats, hands and hearts inside the 120 px window. + */ +const DRAW_W = PANEL_SIZE * 0.8; +const SIDE = (PANEL_SIZE - DRAW_W) / 2; +/** Frame pacing: TimelineView(minimumInterval: 1/30) awake; asleep, barely. */ +const AWAKE_FRAME_MS = 1000 / 30; +const ASLEEP_FRAME_MS = 250; + +class DesktopMochi { + private engine = new BotEngine(); + private canvas: HTMLCanvasElement; + private mode: DesktopMode = "off"; + + private snap: DesktopSnapshot = { + state: "idle", outfit: "none", soundEnabled: true, soundVolume: 0.12, paused: false, + }; + + private visible = false; + private asleep = false; + /** Whether the island has told us anything yet. */ + private informed = false; + private lastAgentActive = performance.now(); + /** Pointer in window coordinates, and when the page last heard of it. */ + private cursor: Point | null = null; + private lastPointer = -Infinity; + + private timer: number | null = null; + private lastDraw = 0; + + /** Canvas offset inside the window: non-zero only in a layer-shell drag. */ + private offset: Point = { x: 0, y: 0 }; + private press: { client: Point; screen: Point } | null = null; + private dragging = false; + /** Window's top-left corner when the drag began (mode's space). */ + private dragOrigin: Point | null = null; + private dragTopLeft: Point | null = null; + private moveFrame = false; + private pokeTimer: number | null = null; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + const dpr = this.dpr(); + canvas.width = Math.round(PANEL_SIZE * dpr); + canvas.height = Math.round(PANEL_SIZE * dpr); + this.engine.particleOverhang = 0; + this.engine.setState("idle", true); + // Three pokes: dizzy, and the island shows the confused view (as on macOS). + this.engine.onDizzy = () => void emitToWindow(ISLAND, DESKTOP_EVENTS.dizzy); + this.wireInput(); + } + + async start() { + void Sound.preload(); + const info = await Bridge.desktopInfo(); + if (info) this.mode = info.mode; + + await onEvent(DESKTOP_EVENTS.state, (s) => this.onSnapshot(s)); + await onEvent<{ emote: BotEmoteName; duration: number }>(DESKTOP_EVENTS.emote, (e) => { + this.lastAgentActive = performance.now(); + this.wake(); + this.engine.triggerEmote(e.emote, e.duration); + this.schedule(); + }); + await onEvent(DESKTOP_EVENTS.cursor, (p) => this.notePointer(p)); + await onEvent(DESKTOP_EVENTS.visible, (on) => this.setVisible(on)); + await onEvent(DESKTOP_EVENTS.flight, (kind) => { + // He fades in on the way out of the island (alphaValue 0 → 1 on macOS). + if (kind !== "out") return; + this.canvas.style.transition = "none"; + this.canvas.style.opacity = "0"; + void this.canvas.offsetWidth; + this.canvas.style.transition = ""; + this.canvas.style.opacity = "1"; + }); + await emitToWindow(ISLAND, DESKTOP_EVENTS.ready); + } + + // ── State ─────────────────────────────────────────────────────────────────── + + private onSnapshot(s: DesktopSnapshot) { + this.snap = s; + this.informed = true; + Sound.setEnabled(s.soundEnabled); + Sound.setVolume(s.soundVolume); + if (agentActive(s.state)) this.lastAgentActive = performance.now(); + if (!this.asleep) this.engine.setState(s.state); + this.updateSleep(performance.now()); + this.schedule(); + } + + private setVisible(on: boolean) { + this.visible = on; + if (on) { + // The two pages may have started in either order: ask again if needed. + if (!this.informed) void emitToWindow(ISLAND, DESKTOP_EVENTS.ready); + // Landing counts as something going on: he looks around for a while. + this.lastAgentActive = performance.now(); + this.asleep = false; + this.engine.setState(this.snap.state, true); + this.engine.setOutfit(this.snap.outfit, false); + this.schedule(); + return; + } + this.cancelPoke(); + this.press = null; + this.dragging = false; + this.setOffset({ x: 0, y: 0 }); + if (this.timer != null) window.clearTimeout(this.timer); + this.timer = null; + Sound.idle(); + } + + private updateSleep(now: number) { + if (!this.visible) return; + const distance = pointerDistance(this.mode === "poll", this.cursor, now - this.lastPointer); + const sleep = + !this.dragging && + (this.snap.paused || shouldSleep((now - this.lastAgentActive) / 1000, distance)); + if (sleep === this.asleep) return; + this.asleep = sleep; + this.engine.setState(sleep ? "sleeping" : this.snap.state); + // Asleep, Rust stops the cursor poll; his whole little window then listens + // for the pointer, and the first move over it wakes him. + void Bridge.desktopSetAsleep(sleep); + if (sleep) Sound.idle(); + this.schedule(); + } + + private wake() { + if (!this.asleep) return; + this.updateSleep(performance.now()); + } + + /** Pointer in window coordinates: his eyes, and waking up. */ + private notePointer(p: Point) { + this.cursor = p; + this.lastPointer = performance.now(); + if (this.asleep) this.wake(); + this.schedule(); + } + + // ── Frames ────────────────────────────────────────────────────────────────── + + private schedule() { + if (this.timer != null || !this.visible) return; + const wait = this.asleep ? ASLEEP_FRAME_MS : AWAKE_FRAME_MS; + const due = Math.max(0, this.lastDraw + wait - performance.now()); + this.timer = window.setTimeout(() => { + // Awake, frames line up with the display; asleep, a timer is enough. + if (this.asleep) { + this.timer = null; + this.frame(performance.now()); + } else { + requestAnimationFrame((t) => { + this.timer = null; + this.frame(t); + }); + } + }, due); + } + + private frame(now: number) { + if (!this.visible) return; + const dt = Math.min(0.05, Math.max(0, (now - this.lastDraw) / 1000)); + this.lastDraw = now; + this.updateSleep(now); + this.draw(dt, now); + this.schedule(); + } + + private draw(dt: number, now: number) { + const ctx = this.canvas.getContext("2d"); + if (!ctx) return; + const engine = this.engine; + engine.setOutfit(this.snap.outfit, true); + if (!this.asleep) { + // Windows: the global cursor. Linux: only while the pointer is over him. + const fresh = this.mode === "poll" || now - this.lastPointer < 1500; + const g = this.cursor && fresh ? gaze(lookOrigin(0, 0), this.cursor) : { lookX: 0, lookY: 0 }; + engine.lookX = g.lookX; + engine.lookY = g.lookY; + } + // No dancing: the Windows and Linux app has no music signal to dance to. + engine.update(dt); + const dpr = this.dpr(); + ctx.setTransform(dpr, 0, 0, dpr, SIDE * dpr, 0); + ctx.clearRect(-SIDE, 0, PANEL_SIZE, PANEL_SIZE); + engine.draw(ctx, DRAW_W, PANEL_SIZE); + } + + private dpr(): number { + return Math.min(2, window.devicePixelRatio || 1); + } + + // ── Input ─────────────────────────────────────────────────────────────────── + + private local(e: MouseEvent): Point { + return { x: e.clientX - this.offset.x, y: e.clientY - this.offset.y }; + } + + private wireInput() { + document.addEventListener("pointerdown", (e) => { + Sound.resume(); + if (e.button !== 0 || !isOverBody(this.local(e))) return; + this.press = { client: this.local(e), screen: { x: e.screenX, y: e.screenY } }; + this.dragging = false; + try { + document.documentElement.setPointerCapture(e.pointerId); + } catch { + /* capture is a nicety */ + } + }); + + document.addEventListener("pointermove", (e) => { + if (!this.dragging) this.notePointer(this.local(e)); + const press = this.press; + if (!press) return; + if (!this.dragging) { + const l = this.local(e); + if (Math.hypot(l.x - press.client.x, l.y - press.client.y) <= DRAG_THRESHOLD) return; + this.beginDrag(); + } + this.dragTo(e); + }); + + document.addEventListener("pointerup", (e) => { + const press = this.press; + this.press = null; + if (!press) return; + if (this.dragging) { + this.endDrag(e); + return; + } + // Single click → poke, once it is clear it isn't a double click. + this.cancelPoke(); + this.pokeTimer = window.setTimeout(() => { + this.pokeTimer = null; + this.lastAgentActive = performance.now(); + this.wake(); + this.engine.slap(); + this.schedule(); + }, DOUBLE_CLICK_MS); + }); + + document.addEventListener("dblclick", (e) => { + if (!isOverBody(this.local(e))) return; + this.cancelPoke(); + void emitToWindow(ISLAND, DESKTOP_EVENTS.home); + }); + + document.addEventListener("contextmenu", (e) => { + e.preventDefault(); + if (isOverBody(this.local(e))) void emitToWindow(ISLAND, DESKTOP_EVENTS.wardrobe); + }); + + document.addEventListener("pointerleave", () => { + if (this.mode !== "poll" && !this.dragging) this.cursor = null; + }); + + // A layer-shell drag stretches the window over the display; once it is + // back to its own size, so is the canvas. + window.addEventListener("resize", () => { + if (!this.dragging && window.innerWidth <= PANEL_SIZE + 20) this.setOffset({ x: 0, y: 0 }); + }); + } + + private cancelPoke() { + if (this.pokeTimer != null) window.clearTimeout(this.pokeTimer); + this.pokeTimer = null; + } + + private beginDrag() { + this.dragging = true; + this.cancelPoke(); + this.dragOrigin = null; + this.dragTopLeft = null; + void Bridge.desktopDragBegin().then((pos) => { + if (!pos) { + this.dragging = false; + this.press = null; + return; + } + this.dragOrigin = { x: pos[0], y: pos[1] }; + }); + } + + /** Linux: the page drives the drag. Windows: the cursor poll carries him. */ + private dragTo(e: PointerEvent) { + const origin = this.dragOrigin; + const press = this.press; + if (!origin || !press || this.mode === "poll") return; + if (this.mode === "layer") { + const applied = window.innerWidth > PANEL_SIZE + 20; + const topLeft = layerDragTopLeft(origin, { x: e.clientX, y: e.clientY }, press.client, applied); + this.dragTopLeft = topLeft; + this.setOffset(applied ? topLeft : { x: topLeft.x - origin.x, y: topLeft.y - origin.y }); + return; + } + if (this.mode === "window") { + const topLeft = windowDragTopLeft(origin, { x: e.screenX, y: e.screenY }, press.screen, this.dpr()); + this.dragTopLeft = topLeft; + if (this.moveFrame) return; + this.moveFrame = true; + requestAnimationFrame(() => { + this.moveFrame = false; + const p = this.dragTopLeft; + if (p && this.dragging) void Bridge.desktopDragMove(p.x, p.y); + }); + } + } + + private endDrag(e: PointerEvent) { + this.dragTo(e); + this.dragging = false; + const topLeft = this.dragTopLeft; + this.dragOrigin = null; + this.dragTopLeft = null; + if (this.mode === "poll" || !topLeft) return; + void Bridge.desktopDragEnd(Math.round(topLeft.x), Math.round(topLeft.y)); + } + + private setOffset(p: Point) { + this.offset = p; + this.canvas.style.left = `${p.x}px`; + this.canvas.style.top = `${p.y}px`; + } +} + +const canvas = document.getElementById("mochi"); +if (canvas instanceof HTMLCanvasElement) void new DesktopMochi(canvas).start(); diff --git a/windows/vite.config.ts b/windows/vite.config.ts index 3cf942c5a..1ca8b2e0a 100644 --- a/windows/vite.config.ts +++ b/windows/vite.config.ts @@ -56,6 +56,7 @@ export default defineConfig({ input: { island: resolve(__dirname, "index.html"), settings: resolve(__dirname, "settings.html"), + mochi: resolve(__dirname, "mochi.html"), }, }, }, From 7a8d5e63d9fdc9f5ba44e9b75f5eb46b6bc742bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:59:19 +0200 Subject: [PATCH 63/77] Desktop Mochi: drag him out of the island; alerts bring him back Pressing on Mochi in the island and moving past 3 px carries him out; the island's own Mochi hides while he is away. The island runs his life cycle: he flies back for a permission or a question and returns once it is answered, jumps when a task finishes, and flies back to his spot at launch once the greeting is over. Co-Authored-By: Claude Opus 5.5 --- windows/src/core/state.ts | 11 +++ windows/src/island/desktop.ts | 170 ++++++++++++++++++++++++++++++++++ windows/src/island/island.ts | 72 +++++++++++++- windows/src/main.ts | 1 + 4 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 windows/src/island/desktop.ts diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 16acf8c4a..674b611db 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -98,6 +98,11 @@ export interface Settings { * Same raw values as the Mac's "mochiOutfit"; read it through parseOutfit. */ mochiOutfit: string; + /** Mochi on the desktop. Rust owns it: whatever the page sends back is ignored. */ + desktopMochi?: { + onDesktop: boolean; + spot: { x: number; y: number; space: string } | null; + }; } export const DEFAULT_SETTINGS: Settings = { @@ -147,6 +152,12 @@ class AppState { integrations: Record = {}; + /** + * Mochi is out of the island — on the desktop, flying, or being dragged + * there — so the island's own Mochi is hidden (AppState.mochiOnDesktop). + */ + mochiOnDesktop = false; + /** Outfit shown on Mochi while the pointer rests on a wardrobe button. */ wardrobePreview: Outfit | null = null; diff --git a/windows/src/island/desktop.ts b/windows/src/island/desktop.ts new file mode 100644 index 000000000..eac54d246 --- /dev/null +++ b/windows/src/island/desktop.ts @@ -0,0 +1,170 @@ +// Mochi on the desktop, island side: runs his life cycle (the controller in +// mochi/desktop-logic.ts) against the real app, tells the desktop window what +// to show, and handles the drag out of the island. The window itself lives in +// src-tauri/src/desktop.rs and draws him in src/desktop/main.ts. + +import { Bridge, emitToWindow, onEvent, type DesktopMode } from "../core/bridge"; +import type { BotEmoteName } from "../core/layout"; +import { Sound } from "../core/sound"; +import { State } from "../core/state"; +import { + DESKTOP_EVENTS, DesktopMochiController, alertActive, type DesktopSnapshot, +} from "../mochi/desktop-logic"; +import { SeasonCache, parseOutfit } from "../mochi/wardrobe"; + +/** Label of the desktop Mochi's window (desktop.rs LABEL). */ +const WINDOW = "mochi"; + +/** What the life cycle needs from the island. */ +export interface DesktopHost { + /** Shows the island (compact) if it is hidden. */ + reveal(): void; + /** Right-click on the desktop Mochi: the wardrobe, or back. */ + wardrobeFromDesktop(): void; + /** Three pokes on the desktop Mochi. */ + dizzyFromDesktop(): void; +} + +export class DesktopLink { + mode: DesktopMode = "off"; + readonly controller: DesktopMochiController; + + /** The pointer is dragging Mochi out of the island. */ + carrying = false; + + private seasons = new SeasonCache(); + private pushed = ""; + private pendingCarry: { x: number; y: number } | null = null; + private carryFrame = false; + private host: DesktopHost; + + constructor(host: DesktopHost) { + this.host = host; + this.controller = new DesktopMochiController({ + flyOut: async () => (await Bridge.desktopFlyOut()) ?? false, + flyHome: async (forget) => (await Bridge.desktopFlyHome(forget)) ?? true, + setAway: (away) => { + State.mochiOnDesktop = away; + State.notify(); + }, + emote: (emote: BotEmoteName, duration = 1.8) => + void emitToWindow(WINDOW, DESKTOP_EVENTS.emote, { emote, duration }), + play: (sound) => Sound.play(sound), + alertActive: () => alertActive(State), + revealIsland: () => { + if (State.mode === "hidden") this.host.reveal(); + }, + later: (fn, ms) => void window.setTimeout(fn, ms), + }); + } + + /** False where windows can't be placed (GNOME on Wayland): he stays in the island. */ + get supported(): boolean { + return this.mode !== "off"; + } + + async init() { + const info = await Bridge.desktopInfo(); + if (!info || info.mode === "off") return; + this.mode = info.mode; + this.controller.enabled = info.onDesktop; + + await onEvent<{ from: "island" | "desktop"; home: boolean }>(DESKTOP_EVENTS.dropped, (e) => + this.onDropped(e.from, e.home), + ); + await onEvent(DESKTOP_EVENTS.home, () => void this.controller.flyHome()); + await onEvent(DESKTOP_EVENTS.wardrobe, () => this.host.wardrobeFromDesktop()); + await onEvent(DESKTOP_EVENTS.dizzy, () => this.host.dizzyFromDesktop()); + // The window's page (re)loaded: it knows nothing yet. + await onEvent(DESKTOP_EVENTS.ready, () => { + this.pushed = ""; + this.push(); + }); + + State.subscribe(() => this.sync()); + this.sync(); + } + + /** The launch greeting is over: back to his spot if that is where he lives. */ + launch() { + if (this.supported) void this.controller.launchFlyIfNeeded(); + } + + // ── Drag out of the island ────────────────────────────────────────────────── + + /** Whether Mochi can be picked up from the island right now. */ + canPickUp(): boolean { + return this.supported && !State.paused && !State.mochiOnDesktop && this.controller.phase === "home"; + } + + /** (x, y): the pointer, island-window coordinates. */ + pickUp(x: number, y: number) { + this.carrying = true; + State.mochiOnDesktop = true; + State.notify(); + void Bridge.desktopPickUp(x, y).then((ok) => { + if (ok) return; + this.carrying = false; + State.mochiOnDesktop = false; + State.notify(); + }); + } + + /** The pointer moved during the drag. Windows carries him from Rust. */ + carry(x: number, y: number) { + if (!this.carrying || this.mode === "poll") return; + this.pendingCarry = { x, y }; + if (this.carryFrame) return; + this.carryFrame = true; + requestAnimationFrame(() => { + this.carryFrame = false; + const p = this.pendingCarry; + this.pendingCarry = null; + if (p && this.carrying) void Bridge.desktopCarry(p.x, p.y); + }); + } + + /** The button went up. On Windows the cursor poll has seen it already. */ + carryEnd(x: number, y: number) { + if (!this.carrying || this.mode === "poll") return; + this.pendingCarry = null; + void Bridge.desktopCarryEnd(x, y); + } + + private onDropped(from: "island" | "desktop", home: boolean) { + if (from === "island") { + this.carrying = false; + if (home) { + // Dropped back on the island: he is simply there again. + State.mochiOnDesktop = false; + State.notify(); + } else { + this.controller.installed(); + } + } else if (home) { + void this.controller.flyHome(); + } + } + + // ── State → desktop window ────────────────────────────────────────────────── + + private sync() { + this.controller.updateAlert(alertActive(State)); + this.controller.updateState(State.effectiveState); + this.push(); + } + + private push() { + const snapshot: DesktopSnapshot = { + state: State.effectiveState, + outfit: State.wardrobePreview ?? this.seasons.get(parseOutfit(State.settings.mochiOutfit)), + soundEnabled: State.settings.soundEnabled, + soundVolume: State.settings.soundVolume, + paused: State.paused, + }; + const key = JSON.stringify(snapshot); + if (key === this.pushed) return; + this.pushed = key; + void emitToWindow(WINDOW, DESKTOP_EVENTS.state, snapshot); + } +} diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 18aabfa6c..bcd8b4f84 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -20,6 +20,8 @@ import { USC, UploadSeq } from "../upload/sequence"; import { buildHeader, buildViews, type ViewActions, type ViewHost } from "../views/views"; import { h } from "../views/dom"; import { IslandStateMachine } from "./fsm"; +import { DesktopLink } from "./desktop"; +import { DRAG_THRESHOLD } from "../mochi/desktop-logic"; const BOT_OVERHANG = 40; /** Extra canvas on each side of Mochi, for the witch hat's brim and the Santa hat's tip. */ @@ -39,6 +41,8 @@ const modeOrder = (m: IslandMode) => (m === "hidden" ? 0 : m === "compact" ? 1 : export class Island { readonly fsm = new IslandStateMachine(); + /** Mochi on the desktop: his life cycle and the drag out of the island. */ + readonly desktop: DesktopLink; private root: HTMLElement; private islandEl!: HTMLElement; @@ -91,12 +95,20 @@ export class Island { private prevViewBeforeConfused: IslandViewName = "overview"; private lastSyncedView: IslandViewName | null = null; + /** Where a press on Mochi started: moving past DRAG_THRESHOLD drags him out. */ + private botPress: { x: number; y: number } | null = null; + /** Drop sequence bookkeeping: last tick played, and whether the ✓ has fired. */ private uploadTens = 0; private uploadDone = false; constructor(root: HTMLElement) { this.root = root; + this.desktop = new DesktopLink({ + reveal: () => this.reveal(), + wardrobeFromDesktop: () => this.wardrobeFromDesktop(), + dizzyFromDesktop: () => this.handleDizzy(), + }); this.build(); this.wireFsm(); this.wireInput(); @@ -245,6 +257,8 @@ export class Island { private wireFsm() { this.fsm.homeToPetitDelay = State.settings.autoCloseInterval; this.fsm.onTransition = (from, to) => { + // The greeting is over, however it ended: back to his desktop spot. + if (from === "coucou" && to !== "coucou") this.desktop.launch(); switch (to) { case "hidden": this.setMode("hidden"); @@ -362,6 +376,19 @@ export class Island { else this.setView("wardrobe"); } + /** + * Right-click on the desktop Mochi (macOS openWardrobeFromDesktop): opens the + * wardrobe from any state, or goes back if it is already open. + */ + wardrobeFromDesktop() { + if (State.mode === "expanded" && State.view === "wardrobe") { + this.setView(State.defaultView()); + return; + } + if (State.paused) return; + this.alert("wardrobe"); + } + /** An alert stopped waiting for an answer: let the island auto-close again. */ dropPin() { this.fsm.pinned = false; @@ -562,6 +589,10 @@ export class Island { this.islandEl.addEventListener("mousedown", (e) => { Sound.resume(); State.lastActivity = performance.now(); + // A press on Mochi may become a drag out to the desktop. + if (e.button === 0 && this.isBotHit(e.clientX, e.clientY)) { + this.botPress = { x: e.clientX, y: e.clientY }; + } // Right-click on Mochi opens the wardrobe, and closes it again. if (e.button === 2 && this.isBotHit(e.clientX, e.clientY)) { this.cancelBotHover(); @@ -584,6 +615,29 @@ export class Island { if (this.isBotHit(e.clientX, e.clientY)) e.preventDefault(); }); + // Dragging Mochi out of the island puts him on the desktop. + window.addEventListener("mousemove", (e) => { + if (this.desktop.carrying) { + this.desktop.carry(e.clientX, e.clientY); + return; + } + const press = this.botPress; + if (!press) return; + if (!(e.buttons & 1)) { + this.botPress = null; + return; + } + if (Math.hypot(e.clientX - press.x, e.clientY - press.y) <= DRAG_THRESHOLD) return; + this.botPress = null; + if (!this.canDragOut()) return; + this.cancelBotHover(); + this.desktop.pickUp(e.clientX, e.clientY); + }); + window.addEventListener("mouseup", (e) => { + this.botPress = null; + if (this.desktop.carrying) this.desktop.carryEnd(e.clientX, e.clientY); + }); + window.addEventListener("keydown", (e) => { if (e.key === "Escape" && State.mode === "expanded" && !State.isPinned) this.collapse(); State.lastActivity = performance.now(); @@ -654,7 +708,15 @@ export class Island { this.ensureRunning(); } + /** The greeting and the drop sequence draw a Mochi of their own: not that one. */ + private canDragOut(): boolean { + if (State.mode === "hidden" || !this.desktop.canPickUp()) return false; + return !(State.mode === "expanded" && (State.view === "greeting" || this.uploadActive)); + } + private isBotHit(x: number, y: number): boolean { + // Out on the desktop, the island's Mochi is invisible: nothing to hit. + if (State.mochiOnDesktop) return false; const rect = this.islandRect(); const cx = rect.x + this.botCx.value; const cy = rect.y + this.botCy.value; @@ -690,7 +752,7 @@ export class Island { } /** Three slaps → dizzy + confused view for 3.3 s, then back. */ - private handleDizzy() { + handleDizzy() { this.prevViewBeforeConfused = State.view; State.stateOverride = "dizzy"; this.engine.setState("dizzy"); @@ -790,11 +852,13 @@ export class Island { this.botSize.target = p.diameter / 0.6; const greetingActive = State.mode === "expanded" && State.view === "greeting"; - // The drop canvas draws its own Mochi; two of them would overlap. - const visible = p.opacity > 0 && !greetingActive && !this.uploadActive; + // The drop canvas draws its own Mochi; two of them would overlap. Out on the + // desktop, he isn't here at all. + const away = State.mochiOnDesktop; + const visible = p.opacity > 0 && !greetingActive && !this.uploadActive && !away; this.botCanvas.style.opacity = visible ? "1" : "0"; - if (State.mode === "expanded" && State.view !== "uploading" && !greetingActive && !this.uploadActive) { + if (State.mode === "expanded" && State.view !== "uploading" && !greetingActive && !this.uploadActive && !away) { const d = p.diameter; const color = botGlowColor(State.effectiveState); this.botGlow.style.display = "block"; diff --git a/windows/src/main.ts b/windows/src/main.ts index 21b2195f9..fc292721e 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -23,6 +23,7 @@ async function main() { island.applySettings(); State.loadIntegrationTasks(); if (boot && !boot.cursorPoll) island.followPageCursor(); + await island.desktop.init(); await onEvent<{ x: number; y: number }>("cursor", ({ x, y }) => island.onCursor(x, y)); From 27d98148b8c283c22f3b7fe9d1caa8c2488fc123 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:59:19 +0200 Subject: [PATCH 64/77] README: Mochi on the desktop Co-Authored-By: Claude Opus 5.5 --- windows/README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/windows/README.md b/windows/README.md index d6da9f5a6..a157f62b4 100644 --- a/windows/README.md +++ b/windows/README.md @@ -44,6 +44,8 @@ installs for the current user only — no admin prompt. | Click Mochi | It gets annoyed. Three times in a row and it goes dizzy | | Rest the pointer on Mochi for two seconds | Hearts | | Right-click Mochi | The wardrobe: rest the pointer on an outfit to try it on, click to keep it. **Auto** dresses him for the season (witch hat in October, Santa hat in December…) | +| Drag Mochi out of the island | He moves onto your desktop and hangs out there, in his outfit, watching your cursor. Drop him back on the island to bring him home | +| On the desktop: click / right-click / double-click Mochi | Poke him / the wardrobe / he flies home. Drag him to move him | | Drag a file onto the island | Mochi turns into a box, swallows it, then offers to answer questions about it | | `Esc` | Closes the island | | Tray icon | Open, Wardrobe…, Settings…, Pause, Quit | @@ -52,6 +54,13 @@ Everything else happens on its own: a Claude Code permission request opens the island with **Deny / Allow**, a finished session shows what it did, and your integrations sit in the coloured pills next to Mochi. +When Mochi lives on the desktop, he flies back to the island with a permission +request or a question and returns to his spot once you have answered; he does a +little jump when a task finishes, and dozes off when nothing has happened for +two minutes and your cursor is elsewhere — asleep, he costs nothing: no cursor +polling, a few frames a second. He remembers his spot between launches; if it +was on a display that is no longer connected, he stays in the island. + ## Claude Code The settings window @@ -124,6 +133,7 @@ npm run icons # regenerates src-tauri/icons from scripts/gen-icons.mjs windows/ src/ island front end (TypeScript, no framework) mochi/ Mochi and the launch greeting, in Canvas 2D + desktop/ Mochi's own little window, when he lives on the desktop island/ state machine, hooks, integrations views/ every island view settings/ the settings window @@ -167,6 +177,11 @@ OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integr - The wardrobe opens with a right-click on Mochi or from the tray menu; the Mac's keyboard shortcut for it isn't there. In the compact island a tall hat is cut by the top edge of the screen, as it is by the notch on a Mac. +- Mochi on the desktop doesn't dance: there is no music integration to dance + to. Dropping him on a window doesn't attach it to the chat, and the Mac's + ⌃⌥D shortcut isn't there — drag him out, double-click him home. While he + sleeps, the transparent square around him (120 px) takes the first mouse + move, which wakes him and gives the rest back to the desktop. ## Linux @@ -192,7 +207,14 @@ What changes on Linux: - **Click-through** is the window's input region, kept equal to the island shape, so the compositor sends every other click to what is underneath. - **Mochi's eyes** follow the pointer only while it is over the island: Wayland - gives no app the cursor position anywhere else. + gives no app the cursor position anywhere else. On the desktop, likewise, + they follow it only while it is over him, and "the cursor is far away" (so + he may fall asleep) means it hasn't been over him for a few seconds. +- **Mochi on the desktop** is a layer-shell surface on the island's display + (KDE Plasma, COSMIC, Hyprland, Sway…), placed with margins and dragged within + that display; on X11 it is an ordinary always-on-top window that goes + anywhere. **GNOME on Wayland** has no layer-shell and lets no app place its + own window, so there Mochi can't leave the island: dragging him does nothing. - **Claude Code hooks** go through `~/.local/share/coucou/bin/coucou-hook` and a Unix socket at `$XDG_RUNTIME_DIR/coucou.sock`. Both ends check that the other runs as the same user. From a722018fcd01097229bf57d5c849c9cc7980b856 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:59:30 +0200 Subject: [PATCH 65/77] Windows README: active pills, waiting cards, Open terminal, what stays Mac-only Co-Authored-By: Claude Opus 5.5 --- windows/README.md | 51 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/windows/README.md b/windows/README.md index 081142cb3..1e8a89ecc 100644 --- a/windows/README.md +++ b/windows/README.md @@ -52,6 +52,24 @@ island with **Deny / Allow**, a question from Claude Code shows its options to pick from, a finished session shows what it did, and your integrations sit in the coloured pills next to Mochi. +A permission card or a question stays until you answer it: the mouse leaving +never folds it, it comes up even when the island is already open or another +pill is in front, and the pill you were on comes back once you answer. To keep +it for later, fold it with the **⌃** in its corner (or `Esc` in the island): the +island shrinks to its compact size and stays on screen, nothing is answered, and +opening it again shows the card. **Open terminal** brings the window the session +runs in to the front. + +## Your pills + +**Settings… → Active pills** lists the tools you use, from the same catalog as +the Mac app. Pick your **main tool** — VS Code, Cursor, Codex or Antigravity — +which is always there and doesn't take a slot, then declare up to four more: +agents (Gemini CLI, Copilot CLI, Muse Code, Claude Desktop), the Anthropic chat, +and the services under **Integrations**. A pill fed by hooks says whether its +hooks are installed, never asks for a key. A session on a pill you didn't +declare still shows up, for as long as it runs. + ## Claude Code The settings window @@ -146,7 +164,8 @@ The relay (`coucou-hook.exe`) works with any tool that can run a command on hook | Claude Code | **Settings → Claude Code → Install hooks** | `%USERPROFILE%\.claude\settings.json` | | Gemini CLI | `--agent gemini` positional arg | `%USERPROFILE%\.gemini\settings.json` | | Antigravity | `--agent antigravity` positional arg | `%USERPROFILE%\.config\antigravity\hooks.json` | -| Cursor | hooks installed automatically | `%USERPROFILE%\.claude\settings.json` | +| Cursor | the Claude Code hooks: Claude Code in Cursor's terminal goes on the Cursor pill | `%USERPROFILE%\.claude\settings.json` | +| Claude Desktop (Windows) | nothing to install: Claude Code sessions from the Claude app are tagged by the relay | — | | Codex | `--agent codex` positional arg | `%USERPROFILE%\.codex\hooks.json` | | Copilot CLI | `--agent copilot` positional arg + camelCase events | `%USERPROFILE%\.copilot\hooks\coucou.json` | | Muse Code | `--agent muse` positional arg | `%USERPROFILE%\.config\muse\settings.json` | @@ -154,15 +173,32 @@ The relay (`coucou-hook.exe`) works with any tool that can run a command on hook OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integration uses a plugin that calls `/bin/sh` with macOS-specific paths; the plugin installer lives in the Mac app only. +A pill is **connected** when Coucou finds `coucou-hook --agent ` in the +config file above (for Claude Code and Cursor: a SessionStart hook running +Coucou's relay). Coucou only reads these files, each time the island opens. +Permission requests get the island's card for Claude Code, in any terminal and +in Cursor; other agents and Claude Desktop answer them in their own window. + ## What's different from the Mac version - No notch, so the island lives at the top centre of the screen and retracts into the top edge instead of hiding in a notch. - Permission approval works from **any** terminal; the Mac build only listens to VS Code sessions. -- Not in this version: sending a file by email, dragging Mochi onto a window to - attach it as context, and jumping to a specific terminal window — "Open - terminal" opens the working folder in VS Code when `code` is on your `PATH`. +- "Open terminal" finds the session's window by walking up from the relay's + process to the terminal or editor that runs it. A session in a classic + console window (`cmd.exe` or PowerShell without Windows Terminal) has no such + ancestor — conhost owns that window — so its folder opens in VS Code instead, + as it does when `code` is on your `PATH` and nothing was found. +- No global keyboard shortcuts yet: a waiting card is folded with its **⌃** or + `Esc` in the island, and reopened by clicking the island or Open in the tray. +- Pills from the Mac catalog that have nothing behind them here are left out: + OpenCode, Amp, Hermes, Apple Music, Ollama and LM Studio. Google AI and OpenAI + can be declared but show "Coming soon": the chat here only talks to Anthropic. +- Not in this version: sending a dropped file by email and dragging Mochi onto + a window to attach it as context. On the Mac, email goes through Resend or + Apple Mail's scripting; neither has a safe equivalent that attaches a file + here, and the drop card would need a third button it doesn't have. - Cal.com shows the next bookings as a list rather than the Mac's calendar. ## Linux @@ -212,5 +248,8 @@ What changes on Linux: - **Files**: preferences in `~/.config/coucou/`, the log at `~/.local/share/coucou/coucou.log`. - What the Windows build leaves out, this one does too: sending a file by - email, dragging Mochi onto a window, and jumping to a specific terminal - window — "Open terminal" opens the folder in VS Code. + email and dragging Mochi onto a window. +- **Open terminal** opens the folder in VS Code: Wayland lets no app bring + another app's window forward, and X11 would need a window-manager client this + build doesn't carry. +- No **Claude Desktop** pill: the Claude app has no Linux build. From a2aa656e652be575d41c2366878cd465d0f5d78e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 23:02:12 +0200 Subject: [PATCH 66/77] OpenCode, Amp and Hermes: plugins that call the relay directly The Mac's plugins (0.2.0 #263, 0.2.1 #288) start /bin/sh on a script at a macOS path. These start coucou-hook itself, at this machine's path (%LOCALAPPDATA%\Coucou\bin\coucou-hook.exe on Windows, ~/.local/share/coucou/bin/coucou-hook on Linux), written as a string literal so no path can break the code. All three are fire-and-forget: the agent never waits, and nothing happens with Coucou closed. - OpenCode: ~/.config/opencode/plugins/coucou.js. Tool input now comes from the hook's output.args and the folder from the plugin context. - Amp: ~/.config/amp/plugins/coucou.ts. Steps come from tool.result: listening to tool.call would mean returning a verdict ("allow") for Amp, and Coucou never makes one. - Hermes: ~/.hermes/plugins/coucou/__init__.py and plugin.yaml, written and checked together. Approvals stay in Hermes, as on the Mac: the plugin only observes pre_approval_request. Coucou does not run the hermes CLI; Settings says to turn the plugin on once with `hermes plugins enable coucou`. A gateway session shows its platform. Plugin files go through the same writer (diff, backup, fingerprint); a file Coucou did not write is neither replaced nor removed. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/agents.rs | 299 ++++++++++++++++++++++++++++++++ windows/src-tauri/src/hooks.rs | 7 +- windows/src/island/hooks.ts | 6 + windows/tests/hooks.test.mjs | 8 + 4 files changed, 318 insertions(+), 2 deletions(-) diff --git a/windows/src-tauri/src/agents.rs b/windows/src-tauri/src/agents.rs index f41273c42..c791d3680 100644 --- a/windows/src-tauri/src/agents.rs +++ b/windows/src-tauri/src/agents.rs @@ -98,6 +98,9 @@ pub enum Agent { Codex, Copilot, Muse, + OpenCode, + Amp, + Hermes, } impl Agent { @@ -108,6 +111,9 @@ impl Agent { Agent::Gemini, Agent::Antigravity, Agent::Cursor, + Agent::OpenCode, + Agent::Amp, + Agent::Hermes, ]; /// The `--agent` name; the pill is `agent_` (PillCatalog.swift). @@ -119,6 +125,9 @@ impl Agent { Agent::Codex => "codex", Agent::Copilot => "copilot", Agent::Muse => "muse", + Agent::OpenCode => "opencode", + Agent::Amp => "amp", + Agent::Hermes => "hermes", } } @@ -135,18 +144,41 @@ impl Agent { Agent::Codex => vec![home.join(".codex").join("hooks.json")], Agent::Copilot => vec![home.join(".copilot").join("hooks").join("coucou.json")], Agent::Muse => vec![home.join(".config").join("muse").join("settings.json")], + // OpenCode and Amp read ~/.config on Windows too. + Agent::OpenCode => vec![home.join(".config").join("opencode").join("plugins").join("coucou.js")], + Agent::Amp => vec![home.join(".config").join("amp").join("plugins").join("coucou.ts")], + Agent::Hermes => { + let dir = home.join(".hermes").join("plugins").join("coucou"); + vec![dir.join("__init__.py"), dir.join("plugin.yaml")] + } } } /// The edits that install (or remove) Coucou for this agent. fn edits(self, home: &Path, relay: &Relay, install: bool) -> Vec> { let files = self.files(home); + if let Some(contents) = self.plugin(relay) { + return files.into_iter().zip(contents).map(|(path, text)| plugin_edit(path, text, install)).collect(); + } let path = files[0].clone(); let label = path.display().to_string(); let change = self.json_change(relay, install); vec![FileEdit { path, edit: config_file::json_edit(label, change) }] } + /// For an agent that loads a plugin rather than running hook commands, the + /// contents of each of its files (same order as `files`). + fn plugin(self, relay: &Relay) -> Option> { + // A JSON string is also a valid JavaScript, TypeScript and Python one. + let hook = serde_json::to_string(&relay.exe).unwrap_or_default(); + match self { + Agent::OpenCode => Some(vec![OPENCODE_PLUGIN.replace("{HOOK}", &hook)]), + Agent::Amp => Some(vec![AMP_PLUGIN.replace("{HOOK}", &hook)]), + Agent::Hermes => Some(vec![HERMES_PLUGIN.replace("{HOOK}", &hook), HERMES_PLUGIN_YAML.to_string()]), + _ => None, + } + } + /// The change to this agent's JSON config: the new object, or `None` to /// remove a file that is Coucou's alone. Command lines are built here, up /// front, so the change itself is pure. @@ -197,6 +229,10 @@ impl Agent { .collect(); Box::new(move |v| muse_install(v, &commands).map(Some)) } + // Plugins are whole files (see `plugin`), never merged into JSON. + (Agent::OpenCode | Agent::Amp | Agent::Hermes, _) => { + Box::new(|_| Err("This agent takes a plugin, not hook entries.".into())) + } } } @@ -204,6 +240,9 @@ impl Agent { /// file we cannot read just reads as "not installed". fn installed(self, home: &Path) -> bool { let files = self.files(home); + if matches!(self, Agent::OpenCode | Agent::Amp | Agent::Hermes) { + return std::fs::read_to_string(&files[0]).is_ok_and(|text| is_our_plugin(&text)); + } let json = || { config_file::read(&files[0]) .ok() @@ -221,6 +260,7 @@ impl Agent { .and_then(Value::as_object) .is_some_and(|h| h.values().filter_map(Value::as_array).flatten().any(copilot_entry_is_ours)), Agent::Muse => groups_have_ours(&json(), "muse"), + Agent::OpenCode | Agent::Amp | Agent::Hermes => false, } } @@ -245,6 +285,9 @@ impl Agent { Agent::Codex => "Codex", Agent::Copilot => "GitHub Copilot CLI", Agent::Muse => "Muse Code", + Agent::OpenCode => "OpenCode", + Agent::Amp => "Amp", + Agent::Hermes => "Hermes Agent", } } @@ -257,6 +300,9 @@ impl Agent { Agent::Codex => "Codex runs new hooks only once you trust them: start Codex and review them once with /hooks.", Agent::Copilot => "Start a new Copilot CLI session to pick the hooks up.", Agent::Muse => "Start a new Muse Code session to pick the hooks up.", + Agent::OpenCode => "Restart OpenCode to load the plugin.", + Agent::Amp => "Restart Amp to load the plugin.", + Agent::Hermes => "Turn it on once with `hermes plugins enable coucou`, then start a new Hermes session.", } } } @@ -311,6 +357,12 @@ pub fn apply(id: &str, install: bool, fingerprint: &str) -> Result Result { let backups = config_file::apply(&agent.edits(home, relay, install), fingerprint)?; + // Hermes loads every folder under plugins/: an empty `coucou` one goes too. + if agent == Agent::Hermes && !install { + if let Some(dir) = agent.files(home)[0].parent() { + let _ = std::fs::remove_dir(dir); + } + } Ok(backups.iter().map(|p| p.display().to_string()).collect::>().join("\n")) } @@ -674,6 +726,202 @@ fn muse_install(root: &Value, commands: &[(String, String, u64)]) -> Result bool { + text.contains(GENERATED) +} + +fn plugin_edit(path: PathBuf, content: String, install: bool) -> FileEdit<'static> { + let label = path.display().to_string(); + let name = label.clone(); + let edit = config_file::text_edit(label, move |current| match (install, current) { + (_, Some(text)) if !is_our_plugin(text) => { + Err(format!("{name} wasn't written by Coucou — {TEXT_UNTOUCHED}")) + } + (true, _) => Ok(Some(content.clone())), + (false, _) => Ok(None), + }); + FileEdit { path, edit } +} + +const OPENCODE_PLUGIN: &str = r#"// Coucou plugin for OpenCode — generated by Coucou. +// Forwards OpenCode's events to Coucou's relay (coucou-hook), fire-and-forget: +// OpenCode never waits on it, and nothing happens when Coucou is closed. +import { spawn } from 'node:child_process'; + +const HOOK = {HOOK}; +const EVENT_MAP = { + 'session.created': 'SessionStart', + 'session.idle': 'Stop', + 'session.error': 'StopFailure', + 'session.deleted': 'SessionEnd', +}; + +function forward(hook_event_name, payload) { + try { + const p = spawn(HOOK, ['--agent', 'opencode'], { + stdio: ['pipe', 'ignore', 'ignore'], + detached: process.platform !== 'win32', + windowsHide: true, + }); + p.on('error', () => {}); + p.stdin.on('error', () => {}); + p.stdin.end(JSON.stringify({ hook_event_name, ...payload }) + '\n'); + p.unref(); + } catch {} +} + +export const CoucouPlugin = async ({ directory } = {}) => ({ + event: async ({ event }) => { + const hook_event_name = EVENT_MAP[event?.type]; + if (!hook_event_name) return; + const props = event.properties || {}; + forward(hook_event_name, { + session_id: props.sessionID || props.info?.id || '', + cwd: directory || '', + }); + }, + 'tool.execute.before': async (input, output) => { + forward('PreToolUse', { + session_id: input?.sessionID || '', + cwd: directory || '', + tool_name: typeof input?.tool === 'string' ? input.tool : '', + tool_input: output?.args ?? null, + }); + }, + 'tool.execute.after': async (input) => { + forward('PostToolUse', { + session_id: input?.sessionID || '', + cwd: directory || '', + tool_name: typeof input?.tool === 'string' ? input.tool : '', + }); + }, +}); +"#; + +const AMP_PLUGIN: &str = r#"// Coucou plugin for Amp — generated by Coucou. +// Forwards Amp's events to Coucou's relay (coucou-hook), fire-and-forget and +// display only: Amp never waits on it, and it never decides anything for Amp. +import { spawn } from 'node:child_process'; + +const HOOK = {HOOK}; + +function forward(hook_event_name: string, fields: Record): void { + try { + const p = spawn(HOOK, ['--agent', 'amp'], { + stdio: ['pipe', 'ignore', 'ignore'], + detached: process.platform !== 'win32', + windowsHide: true, + }); + p.on('error', () => {}); + p.stdin?.on('error', () => {}); + p.stdin?.end(JSON.stringify({ hook_event_name, ...fields }) + '\n'); + p.unref(); + } catch {} +} + +export default function (amp: any): void { + const session = (e: any) => ({ session_id: e?.thread?.id ?? '' }); + amp.on('session.start', (e: any) => { forward('SessionStart', session(e)); }); + amp.on('agent.start', (e: any) => { + forward('UserPromptSubmit', { ...session(e), prompt: typeof e?.message === 'string' ? e.message : '' }); + }); + // A step per tool once it has run: listening to tool.call would mean + // returning a verdict for Amp, and Coucou never makes one. + amp.on('tool.result', (e: any) => { + forward('PreToolUse', { ...session(e), tool_name: typeof e?.tool === 'string' ? e.tool : '', tool_input: e?.input ?? null }); + }); + amp.on('agent.end', (e: any) => { forward('Stop', { ...session(e), status: e?.status ?? '' }); }); +} +"#; + +const HERMES_PLUGIN: &str = r#"# Coucou plugin for Hermes Agent — generated by Coucou. +# Session and tool events go to Coucou's relay (coucou-hook), fire-and-forget: +# Hermes never waits on it, and keeps every approval decision to itself. +import json, os, subprocess, threading + +HOOK = {HOOK} +_current_session_id = '' + + +def _fire(fields): + """Non-blocking: start the relay and return at once. Reaps it in a thread.""" + def _run(): + try: + extra = {'start_new_session': True} if os.name != 'nt' else { + 'creationflags': getattr(subprocess, 'CREATE_NO_WINDOW', 0)} + p = subprocess.Popen([HOOK, '--agent', 'hermes'], stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **extra) + p.stdin.write(json.dumps(fields).encode() + b'\n') + p.stdin.close() + p.wait(timeout=5) + except Exception: + pass + threading.Thread(target=_run, daemon=True).start() + + +def register(ctx): + def on_session_start(**kwargs): + global _current_session_id + sid = kwargs.get('session_id', '') + _current_session_id = sid + platform = kwargs.get('platform', 'cli') or 'cli' + _fire({'hook_event_name': 'SessionStart', 'session_id': sid, 'platform': platform}) + + def on_session_end(**kwargs): + # Stop comes from post_llm_call, which has the last answer. + if kwargs.get('interrupted'): + _fire({'hook_event_name': 'StopFailure', 'session_id': kwargs.get('session_id', '')}) + + def post_llm_call(**kwargs): + _fire({'hook_event_name': 'Stop', + 'session_id': kwargs.get('session_id', '') or _current_session_id, + 'last_assistant_message': kwargs.get('assistant_response', '')}) + + def pre_tool_call(**kwargs): + _fire({'hook_event_name': 'PreToolUse', + 'session_id': kwargs.get('session_id', '') or _current_session_id, + 'tool_name': kwargs.get('tool_name', ''), + 'tool_input': kwargs.get('args') or {}}) + + def post_tool_call(**kwargs): + _fire({'hook_event_name': 'PostToolUse', + 'session_id': kwargs.get('session_id', '') or _current_session_id, + 'tool_name': kwargs.get('tool_name', '')}) + + def pre_approval_request(**kwargs): + # Observer only: Hermes asks and decides; the island just says so. + _fire({'hook_event_name': 'PreToolUse', + 'session_id': kwargs.get('session_key', '') or _current_session_id, + 'tool_name': '⏳ Approval pending in Hermes', + 'tool_input': {'command': kwargs.get('command', ''), + 'description': kwargs.get('description', '')}}) + + ctx.register_hook('on_session_start', on_session_start) + ctx.register_hook('on_session_end', on_session_end) + ctx.register_hook('post_llm_call', post_llm_call) + ctx.register_hook('pre_tool_call', pre_tool_call) + ctx.register_hook('post_tool_call', post_tool_call) + ctx.register_hook('pre_approval_request', pre_approval_request) +"#; + +const HERMES_PLUGIN_YAML: &str = r#"name: coucou +version: "1.0" +description: Coucou island integration — generated by Coucou +"#; + #[cfg(test)] mod tests { use super::*; @@ -948,6 +1196,57 @@ mod tests { assert_eq!(with, ["codex", "copilot", "muse"]); } + #[test] + fn plugins_call_the_relay_directly_and_go_away_whole() { + for agent in [Agent::OpenCode, Agent::Amp, Agent::Hermes] { + let home = scratch(&format!("plugin-{}", agent.id())); + let relay = linux(); + let plan = config_file::preview(&agent.edits(&home, &relay, true)).unwrap(); + apply_in(agent, &home, &relay, true, &plan.fingerprint).unwrap(); + assert!(agent.installed(&home), "{agent:?}"); + let main = std::fs::read_to_string(&agent.files(&home)[0]).unwrap(); + // The relay itself, at this machine's path, as a string literal: no + // shell, no macOS path. + assert!(main.contains(r#""/home/me/.local/share/coucou/bin/coucou-hook""#), "{agent:?}"); + assert!(!main.contains("/bin/sh") && !main.contains("nb-hook"), "{agent:?}"); + assert!(main.contains(&format!("'--agent', '{}'", agent.id())), "{agent:?}"); + // Never a verdict for the agent. + assert!(!main.contains("action: 'allow'") && !main.contains("register_approval_transport")); + + let plan = config_file::preview(&agent.edits(&home, &relay, false)).unwrap(); + apply_in(agent, &home, &relay, false, &plan.fingerprint).unwrap(); + assert!(!agent.installed(&home)); + for file in agent.files(&home) { + assert!(!file.exists(), "{}", file.display()); + } + let _ = std::fs::remove_dir_all(home); + } + } + + #[test] + fn a_plugin_file_coucou_did_not_write_is_left_alone() { + let home = scratch("plugin-foreign"); + let file = Agent::OpenCode.files(&home)[0].clone(); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, "export const Mine = async () => ({});\n").unwrap(); + for install in [true, false] { + let err = config_file::preview(&Agent::OpenCode.edits(&home, &linux(), install)).unwrap_err(); + assert!(err.contains("wasn't written by Coucou"), "{err}"); + } + assert_eq!(std::fs::read_to_string(&file).unwrap(), "export const Mine = async () => ({});\n"); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn a_windows_path_is_a_valid_string_in_every_plugin() { + let plugins = Agent::Hermes.plugin(&windows(WIN_SPACE)).unwrap(); + let literal = serde_json::to_string(WIN_SPACE).unwrap(); + assert!(plugins[0].contains(&format!("HOOK = {literal}"))); + assert!(literal.contains(r"C:\\Users\\Jane O'Neil")); + // plugin.yaml is ours too, so removing it is allowed. + assert!(is_our_plugin(&plugins[1])); + } + #[test] fn another_tools_entry_for_an_event_with_the_wrong_shape_is_refused() { let odd = json!({ "hooks": { "BeforeTool": { "matcher": "x" } } }); diff --git a/windows/src-tauri/src/hooks.rs b/windows/src-tauri/src/hooks.rs index 1b44561ff..2f472c115 100644 --- a/windows/src-tauri/src/hooks.rs +++ b/windows/src-tauri/src/hooks.rs @@ -333,8 +333,11 @@ mod tests { assert!(pre.iter().any(entry_is_ours), "our own hook was not added"); assert!(after["hooks"]["SomeEventWeDoNotTouch"].is_array()); - // Installing twice adds nothing more. - assert_eq!(merged(&after).unwrap(), after); + // Installing twice still leaves one entry of ours per event. (Compared + // by count: another test points HOME elsewhere meanwhile, which moves + // the relay path.) + let twice = merged(&after).unwrap(); + assert_eq!(twice["hooks"]["PreToolUse"].as_array().unwrap().iter().filter(|e| entry_is_ours(e)).count(), 1); // And removing ours puts it back exactly as it was. let cleaned = without_ours(&after).unwrap(); diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index d53fe0f72..cb458675a 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -59,6 +59,8 @@ interface HookPayload { tool_input?: Record; /** Optional agent tag: lowercase, digits and hyphens, ≤ 24 chars. */ coucou_agent?: string; + /** Hermes: where the session runs (telegram, discord…; "cli" in a terminal). */ + platform?: string; } const PROJECT_ALIASES: Record = { @@ -257,6 +259,10 @@ function handleHook(island: Island, payload: HookPayload) { switch (name) { case "SessionStart": ensurePill(); + // Hermes through its gateway says where the session comes from. + if (validAgent === "hermes" && payload.platform && payload.platform !== "cli") { + State.appendStep(agentId, payload.platform.charAt(0).toUpperCase() + payload.platform.slice(1)); + } surface("overview", false); Sound.play("work"); break; diff --git a/windows/tests/hooks.test.mjs b/windows/tests/hooks.test.mjs index e8d5356d1..f5fe07a49 100644 --- a/windows/tests/hooks.test.mjs +++ b/windows/tests/hooks.test.mjs @@ -317,6 +317,14 @@ test("Codex's Interrupt puts its pill back to idle", () => { assert.equal(task("agent_codex").state, "idle"); }); +test("Hermes says where a gateway session comes from", () => { + hook({ hook_event_name: "SessionStart", coucou_agent: "hermes", platform: "telegram" }); + assert.deepEqual(task("agent_hermes").steps, ["Telegram"]); + hook({ hook_event_name: "SessionEnd", coucou_agent: "hermes" }); + hook({ hook_event_name: "SessionStart", coucou_agent: "hermes", platform: "cli" }); + assert.deepEqual(task("agent_hermes").steps, []); +}); + test("an agent's last words show when it stops", () => { hook({ hook_event_name: "SessionStart", coucou_agent: "hermes" }); hook({ hook_event_name: "Stop", coucou_agent: "hermes", last_assistant_message: "All done, tests pass." }); From 3c3e47b15392669e80e5e4a76f0ae5780160e568 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 23:03:06 +0200 Subject: [PATCH 67/77] README: supported agents on Windows and Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table now says where each agent is installed from (Settings → Agents), which file Coucou writes, and who answers its permissions; how the relay never lets anything through on its own; how each agent's hook command is quoted on Windows, and which three are untested there; and what still differs from the Mac (badge rather than focus for an agent's request, no declared pills, Cursor Agent vs Claude Code in Cursor, Hermes enabled by hand). Co-Authored-By: Claude Opus 5.5 --- windows/README.md | 67 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/windows/README.md b/windows/README.md index 081142cb3..384911e02 100644 --- a/windows/README.md +++ b/windows/README.md @@ -139,20 +139,46 @@ problems. It stays on your machine. ## Supported agents -The relay (`coucou-hook.exe`) works with any tool that can run a command on hook events. Pass `--agent ` to create a named pill. - -| Agent | How to connect | Config file | +Every agent below is installed from **Settings → Agents** with the same steps as +Claude Code: the exact diff, the path of the dated backup, nothing written until +you click, and uninstalling removes only Coucou's entries. A config Coucou cannot +read, or where it finds something it does not expect, is left alone and the +reason is shown. Each agent gets its own pill (`agent_`, the Mac's ids and +colours). The files are the Mac's, under `%USERPROFILE%` on Windows and `~` on +Linux. + +| Agent | Installs | Permissions | |---|---|---| -| Claude Code | **Settings → Claude Code → Install hooks** | `%USERPROFILE%\.claude\settings.json` | -| Gemini CLI | `--agent gemini` positional arg | `%USERPROFILE%\.gemini\settings.json` | -| Antigravity | `--agent antigravity` positional arg | `%USERPROFILE%\.config\antigravity\hooks.json` | -| Cursor | hooks installed automatically | `%USERPROFILE%\.claude\settings.json` | -| Codex | `--agent codex` positional arg | `%USERPROFILE%\.codex\hooks.json` | -| Copilot CLI | `--agent copilot` positional arg + camelCase events | `%USERPROFILE%\.copilot\hooks\coucou.json` | -| Muse Code | `--agent muse` positional arg | `%USERPROFILE%\.config\muse\settings.json` | -| Any other | `--agent ` positional arg | your tool's hook config | - -OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integration uses a plugin that calls `/bin/sh` with macOS-specific paths; the plugin installer lives in the Mac app only. +| Claude Code | `.claude\settings.json` (**Settings → Claude Code**) | Allow / Deny and questions in the island | +| Codex | `.codex\hooks.json` — then trust the hooks once with `/hooks` in Codex | Allow / Deny in the island | +| GitHub Copilot CLI | `.copilot\hooks\coucou.json` | Allow / Deny in the island | +| Muse Code | `.config\muse\settings.json` | Allow / Deny in the island | +| Gemini CLI | `.gemini\settings.json` | asked in Gemini CLI | +| Antigravity | `.gemini\config\hooks.json` (a `coucou` hook group) | asked in Antigravity | +| Cursor Agent | `.cursor\hooks.json` | asked in Cursor | +| OpenCode | plugin `.config\opencode\plugins\coucou.js` | asked in OpenCode | +| Amp | plugin `.config\amp\plugins\coucou.ts` | asked in Amp | +| Hermes Agent | plugin `.hermes\plugins\coucou\` — then `hermes plugins enable coucou` once | asked in Hermes | +| Any other | run `coucou-hook --agent []` from your tool's hooks | asked in the tool | + +The relay maps every agent's event and field names onto Claude Code's (Gemini +CLI's `BeforeTool`, Copilot's `preToolUse`, Cursor's `beforeSubmitPrompt`…), and +answers each agent the way it expects. It never lets anything through on its +own: with no click it prints no decision at all (`{}` for the agents that need +JSON, `"ask"` for Copilot, which is fail-closed), so the agent asks in its own +terminal exactly as without Coucou — including when Coucou is closed. + +The plugins start the relay directly, with no shell in between, and never wait +for it. Amp's steps appear as each tool finishes: its "before" hook must return +a verdict, and Coucou never gives one. + +**How each agent runs the relay on Windows.** Hook commands are written for the +shell that runs them: Git Bash for Claude Code (quoted, forward slashes), +PowerShell for Gemini CLI and Copilot CLI (`& '…\coucou-hook.exe'`), `cmd /C` +for Codex. Cursor, Antigravity and Muse Code do not document theirs: the relay +path is written bare when it has no space or special character — which works in +cmd, PowerShell and when started directly — and in double quotes otherwise. +These three are untested on Windows. ## What's different from the Mac version @@ -164,6 +190,16 @@ OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integr attach it as context, and jumping to a specific terminal window — "Open terminal" opens the working folder in VS Code when `code` is on your `PATH`. - Cal.com shows the next bookings as a list rather than the Mac's calendar. +- An agent's permission request that arrives while another pill is in view + badges its pill instead of taking the view; clicking the pill opens the card. + The Mac moves the focus to it. +- Agent pills come with a session and go when it ends: there is no "declared + pills" list in Settings yet, and no main-pill choice. +- The Cursor pill follows Cursor Agent's own hooks. On the Mac it follows + Claude Code running in Cursor's terminal. +- Hermes: Coucou writes the plugin but does not run the `hermes` CLI, so it is + turned on once by hand. Hermes runs natively on Linux; on Windows it is + untested. Claude Desktop's own pill is not here yet. ## Linux @@ -207,7 +243,10 @@ What changes on Linux: gives no app the cursor position anywhere else. - **Claude Code hooks** go through `~/.local/share/coucou/bin/coucou-hook` and a Unix socket at `$XDG_RUNTIME_DIR/coucou.sock`. Both ends check that the other - runs as the same user. + runs as the same user. Every other agent uses the same relay, single-quoted + for `sh`, and its config under `~` (see Supported agents). A config that is a + symlink (dotfiles) is written through to its target, with its permissions + kept. - **Keys** live in the Secret Service (GNOME Keyring, KWallet). - **Files**: preferences in `~/.config/coucou/`, the log at `~/.local/share/coucou/coucou.log`. From 77a7098051a1825393de181f0e8bc13a69e5edf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:00:29 +0200 Subject: [PATCH 68/77] i18n core: the Mac's strings, a key-based t() for the webviews and Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strings are looked up by their English text, like the Mac's catalog: t("Allow"), t("Uploading {name}", { name }), tn(one, other, n) for plurals. - scripts/gen-strings.mjs turns NotchBuddy/Resources/Localizable.xcstrings into src/i18n/strings.json: Mac keys such as "step.reads" become their English text, %@ / %lld become {placeholders} (named where the Windows code uses them), plural variations keep their categories. - src/i18n/extra.json holds the strings only Windows and Linux show, in all ten languages. - src/i18n/i18n.ts: lookup with English fallback, Intl plural rules, the System choice (system language when Coucou has it, else English; pt → pt-BR, zh-CN → zh-Hans), dates through Intl, and onLanguageChange. - views/dom.ts: a tl(…) text given to h() relabels itself in place when the language changes, so no view has to be rebuilt. - src-tauri/src/i18n.rs embeds the same two files with include_str! and answers the same way (t, tf, tn); its tests run in English per thread. - Tests load the JSON through the setup hook, as Vite does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DMzyoPKtGVNBUemdZhnWRG --- windows/scripts/gen-strings.mjs | 132 ++ windows/src-tauri/src/i18n.rs | 290 +++ windows/src/i18n/extra.json | 3299 ++++++++++++++++++++++++++++++ windows/src/i18n/i18n.ts | 268 +++ windows/src/i18n/strings.json | 3381 +++++++++++++++++++++++++++++++ windows/src/views/dom.ts | 84 +- windows/tests/setup.mjs | 13 +- windows/tsconfig.json | 1 + 8 files changed, 7465 insertions(+), 3 deletions(-) create mode 100644 windows/scripts/gen-strings.mjs create mode 100644 windows/src-tauri/src/i18n.rs create mode 100644 windows/src/i18n/extra.json create mode 100644 windows/src/i18n/i18n.ts create mode 100644 windows/src/i18n/strings.json diff --git a/windows/scripts/gen-strings.mjs b/windows/scripts/gen-strings.mjs new file mode 100644 index 000000000..6678d5b9e --- /dev/null +++ b/windows/scripts/gen-strings.mjs @@ -0,0 +1,132 @@ +// Generates src/i18n/strings.json from the Mac app's string catalog +// (NotchBuddy/Resources/Localizable.xcstrings), so Windows and Linux show the +// Mac's own translations. +// +// node scripts/gen-strings.mjs writes src/i18n/strings.json +// node scripts/gen-strings.mjs --check fails if the file is out of date +// +// Every entry is keyed by its English text, as the Mac's are (a Mac key such as +// "step.reads" is replaced by its English value, "Reads"). Format specifiers +// become named or numbered placeholders: "%@" and "%lld" → "{0}", "{1}"… +// ("%1$@" keeps its number), renamed where RENAMES says so. Plural entries keep +// their categories ({ "one": …, "other": … }) and are keyed by the English +// "other" form. English is the key itself, so it is not stored. +// +// Strings the Mac does not have (Windows and Linux only) live in +// src/i18n/extra.json, written by hand; see src/i18n/i18n.ts. + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const SOURCE = resolve(here, "../../NotchBuddy/Resources/Localizable.xcstrings"); +const OUTPUT = resolve(here, "../src/i18n/strings.json"); + +/** The languages Coucou ships, in the Mac's picker order. English is the source. */ +export const LANGUAGES = ["en", "zh-Hans", "hi", "es", "ar", "fr", "bn", "pt-BR", "ru", "id"]; + +/** + * Names for the placeholders of the Mac strings the Windows code uses, by Mac + * key: the n-th specifier becomes `{names[n]}` instead of `{n}`. + */ +const RENAMES = { + "Auto-close · %llds": ["seconds"], + "Chat with %@": ["name"], + "Claude is reading %@…": ["name"], + "Connected · %@": ["detail"], + "Key configured · %@": ["model"], + "Open %@": ["name"], + "Playing · %@": ["title"], + "Uploading %@": ["name"], + "Watching %lld of %lld": ["count", "total"], + "file.ready %@": ["name"], + "mail.with %@": ["name"], + "plan.hours-ago %lld": ["n"], + "plan.mins-ago %lld": ["n"], + "plan.reset-in-hm %lld %lld": ["h", "m"], + "plan.reset-in-m %lld": ["m"], + "settings.slots-used %lld": ["count"], + "status.local.connected %lld": ["count"], + "status.local.no-models %@": ["name"], + "status.local.unreachable %1$@ %2$@": ["name", "url"], +}; + +/** "%@ at %lld" → "{0} at {1}"; "%2$@" → "{1}"; "%%" → "%". */ +export function convertFormat(text, names = []) { + let next = 0; + return text.replace(/%(?:(\d+)\$)?(@|lld|ld|d|lu|u|f|%)/g, (_m, pos, spec) => { + if (spec === "%") return "%"; + const index = pos ? Number(pos) - 1 : next++; + return `{${names[index] ?? index}}`; + }); +} + +function unitValue(loc) { + return loc?.stringUnit?.value; +} + +/** A localization as a string, or as plural categories. */ +function readLocalization(loc, names) { + const plural = loc?.variations?.plural; + if (plural) { + const out = {}; + for (const [category, v] of Object.entries(plural)) { + const value = unitValue(v); + if (typeof value === "string") out[category] = convertFormat(value, names); + } + return Object.keys(out).length ? out : undefined; + } + const value = unitValue(loc); + return typeof value === "string" ? convertFormat(value, names) : undefined; +} + +export function generate(catalog) { + const strings = {}; + for (const [macKey, entry] of Object.entries(catalog.strings ?? {})) { + const names = RENAMES[macKey] ?? []; + const locs = entry.localizations ?? {}; + // A Mac key with no English localization is its own English text. + const en = readLocalization(locs.en, names) ?? convertFormat(macKey, names); + const key = typeof en === "string" ? en : en.other ?? Object.values(en)[0]; + const out = {}; + for (const lang of LANGUAGES) { + if (lang === "en") continue; + const value = readLocalization(locs[lang], names); + if (value !== undefined) out[lang] = value; + } + // The English plural forms are needed to pick "one" in English too. + if (typeof en !== "string") out.en = en; + strings[key] = out; + } + const sorted = Object.fromEntries(Object.entries(strings).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); + return { + _generated: "By scripts/gen-strings.mjs from NotchBuddy/Resources/Localizable.xcstrings. Do not edit: run `node scripts/gen-strings.mjs`.", + languages: LANGUAGES, + strings: sorted, + }; +} + +function main() { + const catalog = JSON.parse(readFileSync(SOURCE, "utf8")); + const text = `${JSON.stringify(generate(catalog), null, 1)}\n`; + if (process.argv.includes("--check")) { + let current = ""; + try { + current = readFileSync(OUTPUT, "utf8"); + } catch { + // missing: out of date + } + if (current !== text) { + console.error("src/i18n/strings.json is out of date: run `node scripts/gen-strings.mjs`."); + process.exit(1); + } + console.log("src/i18n/strings.json is up to date."); + return; + } + writeFileSync(OUTPUT, text); + const count = Object.keys(JSON.parse(text).strings).length; + console.log(`Wrote ${count} strings to src/i18n/strings.json`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/windows/src-tauri/src/i18n.rs b/windows/src-tauri/src/i18n.rs new file mode 100644 index 000000000..bbcb235c0 --- /dev/null +++ b/windows/src-tauri/src/i18n.rs @@ -0,0 +1,290 @@ +// Interface language for what Rust shows: the tray menu, the settings window +// title and the errors the webviews display. Same tables and same lookup as +// src/i18n/i18n.ts: strings keyed by their English text, from the Mac's catalog +// (strings.json, generated by scripts/gen-strings.mjs) and the Windows/Linux +// only ones (extra.json). A string missing in a language shows in English. +// +// The language is Settings.language when it is one of ours; "" follows the +// system: the languages the island's webview reports at launch, else (Linux) +// the LANGUAGE / LC_ALL / LC_MESSAGES / LANG variables, else English. + +use serde_json::{Map, Value}; +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +pub const LANGUAGES: [&str; 10] = ["en", "zh-Hans", "hi", "es", "ar", "fr", "bn", "pt-BR", "ru", "id"]; + +static MAC: &str = include_str!("../../src/i18n/strings.json"); +static EXTRA: &str = include_str!("../../src/i18n/extra.json"); + +type Table = HashMap>; + +fn table() -> &'static Table { + static TABLE: OnceLock = OnceLock::new(); + TABLE.get_or_init(|| { + let mut out = Table::new(); + // extra.json goes last: it wins where both have a string. + for source in [MAC, EXTRA] { + let Ok(Value::Object(root)) = serde_json::from_str::(source) else { continue }; + let Some(Value::Object(strings)) = root.get("strings") else { continue }; + for (key, entry) in strings { + if let Value::Object(langs) = entry { + out.insert(key.clone(), langs.clone()); + } + } + } + out + }) +} + +struct Current { + picked: String, + system: Vec, + resolved: &'static str, +} + +fn state() -> &'static RwLock { + static STATE: OnceLock> = OnceLock::new(); + STATE.get_or_init(|| { + let system = env_languages(); + let resolved = resolve("", &system); + RwLock::new(Current { picked: String::new(), system, resolved }) + }) +} + +/// LANGUAGE (a list), then LC_ALL, LC_MESSAGES and LANG, as gettext reads them. +fn env_languages() -> Vec { + let mut out = Vec::new(); + if let Ok(list) = std::env::var("LANGUAGE") { + out.extend(list.split(':').filter(|s| !s.is_empty()).map(str::to_string)); + } + for name in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(v) = std::env::var(name) { + if !v.is_empty() && v != "C" && v != "POSIX" { + out.push(v); + } + } + } + out +} + +/// The language to show: `picked` when it is one of ours, else the first of +/// the system's languages we have, else English. Same rules as resolveLanguage +/// in src/i18n/i18n.ts. +pub fn resolve(picked: &str, system: &[String]) -> &'static str { + if let Some(code) = LANGUAGES.iter().find(|c| **c == picked) { + return code; + } + for raw in system { + // "fr_FR.UTF-8" → "fr-fr" + let tag = raw.split('.').next().unwrap_or("").replace('_', "-").to_lowercase(); + let base = tag.split('-').next().unwrap_or(""); + if base.is_empty() { + continue; + } + if base == "zh" { + let traditional = ["zh-hant", "zh-tw", "zh-hk", "zh-mo"].iter().any(|p| tag.starts_with(p)); + if traditional { + continue; + } + return "zh-Hans"; + } + if base == "pt" { + return "pt-BR"; + } + if let Some(code) = LANGUAGES.iter().find(|c| c.to_lowercase() == base) { + return code; + } + } + "en" +} + +/// Records Settings.language. Returns true when the shown language changed. +pub fn set_picked(picked: &str) -> bool { + let mut s = state().write().unwrap_or_else(|e| e.into_inner()); + s.picked = picked.to_string(); + let next = resolve(&s.picked, &s.system); + std::mem::replace(&mut s.resolved, next) != next +} + +/// Records the system's languages as the webview sees them. Returns true when +/// the shown language changed. +pub fn set_system(languages: Vec) -> bool { + let mut s = state().write().unwrap_or_else(|e| e.into_inner()); + if !languages.is_empty() { + s.system = languages; + } + let next = resolve(&s.picked, &s.system); + std::mem::replace(&mut s.resolved, next) != next +} + +/// The language shown now. +pub fn current() -> &'static str { + #[cfg(test)] + return TEST_LANG.with(|c| c.get()); + #[cfg(not(test))] + state().read().unwrap_or_else(|e| e.into_inner()).resolved +} + +// Tests run in parallel and many check English text: each test thread has its +// own language, English unless the test picks another. +#[cfg(test)] +thread_local! { + static TEST_LANG: std::cell::Cell<&'static str> = const { std::cell::Cell::new("en") }; +} + +fn interpolate(text: &str, vars: &[(&str, &str)]) -> String { + let mut out = text.to_string(); + for (name, value) in vars { + out = out.replace(&format!("{{{name}}}"), value); + } + out +} + +fn lookup_in(lang: &str, key: &str) -> Option<&'static Value> { + if lang == "en" { + return None; + } + table().get(key)?.get(lang) +} + +/// CLDR plural category of `n` (whole numbers) in `lang`. +fn plural_category(lang: &str, n: u64) -> &'static str { + match lang { + "zh-Hans" | "id" => "other", + "fr" | "pt-BR" | "hi" | "bn" => { + if n <= 1 { "one" } else { "other" } + } + "ru" => { + let (m10, m100) = (n % 10, n % 100); + if m10 == 1 && m100 != 11 { + "one" + } else if (2..=4).contains(&m10) && !(12..=14).contains(&m100) { + "few" + } else { + "many" + } + } + "ar" => match (n, n % 100) { + (0, _) => "zero", + (1, _) => "one", + (2, _) => "two", + (_, 3..=10) => "few", + (_, 11..=99) => "many", + _ => "other", + }, + _ => { + if n == 1 { "one" } else { "other" } + } + } +} + +fn pick_plural(forms: &Map, category: &str) -> Option { + forms + .get(category) + .or_else(|| forms.get("other")) + .or_else(|| forms.values().next()) + .and_then(Value::as_str) + .map(str::to_string) +} + +/// Marks an English key for a table built before it is shown (the tray's +/// labels): the code translates it with `t` when it shows it. The i18n test +/// (tests/i18n.test.mjs) finds the keys through it. +pub const fn n_(key: &'static str) -> &'static str { + key +} + +/// `key` (English text) in the shown language, else the English text. +pub fn t(key: &str) -> String { + tf(key, &[]) +} + +/// `t` with `{name}` placeholders filled in. +pub fn tf(key: &str, vars: &[(&str, &str)]) -> String { + let lang = current(); + let text = match lookup_in(lang, key) { + Some(Value::String(s)) => s.clone(), + Some(Value::Object(forms)) => pick_plural(forms, "other").unwrap_or_else(|| key.to_string()), + _ => key.to_string(), + }; + interpolate(&text, vars) +} + +/// A count with its words: `tn("{count} file", "{count} files", n, &[])`. +#[allow(dead_code)] +pub fn tn(one: &str, other: &str, count: u64, vars: &[(&str, &str)]) -> String { + let lang = current(); + let n = count.to_string(); + let mut all: Vec<(&str, &str)> = vars.to_vec(); + all.push(("count", &n)); + let text = match lookup_in(lang, other) { + Some(Value::Object(forms)) => pick_plural(forms, plural_category(lang, count)), + Some(Value::String(s)) => Some(s.clone()), + _ => None, + }; + let text = text.unwrap_or_else(|| if count == 1 { one.to_string() } else { other.to_string() }); + interpolate(&text, &all) +} + +#[cfg(test)] +pub(crate) fn set_for_test(lang: &str) { + TEST_LANG.with(|c| c.set(resolve(lang, &[]))); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_like_the_webview() { + let sys = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(resolve("fr", &sys(&["ru"])), "fr"); + assert_eq!(resolve("", &sys(&["fr_FR.UTF-8"])), "fr"); + assert_eq!(resolve("", &sys(&["pt-PT"])), "pt-BR"); + assert_eq!(resolve("", &sys(&["zh-CN"])), "zh-Hans"); + assert_eq!(resolve("", &sys(&["zh-TW", "de", "es-MX"])), "es"); + assert_eq!(resolve("", &sys(&["de-DE"])), "en"); + assert_eq!(resolve("xx", &[]), "en"); + } + + #[test] + fn looks_up_both_tables_and_falls_back_to_english() { + set_for_test("fr"); + // From the Mac's catalog. + assert_eq!(t("Allow"), "Autoriser"); + // From extra.json. + assert_eq!(t("Open Coucou"), "Ouvrir Coucou"); + assert_eq!(t("A string nobody translated"), "A string nobody translated"); + assert_eq!(tf("Open {name}", &[("name", "Vercel")]), "Ouvrir Vercel"); + set_for_test("en"); + assert_eq!(t("Allow"), "Allow"); + assert_eq!(tf("Open {name}", &[("name", "Vercel")]), "Open Vercel"); + } + + #[test] + fn plurals_follow_the_language() { + set_for_test("en"); + assert_eq!(tn("✓ Connected · {count} model", "✓ Connected · {count} models", 1, &[]), "✓ Connected · 1 model"); + assert_eq!(tn("✓ Connected · {count} model", "✓ Connected · {count} models", 3, &[]), "✓ Connected · 3 models"); + set_for_test("es"); + assert_eq!(tn("✓ Connected · {count} model", "✓ Connected · {count} models", 1, &[]), "✓ Conectado · 1 modelo"); + assert_eq!(tn("✓ Connected · {count} model", "✓ Connected · {count} models", 4, &[]), "✓ Conectado · 4 modelos"); + assert_eq!(plural_category("ru", 21), "one"); + assert_eq!(plural_category("ru", 23), "few"); + assert_eq!(plural_category("ru", 25), "many"); + assert_eq!(plural_category("ar", 2), "two"); + set_for_test("en"); + } + + #[test] + fn every_extra_string_has_every_language() { + let Value::Object(root) = serde_json::from_str::(EXTRA).unwrap() else { panic!() }; + let Some(Value::Object(strings)) = root.get("strings") else { panic!("no strings") }; + for (key, entry) in strings { + for lang in LANGUAGES.iter().filter(|l| **l != "en") { + assert!(entry.get(*lang).is_some(), "{key:?} has no {lang}"); + } + } + } +} diff --git a/windows/src/i18n/extra.json b/windows/src/i18n/extra.json new file mode 100644 index 000000000..64803e52b --- /dev/null +++ b/windows/src/i18n/extra.json @@ -0,0 +1,3299 @@ +{ + "_about": "Strings only Windows and Linux show, keyed by their English text like the Mac's catalog, with every language but English. Written by hand; the Mac's strings are in strings.json (generated by scripts/gen-strings.mjs). See src/i18n/i18n.ts.", + "strings": { + " · until {date}": { + "zh-Hans": " · 截至 {date}", + "hi": " · {date} तक", + "es": " · hasta el {date}", + "ar": " · حتى {date}", + "fr": " · jusqu'au {date}", + "bn": " · {date} পর্যন্ত", + "pt-BR": " · até {date}", + "ru": " · до {date}", + "id": " · hingga {date}" + }, + "(stored)": { + "zh-Hans": "(已保存)", + "hi": "(सहेजी गई)", + "es": "(guardada)", + "ar": "(محفوظ)", + "fr": "(enregistrée)", + "bn": "(সংরক্ষিত)", + "pt-BR": "(salva)", + "ru": "(сохранён)", + "id": "(tersimpan)" + }, + "+ subagent": { + "zh-Hans": "+ 子代理", + "hi": "+ सब-एजेंट", + "es": "+ subagente", + "ar": "+ وكيل فرعي", + "fr": "+ sous-agent", + "bn": "+ সাব-এজেন্ট", + "pt-BR": "+ subagente", + "ru": "+ субагент", + "id": "+ subagen" + }, + "A hook group named \"coucou\" that Coucou did not write is already there — Coucou has not touched it.": { + "zh-Hans": "已存在一个并非 Coucou 写入的名为“coucou”的 hook 组 — Coucou 没有修改它。", + "hi": "“coucou” नाम का एक hook समूह पहले से है जिसे Coucou ने नहीं लिखा — Coucou ने इसे नहीं छुआ।", + "es": "Ya existe un grupo de hooks llamado «coucou» que no escribió Coucou — Coucou no lo ha tocado.", + "ar": "توجد بالفعل مجموعة hooks باسم «coucou» لم يكتبها Coucou — لم يمسّها Coucou.", + "fr": "Un groupe de hooks nommé « coucou » que Coucou n'a pas écrit existe déjà — Coucou n'y a pas touché.", + "bn": "“coucou” নামে একটি hook গ্রুপ আগে থেকেই আছে যা Coucou লেখেনি — Coucou এতে হাত দেয়নি।", + "pt-BR": "Já existe um grupo de hooks chamado “coucou” que não foi escrito pelo Coucou — o Coucou não mexeu nele.", + "ru": "Уже есть группа хуков «coucou», которую написал не Coucou, — Coucou её не трогал.", + "id": "Sudah ada grup hook bernama “coucou” yang tidak ditulis Coucou — Coucou tidak menyentuhnya." + }, + "AI for the chat": { + "zh-Hans": "聊天用的 AI", + "hi": "चैट के लिए AI", + "es": "IA para el chat", + "ar": "الذكاء الاصطناعي للدردشة", + "fr": "IA pour le chat", + "bn": "চ্যাটের জন্য AI", + "pt-BR": "IA para o chat", + "ru": "ИИ для чата", + "id": "AI untuk obrolan" + }, + "API error {code}": { + "zh-Hans": "API 错误 {code}", + "hi": "API त्रुटि {code}", + "es": "Error de API {code}", + "ar": "خطأ في الواجهة البرمجية {code}", + "fr": "Erreur d'API {code}", + "bn": "API ত্রুটি {code}", + "pt-BR": "Erro de API {code}", + "ru": "Ошибка API {code}", + "id": "Galat API {code}" + }, + "API key": { + "zh-Hans": "API 密钥", + "hi": "API कुंजी", + "es": "Clave de API", + "ar": "مفتاح API", + "fr": "Clé d'API", + "bn": "API কী", + "pt-BR": "Chave de API", + "ru": "API-ключ", + "id": "Kunci API" + }, + "API key (optional)": { + "zh-Hans": "API 密钥(可选)", + "hi": "API कुंजी (वैकल्पिक)", + "es": "Clave de API (opcional)", + "ar": "مفتاح API (اختياري)", + "fr": "Clé d'API (facultative)", + "bn": "API কী (ঐচ্ছিক)", + "pt-BR": "Chave de API (opcional)", + "ru": "API-ключ (необязательно)", + "id": "Kunci API (opsional)" + }, + "API key missing. Open settings.": { + "zh-Hans": "缺少 API 密钥。请打开设置。", + "hi": "API कुंजी नहीं है। सेटिंग्स खोलें।", + "es": "Falta la clave de API. Abre la configuración.", + "ar": "مفتاح API مفقود. افتح الإعدادات.", + "fr": "Clé d'API manquante. Ouvre les réglages.", + "bn": "API কী নেই। সেটিংস খুলুন।", + "pt-BR": "Falta a chave de API. Abra as configurações.", + "ru": "Нет API-ключа. Откройте настройки.", + "id": "Kunci API tidak ada. Buka pengaturan." + }, + "Answer in terminal": { + "zh-Hans": "在终端中回答", + "hi": "टर्मिनल में जवाब दें", + "es": "Responder en la terminal", + "ar": "الرد في الطرفية", + "fr": "Répondre dans le terminal", + "bn": "টার্মিনালে উত্তর দিন", + "pt-BR": "Responder no terminal", + "ru": "Ответить в терминале", + "id": "Jawab di terminal" + }, + "Answer it in your terminal.": { + "zh-Hans": "请在终端中回答。", + "hi": "इसका जवाब अपने टर्मिनल में दें।", + "es": "Respóndela en tu terminal.", + "ar": "أجب عنه في الطرفية.", + "fr": "Réponds-y dans ton terminal.", + "bn": "আপনার টার্মিনালে উত্তর দিন।", + "pt-BR": "Responda no seu terminal.", + "ru": "Ответьте в терминале.", + "id": "Jawab di terminal Anda." + }, + "Ask": { + "zh-Hans": "提问", + "hi": "पूछें", + "es": "Preguntar", + "ar": "اسأل", + "fr": "Demander", + "bn": "জিজ্ঞেস করুন", + "pt-BR": "Perguntar", + "ru": "Спросить", + "id": "Tanya" + }, + "Asking Codex…": { + "zh-Hans": "正在询问 Codex…", + "hi": "Codex से पूछ रहे हैं…", + "es": "Consultando a Codex…", + "ar": "جارٍ سؤال Codex…", + "fr": "Interrogation de Codex…", + "bn": "Codex-কে জিজ্ঞেস করা হচ্ছে…", + "pt-BR": "Consultando o Codex…", + "ru": "Запрос к Codex…", + "id": "Menanyakan Codex…" + }, + "Attach the front window to the chat": { + "zh-Hans": "将前台窗口附加到聊天", + "hi": "सामने वाली विंडो को चैट से जोड़ें", + "es": "Adjuntar la ventana activa al chat", + "ar": "إرفاق النافذة الأمامية بالدردشة", + "fr": "Joindre la fenêtre au premier plan au chat", + "bn": "সামনের উইন্ডোটি চ্যাটে যুক্ত করুন", + "pt-BR": "Anexar a janela em primeiro plano ao chat", + "ru": "Прикрепить активное окно к чату", + "id": "Lampirkan jendela depan ke obrolan" + }, + "Auto (seasons)": { + "zh-Hans": "自动(随季节)", + "hi": "ऑटो (मौसम के अनुसार)", + "es": "Auto (estaciones)", + "ar": "تلقائي (حسب المواسم)", + "fr": "Auto (saisons)", + "bn": "অটো (ঋতু অনুযায়ী)", + "pt-BR": "Auto (estações)", + "ru": "Авто (по сезону)", + "id": "Otomatis (musim)" + }, + "Auto · follows the seasons (now: {outfit})": { + "zh-Hans": "自动 · 随季节变化(当前:{outfit})", + "hi": "ऑटो · मौसम के साथ बदलता है (अभी: {outfit})", + "es": "Auto · sigue las estaciones (ahora: {outfit})", + "ar": "تلقائي · يتبع المواسم (الآن: {outfit})", + "fr": "Auto · suit les saisons (en ce moment : {outfit})", + "bn": "অটো · ঋতু অনুযায়ী বদলায় (এখন: {outfit})", + "pt-BR": "Auto · acompanha as estações (agora: {outfit})", + "ru": "Авто · по сезону (сейчас: {outfit})", + "id": "Otomatis · mengikuti musim (sekarang: {outfit})" + }, + "Auto · {outfit}": { + "zh-Hans": "自动 · {outfit}", + "hi": "ऑटो · {outfit}", + "es": "Auto · {outfit}", + "ar": "تلقائي · {outfit}", + "fr": "Auto · {outfit}", + "bn": "অটো · {outfit}", + "pt-BR": "Auto · {outfit}", + "ru": "Авто · {outfit}", + "id": "Otomatis · {outfit}" + }, + "Auto-close": { + "zh-Hans": "自动关闭", + "hi": "अपने आप बंद", + "es": "Cierre automático", + "ar": "الإغلاق التلقائي", + "fr": "Fermeture auto", + "bn": "স্বয়ংক্রিয় বন্ধ", + "pt-BR": "Fechar sozinho", + "ru": "Автозакрытие", + "id": "Tutup otomatis" + }, + "Back": { + "zh-Hans": "返回", + "hi": "वापस", + "es": "Atrás", + "ar": "رجوع", + "fr": "Retour", + "bn": "ফিরে যান", + "pt-BR": "Voltar", + "ru": "Назад", + "id": "Kembali" + }, + "Back up and remove": { + "zh-Hans": "备份并移除", + "hi": "बैकअप लें और हटाएँ", + "es": "Respaldar y quitar", + "ar": "النسخ الاحتياطي ثم الإزالة", + "fr": "Sauvegarder et retirer", + "bn": "ব্যাকআপ নিয়ে সরান", + "pt-BR": "Fazer backup e remover", + "ru": "Сохранить копию и удалить", + "id": "Cadangkan dan hapus" + }, + "Back up and write": { + "zh-Hans": "备份并写入", + "hi": "बैकअप लें और लिखें", + "es": "Respaldar y escribir", + "ar": "النسخ الاحتياطي ثم الكتابة", + "fr": "Sauvegarder et écrire", + "bn": "ব্যাকআপ নিয়ে লিখুন", + "pt-BR": "Fazer backup e gravar", + "ru": "Сохранить копию и записать", + "id": "Cadangkan dan tulis" + }, + "Backup of {path} failed, nothing was written: {error}": { + "zh-Hans": "{path} 备份失败,未写入任何内容:{error}", + "hi": "{path} का बैकअप विफल रहा, कुछ नहीं लिखा गया: {error}", + "es": "Falló la copia de seguridad de {path}, no se escribió nada: {error}", + "ar": "فشل النسخ الاحتياطي لـ {path}، لم يُكتب أي شيء: {error}", + "fr": "La sauvegarde de {path} a échoué, rien n'a été écrit : {error}", + "bn": "{path}-এর ব্যাকআপ ব্যর্থ, কিছু লেখা হয়নি: {error}", + "pt-BR": "O backup de {path} falhou, nada foi gravado: {error}", + "ru": "Не удалось сохранить копию {path}, ничего не записано: {error}", + "id": "Pencadangan {path} gagal, tidak ada yang ditulis: {error}" + }, + "Backup → {path}": { + "zh-Hans": "备份 → {path}", + "hi": "बैकअप → {path}", + "es": "Copia de seguridad → {path}", + "ar": "النسخة الاحتياطية ← {path}", + "fr": "Sauvegarde → {path}", + "bn": "ব্যাকআপ → {path}", + "pt-BR": "Backup → {path}", + "ru": "Резервная копия → {path}", + "id": "Cadangan → {path}" + }, + "Bad API response: {error}": { + "zh-Hans": "API 响应无效:{error}", + "hi": "API का जवाब गलत है: {error}", + "es": "Respuesta de la API no válida: {error}", + "ar": "استجابة غير صالحة من الواجهة البرمجية: {error}", + "fr": "Réponse de l'API invalide : {error}", + "bn": "API-র উত্তর ঠিক নয়: {error}", + "pt-BR": "Resposta da API inválida: {error}", + "ru": "Некорректный ответ API: {error}", + "id": "Respons API tidak valid: {error}" + }, + "Beanie": { + "zh-Hans": "毛线帽", + "hi": "ऊनी टोपी", + "es": "Gorro de lana", + "ar": "قبعة صوفية", + "fr": "Bonnet", + "bn": "উলের টুপি", + "pt-BR": "Gorro", + "ru": "Шапка", + "id": "Kupluk" + }, + "Bow": { + "zh-Hans": "蝴蝶结", + "hi": "बो", + "es": "Lazo", + "ar": "فيونكة", + "fr": "Nœud", + "bn": "বো", + "pt-BR": "Laço", + "ru": "Бантик", + "id": "Pita" + }, + "Bunny ears": { + "zh-Hans": "兔耳朵", + "hi": "खरगोश के कान", + "es": "Orejas de conejo", + "ar": "أذنا أرنب", + "fr": "Oreilles de lapin", + "bn": "খরগোশের কান", + "pt-BR": "Orelhas de coelho", + "ru": "Заячьи ушки", + "id": "Telinga kelinci" + }, + "Can't read {path}: {error}": { + "zh-Hans": "无法读取 {path}:{error}", + "hi": "{path} पढ़ा नहीं जा सकता: {error}", + "es": "No se puede leer {path}: {error}", + "ar": "تعذّرت قراءة {path}: {error}", + "fr": "Impossible de lire {path} : {error}", + "bn": "{path} পড়া যাচ্ছে না: {error}", + "pt-BR": "Não é possível ler {path}: {error}", + "ru": "Не удаётся прочитать {path}: {error}", + "id": "Tidak dapat membaca {path}: {error}" + }, + "Canceled": { + "zh-Hans": "已取消", + "hi": "रद्द", + "es": "Cancelado", + "ar": "أُلغي", + "fr": "Annulé", + "bn": "বাতিল", + "pt-BR": "Cancelado", + "ru": "Отменено", + "id": "Dibatalkan" + }, + "Cannot copy: {error}": { + "zh-Hans": "无法复制:{error}", + "hi": "कॉपी नहीं हो सका: {error}", + "es": "No se puede copiar: {error}", + "ar": "تعذّر النسخ: {error}", + "fr": "Copie impossible : {error}", + "bn": "কপি করা যায়নি: {error}", + "pt-BR": "Não é possível copiar: {error}", + "ru": "Не удаётся скопировать: {error}", + "id": "Tidak dapat menyalin: {error}" + }, + "Cannot reach {url}. Is the server running?": { + "zh-Hans": "无法连接到 {url}。服务器在运行吗?", + "hi": "{url} तक नहीं पहुँचा जा सका। क्या सर्वर चल रहा है?", + "es": "No se puede contactar con {url}. ¿Está funcionando el servidor?", + "ar": "تعذّر الوصول إلى {url}. هل الخادم قيد التشغيل؟", + "fr": "Impossible de joindre {url}. Le serveur est-il lancé ?", + "bn": "{url}-এ পৌঁছানো যাচ্ছে না। সার্ভার কি চলছে?", + "pt-BR": "Não foi possível alcançar {url}. O servidor está rodando?", + "ru": "Не удаётся подключиться к {url}. Сервер запущен?", + "id": "Tidak dapat menjangkau {url}. Apakah servernya berjalan?" + }, + "Cannot read {path}: {error}": { + "zh-Hans": "无法读取 {path}:{error}", + "hi": "{path} पढ़ा नहीं जा सकता: {error}", + "es": "No se puede leer {path}: {error}", + "ar": "تعذّرت قراءة {path}: {error}", + "fr": "Impossible de lire {path} : {error}", + "bn": "{path} পড়া যাচ্ছে না: {error}", + "pt-BR": "Não é possível ler {path}: {error}", + "ru": "Не удаётся прочитать {path}: {error}", + "id": "Tidak dapat membaca {path}: {error}" + }, + "Chat providers": { + "zh-Hans": "聊天服务商", + "hi": "चैट प्रदाता", + "es": "Proveedores de chat", + "ar": "مزوّدو الدردشة", + "fr": "Fournisseurs de chat", + "bn": "চ্যাট প্রদানকারী", + "pt-BR": "Provedores de chat", + "ru": "Провайдеры чата", + "id": "Penyedia obrolan" + }, + "Chat with Google AI, OpenAI or OpenRouter instead of Claude: add a key here, then click the model name above the chat box to switch provider and model. Keys stay in the system keychain. These providers get no web search and no tools: they can answer, never act on this computer.": { + "zh-Hans": "用 Google AI、OpenAI 或 OpenRouter 代替 Claude 聊天:在这里添加密钥,然后点击聊天框上方的模型名称来切换服务商和模型。密钥保存在系统钥匙串中。这些服务商没有网页搜索和工具:它们只能回答,绝不会在这台电脑上执行操作。", + "hi": "Claude की जगह Google AI, OpenAI या OpenRouter से चैट करें: यहाँ एक कुंजी जोड़ें, फिर प्रदाता और मॉडल बदलने के लिए चैट बॉक्स के ऊपर मॉडल के नाम पर क्लिक करें। कुंजियाँ सिस्टम कीचेन में रहती हैं। इन प्रदाताओं को न वेब सर्च मिलता है न टूल: वे जवाब दे सकते हैं, इस कंप्यूटर पर कभी कुछ नहीं करते।", + "es": "Chatea con Google AI, OpenAI u OpenRouter en lugar de Claude: añade una clave aquí y luego haz clic en el nombre del modelo encima del cuadro de chat para cambiar de proveedor y modelo. Las claves se quedan en el llavero del sistema. Estos proveedores no tienen búsqueda web ni herramientas: pueden responder, nunca actuar en este equipo.", + "ar": "دردش مع Google AI أو OpenAI أو OpenRouter بدلًا من Claude: أضف مفتاحًا هنا، ثم انقر على اسم النموذج فوق مربع الدردشة لتبديل المزوّد والنموذج. تبقى المفاتيح في سلسلة مفاتيح النظام. لا يحصل هؤلاء المزوّدون على بحث الويب ولا على الأدوات: يمكنهم الإجابة، ولا يتصرفون أبدًا على هذا الحاسوب.", + "fr": "Discute avec Google AI, OpenAI ou OpenRouter à la place de Claude : ajoute une clé ici, puis clique sur le nom du modèle au-dessus de la zone de chat pour changer de fournisseur et de modèle. Les clés restent dans le trousseau du système. Ces fournisseurs n'ont ni recherche web ni outils : ils peuvent répondre, jamais agir sur cet ordinateur.", + "bn": "Claude-এর বদলে Google AI, OpenAI বা OpenRouter-এর সঙ্গে চ্যাট করুন: এখানে একটি কী যোগ করুন, তারপর প্রদানকারী ও মডেল বদলাতে চ্যাট বক্সের উপরে মডেলের নামে ক্লিক করুন। কী সিস্টেম কীচেইনে থাকে। এই প্রদানকারীরা ওয়েব সার্চ বা টুল পায় না: তারা উত্তর দিতে পারে, এই কম্পিউটারে কখনো কিছু করে না।", + "pt-BR": "Converse com Google AI, OpenAI ou OpenRouter em vez do Claude: adicione uma chave aqui e clique no nome do modelo acima da caixa de chat para trocar de provedor e de modelo. As chaves ficam no chaveiro do sistema. Esses provedores não têm busca na web nem ferramentas: podem responder, nunca agir neste computador.", + "ru": "Общайтесь с Google AI, OpenAI или OpenRouter вместо Claude: добавьте ключ здесь, затем нажмите на название модели над полем чата, чтобы сменить провайдера и модель. Ключи хранятся в системной связке ключей. У этих провайдеров нет веб-поиска и инструментов: они могут отвечать, но никогда не действуют на этом компьютере.", + "id": "Mengobrol dengan Google AI, OpenAI, atau OpenRouter sebagai ganti Claude: tambahkan kunci di sini, lalu klik nama model di atas kotak obrolan untuk mengganti penyedia dan model. Kunci tetap di keychain sistem. Penyedia ini tidak punya pencarian web maupun alat: mereka bisa menjawab, tidak pernah bertindak di komputer ini." + }, + "Chat with a model you run yourself: Ollama or LM Studio (leave the address empty for the usual one on this computer), or any server that speaks the OpenAI API, such as vLLM or llama.cpp. Once connected, pick it above the chat box.": { + "zh-Hans": "与你自己运行的模型聊天:Ollama 或 LM Studio(地址留空即使用本机的默认地址),或任何兼容 OpenAI API 的服务器,例如 vLLM 或 llama.cpp。连接后,在聊天框上方选择它。", + "hi": "अपने चलाए मॉडल से चैट करें: Ollama या LM Studio (इस कंप्यूटर के सामान्य पते के लिए पता खाली छोड़ें), या OpenAI API बोलने वाला कोई भी सर्वर, जैसे vLLM या llama.cpp। कनेक्ट होने के बाद, उसे चैट बॉक्स के ऊपर चुनें।", + "es": "Chatea con un modelo que ejecutas tú mismo: Ollama o LM Studio (deja la dirección vacía para usar la habitual en este equipo), o cualquier servidor compatible con la API de OpenAI, como vLLM o llama.cpp. Una vez conectado, elígelo encima del cuadro de chat.", + "ar": "دردش مع نموذج تشغّله بنفسك: Ollama أو LM Studio (اترك العنوان فارغًا لاستخدام العنوان المعتاد على هذا الحاسوب)، أو أي خادم يتحدث واجهة OpenAI API مثل vLLM أو llama.cpp. بعد الاتصال، اختره فوق مربع الدردشة.", + "fr": "Discute avec un modèle que tu fais tourner toi-même : Ollama ou LM Studio (laisse l'adresse vide pour celle habituelle sur cet ordinateur), ou tout serveur qui parle l'API d'OpenAI, comme vLLM ou llama.cpp. Une fois connecté, choisis-le au-dessus de la zone de chat.", + "bn": "নিজে চালানো মডেলের সঙ্গে চ্যাট করুন: Ollama বা LM Studio (এই কম্পিউটারের সাধারণ ঠিকানার জন্য ঠিকানা ফাঁকা রাখুন), অথবা OpenAI API বোঝে এমন যেকোনো সার্ভার, যেমন vLLM বা llama.cpp। সংযুক্ত হলে চ্যাট বক্সের উপরে সেটি বেছে নিন।", + "pt-BR": "Converse com um modelo que você mesmo executa: Ollama ou LM Studio (deixe o endereço vazio para usar o padrão neste computador), ou qualquer servidor compatível com a API da OpenAI, como vLLM ou llama.cpp. Depois de conectado, escolha-o acima da caixa de chat.", + "ru": "Общайтесь с моделью, которую запускаете сами: Ollama или LM Studio (оставьте адрес пустым, чтобы использовать обычный адрес на этом компьютере) или любой сервер с API OpenAI, например vLLM или llama.cpp. После подключения выберите его над полем чата.", + "id": "Mengobrol dengan model yang Anda jalankan sendiri: Ollama atau LM Studio (kosongkan alamat untuk alamat biasa di komputer ini), atau server apa pun yang mendukung API OpenAI, seperti vLLM atau llama.cpp. Setelah terhubung, pilih di atas kotak obrolan." + }, + "Choose a model": { + "zh-Hans": "选择模型", + "hi": "मॉडल चुनें", + "es": "Elige un modelo", + "ar": "اختر نموذجًا", + "fr": "Choisis un modèle", + "bn": "একটি মডেল বেছে নিন", + "pt-BR": "Escolha um modelo", + "ru": "Выберите модель", + "id": "Pilih model" + }, + "Classic token with the repo scope, or fine-grained with read access to Pull requests, Commit statuses and Actions.": { + "zh-Hans": "带 repo 权限范围的经典令牌,或对 Pull requests、Commit statuses 和 Actions 具有读取权限的细粒度令牌。", + "hi": "repo स्कोप वाला क्लासिक टोकन, या Pull requests, Commit statuses और Actions पर पढ़ने की अनुमति वाला fine-grained टोकन।", + "es": "Token clásico con el alcance repo, o detallado (fine-grained) con acceso de lectura a Pull requests, Commit statuses y Actions.", + "ar": "رمز كلاسيكي بنطاق repo، أو رمز دقيق الصلاحيات مع إذن قراءة لـ Pull requests وCommit statuses وActions.", + "fr": "Jeton classique avec la portée repo, ou à granularité fine avec un accès en lecture aux Pull requests, Commit statuses et Actions.", + "bn": "repo স্কোপসহ ক্লাসিক টোকেন, অথবা Pull requests, Commit statuses ও Actions-এ পড়ার অনুমতিসহ fine-grained টোকেন।", + "pt-BR": "Token clássico com o escopo repo, ou refinado (fine-grained) com acesso de leitura a Pull requests, Commit statuses e Actions.", + "ru": "Классический токен с областью repo или детализированный (fine-grained) с доступом на чтение к Pull requests, Commit statuses и Actions.", + "id": "Token klasik dengan cakupan repo, atau fine-grained dengan akses baca ke Pull requests, Commit statuses, dan Actions." + }, + "Claude declined this one.": { + "zh-Hans": "Claude 拒绝了这个请求。", + "hi": "Claude ने इसे मना कर दिया।", + "es": "Claude ha rechazado esta petición.", + "ar": "رفض Claude هذا الطلب.", + "fr": "Claude a décliné cette demande.", + "bn": "Claude এটি প্রত্যাখ্যান করেছে।", + "pt-BR": "O Claude recusou este pedido.", + "ru": "Claude отклонил этот запрос.", + "id": "Claude menolak permintaan ini." + }, + "Claude needs an answer.": { + "zh-Hans": "Claude 需要一个回答。", + "hi": "Claude को जवाब चाहिए।", + "es": "Claude necesita una respuesta.", + "ar": "يحتاج Claude إلى إجابة.", + "fr": "Claude attend une réponse.", + "bn": "Claude-এর একটি উত্তর দরকার।", + "pt-BR": "O Claude precisa de uma resposta.", + "ru": "Claude ждёт ответа.", + "id": "Claude membutuhkan jawaban." + }, + "Claude plan usage": { + "zh-Hans": "Claude 套餐用量", + "hi": "Claude प्लान का उपयोग", + "es": "Uso del plan de Claude", + "ar": "استخدام خطة Claude", + "fr": "Utilisation du forfait Claude", + "bn": "Claude প্ল্যানের ব্যবহার", + "pt-BR": "Uso do plano Claude", + "ru": "Использование тарифа Claude", + "id": "Pemakaian paket Claude" + }, + "Close the island": { + "zh-Hans": "关闭岛", + "hi": "आइलैंड बंद करें", + "es": "Cerrar la isla", + "ar": "إغلاق الشريط", + "fr": "Fermer l'île", + "bn": "আইল্যান্ড বন্ধ করুন", + "pt-BR": "Fechar a ilha", + "ru": "Закрыть остров", + "id": "Tutup pulau" + }, + "Code": { + "zh-Hans": "代码", + "hi": "कोड", + "es": "Código", + "ar": "شيفرة", + "fr": "Code", + "bn": "কোড", + "pt-BR": "Código", + "ru": "Код", + "id": "Kode" + }, + "Codex plan": { + "zh-Hans": "Codex 套餐", + "hi": "Codex प्लान", + "es": "Plan de Codex", + "ar": "خطة Codex", + "fr": "Forfait Codex", + "bn": "Codex প্ল্যান", + "pt-BR": "Plano Codex", + "ru": "Тариф Codex", + "id": "Paket Codex" + }, + "Codex plan usage": { + "zh-Hans": "Codex 套餐用量", + "hi": "Codex प्लान का उपयोग", + "es": "Uso del plan de Codex", + "ar": "استخدام خطة Codex", + "fr": "Utilisation du forfait Codex", + "bn": "Codex প্ল্যানের ব্যবহার", + "pt-BR": "Uso do plano Codex", + "ru": "Использование тарифа Codex", + "id": "Pemakaian paket Codex" + }, + "Codex runs new hooks only once you trust them: start Codex and review them once with /hooks.": { + "zh-Hans": "Codex 只会运行你信任的新 hooks:启动 Codex,并用 /hooks 检查一次。", + "hi": "Codex नए hooks तभी चलाता है जब आप उन पर भरोसा करें: Codex शुरू करें और /hooks से एक बार उनकी समीक्षा करें।", + "es": "Codex solo ejecuta hooks nuevos cuando confías en ellos: abre Codex y revísalos una vez con /hooks.", + "ar": "لا يشغّل Codex الـ hooks الجديدة إلا بعد أن تثق بها: شغّل Codex وراجعها مرة واحدة بالأمر /hooks.", + "fr": "Codex n'exécute les nouveaux hooks qu'une fois que tu leur fais confiance : lance Codex et passe-les en revue une fois avec /hooks.", + "bn": "Codex নতুন hooks তখনই চালায় যখন আপনি সেগুলোকে বিশ্বাস করেন: Codex চালু করে /hooks দিয়ে একবার পর্যালোচনা করুন।", + "pt-BR": "O Codex só executa hooks novos depois que você confia neles: abra o Codex e revise-os uma vez com /hooks.", + "ru": "Codex запускает новые хуки только после того, как вы им доверитесь: откройте Codex и один раз проверьте их через /hooks.", + "id": "Codex hanya menjalankan hooks baru setelah Anda memercayainya: buka Codex dan tinjau sekali dengan /hooks." + }, + "Connect {name} in Settings → Local models first.": { + "zh-Hans": "请先在“设置 → 本地模型”中连接 {name}。", + "hi": "पहले सेटिंग्स → लोकल मॉडल में {name} कनेक्ट करें।", + "es": "Conecta primero {name} en Configuración → Modelos locales.", + "ar": "اربط {name} أولًا من الإعدادات ← النماذج المحلية.", + "fr": "Connecte d'abord {name} dans Réglages → Modèles locaux.", + "bn": "আগে সেটিংস → লোকাল মডেল-এ {name} সংযুক্ত করুন।", + "pt-BR": "Conecte primeiro o {name} em Configurações → Modelos locais.", + "ru": "Сначала подключите {name} в «Настройки → Локальные модели».", + "id": "Hubungkan {name} dulu di Pengaturan → Model lokal." + }, + "Copied": { + "zh-Hans": "已复制", + "hi": "कॉपी हो गया", + "es": "Copiado", + "ar": "تم النسخ", + "fr": "Copié", + "bn": "কপি হয়েছে", + "pt-BR": "Copiado", + "ru": "Скопировано", + "id": "Disalin" + }, + "Copied — paste it anywhere.": { + "zh-Hans": "已复制 — 可以粘贴到任何地方。", + "hi": "कॉपी हो गया — कहीं भी पेस्ट करें।", + "es": "Copiada — pégala donde quieras.", + "ar": "تم النسخ — الصقها في أي مكان.", + "fr": "Copiée — colle-la où tu veux.", + "bn": "কপি হয়েছে — যেকোনো জায়গায় পেস্ট করুন।", + "pt-BR": "Copiada — cole onde quiser.", + "ru": "Скопировано — вставьте куда угодно.", + "id": "Disalin — tempel di mana saja." + }, + "Copying images isn't available here — use Save image.": { + "zh-Hans": "此处无法复制图片 — 请使用“保存图片”。", + "hi": "यहाँ छवियाँ कॉपी नहीं हो सकतीं — “छवि सहेजें” का उपयोग करें।", + "es": "Aquí no se pueden copiar imágenes — usa Guardar imagen.", + "ar": "نسخ الصور غير متاح هنا — استخدم «حفظ الصورة».", + "fr": "Copier des images n'est pas possible ici — utilise Enregistrer l'image.", + "bn": "এখানে ছবি কপি করা যায় না — “ছবি সংরক্ষণ করুন” ব্যবহার করুন।", + "pt-BR": "Não é possível copiar imagens aqui — use Salvar imagem.", + "ru": "Копировать изображения здесь нельзя — используйте «Сохранить изображение».", + "id": "Menyalin gambar tidak tersedia di sini — gunakan Simpan gambar." + }, + "Coucou could not list the agents.": { + "zh-Hans": "Coucou 无法列出代理。", + "hi": "Coucou एजेंटों की सूची नहीं बना सका।", + "es": "Coucou no pudo listar los agentes.", + "ar": "تعذّر على Coucou سرد الوكلاء.", + "fr": "Coucou n'a pas pu lister les agents.", + "bn": "Coucou এজেন্টদের তালিকা করতে পারেনি।", + "pt-BR": "O Coucou não conseguiu listar os agentes.", + "ru": "Coucou не удалось получить список агентов.", + "id": "Coucou tidak dapat menampilkan daftar agen." + }, + "Coucou counts your agent sessions as they happen — check back next Monday.": { + "zh-Hans": "Coucou 会实时统计你的代理会话 — 下周一再来看看吧。", + "hi": "Coucou आपके एजेंट सत्रों को होते-होते गिनता है — अगले सोमवार फिर देखें।", + "es": "Coucou cuenta tus sesiones de agentes a medida que ocurren — vuelve el próximo lunes.", + "ar": "يحسب Coucou جلسات وكلائك أثناء حدوثها — عُد يوم الاثنين القادم.", + "fr": "Coucou compte tes sessions d'agents au fil de l'eau — reviens lundi prochain.", + "bn": "Coucou আপনার এজেন্ট সেশন ঘটার সঙ্গে সঙ্গে গোনে — পরের সোমবার আবার দেখুন।", + "pt-BR": "O Coucou conta suas sessões de agentes conforme acontecem — volte na próxima segunda-feira.", + "ru": "Coucou считает ваши сессии агентов по ходу дела — загляните в следующий понедельник.", + "id": "Coucou menghitung sesi agen Anda saat berlangsung — cek lagi Senin depan." + }, + "Coucou is hooked into your Claude Code sessions. Tool calls, questions and permission requests show up in the island, and you can answer them there.": { + "zh-Hans": "Coucou 已接入你的 Claude Code 会话。工具调用、问题和权限请求都会显示在岛中,你可以直接在那里回应。", + "hi": "Coucou आपके Claude Code सत्रों से जुड़ा है। टूल कॉल, प्रश्न और अनुमति अनुरोध आइलैंड में दिखते हैं, और आप वहीं उनका जवाब दे सकते हैं।", + "es": "Coucou está conectado a tus sesiones de Claude Code. Las llamadas a herramientas, las preguntas y las solicitudes de permiso aparecen en la isla y puedes responderlas ahí.", + "ar": "Coucou متصل بجلسات Claude Code لديك. تظهر استدعاءات الأدوات والأسئلة وطلبات الإذن في الشريط، ويمكنك الرد عليها من هناك.", + "fr": "Coucou est branché sur tes sessions Claude Code. Les appels d'outils, les questions et les demandes de permission s'affichent dans l'île, et tu peux y répondre directement.", + "bn": "Coucou আপনার Claude Code সেশনের সঙ্গে যুক্ত। টুল কল, প্রশ্ন আর অনুমতির অনুরোধ আইল্যান্ডে দেখা যায়, এবং আপনি সেখানেই উত্তর দিতে পারেন।", + "pt-BR": "O Coucou está conectado às suas sessões do Claude Code. Chamadas de ferramentas, perguntas e pedidos de permissão aparecem na ilha, e você pode respondê-los ali.", + "ru": "Coucou подключён к вашим сессиям Claude Code. Вызовы инструментов, вопросы и запросы разрешений появляются в острове, и отвечать можно прямо там.", + "id": "Coucou terhubung ke sesi Claude Code Anda. Panggilan alat, pertanyaan, dan permintaan izin muncul di pulau, dan Anda bisa menjawabnya di sana." + }, + "Could not remove {path}: {error}": { + "zh-Hans": "无法删除 {path}:{error}", + "hi": "{path} हटाया नहीं जा सका: {error}", + "es": "No se pudo eliminar {path}: {error}", + "ar": "تعذّر حذف {path}: {error}", + "fr": "Impossible de supprimer {path} : {error}", + "bn": "{path} সরানো যায়নি: {error}", + "pt-BR": "Não foi possível remover {path}: {error}", + "ru": "Не удалось удалить {path}: {error}", + "id": "Tidak dapat menghapus {path}: {error}" + }, + "Could not remove: {error}": { + "zh-Hans": "无法移除:{error}", + "hi": "हटाया नहीं जा सका: {error}", + "es": "No se pudo quitar: {error}", + "ar": "تعذّرت الإزالة: {error}", + "fr": "Suppression impossible : {error}", + "bn": "সরানো যায়নি: {error}", + "pt-BR": "Não foi possível remover: {error}", + "ru": "Не удалось удалить: {error}", + "id": "Tidak dapat menghapus: {error}" + }, + "Could not save the image: {error}": { + "zh-Hans": "无法保存图片:{error}", + "hi": "छवि सहेजी नहीं जा सकी: {error}", + "es": "No se pudo guardar la imagen: {error}", + "ar": "تعذّر حفظ الصورة: {error}", + "fr": "Impossible d'enregistrer l'image : {error}", + "bn": "ছবিটি সংরক্ষণ করা যায়নি: {error}", + "pt-BR": "Não foi possível salvar a imagem: {error}", + "ru": "Не удалось сохранить изображение: {error}", + "id": "Tidak dapat menyimpan gambar: {error}" + }, + "Could not save your current status line next to the relay; nothing was changed.": { + "zh-Hans": "无法将你当前的状态栏保存到中继旁边;未做任何更改。", + "hi": "आपकी मौजूदा स्टेटस लाइन रिले के पास सहेजी नहीं जा सकी; कुछ नहीं बदला गया।", + "es": "No se pudo guardar tu línea de estado actual junto al relay; no se cambió nada.", + "ar": "تعذّر حفظ سطر الحالة الحالي بجانب المُرحِّل؛ لم يتغيّر أي شيء.", + "fr": "Impossible d'enregistrer ta ligne d'état actuelle à côté du relais ; rien n'a été modifié.", + "bn": "আপনার বর্তমান স্ট্যাটাস লাইন রিলের পাশে সংরক্ষণ করা যায়নি; কিছুই বদলানো হয়নি।", + "pt-BR": "Não foi possível salvar sua linha de status atual ao lado do relay; nada foi alterado.", + "ru": "Не удалось сохранить вашу текущую строку состояния рядом с ретранслятором; ничего не изменено.", + "id": "Tidak dapat menyimpan baris status Anda saat ini di samping relay; tidak ada yang diubah." + }, + "Could not save: {error}": { + "zh-Hans": "无法保存:{error}", + "hi": "सहेजा नहीं जा सका: {error}", + "es": "No se pudo guardar: {error}", + "ar": "تعذّر الحفظ: {error}", + "fr": "Enregistrement impossible : {error}", + "bn": "সংরক্ষণ করা যায়নি: {error}", + "pt-BR": "Não foi possível salvar: {error}", + "ru": "Не удалось сохранить: {error}", + "id": "Tidak dapat menyimpan: {error}" + }, + "Could not write: {error}": { + "zh-Hans": "无法写入:{error}", + "hi": "लिखा नहीं जा सका: {error}", + "es": "No se pudo escribir: {error}", + "ar": "تعذّرت الكتابة: {error}", + "fr": "Écriture impossible : {error}", + "bn": "লেখা যায়নি: {error}", + "pt-BR": "Não foi possível gravar: {error}", + "ru": "Не удалось записать: {error}", + "id": "Tidak dapat menulis: {error}" + }, + "Counts and project names only — never commands, files or prompts. Kept on this computer for 12 weeks.": { + "zh-Hans": "仅保存计数和项目名称 — 绝不保存命令、文件或提示词。在这台电脑上保留 12 周。", + "hi": "केवल गिनती और प्रोजेक्ट के नाम — कभी कमांड, फ़ाइलें या प्रॉम्प्ट नहीं। इस कंप्यूटर पर 12 सप्ताह तक रखा जाता है।", + "es": "Solo recuentos y nombres de proyecto — nunca comandos, archivos ni prompts. Se guarda en este equipo durante 12 semanas.", + "ar": "أعداد وأسماء مشاريع فقط — لا أوامر ولا ملفات ولا مطالبات أبدًا. يُحتفظ بها على هذا الحاسوب لمدة 12 أسبوعًا.", + "fr": "Uniquement des compteurs et des noms de projet — jamais de commandes, de fichiers ni de prompts. Conservé sur cet ordinateur pendant 12 semaines.", + "bn": "শুধু গণনা আর প্রজেক্টের নাম — কখনো কমান্ড, ফাইল বা প্রম্পট নয়। এই কম্পিউটারে ১২ সপ্তাহ রাখা হয়।", + "pt-BR": "Somente contagens e nomes de projeto — nunca comandos, arquivos ou prompts. Mantido neste computador por 12 semanas.", + "ru": "Только счётчики и названия проектов — никогда команды, файлы или промпты. Хранится на этом компьютере 12 недель.", + "id": "Hanya hitungan dan nama proyek — tidak pernah perintah, file, atau prompt. Disimpan di komputer ini selama 12 minggu." + }, + "Crown": { + "zh-Hans": "王冠", + "hi": "मुकुट", + "es": "Corona", + "ar": "تاج", + "fr": "Couronne", + "bn": "মুকুট", + "pt-BR": "Coroa", + "ru": "Корона", + "id": "Mahkota" + }, + "Custom server": { + "zh-Hans": "自定义服务器", + "hi": "कस्टम सर्वर", + "es": "Servidor personalizado", + "ar": "خادم مخصص", + "fr": "Serveur personnalisé", + "bn": "কাস্টম সার্ভার", + "pt-BR": "Servidor personalizado", + "ru": "Свой сервер", + "id": "Server kustom" + }, + "Default branch CI": { + "zh-Hans": "默认分支 CI", + "hi": "डिफ़ॉल्ट ब्रांच CI", + "es": "CI de la rama principal", + "ar": "CI للفرع الافتراضي", + "fr": "CI de la branche par défaut", + "bn": "ডিফল্ট ব্রাঞ্চ CI", + "pt-BR": "CI do branch padrão", + "ru": "CI основной ветки", + "id": "CI cabang utama" + }, + "Deployment": { + "zh-Hans": "部署", + "hi": "डिप्लॉयमेंट", + "es": "Despliegue", + "ar": "نشر", + "fr": "Déploiement", + "bn": "ডিপ্লয়মেন্ট", + "pt-BR": "Implantação", + "ru": "Развёртывание", + "id": "Deployment" + }, + "Details": { + "zh-Hans": "详情", + "hi": "विवरण", + "es": "Detalles", + "ar": "التفاصيل", + "fr": "Détails", + "bn": "বিস্তারিত", + "pt-BR": "Detalhes", + "ru": "Подробности", + "id": "Detail" + }, + "Display under the cursor": { + "zh-Hans": "光标所在的显示器", + "hi": "कर्सर वाला डिस्प्ले", + "es": "Pantalla bajo el cursor", + "ar": "الشاشة التي عليها المؤشر", + "fr": "Écran sous le curseur", + "bn": "কার্সর যে ডিসপ্লেতে আছে", + "pt-BR": "Tela sob o cursor", + "ru": "Дисплей под курсором", + "id": "Layar di bawah kursor" + }, + "Docs": { + "zh-Hans": "文档", + "hi": "दस्तावेज़", + "es": "Documentos", + "ar": "مستندات", + "fr": "Docs", + "bn": "ডকুমেন্ট", + "pt-BR": "Docs", + "ru": "Документы", + "id": "Dokumen" + }, + "Done": { + "zh-Hans": "完成", + "hi": "हो गया", + "es": "Listo", + "ar": "تم", + "fr": "Valider", + "bn": "হয়েছে", + "pt-BR": "Concluir", + "ru": "Готово", + "id": "Selesai" + }, + "Done.": { + "zh-Hans": "完成。", + "hi": "हो गया।", + "es": "Listo.", + "ar": "تم.", + "fr": "C'est fait.", + "bn": "হয়ে গেছে।", + "pt-BR": "Pronto.", + "ru": "Готово.", + "id": "Selesai." + }, + "Done. Open a new Claude Code session to pick the hooks up.": { + "zh-Hans": "完成。打开新的 Claude Code 会话即可启用 hooks。", + "hi": "हो गया। hooks लागू करने के लिए Claude Code का नया सत्र खोलें।", + "es": "Listo. Abre una nueva sesión de Claude Code para que cargue los hooks.", + "ar": "تم. افتح جلسة Claude Code جديدة لتفعيل الـ hooks.", + "fr": "C'est fait. Ouvre une nouvelle session Claude Code pour qu'elle prenne les hooks.", + "bn": "হয়ে গেছে। hooks চালু করতে Claude Code-এর নতুন সেশন খুলুন।", + "pt-BR": "Pronto. Abra uma nova sessão do Claude Code para carregar os hooks.", + "ru": "Готово. Откройте новую сессию Claude Code, чтобы хуки заработали.", + "id": "Selesai. Buka sesi Claude Code baru agar hooks dimuat." + }, + "Done. Previous settings saved as {backup}. Open a new Claude Code session to pick the hooks up.": { + "zh-Hans": "完成。旧设置已保存为 {backup}。打开新的 Claude Code 会话即可启用 hooks。", + "hi": "हो गया। पिछली सेटिंग्स {backup} के रूप में सहेजी गईं। hooks लागू करने के लिए Claude Code का नया सत्र खोलें।", + "es": "Listo. La configuración anterior se guardó como {backup}. Abre una nueva sesión de Claude Code para que cargue los hooks.", + "ar": "تم. حُفظت الإعدادات السابقة باسم {backup}. افتح جلسة Claude Code جديدة لتفعيل الـ hooks.", + "fr": "C'est fait. Les anciens réglages sont enregistrés sous {backup}. Ouvre une nouvelle session Claude Code pour qu'elle prenne les hooks.", + "bn": "হয়ে গেছে। আগের সেটিংস {backup} নামে সংরক্ষিত। hooks চালু করতে Claude Code-এর নতুন সেশন খুলুন।", + "pt-BR": "Pronto. As configurações anteriores foram salvas como {backup}. Abra uma nova sessão do Claude Code para carregar os hooks.", + "ru": "Готово. Предыдущие настройки сохранены как {backup}. Откройте новую сессию Claude Code, чтобы хуки заработали.", + "id": "Selesai. Pengaturan sebelumnya disimpan sebagai {backup}. Buka sesi Claude Code baru agar hooks dimuat." + }, + "Done. Previous settings saved as {backup}. The numbers appear after the next reply of a Claude Code session.": { + "zh-Hans": "完成。旧设置已保存为 {backup}。数字会在 Claude Code 会话的下一次回复后出现。", + "hi": "हो गया। पिछली सेटिंग्स {backup} के रूप में सहेजी गईं। संख्याएँ Claude Code सत्र के अगले जवाब के बाद दिखेंगी।", + "es": "Listo. La configuración anterior se guardó como {backup}. Las cifras aparecen tras la próxima respuesta de una sesión de Claude Code.", + "ar": "تم. حُفظت الإعدادات السابقة باسم {backup}. تظهر الأرقام بعد الرد التالي في جلسة Claude Code.", + "fr": "C'est fait. Les anciens réglages sont enregistrés sous {backup}. Les chiffres apparaissent après la prochaine réponse d'une session Claude Code.", + "bn": "হয়ে গেছে। আগের সেটিংস {backup} নামে সংরক্ষিত। Claude Code সেশনের পরের উত্তরের পর সংখ্যাগুলো দেখা যাবে।", + "pt-BR": "Pronto. As configurações anteriores foram salvas como {backup}. Os números aparecem após a próxima resposta de uma sessão do Claude Code.", + "ru": "Готово. Предыдущие настройки сохранены как {backup}. Цифры появятся после следующего ответа в сессии Claude Code.", + "id": "Selesai. Pengaturan sebelumnya disimpan sebagai {backup}. Angkanya muncul setelah balasan berikutnya dari sesi Claude Code." + }, + "Done. Previous version saved as {paths}.": { + "zh-Hans": "完成。旧版本已保存为 {paths}。", + "hi": "हो गया। पिछला संस्करण {paths} के रूप में सहेजा गया।", + "es": "Listo. La versión anterior se guardó como {paths}.", + "ar": "تم. حُفظت النسخة السابقة باسم {paths}.", + "fr": "C'est fait. L'ancienne version est enregistrée sous {paths}.", + "bn": "হয়ে গেছে। আগের সংস্করণ {paths} নামে সংরক্ষিত।", + "pt-BR": "Pronto. A versão anterior foi salva como {paths}.", + "ru": "Готово. Предыдущая версия сохранена как {paths}.", + "id": "Selesai. Versi sebelumnya disimpan sebagai {paths}." + }, + "Done. The numbers appear after the next reply of a Claude Code session.": { + "zh-Hans": "完成。数字会在 Claude Code 会话的下一次回复后出现。", + "hi": "हो गया। संख्याएँ Claude Code सत्र के अगले जवाब के बाद दिखेंगी।", + "es": "Listo. Las cifras aparecen tras la próxima respuesta de una sesión de Claude Code.", + "ar": "تم. تظهر الأرقام بعد الرد التالي في جلسة Claude Code.", + "fr": "C'est fait. Les chiffres apparaissent après la prochaine réponse d'une session Claude Code.", + "bn": "হয়ে গেছে। Claude Code সেশনের পরের উত্তরের পর সংখ্যাগুলো দেখা যাবে।", + "pt-BR": "Pronto. Os números aparecem após a próxima resposta de uma sessão do Claude Code.", + "ru": "Готово. Цифры появятся после следующего ответа в сессии Claude Code.", + "id": "Selesai. Angkanya muncul setelah balasan berikutnya dari sesi Claude Code." + }, + "Drop": { + "zh-Hans": "拖放", + "hi": "छोड़ें", + "es": "Soltar", + "ar": "إفلات", + "fr": "Déposer", + "bn": "ড্রপ", + "pt-BR": "Soltar", + "ru": "Перетащить", + "id": "Letakkan" + }, + "Enter the server address first.": { + "zh-Hans": "请先输入服务器地址。", + "hi": "पहले सर्वर का पता दर्ज करें।", + "es": "Escribe primero la dirección del servidor.", + "ar": "أدخل عنوان الخادم أولًا.", + "fr": "Saisis d'abord l'adresse du serveur.", + "bn": "আগে সার্ভারের ঠিকানা লিখুন।", + "pt-BR": "Digite primeiro o endereço do servidor.", + "ru": "Сначала введите адрес сервера.", + "id": "Masukkan alamat server terlebih dahulu." + }, + "Error": { + "zh-Hans": "错误", + "hi": "त्रुटि", + "es": "Error", + "ar": "خطأ", + "fr": "Erreur", + "bn": "ত্রুটি", + "pt-BR": "Erro", + "ru": "Ошибка", + "id": "Galat" + }, + "File": { + "zh-Hans": "文件", + "hi": "फ़ाइल", + "es": "Archivo", + "ar": "ملف", + "fr": "Fichier", + "bn": "ফাইল", + "pt-BR": "Arquivo", + "ru": "Файл", + "id": "File" + }, + "Folders can't be dropped yet.": { + "zh-Hans": "暂时还不能拖放文件夹。", + "hi": "फ़ोल्डर अभी नहीं छोड़े जा सकते।", + "es": "Todavía no se pueden soltar carpetas.", + "ar": "لا يمكن إفلات المجلدات بعد.", + "fr": "On ne peut pas encore déposer de dossiers.", + "bn": "এখনো ফোল্ডার ড্রপ করা যায় না।", + "pt-BR": "Ainda não é possível soltar pastas.", + "ru": "Папки пока перетаскивать нельзя.", + "id": "Folder belum bisa diletakkan." + }, + "Friday": { + "zh-Hans": "星期五", + "hi": "शुक्रवार", + "es": "Viernes", + "ar": "الجمعة", + "fr": "Vendredi", + "bn": "শুক্রবার", + "pt-BR": "Sexta-feira", + "ru": "Пятница", + "id": "Jumat" + }, + "From anywhere": { + "zh-Hans": "全局", + "hi": "कहीं से भी", + "es": "Desde cualquier lugar", + "ar": "من أي مكان", + "fr": "De partout", + "bn": "যেকোনো জায়গা থেকে", + "pt-BR": "De qualquer lugar", + "ru": "Отовсюду", + "id": "Dari mana saja" + }, + "Go to pill 1 to 9": { + "zh-Hans": "前往第 1 至 9 个标签", + "hi": "टैब 1 से 9 पर जाएँ", + "es": "Ir a la pestaña 1 a 9", + "ar": "الانتقال إلى التبويب من 1 إلى 9", + "fr": "Aller à l'onglet 1 à 9", + "bn": "ট্যাব ১ থেকে ৯-এ যান", + "pt-BR": "Ir para a aba 1 a 9", + "ru": "Перейти к вкладке 1–9", + "id": "Buka tab 1 sampai 9" + }, + "Go to the waiting permission or question": { + "zh-Hans": "前往待处理的权限请求或问题", + "hi": "प्रतीक्षारत अनुमति या प्रश्न पर जाएँ", + "es": "Ir al permiso o la pregunta pendiente", + "ar": "الانتقال إلى الإذن أو السؤال المنتظر", + "fr": "Aller à la permission ou la question en attente", + "bn": "অপেক্ষমাণ অনুমতি বা প্রশ্নে যান", + "pt-BR": "Ir para a permissão ou pergunta pendente", + "ru": "Перейти к ожидающему разрешению или вопросу", + "id": "Buka izin atau pertanyaan yang menunggu" + }, + "Hide project names": { + "zh-Hans": "隐藏项目名称", + "hi": "प्रोजेक्ट के नाम छिपाएँ", + "es": "Ocultar nombres de proyecto", + "ar": "إخفاء أسماء المشاريع", + "fr": "Masquer les noms de projet", + "bn": "প্রজেক্টের নাম লুকান", + "pt-BR": "Ocultar nomes de projeto", + "ru": "Скрыть названия проектов", + "id": "Sembunyikan nama proyek" + }, + "History cleared.": { + "zh-Hans": "历史记录已清除。", + "hi": "इतिहास साफ़ कर दिया गया।", + "es": "Historial borrado.", + "ar": "مُسح السجل.", + "fr": "Historique effacé.", + "bn": "ইতিহাস মুছে ফেলা হয়েছে।", + "pt-BR": "Histórico apagado.", + "ru": "История очищена.", + "id": "Riwayat dihapus." + }, + "Hold Ctrl, Alt or the Windows key with it.": { + "zh-Hans": "同时按住 Ctrl、Alt 或 Windows 键。", + "hi": "इसके साथ Ctrl, Alt या Windows कुंजी दबाए रखें।", + "es": "Mantén pulsada Ctrl, Alt o la tecla Windows a la vez.", + "ar": "اضغط معه باستمرار على Ctrl أو Alt أو مفتاح Windows.", + "fr": "Maintiens Ctrl, Alt ou la touche Windows en même temps.", + "bn": "সঙ্গে Ctrl, Alt বা Windows কী চেপে ধরুন।", + "pt-BR": "Segure Ctrl, Alt ou a tecla Windows junto.", + "ru": "Удерживайте вместе с ней Ctrl, Alt или клавишу Windows.", + "id": "Tahan Ctrl, Alt, atau tombol Windows bersamaan." + }, + "Images": { + "zh-Hans": "图片", + "hi": "छवियाँ", + "es": "Imágenes", + "ar": "صور", + "fr": "Images", + "bn": "ছবি", + "pt-BR": "Imagens", + "ru": "Изображения", + "id": "Gambar" + }, + "In the open island": { + "zh-Hans": "在打开的岛中", + "hi": "खुले आइलैंड में", + "es": "En la isla abierta", + "ar": "في الشريط المفتوح", + "fr": "Dans l'île ouverte", + "bn": "খোলা আইল্যান্ডে", + "pt-BR": "Na ilha aberta", + "ru": "В открытом острове", + "id": "Di pulau yang terbuka" + }, + "In use": { + "zh-Hans": "使用中", + "hi": "उपयोग में", + "es": "En uso", + "ar": "قيد الاستخدام", + "fr": "Utilisé", + "bn": "ব্যবহৃত হচ্ছে", + "pt-BR": "Em uso", + "ru": "Используется", + "id": "Sedang dipakai" + }, + "In use by another app": { + "zh-Hans": "已被其他应用占用", + "hi": "किसी दूसरे ऐप द्वारा उपयोग में", + "es": "En uso por otra app", + "ar": "مستخدم من تطبيق آخر", + "fr": "Utilisé par une autre app", + "bn": "অন্য অ্যাপ ব্যবহার করছে", + "pt-BR": "Em uso por outro app", + "ru": "Занято другим приложением", + "id": "Dipakai aplikasi lain" + }, + "Install hooks…": { + "zh-Hans": "安装 hooks…", + "hi": "hooks इंस्टॉल करें…", + "es": "Instalar hooks…", + "ar": "تثبيت الـ hooks…", + "fr": "Installer les hooks…", + "bn": "hooks ইনস্টল করুন…", + "pt-BR": "Instalar hooks…", + "ru": "Установить хуки…", + "id": "Pasang hooks…" + }, + "Install relay…": { + "zh-Hans": "安装中继…", + "hi": "रिले इंस्टॉल करें…", + "es": "Instalar relay…", + "ar": "تثبيت المُرحِّل…", + "fr": "Installer le relais…", + "bn": "রিলে ইনস্টল করুন…", + "pt-BR": "Instalar relay…", + "ru": "Установить ретранслятор…", + "id": "Pasang relay…" + }, + "Install the hooks to see your Claude Code sessions in the island and approve permissions without leaving what you are doing.": { + "zh-Hans": "安装 hooks,即可在岛中查看 Claude Code 会话,并在不打断手头工作的情况下批准权限。", + "hi": "आइलैंड में अपने Claude Code सत्र देखने और अपना काम छोड़े बिना अनुमतियाँ देने के लिए hooks इंस्टॉल करें।", + "es": "Instala los hooks para ver tus sesiones de Claude Code en la isla y aprobar permisos sin dejar lo que estás haciendo.", + "ar": "ثبّت الـ hooks لترى جلسات Claude Code في الشريط وتوافق على الأذونات دون أن تترك ما تفعله.", + "fr": "Installe les hooks pour voir tes sessions Claude Code dans l'île et valider les permissions sans quitter ce que tu fais.", + "bn": "আইল্যান্ডে আপনার Claude Code সেশন দেখতে এবং কাজ না থামিয়ে অনুমতি দিতে hooks ইনস্টল করুন।", + "pt-BR": "Instale os hooks para ver suas sessões do Claude Code na ilha e aprovar permissões sem largar o que está fazendo.", + "ru": "Установите хуки, чтобы видеть сессии Claude Code в острове и давать разрешения, не отрываясь от работы.", + "id": "Pasang hooks untuk melihat sesi Claude Code di pulau dan menyetujui izin tanpa meninggalkan pekerjaan Anda." + }, + "Install…": { + "zh-Hans": "安装…", + "hi": "इंस्टॉल करें…", + "es": "Instalar…", + "ar": "تثبيت…", + "fr": "Installer…", + "bn": "ইনস্টল করুন…", + "pt-BR": "Instalar…", + "ru": "Установить…", + "id": "Pasang…" + }, + "Instance URL": { + "zh-Hans": "实例地址", + "hi": "इंस्टेंस URL", + "es": "URL de la instancia", + "ar": "عنوان المثيل", + "fr": "URL de l'instance", + "bn": "ইনস্ট্যান্স URL", + "pt-BR": "URL da instância", + "ru": "URL экземпляра", + "id": "URL instans" + }, + "Integration": { + "zh-Hans": "集成", + "hi": "एकीकरण", + "es": "Integración", + "ar": "تكامل", + "fr": "Intégration", + "bn": "ইন্টিগ্রেশন", + "pt-BR": "Integração", + "ru": "Интеграция", + "id": "Integrasi" + }, + "Integration lacks access": { + "zh-Hans": "集成没有访问权限", + "hi": "इंटीग्रेशन के पास पहुँच नहीं है", + "es": "La integración no tiene acceso", + "ar": "التكامل لا يملك صلاحية الوصول", + "fr": "L'intégration n'a pas l'accès", + "bn": "ইন্টিগ্রেশনের অ্যাক্সেস নেই", + "pt-BR": "A integração não tem acesso", + "ru": "У интеграции нет доступа", + "id": "Integrasi tidak punya akses" + }, + "Integration token": { + "zh-Hans": "集成令牌", + "hi": "इंटीग्रेशन टोकन", + "es": "Token de integración", + "ar": "رمز التكامل", + "fr": "Jeton d'intégration", + "bn": "ইন্টিগ্রেশন টোকেন", + "pt-BR": "Token de integração", + "ru": "Токен интеграции", + "id": "Token integrasi" + }, + "Invalid API key (401)": { + "zh-Hans": "API 密钥无效(401)", + "hi": "अमान्य API कुंजी (401)", + "es": "Clave de API no válida (401)", + "ar": "مفتاح API غير صالح (401)", + "fr": "Clé d'API invalide (401)", + "bn": "অবৈধ API কী (401)", + "pt-BR": "Chave de API inválida (401)", + "ru": "Неверный API-ключ (401)", + "id": "Kunci API tidak valid (401)" + }, + "Island lives on": { + "zh-Hans": "岛所在位置", + "hi": "आइलैंड कहाँ रहे", + "es": "La isla vive en", + "ar": "مكان الشريط", + "fr": "L'île vit sur", + "bn": "আইল্যান্ড থাকবে", + "pt-BR": "A ilha fica na", + "ru": "Остров на", + "id": "Pulau berada di" + }, + "Keep the island open": { + "zh-Hans": "保持岛打开", + "hi": "आइलैंड खुला रखें", + "es": "Mantener la isla abierta", + "ar": "إبقاء الشريط مفتوحًا", + "fr": "Garder l'île ouverte", + "bn": "আইল্যান্ড খোলা রাখুন", + "pt-BR": "Manter a ilha aberta", + "ru": "Оставить остров открытым", + "id": "Biarkan pulau tetap terbuka" + }, + "Key configured": { + "zh-Hans": "密钥已配置", + "hi": "कुंजी सेट है", + "es": "Clave configurada", + "ar": "المفتاح مُعدّ", + "fr": "Clé configurée", + "bn": "কী সেট করা আছে", + "pt-BR": "Chave configurada", + "ru": "Ключ настроен", + "id": "Kunci sudah diatur" + }, + "Key from {site}": { + "zh-Hans": "密钥来自 {site}", + "hi": "कुंजी {site} से", + "es": "Clave de {site}", + "ar": "المفتاح من {site}", + "fr": "Clé sur {site}", + "bn": "কী পাবেন {site}-এ", + "pt-BR": "Chave em {site}", + "ru": "Ключ на {site}", + "id": "Kunci dari {site}" + }, + "Key lacks access": { + "zh-Hans": "密钥没有访问权限", + "hi": "कुंजी के पास पहुँच नहीं है", + "es": "La clave no tiene acceso", + "ar": "المفتاح لا يملك صلاحية الوصول", + "fr": "La clé n'a pas l'accès", + "bn": "কী-এর অ্যাক্সেস নেই", + "pt-BR": "A chave não tem acesso", + "ru": "У ключа нет доступа", + "id": "Kunci tidak punya akses" + }, + "Key removed.": { + "zh-Hans": "密钥已移除。", + "hi": "कुंजी हटा दी गई।", + "es": "Clave quitada.", + "ar": "أُزيل المفتاح.", + "fr": "Clé retirée.", + "bn": "কী সরানো হয়েছে।", + "pt-BR": "Chave removida.", + "ru": "Ключ удалён.", + "id": "Kunci dihapus." + }, + "Key saved in the {store}.": { + "zh-Hans": "密钥已保存在 {store} 中。", + "hi": "कुंजी {store} में सहेजी गई है।", + "es": "Clave guardada en {store}.", + "ar": "حُفظ المفتاح في {store}.", + "fr": "Clé enregistrée dans {store}.", + "bn": "কী {store}-এ সংরক্ষিত।", + "pt-BR": "Chave salva em {store}.", + "ru": "Ключ сохранён в {store}.", + "id": "Kunci tersimpan di {store}." + }, + "Later — keep it waiting": { + "zh-Hans": "稍后 — 让它等着", + "hi": "बाद में — इसे इंतज़ार करने दें", + "es": "Más tarde — que espere", + "ar": "لاحقًا — دعه ينتظر", + "fr": "Plus tard — le laisser en attente", + "bn": "পরে — অপেক্ষায় রাখুন", + "pt-BR": "Depois — deixar esperando", + "ru": "Позже — пусть подождёт", + "id": "Nanti — biarkan menunggu" + }, + "Launch at startup": { + "zh-Hans": "开机时启动", + "hi": "स्टार्टअप पर शुरू करें", + "es": "Abrir al iniciar sesión", + "ar": "التشغيل عند بدء التشغيل", + "fr": "Lancer au démarrage", + "bn": "স্টার্টআপে চালু করুন", + "pt-BR": "Abrir ao iniciar", + "ru": "Запуск при старте системы", + "id": "Jalankan saat startup" + }, + "Leave the user name and password out of the address.": { + "zh-Hans": "请不要在地址中包含用户名和密码。", + "hi": "पते में यूज़रनेम और पासवर्ड न डालें।", + "es": "No incluyas el usuario ni la contraseña en la dirección.", + "ar": "لا تضع اسم المستخدم وكلمة المرور في العنوان.", + "fr": "Ne mets ni nom d'utilisateur ni mot de passe dans l'adresse.", + "bn": "ঠিকানায় ইউজারনেম আর পাসওয়ার্ড দেবেন না।", + "pt-BR": "Não coloque usuário e senha no endereço.", + "ru": "Не указывайте имя пользователя и пароль в адресе.", + "id": "Jangan sertakan nama pengguna dan kata sandi di alamat." + }, + "Main display": { + "zh-Hans": "主显示器", + "hi": "मुख्य डिस्प्ले", + "es": "Pantalla principal", + "ar": "الشاشة الرئيسية", + "fr": "Écran principal", + "bn": "প্রধান ডিসপ্লে", + "pt-BR": "Tela principal", + "ru": "Основной дисплей", + "id": "Layar utama" + }, + "Main tool": { + "zh-Hans": "主要工具", + "hi": "मुख्य टूल", + "es": "Herramienta principal", + "ar": "الأداة الرئيسية", + "fr": "Outil principal", + "bn": "প্রধান টুল", + "pt-BR": "Ferramenta principal", + "ru": "Основной инструмент", + "id": "Alat utama" + }, + "Meeting": { + "zh-Hans": "会议", + "hi": "मीटिंग", + "es": "Reunión", + "ar": "اجتماع", + "fr": "Réunion", + "bn": "মিটিং", + "pt-BR": "Reunião", + "ru": "Встреча", + "id": "Rapat" + }, + "Monday": { + "zh-Hans": "星期一", + "hi": "सोमवार", + "es": "Lunes", + "ar": "الاثنين", + "fr": "Lundi", + "bn": "সোমবার", + "pt-BR": "Segunda-feira", + "ru": "Понедельник", + "id": "Senin" + }, + "Mute": { + "zh-Hans": "静音", + "hi": "म्यूट", + "es": "Silenciar", + "ar": "كتم الصوت", + "fr": "Couper le son", + "bn": "মিউট", + "pt-BR": "Silenciar", + "ru": "Без звука", + "id": "Bisukan" + }, + "Mute or unmute Mochi": { + "zh-Hans": "静音或取消静音 Mochi", + "hi": "Mochi को म्यूट या अनम्यूट करें", + "es": "Silenciar o activar a Mochi", + "ar": "كتم صوت Mochi أو إلغاء كتمه", + "fr": "Couper ou remettre le son de Mochi", + "bn": "Mochi-কে মিউট বা আনমিউট করুন", + "pt-BR": "Silenciar ou ativar o som do Mochi", + "ru": "Выключить или включить звук Mochi", + "id": "Bisukan atau bunyikan Mochi" + }, + "Network error: {error}": { + "zh-Hans": "网络错误:{error}", + "hi": "नेटवर्क त्रुटि: {error}", + "es": "Error de red: {error}", + "ar": "خطأ في الشبكة: {error}", + "fr": "Erreur réseau : {error}", + "bn": "নেটওয়ার্ক ত্রুটি: {error}", + "pt-BR": "Erro de rede: {error}", + "ru": "Ошибка сети: {error}", + "id": "Galat jaringan: {error}" + }, + "Next or previous pill": { + "zh-Hans": "下一个或上一个标签", + "hi": "अगला या पिछला टैब", + "es": "Pestaña siguiente o anterior", + "ar": "التبويب التالي أو السابق", + "fr": "Onglet suivant ou précédent", + "bn": "পরের বা আগের ট্যাব", + "pt-BR": "Próxima aba ou anterior", + "ru": "Следующая или предыдущая вкладка", + "id": "Tab berikutnya atau sebelumnya" + }, + "Next pill": { + "zh-Hans": "下一个标签", + "hi": "अगला टैब", + "es": "Siguiente pestaña", + "ar": "التبويب التالي", + "fr": "Onglet suivant", + "bn": "পরের ট্যাব", + "pt-BR": "Próxima aba", + "ru": "Следующая вкладка", + "id": "Tab berikutnya" + }, + "No API key — add it in Settings.": { + "zh-Hans": "没有 API 密钥 — 请在设置中添加。", + "hi": "कोई API कुंजी नहीं — इसे सेटिंग्स में जोड़ें।", + "es": "No hay clave de API — añádela en Configuración.", + "ar": "لا يوجد مفتاح API — أضفه من الإعدادات.", + "fr": "Pas de clé d'API — ajoute-la dans les Réglages.", + "bn": "কোনো API কী নেই — সেটিংসে যোগ করুন।", + "pt-BR": "Sem chave de API — adicione nas Configurações.", + "ru": "Нет API-ключа — добавьте его в настройках.", + "id": "Tidak ada kunci API — tambahkan di Pengaturan." + }, + "No Pictures or Downloads folder to save into.": { + "zh-Hans": "没有可保存的“图片”或“下载”文件夹。", + "hi": "सहेजने के लिए कोई Pictures या Downloads फ़ोल्डर नहीं है।", + "es": "No hay una carpeta Imágenes o Descargas donde guardar.", + "ar": "لا يوجد مجلد «الصور» أو «التنزيلات» للحفظ فيه.", + "fr": "Aucun dossier Images ou Téléchargements où enregistrer.", + "bn": "সংরক্ষণের জন্য কোনো Pictures বা Downloads ফোল্ডার নেই।", + "pt-BR": "Não há uma pasta Imagens ou Downloads para salvar.", + "ru": "Нет папки «Изображения» или «Загрузки» для сохранения.", + "id": "Tidak ada folder Gambar atau Unduhan untuk menyimpan." + }, + "No change.": { + "zh-Hans": "没有改动。", + "hi": "कोई बदलाव नहीं।", + "es": "Sin cambios.", + "ar": "لا تغيير.", + "fr": "Aucun changement.", + "bn": "কোনো পরিবর্তন নেই।", + "pt-BR": "Nenhuma alteração.", + "ru": "Без изменений.", + "id": "Tidak ada perubahan." + }, + "No connection: {error}": { + "zh-Hans": "无连接:{error}", + "hi": "कोई कनेक्शन नहीं: {error}", + "es": "Sin conexión: {error}", + "ar": "لا يوجد اتصال: {error}", + "fr": "Pas de connexion : {error}", + "bn": "সংযোগ নেই: {error}", + "pt-BR": "Sem conexão: {error}", + "ru": "Нет соединения: {error}", + "id": "Tidak ada koneksi: {error}" + }, + "No detail available.": { + "zh-Hans": "没有可用的详情。", + "hi": "कोई विवरण उपलब्ध नहीं।", + "es": "No hay detalles disponibles.", + "ar": "لا توجد تفاصيل.", + "fr": "Aucun détail disponible.", + "bn": "কোনো বিস্তারিত নেই।", + "pt-BR": "Nenhum detalhe disponível.", + "ru": "Подробностей нет.", + "id": "Tidak ada detail." + }, + "No display server was found, so global shortcuts are off.": { + "zh-Hans": "未找到显示服务器,因此全局快捷键已关闭。", + "hi": "कोई डिस्प्ले सर्वर नहीं मिला, इसलिए ग्लोबल शॉर्टकट बंद हैं।", + "es": "No se encontró ningún servidor gráfico, así que los atajos globales están desactivados.", + "ar": "لم يُعثر على خادم عرض، لذا الاختصارات العامة معطّلة.", + "fr": "Aucun serveur d'affichage trouvé, les raccourcis globaux sont donc désactivés.", + "bn": "কোনো ডিসপ্লে সার্ভার পাওয়া যায়নি, তাই গ্লোবাল শর্টকাট বন্ধ।", + "pt-BR": "Nenhum servidor gráfico foi encontrado, então os atalhos globais estão desativados.", + "ru": "Дисплейный сервер не найден, поэтому глобальные сочетания отключены.", + "id": "Server tampilan tidak ditemukan, jadi pintasan global dimatikan." + }, + "No existing file — nothing to back up.": { + "zh-Hans": "没有现有文件 — 无需备份。", + "hi": "कोई मौजूदा फ़ाइल नहीं — बैकअप के लिए कुछ नहीं।", + "es": "No hay ningún archivo — nada que respaldar.", + "ar": "لا يوجد ملف حالي — لا شيء لنسخه احتياطيًا.", + "fr": "Aucun fichier existant — rien à sauvegarder.", + "bn": "কোনো ফাইল নেই — ব্যাকআপ করার কিছু নেই।", + "pt-BR": "Nenhum arquivo existente — nada para fazer backup.", + "ru": "Файла пока нет — нечего сохранять.", + "id": "Belum ada file — tidak ada yang perlu dicadangkan." + }, + "No key yet — the chat needs one.": { + "zh-Hans": "还没有密钥 — 聊天需要一个。", + "hi": "अभी कोई कुंजी नहीं — चैट को एक चाहिए।", + "es": "Aún no hay clave — el chat necesita una.", + "ar": "لا يوجد مفتاح بعد — تحتاج الدردشة إلى مفتاح.", + "fr": "Pas encore de clé — le chat en a besoin.", + "bn": "এখনো কোনো কী নেই — চ্যাটের একটি লাগবে।", + "pt-BR": "Ainda não há chave — o chat precisa de uma.", + "ru": "Ключа пока нет — он нужен для чата.", + "id": "Belum ada kunci — obrolan membutuhkannya." + }, + "No models yet. Download one in {name} first.": { + "zh-Hans": "还没有模型。请先在 {name} 中下载一个。", + "hi": "अभी कोई मॉडल नहीं। पहले {name} में एक डाउनलोड करें।", + "es": "Aún no hay modelos. Descarga uno en {name} primero.", + "ar": "لا توجد نماذج بعد. نزّل نموذجًا في {name} أولًا.", + "fr": "Pas encore de modèle. Télécharges-en un dans {name} d'abord.", + "bn": "এখনো কোনো মডেল নেই। আগে {name}-এ একটি ডাউনলোড করুন।", + "pt-BR": "Ainda não há modelos. Baixe um no {name} primeiro.", + "ru": "Моделей пока нет. Сначала загрузите модель в {name}.", + "id": "Belum ada model. Unduh satu di {name} terlebih dahulu." + }, + "No response text.": { + "zh-Hans": "没有回复内容。", + "hi": "जवाब में कोई टेक्स्ट नहीं।", + "es": "La respuesta no tiene texto.", + "ar": "لا يوجد نص في الرد.", + "fr": "La réponse ne contient pas de texte.", + "bn": "উত্তরে কোনো লেখা নেই।", + "pt-BR": "A resposta não tem texto.", + "ru": "В ответе нет текста.", + "id": "Tidak ada teks balasan." + }, + "No settings.json yet — nothing to back up.": { + "zh-Hans": "还没有 settings.json — 无需备份。", + "hi": "अभी कोई settings.json नहीं — बैकअप के लिए कुछ नहीं।", + "es": "Aún no hay settings.json — nada que respaldar.", + "ar": "لا يوجد settings.json بعد — لا شيء لنسخه احتياطيًا.", + "fr": "Pas encore de settings.json — rien à sauvegarder.", + "bn": "এখনো কোনো settings.json নেই — ব্যাকআপ করার কিছু নেই।", + "pt-BR": "Ainda não há settings.json — nada para fazer backup.", + "ru": "settings.json пока нет — нечего сохранять.", + "id": "Belum ada settings.json — tidak ada yang perlu dicadangkan." + }, + "No telemetry. Network requests only go to the services you configure yourself.": { + "zh-Hans": "没有遥测。网络请求只会发往你自己配置的服务。", + "hi": "कोई टेलीमेट्री नहीं। नेटवर्क अनुरोध केवल उन्हीं सेवाओं को जाते हैं जिन्हें आप खुद सेट करते हैं।", + "es": "Sin telemetría. Las solicitudes de red solo van a los servicios que configuras tú.", + "ar": "لا قياس عن بُعد. تذهب طلبات الشبكة فقط إلى الخدمات التي تضبطها بنفسك.", + "fr": "Aucune télémétrie. Les requêtes réseau ne vont qu'aux services que tu configures toi-même.", + "bn": "কোনো টেলিমেট্রি নেই। নেটওয়ার্ক অনুরোধ শুধু আপনার নিজের সেট করা পরিষেবাগুলোতেই যায়।", + "pt-BR": "Sem telemetria. As requisições de rede só vão para os serviços que você mesmo configura.", + "ru": "Никакой телеметрии. Сетевые запросы идут только к сервисам, которые вы настроили сами.", + "id": "Tanpa telemetri. Permintaan jaringan hanya ke layanan yang Anda atur sendiri." + }, + "None": { + "zh-Hans": "无", + "hi": "कोई नहीं", + "es": "Ninguno", + "ar": "لا شيء", + "fr": "Aucun", + "bn": "কিছু না", + "pt-BR": "Nenhum", + "ru": "Нет", + "id": "Tidak ada" + }, + "Not a valid address: {address}": { + "zh-Hans": "不是有效的地址:{address}", + "hi": "मान्य पता नहीं: {address}", + "es": "Dirección no válida: {address}", + "ar": "عنوان غير صالح: {address}", + "fr": "Adresse non valide : {address}", + "bn": "বৈধ ঠিকানা নয়: {address}", + "pt-BR": "Endereço inválido: {address}", + "ru": "Некорректный адрес: {address}", + "id": "Alamat tidak valid: {address}" + }, + "Not a valid http:// or https:// address.": { + "zh-Hans": "不是有效的 http:// 或 https:// 地址。", + "hi": "मान्य http:// या https:// पता नहीं है।", + "es": "No es una dirección http:// o https:// válida.", + "ar": "ليس عنوان http:// أو https:// صالحًا.", + "fr": "Ce n'est pas une adresse http:// ou https:// valide.", + "bn": "বৈধ http:// বা https:// ঠিকানা নয়।", + "pt-BR": "Não é um endereço http:// ou https:// válido.", + "ru": "Это не корректный адрес http:// или https://.", + "id": "Bukan alamat http:// atau https:// yang valid." + }, + "Not a valid shortcut": { + "zh-Hans": "无效的快捷键", + "hi": "मान्य शॉर्टकट नहीं", + "es": "No es un atajo válido", + "ar": "ليس اختصارًا صالحًا", + "fr": "Raccourci non valide", + "bn": "বৈধ শর্টকাট নয়", + "pt-BR": "Atalho inválido", + "ru": "Недопустимое сочетание", + "id": "Pintasan tidak valid" + }, + "Not available": { + "zh-Hans": "不可用", + "hi": "उपलब्ध नहीं", + "es": "No disponible", + "ar": "غير متاح", + "fr": "Indisponible", + "bn": "উপলব্ধ নয়", + "pt-BR": "Indisponível", + "ru": "Недоступно", + "id": "Tidak tersedia" + }, + "Nothing leaves your PC: the server runs on this computer.": { + "zh-Hans": "数据不会离开你的电脑:服务器运行在这台电脑上。", + "hi": "आपके PC से कुछ बाहर नहीं जाता: सर्वर इसी कंप्यूटर पर चलता है।", + "es": "Nada sale de tu PC: el servidor funciona en este equipo.", + "ar": "لا يغادر أي شيء جهازك: الخادم يعمل على هذا الحاسوب.", + "fr": "Rien ne quitte ton PC : le serveur tourne sur cet ordinateur.", + "bn": "আপনার PC থেকে কিছুই বাইরে যায় না: সার্ভার এই কম্পিউটারেই চলে।", + "pt-BR": "Nada sai do seu PC: o servidor roda neste computador.", + "ru": "Ничего не покидает ваш ПК: сервер работает на этом компьютере.", + "id": "Tidak ada yang keluar dari PC Anda: server berjalan di komputer ini." + }, + "Only files dropped on the island can be added.": { + "zh-Hans": "只能添加拖放到岛上的文件。", + "hi": "केवल आइलैंड पर छोड़ी गई फ़ाइलें ही जोड़ी जा सकती हैं।", + "es": "Solo se pueden añadir archivos soltados en la isla.", + "ar": "يمكن إضافة الملفات المُفلتة على الشريط فقط.", + "fr": "Seuls les fichiers déposés sur l'île peuvent être ajoutés.", + "bn": "শুধু আইল্যান্ডে ড্রপ করা ফাইলই যোগ করা যায়।", + "pt-BR": "Só arquivos soltos na ilha podem ser adicionados.", + "ru": "Добавлять можно только файлы, перетащенные на остров.", + "id": "Hanya file yang diletakkan di pulau yang bisa ditambahkan." + }, + "Open Claude": { + "zh-Hans": "打开 Claude", + "hi": "Claude खोलें", + "es": "Abrir Claude", + "ar": "فتح Claude", + "fr": "Ouvrir Claude", + "bn": "Claude খুলুন", + "pt-BR": "Abrir o Claude", + "ru": "Открыть Claude", + "id": "Buka Claude" + }, + "Open Settings": { + "zh-Hans": "打开设置", + "hi": "सेटिंग्स खोलें", + "es": "Abrir Configuración", + "ar": "فتح الإعدادات", + "fr": "Ouvrir les réglages", + "bn": "সেটিংস খুলুন", + "pt-BR": "Abrir Configurações", + "ru": "Открыть настройки", + "id": "Buka Pengaturan" + }, + "Open in VS Code": { + "zh-Hans": "在 VS Code 中打开", + "hi": "VS Code में खोलें", + "es": "Abrir en VS Code", + "ar": "فتح في VS Code", + "fr": "Ouvrir dans VS Code", + "bn": "VS Code-এ খুলুন", + "pt-BR": "Abrir no VS Code", + "ru": "Открыть в VS Code", + "id": "Buka di VS Code" + }, + "Open or close the island": { + "zh-Hans": "打开或关闭岛", + "hi": "आइलैंड खोलें या बंद करें", + "es": "Abrir o cerrar la isla", + "ar": "فتح الشريط أو إغلاقه", + "fr": "Ouvrir ou fermer l'île", + "bn": "আইল্যান্ড খুলুন বা বন্ধ করুন", + "pt-BR": "Abrir ou fechar a ilha", + "ru": "Открыть или закрыть остров", + "id": "Buka atau tutup pulau" + }, + "Open the chat": { + "zh-Hans": "打开聊天", + "hi": "चैट खोलें", + "es": "Abrir el chat", + "ar": "فتح الدردشة", + "fr": "Ouvrir le chat", + "bn": "চ্যাট খুলুন", + "pt-BR": "Abrir o chat", + "ru": "Открыть чат", + "id": "Buka obrolan" + }, + "Open the terminal": { + "zh-Hans": "打开终端", + "hi": "टर्मिनल खोलें", + "es": "Abrir la terminal", + "ar": "فتح الطرفية", + "fr": "Ouvrir le terminal", + "bn": "টার্মিনাল খুলুন", + "pt-BR": "Abrir o terminal", + "ru": "Открыть терминал", + "id": "Buka terminal" + }, + "Open the wardrobe": { + "zh-Hans": "打开衣橱", + "hi": "अलमारी खोलें", + "es": "Abrir el guardarropa", + "ar": "فتح خزانة الملابس", + "fr": "Ouvrir la garde-robe", + "bn": "পোশাক ঘর খুলুন", + "pt-BR": "Abrir o guarda-roupa", + "ru": "Открыть гардероб", + "id": "Buka lemari" + }, + "OpenAI-compatible": { + "zh-Hans": "兼容 OpenAI", + "hi": "OpenAI-संगत", + "es": "Compatible con OpenAI", + "ar": "متوافق مع OpenAI", + "fr": "Compatible OpenAI", + "bn": "OpenAI-সামঞ্জস্যপূর্ণ", + "pt-BR": "Compatível com OpenAI", + "ru": "Совместимый с OpenAI", + "id": "Kompatibel OpenAI" + }, + "OpenAI-compatible server": { + "zh-Hans": "兼容 OpenAI 的服务器", + "hi": "OpenAI-संगत सर्वर", + "es": "Servidor compatible con OpenAI", + "ar": "خادم متوافق مع OpenAI", + "fr": "Serveur compatible OpenAI", + "bn": "OpenAI-সামঞ্জস্যপূর্ণ সার্ভার", + "pt-BR": "Servidor compatível com OpenAI", + "ru": "Сервер, совместимый с OpenAI", + "id": "Server kompatibel OpenAI" + }, + "PDF": { + "zh-Hans": "PDF", + "hi": "PDF", + "es": "PDF", + "ar": "PDF", + "fr": "PDF", + "bn": "PDF", + "pt-BR": "PDF", + "ru": "PDF", + "id": "PDF" + }, + "Party hat": { + "zh-Hans": "派对帽", + "hi": "पार्टी टोपी", + "es": "Gorro de fiesta", + "ar": "قبعة الحفلة", + "fr": "Chapeau de fête", + "bn": "পার্টি টুপি", + "pt-BR": "Chapéu de festa", + "ru": "Праздничный колпак", + "id": "Topi pesta" + }, + "Pause": { + "zh-Hans": "暂停", + "hi": "रोकें", + "es": "Pausa", + "ar": "إيقاف مؤقت", + "fr": "Pause", + "bn": "বিরতি", + "pt-BR": "Pausar", + "ru": "Пауза", + "id": "Jeda" + }, + "Payment": { + "zh-Hans": "付款", + "hi": "भुगतान", + "es": "Pago", + "ar": "دفعة", + "fr": "Paiement", + "bn": "পেমেন্ট", + "pt-BR": "Pagamento", + "ru": "Платёж", + "id": "Pembayaran" + }, + "Permissions": { + "zh-Hans": "权限", + "hi": "अनुमतियाँ", + "es": "Permisos", + "ar": "الأذونات", + "fr": "Permissions", + "bn": "অনুমতি", + "pt-BR": "Permissões", + "ru": "Разрешения", + "id": "Izin" + }, + "Pick a model above the chat box first.": { + "zh-Hans": "请先在聊天框上方选择一个模型。", + "hi": "पहले चैट बॉक्स के ऊपर एक मॉडल चुनें।", + "es": "Elige primero un modelo encima del cuadro de chat.", + "ar": "اختر نموذجًا فوق مربع الدردشة أولًا.", + "fr": "Choisis d'abord un modèle au-dessus de la zone de chat.", + "bn": "আগে চ্যাট বক্সের উপরে একটি মডেল বেছে নিন।", + "pt-BR": "Escolha primeiro um modelo acima da caixa de chat.", + "ru": "Сначала выберите модель над полем чата.", + "id": "Pilih model di atas kotak obrolan terlebih dahulu." + }, + "Pick a {name} model above the chat box.": { + "zh-Hans": "请在聊天框上方选择一个 {name} 模型。", + "hi": "चैट बॉक्स के ऊपर {name} का एक मॉडल चुनें।", + "es": "Elige un modelo de {name} encima del cuadro de chat.", + "ar": "اختر نموذجًا من {name} فوق مربع الدردشة.", + "fr": "Choisis un modèle {name} au-dessus de la zone de chat.", + "bn": "চ্যাট বক্সের উপরে {name}-এর একটি মডেল বেছে নিন।", + "pt-BR": "Escolha um modelo do {name} acima da caixa de chat.", + "ru": "Выберите модель {name} над полем чата.", + "id": "Pilih model {name} di atas kotak obrolan." + }, + "Pick up to {max} pills to show next to Mochi — {used}/{max} in use. Keys are stored in the {store}, never on disk.": { + "zh-Hans": "最多选择 {max} 个标签显示在 Mochi 旁边 — 已用 {used}/{max}。密钥保存在 {store} 中,绝不写入磁盘。", + "hi": "Mochi के बगल में दिखाने के लिए अधिकतम {max} टैब चुनें — {used}/{max} उपयोग में। कुंजियाँ {store} में रखी जाती हैं, डिस्क पर कभी नहीं।", + "es": "Elige hasta {max} pestañas para mostrar junto a Mochi — {used}/{max} en uso. Las claves se guardan en {store}, nunca en el disco.", + "ar": "اختر حتى {max} تبويبات لعرضها بجانب Mochi — {used}/{max} مستخدمة. تُحفظ المفاتيح في {store}، ولا تُكتب على القرص أبدًا.", + "fr": "Choisis jusqu'à {max} onglets à afficher à côté de Mochi — {used}/{max} utilisés. Les clés sont stockées dans {store}, jamais sur le disque.", + "bn": "Mochi-র পাশে দেখাতে সর্বোচ্চ {max}টি ট্যাব বেছে নিন — {used}/{max} ব্যবহৃত। কী {store}-এ রাখা হয়, কখনো ডিস্কে নয়।", + "pt-BR": "Escolha até {max} abas para mostrar ao lado do Mochi — {used}/{max} em uso. As chaves ficam em {store}, nunca no disco.", + "ru": "Выберите до {max} вкладок рядом с Mochi — занято {used}/{max}. Ключи хранятся в {store}, никогда на диске.", + "id": "Pilih hingga {max} tab untuk ditampilkan di samping Mochi — {used}/{max} terpakai. Kunci disimpan di {store}, tidak pernah di disk." + }, + "Press keys…": { + "zh-Hans": "请按键…", + "hi": "कुंजियाँ दबाएँ…", + "es": "Pulsa las teclas…", + "ar": "اضغط المفاتيح…", + "fr": "Appuie sur les touches…", + "bn": "কী চাপুন…", + "pt-BR": "Pressione as teclas…", + "ru": "Нажмите клавиши…", + "id": "Tekan tombol…" + }, + "Previous pill": { + "zh-Hans": "上一个标签", + "hi": "पिछला टैब", + "es": "Pestaña anterior", + "ar": "التبويب السابق", + "fr": "Onglet précédent", + "bn": "আগের ট্যাব", + "pt-BR": "Aba anterior", + "ru": "Предыдущая вкладка", + "id": "Tab sebelumnya" + }, + "Pumpkin": { + "zh-Hans": "南瓜", + "hi": "कद्दू", + "es": "Calabaza", + "ar": "يقطينة", + "fr": "Citrouille", + "bn": "কুমড়া", + "pt-BR": "Abóbora", + "ru": "Тыква", + "id": "Labu" + }, + "Questions": { + "zh-Hans": "问题", + "hi": "प्रश्न", + "es": "Preguntas", + "ar": "الأسئلة", + "fr": "Questions", + "bn": "প্রশ্ন", + "pt-BR": "Perguntas", + "ru": "Вопросы", + "id": "Pertanyaan" + }, + "Ready": { + "zh-Hans": "就绪", + "hi": "तैयार", + "es": "Listo", + "ar": "جاهز", + "fr": "Prêt", + "bn": "প্রস্তুত", + "pt-BR": "Pronto", + "ru": "Готово", + "id": "Siap" + }, + "Ready · no setup needed": { + "zh-Hans": "就绪 · 无需设置", + "hi": "तैयार · कोई सेटअप ज़रूरी नहीं", + "es": "Listo · sin configuración", + "ar": "جاهز · لا يحتاج إلى إعداد", + "fr": "Prêt · rien à configurer", + "bn": "প্রস্তুত · সেটআপ লাগবে না", + "pt-BR": "Pronto · sem configuração", + "ru": "Готово · настройка не нужна", + "id": "Siap · tanpa pengaturan" + }, + "Reinstall hooks…": { + "zh-Hans": "重新安装 hooks…", + "hi": "hooks फिर से इंस्टॉल करें…", + "es": "Reinstalar hooks…", + "ar": "إعادة تثبيت الـ hooks…", + "fr": "Réinstaller les hooks…", + "bn": "hooks আবার ইনস্টল করুন…", + "pt-BR": "Reinstalar hooks…", + "ru": "Переустановить хуки…", + "id": "Pasang ulang hooks…" + }, + "Reinstall…": { + "zh-Hans": "重新安装…", + "hi": "फिर से इंस्टॉल करें…", + "es": "Reinstalar…", + "ar": "إعادة التثبيت…", + "fr": "Réinstaller…", + "bn": "আবার ইনস্টল করুন…", + "pt-BR": "Reinstalar…", + "ru": "Переустановить…", + "id": "Pasang ulang…" + }, + "Relay": { + "zh-Hans": "中继", + "hi": "रिले", + "es": "Relay", + "ar": "المُرحِّل", + "fr": "Relais", + "bn": "রিলে", + "pt-BR": "Relay", + "ru": "Ретранслятор", + "id": "Relay" + }, + "Remove": { + "zh-Hans": "移除", + "hi": "हटाएँ", + "es": "Quitar", + "ar": "إزالة", + "fr": "Retirer", + "bn": "সরান", + "pt-BR": "Remover", + "ru": "Удалить", + "id": "Hapus" + }, + "Repositories": { + "zh-Hans": "仓库", + "hi": "रिपॉज़िटरी", + "es": "Repositorios", + "ar": "المستودعات", + "fr": "Dépôts", + "bn": "রিপোজিটরি", + "pt-BR": "Repositórios", + "ru": "Репозитории", + "id": "Repositori" + }, + "Reset to defaults": { + "zh-Hans": "恢复默认", + "hi": "डिफ़ॉल्ट पर रीसेट करें", + "es": "Restablecer valores predeterminados", + "ar": "استعادة الإعدادات الافتراضية", + "fr": "Rétablir les valeurs par défaut", + "bn": "ডিফল্টে ফেরান", + "pt-BR": "Restaurar padrões", + "ru": "Сбросить по умолчанию", + "id": "Kembalikan ke bawaan" + }, + "Resets": { + "zh-Hans": "重置", + "hi": "रीसेट", + "es": "Reinicios", + "ar": "إعادات التعيين", + "fr": "Réinitialisations", + "bn": "রিসেট", + "pt-BR": "Redefinições", + "ru": "Сбросы", + "id": "Reset" + }, + "Restart Amp to load the plugin.": { + "zh-Hans": "重启 Amp 以加载插件。", + "hi": "प्लगइन लोड करने के लिए Amp फिर से शुरू करें।", + "es": "Reinicia Amp para cargar el plugin.", + "ar": "أعد تشغيل Amp لتحميل الإضافة.", + "fr": "Redémarre Amp pour charger le plugin.", + "bn": "প্লাগইন লোড করতে Amp আবার চালু করুন।", + "pt-BR": "Reinicie o Amp para carregar o plugin.", + "ru": "Перезапустите Amp, чтобы загрузить плагин.", + "id": "Mulai ulang Amp untuk memuat plugin." + }, + "Restart Cursor to pick the hooks up.": { + "zh-Hans": "重启 Cursor 即可启用 hooks。", + "hi": "hooks लागू करने के लिए Cursor फिर से शुरू करें।", + "es": "Reinicia Cursor para que cargue los hooks.", + "ar": "أعد تشغيل Cursor لتفعيل الـ hooks.", + "fr": "Redémarre Cursor pour qu'il prenne les hooks.", + "bn": "hooks চালু করতে Cursor আবার চালু করুন।", + "pt-BR": "Reinicie o Cursor para carregar os hooks.", + "ru": "Перезапустите Cursor, чтобы хуки заработали.", + "id": "Mulai ulang Cursor agar hooks dimuat." + }, + "Restart OpenCode to load the plugin.": { + "zh-Hans": "重启 OpenCode 以加载插件。", + "hi": "प्लगइन लोड करने के लिए OpenCode फिर से शुरू करें।", + "es": "Reinicia OpenCode para cargar el plugin.", + "ar": "أعد تشغيل OpenCode لتحميل الإضافة.", + "fr": "Redémarre OpenCode pour charger le plugin.", + "bn": "প্লাগইন লোড করতে OpenCode আবার চালু করুন।", + "pt-BR": "Reinicie o OpenCode para carregar o plugin.", + "ru": "Перезапустите OpenCode, чтобы загрузить плагин.", + "id": "Mulai ulang OpenCode untuk memuat plugin." + }, + "Result": { + "zh-Hans": "结果", + "hi": "परिणाम", + "es": "Resultado", + "ar": "النتيجة", + "fr": "Résultat", + "bn": "ফলাফল", + "pt-BR": "Resultado", + "ru": "Результат", + "id": "Hasil" + }, + "Round glasses": { + "zh-Hans": "圆框眼镜", + "hi": "गोल चश्मा", + "es": "Gafas redondas", + "ar": "نظارة دائرية", + "fr": "Lunettes rondes", + "bn": "গোল চশমা", + "pt-BR": "Óculos redondos", + "ru": "Круглые очки", + "id": "Kacamata bulat" + }, + "Santa hat": { + "zh-Hans": "圣诞帽", + "hi": "सांता टोपी", + "es": "Gorro de Papá Noel", + "ar": "قبعة بابا نويل", + "fr": "Bonnet de Père Noël", + "bn": "সান্তা টুপি", + "pt-BR": "Gorro de Papai Noel", + "ru": "Шапка Санты", + "id": "Topi Sinterklas" + }, + "Saturday": { + "zh-Hans": "星期六", + "hi": "शनिवार", + "es": "Sábado", + "ar": "السبت", + "fr": "Samedi", + "bn": "শনিবার", + "pt-BR": "Sábado", + "ru": "Суббота", + "id": "Sabtu" + }, + "Save image": { + "zh-Hans": "保存图片", + "hi": "छवि सहेजें", + "es": "Guardar imagen", + "ar": "حفظ الصورة", + "fr": "Enregistrer l'image", + "bn": "ছবি সংরক্ষণ করুন", + "pt-BR": "Salvar imagem", + "ru": "Сохранить изображение", + "id": "Simpan gambar" + }, + "Save key": { + "zh-Hans": "保存密钥", + "hi": "कुंजी सहेजें", + "es": "Guardar clave", + "ar": "حفظ المفتاح", + "fr": "Enregistrer la clé", + "bn": "কী সংরক্ষণ করুন", + "pt-BR": "Salvar chave", + "ru": "Сохранить ключ", + "id": "Simpan kunci" + }, + "Saved as {name}": { + "zh-Hans": "已保存为 {name}", + "hi": "{name} के रूप में सहेजा गया", + "es": "Guardada como {name}", + "ar": "حُفظت باسم {name}", + "fr": "Enregistrée sous {name}", + "bn": "{name} নামে সংরক্ষিত", + "pt-BR": "Salva como {name}", + "ru": "Сохранено как {name}", + "id": "Disimpan sebagai {name}" + }, + "Saved. It never touches disk.": { + "zh-Hans": "已保存。它从不写入磁盘。", + "hi": "सहेजी गई। यह कभी डिस्क पर नहीं जाती।", + "es": "Guardada. Nunca toca el disco.", + "ar": "تم الحفظ. لا يُكتب على القرص أبدًا.", + "fr": "Enregistrée. Elle ne touche jamais le disque.", + "bn": "সংরক্ষিত। এটি কখনো ডিস্কে লেখা হয় না।", + "pt-BR": "Salva. Ela nunca vai para o disco.", + "ru": "Сохранено. Ключ никогда не попадает на диск.", + "id": "Tersimpan. Kunci tidak pernah ditulis ke disk." + }, + "Saving…": { + "zh-Hans": "正在保存…", + "hi": "सहेजा जा रहा है…", + "es": "Guardando…", + "ar": "جارٍ الحفظ…", + "fr": "Enregistrement…", + "bn": "সংরক্ষণ হচ্ছে…", + "pt-BR": "Salvando…", + "ru": "Сохранение…", + "id": "Menyimpan…" + }, + "Scarf": { + "zh-Hans": "围巾", + "hi": "मफ़लर", + "es": "Bufanda", + "ar": "وشاح", + "fr": "Écharpe", + "bn": "মাফলার", + "pt-BR": "Cachecol", + "ru": "Шарф", + "id": "Syal" + }, + "Secret key": { + "zh-Hans": "私密密钥", + "hi": "सीक्रेट कुंजी", + "es": "Clave secreta", + "ar": "المفتاح السري", + "fr": "Clé secrète", + "bn": "সিক্রেট কী", + "pt-BR": "Chave secreta", + "ru": "Секретный ключ", + "id": "Kunci rahasia" + }, + "Send Mochi to the desktop": { + "zh-Hans": "把 Mochi 送到桌面", + "hi": "Mochi को डेस्कटॉप पर भेजें", + "es": "Enviar a Mochi al escritorio", + "ar": "إرسال Mochi إلى سطح المكتب", + "fr": "Envoyer Mochi sur le bureau", + "bn": "Mochi-কে ডেস্কটপে পাঠান", + "pt-BR": "Enviar o Mochi para a área de trabalho", + "ru": "Отправить Mochi на рабочий стол", + "id": "Kirim Mochi ke desktop" + }, + "Send the message": { + "zh-Hans": "发送消息", + "hi": "संदेश भेजें", + "es": "Enviar el mensaje", + "ar": "إرسال الرسالة", + "fr": "Envoyer le message", + "bn": "বার্তা পাঠান", + "pt-BR": "Enviar a mensagem", + "ru": "Отправить сообщение", + "id": "Kirim pesan" + }, + "Sending by email isn't in this version.": { + "zh-Hans": "此版本暂不支持通过邮件发送。", + "hi": "ईमेल से भेजना इस संस्करण में नहीं है।", + "es": "El envío por correo no está en esta versión.", + "ar": "الإرسال بالبريد الإلكتروني غير متاح في هذا الإصدار.", + "fr": "L'envoi par e-mail n'est pas dans cette version.", + "bn": "ইমেলে পাঠানো এই সংস্করণে নেই।", + "pt-BR": "O envio por e-mail não está nesta versão.", + "ru": "Отправки по почте в этой версии нет.", + "id": "Mengirim lewat email belum ada di versi ini." + }, + "Services": { + "zh-Hans": "服务", + "hi": "सेवाएँ", + "es": "Servicios", + "ar": "الخدمات", + "fr": "Services", + "bn": "পরিষেবা", + "pt-BR": "Serviços", + "ru": "Сервисы", + "id": "Layanan" + }, + "Session stopped on an error.": { + "zh-Hans": "会话因错误而停止。", + "hi": "सत्र एक त्रुटि पर रुक गया।", + "es": "La sesión se detuvo por un error.", + "ar": "توقفت الجلسة بسبب خطأ.", + "fr": "La session s'est arrêtée sur une erreur.", + "bn": "একটি ত্রুটিতে সেশন থেমে গেছে।", + "pt-BR": "A sessão parou por um erro.", + "ru": "Сессия остановилась из-за ошибки.", + "id": "Sesi berhenti karena galat." + }, + "Sessions — approvals stay in the agent": { + "zh-Hans": "会话 — 审批仍在代理中进行", + "hi": "सत्र — अनुमतियाँ एजेंट में ही रहती हैं", + "es": "Sesiones — las aprobaciones se quedan en el agente", + "ar": "الجلسات — تبقى الموافقات داخل الوكيل", + "fr": "Sessions — les validations restent dans l'agent", + "bn": "সেশন — অনুমোদন এজেন্টেই থাকে", + "pt-BR": "Sessões — as aprovações ficam no agente", + "ru": "Сессии — подтверждения остаются в агенте", + "id": "Sesi — persetujuan tetap di agen" + }, + "Sessions, and Allow / Deny from the island": { + "zh-Hans": "会话,以及在岛中允许 / 拒绝", + "hi": "सत्र, और आइलैंड से अनुमति दें / अस्वीकार करें", + "es": "Sesiones, y Permitir / Denegar desde la isla", + "ar": "الجلسات، والسماح / الرفض من الشريط", + "fr": "Sessions, et Autoriser / Refuser depuis l'île", + "bn": "সেশন, আর আইল্যান্ড থেকে অনুমতি / প্রত্যাখ্যান", + "pt-BR": "Sessões, e Permitir / Negar pela ilha", + "ru": "Сессии и «Разрешить / Отклонить» из острова", + "id": "Sesi, dan Izinkan / Tolak dari pulau" + }, + "Settings": { + "zh-Hans": "设置", + "hi": "सेटिंग्स", + "es": "Configuración", + "ar": "الإعدادات", + "fr": "Réglages", + "bn": "সেটিংস", + "pt-BR": "Configurações", + "ru": "Настройки", + "id": "Pengaturan" + }, + "Share your week": { + "zh-Hans": "分享你的一周", + "hi": "अपना हफ़्ता शेयर करें", + "es": "Comparte tu semana", + "ar": "شارك أسبوعك", + "fr": "Partage ta semaine", + "bn": "আপনার সপ্তাহ শেয়ার করুন", + "pt-BR": "Compartilhe sua semana", + "ru": "Поделитесь своей неделей", + "id": "Bagikan minggu Anda" + }, + "Show Codex plan in the notch": { + "zh-Hans": "在刘海中显示 Codex 套餐", + "hi": "नॉच में Codex प्लान दिखाएं", + "es": "Mostrar el plan de Codex en el notch", + "ar": "عرض خطة Codex في الشريط العلوي", + "fr": "Afficher le forfait Codex dans l'encoche", + "bn": "নচে Codex প্ল্যান দেখান", + "pt-BR": "Mostrar o plano Codex no notch", + "ru": "Показывать тариф Codex в нотче", + "id": "Tampilkan paket Codex di notch" + }, + "Show in folder": { + "zh-Hans": "在文件夹中显示", + "hi": "फ़ोल्डर में दिखाएँ", + "es": "Mostrar en la carpeta", + "ar": "إظهار في المجلد", + "fr": "Afficher dans le dossier", + "bn": "ফোল্ডারে দেখান", + "pt-BR": "Mostrar na pasta", + "ru": "Показать в папке", + "id": "Tampilkan di folder" + }, + "Show other coding agents in the island. Coucou adds its entries to each agent's own config: you see the exact change and where the backup goes before anything is written, and uninstalling removes only what Coucou added.": { + "zh-Hans": "在岛中显示其他编程代理。Coucou 会把自己的条目添加到各代理的配置中:写入之前,你会看到确切的改动和备份位置;卸载只会移除 Coucou 添加的内容。", + "hi": "दूसरे कोडिंग एजेंटों को आइलैंड में दिखाएँ। Coucou हर एजेंट की अपनी कॉन्फ़िग में अपनी प्रविष्टियाँ जोड़ता है: कुछ भी लिखे जाने से पहले आप सटीक बदलाव और बैकअप की जगह देखते हैं, और अनइंस्टॉल करने पर केवल वही हटता है जो Coucou ने जोड़ा था।", + "es": "Muestra otros agentes de programación en la isla. Coucou añade sus entradas a la configuración de cada agente: ves el cambio exacto y dónde va la copia de seguridad antes de que se escriba nada, y al desinstalar solo se quita lo que añadió Coucou.", + "ar": "اعرض وكلاء البرمجة الآخرين في الشريط. يضيف Coucou إدخالاته إلى إعدادات كل وكيل: ترى التغيير بالضبط ومكان النسخة الاحتياطية قبل كتابة أي شيء، وإلغاء التثبيت يزيل فقط ما أضافه Coucou.", + "fr": "Affiche les autres agents de code dans l'île. Coucou ajoute ses entrées à la configuration de chaque agent : tu vois la modification exacte et l'emplacement de la sauvegarde avant que quoi que ce soit soit écrit, et la désinstallation ne retire que ce que Coucou a ajouté.", + "bn": "অন্যান্য কোডিং এজেন্টকে আইল্যান্ডে দেখান। Coucou প্রতিটি এজেন্টের নিজস্ব কনফিগে তার এন্ট্রি যোগ করে: কিছু লেখার আগে আপনি সঠিক পরিবর্তন এবং ব্যাকআপ কোথায় যাবে তা দেখতে পান, আর আনইনস্টল করলে শুধু Coucou যা যোগ করেছিল তা-ই সরানো হয়।", + "pt-BR": "Mostre outros agentes de programação na ilha. O Coucou adiciona as próprias entradas à configuração de cada agente: você vê a alteração exata e para onde vai o backup antes de qualquer gravação, e desinstalar remove só o que o Coucou adicionou.", + "ru": "Показывайте другие агенты для кода в острове. Coucou добавляет свои записи в конфигурацию каждого агента: до любой записи вы видите точное изменение и куда пойдёт резервная копия, а удаление убирает только то, что добавил Coucou.", + "id": "Tampilkan agen coding lain di pulau. Coucou menambahkan entrinya ke konfigurasi masing-masing agen: Anda melihat perubahan persisnya dan lokasi cadangan sebelum apa pun ditulis, dan mencopot pemasangan hanya menghapus yang ditambahkan Coucou." + }, + "Shows your Claude plan usage (5-hour and weekly limits) in the island's header. Coucou adds a status line relay in ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.": { + "zh-Hans": "在岛的顶栏显示你的 Claude 套餐用量(5 小时和每周限额)。Coucou 会在 ~/.claude/settings.json 中添加一个状态栏中继。如果你已有状态栏,它会照常工作。仅限 Pro 和 Max 套餐。", + "hi": "आइलैंड के हेडर में आपके Claude प्लान का उपयोग (5 घंटे और साप्ताहिक सीमा) दिखाता है। Coucou ~/.claude/settings.json में एक स्टेटस लाइन रिले जोड़ता है। अगर आपके पास पहले से स्टेटस लाइन है, तो वह पहले की तरह चलती रहेगी। केवल Pro और Max प्लान।", + "es": "Muestra el uso de tu plan de Claude (límites de 5 horas y semanal) en la cabecera de la isla. Coucou añade un relay de línea de estado en ~/.claude/settings.json. Si ya tienes una línea de estado, sigue funcionando como antes. Solo planes Pro y Max.", + "ar": "يعرض استخدام خطة Claude (حدّا الخمس ساعات والأسبوع) في رأس الشريط. يضيف Coucou مُرحِّلًا لسطر الحالة في ~/.claude/settings.json. إن كان لديك سطر حالة فسيستمر في العمل كما كان. لخطتَي Pro وMax فقط.", + "fr": "Affiche l'utilisation de ton forfait Claude (limites sur 5 heures et à la semaine) dans l'en-tête de l'île. Coucou ajoute un relais de ligne d'état dans ~/.claude/settings.json. Si tu as déjà une ligne d'état, elle continue de fonctionner comme avant. Forfaits Pro et Max uniquement.", + "bn": "আইল্যান্ডের হেডারে আপনার Claude প্ল্যানের ব্যবহার (৫ ঘণ্টা ও সাপ্তাহিক সীমা) দেখায়। Coucou ~/.claude/settings.json-এ একটি স্ট্যাটাস লাইন রিলে যোগ করে। আপনার আগে থেকে স্ট্যাটাস লাইন থাকলে সেটি আগের মতোই চলবে। শুধু Pro ও Max প্ল্যান।", + "pt-BR": "Mostra o uso do seu plano Claude (limites de 5 horas e semanal) no cabeçalho da ilha. O Coucou adiciona um relay de linha de status em ~/.claude/settings.json. Se você já tem uma linha de status, ela continua funcionando como antes. Apenas planos Pro e Max.", + "ru": "Показывает использование тарифа Claude (лимиты на 5 часов и на неделю) в заголовке острова. Coucou добавляет ретранслятор строки состояния в ~/.claude/settings.json. Если у вас уже есть строка состояния, она работает как прежде. Только тарифы Pro и Max.", + "id": "Menampilkan pemakaian paket Claude Anda (batas 5 jam dan mingguan) di header pulau. Coucou menambahkan relay baris status di ~/.claude/settings.json. Jika Anda sudah punya baris status, baris itu tetap berjalan seperti biasa. Khusus paket Pro dan Max." + }, + "Shows your Codex plan usage (weekly limit and free resets left) in the island's header. Coucou asks the Codex CLI (codex app-server) when the pill shows; nothing is installed. Codex must be signed in with ChatGPT.": { + "zh-Hans": "在岛的顶栏显示你的 Codex 套餐用量(每周限额和剩余的免费重置次数)。标签显示时,Coucou 会询问 Codex CLI(codex app-server),不会安装任何东西。Codex 必须已用 ChatGPT 登录。", + "hi": "आइलैंड के हेडर में आपके Codex प्लान का उपयोग (साप्ताहिक सीमा और बचे मुफ़्त रीसेट) दिखाता है। टैब दिखने पर Coucou Codex CLI (codex app-server) से पूछता है; कुछ भी इंस्टॉल नहीं होता। Codex में ChatGPT से साइन इन होना ज़रूरी है।", + "es": "Muestra el uso de tu plan de Codex (límite semanal y reinicios gratuitos restantes) en la cabecera de la isla. Coucou consulta a la CLI de Codex (codex app-server) cuando aparece la pestaña; no se instala nada. Codex debe tener la sesión iniciada con ChatGPT.", + "ar": "يعرض استخدام خطة Codex (الحد الأسبوعي وإعادات التعيين المجانية المتبقية) في رأس الشريط. يسأل Coucou واجهة Codex CLI (codex app-server) عند ظهور التبويب، ولا يُثبَّت أي شيء. يجب تسجيل الدخول إلى Codex بحساب ChatGPT.", + "fr": "Affiche l'utilisation de ton forfait Codex (limite hebdomadaire et réinitialisations gratuites restantes) dans l'en-tête de l'île. Coucou interroge la CLI Codex (codex app-server) quand l'onglet s'affiche ; rien n'est installé. Codex doit être connecté avec ChatGPT.", + "bn": "আইল্যান্ডের হেডারে আপনার Codex প্ল্যানের ব্যবহার (সাপ্তাহিক সীমা ও বাকি ফ্রি রিসেট) দেখায়। ট্যাব দেখা গেলে Coucou Codex CLI (codex app-server)-কে জিজ্ঞেস করে; কিছুই ইনস্টল হয় না। Codex-এ ChatGPT দিয়ে সাইন ইন থাকতে হবে।", + "pt-BR": "Mostra o uso do seu plano Codex (limite semanal e redefinições grátis restantes) no cabeçalho da ilha. O Coucou consulta a CLI do Codex (codex app-server) quando a aba aparece; nada é instalado. O Codex precisa estar conectado com o ChatGPT.", + "ru": "Показывает использование тарифа Codex (недельный лимит и оставшиеся бесплатные сбросы) в заголовке острова. Coucou спрашивает Codex CLI (codex app-server), когда видна вкладка; ничего не устанавливается. В Codex нужно войти через ChatGPT.", + "id": "Menampilkan pemakaian paket Codex Anda (batas mingguan dan sisa reset gratis) di header pulau. Coucou menanyakan Codex CLI (codex app-server) saat tab tampil; tidak ada yang dipasang. Codex harus masuk dengan ChatGPT." + }, + "Start a new Antigravity conversation to pick the hooks up.": { + "zh-Hans": "开启新的 Antigravity 对话即可启用 hooks。", + "hi": "hooks लागू करने के लिए Antigravity में नई बातचीत शुरू करें।", + "es": "Abre una nueva conversación de Antigravity para que cargue los hooks.", + "ar": "ابدأ محادثة Antigravity جديدة لتفعيل الـ hooks.", + "fr": "Lance une nouvelle conversation Antigravity pour qu'elle prenne les hooks.", + "bn": "hooks চালু করতে Antigravity-তে নতুন কথোপকথন শুরু করুন।", + "pt-BR": "Inicie uma nova conversa no Antigravity para carregar os hooks.", + "ru": "Начните новый разговор в Antigravity, чтобы хуки заработали.", + "id": "Mulai percakapan Antigravity baru agar hooks dimuat." + }, + "Start a new Copilot CLI session to pick the hooks up.": { + "zh-Hans": "开启新的 Copilot CLI 会话即可启用 hooks。", + "hi": "hooks लागू करने के लिए Copilot CLI का नया सत्र शुरू करें।", + "es": "Abre una nueva sesión de Copilot CLI para que cargue los hooks.", + "ar": "ابدأ جلسة Copilot CLI جديدة لتفعيل الـ hooks.", + "fr": "Lance une nouvelle session Copilot CLI pour qu'elle prenne les hooks.", + "bn": "hooks চালু করতে Copilot CLI-এর নতুন সেশন শুরু করুন।", + "pt-BR": "Inicie uma nova sessão do Copilot CLI para carregar os hooks.", + "ru": "Начните новую сессию Copilot CLI, чтобы хуки заработали.", + "id": "Mulai sesi Copilot CLI baru agar hooks dimuat." + }, + "Start a new Gemini CLI session to pick the hooks up.": { + "zh-Hans": "开启新的 Gemini CLI 会话即可启用 hooks。", + "hi": "hooks लागू करने के लिए Gemini CLI का नया सत्र शुरू करें।", + "es": "Abre una nueva sesión de Gemini CLI para que cargue los hooks.", + "ar": "ابدأ جلسة Gemini CLI جديدة لتفعيل الـ hooks.", + "fr": "Lance une nouvelle session Gemini CLI pour qu'elle prenne les hooks.", + "bn": "hooks চালু করতে Gemini CLI-এর নতুন সেশন শুরু করুন।", + "pt-BR": "Inicie uma nova sessão do Gemini CLI para carregar os hooks.", + "ru": "Начните новую сессию Gemini CLI, чтобы хуки заработали.", + "id": "Mulai sesi Gemini CLI baru agar hooks dimuat." + }, + "Start a new Muse Code session to pick the hooks up.": { + "zh-Hans": "开启新的 Muse Code 会话即可启用 hooks。", + "hi": "hooks लागू करने के लिए Muse Code का नया सत्र शुरू करें।", + "es": "Abre una nueva sesión de Muse Code para que cargue los hooks.", + "ar": "ابدأ جلسة Muse Code جديدة لتفعيل الـ hooks.", + "fr": "Lance une nouvelle session Muse Code pour qu'elle prenne les hooks.", + "bn": "hooks চালু করতে Muse Code-এর নতুন সেশন শুরু করুন।", + "pt-BR": "Inicie uma nova sessão do Muse Code para carregar os hooks.", + "ru": "Начните новую сессию Muse Code, чтобы хуки заработали.", + "id": "Mulai sesi Muse Code baru agar hooks dimuat." + }, + "Start a new chat": { + "zh-Hans": "开始新聊天", + "hi": "नई चैट शुरू करें", + "es": "Empezar un chat nuevo", + "ar": "بدء دردشة جديدة", + "fr": "Commencer un nouveau chat", + "bn": "নতুন চ্যাট শুরু করুন", + "pt-BR": "Começar um novo chat", + "ru": "Начать новый чат", + "id": "Mulai obrolan baru" + }, + "Sunday": { + "zh-Hans": "星期日", + "hi": "रविवार", + "es": "Domingo", + "ar": "الأحد", + "fr": "Dimanche", + "bn": "রবিবার", + "pt-BR": "Domingo", + "ru": "Воскресенье", + "id": "Minggu" + }, + "Sunglasses": { + "zh-Hans": "墨镜", + "hi": "धूप का चश्मा", + "es": "Gafas de sol", + "ar": "نظارة شمسية", + "fr": "Lunettes de soleil", + "bn": "সানগ্লাস", + "pt-BR": "Óculos de sol", + "ru": "Солнечные очки", + "id": "Kacamata hitam" + }, + "Switch provider or model": { + "zh-Hans": "切换服务商或模型", + "hi": "प्रदाता या मॉडल बदलें", + "es": "Cambiar de proveedor o modelo", + "ar": "تبديل المزوّد أو النموذج", + "fr": "Changer de fournisseur ou de modèle", + "bn": "প্রদানকারী বা মডেল বদলান", + "pt-BR": "Trocar de provedor ou modelo", + "ru": "Сменить провайдера или модель", + "id": "Ganti penyedia atau model" + }, + "That key can't be used in a shortcut.": { + "zh-Hans": "这个键不能用于快捷键。", + "hi": "उस कुंजी का शॉर्टकट में उपयोग नहीं हो सकता।", + "es": "Esa tecla no se puede usar en un atajo.", + "ar": "لا يمكن استخدام هذا المفتاح في اختصار.", + "fr": "Cette touche ne peut pas servir dans un raccourci.", + "bn": "সেই কী শর্টকাটে ব্যবহার করা যায় না।", + "pt-BR": "Essa tecla não pode ser usada num atalho.", + "ru": "Эту клавишу нельзя использовать в сочетании.", + "id": "Tombol itu tidak dapat dipakai dalam pintasan." + }, + "The address must start with http:// or https://.": { + "zh-Hans": "地址必须以 http:// 或 https:// 开头。", + "hi": "पता http:// या https:// से शुरू होना चाहिए।", + "es": "La dirección debe empezar por http:// o https://.", + "ar": "يجب أن يبدأ العنوان بـ http:// أو https://.", + "fr": "L'adresse doit commencer par http:// ou https://.", + "bn": "ঠিকানাটি http:// বা https:// দিয়ে শুরু হতে হবে।", + "pt-BR": "O endereço precisa começar com http:// ou https://.", + "ru": "Адрес должен начинаться с http:// или https://.", + "id": "Alamat harus diawali http:// atau https://." + }, + "The file is removed.": { + "zh-Hans": "该文件将被删除。", + "hi": "फ़ाइल हटा दी जाती है।", + "es": "El archivo se elimina.", + "ar": "سيُحذف الملف.", + "fr": "Le fichier est supprimé.", + "bn": "ফাইলটি সরানো হবে।", + "pt-BR": "O arquivo é removido.", + "ru": "Файл удаляется.", + "id": "File dihapus." + }, + "The image could not be read.": { + "zh-Hans": "无法读取图片。", + "hi": "छवि पढ़ी नहीं जा सकी।", + "es": "No se pudo leer la imagen.", + "ar": "تعذّرت قراءة الصورة.", + "fr": "L'image n'a pas pu être lue.", + "bn": "ছবিটি পড়া যায়নি।", + "pt-BR": "Não foi possível ler a imagem.", + "ru": "Не удалось прочитать изображение.", + "id": "Gambar tidak dapat dibaca." + }, + "The image is too large.": { + "zh-Hans": "图片过大。", + "hi": "छवि बहुत बड़ी है।", + "es": "La imagen es demasiado grande.", + "ar": "الصورة كبيرة جدًا.", + "fr": "L'image est trop volumineuse.", + "bn": "ছবিটি অনেক বড়।", + "pt-BR": "A imagem é grande demais.", + "ru": "Изображение слишком большое.", + "id": "Gambar terlalu besar." + }, + "The preview is out of date. Nothing was written — review the new diff.": { + "zh-Hans": "预览已过期。未写入任何内容 — 请查看新的差异。", + "hi": "प्रीव्यू पुराना हो गया है। कुछ नहीं लिखा गया — नया डिफ़ देखें।", + "es": "La vista previa está desactualizada. No se escribió nada — revisa el nuevo diff.", + "ar": "المعاينة قديمة. لم يُكتب أي شيء — راجع الفروقات الجديدة.", + "fr": "L'aperçu n'est plus à jour. Rien n'a été écrit — relis le nouveau diff.", + "bn": "প্রিভিউ পুরোনো হয়ে গেছে। কিছু লেখা হয়নি — নতুন ডিফ দেখুন।", + "pt-BR": "A prévia está desatualizada. Nada foi gravado — revise o novo diff.", + "ru": "Предпросмотр устарел. Ничего не записано — проверьте новый diff.", + "id": "Pratinjau sudah usang. Tidak ada yang ditulis — tinjau diff yang baru." + }, + "The relay isn't installed yet.": { + "zh-Hans": "中继尚未安装。", + "hi": "रिले अभी इंस्टॉल नहीं है।", + "es": "El relay aún no está instalado.", + "ar": "المُرحِّل غير مثبَّت بعد.", + "fr": "Le relais n'est pas encore installé.", + "bn": "রিলে এখনো ইনস্টল হয়নি।", + "pt-BR": "O relay ainda não está instalado.", + "ru": "Ретранслятор ещё не установлен.", + "id": "Relay belum terpasang." + }, + "The relay isn't installed yet. Restart Coucou.": { + "zh-Hans": "中继尚未安装。请重启 Coucou。", + "hi": "रिले अभी इंस्टॉल नहीं है। Coucou को फिर से शुरू करें।", + "es": "El relay aún no está instalado. Reinicia Coucou.", + "ar": "المُرحِّل غير مثبَّت بعد. أعد تشغيل Coucou.", + "fr": "Le relais n'est pas encore installé. Redémarre Coucou.", + "bn": "রিলে এখনো ইনস্টল হয়নি। Coucou আবার চালু করুন।", + "pt-BR": "O relay ainda não está instalado. Reinicie o Coucou.", + "ru": "Ретранслятор ещё не установлен. Перезапустите Coucou.", + "id": "Relay belum terpasang. Mulai ulang Coucou." + }, + "The server refused the key. Check it, then connect again.": { + "zh-Hans": "服务器拒绝了该密钥。请检查后重新连接。", + "hi": "सर्वर ने कुंजी अस्वीकार कर दी। उसे जाँचें, फिर दोबारा कनेक्ट करें।", + "es": "El servidor rechazó la clave. Revísala y vuelve a conectar.", + "ar": "رفض الخادم المفتاح. تحقّق منه ثم اتصل مجددًا.", + "fr": "Le serveur a refusé la clé. Vérifie-la, puis reconnecte-toi.", + "bn": "সার্ভার কী প্রত্যাখ্যান করেছে। যাচাই করে আবার সংযুক্ত করুন।", + "pt-BR": "O servidor recusou a chave. Verifique-a e conecte de novo.", + "ru": "Сервер отклонил ключ. Проверьте его и подключитесь снова.", + "id": "Server menolak kunci. Periksa, lalu hubungkan lagi." + }, + "The server's answer is too large.": { + "zh-Hans": "服务器的回复过大。", + "hi": "सर्वर का जवाब बहुत बड़ा है।", + "es": "La respuesta del servidor es demasiado grande.", + "ar": "رد الخادم كبير جدًا.", + "fr": "La réponse du serveur est trop volumineuse.", + "bn": "সার্ভারের উত্তর অনেক বড়।", + "pt-BR": "A resposta do servidor é grande demais.", + "ru": "Ответ сервера слишком большой.", + "id": "Jawaban server terlalu besar." + }, + "This address is another machine, over plain http: what you ask travels unencrypted.": { + "zh-Hans": "这个地址是另一台机器,且使用明文 http:你的提问会以未加密的形式传输。", + "hi": "यह पता किसी दूसरी मशीन का है, सादे http पर: आप जो पूछते हैं वह बिना एन्क्रिप्शन के जाता है।", + "es": "Esta dirección es otra máquina, por http sin cifrar: lo que preguntas viaja sin cifrado.", + "ar": "هذا العنوان لجهاز آخر عبر http عادي: ما تسأله ينتقل دون تشفير.", + "fr": "Cette adresse est une autre machine, en http simple : ce que tu demandes circule en clair.", + "bn": "এই ঠিকানাটি অন্য মেশিনের, সাধারণ http-তে: আপনি যা জিজ্ঞেস করেন তা এনক্রিপশন ছাড়া যায়।", + "pt-BR": "Este endereço é outra máquina, por http simples: o que você pergunta trafega sem criptografia.", + "ru": "Этот адрес — другой компьютер, по обычному http: ваши запросы передаются без шифрования.", + "id": "Alamat ini adalah mesin lain, melalui http biasa: pertanyaan Anda dikirim tanpa enkripsi." + }, + "This address is another machine: what you ask is sent to it.": { + "zh-Hans": "这个地址是另一台机器:你的提问会发送给它。", + "hi": "यह पता किसी दूसरी मशीन का है: आप जो पूछते हैं वह उसे भेजा जाता है।", + "es": "Esta dirección es otra máquina: lo que preguntas se le envía.", + "ar": "هذا العنوان لجهاز آخر: ما تسأله يُرسل إليه.", + "fr": "Cette adresse est une autre machine : ce que tu demandes lui est envoyé.", + "bn": "এই ঠিকানাটি অন্য একটি মেশিনের: আপনি যা জিজ্ঞেস করেন তা সেখানে পাঠানো হয়।", + "pt-BR": "Este endereço é outra máquina: o que você pergunta é enviado para ela.", + "ru": "Этот адрес — другой компьютер: ваши запросы отправляются туда.", + "id": "Alamat ini adalah mesin lain: pertanyaan Anda dikirim ke sana." + }, + "This agent takes a plugin, not hook entries.": { + "zh-Hans": "此代理使用插件,而不是 hook 条目。", + "hi": "यह एजेंट प्लगइन लेता है, hook प्रविष्टियाँ नहीं।", + "es": "Este agente usa un plugin, no entradas de hooks.", + "ar": "يستخدم هذا الوكيل إضافة، لا إدخالات hooks.", + "fr": "Cet agent prend un plugin, pas des entrées de hooks.", + "bn": "এই এজেন্ট প্লাগইন নেয়, hook এন্ট্রি নয়।", + "pt-BR": "Este agente usa um plugin, não entradas de hooks.", + "ru": "Этот агент использует плагин, а не записи хуков.", + "id": "Agen ini memakai plugin, bukan entri hook." + }, + "This is exactly what changes for {name}. Nothing else is touched.": { + "zh-Hans": "这正是 {name} 将发生的改动。其他内容不会被触碰。", + "hi": "{name} के लिए ठीक यही बदलेगा। और कुछ नहीं छुआ जाता।", + "es": "Esto es exactamente lo que cambia para {name}. No se toca nada más.", + "ar": "هذا بالضبط ما سيتغيّر في {name}. لن يُمسّ أي شيء آخر.", + "fr": "Voici exactement ce qui change pour {name}. Rien d'autre n'est touché.", + "bn": "{name}-এর জন্য ঠিক এটাই বদলাবে। আর কিছুতে হাত দেওয়া হয় না।", + "pt-BR": "Isto é exatamente o que muda para {name}. Nada mais é alterado.", + "ru": "Вот что именно изменится для {name}. Больше ничего не затрагивается.", + "id": "Inilah persisnya yang berubah untuk {name}. Tidak ada yang lain disentuh." + }, + "This is exactly what will change in your settings.json. Your own hooks are left untouched.": { + "zh-Hans": "这正是你的 settings.json 将发生的改动。你自己的 hooks 不会被触碰。", + "hi": "आपकी settings.json में ठीक यही बदलेगा। आपके अपने hooks नहीं छुए जाते।", + "es": "Esto es exactamente lo que cambiará en tu settings.json. Tus propios hooks no se tocan.", + "ar": "هذا بالضبط ما سيتغيّر في ملف settings.json. لن تُمسّ hooks الخاصة بك.", + "fr": "Voici exactement ce qui va changer dans ton settings.json. Tes propres hooks ne sont pas touchés.", + "bn": "আপনার settings.json-এ ঠিক এটাই বদলাবে। আপনার নিজের hooks-এ হাত দেওয়া হয় না।", + "pt-BR": "Isto é exatamente o que vai mudar no seu settings.json. Seus próprios hooks não são alterados.", + "ru": "Вот что именно изменится в вашем settings.json. Ваши собственные хуки не затрагиваются.", + "id": "Inilah persisnya yang akan berubah di settings.json Anda. Hooks milik Anda tidak disentuh." + }, + "This is exactly what will change: only the status line. If you already have one it keeps working, Coucou's relay runs it for you.": { + "zh-Hans": "这正是将发生的改动:只涉及状态栏。如果你已有状态栏,它会照常工作,由 Coucou 的中继替你运行。", + "hi": "ठीक यही बदलेगा: केवल स्टेटस लाइन। अगर आपके पास पहले से एक है तो वह चलती रहेगी, Coucou का रिले उसे आपके लिए चलाता है।", + "es": "Esto es exactamente lo que cambiará: solo la línea de estado. Si ya tienes una, sigue funcionando: el relay de Coucou la ejecuta por ti.", + "ar": "هذا بالضبط ما سيتغيّر: سطر الحالة فقط. إن كان لديك سطر حالة فسيستمر في العمل، إذ يشغّله مُرحِّل Coucou نيابةً عنك.", + "fr": "Voici exactement ce qui va changer : seulement la ligne d'état. Si tu en as déjà une, elle continue de fonctionner, le relais de Coucou la lance pour toi.", + "bn": "ঠিক এটাই বদলাবে: শুধু স্ট্যাটাস লাইন। আপনার আগে থেকে একটি থাকলে সেটি চলতেই থাকবে, Coucou-র রিলে আপনার হয়ে সেটি চালায়।", + "pt-BR": "Isto é exatamente o que vai mudar: apenas a linha de status. Se você já tem uma, ela continua funcionando: o relay do Coucou a executa para você.", + "ru": "Вот что именно изменится: только строка состояния. Если она у вас уже есть, она продолжит работать — её запускает ретранслятор Coucou.", + "id": "Inilah persisnya yang akan berubah: hanya baris status. Jika Anda sudah punya, baris itu tetap berjalan, relay Coucou menjalankannya untuk Anda." + }, + "This puts your previous status line back, or removes the entry if there was none.": { + "zh-Hans": "这会恢复你之前的状态栏;如果之前没有,则移除该条目。", + "hi": "यह आपकी पिछली स्टेटस लाइन वापस लगाता है, या अगर कोई नहीं थी तो प्रविष्टि हटा देता है।", + "es": "Esto devuelve tu línea de estado anterior, o quita la entrada si no había ninguna.", + "ar": "يعيد هذا سطر الحالة السابق، أو يزيل الإدخال إن لم يكن هناك سطر.", + "fr": "Ta ligne d'état précédente est remise en place, ou l'entrée est retirée s'il n'y en avait pas.", + "bn": "এটি আপনার আগের স্ট্যাটাস লাইন ফিরিয়ে আনে, অথবা আগে না থাকলে এন্ট্রিটি সরিয়ে দেয়।", + "pt-BR": "Isto restaura sua linha de status anterior, ou remove a entrada se não havia nenhuma.", + "ru": "Возвращается ваша прежняя строка состояния, а если её не было — запись удаляется.", + "id": "Ini mengembalikan baris status Anda sebelumnya, atau menghapus entri jika sebelumnya tidak ada." + }, + "This removes Coucou's entries only. Everything else stays.": { + "zh-Hans": "这只会移除 Coucou 的条目,其余内容保持不变。", + "hi": "यह केवल Coucou की प्रविष्टियाँ हटाता है। बाकी सब वैसा ही रहता है।", + "es": "Esto solo quita las entradas de Coucou. Todo lo demás se queda.", + "ar": "يزيل هذا إدخالات Coucou فقط. يبقى كل شيء آخر كما هو.", + "fr": "Seules les entrées de Coucou sont retirées. Tout le reste est conservé.", + "bn": "এটি শুধু Coucou-র এন্ট্রি সরায়। বাকি সব থেকে যায়।", + "pt-BR": "Isto remove apenas as entradas do Coucou. O resto continua.", + "ru": "Удаляются только записи Coucou. Всё остальное остаётся.", + "id": "Ini hanya menghapus entri Coucou. Yang lain tetap ada." + }, + "This removes Coucou's entries only. Your own hooks are left untouched.": { + "zh-Hans": "这只会移除 Coucou 的条目。你自己的 hooks 不会被触碰。", + "hi": "यह केवल Coucou की प्रविष्टियाँ हटाता है। आपके अपने hooks नहीं छुए जाते।", + "es": "Esto solo quita las entradas de Coucou. Tus propios hooks no se tocan.", + "ar": "يزيل هذا إدخالات Coucou فقط. لن تُمسّ hooks الخاصة بك.", + "fr": "Seules les entrées de Coucou sont retirées. Tes propres hooks ne sont pas touchés.", + "bn": "এটি শুধু Coucou-র এন্ট্রি সরায়। আপনার নিজের hooks-এ হাত দেওয়া হয় না।", + "pt-BR": "Isto remove apenas as entradas do Coucou. Seus próprios hooks não são alterados.", + "ru": "Удаляются только записи Coucou. Ваши собственные хуки не затрагиваются.", + "id": "Ini hanya menghapus entri Coucou. Hooks milik Anda tidak disentuh." + }, + "Thursday": { + "zh-Hans": "星期四", + "hi": "गुरुवार", + "es": "Jueves", + "ar": "الخميس", + "fr": "Jeudi", + "bn": "বৃহস্পতিবার", + "pt-BR": "Quinta-feira", + "ru": "Четверг", + "id": "Kamis" + }, + "Token": { + "zh-Hans": "令牌", + "hi": "टोकन", + "es": "Token", + "ar": "الرمز المميز", + "fr": "Jeton", + "bn": "টোকেন", + "pt-BR": "Token", + "ru": "Токен", + "id": "Token" + }, + "Token lacks access": { + "zh-Hans": "令牌没有访问权限", + "hi": "टोकन के पास पहुँच नहीं है", + "es": "El token no tiene acceso", + "ar": "الرمز لا يملك صلاحية الوصول", + "fr": "Le jeton n'a pas l'accès", + "bn": "টোকেনের অ্যাক্সেস নেই", + "pt-BR": "O token não tem acesso", + "ru": "У токена нет доступа", + "id": "Token tidak punya akses" + }, + "Token lacks the needed scope": { + "zh-Hans": "令牌缺少所需的权限范围", + "hi": "टोकन में ज़रूरी स्कोप नहीं है", + "es": "Al token le falta el alcance necesario", + "ar": "يفتقر الرمز إلى النطاق المطلوب", + "fr": "Il manque au jeton la portée nécessaire", + "bn": "টোকেনে প্রয়োজনীয় স্কোপ নেই", + "pt-BR": "Falta ao token o escopo necessário", + "ru": "У токена нет нужной области доступа", + "id": "Token tidak memiliki cakupan yang diperlukan" + }, + "Total stars": { + "zh-Hans": "星标总数", + "hi": "कुल स्टार", + "es": "Estrellas totales", + "ar": "إجمالي النجوم", + "fr": "Étoiles au total", + "bn": "মোট স্টার", + "pt-BR": "Total de estrelas", + "ru": "Всего звёзд", + "id": "Total bintang" + }, + "Tuesday": { + "zh-Hans": "星期二", + "hi": "मंगलवार", + "es": "Martes", + "ar": "الثلاثاء", + "fr": "Mardi", + "bn": "মঙ্গলবার", + "pt-BR": "Terça-feira", + "ru": "Вторник", + "id": "Selasa" + }, + "Turn it on once with `hermes plugins enable coucou`, then start a new Hermes session.": { + "zh-Hans": "先用 `hermes plugins enable coucou` 启用一次,然后开启新的 Hermes 会话。", + "hi": "इसे एक बार `hermes plugins enable coucou` से चालू करें, फिर Hermes का नया सत्र शुरू करें।", + "es": "Actívalo una vez con `hermes plugins enable coucou` y luego abre una nueva sesión de Hermes.", + "ar": "فعّله مرة واحدة بالأمر `hermes plugins enable coucou`، ثم ابدأ جلسة Hermes جديدة.", + "fr": "Active-le une fois avec `hermes plugins enable coucou`, puis lance une nouvelle session Hermes.", + "bn": "একবার `hermes plugins enable coucou` দিয়ে চালু করুন, তারপর Hermes-এর নতুন সেশন শুরু করুন।", + "pt-BR": "Ative uma vez com `hermes plugins enable coucou` e depois inicie uma nova sessão do Hermes.", + "ru": "Включите его один раз командой `hermes plugins enable coucou`, затем начните новую сессию Hermes.", + "id": "Aktifkan sekali dengan `hermes plugins enable coucou`, lalu mulai sesi Hermes baru." + }, + "Types “{char}”": { + "zh-Hans": "输入“{char}”", + "hi": "“{char}” टाइप करता है", + "es": "Escribe «{char}»", + "ar": "يكتب «{char}»", + "fr": "Tape « {char} »", + "bn": "“{char}” টাইপ করে", + "pt-BR": "Digita “{char}”", + "ru": "Вводит «{char}»", + "id": "Mengetik “{char}”" + }, + "Unexpected API response.": { + "zh-Hans": "API 返回了意外的响应。", + "hi": "API से अप्रत्याशित जवाब।", + "es": "Respuesta inesperada de la API.", + "ar": "استجابة غير متوقعة من الواجهة البرمجية.", + "fr": "Réponse inattendue de l'API.", + "bn": "API থেকে অপ্রত্যাশিত উত্তর।", + "pt-BR": "Resposta inesperada da API.", + "ru": "Неожиданный ответ API.", + "id": "Respons API tak terduga." + }, + "Uninstall hooks…": { + "zh-Hans": "卸载 hooks…", + "hi": "hooks अनइंस्टॉल करें…", + "es": "Desinstalar hooks…", + "ar": "إلغاء تثبيت الـ hooks…", + "fr": "Désinstaller les hooks…", + "bn": "hooks আনইনস্টল করুন…", + "pt-BR": "Desinstalar hooks…", + "ru": "Удалить хуки…", + "id": "Copot hooks…" + }, + "Uninstall relay…": { + "zh-Hans": "卸载中继…", + "hi": "रिले अनइंस्टॉल करें…", + "es": "Desinstalar relay…", + "ar": "إلغاء تثبيت المُرحِّل…", + "fr": "Désinstaller le relais…", + "bn": "রিলে আনইনস্টল করুন…", + "pt-BR": "Desinstalar relay…", + "ru": "Удалить ретранслятор…", + "id": "Copot relay…" + }, + "Uninstall…": { + "zh-Hans": "卸载…", + "hi": "अनइंस्टॉल करें…", + "es": "Desinstalar…", + "ar": "إلغاء التثبيت…", + "fr": "Désinstaller…", + "bn": "আনইনস্টল করুন…", + "pt-BR": "Desinstalar…", + "ru": "Удалить…", + "id": "Copot…" + }, + "Unknown agent \"{id}\".": { + "zh-Hans": "未知代理“{id}”。", + "hi": "अज्ञात एजेंट “{id}”।", + "es": "Agente desconocido «{id}».", + "ar": "وكيل غير معروف «{id}».", + "fr": "Agent inconnu « {id} ».", + "bn": "অজানা এজেন্ট “{id}”।", + "pt-BR": "Agente desconhecido “{id}”.", + "ru": "Неизвестный агент «{id}».", + "id": "Agen tidak dikenal “{id}”." + }, + "Untitled": { + "zh-Hans": "无标题", + "hi": "बिना शीर्षक", + "es": "Sin título", + "ar": "بلا عنوان", + "fr": "Sans titre", + "bn": "শিরোনামহীন", + "pt-BR": "Sem título", + "ru": "Без названия", + "id": "Tanpa judul" + }, + "Use a secret key (sk_live_… not pk_live_…)": { + "zh-Hans": "请使用私密密钥(sk_live_…,而不是 pk_live_…)", + "hi": "सीक्रेट कुंजी का उपयोग करें (sk_live_…, pk_live_… नहीं)", + "es": "Usa una clave secreta (sk_live_…, no pk_live_…)", + "ar": "استخدم مفتاحًا سريًا (sk_live_… وليس pk_live_…)", + "fr": "Utilise une clé secrète (sk_live_…, pas pk_live_…)", + "bn": "সিক্রেট কী ব্যবহার করুন (sk_live_…, pk_live_… নয়)", + "pt-BR": "Use uma chave secreta (sk_live_…, não pk_live_…)", + "ru": "Используйте секретный ключ (sk_live_…, а не pk_live_…)", + "id": "Gunakan kunci rahasia (sk_live_…, bukan pk_live_…)" + }, + "Use in chat": { + "zh-Hans": "在聊天中使用", + "hi": "चैट में उपयोग करें", + "es": "Usar en el chat", + "ar": "استخدامه في الدردشة", + "fr": "Utiliser dans le chat", + "bn": "চ্যাটে ব্যবহার করুন", + "pt-BR": "Usar no chat", + "ru": "Использовать в чате", + "id": "Pakai di obrolan" + }, + "Used twice": { + "zh-Hans": "重复使用", + "hi": "दो बार उपयोग", + "es": "Usado dos veces", + "ar": "مستخدم مرتين", + "fr": "Utilisé deux fois", + "bn": "দুবার ব্যবহৃত", + "pt-BR": "Usado duas vezes", + "ru": "Используется дважды", + "id": "Dipakai dua kali" + }, + "Wardrobe…": { + "zh-Hans": "衣橱…", + "hi": "अलमारी…", + "es": "Guardarropa…", + "ar": "خزانة الملابس…", + "fr": "Garde-robe…", + "bn": "পোশাক ঘর…", + "pt-BR": "Guarda-roupa…", + "ru": "Гардероб…", + "id": "Lemari…" + }, + "Warning: the key would be sent unencrypted (http://) to another machine. Use https://, or a server on this computer.": { + "zh-Hans": "警告:密钥将以未加密的方式(http://)发送到另一台机器。请使用 https://,或本机上的服务器。", + "hi": "चेतावनी: कुंजी बिना एन्क्रिप्शन (http://) के किसी दूसरी मशीन को भेजी जाएगी। https:// या इसी कंप्यूटर का सर्वर उपयोग करें।", + "es": "Atención: la clave se enviaría sin cifrar (http://) a otra máquina. Usa https:// o un servidor en este equipo.", + "ar": "تحذير: سيُرسل المفتاح دون تشفير (http://) إلى جهاز آخر. استخدم https:// أو خادمًا على هذا الحاسوب.", + "fr": "Attention : la clé serait envoyée en clair (http://) à une autre machine. Utilise https://, ou un serveur sur cet ordinateur.", + "bn": "সতর্কতা: কী এনক্রিপশন ছাড়া (http://) অন্য মেশিনে পাঠানো হবে। https:// অথবা এই কম্পিউটারের সার্ভার ব্যবহার করুন।", + "pt-BR": "Atenção: a chave seria enviada sem criptografia (http://) para outra máquina. Use https:// ou um servidor neste computador.", + "ru": "Внимание: ключ будет отправлен без шифрования (http://) на другой компьютер. Используйте https:// или сервер на этом компьютере.", + "id": "Peringatan: kunci akan dikirim tanpa enkripsi (http://) ke mesin lain. Gunakan https://, atau server di komputer ini." + }, + "Wednesday": { + "zh-Hans": "星期三", + "hi": "बुधवार", + "es": "Miércoles", + "ar": "الأربعاء", + "fr": "Mercredi", + "bn": "বুধবার", + "pt-BR": "Quarta-feira", + "ru": "Среда", + "id": "Rabu" + }, + "Where you code": { + "zh-Hans": "编程环境", + "hi": "जहाँ आप कोड करते हैं", + "es": "Dónde programas", + "ar": "مكان البرمجة", + "fr": "Où tu codes", + "bn": "যেখানে আপনি কোড করেন", + "pt-BR": "Onde você programa", + "ru": "Где вы пишете код", + "id": "Tempat Anda membuat kode" + }, + "Witch hat": { + "zh-Hans": "女巫帽", + "hi": "चुड़ैल की टोपी", + "es": "Sombrero de bruja", + "ar": "قبعة ساحرة", + "fr": "Chapeau de sorcière", + "bn": "ডাইনির টুপি", + "pt-BR": "Chapéu de bruxa", + "ru": "Шляпа ведьмы", + "id": "Topi penyihir" + }, + "Work from any app. Click a shortcut to change it, then press the new keys — Esc cancels, Backspace removes it.": { + "zh-Hans": "在任何应用中都可使用。点击某个快捷键即可修改,然后按下新的按键 — Esc 取消,Backspace 删除。", + "hi": "किसी भी ऐप से काम करते हैं। शॉर्टकट बदलने के लिए उस पर क्लिक करें, फिर नई कुंजियाँ दबाएँ — Esc रद्द करता है, Backspace उसे हटाता है।", + "es": "Funcionan desde cualquier app. Haz clic en un atajo para cambiarlo y pulsa las nuevas teclas — Esc cancela, Backspace lo quita.", + "ar": "تعمل من أي تطبيق. انقر على اختصار لتغييره، ثم اضغط المفاتيح الجديدة — Esc يلغي، وBackspace يزيله.", + "fr": "Ils marchent depuis n'importe quelle app. Clique sur un raccourci pour le changer, puis appuie sur les nouvelles touches — Esc annule, Backspace le retire.", + "bn": "যেকোনো অ্যাপ থেকে কাজ করে। বদলাতে একটি শর্টকাটে ক্লিক করুন, তারপর নতুন কী চাপুন — Esc বাতিল করে, Backspace সেটি সরায়।", + "pt-BR": "Funcionam em qualquer app. Clique num atalho para alterá-lo e pressione as novas teclas — Esc cancela, Backspace remove.", + "ru": "Работают из любого приложения. Нажмите на сочетание, чтобы изменить его, затем нажмите новые клавиши — Esc отменяет, Backspace удаляет.", + "id": "Berfungsi dari aplikasi apa pun. Klik pintasan untuk mengubahnya, lalu tekan tombol baru — Esc membatalkan, Backspace menghapusnya." + }, + "Workflow": { + "zh-Hans": "工作流", + "hi": "वर्कफ़्लो", + "es": "Flujo de trabajo", + "ar": "سير العمل", + "fr": "Workflow", + "bn": "ওয়ার্কফ্লো", + "pt-BR": "Fluxo de trabalho", + "ru": "Сценарий", + "id": "Alur kerja" + }, + "Write to {path} failed: {error}": { + "zh-Hans": "写入 {path} 失败:{error}", + "hi": "{path} में लिखना विफल रहा: {error}", + "es": "Falló la escritura en {path}: {error}", + "ar": "فشلت الكتابة في {path}: {error}", + "fr": "L'écriture dans {path} a échoué : {error}", + "bn": "{path}-এ লেখা ব্যর্থ: {error}", + "pt-BR": "A gravação em {path} falhou: {error}", + "ru": "Не удалось записать {path}: {error}", + "id": "Gagal menulis ke {path}: {error}" + }, + "Your Wayland desktop doesn't let apps listen for keys outside their own windows. Add the shortcuts in your system's keyboard settings instead, with these commands:": { + "zh-Hans": "你的 Wayland 桌面不允许应用监听自身窗口之外的按键。请改为在系统的键盘设置中添加快捷键,使用以下命令:", + "hi": "आपका Wayland डेस्कटॉप ऐप्स को उनकी अपनी विंडो के बाहर कुंजियाँ सुनने नहीं देता। इसके बजाय इन कमांड के साथ अपने सिस्टम की कीबोर्ड सेटिंग्स में शॉर्टकट जोड़ें:", + "es": "Tu escritorio Wayland no deja que las apps escuchen teclas fuera de sus propias ventanas. Añade los atajos en la configuración de teclado del sistema, con estos comandos:", + "ar": "لا يسمح سطح مكتب Wayland لديك للتطبيقات بالتقاط المفاتيح خارج نوافذها. أضف الاختصارات من إعدادات لوحة المفاتيح في نظامك بدلًا من ذلك، بهذه الأوامر:", + "fr": "Ton bureau Wayland ne laisse pas les apps écouter les touches en dehors de leurs propres fenêtres. Ajoute plutôt les raccourcis dans les réglages clavier de ton système, avec ces commandes :", + "bn": "আপনার Wayland ডেস্কটপ অ্যাপকে নিজের উইন্ডোর বাইরে কী শুনতে দেয় না। এর বদলে এই কমান্ডগুলো দিয়ে সিস্টেমের কিবোর্ড সেটিংসে শর্টকাট যোগ করুন:", + "pt-BR": "Seu desktop Wayland não deixa os apps ouvirem teclas fora das próprias janelas. Em vez disso, adicione os atalhos nas configurações de teclado do sistema, com estes comandos:", + "ru": "Ваш рабочий стол Wayland не позволяет приложениям слушать клавиши вне своих окон. Добавьте сочетания в настройках клавиатуры системы с такими командами:", + "id": "Desktop Wayland Anda tidak mengizinkan aplikasi mendengarkan tombol di luar jendelanya sendiri. Tambahkan pintasan di pengaturan keyboard sistem Anda, dengan perintah berikut:" + }, + "all green": { + "zh-Hans": "全部通过", + "hi": "सब हरा", + "es": "todo en verde", + "ar": "كلها خضراء", + "fr": "tout est vert", + "bn": "সব সবুজ", + "pt-BR": "tudo verde", + "ru": "всё зелёное", + "id": "semua hijau" + }, + "coucou-hook.exe is not in place yet. Restart Coucou; if it still fails, build it with `cargo build -p coucou-hook`.": { + "zh-Hans": "coucou-hook.exe 尚未就位。请重启 Coucou;如果仍然失败,请用 `cargo build -p coucou-hook` 构建它。", + "hi": "coucou-hook.exe अभी अपनी जगह पर नहीं है। Coucou फिर से शुरू करें; अगर फिर भी न हो, तो इसे `cargo build -p coucou-hook` से बनाएँ।", + "es": "coucou-hook.exe aún no está en su sitio. Reinicia Coucou; si sigue fallando, compílalo con `cargo build -p coucou-hook`.", + "ar": "coucou-hook.exe ليس في مكانه بعد. أعد تشغيل Coucou؛ وإن استمر الفشل فابنِه بالأمر `cargo build -p coucou-hook`.", + "fr": "coucou-hook.exe n'est pas encore en place. Redémarre Coucou ; si ça échoue encore, compile-le avec `cargo build -p coucou-hook`.", + "bn": "coucou-hook.exe এখনো জায়গামতো নেই। Coucou আবার চালু করুন; তবুও না হলে `cargo build -p coucou-hook` দিয়ে এটি বানান।", + "pt-BR": "O coucou-hook.exe ainda não está no lugar. Reinicie o Coucou; se continuar falhando, compile-o com `cargo build -p coucou-hook`.", + "ru": "coucou-hook.exe ещё не на месте. Перезапустите Coucou; если не поможет, соберите его командой `cargo build -p coucou-hook`.", + "id": "coucou-hook.exe belum ada di tempatnya. Mulai ulang Coucou; jika masih gagal, build dengan `cargo build -p coucou-hook`." + }, + "failing": { + "zh-Hans": "失败", + "hi": "विफल", + "es": "con fallos", + "ar": "فاشل", + "fr": "en échec", + "bn": "ব্যর্থ", + "pt-BR": "falhando", + "ru": "с ошибками", + "id": "gagal" + }, + "finished": { + "zh-Hans": "已完成", + "hi": "पूरा हुआ", + "es": "terminó", + "ar": "انتهى", + "fr": "a terminé", + "bn": "শেষ হয়েছে", + "pt-BR": "terminou", + "ru": "завершил", + "id": "selesai" + }, + "installed": { + "zh-Hans": "已安装", + "hi": "इंस्टॉल है", + "es": "instalado", + "ar": "مثبَّت", + "fr": "installé", + "bn": "ইনস্টল আছে", + "pt-BR": "instalado", + "ru": "установлен", + "id": "terpasang" + }, + "is asking": { + "zh-Hans": "在提问", + "hi": "पूछ रहा है", + "es": "está preguntando", + "ar": "يسأل", + "fr": "demande", + "bn": "জিজ্ঞেস করছে", + "pt-BR": "está perguntando", + "ru": "спрашивает", + "id": "bertanya" + }, + "is asking ({index} of {total})": { + "zh-Hans": "在提问(第 {index} 个,共 {total} 个)", + "hi": "पूछ रहा है ({total} में से {index})", + "es": "está preguntando ({index} de {total})", + "ar": "يسأل ({index} من {total})", + "fr": "demande ({index} sur {total})", + "bn": "জিজ্ঞেস করছে ({total}টির মধ্যে {index})", + "pt-BR": "está perguntando ({index} de {total})", + "ru": "спрашивает ({index} из {total})", + "id": "bertanya ({index} dari {total})" + }, + "is asking a question": { + "zh-Hans": "在提问", + "hi": "एक प्रश्न पूछ रहा है", + "es": "está haciendo una pregunta", + "ar": "يطرح سؤالًا", + "fr": "pose une question", + "bn": "একটি প্রশ্ন করছে", + "pt-BR": "está fazendo uma pergunta", + "ru": "задаёт вопрос", + "id": "mengajukan pertanyaan" + }, + "lines": { + "zh-Hans": "行", + "hi": "पंक्तियाँ", + "es": "líneas", + "ar": "أسطر", + "fr": "lignes", + "bn": "লাইন", + "pt-BR": "linhas", + "ru": "строк", + "id": "baris" + }, + "no repos": { + "zh-Hans": "没有仓库", + "hi": "कोई रिपॉज़िटरी नहीं", + "es": "sin repositorios", + "ar": "لا مستودعات", + "fr": "aucun dépôt", + "bn": "কোনো রিপোজিটরি নেই", + "pt-BR": "nenhum repositório", + "ru": "нет репозиториев", + "id": "tidak ada repositori" + }, + "not installed": { + "zh-Hans": "未安装", + "hi": "इंस्टॉल नहीं है", + "es": "no instalado", + "ar": "غير مثبَّت", + "fr": "non installé", + "bn": "ইনস্টল নেই", + "pt-BR": "não instalado", + "ru": "не установлен", + "id": "belum terpasang" + }, + "passing": { + "zh-Hans": "通过", + "hi": "सफल", + "es": "correcto", + "ar": "ناجح", + "fr": "réussi", + "bn": "সফল", + "pt-BR": "passando", + "ru": "успешно", + "id": "lulus" + }, + "running": { + "zh-Hans": "运行中", + "hi": "चल रहा है", + "es": "en curso", + "ar": "قيد التشغيل", + "fr": "en cours", + "bn": "চলছে", + "pt-BR": "em execução", + "ru": "выполняется", + "id": "berjalan" + }, + "seconds after you leave the island": { + "zh-Hans": "鼠标离开岛后的秒数", + "hi": "आइलैंड छोड़ने के बाद सेकंड", + "es": "segundos después de salir de la isla", + "ar": "ثانية بعد مغادرة الشريط", + "fr": "secondes après avoir quitté l'île", + "bn": "আইল্যান্ড ছাড়ার পর সেকেন্ড", + "pt-BR": "segundos depois de sair da ilha", + "ru": "секунд после ухода с острова", + "id": "detik setelah Anda meninggalkan pulau" + }, + "settings.json: {what} has an unexpected type — Coucou has not touched it.": { + "zh-Hans": "settings.json:{what} 的类型不符合预期 — Coucou 没有修改它。", + "hi": "settings.json: {what} का प्रकार अपेक्षित नहीं है — Coucou ने इसे नहीं छुआ।", + "es": "settings.json: {what} tiene un tipo inesperado — Coucou no lo ha tocado.", + "ar": "settings.json: {what} من نوع غير متوقع — لم يمسّه Coucou.", + "fr": "settings.json : {what} a un type inattendu — Coucou n'y a pas touché.", + "bn": "settings.json: {what}-এর ধরন অপ্রত্যাশিত — Coucou এতে হাত দেয়নি।", + "pt-BR": "settings.json: {what} tem um tipo inesperado — o Coucou não mexeu nele.", + "ru": "settings.json: у {what} неожиданный тип — Coucou его не трогал.", + "id": "settings.json: {what} bertipe tak terduga — Coucou tidak menyentuhnya." + }, + "unknown": { + "zh-Hans": "未知", + "hi": "अज्ञात", + "es": "desconocido", + "ar": "غير معروف", + "fr": "inconnu", + "bn": "অজানা", + "pt-BR": "desconhecido", + "ru": "неизвестно", + "id": "tidak diketahui" + }, + "{allowed} allowed, {denied} denied": { + "zh-Hans": "允许 {allowed} 次,拒绝 {denied} 次", + "hi": "{allowed} अनुमत, {denied} अस्वीकृत", + "es": "{allowed} permitidos, {denied} denegados", + "ar": "{allowed} مسموح، {denied} مرفوض", + "fr": "{allowed} autorisées, {denied} refusées", + "bn": "{allowed}টি অনুমোদিত, {denied}টি প্রত্যাখ্যাত", + "pt-BR": "{allowed} permitidas, {denied} negadas", + "ru": "разрешено: {allowed}, отклонено: {denied}", + "id": "{allowed} diizinkan, {denied} ditolak" + }, + "{count} contributions": { + "zh-Hans": { + "other": "{count} 次贡献" + }, + "hi": { + "one": "{count} योगदान", + "other": "{count} योगदान" + }, + "es": { + "one": "{count} contribución", + "other": "{count} contribuciones" + }, + "ar": { + "zero": "{count} مساهمة", + "one": "مساهمة واحدة", + "two": "مساهمتان", + "few": "{count} مساهمات", + "many": "{count} مساهمة", + "other": "{count} مساهمة" + }, + "fr": { + "one": "{count} contribution", + "other": "{count} contributions" + }, + "bn": { + "one": "{count}টি অবদান", + "other": "{count}টি অবদান" + }, + "pt-BR": { + "one": "{count} contribuição", + "other": "{count} contribuições" + }, + "ru": { + "one": "{count} вклад", + "few": "{count} вклада", + "many": "{count} вкладов", + "other": "{count} вклада" + }, + "id": { + "other": "{count} kontribusi" + } + }, + "{count} models": { + "zh-Hans": { + "other": "{count} 个模型" + }, + "hi": { + "one": "{count} मॉडल", + "other": "{count} मॉडल" + }, + "es": { + "one": "{count} modelo", + "other": "{count} modelos" + }, + "ar": { + "zero": "{count} نموذج", + "one": "نموذج واحد", + "two": "نموذجان", + "few": "{count} نماذج", + "many": "{count} نموذجًا", + "other": "{count} نموذج" + }, + "fr": { + "one": "{count} modèle", + "other": "{count} modèles" + }, + "bn": { + "one": "{count}টি মডেল", + "other": "{count}টি মডেল" + }, + "pt-BR": { + "one": "{count} modelo", + "other": "{count} modelos" + }, + "ru": { + "one": "{count} модель", + "few": "{count} модели", + "many": "{count} моделей", + "other": "{count} модели" + }, + "id": { + "other": "{count} model" + } + }, + "{count} repos": { + "zh-Hans": { + "other": "{count} 个仓库" + }, + "hi": { + "one": "{count} रिपॉज़िटरी", + "other": "{count} रिपॉज़िटरी" + }, + "es": { + "one": "{count} repositorio", + "other": "{count} repositorios" + }, + "ar": { + "zero": "{count} مستودع", + "one": "مستودع واحد", + "two": "مستودعان", + "few": "{count} مستودعات", + "many": "{count} مستودعًا", + "other": "{count} مستودع" + }, + "fr": { + "one": "{count} dépôt", + "other": "{count} dépôts" + }, + "bn": { + "one": "{count}টি রিপোজিটরি", + "other": "{count}টি রিপোজিটরি" + }, + "pt-BR": { + "one": "{count} repositório", + "other": "{count} repositórios" + }, + "ru": { + "one": "{count} репозиторий", + "few": "{count} репозитория", + "many": "{count} репозиториев", + "other": "{count} репозитория" + }, + "id": { + "other": "{count} repositori" + } + }, + "{file} isn't UTF-8 text — Coucou won't touch it.": { + "zh-Hans": "{file} 不是 UTF-8 文本 — Coucou 不会修改它。", + "hi": "{file} UTF-8 टेक्स्ट नहीं है — Coucou इसे नहीं छुएगा।", + "es": "{file} no es texto UTF-8 — Coucou no lo tocará.", + "ar": "{file} ليس نصًا بترميز UTF-8 — لن يمسّه Coucou.", + "fr": "{file} n'est pas du texte UTF-8 — Coucou n'y touchera pas.", + "bn": "{file} UTF-8 টেক্সট নয় — Coucou এতে হাত দেবে না।", + "pt-BR": "{file} não é texto UTF-8 — o Coucou não vai mexer nele.", + "ru": "{file} — не текст в UTF-8, Coucou его не тронет.", + "id": "{file} bukan teks UTF-8 — Coucou tidak akan menyentuhnya." + }, + "{file} isn't a JSON object — Coucou won't touch it.": { + "zh-Hans": "{file} 不是 JSON 对象 — Coucou 不会修改它。", + "hi": "{file} JSON ऑब्जेक्ट नहीं है — Coucou इसे नहीं छुएगा।", + "es": "{file} no es un objeto JSON — Coucou no lo tocará.", + "ar": "{file} ليس كائن JSON — لن يمسّه Coucou.", + "fr": "{file} n'est pas un objet JSON — Coucou n'y touchera pas.", + "bn": "{file} JSON অবজেক্ট নয় — Coucou এতে হাত দেবে না।", + "pt-BR": "{file} não é um objeto JSON — o Coucou não vai mexer nele.", + "ru": "{file} — не объект JSON, Coucou его не тронет.", + "id": "{file} bukan objek JSON — Coucou tidak akan menyentuhnya." + }, + "{file} isn't valid JSON ({error}). Fix or move it, then try again — Coucou won't overwrite it.": { + "zh-Hans": "{file} 不是有效的 JSON({error})。请修复或移走它后重试 — Coucou 不会覆盖它。", + "hi": "{file} मान्य JSON नहीं है ({error})। इसे ठीक करें या हटाएँ, फिर दोबारा कोशिश करें — Coucou इसे ओवरराइट नहीं करेगा।", + "es": "{file} no es un JSON válido ({error}). Corrígelo o muévelo y vuelve a intentarlo — Coucou no lo sobrescribirá.", + "ar": "{file} ليس JSON صالحًا ({error}). أصلحه أو انقله ثم حاول مجددًا — لن يكتب Coucou فوقه.", + "fr": "{file} n'est pas un JSON valide ({error}). Corrige-le ou déplace-le, puis réessaie — Coucou ne l'écrasera pas.", + "bn": "{file} বৈধ JSON নয় ({error})। এটি ঠিক করুন বা সরান, তারপর আবার চেষ্টা করুন — Coucou এটি ওভাররাইট করবে না।", + "pt-BR": "{file} não é um JSON válido ({error}). Corrija ou mova o arquivo e tente de novo — o Coucou não vai sobrescrevê-lo.", + "ru": "{file} — некорректный JSON ({error}). Исправьте или переместите его и попробуйте снова — Coucou не будет его перезаписывать.", + "id": "{file} bukan JSON yang valid ({error}). Perbaiki atau pindahkan, lalu coba lagi — Coucou tidak akan menimpanya." + }, + "{h}h {m}m": { + "zh-Hans": "{h} 小时 {m} 分钟", + "hi": "{h} घं. {m} मि.", + "es": "{h} h {m} min", + "ar": "{h} س {m} د", + "fr": "{h} h {m} min", + "bn": "{h} ঘ. {m} মি.", + "pt-BR": "{h} h {m} min", + "ru": "{h} ч {m} мин", + "id": "{h} j {m} mnt" + }, + "{keys} types “{char}” on your keyboard, so it can't be a shortcut. Pick another key.": { + "zh-Hans": "{keys} 在你的键盘上会输入“{char}”,所以不能用作快捷键。请换一个键。", + "hi": "{keys} आपके कीबोर्ड पर “{char}” टाइप करता है, इसलिए यह शॉर्टकट नहीं हो सकता। कोई दूसरी कुंजी चुनें।", + "es": "{keys} escribe «{char}» en tu teclado, así que no puede ser un atajo. Elige otra tecla.", + "ar": "{keys} يكتب «{char}» على لوحة مفاتيحك، لذا لا يمكن أن يكون اختصارًا. اختر مفتاحًا آخر.", + "fr": "{keys} tape « {char} » sur ton clavier, ce ne peut donc pas être un raccourci. Choisis une autre touche.", + "bn": "{keys} আপনার কিবোর্ডে “{char}” টাইপ করে, তাই এটি শর্টকাট হতে পারে না। অন্য একটি কী বেছে নিন।", + "pt-BR": "{keys} digita “{char}” no seu teclado, então não pode ser um atalho. Escolha outra tecla.", + "ru": "{keys} вводит «{char}» на вашей клавиатуре, поэтому не может быть сочетанием. Выберите другую клавишу.", + "id": "{keys} mengetik “{char}” di keyboard Anda, jadi tidak bisa menjadi pintasan. Pilih tombol lain." + }, + "{model} isn't installed. Pick another model above the chat box.": { + "zh-Hans": "{model} 未安装。请在聊天框上方选择其他模型。", + "hi": "{model} इंस्टॉल नहीं है। चैट बॉक्स के ऊपर कोई दूसरा मॉडल चुनें।", + "es": "{model} no está instalado. Elige otro modelo encima del cuadro de chat.", + "ar": "{model} غير مثبَّت. اختر نموذجًا آخر فوق مربع الدردشة.", + "fr": "{model} n'est pas installé. Choisis un autre modèle au-dessus de la zone de chat.", + "bn": "{model} ইনস্টল করা নেই। চ্যাট বক্সের উপরে অন্য মডেল বেছে নিন।", + "pt-BR": "{model} não está instalado. Escolha outro modelo acima da caixa de chat.", + "ru": "{model} не установлена. Выберите другую модель над полем чата.", + "id": "{model} belum terpasang. Pilih model lain di atas kotak obrolan." + }, + "{name} API key missing. Add it in Settings.": { + "zh-Hans": "缺少 {name} 的 API 密钥。请在设置中添加。", + "hi": "{name} API कुंजी नहीं है। इसे सेटिंग्स में जोड़ें।", + "es": "Falta la clave de API de {name}. Añádela en Configuración.", + "ar": "مفتاح API الخاص بـ {name} مفقود. أضفه من الإعدادات.", + "fr": "Clé d'API {name} manquante. Ajoute-la dans les Réglages.", + "bn": "{name} API কী নেই। সেটিংসে যোগ করুন।", + "pt-BR": "Falta a chave de API do {name}. Adicione nas Configurações.", + "ru": "Нет API-ключа {name}. Добавьте его в настройках.", + "id": "Kunci API {name} tidak ada. Tambahkan di Pengaturan." + }, + "{name} rate limit reached (429): {detail}": { + "zh-Hans": "{name} 已达到速率限制(429):{detail}", + "hi": "{name} की दर सीमा पूरी हो गई (429): {detail}", + "es": "{name}: se alcanzó el límite de solicitudes (429): {detail}", + "ar": "بلغ {name} حدّ المعدّل (429): {detail}", + "fr": "{name} : limite de débit atteinte (429) : {detail}", + "bn": "{name}-এর রেট লিমিটে পৌঁছেছে (429): {detail}", + "pt-BR": "{name}: limite de requisições atingido (429): {detail}", + "ru": "{name}: достигнут лимит запросов (429): {detail}", + "id": "Batas laju {name} tercapai (429): {detail}" + }, + "{name} rejected the API key ({status}). Check it in Settings.": { + "zh-Hans": "{name} 拒绝了 API 密钥({status})。请在设置中检查。", + "hi": "{name} ने API कुंजी अस्वीकार कर दी ({status})। इसे सेटिंग्स में जाँचें।", + "es": "{name} rechazó la clave de API ({status}). Revísala en Configuración.", + "ar": "رفض {name} مفتاح API ({status}). تحقّق منه في الإعدادات.", + "fr": "{name} a refusé la clé d'API ({status}). Vérifie-la dans les Réglages.", + "bn": "{name} API কী প্রত্যাখ্যান করেছে ({status})। সেটিংসে যাচাই করুন।", + "pt-BR": "{name} recusou a chave de API ({status}). Verifique-a nas Configurações.", + "ru": "{name} отклонил API-ключ ({status}). Проверьте его в настройках.", + "id": "{name} menolak kunci API ({status}). Periksa di Pengaturan." + }, + "{name} wasn't written by Coucou — Coucou has not touched it.": { + "zh-Hans": "{name} 不是由 Coucou 写入的 — Coucou 没有修改它。", + "hi": "{name} Coucou ने नहीं लिखा था — Coucou ने इसे नहीं छुआ।", + "es": "{name} no lo escribió Coucou — Coucou no lo ha tocado.", + "ar": "لم يكتب Coucou الملف {name} — لم يمسّه Coucou.", + "fr": "{name} n'a pas été écrit par Coucou — Coucou n'y a pas touché.", + "bn": "{name} Coucou লেখেনি — Coucou এতে হাত দেয়নি।", + "pt-BR": "{name} não foi escrito pelo Coucou — o Coucou não mexeu nele.", + "ru": "{name} записан не Coucou — Coucou его не трогал.", + "id": "{name} tidak ditulis oleh Coucou — Coucou tidak menyentuhnya." + }, + "{name} — {width}×{height} at {x},{y}": { + "zh-Hans": "{name} — {width}×{height},位于 {x},{y}", + "hi": "{name} — {width}×{height}, स्थिति {x},{y}", + "es": "{name} — {width}×{height} en {x},{y}", + "ar": "{name} — {width}×{height} عند {x},{y}", + "fr": "{name} — {width}×{height} à {x},{y}", + "bn": "{name} — {width}×{height}, অবস্থান {x},{y}", + "pt-BR": "{name} — {width}×{height} em {x},{y}", + "ru": "{name} — {width}×{height} в точке {x},{y}", + "id": "{name} — {width}×{height} di {x},{y}" + }, + "{name}: model not found (404). Pick another one above the chat box. {detail}": { + "zh-Hans": "{name}:未找到模型(404)。请在聊天框上方选择其他模型。{detail}", + "hi": "{name}: मॉडल नहीं मिला (404)। चैट बॉक्स के ऊपर कोई दूसरा चुनें। {detail}", + "es": "{name}: modelo no encontrado (404). Elige otro encima del cuadro de chat. {detail}", + "ar": "{name}: لم يُعثر على النموذج (404). اختر غيره فوق مربع الدردشة. {detail}", + "fr": "{name} : modèle introuvable (404). Choisis-en un autre au-dessus de la zone de chat. {detail}", + "bn": "{name}: মডেল পাওয়া যায়নি (404)। চ্যাট বক্সের উপরে অন্য একটি বেছে নিন। {detail}", + "pt-BR": "{name}: modelo não encontrado (404). Escolha outro acima da caixa de chat. {detail}", + "ru": "{name}: модель не найдена (404). Выберите другую над полем чата. {detail}", + "id": "{name}: model tidak ditemukan (404). Pilih yang lain di atas kotak obrolan. {detail}" + }, + "{name}: not enough credits (402). {detail}": { + "zh-Hans": "{name}:额度不足(402)。{detail}", + "hi": "{name}: पर्याप्त क्रेडिट नहीं (402)। {detail}", + "es": "{name}: no hay créditos suficientes (402). {detail}", + "ar": "{name}: الرصيد غير كافٍ (402). {detail}", + "fr": "{name} : crédits insuffisants (402). {detail}", + "bn": "{name}: যথেষ্ট ক্রেডিট নেই (402)। {detail}", + "pt-BR": "{name}: créditos insuficientes (402). {detail}", + "ru": "{name}: недостаточно кредитов (402). {detail}", + "id": "{name}: kredit tidak cukup (402). {detail}" + }, + "{n} available": { + "zh-Hans": "可用 {n} 次", + "hi": "{n} उपलब्ध", + "es": "{n} disponibles", + "ar": "{n} متاحة", + "fr": "{n} disponibles", + "bn": "{n}টি উপলব্ধ", + "pt-BR": "{n} disponíveis", + "ru": "доступно: {n}", + "id": "{n} tersedia" + }, + "{n}d": { + "zh-Hans": "{n} 天", + "hi": "{n} दि.", + "es": "{n} d", + "ar": "{n} ي", + "fr": "{n} j", + "bn": "{n} দি.", + "pt-BR": "{n} d", + "ru": "{n} д", + "id": "{n} h" + }, + "{n}h": { + "zh-Hans": "{n} 小时", + "hi": "{n} घं.", + "es": "{n} h", + "ar": "{n} س", + "fr": "{n} h", + "bn": "{n} ঘ.", + "pt-BR": "{n} h", + "ru": "{n} ч", + "id": "{n} j" + }, + "{n}m": { + "zh-Hans": "{n} 分钟", + "hi": "{n} मि.", + "es": "{n} min", + "ar": "{n} د", + "fr": "{n} min", + "bn": "{n} মি.", + "pt-BR": "{n} min", + "ru": "{n} мин", + "id": "{n} mnt" + }, + "{path} changed since the preview. Nothing was written — review the new diff.": { + "zh-Hans": "{path} 在预览后已被修改。未写入任何内容 — 请查看新的差异。", + "hi": "प्रीव्यू के बाद {path} बदल गया। कुछ नहीं लिखा गया — नया डिफ़ देखें।", + "es": "{path} cambió desde la vista previa. No se escribió nada — revisa el nuevo diff.", + "ar": "تغيّر {path} منذ المعاينة. لم يُكتب أي شيء — راجع الفروقات الجديدة.", + "fr": "{path} a changé depuis l'aperçu. Rien n'a été écrit — relis le nouveau diff.", + "bn": "প্রিভিউয়ের পর {path} বদলে গেছে। কিছু লেখা হয়নি — নতুন ডিফ দেখুন।", + "pt-BR": "{path} mudou desde a prévia. Nada foi gravado — revise o novo diff.", + "ru": "{path} изменился после предпросмотра. Ничего не записано — проверьте новый diff.", + "id": "{path} berubah sejak pratinjau. Tidak ada yang ditulis — tinjau diff yang baru." + }, + "{time} ago": { + "zh-Hans": "{time}前", + "hi": "{time} पहले", + "es": "hace {time}", + "ar": "منذ {time}", + "fr": "il y a {time}", + "bn": "{time} আগে", + "pt-BR": "há {time}", + "ru": "{time} назад", + "id": "{time} yang lalu" + }, + "{total} past year": { + "zh-Hans": "过去一年 {total}", + "hi": "पिछले वर्ष {total}", + "es": "{total} el último año", + "ar": "{total} في العام الماضي", + "fr": "{total} sur un an", + "bn": "গত বছরে {total}", + "pt-BR": "{total} no último ano", + "ru": "{total} за год", + "id": "{total} setahun terakhir" + }, + "{used}/{max} slots in use — the main tool doesn't take one.": { + "zh-Hans": "已用 {used}/{max} 个位置 — 主要工具不占位置。", + "hi": "{used}/{max} स्लॉट उपयोग में — मुख्य टूल कोई स्लॉट नहीं लेता।", + "es": "{used}/{max} espacios en uso — la herramienta principal no ocupa ninguno.", + "ar": "{used}/{max} من الخانات مستخدمة — الأداة الرئيسية لا تشغل خانة.", + "fr": "{used}/{max} emplacements utilisés — l'outil principal n'en prend pas.", + "bn": "{used}/{max} স্লট ব্যবহৃত — প্রধান টুল কোনো স্লট নেয় না।", + "pt-BR": "{used}/{max} espaços em uso — a ferramenta principal não ocupa nenhum.", + "ru": "Занято мест: {used}/{max} — основной инструмент место не занимает.", + "id": "{used}/{max} slot terpakai — alat utama tidak memakai slot." + }, + "{what} has an unexpected type — Coucou has not touched it.": { + "zh-Hans": "{what} 的类型不符合预期 — Coucou 没有修改它。", + "hi": "{what} का प्रकार अपेक्षित नहीं है — Coucou ने इसे नहीं छुआ।", + "es": "{what} tiene un tipo inesperado — Coucou no lo ha tocado.", + "ar": "{what} من نوع غير متوقع — لم يمسّه Coucou.", + "fr": "{what} a un type inattendu — Coucou n'y a pas touché.", + "bn": "{what}-এর ধরন অপ্রত্যাশিত — Coucou এতে হাত দেয়নি।", + "pt-BR": "{what} tem um tipo inesperado — o Coucou não mexeu nele.", + "ru": "У {what} неожиданный тип — Coucou его не трогал.", + "id": "{what} bertipe tak terduga — Coucou tidak menyentuhnya." + }, + "• subagent done": { + "zh-Hans": "• 子代理完成", + "hi": "• सब-एजेंट पूरा", + "es": "• subagente terminado", + "ar": "• انتهى الوكيل الفرعي", + "fr": "• sous-agent terminé", + "bn": "• সাব-এজেন্ট শেষ", + "pt-BR": "• subagente concluído", + "ru": "• субагент завершил", + "id": "• subagen selesai" + }, + "⚠ failed": { + "zh-Hans": "⚠ 失败", + "hi": "⚠ विफल", + "es": "⚠ falló", + "ar": "⚠ فشل", + "fr": "⚠ échec", + "bn": "⚠ ব্যর্থ", + "pt-BR": "⚠ falhou", + "ru": "⚠ ошибка", + "id": "⚠ gagal" + } + } +} diff --git a/windows/src/i18n/i18n.ts b/windows/src/i18n/i18n.ts new file mode 100644 index 000000000..d58fe8b03 --- /dev/null +++ b/windows/src/i18n/i18n.ts @@ -0,0 +1,268 @@ +// Interface language — the same ten languages as the Mac (0.2.0). +// +// Strings are looked up by their English text, like the Mac's catalog: +// `t("Allow")`, `t("Uploading {name}", { name })`. Two tables, both keyed by +// the English text: +// - strings.json, generated from the Mac's Localizable.xcstrings by +// scripts/gen-strings.mjs (never edited by hand); +// - extra.json, the strings only Windows and Linux have, written by hand. +// A string missing from both, or from one language, shows in English. +// +// The Rust side (src-tauri/src/i18n.rs) embeds the same two files and looks up +// the same way, for the tray menu, notifications and the errors it returns. +// +// Changing the language never reloads a window: `setLanguage` tells whoever +// listens (`onLanguageChange`), views/dom.ts relabels the text it was given +// as `tl(…)`, and the views redraw the rest on their next sync. + +import MAC from "./strings.json"; +import EXTRA from "./extra.json"; + +export const LANGUAGE_CODES = ["en", "zh-Hans", "hi", "es", "ar", "fr", "bn", "pt-BR", "ru", "id"] as const; +export type Language = (typeof LANGUAGE_CODES)[number]; + +/** The picker's entries, each in its own language, in the Mac's order. */ +export const LANGUAGES: readonly { code: Language; name: string }[] = [ + { code: "en", name: "English" }, + { code: "zh-Hans", name: "简体中文" }, + { code: "hi", name: "हिन्दी" }, + { code: "es", name: "Español" }, + { code: "ar", name: "العربية" }, + { code: "fr", name: "Français" }, + { code: "bn", name: "বাংলা" }, + { code: "pt-BR", name: "Português (Brasil)" }, + { code: "ru", name: "Русский" }, + { code: "id", name: "Bahasa Indonesia" }, +]; + +export type Vars = Record; +type Plural = Partial>; +type Entry = Partial>; +type Table = Record; + +/** extra.json wins over the Mac's table where both have a string. */ +const TABLE: Table = { + ...((MAC as { strings: Table }).strings), + ...((EXTRA as { strings: Table }).strings), +}; + +let current: Language = "en"; +const listeners = new Set<(lang: Language) => void>(); + +export function isLanguage(code: unknown): code is Language { + return typeof code === "string" && (LANGUAGE_CODES as readonly string[]).includes(code); +} + +/** + * The language to show: the one picked in Settings when it is one of ours, + * else the first of the system's languages we have ("pt" and "pt-PT" read as + * Brazilian Portuguese, "zh-CN", "zh-SG" and "zh" as Simplified Chinese), + * else English. + */ +export function resolveLanguage(picked: string | undefined | null, system: readonly string[] = []): Language { + if (isLanguage(picked)) return picked; + for (const raw of system) { + const tag = String(raw ?? "").replace(/_/g, "-").toLowerCase(); + if (!tag) continue; + const base = tag.split("-")[0]; + if (base === "zh") { + // Traditional Chinese is not one of ours: English rather than the wrong script. + if (/^zh-(hant|tw|hk|mo)\b/.test(tag)) continue; + return "zh-Hans"; + } + if (base === "pt") return "pt-BR"; + const exact = LANGUAGE_CODES.find((c) => c.toLowerCase() === base); + if (exact) return exact; + } + return "en"; +} + +/** The system's languages, as the webview knows them. */ +export function systemLanguages(): string[] { + if (typeof navigator === "undefined") return []; + const list = navigator.languages?.length ? [...navigator.languages] : []; + if (navigator.language && !list.includes(navigator.language)) list.push(navigator.language); + return list; +} + +export function language(): Language { + return current; +} + +/** Arabic reads right to left. */ +export function isRtl(lang: Language = current): boolean { + return lang === "ar"; +} + +/** Switches the language. Returns true when it changed (listeners were told). */ +export function setLanguage(lang: Language): boolean { + if (!isLanguage(lang) || lang === current) return false; + current = lang; + applyDocumentLanguage(); + for (const listener of [...listeners]) listener(lang); + return true; +} + +/** Called after every language change; returns the unsubscribe. */ +export function onLanguageChange(listener: (lang: Language) => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** + * follows the language, so the webview picks fitting fonts, and + * says which way it reads: the island's CSS turns its text + * containers right to left with it, leaving Mochi, the pills and the header + * where they are. (The settings window sets `dir` on the whole page.) + */ +export function applyDocumentLanguage() { + if (typeof document === "undefined" || !document.documentElement) return; + const root = document.documentElement; + root.lang = current; + if (root.dataset) root.dataset.dir = isRtl() ? "rtl" : "ltr"; +} + +function interpolate(text: string, vars?: Vars): string { + if (!vars) return text; + return text.replace(/\{(\w+)\}/g, (m, name: string) => (name in vars ? String(vars[name]) : m)); +} + +function pluralRule(lang: Language, count: number): Intl.LDMLPluralRule { + try { + return new Intl.PluralRules(lang).select(count); + } catch { + return count === 1 ? "one" : "other"; + } +} + +function pickPlural(forms: Plural, lang: Language, count: number): string | undefined { + return forms[pluralRule(lang, count)] ?? forms.other ?? Object.values(forms)[0]; +} + +/** The raw translation of `key` in `lang`, or undefined. */ +export function lookup(key: string, lang: Language = current): string | Plural | undefined { + if (lang === "en") return undefined; + return TABLE[key]?.[lang]; +} + +/** + * `key` (English text) in the current language, its `{placeholders}` filled + * from `vars`. Falls back to the English text. + */ +export function t(key: string, vars?: Vars): string { + const found = lookup(key); + let text: string; + if (typeof found === "string") text = found; + else if (found) text = pickPlural(found, current, Number(vars?.count ?? 0)) ?? key; + else text = key; + return interpolate(text, vars); +} + +/** + * A count with its words: `tn("{count} file", "{count} files", n)`. Languages + * with more forms (Russian, Arabic…) pick theirs from the entry keyed by the + * English plural; `{count}` is filled in. + */ +export function tn(one: string, other: string, count: number, vars?: Vars): string { + const all = { ...vars, count }; + const found = lookup(other); + if (found && typeof found !== "string") { + const text = pickPlural(found, current, count); + if (text) return interpolate(text, all); + } + if (typeof found === "string") return interpolate(found, all); + // English, or a language without this string: the English forms. + const englishForms = TABLE[other]?.en; + if (englishForms && typeof englishForms !== "string") { + const text = pickPlural(englishForms, "en", count); + if (text) return interpolate(text, all); + } + return interpolate(count === 1 ? one : other, all); +} + +/** + * Marks a literal as a key without translating it, for tables built at load + * time (pill categories, outfit names…): the code translates it with `t()` + * where it is shown. The i18n test finds the keys through it. + */ +export function N_(key: T): T { + return key; +} + +/** + * A table of labels whose every read is translated: `labels({ a: N_("Bow") }).a` + * is "Bow" in English, "Nœud" in French. For tables other modules index into. + */ +export function labels(keys: Record): Record { + const out = {} as Record; + for (const k of Object.keys(keys) as K[]) { + Object.defineProperty(out, k, { enumerable: true, get: () => t(keys[k]) }); + } + return out; +} + +/** + * A string that follows the language by itself once it is on screen. Given to + * `h()` (views/dom.ts) as a text, a title, a placeholder or a child, it is + * redrawn in place when the language changes. + */ +export class Msg { + readonly key: string; + readonly vars?: Vars; + constructor(key: string, vars?: Vars) { + this.key = key; + this.vars = vars; + } + toString(): string { + return t(this.key, this.vars); + } +} + +/** `t()` for text built once: see `Msg`. */ +export function tl(key: string, vars?: Vars): Msg { + return new Msg(key, vars); +} + +// ── Dates ───────────────────────────────────────────────────────────────────── +// English keeps the Mac's fixed abbreviations; the other languages ask Intl. + +const EN_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const EN_WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function intl(options: Intl.DateTimeFormatOptions, date: Date): string | null { + try { + return new Intl.DateTimeFormat(current, { ...options, timeZone: "UTC" }).format(date); + } catch { + return null; + } +} + +/** "Oct" for 9 (months from 0). */ +export function monthShort(month: number): string { + if (current === "en") return EN_MONTHS[month] ?? ""; + return intl({ month: "short" }, new Date(Date.UTC(2024, month, 15))) ?? EN_MONTHS[month] ?? ""; +} + +/** "Mon" for 1 (Sunday is 0). */ +export function weekdayShort(day: number): string { + if (current === "en") return EN_WEEKDAYS[day] ?? ""; + // 7 January 2024 was a Sunday. + return intl({ weekday: "short" }, new Date(Date.UTC(2024, 0, 7 + day))) ?? EN_WEEKDAYS[day] ?? ""; +} + +/** "Oct 9" (months from 0), in the language's own order. */ +export function dayMonth(month: number, day: number): string { + if (current === "en") return `${EN_MONTHS[month] ?? ""} ${day}`; + return intl({ month: "short", day: "numeric" }, new Date(Date.UTC(2024, month, day))) ?? `${EN_MONTHS[month] ?? ""} ${day}`; +} + +/** Every key of both tables, for the tests. */ +export function allKeys(): string[] { + return Object.keys(TABLE); +} + +/** True when `key` has a translation in every language but English. */ +export function isComplete(key: string): boolean { + const entry = TABLE[key]; + return !!entry && LANGUAGE_CODES.every((l) => l === "en" || entry[l] !== undefined); +} diff --git a/windows/src/i18n/strings.json b/windows/src/i18n/strings.json new file mode 100644 index 000000000..f32fb6aa6 --- /dev/null +++ b/windows/src/i18n/strings.json @@ -0,0 +1,3381 @@ +{ + "_generated": "By scripts/gen-strings.mjs from NotchBuddy/Resources/Localizable.xcstrings. Do not edit: run `node scripts/gen-strings.mjs`.", + "languages": [ + "en", + "zh-Hans", + "hi", + "es", + "ar", + "fr", + "bn", + "pt-BR", + "ru", + "id" + ], + "strings": { + " is ready.": { + "zh-Hans": " 已就绪。", + "hi": " तैयार है।", + "es": " está listo.", + "ar": " جاهز.", + "fr": " est prêt.", + "bn": " প্রস্তুত।", + "pt-BR": " está pronto.", + "ru": " готов.", + "id": " siap." + }, + "5 hours": { + "zh-Hans": "5 小时", + "hi": "5 घंटे", + "es": "5 horas", + "ar": "٥ ساعات", + "fr": "5 heures", + "bn": "৫ ঘণ্টা", + "pt-BR": "5 horas", + "ru": "5 часов", + "id": "5 jam" + }, + "API key (AI Studio)": { + "zh-Hans": "API 密钥 (AI Studio)", + "hi": "API कुंजी (AI Studio)", + "es": "Clave API (AI Studio)", + "ar": "مفتاح API (AI Studio)", + "fr": "Clé API (AI Studio)", + "bn": "API কী (AI Studio)", + "pt-BR": "Chave de API (AI Studio)", + "ru": "Ключ API (AI Studio)", + "id": "Kunci API (AI Studio)" + }, + "API key (sk-ant-…)": { + "zh-Hans": "API 密钥 (sk-ant-…)", + "hi": "API कुंजी (sk-ant-…)", + "es": "Clave API (sk-ant-…)", + "ar": "مفتاح API (sk-ant-…)", + "fr": "Clé API (sk-ant-…)", + "bn": "API কী (sk-ant-…)", + "pt-BR": "Chave de API (sk-ant-…)", + "ru": "Ключ API (sk-ant-…)", + "id": "Kunci API (sk-ant-…)" + }, + "API key (sk-…)": { + "zh-Hans": "API 密钥 (sk-…)", + "hi": "API कुंजी (sk-…)", + "es": "Clave API (sk-…)", + "ar": "مفتاح API (sk-…)", + "fr": "Clé API (sk-…)", + "bn": "API কী (sk-…)", + "pt-BR": "Chave de API (sk-…)", + "ru": "Ключ API (sk-…)", + "id": "Kunci API (sk-…)" + }, + "AUTO": { + "zh-Hans": "自动", + "hi": "AUTO", + "es": "AUTO", + "ar": "تلقائي", + "fr": "AUTO", + "bn": "অটো", + "pt-BR": "AUTO", + "ru": "АВТО", + "id": "AUTO" + }, + "Active pills": { + "zh-Hans": "活跃标签页", + "hi": "सक्रिय टैब", + "es": "Pestañas activas", + "ar": "التبويبات النشطة", + "fr": "Onglets actifs", + "bn": "সক্রিয় ট্যাব", + "pt-BR": "Abas ativas", + "ru": "Активные вкладки", + "id": "Tab aktif" + }, + "Activity": { + "zh-Hans": "动态", + "hi": "गतिविधि", + "es": "Actividad", + "ar": "النشاط", + "fr": "Activité", + "bn": "কার্যকলাপ", + "pt-BR": "Atividade", + "ru": "Активность", + "id": "Aktivitas" + }, + "Agent": { + "zh-Hans": "代理", + "hi": "एजेंट", + "es": "Agente", + "ar": "وكيل", + "fr": "Agent", + "bn": "এজেন্ট", + "pt-BR": "Agente", + "ru": "Агент", + "id": "Agen" + }, + "Agents": { + "zh-Hans": "代理", + "hi": "एजेंट्स", + "es": "Agentes", + "ar": "الوكلاء", + "fr": "Agents", + "bn": "এজেন্টস", + "pt-BR": "Agentes", + "ru": "Агенты", + "id": "Agen" + }, + "Allow": { + "zh-Hans": "允许", + "hi": "अनुमति दें", + "es": "Permitir", + "ar": "السماح", + "fr": "Autoriser", + "bn": "অনুমতি দিন", + "pt-BR": "Permitir", + "ru": "Разрешить", + "id": "Izinkan" + }, + "Allow Coucou to control Music": { + "zh-Hans": "允许 Coucou 控制音乐", + "hi": "Coucou को Music नियंत्रित करने दें", + "es": "Permitir que Coucou controle Music", + "ar": "السماح لـ Coucou بالتحكم في الموسيقى", + "fr": "Autoriser Coucou à contrôler Music", + "bn": "Coucou-কে Music নিয়ন্ত্রণ করতে দিন", + "pt-BR": "Permitir que o Coucou controle o Music", + "ru": "Разрешить Coucou управлять Music", + "id": "Izinkan Coucou mengontrol Music" + }, + "Always": { + "zh-Hans": "始终允许", + "hi": "हमेशा", + "es": "Siempre", + "ar": "دائماً", + "fr": "Toujours", + "bn": "সর্বদা", + "pt-BR": "Sempre", + "ru": "Всегда", + "id": "Selalu" + }, + "Amp plugin": { + "zh-Hans": "Amp 插件", + "hi": "Amp प्लगइन", + "es": "Plugin de Amp", + "ar": "ملحق Amp", + "fr": "Plugin Amp", + "bn": "Amp প্লাগইন", + "pt-BR": "Plugin do Amp", + "ru": "Плагин Amp", + "id": "Plugin Amp" + }, + "An instruction sent from the iPhone uses the model open in chat. The system prompt and conversation history apply. Instructions sent when Coucou is not running are queued and executed on next launch.": { + "zh-Hans": "从 iPhone 发送的指令使用聊天中当前打开的模型。系统提示和对话历史适用。Coucou 未运行时发送的指令将排队,并在下次启动时执行。", + "hi": "iPhone से भेजा गया निर्देश चैट में वर्तमान में खुले मॉडल का उपयोग करता है। सिस्टम प्रॉम्प्ट और बातचीत का इतिहास लागू होता है। Coucou चल नहीं रहे होने पर भेजे गए निर्देश कतार में रखे जाते हैं और अगली बार चालू होने पर चलाए जाते हैं।", + "es": "Una instrucción enviada desde el iPhone usa el modelo abierto actualmente en el chat. Se aplican el prompt de sistema y el historial de conversación. Las instrucciones enviadas mientras Coucou no está en ejecución se ponen en cola y se ejecutan en el siguiente inicio.", + "ar": "التعليمات المُرسَلة من iPhone تستخدم النموذج المفتوح حاليًا في الدردشة. يُطبَّق موجه النظام وتاريخ المحادثة. التعليمات المُرسَلة أثناء توقف Coucou تُضاف إلى قائمة الانتظار وتُنفَّذ عند التشغيل التالي.", + "fr": "Une instruction envoyée depuis l'iPhone utilise le modèle ouvert dans le chat. Le prompt système et l'historique de conversation s'appliquent. Les instructions envoyées quand Coucou ne tourne pas sont mises en file d'attente et exécutées au prochain démarrage.", + "bn": "iPhone থেকে পাঠানো নির্দেশনা চ্যাটে বর্তমানে খোলা মডেল ব্যবহার করে। সিস্টেম প্রম্পট এবং কথোপকথনের ইতিহাস প্রযোজ্য। Coucou চলছে না থাকলে পাঠানো নির্দেশনাগুলো কিউতে রাখা হয় এবং পরবর্তী চালুতে চালানো হয়।", + "pt-BR": "Uma instrução enviada pelo iPhone usa o modelo aberto no momento no chat. O prompt de sistema e o histórico de conversa se aplicam. Instruções enviadas enquanto o Coucou não está em execução ficam na fila e são executadas no próximo início.", + "ru": "Инструкция, отправленная с iPhone, использует модель, открытую в чате. Системный промпт и история разговора применяются. Инструкции, отправленные пока Coucou не запущен, ставятся в очередь и выполняются при следующем запуске.", + "id": "Instruksi yang dikirim dari iPhone menggunakan model yang sedang terbuka di chat. Prompt sistem dan riwayat percakapan berlaku. Instruksi yang dikirim saat Coucou tidak berjalan dimasukkan ke antrean dan dijalankan saat peluncuran berikutnya." + }, + "Anthropic API": { + "zh-Hans": "Anthropic API", + "hi": "Anthropic API", + "es": "API de Anthropic", + "ar": "واجهة برمجة Anthropic", + "fr": "API Anthropic", + "bn": "Anthropic API", + "pt-BR": "API da Anthropic", + "ru": "Anthropic API", + "id": "API Anthropic" + }, + "Antigravity hooks": { + "zh-Hans": "Antigravity 钩子", + "hi": "Antigravity हुक", + "es": "Hooks de Antigravity", + "ar": "خطافات Antigravity", + "fr": "Hooks Antigravity", + "bn": "Antigravity হুক", + "pt-BR": "Hooks do Antigravity", + "ru": "Хуки Antigravity", + "id": "Hooks Antigravity" + }, + "Applies on next launch": { + "zh-Hans": "下次启动时生效", + "hi": "अगली बार लॉन्च पर लागू होगा", + "es": "Se aplica en el próximo inicio", + "ar": "يُطبَّق عند التشغيل التالي", + "fr": "Appliqué au prochain lancement", + "bn": "পরবর্তী চালুতে প্রযোজ্য হবে", + "pt-BR": "Aplica no próximo lançamento", + "ru": "Применится при следующем запуске", + "id": "Berlaku saat peluncuran berikutnya" + }, + "Approval requests appear in the notch. If Coucou is not running, Hermes falls back to its own prompt.": { + "zh-Hans": "审批请求显示在刘海中。若 Coucou 未运行,Hermes 将回退到其自己的提示。", + "hi": "नॉच में अनुमोदन अनुरोध दिखाए जाते हैं। यदि Coucou नहीं चल रहा तो Hermes अपने प्रॉम्प्ट पर वापस जाता है।", + "es": "Las solicitudes de aprobación aparecen en el notch. Si Coucou no está en ejecución, Hermes usa su propio prompt.", + "ar": "تظهر طلبات الموافقة في الشريط. إذا لم تكن Coucou تعمل، يعود Hermes إلى طرفيته الخاصة.", + "fr": "Les demandes d'approbation s'affichent dans le notch. Si Coucou ne tourne pas, Hermes utilise son propre prompt.", + "bn": "নচে অনুমোদন অনুরোধ দেখানো হয়। Coucou না চললে Hermes তার নিজের প্রম্পটে ফলব্যাক করে।", + "pt-BR": "Solicitações de aprovação aparecem no notch. Se Coucou não estiver em execução, Hermes usa seu próprio prompt.", + "ru": "Запросы подтверждения отображаются в notch. Если Coucou не запущен, Hermes использует собственный prompt.", + "id": "Permintaan persetujuan muncul di notch. Jika Coucou tidak berjalan, Hermes menggunakan promptnya sendiri." + }, + "Approval requests are handled by Hermes (terminal or gateway).": { + "zh-Hans": "审批请求由 Hermes 处理(终端或网关)。", + "hi": "अनुमोदन अनुरोध Hermes द्वारा संभाले जाते हैं (टर्मिनल या गेटवे)।", + "es": "Las solicitudes de aprobación las gestiona Hermes (terminal o gateway).", + "ar": "تعالج Hermes طلبات الموافقة (الطرفية أو البوابة).", + "fr": "Les demandes d'approbation sont gérées par Hermes (terminal ou passerelle).", + "bn": "Hermes অনুমোদন অনুরোধ পরিচালনা করে (টার্মিনাল বা গেটওয়ে)।", + "pt-BR": "Solicitações de aprovação tratadas pelo Hermes (terminal ou gateway).", + "ru": "Запросы подтверждения обрабатывает Hermes (терминал или шлюз).", + "id": "Permintaan persetujuan ditangani oleh Hermes (terminal atau gateway)." + }, + "Approved": { + "zh-Hans": "已批准", + "hi": "अनुमोदित", + "es": "Aprobado", + "ar": "موافق عليه", + "fr": "Approuvé", + "bn": "অনুমোদিত", + "pt-BR": "Aprovado", + "ru": "Одобрено", + "id": "Disetujui" + }, + "Ask Claude": { + "zh-Hans": "问 Claude", + "hi": "Claude से पूछें", + "es": "Preguntar a Claude", + "ar": "اسأل Claude", + "fr": "Demander à Claude", + "bn": "Claude-কে জিজ্ঞেস করুন", + "pt-BR": "Perguntar ao Claude", + "ru": "Спросить Claude", + "id": "Tanya Claude" + }, + "Ask a question": { + "zh-Hans": "提问", + "hi": "एक प्रश्न पूछें", + "es": "Hacer una pregunta", + "ar": "اطرح سؤالاً", + "fr": "Poser une question", + "bn": "একটি প্রশ্ন করুন", + "pt-BR": "Fazer uma pergunta", + "ru": "Задать вопрос", + "id": "Ajukan pertanyaan" + }, + "Ask a question about it": { + "zh-Hans": "提问", + "hi": "इसके बारे में प्रश्न पूछें", + "es": "Hacer una pregunta", + "ar": "اطرح سؤالًا عنه", + "fr": "Poser une question à ce sujet", + "bn": "এটা সম্পর্কে প্রশ্ন করুন", + "pt-BR": "Fazer uma pergunta sobre isso", + "ru": "Задать вопрос", + "id": "Ajukan pertanyaan tentang ini" + }, + "Ask me anything…": { + "zh-Hans": "问我任何事…", + "hi": "कुछ भी पूछें…", + "es": "Pregúntame algo…", + "ar": "اسألني أي شيء…", + "fr": "Demande-moi n'importe quoi…", + "bn": "যেকোনো কিছু জিজ্ঞেস করুন…", + "pt-BR": "Pergunte qualquer coisa…", + "ru": "Спроси меня о чём угодно…", + "id": "Tanyakan apa saja…" + }, + "Attach front window": { + "zh-Hans": "附加前端窗口", + "hi": "फ्रंट विंडो अटैच करें", + "es": "Adjuntar ventana frontal", + "ar": "إرفاق النافذة الأمامية", + "fr": "Attacher la fenêtre de premier plan", + "bn": "সামনের উইন্ডো সংযুক্ত করো", + "pt-BR": "Anexar janela frontal", + "ru": "Прикрепить переднее окно", + "id": "Lampirkan jendela depan" + }, + "Auto-close · {seconds}s": { + "zh-Hans": "自动关闭 · {seconds} 秒", + "hi": "ऑटो-क्लोज़ · {seconds}स", + "es": "Cierre automático · {seconds}s", + "ar": "إغلاق تلقائي · {seconds}ث", + "fr": "Fermeture auto · {seconds}s", + "bn": "স্বয়ংক্রিয়-বন্ধ · {seconds}স", + "pt-BR": "Fechar auto · {seconds}s", + "ru": "Автозакрытие · {seconds} с", + "id": "Tutup otomatis · {seconds}dt" + }, + "Automation not allowed": { + "zh-Hans": "不允许自动化", + "hi": "स्वचालन की अनुमति नहीं", + "es": "Automatización no permitida", + "ar": "الأتمتة غير مسموح بها", + "fr": "Automatisation non autorisée", + "bn": "অটোমেশন অনুমোদিত নয়", + "pt-BR": "Automação não permitida", + "ru": "Автоматизация не разрешена", + "id": "Otomatisasi tidak diizinkan" + }, + "Behavior": { + "zh-Hans": "行为", + "hi": "व्यवहार", + "es": "Comportamiento", + "ar": "السلوك", + "fr": "Comportement", + "bn": "আচরণ", + "pt-BR": "Comportamento", + "ru": "Поведение", + "id": "Perilaku" + }, + "Busiest day": { + "zh-Hans": "最忙的一天", + "hi": "सबसे व्यस्त दिन", + "es": "Día más activo", + "ar": "أكثر يوم نشاطًا", + "fr": "Jour le plus chargé", + "bn": "সবচেয়ে ব্যস্ত দিন", + "pt-BR": "Dia mais ativo", + "ru": "Самый насыщенный день", + "id": "Hari tersibuk" + }, + "COMMANDS": { + "zh-Hans": "命令", + "hi": "कमांड", + "es": "COMANDOS", + "ar": "الأوامر", + "fr": "COMMANDES", + "bn": "কমান্ড", + "pt-BR": "COMANDOS", + "ru": "КОМАНДЫ", + "id": "PERINTAH" + }, + "Can't reach {name} at {url}. Is it running?": { + "zh-Hans": "无法访问 {name}({url})。它正在运行吗?", + "hi": "{name} को {url} पर नहीं पहुंचा जा सका। क्या यह चल रहा है?", + "es": "No se pudo contactar con {name} en {url}. ¿Está en ejecución?", + "ar": "تعذَّر الوصول إلى {name} على {url}. هل هو قيد التشغيل؟", + "fr": "Impossible d'atteindre {name} sur {url}. Est-il en cours d'exécution ?", + "bn": "{name} কে {url} তে পৌঁছানো যাচ্ছে না। এটি কি চলছে?", + "pt-BR": "Não foi possível alcançar {name} em {url}. Ele está em execução?", + "ru": "Не удалось подключиться к {name} по адресу {url}. Он запущен?", + "id": "Tidak dapat menjangkau {name} di {url}. Apakah sedang berjalan?" + }, + "Cancel": { + "zh-Hans": "取消", + "hi": "रद्द करें", + "es": "Cancelar", + "ar": "إلغاء", + "fr": "Annuler", + "bn": "বাতিল", + "pt-BR": "Cancelar", + "ru": "Отмена", + "id": "Batal" + }, + "Chat": { + "zh-Hans": "聊天", + "hi": "चैट", + "es": "Chat", + "ar": "الدردشة", + "fr": "Chat", + "bn": "চ্যাট", + "pt-BR": "Chat", + "ru": "Чат", + "id": "Obrolan" + }, + "Chat with {name}": { + "zh-Hans": "与 {name} 聊天", + "hi": "{name} के साथ चैट", + "es": "Chatear con {name}", + "ar": "الدردشة مع {name}", + "fr": "Discuter avec {name}", + "bn": "{name}-এর সাথে চ্যাট", + "pt-BR": "Conversar com {name}", + "ru": "Чат с {name}", + "id": "Chat dengan {name}" + }, + "Chat — other providers": { + "zh-Hans": "聊天 — 其他服务商", + "hi": "चैट — अन्य प्रदाता", + "es": "Chat — otros proveedores", + "ar": "الدردشة — مزودون آخرون", + "fr": "Chat — autres fournisseurs", + "bn": "চ্যাট — অন্যান্য প্রদানকারী", + "pt-BR": "Chat — outros provedores", + "ru": "Чат — другие провайдеры", + "id": "Chat — penyedia lain" + }, + "Choose the tools you use. Coucou only shows what you declare here.": { + "zh-Hans": "选择你使用的工具。Coucou 只显示你在这里声明的内容。", + "hi": "वे टूल चुनें जो आप उपयोग करते हैं। Coucou केवल वही दिखाता है जो आप यहाँ घोषित करते हैं।", + "es": "Elige las herramientas que usas. Coucou solo muestra lo que declares aquí.", + "ar": "اختر الأدوات التي تستخدمها. يعرض Coucou فقط ما تُعلنه هنا.", + "fr": "Choisis les outils que tu utilises. Coucou n'affiche que ce que tu déclares ici.", + "bn": "আপনি যে টুলগুলো ব্যবহার করেন তা বেছে নিন। Coucou শুধুমাত্র আপনার ঘোষণা করা জিনিস দেখায়।", + "pt-BR": "Escolha as ferramentas que você usa. O Coucou mostra apenas o que você declarar aqui.", + "ru": "Выбери инструменты, которые ты используешь. Coucou показывает только то, что ты здесь объявишь.", + "id": "Pilih alat yang Anda gunakan. Coucou hanya menampilkan yang Anda deklarasikan di sini." + }, + "Classic token with repo scope, or fine-grained with read access to Pull requests, commit statuses, and Actions.": { + "zh-Hans": "具有 repo 权限的经典令牌,或具有 Pull requests、Commit statuses 和 Actions 读取权限的细粒度令牌。", + "hi": "repo स्कोप के साथ क्लासिक टोकन, या Pull requests, Commit statuses और Actions में रीड एक्सेस के साथ फाइन-ग्रेन्ड टोकन।", + "es": "Token clásico con scope repo, o de grano fino con acceso de lectura a Pull requests, Commit statuses y Actions.", + "ar": "رمز كلاسيكي بصلاحية repo، أو رمز دقيق بصلاحية قراءة لـ Pull requests وحالات الإيداع والـ Actions.", + "fr": "Token classique avec le scope repo, ou à portée fine avec accès en lecture aux Pull requests, statuts de commit et Actions.", + "bn": "repo স্কোপ সহ ক্লাসিক টোকেন, অথবা Pull requests, Commit statuses এবং Actions-এ পড়ার অ্যাক্সেস সহ ফাইন-গ্রেইনড টোকেন।", + "pt-BR": "Token clássico com escopo repo, ou fine-grained com acesso de leitura a Pull requests, Commit statuses e Actions.", + "ru": "Классический токен с правом repo или гранулярный с доступом на чтение к Pull requests, Commit statuses и Actions.", + "id": "Token klasik dengan scope repo, atau token fine-grained dengan akses baca ke Pull requests, Commit statuses, dan Actions." + }, + "Claude Code finished": { + "zh-Hans": "Claude Code 已完成", + "hi": "Claude Code समाप्त हो गया", + "es": "Claude Code terminó", + "ar": "انتهى Claude Code", + "fr": "Claude Code a terminé", + "bn": "Claude Code শেষ হয়েছে", + "pt-BR": "Claude Code terminou", + "ru": "Claude Code завершил", + "id": "Claude Code selesai" + }, + "Claude Code hooks": { + "zh-Hans": "Claude Code 钩子", + "hi": "Claude Code हुक", + "es": "Hooks de Claude Code", + "ar": "خطافات Claude Code", + "fr": "Hooks Claude Code", + "bn": "Claude Code হুক", + "pt-BR": "Hooks do Claude Code", + "ru": "Хуки Claude Code", + "id": "Hooks Claude Code" + }, + "Claude Code is asking": { + "zh-Hans": "Claude Code 正在询问", + "hi": "Claude Code पूछ रहा है", + "es": "Claude Code está preguntando", + "ar": "Claude Code يسأل", + "fr": "Claude Code demande", + "bn": "Claude Code জিজ্ঞেস করছে", + "pt-BR": "Claude Code está perguntando", + "ru": "Claude Code спрашивает", + "id": "Claude Code bertanya" + }, + "Claude is reading {name}…": { + "zh-Hans": "Claude 正在读取 {name}…", + "hi": "Claude {name} पढ़ रहा है…", + "es": "Claude está leyendo {name}…", + "ar": "Claude يقرأ {name}…", + "fr": "Claude lit {name}…", + "bn": "Claude {name} পড়ছে…", + "pt-BR": "Claude está lendo {name}…", + "ru": "Claude читает {name}…", + "id": "Claude sedang membaca {name}…" + }, + "Claude is searching…": { + "zh-Hans": "Claude 正在搜索…", + "hi": "Claude खोज रहा है…", + "es": "Claude está buscando…", + "ar": "Claude يبحث…", + "fr": "Claude cherche…", + "bn": "Claude খুঁজছে…", + "pt-BR": "Claude está pesquisando…", + "ru": "Claude ищет…", + "id": "Claude sedang mencari…" + }, + "Claude plan": { + "zh-Hans": "Claude 计划", + "hi": "Claude प्लान", + "es": "Plan Claude", + "ar": "خطة Claude", + "fr": "Forfait Claude", + "bn": "Claude প্ল্যান", + "pt-BR": "Plano Claude", + "ru": "План Claude", + "id": "Paket Claude" + }, + "Clear": { + "zh-Hans": "清除", + "hi": "साफ़ करें", + "es": "Borrar", + "ar": "مسح", + "fr": "Effacer", + "bn": "মুছুন", + "pt-BR": "Limpar", + "ru": "Очистить", + "id": "Hapus" + }, + "Clear history": { + "zh-Hans": "清除历史记录", + "hi": "इतिहास साफ़ करें", + "es": "Borrar historial", + "ar": "مسح السجل", + "fr": "Effacer l'historique", + "bn": "ইতিহাস মুছুন", + "pt-BR": "Limpar histórico", + "ru": "Очистить историю", + "id": "Hapus riwayat" + }, + "Close": { + "zh-Hans": "关闭", + "hi": "बंद करें", + "es": "Cerrar", + "ar": "إغلاق", + "fr": "Fermer", + "bn": "বন্ধ", + "pt-BR": "Fechar", + "ru": "Закрыть", + "id": "Tutup" + }, + "Close after": { + "zh-Hans": "自动关闭", + "hi": "बाद में बंद करें", + "es": "Cerrar después de", + "ar": "إغلاق بعد", + "fr": "Fermer après", + "bn": "পরে বন্ধ করুন", + "pt-BR": "Fechar após", + "ru": "Закрыть через", + "id": "Tutup setelah" + }, + "Close notch (if not pinned)": { + "zh-Hans": "关闭岛(如未固定)", + "hi": "आइलैंड बंद करें (पिन न हो तो)", + "es": "Cerrar island (si no está fijado)", + "ar": "إغلاق الشريط (إذا لم يكن مثبتاً)", + "fr": "Fermer l'encoché (s'il n'est pas épinglé)", + "bn": "আইল্যান্ড বন্ধ করো (পিন না থাকলে)", + "pt-BR": "Fechar island (se não fixado)", + "ru": "Закрыть нотч (если не закреплён)", + "id": "Tutup island (jika tidak disematkan)" + }, + "Codex hooks": { + "zh-Hans": "Codex 钩子", + "hi": "Codex हुक", + "es": "Hooks de Codex", + "ar": "خطافات Codex", + "fr": "Hooks Codex", + "bn": "Codex হুক", + "pt-BR": "Hooks do Codex", + "ru": "Хуки Codex", + "id": "Hooks Codex" + }, + "Coming soon": { + "zh-Hans": "即将推出", + "hi": "जल्द आ रहा है", + "es": "Próximamente", + "ar": "قريباً", + "fr": "Bientôt disponible", + "bn": "শীঘ্রই আসছে", + "pt-BR": "Em breve", + "ru": "Скоро", + "id": "Segera hadir" + }, + "Completed successfully.": { + "zh-Hans": "已成功完成。", + "hi": "सफलतापूर्वक पूर्ण हुआ।", + "es": "Completado con éxito.", + "ar": "اكتمل بنجاح.", + "fr": "Terminé avec succès.", + "bn": "সফলভাবে সম্পন্ন।", + "pt-BR": "Concluído com sucesso.", + "ru": "Завершено успешно.", + "id": "Selesai dengan berhasil." + }, + "Confirm and write": { + "zh-Hans": "确认并写入", + "hi": "पुष्टि करें और लिखें", + "es": "Confirmar y escribir", + "ar": "تأكيد والكتابة", + "fr": "Confirmer et écrire", + "bn": "নিশ্চিত করুন এবং লিখুন", + "pt-BR": "Confirmar e gravar", + "ru": "Подтвердить и записать", + "id": "Konfirmasi & tulis" + }, + "Connect": { + "zh-Hans": "连接", + "hi": "कनेक्ट करें", + "es": "Conectar", + "ar": "اتصال", + "fr": "Connecter", + "bn": "সংযুক্ত করুন", + "pt-BR": "Conectar", + "ru": "Подключить", + "id": "Hubungkan" + }, + "Connect to a local model server. No API key required.": { + "zh-Hans": "连接到本地模型服务器。无需 API 密钥。", + "hi": "एक स्थानीय मॉडल सर्वर से कनेक्ट करें। कोई API कुंजी की आवश्यकता नहीं।", + "es": "Conéctate a un servidor de modelos local. No se necesita clave API.", + "ar": "اتصل بخادم نماذج محلي. لا حاجة لمفتاح API.", + "fr": "Connecte-toi à un serveur de modèles local. Aucune clé API requise.", + "bn": "একটি স্থানীয় মডেল সার্ভারে সংযুক্ত হন। কোনো API কী লাগবে না।", + "pt-BR": "Conecte-se a um servidor de modelos local. Não é necessária chave de API.", + "ru": "Подключись к локальному серверу моделей. API-ключ не нужен.", + "id": "Hubungkan ke server model lokal. Tidak perlu kunci API." + }, + "Connected": { + "zh-Hans": "已连接", + "hi": "जुड़ा है", + "es": "Conectado", + "ar": "متصل", + "fr": "Connecté", + "bn": "সংযুক্ত", + "pt-BR": "Conectado", + "ru": "Подключено", + "id": "Terhubung" + }, + "Connected · loading…": { + "zh-Hans": "已连接 · 加载中…", + "hi": "जुड़ा है · लोड हो रहा है…", + "es": "Conectado · cargando…", + "ar": "متصل · جارٍ التحميل…", + "fr": "Connecté · chargement…", + "bn": "সংযুক্ত · লোড হচ্ছে…", + "pt-BR": "Conectado · carregando…", + "ru": "Подключено · загрузка…", + "id": "Terhubung · memuat…" + }, + "Connected · {detail}": { + "zh-Hans": "已连接 · {detail}", + "hi": "जुड़ा है · {detail}", + "es": "Conectado · {detail}", + "ar": "متصل · {detail}", + "fr": "Connecté · {detail}", + "bn": "সংযুক্ত · {detail}", + "pt-BR": "Conectado · {detail}", + "ru": "Подключено · {detail}", + "id": "Terhubung · {detail}" + }, + "Connecting…": { + "zh-Hans": "正在连接…", + "hi": "कनेक्ट हो रहा है…", + "es": "Conectando…", + "ar": "جارٍ الاتصال…", + "fr": "Connexion…", + "bn": "সংযুক্ত হচ্ছে…", + "pt-BR": "Conectando…", + "ru": "Подключение…", + "id": "Menghubungkan…" + }, + "Continue…": { + "zh-Hans": "继续…", + "hi": "जारी रखें…", + "es": "Continuar…", + "ar": "متابعة…", + "fr": "Continuer…", + "bn": "চালিয়ে যান…", + "pt-BR": "Continuar…", + "ru": "Продолжить…", + "id": "Lanjutkan…" + }, + "Copy": { + "zh-Hans": "复制", + "hi": "कॉपी", + "es": "Copiar", + "ar": "نسخ", + "fr": "Copier", + "bn": "কপি", + "pt-BR": "Copiar", + "ru": "Скопировать", + "id": "Salin" + }, + "Custom…": { + "zh-Hans": "自定义…", + "hi": "कस्टम…", + "es": "Personalizado…", + "ar": "مخصص…", + "fr": "Personnalisé…", + "bn": "কাস্টম…", + "pt-BR": "Personalizado…", + "ru": "Свой…", + "id": "Kustom…" + }, + "Demo": { + "zh-Hans": "演示", + "hi": "डेमो", + "es": "Demo", + "ar": "تجريبي", + "fr": "Démo", + "bn": "ডেমো", + "pt-BR": "Demo", + "ru": "Демо", + "id": "Demo" + }, + "Demo mode": { + "zh-Hans": "演示模式", + "hi": "डेमो मोड", + "es": "Modo demo", + "ar": "وضع العرض", + "fr": "Mode démo", + "bn": "ডেমো মোড", + "pt-BR": "Modo demo", + "ru": "Демо-режим", + "id": "Mode demo" + }, + "Denied": { + "zh-Hans": "已拒绝", + "hi": "अस्वीकृत", + "es": "Denegado", + "ar": "مرفوض", + "fr": "Refusé", + "bn": "প্রত্যাখ্যাত", + "pt-BR": "Negado", + "ru": "Отклонено", + "id": "Ditolak" + }, + "Deny": { + "zh-Hans": "拒绝", + "hi": "अस्वीकार", + "es": "Denegar", + "ar": "رفض", + "fr": "Refuser", + "bn": "প্রত্যাখ্যান", + "pt-BR": "Negar", + "ru": "Отклонить", + "id": "Tolak" + }, + "Deployments": { + "zh-Hans": "部署", + "hi": "तैनाती", + "es": "Despliegues", + "ar": "نشر", + "fr": "Déploiements", + "bn": "ডিপ্লয়মেন্ট", + "pt-BR": "Implantações", + "ru": "Деплои", + "id": "Penerapan" + }, + "Diff too large": { + "zh-Hans": "Diff 太大", + "hi": "Diff बहुत बड़ा है", + "es": "Diff demasiado grande", + "ar": "الفرق كبير جدًا", + "fr": "Diff trop grand", + "bn": "ডিফ খুব বড়", + "pt-BR": "Diff muito grande", + "ru": "Diff слишком большой", + "id": "Diff terlalu besar" + }, + "Disconnect": { + "zh-Hans": "断开连接", + "hi": "डिस्कनेक्ट करें", + "es": "Desconectar", + "ar": "قطع الاتصال", + "fr": "Déconnecter", + "bn": "সংযোগ বিচ্ছিন্ন করুন", + "pt-BR": "Desconectar", + "ru": "Отключить", + "id": "Putuskan" + }, + "Draft": { + "zh-Hans": "草稿", + "hi": "ड्राफ्ट", + "es": "Borrador", + "ar": "مسودة", + "fr": "Brouillon", + "bn": "ড্রাফ্ট", + "pt-BR": "Rascunho", + "ru": "Черновик", + "id": "Draf" + }, + "Drop a file or window, or ask me anything.": { + "zh-Hans": "拖入文件或窗口,或问我任何问题。", + "hi": "कोई फ़ाइल या विंडो ड्रॉप करें, या कुछ भी पूछें।", + "es": "Suelta un archivo o ventana, o pregúntame cualquier cosa.", + "ar": "أسقط ملفاً أو نافذة، أو اسألني أي شيء.", + "fr": "Dépose un fichier ou une fenêtre, ou demande-moi n'importe quoi.", + "bn": "একটি ফাইল বা উইন্ডো ড্রপ করুন, অথবা যেকোনো কিছু জিজ্ঞেস করুন।", + "pt-BR": "Solte um arquivo ou janela, ou me pergunte qualquer coisa.", + "ru": "Брось файл или окно, или спроси меня о чём угодно.", + "id": "Seret file atau jendela, atau tanya apa saja." + }, + "Drop your files here": { + "zh-Hans": "把文件拖到这里", + "hi": "अपनी फ़ाइलें यहाँ छोड़ें", + "es": "Suelta tus archivos aquí", + "ar": "أسقط ملفاتك هنا", + "fr": "Dépose tes fichiers ici", + "bn": "আপনার ফাইলগুলি এখানে ড্রপ করুন", + "pt-BR": "Solte seus arquivos aqui", + "ru": "Перетащите файлы сюда", + "id": "Letakkan file Anda di sini" + }, + "Duplicate": { + "zh-Hans": "重复", + "hi": "डुप्लीकेट", + "es": "Duplicado", + "ar": "مكرر", + "fr": "Doublon", + "bn": "ডুপ্লিকেট", + "pt-BR": "Duplicado", + "ru": "Дублирующийся", + "id": "Duplikat" + }, + "Edits": { + "zh-Hans": "编辑", + "hi": "संपादित करता है", + "es": "Edita", + "ar": "يُعدّل", + "fr": "Modifie", + "bn": "সম্পাদনা করে", + "pt-BR": "Edita", + "ru": "Редактирует", + "id": "Mengedit" + }, + "Emails": { + "zh-Hans": "邮件", + "hi": "ईमेल", + "es": "Correos", + "ar": "الإيميلات", + "fr": "Emails", + "bn": "ইমেইল", + "pt-BR": "Emails", + "ru": "Письма", + "id": "Email" + }, + "Enable sounds": { + "zh-Hans": "启用声音", + "hi": "ध्वनि सक्षम करें", + "es": "Habilitar sonidos", + "ar": "تفعيل الأصوات", + "fr": "Activer les sons", + "bn": "শব্দ সক্ষম করুন", + "pt-BR": "Ativar sons", + "ru": "Включить звуки", + "id": "Aktifkan suara" + }, + "Explore all features without an API key or Claude Code. Nothing is written to disk.": { + "zh-Hans": "无需 API 密钥或 Claude Code,探索所有功能。不会写入任何内容到磁盘。", + "hi": "API कुंजी या Claude Code के बिना सभी सुविधाएँ एक्सप्लोर करें। डिस्क पर कुछ नहीं लिखा जाता।", + "es": "Explora todas las funciones sin clave de API ni Claude Code. Nada se escribe en el disco.", + "ar": "استكشف جميع الميزات بدون مفتاح API أو Claude Code. لا يُكتب شيء على القرص.", + "fr": "Explore toutes les fonctions sans clé d'API ni Claude Code. Rien n'est écrit sur le disque.", + "bn": "API কী বা Claude Code ছাড়াই সমস্ত ফিচার এক্সপ্লোর করুন। ডিস্কে কিছু লেখা হয় না।", + "pt-BR": "Explore todos os recursos sem chave de API ou Claude Code. Nada é gravado no disco.", + "ru": "Изучи все функции без API-ключа и Claude Code. Ничего не записывается на диск.", + "id": "Jelajahi semua fitur tanpa kunci API atau Claude Code. Tidak ada yang ditulis ke disk." + }, + "FILES": { + "zh-Hans": "文件", + "hi": "फ़ाइलें", + "es": "ARCHIVOS", + "ar": "الملفات", + "fr": "FICHIERS", + "bn": "ফাইল", + "pt-BR": "ARQUIVOS", + "ru": "ФАЙЛЫ", + "id": "BERKAS" + }, + "Failed": { + "zh-Hans": "失败", + "hi": "विफल", + "es": "Fallido", + "ar": "فشل", + "fr": "Échoué", + "bn": "ব্যর্থ", + "pt-BR": "Falhou", + "ru": "Ошибка", + "id": "Gagal" + }, + "Fetches": { + "zh-Hans": "获取", + "hi": "प्राप्त करता है", + "es": "Obtiene", + "ar": "يجلب", + "fr": "Récupère", + "bn": "আনে", + "pt-BR": "Busca", + "ru": "Получает", + "id": "Mengambil" + }, + "Gemini CLI hooks": { + "zh-Hans": "Gemini CLI 钩子", + "hi": "Gemini CLI हुक", + "es": "Hooks de Gemini CLI", + "ar": "خطافات Gemini CLI", + "fr": "Hooks Gemini CLI", + "bn": "Gemini CLI হুক", + "pt-BR": "Hooks do Gemini CLI", + "ru": "Хуки Gemini CLI", + "id": "Hooks Gemini CLI" + }, + "General": { + "zh-Hans": "通用", + "hi": "सामान्य", + "es": "General", + "ar": "عام", + "fr": "Général", + "bn": "সাধারণ", + "pt-BR": "Geral", + "ru": "Основные", + "id": "Umum" + }, + "GitHub Copilot CLI hooks": { + "zh-Hans": "GitHub Copilot CLI 钩子", + "hi": "GitHub Copilot CLI हुक", + "es": "Hooks de GitHub Copilot CLI", + "ar": "خطافات GitHub Copilot CLI", + "fr": "Hooks GitHub Copilot CLI", + "bn": "GitHub Copilot CLI হুক", + "pt-BR": "Hooks do GitHub Copilot CLI", + "ru": "Хуки GitHub Copilot CLI", + "id": "Hooks GitHub Copilot CLI" + }, + "Give me a sec — back to work in three seconds.": { + "zh-Hans": "给我一秒钟——三秒后恢复工作。", + "hi": "एक पल — तीन सेकंड में वापस काम पर।", + "es": "Dame un segundo — de vuelta al trabajo en tres segundos.", + "ar": "لحظة — سأعود للعمل في ثلاث ثوانٍ.", + "fr": "Une seconde — de retour au travail dans trois secondes.", + "bn": "এক সেকেন্ড — তিন সেকেন্ডে কাজে ফিরে যাচ্ছি।", + "pt-BR": "Um segundo — voltando ao trabalho em três segundos.", + "ru": "Секунду — вернусь к работе через три секунды.", + "id": "Satu detik — kembali bekerja dalam tiga detik." + }, + "Global shortcuts — active from any app": { + "zh-Hans": "全局快捷键 — 在任何应用中有效", + "hi": "ग्लोबल शॉर्टकट — किसी भी ऐप से काम करते हैं", + "es": "Atajos globales — funcionan desde cualquier app", + "ar": "اختصارات عامة — تعمل من أي تطبيق", + "fr": "Raccourcis globaux — actifs depuis n'importe quelle app", + "bn": "গ্লোবাল শর্টকাট — যেকোনো অ্যাপ থেকে কাজ করে", + "pt-BR": "Atalhos globais — funcionam em qualquer app", + "ru": "Глобальные сочетания — работают из любого приложения", + "id": "Pintasan global — bekerja dari app mana pun" + }, + "Gmail node timed out after 30s. Retry or open n8n.": { + "zh-Hans": "Gmail 节点在 30 秒后超时。请重试或打开 n8n。", + "hi": "Gmail नोड 30 सेकंड बाद टाइम आउट हो गया। पुनः प्रयास करें या n8n खोलें।", + "es": "El nodo de Gmail expiró después de 30s. Reintenta o abre n8n.", + "ar": "انتهت مهلة عقدة Gmail بعد 30 ثانية. أعد المحاولة أو افتح n8n.", + "fr": "Le nœud Gmail a expiré après 30s. Réessaie ou ouvre n8n.", + "bn": "Gmail নোড 30 সেকেন্ড পরে টাইম আউট হয়েছে। পুনরায় চেষ্টা করুন বা n8n খুলুন।", + "pt-BR": "Nó do Gmail expirou após 30s. Tente novamente ou abra o n8n.", + "ru": "Узел Gmail завершился по таймауту через 30с. Повторите или откройте n8n.", + "id": "Node Gmail habis waktu setelah 30 detik. Coba lagi atau buka n8n." + }, + "Go to alert": { + "zh-Hans": "跳转到警告", + "hi": "अलर्ट पर जाएं", + "es": "Ir a la alerta", + "ar": "الانتقال إلى التنبيه", + "fr": "Aller à l'alerte", + "bn": "সতর্কতায় যাও", + "pt-BR": "Ir para o alerta", + "ru": "Перейти к уведомлению", + "id": "Ke notifikasi" + }, + "Go to tab by number": { + "zh-Hans": "按编号切换标签", + "hi": "नंबर से टैब पर जाएं", + "es": "Cambiar a pestaña por número", + "ar": "التبديل إلى تبويب برقمه", + "fr": "Passer à l'onglet par numéro", + "bn": "নম্বর দিয়ে ট্যাবে যাও", + "pt-BR": "Mudar para aba por número", + "ru": "Перейти на вкладку по номеру", + "id": "Pindah ke tab berdasarkan nomor" + }, + "Goes through the Coucou relay to Apple's push service. Only the agent name and state are sent: no project name, command, or path.": { + "zh-Hans": "通过 Coucou 中继传至 Apple 推送服务。仅发送 Agent 的名称和状态:不含项目名称、命令或路径。", + "hi": "Coucou रिले के माध्यम से Apple के पुश सर्विस से जाता है। केवल एजेंट का नाम और स्थिति भेजी जाती है: कोई प्रोजेक्ट नाम, कमांड या पाथ नहीं।", + "es": "Pasa por el relay de Coucou al servicio push de Apple. Solo se envían el nombre y el estado del agente: sin nombre de proyecto, comando ni ruta.", + "ar": "يمر عبر ريلاي Coucou إلى خدمة Apple للإشعارات. يُرسَل فقط اسم الوكيل وحالته: لا اسم مشروع ولا أمر ولا مسار.", + "fr": "Passe par le relais Coucou vers le service push d'Apple. Seuls le nom et l'état de l'agent sont envoyés : aucun nom de projet, commande ou chemin.", + "bn": "Apple-এর পুশ সার্ভিসে Coucou রিলের মাধ্যমে যায়। শুধুমাত্র এজেন্টের নাম এবং অবস্থা পাঠানো হয়: কোনো প্রজেক্ট নাম, কমান্ড বা পাথ নয়।", + "pt-BR": "Passa pelo relay do Coucou para o serviço push da Apple. Apenas o nome e o estado do agente são enviados: sem nome de projeto, comando ou caminho.", + "ru": "Проходит через ретранслятор Coucou к сервису push Apple. Отправляются только имя и состояние агента: без имени проекта, команды или пути.", + "id": "Melewati relay Coucou ke layanan push Apple. Hanya nama dan status agen yang dikirim: tanpa nama proyek, perintah, atau jalur." + }, + "Google AI": { + "zh-Hans": "Google AI", + "hi": "Google AI", + "es": "Google AI", + "ar": "Google AI", + "fr": "Google AI", + "bn": "Google AI", + "pt-BR": "Google AI", + "ru": "Google AI", + "id": "Google AI" + }, + "Hermes Agent": { + "zh-Hans": "Hermes Agent", + "hi": "Hermes Agent", + "es": "Hermes Agent", + "ar": "ملحق Hermes", + "fr": "Hermes Agent", + "bn": "Hermes প্লাগইন", + "pt-BR": "Hermes Agent", + "ru": "Hermes Agent", + "id": "Hermes Agent" + }, + "Hermes plugin removed": { + "zh-Hans": "✓ Hermes 插件已移除。", + "hi": "✓ Hermes प्लगइन हटा दिया गया।", + "es": "✓ Plugin de Hermes eliminado.", + "ar": "✓ تمت إزالة ملحق Hermes.", + "fr": "✓ Plugin Hermes supprimé.", + "bn": "✓ Hermes প্লাগইন সরানো হয়েছে।", + "pt-BR": "✓ Plugin do Hermes removido.", + "ru": "✓ Плагин Hermes удалён.", + "id": "✓ Plugin Hermes dihapus." + }, + "Hide after": { + "zh-Hans": "隐藏于", + "hi": "बाद में छुपाएं", + "es": "Ocultar después de", + "ar": "إخفاء بعد", + "fr": "Masquer après", + "bn": "পরে লুকান", + "pt-BR": "Ocultar após", + "ru": "Скрыть через", + "id": "Sembunyikan setelah" + }, + "Hide project names in shared images": { + "zh-Hans": "在分享图片中隐藏项目名称", + "hi": "साझा छवियों में प्रोजेक्ट नाम छुपाएं", + "es": "Ocultar nombres de proyectos en imágenes compartidas", + "ar": "إخفاء أسماء المشاريع في الصور المشتركة", + "fr": "Masquer les noms de projets dans les images partagées", + "bn": "শেয়ার করা ছবিতে প্রকল্পের নাম লুকান", + "pt-BR": "Ocultar nomes de projetos em imagens compartilhadas", + "ru": "Скрывать названия проектов в изображениях", + "id": "Sembunyikan nama proyek di gambar yang dibagikan" + }, + "Hooks installed": { + "zh-Hans": "Hooks 已安装", + "hi": "Hooks स्थापित", + "es": "Hooks instalados", + "ar": "Hooks مثبتة", + "fr": "Hooks installés", + "bn": "Hooks ইনস্টল করা আছে", + "pt-BR": "Hooks instalados", + "ru": "Хуки установлены", + "id": "Hooks terpasang" + }, + "Hooks installed — open Codex and run /hooks or open Hooks in app settings to approve them": { + "zh-Hans": "Hooks 已安装 — 打开 Codex 并运行 /hooks,或在应用设置中打开 Hooks 以信任它们", + "hi": "हुक इंस्टॉल हो गए — Codex खोलें और /hooks चलाएं या उन्हें विश्वसनीय बनाने के लिए ऐप की सेटिंग में Hooks खोलें", + "es": "Hooks instalados — abre Codex y ejecuta /hooks o abre Hooks en los ajustes de la app para confiar en ellos", + "ar": "الخطافات مثبَّتة — افتح Codex ونفِّذ /hooks أو افتح Hooks في إعدادات التطبيق للموافقة عليها", + "fr": "Hooks installés — ouvre Codex et lance /hooks ou ouvre Hooks dans les réglages de l'app pour les approuver", + "bn": "হুক ইনস্টল হয়েছে — Codex খুলুন এবং /hooks চালান বা অ্যাপের সেটিংসে Hooks খুলুন বিশ্বাস করতে", + "pt-BR": "Hooks instalados — abra o Codex e execute /hooks ou abra Hooks nas configurações do app para confiar neles", + "ru": "Хуки установлены — открой Codex и выполни /hooks или открой Hooks в настройках приложения, чтобы подтвердить", + "id": "Hooks terpasang — buka Codex dan jalankan /hooks atau buka Hooks di pengaturan aplikasi untuk mempercayainya" + }, + "Hooks installed — restart Antigravity to activate them": { + "zh-Hans": "Hooks 已安装 — 重启 Antigravity 以激活", + "hi": "हुक इंस्टॉल हो गए — सक्रिय करने के लिए Antigravity रीस्टार्ट करें", + "es": "Hooks instalados — reinicia Antigravity para activarlos", + "ar": "الخطافات مثبَّتة — أعِد تشغيل Antigravity للتفعيل", + "fr": "Hooks installés — redémarre Antigravity pour les activer", + "bn": "হুক ইনস্টল হয়েছে — সক্রিয় করতে Antigravity রিস্টার্ট করুন", + "pt-BR": "Hooks instalados — reinicie o Antigravity para ativar", + "ru": "Хуки установлены — перезапусти Antigravity для активации", + "id": "Hooks terpasang — mulai ulang Antigravity untuk mengaktifkan" + }, + "Hooks installed — restart Copilot CLI to activate them": { + "zh-Hans": "Hooks 已安装 — 重启 Copilot CLI 以激活", + "hi": "हुक इंस्टॉल हो गए — सक्रिय करने के लिए Copilot CLI रीस्टार्ट करें", + "es": "Hooks instalados — reinicia Copilot CLI para activarlos", + "ar": "الخطافات مثبَّتة — أعِد تشغيل Copilot CLI للتفعيل", + "fr": "Hooks installés — redémarre Copilot CLI pour les activer", + "bn": "হুক ইনস্টল হয়েছে — সক্রিয় করতে Copilot CLI রিস্টার্ট করুন", + "pt-BR": "Hooks instalados — reinicie o Copilot CLI para ativar", + "ru": "Хуки установлены — перезапусти Copilot CLI для активации", + "id": "Hooks terpasang — mulai ulang Copilot CLI untuk mengaktifkan" + }, + "Hooks installed — restart Gemini CLI to activate them": { + "zh-Hans": "Hooks 已安装 — 重启 Gemini CLI 以激活", + "hi": "हुक इंस्टॉल हो गए — सक्रिय करने के लिए Gemini CLI रीस्टार्ट करें", + "es": "Hooks instalados — reinicia Gemini CLI para activarlos", + "ar": "الخطافات مثبَّتة — أعِد تشغيل Gemini CLI للتفعيل", + "fr": "Hooks installés — redémarre Gemini CLI pour les activer", + "bn": "হুক ইনস্টল হয়েছে — সক্রিয় করতে Gemini CLI রিস্টার্ট করুন", + "pt-BR": "Hooks instalados — reinicie o Gemini CLI para ativar", + "ru": "Хуки установлены — перезапусти Gemini CLI для активации", + "id": "Hooks terpasang — mulai ulang Gemini CLI untuk mengaktifkan" + }, + "Hooks installed — restart Muse Code to activate them": { + "zh-Hans": "Hooks 已安装 — 重启 Muse Code 以激活", + "hi": "हुक इंस्टॉल हो गए — सक्रिय करने के लिए Muse Code रीस्टार्ट करें", + "es": "Hooks instalados — reinicia Muse Code para activarlos", + "ar": "الخطافات مثبَّتة — أعِد تشغيل Muse Code للتفعيل", + "fr": "Hooks installés — redémarre Muse Code pour les activer", + "bn": "হুক ইনস্টল হয়েছে — সক্রিয় করতে Muse Code রিস্টার্ট করুন", + "pt-BR": "Hooks instalados — reinicie o Muse Code para ativar", + "ru": "Хуки установлены — перезапусти Muse Code для активации", + "id": "Hooks terpasang — mulai ulang Muse Code untuk mengaktifkan" + }, + "Hooks not installed": { + "zh-Hans": "Hooks 未安装", + "hi": "Hooks स्थापित नहीं", + "es": "Hooks no instalados", + "ar": "Hooks غير مثبتة", + "fr": "Hooks non installés", + "bn": "Hooks ইনস্টল নেই", + "pt-BR": "Hooks não instalados", + "ru": "Хуки не установлены", + "id": "Hooks belum terpasang" + }, + "Hooks outdated — update them to answer Claude's questions from the notch": { + "zh-Hans": "Hooks 已过期 — 请更新以便从刘海处回答 Claude 的问题", + "hi": "हुक पुराने हैं — नॉच से Claude के सवालों का जवाब देने के लिए अपडेट करें", + "es": "Hooks desactualizados — actualízalos para responder las preguntas de Claude desde el notch", + "ar": "الخطافات قديمة — حدِّثها للإجابة على أسئلة Claude من الشريط العلوي", + "fr": "Hooks obsolètes — mets-les à jour pour répondre aux questions de Claude depuis l'encoche", + "bn": "হুক পুরনো — নচ থেকে Claude-এর প্রশ্নের উত্তর দিতে আপডেট করুন", + "pt-BR": "Hooks desatualizados — atualize-os para responder às perguntas do Claude pelo notch", + "ru": "Хуки устарели — обнови их, чтобы отвечать на вопросы Claude из ноча", + "id": "Hooks usang — perbarui untuk menjawab pertanyaan Claude dari notch" + }, + "Hotkey": { + "zh-Hans": "快捷键", + "hi": "हॉटकी", + "es": "Tecla de acceso", + "ar": "المفتاح السريع", + "fr": "Raccourci", + "bn": "হটকি", + "pt-BR": "Tecla de atalho", + "ru": "Горячая клавиша", + "id": "Tombol pintas" + }, + "Install": { + "zh-Hans": "安装", + "hi": "इंस्टॉल करें", + "es": "Instalar", + "ar": "تثبيت", + "fr": "Installer", + "bn": "ইনস্টল করুন", + "pt-BR": "Instalar", + "ru": "Установить", + "id": "Pasang" + }, + "Install Coucou hooks in ~/.claude?": { + "zh-Hans": "是否将 Coucou Hooks 安装到 ~/.claude?", + "hi": "~/.claude में Coucou हुक इंस्टॉल करें?", + "es": "¿Instalar los hooks de Coucou en ~/.claude?", + "ar": "هل تريد تثبيت خطافات Coucou في ~/.claude؟", + "fr": "Installer les hooks Coucou dans ~/.claude ?", + "bn": "~/.claude-এ Coucou হুক ইনস্টল করবেন?", + "pt-BR": "Instalar os hooks do Coucou em ~/.claude?", + "ru": "Установить хуки Coucou в ~/.claude?", + "id": "Pasang hooks Coucou di ~/.claude?" + }, + "Install hooks": { + "zh-Hans": "安装 Hooks", + "hi": "हुक इंस्टॉल करें", + "es": "Instalar hooks", + "ar": "تثبيت الخطافات", + "fr": "Installer les hooks", + "bn": "হুক ইনস্টল করুন", + "pt-BR": "Instalar hooks", + "ru": "Установить хуки", + "id": "Pasang hooks" + }, + "Install plugin": { + "zh-Hans": "安装插件", + "hi": "प्लगइन इंस्टॉल करें", + "es": "Instalar plugin", + "ar": "تثبيت الملحق", + "fr": "Installer le plugin", + "bn": "প্লাগইন ইনস্টল করুন", + "pt-BR": "Instalar plugin", + "ru": "Установить плагин", + "id": "Pasang plugin" + }, + "Install relay": { + "zh-Hans": "安装中继", + "hi": "रिले इंस्टॉल करें", + "es": "Instalar relay", + "ar": "تثبيت المُرحِّل", + "fr": "Installer le relais", + "bn": "রিলে ইনস্টল করুন", + "pt-BR": "Instalar relay", + "ru": "Установить ретранслятор", + "id": "Pasang relay" + }, + "Integrations": { + "zh-Hans": "集成", + "hi": "एकीकरण", + "es": "Integraciones", + "ar": "التكاملات", + "fr": "Intégrations", + "bn": "ইন্টিগ্রেশন", + "pt-BR": "Integrações", + "ru": "Интеграции", + "id": "Integrasi" + }, + "Jump to terminal": { + "zh-Hans": "跳转到终端", + "hi": "टर्मिनल पर जाएं", + "es": "Saltar al terminal", + "ar": "الانتقال إلى الطرفية", + "fr": "Aller au terminal", + "bn": "টার্মিনালে যাও", + "pt-BR": "Ir para o terminal", + "ru": "Перейти в терминал", + "id": "Lompat ke terminal" + }, + "Keep a history of my coding sessions": { + "zh-Hans": "保留我的编程会话历史记录", + "hi": "मेरे कोडिंग सत्रों का इतिहास रखें", + "es": "Guardar un historial de mis sesiones de código", + "ar": "الاحتفاظ بسجل جلسات الكود", + "fr": "Enregistrer l'historique de mes sessions de code", + "bn": "আমার কোডিং সেশনের ইতিহাস রাখুন", + "pt-BR": "Manter um histórico das minhas sessões de código", + "ru": "Хранить историю сессий", + "id": "Simpan riwayat sesi kode saya" + }, + "Key configured · {model}": { + "zh-Hans": "密钥已配置 · {model}", + "hi": "की कॉन्फ़िगर हुई · {model}", + "es": "Clave configurada · {model}", + "ar": "المفتاح مُهيَّأ · {model}", + "fr": "Clé configurée · {model}", + "bn": "কী কনফিগার করা হয়েছে · {model}", + "pt-BR": "Chave configurada · {model}", + "ru": "Ключ настроен · {model}", + "id": "Kunci dikonfigurasi · {model}" + }, + "Key not configured": { + "zh-Hans": "密钥未配置", + "hi": "की कॉन्फ़िगर नहीं हुई", + "es": "Clave no configurada", + "ar": "المفتاح غير مُهيَّأ", + "fr": "Clé non configurée", + "bn": "কী কনফিগার নেই", + "pt-BR": "Chave não configurada", + "ru": "Ключ не настроен", + "id": "Kunci belum dikonfigurasi" + }, + "LM Studio disconnected.": { + "zh-Hans": "LM Studio 已断开连接。", + "hi": "LM Studio डिस्कनेक्ट हो गया।", + "es": "LM Studio desconectado.", + "ar": "تم قطع اتصال LM Studio.", + "fr": "LM Studio déconnecté.", + "bn": "LM Studio সংযোগ বিচ্ছিন্ন হয়েছে।", + "pt-BR": "LM Studio desconectado.", + "ru": "LM Studio отключён.", + "id": "LM Studio terputus." + }, + "Language": { + "zh-Hans": "语言", + "hi": "भाषा", + "es": "Idioma", + "ar": "اللغة", + "fr": "Langue", + "bn": "ভাষা", + "pt-BR": "Idioma", + "ru": "Язык", + "id": "Bahasa" + }, + "Launch at Mac startup": { + "zh-Hans": "在 Mac 启动时自动启动", + "hi": "Mac स्टार्टअप पर लॉन्च करें", + "es": "Iniciar al arrancar el Mac", + "ar": "تشغيل عند بدء تشغيل Mac", + "fr": "Lancer au démarrage du Mac", + "bn": "Mac স্টার্টআপে চালু করুন", + "pt-BR": "Iniciar ao ligar o Mac", + "ru": "Запускать при старте Mac", + "id": "Jalankan saat Mac startup" + }, + "Let my iPhone send instructions to Claude Code": { + "zh-Hans": "允许我的 iPhone 向 Claude Code 发送指令", + "hi": "मेरे iPhone को Claude Code को निर्देश भेजने दें", + "es": "Permitir que mi iPhone envíe instrucciones a Claude Code", + "ar": "السماح لـ iPhone بإرسال تعليمات إلى Claude Code", + "fr": "Laisser mon iPhone envoyer des instructions à Claude Code", + "bn": "আমার iPhone-কে Claude Code-এ নির্দেশ পাঠাতে দাও", + "pt-BR": "Deixar meu iPhone enviar instruções ao Claude Code", + "ru": "Позволять iPhone отправлять инструкции в Claude Code", + "id": "Biarkan iPhone saya mengirim instruksi ke Claude Code" + }, + "Lists": { + "zh-Hans": "列出", + "hi": "सूची", + "es": "Lista", + "ar": "يُدرج", + "fr": "Liste", + "bn": "তালিকা", + "pt-BR": "Lista", + "ru": "Перечисляет", + "id": "Daftar" + }, + "Loading models…": { + "zh-Hans": "正在加载模型…", + "hi": "मॉडल लोड हो रहे हैं…", + "es": "Cargando modelos…", + "ar": "جارٍ تحميل النماذج…", + "fr": "Chargement des modèles…", + "bn": "মডেল লোড হচ্ছে…", + "pt-BR": "Carregando modelos…", + "ru": "Загрузка моделей…", + "id": "Memuat model…" + }, + "Loading…": { + "zh-Hans": "加载中…", + "hi": "लोड हो रहा है…", + "es": "Cargando…", + "ar": "جارٍ التحميل…", + "fr": "Chargement…", + "bn": "লোড হচ্ছে…", + "pt-BR": "Carregando…", + "ru": "Загрузка…", + "id": "Memuat…" + }, + "Local models": { + "zh-Hans": "本地模型", + "hi": "स्थानीय मॉडल", + "es": "Modelos locales", + "ar": "النماذج المحلية", + "fr": "Modèles locaux", + "bn": "স্থানীয় মডেল", + "pt-BR": "Modelos locais", + "ru": "Локальные модели", + "id": "Model lokal" + }, + "Longest session": { + "zh-Hans": "最长会话", + "hi": "सबसे लंबा सेशन", + "es": "Sesión más larga", + "ar": "أطول جلسة", + "fr": "Session la plus longue", + "bn": "দীর্ঘতম সেশন", + "pt-BR": "Sessão mais longa", + "ru": "Самая долгая сессия", + "id": "Sesi terpanjang" + }, + "Mail not available.": { + "zh-Hans": "邮件不可用。", + "hi": "मेल उपलब्ध नहीं है।", + "es": "Correo no disponible.", + "ar": "البريد غير متاح.", + "fr": "Messagerie non disponible.", + "bn": "মেইল পাওয়া যাচ্ছে না।", + "pt-BR": "E-mail não disponível.", + "ru": "Почта недоступна.", + "id": "Mail tidak tersedia." + }, + "Main": { + "zh-Hans": "主要", + "hi": "मुख्य", + "es": "Principal", + "ar": "رئيسي", + "fr": "Principal", + "bn": "প্রধান", + "pt-BR": "Principal", + "ru": "Главный", + "id": "Utama" + }, + "Missing recipient.": { + "zh-Hans": "收件人缺失。", + "hi": "प्राप्तकर्ता गुम है।", + "es": "Falta el destinatario.", + "ar": "المستلم مفقود.", + "fr": "Destinataire manquant.", + "bn": "প্রাপক অনুপস্থিত।", + "pt-BR": "Destinatário ausente.", + "ru": "Получатель не указан.", + "id": "Penerima tidak ada." + }, + "Mochi to desktop on/off": { + "zh-Hans": "Mochi 桌面开/关", + "hi": "Mochi डेस्कटॉप पर चालू / बंद", + "es": "Mochi en el escritorio sí / no", + "ar": "Mochi على / إيقاف سطح المكتب", + "fr": "Mochi sur le bureau oui / non", + "bn": "Mochi ডেস্কটপে চালু / বন্ধ", + "pt-BR": "Mochi na área de trabalho sim / não", + "ru": "Mochi на рабочем столе вкл / выкл", + "id": "Mochi di desktop ya / tidak" + }, + "Model": { + "zh-Hans": "模型", + "hi": "मॉडल", + "es": "Modelo", + "ar": "النموذج", + "fr": "Modèle", + "bn": "মডেল", + "pt-BR": "Modelo", + "ru": "Модель", + "id": "Model" + }, + "Model ID (e.g. claude-sonnet-4-6)": { + "zh-Hans": "模型 ID(如 claude-sonnet-4-6)", + "hi": "मॉडल ID (जैसे claude-sonnet-4-6)", + "es": "ID del modelo (p. ej. claude-sonnet-4-6)", + "ar": "معرِّف النموذج (مثلاً claude-sonnet-4-6)", + "fr": "ID du modèle (ex. claude-sonnet-4-6)", + "bn": "মডেল ID (যেমন claude-sonnet-4-6)", + "pt-BR": "ID do modelo (ex.: claude-sonnet-4-6)", + "ru": "ID модели (напр. claude-sonnet-4-6)", + "id": "ID model (mis. claude-sonnet-4-6)" + }, + "Move Mochi to my iPhone's Dynamic Island when my Mac is locked": { + "zh-Hans": "Mac 锁定时将 Mochi 移至我 iPhone 的灵动岛", + "hi": "Mac लॉक होने पर Mochi को मेरे iPhone की Dynamic Island पर ले जाएं", + "es": "Mover a Mochi a la Dynamic Island de mi iPhone cuando mi Mac esté bloqueado", + "ar": "نقل Mochi إلى Dynamic Island في iPhone عند إغلاق Mac", + "fr": "Déplacer Mochi vers la Dynamic Island de mon iPhone quand mon Mac est verrouillé", + "bn": "Mac লক থাকলে Mochi-কে আমার iPhone-এর Dynamic Island-এ নিয়ে যান", + "pt-BR": "Mover o Mochi para a Dynamic Island do meu iPhone quando meu Mac estiver bloqueado", + "ru": "Переносить Mochi на Dynamic Island моего iPhone, когда Mac заблокирован", + "id": "Pindahkan Mochi ke Dynamic Island iPhone saya saat Mac terkunci" + }, + "Muse Code hooks": { + "zh-Hans": "Muse Code 钩子", + "hi": "Muse Code हुक", + "es": "Hooks de Muse Code", + "ar": "خطافات Muse Code", + "fr": "Hooks Muse Code", + "bn": "Muse Code হুক", + "pt-BR": "Hooks do Muse Code", + "ru": "Хуки Muse Code", + "id": "Hooks Muse Code" + }, + "Mute / unmute sounds": { + "zh-Hans": "静音/取消静音", + "hi": "ध्वनि म्यूट / अनम्यूट", + "es": "Silenciar / activar sonidos", + "ar": "كتم / إلغاء كتم الأصوات", + "fr": "Couper / rétablir les sons", + "bn": "শব্দ মিউট / আনমিউট", + "pt-BR": "Silenciar / ativar sons", + "ru": "Выключить / включить звук", + "id": "Bisukan / aktifkan suara" + }, + "My PRs": { + "zh-Hans": "我的 PR", + "hi": "मेरे PR", + "es": "Mis PRs", + "ar": "طلبات السحب", + "fr": "Mes PRs", + "bn": "আমার PR", + "pt-BR": "Meus PRs", + "ru": "Мои PR", + "id": "PR saya" + }, + "Navigate list items": { + "zh-Hans": "在列表项中导航", + "hi": "सूची आइटम नेविगेट करें", + "es": "Navegar por los elementos de la lista", + "ar": "التنقل بين عناصر القائمة", + "fr": "Naviguer dans les éléments de la liste", + "bn": "তালিকার আইটেম নেভিগেট করো", + "pt-BR": "Navegar pelos itens da lista", + "ru": "Навигация по элементам списка", + "id": "Navigasi item daftar" + }, + "New conversation": { + "zh-Hans": "新建对话", + "hi": "नई बातचीत", + "es": "Nueva conversación", + "ar": "محادثة جديدة", + "fr": "Nouvelle conversation", + "bn": "নতুন কথোপকথন", + "pt-BR": "Nova conversa", + "ru": "Новый разговор", + "id": "Percakapan baru" + }, + "New email": { + "zh-Hans": "新邮件", + "hi": "नया ईमेल", + "es": "Nuevo email", + "ar": "بريد جديد", + "fr": "Nouvel email", + "bn": "নতুন ইমেইল", + "pt-BR": "Novo email", + "ru": "Новое письмо", + "id": "Email baru" + }, + "Next": { + "zh-Hans": "下一步", + "hi": "अगला", + "es": "Siguiente", + "ar": "التالي", + "fr": "Suivant", + "bn": "পরবর্তী", + "pt-BR": "Próximo", + "ru": "Далее", + "id": "Berikutnya" + }, + "Next / previous tab": { + "zh-Hans": "下一个/上一个标签", + "hi": "अगला / पिछला टैब", + "es": "Siguiente / pestaña anterior", + "ar": "التبويب التالي / السابق", + "fr": "Onglet suivant / précédent", + "bn": "পরের / আগের ট্যাব", + "pt-BR": "Próxima / aba anterior", + "ru": "Следующая / предыдущая вкладка", + "id": "Tab berikutnya / sebelumnya" + }, + "Next tab": { + "zh-Hans": "下一个标签", + "hi": "अगला टैब", + "es": "Siguiente pestaña", + "ar": "التبويب التالي", + "fr": "Onglet suivant", + "bn": "পরের ট্যাব", + "pt-BR": "Próxima aba", + "ru": "Следующая вкладка", + "id": "Tab berikutnya" + }, + "No activity last week": { + "zh-Hans": "上周无活动", + "hi": "पिछले सप्ताह कोई गतिविधि नहीं", + "es": "Sin actividad la semana pasada", + "ar": "لا نشاط الأسبوع الماضي", + "fr": "Aucune activité la semaine dernière", + "bn": "গত সপ্তাহে কোনো কার্যক্রম নেই", + "pt-BR": "Sem atividade na semana passada", + "ru": "На прошлой неделе активности не было", + "id": "Tidak ada aktivitas minggu lalu" + }, + "No calls scheduled": { + "zh-Hans": "没有计划的通话", + "hi": "कोई कॉल निर्धारित नहीं", + "es": "Sin llamadas programadas", + "ar": "لا مكالمات مجدولة", + "fr": "Aucun appel prévu", + "bn": "কোনো কল নির্ধারিত নেই", + "pt-BR": "Nenhuma chamada agendada", + "ru": "Нет звонков", + "id": "Tidak ada panggilan terjadwal" + }, + "No changes": { + "zh-Hans": "没有变更", + "hi": "कोई परिवर्तन नहीं", + "es": "Sin cambios", + "ar": "لا تغييرات", + "fr": "Aucun changement", + "bn": "কোনো পরিবর্তন নেই", + "pt-BR": "Sem alterações", + "ru": "Нет изменений", + "id": "Tidak ada perubahan" + }, + "No contributions": { + "zh-Hans": "没有贡献", + "hi": "कोई योगदान नहीं", + "es": "Sin contribuciones", + "ar": "لا مساهمات", + "fr": "Aucune contribution", + "bn": "কোনো অবদান নেই", + "pt-BR": "Sem contribuições", + "ru": "Нет вкладов", + "id": "Tidak ada kontribusi" + }, + "No error details available.": { + "zh-Hans": "无错误详情。", + "hi": "कोई त्रुटि विवरण उपलब्ध नहीं है।", + "es": "No hay detalles del error disponibles.", + "ar": "لا توجد تفاصيل للخطأ.", + "fr": "Aucun détail d'erreur disponible.", + "bn": "কোনো ত্রুটির বিবরণ পাওয়া যায়নি।", + "pt-BR": "Nenhum detalhe de erro disponível.", + "ru": "Нет подробностей об ошибке.", + "id": "Tidak ada detail kesalahan yang tersedia." + }, + "No models yet — download one in {name} first.": { + "zh-Hans": "暂无模型 — 请先在 {name} 中下载一个。", + "hi": "अभी कोई मॉडल नहीं — पहले {name} में एक डाउनलोड करें।", + "es": "Aún no hay modelos — descarga uno en {name} primero.", + "ar": "لا نماذج حتى الآن — قم بتنزيل أحد النماذج في {name} أولاً.", + "fr": "Aucun modèle pour l'instant — télécharges-en un dans {name} d'abord.", + "bn": "এখনো কোনো মডেল নেই — প্রথমে {name}-এ একটি ডাউনলোড করুন।", + "pt-BR": "Nenhum modelo ainda — baixe um no {name} primeiro.", + "ru": "Нет моделей — скачай одну в {name} сначала.", + "id": "Belum ada model — unduh satu di {name} terlebih dahulu." + }, + "Not connected": { + "zh-Hans": "未连接", + "hi": "कनेक्ट नहीं", + "es": "No conectado", + "ar": "غير متصل", + "fr": "Non connecté", + "bn": "সংযুক্ত নয়", + "pt-BR": "Não conectado", + "ru": "Не подключено", + "id": "Tidak terhubung" + }, + "Not playing": { + "zh-Hans": "未播放", + "hi": "नहीं चल रहा", + "es": "No está reproduciendo", + "ar": "لا يعزف", + "fr": "Pas en lecture", + "bn": "চলছে না", + "pt-BR": "Não está tocando", + "ru": "Не воспроизводится", + "id": "Tidak diputar" + }, + "Notch shortcuts — active when the notch is in front": { + "zh-Hans": "岛快捷键 — 岛获得焦点时有效", + "hi": "आइलैंड शॉर्टकट — आइलैंड फोकस में होने पर सक्रिय", + "es": "Atajos del island — activos cuando el island está enfocado", + "ar": "اختصارات الشريط — نشطة عند التركيز على الشريط", + "fr": "Raccourcis de l'encoché — actifs quand l'encoché est au premier plan", + "bn": "আইল্যান্ড শর্টকাট — আইল্যান্ড ফোকাসে থাকলে সক্রিয়", + "pt-BR": "Atalhos do island — ativos quando o island está em foco", + "ru": "Сочетания нотча — работают когда нотч в фокусе", + "id": "Pintasan island — aktif saat island difokuskan" + }, + "Notebook": { + "zh-Hans": "Notebook", + "hi": "Notebook", + "es": "Notebook", + "ar": "Notebook", + "fr": "Notebook", + "bn": "নোটবুক", + "pt-BR": "Notebook", + "ru": "Notebook", + "id": "Notebook" + }, + "Nothing here": { + "zh-Hans": "这里没有内容", + "hi": "यहाँ कुछ नहीं", + "es": "Nada aquí", + "ar": "لا شيء هنا", + "fr": "Rien ici", + "bn": "এখানে কিছু নেই", + "pt-BR": "Nada aqui", + "ru": "Ничего нет", + "id": "Tidak ada apa-apa" + }, + "Nothing running right now.": { + "zh-Hans": "当前没有任务。", + "hi": "अभी कुछ नहीं चल रहा।", + "es": "Nada en ejecución.", + "ar": "لا يوجد شيء يعمل الآن.", + "fr": "Rien ne tourne pour l'instant.", + "bn": "এখন কিছু চলছে না।", + "pt-BR": "Nada em execução no momento.", + "ru": "Сейчас ничего не запущено.", + "id": "Tidak ada yang berjalan sekarang." + }, + "OK": { + "zh-Hans": "确定", + "hi": "ठीक है", + "es": "Aceptar", + "ar": "موافق", + "fr": "OK", + "bn": "ঠিক আছে", + "pt-BR": "OK", + "ru": "ОК", + "id": "OK" + }, + "Ollama disconnected.": { + "zh-Hans": "Ollama 已断开连接。", + "hi": "Ollama डिस्कनेक्ट हो गया।", + "es": "Ollama desconectado.", + "ar": "تم قطع اتصال Ollama.", + "fr": "Ollama déconnecté.", + "bn": "Ollama সংযোগ বিচ্ছিন্ন হয়েছে।", + "pt-BR": "Ollama desconectado.", + "ru": "Ollama отключён.", + "id": "Ollama terputus." + }, + "Only http:// and https:// URLs are accepted.": { + "zh-Hans": "仅支持 http:// 和 https:// URL。", + "hi": "केवल http:// और https:// URL समर्थित हैं।", + "es": "Solo se admiten URL con http:// y https://.", + "ar": "يُدعم http:// وhttps:// فقط.", + "fr": "Seules les URL http:// et https:// sont acceptées.", + "bn": "শুধুমাত্র http:// এবং https:// URL সমর্থিত।", + "pt-BR": "Apenas URLs com http:// e https:// são suportadas.", + "ru": "Поддерживаются только URL с http:// и https://.", + "id": "Hanya URL http:// dan https:// yang didukung." + }, + "Open": { + "zh-Hans": "打开", + "hi": "खोलें", + "es": "Abrir", + "ar": "فتح", + "fr": "Ouvrir", + "bn": "খুলুন", + "pt-BR": "Abrir", + "ru": "Открыть", + "id": "Buka" + }, + "Open / close current diff": { + "zh-Hans": "打开/关闭当前差异", + "hi": "वर्तमान डिफ खोलें / बंद करें", + "es": "Abrir / cerrar diff actual", + "ar": "فتح / إغلاق الفرق الحالي", + "fr": "Ouvrir / fermer le diff actuel", + "bn": "বর্তমান ডিফ খোলা / বন্ধ", + "pt-BR": "Abrir / fechar diff atual", + "ru": "Открыть / закрыть текущий diff", + "id": "Buka / tutup diff saat ini" + }, + "Open / close notch": { + "zh-Hans": "打开/关闭岛", + "hi": "आइलैंड खोलें / बंद करें", + "es": "Abrir / cerrar island", + "ar": "فتح / إغلاق الشريط", + "fr": "Ouvrir / fermer l'encoché", + "bn": "আইল্যান্ড খোলা / বন্ধ", + "pt-BR": "Abrir / fechar island", + "ru": "Открыть / закрыть нотч", + "id": "Buka / tutup island" + }, + "Open / close wardrobe": { + "zh-Hans": "打开/关闭衣橱", + "hi": "वार्डरोब खोलें / बंद करें", + "es": "Abrir / cerrar guardarropa", + "ar": "فتح / إغلاق خزانة الملابس", + "fr": "Ouvrir / fermer la garde-robe", + "bn": "পোশাক খোলা / বন্ধ", + "pt-BR": "Abrir / fechar guarda-roupa", + "ru": "Открыть / закрыть гардероб", + "id": "Buka / tutup lemari" + }, + "Open Codex": { + "zh-Hans": "打开 Codex", + "hi": "Codex खोलें", + "es": "Abrir Codex", + "ar": "فتح Codex", + "fr": "Ouvrir Codex", + "bn": "Codex খুলুন", + "pt-BR": "Abrir Codex", + "ru": "Открыть Codex", + "id": "Buka Codex" + }, + "Open Coucou": { + "zh-Hans": "打开 Coucou", + "hi": "Coucou खोलें", + "es": "Abrir Coucou", + "ar": "فتح Coucou", + "fr": "Ouvrir Coucou", + "bn": "Coucou খুলুন", + "pt-BR": "Abrir Coucou", + "ru": "Открыть Coucou", + "id": "Buka Coucou" + }, + "Open Cursor": { + "zh-Hans": "打开 Cursor", + "hi": "Cursor खोलें", + "es": "Abrir Cursor", + "ar": "فتح Cursor", + "fr": "Ouvrir Cursor", + "bn": "Cursor খুলুন", + "pt-BR": "Abrir Cursor", + "ru": "Открыть Cursor", + "id": "Buka Cursor" + }, + "Open Music": { + "zh-Hans": "打开 Music", + "hi": "Music खोलें", + "es": "Abrir Music", + "ar": "فتح Music", + "fr": "Ouvrir Music", + "bn": "Music খুলুন", + "pt-BR": "Abrir Music", + "ru": "Открыть Music", + "id": "Buka Music" + }, + "Open Settings…": { + "zh-Hans": "打开设置…", + "hi": "सेटिंग्स खोलें…", + "es": "Abrir ajustes…", + "ar": "فتح الإعدادات…", + "fr": "Ouvrir les réglages…", + "bn": "সেটিংস খুলুন…", + "pt-BR": "Abrir Configurações…", + "ru": "Открыть настройки…", + "id": "Buka Pengaturan…" + }, + "Open Visual Studio Code": { + "zh-Hans": "打开 Visual Studio Code", + "hi": "Visual Studio Code खोलें", + "es": "Abrir Visual Studio Code", + "ar": "فتح Visual Studio Code", + "fr": "Ouvrir Visual Studio Code", + "bn": "Visual Studio Code খুলুন", + "pt-BR": "Abrir Visual Studio Code", + "ru": "Открыть Visual Studio Code", + "id": "Buka Visual Studio Code" + }, + "Open chat": { + "zh-Hans": "打开聊天", + "hi": "चैट खोलें", + "es": "Abrir chat", + "ar": "فتح المحادثة", + "fr": "Ouvrir le chat", + "bn": "চ্যাট খোলো", + "pt-BR": "Abrir chat", + "ru": "Открыть чат", + "id": "Buka chat" + }, + "Open in n8n": { + "zh-Hans": "在 n8n 中打开", + "hi": "n8n में खोलें", + "es": "Abrir en n8n", + "ar": "فتح في n8n", + "fr": "Ouvrir dans n8n", + "bn": "n8n-এ খুলুন", + "pt-BR": "Abrir no n8n", + "ru": "Открыть в n8n", + "id": "Buka di n8n" + }, + "Open selected item": { + "zh-Hans": "打开选定项", + "hi": "चयनित आइटम खोलें", + "es": "Abrir elemento seleccionado", + "ar": "فتح العنصر المحدد", + "fr": "Ouvrir l'élément sélectionné", + "bn": "নির্বাচিত আইটেম খোলো", + "pt-BR": "Abrir item selecionado", + "ru": "Открыть выбранный элемент", + "id": "Buka item yang dipilih" + }, + "Open settings": { + "zh-Hans": "打开设置", + "hi": "सेटिंग्स खोलें", + "es": "Abrir Ajustes", + "ar": "فتح الإعدادات", + "fr": "Ouvrir les réglages", + "bn": "সেটিংস খোলো", + "pt-BR": "Abrir Ajustes", + "ru": "Открыть настройки", + "id": "Buka Pengaturan" + }, + "Open terminal": { + "zh-Hans": "打开终端", + "hi": "टर्मिनल खोलें", + "es": "Abrir terminal", + "ar": "فتح الطرفية", + "fr": "Ouvrir le terminal", + "bn": "টার্মিনাল খুলুন", + "pt-BR": "Abrir terminal", + "ru": "Открыть терминал", + "id": "Buka terminal" + }, + "Open {name}": { + "zh-Hans": "打开 {name}", + "hi": "{name} खोलें", + "es": "Abrir {name}", + "ar": "فتح {name}", + "fr": "Ouvrir {name}", + "bn": "{name} খুলুন", + "pt-BR": "Abrir {name}", + "ru": "Открыть {name}", + "id": "Buka {name}" + }, + "OpenAI": { + "zh-Hans": "OpenAI", + "hi": "OpenAI", + "es": "OpenAI", + "ar": "OpenAI", + "fr": "OpenAI", + "bn": "OpenAI", + "pt-BR": "OpenAI", + "ru": "OpenAI", + "id": "OpenAI" + }, + "OpenCode plugin": { + "zh-Hans": "OpenCode 插件", + "hi": "OpenCode प्लगइन", + "es": "Plugin de OpenCode", + "ar": "ملحق OpenCode", + "fr": "Plugin OpenCode", + "bn": "OpenCode প্লাগইন", + "pt-BR": "Plugin do OpenCode", + "ru": "Плагин OpenCode", + "id": "Plugin OpenCode" + }, + "Other…": { + "zh-Hans": "其他…", + "hi": "अन्य…", + "es": "Otro…", + "ar": "أخرى…", + "fr": "Autre…", + "bn": "অন্যান্য…", + "pt-BR": "Outro…", + "ru": "Другое…", + "id": "Lainnya…" + }, + "Overview": { + "zh-Hans": "概览", + "hi": "अवलोकन", + "es": "Resumen", + "ar": "نظرة عامة", + "fr": "Aperçu", + "bn": "সংক্ষিপ্ত বিবরণ", + "pt-BR": "Visão geral", + "ru": "Обзор", + "id": "Ikhtisar" + }, + "Payments": { + "zh-Hans": "付款", + "hi": "भुगतान", + "es": "Pagos", + "ar": "المدفوعات", + "fr": "Paiements", + "bn": "পেমেন্ট", + "pt-BR": "Pagamentos", + "ru": "Платежи", + "id": "Pembayaran" + }, + "Pin / unpin notch": { + "zh-Hans": "固定/取消固定岛", + "hi": "आइलैंड पिन / अनपिन", + "es": "Fijar / desfijar island", + "ar": "تثبيت / إلغاء تثبيت الشريط", + "fr": "Épingler / désépingler l'encoché", + "bn": "আইল্যান্ড পিন / আনপিন", + "pt-BR": "Fixar / soltar island", + "ru": "Закрепить / открепить нотч", + "id": "Sematkan / lepas island" + }, + "Plan usage": { + "zh-Hans": "计划用量", + "hi": "प्लान उपयोग", + "es": "Uso del plan", + "ar": "استخدام الخطة", + "fr": "Utilisation du forfait", + "bn": "প্ল্যান ব্যবহার", + "pt-BR": "Uso do plano", + "ru": "Использование плана", + "id": "Penggunaan paket" + }, + "Playing · {title}": { + "zh-Hans": "正在播放 · {title}", + "hi": "चल रहा है · {title}", + "es": "Reproduciendo · {title}", + "ar": "قيد التشغيل · {title}", + "fr": "Lecture · {title}", + "bn": "চালু আছে · {title}", + "pt-BR": "Tocando · {title}", + "ru": "Играет · {title}", + "id": "Memutar · {title}" + }, + "Plugin installed — restart Amp to activate it": { + "zh-Hans": "插件已安装 — 重启 Amp 以激活", + "hi": "प्लगइन इंस्टॉल हो गया — सक्रिय करने के लिए Amp रीस्टार्ट करें", + "es": "Plugin instalado — reinicia Amp para activarlo", + "ar": "الملحق مثبَّت — أعِد تشغيل Amp للتفعيل", + "fr": "Plugin installé — redémarre Amp pour l'activer", + "bn": "প্লাগইন ইনস্টল হয়েছে — সক্রিয় করতে Amp রিস্টার্ট করুন", + "pt-BR": "Plugin instalado — reinicie o Amp para ativar", + "ru": "Плагин установлен — перезапусти Amp для активации", + "id": "Plugin terpasang — mulai ulang Amp untuk mengaktifkan" + }, + "Plugin installed — restart OpenCode to activate it": { + "zh-Hans": "插件已安装 — 重启 OpenCode 以激活", + "hi": "प्लगइन इंस्टॉल हो गया — सक्रिय करने के लिए OpenCode रीस्टार्ट करें", + "es": "Plugin instalado — reinicia OpenCode para activarlo", + "ar": "الملحق مثبَّت — أعِد تشغيل OpenCode للتفعيل", + "fr": "Plugin installé — redémarre OpenCode pour l'activer", + "bn": "প্লাগইন ইনস্টল হয়েছে — সক্রিয় করতে OpenCode রিস্টার্ট করুন", + "pt-BR": "Plugin instalado — reinicie o OpenCode para ativar", + "ru": "Плагин установлен — перезапусти OpenCode для активации", + "id": "Plugin terpasang — mulai ulang OpenCode untuk mengaktifkan" + }, + "Plugin not installed": { + "zh-Hans": "插件未安装", + "hi": "प्लगइन इंस्टॉल नहीं है", + "es": "Plugin no instalado", + "ar": "المكوّن الإضافي غير مثبَّت", + "fr": "Plugin non installé", + "bn": "প্লাগইন ইনস্টল নেই", + "pt-BR": "Plugin não instalado", + "ru": "Плагин не установлен", + "id": "Plugin tidak terpasang" + }, + "Previous tab": { + "zh-Hans": "上一个标签", + "hi": "पिछला टैब", + "es": "Pestaña anterior", + "ar": "التبويب السابق", + "fr": "Onglet précédent", + "bn": "আগের ট্যাব", + "pt-BR": "Aba anterior", + "ru": "Предыдущая вкладка", + "id": "Tab sebelumnya" + }, + "Projects": { + "zh-Hans": "项目", + "hi": "प्रोजेक्ट", + "es": "Proyectos", + "ar": "المشاريع", + "fr": "Projets", + "bn": "প্রজেক্ট", + "pt-BR": "Projetos", + "ru": "Проекты", + "id": "Proyek" + }, + "Quit": { + "zh-Hans": "退出", + "hi": "छोड़ें", + "es": "Salir", + "ar": "إنهاء", + "fr": "Quitter", + "bn": "বাহির হন", + "pt-BR": "Sair", + "ru": "Выход", + "id": "Keluar" + }, + "Reads": { + "zh-Hans": "读取", + "hi": "पढ़ता है", + "es": "Lee", + "ar": "يقرأ", + "fr": "Lit", + "bn": "পড়ে", + "pt-BR": "Lê", + "ru": "Читает", + "id": "Membaca" + }, + "Recent": { + "zh-Hans": "最近", + "hi": "हाल का", + "es": "Reciente", + "ar": "الأخيرة", + "fr": "Récent", + "bn": "সাম্প্রতিক", + "pt-BR": "Recente", + "ru": "Недавние", + "id": "Terbaru" + }, + "Refresh": { + "zh-Hans": "刷新", + "hi": "रिफ्रेश", + "es": "Actualizar", + "ar": "تحديث", + "fr": "Actualiser", + "bn": "রিফ্রেশ", + "pt-BR": "Atualizar", + "ru": "Обновить", + "id": "Muat ulang" + }, + "Relay: installed": { + "zh-Hans": "中继:已安装", + "hi": "रिले: इंस्टॉल है", + "es": "Relay: instalado", + "ar": "المُرحِّل: مثبَّت", + "fr": "Relais : installé", + "bn": "রিলে: ইনস্টল আছে", + "pt-BR": "Relay: instalado", + "ru": "Ретранслятор: установлен", + "id": "Relay: terpasang" + }, + "Relay: not installed": { + "zh-Hans": "中继:未安装", + "hi": "रिले: इंस्टॉल नहीं है", + "es": "Relay: no instalado", + "ar": "المُرحِّل: غير مثبَّت", + "fr": "Relais : non installé", + "bn": "রিলে: ইনস্টল নেই", + "pt-BR": "Relay: não instalado", + "ru": "Ретранслятор: не установлен", + "id": "Relay: belum terpasang" + }, + "Reply in terminal": { + "zh-Hans": "在终端回复", + "hi": "टर्मिनल में उत्तर दें", + "es": "Responder en terminal", + "ar": "الرد في الطرفية", + "fr": "Répondre dans le terminal", + "bn": "টার্মিনালে উত্তর দিন", + "pt-BR": "Responder no terminal", + "ru": "Ответить в терминале", + "id": "Balas di terminal" + }, + "Resend": { + "zh-Hans": "重新发送", + "hi": "पुनः भेजें", + "es": "Reenviar", + "ar": "إعادة الإرسال", + "fr": "Renvoyer", + "bn": "পুনরায় পাঠান", + "pt-BR": "Reenviar", + "ru": "Повторить", + "id": "Kirim ulang" + }, + "Reset all to defaults": { + "zh-Hans": "全部重置为默认值", + "hi": "सभी को डिफ़ॉल्ट पर रीसेट करें", + "es": "Restablecer todo a los valores predeterminados", + "ar": "إعادة تعيين الكل إلى الافتراضي", + "fr": "Tout réinitialiser par défaut", + "bn": "সব ডিফল্টে রিসেট করো", + "pt-BR": "Redefinir tudo para os padrões", + "ru": "Сбросить всё к значениям по умолчанию", + "id": "Reset semua ke default" + }, + "Resetting…": { + "zh-Hans": "重置中…", + "hi": "रीसेट हो रहा है…", + "es": "Reiniciando…", + "ar": "جارٍ إعادة الضبط…", + "fr": "Réinitialisation…", + "bn": "রিসেট হচ্ছে…", + "pt-BR": "Reiniciando…", + "ru": "Сброс…", + "id": "Mereset…" + }, + "Restart Coucou": { + "zh-Hans": "重启 Coucou", + "hi": "Coucou को पुनः आरंभ करें", + "es": "Reiniciar Coucou", + "ar": "إعادة تشغيل Coucou", + "fr": "Redémarrer Coucou", + "bn": "Coucou পুনরায় শুরু করুন", + "pt-BR": "Reiniciar Coucou", + "ru": "Перезапустить Coucou", + "id": "Mulai ulang Coucou" + }, + "Retry": { + "zh-Hans": "重试", + "hi": "पुनः प्रयास", + "es": "Reintentar", + "ar": "إعادة المحاولة", + "fr": "Réessayer", + "bn": "পুনরায় চেষ্টা", + "pt-BR": "Tentar novamente", + "ru": "Повторить", + "id": "Coba lagi" + }, + "Review the JSON below before confirming.": { + "zh-Hans": "确认前请检查以下 JSON。", + "hi": "पुष्टि करने से पहले नीचे दिए JSON की समीक्षा करें।", + "es": "Revisa el JSON a continuación antes de confirmar.", + "ar": "راجع JSON أدناه قبل التأكيد.", + "fr": "Relis le JSON ci-dessous avant de confirmer.", + "bn": "নিশ্চিত করার আগে নীচের JSON পর্যালোচনা করুন।", + "pt-BR": "Revise o JSON abaixo antes de confirmar.", + "ru": "Просмотри JSON ниже перед подтверждением.", + "id": "Tinjau JSON di bawah sebelum mengonfirmasi." + }, + "Review the content below before confirming.": { + "zh-Hans": "确认前请检查以下内容。", + "hi": "पुष्टि करने से पहले नीचे दी गई सामग्री की समीक्षा करें।", + "es": "Revisa el contenido a continuación antes de confirmar.", + "ar": "راجع المحتوى أدناه قبل التأكيد.", + "fr": "Relis le contenu ci-dessous avant de confirmer.", + "bn": "নিশ্চিত করার আগে নীচের বিষয়বস্তু পর্যালোচনা করুন।", + "pt-BR": "Revise o conteúdo abaixo antes de confirmar.", + "ru": "Просмотри содержимое ниже перед подтверждением.", + "id": "Tinjau konten di bawah sebelum mengonfirmasi." + }, + "Runs": { + "zh-Hans": "执行", + "hi": "चलाता है", + "es": "Ejecuta", + "ar": "يُشغّل", + "fr": "Exécute", + "bn": "চালায়", + "pt-BR": "Executa", + "ru": "Запускает", + "id": "Menjalankan" + }, + "SESSIONS": { + "zh-Hans": "会话", + "hi": "सेशन", + "es": "SESIONES", + "ar": "الجلسات", + "fr": "SESSIONS", + "bn": "সেশন", + "pt-BR": "SESSÕES", + "ru": "СЕССИИ", + "id": "SESI" + }, + "Save": { + "zh-Hans": "保存", + "hi": "सहेजें", + "es": "Guardar", + "ar": "حفظ", + "fr": "Enregistrer", + "bn": "সংরক্ষণ", + "pt-BR": "Salvar", + "ru": "Сохранить", + "id": "Simpan" + }, + "Save integrations": { + "zh-Hans": "保存集成", + "hi": "इंटीग्रेशन सहेजें", + "es": "Guardar integraciones", + "ar": "حفظ التكاملات", + "fr": "Enregistrer les intégrations", + "bn": "ইন্টিগ্রেশন সংরক্ষণ করুন", + "pt-BR": "Salvar integrações", + "ru": "Сохранить интеграции", + "id": "Simpan integrasi" + }, + "Save…": { + "zh-Hans": "存储…", + "hi": "सहेजें…", + "es": "Guardar…", + "ar": "حفظ…", + "fr": "Enregistrer…", + "bn": "সংরক্ষণ করুন…", + "pt-BR": "Salvar…", + "ru": "Сохранить…", + "id": "Simpan…" + }, + "Schedule": { + "zh-Hans": "日程", + "hi": "शेड्यूल", + "es": "Horario", + "ar": "الجدول", + "fr": "Agenda", + "bn": "সময়সূচি", + "pt-BR": "Agenda", + "ru": "Расписание", + "id": "Jadwal" + }, + "Searches": { + "zh-Hans": "搜索", + "hi": "खोजता है", + "es": "Busca", + "ar": "يبحث", + "fr": "Recherche", + "bn": "খোঁজে", + "pt-BR": "Pesquisa", + "ru": "Ищет", + "id": "Mencari" + }, + "Searches the web": { + "zh-Hans": "搜索网络", + "hi": "वेब खोजता है", + "es": "Busca en la web", + "ar": "يبحث على الويب", + "fr": "Recherche web", + "bn": "ওয়েব খোঁজে", + "pt-BR": "Pesquisa na web", + "ru": "Ищет в сети", + "id": "Mencari web" + }, + "Send": { + "zh-Hans": "发送", + "hi": "भेजें", + "es": "Enviar", + "ar": "إرسال", + "fr": "Envoyer", + "bn": "পাঠান", + "pt-BR": "Enviar", + "ru": "Отправить", + "id": "Kirim" + }, + "Send by email": { + "zh-Hans": "通过邮件发送", + "hi": "ईमेल से भेजें", + "es": "Enviar por email", + "ar": "إرسال بالبريد الإلكتروني", + "fr": "Envoyer par email", + "bn": "ইমেইলে পাঠান", + "pt-BR": "Enviar por email", + "ru": "Отправить по email", + "id": "Kirim via email" + }, + "Send chat message": { + "zh-Hans": "发送聊天消息", + "hi": "चैट संदेश भेजें", + "es": "Enviar mensaje del chat", + "ar": "إرسال رسالة الدردشة", + "fr": "Envoyer le message de chat", + "bn": "চ্যাট বার্তা পাঠাও", + "pt-BR": "Enviar mensagem do chat", + "ru": "Отправить сообщение в чат", + "id": "Kirim pesan chat" + }, + "Sending…": { + "zh-Hans": "发送中…", + "hi": "भेज रहा है…", + "es": "Enviando…", + "ar": "جارٍ الإرسال…", + "fr": "Envoi en cours…", + "bn": "পাঠানো হচ্ছে…", + "pt-BR": "Enviando…", + "ru": "Отправка…", + "id": "Mengirim…" + }, + "Sends your sessions to your private iCloud for the Coucou app on iPhone. Project names, commands, and questions are encrypted with your iCloud keys. Disabling removes data from iCloud.": { + "zh-Hans": "将你的会话发送到私有 iCloud,供 Coucou iPhone 应用使用。项目名称、命令和问题使用你的 iCloud 密钥加密。关闭后将从 iCloud 中删除。", + "hi": "Coucou iPhone ऐप के लिए आपके सेशन आपके प्राइवेट iCloud में भेजता है। प्रोजेक्ट नाम, कमांड और सवाल आपकी iCloud कुंजियों से एन्क्रिप्ट किए जाते हैं। बंद करने पर iCloud से हटा दिए जाते हैं।", + "es": "Envía tus sesiones a tu iCloud privado para la app Coucou de iPhone. Los nombres de proyecto, comandos y preguntas se cifran con tus claves de iCloud. Desactivarlo las elimina de iCloud.", + "ar": "يُرسل جلساتك إلى iCloud الخاص بك لتطبيق Coucou على iPhone. تُشفَّر أسماء المشاريع والأوامر والأسئلة بمفاتيح iCloud الخاصة بك. إيقاف التشغيل يحذفها من iCloud.", + "fr": "Envoie tes sessions sur ton iCloud privé pour l'app Coucou sur iPhone. Les noms de projets, commandes et questions sont chiffrés avec tes clés iCloud. Désactiver supprime les données d'iCloud.", + "bn": "Coucou iPhone অ্যাপের জন্য আপনার সেশনগুলো আপনার প্রাইভেট iCloud-এ পাঠায়। প্রজেক্টের নাম, কমান্ড এবং প্রশ্নগুলো আপনার iCloud কী দিয়ে এনক্রিপ্ট করা হয়। বন্ধ করলে iCloud থেকে মুছে যাবে।", + "pt-BR": "Envia suas sessões para seu iCloud privado para o app Coucou no iPhone. Nomes de projetos, comandos e perguntas são criptografados com suas chaves do iCloud. Desativar remove-os do iCloud.", + "ru": "Отправляет твои сессии в личный iCloud для приложения Coucou на iPhone. Имена проектов, команды и вопросы шифруются твоими ключами iCloud. Отключение удаляет их из iCloud.", + "id": "Mengirim sesi Anda ke iCloud pribadi Anda untuk aplikasi Coucou iPhone. Nama proyek, perintah, dan pertanyaan dienkripsi dengan kunci iCloud Anda. Mematikannya menghapusnya dari iCloud." + }, + "Session finished": { + "zh-Hans": "会话已结束", + "hi": "सत्र समाप्त", + "es": "Sesión finalizada", + "ar": "انتهت الجلسة", + "fr": "Session terminée", + "bn": "সেশন শেষ", + "pt-BR": "Sessão finalizada", + "ru": "Сессия завершена", + "id": "Sesi selesai" + }, + "Settings — Coucou": { + "zh-Hans": "设置 — Coucou", + "hi": "सेटिंग्स — Coucou", + "es": "Ajustes — Coucou", + "ar": "الإعدادات — Coucou", + "fr": "Réglages — Coucou", + "bn": "সেটিংস — Coucou", + "pt-BR": "Configurações — Coucou", + "ru": "Настройки — Coucou", + "id": "Pengaturan — Coucou" + }, + "Settings…": { + "zh-Hans": "设置…", + "hi": "सेटिंग्स…", + "es": "Configuración…", + "ar": "الإعدادات…", + "fr": "Réglages…", + "bn": "সেটিংস…", + "pt-BR": "Configurações…", + "ru": "Настройки…", + "id": "Pengaturan…" + }, + "Share image": { + "zh-Hans": "分享图片", + "hi": "छवि शेयर करें", + "es": "Compartir imagen", + "ar": "مشاركة الصورة", + "fr": "Partager l'image", + "bn": "ছবি শেয়ার করুন", + "pt-BR": "Compartilhar imagem", + "ru": "Поделиться картинкой", + "id": "Bagikan gambar" + }, + "Share…": { + "zh-Hans": "分享…", + "hi": "शेयर करें…", + "es": "Compartir…", + "ar": "مشاركة…", + "fr": "Partager…", + "bn": "শেয়ার করুন…", + "pt-BR": "Compartilhar…", + "ru": "Поделиться…", + "id": "Bagikan…" + }, + "Shortcut": { + "zh-Hans": "快捷键", + "hi": "शॉर्टकट", + "es": "Atajo", + "ar": "الاختصار", + "fr": "Raccourci", + "bn": "শর্টকাট", + "pt-BR": "Atalho", + "ru": "Сочетание клавиш", + "id": "Pintasan" + }, + "Shortcuts": { + "zh-Hans": "快捷键", + "hi": "शॉर्टकट", + "es": "Atajos", + "ar": "الاختصارات", + "fr": "Raccourcis", + "bn": "শর্টকাট", + "pt-BR": "Atalhos", + "ru": "Горячие клавиши", + "id": "Pintasan" + }, + "Show approval requests in the notch": { + "zh-Hans": "在刘海中显示审批请求", + "hi": "नॉच में अनुमोदन अनुरोध दिखाएं", + "es": "Mostrar solicitudes de aprobación en el notch", + "ar": "عرض طلبات الموافقة في الشريط", + "fr": "Afficher les demandes d'approbation dans le notch", + "bn": "নচে অনুমোদন অনুরোধ দেখান", + "pt-BR": "Mostrar solicitações de aprovação no notch", + "ru": "Показывать запросы подтверждения в notch", + "id": "Tampilkan permintaan persetujuan di notch" + }, + "Show in notch": { + "zh-Hans": "在刘海中显示", + "hi": "नॉच में दिखाएं", + "es": "Mostrar en el notch", + "ar": "عرض في الشريط العلوي", + "fr": "Afficher dans l'encoche", + "bn": "নচে দেখান", + "pt-BR": "Mostrar no notch", + "ru": "Показывать в нотче", + "id": "Tampilkan di notch" + }, + "Show island with shortcut": { + "zh-Hans": "用快捷键显示岛", + "hi": "शॉर्टकट से द्वीप दिखाएं", + "es": "Mostrar isla con atajo", + "ar": "إظهار الجزيرة بالاختصار", + "fr": "Afficher l'île avec le raccourci", + "bn": "শর্টকাট দিয়ে আইল্যান্ড দেখান", + "pt-BR": "Mostrar ilha com atalho", + "ru": "Показывать остров горячей клавишей", + "id": "Tampilkan island dengan pintasan" + }, + "Show my agent sessions on my iPhone": { + "zh-Hans": "在我的 iPhone 上显示我的 Agent 会话", + "hi": "मेरे iPhone पर मेरे एजेंट सेशन दिखाएं", + "es": "Mostrar mis sesiones de agente en mi iPhone", + "ar": "عرض جلسات الوكيل على iPhone", + "fr": "Afficher mes sessions d'agent sur mon iPhone", + "bn": "আমার iPhone-এ আমার এজেন্ট সেশন দেখান", + "pt-BR": "Mostrar minhas sessões de agente no meu iPhone", + "ru": "Показывать мои сессии агента на iPhone", + "id": "Tampilkan sesi agen saya di iPhone saya" + }, + "Shows your Claude plan usage (5-hour and weekly limits) in the notch header. Coucou adds a status line relay in ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.": { + "zh-Hans": "在刘海标头中显示你的 Claude 计划用量(5 小时及每周限额)。Coucou 会在 ~/.claude/settings.json 中添加一条状态栏中继。如果你已有状态栏,它将照常运行。仅限 Pro 和 Max 计划。", + "hi": "नॉच हेडर में आपके Claude प्लान का उपयोग (5 घंटे और साप्ताहिक सीमाएं) दिखाता है। Coucou ~/.claude/settings.json में एक स्टेटस लाइन रिले जोड़ता है। अगर आपके पास पहले से स्टेटस लाइन है, तो वह पहले की तरह काम करती रहेगी। केवल Pro और Max प्लान के लिए।", + "es": "Muestra el uso de tu plan Claude (límites de 5 horas y semanales) en el encabezado del notch. Coucou añade una línea de estado relay en ~/.claude/settings.json. Si ya tienes una línea de estado, seguirá funcionando como antes. Solo planes Pro y Max.", + "ar": "يعرض استخدام خطة Claude (الحدود الخمس ساعات والأسبوعية) في ترويسة الشريط العلوي. تضيف Coucou سطر حالة مُرحِّلاً إلى ~/.claude/settings.json. إن كان لديك سطر حالة بالفعل، يستمر في العمل كالمعتاد. للخططَين Pro وMax فقط.", + "fr": "Affiche ton utilisation du forfait Claude (limites de 5 heures et hebdomadaire) dans l'en-tête de l'encoche. Coucou ajoute un relais de ligne de statut dans ~/.claude/settings.json. Si tu as déjà une ligne de statut, elle continue de fonctionner comme avant. Forfaits Pro et Max uniquement.", + "bn": "নচ হেডারে আপনার Claude প্ল্যানের ব্যবহার (৫ ঘণ্টা ও সাপ্তাহিক সীমা) দেখায়। Coucou ~/.claude/settings.json-এ একটি স্ট্যাটাস লাইন রিলে যোগ করে। আপনার কাছে ইতিমধ্যে স্ট্যাটাস লাইন থাকলে তা আগের মতোই কাজ করবে। শুধুমাত্র Pro এবং Max প্ল্যানের জন্য।", + "pt-BR": "Mostra o uso do seu plano Claude (limites de 5 horas e semanais) no cabeçalho do notch. O Coucou adiciona um relay de linha de status ao ~/.claude/settings.json. Se você já tiver uma linha de status, ela continua funcionando normalmente. Apenas planos Pro e Max.", + "ru": "Показывает использование плана Claude (лимиты на 5 часов и на неделю) в шапке нотча. Coucou добавляет ретранслятор строки статуса в ~/.claude/settings.json. Если строка статуса уже есть, она продолжит работать как прежде. Только для планов Pro и Max.", + "id": "Menampilkan penggunaan paket Claude (batas 5 jam dan mingguan) di header notch. Coucou menambahkan relay baris status ke ~/.claude/settings.json. Jika Anda sudah memiliki baris status, tetap berjalan seperti sebelumnya. Hanya untuk paket Pro dan Max." + }, + "Sound": { + "zh-Hans": "声音", + "hi": "ध्वनि", + "es": "Sonido", + "ar": "الصوت", + "fr": "Son", + "bn": "শব্দ", + "pt-BR": "Som", + "ru": "Звук", + "id": "Suara" + }, + "Startup": { + "zh-Hans": "启动", + "hi": "स्टार्टअप", + "es": "Inicio", + "ar": "بدء التشغيل", + "fr": "Démarrage", + "bn": "স্টার্টআপ", + "pt-BR": "Inicialização", + "ru": "Запуск", + "id": "Mulai" + }, + "Stop demo": { + "zh-Hans": "停止演示", + "hi": "डेमो रोकें", + "es": "Detener demo", + "ar": "إيقاف العرض", + "fr": "Arrêter la démo", + "bn": "ডেমো বন্ধ করুন", + "pt-BR": "Parar demo", + "ru": "Остановить демо", + "id": "Hentikan demo" + }, + "Stored locally on your Mac. Nothing leaves your Mac. Retained for 12 weeks.": { + "zh-Hans": "储存在 Mac 本地,不会离开你的 Mac,保留 12 周。", + "hi": "आपके Mac पर स्थानीय रूप से संग्रहीत। कुछ भी Mac नहीं छोड़ता। 12 सप्ताह तक रखा जाता है।", + "es": "Almacenado localmente en tu Mac. Nada sale de tu Mac. Retenido durante 12 semanas.", + "ar": "مخزَّن محليًا على Mac. لا يغادر أي شيء جهازك. يُحتفظ به لمدة 12 أسبوعًا.", + "fr": "Stocké localement sur ton Mac. Rien ne quitte ton Mac. Conservé pendant 12 semaines.", + "bn": "তোমার Mac-এ স্থানীয়ভাবে সংরক্ষিত। কিছু Mac ছেড়ে যায় না। ১২ সপ্তাহ ধরে রাখা হয়।", + "pt-BR": "Armazenado localmente no seu Mac. Nada sai do seu Mac. Mantido por 12 semanas.", + "ru": "Хранится локально на Mac. Ничего не покидает Mac. Хранится 12 недель.", + "id": "Disimpan secara lokal di Mac. Tidak ada yang keluar dari Mac Anda. Disimpan selama 12 minggu." + }, + "Subject": { + "zh-Hans": "主题", + "hi": "विषय", + "es": "Asunto", + "ar": "الموضوع", + "fr": "Objet", + "bn": "বিষয়", + "pt-BR": "Assunto", + "ru": "Тема", + "id": "Subjek" + }, + "Success": { + "zh-Hans": "成功", + "hi": "सफलता", + "es": "Éxito", + "ar": "نجاح", + "fr": "Réussi", + "bn": "সফল", + "pt-BR": "Sucesso", + "ru": "Успешно", + "id": "Berhasil" + }, + "System": { + "zh-Hans": "系统", + "hi": "सिस्टम", + "es": "Sistema", + "ar": "النظام", + "fr": "Système", + "bn": "সিস্টেম", + "pt-BR": "Sistema", + "ru": "Системный", + "id": "Sistem" + }, + "System conflict": { + "zh-Hans": "系统冲突", + "hi": "सिस्टम कनफ्लिक्ट", + "es": "Conflicto del sistema", + "ar": "تعارض مع النظام", + "fr": "Conflit système", + "bn": "সিস্টেম কনফ্লিক্ট", + "pt-BR": "Conflito do sistema", + "ru": "Системный конфликт", + "id": "Konflik sistem" + }, + "TIME CODING": { + "zh-Hans": "编程时长", + "hi": "कोडिंग समय", + "es": "TIEMPO PROGRAMANDO", + "ar": "وقت البرمجة", + "fr": "TEMPS DE CODE", + "bn": "কোডিং সময়", + "pt-BR": "TEMPO PROGRAMANDO", + "ru": "ВРЕМЯ КОДИНГА", + "id": "WAKTU PEMROGRAMAN" + }, + "Tasks": { + "zh-Hans": "任务", + "hi": "कार्य", + "es": "Tareas", + "ar": "مهام", + "fr": "Tâches", + "bn": "কার্য", + "pt-BR": "Tarefas", + "ru": "Задачи", + "id": "Tugas" + }, + "Tests": { + "zh-Hans": "测试", + "hi": "परीक्षण करता है", + "es": "Prueba", + "ar": "يختبر", + "fr": "Teste", + "bn": "পরীক্ষা করে", + "pt-BR": "Testa", + "ru": "Тестирует", + "id": "Menguji" + }, + "To": { + "zh-Hans": "收件人", + "hi": "प्रति", + "es": "Para", + "ar": "إلى", + "fr": "À", + "bn": "প্রতি", + "pt-BR": "Para", + "ru": "Кому", + "id": "Kepada" + }, + "To review": { + "zh-Hans": "待审查", + "hi": "समीक्षा के लिए", + "es": "Para revisar", + "ar": "للمراجعة", + "fr": "À relire", + "bn": "পর্যালোচনার জন্য", + "pt-BR": "Para revisar", + "ru": "На ревью", + "id": "Untuk ditinjau" + }, + "To use Google Gemini or OpenAI from the chat. Keys are stored in the Keychain.": { + "zh-Hans": "用于在聊天中使用 Google Gemini 或 OpenAI。密钥存储在 Keychain 中。", + "hi": "चैट से Google Gemini या OpenAI उपयोग करने के लिए। कुंजियां Keychain में संग्रहीत की जाती हैं।", + "es": "Para usar Google Gemini u OpenAI desde el chat. Las claves se guardan en el Keychain.", + "ar": "لاستخدام Google Gemini أو OpenAI من الدردشة. تُحفظ المفاتيح في Keychain.", + "fr": "Pour utiliser Google Gemini ou OpenAI depuis le chat. Les clés sont stockées dans le Trousseau.", + "bn": "চ্যাট থেকে Google Gemini বা OpenAI ব্যবহার করতে। কীগুলো Keychain-এ সংরক্ষিত হয়।", + "pt-BR": "Para usar o Google Gemini ou OpenAI pelo chat. As chaves são armazenadas no Keychain.", + "ru": "Для использования Google Gemini или OpenAI в чате. Ключи хранятся в Keychain.", + "id": "Untuk menggunakan Google Gemini atau OpenAI dari chat. Kunci disimpan di Keychain." + }, + "Too many hits at once.": { + "zh-Hans": "一次点击太多次了。", + "hi": "एक बार में बहुत ज़्यादा टैप।", + "es": "Demasiados golpes a la vez.", + "ar": "عدد كبير من النقرات دفعة واحدة.", + "fr": "Trop de clics d'un coup.", + "bn": "একসাথে অনেক বেশি ক্লিক।", + "pt-BR": "Muitas batidas de uma vez.", + "ru": "Слишком много нажатий подряд.", + "id": "Terlalu banyak ketukan sekaligus." + }, + "Top agent": { + "zh-Hans": "最活跃 Agent", + "hi": "शीर्ष एजेंट", + "es": "Agente principal", + "ar": "أفضل وكيل", + "fr": "Agent principal", + "bn": "শীর্ষ এজেন্ট", + "pt-BR": "Agente principal", + "ru": "Топ агент", + "id": "Agen teratas" + }, + "Top project": { + "zh-Hans": "最活跃项目", + "hi": "शीर्ष प्रोजेक्ट", + "es": "Proyecto principal", + "ar": "أفضل مشروع", + "fr": "Projet principal", + "bn": "শীর্ষ প্রজেক্ট", + "pt-BR": "Projeto principal", + "ru": "Топ проект", + "id": "Proyek teratas" + }, + "Try demo mode": { + "zh-Hans": "试用演示模式", + "hi": "डेमो मोड आज़माएँ", + "es": "Probar el modo demo", + "ar": "تجربة وضع العرض", + "fr": "Essayer la démo", + "bn": "ডেমো মোড চেষ্টা করুন", + "pt-BR": "Experimentar modo demo", + "ru": "Попробовать демо", + "id": "Coba mode demo" + }, + "Uninstall": { + "zh-Hans": "卸载", + "hi": "अनइंस्टॉल करें", + "es": "Desinstalar", + "ar": "إلغاء التثبيت", + "fr": "Désinstaller", + "bn": "আনইনস্টল করুন", + "pt-BR": "Desinstalar", + "ru": "Удалить", + "id": "Hapus" + }, + "Uninstall relay": { + "zh-Hans": "卸载中继", + "hi": "रिले अनइंस्टॉल करें", + "es": "Desinstalar relay", + "ar": "إلغاء تثبيت المُرحِّل", + "fr": "Désinstaller le relais", + "bn": "রিলে আনইনস্টল করুন", + "pt-BR": "Desinstalar relay", + "ru": "Удалить ретранслятор", + "id": "Hapus relay" + }, + "Unknown": { + "zh-Hans": "未知", + "hi": "अज्ञात", + "es": "Desconocido", + "ar": "غير معروف", + "fr": "Inconnu", + "bn": "অজানা", + "pt-BR": "Desconhecido", + "ru": "Неизвестно", + "id": "Tidak diketahui" + }, + "Update hooks": { + "zh-Hans": "更新 Hooks", + "hi": "हुक अपडेट करें", + "es": "Actualizar hooks", + "ar": "تحديث الخطافات", + "fr": "Mettre à jour les hooks", + "bn": "হুক আপডেট করুন", + "pt-BR": "Atualizar hooks", + "ru": "Обновить хуки", + "id": "Perbarui hooks" + }, + "Uploading {name}": { + "zh-Hans": "正在上传 {name}", + "hi": "{name} अपलोड हो रहा है", + "es": "Subiendo {name}", + "ar": "جارٍ رفع {name}", + "fr": "Envoi de {name} en cours", + "bn": "{name} আপলোড হচ্ছে", + "pt-BR": "Enviando {name}", + "ru": "Загружается {name}", + "id": "Mengunggah {name}" + }, + "Used by the chat. The list comes from your Anthropic account.": { + "zh-Hans": "由聊天使用。列表来自你的 Anthropic 账户。", + "hi": "चैट द्वारा उपयोग किया जाता है। सूची आपके Anthropic खाते से आती है।", + "es": "Lo usa el chat. La lista proviene de tu cuenta de Anthropic.", + "ar": "يستخدمه الدردشة. تأتي القائمة من حساب Anthropic الخاص بك.", + "fr": "Utilisé par le chat. La liste vient de ton compte Anthropic.", + "bn": "চ্যাটে ব্যবহৃত হয়। তালিকা আপনার Anthropic অ্যাকাউন্ট থেকে আসে।", + "pt-BR": "Usado pelo chat. A lista vem da sua conta Anthropic.", + "ru": "Используется чатом. Список берётся из твоего аккаунта Anthropic.", + "id": "Digunakan oleh chat. Daftar berasal dari akun Anthropic Anda." + }, + "Volume": { + "zh-Hans": "音量", + "hi": "वॉल्यूम", + "es": "Volumen", + "ar": "مستوى الصوت", + "fr": "Volume", + "bn": "ভলিউম", + "pt-BR": "Volume", + "ru": "Громкость", + "id": "Volume" + }, + "Waiting for a response from Claude Code": { + "zh-Hans": "等待 Claude Code 回复", + "hi": "Claude Code के जवाब का इंतजार", + "es": "Esperando respuesta de Claude Code", + "ar": "في انتظار رد من Claude Code", + "fr": "En attente d'une réponse de Claude Code", + "bn": "Claude Code-এর উত্তরের অপেক্ষায়", + "pt-BR": "Aguardando resposta do Claude Code", + "ru": "Ожидание ответа Claude Code", + "id": "Menunggu balasan Claude Code" + }, + "Wardrobe": { + "zh-Hans": "衣橱", + "hi": "अलमारी", + "es": "Guardarropa", + "ar": "خزانة الملابس", + "fr": "Garde-robe", + "bn": "পোশাক ঘর", + "pt-BR": "Guarda-roupa", + "ru": "Гардероб", + "id": "Lemari" + }, + "Watching {count} of {total}": { + "zh-Hans": "监控 {count} / {total}", + "hi": "{count} में से {total} देख रहे हैं", + "es": "Viendo {count} de {total}", + "ar": "مشاهدة {count} من {total}", + "fr": "{count} sur {total} surveillés", + "bn": "{count} টির মধ্যে {total} টি দেখছেন", + "pt-BR": "Observando {count} de {total}", + "ru": "Наблюдение: {count} из {total}", + "id": "Melihat {count} dari {total}" + }, + "Week": { + "zh-Hans": "一周", + "hi": "सप्ताह", + "es": "Semana", + "ar": "أسبوع", + "fr": "Semaine", + "bn": "সপ্তাহ", + "pt-BR": "Semana", + "ru": "Неделя", + "id": "Minggu" + }, + "Weekly recap": { + "zh-Hans": "每周回顾", + "hi": "साप्ताहिक रिकैप", + "es": "Resumen semanal", + "ar": "الملخص الأسبوعي", + "fr": "Récap de la semaine", + "bn": "সাপ্তাহিক রিক্যাপ", + "pt-BR": "Resumo semanal", + "ru": "Итоги недели", + "id": "Rekap mingguan" + }, + "What do you want to do with it?": { + "zh-Hans": "您想对它做什么?", + "hi": "आप इसके साथ क्या करना चाहते हैं?", + "es": "¿Qué quieres hacer con él?", + "ar": "ماذا تريد أن تفعل به؟", + "fr": "Que veux-tu faire avec ?", + "bn": "আপনি এটা দিয়ে কী করতে চান?", + "pt-BR": "O que você quer fazer com ele?", + "ru": "Что вы хотите с ним сделать?", + "id": "Apa yang ingin Anda lakukan dengannya?" + }, + "Workflow stopped.": { + "zh-Hans": "工作流已停止。", + "hi": "वर्कफ़्लो रुक गया।", + "es": "Flujo de trabajo detenido.", + "ar": "توقف سير العمل.", + "fr": "Workflow arrêté.", + "bn": "ওয়ার্কফ্লো বন্ধ হয়ে গেছে।", + "pt-BR": "Fluxo de trabalho parado.", + "ru": "Рабочий процесс остановлен.", + "id": "Alur kerja dihentikan." + }, + "Workflows": { + "zh-Hans": "工作流", + "hi": "वर्कफ़्लो", + "es": "Flujos de trabajo", + "ar": "سير العمل", + "fr": "Workflows", + "bn": "ওয়ার্কফ্লো", + "pt-BR": "Workflows", + "ru": "Рабочие процессы", + "id": "Alur kerja" + }, + "Writes": { + "zh-Hans": "写入", + "hi": "लिखता है", + "es": "Escribe", + "ar": "يكتب", + "fr": "Écrit", + "bn": "লেখে", + "pt-BR": "Escreve", + "ru": "Записывает", + "id": "Menulis" + }, + "Writes:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (backup created first)": { + "zh-Hans": "将写入:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json(先创建备份)", + "hi": "लिखेगा:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (पहले बैकअप बनाया जाएगा)", + "es": "Escribirá:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (se crea una copia de seguridad primero)", + "ar": "سيكتب:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (يُنشئ نسخة احتياطية أولاً)", + "fr": "Écrira :\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (sauvegarde créée d'abord)", + "bn": "লিখবে:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (আগে ব্যাকআপ তৈরি হবে)", + "pt-BR": "Vai escrever:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (backup criado antes)", + "ru": "Запишет:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (резервная копия создаётся сначала)", + "id": "Akan menulis:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (backup dibuat terlebih dahulu)" + }, + "Your answer…": { + "zh-Hans": "您的答案…", + "hi": "आपका उत्तर…", + "es": "Tu respuesta…", + "ar": "إجابتك…", + "fr": "Ta réponse…", + "bn": "আপনার উত্তর…", + "pt-BR": "Sua resposta…", + "ru": "Твой ответ…", + "id": "Jawaban Anda…" + }, + "coding": { + "zh-Hans": "编程", + "hi": "कोडिंग", + "es": "programando", + "ar": "برمجة", + "fr": "de code", + "bn": "কোডিং", + "pt-BR": "programando", + "ru": "программирования", + "id": "pemrograman" + }, + "commands": { + "zh-Hans": "条命令", + "hi": "कमांड", + "es": "comandos", + "ar": "أوامر", + "fr": "commandes", + "bn": "কমান্ড", + "pt-BR": "comandos", + "ru": "команды", + "id": "perintah" + }, + "file": { + "zh-Hans": "个文件", + "hi": "फ़ाइल", + "es": "archivo", + "ar": "ملف", + "fr": "fichier", + "bn": "ফাইল", + "pt-BR": "arquivo", + "ru": "файл", + "id": "berkas" + }, + "files": { + "zh-Hans": "个文件", + "hi": "फ़ाइलें", + "es": "archivos", + "ar": "ملفات", + "fr": "fichiers", + "bn": "ফাইল", + "pt-BR": "arquivos", + "ru": "файлы", + "id": "berkas" + }, + "in {h} h {m}": { + "zh-Hans": "{h} 小时 {m} 分后", + "hi": "{h} घंटे {m} मिनट में", + "es": "en {h} h {m}", + "ar": "خلال {h} س {m} د", + "fr": "dans {h} h {m}", + "bn": "{h} ঘণ্টা {m} মিনিটে", + "pt-BR": "em {h} h {m}", + "ru": "через {h} ч {m} мин", + "id": "dalam {h} j {m}" + }, + "in {m} min": { + "zh-Hans": "{m} 分钟后", + "hi": "{m} मिनट में", + "es": "en {m} min", + "ar": "خلال {m} دقيقة", + "fr": "dans {m} min", + "bn": "{m} মিনিটে", + "pt-BR": "em {m} min", + "ru": "через {m} мин", + "id": "dalam {m} menit" + }, + "just now": { + "zh-Hans": "刚刚", + "hi": "अभी", + "es": "justo ahora", + "ar": "للتو", + "fr": "à l'instant", + "bn": "এইমাত্র", + "pt-BR": "agora há pouco", + "ru": "только что", + "id": "baru saja" + }, + "min without movement": { + "zh-Hans": "分钟无动作", + "hi": "मिनट बिना हलचल", + "es": "min sin movimiento", + "ar": "دقيقة بدون حركة", + "fr": "min sans mouvement", + "bn": "মিনিট নড়াচড়া ছাড়া", + "pt-BR": "min sem movimento", + "ru": "мин без активности", + "id": "mnt tanpa gerakan" + }, + "needs permission": { + "zh-Hans": "需要权限", + "hi": "अनुमति चाहिए", + "es": "necesita permiso", + "ar": "يحتاج إذناً", + "fr": "a besoin d'une permission", + "bn": "অনুমতি প্রয়োজন", + "pt-BR": "precisa de permissão", + "ru": "требует разрешения", + "id": "butuh izin" + }, + "presses this → island opens": { + "zh-Hans": "按此键 → 岛打开", + "hi": "दबाने पर → द्वीप खुलता है", + "es": "presiona esto → se abre la isla", + "ar": "اضغط هذا → تفتح الجزيرة", + "fr": "appuie → l'île s'ouvre", + "bn": "এটি চাপলে → আইল্যান্ড খোলে", + "pt-BR": "pressione → a ilha abre", + "ru": "нажми → остров открывается", + "id": "tekan ini → island terbuka" + }, + "s inactive": { + "zh-Hans": "秒无活动", + "hi": "से. निष्क्रिय", + "es": "s inactivo", + "ar": "ث بدون نشاط", + "fr": "s d'inactivité", + "bn": "সে. নিষ্ক্রিয়", + "pt-BR": "s inativo", + "ru": "с без активности", + "id": "dtk tidak aktif" + }, + "session": { + "zh-Hans": "个会话", + "hi": "सेशन", + "es": "sesión", + "ar": "جلسة", + "fr": "session", + "bn": "সেশন", + "pt-BR": "sessão", + "ru": "сессия", + "id": "sesi" + }, + "sessions": { + "zh-Hans": "个会话", + "hi": "सेशन", + "es": "sesiones", + "ar": "جلسات", + "fr": "sessions", + "bn": "সেশন", + "pt-BR": "sessões", + "ru": "сессии", + "id": "sesi" + }, + "with": { + "zh-Hans": "附带", + "hi": "के साथ", + "es": "con", + "ar": "مع", + "fr": "avec", + "bn": "সহ", + "pt-BR": "com", + "ru": "с", + "id": "dengan" + }, + "with {name}": { + "zh-Hans": "附件 {name}", + "hi": "{name} के साथ", + "es": "con {name}", + "ar": "مع {name}", + "fr": "avec {name}", + "bn": "{name}-এর সাথে", + "pt-BR": "com {name}", + "ru": "с {name}", + "id": "dengan {name}" + }, + "{count}/4 slots used": { + "zh-Hans": "已用 {count}/4 个插槽", + "hi": "{count}/4 स्लॉट उपयोग में", + "es": "{count}/4 ranuras usadas", + "ar": "{count}/4 فتحات مستخدمة", + "fr": "{count}/4 emplacements utilisés", + "bn": "{count}/4 স্লট ব্যবহৃত", + "pt-BR": "{count}/4 slots usados", + "ru": "{count}/4 слотов занято", + "id": "{count}/4 slot digunakan" + }, + "{name} is ready.": { + "zh-Hans": "{name} 已就绪。", + "hi": "{name} तैयार है।", + "es": "{name} está listo.", + "ar": "{name} جاهز.", + "fr": "{name} est prêt.", + "bn": "{name} প্রস্তুত।", + "pt-BR": "{name} está pronto.", + "ru": "{name} готов.", + "id": "{name} siap." + }, + "{n} h ago": { + "zh-Hans": "{n} 小时前", + "hi": "{n} घंटे पहले", + "es": "hace {n} h", + "ar": "منذ {n} ساعة", + "fr": "il y a {n} h", + "bn": "{n} ঘণ্টা আগে", + "pt-BR": "há {n} h", + "ru": "{n} ч назад", + "id": "{n} jam lalu" + }, + "{n} min ago": { + "zh-Hans": "{n} 分钟前", + "hi": "{n} मिनट पहले", + "es": "hace {n} min", + "ar": "منذ {n} دقيقة", + "fr": "il y a {n} min", + "bn": "{n} মিনিট আগে", + "pt-BR": "há {n} min", + "ru": "{n} мин назад", + "id": "{n} menit lalu" + }, + "✓ Amp plugin installed in ~/.config/amp/plugins/coucou.ts": { + "zh-Hans": "✓ Amp 插件已安装至 ~/.config/amp/plugins/coucou.ts", + "hi": "✓ ~/.config/amp/plugins/coucou.ts में Amp प्लगइन इंस्टॉल हुआ", + "es": "✓ Plugin de Amp instalado en ~/.config/amp/plugins/coucou.ts", + "ar": "✓ ملحق Amp مثبَّت في ~/.config/amp/plugins/coucou.ts", + "fr": "✓ Plugin Amp installé dans ~/.config/amp/plugins/coucou.ts", + "bn": "✓ ~/.config/amp/plugins/coucou.ts-এ Amp প্লাগইন ইনস্টল হয়েছে", + "pt-BR": "✓ Plugin do Amp instalado em ~/.config/amp/plugins/coucou.ts", + "ru": "✓ Плагин Amp установлен в ~/.config/amp/plugins/coucou.ts", + "id": "✓ Plugin Amp terpasang di ~/.config/amp/plugins/coucou.ts" + }, + "✓ Amp plugin removed.": { + "zh-Hans": "✓ Amp 插件已移除。", + "hi": "✓ Amp प्लगइन हटा दिया गया।", + "es": "✓ Plugin de Amp eliminado.", + "ar": "✓ تمت إزالة ملحق Amp.", + "fr": "✓ Plugin Amp supprimé.", + "bn": "✓ Amp প্লাগইন সরানো হয়েছে।", + "pt-BR": "✓ Plugin do Amp removido.", + "ru": "✓ Плагин Amp удалён.", + "id": "✓ Plugin Amp dihapus." + }, + "✓ Antigravity hooks installed in ~/.gemini/config/hooks.json": { + "zh-Hans": "✓ Antigravity Hooks 已安装至 ~/.gemini/config/hooks.json", + "hi": "✓ ~/.gemini/config/hooks.json में Antigravity हुक इंस्टॉल हुए", + "es": "✓ Hooks de Antigravity instalados en ~/.gemini/config/hooks.json", + "ar": "✓ خطافات Antigravity مثبَّتة في ~/.gemini/config/hooks.json", + "fr": "✓ Hooks Antigravity installés dans ~/.gemini/config/hooks.json", + "bn": "✓ ~/.gemini/config/hooks.json-এ Antigravity হুক ইনস্টল হয়েছে", + "pt-BR": "✓ Hooks do Antigravity instalados em ~/.gemini/config/hooks.json", + "ru": "✓ Хуки Antigravity установлены в ~/.gemini/config/hooks.json", + "id": "✓ Hooks Antigravity terpasang di ~/.gemini/config/hooks.json" + }, + "✓ Antigravity hooks removed.": { + "zh-Hans": "✓ Antigravity Hooks 已移除。", + "hi": "✓ Antigravity हुक हटा दिए गए।", + "es": "✓ Hooks de Antigravity eliminados.", + "ar": "✓ تمت إزالة خطافات Antigravity.", + "fr": "✓ Hooks Antigravity supprimés.", + "bn": "✓ Antigravity হুক সরানো হয়েছে।", + "pt-BR": "✓ Hooks do Antigravity removidos.", + "ru": "✓ Хуки Antigravity удалены.", + "id": "✓ Hooks Antigravity dihapus." + }, + "✓ Codex hooks installed — run /hooks in Codex or open Hooks in app settings to approve them.": { + "zh-Hans": "✓ Codex Hooks 已安装 — 在 Codex 中运行 /hooks,或在应用设置中打开 Hooks 以信任它们。", + "hi": "✓ Codex हुक इंस्टॉल हो गए — Codex में /hooks चलाएं या उन्हें विश्वसनीय बनाने के लिए ऐप की सेटिंग में Hooks खोलें।", + "es": "✓ Hooks de Codex instalados — ejecuta /hooks en Codex o abre Hooks en los ajustes de la app para confiar en ellos.", + "ar": "✓ خطافات Codex مثبَّتة — نفِّذ /hooks في Codex أو افتح Hooks في إعدادات التطبيق للموافقة عليها.", + "fr": "✓ Hooks Codex installés — lance /hooks dans Codex ou ouvre Hooks dans les réglages de l'app pour les approuver.", + "bn": "✓ Codex হুক ইনস্টল হয়েছে — Codex-এ /hooks চালান বা সেগুলো বিশ্বাস করতে অ্যাপের সেটিংসে Hooks খুলুন।", + "pt-BR": "✓ Hooks do Codex instalados — execute /hooks no Codex ou abra Hooks nas configurações do app para confiar neles.", + "ru": "✓ Хуки Codex установлены — выполни /hooks в Codex или открой Hooks в настройках приложения, чтобы подтвердить.", + "id": "✓ Hooks Codex terpasang — jalankan /hooks di Codex atau buka Hooks di pengaturan aplikasi untuk mempercayainya." + }, + "✓ Codex hooks removed.": { + "zh-Hans": "✓ Codex Hooks 已移除。", + "hi": "✓ Codex हुक हटा दिए गए।", + "es": "✓ Hooks de Codex eliminados.", + "ar": "✓ تمت إزالة خطافات Codex.", + "fr": "✓ Hooks Codex supprimés.", + "bn": "✓ Codex হুক সরানো হয়েছে।", + "pt-BR": "✓ Hooks do Codex removidos.", + "ru": "✓ Хуки Codex удалены.", + "id": "✓ Hooks Codex dihapus." + }, + "✓ Connected · {count} models": { + "zh-Hans": { + "one": "✓ 已连接 · {count} 个模型", + "other": "✓ 已连接 · {count} 个模型" + }, + "hi": { + "one": "✓ कनेक्ट हो गया · {count} मॉडल", + "other": "✓ कनेक्ट हो गया · {count} मॉडल" + }, + "es": { + "one": "✓ Conectado · {count} modelo", + "other": "✓ Conectado · {count} modelos" + }, + "ar": { + "one": "✓ متصل · نموذج {count}", + "other": "✓ متصل · {count} نماذج" + }, + "fr": { + "one": "✓ Connecté · {count} modèle", + "other": "✓ Connecté · {count} modèles" + }, + "bn": { + "one": "✓ সংযুক্ত · {count} টি মডেল", + "other": "✓ সংযুক্ত · {count} টি মডেল" + }, + "pt-BR": { + "one": "✓ Conectado · {count} modelo", + "other": "✓ Conectado · {count} modelos" + }, + "ru": { + "one": "✓ Подключено · {count} модель", + "other": "✓ Подключено · {count} модели" + }, + "id": { + "one": "✓ Terhubung · {count} model", + "other": "✓ Terhubung · {count} model" + }, + "en": { + "one": "✓ Connected · {count} model", + "other": "✓ Connected · {count} models" + } + }, + "✓ Copilot CLI hooks installed in ~/.copilot/hooks/coucou.json": { + "zh-Hans": "✓ Copilot CLI Hooks 已安装至 ~/.copilot/hooks/coucou.json", + "hi": "✓ ~/.copilot/hooks/coucou.json में Copilot CLI हुक इंस्टॉल हुए", + "es": "✓ Hooks de Copilot CLI instalados en ~/.copilot/hooks/coucou.json", + "ar": "✓ خطافات Copilot CLI مثبَّتة في ~/.copilot/hooks/coucou.json", + "fr": "✓ Hooks Copilot CLI installés dans ~/.copilot/hooks/coucou.json", + "bn": "✓ ~/.copilot/hooks/coucou.json-এ Copilot CLI হুক ইনস্টল হয়েছে", + "pt-BR": "✓ Hooks do Copilot CLI instalados em ~/.copilot/hooks/coucou.json", + "ru": "✓ Хуки Copilot CLI установлены в ~/.copilot/hooks/coucou.json", + "id": "✓ Hooks Copilot CLI terpasang di ~/.copilot/hooks/coucou.json" + }, + "✓ Copilot CLI hooks removed.": { + "zh-Hans": "✓ Copilot CLI Hooks 已移除。", + "hi": "✓ Copilot CLI हुक हटा दिए गए।", + "es": "✓ Hooks de Copilot CLI eliminados.", + "ar": "✓ تمت إزالة خطافات Copilot CLI.", + "fr": "✓ Hooks Copilot CLI supprimés.", + "bn": "✓ Copilot CLI হুক সরানো হয়েছে।", + "pt-BR": "✓ Hooks do Copilot CLI removidos.", + "ru": "✓ Хуки Copilot CLI удалены.", + "id": "✓ Hooks Copilot CLI dihapus." + }, + "✓ Gemini CLI hooks installed in ~/.gemini/settings.json": { + "zh-Hans": "✓ Gemini CLI Hooks 已安装至 ~/.gemini/settings.json", + "hi": "✓ ~/.gemini/settings.json में Gemini CLI हुक इंस्टॉल हुए", + "es": "✓ Hooks de Gemini CLI instalados en ~/.gemini/settings.json", + "ar": "✓ خطافات Gemini CLI مثبَّتة في ~/.gemini/settings.json", + "fr": "✓ Hooks Gemini CLI installés dans ~/.gemini/settings.json", + "bn": "✓ ~/.gemini/settings.json-এ Gemini CLI হুক ইনস্টল হয়েছে", + "pt-BR": "✓ Hooks do Gemini CLI instalados em ~/.gemini/settings.json", + "ru": "✓ Хуки Gemini CLI установлены в ~/.gemini/settings.json", + "id": "✓ Hooks Gemini CLI terpasang di ~/.gemini/settings.json" + }, + "✓ Gemini CLI hooks removed.": { + "zh-Hans": "✓ Gemini CLI Hooks 已移除。", + "hi": "✓ Gemini CLI हुक हटा दिए गए।", + "es": "✓ Hooks de Gemini CLI eliminados.", + "ar": "✓ تمت إزالة خطافات Gemini CLI.", + "fr": "✓ Hooks Gemini CLI supprimés.", + "bn": "✓ Gemini CLI হুক সরানো হয়েছে।", + "pt-BR": "✓ Hooks do Gemini CLI removidos.", + "ru": "✓ Хуки Gemini CLI удалены.", + "id": "✓ Hooks Gemini CLI dihapus." + }, + "✓ Google key saved.": { + "zh-Hans": "✓ Google 密钥已保存。", + "hi": "✓ Google कुंजी सहेजी गई।", + "es": "✓ Clave de Google guardada.", + "ar": "✓ تم حفظ مفتاح Google.", + "fr": "✓ Clé Google enregistrée.", + "bn": "✓ Google কী সংরক্ষিত হয়েছে।", + "pt-BR": "✓ Chave do Google salva.", + "ru": "✓ Ключ Google сохранён.", + "id": "✓ Kunci Google tersimpan." + }, + "✓ Hermes plugin installed": { + "zh-Hans": "✓ Hermes 插件已安装", + "hi": "✓ Hermes प्लगइन इंस्टॉल हुआ", + "es": "✓ Plugin de Hermes instalado", + "ar": "✓ ملحق Hermes مثبَّت", + "fr": "✓ Plugin Hermes installé", + "bn": "✓ Hermes প্লাগইন ইনস্টল হয়েছে", + "pt-BR": "✓ Plugin do Hermes instalado", + "ru": "✓ Плагин Hermes установлен", + "id": "✓ Plugin Hermes terpasang" + }, + "✓ Hermes plugin installed in ~/.hermes/plugins/coucou/": { + "zh-Hans": "✓ Hermes 插件已安装至 ~/.hermes/plugins/coucou/", + "hi": "✓ Hermes प्लगइन ~/.hermes/plugins/coucou/ में इंस्टॉल हुआ", + "es": "✓ Plugin de Hermes instalado en ~/.hermes/plugins/coucou/", + "ar": "✓ ملحق Hermes مثبَّت في ~/.hermes/plugins/coucou/", + "fr": "✓ Plugin Hermes installé dans ~/.hermes/plugins/coucou/", + "bn": "✓ Hermes প্লাগইন ~/.hermes/plugins/coucou/-এ ইনস্টল হয়েছে", + "pt-BR": "✓ Plugin do Hermes instalado em ~/.hermes/plugins/coucou/", + "ru": "✓ Плагин Hermes установлен в ~/.hermes/plugins/coucou/", + "id": "✓ Plugin Hermes terpasang di ~/.hermes/plugins/coucou/" + }, + "✓ Hooks installed in ~/.claude/settings.json": { + "zh-Hans": "✓ Hooks 已安装至 ~/.claude/settings.json", + "hi": "✓ ~/.claude/settings.json में हुक इंस्टॉल हुए", + "es": "✓ Hooks instalados en ~/.claude/settings.json", + "ar": "✓ الخطافات مثبَّتة في ~/.claude/settings.json", + "fr": "✓ Hooks installés dans ~/.claude/settings.json", + "bn": "✓ ~/.claude/settings.json-এ হুক ইনস্টল হয়েছে", + "pt-BR": "✓ Hooks instalados em ~/.claude/settings.json", + "ru": "✓ Хуки установлены в ~/.claude/settings.json", + "id": "✓ Hooks terpasang di ~/.claude/settings.json" + }, + "✓ Hooks installed — restart VS Code to activate them.": { + "zh-Hans": "✓ Hooks 已安装 — 重启 VS Code 以激活。", + "hi": "✓ हुक इंस्टॉल हो गए — सक्रिय करने के लिए VS Code रीस्टार्ट करें।", + "es": "✓ Hooks instalados — reinicia VS Code para activarlos.", + "ar": "✓ الخطافات مثبَّتة — أعِد تشغيل VS Code للتفعيل.", + "fr": "✓ Hooks installés — redémarre VS Code pour les activer.", + "bn": "✓ হুক ইনস্টল হয়েছে — সক্রিয় করতে VS Code রিস্টার্ট করুন।", + "pt-BR": "✓ Hooks instalados — reinicie o VS Code para ativar.", + "ru": "✓ Хуки установлены — перезапусти VS Code для активации.", + "id": "✓ Hooks terpasang — mulai ulang VS Code untuk mengaktifkan." + }, + "✓ Hooks removed.": { + "zh-Hans": "✓ Hooks 已移除。", + "hi": "✓ हुक हटा दिए गए।", + "es": "✓ Hooks eliminados.", + "ar": "✓ تمت إزالة الخطافات.", + "fr": "✓ Hooks supprimés.", + "bn": "✓ হুক সরানো হয়েছে।", + "pt-BR": "✓ Hooks removidos.", + "ru": "✓ Хуки удалены.", + "id": "✓ Hooks dihapus." + }, + "✓ Integration keys saved.": { + "zh-Hans": "✓ 集成密钥已保存。", + "hi": "✓ इंटीग्रेशन कीज़ सेव हो गई।", + "es": "✓ Claves de integración guardadas.", + "ar": "✓ تم حفظ مفاتيح التكامل.", + "fr": "✓ Clés d'intégration enregistrées.", + "bn": "✓ ইন্টিগ্রেশন কি সংরক্ষিত হয়েছে।", + "pt-BR": "✓ Chaves de integração salvas.", + "ru": "✓ Ключи интеграции сохранены.", + "id": "✓ Kunci integrasi tersimpan." + }, + "✓ Key saved.": { + "zh-Hans": "✓ 密钥已保存。", + "hi": "✓ कुंजी सहेजी गई।", + "es": "✓ Clave guardada.", + "ar": "✓ تم حفظ المفتاح.", + "fr": "✓ Clé enregistrée.", + "bn": "✓ কী সংরক্ষিত হয়েছে।", + "pt-BR": "✓ Chave salva.", + "ru": "✓ Ключ сохранён.", + "id": "✓ Kunci tersimpan." + }, + "✓ Muse Code hooks installed in ~/.config/muse/settings.json": { + "zh-Hans": "✓ Muse Code Hooks 已安装至 ~/.config/muse/settings.json", + "hi": "✓ ~/.config/muse/settings.json में Muse Code हुक इंस्टॉल हुए", + "es": "✓ Hooks de Muse Code instalados en ~/.config/muse/settings.json", + "ar": "✓ خطافات Muse Code مثبَّتة في ~/.config/muse/settings.json", + "fr": "✓ Hooks Muse Code installés dans ~/.config/muse/settings.json", + "bn": "✓ ~/.config/muse/settings.json-এ Muse Code হুক ইনস্টল হয়েছে", + "pt-BR": "✓ Hooks do Muse Code instalados em ~/.config/muse/settings.json", + "ru": "✓ Хуки Muse Code установлены в ~/.config/muse/settings.json", + "id": "✓ Hooks Muse Code terpasang di ~/.config/muse/settings.json" + }, + "✓ Muse Code hooks removed.": { + "zh-Hans": "✓ Muse Code Hooks 已移除。", + "hi": "✓ Muse Code हुक हटा दिए गए।", + "es": "✓ Hooks de Muse Code eliminados.", + "ar": "✓ تمت إزالة خطافات Muse Code.", + "fr": "✓ Hooks Muse Code supprimés.", + "bn": "✓ Muse Code হুক সরানো হয়েছে।", + "pt-BR": "✓ Hooks do Muse Code removidos.", + "ru": "✓ Хуки Muse Code удалены.", + "id": "✓ Hooks Muse Code dihapus." + }, + "✓ OpenAI key saved.": { + "zh-Hans": "✓ OpenAI 密钥已保存。", + "hi": "✓ OpenAI कुंजी सहेजी गई।", + "es": "✓ Clave de OpenAI guardada.", + "ar": "✓ تم حفظ مفتاح OpenAI.", + "fr": "✓ Clé OpenAI enregistrée.", + "bn": "✓ OpenAI কী সংরক্ষিত হয়েছে।", + "pt-BR": "✓ Chave da OpenAI salva.", + "ru": "✓ Ключ OpenAI сохранён.", + "id": "✓ Kunci OpenAI tersimpan." + }, + "✓ OpenCode plugin installed in ~/.config/opencode/plugins/coucou.js": { + "zh-Hans": "✓ OpenCode 插件已安装至 ~/.config/opencode/plugins/coucou.js", + "hi": "✓ ~/.config/opencode/plugins/coucou.js में OpenCode प्लगइन इंस्टॉल हुआ", + "es": "✓ Plugin de OpenCode instalado en ~/.config/opencode/plugins/coucou.js", + "ar": "✓ ملحق OpenCode مثبَّت في ~/.config/opencode/plugins/coucou.js", + "fr": "✓ Plugin OpenCode installé dans ~/.config/opencode/plugins/coucou.js", + "bn": "✓ ~/.config/opencode/plugins/coucou.js-এ OpenCode প্লাগইন ইনস্টল হয়েছে", + "pt-BR": "✓ Plugin do OpenCode instalado em ~/.config/opencode/plugins/coucou.js", + "ru": "✓ Плагин OpenCode установлен в ~/.config/opencode/plugins/coucou.js", + "id": "✓ Plugin OpenCode terpasang di ~/.config/opencode/plugins/coucou.js" + }, + "✓ OpenCode plugin removed.": { + "zh-Hans": "✓ OpenCode 插件已移除。", + "hi": "✓ OpenCode प्लगइन हटा दिया गया।", + "es": "✓ Plugin de OpenCode eliminado.", + "ar": "✓ تمت إزالة ملحق OpenCode.", + "fr": "✓ Plugin OpenCode supprimé.", + "bn": "✓ OpenCode প্লাগইন সরানো হয়েছে।", + "pt-BR": "✓ Plugin do OpenCode removido.", + "ru": "✓ Плагин OpenCode удалён.", + "id": "✓ Plugin OpenCode dihapus." + }, + "✓ Status line installed.": { + "zh-Hans": "✓ 状态栏已安装。", + "hi": "✓ स्टेटस लाइन इंस्टॉल हो गई।", + "es": "✓ Línea de estado instalada.", + "ar": "✓ تم تثبيت سطر الحالة.", + "fr": "✓ Ligne de statut installée.", + "bn": "✓ স্ট্যাটাস লাইন ইনস্টল হয়েছে।", + "pt-BR": "✓ Linha de status instalada.", + "ru": "✓ Строка статуса установлена.", + "id": "✓ Baris status terpasang." + }, + "✓ Status line removed.": { + "zh-Hans": "✓ 状态栏已移除。", + "hi": "✓ स्टेटस लाइन हटा दी गई।", + "es": "✓ Línea de estado eliminada.", + "ar": "✓ تمت إزالة سطر الحالة.", + "fr": "✓ Ligne de statut supprimée.", + "bn": "✓ স্ট্যাটাস লাইন সরানো হয়েছে।", + "pt-BR": "✓ Linha de status removida.", + "ru": "✓ Строка статуса удалена.", + "id": "✓ Baris status dihapus." + }, + "❌ Select the .claude folder (hidden, in your Home directory).": { + "zh-Hans": "❌ 请选择 .claude 文件夹(隐藏,在你的 Home 目录中)。", + "hi": "❌ .claude फ़ोल्डर चुनें (छुपा हुआ, आपके Home डायरेक्टरी में)।", + "es": "❌ Selecciona la carpeta .claude (oculta, en tu directorio Home).", + "ar": "❌ اختر مجلد .claude (مخفي، في دليل Home الخاص بك).", + "fr": "❌ Sélectionne le dossier .claude (caché, dans ton répertoire Home).", + "bn": "❌ .claude ফোল্ডার নির্বাচন করুন (লুকানো, আপনার Home ডিরেক্টরিতে)।", + "pt-BR": "❌ Selecione a pasta .claude (oculta, no seu diretório Home).", + "ru": "❌ Выбери папку .claude (скрытая, в домашнем каталоге).", + "id": "❌ Pilih folder .claude (tersembunyi, di direktori Home Anda)." + } + } +} diff --git a/windows/src/views/dom.ts b/windows/src/views/dom.ts index 125660391..38d56f44e 100644 --- a/windows/src/views/dom.ts +++ b/windows/src/views/dom.ts @@ -1,7 +1,75 @@ // Minimal DOM helpers — no framework, as specified. -type Attrs = Record; -type Child = Node | string | null | undefined | false; +import { Msg, onLanguageChange } from "../i18n/i18n"; + +type Attrs = Record; +type Child = Node | string | Msg | null | undefined | false; + +// ── Text that follows the language ──────────────────────────────────────────── +// +// A `tl(…)` string given to h() — as `text`, an attribute (title, placeholder, +// aria-label…) or a child — is remembered with the node it went to. When the +// language changes, every such node still showing what it was given is +// redrawn in place: nothing is rebuilt, so no view loses its state. A node +// whose text some code has replaced since is left alone (and forgotten). + +interface Binding { + node: WeakRef; + /** "text" (an element's text or a text node's data) or an attribute name. */ + attr: string; + msg: Msg; + /** What was last written, to notice when other code has written over it. */ + shown: string; +} + +const bindings = new Set(); +let sincePrune = 0; + +function read(node: Node, attr: string): string | null { + if (attr === "text") return node.nodeType === 3 ? (node as Text).data : node.textContent; + return (node as Element).getAttribute(attr); +} + +function write(node: Node, attr: string, value: string) { + if (attr === "text") { + if (node.nodeType === 3) (node as Text).data = value; + else node.textContent = value; + } else { + (node as Element).setAttribute(attr, value); + } +} + +function bind(node: Node, attr: string, msg: Msg, shown: string) { + bindings.add({ node: new WeakRef(node), attr, msg, shown }); + // Views rebuild rows all the time: drop what has been collected now and then. + if (++sincePrune >= 500) { + sincePrune = 0; + for (const b of bindings) if (!b.node.deref()) bindings.delete(b); + } +} + +/** Redraws every live `tl(…)` text in the current language. */ +export function relabel() { + for (const b of bindings) { + const node = b.node.deref(); + if (!node || read(node, b.attr) !== b.shown) { + bindings.delete(b); + continue; + } + const next = String(b.msg); + if (next !== b.shown) { + write(node, b.attr, next); + b.shown = next; + } + } +} + +/** How many texts follow the language (tests). */ +export function liveTextCount(): number { + return bindings.size; +} + +onLanguageChange(() => relabel()); export function h( tag: K, @@ -11,6 +79,12 @@ export function h( const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (v == null || v === false) continue; + if (v instanceof Msg) { + const text = String(v); + write(el, k, text); + bind(el, k, v, text); + continue; + } if (k === "class") el.className = String(v); else if (k === "text") el.textContent = String(v); else if (k === "html") el.innerHTML = String(v); @@ -21,6 +95,12 @@ export function h( } for (const c of children) { if (c == null || c === false) continue; + if (c instanceof Msg) { + const node = document.createTextNode(String(c)); + bind(node, "text", c, String(c)); + el.append(node); + continue; + } el.append(typeof c === "string" ? document.createTextNode(c) : c); } return el; diff --git a/windows/tests/setup.mjs b/windows/tests/setup.mjs index 5af51b2e6..7c7650190 100644 --- a/windows/tests/setup.mjs +++ b/windows/tests/setup.mjs @@ -2,18 +2,29 @@ // Node runs the island's TypeScript as it is; this only supplies what the // webview and the bundler normally would. +import { readFileSync } from "node:fs"; import { registerHooks } from "node:module"; +import { fileURLToPath } from "node:url"; import { internals } from "./tauri.mjs"; // The sources import each other without an extension, which Vite resolves. registerHooks({ resolve(specifier, context, nextResolve) { const ours = !context.parentURL?.includes("/node_modules/"); - if (ours && specifier.startsWith(".") && !/\.[cm]?[jt]s$/.test(specifier)) { + if (ours && specifier.startsWith(".") && !/\.(?:[cm]?[jt]s|json)$/.test(specifier)) { return nextResolve(`${specifier}.ts`, context); } return nextResolve(specifier, context); }, + // Vite imports JSON (src/i18n/*.json) without `with { type: "json" }`; + // Node would refuse it, so it is handed over as a module. + load(url, context, nextLoad) { + if (url.startsWith("file:") && url.endsWith(".json") && !url.includes("/node_modules/")) { + const source = readFileSync(fileURLToPath(url), "utf8"); + return { format: "module", source: `export default ${source};`, shortCircuit: true }; + } + return nextLoad(url, context); + }, }); // The island reaches timers and Tauri through `window`. diff --git a/windows/tsconfig.json b/windows/tsconfig.json index cda4633dd..b4703d92f 100644 --- a/windows/tsconfig.json +++ b/windows/tsconfig.json @@ -10,6 +10,7 @@ "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "isolatedModules": true, + "resolveJsonModule": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "noEmit": true, From 912de3eb12be5212c96088c43b3e8cb9b05c820e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:00:36 +0200 Subject: [PATCH 69/77] =?UTF-8?q?Settings=20=E2=86=92=20General=20?= =?UTF-8?q?=E2=86=92=20Language,=20switched=20in=20place;=20the=20settings?= =?UTF-8?q?=20window=20translated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As on the Mac, Settings → General → Language offers System and the ten languages, each named in its own. Settings.language ("" = System) is independent of the system locale. - Changing it reloads nothing: the settings window redraws its sections where it was scrolled to; the island relabels in place and redraws on its next sync, keeping its tasks, steps and chat (the reload in #226 wiped them). - Rust follows: the tray menu is relabelled and the settings window retitled. The island reports the system's languages at launch for System (Linux also reads LANGUAGE / LANG before that). - Every string of the settings window goes through t(); Arabic turns the window right to left, diffs, paths and keys staying left to right. The language picker, Settings.language and the tray relabelling come from #226, rebuilt on the key-based lookup. Co-authored-by: Alex Ramirez Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DMzyoPKtGVNBUemdZhnWRG --- windows/src-tauri/src/lib.rs | 26 ++- windows/src-tauri/src/settings.rs | 10 + windows/src-tauri/src/tray.rs | 39 +++- windows/src/core/bridge.ts | 2 + windows/src/core/state.ts | 6 + windows/src/main.ts | 14 ++ windows/src/settings/agents.ts | 48 ++-- windows/src/settings/main.ts | 377 ++++++++++++++++++------------ windows/src/settings/settings.css | 11 +- 9 files changed, 355 insertions(+), 178 deletions(-) diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 8259608bd..c7fcd57bb 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod desktop; mod files; mod github; mod hooks; +mod i18n; mod identity; mod integrations; mod island; @@ -109,10 +110,31 @@ fn save_settings(app: AppHandle, shared: State, settings: Settings) { if shortcuts_changed { shortcuts::apply(&app, &settings.shortcuts); } + if i18n::set_picked(&settings.language) { + language_changed(&app); + } // Keep the other window in step (island ⇄ settings window). let _ = app.emit("settings-changed", settings); } +/// The island reports the system's languages at launch, for "System" in +/// Settings → Language (WebView2 and WebKitGTK know them best). +#[tauri::command] +fn set_system_languages(app: AppHandle, languages: Vec) { + if i18n::set_system(languages) { + language_changed(&app); + } +} + +/// What Rust labels itself follows the new language: the tray menu and the +/// settings window's title. The webviews switch on their own. +fn language_changed(app: &AppHandle) { + tray::retitle(app); + if let Some(window) = app.get_webview_window("settings") { + let _ = window.set_title(&i18n::t("Settings — Coucou")); + } +} + /// Hidden island → shrink the window to the invisible wake strip and park the /// cursor poll; anything else → full panel and 60 Hz polling. #[tauri::command] @@ -544,7 +566,7 @@ fn create_settings_window(app: &AppHandle) { let url = settings_page_url(app); match WebviewWindowBuilder::new(app, "settings", url) .additional_browser_args(BROWSER_ARGS) - .title("Settings — Coucou") + .title(i18n::t("Settings — Coucou")) .inner_size(560.0, 680.0) .min_inner_size(460.0, 480.0) .resizable(true) @@ -584,6 +606,7 @@ fn open_settings_window(app: AppHandle) { pub fn run() { platform::prepare_environment(); let loaded = settings::load(); + i18n::set_picked(&loaded.language); let gate = Arc::new(PollGate::new()); let mut builder = tauri::Builder::default() @@ -615,6 +638,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ boot, save_settings, + set_system_languages, set_collapsed, set_island_rect, focus_window, diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 5826b5146..0d5c84561 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -54,6 +54,10 @@ pub struct Settings { /// season), "none" or an outfit id — the Mac's raw values. The island reads /// anything it doesn't know as "auto", so the value is stored as it comes. pub mochi_outfit: String, + /// Interface language: "" follows the system, else one of i18n::LANGUAGES + /// ("fr", "pt-BR", "zh-Hans"…). Kept as it comes, like `mochi_outfit`: a + /// code this build doesn't know reads as "". + pub language: String, /// Mochi on the desktop: whether he lives there, and his spot. Owned by /// the Rust side (desktop.rs) — what a webview sends back is ignored. pub desktop_mochi: DesktopMochiPref, @@ -108,6 +112,7 @@ impl Default for Settings { custom_url: String::new(), shortcuts: Default::default(), mochi_outfit: "auto".into(), + language: String::new(), desktop_mochi: DesktopMochiPref::default(), } } @@ -381,6 +386,7 @@ mod tests { "customUrl": "https://llm.example.com", "shortcuts": { "openChat": { "keys": "Ctrl+Shift+K", "enabled": false } }, "mochiOutfit": "witchHat", + "language": "pt-BR", "desktopMochi": { "onDesktop": true, "spot": { "x": 1500.5, "y": -300.0, "space": "screen" } } }"#; @@ -509,6 +515,9 @@ mod tests { // the choice must survive a save made by this one. let loaded = parse(&custom_with("mochiOutfit", Some(json!("topHat")))).unwrap(); assert_eq!(loaded.mochi_outfit, "topHat"); + // The language too: "" (follow the system) when absent, as it comes otherwise. + assert_eq!(parse(&custom_with("language", None)).unwrap().language, ""); + assert_eq!(parse(&custom_with("language", Some(json!("xx")))).unwrap().language, "xx"); } #[test] @@ -760,6 +769,7 @@ mod tests { "customUrl", "shortcuts", "mochiOutfit", + "language", "desktopMochi", ] ); diff --git a/windows/src-tauri/src/tray.rs b/windows/src-tauri/src/tray.rs index e7f06c05e..73008e84c 100644 --- a/windows/src-tauri/src/tray.rs +++ b/windows/src-tauri/src/tray.rs @@ -2,21 +2,36 @@ use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIconBuilder; -use tauri::{AppHandle, Emitter}; +use tauri::{AppHandle, Emitter, Manager, Wry}; +use crate::i18n::{n_, t}; use crate::island::WINDOW_LABEL; +/// The menu's items, id and English label, in order. The labels are shown in +/// the interface language (i18n.rs) and follow it when it changes. +const ITEMS: [(&str, &str); 6] = [ + ("open", n_("Open Coucou")), + ("recap", n_("Weekly recap")), + ("wardrobe", n_("Wardrobe…")), + ("settings", n_("Settings…")), + ("pause", n_("Pause")), + ("quit", n_("Quit")), +]; + +/// Kept so a language change relabels the menu without rebuilding the tray. +struct Items(Vec<(&'static str, MenuItem)>); + pub fn build(app: &AppHandle) -> tauri::Result<()> { - let open = MenuItem::with_id(app, "open", "Open Coucou", true, None::<&str>)?; - let recap = MenuItem::with_id(app, "recap", "Weekly recap", true, None::<&str>)?; - let wardrobe = MenuItem::with_id(app, "wardrobe", "Wardrobe…", true, None::<&str>)?; - let settings = MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?; - let pause = MenuItem::with_id(app, "pause", "Pause", true, None::<&str>)?; - let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let mut items = Vec::new(); + for (id, label) in ITEMS { + items.push((label, MenuItem::with_id(app, id, t(label), true, None::<&str>)?)); + } + let [open, recap, wardrobe, settings, pause, quit] = [0, 1, 2, 3, 4, 5].map(|i| &items[i].1); let sep1 = PredefinedMenuItem::separator(app)?; let sep2 = PredefinedMenuItem::separator(app)?; - let menu = Menu::with_items(app, &[&open, &recap, &sep1, &wardrobe, &settings, &pause, &sep2, &quit])?; + let menu = Menu::with_items(app, &[open, recap, &sep1, wardrobe, settings, pause, &sep2, quit])?; + app.manage(Items(items)); let mut builder = TrayIconBuilder::with_id("coucou") .tooltip("Coucou") @@ -36,3 +51,11 @@ pub fn build(app: &AppHandle) -> tauri::Result<()> { builder.build(app)?; Ok(()) } + +/// Relabels the menu in the current language. +pub fn retitle(app: &AppHandle) { + let Some(items) = app.try_state::() else { return }; + for (label, item) in &items.0 { + let _ = item.set_text(t(label)); + } +} diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 8217a100e..fd75ce00f 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -33,6 +33,8 @@ export interface BootInfo { export const Bridge = { boot: () => call("boot"), + /** The system's languages as the webview sees them, for Rust's own texts (i18n.rs). */ + setSystemLanguages: (languages: string[]) => call("set_system_languages", { languages }), saveSettings: (settings: Settings) => call("save_settings", { settings }), diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index e4a6878ac..0951ef015 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -131,6 +131,11 @@ export interface Settings { * Same raw values as the Mac's "mochiOutfit"; read it through parseOutfit. */ mochiOutfit: string; + /** + * Interface language: "" follows the system (when Coucou has its language, + * else English), or one of src/i18n's ten codes ("fr", "pt-BR", "zh-Hans"…). + */ + language: string; /** Mochi on the desktop. Rust owns it: whatever the page sends back is ignored. */ desktopMochi?: { onDesktop: boolean; @@ -161,6 +166,7 @@ export const DEFAULT_SETTINGS: Settings = { customUrl: "", shortcuts: {}, mochiOutfit: DEFAULT_OUTFIT, + language: "", }; type Listener = () => void; diff --git a/windows/src/main.ts b/windows/src/main.ts index c76f76b89..36d446dc8 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -9,6 +9,12 @@ import { registerHookHandlers } from "./island/hooks"; import { registerIntegrationHandlers, refreshConfigured } from "./island/integrations"; import { registerShortcutHandlers } from "./island/shortcuts"; import { Recap } from "./recap/recap"; +import { onLanguageChange, resolveLanguage, setLanguage, systemLanguages } from "./i18n/i18n"; + +/** Shows the language Settings asks for ("" = the system's, when Coucou has it). */ +function applyLanguage() { + setLanguage(resolveLanguage(State.settings.language, systemLanguages())); +} async function main() { const root = document.getElementById("root"); @@ -22,6 +28,13 @@ async function main() { if (boot) { State.settings = { ...State.settings, ...boot.settings }; } + // A language change redraws the island in place: the texts given as tl(…) + // relabel themselves (views/dom.ts) and the views redraw the rest on this + // sync. Nothing is rebuilt, so tasks, steps and the chat stay as they are. + onLanguageChange(() => State.notify()); + applyLanguage(); + // Rust shows the tray and its errors in the same language as the webview. + void Bridge.setSystemLanguages(systemLanguages()); island.applySettings(); State.loadIntegrationTasks(); if (boot && !boot.cursorPoll) island.followPageCursor(); @@ -69,6 +82,7 @@ async function main() { await onEvent("settings-changed", (s) => { const previousMain = State.mainPillId; State.settings = { ...State.settings, ...s }; + applyLanguage(); island.applySettings(); State.loadIntegrationTasks(); // A new main tool comes to the front, as on macOS. diff --git a/windows/src/settings/agents.ts b/windows/src/settings/agents.ts index 82f7f198a..6d646ba53 100644 --- a/windows/src/settings/agents.ts +++ b/windows/src/settings/agents.ts @@ -5,29 +5,35 @@ import { Bridge, type AgentHookStatus } from "../core/bridge"; import { clear, h } from "../views/dom"; import { renderDiff, statusDot } from "./parts"; +import { t } from "../i18n/i18n"; +/** In the current language (src/i18n); the settings window redraws on a change. */ const TEXT = { - title: "Agents", - intro: - "Show other coding agents in the island. Coucou adds its entries to each agent's own config: you see the exact change and where the backup goes before anything is written, and uninstalling removes only what Coucou added.", - none: "Coucou could not list the agents.", - approvals: "Sessions, and Allow / Deny from the island", - displayOnly: "Sessions — approvals stay in the agent", - install: "Install…", - reinstall: "Reinstall…", - uninstall: "Uninstall…", - relayMissing: "The relay isn't installed yet. Restart Coucou.", - previewInstall: (name: string) => - `This is exactly what changes for ${name}. Nothing else is touched.`, - previewRemove: "This removes Coucou's entries only. Everything else stays.", - backup: (to: string) => (to ? `Backup → ${to}` : "No existing file — nothing to back up."), - confirmInstall: "Back up and write", - confirmRemove: "Back up and remove", - cancel: "Cancel", - back: "Back", - done: (backups: string, note: string) => - `Done.${backups ? ` Previous version saved as ${backups.split("\n").join(", ")}.` : ""} ${note}`, - failed: (err: unknown) => `Could not write: ${errorText(err)}`, + get title() { return t("Agents"); }, + get intro() { + return t("Show other coding agents in the island. Coucou adds its entries to each agent's own config: you see the exact change and where the backup goes before anything is written, and uninstalling removes only what Coucou added."); + }, + get none() { return t("Coucou could not list the agents."); }, + get approvals() { return t("Sessions, and Allow / Deny from the island"); }, + get displayOnly() { return t("Sessions — approvals stay in the agent"); }, + get install() { return t("Install…"); }, + get reinstall() { return t("Reinstall…"); }, + get uninstall() { return t("Uninstall…"); }, + get relayMissing() { return t("The relay isn't installed yet. Restart Coucou."); }, + previewInstall: (name: string) => t("This is exactly what changes for {name}. Nothing else is touched.", { name }), + get previewRemove() { return t("This removes Coucou's entries only. Everything else stays."); }, + backup: (to: string) => (to ? t("Backup → {path}", { path: to }) : t("No existing file — nothing to back up.")), + get confirmInstall() { return t("Back up and write"); }, + get confirmRemove() { return t("Back up and remove"); }, + get cancel() { return t("Cancel"); }, + get back() { return t("Back"); }, + done: (backups: string, note: string) => { + const saved = backups + ? t("Done. Previous version saved as {paths}.", { paths: backups.split("\n").join(", ") }) + : t("Done."); + return `${saved} ${note}`; + }, + failed: (err: unknown) => t("Could not write: {error}", { error: errorText(err) }), }; function errorText(err: unknown): string { diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index a9f3d3591..8aa148103 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -17,6 +17,9 @@ import { import { h, clear } from "../views/dom"; import { agentsSection } from "./agents"; import { renderDiff, statusDot } from "./parts"; +import { + LANGUAGES, N_, isRtl, onLanguageChange, resolveLanguage, setLanguage, systemLanguages, t, tn, +} from "../i18n/i18n"; /** Where secrets.rs keeps the keys on this OS. */ const KEY_STORE = navigator.userAgent.includes("Windows") @@ -61,25 +64,25 @@ interface Change { const HOOKS_CHANGE: Change = { preview: Bridge.hooksPreview, apply: Bridge.hooksApply, - installText: "This is exactly what will change in your settings.json. Your own hooks are left untouched.", - removeText: "This removes Coucou's entries only. Your own hooks are left untouched.", - installButton: "Back up and write", - removeButton: "Back up and remove", + get installText() { return t("This is exactly what will change in your settings.json. Your own hooks are left untouched."); }, + get removeText() { return t("This removes Coucou's entries only. Your own hooks are left untouched."); }, + get installButton() { return t("Back up and write"); }, + get removeButton() { return t("Back up and remove"); }, done: (backup) => backup - ? `Done. Previous settings saved as ${backup}. Open a new Claude Code session to pick the hooks up.` - : "Done. Open a new Claude Code session to pick the hooks up.", + ? t("Done. Previous settings saved as {backup}. Open a new Claude Code session to pick the hooks up.", { backup }) + : t("Done. Open a new Claude Code session to pick the hooks up."), }; const STATUS_LINE_CHANGE: Change = { preview: Bridge.statusLinePreview, apply: Bridge.statusLineApply, - installText: "This is exactly what will change: only the status line. If you already have one it keeps working, Coucou's relay runs it for you.", - removeText: "This puts your previous status line back, or removes the entry if there was none.", - installButton: "Back up and write", - removeButton: "Back up and remove", + get installText() { return t("This is exactly what will change: only the status line. If you already have one it keeps working, Coucou's relay runs it for you."); }, + get removeText() { return t("This puts your previous status line back, or removes the entry if there was none."); }, + get installButton() { return t("Back up and write"); }, + get removeButton() { return t("Back up and remove"); }, done: (backup) => backup - ? `Done. Previous settings saved as ${backup}. The numbers appear after the next reply of a Claude Code session.` - : "Done. The numbers appear after the next reply of a Claude Code session.", + ? t("Done. Previous settings saved as {backup}. The numbers appear after the next reply of a Claude Code session.", { backup }) + : t("Done. The numbers appear after the next reply of a Claude Code session."), }; /** @@ -103,7 +106,7 @@ async function reviewChange( clear(body); body.append( h("div", { class: "notice err", text: String(err).replace(/^Error:\s*/, "") }), - h("div", { class: "row" }, h("button", { text: "Back", onclick: back })), + h("div", { class: "row" }, h("button", { text: t("Back"), onclick: back })), ); return; } @@ -116,8 +119,8 @@ async function reviewChange( h("span", { class: "path", text: preview.backup - ? `Backup → ${preview.backup}` - : "No settings.json yet — nothing to back up.", + ? t("Backup → {path}", { path: preview.backup }) + : t("No settings.json yet — nothing to back up."), }), ), ); @@ -134,10 +137,10 @@ async function reviewChange( window.setTimeout(applied, 2600); } catch (err) { confirm.disabled = false; - body.append(h("div", { class: "notice err", text: `Could not write: ${String(err)}` })); + body.append(h("div", { class: "notice err", text: t("Could not write: {error}", { error: String(err) }) })); } }); - body.append(h("div", { class: "row" }, confirm, h("button", { text: "Cancel", onclick: back }))); + body.append(h("div", { class: "row" }, confirm, h("button", { text: t("Cancel"), onclick: back }))); } // ── Claude Code section ─────────────────────────────────────────────────────── @@ -169,15 +172,15 @@ function claudeSection(status: HookStatus): HTMLElement { h("div", { class: "hint", text: status.installed - ? "Coucou is hooked into your Claude Code sessions. Tool calls, questions and permission requests show up in the island, and you can answer them there." - : "Install the hooks to see your Claude Code sessions in the island and approve permissions without leaving what you are doing.", + ? t("Coucou is hooked into your Claude Code sessions. Tool calls, questions and permission requests show up in the island, and you can answer them there.") + : t("Install the hooks to see your Claude Code sessions in the island and approve permissions without leaving what you are doing."), }), h("div", { class: "row" }, h("label", { text: "settings.json" }), h("span", { class: "path", text: status.settingsPath }), ), h("div", { class: "row" }, - h("label", { text: "Relay" }), + h("label", { text: t("Relay") }), h("span", { class: "path", text: status.hookPath }), statusDot(status.hookReady), ), @@ -186,27 +189,27 @@ function claudeSection(status: HookStatus): HTMLElement { if (!status.hookReady) { 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`.", + text: t("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 hooks…" : "Install hooks…", + text: status.installed ? t("Reinstall hooks…") : t("Install hooks…"), onclick: () => void reviewChange(body, HOOKS_CHANGE, true, redraw, () => void rebuild()), }); // Writing hook commands that point at a relay which isn't there would give // every Claude Code session a broken hook and nothing to show for it. if (!status.hookReady) { install.disabled = true; - install.title = "The relay isn't installed yet."; + install.title = t("The relay isn't installed yet."); } actions.append(install); if (status.installed) { actions.append(h("button", { class: "danger", - text: "Uninstall hooks…", + text: t("Uninstall hooks…"), onclick: () => void reviewChange(body, HOOKS_CHANGE, false, redraw, () => void rebuild()), })); } @@ -226,15 +229,15 @@ function claudeSection(status: HookStatus): HTMLElement { * that has been confirmed. A status line the user had keeps working. */ const PLAN_SETTINGS_TEXT = { - claude: "Shows your Claude plan usage (5-hour and weekly limits) in the island's header. Coucou adds a status line relay in ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.", - showClaude: "Show in notch", - codex: "Shows your Codex plan usage (weekly limit and free resets left) in the island's header. Coucou asks the Codex CLI (codex app-server) when the pill shows; nothing is installed. Codex must be signed in with ChatGPT.", - showCodex: "Show Codex plan in the notch", + get claude() { return t("Shows your Claude plan usage (5-hour and weekly limits) in the island's header. Coucou adds a status line relay in ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only."); }, + get showClaude() { return t("Show in notch"); }, + get codex() { return t("Shows your Codex plan usage (weekly limit and free resets left) in the island's header. Coucou asks the Codex CLI (codex app-server) when the pill shows; nothing is installed. Codex must be signed in with ChatGPT."); }, + get showCodex() { return t("Show Codex plan in the notch"); }, }; function planSection(status: HookStatus): HTMLElement { const body = h("div", { style: "display:flex;flex-direction:column;gap:12px" }); - const section = h("section", {}, h("h2", {}, h("span", { text: "Plan usage" })), body); + const section = h("section", {}, h("h2", {}, h("span", { text: t("Plan usage") })), body); const redraw = () => { clear(body); @@ -274,18 +277,18 @@ function planSection(status: HookStatus): HTMLElement { }), h("div", { class: "row" }, h("label", { text: PLAN_SETTINGS_TEXT.showClaude }), sw), h("div", { class: "row" }, - h("label", { text: "Relay" }), + h("label", { text: t("Relay") }), statusDot(status.planRelayInstalled), - h("span", { class: "hint", text: status.planRelayInstalled ? "installed" : "not installed" }), + h("span", { class: "hint", text: status.planRelayInstalled ? t("installed") : t("not installed") }), status.planRelayInstalled ? h("button", { class: "danger", - text: "Uninstall relay…", + text: t("Uninstall relay…"), onclick: () => void reviewChange(body, STATUS_LINE_CHANGE, false, redraw, () => void rebuild()), }) : h("button", { class: "primary", - text: "Install relay…", + text: t("Install relay…"), onclick: () => void reviewChange(body, STATUS_LINE_CHANGE, true, redraw, () => void rebuild()), }), ), @@ -315,27 +318,27 @@ const MODELS: [string, string][] = [ function apiSection(hasKey: boolean): HTMLElement { const dot = statusDot(hasKey); - const state = h("span", { class: "hint", text: hasKey ? `Key saved in the ${KEY_STORE}.` : "No key yet — the chat needs one." }); + const state = h("span", { class: "hint", text: hasKey ? t("Key saved in the {store}.", { store: KEY_STORE }) : t("No key yet — the chat needs one.") }); const field = h("input", { type: "password", - placeholder: hasKey ? "•••••••••••• (stored)" : "sk-ant-...", + placeholder: hasKey ? `•••••••••••• ${t("(stored)")}` : "sk-ant-...", style: "flex:1 1 auto;min-width:0", autocomplete: "off", spellcheck: "false", }) as HTMLInputElement; - const saveBtn = h("button", { class: "primary", text: "Save key" }); - const clearBtn = h("button", { class: "danger", text: "Remove" }); + const saveBtn = h("button", { class: "primary", text: t("Save key") }); + const clearBtn = h("button", { class: "danger", text: t("Remove") }); const feedback = h("div", {}); async function refresh() { const present = (await Bridge.secretPresent("anthropic-api-key")) ?? false; dot.style.background = present ? "#22c55e" : "#f4505e"; state.textContent = present - ? `Key saved in the ${KEY_STORE}.` - : "No key yet — the chat needs one."; - field.placeholder = present ? "•••••••••••• (stored)" : "sk-ant-..."; + ? t("Key saved in the {store}.", { store: KEY_STORE }) + : t("No key yet — the chat needs one."); + field.placeholder = present ? `•••••••••••• ${t("(stored)")}` : "sk-ant-..."; clearBtn.style.display = present ? "" : "none"; } @@ -346,10 +349,10 @@ function apiSection(hasKey: boolean): HTMLElement { try { await Bridge.secretSet("anthropic-api-key", value); field.value = ""; - feedback.append(h("div", { class: "notice ok", text: "Saved. It never touches disk." })); + feedback.append(h("div", { class: "notice ok", text: t("Saved. It never touches disk.") })); await refresh(); } catch (err) { - feedback.append(h("div", { class: "notice err", text: `Could not save: ${String(err)}` })); + feedback.append(h("div", { class: "notice err", text: t("Could not save: {error}", { error: String(err) }) })); } }); @@ -357,10 +360,10 @@ function apiSection(hasKey: boolean): HTMLElement { clear(feedback); try { await Bridge.secretClear("anthropic-api-key"); - feedback.append(h("div", { class: "notice ok", text: "Key removed." })); + feedback.append(h("div", { class: "notice ok", text: t("Key removed.") })); await refresh(); } catch (err) { - feedback.append(h("div", { class: "notice err", text: `Could not remove: ${String(err)}` })); + feedback.append(h("div", { class: "notice err", text: t("Could not remove: {error}", { error: String(err) }) })); } }); @@ -382,8 +385,8 @@ function apiSection(hasKey: boolean): HTMLElement { {}, h("h2", {}, dot, h("span", { text: "Claude" })), state, - h("div", { class: "row" }, h("label", { text: "API key" }), field, saveBtn, clearBtn), - h("div", { class: "row" }, h("label", { text: "Model" }), model), + h("div", { class: "row" }, h("label", { text: t("API key") }), field, saveBtn, clearBtn), + h("div", { class: "row" }, h("label", { text: t("Model") }), model), feedback, ); } @@ -410,10 +413,10 @@ function activePillsSection(connected: Record): HTMLElement { /** Why a pill would show nothing yet, as on the Mac's row. */ function hint(def: PillDefinition): string | null { - if (isComingSoon(def.id)) return "Coming soon"; - if (def.connect.kind === "hooks" && !connected[def.id]) return "Hooks not installed"; - if (def.connect.kind === "key" && !connected[def.id]) return "Key not configured"; - if (def.connect.kind === "server" && !settings[def.connect.field]) return "Not connected"; + if (isComingSoon(def.id)) return t("Coming soon"); + if (def.connect.kind === "hooks" && !connected[def.id]) return t("Hooks not installed"); + if (def.connect.kind === "key" && !connected[def.id]) return t("Key not configured"); + if (def.connect.kind === "server" && !settings[def.connect.field]) return t("Not connected"); return null; } @@ -426,7 +429,7 @@ function activePillsSection(connected: Record): HTMLElement { h("span", { class: "name", text: def.name }), ); if (isMain) { - el.append(h("span", { class: "state", text: "Main" })); + el.append(h("span", { class: "state", text: t("Main") })); return el; } const why = hint(def); @@ -445,7 +448,7 @@ function activePillsSection(connected: Record): HTMLElement { function draw() { const used = settings.activeIntegrations.length; - slots.textContent = `${used}/${MAX_ACTIVE} slots in use — the main tool doesn't take one.`; + slots.textContent = t("{used}/{max} slots in use — the main tool doesn't take one.", { used, max: MAX_ACTIVE }); slots.classList.toggle("full", used >= MAX_ACTIVE); main.value = settings.mainPill; clear(groups); @@ -453,7 +456,7 @@ function activePillsSection(connected: Record): HTMLElement { if (cat.id === "service") continue; const pills = availablePills().filter((p) => p.category === cat.id); if (pills.length === 0) continue; - groups.append(h("div", { class: "pill-group" }, h("h3", { text: cat.title }), ...pills.map(row))); + groups.append(h("div", { class: "pill-group" }, h("h3", { text: t(cat.title) }), ...pills.map(row))); } } declaredViews.push(draw); @@ -462,10 +465,10 @@ function activePillsSection(connected: Record): HTMLElement { return h( "section", {}, - h("h2", {}, h("span", { text: "Active pills" })), - h("div", { class: "hint", text: "Choose the tools you use. Coucou only shows what you declare here." }), + h("h2", {}, h("span", { text: t("Active pills") })), + h("div", { class: "hint", text: t("Choose the tools you use. Coucou only shows what you declare here.") }), slots, - h("div", { class: "row" }, h("label", { text: "Main tool" }), main), + h("div", { class: "row" }, h("label", { text: t("Main tool") }), main), groups, ); } @@ -473,26 +476,26 @@ function activePillsSection(connected: Record): HTMLElement { // ── Chat providers section ──────────────────────────────────────────────────── const CHAT_STRINGS = { - providersTitle: "Chat providers", - providersHint: "Chat with Google AI, OpenAI or OpenRouter instead of Claude: add a key here, then click the model name above the chat box to switch provider and model. Keys stay in the system keychain. These providers get no web search and no tools: they can answer, never act on this computer.", - stored: "•••••••• (stored)", - save: "Save", - remove: "Remove", - localTitle: "Local models", - localHint: "Chat with a model you run yourself: Ollama or LM Studio (leave the address empty for the usual one on this computer), or any server that speaks the OpenAI API, such as vLLM or llama.cpp. Once connected, pick it above the chat box.", - connect: "Connect", - connecting: "Connecting…", - disconnect: "Disconnect", - useInChat: "Use in chat", - inUse: "In use", - keyOptional: "API key (optional)", - localOnly: "Nothing leaves your PC: the server runs on this computer.", - remote: "This address is another machine: what you ask is sent to it.", - remoteHttp: "This address is another machine, over plain http: what you ask travels unencrypted.", - keyOverHttp: "Warning: the key would be sent unencrypted (http://) to another machine. Use https://, or a server on this computer.", - invalid: "Not a valid http:// or https:// address.", - noModels: (name: string) => `No models yet. Download one in ${name} first.`, - models: (n: number) => (n === 1 ? "1 model" : `${n} models`), + get providersTitle() { return t("Chat providers"); }, + get providersHint() { return t("Chat with Google AI, OpenAI or OpenRouter instead of Claude: add a key here, then click the model name above the chat box to switch provider and model. Keys stay in the system keychain. These providers get no web search and no tools: they can answer, never act on this computer."); }, + get stored() { return `•••••••• ${t("(stored)")}`; }, + get save() { return t("Save"); }, + get remove() { return t("Remove"); }, + get localTitle() { return t("Local models"); }, + get localHint() { return t("Chat with a model you run yourself: Ollama or LM Studio (leave the address empty for the usual one on this computer), or any server that speaks the OpenAI API, such as vLLM or llama.cpp. Once connected, pick it above the chat box."); }, + get connect() { return t("Connect"); }, + get connecting() { return t("Connecting…"); }, + get disconnect() { return t("Disconnect"); }, + get useInChat() { return t("Use in chat"); }, + get inUse() { return t("In use"); }, + get keyOptional() { return t("API key (optional)"); }, + get localOnly() { return t("Nothing leaves your PC: the server runs on this computer."); }, + get remote() { return t("This address is another machine: what you ask is sent to it."); }, + get remoteHttp() { return t("This address is another machine, over plain http: what you ask travels unencrypted."); }, + get keyOverHttp() { return t("Warning: the key would be sent unencrypted (http://) to another machine. Use https://, or a server on this computer."); }, + get invalid() { return t("Not a valid http:// or https:// address."); }, + noModels: (name: string) => t("No models yet. Download one in {name} first.", { name }), + models: (n: number) => tn("{count} model", "{count} models", n), }; interface CloudDef { @@ -565,7 +568,7 @@ function chatProvidersSection( ), input, saveBtn, removeBtn, dotEl, ), - h("div", { class: "hint", style: "margin:-4px 0 0 144px", text: `Key from ${def.where}` }), + h("div", { class: "hint", style: "margin:-4px 0 0 144px", text: t("Key from {site}", { site: def.where }) }), ); } return h( @@ -588,7 +591,7 @@ const LOCAL: Record = { ollama: { name: "Ollama", usual: "http://127.0.0.1:11434" }, lmstudio: { name: "LM Studio", usual: "http://127.0.0.1:1234" }, // No usual address: any server that speaks the OpenAI API. - custom: { name: "OpenAI-compatible", usual: "" }, + custom: { name: N_("OpenAI-compatible"), usual: "" }, }; /** What an address means for the user's data, as a hint line. */ @@ -630,7 +633,7 @@ function localSection(customKey: boolean): HTMLElement { const exposure = h("div", {}); const label = h("label", {}, h("i", { class: "dot", style: `background:${p.accent};margin-right:8px` }), - h("span", { text: def.name }), + h("span", { text: t(def.name) }), ); const block = h("div", { style: "display:flex;flex-direction:column;gap:6px" }); @@ -701,7 +704,7 @@ function localSection(customKey: boolean): HTMLElement { const server = await Bridge.localConnect(id, input.value); if (!server.models.length) { clear(status); - status.append(h("div", { class: "notice err", text: CHAT_STRINGS.noModels(def.name) })); + status.append(h("div", { class: "notice err", text: CHAT_STRINGS.noModels(t(def.name)) })); } else { settings[field] = server.url; if (!server.models.includes(settings.chatModels[id] ?? "")) { @@ -744,23 +747,23 @@ interface IntegrationDef { const INTEGRATIONS: IntegrationDef[] = [ { id: "integration_stripe", name: "Stripe", color: "#0570DE", - fields: [{ key: "stripe-api-key", label: "Secret key", placeholder: "sk_live_…", secret: true }] }, + fields: [{ key: "stripe-api-key", label: N_("Secret key"), placeholder: "sk_live_…", secret: true }] }, { id: "integration_github", name: "GitHub", color: "#F4505E", - fields: [{ key: "github-token", label: "Token", placeholder: "ghp_…", secret: true }], - hint: "Classic token with the repo scope, or fine-grained with read access to Pull requests, Commit statuses and Actions." }, + fields: [{ key: "github-token", label: N_("Token"), placeholder: "ghp_…", secret: true }], + hint: N_("Classic token with the repo scope, or fine-grained with read access to Pull requests, Commit statuses and Actions.") }, { id: "integration_vercel", name: "Vercel", color: "#7C5CFF", - fields: [{ key: "vercel-token", label: "Token", placeholder: "…", secret: true }] }, + fields: [{ key: "vercel-token", label: N_("Token"), placeholder: "…", secret: true }] }, { id: "integration_n8n", name: "n8n", color: "#F29B38", fields: [ - { key: "n8n-url", label: "Instance URL", placeholder: "https://n8n.example.com", secret: false }, - { key: "n8n-api-key", label: "API key", placeholder: "…", secret: true }, + { key: "n8n-url", label: N_("Instance URL"), placeholder: "https://n8n.example.com", secret: false }, + { key: "n8n-api-key", label: N_("API key"), placeholder: "…", secret: true }, ] }, { id: "integration_resend", name: "Resend", color: "#22C55E", - fields: [{ key: "resend-api-key", label: "API key", placeholder: "re_…", secret: true }] }, + fields: [{ key: "resend-api-key", label: N_("API key"), placeholder: "re_…", secret: true }] }, { id: "integration_notion", name: "Notion", color: "#8C8C8C", - fields: [{ key: "notion-api-key", label: "Integration token", placeholder: "ntn_…", secret: true }] }, + fields: [{ key: "notion-api-key", label: N_("Integration token"), placeholder: "ntn_…", secret: true }] }, { id: "integration_calcom", name: "Cal.com", color: "#C9956A", - fields: [{ key: "calcom-api-key", label: "API key", placeholder: "cal_…", secret: true }] }, + fields: [{ key: "calcom-api-key", label: N_("API key"), placeholder: "cal_…", secret: true }] }, ]; const MAX_ACTIVE = MAX_DECLARED; @@ -779,7 +782,7 @@ function integrationsSection(present: Record): HTMLElement { function updateNote() { const used = settings.activeIntegrations.length; - note.textContent = `Pick up to ${MAX_ACTIVE} pills to show next to Mochi — ${used}/${MAX_ACTIVE} in use. Keys are stored in the ${KEY_STORE}, never on disk.`; + note.textContent = t("Pick up to {max} pills to show next to Mochi — {used}/{max} in use. Keys are stored in the {store}, never on disk.", { max: MAX_ACTIVE, used, store: KEY_STORE }); } declaredViews.push(updateNote); @@ -802,12 +805,12 @@ function integrationsSection(present: Record): HTMLElement { for (const field of def.fields) { const input = h("input", { type: field.secret ? "password" : "text", - placeholder: present[field.key] ? "•••••••• (stored)" : field.placeholder, + placeholder: present[field.key] ? CHAT_STRINGS.stored : field.placeholder, autocomplete: "off", spellcheck: "false", style: "flex:1 1 auto;min-width:0", }) as HTMLInputElement; - const saveBtn = h("button", { text: "Save" }); + const saveBtn = h("button", { text: t("Save") }); const dotEl = statusDot(present[field.key] ?? false); saveBtn.addEventListener("click", async () => { const value = input.value.trim(); @@ -815,7 +818,7 @@ function integrationsSection(present: Record): HTMLElement { await Bridge.secretSet(field.key, value); present[field.key] = value.length > 0; input.value = ""; - input.placeholder = value ? "•••••••• (stored)" : field.placeholder; + input.placeholder = value ? CHAT_STRINGS.stored : field.placeholder; dotEl.style.background = value ? "#22c55e" : "#f4505e"; } catch { dotEl.style.background = "#f5a524"; @@ -823,13 +826,13 @@ function integrationsSection(present: Record): HTMLElement { }); rows.append( h("div", { class: "row" }, - h("label", { style: "min-width:104px", text: field.label }), + h("label", { style: "min-width:104px", text: t(field.label) }), input, saveBtn, dotEl, ), ); } - if (def.hint) rows.append(h("div", { class: "hint", text: def.hint })); + if (def.hint) rows.append(h("div", { class: "hint", text: t(def.hint) })); list.append( h("div", { style: "display:flex;gap:12px;align-items:flex-start" }, @@ -844,7 +847,7 @@ function integrationsSection(present: Record): HTMLElement { } updateNote(); - return h("section", {}, h("h2", {}, h("span", { text: "Integrations" })), note, list); + return h("section", {}, h("h2", {}, h("span", { text: t("Integrations") })), note, list); } // ── General section ─────────────────────────────────────────────────────────── @@ -872,8 +875,8 @@ function generalSection(): HTMLElement { const screen = h("select", {}) as HTMLSelectElement; screen.append( - h("option", { value: "primary", text: "Main display" }), - h("option", { value: "cursor", text: "Display under the cursor" }), + h("option", { value: "primary", text: t("Main display") }), + h("option", { value: "cursor", text: t("Display under the cursor") }), ); screen.value = settings.screen; void Bridge.listMonitors().then((list) => { @@ -899,52 +902,72 @@ function generalSection(): HTMLElement { return h( "section", {}, - h("h2", {}, h("span", { text: "General" })), + h("h2", {}, h("span", { text: t("General") })), h("div", { class: "row" }, - h("label", { text: "Sound" }), + h("label", { text: t("Sound") }), toggle(settings.soundEnabled, (v) => { settings.soundEnabled = v; void save(); }), volume, ), h("div", { class: "row" }, - h("label", { text: "Auto-close" }), + h("label", { text: t("Auto-close") }), autoClose, - h("span", { class: "hint", text: "seconds after you leave the island" }), + h("span", { class: "hint", text: t("seconds after you leave the island") }), ), h("div", { class: "row" }, - h("label", { text: "Island lives on" }), + h("label", { text: t("Island lives on") }), screen, ), h("div", { class: "row" }, - h("label", { text: "Launch at startup" }), + h("label", { text: t("Launch at startup") }), toggle(settings.autostart, (v) => { settings.autostart = v; void save(); }), ), ...recapRows(), + languageRow(), ); } +/** + * Settings → General → Language, as on the Mac: "System" follows the + * system's language when Coucou has it (else English), or one of the ten. + * Both windows and the tray switch in place, without a restart. + */ +function languageRow(): HTMLElement { + const select = h("select", {}) as HTMLSelectElement; + select.append(h("option", { value: "", text: t("System") })); + for (const { code, name } of LANGUAGES) select.append(h("option", { value: code, text: name, lang: code })); + select.value = LANGUAGES.some((l) => l.code === settings.language) ? settings.language : ""; + select.addEventListener("change", () => { + settings.language = select.value; + void save(); + applyLanguage(); + }); + return h("div", { class: "row" }, h("label", { text: t("Language") }), select); +} + // ── Shortcuts section ───────────────────────────────────────────────────────── const SHORTCUTS_UI = { - title: "Shortcuts", - hint: "Work from any app. Click a shortcut to change it, then press the new keys — Esc cancels, Backspace removes it.", - global: "From anywhere", - island: "In the open island", - recording: "Press keys…", - none: "None", - reset: "Reset to defaults", - inUse: "In use by another app", - duplicate: "Used twice", - invalid: "Not a valid shortcut", - unavailable: "Not available", - types: (ch: string) => `Types “${ch}”`, + get title() { return t("Shortcuts"); }, + get hint() { return t("Work from any app. Click a shortcut to change it, then press the new keys — Esc cancels, Backspace removes it."); }, + get global() { return t("From anywhere"); }, + get island() { return t("In the open island"); }, + get recording() { return t("Press keys…"); }, + get none() { return t("None"); }, + get reset() { return t("Reset to defaults"); }, + get inUse() { return t("In use by another app"); }, + get duplicate() { return t("Used twice"); }, + get invalid() { return t("Not a valid shortcut"); }, + get unavailable() { return t("Not available"); }, + types: (ch: string) => t("Types “{char}”", { char: ch }), typesNote: (keys: string, ch: string) => - `${keys} types “${ch}” on your keyboard, so it can't be a shortcut. Pick another key.`, - needsModifier: "Hold Ctrl, Alt or the Windows key with it.", - unsupportedKey: "That key can't be used in a shortcut.", - wayland: - "Your Wayland desktop doesn't let apps listen for keys outside their own windows. Add the shortcuts in your system's keyboard settings instead, with these commands:", - noDisplay: "No display server was found, so global shortcuts are off.", -} as const; + t("{keys} types “{char}” on your keyboard, so it can't be a shortcut. Pick another key.", { keys, char: ch }), + get needsModifier() { return t("Hold Ctrl, Alt or the Windows key with it."); }, + get unsupportedKey() { return t("That key can't be used in a shortcut."); }, + get wayland() { + return t("Your Wayland desktop doesn't let apps listen for keys outside their own windows. Add the shortcuts in your system's keyboard settings instead, with these commands:"); + }, + get noDisplay() { return t("No display server was found, so global shortcuts are off."); }, +}; function shortcutsSection(initial: ShortcutsReport | null): HTMLElement { let report = initial; @@ -1043,7 +1066,7 @@ function shortcutsSection(initial: ShortcutsReport | null): HTMLElement { const tag = binding.enabled ? tagFor(d.id, dups) : null; list.append(h("div", { class: binding.enabled ? "row shortcut" : "row shortcut off" }, sw, - h("span", { class: "shortcut-name", text: SHORTCUT_TEXT[d.id] }), + h("span", { class: "shortcut-name", text: t(SHORTCUT_TEXT[d.id]) }), ...(tag ? [tag] : []), keycap, )); @@ -1064,7 +1087,7 @@ function shortcutsSection(initial: ShortcutsReport | null): HTMLElement { const islandList = h("div", { class: "shortcut-list" }); for (const row of ISLAND_SHORTCUTS) { islandList.append(h("div", { class: "row shortcut" }, - h("span", { class: "shortcut-name", text: row.description }), + h("span", { class: "shortcut-name", text: t(row.description) }), h("span", { class: "keycap static", text: row.keys }), )); } @@ -1080,14 +1103,16 @@ function shortcutsSection(initial: ShortcutsReport | null): HTMLElement { }, }); - void onEvent("shortcuts-status", (fresh) => { - report = fresh; - if (!stopRecording) draw(); - }); - void onEvent("settings-changed", (s) => { - settings = { ...settings, ...s }; - if (!stopRecording) draw(); - }); + // The events are listened to once (see main); only the section on screen redraws. + shortcutsListener = { + report(fresh) { + report = fresh; + if (!stopRecording) draw(); + }, + settingsChanged() { + if (!stopRecording) draw(); + }, + }; draw(); return h( @@ -1108,11 +1133,11 @@ function shortcutsSection(initial: ShortcutsReport | null): HTMLElement { /** Settings → General → Weekly recap. The prefs live with the history in Rust. */ function recapRows(): HTMLElement[] { const T = { - label: "Weekly recap", - keep: "Keep a history of my coding sessions", - clear: "Clear history", - cleared: "History cleared.", - about: "Counts and project names only — never commands, files or prompts. Kept on this computer for 12 weeks.", + label: t("Weekly recap"), + keep: t("Keep a history of my coding sessions"), + clear: t("Clear history"), + cleared: t("History cleared."), + about: t("Counts and project names only — never commands, files or prompts. Kept on this computer for 12 weeks."), }; const feedback = h("span", { class: "hint" }); const sw = toggle(true, (v) => { void Bridge.recapSetEnabled(v); }); @@ -1150,6 +1175,47 @@ function recapRows(): HTMLElement[] { ]; } +// ── Language ────────────────────────────────────────────────────────────────── + +/** The shortcuts section on screen, told about the events listened to once in main. */ +let shortcutsListener: { report(fresh: ShortcutsReport): void; settingsChanged(): void } | null = null; + +/** + * Shows the language Settings asks for. A change redraws the window in place, + * where it was scrolled to: nothing reloads, nothing is written. + */ +function applyLanguage() { + setLanguage(resolveLanguage(settings.language, systemLanguages())); +} + +function applyDirection() { + document.documentElement.dir = isRtl() ? "rtl" : "ltr"; + document.title = t("Settings — Coucou"); +} + +let rendering: Promise | null = null; +let renderAgain = false; + +/** Redraws every section from fresh state, keeping the scroll position. */ +async function rerender() { + if (rendering) { + renderAgain = true; + return; + } + const scroll = document.scrollingElement?.scrollTop ?? 0; + rendering = render(); + try { + await rendering; + } finally { + rendering = null; + } + if (document.scrollingElement) document.scrollingElement.scrollTop = scroll; + if (renderAgain) { + renderAgain = false; + await rerender(); + } +} + // ── Boot ────────────────────────────────────────────────────────────────────── async function main() { @@ -1158,6 +1224,28 @@ async function main() { settings = { ...settings, ...boot.settings }; version = boot.version; } + setLanguage(resolveLanguage(settings.language, systemLanguages())); + applyDirection(); + onLanguageChange(() => { + applyDirection(); + void rerender(); + }); + await render(); + + void onEvent("shortcuts-status", (fresh) => shortcutsListener?.report(fresh)); + void onEvent("settings-changed", (s) => { + const before = `${settings.chatProvider}|${settings.ollamaUrl}|${settings.lmstudioUrl}|${settings.customUrl}`; + settings = { ...settings, ...s }; + shortcutsListener?.settingsChanged(); + for (const redraw of declaredViews) redraw(); + const after = `${settings.chatProvider}|${settings.ollamaUrl}|${settings.lmstudioUrl}|${settings.customUrl}`; + if (before !== after) localRedraw?.(); + applyLanguage(); + }); +} + +/** Reads what the sections show and draws them all. */ +async function render() { const status = (await Bridge.hooksStatus()) ?? { installed: false, planRelayInstalled: false, settingsPath: "", hookPath: "", hookReady: false, }; @@ -1195,6 +1283,9 @@ async function main() { } const customKey = (await Bridge.secretPresent(CUSTOM_SERVER_KEY)) ?? false; + declaredViews.length = 0; + localRedraw = null; + shortcutsListener = null; clear(root); root.append( h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), @@ -1210,17 +1301,9 @@ async function main() { shortcutsSection(shortcutReport), h("div", { class: "hint", - text: "No telemetry. Network requests only go to the services you configure yourself.", + text: t("No telemetry. Network requests only go to the services you configure yourself."), }), ); - - void onEvent("settings-changed", (s) => { - const before = `${settings.chatProvider}|${settings.ollamaUrl}|${settings.lmstudioUrl}|${settings.customUrl}`; - settings = { ...settings, ...s }; - for (const redraw of declaredViews) redraw(); - const after = `${settings.chatProvider}|${settings.ollamaUrl}|${settings.lmstudioUrl}|${settings.customUrl}`; - if (before !== after) localRedraw?.(); - }); } void main(); diff --git a/windows/src/settings/settings.css b/windows/src/settings/settings.css index f8b446144..155113b74 100644 --- a/windows/src/settings/settings.css +++ b/windows/src/settings/settings.css @@ -5,7 +5,10 @@ the