diff --git a/windows/README.md b/windows/README.md index 60374024cf..c463fd917d 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,27 @@ 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. +- **Answer Claude's questions** from the island, as on the Mac: when Claude Code asks + something (`AskUserQuestion`), the choices appear as chips: one click answers a single + choice, several questions or a multi-select use Next / Send, "Other…" takes free text, and + "Reply in terminal" hands the question back. Needs Claude Code 2.1.85 or newer and the + `--ask` hook; hooks installed before that show "Hooks outdated" in Settings with an update + button (you see the diff first). If Coucou is closed or can't show the question, Claude + Code asks in the terminal as usual. +- **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..14d1c9000e 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -10,15 +10,19 @@ //! * Every step runs under a deadline enforced by the main thread, so a pipe that //! accepts the connection and then stops reading cannot wedge the session //! either: we abandon the worker and exit. -//! * Only `PermissionRequest` waits for an answer, because approving from the -//! island is the whole point. No answer means empty stdout, and Claude Code -//! asks in the terminal exactly as if Coucou were not installed. +//! * Only `PermissionRequest` and a question (`--ask`) wait for an answer, because +//! answering from the island is the whole point. No answer means empty stdout, +//! and Claude Code asks in the terminal exactly as if Coucou were not installed. //! -//! 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. +//! `coucou-hook --ask` is the `PreToolUse` hook for `AskUserQuestion`: the island +//! shows the choices and what is picked goes back to Claude Code as the answer. 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 +30,11 @@ 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 a question may stay on screen. The hook's own timeout in settings.json +/// is a little longer, so we always answer (or give up) first. +const ASK_BUDGET: Duration = Duration::from_secs(125); +/// 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 +42,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,10 +57,18 @@ 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"; - let budget = if waits_for_answer { DECISION_BUDGET } else { FIRE_AND_FORGET_BUDGET }; + let asking = event == ASK_EVENT; + let waits_for_answer = event == "PermissionRequest" || asking; + let budget = match (asking, waits_for_answer) { + (true, _) => ASK_BUDGET, + (_, true) => DECISION_BUDGET, + _ => FIRE_AND_FORGET_BUDGET, + }; // The worker owns every blocking call. If it overruns the budget we simply // stop listening and exit: the process dying takes the pipe handle with it. @@ -60,7 +80,12 @@ fn main() { }); if let Ok(Some(decision)) = rx.recv_timeout(budget) { - if let Some(json) = decision_json(&decision) { + let output = if asking { + ASK_INPUT.get().and_then(|input| ask_json(&decision, input)) + } else { + decision_json(&decision) + }; + if let Some(json) = output { let mut out = std::io::stdout(); let _ = writeln!(out, "{json}"); let _ = out.flush(); @@ -70,6 +95,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 @@ -86,6 +207,34 @@ fn decision_json(decision: &str) -> Option { )) } +/// What the island sees a question as: not a `PreToolUse`, which it ignores for this tool. +const ASK_EVENT: &str = "AskUserQuestion"; + +/// The question's `tool_input` as Claude Code sent it. The payload we forward is +/// cut short; the answer that goes back must carry the questions whole. +static ASK_INPUT: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// The documented `PreToolUse` output that answers a question: allow the tool and +/// hand it its input with `answers` filled in, so Claude Code does not ask again. +/// `answer` is what the island sent, `{"answers":{"":"