From f55c85e13048a8b553b21e96544ff3e6cb344898 Mon Sep 17 00:00:00 2001 From: Justin Minkmar Date: Sat, 3 Oct 2026 00:17:08 +0200 Subject: [PATCH 01/11] Linux: keep WebKitGTK from crashing the island on NVIDIA With NVIDIA's proprietary driver, WebKitGTK's DMABUF renderer commits frames to a Wayland surface after announcing explicit sync without an acquire point. The compositor then closes the connection with a protocol error and the app dies on its first frame. Switch the DMABUF renderer off when the NVIDIA driver is loaded, unless the user set WEBKIT_DISABLE_DMABUF_RENDERER themselves. Split prepare_environment into one function per concern. --- windows/README.md | 3 +++ windows/src-tauri/src/platform/linux.rs | 25 +++++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/windows/README.md b/windows/README.md index 60374024c..c232d494f 100644 --- a/windows/README.md +++ b/windows/README.md @@ -170,6 +170,9 @@ What changes on Linux: is a regular window. `COUCOU_LAYER_SHELL=0` forces that mode 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. +- **NVIDIA**: with the proprietary driver, WebKitGTK's DMABUF renderer makes the + compositor kill the app on its first frame, so Coucou switches it off + (`WEBKIT_DISABLE_DMABUF_RENDERER=1`) unless you set that variable yourself. - **Mochi's eyes** follow the pointer only while it is over the island: Wayland gives no app the cursor position anywhere else. - **Claude Code hooks** go through `~/.local/share/coucou/bin/coucou-hook` and a diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e748a336a..e86d4ef5d 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -51,15 +51,32 @@ pub fn local_dir() -> PathBuf { xdg("XDG_DATA_HOME", ".local/share").join("coucou") } -/// Environment the webview must inherit, set before any thread or process -/// starts. -/// +/// Environment the webview must inherit. Set before any thread or process starts. +pub fn prepare_environment() { + work_around_nvidia_explicit_sync(); + isolate_appimage_gstreamer_registry(); +} + +/// With NVIDIA's proprietary driver, WebKitGTK's DMABUF renderer commits frames +/// to a Wayland surface after announcing explicit sync without an acquire point. +/// The compositor answers with a protocol error and the app dies on its first +/// frame ("explicit sync is used, but no acquire point is set", seen on KDE with +/// an RTX 2080 Ti). Software frames avoid it, and an island this small does not +/// need the fast path. A value the user set themselves stays. +fn work_around_nvidia_explicit_sync() { + if Path::new("/proc/driver/nvidia/version").exists() + && std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() + { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } +} + /// Inside an AppImage, WebKit uses the GStreamer bundled with it, and GStreamer /// keeps its plugin registry in ~/.cache/gstreamer-1.0 by default — the same /// file the system's GStreamer uses. The AppImage is mounted somewhere new on /// 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() { +fn isolate_appimage_gstreamer_registry() { if std::env::var_os("APPIMAGE").is_none() || std::env::var_os("GST_REGISTRY").is_some() { return; } From 44688fe89da369504ff45c25966bf9fa8effe9c9 Mon Sep 17 00:00:00 2001 From: Justin Minkmar Date: Sat, 3 Oct 2026 00:17:08 +0200 Subject: [PATCH 02/11] Linux: pick the display the island lives on Wayland has no main display, and the layer surface was never assigned an output, so the compositor picked one. - Pin the layer surface to the chosen monitor (gtk_layer_set_monitor). - Settings -> Island lives on lists every display by name and size. - "Main display" now asks XWayland's RandR for the primary output, which KWin and Mutter set from the user's choice, and falls back to the first display. --- windows/README.md | 3 + windows/src-tauri/src/island.rs | 44 ++++++++++++-- windows/src-tauri/src/lib.rs | 7 +++ windows/src-tauri/src/platform/linux.rs | 73 ++++++++++++++++++++++- windows/src-tauri/src/platform/windows.rs | 7 +++ windows/src/core/bridge.ts | 3 + windows/src/core/state.ts | 3 +- windows/src/settings/main.ts | 8 ++- 8 files changed, 139 insertions(+), 9 deletions(-) diff --git a/windows/README.md b/windows/README.md index c232d494f..7682ff774 100644 --- a/windows/README.md +++ b/windows/README.md @@ -170,6 +170,9 @@ What changes on Linux: is a regular window. `COUCOU_LAYER_SHELL=0` forces that mode 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. +- **Which display**: Wayland has no "main display", so "Main display" is read from + XWayland's RandR primary (what KDE and Mutter set), via `xrandr`. Without it the + first display wins. **Settings → Island lives on** also lists every display by name. - **NVIDIA**: with the proprietary driver, WebKitGTK's DMABUF renderer makes the compositor kill the app on its first frame, so Coucou switches it off (`WEBKIT_DISABLE_DMABUF_RENDERER=1`) unless you set that variable yourself. diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 92c9cd4cb..bd844e27c 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -114,9 +114,15 @@ fn monitor_contains(m: &Monitor, x: f64, y: f64) -> bool { && y < (p.y + s.height as i32) as f64 } -/// The display the island lives on: the primary one, or the one under the cursor. +/// The display the island lives on: a named one, the one under the cursor, or +/// the primary one (also the fallback when the named display is unplugged). fn target_monitor(app: &AppHandle, pref: &str) -> Option { let monitors = app.available_monitors().ok()?; + if pref != "primary" && pref != "cursor" { + if let Some(m) = monitors.iter().find(|m| m.name().is_some_and(|n| n == pref)) { + 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)) { @@ -124,10 +130,32 @@ fn target_monitor(app: &AppHandle, pref: &str) -> Option { } } } - app.primary_monitor() - .ok() - .flatten() - .or_else(|| monitors.into_iter().next()) + if let Some((x, y)) = platform::primary_monitor_origin() { + if let Some(m) = monitors.iter().find(|m| m.position().x == x && m.position().y == y) { + return Some(m.clone()); + } + } + app.primary_monitor().ok().flatten().or_else(|| monitors.into_iter().next()) +} + +#[derive(Serialize)] +pub struct MonitorInfo { + pub name: String, + pub width: u32, + pub height: u32, +} + +/// Every display, for the picker in the settings. +// Known limit: two identical displays share a name and the first one wins. +pub fn monitor_list(app: &AppHandle) -> Vec { + app.available_monitors() + .unwrap_or_default() + .iter() + .filter_map(|m| { + let s = m.size(); + Some(MonitorInfo { name: m.name()?.clone(), width: s.width, height: s.height }) + }) + .collect() } pub fn screen_info(app: &AppHandle, pref: &str) -> ScreenInfo { @@ -157,6 +185,12 @@ pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { let mp = *m.position(); let ms = *m.size(); + if let Some(i) = app.available_monitors().ok().and_then(|all| { + all.iter().position(|o| o.position() == m.position() && o.size() == m.size()) + }) { + platform::place_on_monitor(&win, i); + } + let (lw, lh) = if collapsed { (STRIP_W, STRIP_H) } else { (PANEL_W, PANEL_H) }; let pw = (lw * scale).round().max(1.0) as u32; let ph = (lh * scale).round().max(1.0) as u32; diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..a371ddb32 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -117,6 +117,12 @@ fn focus_window(app: AppHandle, focused: bool) { } } +/// Every display by name and size, for the picker in the settings. +#[tauri::command] +fn monitors(app: AppHandle) -> Vec { + island::monitor_list(&app) +} + #[tauri::command] fn reposition(app: AppHandle, shared: State) { let pref = shared.settings.lock().unwrap().screen.clone(); @@ -381,6 +387,7 @@ pub fn run() { set_island_rect, focus_window, reposition, + monitors, open_url, open_in_vscode, quit_app, diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e86d4ef5d..a8fcad490 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -12,9 +12,9 @@ use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::process::{Command, Stdio}; use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use gtk::glib::translate::ToGlibPtr; use gtk::prelude::*; @@ -198,6 +198,10 @@ 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, + ); } } @@ -317,10 +321,75 @@ fn apply_input_region(gw: &impl IsA, rect: Region) { } } +// ── Displays ────────────────────────────────────────────────────────────────── + +/// Top-left corner of the primary display, in pixels. +/// +/// Wayland has no primary display and GDK just lists the first one, which is +/// rarely the one the user calls the main screen. Compositors do tell XWayland +/// though (KWin from its "primary" priority, Mutter likewise), so ask RandR. +/// `None` where there is no `xrandr` or no X server: the caller falls back to +/// GDK's order. Cached for a few seconds, the display poll asks twice a second. +// Known limit: positions are compared 1:1 with tao's, so a fractional-scale primary may not match. +pub fn primary_monitor_origin() -> Option<(i32, i32)> { + use std::time::{Duration, Instant}; + static CACHE: Mutex)>> = Mutex::new(None); + let mut cache = CACHE.lock().unwrap(); + if let Some((at, found)) = *cache { + if at.elapsed() < Duration::from_secs(5) { + return found; + } + } + let found = Command::new("xrandr") + .arg("--listmonitors") + .stderr(Stdio::null()) + .output() + .ok() + .and_then(|o| parse_primary_origin(&String::from_utf8_lossy(&o.stdout))); + *cache = Some((Instant::now(), found)); + found +} + +/// `" 0: +*DP-1 2560/597x1440/336+1920+0 DP-1"` → `(1920, 0)`. +fn parse_primary_origin(listing: &str) -> Option<(i32, i32)> { + let line = listing.lines().find(|l| l.contains("+*"))?; + let mut at = line.split_whitespace().nth(2)?.split('+').skip(1); + Some((at.next()?.parse().ok()?, at.next()?.parse().ok()?)) +} + +/// Index of the monitor the layer surface was last pinned to (none yet). +static PINNED_MONITOR: AtomicUsize = AtomicUsize::new(usize::MAX); + +/// Pins the layer surface to the monitor with this index. tao lists monitors in +/// GDK's order, so the index means the same thing on both sides. Without this +/// the compositor picks the output, and it is rarely the one the user wants. +/// A no-op for an ordinary window, which `set_position` already places. +pub fn place_on_monitor(win: &WebviewWindow, index: usize) { + if !LAYER_SURFACE.load(Ordering::Relaxed) + || PINNED_MONITOR.swap(index, Ordering::Relaxed) == index + { + return; + } + let Ok(gw) = win.gtk_window() else { return }; + let Some(monitor) = gtk::gdk::Display::default().and_then(|d| d.monitor(index as i32)) else { + return; + }; + unsafe { layer::gtk_layer_set_monitor(gtk_window_ptr(&gw), monitor.to_glib_none().0) }; +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn the_primary_display_is_the_one_marked_with_a_star() { + let out = "Monitors: 2\n 0: +*DP-1 2560/597x1440/336+1920+0 DP-1\n 1: +HDMI-A-1 1920/521x1080/293+0+360 HDMI-A-1\n"; + assert_eq!(parse_primary_origin(out), Some((1920, 0))); + assert_eq!(parse_primary_origin(" 0: +DP-1 1920/1x1080/1+0+0 DP-1\n"), None); + let left = " 0: +*DP-2 1920/1x1080/1+-1920+0 DP-2\n"; + assert_eq!(parse_primary_origin(left), Some((-1920, 0))); + } + #[test] fn only_a_private_directory_of_ours_can_hold_the_relay_socket() { let base = std::env::temp_dir().join(format!("coucou-rt-{}", std::process::id())); diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 6e1200a8a..0e7215569 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -233,3 +233,10 @@ 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)>) {} +/// Windows reports the primary display itself (`primary_monitor`). +pub fn primary_monitor_origin() -> Option<(i32, i32)> { + None +} + +/// Windows places the window with `set_position`; nothing to pin. +pub fn place_on_monitor(_win: &WebviewWindow, _index: usize) {} diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..f90307573 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -50,6 +50,9 @@ export const Bridge = { reposition: () => call("reposition"), + /** Every display, for the settings picker. */ + monitors: () => call<{ name: string; width: number; height: number }[]>("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..c4ab1ddb7 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 a display name from Bridge.monitors(). */ + 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..12a32a021 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -386,9 +386,15 @@ function generalSection(): HTMLElement { h("option", { value: "primary", text: "Main display" }), h("option", { value: "cursor", text: "Display under the cursor" }), ); + void Bridge.monitors().then((list) => { + for (const m of list ?? []) { + screen.append(h("option", { value: m.name, text: `${m.name} (${m.width}×${m.height})` })); + } + screen.value = settings.screen; + }); screen.value = settings.screen; screen.addEventListener("change", () => { - settings.screen = screen.value as Settings["screen"]; + settings.screen = screen.value; void save(); }); From 31c4e795d2fcfe83820e88975274705b68a7e05d Mon Sep 17 00:00:00 2001 From: Justin Minkmar Date: Sat, 3 Oct 2026 00:17:08 +0200 Subject: [PATCH 03/11] Fix the island on Wayland: closing, dropping, overlapping rows - Report the compositor's pointer leave (GTK leave-notify) to the page. WebKitGTK sends no mouseleave when the pointer leaves the surface, so the island never learned the mouse was gone and never auto-closed. - Restart the collapse timer when an alert reaches an island that is already open. forceHome cancelled it and only a state change started it again. - Start the drop sequence after the island has opened. Waking a closed island passes through its default view, which stopped the sequence and left "uploading" stuck. - Let clicks through #content and the hidden DOM view to the drop canvas' buttons; Cancel and Ask were covered by invisible elements. - Give the ticker's dim text top:0. Without it WebKit places the absolute box one line below the text it should overlap. - Let notes dismiss themselves after five seconds. --- windows/src-tauri/src/platform/linux.rs | 19 ++++++++++++++++++- windows/src/island/island.ts | 15 ++++++++++++--- windows/src/style.css | 19 +++++++++++++++++++ windows/src/views/ticker.ts | 4 +++- windows/src/views/views.ts | 12 ++++++++++-- 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index a8fcad490..c89aa6e00 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -18,7 +18,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use gtk::glib::translate::ToGlibPtr; use gtk::prelude::*; -use tauri::{AppHandle, WebviewWindow}; +use tauri::{AppHandle, Emitter, WebviewWindow}; use super::{home_dir, LocalTime}; @@ -222,6 +222,22 @@ fn gtk_window_ptr(win: >k::ApplicationWindow) -> *mut gtk::ffi::GtkWindow { /// WebKitGTK has no competing drop target to remove. pub fn unblock_webview_drops(_app: &AppHandle) {} +/// WebKitGTK sends the page no mouseleave when the pointer leaves the surface +/// (and Wayland has no global cursor either), so the island would never learn +/// the mouse is gone and never auto-close. GTK does get the compositor's leave: +/// report it as the cursor being far away, which is what the Windows poll says. +fn report_pointer_leaving(win: &WebviewWindow, gw: >k::ApplicationWindow) { + gw.add_events(gtk::gdk::EventMask::LEAVE_NOTIFY_MASK); + let win = win.clone(); + gw.connect_leave_notify_event(move |_, ev| { + // Moving onto the webview inside the window is not leaving. + if ev.detail() != gtk::gdk::NotifyType::Inferior { + let _ = win.emit("cursor", crate::island::CursorPayload { x: -10_000.0, y: -10_000.0 }); + } + gtk::glib::Propagation::Proceed + }); +} + /// Turns the island into an overlay surface on the top edge that never takes /// the keyboard. Must run before the window is first shown: a layer surface /// cannot be made out of a window the compositor already knows. @@ -231,6 +247,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 }; + report_pointer_leaving(win, &gw); // 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; diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb61802..a640b58f5 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -328,6 +328,13 @@ export class Island { this.fsm.pinned = State.isPinned; this.fsm.forceHome(); this.expand(view); + // forceHome cancels the collapse timer, and one only starts again on a state + // change: an alert on an island that is already open, with the mouse + // elsewhere, would otherwise stay open for good. + if (!this.wasInIsland) { + this.fsm.mouseLeft(); + if (!State.isPinned) this.homeCollapseAt = performance.now() + State.settings.autoCloseInterval * 1000; + } } reveal() { @@ -350,10 +357,11 @@ export class Island { if (State.fileDragOver) return; State.fileDragOver = true; this.engine.animateMorph(1); - // enterZone must run before the island expands, so the sequence is - // already active by the time the view becomes `upload`. - UploadSeq.enterZone(State.mouseInIsland.x, State.mouseInIsland.y); + // Open first, then start the sequence: waking a closed island passes + // through its default view, and leaving the drop views stops the sequence + // — started earlier, it would be dead before the drop (stuck "uploading"). this.alert("upload"); + UploadSeq.enterZone(State.mouseInIsland.x, State.mouseInIsland.y); break; } case "leave": { @@ -715,6 +723,7 @@ export class Island { if (uploadActive) this.uploadCanvas.draw(UploadSeq.frame(), nowMs / 1000); this.uploadCanvas.el.classList.toggle("on", uploadActive); this.viewsEl.classList.toggle("hidden-by-upload", uploadActive); + this.contentEl.classList.toggle("upload-on", uploadActive); tickMiniBots(dt); this.views.get(State.view)?.tick?.(nowMs); diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c..1e3198bc3 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -133,6 +133,25 @@ body { transition: opacity 0.22s ease-in-out; } +/* `.view.on` sets pointer-events itself, which beats the value inherited from + #views: without this the invisible view sits on top of the drop canvas and + eats the clicks meant for its painted buttons. */ +#views.hidden-by-upload .view.on { + pointer-events: none; +} + +/* #content sits above the drop canvas in the stacking order and covers the whole + island, so it has to let clicks through to the canvas's hit areas. Only the + header, which stays on top of the sequence, keeps the mouse. (!important: + the island sets pointer-events on #content inline.) */ +#content.upload-on { + pointer-events: none !important; +} + +#content.upload-on #header { + pointer-events: auto; +} + #bot-glow { position: absolute; pointer-events: none; diff --git a/windows/src/views/ticker.ts b/windows/src/views/ticker.ts index 72b18023e..e2e9cadbc 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 is needed: an absolute box without it keeps its static position, which + // WebKit puts one line below the shimmer text it is meant to overlap. + style: "position:absolute;left:0;right:0;top:0;color:#6b7079", }); const el = h( "div", diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f..bf4b191d7 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -394,13 +394,21 @@ function buildConfused(): ViewHost { // ── Note ────────────────────────────────────────────────────────────────────── -function buildNote(): ViewHost { +function buildNote(actions: ViewActions): ViewHost { const title = h("div", { class: "title" }); const el = h("div", { class: "view" }, card(null, h("div", { class: "stack", style: "padding:0 18px 0 98px" }, title))); + let timer: number | null = null; return { el, sync() { title.textContent = State.noteMessage ?? ""; + // A notice answers nothing and nobody is asked to dismiss it, so it goes + // away by itself. Scheduled once: sync runs on every state change. + if (timer != null) return; + timer = window.setTimeout(() => { + timer = null; + if (State.view === "note") actions.setView(State.defaultView()); + }, 5000); }, }; } @@ -495,7 +503,7 @@ export function buildViews( map.set("error", buildError(actions)); map.set("finished", buildFinished(actions)); map.set("confused", buildConfused()); - map.set("note", buildNote()); + map.set("note", buildNote(actions)); map.set("settings", buildSettings(actions)); map.set("prompt", buildPrompt(onChatHeightChange)); map.set("upload", buildUpload()); From 8c029e04744d615d2c4f26767eb7bb763ff899e8 Mon Sep 17 00:00:00 2001 From: Justin Minkmar Date: Sat, 3 Oct 2026 00:17:08 +0200 Subject: [PATCH 04/11] Open terminal: jump to the Konsole tab, find VS Code forks, say when there is no editor - The relay passes Konsole's D-Bus names. The app switches to the tab over D-Bus and raises its window with a short KWin script, found by Konsole's process id and the tab's title. - Look for code, code-insiders, code-oss, codium, vscodium and cursor in that order. - When no editor is installed the island says so instead of silently opening the file manager. - open_in_vscode returns an enum instead of a bool. --- windows/README.md | 7 +- windows/hook/src/main.rs | 4 + windows/src-tauri/src/lib.rs | 42 ++++++-- windows/src-tauri/src/platform/linux.rs | 119 ++++++++++++++++++++++ windows/src-tauri/src/platform/windows.rs | 6 ++ windows/src/core/bridge.ts | 7 +- windows/src/core/state.ts | 14 +++ windows/src/island/hooks.ts | 16 ++- windows/src/island/island.ts | 8 +- windows/src/views/integrations.ts | 3 +- windows/src/views/open-session.ts | 16 +++ 11 files changed, 222 insertions(+), 20 deletions(-) create mode 100644 windows/src/views/open-session.ts diff --git a/windows/README.md b/windows/README.md index 7682ff774..d0db3ad4a 100644 --- a/windows/README.md +++ b/windows/README.md @@ -184,6 +184,9 @@ What changes on Linux: - **Keys** live in the Secret Service (GNOME Keyring, KWallet). - **Files**: preferences in `~/.config/coucou/`, the log at `~/.local/share/coucou/coucou.log`. +- **Open terminal** jumps to the Konsole tab the session runs in: the relay passes + Konsole's D-Bus names, the app switches the tab over D-Bus and asks KWin (a + short script, via `gdbus`) to raise that window. In any other terminal it opens + the folder in VS Code, as on Windows. - 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. diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs index 5bb363781..385fdcf08 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -154,6 +154,10 @@ fn read_event() -> Option<(String, String)> { ("term_session_id", "TERM_SESSION_ID"), ("vscode_pid", "VSCODE_PID"), ("session_pid", "CLAUDE_CODE_SSE_PORT"), + // Konsole: where "Open terminal" can jump to (its D-Bus tab and window). + ("konsole_service", "KONSOLE_DBUS_SERVICE"), + ("konsole_session", "KONSOLE_DBUS_SESSION"), + ("konsole_window", "KONSOLE_DBUS_WINDOW"), ] { if !map.contains_key(key) { let value = std::env::var(var).unwrap_or_default(); diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index a371ddb32..200122661 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -138,10 +138,28 @@ fn open_url(url: String) { platform::open_url(&url); } -/// "Open terminal" opens the working folder in VS Code when `code` is on PATH, -/// and falls back to the file manager otherwise. +/// Brings the Konsole tab a session runs in to the front (Linux). The three +/// values come out of the hook's environment; `platform` checks their shape. #[tauri::command] -fn open_in_vscode(path: Option) -> bool { +fn focus_terminal(service: String, session: String, window: String) -> bool { + platform::focus_terminal(&service, &session, &window) +} + +/// How "Open terminal" ended, as the island needs to tell it. +#[derive(serde::Serialize)] +#[serde(rename_all = "kebab-case")] +enum OpenResult { + Opened, + /// No VS Code (or fork) on PATH: the folder went to the file manager instead. + NoEditor, + /// The path was not an existing folder given in full; nothing was launched. + BadPath, +} + +/// "Open terminal" opens the working folder in VS Code (or a fork of it) when one +/// is on PATH, and falls back to the file manager otherwise. +#[tauri::command] +fn open_in_vscode(path: Option) -> OpenResult { // No shell anywhere near this. The path is a project folder chosen by // whoever is using Claude Code, and a shell would happily read `&`, `^`, `%` // or `$` in a folder name as syntax. Finding the launcher ourselves and @@ -153,22 +171,29 @@ fn open_in_vscode(path: Option) -> bool { if let Some(p) = path.as_deref() { let p = std::path::Path::new(p); if !(p.is_absolute() && p.is_dir()) { - return false; + return OpenResult::BadPath; } } - if let Some(code) = platform::find_on_path("code") { - let mut cmd = Command::new(code); + // VS Code under its various names (Microsoft's build, Insiders, the OSS + // build, VSCodium), then Cursor, a VS Code fork that takes the same arguments. + for name in ["code", "code-insiders", "code-oss", "codium", "vscodium", "cursor"] { + let Some(exe) = platform::find_on_path(name) else { + continue; + }; + let mut cmd = Command::new(exe); if let Some(p) = path.as_deref() { cmd.arg(p); } if platform::no_console(&mut cmd).spawn().is_ok() { - return true; + return OpenResult::Opened; } } + // No editor: the folder in the file manager is still better than nothing, + // and the island says why nothing else happened. if let Some(p) = path.as_deref() { platform::reveal_folder(p); } - false + OpenResult::NoEditor } #[tauri::command] @@ -390,6 +415,7 @@ pub fn run() { monitors, open_url, open_in_vscode, + focus_terminal, quit_app, hooks_status, hooks_preview, diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index c89aa6e00..62b5fda95 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -338,6 +338,96 @@ fn apply_input_region(gw: &impl IsA, rect: Region) { } } +// ── Terminal ────────────────────────────────────────────────────────────────── + +/// `:1.42` or `org.kde.konsole-1234`: a D-Bus service name, nothing else. +fn is_dbus_service(s: &str) -> bool { + !s.is_empty() + && s.len() < 64 + && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b':' | b'.' | b'-' | b'_')) +} + +/// `/Sessions/3` → `3`. +fn object_index<'a>(path: &'a str, kind: &str) -> Option<&'a str> { + let n = path.strip_prefix(kind)?; + (!n.is_empty() && n.len() < 10 && n.bytes().all(|b| b.is_ascii_digit())).then_some(n) +} + +/// One `gdbus call` on the session bus; its stdout on success. +fn gdbus(dest: &str, path: &str, method: &str, args: &[&str]) -> Option { + let out = Command::new("gdbus") + .args(["call", "--session", "--dest", dest, "--object-path", path, "--method", method]) + .args(args) + .output() + .ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// Switches Konsole to the tab the session runs in and raises its window. +/// +/// Wayland lets no app raise a window, but KWin runs scripts that can: the +/// window is picked by Konsole's process id and, when one Konsole owns several +/// windows, by the tab's title. Without KWin (or `gdbus`) the tab still +/// switches and false is returned only if Konsole itself did not answer. +pub fn focus_terminal(service: &str, session: &str, window: &str) -> bool { + let (Some(id), Some(_)) = + (object_index(session, "/Sessions/"), object_index(window, "/Windows/")) + else { + return false; + }; + if !is_dbus_service(service) { + return false; + } + let title = gdbus(service, session, "org.kde.konsole.Session.title", &["1"]) + .and_then(|t| t.trim().strip_prefix("('")?.strip_suffix("',)").map(str::to_string)) + .unwrap_or_default(); + if gdbus(service, window, "org.kde.konsole.Window.setCurrentSession", &[id]).is_none() { + return false; + } + let pid = gdbus( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus.GetConnectionUnixProcessID", + &[service], + ) + .and_then(|o| o.split_whitespace().nth(1)?.trim_end_matches(',').parse::().ok()); + if let Some(pid) = pid { + raise_window_of(pid, &title); + } + true +} + +/// Asks KWin to activate the window of process `pid` whose caption starts with +/// `title` (any window of the process if none does). The title goes in as a JSON +/// string, which is also a valid JS one. +fn raise_window_of(pid: u32, title: &str) { + let script = format!( + "const ws = workspace.windowList().filter(w => w.pid === {pid});\n\ + const w = ws.find(w => w.caption.startsWith({title})) || ws[0];\n\ + if (w) workspace.activeWindow = w;\n", + title = serde_json::to_string(title).unwrap_or_else(|_| "\"\"".into()), + ); + let path = std::env::temp_dir().join(format!("coucou-focus-{}.js", std::process::id())); + if std::fs::write(&path, script).is_err() { + return; + } + let name = format!("coucou-focus-{}", std::process::id()); + let path_str = path.to_string_lossy(); + if let Some(id) = gdbus( + "org.kde.KWin", + "/Scripting", + "org.kde.kwin.Scripting.loadScript", + &[&path_str, &name], + ) + .and_then(|o| { + o.split(|c: char| !c.is_ascii_digit()).find(|n| !n.is_empty()).map(str::to_string) + }) { + gdbus("org.kde.KWin", &format!("/Scripting/Script{id}"), "org.kde.kwin.Script.run", &[]); + gdbus("org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.unloadScript", &[&name]); + } + let _ = std::fs::remove_file(path); +} + // ── Displays ────────────────────────────────────────────────────────────────── /// Top-left corner of the primary display, in pixels. @@ -398,6 +488,35 @@ pub fn place_on_monitor(win: &WebviewWindow, index: usize) { mod tests { use super::*; + /// Needs a running Konsole with this test started inside it: + /// `cargo test --lib -- --ignored focus_own_konsole_tab`. + #[test] + #[ignore] + fn focus_own_konsole_tab() { + let get = |k| std::env::var(k).expect(k); + assert!(focus_terminal( + &get("KONSOLE_DBUS_SERVICE"), + &get("KONSOLE_DBUS_SESSION"), + &get("KONSOLE_DBUS_WINDOW"), + )); + } + + #[test] + fn only_konsole_shaped_names_reach_the_bus() { + assert!(is_dbus_service(":1.42") && is_dbus_service("org.kde.konsole-1234")); + assert!( + !is_dbus_service("") + && !is_dbus_service("a b") + && !is_dbus_service("x;y") + && !is_dbus_service("--help ") + ); + assert_eq!(object_index("/Sessions/3", "/Sessions/"), Some("3")); + assert_eq!(object_index("/Windows/12", "/Windows/"), Some("12")); + assert_eq!(object_index("/Sessions/3/x", "/Sessions/"), None); + assert_eq!(object_index("/Windows/1", "/Sessions/"), None); + assert_eq!(object_index("/Sessions/", "/Sessions/"), None); + } + #[test] fn the_primary_display_is_the_one_marked_with_a_star() { let out = "Monitors: 2\n 0: +*DP-1 2560/597x1440/336+1920+0 DP-1\n 1: +HDMI-A-1 1920/521x1080/293+0+360 HDMI-A-1\n"; diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 0e7215569..0c4764ee7 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -233,6 +233,12 @@ 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)>) {} + +/// No jump to a specific terminal window on Windows. +pub fn focus_terminal(_service: &str, _session: &str, _window: &str) -> bool { + false +} + /// Windows reports the primary display itself (`primary_monitor`). pub fn primary_monitor_origin() -> Option<(i32, i32)> { None diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index f90307573..fe2810aba 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -5,7 +5,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 { Settings, TerminalRef } from "./state"; export const IS_TAURI = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; @@ -56,7 +56,10 @@ export const Bridge = { openUrl: (url: string) => call("open_url", { url }), /** "Open terminal" → opens the folder in VS Code when `code` is on PATH. */ - openInVSCode: (path: string | null) => call("open_in_vscode", { path }), + openInVSCode: (path: string | null) => call<"ok" | "no-editor" | "bad-path">("open_in_vscode", { path }), + + /** Brings the terminal tab the session runs in to the front. False: not possible here. */ + focusTerminal: (t: TerminalRef) => call("focus_terminal", { ...t }), quit: () => call("quit_app"), diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index c4ab1ddb7..bcd4c4aaa 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -19,6 +19,15 @@ export interface AgentTask { miniEye?: EyeShape | null; pillBadge?: PillBadge | null; sessionCwd?: string | null; + /** Where the session's terminal lives, when we know how to jump to it. */ + terminal?: TerminalRef | null; +} + +/** Konsole's D-Bus names for a terminal tab, as the hook's environment gives them. */ +export interface TerminalRef { + service: string; + session: string; + window: string; } export interface ApprovalInfo { @@ -165,6 +174,11 @@ class AppState { return this.stateOverride ?? this.focusTask?.state ?? "idle"; } + /** The Claude Code pill. */ + get claudeTask(): AgentTask | undefined { + return this.tasks.find((t) => t.id === "integration_claude"); + } + get otherTasks(): AgentTask[] { return this.tasks.filter((t) => t.id !== this.focusId); } diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index d90a78f09..091b16a1a 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -25,6 +25,10 @@ interface HookPayload { tool_input?: Record; /** Optional agent tag: lowercase, digits and hyphens, ≤ 24 chars. */ coucou_agent?: string; + /** Konsole's D-Bus names for the terminal the session runs in (Linux). */ + konsole_service?: string; + konsole_session?: string; + konsole_window?: string; } /** Same rule as HookServer.validateAgent on macOS. "claude" is reserved. */ @@ -121,14 +125,14 @@ function approvalTarget(tool: string, input: Record): string { } function upsert(projectName: string, cwd: string) { - const t = State.tasks.find((x) => x.id === CLAUDE_ID); + const t = State.claudeTask; if (!t) return; t.name = projectName; if (cwd) t.sessionCwd = cwd; } function clearSession() { - const t = State.tasks.find((x) => x.id === CLAUDE_ID); + const t = State.claudeTask; if (!t) return; t.steps = []; t.stepIndex = 0; @@ -140,6 +144,13 @@ export function registerHookHandlers(island: Island) { void onEvent("hook", (payload) => handleHook(island, payload)); } +/** Remembers which Konsole tab the session runs in, for "Open terminal". */ +function noteTerminal(payload: HookPayload) { + const { konsole_service: service, konsole_session: session, konsole_window: window } = payload; + const task = State.claudeTask; + if (task && service && session && window) task.terminal = { service, session, window }; +} + function handleHook(island: Island, payload: HookPayload) { if (State.paused) { // Silence here used to cost Claude Code nearly two minutes: the relay waited @@ -151,6 +162,7 @@ function handleHook(island: Island, payload: HookPayload) { const name = payload.hook_event_name ?? ""; const cwd = payload.cwd ?? ""; + noteTerminal(payload); const raw = lastPathComponent(cwd); const projectName = aliasProjectName(raw || "Session"); diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index a640b58f5..044706de1 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -16,6 +16,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 { openSession } from "../views/open-session"; import { buildHeader, buildViews, type ViewActions, type ViewHost } from "../views/views"; import { h } from "../views/dom"; import { IslandStateMachine } from "./fsm"; @@ -111,10 +112,7 @@ export class Island { State.setFocus(id); Sound.play("blip"); }, - openTerminal: () => { - const cwd = State.focusTask?.sessionCwd ?? null; - void Bridge.openInVSCode(cwd); - }, + openTerminal: () => void openSession(State.focusTask), // The ↗ button — same targets as openAgentTarget() on macOS. openTarget: () => { const task = State.focusTask; @@ -127,7 +125,7 @@ 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); + if (task.id === "integration_claude") void openSession(task); else if (task.id === "integration_n8n") void Bridge.openN8n(); else if (urls[task.id]) void Bridge.openUrl(urls[task.id]); }, diff --git a/windows/src/views/integrations.ts b/windows/src/views/integrations.ts index b8ad73c8f..c966a4f79 100644 --- a/windows/src/views/integrations.ts +++ b/windows/src/views/integrations.ts @@ -8,6 +8,7 @@ import { h, svg, clear, dot } from "./dom"; import { ICONS } from "./icons"; import { State, type AgentTask } from "../core/state"; import { Bridge } from "../core/bridge"; +import { openSession } from "./open-session"; /** Same shape as the Swift `timeAgo` computed properties. */ export function timeAgo(value: unknown): string { @@ -70,7 +71,7 @@ function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { class: "link-btn", style: `color:${task.color}b3`, text: "Open Visual Studio Code", - onclick: () => void Bridge.openInVSCode(task.sessionCwd ?? null), + onclick: () => void openSession(task), }), ); } else if (task.id === "integration_n8n") { diff --git a/windows/src/views/open-session.ts b/windows/src/views/open-session.ts new file mode 100644 index 000000000..96e86a546 --- /dev/null +++ b/windows/src/views/open-session.ts @@ -0,0 +1,16 @@ +// "Open terminal" and the ↗ on the overview: bring the session's terminal to the +// front when we can reach it, open its folder in an editor otherwise. + +import { Bridge } from "../core/bridge"; +import { State, type AgentTask } from "../core/state"; + +/** "Open terminal": the session's own terminal when we can reach it, VS Code otherwise. */ +export async function openSession(task: AgentTask | null | undefined) { + if (task?.terminal && (await Bridge.focusTerminal(task.terminal))) return; + if ((await Bridge.openInVSCode(task?.sessionCwd ?? null)) === "no-editor") { + // Say so, or the click looks like it did nothing: the folder opened in the file manager at best. + State.noteMessage = "No code editor found. Install Visual Studio Code (the `code` command) to open projects from here."; + State.view = "note"; + State.notify(); + } +} From 1953555fe943a54db97cd9e4e03a263ef37c825e Mon Sep 17 00:00:00 2001 From: Justin Minkmar Date: Sat, 3 Oct 2026 00:17:08 +0200 Subject: [PATCH 05/11] Add a language setting for Mochi's answers and the island's wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings -> Language (system, English, Deutsch, Français). An explicit choice tells the chat to always answer in that language, whatever the question or the search results are in. The step labels ("Runs", "Führt aus", "Exécute") follow it, where they were fixed to French. --- windows/src-tauri/src/claude.rs | 45 +++++++++++++++++++++++++++---- windows/src-tauri/src/lib.rs | 7 +++-- windows/src-tauri/src/settings.rs | 9 +++++++ windows/src/core/i18n.ts | 14 ++++++++++ windows/src/core/state.ts | 3 +++ windows/src/island/hooks.ts | 37 +++++++++++++------------ windows/src/settings/main.ts | 18 +++++++++++++ 7 files changed, 109 insertions(+), 24 deletions(-) create mode 100644 windows/src/core/i18n.ts diff --git a/windows/src-tauri/src/claude.rs b/windows/src-tauri/src/claude.rs index 060a57c64..a90a7ccb4 100644 --- a/windows/src-tauri/src/claude.rs +++ b/windows/src-tauri/src/claude.rs @@ -22,10 +22,36 @@ 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. \ +/// The language the chat answers in. An explicit choice wins over whatever +/// language the question or the web results are in; "auto" follows the system. +fn language_name(setting: &str) -> Option<&'static str> { + let system = ["LC_ALL", "LC_MESSAGES", "LANG"] + .iter() + .find_map(|k| std::env::var(k).ok().filter(|v| !v.is_empty() && v != "C" && v != "POSIX")); + let code = if setting == "auto" { system.as_deref().unwrap_or("") } else { setting }; + match code.get(..2)? { + "de" => Some("German"), + "en" => Some("English"), + "fr" => Some("French"), + _ => None, + } +} + +/// The system prompt of both ways to ask: the API and Claude Code. +pub(crate) fn system_prompt(language: &str) -> String { + let respond = match language_name(language) { + Some(name) => format!( + "Always respond in {name}, whatever language the question or the search results are in." + ), + None => "Respond in the user's language.".to_string(), + }; + format!( + "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."; +{respond} 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 { @@ -73,6 +99,7 @@ pub struct ChatReply { pub async fn send( chat: &Chat, model: &str, + language: &str, query: String, context: Option, ) -> Result { @@ -108,7 +135,7 @@ pub async fn send( let body = json!({ "model": model, "max_tokens": MAX_TOKENS, - "system": SYSTEM_PROMPT, + "system": system_prompt(language), "tools": [{ "type": "web_search_20260209", "name": "web_search", "max_uses": 5 }], "fallbacks": "default", "messages": chat.snapshot(), @@ -248,7 +275,7 @@ fn base64(bytes: &[u8]) -> String { #[cfg(test)] mod tests { - use super::base64; + use super::{base64, system_prompt}; #[test] fn base64_matches_rfc4648_vectors() { @@ -260,4 +287,12 @@ mod tests { assert_eq!(base64(b"fooba"), "Zm9vYmE="); assert_eq!(base64(b"foobar"), "Zm9vYmFy"); } + + #[test] + fn an_explicit_language_overrides_the_language_of_the_search_results() { + assert!(system_prompt("de").contains("Always respond in German")); + assert!(system_prompt("fr").contains("Always respond in French")); + // Unknown choice: the model decides, as before. + assert!(system_prompt("xx").contains("Respond in the user's language.")); + } } diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 200122661..3a2a633ff 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -272,8 +272,11 @@ async fn chat_send( query: String, context: Option, ) -> Result { - let model = shared.settings.lock().unwrap().model.clone(); - claude::send(&chat, &model, query, context).await + let (model, language) = { + let s = shared.settings.lock().unwrap(); + (s.model.clone(), s.language.clone()) + }; + claude::send(&chat, &model, &language, query, context).await } #[tauri::command] diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 2a8d7d359..af722ebec 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -20,6 +20,14 @@ pub struct Settings { /// Defaulted explicitly so a settings.json written by an older build still loads. #[serde(default = "default_model")] pub model: String, + /// "auto" follows the system language; otherwise "en", "de" or "fr". Drives the + /// chat's answer language and the island's step labels. + #[serde(default = "default_language")] + pub language: String, +} + +fn default_language() -> String { + "auto".to_string() } fn default_model() -> String { @@ -43,6 +51,7 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + language: default_language(), } } } diff --git a/windows/src/core/i18n.ts b/windows/src/core/i18n.ts new file mode 100644 index 000000000..20ce453ba --- /dev/null +++ b/windows/src/core/i18n.ts @@ -0,0 +1,14 @@ +// Which language the island's own wording is in: the one chosen in the settings, +// or the system's. The chat's answer language is decided on the Rust side. + +import { State } from "./state"; + +/** The locale to format with, e.g. "de-DE". */ +export function uiLocale(): string { + return State.settings.language === "auto" ? navigator.language : State.settings.language; +} + +/** The entry of `table` for the UI language, English where there is none. */ +export function localized(table: Record & { en: T }): T { + return table[uiLocale().slice(0, 2).toLowerCase()] ?? table.en; +} diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index bcd4c4aaa..e4699ecb9 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -102,6 +102,8 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + /** "auto" (system language), "en", "de" or "fr". */ + language: string; } export const DEFAULT_SETTINGS: Settings = { @@ -116,6 +118,7 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + language: "auto", }; type Listener = () => void; diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index 091b16a1a..aeec36d7c 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 { localized } from "../core/i18n"; import { Sound } from "../core/sound"; import { State } from "../core/state"; import type { Island } from "./island"; @@ -64,26 +65,28 @@ function lastPathComponent(p: string): string { return idx >= 0 ? cleaned.slice(idx + 1) : cleaned; } -/** frenchStep() — same labels as the macOS app. */ -const TOOL_LABELS: Record = { - Bash: "Exécute", - Read: "Lit", - Write: "Écrit", - Edit: "Modifie", - Glob: "Cherche", - Grep: "Recherche", - WebSearch: "Recherche web", - WebFetch: "Récupère", - TodoWrite: "Tâches", - Task: "Agent", - LS: "Liste", - MultiEdit: "Modifie", - NotebookEdit: "Notebook", - PowerShell: "Exécute", +/** The island's own wording for what Claude is doing. Anything else falls back to English. */ +const TOOL_LABELS: { en: Record } & Record> = { + en: { + Bash: "Runs", Read: "Reads", Write: "Writes", Edit: "Edits", Glob: "Finds", Grep: "Searches", + WebSearch: "Web search", WebFetch: "Fetches", TodoWrite: "Tasks", Task: "Agent", LS: "Lists", + MultiEdit: "Edits", NotebookEdit: "Notebook", PowerShell: "Runs", + }, + de: { + Bash: "Führt aus", Read: "Liest", Write: "Schreibt", Edit: "Ändert", Glob: "Sucht", Grep: "Durchsucht", + WebSearch: "Websuche", WebFetch: "Ruft ab", TodoWrite: "Aufgaben", Task: "Agent", LS: "Listet", + MultiEdit: "Ändert", NotebookEdit: "Notebook", PowerShell: "Führt aus", + }, + // Same labels as the macOS app. + fr: { + Bash: "Exécute", Read: "Lit", Write: "Écrit", Edit: "Modifie", Glob: "Cherche", Grep: "Recherche", + WebSearch: "Recherche web", WebFetch: "Récupère", TodoWrite: "Tâches", Task: "Agent", LS: "Liste", + MultiEdit: "Modifie", NotebookEdit: "Notebook", PowerShell: "Exécute", + }, }; function stepLabel(tool: string, input: Record): string { - const label = TOOL_LABELS[tool] ?? tool; + const label = localized(TOOL_LABELS)[tool] ?? tool; const str = (k: string) => (typeof input[k] === "string" ? (input[k] as string) : null); const cmd = str("command"); if (cmd) return `${label} · ${cmd.slice(0, 40)}`; diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 12a32a021..a887afb90 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -386,6 +386,19 @@ function generalSection(): HTMLElement { h("option", { value: "primary", text: "Main display" }), h("option", { value: "cursor", text: "Display under the cursor" }), ); + const language = h("select", {}) as HTMLSelectElement; + language.append( + h("option", { value: "auto", text: "System language" }), + h("option", { value: "en", text: "English" }), + h("option", { value: "de", text: "Deutsch" }), + h("option", { value: "fr", text: "Français" }), + ); + language.value = settings.language; + language.addEventListener("change", () => { + settings.language = language.value; + void save(); + }); + void Bridge.monitors().then((list) => { for (const m of list ?? []) { screen.append(h("option", { value: m.name, text: `${m.name} (${m.width}×${m.height})` })); @@ -416,6 +429,11 @@ function generalSection(): HTMLElement { h("label", { text: "Island lives on" }), screen, ), + h("div", { class: "row" }, + h("label", { text: "Language" }), + language, + h("span", { class: "hint", text: "for Mochi's answers and the activity labels" }), + ), h("div", { class: "row" }, h("label", { text: "Launch at startup" }), toggle(settings.autostart, (v) => { settings.autostart = v; void save(); }), From 9b919538ed38812006b047c162a4c5000da3b110 Mon Sep 17 00:00:00 2001 From: Justin Minkmar Date: Sat, 3 Oct 2026 00:17:08 +0200 Subject: [PATCH 06/11] Chat through Claude Code when there is no API key Without an API key the chat runs `claude -p` on the user's own login instead of failing with "API key missing". Read, WebSearch and WebFetch are pre-approved, the user's hooks are left out (--setting-sources project, run from a temp directory) so the helper does not show up as a session, and follow-up questions resume the same session. --- windows/README.md | 6 ++ windows/src-tauri/src/claude.rs | 9 ++- windows/src-tauri/src/claude_cli.rs | 98 +++++++++++++++++++++++++++++ windows/src-tauri/src/lib.rs | 1 + 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 windows/src-tauri/src/claude_cli.rs diff --git a/windows/README.md b/windows/README.md index d0db3ad4a..18f015217 100644 --- a/windows/README.md +++ b/windows/README.md @@ -74,6 +74,12 @@ 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. +Without an API key, the chat asks **Claude Code** instead (`claude -p`, on your own +login) when `claude` is on your PATH. It may read the dropped file and search or +fetch the web, nothing else, and your hooks stay out of it. **Settings → Language** +sets the language of the answers and of the island's activity labels (system +language by default). + No telemetry. The only network requests Coucou makes are to the services you configure yourself. diff --git a/windows/src-tauri/src/claude.rs b/windows/src-tauri/src/claude.rs index a90a7ccb4..32a1029d2 100644 --- a/windows/src-tauri/src/claude.rs +++ b/windows/src-tauri/src/claude.rs @@ -57,11 +57,15 @@ No markdown formatting (no **, no ##, no bullet dashes). Use plain text with lin pub struct Chat { /// Full multi-turn history, including tool_use / tool_result blocks. messages: Mutex>, + /// Claude Code session of this conversation, when it runs without an API key + /// (see claude_cli.rs). + pub(crate) cli_session: Mutex>, } impl Chat { pub fn reset(&self) { self.messages.lock().unwrap().clear(); + *self.cli_session.lock().unwrap() = None; } fn is_empty(&self) -> bool { @@ -103,8 +107,9 @@ pub async fn send( query: String, context: Option, ) -> Result { - let key = secrets::get("anthropic-api-key") - .ok_or_else(|| "API key missing. Open settings.".to_string())?; + let Some(key) = secrets::get("anthropic-api-key") else { + return crate::claude_cli::send(chat, language, query, context).await; + }; let mut content: Vec = Vec::new(); diff --git a/windows/src-tauri/src/claude_cli.rs b/windows/src-tauri/src/claude_cli.rs new file mode 100644 index 000000000..301a4c553 --- /dev/null +++ b/windows/src-tauri/src/claude_cli.rs @@ -0,0 +1,98 @@ +// Chat without an API key: ask Claude Code instead. +// +// `claude -p` runs on the user's own Claude Code login, so the island's chat +// works for anyone who has Claude Code, key or not. It is the fallback of +// claude.rs, not a second client: the same system prompt, the same answers. + +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; + +use serde_json::Value; + +use crate::claude::{Chat, ChatContext, ChatReply, system_prompt}; +use crate::platform; + +/// The launcher's name on each platform (npm installs it as `claude.cmd`). +const LAUNCHERS: &[&str] = if cfg!(windows) { &["claude.exe", "claude.cmd"] } else { &["claude"] }; + +/// No API key: ask Claude Code instead (`claude -p`), on the user's own login. +/// Same system prompt; Read, WebSearch and WebFetch are pre-approved so it never +/// stops on a permission prompt, and `--setting-sources project` from a temp +/// directory keeps the user's hooks out, or every answer would show up in the +/// island as a session of its own. +pub async fn send( + chat: &Chat, + language: &str, + query: String, + context: Option, +) -> Result { + let Some(exe) = LAUNCHERS.iter().find_map(|name| platform::find_on_path(name)) else { + return Err( + "API key missing. Open settings, or install Claude Code to use it instead.".into() + ); + }; + + let session = chat.cli_session.lock().unwrap().clone(); + let mut prompt = String::new(); + let mut add_dir = None; + // File / window context rides along with the first message only. + if session.is_none() { + match &context { + Some(ChatContext::File { name, path }) => { + prompt.push_str(&format!("The user dropped the file \"{name}\" ({path}). Read it if the question needs it.\n\n")); + add_dir = Path::new(path).parent().map(Path::to_path_buf); + } + Some(ChatContext::Window { app_name, title, url }) => { + prompt.push_str(&format!("Context — App: {app_name}, Window: {title}")); + if let Some(url) = url { + prompt.push_str(&format!(", URL: {url}")); + } + prompt.push_str("\n\n"); + } + None => {} + } + } + prompt.push_str(&query); + let system = system_prompt(language); + + // Known limit: there is no timeout, so a stuck `claude` keeps the island thinking. + let out = tokio::task::spawn_blocking(move || { + let mut cmd = Command::new(exe); + cmd.args(["-p", "--output-format", "json", "--setting-sources", "project"]) + .args(["--allowedTools", "Read", "WebSearch", "WebFetch"]) + .args(["--append-system-prompt", &system]); + if let Some(id) = &session { + cmd.args(["--resume", id]); + } + if let Some(dir) = &add_dir { + cmd.arg("--add-dir").arg(dir); + } + // The prompt goes in on stdin: the options above take lists and would swallow it. + let mut child = platform::no_console(&mut cmd) + .current_dir(std::env::temp_dir()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + child.stdin.take().expect("piped").write_all(prompt.as_bytes())?; + child.wait_with_output() + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| format!("Could not start Claude Code: {e}"))?; + + let reply: Value = serde_json::from_slice(&out.stdout).unwrap_or(Value::Null); + let text = reply.get("result").and_then(Value::as_str).unwrap_or("").trim(); + if !out.status.success() || reply.get("is_error") == Some(&Value::Bool(true)) || text.is_empty() + { + let stderr = String::from_utf8_lossy(&out.stderr); + let why = + if !text.is_empty() { text } else { stderr.lines().next().unwrap_or("No response.") }; + return Err(format!("Claude Code: {why}")); + } + if let Some(id) = reply.get("session_id").and_then(Value::as_str) { + *chat.cli_session.lock().unwrap() = Some(id.to_string()); + } + Ok(ChatReply { text: text.to_string() }) +} diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 3a2a633ff..ad7660db1 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 claude_cli; mod files; mod hooks; mod integrations; From 41811b2e3620718975eae8b09aadb3b0ece38332 Mon Sep 17 00:00:00 2001 From: Justin Minkmar Date: Sat, 3 Oct 2026 00:17:09 +0200 Subject: [PATCH 07/11] Use Font Awesome icons for the header and the phases Embed the handful of Font Awesome Free 7 (solid) paths in use, with the CC BY 4.0 notice, and drop the hand-drawn header icons. The tabs get a little more room. --- windows/src/style.css | 6 +++--- windows/src/views/fa.ts | 40 ++++++++++++++++++++++++++++++++++++++ windows/src/views/icons.ts | 13 ------------- windows/src/views/views.ts | 15 +++++++------- 4 files changed, 51 insertions(+), 23 deletions(-) create mode 100644 windows/src/views/fa.ts diff --git a/windows/src/style.css b/windows/src/style.css index 1e3198bc3..9e18600ef 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -227,13 +227,13 @@ body { } .tab { - width: 30px; - height: 22px; + width: 38px; + height: 26px; display: grid; place-items: center; border: 0; background: transparent; - border-radius: 11px; + border-radius: 13px; color: var(--dim-2); cursor: default; } diff --git a/windows/src/views/fa.ts b/windows/src/views/fa.ts new file mode 100644 index 000000000..8ece3af52 --- /dev/null +++ b/windows/src/views/fa.ts @@ -0,0 +1,40 @@ +// Icons from Font Awesome Free 7.3.1 (solid) — https://fontawesome.com +// Copyright 2025 Fonticons, Inc. The icons are licensed CC BY 4.0 +// (https://creativecommons.org/licenses/by/4.0/), see https://fontawesome.com/license/free +// +// Only the few paths used here are embedded: no package is installed and nothing +// is loaded at run time. Each entry is [viewBox width, viewBox height, path]. + +const FA = { + house: [512, 512, "M277.8 8.6c-12.3-11.4-31.3-11.4-43.5 0l-224 208c-9.6 9-12.8 22.9-8 35.1S18.8 272 32 272l16 0 0 176c0 35.3 28.7 64 64 64l288 0c35.3 0 64-28.7 64-64l0-176 16 0c13.2 0 25-8.1 29.8-20.3s1.6-26.2-8-35.1l-224-208zM240 320l32 0c26.5 0 48 21.5 48 48l0 96-128 0 0-96c0-26.5 21.5-48 48-48z"], + comment: [512, 512, "M512 240c0 132.5-114.6 240-256 240-37.1 0-72.3-7.4-104.1-20.7L33.5 510.1c-9.4 4-20.2 1.7-27.1-5.8S-2 485.8 2.8 476.8l48.8-92.2C19.2 344.3 0 294.3 0 240 0 107.5 114.6 0 256 0S512 107.5 512 240z"], + plus: [448, 512, "M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"], + gear: [512, 512, "M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"], + volumeHigh: [640, 512, "M533.6 32.5c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C557.5 113.8 592 180.8 592 256s-34.5 142.2-88.7 186.3c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5C598.5 426.7 640 346.2 640 256S598.5 85.2 533.6 32.5zM473.1 107c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C475.3 170.7 496 210.9 496 256s-20.7 85.3-53.2 111.8c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5c43.2-35.2 70.9-88.9 70.9-149s-27.7-113.8-70.9-149zm-60.5 74.5c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C393.1 227.6 400 241 400 256s-6.9 28.4-17.7 37.3c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5C434.1 312.9 448 286.1 448 256s-13.9-56.9-35.4-74.5zM80 352l48 0 134.1 119.2c6.4 5.7 14.6 8.8 23.1 8.8 19.2 0 34.8-15.6 34.8-34.8l0-378.4c0-19.2-15.6-34.8-34.8-34.8-8.5 0-16.7 3.1-23.1 8.8L128 160 80 160c-26.5 0-48 21.5-48 48l0 96c0 26.5 21.5 48 48 48z"], + volumeXmark: [576, 512, "M48 352l48 0 134.1 119.2c6.4 5.7 14.6 8.8 23.1 8.8 19.2 0 34.8-15.6 34.8-34.8l0-378.4c0-19.2-15.6-34.8-34.8-34.8-8.5 0-16.7 3.1-23.1 8.8L96 160 48 160c-26.5 0-48 21.5-48 48l0 96c0 26.5 21.5 48 48 48zM367 175c-9.4 9.4-9.4 24.6 0 33.9l47 47-47 47c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l47-47 47 47c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-47-47 47-47c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-47 47-47-47c-9.4-9.4-24.6-9.4-33.9 0z"], + check: [448, 512, "M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z"], + circleCheck: [512, 512, "M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"], + circleNotch: [512, 512, "M222.7 32.1c5 16.9-4.6 34.8-21.5 39.8-79.3 23.6-137.1 97.1-137.1 184.1 0 106 86 192 192 192s192-86 192-192c0-86.9-57.8-160.4-137.1-184.1-16.9-5-26.6-22.9-21.5-39.8s22.9-26.6 39.8-21.5C434.9 42.1 512 140 512 256 512 397.4 397.4 512 256 512S0 397.4 0 256c0-116 77.1-213.9 182.9-245.4 16.9-5 34.8 4.6 39.8 21.5z"], + terminal: [512, 512, "M9.4 118.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L146.7 256 9.4 118.6zM224 384l256 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z"], + pen: [512, 512, "M352.9 21.2L308 66.1 445.9 204 490.8 159.1C504.4 145.6 512 127.2 512 108s-7.6-37.6-21.2-51.1L455.1 21.2C441.6 7.6 423.2 0 404 0s-37.6 7.6-51.1 21.2zM274.1 100L58.9 315.1c-10.7 10.7-18.5 24.1-22.6 38.7L.9 481.6c-2.3 8.3 0 17.3 6.2 23.4s15.1 8.5 23.4 6.2l127.8-35.5c14.6-4.1 27.9-11.8 38.7-22.6L412 237.9 274.1 100z"], + expand: [512, 512, "M344 0L488 0c13.3 0 24 10.7 24 24l0 144c0 9.7-5.8 18.5-14.8 22.2s-19.3 1.7-26.2-5.2l-39-39-87 87c-9.4 9.4-24.6 9.4-33.9 0l-32-32c-9.4-9.4-9.4-24.6 0-33.9l87-87-39-39c-6.9-6.9-8.9-17.2-5.2-26.2S334.3 0 344 0zM168 512L24 512c-13.3 0-24-10.7-24-24L0 344c0-9.7 5.8-18.5 14.8-22.2S34.1 320.2 41 327l39 39 87-87c9.4-9.4 24.6-9.4 33.9 0l32 32c9.4 9.4 9.4 24.6 0 33.9l-87 87 39 39c6.9 6.9 8.9 17.2 5.2 26.2S177.7 512 168 512z"], + compress: [512, 512, "M439.5 7c9.4-9.4 24.6-9.4 33.9 0l32 32c9.4 9.4 9.4 24.6 0 33.9l-87 87 39 39c6.9 6.9 8.9 17.2 5.2 26.2S450.2 240 440.5 240l-144 0c-13.3 0-24-10.7-24-24l0-144c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2l39 39 87-87zM72.5 272l144 0c13.3 0 24 10.7 24 24l0 144c0 9.7-5.8 18.5-14.8 22.2s-19.3 1.7-26.2-5.2l-39-39-87 87c-9.4 9.4-24.6 9.4-33.9 0l-32-32c-9.4-9.4-9.4-24.6 0-33.9l87-87-39-39c-6.9-6.9-8.9-17.2-5.2-26.2S62.8 272 72.5 272z"], + fileLines: [384, 512, "M0 64C0 28.7 28.7 0 64 0L213.5 0c17 0 33.3 6.7 45.3 18.7L365.3 125.3c12 12 18.7 28.3 18.7 45.3L384 448c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm208-5.5l0 93.5c0 13.3 10.7 24 24 24L325.5 176 208 58.5zM120 256c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0zm0 96c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0z"], +} as const; + +export type FaName = keyof typeof FA; + +/** An inline SVG of the icon, `size` px tall, coloured by the surrounding text colour. */ +export function fa(name: FaName, size = 14): SVGSVGElement { + const [w, h, path] = FA[name]; + const el = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + el.setAttribute("viewBox", `0 0 ${w} ${h}`); + el.setAttribute("width", String(Math.round((size * w) / h * 100) / 100)); + el.setAttribute("height", String(size)); + el.setAttribute("aria-hidden", "true"); + el.setAttribute("fill", "currentColor"); + const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); + p.setAttribute("d", path); + el.append(p); + return el; +} diff --git a/windows/src/views/icons.ts b/windows/src/views/icons.ts index d2ff82475..80688b828 100644 --- a/windows/src/views/icons.ts +++ b/windows/src/views/icons.ts @@ -2,19 +2,6 @@ // Drawn on a 24×24 grid so they read at the same optical size. export const ICONS = { - // house.fill - house: "M12 3.2 2.8 10.6V21h6.6v-5.4h5.2V21h6.6V10.6L12 3.2z", - // bubble.left.fill - bubble: "M12 3.6c-5 0-9 3.3-9 7.4 0 2.3 1.3 4.4 3.3 5.7-.2 1.2-.8 2.4-1.7 3.4 1.9-.2 3.6-.9 4.9-1.9 .8.2 1.6.3 2.5.3 5 0 9-3.3 9-7.5s-4-7.4-9-7.4z", - // plus - plus: "M11 4h2v7h7v2h-7v7h-2v-7H4v-2h7V4z", - // gearshape - gear: "M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8zm0 1.8a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2zM10.9 2h2.2l.35 2.1c.6.17 1.16.4 1.67.71l1.9-1 1.55 1.55-1 1.9c.3.5.54 1.07.7 1.67l2.13.35v2.2l-2.12.35c-.17.6-.4 1.16-.71 1.67l1 1.9-1.55 1.55-1.9-1c-.5.3-1.07.54-1.67.7L13.1 22h-2.2l-.35-2.12c-.6-.17-1.16-.4-1.67-.71l-1.9 1L5.43 18.6l1-1.9c-.3-.5-.54-1.07-.7-1.67L3.6 14.7v-2.2l2.12-.35c.17-.6.4-1.16.71-1.67l-1-1.9 1.55-1.55 1.9 1c.5-.3 1.07-.54 1.67-.7L10.9 2z", - gearFill: "M10.9 2h2.2l.35 2.1c.6.17 1.16.4 1.67.71l1.9-1 1.55 1.55-1 1.9c.3.5.54 1.07.7 1.67l2.13.35v2.2l-2.12.35c-.17.6-.4 1.16-.71 1.67l1 1.9-1.55 1.55-1.9-1c-.5.3-1.07.54-1.67.7L13.1 22h-2.2l-.35-2.12c-.6-.17-1.16-.4-1.67-.71l-1.9 1L5.43 18.6l1-1.9c-.3-.5-.54-1.07-.7-1.67L3.6 14.7v-2.2l2.12-.35c.17-.6.4-1.16.71-1.67l-1-1.9 1.55-1.55 1.9 1c.5-.3 1.07-.54 1.67-.7L10.9 2zM12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6z", - // speaker.wave.2 - speakerOn: "M11 4.5 6.5 8.2H3.4v7.6h3.1L11 19.5v-15zm3.2 3a5.3 5.3 0 0 1 0 9 .9.9 0 0 0 .9 1.55 7.1 7.1 0 0 0 0-12.1.9.9 0 0 0-.9 1.55zm2.6-3.1a8.9 8.9 0 0 1 0 15.2.9.9 0 0 0 .92 1.55 10.7 10.7 0 0 0 0-18.3.9.9 0 0 0-.92 1.55z", - // speaker.slash - 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", // chevron.right diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index bf4b191d7..26d3fbc69 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -4,6 +4,7 @@ import { h, svg, clear, dot } from "./dom"; import { ICONS } from "./icons"; +import { fa } from "./fa"; import { Ticker } from "./ticker"; import { State, type AgentTask } from "../core/state"; import { washRGBA, type IslandViewName, type Wash } from "../core/layout"; @@ -78,12 +79,12 @@ function stack(padLeft: number, padRight: number, ...children: Node[]): HTMLElem // ── Header ──────────────────────────────────────────────────────────────────── export function buildHeader(actions: ViewActions): ViewHost { - const tabHome = h("button", { class: "tab", title: "Overview", onclick: () => go("overview") }, svg(ICONS.house, 13)); - const tabChat = h("button", { class: "tab", title: "Ask", onclick: () => go("prompt") }, svg(ICONS.bubble, 13)); - const tabDrop = h("button", { class: "tab", title: "Drop", onclick: () => go("upload") }, svg(ICONS.plus, 13)); + const tabHome = h("button", { class: "tab", title: "Overview", onclick: () => go("overview") }, fa("house", 15)); + const tabChat = h("button", { class: "tab", title: "Ask", onclick: () => go("prompt") }, fa("comment", 15)); + const tabDrop = h("button", { class: "tab", title: "Drop", onclick: () => go("upload") }, fa("plus", 14)); - 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)); + const gearBtn = h("button", { title: "Settings", onclick: () => go("settings") }, fa("gear", 16)); + const soundBtn = h("button", { title: "Mute", onclick: () => actions.toggleSound() }, fa("volumeHigh", 16)); function go(v: IslandViewName) { actions.blip(); @@ -106,9 +107,9 @@ export function buildHeader(actions: ViewActions): ViewHost { tabDrop.classList.toggle("on", v === "upload"); gearBtn.classList.toggle("on", v === "settings"); clear(gearBtn); - gearBtn.append(svg(v === "settings" ? ICONS.gearFill : ICONS.gear, 14)); + gearBtn.append(fa("gear", 16)); clear(soundBtn); - soundBtn.append(svg(State.settings.soundEnabled ? ICONS.speakerOn : ICONS.speakerOff, 14)); + soundBtn.append(fa(State.settings.soundEnabled ? "volumeHigh" : "volumeXmark", 16)); el.style.opacity = v === "confused" ? "0" : "1"; }, }; From 8e2d3e153be33f086330f48a674cc3cc9f092231 Mon Sep 17 00:00:00 2001 From: Justin Minkmar Date: Sat, 3 Oct 2026 00:17:09 +0200 Subject: [PATCH 08/11] Add a code view: what Claude edits and runs, in a small editor Clicking the overview's left card opens an editor view while the session has edited a file or run a command: Mochi and the Read / Edit / Bash / Done phases on the left, on the right the changed lines in red and green with a few lines of context, and under them the last command and what it printed. Nothing opens by itself. - snippet.rs reads the lines around an edit, only from a regular file inside the session's folder, up to 2 MB. - The relay forwards the last three lines a Bash command printed, without colour codes; the rest of tool_response stays out. --- windows/README.md | 4 + windows/hook/src/main.rs | 73 +++++++ windows/src-tauri/src/lib.rs | 13 ++ windows/src-tauri/src/snippet.rs | 80 ++++++++ windows/src/core/bridge.ts | 14 ++ windows/src/core/layout.ts | 3 + windows/src/core/state.ts | 3 + windows/src/island/hooks.ts | 25 +++ windows/src/style.css | 316 +++++++++++++++++++++++++++++ windows/src/views/session.ts | 327 +++++++++++++++++++++++++++++++ windows/src/views/views.ts | 13 +- 11 files changed, 869 insertions(+), 2 deletions(-) create mode 100644 windows/src-tauri/src/snippet.rs create mode 100644 windows/src/views/session.ts diff --git a/windows/README.md b/windows/README.md index 18f015217..c94acfc13 100644 --- a/windows/README.md +++ b/windows/README.md @@ -190,6 +190,10 @@ What changes on Linux: - **Keys** live in the Secret Service (GNOME Keyring, KWallet). - **Files**: preferences in `~/.config/coucou/`, the log at `~/.local/share/coucou/coucou.log`. +- **Code view**: while a session has edited a file or run a command, a click on the + overview's left card opens a small editor (the changed lines in red and green with + a few lines of context from the file, then the last command and what it printed); + the button in its corner goes back. Nothing opens by itself. - **Open terminal** jumps to the Konsole tab the session runs in: the relay passes Konsole's D-Bus names, the app switches the tab over D-Bus and asks KWin (a short script, via `gdbus`) to raise that window. In any other terminal it opens diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs index 385fdcf08..8d34612f8 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -33,6 +33,9 @@ const DROPPED_FIELDS: &[&str] = &["tool_response", "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; +/// How much of what a command printed goes on: its last lines, each cut short. +const TAIL_LINES: usize = 3; +const TAIL_WIDTH: usize = 160; #[cfg(windows)] mod win; @@ -128,6 +131,17 @@ fn read_event() -> Option<(String, String)> { .unwrap_or(arg_event); map.insert("hook_event_name".into(), serde_json::Value::String(event.clone())); + // What a finished command printed is dropped with the rest of `tool_response`, + // except its last lines: the island shows them under the command, like a + // terminal pane. Never the whole output — it may hold anything. + if event == "PostToolUse" + && matches!(map.get("tool_name").and_then(|v| v.as_str()), Some("Bash" | "PowerShell")) + { + if let Some(lines) = map.get("tool_response").and_then(output_tail) { + map.insert("tool_tail".into(), serde_json::json!(lines)); + } + } + for field in DROPPED_FIELDS { map.remove(*field); } @@ -172,6 +186,49 @@ fn read_event() -> Option<(String, String)> { Some((line, event)) } +/// The last few non-empty lines a command printed (stdout, else stderr), without +/// colour codes and cut to a width the island can show. +fn output_tail(response: &serde_json::Value) -> Option> { + let text = match response { + serde_json::Value::String(s) => s.as_str(), + serde_json::Value::Object(o) => ["stdout", "stderr"] + .iter() + .filter_map(|k| o.get(*k)?.as_str()) + .find(|s| !s.trim().is_empty())?, + _ => return None, + }; + let mut lines: Vec = text + .lines() + .map(|l| strip_ansi(l).trim_end().to_string()) + .filter(|l| !l.trim().is_empty()) + .rev() + .take(TAIL_LINES) + .map(|l| l.chars().take(TAIL_WIDTH).collect()) + .collect(); + lines.reverse(); + (!lines.is_empty()).then_some(lines) +} + +/// Drops `ESC [ … letter` colour and cursor sequences. +fn strip_ansi(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' && chars.peek() == Some(&'[') { + chars.next(); + while let Some(&n) = chars.peek() { + chars.next(); + if n.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out +} + /// Caps every string in the payload. A single Write can carry a whole file. fn truncate_strings(value: &mut serde_json::Value) { match value { @@ -249,6 +306,22 @@ mod tests { assert!(decision_json(r#"{"permissionDecision":"allow"}"#).is_none()); } + #[test] + fn only_the_last_lines_of_a_command_survive_and_without_colours() { + let out = serde_json::json!({ "stdout": "a\n\n\u{1b}[32mPASS\u{1b}[0m tests/x.ts\n ✓ works (3 ms)\nTests: 1 passed\n" }); + assert_eq!( + output_tail(&out).unwrap(), + vec!["PASS tests/x.ts", " ✓ works (3 ms)", "Tests: 1 passed"] + ); + // stderr is the fallback; nothing printed means nothing to show. + assert_eq!( + output_tail(&serde_json::json!({ "stdout": "", "stderr": "boom" })).unwrap(), + vec!["boom"] + ); + assert!(output_tail(&serde_json::json!({ "stdout": " \n" })).is_none()); + assert!(output_tail(&serde_json::json!(42)).is_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/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index ad7660db1..f6574cf1f 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ mod pipe; mod platform; mod secrets; mod settings; +mod snippet; mod tray; use std::process::Command; @@ -139,6 +140,17 @@ fn open_url(url: String) { platform::open_url(&url); } +/// The lines around an edit, for the session view (see snippet.rs). +#[tauri::command] +fn file_snippet( + cwd: String, + path: String, + find: String, + context: usize, +) -> Option { + snippet::around(&cwd, &path, &find, context.min(6)) +} + /// Brings the Konsole tab a session runs in to the front (Linux). The three /// values come out of the hook's environment; `platform` checks their shape. #[tauri::command] @@ -420,6 +432,7 @@ pub fn run() { open_url, open_in_vscode, focus_terminal, + file_snippet, quit_app, hooks_status, hooks_preview, diff --git a/windows/src-tauri/src/snippet.rs b/windows/src-tauri/src/snippet.rs new file mode 100644 index 000000000..8901790d1 --- /dev/null +++ b/windows/src-tauri/src/snippet.rs @@ -0,0 +1,80 @@ +// The lines around an edit, for the session view's editor pane. +// +// The hook only carries the edited text itself. To show it in place — line +// numbers and a few lines of context, like an editor — the island asks for the +// file's own lines. Only a regular file inside the session's folder, up to 2 MB, +// is ever read: the path arrives in a hook payload and is not trusted. + +use std::path::Path; + +use serde::Serialize; + +const MAX_FILE: u64 = 2 * 1024 * 1024; +const MAX_LINE: usize = 200; + +#[derive(Serialize, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Snippet { + /// Line number (1-based) of `lines[0]`. + pub start: usize, + pub lines: Vec, + /// Where the edited block begins in `lines`, and how many lines it spans. + pub at: usize, + pub len: usize, +} + +/// `find` is the text the edit wrote; `context` lines are kept on each side. +pub fn around(cwd: &str, path: &str, find: &str, context: usize) -> Option { + if find.is_empty() { + return None; + } + let root = Path::new(cwd).canonicalize().ok()?; + let file = Path::new(path).canonicalize().ok()?; + let meta = std::fs::metadata(&file).ok()?; + if !file.starts_with(&root) || !meta.is_file() || meta.len() > MAX_FILE { + return None; + } + let text = std::fs::read_to_string(&file).ok()?; + let first = text[..text.find(find)?].matches('\n').count(); + let len = find.trim_end_matches('\n').matches('\n').count() + 1; + + let all: Vec<&str> = text.lines().collect(); + let from = first.saturating_sub(context); + let to = (first + len + context).min(all.len()); + Some(Snippet { + start: from + 1, + lines: all[from..to].iter().map(|l| l.chars().take(MAX_LINE).collect()).collect(), + at: first - from, + len, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_the_block_with_context_and_refuses_everything_else() { + let dir = std::env::temp_dir().join(format!("coucou-snippet-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("a.ts"); + std::fs::write(&file, "l1\nl2\nl3\nconst X = 2\nreturn X\nl6\nl7\nl8\n").unwrap(); + let (cwd, path) = (dir.to_str().unwrap(), file.to_str().unwrap()); + + let s = around(cwd, path, "const X = 2\nreturn X", 2).unwrap(); + assert_eq!((s.start, s.at, s.len), (2, 2, 2)); + assert_eq!(s.lines, ["l2", "l3", "const X = 2", "return X", "l6", "l7"]); + + assert!(around(cwd, path, "not in the file", 2).is_none()); + assert!(around(cwd, path, "", 2).is_none()); + // Outside the session folder: never read. + assert!(around(dir.join("sub").to_str().unwrap(), path, "l1", 2).is_none()); + let other = std::env::temp_dir().join("coucou-snippet-other.txt"); + std::fs::write(&other, "secret").unwrap(); + assert!(around(cwd, other.to_str().unwrap(), "secret", 2).is_none()); + + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_file(other); + } +} diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index fe2810aba..ccf20a50e 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -58,6 +58,10 @@ export const Bridge = { /** "Open terminal" → opens the folder in VS Code when `code` is on PATH. */ openInVSCode: (path: string | null) => call<"ok" | "no-editor" | "bad-path">("open_in_vscode", { path }), + /** The lines around an edit in the file itself (null: unreadable, outside the session folder, not found). */ + fileSnippet: (cwd: string, path: string, find: string, context: number) => + call("file_snippet", { cwd, path, find, context }), + /** Brings the terminal tab the session runs in to the front. False: not possible here. */ focusTerminal: (t: TerminalRef) => call("focus_terminal", { ...t }), @@ -124,6 +128,16 @@ export interface DroppedFile { size: number; } +/** The lines around an edit, read from the file itself (see snippet.rs). */ +export interface Snippet { + /** Line number (1-based) of `lines[0]`. */ + start: number; + lines: string[]; + /** Where the edited block begins in `lines`, and how many lines it spans. */ + at: number; + len: number; +} + export interface HookStatus { installed: boolean; settingsPath: string; diff --git a/windows/src/core/layout.ts b/windows/src/core/layout.ts index 6f70eea4c..6e6db15b4 100644 --- a/windows/src/core/layout.ts +++ b/windows/src/core/layout.ts @@ -6,6 +6,7 @@ export type IslandMode = "hidden" | "compact" | "expanded"; export type IslandViewName = | "overview" + | "session" | "empty" | "approval" | "question" @@ -68,6 +69,8 @@ export const WAKE_STRIP_H = 6; export const VIEW_LAYOUTS: Record = { overview: { height: 160, botX: 68, botY: null, botDiameter: 58, agentMode: "pills" }, + // The editor view: Mochi top-left over the phases, the code on the right. + session: { height: 320, botX: 65, botY: 103, botDiameter: 62, agentMode: "none" }, empty: { height: 160, botX: 70, botY: null, botDiameter: 62, agentMode: "none" }, approval: { height: 160, botX: 62, botY: null, botDiameter: 56, agentMode: "column" }, question: { height: 160, botX: 62, botY: null, botDiameter: 56, agentMode: "column" }, diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index e4699ecb9..dbdc87fd8 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 { SessionData } from "../views/session"; 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; + /** What the session view shows: the last edit, the last command, the phases. */ + session?: SessionData | null; /** Where the session's terminal lives, when we know how to jump to it. */ terminal?: TerminalRef | null; } diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index aeec36d7c..1dc40064c 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -8,6 +8,7 @@ import { localized } from "../core/i18n"; import { Sound } from "../core/sound"; import { State } from "../core/state"; import type { Island } from "./island"; +import { beginTurn, endTurn, toolFinished, toolStarted } from "../views/session"; const CLAUDE_ID = "integration_claude"; @@ -30,6 +31,10 @@ interface HookPayload { konsole_service?: string; konsole_session?: string; konsole_window?: string; + /** The last lines a Bash command printed (the relay keeps nothing else of its output). */ + tool_tail?: string[]; + /** PostToolUseFailure: why the tool failed. */ + error?: string; } /** Same rule as HookServer.validateAgent on macOS. "claude" is reserved. */ @@ -141,6 +146,7 @@ function clearSession() { t.stepIndex = 0; t.name = "VS Code"; t.pillBadge = null; + t.session = null; } export function registerHookHandlers(island: Island) { @@ -210,6 +216,11 @@ function handleHook(island: Island, payload: HookPayload) { // The field is `prompt`; reading `message` meant this step was always blank. const asked = payload.prompt ?? payload.message; if (asked) State.appendStep(agentId, asked.slice(0, 60)); + if (!isExternalAgent) { + beginTurn(); + // The new turn has shown nothing yet, so an editor view would be empty. + if (State.view === "session") island.setView(State.defaultView()); + } surface("overview", false); break; } @@ -219,17 +230,25 @@ function handleHook(island: Island, payload: HookPayload) { State.updateTask(agentId, "working"); const tool = payload.tool_name ?? "Tool"; State.appendStep(agentId, stepLabel(tool, payload.tool_input ?? {})); + // What the editor view shows is kept up to date; opening it is the user's click. + if (!isExternalAgent) toolStarted(tool, payload.tool_input ?? {}, cwd); surface("overview", false); break; } case "PostToolUse": State.updateTask(agentId, "working"); + if (!isExternalAgent) { + toolFinished(payload.tool_name ?? "", payload.tool_input ?? {}, cwd, { tail: payload.tool_tail }); + } break; case "PostToolUseFailure": State.updateTask(agentId, "working"); State.appendStep(agentId, "⚠ failed"); + if (!isExternalAgent) { + toolFinished(payload.tool_name ?? "", payload.tool_input ?? {}, cwd, { failed: true, error: payload.error }); + } break; case "Notification": { @@ -246,6 +265,7 @@ function handleHook(island: Island, payload: HookPayload) { } case "Stop": + if (!isExternalAgent) endTurn(); State.updateTask(agentId, "finished"); if (payload.message) State.appendStep(agentId, payload.message.slice(0, 60)); Sound.play("finish"); @@ -257,6 +277,10 @@ function handleHook(island: Island, payload: HookPayload) { } else { State.updateTask(agentId, "idle"); State.setPillBadge(agentId, null); + // The finished session is shown for a moment, then the view lets go. + const claude = State.claudeTask; + if (claude) claude.session = null; + if (State.view === "session") island.setView(State.defaultView()); } }, 5200); break; @@ -274,6 +298,7 @@ function handleHook(island: Island, payload: HookPayload) { } else { State.updateTask(agentId, "idle"); clearSession(); + if (State.view === "session") island.setView(State.defaultView()); } break; diff --git a/windows/src/style.css b/windows/src/style.css index 9e18600ef..8430093b3 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -1384,3 +1384,319 @@ input[type="range"]::-webkit-slider-thumb { .int-card.detail { padding-top: 8px; } + +/* ── Session view: a small editor next to Mochi and the turn's phases ──────── */ +/* Proportions follow docs/media/claude-code.png (island 640 × 320). */ + +.sess { + position: relative; + z-index: 1; + display: flex; + gap: 10px; + height: 100%; + padding: 10px 11px 8px 10px; +} + +.sess-left { + flex: 0 0 112px; + min-width: 0; + /* Mochi, on its own canvas, fills the top of this column. */ + padding: 112px 0 0 8px; +} + +.sess-name { + display: flex; + align-items: center; + gap: 6px; + font: 700 14px var(--font); + color: var(--ink); +} + +.sess-name span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sess-tool { + display: flex; + align-items: center; + justify-content: space-between; + margin: 1px 0 14px; + font: 400 12.5px var(--font); + color: var(--dim-2); +} + +.sess-back { + display: grid; + place-items: center; + width: 20px; + height: 20px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--dim-4); + cursor: default; +} + +.sess-back:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--ink); +} + +.phases { + display: flex; + flex-direction: column; + gap: 8px; +} + +.phase { + display: flex; + align-items: center; + gap: 8px; + font: 600 13.5px/16px var(--font); + color: var(--dim-4); +} + +.ph-icon { + flex: 0 0 16px; + width: 16px; + height: 16px; + display: grid; + place-items: center; + box-sizing: border-box; +} + +.ph-icon.ok { + color: var(--green-2); +} + +/* The running phase: Font Awesome's ring with a gap, turning. */ +.ph-icon.spin { + color: var(--ink); + animation: ph-spin 0.9s linear infinite; +} + +/* A phase that has not started: its glyph in a rounded square. */ +.ph-icon.todo { + border-radius: 4.5px; + color: var(--dim-4); + box-shadow: inset 0 0 0 1.5px var(--dim-4); +} + +/* "Done" before the end: the same disc, muted. */ +.ph-icon.done-todo { + color: var(--dim-4); +} + +.phase.done { + color: var(--dim-2); +} + +.phase.active { + color: var(--ink); + font-weight: 700; +} + +@keyframes ph-spin { + to { transform: rotate(360deg); } +} + +.editor { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + border-radius: 12px; + background: #0b0c0e; + border: 1px solid var(--hairline); + overflow: hidden; + font: 400 10px/17px var(--mono); +} + +.ed-tab { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + margin: 8px 10px 4px 8px; + color: var(--ink-2); + font: 500 10.5px var(--mono); +} + +/* The file tab: one pill around the language chip, the name and the unsaved dot. */ +.ed-file { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 9px 3px 6px; + border-radius: 8px; + background: #15161a; +} + +.ed-chip { + padding: 0 4px; + border-radius: 3px; + font: 700 8.5px/13px var(--font); +} + +.ed-dot { + font-size: 7px; + color: var(--amber); +} + +.ed-dir { + margin-left: auto; + min-width: 0; + color: var(--dim-4); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + direction: rtl; /* a long path keeps its end, the useful part */ +} + +.ed-code { + flex: 0 1 auto; + min-height: 0; + overflow: hidden; + padding-top: 4px; +} + +.ed-row { + display: flex; + white-space: pre; + overflow: hidden; + border-left: 2px solid transparent; +} + +.ed-n { + flex: 0 0 34px; + padding-right: 8px; + text-align: right; + color: var(--dim-4); +} + +.ed-sign { + flex: 0 0 14px; + text-align: center; + color: var(--dim-4); +} + +.ed-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: #c4cad4; +} + +.ed-row.add { + background: rgba(52, 211, 153, 0.13); + border-left-color: var(--green-2); +} + +.ed-row.add .ed-sign, +.ed-row.add .ed-n { + color: var(--green-2); +} + +.ed-row.del { + background: rgba(244, 80, 94, 0.15); + border-left-color: var(--red); +} + +.ed-row.del .ed-sign, +.ed-row.del .ed-n { + color: var(--red-text); +} + +.ed-row.del .ed-text { + color: var(--dim-3); + text-decoration: line-through; + text-decoration-color: rgba(244, 80, 94, 0.5); +} + +.term { + flex: 0 0 auto; + padding: 8px 12px 9px; +} + +.term-line { + display: flex; + align-items: center; + gap: 8px; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; + color: var(--dim-2); +} + +.term-line.good { color: var(--green-2); } +.term-line.bad { color: var(--red-text); } + +.term-line .prompt { + color: var(--cyan); +} + +.term-cmd { + color: var(--ink-2); + overflow: hidden; + text-overflow: ellipsis; +} + +.term-run { + color: var(--dim-3); +} + +.term-line .dim { + color: var(--dim-2); +} + +.badge { + padding: 0 5px; + border-radius: 3px; + font: 700 9px/14px var(--font); + letter-spacing: 0.03em; +} + +.badge.pass { + background: var(--green-2); + color: #052e1c; +} + +.badge.fail { + background: var(--red); + color: #2c0509; +} + +.tok-k { color: #c3a6ff; } +.tok-s { color: #d3dd8e; } +.tok-n { color: #f4a46a; } +.tok-t { color: #f2c070; } +.tok-f { color: #f2c070; } +.tok-c { color: var(--dim-4); font-style: italic; } + +/* The overview's left card opens the code view when there is code to show. */ +.expand-hint { + position: absolute; + right: 12px; + bottom: 10px; + z-index: 2; + display: none; + color: var(--dim-4); +} + +.card.expandable { + cursor: pointer; +} + +.card.expandable:hover { + border-color: rgba(255, 255, 255, 0.1); +} + +.card.expandable .expand-hint { + display: block; +} + +.card.expandable:hover .expand-hint { + color: var(--dim-2); +} diff --git a/windows/src/views/session.ts b/windows/src/views/session.ts new file mode 100644 index 000000000..b14a4d469 --- /dev/null +++ b/windows/src/views/session.ts @@ -0,0 +1,327 @@ +// The session view: while Claude Code works, the island becomes a small editor — +// the file it just changed (a few lines of context, the removed lines in red, the +// added ones in green) and under it the last command with what it printed — next +// to Mochi and the phases of the turn. +// +// Everything comes from the hooks. `tool_input` carries the edit itself; the +// surrounding lines are read from the file by Rust (snippet.rs), the last lines a +// command printed arrive in `tool_tail`. All text goes in as text nodes: a file's +// content is not ours to trust as HTML. + +import { Bridge, type Snippet } from "../core/bridge"; +import { localized } from "../core/i18n"; +import { State } from "../core/state"; +import { h, clear, dot } from "./dom"; +import { fa } from "./fa"; +import type { ViewActions, ViewHost } from "./views"; + +export interface EditShown { + kind: "edit" | "write"; + /** Relative to the session's folder when it lies inside it. */ + file: string; + removed: string; + added: string; + snippet?: Snippet | null; +} + +export interface CommandShown { + command: string; + tail: string[]; + status: "running" | "ok" | "failed"; +} + +export type Phase = "read" | "edit" | "bash"; + +export interface SessionData { + edit?: EditShown; + command?: CommandShown; + /** Which of Read / Edit / Bash this turn has used, and which it is in now. */ + seen: Phase[]; + current: Phase | null; + finished: boolean; +} + +const str = (v: unknown): string => (typeof v === "string" ? v : ""); +const fresh = (): SessionData => ({ seen: [], current: null, finished: false }); + +const PHASE_OF: Record = { + Read: "read", Glob: "read", Grep: "read", LS: "read", WebFetch: "read", WebSearch: "read", + Edit: "edit", MultiEdit: "edit", Write: "edit", NotebookEdit: "edit", + Bash: "bash", PowerShell: "bash", +}; + +/** A new prompt starts a new turn: the old turn's code and phases go. */ +export function beginTurn() { + const t = State.claudeTask; + if (t) t.session = fresh(); +} + +export function endTurn() { + const s = State.claudeTask?.session; + if (s) s.finished = true; +} + +/** True once the turn has shown something worth a view of its own. */ +export function hasSession(): boolean { + const s = State.claudeTask?.session; + return !!s && !!(s.edit || s.command); +} + +/** A tool is about to run: note it, and what the session view will show of it. */ +export function toolStarted(tool: string, input: Record, cwd: string) { + const t = State.claudeTask; + if (!t) return; + const s = (t.session ??= fresh()); + s.finished = false; + const phase = PHASE_OF[tool]; + if (phase) { + s.current = phase; + if (!s.seen.includes(phase)) s.seen.push(phase); + } + + const abs = str(input.file_path); + const file = cwd && abs.startsWith(cwd + "/") ? abs.slice(cwd.length + 1) : abs; + switch (tool) { + case "Edit": + s.edit = { kind: "edit", file, removed: str(input.old_string), added: str(input.new_string) }; + break; + case "MultiEdit": { + const edits = Array.isArray(input.edits) ? (input.edits as Record[]) : []; + // The first edit stands for the call: the pane has room for a handful of lines. + s.edit = { kind: "edit", file, removed: str(edits[0]?.old_string), added: str(edits[0]?.new_string) }; + break; + } + case "Write": + s.edit = { kind: "write", file, removed: "", added: str(input.content) }; + break; + case "Bash": + case "PowerShell": + if (str(input.command)) s.command = { command: str(input.command), tail: [], status: "running" }; + break; + } +} + +/** A tool finished (or failed): fill in what only exists afterwards. */ +export function toolFinished( + tool: string, + input: Record, + cwd: string, + extra: { tail?: string[]; failed?: boolean; error?: string }, +) { + const s = State.claudeTask?.session; + if (!s) return; + if ((tool === "Bash" || tool === "PowerShell") && s.command) { + s.command.status = extra.failed ? "failed" : "ok"; + s.command.tail = extra.tail ?? (extra.error ? [extra.error.split("\n")[0].slice(0, 160)] : []); + State.notify(); + } else if (tool === "Edit" && s.edit?.kind === "edit" && !extra.failed && s.edit.added) { + const edit = s.edit; + void Bridge.fileSnippet(cwd, str(input.file_path), edit.added, 3).then((snip) => { + // Only if it is still the edit this answer belongs to. + if (snip && State.claudeTask?.session?.edit === edit) { + edit.snippet = snip; + State.notify(); + } + }); + } +} + +// ── Rendering ─────────────────────────────────────────────────────────────── + +const TOKEN = + /(\/\/.*|^\s*#.*)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`[^`]*`)|\b(\d+(?:\.\d+)?)\b|\b(const|let|var|function|return|import|export|from|if|else|for|while|class|new|async|await|def|fn|pub|use|type|interface|struct|impl|enum|match|in|of|true|false|null|None)\b|\b([A-Z][A-Za-z0-9_]*)\b|\b([a-z_][A-Za-z0-9_]*)(?=\()/g; + +/** One line of code as coloured spans (comment, string, number, keyword). */ +function highlight(line: string): Node[] { + const out: Node[] = []; + let last = 0; + for (const m of line.matchAll(TOKEN)) { + const at = m.index ?? 0; + if (at > last) out.push(document.createTextNode(line.slice(last, at))); + const cls = m[1] != null ? "c" : m[2] != null ? "s" : m[3] != null ? "n" : m[4] != null ? "k" : m[5] != null ? "t" : "f"; + out.push(h("span", { class: `tok-${cls}`, text: m[0] })); + last = at + m[0].length; + } + if (last < line.length) out.push(document.createTextNode(line.slice(last))); + return out; +} + +interface Row { + kind: "ctx" | "add" | "del"; + n: number | null; + text: string; +} + +const lines = (text: string): string[] => text.replace(/\n$/, "").split("\n"); + +/** The rows of the editor pane for one edit, in file order. */ +function codeRows(e: EditShown): Row[] { + const removed = e.removed ? lines(e.removed) : []; + const added = e.added ? lines(e.added) : []; + const snip = e.snippet; + if (e.kind === "write" || !snip) { + const from = e.kind === "write" ? 1 : null; + return [ + ...removed.map((text) => ({ kind: "del" as const, n: null, text })), + ...added.map((text, i) => ({ kind: "add" as const, n: from == null ? null : from + i, text })), + ]; + } + const rows: Row[] = []; + snip.lines.forEach((text, i) => { + if (i === snip.at) { + removed.forEach((r, k) => rows.push({ kind: "del", n: snip.start + snip.at + k, text: r })); + } + const inBlock = i >= snip.at && i < snip.at + snip.len; + rows.push({ kind: inBlock ? "add" : "ctx", n: snip.start + i, text }); + }); + return rows; +} + +/** Keeps the changed rows and as much context as fits, dropping the far context first. */ +function fit(rows: Row[], max: number): Row[] { + let from = 0; + let to = rows.length; + while (to - from > max) { + if (rows[from].kind === "ctx") from++; + else if (rows[to - 1].kind === "ctx") to--; + else to--; // only changed rows left: keep the start of the change + } + return rows.slice(from, to); +} + +const CHIPS: Record = { + ts: ["TS", "#3b82f6"], tsx: ["TS", "#3b82f6"], js: ["JS", "#eab308"], jsx: ["JS", "#eab308"], + py: ["PY", "#38bdf8"], rs: ["RS", "#f97316"], go: ["GO", "#22d3ee"], md: ["MD", "#9ca3af"], + json: ["{}", "#a3a3a3"], css: ["CS", "#a78bfa"], html: ["<>", "#fb923c"], sh: ["SH", "#86efac"], + yml: ["YM", "#f472b6"], yaml: ["YM", "#f472b6"], toml: ["TM", "#fbbf24"], +}; + +function tab(e: EditShown | undefined): HTMLElement { + if (!e) return h("div", { class: "ed-tab" }, h("span", { class: "ed-name", text: "terminal" })); + const name = e.file.split("/").pop() || e.file; + const dir = e.file; + const [label, color] = CHIPS[(name.split(".").pop() ?? "").toLowerCase()] ?? ["·", "#6b7079"]; + const chip = h("span", { class: "ed-chip", text: label }); + chip.style.background = `${color}2e`; + chip.style.color = color; + return h( + "div", + { class: "ed-tab" }, + h("span", { class: "ed-file" }, chip, h("span", { class: "ed-name", text: name }), h("span", { class: "ed-dot", text: "●" })), + h("span", { class: "ed-dir", text: dir }), + ); +} + +function codeRow(r: Row): HTMLElement { + return h( + "div", + { class: `ed-row ${r.kind}` }, + h("span", { class: "ed-n", text: r.n == null ? "" : String(r.n) }), + h("span", { class: "ed-sign", text: r.kind === "add" ? "+" : r.kind === "del" ? "−" : "" }), + h("span", { class: "ed-text" }, ...highlight(r.text)), + ); +} + +/** A line a test runner printed: PASS / FAIL as a badge, ticks and crosses coloured. */ +function tailLine(text: string): HTMLElement { + const m = /^\s*(PASS|FAIL)\b\s*(.*)$/.exec(text); + if (m) { + return h( + "div", + { class: "term-line" }, + h("span", { class: `badge ${m[1] === "PASS" ? "pass" : "fail"}`, text: m[1] }), + h("span", { class: "dim", text: m[2] }), + ); + } + const tone = /[✓✔]|\bpassed\b|\bok\b/i.test(text) ? "good" : /[✗✘×]|\bfail|\berror\b/i.test(text) ? "bad" : "dim"; + return h("div", { class: `term-line ${tone}`, text: text.trim() }); +} + +function terminal(c: CommandShown): HTMLElement { + const box = h("div", { class: "term" }, h( + "div", + { class: "term-line cmd" }, + h("span", { class: "prompt", text: "$" }), + h("span", { class: "term-cmd", text: c.command.replace(/\s+/g, " ") }), + c.status === "running" ? h("span", { class: "term-run", text: "…" }) : null, + )); + const tail = c.tail.slice(-2); + for (const l of tail) box.append(tailLine(l)); + if (c.status === "failed" && tail.length === 0) box.append(h("div", { class: "term-line bad", text: "✗ failed" })); + return box; +} + +const DONE = { en: "Done", de: "Fertig", fr: "Terminé" }; + +const PHASES: { id: Phase; label: string }[] = [ + { id: "read", label: "Read" }, + { id: "edit", label: "Edit" }, + { id: "bash", label: "Bash" }, +]; + +const GLYPHS: Record = { read: "fileLines", edit: "pen", bash: "terminal" }; + +function phaseIcon(state: "done" | "active" | "todo", phase: Phase): HTMLElement { + if (state === "done") return h("span", { class: "ph-icon ok" }, fa("circleCheck", 16)); + if (state === "active") return h("span", { class: "ph-icon spin" }, fa("circleNotch", 16)); + return h("span", { class: "ph-icon todo" }, fa(GLYPHS[phase], 9)); +} + +function phaseList(s: SessionData): HTMLElement { + const doneLabel = localized(DONE); + const list = h("div", { class: "phases" }); + for (const { id, label } of PHASES) { + const active = !s.finished && s.current === id; + const state = active ? "active" : s.seen.includes(id) ? "done" : "todo"; + list.append(h( + "div", + { class: `phase ${state}` }, + phaseIcon(state, id), + h("span", { text: label }), + )); + } + list.append(h( + "div", + { class: `phase ${s.finished ? "done" : "todo"}` }, + h("span", { class: s.finished ? "ph-icon ok" : "ph-icon done-todo" }, fa("circleCheck", 16)), + h("span", { text: doneLabel }), + )); + return list; +} + +const CODE_ROWS = 11; + +export function buildSession(actions: ViewActions): ViewHost { + const left = h("div", { class: "sess-left" }); + const editor = h("div", { class: "editor" }); + const el = h("div", { class: "view" }, h("div", { class: "card" }, h("div", { class: "sess" }, left, editor))); + let key = ""; + + return { + el, + sync() { + const task = State.claudeTask; + const s = task?.session; + if (!task || !s) return; + const next = JSON.stringify([task.name, s]); + if (next === key) return; + key = next; + + clear(left); + left.append( + h("div", { class: "sess-name" }, dot(task.color, 7), h("span", { text: task.name })), + h("div", { class: "sess-tool" }, + h("span", { text: "Claude Code" }), + h("button", { class: "sess-back", title: "Back", onclick: () => actions.setView("overview") }, fa("compress", 11)), + ), + phaseList(s), + ); + + clear(editor); + editor.append(tab(s.edit)); + const rows = s.edit ? fit(codeRows(s.edit), s.command ? CODE_ROWS - 3 : CODE_ROWS) : []; + if (rows.length) editor.append(h("div", { class: "ed-code" }, ...rows.map(codeRow))); + if (s.command) editor.append(terminal(s.command)); + }, + }; +} diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index 26d3fbc69..11d45f0b7 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -10,6 +10,7 @@ import { State, type AgentTask } from "../core/state"; import { washRGBA, type IslandViewName, type Wash } from "../core/layout"; import { createMiniBot, pruneMiniBots } from "../mochi/minibots"; import { buildPrompt } from "./chat"; +import { buildSession, hasSession } from "./session"; import { buildChoose, buildUpload, buildUploading } from "./upload"; import { renderIntegrationCard, type IntegrationCardHooks } from "./integrations"; @@ -102,7 +103,7 @@ export function buildHeader(actions: ViewActions): ViewHost { el, sync() { const v = State.view; - tabHome.classList.toggle("on", v === "overview" || v === "empty"); + tabHome.classList.toggle("on", v === "overview" || v === "empty" || v === "session"); tabChat.classList.toggle("on", v === "prompt"); tabDrop.classList.toggle("on", v === "upload"); gearBtn.classList.toggle("on", v === "settings"); @@ -127,7 +128,13 @@ function buildOverview(actions: ViewActions): ViewHost { { class: "icon-btn jump", title: "Open", onclick: () => actions.openTarget() }, svg(ICONS.arrowUpRight, 8), ); - const left = card(null, leftBody, jump); + // While the session has edited a file or run a command, the left card opens + // the code view. Just a click: nothing changes by itself. + const expandHint = h("span", { class: "expand-hint", title: "Show the code" }, fa("expand", 10)); + const left = card(null, leftBody, jump, expandHint); + left.addEventListener("click", (e) => { + if (mode === "ticker" && hasSession() && !(e.target as HTMLElement).closest(".jump")) actions.setView("session"); + }); const pills = h("div", { class: "pills" }); const right = card(null, pills); @@ -214,6 +221,7 @@ function buildOverview(actions: ViewActions): ViewHost { } jump.style.display = detailOpen ? "none" : ""; + left.classList.toggle("expandable", mode === "ticker" && hasSession()); const others = State.otherTasks.slice(0, 4); const pillKey = others.map((t) => `${t.id}:${t.pillBadge ?? ""}`).join("|"); @@ -498,6 +506,7 @@ export function buildViews( ): Map { const map = new Map(); map.set("overview", buildOverview(actions)); + map.set("session", buildSession(actions)); map.set("empty", buildEmpty(actions)); map.set("approval", buildApproval(actions)); map.set("question", buildQuestion()); From 33ccaf7e801c3ef60379063844a28f1924678c52 Mon Sep 17 00:00:00 2001 From: Justin Minkmar Date: Sat, 3 Oct 2026 00:17:09 +0200 Subject: [PATCH 09/11] Ease the halo behind Mochi instead of transitioning a gradient CSS cannot transition between two gradients, so the halo's colour flipped in one step. Ease colour and opacity per frame, like Mochi's position. --- windows/src/island/island.ts | 42 ++++++++++++++++++++++++++++++------ windows/src/style.css | 2 +- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 044706de1..4fae98973 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -25,6 +25,9 @@ const BOT_OVERHANG = 40; /** Same margin as the Rust hit test (src-tauri/src/island.rs). */ const HIT_MARGIN = 14; +/** Time constant of the halo's colour and opacity easing, in seconds (~0.5 s to settle). */ +const GLOW_EASE_S = 0.16; + /** The three views the drop sequence owns; leaving them stops the engine. */ const UPLOAD_VIEWS: ReadonlySet = new Set(["upload", "uploading", "choose"]); @@ -43,6 +46,10 @@ export class Island { private viewsEl!: HTMLElement; private botCanvas!: HTMLCanvasElement; private botGlow!: HTMLElement; + /** The halo's colour (0–1 per channel) and opacity, eased toward the state's. */ + private glowRGB: [number, number, number] = [1, 1, 1]; + private glowAlpha = 0; + private glowSettled = true; private greetingCanvas!: HTMLCanvasElement; private miniGrid!: HTMLElement; private countdown!: HTMLElement; @@ -698,7 +705,7 @@ export class Island { this.syncDom(); } - this.updateBotTargets(); + this.updateBotTargets(dt); this.botCx.step(dt); this.botCy.step(dt); this.botSize.step(dt); @@ -739,7 +746,7 @@ export class Island { const busy = State.mode === "hidden" ? settling : settling || - !this.botCx.settled || !this.botCy.settled || !this.botSize.settled || + !this.botCx.settled || !this.botCy.settled || !this.botSize.settled || !this.glowSettled || greetingActive || this.engine.busy || UploadSeq.isActive; if (busy) { @@ -750,7 +757,7 @@ export class Island { } }; - private updateBotTargets() { + private updateBotTargets(dt: number) { const p = botPosition(State.mode, State.view, this.height.value, State.uploadProgress); this.botCx.target = p.cx; this.botCy.target = p.cy; @@ -763,17 +770,40 @@ export class Island { if (State.mode === "expanded" && State.view !== "uploading" && !greetingActive && !this.uploadActive) { const d = p.diameter; - const color = botGlowColor(State.effectiveState); this.botGlow.style.display = "block"; this.botGlow.style.width = `${d * 2.2}px`; this.botGlow.style.height = `${d * 2.2}px`; this.botGlow.style.left = `${this.botCx.value - d * 1.1}px`; this.botGlow.style.top = `${this.botCy.value - d * 1.1}px`; - this.botGlow.style.background = `radial-gradient(circle, ${color} 0%, transparent 62%)`; - this.botGlow.style.opacity = String(botGlowOpacity(State.effectiveState)); + this.paintGlow(dt); } else { this.botGlow.style.display = "none"; + // Fades in again from nothing the next time it shows. + this.glowAlpha = 0; + this.glowSettled = true; + } + } + + /** + * Eases the halo toward the colour and opacity of Mochi's state, and paints it. + * CSS cannot transition between two gradients (the colour would flip in one + * step), so this happens here, every frame, like Mochi's position does. + */ + private paintGlow(dt: number) { + const target = hexToRGB(botGlowColor(State.effectiveState)); + const alpha = botGlowOpacity(State.effectiveState); + const k = 1 - Math.exp(-dt / GLOW_EASE_S); + // How far from the target; colour channels count for less than opacity. + let off = Math.abs(alpha - this.glowAlpha); + this.glowAlpha += (alpha - this.glowAlpha) * k; + for (let i = 0; i < 3; i++) { + off = Math.max(off, Math.abs(target[i] - this.glowRGB[i]) * 0.4); + this.glowRGB[i] += (target[i] - this.glowRGB[i]) * k; } + this.glowSettled = off < 0.004; + const [r, g, b] = this.glowRGB.map((c) => Math.round(c * 255)); + this.botGlow.style.background = `radial-gradient(circle, rgb(${r},${g},${b}) 0%, transparent 62%)`; + this.botGlow.style.opacity = this.glowAlpha.toFixed(3); } private drawBot(dt: number) { diff --git a/windows/src/style.css b/windows/src/style.css index 8430093b3..b39ea732d 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -157,7 +157,7 @@ body { pointer-events: none; border-radius: 50%; filter: blur(6px); - transition: opacity 0.4s ease-in-out, background 0.4s ease-in-out; + /* No CSS transition: the island eases the colour and the opacity itself. */ } #countdown { From b47038fad966e00549db02c553fe33452d4e11f7 Mon Sep 17 00:00:00 2001 From: Justin Minkmar Date: Sat, 3 Oct 2026 00:31:00 +0200 Subject: [PATCH 10/11] Linux: ignore a stale mouse-move right after the pointer left the island GTK's leave-notify reaches the page over IPC and can overtake the last mouse-move WebKit was still handing it. That move put the mouse "inside" again after the leave, and since nothing follows a pointer that is gone, the island never started its auto-close timer; it stayed open until OK was pressed. Ignore page mouse-moves for 400 ms after a leave. A real re-entry is picked up by the next moves right after. --- windows/src/island/island.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 4fae98973..315cdac68 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -28,6 +28,14 @@ const HIT_MARGIN = 14; /** Time constant of the halo's colour and opacity easing, in seconds (~0.5 s to settle). */ const GLOW_EASE_S = 0.16; +/** + * After the pointer has left, page mouse-moves are ignored for this long. GTK's + * leave reaches the page over IPC and can overtake the last move the page was + * still handed; that stale move would put the mouse "inside" again, and since + * nothing follows it, for good — the island would never auto-close. + */ +const POINTER_LEAVE_GRACE_MS = 400; + /** The three views the drop sequence owns; leaving them stops the engine. */ const UPLOAD_VIEWS: ReadonlySet = new Set(["upload", "uploading", "choose"]); @@ -78,6 +86,8 @@ export class Island { private collapsed = false; private collapseTimer: number | null = null; private wasInIsland = false; + /** When the pointer last left the island window (performance.now()). */ + private pointerLeftAt = -Infinity; /** 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; @@ -576,7 +586,10 @@ export class Island { * reported as a cursor far away, which is what the poll would have said. */ followPageCursor() { - window.addEventListener("mousemove", (e) => this.onCursor(e.clientX, e.clientY)); + window.addEventListener("mousemove", (e) => { + if (performance.now() - this.pointerLeftAt < POINTER_LEAVE_GRACE_MS) return; + this.onCursor(e.clientX, e.clientY); + }); window.addEventListener("mouseout", (e) => { if (e.relatedTarget == null) this.onCursor(-10_000, -10_000); }); @@ -584,6 +597,7 @@ export class Island { /** Cursor in window-logical coordinates. */ onCursor(x: number, y: number) { + if (x <= -9999) this.pointerLeftAt = performance.now(); State.mouse = { x, y }; const rect = this.islandRect(); State.mouseInIsland = { x: x - rect.x, y: y - rect.y }; From d40e63fd7b23eeac4aa89b04318a51e49de14d5e Mon Sep 17 00:00:00 2001 From: Justin Minkmar Date: Sat, 3 Oct 2026 00:52:20 +0200 Subject: [PATCH 11/11] Keep the code view's data per session, so parallel sessions do not mix The view read its data from the one shared "Claude Code" pill. With several sessions running, a new prompt in one of them wiped what the view showed and the project name jumped between them. Each session now has its own record, keyed by session_id, and the view shows the one that was active last and has something to show. A session remembers the folder it started in: its name, the paths in the diff and the folder the file is read from no longer change when the shell cd's. A finished session is dropped after its view has been shown; one that never says so is forgotten after 30 minutes. --- windows/src/core/state.ts | 3 -- windows/src/island/hooks.ts | 26 +++++----- windows/src/views/session.ts | 95 ++++++++++++++++++++++++++---------- 3 files changed, 83 insertions(+), 41 deletions(-) diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index dbdc87fd8..e4699ecb9 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -2,7 +2,6 @@ import type { BotEmoteName, BotStateName, IslandMode, IslandViewName } from "./layout"; import type { EyeShape } from "../mochi/engine"; -import type { SessionData } from "../views/session"; export type AgentSource = "claudeCode" | "n8n" | "agent"; export type PillBadge = "approval" | "finished" | "error"; @@ -20,8 +19,6 @@ export interface AgentTask { miniEye?: EyeShape | null; pillBadge?: PillBadge | null; sessionCwd?: string | null; - /** What the session view shows: the last edit, the last command, the phases. */ - session?: SessionData | null; /** Where the session's terminal lives, when we know how to jump to it. */ terminal?: TerminalRef | null; } diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index 1dc40064c..2bff0892b 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -8,7 +8,7 @@ import { localized } from "../core/i18n"; import { Sound } from "../core/sound"; import { State } from "../core/state"; import type { Island } from "./island"; -import { beginTurn, endTurn, toolFinished, toolStarted } from "../views/session"; +import { beginTurn, dropSession, endTurn, hasSession, toolFinished, toolStarted } from "../views/session"; const CLAUDE_ID = "integration_claude"; @@ -146,7 +146,6 @@ function clearSession() { t.stepIndex = 0; t.name = "VS Code"; t.pillBadge = null; - t.session = null; } export function registerHookHandlers(island: Island) { @@ -171,6 +170,7 @@ function handleHook(island: Island, payload: HookPayload) { const name = payload.hook_event_name ?? ""; const cwd = payload.cwd ?? ""; + const sessionId = payload.session_id ?? ""; noteTerminal(payload); const raw = lastPathComponent(cwd); const projectName = aliasProjectName(raw || "Session"); @@ -217,9 +217,9 @@ function handleHook(island: Island, payload: HookPayload) { const asked = payload.prompt ?? payload.message; if (asked) State.appendStep(agentId, asked.slice(0, 60)); if (!isExternalAgent) { - beginTurn(); - // The new turn has shown nothing yet, so an editor view would be empty. - if (State.view === "session") island.setView(State.defaultView()); + beginTurn(sessionId, cwd, projectName); + // The new turn has shown nothing yet; if no other session has, the editor view would be empty. + if (State.view === "session" && !hasSession()) island.setView(State.defaultView()); } surface("overview", false); break; @@ -231,7 +231,7 @@ function handleHook(island: Island, payload: HookPayload) { const tool = payload.tool_name ?? "Tool"; State.appendStep(agentId, stepLabel(tool, payload.tool_input ?? {})); // What the editor view shows is kept up to date; opening it is the user's click. - if (!isExternalAgent) toolStarted(tool, payload.tool_input ?? {}, cwd); + if (!isExternalAgent) toolStarted(sessionId, tool, payload.tool_input ?? {}, cwd, projectName); surface("overview", false); break; } @@ -239,7 +239,7 @@ function handleHook(island: Island, payload: HookPayload) { case "PostToolUse": State.updateTask(agentId, "working"); if (!isExternalAgent) { - toolFinished(payload.tool_name ?? "", payload.tool_input ?? {}, cwd, { tail: payload.tool_tail }); + toolFinished(sessionId, payload.tool_name ?? "", payload.tool_input ?? {}, cwd, { tail: payload.tool_tail }); } break; @@ -247,7 +247,7 @@ function handleHook(island: Island, payload: HookPayload) { State.updateTask(agentId, "working"); State.appendStep(agentId, "⚠ failed"); if (!isExternalAgent) { - toolFinished(payload.tool_name ?? "", payload.tool_input ?? {}, cwd, { failed: true, error: payload.error }); + toolFinished(sessionId, payload.tool_name ?? "", payload.tool_input ?? {}, cwd, { failed: true, error: payload.error }); } break; @@ -265,7 +265,7 @@ function handleHook(island: Island, payload: HookPayload) { } case "Stop": - if (!isExternalAgent) endTurn(); + if (!isExternalAgent) endTurn(sessionId); State.updateTask(agentId, "finished"); if (payload.message) State.appendStep(agentId, payload.message.slice(0, 60)); Sound.play("finish"); @@ -278,9 +278,8 @@ function handleHook(island: Island, payload: HookPayload) { State.updateTask(agentId, "idle"); State.setPillBadge(agentId, null); // The finished session is shown for a moment, then the view lets go. - const claude = State.claudeTask; - if (claude) claude.session = null; - if (State.view === "session") island.setView(State.defaultView()); + dropSession(sessionId); + if (State.view === "session" && !hasSession()) island.setView(State.defaultView()); } }, 5200); break; @@ -298,7 +297,8 @@ function handleHook(island: Island, payload: HookPayload) { } else { State.updateTask(agentId, "idle"); clearSession(); - if (State.view === "session") island.setView(State.defaultView()); + dropSession(sessionId); + if (State.view === "session" && !hasSession()) island.setView(State.defaultView()); } break; diff --git a/windows/src/views/session.ts b/windows/src/views/session.ts index b14a4d469..0be238668 100644 --- a/windows/src/views/session.ts +++ b/windows/src/views/session.ts @@ -3,7 +3,9 @@ // added ones in green) and under it the last command with what it printed — next // to Mochi and the phases of the turn. // -// Everything comes from the hooks. `tool_input` carries the edit itself; the +// Every session keeps its own data, keyed by its session id: with several running +// at once the view shows the one that was active last, and a new prompt in another +// session cannot wipe it. Everything comes from the hooks. `tool_input` carries the edit itself; the // surrounding lines are read from the file by Rust (snippet.rs), the last lines a // command printed arrive in `tool_tail`. All text goes in as text nodes: a file's // content is not ours to trust as HTML. @@ -33,6 +35,12 @@ export interface CommandShown { export type Phase = "read" | "edit" | "bash"; export interface SessionData { + /** The project's name, from the folder the session started in. */ + project: string; + /** That folder: files are shown relative to it and read from inside it, however often the shell `cd`s. */ + root: string; + /** When the session last did anything (epoch ms), to pick the active one. */ + touchedAt: number; edit?: EditShown; command?: CommandShown; /** Which of Read / Edit / Bash this turn has used, and which it is in now. */ @@ -42,7 +50,6 @@ export interface SessionData { } const str = (v: unknown): string => (typeof v === "string" ? v : ""); -const fresh = (): SessionData => ({ seen: [], current: null, finished: false }); const PHASE_OF: Record = { Read: "read", Glob: "read", Grep: "read", LS: "read", WebFetch: "read", WebSearch: "read", @@ -50,28 +57,56 @@ const PHASE_OF: Record = { Bash: "bash", PowerShell: "bash", }; -/** A new prompt starts a new turn: the old turn's code and phases go. */ -export function beginTurn() { - const t = State.claudeTask; - if (t) t.session = fresh(); +/** Sessions that did nothing for this long are forgotten (a session that never sent Stop or SessionEnd). */ +const FORGET_AFTER_MS = 30 * 60_000; + +const sessions = new Map(); + +/** The session's data, created on its first event. `cwd` of that event is its root. */ +function sessionFor(id: string, cwd: string, project: string): SessionData { + let s = sessions.get(id); + if (!s) { + s = { project, root: cwd, touchedAt: 0, seen: [], current: null, finished: false }; + sessions.set(id, s); + } + s.touchedAt = Date.now(); + for (const [other, o] of sessions) if (s.touchedAt - o.touchedAt > FORGET_AFTER_MS) sessions.delete(other); + return s; } -export function endTurn() { - const s = State.claudeTask?.session; +/** The session the view shows: the last active one that has something to show. */ +function active(): SessionData | null { + let best: SessionData | null = null; + for (const s of sessions.values()) { + if ((s.edit || s.command) && (!best || s.touchedAt > best.touchedAt)) best = s; + } + return best; +} + +/** A new prompt starts a new turn of that session: its old code and phases go. */ +export function beginTurn(id: string, cwd: string, project: string) { + const s = sessionFor(id, cwd, project); + Object.assign(s, { edit: undefined, command: undefined, seen: [], current: null, finished: false }); +} + +export function endTurn(id: string) { + const s = sessions.get(id); if (s) s.finished = true; } -/** True once the turn has shown something worth a view of its own. */ +/** The session is over (or its finished view has been shown long enough). */ +export function dropSession(id: string) { + sessions.delete(id); +} + +/** True once some session has shown something worth a view of its own. */ export function hasSession(): boolean { - const s = State.claudeTask?.session; - return !!s && !!(s.edit || s.command); + return active() !== null; } /** A tool is about to run: note it, and what the session view will show of it. */ -export function toolStarted(tool: string, input: Record, cwd: string) { - const t = State.claudeTask; - if (!t) return; - const s = (t.session ??= fresh()); +export function toolStarted(id: string, tool: string, input: Record, cwd: string, project: string) { + const s = sessionFor(id, cwd, project); s.finished = false; const phase = PHASE_OF[tool]; if (phase) { @@ -79,8 +114,7 @@ export function toolStarted(tool: string, input: Record, cwd: s if (!s.seen.includes(phase)) s.seen.push(phase); } - const abs = str(input.file_path); - const file = cwd && abs.startsWith(cwd + "/") ? abs.slice(cwd.length + 1) : abs; + const file = relativeTo(str(input.file_path), s.root, cwd); switch (tool) { case "Edit": s.edit = { kind: "edit", file, removed: str(input.old_string), added: str(input.new_string) }; @@ -101,14 +135,23 @@ export function toolStarted(tool: string, input: Record, cwd: s } } +/** `abs` relative to the session's folder, else to the shell's current one, else as it is. */ +function relativeTo(abs: string, root: string, cwd: string): string { + for (const base of [root, cwd]) { + if (base && abs.startsWith(base + "/")) return abs.slice(base.length + 1); + } + return abs; +} + /** A tool finished (or failed): fill in what only exists afterwards. */ export function toolFinished( + id: string, tool: string, input: Record, cwd: string, extra: { tail?: string[]; failed?: boolean; error?: string }, ) { - const s = State.claudeTask?.session; + const s = sessions.get(id); if (!s) return; if ((tool === "Bash" || tool === "PowerShell") && s.command) { s.command.status = extra.failed ? "failed" : "ok"; @@ -116,9 +159,12 @@ export function toolFinished( State.notify(); } else if (tool === "Edit" && s.edit?.kind === "edit" && !extra.failed && s.edit.added) { const edit = s.edit; - void Bridge.fileSnippet(cwd, str(input.file_path), edit.added, 3).then((snip) => { + const path = str(input.file_path); + // Read from inside the session's folder; the shell's folder only if the file is not in it. + const inRoot = s.root && path.startsWith(s.root + "/"); + void Bridge.fileSnippet(inRoot ? s.root : cwd, path, edit.added, 3).then((snip) => { // Only if it is still the edit this answer belongs to. - if (snip && State.claudeTask?.session?.edit === edit) { + if (snip && sessions.get(id)?.edit === edit) { edit.snippet = snip; State.notify(); } @@ -300,16 +346,15 @@ export function buildSession(actions: ViewActions): ViewHost { return { el, sync() { - const task = State.claudeTask; - const s = task?.session; - if (!task || !s) return; - const next = JSON.stringify([task.name, s]); + const s = active(); + if (!s) return; + const next = JSON.stringify(s); if (next === key) return; key = next; clear(left); left.append( - h("div", { class: "sess-name" }, dot(task.color, 7), h("span", { text: task.name })), + h("div", { class: "sess-name" }, dot(State.claudeTask?.color ?? "#ffffff", 7), h("span", { text: s.project })), h("div", { class: "sess-tool" }, h("span", { text: "Claude Code" }), h("button", { class: "sess-back", title: "Back", onclick: () => actions.setView("overview") }, fa("compress", 11)),