diff --git a/windows/Cargo.lock b/windows/Cargo.lock index 6278c7525..db7b79755 100644 --- a/windows/Cargo.lock +++ b/windows/Cargo.lock @@ -4282,6 +4282,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "windows-sys 0.61.2", ] diff --git a/windows/README.md b/windows/README.md index 60374024c..1db71537b 100644 --- a/windows/README.md +++ b/windows/README.md @@ -168,6 +168,16 @@ What changes on Linux: top panel, on compositors that support it: COSMIC, KDE Plasma, Hyprland, Sway and other wlroots compositors. GNOME has no layer-shell, so there the island is a regular window. `COUCOU_LAYER_SHELL=0` forces that mode anywhere. +- **Chat with your Cursor plan**: install the Cursor CLI, then Settings → + Cursor → "Sign in with Cursor", then turn Cursor on in Settings → Chat. It + runs `agent -p --mode ask` (read-only) in an empty private folder. +- **Chat with your Kiro plan**: install the Kiro CLI, run `kiro-cli login` + once, then turn Kiro on in Settings → Chat. It runs + `kiro-cli chat --no-interactive --trust-tools=` (no tools) in an empty + private folder. +- **Several chats at once**: everything turned on in Settings → Chat is tried + in order (Cursor, Kiro, then the Claude API) until one answers, so running + out of credits on one plan hands the chat to the next. - **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. - **Mochi's eyes** follow the pointer only while it is over the island: Wayland diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml index f581867d9..a413a99fc 100644 --- a/windows/src-tauri/Cargo.toml +++ b/windows/src-tauri/Cargo.toml @@ -19,7 +19,7 @@ tauri-plugin-single-instance = "2" tauri-plugin-autostart = "2" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } -tokio = { version = "1", features = ["net", "io-util", "sync", "time", "rt"] } +tokio = { version = "1", features = ["net", "io-util", "sync", "time", "rt", "process"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } [target.'cfg(windows)'.dependencies] diff --git a/windows/src-tauri/src/claude.rs b/windows/src-tauri/src/claude.rs index 060a57c64..4de8e2f57 100644 --- a/windows/src-tauri/src/claude.rs +++ b/windows/src-tauri/src/claude.rs @@ -22,7 +22,7 @@ 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. \ +pub(crate) const SYSTEM_PROMPT: &str = "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."; diff --git a/windows/src-tauri/src/cursor_chat.rs b/windows/src-tauri/src/cursor_chat.rs new file mode 100644 index 000000000..0cce37ae6 --- /dev/null +++ b/windows/src-tauri/src/cursor_chat.rs @@ -0,0 +1,277 @@ +// Chat through the Cursor CLI (`agent`), on the user's own Cursor plan. +// +// Cursor has no public chat API, and calling its private endpoints breaks its +// terms. The CLI is the supported way: `agent login` once in a browser, then +// headless `agent -p` runs. It always runs Cursor's agent harness, so it is +// slower than a raw model call; `--mode ask` keeps it read-only. +// +// Runs in a private, empty folder so the agent has nothing of the user's to +// read, and the same folder every time because `--resume` is scoped to it. + +use std::process::Stdio; +use std::sync::Mutex; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio::process::Command; + +use crate::claude::{ChatContext, ChatReply, SYSTEM_PROMPT}; +use crate::{platform, secrets}; + +pub const DEFAULT_MODEL: &str = "auto"; +const TIMEOUT: Duration = Duration::from_secs(120); +const MAX_INLINE_TEXT: u64 = 200_000; + +/// The CLI session the next turn resumes; None until the first answer. +#[derive(Default)] +pub struct CursorChat { + session: Mutex>, +} + +impl CursorChat { + pub fn reset(&self) { + *self.session.lock().unwrap() = None; + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CursorStatus { + /// Path of the CLI, or None when it is not installed. + pub cli: Option, + /// What `agent status` says, e.g. "Logged in as …". + pub status: String, + pub logged_in: bool, +} + +#[derive(Deserialize)] +struct CliResult { + #[serde(default)] + is_error: bool, + #[serde(default)] + result: String, + session_id: Option, +} + +fn cli_path() -> Option { + find_cli(&["agent", "cursor-agent"]) +} + +/// CLI installers put their binary in ~/.local/bin, which a session started +/// from the desktop launcher often does not have on its PATH. +pub(crate) fn find_cli(names: &[&str]) -> Option { + names.iter().find_map(|n| platform::find_on_path(n)).or_else(|| { + let bin = platform::home_dir().join(".local").join("bin"); + names.iter().map(|n| bin.join(n)).find(|p| p.is_file()) + }) +} + +fn workdir() -> Result { + let dir = crate::settings::local_dir().join("cursor-chat"); + platform::ensure_private_dir(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +fn command(cli: &std::path::Path) -> Command { + let mut cmd = Command::new(cli); + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true); + // Through the environment, never argv: other users can read argv in `ps`. + // Without a key the CLI uses the `agent login` session, i.e. the plan. + if let Some(key) = secrets::get("cursor-api-key") { + cmd.env("CURSOR_API_KEY", key); + } + #[cfg(windows)] + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + cmd +} + +async fn run(mut cmd: Command) -> Result { + let child = cmd.spawn().map_err(|e| format!("Could not start the Cursor CLI: {e}"))?; + match tokio::time::timeout(TIMEOUT, child.wait_with_output()).await { + Ok(out) => out.map_err(|e| e.to_string()), + Err(_) => Err("Cursor took too long to answer.".into()), + } +} + +/// One chat turn. +pub async fn send( + chat: &CursorChat, + model: &str, + query: String, + context: Option, +) -> Result { + let cli = cli_path().ok_or("Cursor CLI not found. Install it, then run `agent login`.")?; + let session = chat.session.lock().unwrap().clone(); + let prompt = build_prompt(session.is_none(), context, &query, "Cursor")?; + + let mut cmd = command(&cli); + cmd.current_dir(workdir()?) + .args(["-p", "--output-format", "json", "--mode", "ask", "--trust", "--model", model]); + if let Some(id) = &session { + cmd.args(["--resume", id]); + } + // `--` so a message starting with "-" is never read as an option. + cmd.arg("--").arg(prompt); + + let out = run(cmd).await?; + let parsed = parse_result(&out.stdout, &out.stderr)?; + if let Some(id) = parsed.1 { + *chat.session.lock().unwrap() = Some(id); + } + Ok(ChatReply { text: parsed.0 }) +} + +/// The message for a CLI that keeps the conversation itself: the system prompt +/// and the context go in the first turn only. +pub(crate) fn build_prompt( + first: bool, + context: Option, + query: &str, + who: &str, +) -> Result { + let mut prompt = String::new(); + if first { + prompt.push_str(SYSTEM_PROMPT); + prompt.push_str("\n\n"); + match &context { + Some(ChatContext::File { name, path }) => { + let text = inline_text(path) + .ok_or(format!("Only text files can be sent to {who} for now."))?; + prompt.push_str(&format!("File: {name}\nFile contents:\n{text}\n\n")); + } + 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); + Ok(prompt) +} + +/// (answer, session id) from the CLI's JSON, or the CLI's own error message. +fn parse_result(stdout: &[u8], stderr: &[u8]) -> Result<(String, Option), String> { + let stdout = String::from_utf8_lossy(stdout); + let last = stdout.lines().rev().find(|l| l.trim_start().starts_with('{')); + if let Some(r) = last.and_then(|l| serde_json::from_str::(l).ok()) { + let text = r.result.trim().to_string(); + if r.is_error { + return Err(if text.is_empty() { "Cursor returned an error.".into() } else { text }); + } + if text.is_empty() { + return Err("No response text.".into()); + } + return Ok((text, r.session_id)); + } + let stderr = String::from_utf8_lossy(stderr); + let msg = [stderr.trim(), stdout.trim()].into_iter().find(|s| !s.is_empty()).unwrap_or("Cursor CLI failed."); + Err(msg.chars().take(300).collect()) +} + +fn inline_text(path: &str) -> Option { + if std::fs::metadata(path).ok()?.len() > MAX_INLINE_TEXT { + return None; + } + std::fs::read_to_string(path).ok() +} + +/// Whether the CLI is installed and signed in, for the settings window. +pub async fn status() -> CursorStatus { + let Some(cli) = cli_path() else { + return CursorStatus { cli: None, status: String::new(), logged_in: false }; + }; + let mut cmd = command(&cli); + cmd.arg("status"); + let text = match run(cmd).await { + Ok(out) => String::from_utf8_lossy(&out.stdout).trim().to_string(), + Err(err) => err, + }; + let text = strip_ansi(&text); + CursorStatus { + logged_in: text.to_lowercase().contains("logged in as"), + status: text, + cli: Some(cli.to_string_lossy().to_string()), + } +} + +/// Opens the browser sign-in. Returns at once; the settings window polls status. +pub fn login() -> Result<(), String> { + let cli = cli_path().ok_or("Cursor CLI not found.")?; + let mut cmd = std::process::Command::new(cli); + cmd.arg("login").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + platform::no_console(&mut cmd).spawn().map(|_| ()).map_err(|e| e.to_string()) +} + +/// (id, label) pairs from `agent models`. +pub async fn models() -> Vec<(String, String)> { + let Some(cli) = cli_path() else { return Vec::new() }; + let mut cmd = command(&cli); + cmd.arg("models"); + match run(cmd).await { + Ok(out) => parse_models(&strip_ansi(&String::from_utf8_lossy(&out.stdout))), + Err(_) => Vec::new(), + } +} + +fn parse_models(text: &str) -> Vec<(String, String)> { + text.lines() + .filter_map(|l| l.trim().split_once(" - ")) + .filter(|(id, _)| !id.is_empty() && !id.contains(' ')) + .map(|(id, label)| (id.to_string(), label.trim().to_string())) + .collect() +} + +pub(crate) fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\u{1b}' { + for c in chars.by_ref() { + if c.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_answer_and_session_from_the_cli_json() { + let ok = br#"{"type":"result","subtype":"success","is_error":false,"result":"oi","session_id":"abc"}"#; + assert_eq!(parse_result(ok, b""), Ok(("oi".into(), Some("abc".into())))); + + let failed = br#"{"type":"result","is_error":true,"result":"quota exceeded"}"#; + assert_eq!(parse_result(failed, b""), Err("quota exceeded".into())); + + // Not JSON at all: the CLI's own message is what the user needs. + assert_eq!( + parse_result(b"Cannot use this model: x.\n", b""), + Err("Cannot use this model: x.".into()) + ); + assert_eq!(parse_result(b"", b"Not authenticated\n"), Err("Not authenticated".into())); + } + + #[test] + fn lists_models_and_skips_headers() { + let text = "Available models\n\nauto - Auto (current, default)\ncomposer-2.5 - Composer 2.5\n"; + assert_eq!( + parse_models(text), + vec![ + ("auto".into(), "Auto (current, default)".into()), + ("composer-2.5".into(), "Composer 2.5".into()), + ] + ); + assert_eq!(strip_ansi("\u{1b}[32m✓\u{1b}[0m Logged in"), "✓ Logged in"); + } +} diff --git a/windows/src-tauri/src/kiro_chat.rs b/windows/src-tauri/src/kiro_chat.rs new file mode 100644 index 000000000..e61834452 --- /dev/null +++ b/windows/src-tauri/src/kiro_chat.rs @@ -0,0 +1,177 @@ +// Chat through the Kiro CLI (`kiro-cli`), on the user's own Kiro plan. +// +// Like the Cursor CLI: `kiro-cli login` once, then headless +// `kiro-cli chat --no-interactive` runs. `--trust-tools=` trusts no tool, so +// the agent can only answer. +// +// The text output is used, not `--output-format stream-json`: that one needs +// the v2 engine, which does not resume conversations yet. On the v1 engine +// `--resume` picks the folder's latest conversation, so the chat runs in a +// private folder of its own and a reset just skips `--resume` once. + +use std::process::Stdio; +use std::sync::Mutex; +use std::time::Duration; + +use serde::Deserialize; +use tokio::process::Command; + +use crate::claude::{ChatContext, ChatReply}; +use crate::cursor_chat::{build_prompt, find_cli, strip_ansi, CursorStatus}; +use crate::platform; + +pub const DEFAULT_MODEL: &str = "auto"; +const TIMEOUT: Duration = Duration::from_secs(120); + +/// Whether the folder's latest conversation is this chat's. +#[derive(Default)] +pub struct KiroChat { + started: Mutex, +} + +impl KiroChat { + pub fn reset(&self) { + *self.started.lock().unwrap() = false; + } +} + +fn cli_path() -> Option { + find_cli(&["kiro-cli"]) +} + +fn workdir() -> Result { + let dir = crate::settings::local_dir().join("kiro-chat"); + platform::ensure_private_dir(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +fn command(cli: &std::path::Path) -> Command { + let mut cmd = Command::new(cli); + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true); + #[cfg(windows)] + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + cmd +} + +async fn run(mut cmd: Command) -> Result { + let child = cmd.spawn().map_err(|e| format!("Could not start the Kiro CLI: {e}"))?; + match tokio::time::timeout(TIMEOUT, child.wait_with_output()).await { + Ok(out) => out.map_err(|e| e.to_string()), + Err(_) => Err("Kiro took too long to answer.".into()), + } +} + +/// One chat turn. +pub async fn send( + chat: &KiroChat, + model: &str, + query: String, + context: Option, +) -> Result { + let cli = cli_path().ok_or("Kiro CLI not found. Install it, then run `kiro-cli login`.")?; + let started = *chat.started.lock().unwrap(); + let prompt = build_prompt(!started, context, &query, "Kiro")?; + + let mut cmd = command(&cli); + cmd.current_dir(workdir()?).args([ + "chat", "--agent-engine", "v1", "--no-interactive", "--trust-tools=", "--wrap", "never", + "--model", model, + ]); + if started { + cmd.arg("--resume"); + } + // `--` so a message starting with "-" is never read as an option. + cmd.arg("--").arg(prompt); + + let out = run(cmd).await?; + let text = parse_answer(out.status.success(), &out.stdout, &out.stderr)?; + *chat.started.lock().unwrap() = true; + Ok(ChatReply { text }) +} + +/// The answer from stdout, or the CLI's `error: …` line. +fn parse_answer(ok: bool, stdout: &[u8], stderr: &[u8]) -> Result { + if ok { + let text = strip_ansi(&String::from_utf8_lossy(stdout)); + let text = text.trim_start(); + let text = text.strip_prefix('>').unwrap_or(text).trim(); + return if text.is_empty() { Err("No response text.".into()) } else { Ok(text.to_string()) }; + } + let stderr = strip_ansi(&String::from_utf8_lossy(stderr)); + let msg = stderr + .lines() + .find_map(|l| l.trim().strip_prefix("error:")) + .or_else(|| stderr.lines().map(str::trim).filter(|l| !l.is_empty()).last()) + .unwrap_or("Kiro CLI failed."); + Err(msg.trim().chars().take(300).collect()) +} + +/// Whether the CLI is installed and signed in, for the settings window. +pub async fn status() -> CursorStatus { + let Some(cli) = cli_path() else { + return CursorStatus { cli: None, status: String::new(), logged_in: false }; + }; + let mut cmd = command(&cli); + cmd.arg("whoami"); + let (logged_in, text) = match run(cmd).await { + Ok(out) => (out.status.success(), String::from_utf8_lossy(&out.stdout).to_string()), + Err(err) => (false, err), + }; + let first = strip_ansi(&text).lines().next().unwrap_or("").trim().to_string(); + CursorStatus { logged_in, status: first, cli: Some(cli.to_string_lossy().to_string()) } +} + +#[derive(Deserialize)] +struct ModelList { + models: Vec, +} + +#[derive(Deserialize)] +struct Model { + model_id: String, + model_name: String, +} + +/// (id, label) pairs from `kiro-cli chat --list-models`. +pub async fn models() -> Vec<(String, String)> { + let Some(cli) = cli_path() else { return Vec::new() }; + let mut cmd = command(&cli); + cmd.args(["chat", "--list-models", "--format", "json"]); + match run(cmd).await { + Ok(out) => parse_models(&out.stdout), + Err(_) => Vec::new(), + } +} + +fn parse_models(json: &[u8]) -> Vec<(String, String)> { + serde_json::from_slice::(json) + .map(|l| l.models.into_iter().map(|m| (m.model_id, m.model_name)).collect()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_answer_or_the_cli_error() { + let ok = b"\x1b[m> \x1b[0m\x1b[1m## Fruits\x1b[0m\n\n- Apple\x1b[0m"; + assert_eq!(parse_answer(true, ok, b""), Ok("## Fruits\n\n- Apple".into())); + assert_eq!(parse_answer(true, b"\x1b[m> \x1b[0m", b""), Err("No response text.".into())); + + let err = b"\x1b[mWARNING: \x1b[0m--trust-tools arg\nerror: Model 'x' does not exist.\n"; + assert_eq!(parse_answer(false, b"", err), Err("Model 'x' does not exist.".into())); + assert_eq!(parse_answer(false, b"", b"Not logged in\n"), Err("Not logged in".into())); + } + + #[test] + fn lists_models_from_the_cli_json() { + let json = br#"{"models":[{"model_name":"auto","description":"x","model_id":"auto"}, + {"model_name":"claude-sonnet-5","model_id":"claude-sonnet-5","rate_multiplier":1.3}]}"#; + assert_eq!( + parse_models(json), + vec![("auto".into(), "auto".into()), ("claude-sonnet-5".into(), "claude-sonnet-5".into())] + ); + assert!(parse_models(b"not json").is_empty()); + } +} diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..33aaf0bbe 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -1,6 +1,8 @@ // Coucou for Windows — app wiring and the commands the island calls. mod claude; +mod cursor_chat; +mod kiro_chat; mod files; mod hooks; mod integrations; @@ -21,6 +23,8 @@ use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder use tauri_plugin_autostart::{ManagerExt, MacosLauncher}; use claude::{Chat, ChatContext, ChatReply}; +use cursor_chat::CursorChat; +use kiro_chat::KiroChat; use files::DroppedFile; use hooks::{HookPreview, HookStatus}; use island::{PollGate, ScreenInfo}; @@ -238,16 +242,78 @@ fn approval_decline(app: AppHandle, request_id: String) { async fn chat_send( shared: State<'_, Shared>, chat: State<'_, Chat>, + cursor: State<'_, CursorChat>, + kiro: State<'_, KiroChat>, query: String, context: Option, ) -> Result { - let model = shared.settings.lock().unwrap().model.clone(); - claude::send(&chat, &model, query, context).await + let (providers, model, cursor_model, kiro_model) = { + let s = shared.settings.lock().unwrap(); + (s.chat_providers.clone(), s.model.clone(), s.cursor_model.clone(), s.kiro_model.clone()) + }; + // Each provider keeps its own conversation: one taking over starts fresh. + let mut errors = Vec::new(); + for p in CHAT_ORDER.iter().filter(|p| providers.iter().any(|q| q == *p)) { + let (q, c) = (query.clone(), context.clone()); + let reply = match *p { + "cursor" => cursor_chat::send(&cursor, &cursor_model, q, c).await, + "kiro" => kiro_chat::send(&kiro, &kiro_model, q, c).await, + _ => claude::send(&chat, &model, q, c).await, + }; + match reply { + Ok(r) => return Ok(r), + Err(e) => errors.push((*p, e)), + } + } + Err(match errors.len() { + 0 => "No chat is turned on. Pick one in Settings → Chat.".into(), + 1 => errors.remove(0).1, + _ => errors.iter().map(|(p, e)| format!("{}: {e}", chat_name(p))).collect::>().join("\n"), + }) +} + +/// The order chat providers are tried in: the user's plans before the paid API. +const CHAT_ORDER: [&str; 3] = ["cursor", "kiro", "anthropic"]; + +fn chat_name(provider: &str) -> &'static str { + match provider { + "cursor" => "Cursor", + "kiro" => "Kiro", + _ => "Claude API", + } } #[tauri::command] -fn chat_reset(chat: State) { +fn chat_reset(chat: State, cursor: State, kiro: State) { chat.reset(); + cursor.reset(); + kiro.reset(); +} + +#[tauri::command] +async fn kiro_status() -> cursor_chat::CursorStatus { + kiro_chat::status().await +} + +#[tauri::command] +async fn kiro_models() -> Vec<(String, String)> { + kiro_chat::models().await +} + +#[tauri::command] +async fn cursor_status() -> cursor_chat::CursorStatus { + cursor_chat::status().await +} + +#[tauri::command] +async fn cursor_models() -> Vec<(String, String)> { + cursor_chat::models().await +} + +/// Only from the "Sign in with Cursor" button: opens the browser sign-in. +#[tauri::command] +fn cursor_login() -> Result<(), String> { + cursor_chat::login() } /// Copies a dropped file into the inbox and reports its name back. @@ -374,6 +440,8 @@ pub fn run() { }) .manage(Pending::default()) .manage(Chat::default()) + .manage(CursorChat::default()) + .manage(KiroChat::default()) .invoke_handler(tauri::generate_handler![ boot, save_settings, @@ -393,6 +461,11 @@ pub fn run() { log_line, chat_send, chat_reset, + cursor_status, + cursor_models, + cursor_login, + kiro_status, + kiro_models, ingest_file, secret_present, secret_set, diff --git a/windows/src-tauri/src/secrets.rs b/windows/src-tauri/src/secrets.rs index 5c37b6529..55438f955 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", + "cursor-api-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 2a8d7d359..9fcc35e91 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -20,12 +20,32 @@ pub struct Settings { /// Defaulted explicitly so a settings.json written by an older build still loads. #[serde(default = "default_model")] pub model: String, + /// Who may answer in the chat, tried in this order until one answers: + /// "cursor", "kiro" (that CLI and the user's plan), "anthropic" (API key). + #[serde(default = "default_chat_providers")] + pub chat_providers: Vec, + #[serde(default = "default_cursor_model")] + pub cursor_model: String, + #[serde(default = "default_kiro_model")] + pub kiro_model: String, } fn default_model() -> String { crate::claude::DEFAULT_MODEL.to_string() } +fn default_chat_providers() -> Vec { + vec!["anthropic".into()] +} + +fn default_cursor_model() -> String { + crate::cursor_chat::DEFAULT_MODEL.to_string() +} + +fn default_kiro_model() -> String { + crate::kiro_chat::DEFAULT_MODEL.to_string() +} + impl Default for Settings { fn default() -> Self { Self { @@ -43,6 +63,9 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + chat_providers: default_chat_providers(), + cursor_model: default_cursor_model(), + kiro_model: default_kiro_model(), } } } diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..58a548087 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -85,6 +85,16 @@ export const Bridge = { chatSend: (query: string, context: ChatContext | null) => callOrThrow<{ text: string }>("chat_send", { query, context }), chatReset: () => call("chat_reset"), + /** Whether the Cursor CLI is installed and signed in. */ + cursorStatus: () => call("cursor_status"), + /** [id, label] pairs the user's Cursor plan can use. */ + cursorModels: () => call<[string, string][]>("cursor_models"), + /** Opens Cursor's browser sign-in. */ + cursorLogin: () => callOrThrow("cursor_login"), + /** Whether the Kiro CLI is installed and signed in. */ + kiroStatus: () => call("kiro_status"), + /** [id, label] pairs the user's Kiro plan can use. */ + kiroModels: () => call<[string, string][]>("kiro_models"), /** 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,6 +128,13 @@ export interface DroppedFile { size: number; } +export interface CursorStatus { + /** Path of the CLI, or null when it is not installed. */ + cli: string | null; + status: string; + loggedIn: boolean; +} + export interface HookStatus { installed: boolean; settingsPath: string; diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..d4c48ffcc 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -92,6 +92,10 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + /** Who may answer in the chat; Rust tries Cursor, then Kiro, then the API, until one answers. */ + chatProviders: ("anthropic" | "cursor" | "kiro")[]; + cursorModel: string; + kiroModel: string; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +110,9 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + chatProviders: ["anthropic"], + cursorModel: "auto", + kiroModel: "auto", }; type Listener = () => void; diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..f833fda49 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -254,6 +254,188 @@ function apiSection(hasKey: boolean): HTMLElement { ); } +// ── Chat section ────────────────────────────────────────────────────────────── + +type ChatProvider = Settings["chatProviders"][number]; + +function chatSection(): HTMLElement { + const providers: [ChatProvider, string][] = [ + ["cursor", "Cursor (your plan)"], + ["kiro", "Kiro (your plan)"], + ["anthropic", "Claude (API key)"], + ]; + const rows = providers.map(([id, name]) => + h("div", { class: "row" }, + toggle(settings.chatProviders.includes(id), (on) => { + const next = settings.chatProviders.filter((p) => p !== id); + settings.chatProviders = on ? [...next, id] : next; + void save(); + }), + h("span", { text: name }), + ), + ); + return h( + "section", + {}, + h("h2", {}, h("span", { text: "Chat" })), + h("span", { + class: "hint", + text: "Who answers in the island chat. Turned-on ones are tried top to bottom: when one fails, out of credits for instance, the next one answers.", + }), + ...rows, + ); +} + +// ── Cursor section ──────────────────────────────────────────────────────────── + +const CURSOR_INSTALL = "curl https://cursor.com/install -fsS | bash"; + +function cursorSection(hasKey: boolean): HTMLElement { + const dot = statusDot(false); + const state = h("span", { class: "hint", text: "Checking the Cursor CLI…" }); + + const model = h("select", {}) as HTMLSelectElement; + const setModels = (list: [string, string][]) => { + clear(model); + if (!list.some(([id]) => id === "auto")) list.unshift(["auto", "Auto"]); + for (const [id, label] of list) model.append(h("option", { value: id, text: `${label} (${id})` })); + if (!list.some(([id]) => id === settings.cursorModel)) { + model.append(h("option", { value: settings.cursorModel, text: settings.cursorModel })); + } + model.value = settings.cursorModel; + }; + setModels([]); + model.addEventListener("change", () => { + settings.cursorModel = model.value; + void save(); + }); + + const login = h("button", { class: "primary", text: "Sign in with Cursor" }); + const recheck = h("button", { text: "Check again" }); + const feedback = h("div", {}); + + async function refresh() { + const s = await Bridge.cursorStatus(); + clear(feedback); + if (!s || !s.cli) { + dot.style.background = "#f4505e"; + state.textContent = "Cursor CLI not found. Install it, then sign in:"; + feedback.append(h("div", { class: "row" }, h("span", { class: "path", text: CURSOR_INSTALL }))); + login.style.display = "none"; + return; + } + dot.style.background = s.loggedIn ? "#22c55e" : "#f4505e"; + state.textContent = s.loggedIn + ? `${s.status.replace(/^✓\s*/, "")}. Turn Cursor on under Chat to use it, read-only.` + : "Not signed in. Sign in once in the browser, then check again."; + login.style.display = s.loggedIn ? "none" : ""; + if (s.loggedIn) setModels((await Bridge.cursorModels()) ?? []); + } + + login.addEventListener("click", async () => { + clear(feedback); + try { + await Bridge.cursorLogin(); + feedback.append(h("div", { class: "notice ok", text: "Finish signing in in your browser, then click Check again." })); + } catch (err) { + feedback.append(h("div", { class: "notice err", text: `Could not start the sign-in: ${String(err)}` })); + } + }); + recheck.addEventListener("click", () => void refresh()); + + const key = h("input", { + type: "password", + placeholder: hasKey ? "•••••••••••• (stored)" : "optional, instead of signing in", + style: "flex:1 1 auto;min-width:0", + autocomplete: "off", + spellcheck: "false", + }) as HTMLInputElement; + const saveKey = h("button", { text: "Save" }); + saveKey.addEventListener("click", async () => { + const value = key.value.trim(); + try { + await Bridge.secretSet("cursor-api-key", value); + key.value = ""; + key.placeholder = value ? "•••••••••••• (stored)" : "optional, instead of signing in"; + } catch (err) { + feedback.append(h("div", { class: "notice err", text: `Could not save: ${String(err)}` })); + } + }); + + void refresh(); + + return h( + "section", + {}, + h("h2", {}, dot, h("span", { text: "Cursor" })), + state, + h("div", { class: "row" }, h("label", { text: "Model" }), model), + h("div", { class: "row" }, login, recheck), + h("div", { class: "row" }, h("label", { text: "API key" }), key, saveKey), + feedback, + ); +} + +// ── Kiro section ────────────────────────────────────────────────────────────── + +const KIRO_INSTALL = "curl -fsSL https://cli.kiro.dev/install | bash"; + +function kiroSection(): HTMLElement { + const dot = statusDot(false); + const state = h("span", { class: "hint", text: "Checking the Kiro CLI…" }); + + const model = h("select", {}) as HTMLSelectElement; + const setModels = (list: [string, string][]) => { + clear(model); + if (!list.some(([id]) => id === "auto")) list.unshift(["auto", "auto"]); + for (const [id, label] of list) { + model.append(h("option", { value: id, text: label === id ? id : `${label} (${id})` })); + } + if (!list.some(([id]) => id === settings.kiroModel)) { + model.append(h("option", { value: settings.kiroModel, text: settings.kiroModel })); + } + model.value = settings.kiroModel; + }; + setModels([]); + model.addEventListener("change", () => { + settings.kiroModel = model.value; + void save(); + }); + + const recheck = h("button", { text: "Check again" }); + const feedback = h("div", {}); + + async function refresh() { + const s = await Bridge.kiroStatus(); + clear(feedback); + if (!s || !s.cli) { + dot.style.background = "#f4505e"; + state.textContent = "Kiro CLI not found. Install it, then sign in:"; + feedback.append(h("div", { class: "row" }, h("span", { class: "path", text: KIRO_INSTALL }))); + return; + } + dot.style.background = s.loggedIn ? "#22c55e" : "#f4505e"; + state.textContent = s.loggedIn + ? `${s.status}. Turn Kiro on under Chat to use it, read-only.` + : "Not signed in. Run this once in a terminal, then check again:"; + if (s.loggedIn) setModels((await Bridge.kiroModels()) ?? []); + else feedback.append(h("div", { class: "row" }, h("span", { class: "path", text: "kiro-cli login" }))); + } + recheck.addEventListener("click", () => void refresh()); + + void refresh(); + + return h( + "section", + {}, + h("h2", {}, dot, h("span", { text: "Kiro" })), + state, + h("div", { class: "row" }, h("label", { text: "Model" }), model), + h("div", { class: "row" }, recheck), + feedback, + ); +} + // ── Integrations section ────────────────────────────────────────────────────── interface IntegrationDef { @@ -443,6 +625,9 @@ async function main() { h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), apiSection(hasKey), + chatSection(), + cursorSection((await Bridge.secretPresent("cursor-api-key")) ?? false), + kiroSection(), integrationsSection(present), generalSection(), h("div", { diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f..2afa9c645 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -6,6 +6,7 @@ import { h, svg, clear, dot } from "./dom"; import { ICONS } from "./icons"; import { Ticker } from "./ticker"; import { State, type AgentTask } from "../core/state"; +import { Bridge, type CursorStatus } from "../core/bridge"; import { washRGBA, type IslandViewName, type Wash } from "../core/layout"; import { createMiniBot, pruneMiniBots } from "../mochi/minibots"; import { buildPrompt } from "./chat"; @@ -407,6 +408,29 @@ function buildNote(): ViewHost { // ── In-island settings ──────────────────────────────────────────────────────── +/** Green when the CLI is signed in. Its status spawns the CLI: checked at most every 30 s. */ +function cliBadge(name: string, status: () => Promise) { + const el = h("span", { class: "status-badge" }); + let loggedIn = false; + let checkedAt = 0; + const paint = () => { + clear(el); + el.append(dot(loggedIn ? "#22C55E" : "#F4505E", 6), h("span", { text: name })); + }; + return { + el, + sync() { + paint(); + if (Date.now() - checkedAt < 30_000) return; + checkedAt = Date.now(); + void status().then((st) => { + loggedIn = st?.loggedIn ?? false; + paint(); + }); + }, + }; +} + function buildSettings(actions: ViewActions): ViewHost { const soundSwitch = h("button", { class: "switch", onclick: () => actions.toggleSound() }); const volume = h("input", { @@ -419,6 +443,8 @@ function buildSettings(actions: ViewActions): ViewHost { ); const claudeBadge = h("span", { class: "status-badge" }); const apiBadge = h("span", { class: "status-badge" }); + const cursorBadge = cliBadge("Cursor", Bridge.cursorStatus); + const kiroBadge = cliBadge("Kiro", Bridge.kiroStatus); const rows = h( "div", @@ -436,6 +462,8 @@ function buildSettings(actions: ViewActions): ViewHost { { class: "settings-row", style: "gap:14px" }, claudeBadge, apiBadge, + cursorBadge.el, + kiroBadge.el, h("div", { class: "grow" }), h("button", { class: "link-btn", @@ -465,6 +493,8 @@ function buildSettings(actions: ViewActions): ViewHost { ); clear(apiBadge); apiBadge.append(dot("#F4505E", 6), h("span", { text: "API" })); + cursorBadge.sync(); + kiroBadge.sync(); }, }; }