diff --git a/README.md b/README.md index 606c4cd..7afe685 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Ultralytics Forums](https://img.shields.io/discourse/users?server=https%3A%2F%2Fcommunity.ultralytics.com&logo=discourse&label=Forums&color=blue)](https://community.ultralytics.com) [![Ultralytics Reddit](https://img.shields.io/reddit/subreddit-subscribers/ultralytics?style=flat&logo=reddit&logoColor=white&label=Reddit&color=blue)](https://reddit.com/r/ultralytics) -Lite is a fast, local workspace for [Claude Code](https://code.claude.com/docs/en/overview), [Codex](https://developers.openai.com/codex/cli) on OpenAI or [DeepSeek](https://api-docs.deepseek.com/quick_start/agent_integrations/codex), [Kimi Code](https://www.kimi.com/code), and your shell. Keep agent sessions, files, and Git context together without repository indexing, telemetry, or a cloud service. +Lite is a fast, local workspace for [Claude Code](https://code.claude.com/docs/en/overview), [Codex](https://developers.openai.com/codex/cli) on OpenAI, [DeepSeek](https://api-docs.deepseek.com/quick_start/agent_integrations/codex), or [OpenRouter](https://openrouter.ai/docs/cookbook/coding-agents/codex-cli), [Gemini CLI](https://google-gemini.github.io/gemini-cli/), [Kimi Code](https://www.kimi.com/code), [Qwen Code](https://qwenlm.github.io/qwen-code-docs/en/), and your shell. Keep agent sessions, files, and Git context together without repository indexing, telemetry, or a cloud service.

@@ -21,11 +21,12 @@ Lite is a fast, local workspace for [Claude Code](https://code.claude.com/docs/e ## ✨ Features -- Run Claude Code, Codex, Kimi Code, and shell sessions side by side -- Run Codex against DeepSeek when your Codex configuration provides that model +- Run Claude Code, Codex, Gemini CLI, Kimi Code, Qwen Code, and shell sessions side by side +- Run Codex against DeepSeek or OpenRouter without changing your default Codex provider - Resume session tabs automatically after closing Lite or restarting your computer +- Undo a restart or close for eight seconds before Lite stops the terminal - Authenticate once with each provider and reuse its existing local credentials -- Or save an API key per provider in Lite and skip the sign-in flows entirely +- Or save API keys for supported providers in Lite and skip their sign-in flows entirely - Browse files on demand with syntax highlighting for popular languages - Preview rendered Markdown safely alongside source files - See the active Git branch, worktree, and changed files @@ -73,11 +74,13 @@ Install the provider CLIs you want to use: - [Claude Code](https://code.claude.com/docs/en/setup) - [Codex](https://developers.openai.com/codex/cli) +- [Gemini CLI](https://google-gemini.github.io/gemini-cli/) - [Kimi Code](https://www.kimi.com/code/docs/en/kimi-code-cli/guides/getting-started.html) — Windows also needs Git for Windows +- [Qwen Code](https://qwenlm.github.io/qwen-code-docs/en/) -Open each provider in Lite and complete its normal sign-in once. Every CLI keeps credentials in its own local store, so later Lite sessions reuse the same authentication. Lite never reads or copies those stores. The new-session dialog tells you when a CLI is missing and links to its setup guide. +Open each provider in Lite and complete its normal sign-in once. Every CLI keeps credentials in its own local store, so later Lite sessions reuse the same authentication. Lite never reads or copies those stores. The new-session dialog installs a missing CLI for you. -If you would rather use API keys, open **API keys** in the Lite menu and paste one per provider. Lite keeps them in an owner-only file in its own data folder — the same shape Codex and Kimi already use for their credentials — and passes a key to a session through the environment variable that CLI already reads (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `MOONSHOT_API_KEY`). Nothing is written into provider configuration, deleting a key takes effect on the next launch, and app updates keep the file since the updater replaces the bundle and not your data. +If you would rather use API keys, open **API keys** in the Lite menu and paste one for a supported provider. Lite keeps them in an owner-only file in its own data folder — the same shape Codex and Kimi already use for their credentials — and passes a key to a session through the environment variable that CLI already reads (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, or `MOONSHOT_API_KEY`). Qwen Code keeps ownership of its regional provider and authentication setup. Nothing is written into provider configuration, deleting a key takes effect on the next launch, and app updates keep the file since the updater replaces the bundle and not your data.
Saving provider API keys in Lite @@ -98,6 +101,8 @@ experimental_bearer_token = "" Leave `preferred_auth_method` and `forced_login_method` out. Codex applies those globally, and setting them signs you out of ChatGPT the next time it runs. Lite selects the DeepSeek provider per launch, so your default Codex provider and existing OpenAI sessions are untouched, and it never reads or stores the DeepSeek key. +**Codex · OpenRouter** works the same way: save an OpenRouter key in Lite or configure an `openrouter` Codex provider, and Lite launches OpenRouter's current OpenAI model route without changing your default Codex setup. + Choose a project folder, create a session, and work. Lite saves only the local metadata needed to restore tabs; provider conversation history remains owned by the provider CLI. ## 🛠️ Development diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b205612..0eb9544 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1857,7 +1857,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "lite" -version = "0.0.13" +version = "0.0.14" dependencies = [ "atomicwrites", "portable-pty", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 03adbf9..25586fd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "lite" -version = "0.0.13" +version = "0.0.14" description = "A fast, local workspace for AI coding agents." authors = ["Ultralytics"] edition = "2024" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8910724..013bdc1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -29,9 +29,44 @@ const MAX_GIT_DIFF_BYTES: u64 = 1_000_000; const CODEX_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); // Requests stay bounded so an app server that never answers surfaces an error instead of a stuck tab. const CODEX_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); -const DEEPSEEK_PROFILE: &str = "deepseek"; const DEEPSEEK_MODEL: &str = "deepseek-v4-flash"; -const SUPPORTED_KEYS: [&str; 4] = ["claude", "codex", "deepseek", "kimi"]; +const SUPPORTED_KEYS: [&str; 6] = [ + "claude", + "codex", + "deepseek", + "openrouter", + "gemini", + "kimi", +]; + +#[derive(Clone, Copy)] +struct CodexProvider { + id: &'static str, + name: &'static str, + base_url: &'static str, + env_key: &'static str, + model: &'static str, + setup_url: &'static str, +} + +const CODEX_PROVIDERS: [CodexProvider; 2] = [ + CodexProvider { + id: "deepseek", + name: "DeepSeek", + base_url: "https://api.deepseek.com/", + env_key: "DEEPSEEK_API_KEY", + model: DEEPSEEK_MODEL, + setup_url: "https://api-docs.deepseek.com/quick_start/agent_integrations/codex", + }, + CodexProvider { + id: "openrouter", + name: "OpenRouter", + base_url: "https://openrouter.ai/api/v1", + env_key: "OPENROUTER_API_KEY", + model: "~openai/gpt-latest", + setup_url: "https://openrouter.ai/docs/cookbook/coding-agents/codex-cli", + }, +]; struct PtySession { child: Box, @@ -220,12 +255,14 @@ fn is_sensitive_component(component: &std::ffi::OsStr) -> bool { ".codex", ".config", ".docker", + ".gemini", ".git-credentials", ".gnupg", ".kimi-code", ".netrc", ".npmrc", ".pypirc", + ".qwen", ".ssh", "id_ed25519", "id_rsa", @@ -1333,21 +1370,69 @@ fn shell_path(shell: &str) -> Option { // An account shell that does not speak this command leaves nothing usable, so the search falls back to // the POSIX shell rather than losing every provider CLI. -// Asking costs a login shell, and the answer holds for as long as Lite is open, so it is asked once -// and every command Lite resolves afterwards is free. +// Asking costs a login shell, so the answer is cached until Lite itself installs a CLI that changes it. #[cfg(unix)] fn user_path() -> Option { - static PATH: std::sync::OnceLock> = std::sync::OnceLock::new(); + path_cache().lock().ok().and_then(|path| path.clone()) +} + +#[cfg(unix)] +fn path_cache() -> &'static Mutex> { + static PATH: std::sync::OnceLock>> = std::sync::OnceLock::new(); PATH.get_or_init(|| { - shell_path(&CommandBuilder::new_default_prog().get_shell()) - .or_else(|| shell_path("/bin/sh")) + Mutex::new( + shell_path(&CommandBuilder::new_default_prog().get_shell()) + .or_else(|| shell_path("/bin/sh")), + ) }) - .clone() +} + +#[cfg(unix)] +fn refresh_user_path() { + let refreshed = shell_path(&CommandBuilder::new_default_prog().get_shell()) + .or_else(|| shell_path("/bin/sh")); + if let Ok(mut path) = path_cache().lock() { + *path = refreshed; + } } #[cfg(windows)] fn user_path() -> Option { - std::env::var("PATH").ok() + path_cache() + .lock() + .ok() + .and_then(|path| path.clone()) + .or_else(|| std::env::var("PATH").ok()) +} + +#[cfg(windows)] +fn path_cache() -> &'static Mutex> { + static PATH: std::sync::OnceLock>> = std::sync::OnceLock::new(); + PATH.get_or_init(|| Mutex::new(None)) +} + +#[cfg(windows)] +fn refresh_user_path() { + let refreshed = Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + "[Environment]::GetEnvironmentVariable('Path','User')", + ]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|path| path.trim().to_owned()) + .filter(|path| !path.is_empty()); + if let Some(refreshed) = refreshed + && let Ok(mut path) = path_cache().lock() + { + *path = Some(format!( + "{refreshed};{}", + std::env::var("PATH").unwrap_or_default() + )); + } } // Keys live in one owner-only file beside Lite's other local state, which is what the Codex and Kimi @@ -1459,11 +1544,23 @@ fn api_keys_path(app: &AppHandle) -> Result { .join("api-keys.json")) } +fn codex_provider(id: Option<&str>) -> Option { + CODEX_PROVIDERS + .iter() + .copied() + .find(|provider| Some(provider.id) == id) +} + fn api_key_env(agent: &str, provider: Option<&str>) -> Option<(&'static str, &'static str)> { + if agent == "codex" + && let Some(provider) = codex_provider(provider) + { + return Some((provider.id, provider.env_key)); + } match (agent, provider) { ("claude", _) => Some(("claude", "ANTHROPIC_API_KEY")), - ("codex", Some("deepseek")) => Some(("deepseek", "DEEPSEEK_API_KEY")), ("codex", _) => Some(("codex", "OPENAI_API_KEY")), + ("gemini", _) => Some(("gemini", "GEMINI_API_KEY")), ("kimi", _) => Some(("kimi", "MOONSHOT_API_KEY")), _ => None, } @@ -1636,13 +1733,27 @@ fn cli_auth(app: &AppHandle, name: &str) -> Option { method: CliAuthMethod::Provider, key_hint: None, }), - "deepseek" => { - (deepseek_profile_exists(app) || codex_declares_deepseek(app)).then_some(CliAuth { - method: CliAuthMethod::ApiKey, - key_hint: None, - }) + name if CODEX_PROVIDERS.iter().any(|provider| provider.id == name) => { + let provider = codex_provider(Some(name))?; + (codex_profile_exists(app, provider.id) || codex_declares_provider(app, provider.id)) + .then_some(CliAuth { + method: CliAuthMethod::ApiKey, + key_hint: None, + }) } + "gemini" => gemini_home(app) + .is_ok_and(|home| home.join("oauth_creds.json").is_file()) + .then_some(CliAuth { + method: CliAuthMethod::Provider, + key_hint: None, + }), "kimi" => kimi_auth(app), + "qwen" => qwen_home(app) + .is_ok_and(|home| home.join("oauth_creds.json").is_file()) + .then_some(CliAuth { + method: CliAuthMethod::Provider, + key_hint: None, + }), _ => None, } } @@ -1661,12 +1772,14 @@ async fn provider_auth(app: AppHandle) -> Result, String> { let keys = load_api_keys(&app); Ok(SUPPORTED_KEYS .iter() + .copied() + .chain(["qwen"]) .map(|name| { let cli_auth = cli_auth(&app, name); ProviderAuth { - name: (*name).to_owned(), + name: name.to_owned(), // Only the last characters travel to the interface, enough to tell two keys apart. - key_hint: keys.get(*name).map(|key| key_hint(key)), + key_hint: keys.get(name).map(|key| key_hint(key)), cli_auth_method: cli_auth.as_ref().map(|auth| auth.method), cli_key_hint: cli_auth.and_then(|auth| auth.key_hint), } @@ -1724,12 +1837,30 @@ fn executable_exists(name: &str) -> bool { resolve_executable(name).is_some() } +fn agent_executable(agent: &str) -> Option<&'static str> { + match agent { + "claude" => Some("claude"), + "codex" => Some("codex"), + "gemini" => Some("gemini"), + "kimi" => Some("kimi"), + "qwen" => Some("qwen"), + _ => None, + } +} + +fn agent_builder(agent: &str) -> Result { + let executable = agent_executable(agent).ok_or("Unknown session type")?; + let path = resolve_executable(executable) + .ok_or_else(|| format!("Could not find {executable} in your PATH"))?; + Ok(CommandBuilder::new(path)) +} + fn codex_home(app: &AppHandle) -> Result { provider_home(app, "CODEX_HOME", ".codex") } -// Looks only for the section header. The DeepSeek credential lives in that file and is never read. -fn codex_declares_deepseek(app: &AppHandle) -> bool { +// Looks only for the section header. Provider credentials in that file are never read. +fn codex_declares_provider(app: &AppHandle, provider: &str) -> bool { let Ok(home) = codex_home(app) else { return false; }; @@ -1739,7 +1870,7 @@ fn codex_declares_deepseek(app: &AppHandle) -> bool { BufReader::new(file) .lines() .map_while(Result::ok) - .any(|line| line.trim() == "[model_providers.deepseek]") + .any(|line| line.trim() == format!("[model_providers.{provider}]")) } // A catalog is a replacement rather than a merge, so one the user configured is left to win. @@ -1756,10 +1887,62 @@ fn codex_declares_catalog(app: &AppHandle) -> bool { .any(|line| line.trim_start().starts_with("model_catalog_json")) } -fn deepseek_profile_exists(app: &AppHandle) -> bool { - codex_home(app).is_ok_and(|home| { - home.join(format!("{DEEPSEEK_PROFILE}.config.toml")) - .is_file() +fn codex_profile_exists(app: &AppHandle, provider: &str) -> bool { + codex_home(app).is_ok_and(|home| home.join(format!("{provider}.config.toml")).is_file()) +} + +fn gemini_home(app: &AppHandle) -> Result { + provider_home(app, "GEMINI_CLI_HOME", ".gemini") +} + +fn qwen_home(app: &AppHandle) -> Result { + provider_home(app, "QWEN_HOME", ".qwen") +} + +fn native_session_exists(app: &AppHandle, agent: &str, session_id: &str) -> bool { + let (home, directory) = match agent { + "gemini" => (gemini_home(app), "tmp"), + "qwen" => (qwen_home(app), "projects"), + _ => return false, + }; + let Ok(projects) = + home.and_then(|home| fs::read_dir(home.join(directory)).map_err(|error| error.to_string())) + else { + return false; + }; + let expected = if agent == "qwen" { + format!("{session_id}.jsonl") + } else { + format!("-{}.jsonl", session_id.chars().take(8).collect::()) + }; + projects.flatten().take(512).any(|project| { + fs::read_dir(project.path().join("chats")).is_ok_and(|chats| { + chats.flatten().take(512).any(|chat| { + let name = chat.file_name(); + let name = name.to_string_lossy(); + if agent == "qwen" { + name == expected + } else { + name.ends_with(&expected) + && fs::File::open(chat.path()).is_ok_and(|file| { + BufReader::new(file) + .lines() + .next() + .and_then(Result::ok) + .and_then(|line| { + serde_json::from_str::(&line).ok() + }) + .and_then(|value| { + value + .get("sessionId") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .is_some_and(|id| id == session_id) + }) + } + }) + }) }) } @@ -1841,7 +2024,7 @@ fn agent_command( ) -> Result { let mut command = match agent { "claude" => { - let mut command = CommandBuilder::new("claude"); + let mut command = agent_builder(agent)?; if resume { command.args(["--resume", session_id]); } else { @@ -1852,31 +2035,38 @@ fn agent_command( command } "codex" => { - let mut command = CommandBuilder::new("codex"); - // DeepSeek is selected per launch so the default Codex provider stays whatever the user set. - if provider == Some("deepseek") { - let key = saved_api_key(app, agent, provider).is_some(); - if !key && deepseek_profile_exists(app) { + let mut command = agent_builder(agent)?; + // Providers are selected per launch so the user's default Codex provider stays untouched. + if let Some(provider) = codex_provider(provider) { + let key = saved_api_key(app, agent, Some(provider.id)).is_some(); + if !key && codex_profile_exists(app, provider.id) { // A profile the user wrote owns the whole model configuration, catalog included. - command.args(["--profile", DEEPSEEK_PROFILE]); + command.args(["--profile", provider.id]); } else { if key { // A key held by Lite defines the provider inline and is read from the // environment, so no Codex configuration file has to exist or be written. for override_value in [ - "model_providers.deepseek.name=\"deepseek\"", - "model_providers.deepseek.base_url=\"https://api.deepseek.com/\"", - "model_providers.deepseek.wire_api=\"responses\"", - "model_providers.deepseek.env_key=\"DEEPSEEK_API_KEY\"", + format!("model_providers.{}.name=\"{}\"", provider.id, provider.name), + format!( + "model_providers.{}.base_url=\"{}\"", + provider.id, provider.base_url + ), + format!("model_providers.{}.wire_api=\"responses\"", provider.id), + format!( + "model_providers.{}.env_key=\"{}\"", + provider.id, provider.env_key + ), ] { - command.args(["-c", override_value]); + command.args(["-c", &override_value]); } } - command.args(["-c", "model_provider=\"deepseek\""]); - command.args(["-c", &format!("model=\"{DEEPSEEK_MODEL}\"")]); - if let Some(catalog) = (!codex_declares_catalog(app)) - .then(|| deepseek_catalog(app)) - .flatten() + command.args(["-c", &format!("model_provider=\"{}\"", provider.id)]); + command.args(["-c", &format!("model=\"{}\"", provider.model)]); + if let Some(catalog) = (provider.id == "deepseek" + && !codex_declares_catalog(app)) + .then(|| deepseek_catalog(app)) + .flatten() { // A Windows path is full of backslashes, which TOML reads as escapes. let catalog = path_text(&catalog) @@ -1891,16 +2081,34 @@ fn agent_command( } command } + "gemini" => { + let mut command = agent_builder(agent)?; + command.args(if resume { + ["--resume", session_id] + } else { + ["--session-id", session_id] + }); + command + } "kimi" => { // Only an exact id resumes. `--continue` would reopen whatever ran last in the directory, // which two tabs there would both land on, and it creates no entry for discovery to record, // so the tab could never learn which session it is showing. - let mut command = CommandBuilder::new("kimi"); + let mut command = agent_builder(agent)?; if let Some(provider_session_id) = provider_session_id { command.args(["--session", provider_session_id]); } command } + "qwen" => { + let mut command = agent_builder(agent)?; + command.args(if resume { + ["--resume", session_id] + } else { + ["--session-id", session_id] + }); + command + } "shell" => { let mut command = CommandBuilder::new_default_prog(); #[cfg(target_os = "macos")] @@ -1916,7 +2124,10 @@ fn agent_command( if let Some((_, variable)) = api_key_env(agent, provider) && let Some(key) = saved_api_key(app, agent, provider) { - command.env(variable, key); + command.env(variable, &key); + if agent == "gemini" { + command.env("GEMINI_DEFAULT_AUTH_TYPE", "gemini-api-key"); + } } Ok(command) } @@ -1925,20 +2136,22 @@ fn agent_command( fn login_command(agent: &str) -> Result { let mut command = match agent { "claude" => { - let mut command = CommandBuilder::new("claude"); + let mut command = agent_builder(agent)?; command.args(["auth", "login"]); command } "codex" => { - let mut command = CommandBuilder::new("codex"); + let mut command = agent_builder(agent)?; command.arg("login"); command } + "gemini" => agent_builder(agent)?, "kimi" => { - let mut command = CommandBuilder::new("kimi"); + let mut command = agent_builder(agent)?; command.arg("login"); command } + "qwen" => agent_builder(agent)?, _ => return Err("This provider signs in with an API key".into()), }; if let Some(path) = user_path() { @@ -1951,6 +2164,7 @@ fn login_command(agent: &str) -> Result { #[serde(rename_all = "camelCase")] struct Availability { available: bool, + installable: bool, detail: String, } @@ -1960,69 +2174,178 @@ async fn agent_availability( agent: String, provider: Option, ) -> Result { - let (executable, missing) = match agent.as_str() { - "claude" => ( - "claude", - "Install the Claude Code CLI, then sign in with `claude`.", - ), - "codex" => ( - "codex", - "Install the Codex CLI, then sign in with `codex login`.", - ), - "kimi" => ( - "kimi", + let missing = match agent.as_str() { + "claude" => "Install the Claude Code CLI, then sign in with `claude`.", + "codex" => "Install the Codex CLI, then sign in with `codex login`.", + "gemini" => { + "Install Gemini CLI with `npm install -g @google/gemini-cli`, then run `gemini`." + } + "kimi" => { if cfg!(windows) { "Install Git for Windows, then run `irm https://code.kimi.com/kimi-code/install.ps1 | iex` in PowerShell." } else { "Install Kimi Code with `curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash`." - }, - ), + } + } + "qwen" => { + "Install Qwen Code with `npm install -g @qwen-code/qwen-code@latest`, then run `qwen`." + } "shell" => { return Ok(Availability { available: true, + installable: false, detail: String::new(), }); } _ => return Err("Unknown session type".into()), }; - if !executable_exists(executable) { + if !agent_executable(&agent).is_some_and(executable_exists) { return Ok(Availability { available: false, + installable: true, detail: missing.into(), }); } - if agent == "codex" && provider.as_deref() == Some("deepseek") { + if agent == "codex" + && let Some(codex_provider) = codex_provider(provider.as_deref()) + { let configured = saved_api_key(&app, &agent, provider.as_deref()).is_some() - || deepseek_profile_exists(&app) - || codex_declares_deepseek(&app); + || codex_profile_exists(&app, codex_provider.id) + || codex_declares_provider(&app, codex_provider.id); return Ok(Availability { available: configured, + installable: false, detail: if configured { String::new() } else { format!( - "Save a DeepSeek key in Lite's settings, or add a DeepSeek provider to your Codex configuration. Either way Lite launches `{DEEPSEEK_MODEL}` through it." + "Save a {} key in Lite's settings, or add a {} provider to your Codex configuration. Either way Lite launches `{}` through it.", + codex_provider.name, codex_provider.name, codex_provider.model ) }, }); } Ok(Availability { available: true, + installable: false, detail: String::new(), }) } +#[tauri::command] +async fn install_agent(agent: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let mut command = if agent == "kimi" { + #[cfg(windows)] + { + let powershell = resolve_executable("powershell") + .ok_or("Could not find PowerShell in your PATH")?; + let mut command = Command::new(powershell); + command.args([ + "-NoProfile", + "-Command", + "irm https://code.kimi.com/kimi-code/install.ps1 | iex", + ]); + command + } + #[cfg(unix)] + { + let mut command = Command::new("/bin/sh"); + command.args([ + "-c", + "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash", + ]); + command + } + } else { + let package = match agent.as_str() { + "claude" => "@anthropic-ai/claude-code", + "codex" => "@openai/codex", + "gemini" => "@google/gemini-cli", + "qwen" => "@qwen-code/qwen-code@latest", + _ => return Err("This session type cannot be installed automatically".into()), + }; + let npm = resolve_executable("npm").ok_or("Install Node.js and npm first")?; + let mut command = Command::new(npm); + command.args(["install", "-g", package]); + command + }; + if let Some(path) = user_path() { + command.env("PATH", path); + } + command.stdout(Stdio::null()).stderr(Stdio::piped()); + let mut child = command + .spawn() + .map_err(|error| format!("Could not run the installer: {error}"))?; + let mut stderr = child + .stderr + .take() + .ok_or("Could not read installer errors")?; + let mut detail = Vec::with_capacity(16_384); + let mut buffer = [0_u8; 4096]; + while let Ok(count) = stderr.read(&mut buffer) { + if count == 0 { + break; + } + detail.extend_from_slice(&buffer[..count]); + if detail.len() > 16_384 { + detail.drain(..detail.len() - 16_384); + } + } + let status = child + .wait() + .map_err(|error| format!("Could not finish the installer: {error}"))?; + if status.success() { + refresh_user_path(); + let executable = agent_executable(&agent).ok_or("Unknown session type")?; + let path = resolve_executable(executable) + .ok_or_else(|| format!("Installed {agent}, but could not find it in your PATH"))?; + let mut probe = Command::new(path); + probe + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Some(path) = user_path() { + probe.env("PATH", path); + } + return probe + .status() + .map_err(|error| format!("Installed {agent}, but could not run it: {error}"))? + .success() + .then_some(()) + .ok_or_else(|| { + format!( + "Installed {agent}, but it cannot run with the current system requirements" + ) + }); + } + let detail = String::from_utf8_lossy(&detail); + let detail = detail.trim(); + Err(if detail.is_empty() { + format!("The {agent} installer exited with {status}") + } else { + detail.to_owned() + }) + }) + .await + .map_err(|error| format!("Could not finish the install: {error}"))? +} + #[tauri::command] async fn open_setup_docs(agent: String, provider: Option) -> Result<(), String> { + if agent == "codex" + && let Some(provider) = codex_provider(provider.as_deref()) + { + return open_external(provider.setup_url); + } let url = match (agent.as_str(), provider.as_deref()) { ("claude", _) => "https://code.claude.com/docs/en/setup", - ("codex", Some("deepseek")) => { - "https://api-docs.deepseek.com/quick_start/agent_integrations/codex" - } ("codex", _) => "https://developers.openai.com/codex/cli", + ("gemini", _) => "https://google-gemini.github.io/gemini-cli/", ("kimi", _) => { "https://www.kimi.com/code/docs/en/kimi-code-cli/guides/getting-started.html" } + ("qwen", _) => "https://qwenlm.github.io/qwen-code-docs/en/", _ => return Err("No setup guide for this session type".into()), }; open_external(url) @@ -2104,8 +2427,13 @@ async fn spawn_session( let cwd = root_path(&roots, &root_id)?; // A sign-in runs the provider's own login command and owns no session of its own. let signing_in = mode.as_deref() == Some("login"); - // Resuming a Claude session it never recorded fails outright, so that tab starts one instead. - let resume = resume && (agent != "claude" || claude_session_exists(&app, &session_id)); + // A tab whose provider history was removed starts again instead of becoming permanently unusable. + let resume = resume + && match agent.as_str() { + "claude" => claude_session_exists(&app, &session_id), + "gemini" | "qwen" => native_session_exists(&app, &agent, &session_id), + _ => true, + }; if !signing_in && (agent == "codex" || agent == "kimi") { if let Some(saved_provider_session_id) = provider_sessions .0 @@ -2371,7 +2699,9 @@ fn is_agent_process(process: &sysinfo::Process, agent: &str) -> bool { .unwrap_or_default(); executable.strip_suffix(".exe").unwrap_or(executable) == agent || (agent == "codex" && executable == "codex.js") + || (agent == "gemini" && executable == "gemini.js") || (agent == "kimi" && argument.contains("kimi-code")) + || (agent == "qwen" && argument.contains("qwen-code")) }) } @@ -2404,7 +2734,10 @@ fn watch_shell_agent( session_id: String, agent: String, ) -> Result<(), String> { - if !matches!(agent.as_str(), "claude" | "codex" | "kimi") { + if !matches!( + agent.as_str(), + "claude" | "codex" | "gemini" | "kimi" | "qwen" + ) { return Err("Unknown agent".into()); } let (root, run_id, alive, agent_watch) = { @@ -2811,8 +3144,8 @@ async fn read_usage( provider: Option, session_id: String, ) -> Result, String> { - // A DeepSeek-backed Codex session bills DeepSeek, so the OpenAI account limits are not its usage. - if agent == "codex" && provider.as_deref() == Some("deepseek") { + // A custom-provider Codex session does not bill OpenAI, so OpenAI account limits are not its usage. + if agent == "codex" && codex_provider(provider.as_deref()).is_some() { return Ok(None); } match agent.as_str() { @@ -2892,7 +3225,7 @@ async fn read_usage( .then_some(usage)) } "codex" => codex_usage(&codex_server).map(Some), - "kimi" | "shell" => Ok(None), + "gemini" | "kimi" | "qwen" | "shell" => Ok(None), _ => Err("Unknown session type".into()), } } @@ -3097,6 +3430,7 @@ pub fn run() { git_remote, read_usage, agent_availability, + install_agent, open_setup_docs, open_url, open_directory, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e2d2136..38eb820 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -51,7 +51,7 @@ "licenseFile": "../LICENSE", "category": "DeveloperTool", "shortDescription": "A fast, local workspace for AI coding agents.", - "longDescription": "Lite is a fast, local workspace for Claude Code, Codex on OpenAI or DeepSeek, Kimi Code, and your shell. Keep agent sessions, files, and Git context together without repository indexing, telemetry, or a cloud service.", + "longDescription": "Lite is a fast, local workspace for Claude Code, Codex on OpenAI, DeepSeek, or OpenRouter, Gemini CLI, Kimi Code, Qwen Code, and your shell. Keep agent sessions, files, and Git context together without repository indexing, telemetry, or a cloud service.", "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "macOS": { "signingIdentity": "-" diff --git a/src/App.tsx b/src/App.tsx index b43e672..b437c0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -894,7 +894,13 @@ function commandAgent(command: string): Agent | undefined { .pop() ?.replace(/\.exe$/i, "") .toLowerCase(); - return executable === "claude" || executable === "codex" || executable === "kimi" ? executable : undefined; + return executable === "claude" || + executable === "codex" || + executable === "gemini" || + executable === "kimi" || + executable === "qwen" + ? executable + : undefined; } function App() { @@ -1213,7 +1219,7 @@ function App() { }, [markDirectory, markWorking, markTitle]); const launch = useCallback(async (session: Session, resume: boolean) => { - if (runs.current.has(session.id)) return; + if (runs.current.has(session.id)) return true; const runId = crypto.randomUUID(); runs.current.set(session.id, runId); setStartingIds((current) => new Set(current).add(session.id)); @@ -1233,7 +1239,7 @@ function App() { cols: 100, rows: 30, }); - if (runs.current.get(session.id) !== runId) return; + if (runs.current.get(session.id) !== runId) return false; setSessions((current) => current.map((item) => item.id === session.id @@ -1245,10 +1251,12 @@ function App() { : item, ), ); + return true; } catch (reason) { if (runs.current.get(session.id) === runId) runs.current.delete(session.id); setSessions((current) => current.map((item) => (item.id === session.id ? { ...item, running: false } : item))); setError(String(reason)); + return false; } finally { setStartingIds((current) => { const next = new Set(current); @@ -1310,31 +1318,69 @@ function App() { void launch(session, false); } + function deferSessionAction(session: Session, action: "Close" | "Restart", perform: () => Promise) { + let undone = false; + toast.add({ + title: `${action} “${session.name}”`, + type: "success", + timeout: 8000, + onClose: async () => { + if (!undone) await perform(); + }, + actionProps: { + children: "Undo", + onClick: () => { + undone = true; + }, + }, + }); + } + // Restarting keeps the tab and its folder but asks the provider for a conversation of its own, so the - // session it resumed by id is forgotten first and the tab takes a new one. - async function restartSession(session: Session, select = true) { + // tab takes a new id. Nothing changes until the shared Undo window expires. + function restartSession(session: Session, select = true) { + deferSessionAction(session, "Restart", () => restartSessionNow(session, select)); + } + + async function restartSessionNow(session: Session, select: boolean) { runs.current.delete(session.id); setSessions((current) => current.map((item) => (item.id === session.id ? { ...item, running: false } : item))); - await invoke("stop_session", { sessionId: session.id }); try { - await invoke("delete_session_data", { sessionId: session.id }); + await invoke("stop_session", { sessionId: session.id }); } catch (reason) { - setError(String(reason)); + setSessions((current) => + current.map((item) => (item.id === session.id ? { ...item, running: session.running } : item)), + ); + setError(`Session could not be restarted: ${String(reason)}`); + return; } - clearOutput(session.id); - clearUsageCache(session.id); const fresh: Session = { ...session, id: crypto.randomUUID(), providerSessionId: undefined, running: false }; setSessions((current) => current.map((item) => (item.id === session.id ? fresh : item))); if (select) { setSelectedId(fresh.id); resumed.current = fresh.id; } - await launch(fresh, false); + if (!(await launch(fresh, false))) { + await invoke("delete_session_data", { sessionId: fresh.id }).catch(() => {}); + clearOutput(fresh.id); + clearUsageCache(fresh.id); + const restored = { ...session, running: false }; + setSessions((current) => current.map((item) => (item.id === fresh.id ? restored : item))); + setSelectedId((current) => (current === fresh.id ? session.id : current)); + resumed.current = session.id; + if (await launch(restored, true)) setError("Restart failed; the original session was restored."); + return; + } if (fresh.agent === "kimi") startKimiConversation(fresh.id); + await invoke("delete_session_data", { sessionId: session.id }).catch((reason) => + setError(`Session restarted, but local cleanup failed: ${String(reason)}`), + ); + clearOutput(session.id); + clearUsageCache(session.id); } async function restartAllSessions() { - await Promise.all(sessions.map((session) => restartSession(session, session.id === selectedId))); + for (const session of sessions) restartSession(session, session.id === selectedId); } async function cleanupSession(session: Session) { @@ -1354,13 +1400,20 @@ function App() { if (cleanupError) setError(`Session closed, but local cleanup failed: ${cleanupError}`); } - // Closing a session is reversible: the row leaves immediately and its PTY stops, while the - // provider session metadata and directory grant remain until the toast closes without being undone. + // Closing uses the same deferred action as restarting: Undo leaves the running terminal untouched. function closeSession(session: Session) { - const index = sessions.findIndex((item) => item.id === session.id); + deferSessionAction(session, "Close", () => closeSessionNow(session)); + } + + async function closeSessionNow(session: Session) { const wasSelected = selectedId === session.id; const nextSelectedId = sessions.find((item) => item.id !== session.id)?.id ?? ""; - const runId = runs.current.get(session.id); + try { + await invoke("stop_session", { sessionId: session.id }); + } catch (reason) { + setError(`Session could not be closed: ${String(reason)}`); + return; + } runs.current.delete(session.id); if (resumed.current === session.id) resumed.current = ""; const timer = workTimers.current.get(session.id); @@ -1373,47 +1426,7 @@ function App() { }); setSessions((current) => current.filter((item) => item.id !== session.id)); if (wasSelected) setSelectedId(nextSelectedId); - - let undone = false; - let toastId = ""; - function restore(running: boolean) { - setSessions((current) => { - if (current.some((item) => item.id === session.id)) return current; - const restored = [...current]; - restored.splice(Math.min(index, restored.length), 0, { ...session, running }); - return restored; - }); - if (wasSelected) setSelectedId((current) => (current === nextSelectedId ? session.id : current)); - } - const stopped = invoke("stop_session", { sessionId: session.id }).then( - () => true, - (reason) => { - undone = true; - if (runId) { - runs.current.set(session.id, runId); - resumed.current = session.id; - } - restore(session.running); - setError(`Session could not be closed: ${String(reason)}`); - toast.close(toastId); - return false; - }, - ); - toastId = toast.add({ - title: `Closed “${session.name}”`, - type: "success", - timeout: 8000, - onClose: async () => { - if ((await stopped) && !undone) await cleanupSession(session); - }, - actionProps: { - children: "Undo", - onClick: async () => { - undone = true; - if (await stopped) restore(false); - }, - }, - }); + await cleanupSession(session); } async function checkForUpdates() { diff --git a/src/brand-icons.tsx b/src/brand-icons.tsx index 361b270..2dbc503 100644 --- a/src/brand-icons.tsx +++ b/src/brand-icons.tsx @@ -17,13 +17,65 @@ export const ProviderIcon = ({ if (agent === "codex") return provider === "deepseek" ? ( + ) : provider === "openrouter" ? ( + ) : ( ); + if (agent === "gemini") return ; if (agent === "kimi") return ; + if (agent === "qwen") return ; return ; }; +const OpenRouterLogomark = ({ className = "size-4" }: { className?: string }) => ( + + OpenRouter + + +); + +// Reused from Portal's shared icon source. +const GoogleIcon = ({ className = "size-4" }: { className?: string }) => ( + + Google + + + + + +); + +const AlibabaLogomark = ({ className = "size-4" }: { className?: string }) => ( + + Alibaba + + + + +); + export const UltralyticsLogomark = ({ className = "size-4" }: { className?: string }) => ( Ultralytics diff --git a/src/inspector.tsx b/src/inspector.tsx index bdd7180..80db4b7 100644 --- a/src/inspector.tsx +++ b/src/inspector.tsx @@ -59,6 +59,7 @@ import { type DirectoryListing, type FileEntry, type GitStatus, + providerLabel, type Session, sessionLabel, } from "@/types"; @@ -882,8 +883,8 @@ function GitPanel({ rootId, sessionId, remote }: { rootId: string; sessionId: st function missingUsage(session: Session): string { if (session.agent === "shell") return "Shell sessions report no provider usage."; if (session.agent === "kimi") return "Kimi Code keeps session usage inside its own terminal view."; - if (session.agent === "codex" && session.provider === "deepseek") - return "DeepSeek publishes no account limits locally. Codex reports this session's usage in the terminal."; + if (session.agent === "codex" && session.provider && session.provider !== "openai") + return `${providerLabel(session.provider)} publishes no account limits locally. Codex reports this session's usage in the terminal.`; if (session.agent === "claude") return "Account limits appear after any Lite Claude session receives a response."; return "This provider reports no usage locally."; } @@ -920,11 +921,7 @@ function UsagePanel({ session }: { session: Session }) {
- {session.agent === "codex" - ? session.provider === "deepseek" - ? "DeepSeek" - : "OpenAI" - : sessionLabel(session)} + {session.agent === "codex" && session.provider ? providerLabel(session.provider) : sessionLabel(session)}
{error ? (

{error}

diff --git a/src/new-session-dialog.tsx b/src/new-session-dialog.tsx index c6f7caa..dcad750 100644 --- a/src/new-session-dialog.tsx +++ b/src/new-session-dialog.tsx @@ -21,30 +21,12 @@ import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { AUTH_PROVIDERS, type AuthProviderId, type ProviderAuth, ProviderAuthDescription } from "@/provider-auth"; -import { type Agent, type ModelProvider, type Session, sessionLabel } from "@/types"; +import { AUTH_PROVIDERS, type ProviderAuth, ProviderAuthDescription } from "@/provider-auth"; +import { type Session, sessionLabel } from "@/types"; -interface Choice { - id: string; - agent: Agent; - provider?: ModelProvider; - auth?: AuthProviderId; - description?: string; - note?: string; -} - -const choices: Choice[] = [ - { id: "claude", agent: "claude", auth: "claude" }, - { id: "codex", agent: "codex", provider: "openai", auth: "codex" }, - { - id: "codex-deepseek", - agent: "codex", - provider: "deepseek", - auth: "deepseek", - note: "Runs the Codex harness against the DeepSeek provider in your Codex configuration. Usage bills DeepSeek, not OpenAI.", - }, - { id: "kimi", agent: "kimi", auth: "kimi" }, - { id: "shell", agent: "shell", description: "Open your default shell" }, +const choices = [ + ...Object.values(AUTH_PROVIDERS), + { id: "shell", agent: "shell" as const, provider: undefined, description: "Open your default shell" }, ]; // The quiet heading that separates the two questions the dialog asks, in the sidebar's own label style. @@ -57,6 +39,7 @@ interface DirectoryGrant { interface Availability { available: boolean; + installable: boolean; detail: string; } @@ -74,10 +57,11 @@ export function NewSessionDialog({ const [path, setPath] = useState(""); const [availability, setAvailability] = useState>({}); const [auth, setAuth] = useState(); + const [installing, setInstalling] = useState(""); const [error, setError] = useState(""); const choice = choices.find((option) => option.id === choiceId) ?? choices[0]; const status = availability[choice.id]; - // An agent that is not installed cannot take a session yet, so the dialog offers its setup guide instead. + // An agent that is not installed cannot take a session yet, so the dialog offers to install it instead. const missing = status && !status.available ? status : undefined; useEffect(() => { @@ -147,6 +131,7 @@ export function NewSessionDialog({ } function changeOpen(open: boolean) { + if (!open && installing) return; if (!open && directory) { void invoke("revoke_directory", { rootId: directory.id }); setDirectory(undefined); @@ -171,12 +156,42 @@ export function NewSessionDialog({ onOpenChange(false); } + async function install() { + setInstalling(choice.id); + setError(""); + try { + await invoke("install_agent", { agent: choice.agent }); + const results = await Promise.all( + choices + .filter((option) => option.agent === choice.agent) + .map( + async (option) => + [ + option.id, + await invoke("agent_availability", { + agent: option.agent, + provider: option.provider, + }), + ] as const, + ), + ); + setAvailability((current) => ({ ...current, ...Object.fromEntries(results) })); + const result = results.find(([id]) => id === choice.id)?.[1]; + if (result && !result.available) setError(result.detail); + } catch (reason) { + setError(String(reason)); + } finally { + setInstalling(""); + } + } + // Everything the dialog can be asked for arrives here: the submit button, Enter from the folder field, // and a second click on the agent already chosen. An agent that is not installed reads them all as a - // request for its setup guide, which is the only one of the two it can answer. - const ready = Boolean(missing || (path.trim() && status)); + // request to install it, which is the only one of the two it can answer. + const ready = Boolean(!installing && (missing || (path.trim() && status))); function start() { - if (missing) + if (missing?.installable) void install(); + else if (missing) void invoke("open_setup_docs", { agent: choice.agent, provider: choice.provider }).catch((reason) => setError(String(reason)), ); @@ -226,7 +241,7 @@ export function NewSessionDialog({ {choices.map((option) => { const state = availability[option.id]; const active = option.id === choiceId; - const authProvider = option.auth ? AUTH_PROVIDERS[option.auth] : undefined; + const authProvider = "configured" in option ? option : undefined; const authStatus = authProvider ? auth?.find((entry) => entry.name === authProvider.id) : undefined; const row = ( (active && ready ? start() : setChoiceId(option.id))} /> } @@ -251,7 +267,7 @@ export function NewSessionDialog({ {authProvider ? ( ) : ( - {option.description} + {"description" in option ? option.description : undefined} )} @@ -260,7 +276,7 @@ export function NewSessionDialog({ ); - return option.note ? ( + return "note" in option ? ( {option.note} @@ -276,12 +292,18 @@ export function NewSessionDialog({ {error ?

{error}

: null} - diff --git a/src/provider-auth.tsx b/src/provider-auth.tsx index e08b6ac..b571e14 100644 --- a/src/provider-auth.tsx +++ b/src/provider-auth.tsx @@ -32,6 +32,26 @@ export const AUTH_PROVIDERS = { variable: "DEEPSEEK_API_KEY", configured: "Using API key", signIn: false, + note: "Runs Codex against DeepSeek. Usage bills DeepSeek, not OpenAI.", + }, + openrouter: { + id: "openrouter", + agent: "codex", + provider: "openrouter", + label: "OpenRouter", + variable: "OPENROUTER_API_KEY", + configured: "Using API key", + signIn: false, + note: "Runs Codex against OpenRouter. Usage bills OpenRouter, not OpenAI.", + }, + gemini: { + id: "gemini", + agent: "gemini", + provider: undefined, + label: "Google Gemini", + variable: "GEMINI_API_KEY", + configured: "Signed in through Gemini CLI", + signIn: true, }, kimi: { id: "kimi", @@ -42,6 +62,14 @@ export const AUTH_PROVIDERS = { configured: "Signed in through Kimi Code", signIn: true, }, + qwen: { + id: "qwen", + agent: "qwen", + provider: undefined, + label: "Alibaba ModelStudio", + configured: "Set up through Qwen Code", + signIn: true, + }, } as const satisfies Record< string, { @@ -49,9 +77,10 @@ export const AUTH_PROVIDERS = { agent: Agent; provider?: ModelProvider; label: string; - variable: string; + variable?: string; configured: string; signIn: boolean; + note?: string; } >; diff --git a/src/settings-dialog.tsx b/src/settings-dialog.tsx index 900a7ae..58ace40 100644 --- a/src/settings-dialog.tsx +++ b/src/settings-dialog.tsx @@ -23,7 +23,7 @@ import { AUTH_PROVIDERS, type ProviderAuth, ProviderAuthDescription } from "@/pr import type { Agent } from "@/types"; // Each CLI signs in on its own; a key here is the alternative for anyone who would rather not. -const providers = Object.values(AUTH_PROVIDERS); +const providers = Object.values(AUTH_PROVIDERS).filter((provider) => "variable" in provider); export function SettingsDialog({ open: isOpen, diff --git a/src/types.ts b/src/types.ts index e721275..e2a13d9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,8 @@ // Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license -// A harness runs the session; a model provider bills it. Codex is the one harness that supports both. -export type Agent = "claude" | "codex" | "kimi" | "shell"; -export type ModelProvider = "openai" | "deepseek"; +// A harness runs the session; a model provider bills it. Codex can run against multiple providers. +export type Agent = "claude" | "codex" | "gemini" | "kimi" | "qwen" | "shell"; +export type ModelProvider = "openai" | "deepseek" | "openrouter"; export interface Session { id: string; @@ -22,12 +22,24 @@ export interface Session { const agentLabels: Record = { claude: "Claude Code", codex: "Codex", + gemini: "Gemini CLI", kimi: "Kimi Code", + qwen: "Qwen Code", shell: "Shell", }; +const providerLabels: Record = { + openai: "OpenAI", + deepseek: "DeepSeek", + openrouter: "OpenRouter", +}; + +export function providerLabel(provider: ModelProvider): string { + return providerLabels[provider]; +} + export function sessionLabel({ agent, provider }: Pick): string { - if (agent === "codex" && provider === "deepseek") return "Codex · DeepSeek"; + if (agent === "codex" && provider && provider !== "openai") return `Codex · ${providerLabels[provider]}`; return agentLabels[agent]; }