diff --git a/windows/README.md b/windows/README.md index 60374024cf..d7fa6dba2f 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,20 @@ 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. +- **Plan usage**, as on the Mac: a small pill in the island's header ("Claude 73%", green + below 50 %, orange up to 80 %, red above) shows your 5-hour and weekly Claude limits, + and clicking it opens the details and reset times. Settings → Plan usage → + **Show in the notch** turns it on and installs a status line relay after showing you the + diff of `~/.claude/settings.json`; a status line you already have keeps working (the relay + runs it) and **Uninstall relay** puts it back. Pro and Max plans only, and the numbers + arrive with Claude Code's replies. +- **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 5bb3637818..943e00a086 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -14,11 +14,13 @@ //! island is the whole point. No answer means empty stdout, and Claude Code //! asks in the terminal exactly as if Coucou were not installed. //! -//! Usage: `coucou-hook ` (the name is also read from the JSON). +//! Usage: `coucou-hook ` (the name is also read from the JSON), or +//! `coucou-hook --statusline` as Claude Code's status line command: it passes the +//! plan limits on and runs the status line the user had before, so that keeps working. use std::io::{Read, Write}; use std::sync::mpsc; -use std::time::Duration; +use std::time::{Duration, Instant}; /// Budget for getting a pipe connection. Beyond this Claude Code wins, always. const CONNECT_TIMEOUT: Duration = Duration::from_millis(300); @@ -26,6 +28,8 @@ const CONNECT_TIMEOUT: Duration = Duration::from_millis(300); const FIRE_AND_FORGET_BUDGET: Duration = Duration::from_secs(2); /// How long a permission prompt may stay on screen before the terminal takes over. const DECISION_BUDGET: Duration = Duration::from_secs(110); +/// How long the user's own status line may take before we give up on it. +const PREVIOUS_STATUS_LINE_BUDGET: Duration = Duration::from_secs(10); /// Fields that are pointless to forward and can be enormous (a whole file read, /// a full command output). The island never shows them. @@ -33,6 +37,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; @@ -45,6 +52,9 @@ mod unix; use unix::connect; fn main() { + if std::env::args().any(|a| a == "--statusline") { + run_status_line(); + } let Some((payload, event)) = read_event() else { std::process::exit(0) }; let waits_for_answer = event == "PermissionRequest"; @@ -70,6 +80,102 @@ fn main() { std::process::exit(0); } +/// `coucou-hook --statusline`: the status line command of Claude Code. +/// +/// It gets the session's JSON on stdin and whatever it prints becomes the status +/// line. Coucou only wants the plan limits in it, so they go on in the background +/// (never delaying the status line), and if the user had a status line of their +/// own it is run with the same input and its output passed on untouched. +fn run_status_line() -> ! { + let mut raw = Vec::new(); + let _ = std::io::stdin().read_to_end(&mut raw); + if raw.starts_with(&[0xEF, 0xBB, 0xBF]) { + raw.drain(..3); + } + + let sent = serde_json::from_slice::(&raw) + .ok() + .and_then(|v| status_line_payload(v.as_object()?)) + .map(|line| { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(talk(&line, false)); + }); + rx + }); + + if let Some(command) = previous_status_line() { + if let Some(out) = run_previous(&command, &raw, PREVIOUS_STATUS_LINE_BUDGET) { + let mut stdout = std::io::stdout(); + let _ = stdout.write_all(&out); + let _ = stdout.flush(); + } + } + // Coucou's share is a few hundred milliseconds at most. + if let Some(rx) = sent { + let _ = rx.recv_timeout(CONNECT_TIMEOUT); + } + std::process::exit(0); +} + +/// The command of the status line the user had before Coucou's relay took its place, +/// saved by the installer next to this program. +fn previous_status_line() -> Option { + let path = std::env::current_exe().ok()?.with_file_name("statusline-previous.json"); + let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(path).ok()?).ok()?; + saved.get("command")?.as_str().filter(|c| !c.trim().is_empty()).map(str::to_string) +} + +/// Runs `command` through the shell with `input` on stdin and returns what it +/// printed, or nothing if it could not start or ran past `budget` (then it is killed). +fn run_previous(command: &str, input: &[u8], budget: Duration) -> Option> { + use std::process::{Command, Stdio}; + + #[cfg(unix)] + let mut cmd = { + let mut c = Command::new("/bin/sh"); + c.args(["-c", command]); + c + }; + #[cfg(windows)] + let mut cmd = { + use std::os::windows::process::CommandExt; + let mut c = Command::new("cmd"); + c.args(["/C", command]).creation_flags(0x0800_0000); // no console window + c + }; + let mut child = + cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?; + + // Feed and drain on their own threads: a long input or output cannot deadlock us. + let mut stdin = child.stdin.take()?; + let data = input.to_vec(); + std::thread::spawn(move || { + let _ = stdin.write_all(&data); + }); + let mut stdout = child.stdout.take()?; + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut out = Vec::new(); + let _ = stdout.read_to_end(&mut out); + let _ = tx.send(out); + }); + + let deadline = Instant::now() + budget; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + rx.recv_timeout(Duration::from_millis(500)).ok() +} + /// The documented PermissionRequest output. Anything we do not recognise prints /// nothing at all rather than guessing — silence is the safe answer. /// See https://code.claude.com/docs/en/hooks @@ -128,6 +234,24 @@ fn read_event() -> Option<(String, String)> { .unwrap_or(arg_event); map.insert("hook_event_name".into(), serde_json::Value::String(event.clone())); + // Claude Code's status line hands us its usage limits (5 h, weekly). That is + // all the island wants of it, so only that goes on; without limits (API-key + // users) there is nothing to send. The command's own output stays empty. + if event == "StatusLine" { + return status_line_payload(map).map(|line| (line, event)); + } + + // 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 +278,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 +296,63 @@ fn read_event() -> Option<(String, String)> { Some((line, event)) } +/// The one line the island gets from a status-line call: the limits and which +/// session they came from. +fn status_line_payload(map: &serde_json::Map) -> Option { + let limits = map.get("rate_limits").filter(|v| v.is_object())?; + let mut line = serde_json::json!({ + "hook_event_name": "StatusLine", + "session_id": map.get("session_id").cloned().unwrap_or(serde_json::Value::Null), + "rate_limits": limits, + }) + .to_string(); + line.push('\n'); + Some(line) +} + +/// 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 +430,54 @@ mod tests { assert!(decision_json(r#"{"permissionDecision":"allow"}"#).is_none()); } + #[cfg(unix)] + #[test] + fn the_users_own_status_line_gets_the_same_input_and_its_output_comes_back() { + let out = run_previous("cat; echo ' tail'", b"{\"a\":1}", Duration::from_secs(5)).unwrap(); + assert_eq!(String::from_utf8(out).unwrap(), "{\"a\":1} tail\n"); + } + + #[cfg(unix)] + #[test] + fn a_status_line_that_hangs_is_killed_and_prints_nothing() { + let started = Instant::now(); + assert!(run_previous("sleep 5", b"{}", Duration::from_millis(200)).is_none()); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + #[test] + fn a_status_line_call_forwards_only_the_limits() { + let input = serde_json::json!({ + "session_id": "s1", "cwd": "/secret", "model": { "id": "x" }, + "rate_limits": { "five_hour": { "used_percentage": 42, "resets_at": 1 } } + }); + let line = status_line_payload(input.as_object().unwrap()).unwrap(); + let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap(); + assert_eq!(v["rate_limits"]["five_hour"]["used_percentage"], 42); + assert!(v.get("cwd").is_none() && v.get("model").is_none()); + // No limits (API-key users): nothing is sent. + assert!( + status_line_payload(serde_json::json!({ "session_id": "s1" }).as_object().unwrap()) + .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 060a57c641..32a1029d2b 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 0000000000..301a4c553a --- /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/hooks.rs b/windows/src-tauri/src/hooks.rs index 3da1d0e5a7..31ee2cd588 100644 --- a/windows/src-tauri/src/hooks.rs +++ b/windows/src-tauri/src/hooks.rs @@ -40,6 +40,8 @@ const MARKER: &str = "coucou-hook"; #[serde(rename_all = "camelCase")] pub struct HookStatus { pub installed: bool, + /// Coucou's status line relay (plan usage) is the one in settings.json. + pub plan_relay_installed: bool, pub settings_path: String, pub hook_path: String, pub hook_ready: bool, @@ -139,6 +141,12 @@ fn entry_is_ours(entry: &Value) -> bool { .unwrap_or(false) } +/// The status line in settings.json is Coucou's relay (old installs wrote +/// `coucou-hook StatusLine`, new ones `coucou-hook --statusline`; both match). +fn status_line_is_ours(v: &Value) -> bool { + v.get("command").and_then(Value::as_str).is_some_and(|c| c.contains(MARKER)) +} + /// Settings with Coucou's hooks added; everything else is left untouched. fn merged(existing: &Value) -> Value { let mut root = existing.as_object().cloned().unwrap_or_default(); @@ -253,6 +261,7 @@ pub fn status() -> HookStatus { let hook_path = settings::hook_exe_path(); HookStatus { installed, + plan_relay_installed: plan_relay_installed(¤t), settings_path: settings_path().to_string_lossy().to_string(), hook_ready: hook_path.exists(), hook_path: hook_path.to_string_lossy().to_string(), @@ -260,10 +269,19 @@ pub fn status() -> HookStatus { } pub fn preview(install: bool) -> Result { + preview_with(|current| if install { merged(current) } else { without_ours(current) }, pretty) +} + +/// The diff of what `next` makes of settings.json, shown with `view` (the whole +/// file, or just the key that changes). +fn preview_with( + next: impl Fn(&Value) -> Value, + view: fn(&Value) -> String, +) -> Result { let current = read_settings()?; - let next = if install { merged(¤t) } else { without_ours(¤t) }; + let next = next(¤t); Ok(HookPreview { - diff: unified_diff(&pretty(¤t), &pretty(&next)), + diff: unified_diff(&view(¤t), &view(&next)), backup: backup_path().to_string_lossy().to_string(), settings_path: settings_path().to_string_lossy().to_string(), fingerprint: current_fingerprint(), @@ -277,6 +295,10 @@ pub fn preview(install: bool) -> Result { /// and make them look at a fresh diff, because the only thing worse than not /// installing the hooks is silently reverting somebody else's edit. pub fn write(install: bool, fingerprint: &str) -> Result { + write_with(|current| if install { merged(current) } else { without_ours(current) }, fingerprint) +} + +fn write_with(next: impl Fn(&Value) -> Value, fingerprint: &str) -> Result { let path = settings_path(); let dir = path.parent().unwrap_or(Path::new(".")); std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; @@ -296,7 +318,7 @@ pub fn write(install: bool, fingerprint: &str) -> Result { std::fs::copy(&path, &backup).map_err(|e| format!("backup failed: {e}"))?; } - let next = if install { merged(¤t) } else { without_ours(¤t) }; + let next = next(¤t); let mut text = pretty(&next); text.push('\n'); @@ -319,6 +341,99 @@ pub fn write(install: bool, fingerprint: &str) -> Result { Ok(backup.to_string_lossy().to_string()) } +// ── Status line (plan usage) ────────────────────────────────────────────────── +// +// Claude Code runs one `statusLine` command and hands it the plan limits. Coucou +// puts its relay there; a status line the user already had is kept in +// statusline-previous.json beside the relay, and the relay still runs it, so it +// keeps working. Installing and removing it is separate from the hooks. + +/// Where the user's own status line waits while the relay stands in for it. +pub fn status_line_previous_path() -> PathBuf { + settings::hook_exe_path().with_file_name("statusline-previous.json") +} + +fn read_status_line_previous() -> Option { + let bytes = std::fs::read(status_line_previous_path()).ok()?; + serde_json::from_slice::(&bytes).ok().filter(Value::is_object) +} + +/// True when the `statusLine` in settings.json is Coucou's relay. +pub fn plan_relay_installed(settings: &Value) -> bool { + settings.get("statusLine").is_some_and(status_line_is_ours) +} + +/// `statusLine` as it reads after installing or removing the relay. `None`: the +/// key goes. Installing swaps only `command`, so `padding`, `refreshInterval` +/// and the rest of the user's status line stay as they were. +fn status_line_after(existing: Option<&Value>, install: bool, previous: Option<&Value>) -> Option { + if install { + let mut sl = existing.filter(|v| v.is_object()).cloned().unwrap_or_else(|| json!({})); + let obj = sl.as_object_mut().expect("an object"); + obj.entry("type").or_insert_with(|| json!("command")); + obj.insert("command".into(), json!(hook_command("--statusline"))); + Some(sl) + } else if existing.is_some_and(status_line_is_ours) { + previous.cloned() + } else { + existing.cloned() // not ours any more (the user changed it): leave it alone + } +} + +fn status_line_settings(current: &Value, install: bool, previous: Option<&Value>) -> Value { + let mut root = current.as_object().cloned().unwrap_or_default(); + match status_line_after(root.get("statusLine"), install, previous) { + Some(sl) => root.insert("statusLine".into(), sl), + None => root.remove("statusLine"), + }; + Value::Object(root) +} + +/// Only the `statusLine` key, which is all the diff of this change is about. +fn status_line_view(settings: &Value) -> String { + pretty(&json!({ "statusLine": settings.get("statusLine").cloned().unwrap_or(Value::Null) })) +} + +/// The diff the user has to look at before the relay goes in or out. +pub fn status_line_preview(install: bool) -> Result { + let previous = read_status_line_previous(); + preview_with(|current| status_line_settings(current, install, previous.as_ref()), status_line_view) +} + +/// Installs or removes the relay, after the same backup and fingerprint checks as +/// the hooks. Installing first saves a status line of the user's own, removing +/// puts it back (or removes the key if there was none). +pub fn status_line_write(install: bool, fingerprint: &str) -> Result { + let before = read_settings()?; + let previous = read_status_line_previous(); + let own = before.get("statusLine").filter(|v| !status_line_is_ours(v)).cloned(); + let saved = install && own.is_some() && save_status_line_previous(own.as_ref().expect("some")).is_ok(); + if install && own.is_some() && !saved { + return Err("could not save your current status line next to the relay; nothing was changed".into()); + } + let backup = write_with(|current| status_line_settings(current, install, previous.as_ref()), fingerprint); + match &backup { + // Back in settings.json: the saved copy has done its job. + Ok(_) if !install => { + let _ = std::fs::remove_file(status_line_previous_path()); + } + // Nothing was written: do not leave a stale copy behind. + Err(_) if saved => { + let _ = std::fs::remove_file(status_line_previous_path()); + } + _ => {} + } + backup +} + +fn save_status_line_previous(status_line: &Value) -> std::io::Result<()> { + let path = status_line_previous_path(); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + write_like(&path, &path, pretty(status_line).as_bytes()) +} + /// Writes `bytes` to `temp`, which is about to replace `original`. /// /// On Linux a fresh file would get the umask's 0644, and settings.json can hold @@ -511,6 +626,49 @@ mod tests { const WHERE: &str = "settings.json"; + #[test] + fn the_hooks_leave_the_status_line_alone() { + let theirs = json!({ "statusLine": { "type": "command", "command": "~/bin/my-line" } }); + assert_eq!(merged(&theirs)["statusLine"], theirs["statusLine"]); + assert_eq!(without_ours(&theirs)["statusLine"], theirs["statusLine"]); + assert!(merged(&json!({})).get("statusLine").is_none()); + } + + #[test] + fn the_relay_takes_the_status_line_and_keeps_the_users_other_fields() { + // None yet: ours is added. + let fresh = status_line_settings(&json!({ "model": "opus" }), true, None); + assert!(status_line_is_ours(&fresh["statusLine"])); + assert_eq!(fresh["statusLine"]["type"], "command"); + assert_eq!(fresh["model"], "opus"); + + // Their own: only the command is swapped; padding and refresh stay. + let own = json!({ "statusLine": { "type": "command", "command": "~/bin/my-line", "padding": 2, "refreshInterval": 5 } }); + let taken = status_line_settings(&own, true, None); + assert!(status_line_is_ours(&taken["statusLine"])); + assert_eq!(taken["statusLine"]["padding"], 2); + assert_eq!(taken["statusLine"]["refreshInterval"], 5); + + // Installing again keeps ours and its extra fields. + let again = status_line_settings(&taken, true, None); + assert_eq!(again["statusLine"]["padding"], 2); + assert!(status_line_is_ours(&again["statusLine"])); + } + + #[test] + fn removing_the_relay_restores_what_was_there_and_never_touches_anything_else() { + let previous = json!({ "type": "command", "command": "~/bin/my-line", "padding": 2 }); + let ours = status_line_settings(&json!({}), true, None); + + // There was one before: it comes back exactly. + assert_eq!(status_line_settings(&ours, false, Some(&previous))["statusLine"], previous); + // There was none: the key goes. + assert!(status_line_settings(&ours, false, None).get("statusLine").is_none()); + // The user changed it since: not ours, so untouched. + let theirs = json!({ "statusLine": { "type": "command", "command": "~/bin/other" } }); + assert_eq!(status_line_settings(&theirs, false, Some(&previous))["statusLine"], theirs["statusLine"]); + } + #[test] fn a_utf8_bom_is_stripped_not_treated_as_corruption() { // PowerShell 5's `Set-Content -Encoding utf8` produces exactly this. diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 92c9cd4cbf..bd844e27c2 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 714103e59e..03cefb597b 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; @@ -48,7 +50,9 @@ pub struct BootInfo { fn boot(app: AppHandle, shared: State) -> BootInfo { let mut settings = shared.settings.lock().unwrap().clone(); // The real state of ~/.claude/settings.json wins over whatever we stored. - settings.hooks_installed = hooks::status().installed; + let hooks_status = hooks::status(); + settings.hooks_installed = hooks_status.installed; + settings.plan_relay_installed = hooks_status.plan_relay_installed; let screen = island::screen_info(&app, &settings.screen); BootInfo { settings, @@ -117,6 +121,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 +142,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 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) -> bool { +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 +186,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] @@ -211,6 +257,32 @@ fn hooks_apply( Ok(backup) } +/// The diff of putting the plan usage relay into (or taking it out of) the +/// status line: just the `statusLine` key, before and after. +#[tauri::command] +fn status_line_preview(install: bool) -> Result { + hooks::status_line_preview(install) +} + +/// Only ever called from an explicit click in the settings window. +#[tauri::command] +fn status_line_apply( + app: AppHandle, + shared: State, + install: bool, + fingerprint: String, +) -> Result { + let backup = hooks::status_line_write(install, &fingerprint)?; + let updated = { + let mut current = shared.settings.lock().unwrap(); + current.plan_relay_installed = install; + let _ = settings::save(¤t); + current.clone() + }; + let _ = app.emit("settings-changed", updated); + Ok(backup) +} + #[tauri::command] fn approval_decision(app: AppHandle, request_id: String, decision: String) { pipe::answer(&app, &request_id, &decision); @@ -241,8 +313,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,12 +456,17 @@ 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, hooks_apply, + status_line_preview, + status_line_apply, approval_decision, approval_ack, approval_decline, diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e748a336a4..62b5fda95d 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 6e1200a8aa..0c4764ee7b 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 2a8d7d359c..b7d03fb21e 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -20,6 +20,22 @@ 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, + /// Show the plan usage pill (5 h and weekly limits) in the island's header. + /// Off until the user turns it on, so the header stays as it shipped. + #[serde(default)] + pub show_plan_in_notch: bool, + /// Coucou's status line relay is the one in settings.json. Like + /// `hooks_installed`, the real state wins at launch over what was stored. + #[serde(default)] + pub plan_relay_installed: bool, +} + +fn default_language() -> String { + "auto".to_string() } fn default_model() -> String { @@ -43,6 +59,9 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + language: default_language(), + show_plan_in_notch: false, + plan_relay_installed: false, } } } diff --git a/windows/src-tauri/src/snippet.rs b/windows/src-tauri/src/snippet.rs new file mode 100644 index 0000000000..8901790d18 --- /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 24aaf55ff9..d8fc32b866 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"), @@ -73,6 +83,13 @@ export const Bridge = { hooksApply: (install: boolean, fingerprint: string) => callOrThrow("hooks_apply", { install, fingerprint }), + // ── Plan usage: the status line relay, installed apart from the hooks ────── + /** Diff of the `statusLine` key only. `install: false` previews taking the relay out. */ + statusLinePreview: (install: boolean) => callOrThrow("status_line_preview", { install }), + /** Same rules as hooksApply: an explicit click, and only for the diff that was shown. */ + statusLineApply: (install: boolean, fingerprint: string) => + callOrThrow("status_line_apply", { install, fingerprint }), + approvalDecision: (requestId: string, decision: "allow" | "deny") => call("approval_decision", { requestId, decision }), /** "The card is up" — until this lands the relay only waits a moment. */ @@ -118,8 +135,20 @@ 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; + /** Coucou's status line relay (plan usage) is the status line in settings.json. */ + planRelayInstalled: boolean; settingsPath: string; hookPath: string; hookReady: boolean; diff --git a/windows/src/core/i18n.ts b/windows/src/core/i18n.ts new file mode 100644 index 0000000000..20ce453ba7 --- /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 6f70eea4c5..6e6db15b42 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 01236b80a9..ec350a0b40 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -2,6 +2,7 @@ import type { BotEmoteName, BotStateName, IslandMode, IslandViewName } from "./layout"; import type { EyeShape } from "../mochi/engine"; +import type { PlanUsage } from "../views/usage"; export type AgentSource = "claudeCode" | "n8n" | "agent"; export type PillBadge = "approval" | "finished" | "error"; @@ -19,6 +20,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 +97,18 @@ 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; + /** Show the plan usage pill (5 h and weekly limits) in the island's header. */ + showPlanInNotch: boolean; + /** Coucou's status line relay is installed in Claude Code's settings. */ + planRelayInstalled: boolean; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +123,9 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + language: "auto", + showPlanInNotch: false, + planRelayInstalled: false, }; type Listener = () => void; @@ -130,6 +150,10 @@ class AppState { uploadProgress = 0; uploadDuration = 2.4; fileDragOver = false; + /** Claude's 5 h / weekly limits, from the status line (null until the first call). */ + planUsage: PlanUsage | null = null; + /** The plan card is open in place of the overview's left card. */ + showingPlanDetail = false; promptContext: PromptContext | null = null; droppedFile: { name: string; path: string } | null = null; @@ -164,6 +188,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); } @@ -172,6 +201,7 @@ class AppState { const t = this.tasks.find((x) => x.id === id); if (!t) return; this.focusId = id; + this.showingPlanDetail = false; t.pillBadge = null; this.notify(); } diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index d90a78f099..fd731c469e 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -4,9 +4,12 @@ // 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"; +import { parsePlanUsage } from "../views/usage"; const CLAUDE_ID = "integration_claude"; @@ -25,6 +28,16 @@ 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; + /** StatusLine: Claude Code's usage limits. */ + rate_limits?: unknown; } /** Same rule as HookServer.validateAgent on macOS. "claude" is reserved. */ @@ -60,26 +73,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 +136,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,7 +155,24 @@ 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) { + // Account-wide numbers, not part of any session: just keep the latest. + if (payload.hook_event_name === "StatusLine") { + const usage = parsePlanUsage(payload.rate_limits); + if (usage) { + State.planUsage = usage; + State.notify(); + } + return; + } + if (State.paused) { // Silence here used to cost Claude Code nearly two minutes: the relay waited // for a decision from an island that had already decided not to look. Say so, @@ -151,6 +183,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 +229,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 +243,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 +278,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 +290,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 +310,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 7fdb618027..bed98fb3c0 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -16,6 +16,8 @@ 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 { currentPlanColor, planPillVisible } from "../views/usage"; import { buildHeader, buildViews, type ViewActions, type ViewHost } from "../views/views"; import { h } from "../views/dom"; import { IslandStateMachine } from "./fsm"; @@ -24,6 +26,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 +55,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 +87,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 +130,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 +143,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]); }, @@ -267,6 +283,7 @@ export class Island { void Bridge.focusWindow(false); } if (mode !== "expanded") { + State.showingPlanDetail = false; this.engine.resetMorph(); // Nothing can be seen of the sequence once the island is shut, and leaving // it running would keep the frame loop awake — the island must cost @@ -290,6 +307,7 @@ export class Island { expand(view: IslandViewName) { this.stopSequenceIfLeaving(view); + if (view !== "overview") State.showingPlanDetail = false; State.view = view; if (State.mode !== "expanded") this.setMode("expanded"); else this.animateGeometry(false); @@ -300,6 +318,7 @@ export class Island { setView(view: IslandViewName) { this.stopSequenceIfLeaving(view); + if (view !== "overview") State.showingPlanDetail = false; if (State.mode !== "expanded") { this.fsm.forceHome(); State.view = view; @@ -328,6 +347,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 +376,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 +590,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 +601,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 +723,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 +746,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 +764,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 +775,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,19 +788,42 @@ 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) { const size = this.botSize.value; const w = Math.max(1, Math.round(size)); @@ -788,7 +843,13 @@ export class Island { if (!ctx) return; const focus = State.focusTask; - this.engine.bodyColor = focus?.isIntegration ? hexToRGB(focus.color) : null; + // While the plan card is open Mochi wears the plan's colour, like the pill. + this.engine.bodyColor = + State.showingPlanDetail && planPillVisible() + ? hexToRGB(currentPlanColor()) + : focus?.isIntegration + ? hexToRGB(focus.color) + : null; this.engine.particleOverhang = BOT_OVERHANG; this.engine.lookX = this.lookX(); this.engine.lookY = this.lookY(); diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a7..0adb67412e 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -3,7 +3,7 @@ // integrations land here too in a later stage. import "./settings.css"; -import { Bridge, onEvent, type HookStatus } from "../core/bridge"; +import { Bridge, onEvent, type HookPreview, type HookStatus } from "../core/bridge"; import { DEFAULT_SETTINGS, type Settings } from "../core/state"; import { h, clear } from "../views/dom"; @@ -41,6 +41,90 @@ function renderDiff(text: string): HTMLElement { return box; } +// ── Changes to Claude Code's settings.json ──────────────────────────────────── + +/** One kind of change to ~/.claude/settings.json, with the words that go with it. */ +interface Change { + preview: (install: boolean) => Promise; + apply: (install: boolean, fingerprint: string) => Promise; + installText: string; + removeText: string; + installButton: string; + removeButton: string; + done: (backup: string) => string; +} + +const HOOKS_CHANGE: Change = { + preview: Bridge.hooksPreview, + apply: Bridge.hooksApply, + installText: "This is exactly what will change in your settings.json. Your own hooks are left untouched.", + removeText: "This removes Coucou's entries only. Your own hooks are left untouched.", + installButton: "Back up and write", + removeButton: "Back up and remove", + done: (backup) => `Done. Previous settings saved as ${backup}. Open a new Claude Code session to pick the hooks up.`, +}; + +const STATUS_LINE_CHANGE: Change = { + preview: Bridge.statusLinePreview, + apply: Bridge.statusLineApply, + installText: "This is exactly what will change: only the status line. If you already have one it keeps working, Coucou's relay runs it for you.", + removeText: "This puts your previous status line back, or removes the entry if there was none.", + installButton: "Back up and write", + removeButton: "Back up and remove", + done: (backup) => `Done. Previous settings saved as ${backup}. The numbers appear after the next reply of a Claude Code session.`, +}; + +/** + * Shows the diff of a change in `body` and writes it only after an explicit + * click, and only if settings.json still matches the diff that was shown. + * `back` redraws the section; `applied` runs a moment after a successful write. + */ +async function reviewChange( + body: HTMLElement, + change: Change, + install: boolean, + back: () => void, + applied: () => void, +) { + let preview; + try { + preview = await change.preview(install); + } catch (err) { + // An unreadable or invalid settings.json stops here rather than being + // treated as empty and written over. + clear(body); + body.append( + h("div", { class: "notice err", text: String(err).replace(/^Error:\s*/, "") }), + h("div", { class: "row" }, h("button", { text: "Back", onclick: back })), + ); + return; + } + if (!preview) return; + clear(body); + body.append( + h("div", { class: "hint", text: install ? change.installText : change.removeText }), + renderDiff(preview.diff), + h("div", { class: "row" }, h("span", { class: "path", text: `Backup → ${preview.backup}` })), + ); + const confirm = h("button", { + class: install ? "primary" : "danger", + text: install ? change.installButton : change.removeButton, + }); + confirm.addEventListener("click", async () => { + confirm.disabled = true; + try { + const backup = await change.apply(install, preview.fingerprint); + clear(body); + body.append(h("div", { class: "notice ok", text: change.done(String(backup)) })); + window.setTimeout(applied, 2600); + } catch (err) { + confirm.disabled = false; + body.append(h("div", { class: "notice err", text: `Could not write: ${String(err)}` })); + } + }); + body.append(h("div", { class: "row" }, confirm, h("button", { text: "Cancel", onclick: back }))); +} + // ── Claude Code section ─────────────────────────────────────────────────────── function claudeSection(status: HookStatus): HTMLElement { @@ -52,11 +136,14 @@ function claudeSection(status: HookStatus): HTMLElement { body, ); + const redraw = () => { + clear(body); + draw(); + }; const rebuild = async () => { const fresh = await Bridge.hooksStatus(); if (fresh) Object.assign(status, fresh); - clear(body); - draw(); + redraw(); const head = section.querySelector("h2")!; clear(head); head.append(statusDot(status.installed), h("span", { text: "Claude Code" })); @@ -92,7 +179,7 @@ function claudeSection(status: HookStatus): HTMLElement { const install = h("button", { class: "primary", text: status.installed ? "Reinstall hooks…" : "Install hooks…", - onclick: () => showPreview(true), + onclick: () => void reviewChange(body, HOOKS_CHANGE, true, redraw, () => void rebuild()), }); // Writing hook commands that point at a relay which isn't there would give // every Claude Code session a broken hook and nothing to show for it. @@ -105,66 +192,80 @@ function claudeSection(status: HookStatus): HTMLElement { actions.append(h("button", { class: "danger", text: "Uninstall hooks…", - onclick: () => showPreview(false), + onclick: () => void reviewChange(body, HOOKS_CHANGE, false, redraw, () => void rebuild()), })); } body.append(actions); } - async function showPreview(install: boolean) { - let preview; - try { - preview = await Bridge.hooksPreview(install); - } catch (err) { - // An unreadable or invalid settings.json stops here rather than being - // treated as empty and written over. - clear(body); - body.append( - h("div", { class: "notice err", text: String(err).replace(/^Error:\s*/, "") }), - h("div", { class: "row" }, h("button", { - text: "Back", - onclick: () => { clear(body); draw(); }, - })), - ); - return; - } - if (!preview) return; + draw(); + return section; +} + +// ── Plan usage section ──────────────────────────────────────────────────────── + +/** + * The 5-hour and weekly limits in the island's header. They come from Claude + * Code's status line, so the relay has to be the status line first: turning the + * switch on without it starts the install, and the switch only stays on once + * that has been confirmed. A status line the user had keeps working. + */ +function planSection(status: HookStatus): HTMLElement { + const body = h("div", { style: "display:flex;flex-direction:column;gap:12px" }); + const section = h("section", {}, h("h2", {}, h("span", { text: "Plan usage" })), body); + + const redraw = () => { clear(body); + draw(); + }; + const rebuild = async () => { + const fresh = await Bridge.hooksStatus(); + if (fresh) Object.assign(status, fresh); + // Cancelled or failed: a switch that was waiting for the install falls back. + if (!status.planRelayInstalled) settings.showPlanInNotch = false; + redraw(); + }; + + function draw() { + // The switch shows "on" while the install it asked for is being reviewed. + const sw = toggle(settings.showPlanInNotch, (on) => { + if (!on) { + settings.showPlanInNotch = false; + void save(); + } else if (status.planRelayInstalled) { + settings.showPlanInNotch = true; + void save(); + } else { + // Turned on before the relay is in: install it first; it stays on once confirmed. + void reviewChange(body, STATUS_LINE_CHANGE, true, () => void rebuild(), () => { + settings.showPlanInNotch = true; + void save().then(rebuild); + }); + } + }); body.append( h("div", { class: "hint", - text: install - ? "This is exactly what will change in your settings.json. Your own hooks are left untouched." - : "This removes Coucou's entries only. Your own hooks are left untouched.", + text: "Shows your Claude plan usage (5-hour and weekly limits) in the island's header. Coucou adds a status line relay to ~/.claude/settings.json; if you already have a status line, it keeps working as before. Pro and Max plans only.", }), - renderDiff(preview.diff), + h("div", { class: "row" }, h("label", { text: "Show in the notch" }), sw), h("div", { class: "row" }, - h("span", { class: "path", text: `Backup → ${preview.backup}` }), + h("label", { text: "Relay" }), + statusDot(status.planRelayInstalled), + h("span", { class: "hint", text: status.planRelayInstalled ? "installed" : "not installed" }), + status.planRelayInstalled + ? h("button", { + class: "danger", + text: "Uninstall relay…", + onclick: () => void reviewChange(body, STATUS_LINE_CHANGE, false, redraw, () => void rebuild()), + }) + : h("button", { + class: "primary", + text: "Install relay…", + onclick: () => void reviewChange(body, STATUS_LINE_CHANGE, true, redraw, () => void rebuild()), + }), ), ); - const confirm = h("button", { - class: install ? "primary" : "danger", - text: install ? "Back up and write" : "Back up and remove", - }); - confirm.addEventListener("click", async () => { - confirm.disabled = true; - try { - const backup = await Bridge.hooksApply(install, preview.fingerprint); - clear(body); - body.append(h("div", { - class: "notice ok", - text: `Done. Previous settings saved as ${backup}. Open a new Claude Code session to pick the hooks up.`, - })); - window.setTimeout(() => void rebuild(), 2600); - } catch (err) { - confirm.disabled = false; - body.append(h("div", { class: "notice err", text: `Could not write: ${String(err)}` })); - } - }); - body.append(h("div", { class: "row" }, confirm, h("button", { - text: "Cancel", - onclick: () => { clear(body); draw(); }, - }))); } draw(); @@ -386,9 +487,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 +530,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(); }), @@ -426,7 +551,7 @@ async function main() { version = boot.version; } const status = (await Bridge.hooksStatus()) ?? { - installed: false, settingsPath: "", hookPath: "", hookReady: false, + installed: false, planRelayInstalled: false, settingsPath: "", hookPath: "", hookReady: false, }; const hasKey = (await Bridge.secretPresent("anthropic-api-key")) ?? false; @@ -442,6 +567,7 @@ async function main() { root.append( h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), + planSection(status), apiSection(hasKey), integrationsSection(present), generalSection(), diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c8..5da649e2c9 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,442 @@ 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; } + +/* ── Plan usage: the pill in the header and the card behind it ─────────────── */ + +.header-actions { + align-items: center; +} + +.plan-pill { + --plan: #6b7079; + display: flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border: 1px solid color-mix(in srgb, var(--plan) 14%, transparent); + border-radius: 999px; + background: #0e0f11; + color: var(--dim-3); + font: 600 10px var(--font); + white-space: nowrap; + cursor: default; + transition: background 0.18s, border-color 0.18s, color 0.18s; +} + +.plan-pill:hover, +.plan-pill.active { + background: color-mix(in srgb, var(--plan) 18%, transparent); + border-color: color-mix(in srgb, var(--plan) 55%, transparent); + color: color-mix(in srgb, var(--plan) 70%, white); +} + +.plan-pill-dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.plan-card { + position: relative; + z-index: 1; + padding-top: 10px; +} + +.plan-head { + display: flex; + align-items: center; + gap: 6px; + padding: 0 36px 0 108px; + white-space: nowrap; +} + +.plan-head > * { + flex: 0 0 auto; +} + +.plan-title { + font: 600 12px var(--font); + color: var(--ink); +} + +.plan-sub { + flex: 0 1 auto !important; /* the one part that gives way, with an ellipsis */ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font: 400 11px var(--font); + color: var(--dim-2); +} + +.plan-rows { + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px 12px 0 108px; +} + +.plan-row { + display: flex; + align-items: center; + gap: 5px; + font: 400 11px var(--font); + white-space: nowrap; +} + +.plan-label { + flex: 0 0 40px; + color: var(--dim-3); +} + +.plan-bar { + flex: 0 0 50px; + height: 4px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.plan-fill { + display: block; + height: 100%; + border-radius: 2px; +} + +.plan-pct { + flex: 0 0 30px; + text-align: right; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: #c5c8cd; +} + +.plan-reset-icon { + display: inline-flex; + color: var(--dim-3); +} + +.plan-reset { + font-size: 10px; + color: var(--dim-3); +} + +.plan-none { + color: var(--dim-3); +} + +/* 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 0000000000..9a690a1751 --- /dev/null +++ b/windows/src/views/fa.ts @@ -0,0 +1,41 @@ +// 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"], + arrowRotateRight: [512, 512, "M436.7 74.7L448 85.4 448 32c0-17.7 14.3-32 32-32s32 14.3 32 32l0 128c0 17.7-14.3 32-32 32l-128 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l47.9 0-7.6-7.2c-.2-.2-.4-.4-.6-.6-75-75-196.5-75-271.5 0s-75 196.5 0 271.5 196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c99.9-99.9 261.7-100 361.7-.3z"], + 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 d2ff82475b..80688b8281 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 b8ad73c8fb..c966a4f797 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 0000000000..96e86a5464 --- /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 0000000000..0be2386683 --- /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 72b18023ef..e2e9cadbcd 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/usage.ts b/windows/src/views/usage.ts new file mode 100644 index 0000000000..138d70ccc4 --- /dev/null +++ b/windows/src/views/usage.ts @@ -0,0 +1,186 @@ +// Claude plan usage: the 5-hour and weekly limits, as a pill in the island's header +// and a card behind it. Same behaviour as ClaudePlanGauge / ClaudePlanCardView on +// the Mac (docs/INTEGRATIONS.md, "Jauge de forfait Claude"). +// +// The numbers come from Claude Code's own status line (`rate_limits` in what it +// hands its status-line command), through the relay: nothing is read from any +// credentials and nothing is fetched. They exist for Pro and Max plans only. + +import { localized, uiLocale } from "../core/i18n"; +import { State } from "../core/state"; +import { clear, dot, h } from "./dom"; +import { fa } from "./fa"; + +export interface PlanWindow { + /** 0–100, clamped. */ + usedPct: number; + /** Epoch milliseconds. */ + resetsAt: number; +} + +export interface PlanUsage { + fiveHour?: PlanWindow; + sevenDay?: PlanWindow; + /** When the numbers arrived (epoch ms). */ + updatedAt: number; +} + +const DAY_MS = 86_400_000; + +function parseWindow(raw: unknown, now: number): PlanWindow | undefined { + const w = raw as { used_percentage?: unknown; resets_at?: unknown } | null; + if (typeof w?.used_percentage !== "number" || typeof w.resets_at !== "number") return undefined; + // Anything outside 0–200 is not a percentage; 100–200 is a plan over its limit, shown full. + if (!(w.used_percentage >= 0 && w.used_percentage <= 200)) return undefined; + // resets_at is epoch seconds. A date more than 400 days away is milliseconds in disguise. + if (!(w.resets_at > 0) || w.resets_at * 1000 > now + 400 * DAY_MS) return undefined; + return { usedPct: Math.min(100, w.used_percentage), resetsAt: w.resets_at * 1000 }; +} + +/** `rate_limits` from a status-line call, or null when it holds neither window. */ +export function parsePlanUsage(rateLimits: unknown, now = Date.now()): PlanUsage | null { + const rl = rateLimits as { five_hour?: unknown; seven_day?: unknown } | null; + const fiveHour = parseWindow(rl?.five_hour, now); + const sevenDay = parseWindow(rl?.seven_day, now); + return fiveHour || sevenDay ? { fiveHour, sevenDay, updatedAt: now } : null; +} + +/** What to show for a window: 0 once its reset time has passed. */ +export const effectivePct = (w: PlanWindow, now = Date.now()): number => (w.resetsAt <= now ? 0 : w.usedPct); + +/** The higher of the two effective percentages; null when there are no windows. */ +export function dominantPct(u: PlanUsage | null, now = Date.now()): number | null { + const pcts = [u?.fiveHour, u?.sevenDay].filter((w): w is PlanWindow => !!w).map((w) => effectivePct(w, now)); + return pcts.length ? Math.max(...pcts) : null; +} + +/** Green below 50 %, orange up to 80 %, red above, grey without data. */ +export function planColor(pct: number | null): string { + if (pct == null) return "#6B7079"; + return pct < 50 ? "#22C55E" : pct < 80 ? "#F59E0B" : "#F4505E"; +} + +/** The colour the pill (and Mochi, while the card is open) has right now. */ +export const currentPlanColor = (): string => planColor(dominantPct(State.planUsage)); + +/** The pill is in the header: on the overview, once turned on, once the relay is in. */ +export function planPillVisible(): boolean { + return State.view === "overview" && State.settings.showPlanInNotch && State.settings.planRelayInstalled; +} + +const TEXT = { + en: { + plan: "Claude plan", waiting: "Waiting for a Claude Code reply", justNow: "just now", + minAgo: (n: number) => `${n} min ago`, hAgo: (n: number) => `${n} h ago`, + fiveHours: "5 hours", week: "Week", resetting: "Resetting…", + inH: (h: number, m: number) => `in ${h} h ${m}`, inMin: (m: number) => `in ${m} min`, + }, + de: { + plan: "Claude-Abo", waiting: "Warte auf eine Antwort von Claude Code", justNow: "gerade eben", + minAgo: (n: number) => `vor ${n} Min.`, hAgo: (n: number) => `vor ${n} Std.`, + fiveHours: "5 Stunden", week: "Woche", resetting: "Setzt zurück…", + inH: (h: number, m: number) => `in ${h} Std. ${m}`, inMin: (m: number) => `in ${m} Min.`, + }, + fr: { + plan: "Forfait Claude", waiting: "En attente d'une réponse de Claude Code", justNow: "à l'instant", + minAgo: (n: number) => `il y a ${n} min`, hAgo: (n: number) => `il y a ${n} h`, + fiveHours: "5 heures", week: "Semaine", resetting: "Réinitialisation…", + inH: (h: number, m: number) => `dans ${h} h ${m}`, inMin: (m: number) => `dans ${m} min`, + }, +}; + +/** "Claude 73%" on the pill; "Claude —" while no numbers have come in. */ +export function pillLabel(): string { + const pct = dominantPct(State.planUsage); + return pct == null ? "Claude —" : `Claude ${Math.round(pct)}%`; +} + +/** The pill itself: colour dot and label, lit while hovered or while the card is open. */ +export function buildPlanPill(): { el: HTMLElement; sync: () => void } { + const label = h("span", { class: "plan-pill-label" }); + const dotEl = h("i", { class: "plan-pill-dot" }); + const el = h("button", { + class: "plan-pill", + title: "Claude plan usage", + onclick: () => { + State.showingPlanDetail = !State.showingPlanDetail; + State.notify(); + }, + }, dotEl, label); + return { + el, + sync() { + const color = currentPlanColor(); + el.style.setProperty("--plan", color); + el.classList.toggle("active", State.showingPlanDetail); + dotEl.style.background = color; + label.textContent = pillLabel(); + }, + }; +} + +function subtitle(u: PlanUsage | null, now: number): string { + const t = localized(TEXT); + if (!u) return t.waiting; + const mins = Math.floor((now - u.updatedAt) / 60_000); + return mins < 1 ? t.justNow : mins < 60 ? t.minAgo(mins) : t.hAgo(Math.floor(mins / 60)); +} + +/** "in 1 h 20" / "in 5 min" for the 5-hour window, "Mon 9:00" for the week. */ +function resetLabel(w: PlanWindow, weekly: boolean, now: number): string { + const t = localized(TEXT); + const secs = (w.resetsAt - now) / 1000; + if (secs <= 0) return t.resetting; + if (weekly) { + return new Date(w.resetsAt).toLocaleString(uiLocale(), { weekday: "short", hour: "numeric", minute: "2-digit", hour12: false }); + } + const hours = Math.floor(secs / 3600); + const mins = Math.floor((secs % 3600) / 60); + return hours > 0 ? t.inH(hours, mins) : t.inMin(mins); +} + +function gaugeRow(label: string, w: PlanWindow | undefined, weekly: boolean, now: number): HTMLElement { + const row = h("div", { class: "plan-row" }, h("span", { class: "plan-label", text: label })); + if (!w) { + row.append(h("span", { class: "plan-none", text: "—" })); + return row; + } + const pct = effectivePct(w, now); + const fill = h("i", { class: "plan-fill" }); + fill.style.width = `${pct}%`; + fill.style.background = planColor(pct); + row.append( + h("span", { class: "plan-bar" }, fill), + h("span", { class: "plan-pct", text: `${Math.round(pct)}%` }), + h("span", { class: "plan-reset-icon" }, fa("arrowRotateRight", 8)), + h("span", { class: "plan-reset", text: resetLabel(w, weekly, now) }), + ); + return row; +} + +/** The card that replaces the left card while the pill is open. */ +export class PlanCard { + readonly el = h("div", { class: "plan-card" }); + private key = ""; + + /** Re-renders when the numbers change or a minute has gone by (the countdowns). */ + sync(now = Date.now()) { + const u = State.planUsage; + const key = `${JSON.stringify(u)}|${State.settings.language}|${Math.floor(now / 60_000)}`; + if (key === this.key) return; + this.key = key; + clear(this.el); + const t = localized(TEXT); + this.el.append( + h("div", { class: "plan-head" }, + dot(currentPlanColor(), 7), + h("span", { class: "plan-title", text: t.plan }), + h("span", { class: "plan-sub", text: subtitle(u, now) }), + ), + h("div", { class: "plan-rows" }, + gaugeRow(t.fiveHours, u?.fiveHour, false, now), + gaugeRow(t.week, u?.sevenDay, true, now), + ), + ); + } +} diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f7..658ce96aa7 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -4,11 +4,14 @@ 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 { PlanCard, buildPlanPill, planPillVisible } from "./usage"; import { buildChoose, buildUpload, buildUploading } from "./upload"; import { renderIntegrationCard, type IntegrationCardHooks } from "./integrations"; @@ -78,12 +81,13 @@ 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 planPill = buildPlanPill(); + 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(); @@ -94,21 +98,24 @@ export function buildHeader(actions: ViewActions): ViewHost { "div", { id: "header" }, h("div", { class: "tabs" }, tabHome, tabChat, tabDrop), - h("div", { class: "header-actions" }, gearBtn, soundBtn), + h("div", { class: "header-actions" }, planPill.el, gearBtn, soundBtn), ); return { 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)); + const pillOn = planPillVisible(); + planPill.el.style.display = pillOn ? "" : "none"; + if (pillOn) planPill.sync(); el.style.opacity = v === "confused" ? "0" : "1"; }, }; @@ -126,9 +133,17 @@ 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); + // Opened from the plan pill in the header: stands in for the left card. + const plan = new PlanCard(); const el = h("div", { class: "view overview" }, h("div", { class: "left" }, left), @@ -138,7 +153,7 @@ function buildOverview(actions: ViewActions): ViewHost { let pillIds = ""; let detailOpen = false; let lastFocus: string | null = null; - let mode: "ticker" | "card" | null = null; + let mode: "ticker" | "card" | "plan" | null = null; let cardKey = ""; const hooks: IntegrationCardHooks = { @@ -162,6 +177,7 @@ function buildOverview(actions: ViewActions): ViewHost { el, tick(nowMs: number) { if (mode === "ticker") ticker.tick(nowMs); + if (mode === "plan") plan.sync(); }, sync() { const task = State.focusTask; @@ -177,7 +193,18 @@ function buildOverview(actions: ViewActions): ViewHost { const sessionActive = task?.id === "integration_claude" && (task.state !== "idle" || task.steps.length > 0); - if (task && sessionActive) { + const planOpen = State.showingPlanDetail && planPillVisible(); + if (mode === "plan" && !planOpen) mode = null; + + if (planOpen) { + if (mode !== "plan") { + clear(leftBody); + leftBody.append(plan.el); + mode = "plan"; + cardKey = ""; + } + plan.sync(); + } else if (task && sessionActive) { if (mode !== "ticker") { clear(leftBody); leftBody.append(tickerBody); @@ -212,7 +239,8 @@ function buildOverview(actions: ViewActions): ViewHost { } } - jump.style.display = detailOpen ? "none" : ""; + jump.style.display = detailOpen || mode === "plan" ? "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 +422,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 +525,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());