Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -178,6 +190,13 @@ What changes on Linux:
- **Keys** live in the Secret Service (GNOME Keyring, KWallet).
- **Files**: preferences in `~/.config/coucou/`, the log at
`~/.local/share/coucou/coucou.log`.
- **Code view**: while a session has edited a file or run a command, a click on the
overview's left card opens a small editor (the changed lines in red and green with
a few lines of context from the file, then the last command and what it printed);
the button in its corner goes back. Nothing opens by itself.
- **Open terminal** jumps to the Konsole tab the session runs in: the relay passes
Konsole's D-Bus names, the app switches the tab over D-Bus and asks KWin (a
short script, via `gdbus`) to raise that window. In any other terminal it opens
the folder in VS Code, as on Windows.
- What the Windows build leaves out, this one does too: sending a file by
email, dragging Mochi onto a window, and jumping to a specific terminal
window — "Open terminal" opens the folder in VS Code.
email and dragging Mochi onto a window.
77 changes: 77 additions & 0 deletions windows/hook/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const DROPPED_FIELDS: &[&str] = &["tool_response", "transcript_path"];
/// Longest string forwarded for any single field; the island truncates to far
/// less than this anyway.
const MAX_FIELD_LEN: usize = 2_000;
/// How much of what a command printed goes on: its last lines, each cut short.
const TAIL_LINES: usize = 3;
const TAIL_WIDTH: usize = 160;

#[cfg(windows)]
mod win;
Expand Down Expand Up @@ -128,6 +131,17 @@ fn read_event() -> Option<(String, String)> {
.unwrap_or(arg_event);
map.insert("hook_event_name".into(), serde_json::Value::String(event.clone()));

// What a finished command printed is dropped with the rest of `tool_response`,
// except its last lines: the island shows them under the command, like a
// terminal pane. Never the whole output — it may hold anything.
if event == "PostToolUse"
&& matches!(map.get("tool_name").and_then(|v| v.as_str()), Some("Bash" | "PowerShell"))
{
if let Some(lines) = map.get("tool_response").and_then(output_tail) {
map.insert("tool_tail".into(), serde_json::json!(lines));
}
}

for field in DROPPED_FIELDS {
map.remove(*field);
}
Expand All @@ -154,6 +168,10 @@ fn read_event() -> Option<(String, String)> {
("term_session_id", "TERM_SESSION_ID"),
("vscode_pid", "VSCODE_PID"),
("session_pid", "CLAUDE_CODE_SSE_PORT"),
// Konsole: where "Open terminal" can jump to (its D-Bus tab and window).
("konsole_service", "KONSOLE_DBUS_SERVICE"),
("konsole_session", "KONSOLE_DBUS_SESSION"),
("konsole_window", "KONSOLE_DBUS_WINDOW"),
] {
if !map.contains_key(key) {
let value = std::env::var(var).unwrap_or_default();
Expand All @@ -168,6 +186,49 @@ fn read_event() -> Option<(String, String)> {
Some((line, event))
}

/// The last few non-empty lines a command printed (stdout, else stderr), without
/// colour codes and cut to a width the island can show.
fn output_tail(response: &serde_json::Value) -> Option<Vec<String>> {
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<String> = 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 {
Expand Down Expand Up @@ -245,6 +306,22 @@ mod tests {
assert!(decision_json(r#"{"permissionDecision":"allow"}"#).is_none());
}

#[test]
fn only_the_last_lines_of_a_command_survive_and_without_colours() {
let out = serde_json::json!({ "stdout": "a\n\n\u{1b}[32mPASS\u{1b}[0m tests/x.ts\n ✓ works (3 ms)\nTests: 1 passed\n" });
assert_eq!(
output_tail(&out).unwrap(),
vec!["PASS tests/x.ts", " ✓ works (3 ms)", "Tests: 1 passed"]
);
// stderr is the fallback; nothing printed means nothing to show.
assert_eq!(
output_tail(&serde_json::json!({ "stdout": "", "stderr": "boom" })).unwrap(),
vec!["boom"]
);
assert!(output_tail(&serde_json::json!({ "stdout": " \n" })).is_none());
assert!(output_tail(&serde_json::json!(42)).is_none());
}

#[test]
fn long_strings_are_cut_on_a_char_boundary() {
let mut v = serde_json::json!({ "tool_input": { "content": "é".repeat(4000) } });
Expand Down
54 changes: 47 additions & 7 deletions windows/src-tauri/src/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,50 @@ const MAX_INLINE_TEXT: u64 = 200_000;

pub const DEFAULT_MODEL: &str = "claude-opus-5";

const SYSTEM_PROMPT: &str = "You are Mochi, a personal AI assistant living at the top of the user's screen. \
/// The language the chat answers in. An explicit choice wins over whatever
/// language the question or the web results are in; "auto" follows the system.
fn language_name(setting: &str) -> Option<&'static str> {
let system = ["LC_ALL", "LC_MESSAGES", "LANG"]
.iter()
.find_map(|k| std::env::var(k).ok().filter(|v| !v.is_empty() && v != "C" && v != "POSIX"));
let code = if setting == "auto" { system.as_deref().unwrap_or("") } else { setting };
match code.get(..2)? {
"de" => Some("German"),
"en" => Some("English"),
"fr" => Some("French"),
_ => None,
}
}

/// The system prompt of both ways to ask: the API and Claude Code.
pub(crate) fn system_prompt(language: &str) -> String {
let respond = match language_name(language) {
Some(name) => format!(
"Always respond in {name}, whatever language the question or the search results are in."
),
None => "Respond in the user's language.".to_string(),
};
format!(
"You are Mochi, a personal AI assistant living at the top of the user's screen. \
You have web search access and can help with absolutely anything — research, coding, finding places, recommendations, tasks, questions. \
Respond in the user's language. Be thorough and complete — use as much detail as the task requires. \
No markdown formatting (no **, no ##, no bullet dashes). Use plain text with line breaks.";
{respond} Be thorough and complete — use as much detail as the task requires. \
No markdown formatting (no **, no ##, no bullet dashes). Use plain text with line breaks."
)
}

#[derive(Default)]
pub struct Chat {
/// Full multi-turn history, including tool_use / tool_result blocks.
messages: Mutex<Vec<Value>>,
/// Claude Code session of this conversation, when it runs without an API key
/// (see claude_cli.rs).
pub(crate) cli_session: Mutex<Option<String>>,
}

impl Chat {
pub fn reset(&self) {
self.messages.lock().unwrap().clear();
*self.cli_session.lock().unwrap() = None;
}

fn is_empty(&self) -> bool {
Expand Down Expand Up @@ -73,11 +103,13 @@ pub struct ChatReply {
pub async fn send(
chat: &Chat,
model: &str,
language: &str,
query: String,
context: Option<ChatContext>,
) -> Result<ChatReply, String> {
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<Value> = Vec::new();

Expand Down Expand Up @@ -108,7 +140,7 @@ pub async fn send(
let body = json!({
"model": model,
"max_tokens": MAX_TOKENS,
"system": SYSTEM_PROMPT,
"system": system_prompt(language),
"tools": [{ "type": "web_search_20260209", "name": "web_search", "max_uses": 5 }],
"fallbacks": "default",
"messages": chat.snapshot(),
Expand Down Expand Up @@ -248,7 +280,7 @@ fn base64(bytes: &[u8]) -> String {

#[cfg(test)]
mod tests {
use super::base64;
use super::{base64, system_prompt};

#[test]
fn base64_matches_rfc4648_vectors() {
Expand All @@ -260,4 +292,12 @@ mod tests {
assert_eq!(base64(b"fooba"), "Zm9vYmE=");
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
}

#[test]
fn an_explicit_language_overrides_the_language_of_the_search_results() {
assert!(system_prompt("de").contains("Always respond in German"));
assert!(system_prompt("fr").contains("Always respond in French"));
// Unknown choice: the model decides, as before.
assert!(system_prompt("xx").contains("Respond in the user's language."));
}
}
98 changes: 98 additions & 0 deletions windows/src-tauri/src/claude_cli.rs
Original file line number Diff line number Diff line change
@@ -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<ChatContext>,
) -> Result<ChatReply, String> {
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() })
}
Loading