diff --git a/windows/README.md b/windows/README.md
index 60374024cf..7ed8cb7833 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,36 @@ 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.
+- **Local models and Markdown in the chat**, as on the Mac: Settings → Local models connects
+ Ollama or LM Studio (empty address = the usual one on this machine) or any server that
+ speaks the OpenAI API, such as Unsloth (address, optional key kept in the keychain), and
+ "Chat with" switches the chat to it. No key is needed for Ollama and LM Studio. Answers
+ stream in as they are written, `` blocks of reasoning models stay hidden, and a
+ text file you dropped goes along inline (24 000 characters at most; images and PDFs by name
+ only). Answers from every provider are shown with Markdown: headings, lists, bold, inline
+ code, links (http/https only), quotes, and code blocks with a copy button. Unlike the Mac
+ build, the chat does not offer Gemini or OpenAI cloud models.
+- **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":{"":"" | [labels]}}`;
+/// anything else prints nothing, and the terminal asks.
+fn ask_json(answer: &str, tool_input: &serde_json::Value) -> Option {
+ let answers = serde_json::from_str::(answer.trim()).ok()?.get("answers")?.clone();
+ if !answers.as_object().is_some_and(|a| !a.is_empty()) {
+ return None;
+ }
+ let mut input = tool_input.as_object()?.clone();
+ input.insert("answers".into(), answers);
+ Some(
+ serde_json::json!({ "hookSpecificOutput": {
+ "hookEventName": "PreToolUse",
+ "permissionDecision": "allow",
+ "updatedInput": input,
+ }})
+ .to_string(),
+ )
+}
+
/// Reads stdin and returns the payload to forward plus the event name.
fn read_event() -> Option<(String, String)> {
let mut raw = Vec::new();
@@ -110,7 +259,7 @@ fn read_event() -> Option<(String, String)> {
while let Some(arg) = it.next() {
if arg == "--agent" {
agent = it.next().unwrap_or_default();
- } else if arg_event.is_empty() {
+ } else if arg_event.is_empty() && !arg.starts_with("--") {
arg_event = arg;
}
}
@@ -126,7 +275,32 @@ fn read_event() -> Option<(String, String)> {
.map(str::to_string)
.filter(|s| !s.is_empty())
.unwrap_or(arg_event);
+ // `--ask` is only ever installed for AskUserQuestion; whatever the JSON calls it, it is a question.
+ let event = if std::env::args().any(|a| a == "--ask") { ASK_EVENT.to_string() } else { event };
map.insert("hook_event_name".into(), serde_json::Value::String(event.clone()));
+ if event == ASK_EVENT {
+ if let Some(input) = map.get("tool_input") {
+ let _ = ASK_INPUT.set(input.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 +328,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 +346,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 {
@@ -237,6 +472,31 @@ mod tests {
assert!(decision_json("always").unwrap().contains(r#""behavior":"allow""#));
}
+ #[test]
+ fn an_answer_goes_back_as_the_questions_own_input_with_answers_filled_in() {
+ let input = serde_json::json!({ "questions": [{ "question": "Which?", "options": [{ "label": "A" }, { "label": "B" }], "multiSelect": false }] });
+ let out = ask_json(r#"{"answers":{"Which?":"B"}}"#, &input).unwrap();
+ let out: serde_json::Value = serde_json::from_str(&out).unwrap();
+ let hso = &out["hookSpecificOutput"];
+ assert_eq!(hso["hookEventName"], "PreToolUse");
+ assert_eq!(hso["permissionDecision"], "allow");
+ assert_eq!(hso["updatedInput"]["answers"]["Which?"], "B");
+ // The questions go back whole.
+ assert_eq!(hso["updatedInput"]["questions"], input["questions"]);
+ // Multi-select answers are arrays.
+ assert!(ask_json(r#"{"answers":{"Which?":["A","B"]}}"#, &input).is_some());
+ }
+
+ #[test]
+ fn a_question_without_a_usable_answer_prints_nothing_so_the_terminal_asks() {
+ let input = serde_json::json!({ "questions": [] });
+ assert!(ask_json("", &input).is_none());
+ assert!(ask_json("allow", &input).is_none());
+ assert!(ask_json(r#"{"answers":{}}"#, &input).is_none());
+ assert!(ask_json(r#"{"answers":"B"}"#, &input).is_none());
+ assert!(ask_json(r#"{"answers":{"Q":"B"}}"#, &serde_json::json!("not an object")).is_none());
+ }
+
#[test]
fn anything_unrecognised_prints_nothing() {
assert!(decision_json("").is_none());
@@ -245,6 +505,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..74b90ac827 100644
--- a/windows/src-tauri/src/claude.rs
+++ b/windows/src-tauri/src/claude.rs
@@ -22,20 +22,54 @@ 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. \
+Use light Markdown when it helps: short paragraphs, bullet lists, **bold**, `inline code` and fenced code blocks. Avoid tables and big headings: the chat window is small."
+ )
+}
#[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>,
+ /// The conversation with a local model (see local_chat.rs), in the plain
+ /// OpenAI shape a local server understands.
+ pub(crate) local: Mutex>,
}
impl Chat {
pub fn reset(&self) {
self.messages.lock().unwrap().clear();
+ *self.cli_session.lock().unwrap() = None;
+ self.local.lock().unwrap().clear();
}
fn is_empty(&self) -> bool {
@@ -73,11 +107,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 +144,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 +284,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 +296,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..efb205bc37 100644
--- a/windows/src-tauri/src/hooks.rs
+++ b/windows/src-tauri/src/hooks.rs
@@ -33,6 +33,12 @@ pub const HOOK_EVENTS: &[(&str, u64)] = &[
("SubagentStop", 10),
];
+/// `AskUserQuestion` (Claude Code 2.1.85+) comes in as a `PreToolUse`, so it gets
+/// its own entry: matched on that tool, run as `--ask`, and long enough for a human
+/// to answer. The general `PreToolUse` entry ignores that tool.
+const ASK_MATCHER: &str = "AskUserQuestion";
+const ASK_TIMEOUT: u64 = 130;
+
/// Marker that identifies a Coucou entry inside settings.json.
const MARKER: &str = "coucou-hook";
@@ -40,6 +46,10 @@ 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,
+ /// The `--ask` hook is in: without it Claude's questions can't be answered from the island.
+ pub ask_hook_installed: bool,
pub settings_path: String,
pub hook_path: String,
pub hook_ready: bool,
@@ -139,6 +149,19 @@ fn entry_is_ours(entry: &Value) -> bool {
.unwrap_or(false)
}
+/// Coucou's entry for `AskUserQuestion`.
+fn entry_is_ask(entry: &Value) -> bool {
+ entry_is_ours(entry)
+ && entry.get("matcher").and_then(Value::as_str) == Some(ASK_MATCHER)
+ && entry.to_string().contains("--ask")
+}
+
+/// 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();
@@ -162,6 +185,16 @@ fn merged(existing: &Value) -> Value {
"timeout": timeout,
}]
}));
+ if *event == "PreToolUse" {
+ list.push(json!({
+ "matcher": ASK_MATCHER,
+ "hooks": [{
+ "type": "command",
+ "command": hook_command("--ask"),
+ "timeout": ASK_TIMEOUT,
+ }]
+ }));
+ }
hooks.insert((*event).to_string(), Value::Array(list));
}
@@ -250,9 +283,15 @@ pub fn status() -> HookStatus {
.any(entry_is_ours)
})
.unwrap_or(false);
+ let ask_hook_installed = current
+ .pointer("/hooks/PreToolUse")
+ .and_then(Value::as_array)
+ .is_some_and(|list| list.iter().any(entry_is_ask));
let hook_path = settings::hook_exe_path();
HookStatus {
installed,
+ ask_hook_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 +299,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 +325,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 +348,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 +371,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 +656,65 @@ 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 questions_get_their_own_pre_tool_use_entry_that_install_and_removal_handle() {
+ let theirs = json!({ "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "mine" }] }] } });
+ let installed = merged(&theirs);
+ let list = installed["hooks"]["PreToolUse"].as_array().unwrap();
+ // theirs, our general entry, our question entry
+ assert_eq!(list.len(), 3);
+ let ask = list.iter().find(|e| entry_is_ask(e)).unwrap();
+ assert_eq!(ask["matcher"], "AskUserQuestion");
+ assert_eq!(ask["hooks"][0]["timeout"], 130);
+ assert!(ask["hooks"][0]["command"].as_str().unwrap().ends_with(" --ask"));
+ // Installing twice does not stack them up; removing leaves only theirs.
+ assert_eq!(merged(&installed)["hooks"]["PreToolUse"].as_array().unwrap().len(), 3);
+ assert_eq!(without_ours(&installed), theirs);
+ }
+
+ #[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..8dd0914008 100644
--- a/windows/src-tauri/src/lib.rs
+++ b/windows/src-tauri/src/lib.rs
@@ -1,15 +1,18 @@
// Coucou for Windows — app wiring and the commands the island calls.
mod claude;
+mod claude_cli;
mod files;
mod hooks;
mod integrations;
mod island;
+mod local_chat;
mod log;
mod pipe;
mod platform;
mod secrets;
mod settings;
+mod snippet;
mod tray;
use std::process::Command;
@@ -48,7 +51,9 @@ pub struct BootInfo {
fn boot(app: AppHandle, shared: State) -> BootInfo {
let mut settings = shared.settings.lock().unwrap().clone();
// The real state of ~/.claude/settings.json wins over whatever we stored.
- settings.hooks_installed = hooks::status().installed;
+ let hooks_status = hooks::status();
+ settings.hooks_installed = hooks_status.installed;
+ settings.plan_relay_installed = hooks_status.plan_relay_installed;
let screen = island::screen_info(&app, &settings.screen);
BootInfo {
settings,
@@ -117,6 +122,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 +143,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 +187,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,11 +258,43 @@ 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);
}
+/// A question from Claude Code was answered in the island.
+#[tauri::command]
+fn question_answer(app: AppHandle, request_id: String, answers: serde_json::Value) {
+ pipe::answer_question(&app, &request_id, answers);
+}
+
/// The island has the card on screen, so the long wait for a human may begin.
/// Until this arrives the relay only waits a few hundred milliseconds, which is
/// what stops a paused or unresponsive island from freezing Claude Code.
@@ -236,13 +315,43 @@ fn approval_decline(app: AppHandle, request_id: String) {
/// One chat turn. The API key and any file bytes stay on the Rust side.
#[tauri::command]
async fn chat_send(
+ app: AppHandle,
shared: State<'_, Shared>,
chat: State<'_, Chat>,
query: String,
context: Option,
) -> Result {
- let model = shared.settings.lock().unwrap().model.clone();
- claude::send(&chat, &model, query, context).await
+ let s = shared.settings.lock().unwrap().clone();
+ let key = secrets::get(CUSTOM_KEY);
+ match s.chat_provider.as_str() {
+ "ollama" => local_chat::send(&app, &chat, &s.ollama_url, None, &s.ollama_model, &s.language, query, context).await,
+ "lmstudio" => local_chat::send(&app, &chat, &s.lmstudio_url, None, &s.lmstudio_model, &s.language, query, context).await,
+ "custom" => local_chat::send(&app, &chat, &s.custom_url, key.as_deref(), &s.custom_model, &s.language, query, context).await,
+ _ => claude::send(&chat, &s.model, &s.language, query, context).await,
+ }
+}
+
+/// Keychain entry of the key of the user's own OpenAI-compatible server.
+const CUSTOM_KEY: &str = "openai-compatible-key";
+
+#[cfg(test)]
+#[test]
+fn the_openai_compatible_key_may_be_stored() {
+ // secrets.rs refuses anything outside its list ("unknown key").
+ assert!(secrets::KNOWN_KEYS.contains(&CUSTOM_KEY));
+}
+
+/// Settings → Local models → Connect: does the server answer, and which models does it have?
+/// `provider` picks the usual address for an empty field.
+#[tauri::command]
+async fn local_connect(provider: String, url: String) -> Result {
+ // An OpenAI-compatible server has no usual address; the others do, and take no key.
+ let (usual, key) = match provider.as_str() {
+ "lmstudio" => ("http://127.0.0.1:1234", None),
+ "custom" => ("", secrets::get(CUSTOM_KEY)),
+ _ => ("http://127.0.0.1:11434", None),
+ };
+ local_chat::connect(&url, usual, key.as_deref()).await
}
#[tauri::command]
@@ -381,17 +490,24 @@ 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,
+ question_answer,
approval_ack,
approval_decline,
log_line,
chat_send,
+ local_connect,
chat_reset,
ingest_file,
secret_present,
diff --git a/windows/src-tauri/src/local_chat.rs b/windows/src-tauri/src/local_chat.rs
new file mode 100644
index 0000000000..3a7e5c7e48
--- /dev/null
+++ b/windows/src-tauri/src/local_chat.rs
@@ -0,0 +1,362 @@
+// Chat with a model running on the user's own machine (Ollama, LM Studio) — the same
+// integration as LocalChat.swift: any server that speaks the OpenAI API. Those two
+// need no key; "OpenAI-compatible" is the same for any other such server (Unsloth,
+// vLLM, llama.cpp…), with an optional key.
+//
+// The answer is streamed token by token. Each step goes to the island as a
+// `chat-delta` event carrying the text visible so far, and the island shows it
+// growing; the reply of the command is the finished text.
+//
+// Nothing leaves the machine unless the user pointed the server address somewhere
+// else: that address is the only place this module talks to.
+
+use std::time::{Duration, Instant};
+
+use serde::Serialize;
+use serde_json::{json, Value};
+use tauri::{AppHandle, Emitter};
+
+use crate::claude::{system_prompt, Chat, ChatContext, ChatReply};
+use crate::island::WINDOW_LABEL;
+
+const MAX_TOKENS: u32 = 4096;
+/// A text file is sent inline up to this many characters; the rest is cut.
+const MAX_INLINE_CHARS: usize = 24_000;
+/// The island is told about new text at most this often.
+const DELTA_INTERVAL: Duration = Duration::from_millis(1000 / 15);
+/// Ollama and LM Studio ignore the key, but some OpenAI clients insist on sending one.
+const NO_KEY: &str = "ollama";
+
+fn bearer(key: Option<&str>) -> String {
+ format!("Bearer {}", key.filter(|k| !k.is_empty()).unwrap_or(NO_KEY))
+}
+/// Models that embed or rank rather than chat are left out of the list.
+const NOT_CHAT: &[&str] = &["embed", "bge-", "all-minilm", "clip", "rerank"];
+
+/// A server that answered: its address as it will be stored, and the models it has.
+#[derive(Debug, Serialize)]
+pub struct Server {
+ pub url: String,
+ pub models: Vec,
+}
+
+fn unreachable(url: &str) -> String {
+ format!("Cannot reach {url}. Is the server running?")
+}
+
+/// Drops trailing slashes and the documentation sub-paths people paste (`/api`, `/v1`).
+pub fn normalise_url(raw: &str) -> String {
+ let mut s = raw.trim();
+ while let Some(rest) = s.strip_suffix('/') {
+ s = rest;
+ }
+ for suffix in ["/api", "/v1"] {
+ if let Some(rest) = s.strip_suffix(suffix) {
+ s = rest;
+ }
+ }
+ s.to_string()
+}
+
+/// `delta.content` of one OpenAI server-sent event line; none for anything else
+/// (other lines, `[DONE]`, a missing or null content).
+fn parse_sse_delta(line: &str) -> Option {
+ let payload = line.strip_prefix("data: ")?;
+ if payload == "[DONE]" {
+ return None;
+ }
+ let json: Value = serde_json::from_str(payload).ok()?;
+ json.pointer("/choices/0/delta/content")?.as_str().map(str::to_string)
+}
+
+/// Removes finished `… ` blocks (reasoning models such as DeepSeek-R1).
+fn filter_thinking_blocks(text: &str) -> String {
+ let mut out = String::with_capacity(text.len());
+ let mut rest = text;
+ while let Some(start) = rest.find("") {
+ let Some(len) = rest[start..].find(" ") else { break };
+ out.push_str(&rest[..start]);
+ rest = &rest[start + len + " ".len()..];
+ }
+ out.push_str(rest);
+ out.trim().to_string()
+}
+
+/// For the text shown while streaming: finished blocks go, and so does everything
+/// after a block that has not been closed yet.
+fn progressive_filter(text: &str) -> String {
+ let cleaned = filter_thinking_blocks(text);
+ match cleaned.find("") {
+ Some(at) => cleaned[..at].trim().to_string(),
+ None => cleaned,
+ }
+}
+
+/// The chat models a server has (`GET /v1/models`), or why it can't be reached.
+pub async fn models(base: &str, key: Option<&str>) -> Result, String> {
+ let client = reqwest::Client::builder()
+ .timeout(Duration::from_secs(5))
+ .build()
+ .map_err(|e| e.to_string())?;
+ let reply = client
+ .get(format!("{base}/v1/models"))
+ .header("Authorization", bearer(key))
+ .send()
+ .await
+ .map_err(|_| unreachable(base))?;
+ if !reply.status().is_success() {
+ return Err(unreachable(base));
+ }
+ let body: Value = reply.json().await.map_err(|_| unreachable(base))?;
+ let items = body.get("data").and_then(Value::as_array).ok_or_else(|| unreachable(base))?;
+ Ok(items
+ .iter()
+ .filter_map(|m| m.get("id").and_then(Value::as_str))
+ .filter(|id| !NOT_CHAT.iter().any(|n| id.to_lowercase().contains(n)))
+ .map(str::to_string)
+ .collect())
+}
+
+/// The "Connect" button: checks the server answers and reports what it has. An
+/// empty address means the usual one on this machine.
+pub async fn connect(url: &str, default_url: &str, key: Option<&str>) -> Result {
+ let url = normalise_url(if url.trim().is_empty() { default_url } else { url });
+ if url.is_empty() {
+ return Err("Enter the server address first.".into());
+ }
+ let models = models(&url, key).await?;
+ Ok(Server { url, models })
+}
+
+/// What the file adds to the first message: text inline (cut), anything else by name.
+fn file_note(name: &str, path: &str) -> String {
+ let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
+ let binary = matches!(ext.as_str(), "pdf" | "jpg" | "jpeg" | "png" | "gif" | "webp");
+ match std::fs::read_to_string(path) {
+ Ok(text) if !binary => {
+ let body: String = text.chars().take(MAX_INLINE_CHARS).collect();
+ format!("File: {name}\nFile contents:\n{body}")
+ }
+ _ => format!("File: {name}"),
+ }
+}
+
+/// One chat turn with a local model. The history is kept here, apart from Claude's
+/// (whose messages carry tool blocks a local server would not understand).
+pub async fn send(
+ app: &AppHandle,
+ chat: &Chat,
+ base: &str,
+ key: Option<&str>,
+ model: &str,
+ language: &str,
+ query: String,
+ context: Option,
+) -> Result {
+ let base = normalise_url(base);
+ if base.is_empty() {
+ return Err("Connect a local model server in Settings first.".into());
+ }
+ if model.is_empty() {
+ return Err("Pick a model in Settings → Local models first.".into());
+ }
+
+ let mut text = String::new();
+ // File / window context rides along with the first message only.
+ if chat.local.lock().unwrap().is_empty() {
+ match &context {
+ Some(ChatContext::File { name, path }) => text.push_str(&format!("{}\n\n", file_note(name, path))),
+ Some(ChatContext::Window { app_name, title, url }) => {
+ text.push_str(&format!("Context — App: {app_name}, Window: {title}"));
+ if let Some(url) = url {
+ text.push_str(&format!(", URL: {url}"));
+ }
+ text.push_str("\n\n");
+ }
+ None => {}
+ }
+ }
+ text.push_str(&query);
+
+ let user = json!({ "role": "user", "content": text });
+ let mut messages = vec![json!({ "role": "system", "content": local_system_prompt(language) })];
+ messages.extend(chat.local.lock().unwrap().iter().cloned());
+ messages.push(user.clone());
+
+ let answer = stream(app, &base, key, model, &messages).await?;
+ if answer.is_empty() {
+ return Err("No response text.".into());
+ }
+ let mut history = chat.local.lock().unwrap();
+ history.push(user);
+ history.push(json!({ "role": "assistant", "content": answer }));
+ Ok(ChatReply { text: answer })
+}
+
+/// Claude's system prompt, minus the web search a local model does not have.
+fn local_system_prompt(language: &str) -> String {
+ system_prompt(language).replace("You have web search access and can help", "You can help")
+}
+
+async fn stream(app: &AppHandle, base: &str, key: Option<&str>, model: &str, messages: &[Value]) -> Result {
+ let client = reqwest::Client::builder()
+ .timeout(Duration::from_secs(300))
+ .build()
+ .map_err(|e| e.to_string())?;
+ let mut reply = client
+ .post(format!("{base}/v1/chat/completions"))
+ .header("Authorization", bearer(key))
+ .json(&json!({ "model": model, "messages": messages, "stream": true, "max_tokens": MAX_TOKENS }))
+ .send()
+ .await
+ .map_err(|_| unreachable(base))?;
+
+ let status = reply.status();
+ if status.as_u16() == 404 {
+ return Err(format!("Model '{model}' is not installed."));
+ }
+ if !status.is_success() {
+ let body = reply.text().await.unwrap_or_default();
+ let why = serde_json::from_str::(&body)
+ .ok()
+ .and_then(|v| v.pointer("/error/message").and_then(Value::as_str).map(str::to_string));
+ return Err(why.unwrap_or_else(|| format!("HTTP {}", status.as_u16())));
+ }
+
+ let mut accumulated = String::new();
+ let mut pending = Vec::::new();
+ let mut last = Instant::now() - DELTA_INTERVAL;
+ while let Some(chunk) = reply.chunk().await.map_err(|_| unreachable(base))? {
+ pending.extend_from_slice(&chunk);
+ // Whole lines only: a chunk may end in the middle of one, or of a character.
+ while let Some(end) = pending.iter().position(|b| *b == b'\n') {
+ let line: Vec = pending.drain(..=end).collect();
+ let line = String::from_utf8_lossy(&line);
+ if let Some(delta) = parse_sse_delta(line.trim_end()) {
+ accumulated.push_str(&delta);
+ }
+ }
+ if last.elapsed() >= DELTA_INTERVAL {
+ last = Instant::now();
+ let _ = app.emit_to(WINDOW_LABEL, "chat-delta", progressive_filter(&accumulated));
+ }
+ }
+ // The last state always goes out, whatever the throttle skipped.
+ let _ = app.emit_to(WINDOW_LABEL, "chat-delta", progressive_filter(&accumulated));
+ Ok(filter_thinking_blocks(&accumulated))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_address_loses_slashes_and_the_paths_people_paste() {
+ assert_eq!(normalise_url(" http://localhost:11434/ "), "http://localhost:11434");
+ assert_eq!(normalise_url("http://localhost:11434/v1"), "http://localhost:11434");
+ assert_eq!(normalise_url("http://localhost:1234/v1/"), "http://localhost:1234");
+ assert_eq!(normalise_url("http://localhost:11434/api"), "http://localhost:11434");
+ assert_eq!(normalise_url(""), "");
+ }
+
+ #[test]
+ fn only_content_deltas_are_taken_from_the_event_stream() {
+ let line = r#"data: {"choices":[{"delta":{"content":"Hel"}}]}"#;
+ assert_eq!(parse_sse_delta(line).as_deref(), Some("Hel"));
+ assert_eq!(parse_sse_delta("data: [DONE]"), None);
+ assert_eq!(parse_sse_delta(""), None);
+ assert_eq!(parse_sse_delta(": keep-alive"), None);
+ assert_eq!(parse_sse_delta(r#"data: {"choices":[{"delta":{"role":"assistant"}}]}"#), None);
+ assert_eq!(parse_sse_delta(r#"data: {"choices":[{"delta":{"content":null}}]}"#), None);
+ assert_eq!(parse_sse_delta("data: not json"), None);
+ }
+
+ #[test]
+ fn finished_thinking_blocks_go_and_an_open_one_hides_what_follows() {
+ assert_eq!(filter_thinking_blocks("hmm \n\nThe answer."), "The answer.");
+ assert_eq!(filter_thinking_blocks("ax by c"), "abc");
+ assert_eq!(filter_thinking_blocks("plain"), "plain");
+ // Streaming: nothing of an unfinished block shows.
+ assert_eq!(progressive_filter("Sure. let me think"), "Sure.");
+ assert_eq!(progressive_filter("still thinking"), "");
+ assert_eq!(progressive_filter("done Visible again"), "Visible");
+ // A tag that is only half written is not a block yet.
+ assert_eq!(progressive_filter("text String {
+ use std::io::{Read, Write};
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+ let url = format!("http://{}", listener.local_addr().unwrap());
+ let reply = format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
+ std::thread::spawn(move || {
+ let (mut conn, _) = listener.accept().unwrap();
+ let mut buf = [0u8; 2048];
+ let _ = conn.read(&mut buf);
+ let _ = conn.write_all(reply.as_bytes());
+ });
+ url
+ }
+
+ fn block_on(f: impl std::future::Future) -> T {
+ tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(f)
+ }
+
+ #[test]
+ fn the_model_list_leaves_out_embedding_models_and_says_when_nothing_answers() {
+ let url = serve_once("200 OK", r#"{"data":[{"id":"llama3.2"},{"id":"nomic-embed-text"},{"id":"BGE-large"},{"id":"qwen2.5-coder"}]}"#);
+ assert_eq!(block_on(models(&url, None)).unwrap(), vec!["llama3.2", "qwen2.5-coder"]);
+
+ // Wrong status, not JSON, or nobody home: the same plain message.
+ let url = serve_once("500 Internal Server Error", "{}");
+ assert_eq!(block_on(models(&url, None)).unwrap_err(), format!("Cannot reach {url}. Is the server running?"));
+ let url = serve_once("200 OK", "not json");
+ assert!(block_on(models(&url, None)).unwrap_err().starts_with("Cannot reach"));
+ assert!(block_on(models("http://127.0.0.1:1", None)).unwrap_err().starts_with("Cannot reach"));
+ }
+
+ #[test]
+ fn connect_takes_the_usual_address_for_an_empty_field_and_cleans_a_pasted_one() {
+ let url = serve_once("200 OK", r#"{"data":[{"id":"m"}]}"#);
+ let server = block_on(connect(&format!("{url}/v1/"), "http://unused", None)).unwrap();
+ assert_eq!(server.url, url);
+ assert_eq!(server.models, vec!["m"]);
+ let url = serve_once("200 OK", r#"{"data":[]}"#);
+ assert_eq!(block_on(connect(" ", &url, None)).unwrap().url, url);
+ }
+
+ #[test]
+ fn a_key_is_sent_as_the_bearer_and_without_one_the_placeholder_is() {
+ assert_eq!(bearer(Some("sk-abc")), "Bearer sk-abc");
+ assert_eq!(bearer(Some("")), "Bearer ollama");
+ assert_eq!(bearer(None), "Bearer ollama");
+ // No usual address and nothing typed: asked for, not guessed.
+ assert_eq!(block_on(connect(" ", "", None)).unwrap_err(), "Enter the server address first.");
+ }
+
+ #[test]
+ fn a_local_model_is_not_told_it_can_search_the_web() {
+ let p = local_system_prompt("en");
+ assert!(!p.contains("web search"));
+ assert!(p.contains("You can help with"));
+ assert!(system_prompt("en").contains("web search"));
+ }
+
+ #[test]
+ fn text_files_go_inline_cut_and_everything_else_by_name() {
+ let dir = std::env::temp_dir().join(format!("coucou-local-chat-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let txt = dir.join("notes.txt");
+ std::fs::write(&txt, "x".repeat(MAX_INLINE_CHARS + 500)).unwrap();
+ let note = file_note("notes.txt", txt.to_str().unwrap());
+ let body = note.strip_prefix("File: notes.txt\nFile contents:\n").expect("inline text");
+ assert_eq!(body.chars().count(), MAX_INLINE_CHARS);
+
+ let png = dir.join("pic.png");
+ std::fs::write(&png, "not really a picture").unwrap();
+ assert_eq!(file_note("pic.png", png.to_str().unwrap()), "File: pic.png");
+ assert_eq!(file_note("gone.txt", "/no/such/file"), "File: gone.txt");
+ let _ = std::fs::remove_dir_all(dir);
+ }
+}
diff --git a/windows/src-tauri/src/pipe.rs b/windows/src-tauri/src/pipe.rs
index 36b6316c24..a44538176d 100644
--- a/windows/src-tauri/src/pipe.rs
+++ b/windows/src-tauri/src/pipe.rs
@@ -2,9 +2,10 @@
//
// Windows: the named pipe `\\.\pipe\coucou-`, one instance per connection.
// Linux: the Unix socket `$XDG_RUNTIME_DIR/coucou.sock`. Every hook event is
-// forwarded to the island as a `hook` event. `PermissionRequest` is the only one
-// that keeps its connection open: it waits for the island's decision and writes
-// it back on the same connection, which is how approving from the island works.
+// forwarded to the island as a `hook` event. `PermissionRequest` and
+// `AskUserQuestion` are the only ones that keep their connection open: they wait
+// for the island's decision (or the answer to the question) and write it back on
+// the same connection, which is how approving and answering from the island work.
//
// Claude Code is never blocked by us. Three things guarantee it:
// * coucou-hook gives the connection 300 ms and exits cleanly if we are closed;
@@ -14,9 +15,10 @@
// * whatever happens we drop the connection after the decision timeout, and
// the terminal takes over.
//
-// What we write back is the bare word `allow` or `deny`. Turning that into the
-// documented hookSpecificOutput JSON is coucou-hook's job, so the wire format
-// Claude Code expects lives in exactly one place.
+// What we write back is the bare word `allow` or `deny`, or for a question the
+// answers as one JSON line. Turning that into the documented hookSpecificOutput
+// JSON is coucou-hook's job, so the wire format Claude Code expects lives in
+// exactly one place.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -35,6 +37,8 @@ use crate::log;
/// Slightly under coucou-hook's own 110 s wait, so we always answer first.
const DECISION_TIMEOUT: Duration = Duration::from_secs(108);
+/// A question takes longer to read and answer; coucou-hook waits 125 s for it.
+const QUESTION_TIMEOUT: Duration = Duration::from_secs(120);
/// How long the island gets to say "the card is up". This is the whole of B4:
/// without it, an island that is paused, hidden behind a crashed webview or
/// simply not listening would leave Claude Code staring at a prompt nobody can
@@ -195,7 +199,7 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) {
.unwrap_or_default()
.to_string();
- if event != "PermissionRequest" {
+ if event != "PermissionRequest" && event != "AskUserQuestion" {
log::line(format!("hook {event}"));
let _ = app.emit_to(WINDOW_LABEL, "hook", payload);
pipe.finish();
@@ -209,10 +213,11 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) {
pending.0.lock().unwrap().insert(id.clone(), tx);
}
payload["request_id"] = json!(id);
- log::line(format!("hook PermissionRequest id={id}"));
+ log::line(format!("hook {event} id={id}"));
let _ = app.emit_to(WINDOW_LABEL, "hook", payload);
- let decision = wait_for_decision(&id, &mut rx).await;
+ let limit = if event == "AskUserQuestion" { QUESTION_TIMEOUT } else { DECISION_TIMEOUT };
+ let decision = wait_for_decision(&id, &mut rx, limit).await;
app.state::().0.lock().unwrap().remove(&id);
// No decision: say nothing at all. coucou-hook then writes nothing to stdout
@@ -225,7 +230,7 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) {
}
/// Two waits: a short one for "the card is up", then the long one for a human.
-async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option {
+async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver, limit: Duration) -> Option {
match tokio::time::timeout(ACK_TIMEOUT, rx.recv()).await {
Ok(Some(Reply::Ack)) => {}
// A click that beats the ack is still a click.
@@ -244,7 +249,7 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option {
log::line(format!("hook id={id} answered {d}"));
Some(d)
@@ -295,3 +300,11 @@ pub fn answer(app: &AppHandle, request_id: &str, decision: &str) {
log::line(format!("decision id={request_id} {word}"));
send(app, request_id, Reply::Decision(word.to_string()), false);
}
+
+/// Called when a question is answered in the island. `answers` maps each question's
+/// text to the label picked (or an array of labels for a multi-select); coucou-hook
+/// turns it into what Claude Code expects.
+pub fn answer_question(app: &AppHandle, request_id: &str, answers: Value) {
+ log::line(format!("answer id={request_id}"));
+ send(app, request_id, Reply::Decision(json!({ "answers": answers }).to_string()), false);
+}
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/secrets.rs b/windows/src-tauri/src/secrets.rs
index 5c37b65291..c45d7279f3 100644
--- a/windows/src-tauri/src/secrets.rs
+++ b/windows/src-tauri/src/secrets.rs
@@ -8,6 +8,7 @@ const SERVICE: &str = "fr.louisraille.coucou";
/// Every key Coucou may store. Anything outside this list is refused.
pub const KNOWN_KEYS: &[&str] = &[
"anthropic-api-key",
+ "openai-compatible-key",
"n8n-url",
"n8n-api-key",
"vercel-token",
diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs
index 2a8d7d359c..29c339363d 100644
--- a/windows/src-tauri/src/settings.rs
+++ b/windows/src-tauri/src/settings.rs
@@ -20,6 +20,45 @@ 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,
+ /// Who the chat talks to: "anthropic" (Claude), or a local model server,
+ /// "ollama", "lmstudio" or "custom" (any OpenAI-compatible server, see local_chat.rs).
+ #[serde(default = "default_provider")]
+ pub chat_provider: String,
+ /// Addresses of the local model servers once connected; empty means not connected.
+ #[serde(default)]
+ pub ollama_url: String,
+ #[serde(default)]
+ pub lmstudio_url: String,
+ /// The model chosen on each local server.
+ #[serde(default)]
+ pub ollama_model: String,
+ #[serde(default)]
+ pub lmstudio_model: String,
+ /// An OpenAI-compatible server of the user's own; its key, if any, is in the keychain.
+ #[serde(default)]
+ pub custom_url: String,
+ #[serde(default)]
+ pub custom_model: 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_provider() -> String {
+ "anthropic".to_string()
}
fn default_model() -> String {
@@ -43,6 +82,16 @@ impl Default for Settings {
autostart: false,
hooks_installed: false,
model: default_model(),
+ language: default_language(),
+ chat_provider: default_provider(),
+ ollama_url: String::new(),
+ lmstudio_url: String::new(),
+ ollama_model: String::new(),
+ lmstudio_model: String::new(),
+ custom_url: String::new(),
+ custom_model: String::new(),
+ 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..17163d1e68 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,8 +83,18 @@ 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 answers to a question from Claude Code, one value per question text. */
+ questionAnswer: (requestId: string, answers: Record) =>
+ call("question_answer", { requestId, answers }),
/** "The card is up" — until this lands the relay only waits a moment. */
approvalAck: (requestId: string) => call("approval_ack", { requestId }),
/** "Nobody can act on this" — Claude Code asks in the terminal right away. */
@@ -85,6 +105,9 @@ export const Bridge = {
chatSend: (query: string, context: ChatContext | null) =>
callOrThrow<{ text: string }>("chat_send", { query, context }),
chatReset: () => call("chat_reset"),
+ /** Does the local model server answer? Its address as stored, and its chat models. */
+ localConnect: (provider: "ollama" | "lmstudio" | "custom", url: string) =>
+ callOrThrow<{ url: string; models: string[] }>("local_connect", { provider, url }),
/** Copies a dropped file into the inbox. */
ingestFile: (path: string) => callOrThrow("ingest_file", { path }),
/** Only ever tells you whether a key exists — never its value. */
@@ -118,8 +141,22 @@ 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;
+ /** The `--ask` hook is in. Missing on hooks installed before questions were supported. */
+ askHookInstalled: 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..b584322aff 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,9 +69,11 @@ 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" },
+ question: { height: 196, botX: 62, botY: null, botDiameter: 56, agentMode: "column" },
error: { height: 160, botX: 62, botY: null, botDiameter: 58, agentMode: "column" },
finished: { height: 160, botX: 62, botY: null, botDiameter: 58, agentMode: "column" },
confused: { height: 160, botX: 76, botY: null, botDiameter: 66, agentMode: "column" },
diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts
index 01236b80a9..470f40c31f 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 {
@@ -28,6 +38,24 @@ export interface ApprovalInfo {
command: string;
}
+/** One question of an AskUserQuestion call. */
+export interface AskQuestion {
+ question: string;
+ /** A short label (12 characters at most), shown as the chip's title. */
+ header: string;
+ options: { label: string; description: string }[];
+ multiSelect: boolean;
+}
+
+/** The label picked per question text; an array of labels for a multi-select. */
+export type AskAnswers = Record;
+
+export interface QuestionInfo {
+ requestId: string;
+ sessionId: string;
+ questions: AskQuestion[];
+}
+
export interface ChatMessage {
id: number;
role: "user" | "assistant";
@@ -87,11 +115,28 @@ 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;
+ /** Who the chat talks to: Claude, or a model server on this machine. */
+ chatProvider: "anthropic" | "ollama" | "lmstudio" | "custom";
+ /** Addresses of the local servers once connected (empty: not connected) and the model picked on each. */
+ ollamaUrl: string;
+ lmstudioUrl: string;
+ ollamaModel: string;
+ lmstudioModel: string;
+ /** Any other OpenAI-compatible server (its key, if any, is in the keychain). */
+ customUrl: string;
+ customModel: 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 +151,16 @@ export const DEFAULT_SETTINGS: Settings = {
autostart: false,
hooksInstalled: false,
model: "claude-opus-5",
+ language: "auto",
+ chatProvider: "anthropic",
+ ollamaUrl: "",
+ lmstudioUrl: "",
+ ollamaModel: "",
+ lmstudioModel: "",
+ customUrl: "",
+ customModel: "",
+ showPlanInNotch: false,
+ planRelayInstalled: false,
};
type Listener = () => void;
@@ -130,6 +185,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;
@@ -137,6 +196,8 @@ class AppState {
searchResult: SearchResult | null = null;
chatHistory: ChatMessage[] = [];
pendingApproval: ApprovalInfo | null = null;
+ /** A question from Claude Code waiting for an answer in the island. */
+ pendingQuestion: QuestionInfo | null = null;
integrations: Record = {};
@@ -164,6 +225,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 +238,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..e013aefe88 100644
--- a/windows/src/island/hooks.ts
+++ b/windows/src/island/hooks.ts
@@ -4,14 +4,35 @@
// 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 { parseQuestions } from "../views/question";
+import { parsePlanUsage } from "../views/usage";
const CLAUDE_ID = "integration_claude";
/** Clears the approval card if no decision was made before the hook gave up. */
let pendingTimeout: number | null = null;
+/** The same for a question; the relay waits a little longer for those. */
+let questionTimeout: number | null = null;
+const QUESTION_CARD_MS = 125_000;
+
+/** The question card is over — answered in the terminal, or the hook gave up. */
+function dropQuestion(island: Island) {
+ if (questionTimeout != null) window.clearTimeout(questionTimeout);
+ questionTimeout = null;
+ if (!State.pendingQuestion) return;
+ State.pendingQuestion = null;
+ State.isPinned = false;
+ island.dropPin();
+ State.updateTask(CLAUDE_ID, "working");
+ State.setPillBadge(CLAUDE_ID, null);
+ if (State.view === "question") island.setView(State.defaultView());
+ State.notify();
+}
interface HookPayload {
hook_event_name?: string;
@@ -25,6 +46,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 +91,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 +154,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 +173,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 +201,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,26 +247,43 @@ 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;
}
case "PreToolUse": {
+ // A question has its own hook (AskUserQuestion below); this one only repeats it.
+ if (payload.tool_name === "AskUserQuestion") break;
ensurePill();
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":
+ // Answered somewhere else (in the terminal): the card would be lying.
+ if (payload.tool_name === "AskUserQuestion" && State.pendingQuestion?.sessionId === sessionId) dropQuestion(island);
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 +300,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 +312,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 +332,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;
@@ -280,10 +355,16 @@ function handleHook(island: Island, payload: HookPayload) {
}
const requestId = payload.request_id ?? "";
+ // Older Claude Code asks questions this way. They are answered through the
+ // question hook, so there is no permission card; the terminal takes it.
+ if (payload.tool_name === "AskUserQuestion") {
+ if (requestId) void Bridge.approvalDecline(requestId);
+ break;
+ }
// One card, one request. A second one must never quietly replace the first
// — that would leave a human staring at request B while request A waits for
// a decision nobody can give. Hand it straight back to the terminal.
- if (State.pendingApproval && State.pendingApproval.requestId !== requestId) {
+ if (State.pendingQuestion || (State.pendingApproval && State.pendingApproval.requestId !== requestId)) {
if (requestId) void Bridge.approvalDecline(requestId);
break;
}
@@ -328,6 +409,32 @@ function handleHook(island: Island, payload: HookPayload) {
break;
}
+ case "AskUserQuestion": {
+ const requestId = payload.request_id ?? "";
+ const questions = isExternalAgent ? null : parseQuestions(payload.tool_input);
+ // Nothing the island can show, or another card already holds the view: the
+ // terminal asks, exactly as without Coucou. One card, one request.
+ if (!questions || State.pendingApproval || (State.pendingQuestion && State.pendingQuestion.requestId !== requestId)) {
+ if (requestId) void Bridge.approvalDecline(requestId);
+ break;
+ }
+ upsert(projectName, cwd);
+ if (questionTimeout != null) window.clearTimeout(questionTimeout);
+ State.pendingQuestion = { requestId, sessionId, questions };
+ if (requestId) void Bridge.approvalAck(requestId);
+ State.updateTask(CLAUDE_ID, "question");
+ State.isPinned = true;
+ Sound.play("question");
+ if (focused) {
+ island.alert("question");
+ } else {
+ State.setPillBadge(CLAUDE_ID, "approval");
+ island.reveal();
+ }
+ questionTimeout = window.setTimeout(() => dropQuestion(island), QUESTION_CARD_MS);
+ break;
+ }
+
default:
break;
}
diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts
index 7fdb618027..11ce0c88d5 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]);
},
@@ -147,6 +163,22 @@ export class Island {
State.setPillBadge("integration_claude", null);
this.setView(State.defaultView());
},
+ answerQuestion: (answers) => {
+ const req = State.pendingQuestion;
+ if (!req) return;
+ Sound.play("approve");
+ void Bridge.questionAnswer(req.requestId, answers);
+ this.finishQuestion();
+ },
+ replyInTerminal: () => {
+ const req = State.pendingQuestion;
+ if (!req) return;
+ // No answer: Claude Code asks in the terminal, and that is where to look.
+ void Bridge.approvalDecline(req.requestId);
+ this.finishQuestion();
+ const task = State.claudeTask;
+ if (task) void openSession(task);
+ },
toggleSound: () => {
State.settings.soundEnabled = !State.settings.soundEnabled;
Sound.setEnabled(State.settings.soundEnabled);
@@ -267,6 +299,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 +323,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 +334,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 +363,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() {
@@ -335,6 +377,16 @@ export class Island {
}
/** An alert stopped waiting for an answer: let the island auto-close again. */
+ /** The question card is done, answered or handed back: Claude Code carries on. */
+ private finishQuestion() {
+ State.pendingQuestion = null;
+ State.isPinned = false;
+ this.fsm.pinned = false;
+ State.updateTask("integration_claude", "working");
+ State.setPillBadge("integration_claude", null);
+ this.setView(State.defaultView());
+ }
+
dropPin() {
this.fsm.pinned = false;
}
@@ -350,10 +402,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 +616,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 +627,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 +749,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 +772,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 +790,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 +801,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 +814,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 +869,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();
@@ -850,7 +937,7 @@ export class Island {
// The chat is the only view with a text field, so it is the only time the
// island is allowed to take keyboard focus.
if (this.lastSyncedView !== State.view) {
- const wasChat = this.lastSyncedView === "prompt";
+ const wasChat = this.lastSyncedView === "prompt" || this.lastSyncedView === "question";
this.lastSyncedView = State.view;
if (State.view === "prompt") {
void Bridge.focusWindow(true);
diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts
index 3ab9ab94a7..896c4b7107 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" }));
@@ -88,11 +175,20 @@ function claudeSection(status: HookStatus): HTMLElement {
}));
}
+ // Hooks from before questions could be answered here lack the --ask entry.
+ const outdated = status.installed && !status.askHookInstalled;
+ if (outdated) {
+ body.append(h("div", {
+ class: "notice",
+ text: "Hooks outdated — update them to answer Claude's questions from the notch. You see the change before anything is written.",
+ }));
+ }
+
const actions = h("div", { class: "row" });
const install = h("button", {
class: "primary",
- text: status.installed ? "Reinstall hooks…" : "Install hooks…",
- onclick: () => showPreview(true),
+ text: outdated ? "Update hooks…" : status.installed ? "Reinstall hooks…" : "Install hooks…",
+ onclick: () => void reviewChange(body, HOOKS_CHANGE, true, redraw, () => void rebuild()),
});
// Writing hook commands that point at a relay which isn't there would give
// every Claude Code session a broken hook and nothing to show for it.
@@ -105,66 +201,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();
@@ -254,6 +364,153 @@ function apiSection(hasKey: boolean): HTMLElement {
);
}
+// ── Local models section ──────────────────────────────────────────────────────
+
+type Local = "ollama" | "lmstudio" | "custom";
+
+const LOCAL: Record = {
+ ollama: { name: "Ollama", url: "ollamaUrl", model: "ollamaModel", usual: "http://127.0.0.1:11434" },
+ lmstudio: { name: "LM Studio", url: "lmstudioUrl", model: "lmstudioModel", usual: "http://127.0.0.1:1234" },
+ // No usual address: any server that speaks the OpenAI API (Unsloth, vLLM, llama.cpp…).
+ custom: { name: "OpenAI-compatible", url: "customUrl", model: "customModel", usual: "" },
+};
+
+/** Keychain entry of the OpenAI-compatible server's key. */
+const CUSTOM_KEY = "openai-compatible-key";
+
+/**
+ * Chat with a model on this computer through Ollama or LM Studio: connecting
+ * checks that the server answers and lists its models. No key; the address is
+ * the only place Coucou talks to, and it stays on this machine by default.
+ */
+function localSection(): HTMLElement {
+ const body = h("div", { style: "display:flex;flex-direction:column;gap:12px" });
+ const section = h("section", {}, h("h2", {}, h("span", { text: "Local models" })), body);
+ const redraw = () => {
+ clear(body);
+ draw();
+ };
+
+ /** Who the chat talks to: Claude, or any server that is connected. */
+ function providerRow(): HTMLElement {
+ const pick = h("select", {}, h("option", { value: "anthropic", text: "Claude" })) as HTMLSelectElement;
+ for (const [id, def] of Object.entries(LOCAL) as [Local, (typeof LOCAL)[Local]][]) {
+ if (settings[def.url]) pick.append(h("option", { value: id, text: def.name }));
+ }
+ pick.value = [...pick.options].some((o) => o.value === settings.chatProvider) ? settings.chatProvider : "anthropic";
+ pick.addEventListener("change", () => {
+ settings.chatProvider = pick.value as Settings["chatProvider"];
+ void save();
+ });
+ return h("div", { class: "row" }, h("label", { text: "Chat with" }), pick);
+ }
+
+ function serverBlock(id: Local): HTMLElement {
+ const def = LOCAL[id];
+ const connected = settings[def.url] !== "";
+ const input = h("input", {
+ type: "text",
+ placeholder: def.usual || "http://127.0.0.1:8000",
+ value: settings[def.url],
+ style: "flex:1 1 auto;min-width:0",
+ spellcheck: "false",
+ }) as HTMLInputElement;
+ const status = h("div", { class: "hint" });
+ const row = h("div", { class: "row" }, h("label", { text: def.name }), input);
+ // An OpenAI-compatible server may want a key; it goes to the keychain, never to the settings file.
+ const key = h("input", {
+ type: "password",
+ placeholder: "API key (optional)",
+ style: "flex:1 1 auto;min-width:0",
+ autocomplete: "off",
+ spellcheck: "false",
+ }) as HTMLInputElement;
+ const keyRow = id === "custom" && !connected ? h("div", { class: "row" }, h("label", { text: "Key" }), key) : null;
+
+ if (connected) {
+ input.disabled = true;
+ const models = h("select", {}, h("option", { value: settings[def.model], text: settings[def.model] })) as HTMLSelectElement;
+ models.addEventListener("change", () => {
+ settings[def.model] = models.value;
+ void save();
+ });
+ row.append(
+ models,
+ h("button", {
+ class: "danger",
+ text: "Disconnect",
+ onclick: () => {
+ settings[def.url] = "";
+ settings[def.model] = "";
+ if (settings.chatProvider === id) settings.chatProvider = "anthropic";
+ if (id === "custom") void Bridge.secretClear(CUSTOM_KEY).catch(() => {});
+ void save().then(redraw);
+ },
+ }),
+ );
+ // The list is asked for again each time, so it is never older than the server.
+ Bridge.localConnect(id, settings[def.url]).then(
+ (server) => {
+ if (!server.models.length) return;
+ models.replaceChildren(...server.models.map((m) => h("option", { value: m, text: m })));
+ if (!server.models.includes(settings[def.model])) {
+ settings[def.model] = server.models[0];
+ void save();
+ }
+ models.value = settings[def.model];
+ },
+ (err) => { status.textContent = String(err).replace(/^Error:\s*/, ""); },
+ );
+ } else {
+ const connect = h("button", { class: "primary", text: "Connect" });
+ connect.addEventListener("click", async () => {
+ connect.disabled = true;
+ status.className = "hint";
+ status.textContent = "Connecting…";
+ try {
+ if (id === "custom" && key.value.trim()) {
+ await Bridge.secretSet(CUSTOM_KEY, key.value.trim());
+ key.value = "";
+ }
+ const server = await Bridge.localConnect(id, input.value);
+ if (!server.models.length) {
+ status.className = "notice err";
+ status.textContent = `No models yet. Download one in ${def.name} first.`;
+ } else {
+ settings[def.url] = server.url;
+ settings[def.model] = server.models[0];
+ await save();
+ redraw();
+ return;
+ }
+ } catch (err) {
+ status.className = "notice err";
+ status.textContent = String(err).replace(/^Error:\s*/, "");
+ }
+ connect.disabled = false;
+ });
+ row.append(connect);
+ }
+ return h("div", { style: "display:flex;flex-direction:column;gap:6px" }, row, keyRow, status);
+ }
+
+ function draw() {
+ body.append(
+ h("div", {
+ class: "hint",
+ text: "Chat with a model running on this computer through Ollama or LM Studio (leave the address empty for the usual one), or with any server that speaks the OpenAI API, such as Unsloth. If you point an address at another machine, that machine receives what you ask.",
+ }),
+ serverBlock("ollama"),
+ serverBlock("lmstudio"),
+ serverBlock("custom"),
+ providerRow(),
+ );
+ }
+
+ draw();
+ return section;
+}
+
// ── Integrations section ──────────────────────────────────────────────────────
interface IntegrationDef {
@@ -386,9 +643,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 +686,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 +707,7 @@ async function main() {
version = boot.version;
}
const status = (await Bridge.hooksStatus()) ?? {
- installed: false, settingsPath: "", hookPath: "", hookReady: false,
+ installed: false, askHookInstalled: false, planRelayInstalled: false, settingsPath: "", hookPath: "", hookReady: false,
};
const hasKey = (await Bridge.secretPresent("anthropic-api-key")) ?? false;
@@ -442,7 +723,9 @@ async function main() {
root.append(
h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })),
claudeSection(status),
+ planSection(status),
apiSection(hasKey),
+ localSection(),
integrationsSection(present),
generalSection(),
h("div", {
diff --git a/windows/src/style.css b/windows/src/style.css
index b2cf6756c8..29456ce617 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;
}
@@ -829,6 +848,126 @@ input[type="range"]::-webkit-slider-thumb {
user-select: text;
}
+/* ── Markdown in the answers ─────────────────────────────────────────────── */
+
+.reply {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.md-p {
+ font: 400 12.5px var(--font);
+ color: #b0b5be;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.md-h {
+ font: 600 13px var(--font);
+ color: #f1f2f4;
+ overflow-wrap: anywhere;
+}
+
+.md-h1 {
+ font-weight: 700;
+ font-size: 14px;
+}
+
+.md-p strong {
+ color: #e8e9ec;
+ font-weight: 600;
+}
+
+.md-code {
+ padding: 1px 4px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.08);
+ font: 400 11.5px var(--mono);
+ color: #c8cdd4;
+}
+
+.md-link {
+ color: #60a5fa;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.md-pre {
+ position: relative;
+ padding: 10px 32px 10px 10px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ background: #0d0e12;
+}
+
+.md-pre pre {
+ margin: 0;
+ font: 400 11px var(--mono);
+ color: #c8cdd4;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.md-copy {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ padding: 0;
+ border: 0;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.06);
+ color: #6b7079;
+ cursor: default;
+}
+
+.md-copy.done {
+ color: #22c55e;
+}
+
+.md-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+}
+
+.md-bullet {
+ flex: 0 0 auto;
+ min-width: 10px;
+ font: 400 12.5px var(--font);
+ color: #6b7079;
+}
+
+.md-quote {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.md-quote i {
+ flex: 0 0 2px;
+ align-self: stretch;
+ border-radius: 1px;
+ background: #4b5563;
+}
+
+.md-quote .md-p {
+ color: #8a8f98;
+}
+
+.md-rule {
+ width: 100%;
+ margin: 2px 0;
+ border: 0;
+ border-top: 1px solid rgba(255, 255, 255, 0.3);
+ opacity: 0.3;
+}
+
.typing {
display: flex;
gap: 4px;
@@ -1365,3 +1504,543 @@ 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; }
+
+/* ── Question (AskUserQuestion) ─────────────────────────────────────────────── */
+
+.q-stack {
+ justify-content: center;
+ gap: 7px;
+ padding: 4px 16px 4px 116px; /* clear of Mochi, like the other cards */
+}
+
+.q-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.q-count {
+ padding: 0 6px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.09);
+ font: 600 10.5px var(--font);
+ color: var(--dim-2);
+}
+
+.q-terminal {
+ border: 0;
+ background: transparent;
+ font: 500 11px var(--font);
+ color: var(--dim-2);
+ cursor: default;
+}
+
+.q-terminal:hover {
+ color: var(--ink);
+}
+
+.q-title {
+ font: 600 13.5px var(--font);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.q-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.q-chip {
+ max-width: 100%;
+ padding: 5px 11px;
+ border: 1px solid transparent;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.09);
+ color: var(--ink-2);
+ font: 500 12px var(--font);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: default;
+ transition: background 0.12s ease-out, border-color 0.12s ease-out;
+}
+
+.q-chip:hover {
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.q-chip.on {
+ border-color: #22d3ee;
+ background: rgba(34, 211, 238, 0.16);
+ color: var(--ink);
+}
+
+.q-foot {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.q-other {
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 6px 11px;
+ border: 0;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.07);
+ color: var(--ink);
+ font: 400 12px var(--font);
+ outline: none;
+ cursor: text;
+}
+
+.q-other::placeholder {
+ color: var(--dim-3);
+}
+
+.q-go:disabled {
+ opacity: 0.4;
+}
+
+/* ── 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/chat.ts b/windows/src/views/chat.ts
index 2cd253f7b3..7f4a63e03f 100644
--- a/windows/src/views/chat.ts
+++ b/windows/src/views/chat.ts
@@ -3,9 +3,10 @@
import { h, svg, clear } from "./dom";
import { ICONS } from "./icons";
-import { Bridge, type ChatContext } from "../core/bridge";
+import { Bridge, onEvent, type ChatContext } from "../core/bridge";
import { Sound } from "../core/sound";
import { State, type ChatMessage } from "../core/state";
+import { renderMarkdown } from "./markdown";
import type { ViewHost } from "./views";
let nextId = 1;
@@ -18,7 +19,9 @@ function bubble(message: ChatMessage): HTMLElement {
h("div", { class: "bubble", text: message.content }),
);
}
- return h("div", { class: "chat-row" }, h("div", { class: "reply", text: message.content }));
+ const reply = h("div", { class: "reply" });
+ renderMarkdown(reply, message.content);
+ return h("div", { class: "chat-row" }, reply);
}
function typingDots(): HTMLElement {
@@ -57,6 +60,20 @@ export function buildPrompt(onHeightChange: () => void): ViewHost {
let sending = false;
let renderedCount = -1;
+ // A local model answers token by token: the text so far, and where it is shown.
+ let live: HTMLElement | null = null;
+ void onEvent("chat-delta", (text) => {
+ if (!sending) return;
+ if (!text) return; // still thinking: the dots stay
+ if (!live) {
+ live = h("div", { class: "reply" });
+ log.querySelector(".typing")?.parentElement?.remove();
+ log.append(h("div", { class: "chat-row" }, live));
+ }
+ renderMarkdown(live, text);
+ log.scrollTop = log.scrollHeight;
+ onHeightChange();
+ });
async function submit() {
const query = input.value.trim();
@@ -86,6 +103,8 @@ export function buildPrompt(onHeightChange: () => void): ViewHost {
Sound.play("error");
} finally {
sending = false;
+ live = null;
+ renderedCount = -1; // the finished answer replaces the streamed one
State.notify();
onHeightChange();
input.focus();
diff --git a/windows/src/views/fa.ts b/windows/src/views/fa.ts
new file mode 100644
index 0000000000..08439381ed
--- /dev/null
+++ b/windows/src/views/fa.ts
@@ -0,0 +1,42 @@
+// 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"],
+ copy: [448, 512, "M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"],
+ 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/markdown.ts b/windows/src/views/markdown.ts
new file mode 100644
index 0000000000..f51f4f2462
--- /dev/null
+++ b/windows/src/views/markdown.ts
@@ -0,0 +1,199 @@
+// Markdown in the chat's answers, as on the Mac (ChatMarkdown.swift and
+// ChatMarkdownView.swift): headings, paragraphs, fenced code with a copy button,
+// bullet and numbered lists, quotes and rules, with bold, italic, `code` and links
+// inside the text. Everything is built as DOM nodes, never as HTML, so nothing a
+// model writes can inject markup; links open only when they are http(s).
+
+import { Bridge } from "../core/bridge";
+import { h } from "./dom";
+import { fa } from "./fa";
+
+export type Block =
+ | { kind: "heading"; level: number; text: string }
+ | { kind: "paragraph"; text: string }
+ | { kind: "code"; lang: string; code: string }
+ /** `prefix` is "•" or "1."; `indent` the nesting level, from 0. */
+ | { kind: "item"; prefix: string; text: string; indent: number }
+ | { kind: "quote"; text: string }
+ | { kind: "rule" };
+
+const isRule = (s: string) => s === "---" || s === "***" || s === "___";
+const isBullet = (s: string) => /^[-*+] /.test(s);
+const ordered = (s: string) => /^(\d+)\.\s+/.exec(s);
+const isQuote = (s: string) => s.startsWith("> ") || s === ">";
+
+/** Same rules as the Mac's parser, line by line. */
+export function parseMarkdown(input: string): Block[] {
+ const blocks: Block[] = [];
+ const lines = input.split("\n");
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+
+ if (line.startsWith("```")) {
+ const lang = line.slice(3).trim();
+ const code: string[] = [];
+ for (i++; i < lines.length && !lines[i].startsWith("```"); i++) code.push(lines[i]);
+ blocks.push({ kind: "code", lang, code: code.join("\n") });
+ i++;
+ continue;
+ }
+
+ // A heading needs a space after the #s; "#hashtag" stays a paragraph.
+ const hashes = /^#+/.exec(line)?.[0].length ?? 0;
+ if (hashes && (line.length === hashes || line[hashes] === " ")) {
+ const text = line.slice(hashes).trim();
+ if (text) blocks.push({ kind: "heading", level: Math.min(hashes, 6), text });
+ i++;
+ continue;
+ }
+
+ const stripped = line.trim();
+ if (isRule(stripped)) {
+ blocks.push({ kind: "rule" });
+ i++;
+ continue;
+ }
+ if (isQuote(stripped)) {
+ blocks.push({ kind: "quote", text: stripped.startsWith("> ") ? stripped.slice(2) : "" });
+ i++;
+ continue;
+ }
+
+ const indent = Math.floor((line.length - line.trimStart().length) / 2);
+ if (isBullet(stripped)) {
+ blocks.push({ kind: "item", prefix: "•", text: stripped.slice(2), indent });
+ i++;
+ continue;
+ }
+ const number = ordered(stripped);
+ if (number) {
+ blocks.push({ kind: "item", prefix: `${number[1]}.`, text: stripped.slice(number[0].length), indent });
+ i++;
+ continue;
+ }
+ if (!stripped) {
+ i++;
+ continue;
+ }
+
+ // A paragraph runs until a blank line or the start of another block.
+ const para = [line];
+ for (i++; i < lines.length; i++) {
+ const next = lines[i];
+ const s = next.trim();
+ if (!s || next.startsWith("#") || next.startsWith("```") || isQuote(s) || isBullet(s) || isRule(s) || ordered(s)) break;
+ para.push(next);
+ }
+ blocks.push({ kind: "paragraph", text: para.join("\n") });
+ }
+ return blocks;
+}
+
+// ── Inline ────────────────────────────────────────────────────────────────────
+
+/** `code`, **bold**, __bold__, *italic*, _italic_ and [text](url), whichever comes first. */
+const INLINE = /`([^`\n]+)`|\*\*([^*]+?)\*\*|__([^_]+?)__|\*([^*\s][^*]*?)\*|(? at) into.append(text.slice(at, m.index));
+ at = m.index + m[0].length;
+ if (m[1] != null) {
+ into.append(h("code", { class: "md-code", text: m[1] }));
+ } else if (m[6] != null) {
+ const url = safeWebUrl(m[7]);
+ if (url) {
+ const a = h("a", { class: "md-link", href: url, title: url });
+ a.addEventListener("click", (e) => {
+ e.preventDefault();
+ void Bridge.openUrl(url);
+ });
+ inline(m[6], a);
+ into.append(a);
+ } else {
+ into.append(m[6]);
+ }
+ } else {
+ const strong = m[2] ?? m[3];
+ const el = h(strong != null ? "strong" : "em");
+ inline(strong ?? m[4] ?? m[5], el);
+ into.append(el);
+ }
+ }
+ if (at < text.length) into.append(text.slice(at));
+}
+
+// ── Blocks ────────────────────────────────────────────────────────────────────
+
+/** Puts the text on the clipboard; the check mark shows for a moment. */
+function copyButton(text: string): HTMLElement {
+ const btn = h("button", { class: "md-copy", title: "Copy" }, fa("copy", 10));
+ btn.addEventListener("click", async () => {
+ try {
+ await navigator.clipboard.writeText(text);
+ } catch {
+ // No clipboard API (or no permission): the old way, through a hidden field.
+ const area = h("textarea", { style: "position:fixed;opacity:0" }) as HTMLTextAreaElement;
+ area.value = text;
+ document.body.append(area);
+ area.select();
+ document.execCommand("copy");
+ area.remove();
+ }
+ btn.replaceChildren(fa("check", 10));
+ btn.classList.add("done");
+ window.setTimeout(() => {
+ btn.replaceChildren(fa("copy", 10));
+ btn.classList.remove("done");
+ }, 1500);
+ });
+ return btn;
+}
+
+function render(block: Block): HTMLElement {
+ switch (block.kind) {
+ case "heading": {
+ const el = h("div", { class: block.level <= 2 ? "md-h md-h1" : "md-h" });
+ inline(block.text, el);
+ return el;
+ }
+ case "paragraph": {
+ const el = h("div", { class: "md-p" });
+ inline(block.text, el);
+ return el;
+ }
+ case "code":
+ return h("div", { class: "md-pre" }, h("pre", { text: block.code }), copyButton(block.code));
+ case "item": {
+ const text = h("span", { class: "md-p" });
+ inline(block.text, text);
+ const item = h("div", { class: "md-item" }, h("span", { class: "md-bullet", text: block.prefix }), text);
+ item.style.paddingLeft = `${block.indent * 12}px`;
+ return item;
+ }
+ case "quote": {
+ const text = h("span", { class: "md-p" });
+ inline(block.text, text);
+ return h("div", { class: "md-quote" }, h("i"), text);
+ }
+ case "rule":
+ return h("hr", { class: "md-rule" });
+ }
+}
+
+/** Replaces what `into` shows with the Markdown rendered. */
+export function renderMarkdown(into: HTMLElement, markdown: string) {
+ into.replaceChildren(...parseMarkdown(markdown).map(render));
+}
diff --git a/windows/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/question.ts b/windows/src/views/question.ts
new file mode 100644
index 0000000000..da5d68ed14
--- /dev/null
+++ b/windows/src/views/question.ts
@@ -0,0 +1,166 @@
+// Claude Code's questions (`AskUserQuestion`), answered from the island.
+//
+// Same behaviour as the Mac's question view (docs/INTEGRATIONS.md, "Répondre aux
+// questions"): one question at a time with a 1/N counter, the options as chips, a
+// free "Other…" field, and "Reply in terminal" to let Claude Code ask there instead.
+// A single-choice question with nothing else to answer is answered by the click
+// itself; multi-select and several questions use a Next / Send button.
+
+import { Bridge } from "../core/bridge";
+import { State, type AskAnswers, type AskQuestion } from "../core/state";
+import { localized } from "../core/i18n";
+import { clear, h } from "./dom";
+import type { ViewActions, ViewHost } from "./views";
+
+const MAX_QUESTIONS = 4;
+
+/** `tool_input.questions` as the model: null unless every question can really be asked. */
+export function parseQuestions(input: unknown): AskQuestion[] | null {
+ const raw = (input as { questions?: unknown } | null)?.questions;
+ if (!Array.isArray(raw) || raw.length < 1 || raw.length > MAX_QUESTIONS) return null;
+ const questions: AskQuestion[] = [];
+ for (const q of raw as Record[]) {
+ if (typeof q?.question !== "string" || !q.question.trim() || !Array.isArray(q.options)) return null;
+ const options = (q.options as Record[]).filter((o) => typeof o?.label === "string" && o.label);
+ if (options.length < 2) return null;
+ questions.push({
+ question: q.question,
+ header: typeof q.header === "string" ? q.header : "",
+ options: options.map((o) => ({
+ label: o.label as string,
+ description: typeof o.description === "string" ? o.description : "",
+ })),
+ multiSelect: q.multiSelect === true,
+ });
+ }
+ // Two questions with the same text would share one answer.
+ return new Set(questions.map((q) => q.question)).size === questions.length ? questions : null;
+}
+
+const TEXT = {
+ en: { asking: "is asking a question", other: "Other…", next: "Next", send: "Send", terminal: "Reply in terminal" },
+ de: { asking: "stellt eine Frage", other: "Andere…", next: "Weiter", send: "Senden", terminal: "Im Terminal antworten" },
+ fr: { asking: "pose une question", other: "Autre…", next: "Suivant", send: "Envoyer", terminal: "Répondre dans le terminal" },
+};
+
+export function buildQuestion(actions: ViewActions): ViewHost {
+ const who = h("div", { class: "who-row" });
+ const terminal = h("button", { class: "q-terminal", onclick: () => actions.replyInTerminal() });
+ const head = h("div", { class: "q-head" }, who, terminal);
+ const title = h("div", { class: "q-title" });
+ const chips = h("div", { class: "q-chips" });
+ const other = h("input", { class: "q-other", spellcheck: "false", autocomplete: "off" }) as HTMLInputElement;
+ const go = h("button", { class: "btn primary q-go" });
+ const foot = h("div", { class: "q-foot" }, other, go);
+ const body = h("div", { class: "stack q-stack" }, head, title, chips, foot);
+ const el = h("div", { class: "view" }, h("div", { class: "card wash" }, body));
+ el.querySelector(".card")!.style.setProperty("--wash", "rgba(34,211,238,0.10)");
+
+ // Where we are in the current request.
+ let shown = "";
+ let index = 0;
+ let answers: AskAnswers = {};
+ let picked: string[] = [];
+
+ const current = (): AskQuestion | undefined => State.pendingQuestion?.questions[index];
+ const last = () => index === (State.pendingQuestion?.questions.length ?? 1) - 1;
+ /** One click is the whole answer: a single question that takes a single choice. */
+ const oneClick = () => !current()?.multiSelect && State.pendingQuestion?.questions.length === 1;
+
+ function submit(value: string | string[]) {
+ const q = current();
+ if (!q || !State.pendingQuestion) return;
+ answers = { ...answers, [q.question]: value };
+ if (last()) {
+ actions.answerQuestion(answers);
+ } else {
+ index++;
+ picked = [];
+ render();
+ State.notify();
+ }
+ }
+
+ /** What the Next / Send button would answer with right now (null: nothing chosen). */
+ function value(): string | string[] | null {
+ const typed = other.value.trim();
+ if (current()?.multiSelect) {
+ const all = typed ? [...picked, typed] : picked;
+ return all.length ? all : null;
+ }
+ return typed || picked[0] || null;
+ }
+
+ function syncGo() {
+ const t = localized(TEXT);
+ go.textContent = last() ? t.send : t.next;
+ (go as HTMLButtonElement).disabled = value() == null;
+ go.style.display = oneClick() && !other.value.trim() ? "none" : "";
+ }
+
+ function choose(label: string, chip: HTMLElement) {
+ if (current()?.multiSelect) {
+ picked = picked.includes(label) ? picked.filter((l) => l !== label) : [...picked, label];
+ } else if (oneClick()) {
+ return submit(label);
+ } else {
+ picked = [label];
+ }
+ // Only the look changes; rebuilding the chips between a mouse-down and a
+ // mouse-up would swallow the click.
+ for (const c of chips.children) c.classList.toggle("on", picked.includes((c as HTMLElement).dataset.label ?? ""));
+ chip.blur();
+ syncGo();
+ }
+
+ function render() {
+ const q = current();
+ if (!q) return;
+ const t = localized(TEXT);
+ title.textContent = q.question;
+ clear(chips);
+ for (const o of q.options) {
+ const chip = h("button", { class: "q-chip", title: o.description, text: o.label });
+ chip.dataset.label = o.label;
+ chip.addEventListener("click", () => choose(o.label, chip));
+ chips.append(chip);
+ }
+ other.value = "";
+ other.placeholder = t.other;
+ terminal.textContent = t.terminal;
+ syncGo();
+ }
+
+ other.addEventListener("input", syncGo);
+ other.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && value() != null) submit(value()!);
+ });
+ go.addEventListener("click", () => value() != null && submit(value()!));
+ // The island only takes the keyboard when the user clicks into the field: a
+ // question must never steal what they are typing in the terminal.
+ other.addEventListener("focus", () => void Bridge.focusWindow(true));
+ other.addEventListener("blur", () => void Bridge.focusWindow(false));
+
+ return {
+ el,
+ sync() {
+ const req = State.pendingQuestion;
+ if (!req) return;
+ const t = localized(TEXT);
+ clear(who);
+ who.append(
+ h("span", { class: "dot", style: `width:8px;height:8px;background:${State.focusTask?.color ?? "#fff"}` }),
+ h("span", { class: "n", text: State.focusTask?.name ?? "Claude Code" }),
+ h("span", { text: t.asking }),
+ );
+ if (req.questions.length > 1) who.append(h("span", { class: "q-count", text: `${index + 1}/${req.questions.length}` }));
+ if (shown !== req.requestId) {
+ shown = req.requestId;
+ index = 0;
+ answers = {};
+ picked = [];
+ render();
+ }
+ },
+ };
+}
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..54d18065fa 100644
--- a/windows/src/views/views.ts
+++ b/windows/src/views/views.ts
@@ -4,11 +4,15 @@
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 { buildQuestion } from "./question";
+import { buildSession, hasSession } from "./session";
+import { PlanCard, buildPlanPill, planPillVisible } from "./usage";
import { buildChoose, buildUpload, buildUploading } from "./upload";
import { renderIntegrationCard, type IntegrationCardHooks } from "./integrations";
@@ -21,6 +25,10 @@ export interface ViewActions {
openTarget(): void;
openUrl(url: string): void;
decide(d: "allow" | "deny"): void;
+ /** The answers to Claude Code's question (one value per question text). */
+ answerQuestion(answers: Record): void;
+ /** Hand the question back: Claude Code asks it in the terminal. */
+ replyInTerminal(): void;
toggleSound(): void;
setVolume(v: number): void;
setAutoClose(seconds: number): void;
@@ -78,12 +86,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 +103,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 +138,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 +158,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 +182,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 +198,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 +244,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("|");
@@ -317,26 +350,6 @@ function buildApproval(actions: ViewActions): ViewHost {
};
}
-// ── Question ──────────────────────────────────────────────────────────────────
-
-function buildQuestion(): ViewHost {
- const who = h("div");
- const title = h("div", { class: "title" });
- const row = h("div", { class: "actions" });
- const el = h("div", { class: "view" }, card("cyan", stack(116, 16, who, title, row)));
- return {
- el,
- sync() {
- clear(who);
- who.append(agentWho(State.focusTask, "Claude Code is asking a question"));
- const task = State.focusTask;
- title.textContent = task?.steps.at(-1) ?? "Claude needs an answer.";
- clear(row);
- row.append(h("div", { class: "sub", text: "Answer in your terminal — Coucou can't reply for you yet." }));
- },
- };
-}
-
// ── Error ─────────────────────────────────────────────────────────────────────
function buildError(actions: ViewActions): ViewHost {
@@ -394,13 +407,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 +510,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("question", buildQuestion(actions));
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());