diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 19dc173..d8001c4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1956,7 +1956,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "lite" -version = "0.0.44" +version = "0.0.45" dependencies = [ "atomicwrites", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 253ea13..65c1873 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "lite" -version = "0.0.44" +version = "0.0.45" 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 cf33956..1a95132 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -46,11 +46,13 @@ 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_MODEL: &str = "deepseek-v4-flash"; -const CODEX_NOTIFICATION_ARGS: [&str; 4] = [ +const CODEX_NOTIFICATION_ARGS: [&str; 6] = [ "-c", r#"tui.notification_method="osc9""#, "-c", r#"tui.notification_condition="always""#, + "-c", + r#"tui.terminal_title=["session-id","thread"]"#, ]; const SUPPORTED_KEYS: [&str; 6] = [ "claude", @@ -2387,7 +2389,7 @@ fn codex_thread_ids(server: &CodexServer, cwd: &Path) -> Result, &[( 3, "thread/list", - serde_json::json!({"cwd": path_text(cwd), "limit": 100}), + serde_json::json!({"cwd": path_text(cwd), "limit": 100, "modelProviders": []}), )], )?; Ok(responses @@ -2401,6 +2403,63 @@ fn codex_thread_ids(server: &CodexServer, cwd: &Path) -> Result, .collect()) } +// Codex reports its thread in this PTY's title. Resolve a shortened title only against +// matching IDs, never against whichever conversation appeared next in the same folder. +#[tauri::command] +async fn record_codex_session( + app: AppHandle, + session_id: String, + run_id: String, + root_id: String, + title: String, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let prefix = title.strip_suffix("...").unwrap_or(&title); + if !(20..=36).contains(&prefix.len()) + || !prefix + .bytes() + .all(|byte| byte.is_ascii_hexdigit() || byte == b'-') + { + return Err("Invalid Codex thread title".into()); + } + let provider_sessions = app.state::(); + if provider_sessions + .0 + .lock() + .map_err(|error| error.to_string())? + .get(&session_id) + .is_some_and(|id| id.starts_with(prefix)) + { + return Ok(()); + } + let roots = app.state::(); + let ids = if let Some(root) = ssh_root(&roots, &root_id)? { + ssh_provider_session_ids(&root, "codex")? + } else { + codex_thread_ids(&app.state::(), &root_path(&roots, &root_id)?)? + }; + let mut matching = ids.iter().filter(|id| id.starts_with(prefix)); + let id = matching + .next() + .ok_or("Codex has not saved the reported conversation")?; + if matching.next().is_some() { + return Err("Codex reported an ambiguous conversation ID".into()); + } + // Serialize the association with stopping/replacing its owning PTY. + let sessions = app.state::(); + let running = sessions.0.lock().map_err(|error| error.to_string())?; + if running + .get(&session_id) + .is_some_and(|session| session.run_id == run_id) + { + update_provider_session(&app, &provider_sessions, &session_id, Some(id.clone()))?; + } + Ok(()) + }) + .await + .map_err(|error| error.to_string())? +} + fn codex_thread_resumable(server: &CodexServer, thread_id: &str) -> Result { codex_requests( server, @@ -2743,11 +2802,6 @@ enum CliAuthMethod { ApiKey, } -struct CliAuth { - method: CliAuthMethod, - key_hint: Option, -} - fn key_hint(key: &str) -> String { let key = key.trim(); key.chars() @@ -2755,126 +2809,47 @@ fn key_hint(key: &str) -> String { .collect() } -fn kimi_provider_name(config: &toml_edit::DocumentMut) -> Option { - let model = config.get("default_model")?.as_str()?; - config - .get("models")? - .get(model)? - .get("provider")? - .as_str() - .map(str::to_owned) -} - -fn kimi_env_api_key(provider: &dyn toml_edit::TableLike) -> Option<&'static str> { - match provider.get("type")?.as_str()? { - "kimi" => Some("KIMI_API_KEY"), - "anthropic" => Some("ANTHROPIC_API_KEY"), - "openai" | "openai_responses" => Some("OPENAI_API_KEY"), - "google-genai" => Some("GOOGLE_API_KEY"), - "vertexai" => Some("VERTEXAI_API_KEY"), - _ => None, - } -} - -fn kimi_auth(app: &AppHandle) -> Option { - let config = fs::read_to_string(kimi_home(app).ok()?.join("config.toml")).ok()?; - let config = config.parse::().ok()?; - let provider = kimi_provider_name(&config)?; - let provider = config.get("providers")?.get(&provider)?.as_table_like()?; - - let api_key = provider - .get("api_key") - .and_then(toml_edit::Item::as_str) - .filter(|key| !key.trim().is_empty()) - .or_else(|| { - let name = kimi_env_api_key(provider)?; - provider - .get("env") - .and_then(toml_edit::Item::as_table_like) - .and_then(|env| env.get(name)) - .and_then(toml_edit::Item::as_str) - .filter(|key| !key.trim().is_empty()) - }); - if let Some(api_key) = api_key { - Some(CliAuth { - method: CliAuthMethod::ApiKey, - key_hint: Some(key_hint(api_key)), - }) - } else if provider.get("oauth").is_some() { - Some(CliAuth { - method: CliAuthMethod::Provider, - key_hint: None, - }) - } else { - None +fn kimi_auth(app: &AppHandle) -> Option { + let home = kimi_home(app).ok()?; + // As with Codex and Gemini, check only whether the CLI has stored its sign-in. + if home.join("credentials/kimi-code.json").is_file() { + return Some(CliAuthMethod::Provider); } -} - -fn delete_kimi_api_key(app: &AppHandle) -> Result<(), String> { - let path = kimi_home(app)?.join("config.toml"); - let permissions = fs::metadata(&path) - .map_err(|error| error.to_string())? - .permissions(); - let text = fs::read_to_string(&path).map_err(|error| error.to_string())?; - let mut config = text - .parse::() - .map_err(|error| error.to_string())?; - let provider = kimi_provider_name(&config).ok_or("Kimi's default provider is missing")?; - let provider = config - .get_mut("providers") - .and_then(toml_edit::Item::as_table_like_mut) - .and_then(|providers| providers.get_mut(&provider)) - .and_then(toml_edit::Item::as_table_like_mut) - .ok_or("Kimi's default provider is missing")?; - - let env_api_key = kimi_env_api_key(provider); - let removed_env = env_api_key.is_some_and(|name| { - provider - .get_mut("env") - .and_then(toml_edit::Item::as_table_like_mut) - .is_some_and(|env| env.remove(name).is_some()) - }); - let removed = provider.remove("api_key").is_some() || removed_env; - if removed { - write_atomic(&path, config.to_string().as_bytes())?; - fs::set_permissions(&path, permissions).map_err(|error| error.to_string())?; + if !home.join("config.toml").is_file() { + return None; } - Ok(()) + // Ask the CLI for its public summary; its configuration and credentials stay its own. + let output = Command::new(resolve_executable("kimi")?) + .args(["provider", "list"]) + .env("PATH", user_path()?) + .stderr(Stdio::null()) + .output() + .ok()?; + (output.status.success() + && String::from_utf8_lossy(&output.stdout) + .lines() + .any(|line| line.contains(" type=") && !line.contains(" source=oauth"))) + .then_some(CliAuthMethod::Provider) } -fn cli_auth(app: &AppHandle, name: &str) -> Option { +fn cli_auth(app: &AppHandle, name: &str) -> Option { match name { - "claude" => claude_signed_in(app).then_some(CliAuth { - method: CliAuthMethod::Provider, - key_hint: None, - }), + "claude" => claude_signed_in(app).then_some(CliAuthMethod::Provider), "codex" => codex_home(app) .is_ok_and(|home| home.join("auth.json").is_file()) - .then_some(CliAuth { - method: CliAuthMethod::Provider, - key_hint: None, - }), + .then_some(CliAuthMethod::Provider), 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, - }) + .then_some(CliAuthMethod::ApiKey) } "gemini" => gemini_home(app) .is_ok_and(|home| home.join("oauth_creds.json").is_file()) - .then_some(CliAuth { - method: CliAuthMethod::Provider, - key_hint: None, - }), + .then_some(CliAuthMethod::Provider), "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, - }), + .then_some(CliAuthMethod::Provider), _ => None, } } @@ -2885,27 +2860,29 @@ struct ProviderAuth { name: String, key_hint: Option, cli_auth_method: Option, - cli_key_hint: Option, } #[tauri::command] 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(), - // Only the last characters travel to the interface, enough to tell two keys apart. - 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), - } - }) - .collect()) + tauri::async_runtime::spawn_blocking(move || { + 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(), + // Only the last characters travel to the interface, enough to tell two keys apart. + key_hint: keys.get(name).map(|key| key_hint(key)), + cli_auth_method: cli_auth, + } + }) + .collect()) + }) + .await + .map_err(|error| error.to_string())? } #[tauri::command] @@ -2928,9 +2905,6 @@ async fn delete_api_key(app: AppHandle, name: String) -> Result<(), String> { if keys.remove(&name).is_some() { return write_api_keys(&app, &keys); } - if name == "kimi" { - return delete_kimi_api_key(&app); - } Ok(()) } @@ -3971,6 +3945,25 @@ async fn spawn_session( ssh_session_command(root, &launch, initial_prompt.as_deref())? } else if signing_in { login_command(&agent)? + } else if mode.as_deref() == Some("rebuild") { + #[cfg(unix)] + let mut command = { + let mut command = CommandBuilder::new("/bin/sh"); + command.args(["-c", "git pull --ff-only origin main && bun run local; printf '\\033]6973;lite-rebuild-finished\\007'; exec \"${SHELL:-/bin/sh}\" -l"]); + command + }; + #[cfg(windows)] + let mut command = { + let mut command = CommandBuilder::new( + std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()), + ); + command.args(["/C", "(git pull --ff-only origin main && bun run local) & echo \x1b]6973;lite-rebuild-finished\x07 & cmd /K"]); + command + }; + if let Some(path) = user_path() { + command.env("PATH", path); + } + command } else { agent_command(&app, &launch)? }; @@ -3986,21 +3979,6 @@ async fn spawn_session( ssh.is_none().then_some(cwd.as_path()), theme.as_deref(), ); - // Codex records a new thread per launch, so its discovery watches for one the tab did not start with. - // Kimi attaches to the directory's session instead, so its discovery reads that session directly. - let known_sessions = - if !signing_in && provider_session_id.is_none() && (agent == "codex" || agent == "kimi") { - if agent == "kimi" { - Some(HashSet::new()) - } else if ssh.is_some() { - remote_sessions - } else { - // Losing discovery costs exact resume, not the session, so a failure still opens the terminal. - codex_thread_ids(&codex_server, &cwd).ok() - } - } else { - None - }; let mut child = match pair.slave.spawn_command(command) { Ok(child) => child, Err(error) => { @@ -4103,10 +4081,9 @@ async fn spawn_session( ); }); - if let Some(existing) = known_sessions { + if !signing_in && provider_session_id.is_none() && agent == "kimi" { let discovery_app = app.clone(); let discovery_session_id = session_id.clone(); - let discovery_agent = agent.clone(); let discovery_ssh = ssh.clone(); thread::spawn(move || { // The id appears with the first turn, which may be minutes away, so an idle tab is asked @@ -4114,27 +4091,14 @@ async fn spawn_session( let mut wait = Duration::from_secs(1); loop { let current = if let Some(root) = discovery_ssh.as_ref() { - let remote_agent = if discovery_agent == "kimi" { - "kimi-current" - } else { - discovery_agent.as_str() - }; - ssh_provider_session_ids(root, remote_agent) - } else if discovery_agent == "kimi" { + ssh_provider_session_ids(root, "kimi-current") + } else { Ok(kimi_current_session(&discovery_app, &cwd) .into_iter() .collect::>()) - } else { - codex_thread_ids(&discovery_app.state::(), &cwd) }; - let candidates = current.map(|current| { - current - .difference(&existing) - .cloned() - .collect::>() - }); // Whatever another tab already claimed is skipped, so overlapping launches settle apart. - if let Ok(candidates) = candidates + if let Ok(candidates) = current && candidates.iter().any(|provider_session_id| { update_provider_session( &discovery_app, @@ -4452,28 +4416,34 @@ async fn list_directory( .map_err(|error| error.to_string())?; } let root = root_path(&roots, &root_id)?; - let path = scoped_path(&root, &path)?; - let after = after.map(|cursor| directory_key(&cursor.name, &cursor.path, cursor.is_directory)); - let mut page = BTreeMap::new(); - let mut has_more = false; - for entry in fs::read_dir(path).map_err(|error| error.to_string())? { - let Ok(entry) = entry else { continue }; - let Ok(file_type) = entry.file_type() else { - continue; - }; - let name = entry.file_name().to_string_lossy().into_owned(); - if settings.hide_hidden.load(Ordering::Relaxed) && name.starts_with('.') { - continue; + let hide_hidden = settings.hide_hidden.load(Ordering::Relaxed); + tauri::async_runtime::spawn_blocking(move || { + let path = scoped_path(&root, &path)?; + let after = + after.map(|cursor| directory_key(&cursor.name, &cursor.path, cursor.is_directory)); + let mut page = BTreeMap::new(); + let mut has_more = false; + for entry in fs::read_dir(path).map_err(|error| error.to_string())? { + let Ok(entry) = entry else { continue }; + let Ok(file_type) = entry.file_type() else { + continue; + }; + let name = entry.file_name().to_string_lossy().into_owned(); + if hide_hidden && name.starts_with('.') { + continue; + } + let entry = FileEntry { + name, + path: path_text(&entry.path()), + is_directory: file_type.is_dir(), + is_symlink: file_type.is_symlink(), + }; + page_directory_entry(&mut page, &mut has_more, &after, entry); } - let entry = FileEntry { - name, - path: path_text(&entry.path()), - is_directory: file_type.is_dir(), - is_symlink: file_type.is_symlink(), - }; - page_directory_entry(&mut page, &mut has_more, &after, entry); - } - Ok(directory_listing(page, has_more)) + Ok(directory_listing(page, has_more)) + }) + .await + .map_err(|error| error.to_string())? } #[tauri::command] @@ -4667,8 +4637,9 @@ async fn write_text_file( root_id: String, path: String, contents: String, + original: String, ) -> Result<(), String> { - if contents.len() > MAX_FILE_BYTES as usize { + if contents.len() > MAX_FILE_BYTES as usize || original.len() > MAX_FILE_BYTES as usize { return Err("File is larger than 500 KB".into()); } if let Some(root) = ssh_root(&roots, &root_id)? { @@ -4676,23 +4647,39 @@ async fn write_text_file( let script = scoped_ssh_script( &root, &path, - "set -e; test -f \"$path\" || { printf '%s\\n' 'Only files can be edited' >&2; exit 1; }; parent=${path%/*}; test -n \"$parent\" || parent=/; tmp=$(mktemp \"$parent\"/.lite.XXXXXX); trap 'rm -f -- \"$tmp\"' EXIT; cat > \"$tmp\"; chmod --reference=\"$path\" \"$tmp\"; mv -- \"$tmp\" \"$path\"; trap - EXIT", + &format!("set -e; test -f \"$path\" || {{ printf '%s\\n' 'Only files can be edited' >&2; exit 1; }}; parent=${{path%/*}}; test -n \"$parent\" || parent=/; tmp=$(mktemp \"$parent\"/.lite.XXXXXX); trap 'rm -f -- \"$tmp\" \"$input\"' EXIT; input=$(mktemp \"$parent\"/.lite.XXXXXX); cat > \"$input\"; tail -c +{} \"$input\" > \"$tmp\"; head -c {} \"$input\" | cmp -s - \"$path\" || {{ printf '%s\\n' 'The file changed on disk. Copy your draft before reopening it to compare changes.' >&2; exit 1; }}; chmod --reference=\"$path\" \"$tmp\"; mv -- \"$tmp\" \"$path\"", original.len() + 1, original.len()), )?; - ssh_stream(&root, &script, Some(contents.as_bytes()), |_| Ok(())) + let mut input = original.into_bytes(); + input.extend_from_slice(contents.as_bytes()); + ssh_stream(&root, &script, Some(&input), |_| Ok(())) }) .await .map_err(|error| error.to_string())?; } let root = root_path(&roots, &root_id)?; - let path = scoped_path(&root, &path)?; - if !path.is_file() { - return Err("Only files can be edited".into()); - } - let permissions = fs::metadata(&path) - .map_err(|error| error.to_string())? - .permissions(); - write_atomic(&path, contents.as_bytes())?; - fs::set_permissions(path, permissions).map_err(|error| error.to_string()) + tauri::async_runtime::spawn_blocking(move || { + let path = scoped_path(&root, &path)?; + if !path.is_file() { + return Err("Only files can be edited".into()); + } + let mut current = Vec::new(); + fs::File::open(&path) + .and_then(|file| file.take(MAX_FILE_BYTES + 1).read_to_end(&mut current)) + .map_err(|error| error.to_string())?; + if current != original.as_bytes() { + return Err( + "The file changed on disk. Copy your draft before reopening it to compare changes." + .into(), + ); + } + let permissions = fs::metadata(&path) + .map_err(|error| error.to_string())? + .permissions(); + write_atomic(&path, contents.as_bytes())?; + fs::set_permissions(path, permissions).map_err(|error| error.to_string()) + }) + .await + .map_err(|error| error.to_string())? } #[tauri::command] @@ -4959,82 +4946,87 @@ async fn git_diff( .map_err(|error| error.to_string())?; } let granted = root_path(&roots, &root_id)?; - let git = resolve_executable("git").unwrap_or_else(|| "git".into()); - let repository = fs::canonicalize(command_output( - &git, - &granted, - &["rev-parse", "--show-toplevel"], - )?) - .map_err(|error| error.to_string())?; - let file = relative - .components() - .fold(repository.clone(), |mut file, component| { - if let Component::Normal(part) = component { - file.push(part); - } - file - }); - let mut ancestor = file.as_path(); - while !ancestor.exists() { - ancestor = ancestor - .parent() - .ok_or("This change is outside the selected folder")?; - } - let ancestor = fs::canonicalize(ancestor).map_err(|error| error.to_string())?; - if !ancestor.starts_with(&granted) { - return Err( - "This change is outside the selected folder; start a session from the repository root to view it" - .into(), - ); - } - let pathspec = path_text(relative); - let file = path_text(&file); - let untracked = !bounded_git_output( - &git, - &repository, - &[ - "--literal-pathspecs", - "ls-files", - "--others", - "--exclude-standard", - "-z", - "--", - &pathspec, - ], - &[0], - )? - .is_empty(); - if untracked { - let null = if cfg!(windows) { "NUL" } else { "/dev/null" }; - return bounded_git_output( + tauri::async_runtime::spawn_blocking(move || { + let relative = Path::new(&path); + let git = resolve_executable("git").unwrap_or_else(|| "git".into()); + let repository = fs::canonicalize(command_output( + &git, + &granted, + &["rev-parse", "--show-toplevel"], + )?) + .map_err(|error| error.to_string())?; + let file = relative + .components() + .fold(repository.clone(), |mut file, component| { + if let Component::Normal(part) = component { + file.push(part); + } + file + }); + let mut ancestor = file.as_path(); + while !ancestor.exists() { + ancestor = ancestor + .parent() + .ok_or("This change is outside the selected folder")?; + } + let ancestor = fs::canonicalize(ancestor).map_err(|error| error.to_string())?; + if !ancestor.starts_with(&granted) { + return Err( + "This change is outside the selected folder; start a session from the repository root to view it" + .into(), + ); + } + let pathspec = path_text(relative); + let file = path_text(&file); + let untracked = !bounded_git_output( &git, &repository, &[ - "diff", - "--no-index", - "--no-ext-diff", - "--no-textconv", - "--no-renames", - "--no-color", + "--literal-pathspecs", + "ls-files", + "--others", + "--exclude-standard", + "-z", "--", - null, - &file, + &pathspec, ], - &[0, 1], - ); - } - let base = git_diff_base(&git, &repository)?; - let mut args = vec![ - "--literal-pathspecs", - "diff", - "--no-ext-diff", - "--no-textconv", - "--no-renames", - "--no-color", - ]; - args.push(&base); - args.extend(["--", &pathspec]); - bounded_git_output(&git, &repository, &args, &[0]) + &[0], + )? + .is_empty(); + if untracked { + let null = if cfg!(windows) { "NUL" } else { "/dev/null" }; + return bounded_git_output( + &git, + &repository, + &[ + "diff", + "--no-index", + "--no-ext-diff", + "--no-textconv", + "--no-renames", + "--no-color", + "--", + null, + &file, + ], + &[0, 1], + ); + } + let base = git_diff_base(&git, &repository)?; + let mut args = vec![ + "--literal-pathspecs", + "diff", + "--no-ext-diff", + "--no-textconv", + "--no-renames", + "--no-color", + ]; + args.push(&base); + args.extend(["--", &pathspec]); + bounded_git_output(&git, &repository, &args, &[0]) + }) + .await + .map_err(|error| error.to_string())? } #[tauri::command] @@ -5099,88 +5091,92 @@ async fn git_status(roots: State<'_, Roots>, root_id: String) -> Result root, - Err(_) => return Ok(None), - }; - let repository = fs::canonicalize(&root).map_err(|error| error.to_string())?; - let scope = path - .strip_prefix(&repository) - .map_err(|_| "The selected folder is outside the Git repository")?; - let scope_text = if scope.as_os_str().is_empty() { - ".".into() - } else { - path_text(scope) - }; - let branch = command_output(&git, &path, &["branch", "--show-current"])?; - let (changes, changes_truncated) = bounded_git_changes( - &git, - &repository, - &[ - "--literal-pathspecs", - "status", - "--porcelain=v1", - "-z", - "--no-renames", - "--untracked-files=all", - "--", - &scope_text, - ], - )?; - let base = git_diff_base(&git, &repository)?; - let line_diffs = Command::new(&git) - .arg("-C") - .arg(path_text(&repository)) - .args([ - "--literal-pathspecs", - "diff", - "--no-ext-diff", - "--no-textconv", - "--no-renames", - "--numstat", - "-z", - &base, - "--", - &scope_text, - ]) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .ok() - .and_then(|mut child| { - let stdout = child.stdout.take()?; - let mut output = Vec::new(); - if stdout - .take(MAX_GIT_DIFF_BYTES + 1) - .read_to_end(&mut output) - .is_err() - { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - if output.len() > MAX_GIT_DIFF_BYTES as usize { - output.truncate(MAX_GIT_DIFF_BYTES as usize); - let _ = child.kill(); - let _ = child.wait(); - } else if !child.wait().ok()?.success() { - return None; - } - truncate_to_record(&mut output); - Some(output) - }) - .map(|output| git_line_diffs(&output)) - .unwrap_or_default(); - Ok(Some(git_status_result( - root, - branch, - changes, - line_diffs, - changes_truncated, - scope, - ))) + tauri::async_runtime::spawn_blocking(move || { + // Locating Git runs a login shell, so one refresh resolves it once rather than once per command. + let git = resolve_executable("git").unwrap_or_else(|| "git".into()); + let root = match command_output(&git, &path, &["rev-parse", "--show-toplevel"]) { + Ok(root) => root, + Err(_) => return Ok(None), + }; + let repository = fs::canonicalize(&root).map_err(|error| error.to_string())?; + let scope = path + .strip_prefix(&repository) + .map_err(|_| "The selected folder is outside the Git repository")?; + let scope_text = if scope.as_os_str().is_empty() { + ".".into() + } else { + path_text(scope) + }; + let branch = command_output(&git, &path, &["branch", "--show-current"])?; + let (changes, changes_truncated) = bounded_git_changes( + &git, + &repository, + &[ + "--literal-pathspecs", + "status", + "--porcelain=v1", + "-z", + "--no-renames", + "--untracked-files=all", + "--", + &scope_text, + ], + )?; + let base = git_diff_base(&git, &repository)?; + let line_diffs = Command::new(&git) + .arg("-C") + .arg(path_text(&repository)) + .args([ + "--literal-pathspecs", + "diff", + "--no-ext-diff", + "--no-textconv", + "--no-renames", + "--numstat", + "-z", + &base, + "--", + &scope_text, + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok() + .and_then(|mut child| { + let stdout = child.stdout.take()?; + let mut output = Vec::new(); + if stdout + .take(MAX_GIT_DIFF_BYTES + 1) + .read_to_end(&mut output) + .is_err() + { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + if output.len() > MAX_GIT_DIFF_BYTES as usize { + output.truncate(MAX_GIT_DIFF_BYTES as usize); + let _ = child.kill(); + let _ = child.wait(); + } else if !child.wait().ok()?.success() { + return None; + } + truncate_to_record(&mut output); + Some(output) + }) + .map(|output| git_line_diffs(&output)) + .unwrap_or_default(); + Ok(Some(git_status_result( + root, + branch, + changes, + line_diffs, + changes_truncated, + scope, + ))) + }) + .await + .map_err(|error| error.to_string())? } // A remote is stored the way the repository was cloned, and only its https form opens in a browser. @@ -5920,7 +5916,6 @@ async fn remove_worktree( #[tauri::command] async fn read_usage( app: AppHandle, - codex_server: State<'_, CodexServer>, provider_sessions: State<'_, ProviderSessions>, agent: String, provider: Option, @@ -5940,99 +5935,110 @@ async fn read_usage( } else { None }; - match agent.as_str() { - "claude" => { - let directory = app - .path() - .app_data_dir() - .map_err(|error| error.to_string())?; - let path = directory.join(format!("usage-{session_id}.json")); - let mut usage = match fs::read(path) { - Ok(bytes) => serde_json::from_slice(&bytes).map_err(|error| error.to_string())?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - UsageSnapshot::default() - } - Err(error) => return Err(error.to_string()), - }; - // Claude's limits are account-wide, while its context and cost belong to this session. - // Use Claude's newest report for each account-wide limit rather than hiding one when the - // selected session has not sent a message yet or another report omitted that window. - if let Ok(entries) = fs::read_dir(directory) { - let mut latest = [None, None]; - for (modified, window) in entries - .flatten() - .filter(|entry| { - entry.file_name().to_str().is_some_and(|name| { - name.starts_with("usage-") && name.ends_with(".json") + tauri::async_runtime::spawn_blocking(move || { + match agent.as_str() { + "claude" => { + let directory = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())?; + let path = directory.join(format!("usage-{session_id}.json")); + let mut usage = match fs::read(path) { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|error| error.to_string())? + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + UsageSnapshot::default() + } + Err(error) => return Err(error.to_string()), + }; + // Claude's limits are account-wide, while its context and cost belong to this session. + // Use Claude's newest report for each account-wide limit rather than hiding one when the + // selected session has not sent a message yet or another report omitted that window. + if let Ok(entries) = fs::read_dir(directory) { + let mut latest = [None, None]; + for (modified, window) in entries + .flatten() + .filter(|entry| { + entry.file_name().to_str().is_some_and(|name| { + name.starts_with("usage-") && name.ends_with(".json") + }) + }) + .filter_map(|entry| { + let modified = entry.metadata().ok()?.modified().ok()?; + let snapshot: UsageSnapshot = + serde_json::from_slice(&fs::read(entry.path()).ok()?).ok()?; + Some((modified, snapshot.windows)) + }) + .flat_map(|(modified, windows)| { + windows.into_iter().map(move |window| (modified, window)) }) - }) - .filter_map(|entry| { - let modified = entry.metadata().ok()?.modified().ok()?; - let snapshot: UsageSnapshot = - serde_json::from_slice(&fs::read(entry.path()).ok()?).ok()?; - Some((modified, snapshot.windows)) - }) - .flat_map(|(modified, windows)| { - windows.into_iter().map(move |window| (modified, window)) - }) - { - let index = match window.label.as_str() { - "Current session" | "5 hour" => 0, - "Current week" | "7 day" => 1, - _ => continue, - }; - if latest[index] - .as_ref() - .is_none_or(|(current, _)| modified > *current) { - latest[index] = Some((modified, window)); + let index = match window.label.as_str() { + "Current session" | "5 hour" => 0, + "Current week" | "7 day" => 1, + _ => continue, + }; + if latest[index] + .as_ref() + .is_none_or(|(current, _)| modified > *current) + { + latest[index] = Some((modified, window)); + } + } + let windows: Vec<_> = latest + .into_iter() + .flatten() + .map(|(_, window)| window) + .collect(); + if !windows.is_empty() { + usage.windows = windows; } } - let windows: Vec<_> = latest - .into_iter() - .flatten() - .map(|(_, window)| window) - .collect(); - if !windows.is_empty() { - usage.windows = windows; + for window in &mut usage.windows { + window.label = match window.label.as_str() { + "5 hour" => "Current session".into(), + "7 day" => "Current week".into(), + _ => continue, + }; } + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()); + usage + .windows + .retain(|window| window.resets_at.is_none_or(|reset| reset > now)); + Ok((usage.context_used_percent.is_some() + || usage.context_tokens.is_some_and(|tokens| tokens > 0) + || usage.cost_usd.is_some_and(|cost| cost > 0.0) + || !usage.windows.is_empty()) + .then_some(usage)) } - for window in &mut usage.windows { - window.label = match window.label.as_str() { - "5 hour" => "Current session".into(), - "7 day" => "Current week".into(), - _ => continue, - }; - } - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs()); - usage - .windows - .retain(|window| window.resets_at.is_none_or(|reset| reset > now)); - Ok((usage.context_used_percent.is_some() - || usage.context_tokens.is_some_and(|tokens| tokens > 0) - || usage.cost_usd.is_some_and(|cost| cost > 0.0) - || !usage.windows.is_empty()) - .then_some(usage)) - } - "codex" => { - // Custom providers have local thread context but do not bill OpenAI, so omit only the - // account requests rather than omitting the whole session. - let account = codex_provider(provider.as_deref()).is_none(); - if !account && provider_session_id.is_none() { - return Ok(None); + "codex" => { + // Custom providers have local thread context but do not bill OpenAI, so omit only the + // account requests rather than omitting the whole session. + let account = codex_provider(provider.as_deref()).is_none(); + if !account && provider_session_id.is_none() { + return Ok(None); + } + codex_usage( + &app.state::(), + provider_session_id.as_deref(), + account, + ) + .map(Some) } - codex_usage(&codex_server, provider_session_id.as_deref(), account).map(Some) + "gemini" | "qwen" => Ok(native_session_path(&app, &agent, &session_id) + .and_then(|path| native_context(&path, &agent))), + "kimi" => Ok(provider_session_id + .as_deref() + .and_then(|id| kimi_context(&app, id))), + "shell" => Ok(None), + _ => Err("Unknown session type".into()), } - "gemini" | "qwen" => Ok(native_session_path(&app, &agent, &session_id) - .and_then(|path| native_context(&path, &agent))), - "kimi" => Ok(provider_session_id - .as_deref() - .and_then(|id| kimi_context(&app, id))), - "shell" => Ok(None), - _ => Err("Unknown session type".into()), - } + }) + .await + .map_err(|error| error.to_string())? } fn stop_runtime(app: &AppHandle) { @@ -6260,6 +6266,7 @@ pub fn run() { default_directory, revoke_directory, spawn_session, + record_codex_session, write_session, watch_shell_agent, resize_session, diff --git a/src/App.tsx b/src/App.tsx index bed3d96..5fdf199 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -95,7 +95,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Spinner } from "@/components/ui/spinner"; import { Toaster, toast } from "@/components/ui/toast"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { clearInspectorCache, Inspector, rememberGitHubReferences, SearchInput } from "@/inspector"; +import { clearInspectorCache, Inspector, rememberGitHubReferences, SearchInput, unsavedFile } from "@/inspector"; import { including, swapped, without } from "@/lib/utils"; import { NewSessionDialog, SESSION_CHOICES } from "@/new-session-dialog"; import { @@ -617,7 +617,7 @@ function AppContextMenu({ {linkGroup && surfaceGroup ? : null} {context.refresh ? ( context.refresh?.click()}> - + Refresh ) : null} @@ -838,7 +838,7 @@ function VersionBadge({ variant={commit ? "error" : BADGE_VARIANT[release]} render={ - @@ -3686,6 +3722,7 @@ function App() { variant="destructive" disabled={closingAllRunning} onClick={() => { + if (!canCloseEditors()) return; attentionRef.current = []; setAttention([]); for (const session of sessions) { diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 276392e..2afdf91 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -10,17 +10,14 @@ function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive ); } const tabsListVariants = cva( - "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none", { variants: { variant: { @@ -54,10 +51,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { (null); + const [limited, setLimited] = useState(false); async function walk(expand: boolean) { + walkController.current?.abort(); + const controller = new AbortController(); + walkController.current = controller; + const { signal } = controller; setExpandingAll(true); - const nextChildren = { ...children }; + setLimited(false); + const nextChildren: typeof children = {}; const directories = new Set(); const pending = [root]; + const limit = 10_000; + let count = 0; try { - while (pending.length) { - const path = pending.shift(); - if (!path || directories.has(path)) continue; + for (let index = 0; index < pending.length && count < limit; index++) { + const path = pending[index]; + if (directories.has(path)) continue; directories.add(path); - // A directory is walked through every page it has: a walk that silently stopped at the first - // 250 entries would answer "No matches" about a file that exists. - let listing = nextChildren[path]; + let listing = children[path]; if (!listing || listing.after || listing.nextCursor) { - let entries: FileEntry[] = []; + const entries: FileEntry[] = []; let cursor: DirectoryCursor | null = null; do { + signal.throwIfAborted(); const page: DirectoryListing = await invoke("list_directory", { rootId, path, after: cursor }); - entries = entries.concat(page.entries); - cursor = page.nextCursor; - } while (cursor); - listing = { entries, nextCursor: null, after: null }; - nextChildren[path] = listing; + signal.throwIfAborted(); + const loaded = page.entries.slice(0, limit - count - entries.length); + entries.push(...loaded); + cursor = loaded.length < page.entries.length ? (loaded[loaded.length - 1] ?? null) : page.nextCursor; + } while (cursor && count + entries.length < limit); + listing = { entries, nextCursor: cursor, after: null }; } - pending.push( - ...listing.entries.filter((entry) => entry.isDirectory && !entry.isSymlink).map((entry) => entry.path), - ); + const entries = listing.entries.slice(0, limit - count); + count += entries.length; + nextChildren[path] = + entries.length < listing.entries.length + ? { ...listing, entries, nextCursor: entries[entries.length - 1] } + : listing; + pending.push(...entries.filter((entry) => entry.isDirectory && !entry.isSymlink).map((entry) => entry.path)); } - setChildren(nextChildren); + setChildren((current) => ({ ...current, ...nextChildren })); if (expand) setExpanded(directories); - walked.current = true; + setLimited(count >= limit); setError(""); } catch (reason) { - setError(String(reason)); + if (!signal.aborted) setError(String(reason)); } finally { - setExpandingAll(false); + if (!signal.aborted) setExpandingAll(false); } } @@ -586,17 +597,18 @@ function FileTree({ } } - // A search has to see the whole tree, so the first searching keystroke loads it and the next query - // — not the render a failure causes — retries a walk that failed; one that succeeded is not - // repeated, and a cleared search forgets the failure so the same query asks again. - const attempted = useRef(""); + const searchFiles = useEffectEvent(() => void walk(false)); useEffect(() => { - if (!lowered) attempted.current = ""; - else if (lowered !== attempted.current && !walked.current && !expandingAll) { - attempted.current = lowered; - void walk(false); + if (!lowered) { + setExpandingAll(false); + setLimited(false); } - }); + const timer = lowered ? window.setTimeout(searchFiles, 150) : undefined; + return () => { + window.clearTimeout(timer); + walkController.current?.abort(); + }; + }, [lowered]); // A folder is worth showing while searching if anything under it matches; only loaded listings can // answer, which is what the walk above is for. One pass marks every such folder, because the answer @@ -774,9 +786,13 @@ function FileTree({ - {/* A walk that failed searched a partial tree, so its error shows over whatever it did find. */} + {limited ? ( +

+ Showing up to 10,000 entries. Open a smaller folder to search further. +

+ ) : null} {lowered && error ?

{error}

: null} - {lowered && !error && !expandingAll && children[root] && !matching.has(root) ? ( + {lowered && !limited && !error && !expandingAll && children[root] && !matching.has(root) ? (

No matches

) : null} {rootOpen ? rows(root, 1) : null} @@ -1017,7 +1033,7 @@ function FileViewer({ {renderable ? ( - +
}> @@ -1059,6 +1075,11 @@ interface TextFile { const fileEditorsBySession = new Map(); +export function unsavedFile(sessionId?: string) { + for (const [id, editor] of fileEditorsBySession) + if ((!sessionId || id === sessionId) && editor.draft !== editor.source) return editor.selected.path; +} + function FilesPanel({ root, rootId, @@ -1130,7 +1151,7 @@ function FilesPanel({ const entry = selectedRef.current; if (!entry) return; const id = request.current; - await invoke("write_text_file", { rootId, path: entry.path, contents }); + await invoke("write_text_file", { rootId, path: entry.path, contents, original: source }); if (request.current !== id) return; const current = fileEditorsBySession.get(sessionId); if (current?.rootId === rootId && current.selected.path === entry.path) @@ -1585,7 +1606,6 @@ function GitPanel({
{error ?

{error}

: null} - {!error && status === undefined ? : null} {shown.map((repository) => ( refreshTab(tab as keyof typeof reload)} > - {refreshing === tab ? : } + )}
diff --git a/src/new-session-dialog.tsx b/src/new-session-dialog.tsx index 2d80c89..7cd3b95 100644 --- a/src/new-session-dialog.tsx +++ b/src/new-session-dialog.tsx @@ -716,7 +716,15 @@ export function NewSessionDialog({ disabled={Boolean(installing)} onClick={() => void install(option)} > - {busy ? : action.label === "Install" ? : } + {action.label === "Install" ? ( + busy ? ( + + ) : ( + + ) + ) : ( + + )} ) : null}
diff --git a/src/output-store.ts b/src/output-store.ts index 37c2b8b..0769fba 100644 --- a/src/output-store.ts +++ b/src/output-store.ts @@ -111,6 +111,7 @@ export function appendOutput(sessionId: string, bytes: Uint8Array) { let path = ""; let activity: boolean | undefined; let notification = false; + let rebuildFinished = false; OSC_OR_BELL.lastIndex = 0; for (let match = OSC_OR_BELL.exec(text); !notification && match; match = OSC_OR_BELL.exec(text)) { const sequence = match[0]; @@ -125,6 +126,7 @@ export function appendOutput(sessionId: string, bytes: Uint8Array) { if (match[1] === "7") { if (match[2].startsWith("file://")) path = reportedPath(match[2]); } else if (match[1] === "6973") { + if (match[2] === "lite-rebuild-finished") rebuildFinished = true; const working = match[2] === "lite-working"; if (working || match[2] === "lite-idle") activity = working; } else title = match[2]; @@ -142,6 +144,7 @@ export function appendOutput(sessionId: string, bytes: Uint8Array) { activityChanged, backgroundActivity: buffer.backgroundActivity, notification, + rebuildFinished, }; } diff --git a/src/provider-auth.tsx b/src/provider-auth.tsx index a54d64a..f09678f 100644 --- a/src/provider-auth.tsx +++ b/src/provider-auth.tsx @@ -59,7 +59,7 @@ export const AUTH_PROVIDERS = { provider: undefined, label: "Moonshot AI", variable: "MOONSHOT_API_KEY", - configured: "Signed in through Kimi Code", + configured: "Configured through Kimi Code", signIn: true, }, qwen: { @@ -88,7 +88,6 @@ export interface ProviderAuth { name: string; keyHint: string | null; cliAuthMethod: "provider" | "apiKey" | null; - cliKeyHint: string | null; } export function ProviderAuthDescription({ @@ -98,8 +97,8 @@ export function ProviderAuthDescription({ provider: (typeof AUTH_PROVIDERS)[keyof typeof AUTH_PROVIDERS]; status?: ProviderAuth; }) { - const hint = status?.keyHint ?? status?.cliKeyHint; - const configured = status?.keyHint || status?.cliKeyHint ? "Using API key" : provider.configured; + const hint = status?.keyHint; + const configured = status?.keyHint ? "Using API key" : provider.configured; return ( {status && (hint || status.cliAuthMethod) ? ( diff --git a/src/settings-dialog.tsx b/src/settings-dialog.tsx index efb2b5e..d7b519f 100644 --- a/src/settings-dialog.tsx +++ b/src/settings-dialog.tsx @@ -404,7 +404,7 @@ export function SettingsDialog({ : "Use API key"} ) : null} - {status?.keyHint || status?.cliKeyHint ? ( + {status?.keyHint ? ( !found.done, - )?.value; + const cursor = of.getCursor(view.state, start); + let found = cursor.next(); + if (!incremental && !found.done && found.value.from === from && found.value.to === to) found = cursor.next(); + if (found.done) found = of.getCursor(view.state, 0).next(); + match = found.done ? undefined : found.value; } if (match) view.dispatch({ diff --git a/src/terminal.tsx b/src/terminal.tsx index 57b3a2d..b0074d6 100644 --- a/src/terminal.tsx +++ b/src/terminal.tsx @@ -206,6 +206,7 @@ export function TerminalView({ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", fontSize: fontSizeRef.current, lineHeight: 1.25, + minimumContrastRatio: 4.5, overviewRuler: { width: 6 }, linkHandler: { activate: (event, url) => { diff --git a/src/types.ts b/src/types.ts index 7cadef5..5b878e8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,8 +14,8 @@ export interface Session { // Provider model chosen when the session was created; absent for providers that own model choice. model?: string; reasoningEffort?: string; - // A sign-in session runs the provider's own login command; it is never stored or resumed. - mode?: "login"; + // Sign-in and rebuild commands are temporary sessions; neither is stored or resumed. + mode?: "login" | "rebuild"; name: string; // A name the user typed is theirs, so nothing the session says about itself overwrites it again. renamed?: boolean;