From d95b4eea86bf7e51de3c768c597af9f21f14073b Mon Sep 17 00:00:00 2001 From: Beto Doescher Date: Fri, 2 Oct 2026 16:20:25 -0300 Subject: [PATCH 1/4] Chat with your own Cursor plan, through the Cursor CLI Cursor has no public chat API, and calling its private endpoints is against its terms. The supported way is the Cursor CLI: sign in once with `agent login`, then each chat turn runs `agent -p --output-format json --mode ask --trust --model `, resuming the same session for the next turns. - Read-only (`--mode ask`), in an empty private folder under the local data directory, so the agent has nothing of the user's to read. The folder is always the same because `--resume` is scoped to it. - 120 s timeout, the child is killed on drop. An optional API key goes through CURSOR_API_KEY in the environment (never argv) and lives in the Secret Service / Credential Manager like every other key. - Text files ride along inline on the first turn; images and PDFs say they are not supported with Cursor yet. Settings -> Cursor: "Chat answers with" Claude (API key) or Cursor (your plan), the model list from `agent models`, sign-in status from `agent status`, a "Sign in with Cursor" button and the optional key. Claude stays the default. Co-authored-by: Cursor --- windows/Cargo.lock | 1 + windows/README.md | 3 + windows/src-tauri/Cargo.toml | 2 +- windows/src-tauri/src/claude.rs | 2 +- windows/src-tauri/src/cursor_chat.rs | 257 +++++++++++++++++++++++++++ windows/src-tauri/src/lib.rs | 37 +++- windows/src-tauri/src/secrets.rs | 1 + windows/src-tauri/src/settings.rs | 15 ++ windows/src/core/bridge.ts | 13 ++ windows/src/core/state.ts | 5 + windows/src/settings/main.ts | 103 +++++++++++ 11 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 windows/src-tauri/src/cursor_chat.rs 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..c8559f15d 100644 --- a/windows/README.md +++ b/windows/README.md @@ -168,6 +168,9 @@ 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" and pick "Cursor (your plan)". It runs + `agent -p --mode ask` (read-only) in an empty private folder. - **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..2a964a3b2 --- /dev/null +++ b/windows/src-tauri/src/cursor_chat.rs @@ -0,0 +1,257 @@ +// 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 { + platform::find_on_path("agent").or_else(|| platform::find_on_path("cursor-agent")) +} + +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 mut prompt = String::new(); + if session.is_none() { + prompt.push_str(SYSTEM_PROMPT); + prompt.push_str("\n\n"); + match &context { + Some(ChatContext::File { name, path }) => { + let text = inline_text(path) + .ok_or("Only text files can be sent to Cursor 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); + + 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 }) +} + +/// (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() +} + +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/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..4cf578e8f 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ // Coucou for Windows — app wiring and the commands the island calls. mod claude; +mod cursor_chat; mod files; mod hooks; mod integrations; @@ -21,6 +22,7 @@ use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder use tauri_plugin_autostart::{ManagerExt, MacosLauncher}; use claude::{Chat, ChatContext, ChatReply}; +use cursor_chat::CursorChat; use files::DroppedFile; use hooks::{HookPreview, HookStatus}; use island::{PollGate, ScreenInfo}; @@ -238,16 +240,41 @@ fn approval_decline(app: AppHandle, request_id: String) { async fn chat_send( shared: State<'_, Shared>, chat: State<'_, Chat>, + cursor: State<'_, CursorChat>, query: String, context: Option, ) -> Result { - let model = shared.settings.lock().unwrap().model.clone(); - claude::send(&chat, &model, query, context).await + let (provider, model, cursor_model) = { + let s = shared.settings.lock().unwrap(); + (s.chat_provider.clone(), s.model.clone(), s.cursor_model.clone()) + }; + if provider == "cursor" { + cursor_chat::send(&cursor, &cursor_model, query, context).await + } else { + claude::send(&chat, &model, query, context).await + } } #[tauri::command] -fn chat_reset(chat: State) { +fn chat_reset(chat: State, cursor: State) { chat.reset(); + cursor.reset(); +} + +#[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 +401,7 @@ pub fn run() { }) .manage(Pending::default()) .manage(Chat::default()) + .manage(CursorChat::default()) .invoke_handler(tauri::generate_handler![ boot, save_settings, @@ -393,6 +421,9 @@ pub fn run() { log_line, chat_send, chat_reset, + cursor_status, + cursor_models, + cursor_login, 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..12a85ff98 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -20,12 +20,25 @@ pub struct Settings { /// Defaulted explicitly so a settings.json written by an older build still loads. #[serde(default = "default_model")] pub model: String, + /// "anthropic" (API key) or "cursor" (the Cursor CLI and the user's plan). + #[serde(default = "default_chat_provider")] + pub chat_provider: String, + #[serde(default = "default_cursor_model")] + pub cursor_model: String, } fn default_model() -> String { crate::claude::DEFAULT_MODEL.to_string() } +fn default_chat_provider() -> String { + "anthropic".into() +} + +fn default_cursor_model() -> String { + crate::cursor_chat::DEFAULT_MODEL.to_string() +} + impl Default for Settings { fn default() -> Self { Self { @@ -43,6 +56,8 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + chat_provider: default_chat_provider(), + cursor_model: default_cursor_model(), } } } diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..807af30e0 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -85,6 +85,12 @@ 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"), /** 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 +124,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..9ff757122 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -92,6 +92,9 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + /** Who answers in the chat: the Anthropic API, or the Cursor CLI on the user's plan. */ + chatProvider: "anthropic" | "cursor"; + cursorModel: string; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +109,8 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + chatProvider: "anthropic", + cursorModel: "auto", }; type Listener = () => void; diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..7921a2a1b 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -254,6 +254,108 @@ function apiSection(hasKey: boolean): HTMLElement { ); } +// ── 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 provider = h("select", {}) as HTMLSelectElement; + provider.append( + h("option", { value: "anthropic", text: "Claude (API key)" }), + h("option", { value: "cursor", text: "Cursor (your plan)" }), + ); + provider.value = settings.chatProvider; + provider.addEventListener("change", () => { + settings.chatProvider = provider.value as Settings["chatProvider"]; + void save(); + }); + + 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*/, "")}. The chat uses your Cursor plan, 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: "Chat answers with" }), provider), + 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, + ); +} + // ── Integrations section ────────────────────────────────────────────────────── interface IntegrationDef { @@ -443,6 +545,7 @@ async function main() { h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), apiSection(hasKey), + cursorSection((await Bridge.secretPresent("cursor-api-key")) ?? false), integrationsSection(present), generalSection(), h("div", { From 275a7b04873f06c517acdc631f307f99f3dc29c7 Mon Sep 17 00:00:00 2001 From: Beto Doescher Date: Fri, 2 Oct 2026 16:28:51 -0300 Subject: [PATCH 2/4] Find the Cursor CLI in ~/.local/bin when the desktop session's PATH lacks it Co-authored-by: Cursor --- windows/src-tauri/src/cursor_chat.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/windows/src-tauri/src/cursor_chat.rs b/windows/src-tauri/src/cursor_chat.rs index 2a964a3b2..073cf16ac 100644 --- a/windows/src-tauri/src/cursor_chat.rs +++ b/windows/src-tauri/src/cursor_chat.rs @@ -53,8 +53,15 @@ struct CliResult { session_id: Option, } +/// The installer puts the CLI in ~/.local/bin, which a session started from the +/// desktop launcher often does not have on its PATH. fn cli_path() -> Option { - platform::find_on_path("agent").or_else(|| platform::find_on_path("cursor-agent")) + platform::find_on_path("agent") + .or_else(|| platform::find_on_path("cursor-agent")) + .or_else(|| { + let bin = platform::home_dir().join(".local").join("bin"); + ["agent", "cursor-agent"].into_iter().map(|n| bin.join(n)).find(|p| p.is_file()) + }) } fn workdir() -> Result { From eb43e6a67e6c8b0d8ed05ac3e41270682286939c Mon Sep 17 00:00:00 2001 From: Beto Doescher Date: Fri, 2 Oct 2026 16:38:54 -0300 Subject: [PATCH 3/4] Show the Cursor sign-in status next to Claude Code and API in the island settings Co-authored-by: Cursor --- windows/src/views/views.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f..3145ca969 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 } from "../core/bridge"; import { washRGBA, type IslandViewName, type Wash } from "../core/layout"; import { createMiniBot, pruneMiniBots } from "../mochi/minibots"; import { buildPrompt } from "./chat"; @@ -419,6 +420,22 @@ function buildSettings(actions: ViewActions): ViewHost { ); const claudeBadge = h("span", { class: "status-badge" }); const apiBadge = h("span", { class: "status-badge" }); + const cursorBadge = h("span", { class: "status-badge" }); + let cursorLoggedIn = false; + let cursorCheckedAt = 0; + const paintCursor = () => { + clear(cursorBadge); + cursorBadge.append(dot(cursorLoggedIn ? "#22C55E" : "#F4505E", 6), h("span", { text: "Cursor" })); + }; + // `agent status` spawns the CLI: checked at most every 30 s, not on every sync. + const refreshCursor = () => { + if (Date.now() - cursorCheckedAt < 30_000) return; + cursorCheckedAt = Date.now(); + void Bridge.cursorStatus().then((st) => { + cursorLoggedIn = st?.loggedIn ?? false; + paintCursor(); + }); + }; const rows = h( "div", @@ -436,6 +453,7 @@ function buildSettings(actions: ViewActions): ViewHost { { class: "settings-row", style: "gap:14px" }, claudeBadge, apiBadge, + cursorBadge, h("div", { class: "grow" }), h("button", { class: "link-btn", @@ -465,6 +483,8 @@ function buildSettings(actions: ViewActions): ViewHost { ); clear(apiBadge); apiBadge.append(dot("#F4505E", 6), h("span", { text: "API" })); + paintCursor(); + refreshCursor(); }, }; } From dcf1ac1a650d63a1cf838ea1503e40aa385a969c Mon Sep 17 00:00:00 2001 From: Beto Doescher Date: Fri, 2 Oct 2026 18:13:51 -0300 Subject: [PATCH 4/4] Chat with your own Kiro plan, and fall back between chats Adds the Kiro CLI as a chat provider next to Cursor and the Claude API: `kiro-cli chat --no-interactive --trust-tools=` (no tools, answers only) in a private folder, with `--resume` from the second turn. The text output is used because stream-json needs the v2 engine, which does not resume yet. Settings -> Chat now turns each provider on or off. The turned-on ones are tried in order (Cursor, Kiro, then the API) until one answers, so running out of credits on one plan hands the chat to the next. A Kiro section shows the CLI status and models, and the island shows a Kiro dot next to Cursor. Co-authored-by: Cursor --- windows/README.md | 11 +- windows/src-tauri/src/cursor_chat.rs | 69 ++++++----- windows/src-tauri/src/kiro_chat.rs | 177 +++++++++++++++++++++++++++ windows/src-tauri/src/lib.rs | 56 +++++++-- windows/src-tauri/src/settings.rs | 20 ++- windows/src/core/bridge.ts | 4 + windows/src/core/state.ts | 8 +- windows/src/settings/main.ts | 108 ++++++++++++++-- windows/src/views/views.ts | 50 +++++--- 9 files changed, 424 insertions(+), 79 deletions(-) create mode 100644 windows/src-tauri/src/kiro_chat.rs diff --git a/windows/README.md b/windows/README.md index c8559f15d..1db71537b 100644 --- a/windows/README.md +++ b/windows/README.md @@ -169,8 +169,15 @@ What changes on Linux: 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" and pick "Cursor (your plan)". It runs - `agent -p --mode ask` (read-only) in an empty private folder. + 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/src/cursor_chat.rs b/windows/src-tauri/src/cursor_chat.rs index 073cf16ac..0cce37ae6 100644 --- a/windows/src-tauri/src/cursor_chat.rs +++ b/windows/src-tauri/src/cursor_chat.rs @@ -53,15 +53,17 @@ struct CliResult { session_id: Option, } -/// The installer puts the CLI in ~/.local/bin, which a session started from the -/// desktop launcher often does not have on its PATH. fn cli_path() -> Option { - platform::find_on_path("agent") - .or_else(|| platform::find_on_path("cursor-agent")) - .or_else(|| { - let bin = platform::home_dir().join(".local").join("bin"); - ["agent", "cursor-agent"].into_iter().map(|n| bin.join(n)).find(|p| p.is_file()) - }) + 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 { @@ -100,15 +102,41 @@ pub async fn send( ) -> 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 session.is_none() { + 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("Only text files can be sent to Cursor for now.")?; + .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 }) => { @@ -121,23 +149,8 @@ pub async fn send( None => {} } } - prompt.push_str(&query); - - 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 }) + prompt.push_str(query); + Ok(prompt) } /// (answer, session id) from the CLI's JSON, or the CLI's own error message. @@ -212,7 +225,7 @@ fn parse_models(text: &str) -> Vec<(String, String)> { .collect() } -fn strip_ansi(s: &str) -> String { +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() { 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 4cf578e8f..33aaf0bbe 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod claude; mod cursor_chat; +mod kiro_chat; mod files; mod hooks; mod integrations; @@ -23,6 +24,7 @@ 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}; @@ -241,24 +243,61 @@ async fn chat_send( shared: State<'_, Shared>, chat: State<'_, Chat>, cursor: State<'_, CursorChat>, + kiro: State<'_, KiroChat>, query: String, context: Option, ) -> Result { - let (provider, model, cursor_model) = { + let (providers, model, cursor_model, kiro_model) = { let s = shared.settings.lock().unwrap(); - (s.chat_provider.clone(), s.model.clone(), s.cursor_model.clone()) + (s.chat_providers.clone(), s.model.clone(), s.cursor_model.clone(), s.kiro_model.clone()) }; - if provider == "cursor" { - cursor_chat::send(&cursor, &cursor_model, query, context).await - } else { - claude::send(&chat, &model, query, context).await + // 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, cursor: 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] @@ -402,6 +441,7 @@ pub fn run() { .manage(Pending::default()) .manage(Chat::default()) .manage(CursorChat::default()) + .manage(KiroChat::default()) .invoke_handler(tauri::generate_handler![ boot, save_settings, @@ -424,6 +464,8 @@ pub fn run() { cursor_status, cursor_models, cursor_login, + kiro_status, + kiro_models, ingest_file, secret_present, secret_set, diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 12a85ff98..9fcc35e91 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -20,25 +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, - /// "anthropic" (API key) or "cursor" (the Cursor CLI and the user's plan). - #[serde(default = "default_chat_provider")] - pub chat_provider: 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_provider() -> String { - "anthropic".into() +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 { @@ -56,8 +63,9 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), - chat_provider: default_chat_provider(), + 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 807af30e0..58a548087 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -91,6 +91,10 @@ export const Bridge = { 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. */ diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 9ff757122..d4c48ffcc 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -92,9 +92,10 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; - /** Who answers in the chat: the Anthropic API, or the Cursor CLI on the user's plan. */ - chatProvider: "anthropic" | "cursor"; + /** 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 = { @@ -109,8 +110,9 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", - chatProvider: "anthropic", + chatProviders: ["anthropic"], cursorModel: "auto", + kiroModel: "auto", }; type Listener = () => void; diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 7921a2a1b..f833fda49 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -254,6 +254,38 @@ 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"; @@ -262,17 +294,6 @@ function cursorSection(hasKey: boolean): HTMLElement { const dot = statusDot(false); const state = h("span", { class: "hint", text: "Checking the Cursor CLI…" }); - const provider = h("select", {}) as HTMLSelectElement; - provider.append( - h("option", { value: "anthropic", text: "Claude (API key)" }), - h("option", { value: "cursor", text: "Cursor (your plan)" }), - ); - provider.value = settings.chatProvider; - provider.addEventListener("change", () => { - settings.chatProvider = provider.value as Settings["chatProvider"]; - void save(); - }); - const model = h("select", {}) as HTMLSelectElement; const setModels = (list: [string, string][]) => { clear(model); @@ -305,7 +326,7 @@ function cursorSection(hasKey: boolean): HTMLElement { } dot.style.background = s.loggedIn ? "#22c55e" : "#f4505e"; state.textContent = s.loggedIn - ? `${s.status.replace(/^✓\s*/, "")}. The chat uses your Cursor plan, read-only.` + ? `${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()) ?? []); @@ -348,7 +369,6 @@ function cursorSection(hasKey: boolean): HTMLElement { {}, h("h2", {}, dot, h("span", { text: "Cursor" })), state, - h("div", { class: "row" }, h("label", { text: "Chat answers with" }), provider), 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), @@ -356,6 +376,66 @@ function cursorSection(hasKey: boolean): HTMLElement { ); } +// ── 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 { @@ -545,7 +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 3145ca969..2afa9c645 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -6,7 +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 } from "../core/bridge"; +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"; @@ -408,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", { @@ -420,22 +443,8 @@ function buildSettings(actions: ViewActions): ViewHost { ); const claudeBadge = h("span", { class: "status-badge" }); const apiBadge = h("span", { class: "status-badge" }); - const cursorBadge = h("span", { class: "status-badge" }); - let cursorLoggedIn = false; - let cursorCheckedAt = 0; - const paintCursor = () => { - clear(cursorBadge); - cursorBadge.append(dot(cursorLoggedIn ? "#22C55E" : "#F4505E", 6), h("span", { text: "Cursor" })); - }; - // `agent status` spawns the CLI: checked at most every 30 s, not on every sync. - const refreshCursor = () => { - if (Date.now() - cursorCheckedAt < 30_000) return; - cursorCheckedAt = Date.now(); - void Bridge.cursorStatus().then((st) => { - cursorLoggedIn = st?.loggedIn ?? false; - paintCursor(); - }); - }; + const cursorBadge = cliBadge("Cursor", Bridge.cursorStatus); + const kiroBadge = cliBadge("Kiro", Bridge.kiroStatus); const rows = h( "div", @@ -453,7 +462,8 @@ function buildSettings(actions: ViewActions): ViewHost { { class: "settings-row", style: "gap:14px" }, claudeBadge, apiBadge, - cursorBadge, + cursorBadge.el, + kiroBadge.el, h("div", { class: "grow" }), h("button", { class: "link-btn", @@ -483,8 +493,8 @@ function buildSettings(actions: ViewActions): ViewHost { ); clear(apiBadge); apiBadge.append(dot("#F4505E", 6), h("span", { text: "API" })); - paintCursor(); - refreshCursor(); + cursorBadge.sync(); + kiroBadge.sync(); }, }; }