Merge main and host authorization-code OAuth in core

This commit is contained in:
leokun
2026-09-04 17:16:29 +08:00
224 changed files with 10351 additions and 21466 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.5",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.5",
"version": "0.1.6",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.5",
"version": "0.1.6",
"description": "Cursor BYOK desktop management application",
"type": "module",
"scripts": {
@@ -8,7 +8,7 @@
"dev": "vite",
"typecheck": "tsc --noEmit",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"i18n:scan": "STATIC_I18N_SCAN=true vite build",
"i18n:scan": "cross-env STATIC_I18N_SCAN=true vite build",
"build": "vite build",
"build:demo": "npm run typecheck && npm run typecheck:node && vite build --config vite.demo.config.ts",
"check": "npm run typecheck && npm run typecheck:node && npm run build",
+8 -2
View File
@@ -1,12 +1,12 @@
[package]
name = "cursor-byok-desktop"
version = "0.1.5"
version = "0.1.6"
edition = "2021"
publish = false
[lib]
name = "cursor_byok_desktop"
crate-type = ["rlib"]
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
@@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
axum = "0.8"
cursor-server = { path = "../../../server" }
libc = "0.2"
rfd = "0.15"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -24,9 +25,14 @@ tauri-plugin-opener = "2"
tauri-plugin-autostart = "2"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
tempfile = "3"
tokio = { version = "1", features = ["time"] }
tokio-util = "0.7"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
zip = { version = "4", default-features = false, features = ["deflate"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading"] }
+5 -1
View File
@@ -1,5 +1,9 @@
fn main() {
let manifest = tauri_build::AppManifest::new().commands(&["open_terminal_with_command"]);
let manifest = tauri_build::AppManifest::new().commands(&[
"open_terminal_with_command",
"check_portable_update",
"install_portable_update",
]);
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
.expect("failed to build Tauri application")
}
@@ -18,6 +18,8 @@
"core:window:allow-close",
"core:app:allow-set-dock-visibility",
"allow-open-terminal-with-command",
"allow-check-portable-update",
"allow-install-portable-update",
"clipboard-manager:allow-write-text",
"autostart:default",
"process:allow-restart",
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-check-portable-update"
description = "Enables the check_portable_update command without any pre-configured scope."
commands.allow = ["check_portable_update"]
[[permission]]
identifier = "deny-check-portable-update"
description = "Denies the check_portable_update command without any pre-configured scope."
commands.deny = ["check_portable_update"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-install-portable-update"
description = "Enables the install_portable_update command without any pre-configured scope."
commands.allow = ["install_portable_update"]
[[permission]]
identifier = "deny-install-portable-update"
description = "Denies the install_portable_update command without any pre-configured scope."
commands.deny = ["install_portable_update"]
+23 -1
View File
@@ -149,6 +149,23 @@ pub fn run() -> ExitCode {
return ExitCode::FAILURE;
}
};
#[cfg(unix)]
{
let open_file_limit = match crate::resource_limits::raise_open_file_limit() {
Ok(limit) => limit,
Err(error) => {
diagnostics.report_fatal(&error);
return ExitCode::FAILURE;
}
};
tracing::info!(
requested = crate::resource_limits::REQUESTED_OPEN_FILE_LIMIT,
previous = open_file_limit.previous,
effective = open_file_limit.effective,
hard = open_file_limit.hard,
"open file limit configured"
);
}
tracing::info!(
version = env!("CARGO_PKG_VERSION"),
os = std::env::consts::OS,
@@ -160,7 +177,11 @@ pub fn run() -> ExitCode {
let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG);
let app = tauri::Builder::default()
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
.invoke_handler(tauri::generate_handler![
open_terminal_with_command,
crate::update::check_portable_update,
crate::update::install_portable_update,
])
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
tray::show_main_window(app);
@@ -228,6 +249,7 @@ pub fn run() -> ExitCode {
window.set_focus()?;
}
tray::create(app)?;
crate::update::signal_ready_if_requested()?;
Ok(())
})
.build(tauri::generate_context!());
+8 -1
View File
@@ -1,7 +1,14 @@
mod desktop;
#[cfg(not(dev))]
mod frontend;
mod resource_limits;
mod startup;
mod tray;
mod update;
pub use desktop::run;
pub fn run() -> std::process::ExitCode {
if let Some(exit_code) = update::run_replacement_if_requested() {
return exit_code;
}
desktop::run()
}
@@ -0,0 +1,56 @@
//! Configures process resource limits before the desktop runtime starts.
#[cfg(unix)]
use std::io;
#[cfg(unix)]
pub(crate) const REQUESTED_OPEN_FILE_LIMIT: u64 = 65_536;
#[cfg(unix)]
pub(crate) struct OpenFileLimit {
pub(crate) previous: u64,
pub(crate) effective: u64,
pub(crate) hard: u64,
}
#[cfg(unix)]
pub(crate) fn raise_open_file_limit() -> io::Result<OpenFileLimit> {
let mut limits = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
// SAFETY: `limits` points to writable memory for one `rlimit` value.
if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limits) } != 0 {
return Err(io::Error::last_os_error());
}
let previous = limits.rlim_cur;
let target = limits
.rlim_max
.min(REQUESTED_OPEN_FILE_LIMIT as libc::rlim_t);
if previous < target {
let requested = libc::rlimit {
rlim_cur: target,
rlim_max: limits.rlim_max,
};
// SAFETY: `requested` is a valid `rlimit` value and does not raise the hard limit.
if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &requested) } != 0 {
return Err(io::Error::last_os_error());
}
}
let mut effective = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
// SAFETY: `effective` points to writable memory for one `rlimit` value.
if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut effective) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(OpenFileLimit {
previous,
effective: effective.rlim_cur,
hard: effective.rlim_max,
})
}
+282
View File
@@ -0,0 +1,282 @@
use std::process::ExitCode;
#[cfg(target_os = "windows")]
use std::{
fs::{self, OpenOptions},
io::Write,
path::PathBuf,
process::Command,
};
#[cfg(any(target_os = "windows", test))]
use std::{
io::{Cursor, Read},
path::Path,
};
use serde::Serialize;
use tauri::AppHandle;
#[cfg(target_os = "windows")]
use tauri_plugin_updater::UpdaterExt;
#[cfg(target_os = "windows")]
mod replacement;
#[cfg(target_os = "windows")]
const PORTABLE_UPDATE_ENDPOINT: &str =
"https://github.com/leookun/cursor-byok/releases/latest/download/portable-latest.json";
#[cfg(any(target_os = "windows", test))]
const WINDOWS_PAYLOAD_NAME: &str = "cursor-byok-desktop.exe";
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PortableUpdateInfo {
version: String,
}
pub fn run_replacement_if_requested() -> Option<ExitCode> {
#[cfg(target_os = "windows")]
{
match replacement::request_from_args() {
Ok(Some(request)) => return Some(replacement::run(request)),
Ok(None) => {}
Err(error) => {
eprintln!("invalid portable update replacement request: {error}");
return Some(ExitCode::FAILURE);
}
}
}
None
}
pub(crate) fn signal_ready_if_requested() -> std::io::Result<()> {
#[cfg(target_os = "windows")]
if let Some(path) = replacement::ready_marker_from_args() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, b"ready")?;
}
Ok(())
}
#[tauri::command]
pub(crate) async fn check_portable_update(
app: AppHandle,
) -> Result<Option<PortableUpdateInfo>, String> {
#[cfg(target_os = "windows")]
{
let update = portable_update(&app).await?;
Ok(update.map(|update| PortableUpdateInfo {
version: update.version,
}))
}
#[cfg(not(target_os = "windows"))]
{
let _ = app;
Err("portable updates are only supported on Windows".into())
}
}
#[tauri::command]
pub(crate) async fn install_portable_update(
app: AppHandle,
expected_version: String,
) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let update = portable_update(&app)
.await?
.ok_or_else(|| "the selected update is no longer available".to_string())?;
if update.version != expected_version {
return Err(format!(
"available update changed from {expected_version} to {}",
update.version
));
}
let target = std::env::current_exe()
.map_err(|error| format!("failed to locate the running executable: {error}"))?;
ensure_target_writable(&target)
.map_err(|error| format!("the application directory is not writable: {error}"))?;
let bytes = update
.download(|_, _| {}, || {})
.await
.map_err(|error| format!("failed to download or verify the update: {error}"))?;
let payload = extract_windows_payload(&bytes)
.map_err(|error| format!("invalid Windows update archive: {error}"))?;
let staged = stage_payload(&target, &payload)
.map_err(|error| format!("failed to stage the update: {error}"))?;
let handshake = staged.with_extension("started");
let _ = fs::remove_file(&handshake);
let mut replacement = Command::new(&staged)
.arg("--apply-portable-update")
.arg("--update-target")
.arg(&target)
.arg("--update-wait-pid")
.arg(std::process::id().to_string())
.arg("--update-handshake")
.arg(&handshake)
.spawn()
.map_err(|error| format!("failed to start the update replacement process: {error}"))?;
wait_for_replacement_start(&mut replacement, &handshake).await?;
app.exit(0);
Ok(())
}
#[cfg(not(target_os = "windows"))]
{
let _ = (app, expected_version);
Err("portable updates are only supported on Windows".into())
}
}
#[cfg(target_os = "windows")]
async fn portable_update(app: &AppHandle) -> Result<Option<tauri_plugin_updater::Update>, String> {
let endpoint = PORTABLE_UPDATE_ENDPOINT
.parse()
.map_err(|error| format!("invalid portable update endpoint: {error}"))?;
let updater = app
.updater_builder()
.endpoints(vec![endpoint])
.map_err(|error| format!("failed to configure the updater: {error}"))?
.build()
.map_err(|error| format!("failed to initialize the updater: {error}"))?;
updater
.check()
.await
.map_err(|error| format!("failed to check for updates: {error}"))
}
#[cfg(target_os = "windows")]
async fn wait_for_replacement_start(
child: &mut std::process::Child,
handshake: &Path,
) -> Result<(), String> {
let wait = async {
loop {
if handshake.is_file() {
return Ok(());
}
if let Some(status) = child
.try_wait()
.map_err(|error| format!("failed to inspect replacement process: {error}"))?
{
return Err(format!(
"update replacement process exited before it was ready: {status}"
));
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
};
let result = match tokio::time::timeout(std::time::Duration::from_secs(5), wait).await {
Ok(result) => result,
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return Err("update replacement process did not become ready".into());
}
};
if result.is_err() {
let _ = child.kill();
let _ = child.wait();
}
result
}
#[cfg(target_os = "windows")]
fn ensure_target_writable(target: &Path) -> std::io::Result<()> {
let parent = target
.parent()
.ok_or_else(|| std::io::Error::other("application executable has no parent directory"))?;
let probe = parent.join(format!(
".cursor-byok-update-write-test-{}",
std::process::id()
));
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe)?;
file.write_all(b"test")?;
drop(file);
fs::remove_file(probe)
}
#[cfg(any(target_os = "windows", test))]
fn extract_windows_payload(bytes: &[u8]) -> Result<Vec<u8>, String> {
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
.map_err(|error| format!("failed to open ZIP: {error}"))?;
if archive.len() != 1 {
return Err("archive must contain exactly one file".into());
}
let mut entry = archive
.by_index(0)
.map_err(|error| format!("failed to read ZIP entry: {error}"))?;
let name = Path::new(entry.name())
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| "archive entry has an invalid file name".to_string())?;
if name != WINDOWS_PAYLOAD_NAME || entry.is_dir() {
return Err(format!(
"expected {WINDOWS_PAYLOAD_NAME}, found {}",
entry.name()
));
}
let mut payload = Vec::with_capacity(entry.size() as usize);
entry
.read_to_end(&mut payload)
.map_err(|error| format!("failed to extract executable: {error}"))?;
if payload.len() < 2 || &payload[..2] != b"MZ" {
return Err("payload is not a Windows executable".into());
}
Ok(payload)
}
#[cfg(target_os = "windows")]
fn stage_payload(target: &Path, payload: &[u8]) -> std::io::Result<PathBuf> {
let directory = tempfile::Builder::new()
.prefix("cursor-byok-portable-update-")
.tempdir()?;
let name = target
.file_name()
.ok_or_else(|| std::io::Error::other("application executable has no file name"))?;
let path = directory.path().join(name);
fs::write(&path, payload)?;
let _ = directory.keep();
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn update_zip(name: &str, payload: &[u8]) -> Vec<u8> {
let mut bytes = Cursor::new(Vec::new());
{
let mut archive = zip::ZipWriter::new(&mut bytes);
archive
.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
archive.write_all(payload).unwrap();
archive.finish().unwrap();
}
bytes.into_inner()
}
#[test]
fn extracts_the_single_expected_windows_executable() {
let bytes = update_zip(WINDOWS_PAYLOAD_NAME, b"MZpayload");
assert_eq!(extract_windows_payload(&bytes).unwrap(), b"MZpayload");
}
#[test]
fn rejects_unexpected_or_non_executable_payloads() {
let wrong_name = update_zip("other.exe", b"MZpayload");
assert!(extract_windows_payload(&wrong_name).is_err());
let wrong_content = update_zip(WINDOWS_PAYLOAD_NAME, b"not an executable");
assert!(extract_windows_payload(&wrong_content).is_err());
}
}
@@ -0,0 +1,296 @@
use std::{
ffi::{OsStr, OsString},
fs, io,
path::{Path, PathBuf},
process::{Child, Command, ExitCode},
thread,
time::{Duration, Instant},
};
const APPLY_ARG: &str = "--apply-portable-update";
const TARGET_ARG: &str = "--update-target";
const PID_ARG: &str = "--update-wait-pid";
const HANDSHAKE_ARG: &str = "--update-handshake";
pub(super) const READY_ARG: &str = "--portable-update-ready";
const PROCESS_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
const READY_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
pub(super) struct ReplacementRequest {
target: PathBuf,
pid: u32,
handshake: PathBuf,
}
pub(super) fn request_from_args() -> Result<Option<ReplacementRequest>, String> {
let args = std::env::args_os().collect::<Vec<_>>();
if !args.iter().any(|arg| arg == APPLY_ARG) {
return Ok(None);
}
let target = PathBuf::from(
argument_value(&args, TARGET_ARG).ok_or_else(|| format!("{TARGET_ARG} is required"))?,
);
let pid = argument_value(&args, PID_ARG)
.ok_or_else(|| format!("{PID_ARG} is required"))?
.to_string_lossy()
.parse::<u32>()
.map_err(|error| format!("invalid {PID_ARG}: {error}"))?;
let handshake = PathBuf::from(
argument_value(&args, HANDSHAKE_ARG)
.ok_or_else(|| format!("{HANDSHAKE_ARG} is required"))?,
);
validate_target(&target).map_err(|error| error.to_string())?;
Ok(Some(ReplacementRequest {
target,
pid,
handshake,
}))
}
pub(super) fn ready_marker_from_args() -> Option<PathBuf> {
let args = std::env::args_os().collect::<Vec<_>>();
argument_value(&args, READY_ARG).map(PathBuf::from)
}
fn argument_value<'a>(args: &'a [OsString], name: &str) -> Option<&'a OsStr> {
args.iter()
.position(|arg| arg == name)
.and_then(|index| args.get(index + 1))
.map(OsString::as_os_str)
}
fn validate_target(target: &Path) -> io::Result<()> {
if !target.is_absolute() || !target.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"update target must be an existing absolute file",
));
}
let source_name = std::env::current_exe()?
.file_name()
.map(OsStr::to_os_string)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "updater has no file name"))?;
if target.file_name() != Some(source_name.as_os_str()) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"update target file name does not match the updater",
));
}
Ok(())
}
pub(super) fn run(request: ReplacementRequest) -> ExitCode {
match run_inner(request) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("portable update replacement failed: {error}");
ExitCode::FAILURE
}
}
}
fn run_inner(request: ReplacementRequest) -> io::Result<()> {
wait_for_process(request.pid, PROCESS_WAIT_TIMEOUT, &request.handshake)?;
remove_file_if_exists(&request.handshake)?;
let source = std::env::current_exe()?;
let backup = backup_path(&request.target);
let ready = source.with_extension("ready");
remove_file_if_exists(&ready)?;
if let Err(error) = install_staged(&source, &request.target, &backup) {
relaunch(&request.target);
return Err(error);
}
let mut child = match Command::new(&request.target)
.arg(READY_ARG)
.arg(&ready)
.spawn()
{
Ok(child) => child,
Err(error) => {
restore_backup(&request.target, &backup)?;
relaunch(&request.target);
return Err(error);
}
};
match wait_until_ready(&mut child, &ready, READY_WAIT_TIMEOUT) {
Ok(()) => {
let _ = fs::remove_file(&backup);
let _ = fs::remove_file(&ready);
Ok(())
}
Err(error) => {
let _ = child.kill();
let _ = child.wait();
restore_backup(&request.target, &backup)?;
relaunch(&request.target);
Err(error)
}
}
}
fn relaunch(target: &Path) {
if target.is_file() {
let _ = Command::new(target).spawn();
}
}
fn remove_file_if_exists(path: &Path) -> io::Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
fn backup_path(target: &Path) -> PathBuf {
path_with_suffix(target, ".old")
}
fn pending_path(target: &Path) -> PathBuf {
path_with_suffix(target, ".new")
}
fn path_with_suffix(target: &Path, suffix: &str) -> PathBuf {
let mut name = target.as_os_str().to_os_string();
name.push(suffix);
PathBuf::from(name)
}
fn install_staged(source: &Path, target: &Path, backup: &Path) -> io::Result<()> {
let pending = pending_path(target);
remove_file_if_exists(&pending)?;
fs::copy(source, &pending)?;
let result = activate_pending(&pending, target, backup);
if result.is_err() {
let _ = fs::remove_file(&pending);
}
result
}
fn activate_pending(pending: &Path, target: &Path, backup: &Path) -> io::Result<()> {
remove_file_if_exists(backup)?;
fs::rename(target, backup)?;
if let Err(error) = fs::rename(pending, target) {
if let Err(restore_error) = restore_backup(target, backup) {
return Err(io::Error::other(format!(
"failed to install update ({error}) and restore the original executable ({restore_error})"
)));
}
return Err(error);
}
Ok(())
}
fn restore_backup(target: &Path, backup: &Path) -> io::Result<()> {
remove_file_if_exists(target)?;
fs::rename(backup, target)
}
fn wait_until_ready(child: &mut Child, marker: &Path, timeout: Duration) -> io::Result<()> {
let deadline = Instant::now() + timeout;
loop {
if marker.is_file() {
return Ok(());
}
if let Some(status) = child.try_wait()? {
return Err(io::Error::other(format!(
"updated application exited before startup completed: {status}"
)));
}
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"updated application did not report a successful startup",
));
}
thread::sleep(Duration::from_millis(100));
}
}
#[cfg(windows)]
fn wait_for_process(pid: u32, timeout: Duration, handshake: &Path) -> io::Result<()> {
use windows_sys::Win32::{
Foundation::{
CloseHandle, GetLastError, ERROR_INVALID_PARAMETER, WAIT_OBJECT_0, WAIT_TIMEOUT,
},
System::Threading::{OpenProcess, WaitForSingleObject},
};
const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000;
let handle = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, pid) };
if handle.is_null() {
let error = unsafe { GetLastError() };
return if error == ERROR_INVALID_PARAMETER {
fs::write(handshake, b"started")
} else {
Err(io::Error::from_raw_os_error(error as i32))
};
}
if let Err(error) = fs::write(handshake, b"started") {
unsafe { CloseHandle(handle) };
return Err(error);
}
let milliseconds = timeout.as_millis().min(u32::MAX as u128) as u32;
let result = unsafe { WaitForSingleObject(handle, milliseconds) };
unsafe { CloseHandle(handle) };
match result {
WAIT_OBJECT_0 => Ok(()),
WAIT_TIMEOUT => Err(io::Error::new(
io::ErrorKind::TimedOut,
"running application did not exit before the update timeout",
)),
_ => Err(io::Error::last_os_error()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn staged_file_can_be_restored() {
let directory = tempfile::tempdir().unwrap();
let source = directory.path().join("source.exe");
let target = directory.path().join("target.exe");
let backup = backup_path(&target);
fs::write(&source, b"new").unwrap();
fs::write(&target, b"old").unwrap();
install_staged(&source, &target, &backup).unwrap();
assert_eq!(fs::read(&target).unwrap(), b"new");
assert_eq!(fs::read(&backup).unwrap(), b"old");
restore_backup(&target, &backup).unwrap();
assert_eq!(fs::read(&target).unwrap(), b"old");
assert!(!backup.exists());
}
#[test]
fn missing_pending_file_restores_original_after_backup() {
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join("target.exe");
let pending = pending_path(&target);
let backup = backup_path(&target);
fs::write(&target, b"old").unwrap();
assert!(activate_pending(&pending, &target, &backup).is_err());
assert_eq!(fs::read(&target).unwrap(), b"old");
assert!(!backup.exists());
}
#[test]
fn missing_source_preserves_original_file() {
let directory = tempfile::tempdir().unwrap();
let source = directory.path().join("missing.exe");
let target = directory.path().join("target.exe");
let backup = backup_path(&target);
fs::write(&target, b"old").unwrap();
assert!(install_staged(&source, &target, &backup).is_err());
assert_eq!(fs::read(&target).unwrap(), b"old");
assert!(!backup.exists());
assert!(!pending_path(&target).exists());
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "0.1.5",
"version": "0.1.6",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
@@ -12,7 +12,7 @@
"app": {
"windows": [],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data: https:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
"dangerousDisableAssetCspModification": [
"style-src"
]
+33 -6
View File
@@ -13,6 +13,8 @@ import { SettingsPage } from "./features/settings/SettingsPage";
import { useAppStore } from "./shared/store/appStore";
import { updateStore } from "./shared/store/updateStore";
const AUTO_UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000;
export function App() {
return (
<>
@@ -39,6 +41,7 @@ export function App() {
function AppMessages() {
const { error } = useAppStore();
const previousError = useRef<string | null>(null);
const lastAutomaticUpdateCheckAt = useRef(0);
const showMessage = useMessage();
useEffect(() => {
@@ -47,12 +50,36 @@ function AppMessages() {
}, [error, showMessage]);
useEffect(() => {
void updateStore.check().then((version) => {
if (!version) return;
showMessage(t("发现新版本 {version},可在设置中安装", { version }), { duration: 6_000 });
}).catch(() => {
// Startup checks are best-effort; manual checks in Settings report errors.
});
let disposed = false;
const checkAutomatically = () => {
const now = Date.now();
if (now - lastAutomaticUpdateCheckAt.current < AUTO_UPDATE_CHECK_INTERVAL_MS) return;
lastAutomaticUpdateCheckAt.current = now;
const previousVersion = updateStore.getSnapshot().availableVersion;
void updateStore.check().then((version) => {
if (disposed || !version || version === previousVersion) return;
showMessage(t("发现新版本 {version},可在设置中安装", { version }), { duration: 6_000 });
}).catch(() => {
if (!disposed) lastAutomaticUpdateCheckAt.current = 0;
// Automatic checks are best-effort; manual checks in Settings report errors.
});
};
const checkWhenVisible = () => {
if (document.visibilityState === "visible") checkAutomatically();
};
checkAutomatically();
window.addEventListener("focus", checkAutomatically);
window.addEventListener("online", checkAutomatically);
document.addEventListener("visibilitychange", checkWhenVisible);
const timer = window.setInterval(checkAutomatically, AUTO_UPDATE_CHECK_INTERVAL_MS);
return () => {
disposed = true;
window.removeEventListener("focus", checkAutomatically);
window.removeEventListener("online", checkAutomatically);
document.removeEventListener("visibilitychange", checkWhenVisible);
window.clearInterval(timer);
};
}, [showMessage]);
return <MessageProvider />;
+14 -3
View File
@@ -73,12 +73,13 @@ const calls: LlmCall[] = Array.from({ length: 24 }, (_, index) => {
};
});
const harnessStatus: CursorHarnessStatus = {
let harnessStatus: CursorHarnessStatus = {
platform: "macos",
ca: "ready",
configured_models: models.length,
enabled_models: models.length,
integration: "enabled",
settings_applied: true,
proxy_url: "http://127.0.0.1:54321",
ca_install_command: null,
};
@@ -86,7 +87,7 @@ const harnessStatus: CursorHarnessStatus = {
let detailed = true;
let portSettings = { proxy_port: 0, service_port: 0 };
let proxySettings: ProxySettings = {
mode: "system",
mode: "default",
address: "",
auth_enabled: false,
username: "",
@@ -126,7 +127,17 @@ export function installDemoApi() {
if (path === "/llm-calls") return json(calls);
if (path.startsWith("/llm-calls/")) return json(createCallDetail(path.slice("/llm-calls/".length)));
if (path === "/harness/cursor/status") return json(harnessStatus);
if (path === "/harness/cursor/ca/initialize" || path === "/harness/cursor/enabled") return json(harnessStatus);
if (path === "/harness/cursor/ca/initialize") return json(harnessStatus);
if (path === "/harness/cursor/enabled") {
const enabled = Boolean((body as { enabled?: unknown } | null)?.enabled);
harnessStatus = {
...harnessStatus,
integration: enabled ? "enabled" : "disabled",
settings_applied: enabled,
proxy_url: enabled ? "http://127.0.0.1:54321" : null,
};
return json(harnessStatus);
}
if (path === "/settings/observability" && method === "GET") return json({ detailed });
if (path === "/settings/observability") {
detailed = Boolean((body as { detailed?: unknown } | null)?.detailed);
+43 -10
View File
@@ -1,15 +1,17 @@
import { useEffect, useState } from "react";
import { api, configuredPluginModels, type Overview } from "../../shared/api";
import { api, pluginText, type Overview } from "../../shared/api";
import { ContributionCalendarChart } from "./charts/ContributionCalendarChart";
import { DailyTokenUsageChart } from "./charts/DailyTokenUsageChart";
import { HomeMetrics } from "./metrics/HomeMetrics";
import { PageContent } from "../../shell/layout/PageContent";
import type { VirtualPageSection } from "../../shell/layout/VirtualPage";
import { OverviewTimeRangeFilter, type OverviewRangePreset } from "./overview/OverviewTimeRangeFilter";
import { OverviewTimeRangeFilter, type OverviewRangePreset, type QuickPreset } from "./overview/OverviewTimeRangeFilter";
import { PageActions } from "../../shell/PageActions";
import { appStore, useAppStore } from "../../shared/store/appStore";
import { formatTimeInput, parseTimeInput } from "../../shared/utils/parseTimeInput";
import { modelProviderName } from "../../shared/utils/modelProvider";
import { claudeIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
import { useI18n } from "../../i18n/store";
type TimeRange = { startMs: number; endMs: number };
@@ -25,13 +27,18 @@ function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new D
start.setMonth(start.getMonth() - 1);
return { startMs: start.getTime(), endMs };
}
const duration = preset === "ten-minutes" ? 10 * 60_000 : preset === "hour" ? 60 * 60_000 : 7 * 24 * 60 * 60_000;
const duration = preset === "ten-minutes" ? 10 * 60_000
: preset === "hour" ? 60 * 60_000
: 7 * 24 * 60 * 60_000;
return { startMs: now.getTime() - duration, endMs };
}
export function HomePage() {
const { overview, busy, models, plugins } = useAppStore();
const { locale } = useI18n();
const [preset, setPreset] = useState<OverviewRangePreset>("month");
const [quick, setQuick] = useState<QuickPreset | null>(null);
const [fourHourBucket, setFourHourBucket] = useState<number | undefined>(undefined);
const [customRange, setCustomRange] = useState<TimeRange | null>(null);
const [customOpen, setCustomOpen] = useState(false);
const [customStart, setCustomStart] = useState("");
@@ -50,13 +57,14 @@ export function HomePage() {
void api.overview({
...selectedRange,
modelHashes: appliedModels,
bucketMs: fourHourBucket,
}).then((next) => {
if (active) setRangeOverview(next);
}).finally(() => {
if (active) setRangeBusy(false);
});
return () => { active = false; };
}, [preset, customRange, overview, refreshVersion, appliedModels]);
}, [preset, customRange, overview, refreshVersion, appliedModels, fourHourBucket]);
const filteredOverview = rangeOverview ?? overview;
const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({
@@ -93,9 +101,26 @@ export function HomePage() {
if (startMs === null || endMs === null || startMs >= endMs) return;
setCustomRange({ startMs, endMs });
setAppliedModels(selectedModels);
setQuick(null);
setFourHourBucket(undefined);
setPreset("custom");
setCustomOpen(false);
};
const selectQuick = (durationMs: number, bucketMs?: number) => {
const endMs = Date.now();
setCustomRange({ startMs: endMs - durationMs, endMs });
setAppliedModels(selectedModels);
setQuick(durationMs === 4 * 60 * 60_000 ? "four-hours" : "twenty-four-hours");
setFourHourBucket(durationMs === 4 * 60 * 60_000 ? bucketMs : undefined);
setPreset("custom");
setCustomOpen(false);
};
const selectPreset = (value: Exclude<OverviewRangePreset, "custom">) => {
setPreset(value);
setQuick(null);
setFourHourBucket(undefined);
setCustomOpen(false);
};
const refresh = async () => {
await appStore.refresh();
setRefreshVersion((version) => version + 1);
@@ -105,13 +130,18 @@ export function HomePage() {
...models.map((model) => ({
value: model.model_hash,
label: model.display_name,
group: modelProviderName(model),
icon: iconFor(model.type),
})),
...configuredPluginModels(plugins).map((model) => ({
value: model.id,
label: model.displayName,
icon: flatColorOrganizationIcon,
})),
...plugins.flatMap((plugin) => plugin.providers.flatMap((provider) =>
provider.configured ? provider.models.map((model) => ({
value: model.id,
label: model.displayName,
group: pluginText(provider.displayName, locale) || model.pluginName,
iconSrc: model.icon || undefined,
icon: model.icon ? undefined : flatColorOrganizationIcon,
})) : [],
)),
];
const sections: VirtualPageSection[] = [
{
@@ -138,13 +168,16 @@ export function HomePage() {
return <>
<PageActions><OverviewTimeRangeFilter
value={preset}
quick={quick}
fourHourBucket={fourHourBucket}
customOpen={customOpen}
customStart={customStart}
customEnd={customEnd}
modelOptions={modelOptions}
selectedModels={selectedModels}
busy={busy || rangeBusy}
onSelect={(value) => { setPreset(value); setCustomOpen(false); }}
onSelect={selectPreset}
onQuickSelect={selectQuick}
onCustomOpenChange={openCustom}
onCustomStartChange={setCustomStart}
onCustomEndChange={setCustomEnd}
@@ -76,3 +76,50 @@
justify-content: flex-end;
gap: 6px;
}
.quickPresets {
display: flex;
gap: 6px;
button {
height: 26px;
padding: 0 10px;
color: var(--vscode-descriptionForeground);
background: var(--vscode-button-secondaryBackground, var(--vscode-sideBar-background));
border: 1px solid var(--vscode-sideBar-border);
border-radius: 4px;
font-size: type.$font-size-xs;
white-space: nowrap;
&:hover { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); border-color: transparent; }
}
}
.granularityMenu {
position: fixed;
z-index: 15000;
min-width: 96px;
padding: 4px;
background: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border);
border-radius: 6px;
box-shadow: var(--oa-dropdown-shadow);
button {
width: 100%;
min-height: 28px;
display: block;
padding: 4px 8px;
color: var(--vscode-dropdown-foreground);
text-align: left;
background: transparent;
border: 0;
border-radius: 4px;
font-size: type.$font-size-xs;
white-space: nowrap;
&:hover { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); }
&[aria-pressed="true"] { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); }
}
}
@@ -4,22 +4,26 @@ import { createPortal } from "react-dom";
import { parseTimeInput } from "../../../shared/utils/parseTimeInput";
import controls from "../../../shared/ui/Controls.module.scss";
import { Icon } from "../../../shared/ui/Icon";
import { MultiSelect, type MultiSelectOption } from "../../../shared/ui/MultiSelect";
import { ModelSelect, type ModelSelectOption } from "../../../shared/ui/ModelSelect";
import { TooltipTrigger } from "../../../shared/ui/TooltipTrigger";
import { refreshIcon } from "../../../shared/ui/icons";
import styles from "./OverviewTimeRangeFilter.module.scss";
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
export type QuickPreset = "four-hours" | "twenty-four-hours";
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
export function OverviewTimeRangeFilter({ value, quick, fourHourBucket, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onQuickSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
value: OverviewRangePreset;
quick: QuickPreset | null;
fourHourBucket: number | undefined;
customOpen: boolean;
customStart: string;
customEnd: string;
modelOptions: MultiSelectOption[];
modelOptions: ModelSelectOption[];
selectedModels: string[];
busy: boolean;
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
onQuickSelect: (durationMs: number, bucketMs?: number) => void;
onCustomOpenChange: (open: boolean) => void;
onCustomStartChange: (value: string) => void;
onCustomEndChange: (value: string) => void;
@@ -34,10 +38,25 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
{ value: "week", label: t("近一周") },
{ value: "month", label: t("近一个月") },
];
const quickPresets: Array<{ value: QuickPreset; label: string }> = [
{ value: "four-hours", label: t("近4小时") },
{ value: "twenty-four-hours", label: t("近24小时") },
];
const granularityOptions = [
{ bucketMs: 60_000, label: t("1分钟") },
{ bucketMs: 15 * 60_000, label: t("15分钟") },
{ bucketMs: 30 * 60_000, label: t("30分钟") },
{ bucketMs: 60 * 60_000, label: t("1小时") },
];
const customButton = useRef<HTMLButtonElement>(null);
const popover = useRef<HTMLDivElement>(null);
const fourHourButton = useRef<HTMLButtonElement>(null);
const granularityMenu = useRef<HTMLDivElement>(null);
const popoverId = useId();
const granularityMenuId = useId();
const [position, setPosition] = useState({ left: 0, top: 0, width: 300, maxHeight: 480 });
const [menuPosition, setMenuPosition] = useState({ left: 0, top: 0 });
const [granularityOpen, setGranularityOpen] = useState(false);
useLayoutEffect(() => {
if (!customOpen || !customButton.current || !popover.current) return;
@@ -53,11 +72,28 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
}, [customOpen]);
useLayoutEffect(() => {
if (!granularityOpen || !fourHourButton.current || !granularityMenu.current) return;
return autoUpdate(fourHourButton.current, granularityMenu.current, () =>
void computePosition(fourHourButton.current!, granularityMenu.current!, {
placement: "right-start",
middleware: [offset(4), flip({ padding: 10 }), shift({ padding: 10 })],
}).then(({ x, y }) => setMenuPosition({ left: x, top: y })));
}, [granularityOpen]);
useEffect(() => {
if (!customOpen) setGranularityOpen(false);
}, [customOpen]);
useEffect(() => {
if (!customOpen) return;
const closeOutside = (event: PointerEvent) => {
const target = event.target as Node;
if (!customButton.current?.contains(target) && !popover.current?.contains(target)) onCustomOpenChange(false);
if (
!customButton.current?.contains(target)
&& !popover.current?.contains(target)
&& !granularityMenu.current?.contains(target)
) onCustomOpenChange(false);
};
document.addEventListener("pointerdown", closeOutside);
return () => document.removeEventListener("pointerdown", closeOutside);
@@ -66,6 +102,10 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
const parsedStart = parseTimeInput(customStart);
const parsedEnd = parseTimeInput(customEnd);
const customValid = parsedStart !== null && parsedEnd !== null && parsedStart < parsedEnd;
const closeGranularity = () => {
setGranularityOpen(false);
fourHourButton.current?.focus();
};
return <div className={styles.root} aria-label={t("概览时间范围")}>
<div className={styles.presets}>
@@ -98,18 +138,70 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
if (granularityOpen) {
closeGranularity();
return;
}
onCustomOpenChange(false);
customButton.current?.focus();
}
}}
>
<div className={styles.quickPresets} aria-label={t("快捷时间范围")}>
{quickPresets.map((preset) => preset.value === "four-hours"
? <button
key={preset.value}
ref={fourHourButton}
type="button"
aria-haspopup="menu"
aria-controls={granularityOpen ? granularityMenuId : undefined}
aria-expanded={granularityOpen}
aria-pressed={quick === "four-hours"}
onClick={() => setGranularityOpen((current) => !current)}
>{preset.label}</button>
: <button
key={preset.value}
type="button"
aria-pressed={quick === "twenty-four-hours"}
onClick={() => {
setGranularityOpen(false);
onQuickSelect(24 * 60 * 60_000);
}}
>{preset.label}</button>)}
</div>
<label><span>{t("开始时间")}</span><input type="text" placeholder={t("如:2026-08-23 09:00、1小时前")} value={customStart} onChange={(event) => onCustomStartChange(event.target.value)} /></label>
<label><span>{t("结束时间")}</span><input type="text" placeholder={t("如:现在、2026-08-23 18:00")} value={customEnd} onChange={(event) => onCustomEndChange(event.target.value)} /></label>
<div className={styles.filterRow}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
<div className={styles.filterRow}><ModelSelect mode="multiple" label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
<div className={styles.popoverActions}>
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
</div>
</div>, document.body)}
{granularityOpen && customOpen && createPortal(<div
id={granularityMenuId}
ref={granularityMenu}
className={styles.granularityMenu}
role="menu"
aria-label={t("显示粒度")}
style={menuPosition}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
event.preventDefault();
closeGranularity();
}
}}
>
{granularityOptions.map((option) => <button
key={option.bucketMs}
type="button"
role="menuitem"
aria-pressed={quick === "four-hours" && fourHourBucket === option.bucketMs}
onClick={() => {
setGranularityOpen(false);
onQuickSelect(4 * 60 * 60_000, option.bucketMs);
}}
>{option.label}</button>)}
</div>, document.body)}
</div>;
}
@@ -4,7 +4,7 @@ import Sortable from "sortablejs";
import type { Model, PluginModelDescriptor } from "../../shared/api";
import { Card } from "../../shared/ui/Card";
import { Icon } from "../../shared/ui/Icon";
import { chevronDownIcon, chevronRightIcon, claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
import { chevronDownIcon, chevronRightIcon, claudeIcon, dragIcon, editIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
import { TruncatedButton } from "../../shared/ui/TruncatedButton";
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
import styles from "./CursorSettings.module.scss";
@@ -61,6 +61,7 @@ export function CursorModelCards(props: CursorModelCardsProps) {
key={group.key}
label={group.label}
icon={group.icon}
defaultOpen={false}
onSettings={props.grouping === "provider" ? () => props.onGroupSettings(group) : undefined}
>
{group.models.map((model) => <ModelListRow
@@ -79,9 +80,10 @@ export function CursorModelCards(props: CursorModelCardsProps) {
return <div className={styles.modelGroups}>
{builtins}
{pluginGroups(props.pluginModels).map((group) => <CollapsibleGroup
key={group.pluginId}
key={`${props.grouping}:${group.pluginId}`}
label={group.pluginName}
iconSrc={group.icon}
defaultOpen={props.grouping === "flat"}
>
{group.models.map((model) => <PluginModelRow
key={model.id}
@@ -109,14 +111,15 @@ function pluginGroups(models: PluginModelDescriptor[]) {
return groups;
}
function CollapsibleGroup({ label, icon, iconSrc, onSettings, children }: {
function CollapsibleGroup({ label, icon, iconSrc, defaultOpen = true, onSettings, children }: {
label: string;
icon?: IconifyIcon;
iconSrc?: string;
defaultOpen?: boolean;
onSettings?: () => void;
children: ReactNode;
}) {
const [open, setOpen] = useState(true);
const [open, setOpen] = useState(defaultOpen);
return <Card className={styles.groupCard}>
<div className={styles.groupHeader}>
<button
@@ -129,7 +132,10 @@ function CollapsibleGroup({ label, icon, iconSrc, onSettings, children }: {
{iconSrc && <Icon src={iconSrc} size="1.1em" />}
<span className={styles.groupLabel}>{label}</span>
</button>
{onSettings && <button type="button" className={styles.groupSettings} onClick={onSettings}>{t("分组设置")}</button>}
{onSettings && <button type="button" className={styles.groupSettings} onClick={onSettings}>
<Icon icon={editIcon} size="1em" />
{t("分组设置")}
</button>}
<button
type="button"
className={styles.groupChevron}
@@ -1,3 +1,4 @@
import { useRef } from "react";
import type { ModelInput, ModelType } from "../../shared/api";
import { defaultCustomHeadersText } from "../../shared/utils/modelDefaults";
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../shared/utils/modelPresets";
@@ -5,7 +6,7 @@ import { Button } from "../../shared/ui/Button";
import { Checkbox } from "../../shared/ui/Checkbox";
import { FormField, SecretTextInput, TextInput } from "../../shared/ui/FormControls";
import { JsonEditor } from "../../shared/ui/JsonEditor";
import { Combobox, Select } from "../../shared/ui/Select";
import { Combobox, Select, type ComboboxHandle } from "../../shared/ui/Select";
import { Switch } from "../../shared/ui/Switch";
import { claudeIcon, openAiIcon } from "../../shared/ui/icons";
import { CursorPresetChips } from "./CursorPresetChips";
@@ -55,8 +56,9 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
modelOptions: string[];
discovering: boolean;
onChange: (draft: CursorModelDraft) => void;
onDiscover: () => void;
onDiscover: () => Promise<boolean>;
}) {
const modelCombobox = useRef<ComboboxHandle>(null);
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
const setType = (type: ModelType) => {
// 切换协议类型时,若当前地址命中某预设的另一协议端点,自动换到该预设对应协议的端点,
@@ -89,6 +91,9 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
.filter((preset) => trimTrailingSlash(presetEndpoint(preset, draft.model.type).baseUrl) === trimTrailingSlash(draft.model.base_url.trim()))
.flatMap((preset) => preset.models.map((item) => item.model_id));
const combinedOptions = [...new Set([...modelOptions, ...presetModelOptions])];
const discoverModels = async () => {
if (await onDiscover()) modelCombobox.current?.openAll();
};
const applyPreset = (preset: ModelPreset) => {
const endpoint = presetEndpoint(preset, draft.model.type);
const first = preset.models[0];
@@ -151,7 +156,7 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
</div>
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}> <SecretTextInput placeholder="sk-xxxxxx" autoComplete="off" value={draft.model.api_key} onChange={(event) => setModel({ api_key: event.target.value })} /></FormField>
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={combinedOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}><Combobox ref={modelCombobox} value={draft.model.model_id} options={combinedOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={() => void discoverModels()}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput placeholder={t("例如:主力模型")} value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput placeholder={t("请输入模型备注")} value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
@@ -5,6 +5,20 @@
gap: 16px;
}
.takeoverActions {
display: flex;
align-items: center;
gap: 7px;
}
.takeoverStatus {
min-width: 39px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
text-align: right;
white-space: nowrap;
}
.groupActions {
display: flex;
align-items: center;
@@ -79,6 +93,9 @@
gap: 8px;
}
.groupSettings {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 2px;
color: var(--vscode-descriptionForeground);
background: none;
@@ -13,6 +13,7 @@ import { FormField, SecretTextInput, TextInput } from "../../shared/ui/FormContr
import controls from "../../shared/ui/Controls.module.scss";
import { Icon } from "../../shared/ui/Icon";
import { Modal } from "../../shared/ui/Modal";
import { Switch } from "../../shared/ui/Switch";
import { TooltipTrigger } from "../../shared/ui/TooltipTrigger";
import { addIcon } from "../../shared/ui/icons";
import { useMessage } from "../../shared/ui/message";
@@ -30,6 +31,7 @@ export function CursorSettingsPage() {
const [caCommand, setCaCommand] = useState<string | null>(null);
const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false);
const [deleting, setDeleting] = useState<Model | null>(null);
const [confirmDisableTakeover, setConfirmDisableTakeover] = useState(false);
const [testingModelHashes, setTestingModelHashes] = useState<Set<string>>(() => new Set());
const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map());
const [savingAndTesting, setSavingAndTesting] = useState(false);
@@ -42,6 +44,8 @@ export function CursorSettingsPage() {
const [groupSettingsBusy, setGroupSettingsBusy] = useState(false);
const activeModelTests = useRef(new Map<string, { testId: string; controller: AbortController; cancelling: boolean }>());
const caReady = cursorHarness?.ca === "ready";
const cursorTakenOver = cursorHarness?.settings_applied ?? false;
const takeoverLabel = cursorTakenOver ? t("关闭接管Cursor") : t("开启接管Cursor");
const pluginModels = configuredPluginModels(plugins);
const testTargets = [
...models.map((model) => ({ model_hash: model.model_hash, display_name: model.display_name })),
@@ -84,8 +88,8 @@ export function CursorSettingsPage() {
anthropicExtraParamsText: JSON.stringify(model.anthropic_extra_params, null, 2),
});
};
const discover = async () => {
if (!draft) return;
const discover = async (): Promise<boolean> => {
if (!draft) return false;
setDiscovering(true);
try {
const custom_headers = parseHeaders(draft.customHeadersText);
@@ -97,8 +101,10 @@ export function CursorSettingsPage() {
custom_headers,
});
setModelOptions([...new Set(result.models)]);
return true;
} catch (cause) {
message(errorText(cause));
return false;
} finally {
setDiscovering(false);
}
@@ -123,11 +129,10 @@ export function CursorSettingsPage() {
const active = activeModelTests.current.get(modelHash);
if (!active || active.cancelling) return;
active.cancelling = true;
active.controller.abort();
try {
await api.cancelModelTest(modelHash, active.testId);
active.controller.abort();
} catch (cause) {
active.cancelling = false;
message(t("取消测试失败:{error}", { error: errorText(cause) }), { duration: 5000 });
}
};
@@ -296,19 +301,46 @@ export function CursorSettingsPage() {
: Math.max(380, activeGroups.reduce((height, group) => height + 60 + group.models.length * 56, 0) + Math.max(0, activeGroups.length - 1) * 20 + pluginSectionHeight);
return <>
{testTargets.length > 0 && <PageActions position="left">
<div className={styles.groupActions} role="group" aria-label={t("操作")}>
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
{canGroupByType && <button type="button" aria-pressed={grouping === "type"} onClick={() => setGrouping("type")}>{t("按类型")}</button>}
<button type="button" disabled={cursorBusy || (!batchTesting && testingModelHashes.size > 0)} onClick={() => void (batchTesting ? cancelAllModelTests() : testAllModels())}>{batchTesting ? t("取消全部测试") : t("一键测试")}</button>
<PageActions position="left">
<div className={styles.takeoverActions}>
<span className={styles.takeoverStatus}>{cursorTakenOver ? t("已接管") : t("未接管")}</span>
<TooltipTrigger label={takeoverLabel}>
<Switch
checked={cursorTakenOver}
disabled={cursorBusy || (!cursorTakenOver && !caReady)}
label={takeoverLabel}
onChange={(enabled) => {
if (enabled) void appStore.setCursorEnabled(true);
else setConfirmDisableTakeover(true);
}}
/>
</TooltipTrigger>
{testTargets.length > 0 && <div className={styles.groupActions} role="group" aria-label={t("操作")}>
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
{canGroupByType && <button type="button" aria-pressed={grouping === "type"} onClick={() => setGrouping("type")}>{t("按类型")}</button>}
<button type="button" disabled={cursorBusy || (!batchTesting && testingModelHashes.size > 0)} onClick={() => void (batchTesting ? cancelAllModelTests() : testAllModels())}>{batchTesting ? t("取消全部测试") : t("一键测试")}</button>
</div>}
</div>
</PageActions>}
</PageActions>
<PageActions><TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
<PageContent title="Cursor" sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
<ConfirmDialog
open={confirmDisableTakeover}
title={t("关闭接管Cursor?")}
cancelLabel={t("取消")}
confirmLabel={t("关闭接管")}
onCancel={() => setConfirmDisableTakeover(false)}
onConfirm={() => {
setConfirmDisableTakeover(false);
void appStore.setCursorEnabled(false);
}}
>
<p>{t("关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?")}</p>
</ConfirmDialog>
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} submitLabel={t("保存")} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
{draft && <>
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={discover} />
</>}
</Modal>
<ConfirmDialog open={caCommand !== null} title={t("安装本地 CA")} cancelLabel={t("关闭")} confirmLabel={t("打开终端")} onCancel={() => setCaCommand(null)} onConfirm={openCaTerminal}>
@@ -99,13 +99,11 @@
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 6px;
gap: 7px;
> button {
min-width: 0;
flex: 0 1 auto;
padding-left: 8px;
padding-right: 8px;
}
}
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { api, getDisabledPluginModelIds, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api";
import { api, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api";
import { useI18n } from "../../i18n/store";
import { PageContent } from "../../shell/layout/PageContent";
import { appStore, useAppStore } from "../../shared/store/appStore";
@@ -138,17 +138,7 @@ function PluginCard({ plugin, onOpen }: {
const [importing, setImporting] = useState(false);
const configured = plugin.providers.some((provider) => provider.configured);
const accountCount = plugin.resources.reduce((count, resource) => count + resource.resources.length, 0);
const [disabledModelIds, setDisabledModelIds] = useState<Set<string>>(() => getDisabledPluginModelIds());
useEffect(() => {
const handleUpdate = () => setDisabledModelIds(getDisabledPluginModelIds());
window.addEventListener("cursor_plugin_models_changed", handleUpdate);
return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate);
}, []);
const modelCount = plugin.providers.reduce(
(count, provider) => count + provider.models.filter((m) => !disabledModelIds.has(m.id)).length,
0,
);
const modelCount = plugin.providers.reduce((count, provider) => count + provider.models.length, 0);
const subtitle = plugin.providers.map((provider) => pluginText(provider.displayName, locale)).join(" · ") || plugin.id;
const importResource = plugin.resources.find((resource) => resource.import);
const exportResource = plugin.resources.find((resource) => resource.resources.length > 0);
@@ -12,7 +12,7 @@
gap: 10px;
padding: 16px;
>span {
> span {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
@@ -37,10 +37,6 @@
}
button {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 6px 10px;
color: var(--vscode-foreground);
background: var(--vscode-textCodeBlock-background);
@@ -101,21 +97,15 @@
gap: 8px;
}
.providerCard {
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.providerRow {
.providerRow,
.resourceRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
>div:first-child {
> div:first-child {
min-width: 0;
display: flex;
flex-direction: column;
@@ -128,576 +118,6 @@
}
}
.resourceRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
.resourceText {
display: flex;
flex-direction: column;
gap: 4px;
.resourceHeaderLine {
display: flex;
align-items: center;
gap: 8px;
}
.resourceDescription {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
}
.metricsRow {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.metricBadge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 7px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
transition: all 0.15s ease;
.metricPrefix {
font-weight: 700;
opacity: 0.9;
}
.metricValue {
font-variant-numeric: tabular-nums;
}
}
.claudeMetricBadge {
background: rgba(234, 88, 12, 0.14);
color: #fb923c;
border: 1px solid rgba(234, 88, 12, 0.28);
}
.geminiMetricBadge {
background: rgba(59, 130, 246, 0.14);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.28);
}
.genericMetricBadge {
background: color-mix(in srgb, var(--vscode-badge-background, #333) 40%, transparent);
color: var(--vscode-badge-foreground, #ccc);
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border, #444) 40%, transparent);
}
.proBadge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
border-radius: 4px;
background: linear-gradient(135deg, rgba(234, 88, 12, 0.25), rgba(249, 115, 22, 0.15));
color: #fb923c;
border: 1px solid rgba(249, 115, 22, 0.4);
box-shadow: 0 0 6px rgba(249, 115, 22, 0.15);
}
.freeBadge {
display: inline-flex;
align-items: center;
padding: 1px 5px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
border-radius: 4px;
background: color-mix(in srgb, var(--vscode-badge-background, #333) 30%, transparent);
color: var(--vscode-descriptionForeground, #888);
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border, #444) 40%, transparent);
}
.providerActions {
display: flex;
align-items: center;
gap: 8px;
}
.modelsView {
display: flex;
flex-direction: column;
gap: 12px;
}
.modelsViewHeader {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 2px;
.backButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: var(--vscode-descriptionForeground);
background: transparent;
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 60%, transparent);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--vscode-foreground);
background: color-mix(in srgb, var(--vscode-foreground) 8%, transparent);
border-color: var(--vscode-focusBorder, #007fd4);
}
}
.headerTitle {
display: flex;
align-items: center;
gap: 10px;
strong {
color: var(--vscode-foreground);
font-size: type.$font-size-base;
font-weight: 600;
}
span {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
.headerCountChip {
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--vscode-focusBorder, #007fd4);
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 30%, transparent);
}
}
}
.modelListToolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.searchContainer {
position: relative;
flex: 1 1 auto;
min-width: 0;
.searchIcon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--vscode-descriptionForeground);
pointer-events: none;
}
.clearSearchBtn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--vscode-descriptionForeground);
background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent);
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
color: var(--vscode-foreground);
background: color-mix(in srgb, var(--vscode-foreground) 20%, transparent);
}
}
}
.modelSearchInput {
width: 100%;
padding: 7px 30px 7px 32px;
font-size: type.$font-size-xs;
color: var(--vscode-foreground);
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border));
border-radius: 6px;
outline: none;
transition: all 0.2s ease;
box-sizing: border-box;
&::placeholder {
color: color-mix(in srgb, var(--vscode-descriptionForeground) 70%, transparent);
}
&:focus {
border-color: var(--vscode-focusBorder, #007fd4);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 20%, transparent);
}
}
.modelBatchActions {
display: flex;
align-items: center;
gap: 8px;
.pillButton {
padding: 5px 12px;
font-size: type.$font-size-xs;
font-weight: 500;
color: var(--vscode-foreground);
background: color-mix(in srgb, var(--vscode-button-secondaryBackground, #3a3d41) 60%, transparent);
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 80%, transparent);
border-radius: 999px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
color: var(--vscode-button-foreground, #fff);
background: var(--vscode-button-secondaryHoverBackground, #45494e);
border-color: color-mix(in srgb, var(--vscode-button-secondaryHoverBackground, #45494e) 50%, transparent);
}
}
}
.modelsViewList {
display: flex;
flex-direction: column;
gap: 8px;
}
.emptySearch {
padding: 32px 16px;
text-align: center;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
.modelItem {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 14px;
background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, var(--vscode-editor-background));
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 70%, transparent);
border-radius: 8px;
font-size: type.$font-size-xs;
cursor: pointer;
user-select: none;
position: relative;
transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 50%, transparent);
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.18);
background: color-mix(in srgb, var(--vscode-sideBar-background) 80%, var(--vscode-focusBorder, #007fd4) 6%);
}
}
.modelItemActive {
border-color: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 55%, transparent);
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 7%, var(--vscode-editor-background));
}
.modelItemDisabled {
opacity: 0.45;
background: color-mix(in srgb, var(--vscode-editor-background) 80%, transparent);
border-color: color-mix(in srgb, var(--vscode-sideBar-border) 40%, transparent);
&:hover {
opacity: 0.8;
transform: none;
}
}
.modelCheckbox {
flex: 0 0 auto;
width: 16px;
height: 16px;
margin: 0;
cursor: pointer;
accent-color: var(--vscode-focusBorder, #007fd4);
transition: transform 0.15s ease;
&:hover {
transform: scale(1.1);
}
}
.modelInfo {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.modelName {
font-weight: 500;
color: var(--vscode-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelId {
font-family: var(--vscode-editor-font-family);
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-2xs;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelBadges {
flex: 0 0 auto;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.claudeTag,
.geminiTag,
.gptTag,
.thinkingTag,
.highTag,
.mediumTag,
.lowTag,
.extraLowTag,
.imageTag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
line-height: 1.3;
}
.claudeTag {
color: #f59e0b;
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.35);
}
.geminiTag {
color: #60a5fa;
background: rgba(96, 165, 250, 0.12);
border: 1px solid rgba(96, 165, 250, 0.35);
}
.gptTag {
color: #34d399;
background: rgba(52, 211, 153, 0.12);
border: 1px solid rgba(52, 211, 153, 0.35);
}
.thinkingTag {
color: #c084fc;
background: rgba(192, 132, 252, 0.12);
border: 1px solid rgba(192, 132, 252, 0.35);
}
.highTag {
color: #f87171;
background: rgba(248, 113, 113, 0.12);
border: 1px solid rgba(248, 113, 113, 0.35);
}
.mediumTag {
color: #fbbf24;
background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.35);
}
.lowTag {
color: #2dd4bf;
background: rgba(45, 212, 191, 0.12);
border: 1px solid rgba(45, 212, 191, 0.35);
}
.extraLowTag {
color: #38bdf8;
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.35);
}
.imageTag {
color: #f472b6;
background: rgba(244, 114, 182, 0.12);
border: 1px solid rgba(244, 114, 182, 0.35);
}
.resourceIdentity {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.resourceText {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.resourceRowDisabled {
opacity: 0.6;
}
.resourceActions {
display: flex;
align-items: center;
gap: 8px;
}
.actionIconButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
color: var(--vscode-textLink-foreground, #3b82f6);
background: color-mix(in srgb, var(--vscode-textLink-foreground, #3b82f6) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--vscode-textLink-foreground, #3b82f6) 30%, transparent);
&:hover:not(:disabled) {
color: #fff;
background: var(--vscode-textLink-foreground, #3b82f6);
border-color: var(--vscode-textLink-foreground, #3b82f6);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.refreshingSpin {
animation: spin 0.8s linear infinite;
opacity: 0.7;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.actionDeleteButton {
color: #ef4444;
background: color-mix(in srgb, #ef4444 10%, transparent);
border: 1px solid color-mix(in srgb, #ef4444 30%, transparent);
&:hover:not(:disabled) {
color: #fff;
background: #ef4444;
border-color: #ef4444;
}
}
.readyBadge,
.coolingBadge,
.invalidBadge,
.disabledBadge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 999px;
font-size: type.$font-size-2xs;
font-weight: 500;
letter-spacing: 0.1px;
}
.badgeDot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.readyBadge {
color: var(--vscode-testing-iconPassed, #73c991);
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 30%, transparent);
.badgeDot {
background: var(--vscode-testing-iconPassed, #73c991);
box-shadow: 0 0 6px color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 60%, transparent);
}
}
.coolingBadge {
color: var(--vscode-editorWarning-foreground, #cca700);
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 30%, transparent);
.badgeDot {
background: var(--vscode-editorWarning-foreground, #cca700);
}
}
.invalidBadge {
color: var(--vscode-errorForeground, #f48771);
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--vscode-errorForeground, #f48771) 30%, transparent);
.badgeDot {
background: var(--vscode-errorForeground, #f48771);
}
}
.disabledBadge {
color: var(--vscode-descriptionForeground);
background: color-mix(in srgb, var(--vscode-descriptionForeground) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 80%, transparent);
.badgeDot {
background: var(--vscode-descriptionForeground);
}
}
.ready {
color: var(--vscode-testing-iconPassed, #73c991) !important;
}
@@ -733,4 +153,4 @@
.error {
color: var(--vscode-errorForeground, #f48771);
font-size: type.$font-size-xs;
}
}
@@ -1,11 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
api,
getDisabledPluginAccountIds,
getDisabledPluginModelIds,
setPluginAccountEnabled,
setPluginModelEnabled,
setMultiplePluginModelsEnabled,
pluginText,
type PluginAddMethod,
type PluginDescriptor,
@@ -19,10 +14,6 @@ import { appStore } from "../../shared/store/appStore";
import { Button } from "../../shared/ui/Button";
import { Card } from "../../shared/ui/Card";
import { FormField, TextInput } from "../../shared/ui/FormControls";
import { Icon } from "../../shared/ui/Icon";
import { refreshIcon, trashIcon } from "../../shared/ui/icons";
import { Switch } from "../../shared/ui/Switch";
import { TooltipTrigger } from "../../shared/ui/TooltipTrigger";
import styles from "./PluginResourcePanels.module.scss";
const PAGE_SIZE = 10;
@@ -118,7 +109,7 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
const next = await api.pluginOAuthBegin(pluginId, resourceType, method.id);
setBegun(next);
setStatus("polling");
await api.copyCursorText(next.userCode).catch(() => undefined);
if (next.userCode) await api.copyCursorText(next.userCode).catch(() => undefined);
await api.openExternalUrl(next.verificationUrlComplete || next.verificationUrl);
} catch (cause) {
setStatus("error");
@@ -126,23 +117,14 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
}
};
const userCode = begun?.userCode;
return <Card className={styles.methodCard}>
<strong>{pluginText(method.displayName, locale)}</strong>
{method.description && <span>{pluginText(method.description, locale)}</span>}
{begun && status === "polling" && <div className={styles.deviceCode}>
{userCode && status === "polling" && <div className={styles.deviceCode}>
<small>{t("设备验证码")}</small>
<button
type="button"
title={begun.verificationUrlComplete || begun.verificationUrl || begun.userCode}
onClick={() => void copyCode(begun.verificationUrlComplete || begun.verificationUrl || begun.userCode)}
>
{begun.userCode.startsWith("http") ? t("授权链接") : begun.userCode}
</button>
<button
type="button"
className={styles.copy}
onClick={() => void copyCode(begun.verificationUrlComplete || begun.verificationUrl || begun.userCode)}
>
<button type="button" onClick={() => void copyCode(userCode)}>{userCode}</button>
<button type="button" className={styles.copy} onClick={() => void copyCode(userCode)}>
{copied ? t("已复制") : t("复制")}
</button>
</div>}
@@ -160,7 +142,6 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [activeProviderId, setActiveProviderId] = useState<string | null>(null);
const run = async (key: string, task: () => Promise<void>) => {
setBusy(key);
@@ -175,40 +156,6 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
}
};
useEffect(() => {
let active = true;
const autoRefreshResources = async () => {
let didRefresh = false;
for (const res of plugin.resources) {
if (!res.canRefresh) continue;
for (const item of res.resources) {
if (!active) return;
// Auto-refresh if resource is marked invalid or missing metrics
if (item.state.status === "invalid" || item.metrics.length === 0) {
try {
await api.refreshPluginResource(plugin.id, res.type, item.id);
didRefresh = true;
} catch {
// ignore background refresh failure
}
}
}
}
if (active && didRefresh) {
await appStore.refreshPlugins();
}
};
void autoRefreshResources();
return () => { active = false; };
}, [plugin.id]);
if (activeProviderId) {
const provider = plugin.providers.find((p) => p.id === activeProviderId);
if (provider) {
return <ProviderModelsView provider={provider} onBack={() => setActiveProviderId(null)} />;
}
}
return <div className={styles.panel}>
{plugin.providers.map((provider) => <ProviderRow
key={provider.id}
@@ -218,12 +165,11 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
onSync={() => void run(`sync:${provider.id}`, async () => {
await api.syncPluginModels(plugin.id, provider.id);
})}
onViewModels={() => setActiveProviderId(provider.id)}
/>)}
{plugin.resources.map((resource) => <ResourceList
key={resource.type}
resource={resource}
busyKey={busy}
busy={busy !== null}
onRefresh={(item) => void run(`refresh:${item.id}`, async () => {
await api.refreshPluginResource(plugin.id, resource.type, item.id);
})}
@@ -235,190 +181,33 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
</div>;
}
function ProviderRow({ provider, busy, syncing, onSync, onViewModels }: {
function ProviderRow({ provider, busy, syncing, onSync }: {
provider: PluginProviderDescriptor;
busy: boolean;
syncing: boolean;
onSync: () => void;
onViewModels: () => void;
}) {
const { locale } = useI18n();
const [disabledIds, setDisabledIds] = useState<Set<string>>(() => getDisabledPluginModelIds());
useEffect(() => {
const handleUpdate = () => setDisabledIds(getDisabledPluginModelIds());
window.addEventListener("cursor_plugin_models_changed", handleUpdate);
return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate);
}, []);
const enabledCount = provider.models.filter((m) => !disabledIds.has(m.id)).length;
return (
<Card className={styles.providerCard}>
<div className={styles.providerRow}>
<div>
<strong>{pluginText(provider.displayName, locale)}</strong>
<span>
{provider.providerType}
{" · "}
{provider.models.length > 0
? `${enabledCount}/${t("{count} 个模型", { count: provider.models.length })}`
: t("尚未同步模型")}
{" · "}
{provider.configured ? t("可调用") : t("未就绪")}
</span>
</div>
<div className={styles.providerActions}>
{provider.models.length > 0 && (
<Button size="small" onClick={onViewModels}>
{t("查看模型")}
</Button>
)}
{provider.hasModels && (
<Button size="small" variant="primary" disabled={busy} onClick={onSync}>
{syncing ? t("正在同步…") : t("同步模型")}
</Button>
)}
</div>
</div>
</Card>
);
}
function ProviderModelsView({ provider, onBack }: { provider: PluginProviderDescriptor; onBack: () => void }) {
const { locale } = useI18n();
const [search, setSearch] = useState("");
const [disabledIds, setDisabledIds] = useState<Set<string>>(() => getDisabledPluginModelIds());
useEffect(() => {
const handleUpdate = () => setDisabledIds(getDisabledPluginModelIds());
window.addEventListener("cursor_plugin_models_changed", handleUpdate);
return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate);
}, []);
const toggleModel = (modelId: string) => {
const isCurrentlyDisabled = disabledIds.has(modelId);
setPluginModelEnabled(modelId, isCurrentlyDisabled);
setDisabledIds(getDisabledPluginModelIds());
};
const toggleAll = (enable: boolean) => {
const target = filteredModels.length > 0 ? filteredModels : provider.models;
setMultiplePluginModelsEnabled(target.map((m) => m.id), enable);
setDisabledIds(getDisabledPluginModelIds());
};
const enabledCount = provider.models.filter((m) => !disabledIds.has(m.id)).length;
const filteredModels = provider.models.filter((m) => {
if (!search.trim()) return true;
const q = search.toLowerCase();
const shortId = m.id.split("/").pop() || m.id;
return m.displayName.toLowerCase().includes(q) || shortId.toLowerCase().includes(q);
});
return (
<div className={styles.modelsView}>
<div className={styles.modelsViewHeader}>
<TooltipTrigger label={t("返回")}>
<button type="button" className={styles.backButton} onClick={onBack} aria-label={t("返回")}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
</button>
</TooltipTrigger>
<div className={styles.headerTitle}>
<strong>{pluginText(provider.displayName, locale)}</strong>
<span>{t("模型列表")}</span>
<span className={styles.headerCountChip}>
{enabledCount}/{provider.models.length}
</span>
</div>
</div>
<div className={styles.modelListToolbar}>
<div className={styles.searchContainer}>
<svg className={styles.searchIcon} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input
type="text"
placeholder={t("搜索模型…")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className={styles.modelSearchInput}
/>
{search && (
<button type="button" className={styles.clearSearchBtn} onClick={() => setSearch("")} aria-label="Clear">
✕
</button>
)}
</div>
<div className={styles.modelBatchActions}>
<button type="button" onClick={() => toggleAll(true)} className={styles.pillButton}>
{t("全选")}
</button>
<button type="button" onClick={() => toggleAll(false)} className={styles.pillButton}>
{t("全不选")}
</button>
</div>
</div>
<div className={styles.modelsViewList}>
{filteredModels.map((m) => {
const shortId = m.id.split("/").pop() || m.id;
const nameLower = m.displayName.toLowerCase();
const idLower = m.id.toLowerCase();
const isEnabled = !disabledIds.has(m.id);
const isClaude = nameLower.includes("claude") || idLower.includes("claude");
const isGemini = nameLower.includes("gemini") || idLower.includes("gemini");
const isGpt = nameLower.includes("gpt") || idLower.includes("gpt");
const isThinking = nameLower.includes("thinking") || idLower.includes("thinking");
const isHigh = nameLower.includes("(high)") || idLower.includes("-high");
const isMedium = nameLower.includes("(medium)") || idLower.includes("-medium");
const isLow = nameLower.includes("(low)") || idLower.includes("-low");
const isExtraLow = nameLower.includes("(extra-low)") || idLower.includes("-extra-low");
const isImage = nameLower.includes("image") || idLower.includes("image");
return (
<div
key={m.id}
className={`${styles.modelItem} ${isEnabled ? styles.modelItemActive : styles.modelItemDisabled}`}
onClick={() => toggleModel(m.id)}
>
<input
type="checkbox"
checked={isEnabled}
onChange={() => toggleModel(m.id)}
className={styles.modelCheckbox}
onClick={(e) => e.stopPropagation()}
/>
<div className={styles.modelInfo}>
<span className={styles.modelName}>{pluginText(m.displayName, locale) || shortId}</span>
<span className={styles.modelId}>{shortId}</span>
</div>
<div className={styles.modelBadges}>
{isClaude && <span className={styles.claudeTag}>Claude</span>}
{isGemini && <span className={styles.geminiTag}>Gemini</span>}
{isGpt && <span className={styles.gptTag}>GPT-OSS</span>}
{isThinking && <span className={styles.thinkingTag}>Thinking</span>}
{isHigh && <span className={styles.highTag}>High</span>}
{isMedium && <span className={styles.mediumTag}>Medium</span>}
{isLow && <span className={styles.lowTag}>Low</span>}
{isExtraLow && <span className={styles.extraLowTag}>Extra-Low</span>}
{isImage && <span className={styles.imageTag}>Image</span>}
</div>
</div>
);
})}
{filteredModels.length === 0 && (
<div className={styles.emptySearch}>
<span>{t("暂无数据")}</span>
</div>
)}
</div>
return <Card className={styles.providerRow}>
<div>
<strong>{pluginText(provider.displayName, locale)}</strong>
<span>
{provider.providerType}
{" · "}
{provider.models.length > 0 ? t("{count} 个模型", { count: provider.models.length }) : t("尚未同步模型")}
{" · "}
{provider.configured ? t("可调用") : t("未就绪")}
</span>
</div>
);
{provider.hasModels && <Button size="small" disabled={busy} onClick={onSync}>
{syncing ? t("正在同步…") : t("同步模型")}
</Button>}
</Card>;
}
function ResourceList({ resource, busyKey, onRefresh, onDelete }: {
function ResourceList({ resource, busy, onRefresh, onDelete }: {
resource: PluginResourceDescriptor;
busyKey: string | null;
busy: boolean;
onRefresh: (item: PluginResourceView) => void;
onDelete: (item: PluginResourceView) => void;
}) {
@@ -440,20 +229,14 @@ function ResourceList({ resource, busyKey, onRefresh, onDelete }: {
<TextInput aria-label={t("搜索资源")} placeholder={t("搜索资源")} value={query} onChange={(event) => setQuery(event.target.value)} />
</div>}
<div className={styles.resourceList}>
{visible.map((item) => {
const isRefreshing = busyKey === `refresh:${item.id}`;
const isDeleting = busyKey === `delete:${item.id}`;
return <ResourceRow
key={item.id}
item={item}
canRefresh={resource.canRefresh}
isRefreshing={isRefreshing}
isDeleting={isDeleting}
disabled={isRefreshing || isDeleting}
onRefresh={() => onRefresh(item)}
onDelete={() => onDelete(item)}
/>;
})}
{visible.map((item) => <ResourceRow
key={item.id}
item={item}
canRefresh={resource.canRefresh}
disabled={busy}
onRefresh={() => onRefresh(item)}
onDelete={() => onDelete(item)}
/>)}
{visible.length === 0 && <span className={styles.empty}>{t("还没有资源,请先添加。")}</span>}
</div>
{pageCount > 1 && <div className={styles.pagination}>
@@ -465,116 +248,40 @@ function ResourceList({ resource, busyKey, onRefresh, onDelete }: {
</FormField>;
}
function ResourceRow({ item, canRefresh, isRefreshing, isDeleting, disabled, onRefresh, onDelete }: {
function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: {
item: PluginResourceView;
canRefresh: boolean;
isRefreshing: boolean;
isDeleting: boolean;
disabled: boolean;
onRefresh: () => void;
onDelete: () => void;
}) {
const { locale } = useI18n();
const [disabledAccountIds, setDisabledAccountIds] = useState<Set<string>>(() => getDisabledPluginAccountIds());
useEffect(() => {
const handleUpdate = () => setDisabledAccountIds(getDisabledPluginAccountIds());
window.addEventListener("cursor_plugin_accounts_changed", handleUpdate);
return () => window.removeEventListener("cursor_plugin_accounts_changed", handleUpdate);
}, []);
const isEnabled = !disabledAccountIds.has(item.id);
const toggleAccount = (checked: boolean) => {
setPluginAccountEnabled(item.id, checked);
setDisabledAccountIds(getDisabledPluginAccountIds());
};
return <Card className={`${styles.resourceRow} ${!isEnabled ? styles.resourceRowDisabled : ""}`}>
<div className={styles.resourceIdentity}>
<Switch
checked={isEnabled}
disabled={disabled}
label={item.displayName}
onChange={toggleAccount}
/>
<div className={styles.resourceText}>
<div className={styles.resourceHeaderLine}>
<strong>{item.displayName}</strong>
{item.description && (() => {
const desc = pluginText(item.description, locale).trim();
const isPro = desc.toLowerCase().includes("pro") || desc.toLowerCase().includes("ultra") || desc.toLowerCase().includes("premium") || desc.toLowerCase().includes("advanced");
const label = isPro ? (desc.toLowerCase().includes("ultra") ? "ULTRA" : "PRO") : "FREE";
return <span className={isPro ? styles.proBadge : styles.freeBadge}>{isPro ? `🔥 ${label}` : label}</span>;
})()}
</div>
{item.metrics.length > 0 && (
<div className={styles.metricsRow}>
{item.metrics.map((metric) => {
const isClaude = metric.id.toLowerCase().includes("claude");
const isGemini = metric.id.toLowerCase().includes("gemini");
const badgeStyle = isClaude
? styles.claudeMetricBadge
: isGemini
? styles.geminiMetricBadge
: styles.genericMetricBadge;
const shortLabel = isClaude ? "C" : isGemini ? "G" : pluginText(metric.label, locale);
return (
<span
key={metric.id}
className={`${styles.metricBadge} ${badgeStyle}`}
title={metric.resetAtMs ? `${pluginText(metric.label, locale)} Reset: ${new Date(metric.resetAtMs).toLocaleTimeString()}` : `${pluginText(metric.label, locale)} Quota: ${Math.round(metric.value)}%`}
>
<span className={styles.metricPrefix}>{shortLabel}</span>
<span className={styles.metricValue}>{Math.round(metric.value)}%</span>
</span>
);
})}
</div>
)}
</div>
return <Card className={styles.resourceRow}>
<div>
<strong>{item.displayName}</strong>
{item.description && <span>{pluginText(item.description, locale)}</span>}
{item.metrics.map((metric) => <span key={metric.id}>
{metric.unit === "percent"
? t("{label} 剩余 {percent}%", { label: pluginText(metric.label, locale), percent: Math.round(metric.value) })
: `${pluginText(metric.label, locale)}: ${metric.value}`}
</span>)}
</div>
<div className={styles.resourceActions}>
<StateBadge isEnabled={isEnabled} state={item.state} />
{canRefresh && (
<TooltipTrigger label={t("刷新")}>
<button
type="button"
className={`${styles.actionIconButton} ${isRefreshing ? styles.refreshingSpin : ""}`}
disabled={disabled}
onClick={onRefresh}
aria-label={t("刷新")}
>
<Icon icon={refreshIcon} size="1.05em" />
</button>
</TooltipTrigger>
)}
<TooltipTrigger label={t("删除")}>
<button
type="button"
className={`${styles.actionIconButton} ${styles.actionDeleteButton}`}
disabled={disabled || isDeleting}
onClick={onDelete}
aria-label={t("删除")}
>
<Icon icon={trashIcon} size="1.05em" />
</button>
</TooltipTrigger>
<div className={styles.actions}>
<StateBadge state={item.state} />
{canRefresh && <Button size="small" disabled={disabled} onClick={onRefresh}>{t("刷新")}</Button>}
<Button size="small" disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
</div>
</Card>;
}
function StateBadge({ isEnabled = true, state }: { isEnabled?: boolean; state: PluginResourceView["state"] }) {
if (!isEnabled) {
return <span className={styles.disabledBadge}><span className={styles.badgeDot} />{t("已停用")}</span>;
}
function StateBadge({ state }: { state: PluginResourceView["state"] }) {
if (state.status === "cooling") {
return <span className={styles.coolingBadge} title={state.message ?? undefined}><span className={styles.badgeDot} />{t("冷却中")}</span>;
return <span className={styles.cooling} title={state.message ?? undefined}>{t("冷却中")}</span>;
}
if (state.status === "invalid") {
return <span className={styles.invalidBadge} title={state.message ?? undefined}><span className={styles.badgeDot} />{t("已失效")}</span>;
return <span className={styles.invalid} title={state.message ?? undefined}>{t("已失效")}</span>;
}
return <span className={styles.readyBadge}><span className={styles.badgeDot} />{t("可用")}</span>;
return <span className={styles.ready}>{t("可用")}</span>;
}
function errorText(cause: unknown) {
@@ -95,7 +95,8 @@ export function AppLifecycleSettingsCard() {
const nextVersion = await updateStore.check();
message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
const error = cause instanceof Error ? cause.message : String(cause);
message(t("检查更新失败:{error}", { error }));
}
};
@@ -103,7 +104,8 @@ export function AppLifecycleSettingsCard() {
try {
await updateStore.install();
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
const error = cause instanceof Error ? cause.message : String(cause);
message(t("安装更新失败:{error}", { error }));
}
};
@@ -0,0 +1,76 @@
@use "../../styles/typography" as type;
.content {
padding: 16px;
}
.row {
min-width: 0;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.details {
display: grid;
gap: 5px;
}
.select {
width: min(320px, 50%);
}
.value {
min-width: 0;
max-width: 70%;
color: var(--vscode-descriptionForeground);
overflow-wrap: anywhere;
text-align: right;
}
.actionGroup {
display: flex;
align-items: center;
gap: 6px;
}
.textButton {
padding: 4px;
color: var(--vscode-textLink-foreground);
background: transparent;
border: 0;
&:hover { color: var(--vscode-textLink-activeForeground); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.promptEditor {
width: 100%;
flex: 1;
min-height: 320px;
box-sizing: border-box;
padding: 10px 12px;
color: var(--vscode-input-foreground);
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border));
border-radius: 6px;
font-family: var(--vscode-editor-font-family, monospace);
font-size: type.$font-size-xs;
line-height: 1.6;
resize: none;
&:focus {
outline: 1px solid var(--vscode-focusBorder);
}
}
@media (max-width: 680px) {
.select { width: 68%; }
}
@@ -0,0 +1,229 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { api, pluginText, type CommitSettingsView } from "../../shared/api";
import { useI18n } from "../../i18n/store";
import { useAppStore } from "../../shared/store/appStore";
import { Button } from "../../shared/ui/Button";
import { Modal } from "../../shared/ui/Modal";
import { ModelSelect, type ModelSelectOption } from "../../shared/ui/ModelSelect";
import { claudeIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
import { TitledCard } from "../../shared/ui/TitledCard";
import { useMessage } from "../../shared/ui/message";
import controls from "../../shared/ui/Controls.module.scss";
import { modelProviderName } from "../../shared/utils/modelProvider";
import styles from "./CommitSettingsCard.module.scss";
function errorText(cause: unknown) {
return cause instanceof Error ? cause.message : String(cause);
}
export function CommitSettingsCard() {
const { models, plugins } = useAppStore();
const { locale } = useI18n();
const message = useMessage();
const [view, setView] = useState<CommitSettingsView | null>(null);
const [modelDraft, setModelDraft] = useState("");
const [editing, setEditing] = useState(false);
const [savingModel, setSavingModel] = useState(false);
const [promptOpen, setPromptOpen] = useState(false);
const [promptDraft, setPromptDraft] = useState("");
const [savingPrompt, setSavingPrompt] = useState(false);
useEffect(() => {
let active = true;
void (async () => {
try {
let loaded = await api.commitSettings(locale);
if (!loaded.prompt.trim() && loaded.prompt_locale !== locale) {
loaded = await api.setCommitSettings({
model_id: loaded.model_id,
prompt: "",
prompt_locale: locale,
});
}
if (active) {
setView(loaded);
setModelDraft(loaded.model_id);
}
} catch (cause) {
if (active) message(errorText(cause));
}
})();
return () => {
active = false;
};
}, [locale, message]);
const modelOptions = useMemo(() => {
const options: ModelSelectOption[] = [{ value: "", label: t("直连"), group: "Cursor" }];
const seen = new Set<string>();
for (const model of models) {
seen.add(model.model_hash);
options.push({
value: model.model_hash,
label: model.display_name && model.display_name !== model.model_id
? `${model.display_name}(${model.model_id})`
: model.display_name || model.model_id,
group: modelProviderName(model),
icon: model.type === "anthropic" ? claudeIcon : openAiIcon,
});
}
for (const plugin of plugins) {
for (const provider of plugin.providers) {
if (!provider.configured) continue;
const group = pluginText(provider.displayName, locale) || plugin.name;
for (const model of provider.models) {
seen.add(model.id);
options.push({
value: model.id,
label: model.displayName,
group,
iconSrc: model.icon || undefined,
icon: model.icon ? undefined : flatColorOrganizationIcon,
});
}
}
}
if (view?.model_id && !seen.has(view.model_id)) {
options.push({ value: view.model_id, label: view.model_id, group: "Cursor" });
}
return options;
}, [locale, models, plugins, view]);
const persist = useCallback(
async (modelId: string, prompt: string) => {
if (!view) return null;
const normalizedPrompt =
prompt.trim() === view.default_prompt.trim() ? "" : prompt.trim();
return api.setCommitSettings({
model_id: modelId,
prompt: normalizedPrompt,
prompt_locale: locale,
});
},
[view, locale],
);
const editModel = useCallback(() => {
if (!view) return;
setModelDraft(view.model_id);
setEditing(true);
}, [view]);
const cancelModelEdit = useCallback(() => {
setModelDraft(view?.model_id ?? "");
setEditing(false);
}, [view]);
const saveModel = useCallback(async () => {
if (!view) return;
setSavingModel(true);
try {
const saved = await persist(modelDraft, view.prompt);
if (saved) {
setView(saved);
setModelDraft(saved.model_id);
setEditing(false);
}
} catch (cause) {
message(errorText(cause));
} finally {
setSavingModel(false);
}
}, [modelDraft, view, persist, message]);
const openPrompt = useCallback(() => {
if (!view) return;
setPromptDraft(view.prompt || view.default_prompt);
setPromptOpen(true);
}, [view]);
const savePrompt = useCallback(async () => {
if (!view) return;
setSavingPrompt(true);
try {
const saved = await persist(view.model_id, promptDraft);
if (saved) setView(saved);
setPromptOpen(false);
message(t("提示词设置已保存"));
} catch (cause) {
message(errorText(cause));
} finally {
setSavingPrompt(false);
}
}, [view, promptDraft, persist, message]);
const resetPrompt = useCallback(() => {
if (!view) return;
setPromptDraft(view.default_prompt);
}, [view]);
const selectedModelLabel = modelOptions.find((option) => option.value === view?.model_id)?.label
?? view?.model_id
?? t("加载中…");
const action = editing ? (
<div className={styles.actionGroup}>
<Button size="small" disabled={savingModel} onClick={cancelModelEdit}>{t("取消")}</Button>
<Button variant="primary" size="small" disabled={savingModel} onClick={() => void saveModel()}>
{savingModel ? t("保存中…") : t("保存")}
</Button>
</div>
) : (
<div className={styles.actionGroup}>
<button type="button" className={styles.textButton} disabled={!view} onClick={openPrompt}>
{t("提示词设置")}
</button>
<button type="button" className={styles.textButton} disabled={!view} onClick={editModel}>
{t("编辑")}
</button>
</div>
);
return (
<>
<TitledCard title={t("Commit 提交代码模型设置")} action={action}>
<div className={styles.content}>
<div className={styles.row}>
<div className={styles.details}>
<strong>{t("生成模型")}</strong>
</div>
{editing ? <div className={styles.select}>
<ModelSelect
mode="single"
value={modelDraft}
options={modelOptions}
disabled={savingModel}
label={t("生成模型")}
onChange={setModelDraft}
/>
</div> : <span className={styles.value}>{selectedModelLabel}</span>}
</div>
</div>
</TitledCard>
<Modal
open={promptOpen}
title={t("提示词设置")}
wide
fullHeight
busy={savingPrompt}
onClose={() => setPromptOpen(false)}
onSubmit={() => void savePrompt()}
submitLabel={t("保存")}
closeLabel={t("取消")}
secondaryAction={
<button type="button" className={controls.secondary} onClick={resetPrompt}>
{t("恢复默认")}
</button>
}
>
<textarea
className={styles.promptEditor}
value={promptDraft}
spellCheck={false}
aria-label={t("提交信息提示词")}
onChange={(event) => setPromptDraft(event.target.value)}
/>
</Modal>
</>
);
}
@@ -20,7 +20,7 @@
}
.control {
width: min(420px, 62%);
width: min(320px, 50%);
}
.value {
@@ -26,7 +26,7 @@ export function ProxySettingsCard({
onSave: () => void;
}) {
const custom = draft.mode === "custom";
const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "system" ? t("使用系统代理") : t("自定义");
const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "default" ? t("默认") : t("自定义");
const action = editing ? (
<div className={styles.actionGroup}>
<Button size="small" disabled={saving} onClick={onCancel}>{t("取消")}</Button>
@@ -43,7 +43,7 @@ export function ProxySettingsCard({
{editing ? <>
<div className={styles.row}>
<strong>{t("代理方式")}</strong>
<div className={styles.control}><Select ariaLabel={t("代理方式")} value={draft.mode} options={[{ value: "system", label: t("使用系统代理") }, { value: "custom", label: t("自定义") }]} onChange={(mode) => onDraftChange({ ...draft, mode: mode as ProxySettingsInput["mode"] })} /></div>
<div className={styles.control}><Select ariaLabel={t("代理方式")} value={draft.mode} options={[{ value: "default", label: t("默认") }, { value: "custom", label: t("自定义") }]} onChange={(mode) => onDraftChange({ ...draft, mode: mode as ProxySettingsInput["mode"] })} /></div>
</div>
{custom && <div className={styles.customFields}>
<div className={styles.row}>
@@ -3,6 +3,7 @@ import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorag
import { PageContent } from "../../shell/layout/PageContent";
import { LegacyModelImport } from "../models/LegacyModelImport";
import { AppLifecycleSettingsCard } from "./AppLifecycleSettingsCard";
import { CommitSettingsCard } from "./CommitSettingsCard";
import { ProxySettingsCard } from "./ProxySettingsCard";
import { TabSettingsCard } from "./TabSettingsCard";
import { Button } from "../../shared/ui/Button";
@@ -30,7 +31,7 @@ export function SettingsPage() {
const [clearScope, setClearScope] = useState<StatisticsStorageScope>("details");
const [clearing, setClearing] = useState(false);
const [outboundProxy, setOutboundProxy] = useState<ProxySettings | null>(null);
const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "system", address: "", auth_enabled: false, username: "", password: "" });
const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "default", address: "", auth_enabled: false, username: "", password: "" });
const [editingProxy, setEditingProxy] = useState(false);
const [savingProxy, setSavingProxy] = useState(false);
const [tabSettings, setTabSettings] = useState<TabSettings | null>(null);
@@ -229,6 +230,7 @@ export function SettingsPage() {
</TitledCard>
<ProxySettingsCard settings={outboundProxy} draft={proxyDraft} editing={editingProxy} saving={savingProxy} onDraftChange={setProxyDraft} onEdit={editProxy} onCancel={cancelProxyEdit} onSave={() => void saveProxy()} />
<TabSettingsCard settings={tabSettings} draft={tabDraft} editing={editingTab} saving={savingTab} onDraftChange={setTabDraft} onEdit={editTab} onCancel={cancelTabEdit} onSave={() => void saveTab()} />
<CommitSettingsCard />
<AppLifecycleSettingsCard />
<LegacyModelImport>{({ busy, previewing, open }) => <TitledCard title={t("导入")}>
<div className={styles.importRow}>
@@ -34,7 +34,7 @@
}
.control {
width: min(420px, 62%);
width: min(320px, 50%);
}
.value {
File diff suppressed because it is too large Load Diff
+27 -14
View File
@@ -7,6 +7,7 @@
"028c60a8a8e30a1b": "Page {page} / {total}",
"03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "Edit",
"0525054436acdb6e": "Commit message prompt",
"05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)",
"0580e0a99a6f1afc": "Artifacts",
"06619f339fa0ab46": "Preparing the plugin runtime",
@@ -26,11 +27,10 @@
"0e41f8e3d59ec47b": "Storage management",
"0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models",
"0ec1e85b0c3cfa65": "Call details",
"0fa9ab1b5cdaca63": "Authorization URL",
"100fad4a0b3ab781": "Last 24 hours",
"105a9082c346f958": "Testing…",
"124be3f86f197802": "Token usage",
"12ae77e6202d063e": "Custom Headers",
"12b86e89701b0241": "Enter a model name and press Enter to add it",
"133340e53175128a": "Test all",
"13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.",
"13b61c5f697b6700": "Cache hit rate",
@@ -41,6 +41,7 @@
"1813d362a82fd437": "Maximize window",
"18165f8865eacc91": "No plugins installed",
"19658d9fa9aa8de4": "Installing…",
"19bb071f8c362695": "Prompt settings",
"1a3f0617d6de8e52": "Username",
"1a60c9eb3cf1dbb5": "Import finished: {added} added, {updated} updated",
"1aa65c55c6cc6163": "Device code",
@@ -63,11 +64,13 @@
"24a0a24864454575": "Existing, skipped",
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
"29585d7193539200": "Current version {version}",
"296da37506328d89": "Disable Cursor takeover",
"29fbbef32a6eb58b": "Do not show this ad again",
"2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.",
"2caeaec539e78898": "Thinking budget tokens",
"2cbc58108d78b06c": "Waiting for browser authorization…",
"2cd0f3be8738a86c": "Cancel",
"2cdf7d3275016cc2": "Disable Cursor takeover?",
"2d30c2a98ebb5278": "Current: {rate}",
"2eb2bf7c6597ab9a": "Detailed records",
"2f280a8120c0db12": "Used only for display and does not change the model name sent to the model service.",
@@ -76,7 +79,6 @@
"2f5f1d6fbfb061ed": "Not set",
"2f6416a2c424856b": "Final request URL",
"2f7ba5fd1d12f7f9": "Open the tutorial?",
"2f7dec3be28d7597": "{count} selected",
"2f9daa828907b93f": "Delete",
"2fe5a8d0eee9f14c": "Invalid",
"303c30f301514250": "Search resources",
@@ -90,7 +92,6 @@
"382f2e3419a02fef": "Only clear detailed records",
"38844b135cf70dfc": "More",
"393e1241552b1870": "Request",
"398f8e6c6f0a0b97": "Continue selecting or typing",
"39f52eee100131d7": "Cached input",
"3a0fb74abe2460b6": "Chunks",
"3a3f595df70ec8ff": "Clear storage",
@@ -107,6 +108,7 @@
"402495402ce333b1": "Reinitialize plugins",
"40a08e7cf320ae07": "Clear detailed records?",
"4125fc7ba333524c": "Default light",
"4256064de039de89": "Display granularity",
"42655ed8e4108ae2": "Input (non-cached)",
"42a1d9e5b037c210": "Bytes",
"42aa8e01e98c0d8c": "Total duration",
@@ -130,17 +132,19 @@
"4d99c976beb8827e": "Ready",
"4e30d7c9ed2b0eee": "Not set",
"4eafa9e925b30bcd": "Custom",
"4f0f45b0312ca4ba": "1 hour",
"51d04bc3d286f018": "Last calendar day",
"51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models",
"5228358a6db59fe7": "For example: now, 2026-08-23 18:00",
"5319e7374f78fb38": "Taken over",
"5401344227e49e2f": "TAB settings",
"54644705e9c61009": "Port settings",
"54c53e5fe791d1f3": "Initialize CA",
"54e6745ff43c9c74": "Unable to save model order",
"550eddc3c7fefa99": "Sponsored",
"552a5d4baf45d878": "Commit Message Model Settings",
"56432ba297009bdc": "Initialize the CA first",
"56627c94a9decee6": "Maximum output tokens",
"572cf45ba43634b3": "Back",
"576d81bb0631b165": "Import",
"5886afc1c71df1fe": "Shown in the Cursor model description.",
"59346e82b3dd2998": "TAB service address",
@@ -155,12 +159,13 @@
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
"5fd2ec5a6e9b654c": "Total: {cost}",
"6078a681a306930d": "Cache write",
"609640f72d422b57": "Quick time ranges",
"61a4c7bac12dc125": "Failed calls: {count}",
"61c7d1f758b647d5": "Call information",
"621f63a5f08384ac": "Cache read: {tokens}",
"6320b4a8722a851f": "Status",
"63c73c4730f4473e": "Apply",
"63d90d977348ab1f": "Copy",
"63d90d977348ab1f": "Duplicate",
"6478a5f1218c484e": "Use the desktop app to copy to the system clipboard",
"651f274470153a05": "Software updates",
"652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}",
@@ -177,7 +182,6 @@
"6ae80538c2b2572d": "Minimize window",
"6d1876364ac6457d": "Proxy mode",
"6e86570183c3cdd0": "You're up to date",
"6f281e1d8380a055": "Hide models",
"7005693f4f050bce": "Cache I/O {cost}",
"72644ec4389da2f7": "Default layout",
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
@@ -198,13 +202,14 @@
"802b0faf0ceb513e": "{label}: {percent}% left",
"80a57e03f0717f91": "Not configured",
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
"8135034002c770ac": "View models",
"8213941f12320ce1": "This operating system or CPU architecture is not currently supported",
"83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed",
"83e8d0b7aff2b394": "Downloaded {downloaded} / {total}",
"83fcfb4c1f2c1641": "Fetch models",
"842b9f11cdd96bda": "Launch at login",
"843ac7e15a5047a7": "Confirm legacy model configuration import",
"844b8cc8dff7c1d8": "Default",
"859757ea792a73da": "30 minutes",
"864597982c308d72": "Silent start enabled",
"86de7c4ee8fa7689": "Sync models",
"8716e1344b0daddb": "Cursor official",
@@ -218,19 +223,21 @@
"8d0c47eb9eac2d34": "Call type",
"8df48894086d6fbd": "Reason (optional)",
"8e2d04638a11a7cb": "Only determines the request and response format; it does not change the request URL.",
"8ec7b052f694e3c0": "Search models…",
"8f6f8d979c981ced": "Copied",
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
"90800c48a1dd0655": "{label} must be a JSON object",
"919cb0ce0c8db4e7": "Leave blank to keep the current password",
"91aaf184cfc17ffd": "Overview",
"91af6e57e7453fbe": "Add",
"91af6e57e7453fbe": "Add account",
"92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.",
"92e26b27d5ea8f0e": "Failed to check for updates: {error}",
"9305c0e13642cab4": "Not taken over",
"940a168911ade998": "Items per page",
"945fb1c67eca8493": "Installing the plugin runtime",
"946b3ffc02f026c0": "Delete this model?",
"954ec984cd4f49d1": "Syncing…",
"966498853d801a52": "TAB connection",
"96f7642963ca0dbf": "Enable Cursor takeover",
"9850ed41a5bfbb0c": "{count} selected",
"997ec8201c2adeda": "Open terminal to install CA",
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
@@ -264,7 +271,7 @@
"a748cc074f78de00": "View details",
"a7617f42f898b2bf": "Use complete request URL",
"a8036485f9227f2c": "Drag to reorder",
"a8c3698b5b8c485d": "Disabled",
"a80b53f8848e6d27": "Failed to install update: {error}",
"a98585871c5313ff": "Display name",
"ab9084a640fbb864": "Deselect all",
"abecab6701177721": "Launch at login enabled",
@@ -291,6 +298,7 @@
"b8c9b486c83b5778": "Hide ad",
"b9670c85a4ab939e": "Route",
"b9af2de88d903be7": "Proxy address",
"ba2e93e73037c71e": "Restore default",
"ba5865fbc734e672": "For example: Primary model",
"ba6403d22876d626": "Cooling down",
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
@@ -330,14 +338,16 @@
"d59e47070f7f358e": "Callable",
"d60669bb26a22f5d": "Leave blank to use the default",
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
"d71b0171c44b668a": "Disabling this will remove Cursor's local proxy configuration. If you need to sign in to an official account, you usually do not need to disable it. You can sign in directly because BYOK models and official-account models now work together seamlessly. Do you want to continue disabling and clearing the proxy configuration?",
"d766536c18e8e990": "Plugin runtime {version} is installed and ready to use.",
"d7e266bdc8064193": "Group name",
"d86fa42c3848c680": "Use system proxy",
"d8c47e9776cf1082": "Main menu",
"d8c589c455675b46": "Prompt settings saved",
"da521d1c1cbd36af": "Authorization is required to install the certificate",
"da7ae985487c38e6": "Last hour",
"daede9881787abe7": "Notes",
"db340a9896306d08": "Test cancelled",
"dbaa1c565cb1f108": "15 minutes",
"dbd3596e4a86f3c2": "Configured models",
"ddde16f8839da3ce": "Total requests",
"de8184da1ef88d03": "Configured",
@@ -358,6 +368,7 @@
"e24096c81b1a8af4": "Authorization was denied or failed.",
"e24ebe4a866d69bf": "Test failed: {error}",
"e25bf3f419bb68f0": "Call history",
"e2ebc59779f012ed": "Generation model",
"e3fee05f688708b4": "LLM calls",
"e4760c6a1df24f17": "Complete request URL",
"e5043c7a2b408271": "Last 10 minutes",
@@ -367,14 +378,14 @@
"e77e3d58b0dcffaa": "Duration",
"e825a2a42c22380e": "Model type",
"e828bd3a0151edc2": "The local CA must be trusted by the system",
"e85373502c372776": "1 minute",
"e8b1268c1e3610f2": "Existing",
"ea26b760e930a7ca": "Call observability",
"ea4bf042d1bc5eb8": "Models",
"eb11e2df1d8ae387": "Provider URL",
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
"eb4a3db23661fb52": "Applies to every model in this group and is used as the badge label in Cursor's model picker; clear it to fall back to the server domain.",
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
"eba54690937bc532": "Manage",
"eba54690937bc532": "Manage accounts",
"ed31fbb483ee1b0a": "Actions",
"edc70de18c6da1a6": "Install local CA",
"ee239f3943293f87": "Sunday",
@@ -387,6 +398,7 @@
"f6dc1b1641600dd0": "Importing…",
"f78265089144369a": "Plugins",
"f78413c36d36f090": "Uses the operating system language by default; unsupported languages fall back to English. Current: {language}",
"f784b165bd3fcb0e": "Last 4 hours",
"f85537d1fd2ef6f6": "Custom overview filters",
"f95ea7f4c063eea7": "Disabled",
"f9615d05d8e18595": "Clear {label} filter",
@@ -398,6 +410,7 @@
"fbe8778fa8b9bab5": "Initialize the local CA first",
"fc22d1ab9ac73c6f": "Checking the plugin runtime",
"fc3947ebe6b2177b": "Default {defaultRate} / include creation {reuseRate}",
"fc50d0b72bc871db": "Disable takeover",
"fcd311fd8ad42462": "Open model list",
"fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.",
"fd77192739703811": "Bulk import",
+24 -11
View File
@@ -7,6 +7,7 @@
"028c60a8a8e30a1b": "第 {page} / {total} 页",
"03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "编辑",
"0525054436acdb6e": "提交信息提示词",
"05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)",
"0580e0a99a6f1afc": "工件数",
"06619f339fa0ab46": "正在准备插件运行时",
@@ -26,11 +27,10 @@
"0e41f8e3d59ec47b": "存储管理",
"0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型",
"0ec1e85b0c3cfa65": "调用详情",
"0fa9ab1b5cdaca63": "授权链接",
"100fad4a0b3ab781": "近24小时",
"105a9082c346f958": "测试中…",
"124be3f86f197802": "Token 消耗",
"12ae77e6202d063e": "自定义 Headers",
"12b86e89701b0241": "输入模型名称后按 Enter 添加",
"133340e53175128a": "一键测试",
"13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。",
"13b61c5f697b6700": "缓存命中率",
@@ -41,6 +41,7 @@
"1813d362a82fd437": "最大化窗口",
"18165f8865eacc91": "还没有安装插件",
"19658d9fa9aa8de4": "安装中…",
"19bb071f8c362695": "提示词设置",
"1a3f0617d6de8e52": "用户名",
"1a60c9eb3cf1dbb5": "导入完成:新增 {added},更新 {updated}",
"1aa65c55c6cc6163": "设备验证码",
@@ -63,11 +64,13 @@
"24a0a24864454575": "已存在,跳过",
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
"29585d7193539200": "当前版本 {version}",
"296da37506328d89": "关闭接管Cursor",
"29fbbef32a6eb58b": "不再显示此广告",
"2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
"2caeaec539e78898": "思考预算 Token",
"2cbc58108d78b06c": "等待网页端确认授权中…",
"2cd0f3be8738a86c": "取消",
"2cdf7d3275016cc2": "关闭接管Cursor?",
"2d30c2a98ebb5278": "当前:{rate}",
"2eb2bf7c6597ab9a": "详细记录",
"2f280a8120c0db12": "仅用于界面展示,不会改变发送给模型服务的模型名称。",
@@ -76,7 +79,6 @@
"2f5f1d6fbfb061ed": "未设置",
"2f6416a2c424856b": "最终请求地址",
"2f7ba5fd1d12f7f9": "打开使用教程?",
"2f7dec3be28d7597": "已选择 {count} 个",
"2f9daa828907b93f": "删除",
"2fe5a8d0eee9f14c": "已失效",
"303c30f301514250": "搜索资源",
@@ -90,7 +92,6 @@
"382f2e3419a02fef": "仅清理详细记录",
"38844b135cf70dfc": "更多",
"393e1241552b1870": "请求",
"398f8e6c6f0a0b97": "继续选择或输入",
"39f52eee100131d7": "缓存输入",
"3a0fb74abe2460b6": "分块数",
"3a3f595df70ec8ff": "清理存储空间",
@@ -107,6 +108,7 @@
"402495402ce333b1": "重新初始化插件",
"40a08e7cf320ae07": "确定要清理详细记录吗?",
"4125fc7ba333524c": "默认亮色",
"4256064de039de89": "显示粒度",
"42655ed8e4108ae2": "输入(非缓存)",
"42a1d9e5b037c210": "字节数",
"42aa8e01e98c0d8c": "总耗时",
@@ -130,17 +132,19 @@
"4d99c976beb8827e": "可用",
"4e30d7c9ed2b0eee": "不设置",
"4eafa9e925b30bcd": "自定义",
"4f0f45b0312ca4ba": "1小时",
"51d04bc3d286f018": "近1自然日",
"51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功",
"5228358a6db59fe7": "如:现在、2026-08-23 18:00",
"5319e7374f78fb38": "已接管",
"5401344227e49e2f": "TAB 设置",
"54644705e9c61009": "端口设置",
"54c53e5fe791d1f3": "初始化 CA",
"54e6745ff43c9c74": "排序失败",
"550eddc3c7fefa99": "推广",
"552a5d4baf45d878": "Commit 提交代码模型设置",
"56432ba297009bdc": "请先初始化 CA",
"56627c94a9decee6": "最大输出 Token",
"572cf45ba43634b3": "返回",
"576d81bb0631b165": "导入",
"5886afc1c71df1fe": "显示在 Cursor 模型说明中。",
"59346e82b3dd2998": "TAB 服务地址",
@@ -155,6 +159,7 @@
"5f9acfb945229062": "你确认不想再看到此广告吗?",
"5fd2ec5a6e9b654c": "合计:{cost}",
"6078a681a306930d": "缓存写入",
"609640f72d422b57": "快捷时间范围",
"61a4c7bac12dc125": "异常调用:{count}",
"61c7d1f758b647d5": "调用信息",
"621f63a5f08384ac": "缓存读取:{tokens}",
@@ -177,7 +182,6 @@
"6ae80538c2b2572d": "最小化窗口",
"6d1876364ac6457d": "代理方式",
"6e86570183c3cdd0": "当前已是最新版本",
"6f281e1d8380a055": "收起模型",
"7005693f4f050bce": "缓存读写 {cost}",
"72644ec4389da2f7": "默认平铺",
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
@@ -198,13 +202,14 @@
"802b0faf0ceb513e": "{label} 剩余 {percent}%",
"80a57e03f0717f91": "未配置",
"811a3b22a5a7f2d5": "无法连接本地管理服务",
"8135034002c770ac": "查看模型",
"8213941f12320ce1": "当前操作系统或 CPU 架构暂不受支持",
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
"83e8d0b7aff2b394": "已下载 {downloaded} / {total}",
"83fcfb4c1f2c1641": "获取模型",
"842b9f11cdd96bda": "开机启动",
"843ac7e15a5047a7": "确认导入旧版模型配置",
"844b8cc8dff7c1d8": "默认",
"859757ea792a73da": "30分钟",
"864597982c308d72": "已开启静默启动",
"86de7c4ee8fa7689": "同步模型",
"8716e1344b0daddb": "Cursor 官方",
@@ -218,7 +223,6 @@
"8d0c47eb9eac2d34": "调用类型",
"8df48894086d6fbd": "原因(可选)",
"8e2d04638a11a7cb": "只决定请求与响应的格式,不会改变请求地址。",
"8ec7b052f694e3c0": "搜索模型…",
"8f6f8d979c981ced": "已复制",
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
@@ -226,11 +230,14 @@
"91aaf184cfc17ffd": "数据概览",
"91af6e57e7453fbe": "添加账号",
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
"92e26b27d5ea8f0e": "检查更新失败:{error}",
"9305c0e13642cab4": "未接管",
"940a168911ade998": "每页条数",
"945fb1c67eca8493": "正在安装插件运行时",
"946b3ffc02f026c0": "确定删除这个模型吗?",
"954ec984cd4f49d1": "正在同步…",
"966498853d801a52": "TAB 选择",
"96f7642963ca0dbf": "开启接管Cursor",
"9850ed41a5bfbb0c": "已选 {count} 项",
"997ec8201c2adeda": "打开终端安装 CA",
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
@@ -264,7 +271,7 @@
"a748cc074f78de00": "查看详情",
"a7617f42f898b2bf": "使用完整请求地址",
"a8036485f9227f2c": "拖动排序",
"a8c3698b5b8c485d": "已停用",
"a80b53f8848e6d27": "安装更新失败:{error}",
"a98585871c5313ff": "显示名称",
"ab9084a640fbb864": "全不选",
"abecab6701177721": "已开启开机启动",
@@ -291,6 +298,7 @@
"b8c9b486c83b5778": "不再显示广告",
"b9670c85a4ab939e": "路由",
"b9af2de88d903be7": "代理地址",
"ba2e93e73037c71e": "恢复默认",
"ba5865fbc734e672": "例如:主力模型",
"ba6403d22876d626": "冷却中",
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
@@ -330,14 +338,16 @@
"d59e47070f7f358e": "可调用",
"d60669bb26a22f5d": "留空使用默认值",
"d6b1f203680f5496": "留空使用 adaptive thinking",
"d71b0171c44b668a": "关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?",
"d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。",
"d7e266bdc8064193": "分组名称",
"d86fa42c3848c680": "使用系统代理",
"d8c47e9776cf1082": "主菜单",
"d8c589c455675b46": "提示词设置已保存",
"da521d1c1cbd36af": "需要授权安装证书",
"da7ae985487c38e6": "近1小时",
"daede9881787abe7": "备注",
"db340a9896306d08": "测试已取消",
"dbaa1c565cb1f108": "15分钟",
"dbd3596e4a86f3c2": "配置模型",
"ddde16f8839da3ce": "总请求",
"de8184da1ef88d03": "已配置",
@@ -358,6 +368,7 @@
"e24096c81b1a8af4": "授权被拒绝或已失败。",
"e24ebe4a866d69bf": "测试失败:{error}",
"e25bf3f419bb68f0": "调用详细",
"e2ebc59779f012ed": "生成模型",
"e3fee05f688708b4": "LLM 调用",
"e4760c6a1df24f17": "完整请求 URL",
"e5043c7a2b408271": "近10分钟",
@@ -367,8 +378,8 @@
"e77e3d58b0dcffaa": "耗时",
"e825a2a42c22380e": "模型类型",
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
"e85373502c372776": "1分钟",
"e8b1268c1e3610f2": "已存在",
"ea4bf042d1bc5eb8": "模型列表",
"ea26b760e930a7ca": "调用观测",
"eb11e2df1d8ae387": "上游地址",
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
@@ -387,6 +398,7 @@
"f6dc1b1641600dd0": "正在导入…",
"f78265089144369a": "插件配置",
"f78413c36d36f090": "默认跟随操作系统;不支持的系统语言使用英文。当前:{language}",
"f784b165bd3fcb0e": "近4小时",
"f85537d1fd2ef6f6": "自定义概览筛选",
"f95ea7f4c063eea7": "未启用",
"f9615d05d8e18595": "清除{label}筛选",
@@ -398,6 +410,7 @@
"fbe8778fa8b9bab5": "需要先初始化本地 CA",
"fc22d1ab9ac73c6f": "正在检查插件运行时",
"fc3947ebe6b2177b": "默认 {defaultRate} / 计入创建 {reuseRate}",
"fc50d0b72bc871db": "关闭接管",
"fcd311fd8ad42462": "打开模型列表",
"fd415f8e0097c832": "缓存读写已计入提示词侧统计。",
"fd77192739703811": "批量导入",
+20 -94
View File
@@ -103,6 +103,7 @@ export interface CursorHarnessStatus {
configured_models: number;
enabled_models: number;
integration: IntegrationState;
settings_applied: boolean;
proxy_url: string | null;
ca_install_command: string | null;
}
@@ -120,7 +121,7 @@ export interface StatisticsStorage {
export type StatisticsStorageScope = "details" | "all";
export type ProxyMode = "system" | "custom";
export type ProxyMode = "default" | "custom";
export interface ProxySettings {
mode: ProxyMode;
@@ -150,6 +151,16 @@ export interface DesktopSettings {
show_dock_icon: boolean;
}
export interface CommitSettings {
model_id: string;
prompt: string;
prompt_locale: Locale;
}
export interface CommitSettingsView extends CommitSettings {
default_prompt: string;
}
export type PluginRuntimeState = "uninitialized" | "initializing" | "ready" | "failed" | "unsupported";
export type PluginRuntimePhase = "checking" | "downloading" | "verifying" | "installing" | "validating";
@@ -202,10 +213,11 @@ export interface PluginResourceView {
}
export interface PluginAddMethod {
type: "oauth2.0";
type: "oauth2.0" | "oauth2.authorization-code";
id: string;
displayName: PluginLocalizedText;
description: PluginLocalizedText | null;
callback?: { port: number | null; path: string | null };
}
export interface PluginImportDescriptor {
@@ -263,7 +275,7 @@ export interface PluginDescriptor {
export interface PluginOAuthBegin {
sessionId: string;
userCode: string;
userCode: string | null;
verificationUrl: string;
verificationUrlComplete: string | null;
expiresAtMs: number;
@@ -288,94 +300,9 @@ export interface PluginImportResult {
modelSyncError: string | null;
}
export type ConfiguredModel =
| { kind: "builtin"; id: string; name: string; builtin: Model }
| { kind: "plugin"; id: string; name: string; plugin: PluginModelDescriptor };
let cachedDisabledPluginModelIds: Set<string> = new Set();
let cachedDisabledPluginAccountIds: Set<string> = new Set();
let modelsLoaded = false;
let accountsLoaded = false;
export function getDisabledPluginModelIds(): Set<string> {
if (!modelsLoaded) {
modelsLoaded = true;
void api.disabledPluginModels().then((ids) => {
cachedDisabledPluginModelIds = new Set(ids);
window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed"));
}).catch(() => {});
}
return new Set(cachedDisabledPluginModelIds);
}
export function setPluginModelEnabled(modelId: string, enabled: boolean): void {
if (enabled) {
cachedDisabledPluginModelIds.delete(modelId);
} else {
cachedDisabledPluginModelIds.add(modelId);
}
window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed"));
void api.setDisabledPluginModels([...cachedDisabledPluginModelIds]).catch(() => {});
}
export function setMultiplePluginModelsEnabled(modelIds: string[], enabled: boolean): void {
if (enabled) {
for (const id of modelIds) {
cachedDisabledPluginModelIds.delete(id);
}
} else {
for (const id of modelIds) {
cachedDisabledPluginModelIds.add(id);
}
}
window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed"));
void api.setDisabledPluginModels([...cachedDisabledPluginModelIds]).catch(() => {});
}
export function getDisabledPluginAccountIds(): Set<string> {
if (!accountsLoaded) {
accountsLoaded = true;
void api.disabledPluginAccounts().then((ids) => {
cachedDisabledPluginAccountIds = new Set(ids);
window.dispatchEvent(new CustomEvent("cursor_plugin_accounts_changed"));
}).catch(() => {});
}
return new Set(cachedDisabledPluginAccountIds);
}
export function setPluginAccountEnabled(accountId: string, enabled: boolean): void {
if (enabled) {
cachedDisabledPluginAccountIds.delete(accountId);
} else {
cachedDisabledPluginAccountIds.add(accountId);
}
window.dispatchEvent(new CustomEvent("cursor_plugin_accounts_changed"));
void api.setDisabledPluginAccounts([...cachedDisabledPluginAccountIds]).catch(() => {});
}
export function updateCachedDisabledStates(models: string[], accounts: string[]): void {
cachedDisabledPluginModelIds = new Set(models);
cachedDisabledPluginAccountIds = new Set(accounts);
modelsLoaded = true;
accountsLoaded = true;
window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed"));
window.dispatchEvent(new CustomEvent("cursor_plugin_accounts_changed"));
}
export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] {
const disabled = getDisabledPluginModelIds();
return plugins.flatMap((plugin) =>
plugin.providers.flatMap((provider) =>
provider.configured ? provider.models.filter((m) => !disabled.has(m.id)) : []
)
);
}
export function configuredModels(models: Model[], plugins: PluginDescriptor[]): ConfiguredModel[] {
return [
...models.map((model): ConfiguredModel => ({ kind: "builtin", id: model.model_hash, name: model.display_name, builtin: model })),
...configuredPluginModels(plugins).map((model): ConfiguredModel => ({ kind: "plugin", id: model.id, name: model.displayName, plugin: model })),
];
plugin.providers.flatMap((provider) => provider.configured ? provider.models : []));
}
export interface OverviewMetrics {
@@ -527,12 +454,13 @@ export const api = {
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; bucketMs?: number }) => {
const params = new URLSearchParams();
if (filter) {
params.set("start_ms", String(filter.startMs));
params.set("end_ms", String(filter.endMs));
if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes));
if (filter.bucketMs) params.set("bucket_ms", String(filter.bucketMs));
}
const query = params.toString();
return request<Overview>(`/overview${query ? `?${query}` : ""}`);
@@ -540,10 +468,6 @@ export const api = {
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
plugins: () => request<PluginDescriptor[]>("/plugins"),
disabledPluginModels: () => request<string[]>("/plugins/disabled-models"),
setDisabledPluginModels: (modelIds: string[]) => request<string[]>("/plugins/disabled-models", { method: "PUT", body: JSON.stringify({ modelIds }) }),
disabledPluginAccounts: () => request<string[]>("/plugins/disabled-accounts"),
setDisabledPluginAccounts: (accountIds: string[]) => request<string[]>("/plugins/disabled-accounts", { method: "PUT", body: JSON.stringify({ accountIds }) }),
pluginOAuthBegin: (pluginId: string, resourceType: string, methodId: string) => request<PluginOAuthBegin>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/add/${encodeURIComponent(methodId)}/begin`, { method: "POST" }),
pluginOAuthPoll: (sessionId: string, signal?: AbortSignal) => request<PluginOAuthPoll>(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }),
importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request<PluginImportResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }),
@@ -586,4 +510,6 @@ export const api = {
setTabSettings: (settings: TabSettings) => request<TabSettings>("/settings/tab", { method: "PUT", body: JSON.stringify(settings) }),
desktopSettings: () => request<DesktopSettings>("/settings/desktop"),
setDesktopSettings: (settings: DesktopSettings) => request<DesktopSettings>("/settings/desktop", { method: "PUT", body: JSON.stringify(settings) }),
commitSettings: (locale: Locale) => request<CommitSettingsView>("/settings/commit", { headers: { "accept-language": locale } }),
setCommitSettings: (settings: CommitSettings) => request<CommitSettingsView>("/settings/commit", { method: "PUT", body: JSON.stringify(settings) }),
};
+34 -6
View File
@@ -1,5 +1,5 @@
import { getVersion, setDockVisibility } from "@tauri-apps/api/app";
import { isTauri } from "@tauri-apps/api/core";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
import { relaunch } from "@tauri-apps/plugin-process";
import { check, type Update } from "@tauri-apps/plugin-updater";
@@ -46,11 +46,39 @@ export async function writeDockIconVisibility(visible: boolean): Promise<void> {
}
}
export async function checkForUpdate(): Promise<Update | null> {
return check();
export type AppUpdate = {
version: string;
install(): Promise<void>;
close(): Promise<void>;
};
type PortableUpdateInfo = {
version: string;
};
export async function checkForUpdate(): Promise<AppUpdate | null> {
if (desktopPlatform() === "windows") {
const update = await invoke<PortableUpdateInfo | null>("check_portable_update");
if (!update) return null;
return {
version: update.version,
install: () => invoke("install_portable_update", { expectedVersion: update.version }),
close: async () => {},
};
}
const update: Update | null = await check();
if (!update) return null;
return {
version: update.version,
install: async () => {
await update.downloadAndInstall();
await relaunch();
},
close: () => update.close(),
};
}
export async function installUpdate(update: Update): Promise<void> {
await update.downloadAndInstall();
await relaunch();
export async function installUpdate(update: AppUpdate): Promise<void> {
await update.install();
}
+2 -8
View File
@@ -1,5 +1,5 @@
import { useSyncExternalStore } from "react";
import { api, updateCachedDisabledStates, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api";
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api";
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
export type AppSnapshot = {
@@ -151,13 +151,7 @@ export const appStore = {
},
async refreshPlugins() {
try {
const [plugins, disabledModels, disabledAccounts] = await Promise.all([
api.plugins(),
api.disabledPluginModels(),
api.disabledPluginAccounts(),
]);
updateCachedDisabledStates(disabledModels, disabledAccounts);
update({ plugins });
update({ plugins: await api.plugins() });
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
}
+3 -3
View File
@@ -1,9 +1,9 @@
import { useSyncExternalStore } from "react";
import type { Update } from "@tauri-apps/plugin-updater";
import {
checkForUpdate,
hasNativeAppLifecycle,
installUpdate,
type AppUpdate,
} from "../native/appLifecycle";
export type UpdateSnapshot = {
@@ -17,7 +17,7 @@ let snapshot: UpdateSnapshot = {
checking: false,
installing: false,
};
let availableUpdate: Update | null = null;
let availableUpdate: AppUpdate | null = null;
let pendingCheck: Promise<string | null> | null = null;
const listeners = new Set<() => void>();
@@ -26,7 +26,7 @@ function update(patch: Partial<UpdateSnapshot>) {
listeners.forEach((listener) => listener());
}
async function replaceAvailableUpdate(next: Update | null) {
async function replaceAvailableUpdate(next: AppUpdate | null) {
const previous = availableUpdate;
availableUpdate = next;
update({ availableVersion: next?.version ?? null });
@@ -12,6 +12,7 @@
&:focus-within,
&[data-open] { border-color: var(--vscode-focusBorder); }
&[data-disabled] { opacity: 0.55; }
}
.trigger {
@@ -64,35 +65,6 @@
border: 1px solid var(--vscode-dropdown-border);
border-radius: var(--oa-overlay-radius);
box-shadow: var(--oa-dropdown-shadow);
button {
width: 100%;
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 8px;
color: var(--vscode-dropdown-foreground);
background: transparent;
border: 0;
border-radius: 4px;
font-size: type.$font-size-xs;
&:hover {
color: var(--vscode-list-hoverForeground, var(--vscode-dropdown-foreground));
background: var(--vscode-list-hoverBackground);
}
&[aria-selected="true"] {
color: var(--vscode-list-activeSelectionForeground);
background: var(--vscode-list-activeSelectionBackground);
}
&[aria-selected="true"]:hover {
background: var(--vscode-list-inactiveSelectionBackground, var(--vscode-list-activeSelectionBackground));
}
}
}
.listFrame,
@@ -106,6 +78,101 @@
overflow: hidden;
}
.group,
.groupLabel {
height: 32px;
display: flex;
align-items: center;
box-sizing: border-box;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
font-weight: 600;
}
.group {
gap: 8px;
padding: 7px 8px 3px;
cursor: pointer;
input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
&:has(input:focus-visible) .checkbox {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
}
.groupLabel {
padding: 7px 8px 3px;
}
.optionRow {
width: 100%;
min-height: 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px 5px 32px;
box-sizing: border-box;
color: var(--vscode-dropdown-foreground);
background: transparent;
border-radius: 4px;
font-size: type.$font-size-xs;
cursor: pointer;
input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
&:hover {
color: var(--vscode-list-hoverForeground, var(--vscode-dropdown-foreground));
background: var(--vscode-list-hoverBackground);
}
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
}
.checkbox {
width: 16px;
height: 16px;
flex: 0 0 16px;
display: grid;
place-items: center;
color: var(--vscode-checkbox-foreground, var(--vscode-button-foreground));
background: var(--vscode-checkbox-background, var(--vscode-input-background));
border: 1px solid var(--vscode-checkbox-border, var(--vscode-input-border));
border-radius: 3px;
}
.optionRow:has(input:checked) .checkbox,
.group:has(input:checked) .checkbox,
.checkbox[data-indeterminate] {
color: var(--vscode-button-foreground);
background: var(--vscode-button-background);
border-color: var(--vscode-button-background);
}
.checkbox[data-indeterminate]::after {
width: 8px;
height: 2px;
content: "";
background: currentColor;
border-radius: 1px;
}
.option {
min-width: 0;
display: flex;
@@ -119,7 +186,6 @@
}
}
.check { width: 1.1em; flex: 0 0 1.1em; }
.empty { padding: 8px; color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; }
.footer {
@@ -133,7 +199,16 @@
button {
width: auto;
min-height: 28px;
display: flex;
align-items: center;
justify-content: center;
padding: 5px 8px;
color: var(--vscode-textLink-foreground);
background: transparent;
border: 0;
border-radius: 4px;
font-size: type.$font-size-xs;
&:hover { background: var(--vscode-list-hoverBackground); }
}
}
+237
View File
@@ -0,0 +1,237 @@
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon, type IconProps } from "./Icon";
import { checkIcon, chevronDownIcon, windowCloseIcon } from "./icons";
import { VirtualList } from "../virtual/VirtualList";
import styles from "./ModelSelect.module.scss";
export type ModelSelectOption = {
value: string;
label: string;
group: string;
icon?: IconProps["icon"];
iconSrc?: string;
};
type ModelSelectProps = {
label: string;
options: ModelSelectOption[];
disabled?: boolean;
} & (
| { mode: "single"; value: string; onChange: (value: string) => void }
| { mode: "multiple"; value: string[]; onChange: (value: string[]) => void }
);
type MenuItem =
| { kind: "group"; key: string; label: string; options: ModelSelectOption[] }
| { kind: "option"; key: string; option: ModelSelectOption };
function groupedItems(options: ModelSelectOption[]): MenuItem[] {
const groups = new Map<string, ModelSelectOption[]>();
for (const option of options) {
const group = groups.get(option.group);
if (group) group.push(option);
else groups.set(option.group, [option]);
}
return [...groups].flatMap(([group, groupOptions]) => [
{ kind: "group" as const, key: `group:${group}`, label: group, options: groupOptions },
...groupOptions.map((option) => ({ kind: "option" as const, key: `option:${option.value}`, option })),
]);
}
function GroupCheckbox({ label, options, selected, onChange }: {
label: string;
options: ModelSelectOption[];
selected: Set<string>;
onChange: (checked: boolean) => void;
}) {
const input = useRef<HTMLInputElement>(null);
const selectedCount = options.filter((option) => selected.has(option.value)).length;
const checked = selectedCount === options.length;
const indeterminate = selectedCount > 0 && !checked;
useEffect(() => {
if (input.current) input.current.indeterminate = indeterminate;
}, [indeterminate]);
return <label className={styles.group}>
<input
ref={input}
type="checkbox"
checked={checked}
aria-checked={indeterminate ? "mixed" : checked}
onChange={(event) => onChange(event.target.checked)}
/>
<span className={styles.checkbox} data-indeterminate={indeterminate || undefined} aria-hidden="true">
{checked && <Icon icon={checkIcon} size="0.875em" />}
</span>
<span>{label}</span>
</label>;
}
export function ModelSelect(props: ModelSelectProps) {
const { label, options, disabled, mode } = props;
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const menu = useRef<HTMLDivElement>(null);
const firstOption = useRef<HTMLLabelElement>(null);
const menuId = useId();
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ left: 0, top: 0, width: 220, maxHeight: 280 });
const items = useMemo(() => groupedItems(options), [options]);
const selected = useMemo(
() => new Set(mode === "multiple" ? props.value : [props.value]),
[mode, props.value],
);
useLayoutEffect(() => {
if (!open || !root.current || !menu.current) return;
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
placement: "bottom-start",
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({
padding: 10,
apply: ({ rects, availableHeight }) => setPosition((current) => ({
...current,
width: rects.reference.width,
maxHeight: Math.max(120, availableHeight),
})),
})],
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
}, [open]);
const close = () => {
setOpen(false);
trigger.current?.focus();
};
useEffect(() => {
if (!open) return;
firstOption.current?.focus();
const closeOutside = (event: PointerEvent) => {
const target = event.target as Node;
if (!root.current?.contains(target) && !menu.current?.contains(target)) close();
};
document.addEventListener("pointerdown", closeOutside);
return () => document.removeEventListener("pointerdown", closeOutside);
}, [open]);
const summary = mode === "multiple"
? props.value.length === 0 ? t("全部") : t("已选 {count} 项", { count: props.value.length })
: options.find((option) => option.value === props.value)?.label ?? props.value;
const choose = (option: ModelSelectOption) => {
if (mode === "single") {
props.onChange(option.value);
close();
return;
}
props.onChange(selected.has(option.value)
? props.value.filter((item) => item !== option.value)
: [...props.value, option.value]);
};
const toggleGroup = (groupOptions: ModelSelectOption[], checked: boolean) => {
if (mode !== "multiple") return;
const groupValues = new Set(groupOptions.map((option) => option.value));
props.onChange(checked
? [...props.value, ...groupOptions.filter((option) => !selected.has(option.value)).map((option) => option.value)]
: props.value.filter((value) => !groupValues.has(value)));
};
const openMenu = () => {
if (disabled) return;
const width = root.current?.getBoundingClientRect().width;
if (width) setPosition((current) => ({ ...current, width }));
setOpen(true);
};
return <>
<div ref={root} className={styles.target} data-open={open || undefined} data-disabled={disabled || undefined}>
<button
ref={trigger}
type="button"
className={styles.trigger}
disabled={disabled}
aria-label={t("筛选项:{label},{summary}", { label, summary })}
aria-haspopup="listbox"
aria-controls={open ? menuId : undefined}
aria-expanded={open}
onClick={() => { if (open) close(); else openMenu(); }}
onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); openMenu(); } }}
>
<span>{label}</span><span className={styles.summary} aria-hidden="true">{summary}</span><Icon icon={chevronDownIcon} size="1.1em" />
</button>
{mode === "multiple" && props.value.length > 0 && <button
type="button"
className={styles.clear}
aria-label={t("清除{label}筛选", { label })}
onClick={() => props.onChange([])}
><Icon icon={windowCloseIcon} size="1.1em" /></button>}
</div>
{open && createPortal(<div
id={menuId}
ref={menu}
className={styles.menu}
aria-label={label}
style={position}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); close(); } }}
>
<div
className={styles.listFrame}
role="listbox"
aria-label={label}
aria-multiselectable={mode === "multiple" ? "true" : undefined}
style={{ height: Math.min(items.length * 32, Math.max(32, position.maxHeight - (mode === "multiple" ? 45 : 0))) }}
><VirtualList
items={items}
itemKey="key"
className={styles.list}
style={{ height: "100%" }}
estimatedItemHeight={32}
overscan={4}
empty={<div className={styles.empty}>{t("暂无选项")}</div>}
>{(item, index) => item.kind === "group"
? mode === "multiple"
? <GroupCheckbox
label={item.label}
options={item.options}
selected={selected}
onChange={(checked) => toggleGroup(item.options, checked)}
/>
: <div className={styles.groupLabel}>{item.label}</div>
: <label
ref={index === 1 ? firstOption : undefined}
className={styles.optionRow}
role="option"
aria-selected={selected.has(item.option.value)}
tabIndex={index === 1 ? 0 : -1}
onKeyDown={(event) => {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
choose(item.option);
}
}}
>
<input
type="checkbox"
checked={selected.has(item.option.value)}
tabIndex={-1}
onChange={() => choose(item.option)}
/>
<span className={styles.checkbox} aria-hidden="true">
{selected.has(item.option.value) && <Icon icon={checkIcon} size="0.875em" />}
</span>
<span className={styles.option}>
{(item.option.icon || item.option.iconSrc) && <Icon icon={item.option.icon} src={item.option.iconSrc} />}
<span>{item.option.label}</span>
</span>
</label>}</VirtualList></div>
{mode === "multiple" && options.length > 0 && <div className={styles.footer}>
<button type="button" onClick={() => props.onChange(options.map((option) => option.value))}>{t("全选")}</button>
<button type="button" onClick={() => props.onChange([])}>{t("全不选")}</button>
</div>}
</div>, document.body)}
</>;
}
-127
View File
@@ -1,127 +0,0 @@
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon, type IconProps } from "./Icon";
import { checkIcon, chevronDownIcon, windowCloseIcon } from "./icons";
import { VirtualList } from "../virtual/VirtualList";
import styles from "./MultiSelect.module.scss";
export type MultiSelectOption = {
value: string;
label: string;
icon?: IconProps["icon"];
};
export function MultiSelect({ label, value, options, onChange }: {
label: string;
value: string[];
options: MultiSelectOption[];
onChange: (value: string[]) => void;
}) {
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const menu = useRef<HTMLDivElement>(null);
const firstOption = useRef<HTMLButtonElement>(null);
const menuId = useId();
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ left: 0, top: 0, width: 220, maxHeight: 280 });
useLayoutEffect(() => {
if (!open || !root.current || !menu.current) return;
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
placement: "bottom-start",
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({
padding: 10,
apply: ({ rects, availableHeight }) => setPosition((current) => ({
...current,
width: rects.reference.width,
maxHeight: Math.max(120, availableHeight),
})),
})],
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
}, [open]);
useEffect(() => {
if (!open) return;
firstOption.current?.focus();
const closeOutside = (event: PointerEvent) => {
const target = event.target as Node;
if (!root.current?.contains(target) && !menu.current?.contains(target)) setOpen(false);
};
document.addEventListener("pointerdown", closeOutside);
return () => document.removeEventListener("pointerdown", closeOutside);
}, [open]);
const selected = new Set(value);
const summary = value.length === 0 ? t("全部") : t("已选 {count} 项", { count: value.length });
const toggle = (option: string) => onChange(selected.has(option)
? value.filter((item) => item !== option)
: [...value, option]);
const close = () => { setOpen(false); trigger.current?.focus(); };
const openMenu = () => {
const width = root.current?.getBoundingClientRect().width;
if (width) setPosition((current) => ({ ...current, width }));
setOpen(true);
};
return <>
<div ref={root} className={styles.target} data-open={open || undefined}>
<button
ref={trigger}
type="button"
className={styles.trigger}
aria-label={t("筛选项:{label},{summary}", { label, summary })}
aria-haspopup="listbox"
aria-controls={open ? menuId : undefined}
aria-expanded={open}
onClick={() => { if (open) close(); else openMenu(); }}
onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); openMenu(); } }}
>
<span>{label}</span><span className={styles.summary} aria-hidden="true">{summary}</span><Icon icon={chevronDownIcon} size="1.1em" />
</button>
{value.length > 0 && <button type="button" className={styles.clear} aria-label={t("清除{label}筛选", { label })} onClick={() => onChange([])}>
<Icon icon={windowCloseIcon} size="1.1em" />
</button>}
</div>
{open && createPortal(<div
id={menuId}
ref={menu}
className={styles.menu}
aria-label={label}
style={position}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); close(); } }}
>
<div
className={styles.listFrame}
role="listbox"
aria-label={label}
aria-multiselectable="true"
style={{ height: Math.min(options.length * 32, Math.max(32, position.maxHeight - 45)) }}
><VirtualList
items={options}
itemKey="value"
className={styles.list}
style={{ height: "100%" }}
estimatedItemHeight={32}
overscan={4}
empty={<div className={styles.empty}>{t("暂无选项")}</div>}
>{(option, index) => <button
ref={index === 0 ? firstOption : undefined}
type="button"
role="option"
aria-label={option.label}
aria-selected={selected.has(option.value)}
onClick={() => toggle(option.value)}
>
<span className={styles.option}>{option.icon && <Icon icon={option.icon} />}<span>{option.label}</span></span>
<span className={styles.check} aria-hidden="true">{selected.has(option.value) && <Icon icon={checkIcon} size="1.1em" />}</span>
</button>}</VirtualList></div>
{options.length > 0 && <div className={styles.footer}>
<button type="button" onClick={() => onChange(options.map((option) => option.value))}>{t("全选")}</button>
<button type="button" onClick={() => onChange([])}>{t("全不选")}</button>
</div>}
</div>, document.body)}
</>;
}
@@ -62,55 +62,6 @@
border-color: var(--vscode-focusBorder);
}
}
.multiCombo {
min-width: 0;
min-height: 34px;
flex: 1;
display: flex;
align-items: stretch;
overflow: hidden;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border, transparent);
border-radius: 6px;
&:focus-within,
&[data-open] {
border-color: var(--vscode-focusBorder);
}
}
.multiValues {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
> input {
min-width: 120px;
min-height: 24px;
flex: 1;
padding: 0 3px;
color: var(--vscode-input-foreground);
background: transparent;
border: 0;
outline: 0;
font-size: type.$font-size-xs;
}
}
.multiCount {
height: 24px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
padding: 0 3px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
white-space: nowrap;
}
.combo input {
min-width: 0;
flex: 1;
@@ -172,23 +123,3 @@
}
}
}
.menu .multiOption {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
> span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.empty {
padding: 8px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
+38 -90
View File
@@ -1,10 +1,10 @@
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { VirtualList } from "../virtual/VirtualList";
import type { VirtualListApi } from "../virtual/virtualTypes";
import { Icon, type IconProps } from "./Icon";
import { checkIcon, chevronDownIcon } from "./icons";
import { chevronDownIcon } from "./icons";
import styles from "./Select.module.scss";
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"]; iconSrc?: string };
@@ -57,19 +57,44 @@ export function Select({ value, options, disabled, ariaLabel, onChange }: { valu
</>;
}
export function Combobox({ value, options = [], placeholder, disabled, append, onChange }: { value: string; options?: string[]; placeholder?: string; disabled?: boolean; append?: ReactNode; onChange: (value: string) => void }) {
export type ComboboxHandle = {
openAll: () => void;
};
type ComboboxProps = {
value: string;
options?: string[];
placeholder?: string;
disabled?: boolean;
append?: ReactNode;
onChange: (value: string) => void;
};
export const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combobox({ value, options = [], placeholder, disabled, append, onChange }, ref) {
const root = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null);
const menu = useRef<HTMLDivElement>(null);
const listApi = useRef<VirtualListApi | null>(null);
const closeTimer = useRef<number | null>(null);
const menuId = useId();
const [open, setOpen] = useState(false);
const [filtering, setFiltering] = useState(true);
const [active, setActive] = useState(-1);
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 });
const filtered = useMemo(() => {
if (!filtering) return options;
const query = value.trim().toLocaleLowerCase();
return options.filter((option) => !query || option.toLocaleLowerCase().includes(query));
}, [options, value]);
}, [filtering, options, value]);
useImperativeHandle(ref, () => ({
openAll: () => {
if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
setFiltering(false);
setActive(0);
setOpen(true);
},
}), []);
useLayoutEffect(() => {
if (!open || !root.current || !menu.current) return;
@@ -79,6 +104,9 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
}, [open, filtered.length]);
useEffect(() => () => {
if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
}, []);
useEffect(() => {
if (!open) return;
const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false); };
@@ -91,8 +119,11 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
const openMenu = () => {
if (disabled || options.length === 0) return;
setFiltering(true);
setOpen(true);
const selected = filtered.indexOf(value);
const query = value.trim().toLocaleLowerCase();
const matchingOptions = options.filter((option) => !query || option.toLocaleLowerCase().includes(query));
const selected = matchingOptions.indexOf(value);
setActive(selected >= 0 ? selected : 0);
};
const choose = (option: string) => { onChange(option); setOpen(false); input.current?.focus(); };
@@ -102,7 +133,7 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
setActive((index) => (index < 0 ? 0 : (index + step + filtered.length) % filtered.length));
};
return <div className={styles.comboRow}><div ref={root} className={styles.combo}>
<input ref={input} value={value} placeholder={placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) openMenu(); }} onBlur={() => window.setTimeout(() => setOpen(false), 100)} onChange={(event) => { onChange(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
<input ref={input} value={value} placeholder={placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) openMenu(); }} onBlur={() => { closeTimer.current = window.setTimeout(() => setOpen(false), 100); }} onChange={(event) => { setFiltering(true); onChange(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
if (event.key === "Enter" && open && filtered[active]) { event.preventDefault(); choose(filtered[active]); }
@@ -115,88 +146,5 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
</VirtualList>
</div>, document.body)}
</div>{append}</div>;
}
});
export function MultiCombobox({ value, options = [], placeholder, disabled, append, onChange }: {
value: string[];
options?: string[];
placeholder?: string;
disabled?: boolean;
append?: ReactNode;
onChange: (value: string[]) => void;
}) {
const root = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null);
const menu = useRef<HTMLDivElement>(null);
const listApi = useRef<VirtualListApi | null>(null);
const menuId = useId();
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 });
const selected = useMemo(() => new Set(value), [value]);
const filtered = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase();
return options.filter((option) => !normalized || option.toLocaleLowerCase().includes(normalized));
}, [options, query]);
useLayoutEffect(() => {
if (!open || !root.current || !menu.current) return;
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
placement: "bottom-start",
middleware: [offset(6), flip({ padding: 12 }), shift({ padding: 12 }), size({ padding: 12, apply: ({ rects, availableHeight }) => setPosition((current) => ({ ...current, width: rects.reference.width, maxHeight: Math.max(160, availableHeight) })) })],
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
}, [filtered.length, open]);
useEffect(() => {
if (!open) return;
const outside = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", outside);
return () => document.removeEventListener("pointerdown", outside);
}, [open]);
useEffect(() => {
if (open && active >= 0) listApi.current?.scrollToIndex(active);
}, [active, open]);
const add = (modelId: string) => {
const normalized = modelId.trim();
if (normalized && !selected.has(normalized)) onChange([...value, normalized]);
setQuery("");
};
const toggle = (modelId: string) => {
onChange(selected.has(modelId) ? value.filter((item) => item !== modelId) : [...value, modelId]);
setQuery("");
input.current?.focus();
};
const move = (step: number) => {
if (!filtered.length) return;
setOpen(true);
setActive((index) => (index + step + filtered.length) % filtered.length);
};
return <div className={styles.comboRow}><div ref={root} className={styles.multiCombo} data-open={open || undefined}>
<div className={styles.multiValues}>
{value.length > 0 && <span className={styles.multiCount}>{t("已选择 {count} 个", { count: value.length })}</span>}
<input ref={input} value={query} placeholder={value.length ? t("继续选择或输入") : placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) setOpen(true); }} onBlur={() => add(query)} onChange={(event) => { setQuery(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
if (event.key === "Enter") {
event.preventDefault();
const normalized = query.trim();
const exact = filtered.find((option) => option.toLocaleLowerCase() === normalized.toLocaleLowerCase());
if (normalized && !exact) add(normalized);
else if (open && filtered[active]) toggle(filtered[active]);
}
if (event.key === "Backspace" && !query && value.length) onChange(value.slice(0, -1));
if (event.key === "Escape") { setOpen(false); input.current?.focus(); }
}} />
</div>
<button type="button" className={styles.comboToggle} disabled={disabled || !options.length} aria-label={t("打开模型列表")} aria-expanded={open} tabIndex={-1} onMouseDown={(event) => event.preventDefault()} onClick={() => { setOpen(!open); input.current?.focus(); }}><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" aria-multiselectable="true" style={{ left: position.left, top: position.top, width: position.width }} onPointerDown={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); setOpen(false); input.current?.focus(); } }}>
{filtered.length ? <VirtualList items={filtered} itemKey={(option) => option} estimatedItemHeight={30} onReady={(api) => { listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(filtered.length * 30, Math.max(30, position.maxHeight - 8)) }}>
{(option, index) => <button type="button" className={styles.multiOption} role="option" aria-selected={selected.has(option)} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onMouseDown={(event) => event.preventDefault()} onClick={() => toggle(option)}><span>{option}</span>{selected.has(option) && <Icon icon={checkIcon} size="1.1em" />}</button>}
</VirtualList> : <div className={styles.empty}>{t("输入模型名称后按 Enter 添加")}</div>}
</div>, document.body)}
</div>{append}</div>;
}
+14 -3
View File
@@ -1,14 +1,25 @@
import type { ButtonHTMLAttributes } from "react";
import styles from "./Switch.module.scss";
export function Switch({ checked, disabled, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }) {
type SwitchProps = {
checked: boolean;
label: string;
onChange: (checked: boolean) => void;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label" | "children" | "onChange" | "role">;
export function Switch({ checked, disabled, label, onChange, onClick, ...props }: SwitchProps) {
return <button
{...props}
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
className={styles.root}
className={[styles.root, props.className].filter(Boolean).join(" ")}
data-checked={checked || undefined}
onClick={() => onChange(!checked)}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) onChange(!checked);
}}
><span /></button>;
}
@@ -0,0 +1,17 @@
import type { Model } from "../api";
export function modelProviderName(model: Pick<Model, "group_name" | "base_url">): string {
const configuredName = model.group_name?.trim();
if (configuredName) return configuredName;
const value = model.base_url.trim();
try {
return new URL(value).hostname.toLowerCase() || value;
} catch {
try {
return new URL(`https://${value}`).hostname.toLowerCase() || value;
} catch {
return value;
}
}
}
+23 -1
View File
@@ -83,6 +83,24 @@
}
}
.menuStatusTag {
flex: 0 0 auto;
margin-left: auto;
padding: 1px 5px;
color: var(--vscode-descriptionForeground);
background: color-mix(in srgb, var(--vscode-descriptionForeground) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--vscode-descriptionForeground) 28%, transparent);
border-radius: 999px;
font-size: type.$font-size-2xs;
line-height: 1.4;
&[data-taken] {
color: var(--vscode-testing-iconPassed, #73c991);
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 10%, transparent);
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 28%, transparent);
}
}
.menuIndicatorDot {
width: 7px;
height: 7px;
@@ -151,7 +169,11 @@
-ms-user-select: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
-webkit-app-region: drag;
// Page actions (e.g. the overview time-range bar and the floating refresh
// button) must not act as a window drag handle; the dedicated drag layer in
// AppHeader owns window dragging. Otherwise these controls get dragged and
// double-click maximizes the window.
-webkit-app-region: no-drag;
}
.actions {
+7 -1
View File
@@ -43,7 +43,7 @@ function loadStoredAdIds(key: string): Set<string> {
}
export function AppLayout() {
const { busy } = useAppStore();
const { busy, cursorHarness } = useAppStore();
const { availableVersion } = useUpdateStore();
const { locale } = useI18n();
const message = useMessage();
@@ -215,6 +215,12 @@ export function AppLayout() {
? <Icon src={item.icon} size="1.3em" />
: <Icon icon={item.icon} size="1.3em" />}
<span>{item.label}</span>
{item.path === "/harness/cursor" && cursorHarness && <span
className={styles.menuStatusTag}
data-taken={cursorHarness.settings_applied || undefined}
>
{cursorHarness.settings_applied ? t("已接管") : t("未接管")}
</span>}
{item.path === "/settings" && availableVersion && <span className={styles.menuIndicatorDot} aria-hidden="true" />}
</NavLink>
</div>}
+3 -3
View File
@@ -24,14 +24,14 @@
padding: 0 32px 0 8px;
color: inherit;
text-align: left;
background: transparent;
background: color-mix(in srgb, var(--vscode-foreground) 4%, transparent);
border: 1px solid var(--vscode-sideBar-border);
border-radius: 6px;
&:hover,
&[aria-expanded="true"] {
background: var(--vscode-list-hoverBackground);
border: 1px solid var(--vscode-sideBar-border);
background: color-mix(in srgb, var(--vscode-foreground) 9%, transparent);
border-color: var(--vscode-sideBar-border);
}
&:focus-visible {