diff --git a/windows/README.md b/windows/README.md index 60374024c..c94acfc13 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. @@ -170,6 +176,12 @@ 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. - **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 @@ -178,6 +190,13 @@ 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 + 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..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); } @@ -154,6 +168,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(); @@ -168,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 { @@ -245,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/claude.rs b/windows/src-tauri/src/claude.rs index 060a57c64..32a1029d2 100644 --- a/windows/src-tauri/src/claude.rs +++ b/windows/src-tauri/src/claude.rs @@ -22,20 +22,50 @@ 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 { /// 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 { @@ -73,11 +103,13 @@ pub struct ChatReply { pub async fn send( chat: &Chat, model: &str, + language: &str, 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(); @@ -108,7 +140,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 +280,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 +292,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/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/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..f6574cf1f 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; @@ -10,6 +11,7 @@ mod pipe; mod platform; mod secrets; mod settings; +mod snippet; mod tray; use std::process::Command; @@ -117,6 +119,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(); @@ -132,10 +140,39 @@ 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. +/// 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] -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 @@ -147,22 +184,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] @@ -241,8 +285,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] @@ -381,8 +428,11 @@ pub fn run() { set_island_rect, focus_window, reposition, + monitors, open_url, open_in_vscode, + focus_terminal, + file_snippet, 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 e748a336a..62b5fda95 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -12,13 +12,13 @@ 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::*; -use tauri::{AppHandle, WebviewWindow}; +use tauri::{AppHandle, Emitter, WebviewWindow}; use super::{home_dir, LocalTime}; @@ -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; } @@ -181,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, + ); } } @@ -201,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. @@ -210,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; @@ -300,10 +338,194 @@ 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. +/// +/// 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::*; + /// 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"; + 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..0c4764ee7 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -233,3 +233,16 @@ 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 +} + +/// Windows places the window with `set_position`; nothing to pin. +pub fn place_on_monitor(_win: &WebviewWindow, _index: usize) {} 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-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 24aaf55ff..ccf20a50e 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; @@ -50,10 +50,20 @@ 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. */ - openInVSCode: (path: string | null) => call("open_in_vscode", { 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 }), quit: () => call("quit_app"), @@ -118,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/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/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 01236b80a..e4699ecb9 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 { @@ -87,11 +96,14 @@ 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. */ model: string; + /** "auto" (system language), "en", "de" or "fr". */ + language: string; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +118,7 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + language: "auto", }; type Listener = () => void; @@ -164,6 +177,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..2bff0892b 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -4,9 +4,11 @@ // 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"; +import { beginTurn, dropSession, endTurn, hasSession, toolFinished, toolStarted } from "../views/session"; const CLAUDE_ID = "integration_claude"; @@ -25,6 +27,14 @@ 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; + /** 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. */ @@ -60,26 +70,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)}`; @@ -121,14 +133,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 +152,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 +170,8 @@ 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"); @@ -195,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(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; } @@ -204,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(sessionId, tool, payload.tool_input ?? {}, cwd, projectName); surface("overview", false); break; } case "PostToolUse": State.updateTask(agentId, "working"); + if (!isExternalAgent) { + toolFinished(sessionId, 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(sessionId, payload.tool_name ?? "", payload.tool_input ?? {}, cwd, { failed: true, error: payload.error }); + } break; case "Notification": { @@ -231,6 +265,7 @@ function handleHook(island: Island, payload: HookPayload) { } case "Stop": + if (!isExternalAgent) endTurn(sessionId); State.updateTask(agentId, "finished"); if (payload.message) State.appendStep(agentId, payload.message.slice(0, 60)); Sound.play("finish"); @@ -242,6 +277,9 @@ 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. + dropSession(sessionId); + if (State.view === "session" && !hasSession()) island.setView(State.defaultView()); } }, 5200); break; @@ -259,6 +297,8 @@ function handleHook(island: Island, payload: HookPayload) { } else { State.updateTask(agentId, "idle"); clearSession(); + dropSession(sessionId); + if (State.view === "session" && !hasSession()) island.setView(State.defaultView()); } break; diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb61802..315cdac68 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"; @@ -24,6 +25,17 @@ 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; + +/** + * 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"]); @@ -42,6 +54,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; @@ -70,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; @@ -111,10 +129,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 +142,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]); }, @@ -328,6 +343,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 +372,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": { @@ -563,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); }); @@ -571,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 }; @@ -692,7 +719,7 @@ export class Island { this.syncDom(); } - this.updateBotTargets(); + this.updateBotTargets(dt); this.botCx.step(dt); this.botCy.step(dt); this.botSize.step(dt); @@ -715,6 +742,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); @@ -732,7 +760,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) { @@ -743,7 +771,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; @@ -756,17 +784,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/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..a887afb90 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -386,9 +386,28 @@ 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})` })); + } + screen.value = settings.screen; + }); screen.value = settings.screen; screen.addEventListener("change", () => { - settings.screen = screen.value as Settings["screen"]; + settings.screen = screen.value; void save(); }); @@ -410,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(); }), diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c..b39ea732d 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -133,12 +133,31 @@ 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; 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 { @@ -208,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; } @@ -1365,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/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/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(); + } +} diff --git a/windows/src/views/session.ts b/windows/src/views/session.ts new file mode 100644 index 000000000..0be238668 --- /dev/null +++ b/windows/src/views/session.ts @@ -0,0 +1,372 @@ +// 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. +// +// 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. + +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 { + /** 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. */ + seen: Phase[]; + current: Phase | null; + finished: boolean; +} + +const str = (v: unknown): string => (typeof v === "string" ? v : ""); + +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", +}; + +/** 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; +} + +/** 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; +} + +/** 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 { + return active() !== null; +} + +/** A tool is about to run: note it, and what the session view will show of it. */ +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) { + s.current = phase; + if (!s.seen.includes(phase)) s.seen.push(phase); + } + + 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) }; + 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; + } +} + +/** `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 = sessions.get(id); + 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; + 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 && sessions.get(id)?.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 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(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)), + ), + 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/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..11d45f0b7 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -4,11 +4,13 @@ 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"; 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"; @@ -78,12 +80,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(); @@ -101,14 +103,14 @@ 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"); 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"; }, }; @@ -126,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); @@ -213,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("|"); @@ -394,13 +403,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); }, }; } @@ -489,13 +506,14 @@ 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()); 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());