дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-14 21:55:44 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
fd0c8a9f05 |
No files matched your search
Створено
@@ -4146,7 +4146,6 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
name = "ide-agent"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"ide-core",
|
||||
|
||||
@@ -7,7 +7,6 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs.workspace = true
|
||||
blake3.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-mcp.workspace = true
|
||||
@@ -25,6 +24,3 @@ dunce.workspace = true
|
||||
uuid.workspace = true
|
||||
tempfile.workspace = true
|
||||
which = "8"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
@@ -3,7 +3,6 @@
|
||||
//! `initialize` alone).
|
||||
|
||||
use super::{Conn, Dialect};
|
||||
use crate::catalogs::Discovered;
|
||||
use crate::config;
|
||||
use crate::model::AgentConfigOption;
|
||||
use crate::provider::ProviderSpec;
|
||||
@@ -16,7 +15,7 @@ use std::time::Duration;
|
||||
const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else { Duration::from_secs(120) };
|
||||
|
||||
/// Opens `session/new`, reads `configOptions`, and records per-model `thought_level` choices.
|
||||
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Discovered, String> {
|
||||
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<(), String> {
|
||||
let root = shared.paths.project_root.clone();
|
||||
let launch = spec.clone();
|
||||
let launch = tokio::task::spawn_blocking({
|
||||
@@ -26,12 +25,12 @@ pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Dis
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let cwd = dunce::canonicalize(&root).unwrap_or(root);
|
||||
let updates = Arc::new(DiscoveryUpdates { provider: spec.id.clone(), commands: Mutex::new(None) });
|
||||
let client = Dialect::wrap(updates.clone());
|
||||
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(|_| {});
|
||||
let stderr: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||
let lines = stderr.clone();
|
||||
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(move |l| lines.lock().push(l));
|
||||
let client = Dialect::wrap(Arc::new(ide_jsonrpc::NoopHandler));
|
||||
let peer = (shared.connector)(&launch, &cwd, client.clone(), on_stderr)
|
||||
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", launch.name, launch.command))?;
|
||||
let _stop = crate::catalogs::KillPeer(peer.clone());
|
||||
let conn = Conn::new(peer.clone(), client);
|
||||
let work = async {
|
||||
conn.initialize(&client_info()).await.map_err(|e| e.to_string())?;
|
||||
@@ -52,41 +51,34 @@ pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Dis
|
||||
(Some(model), Some(effort)) => model.current.clone().map(|m| vec![(m, effort)]).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let commands = updates
|
||||
.commands
|
||||
.lock()
|
||||
.clone()
|
||||
.or_else(|| new.get("availableCommands").map(|_| config::commands_of(&new, &launch.id)));
|
||||
Ok::<_, String>(Discovered { config: options, commands, levels: levels.into_iter().collect() })
|
||||
let option_ids: Vec<_> = options.iter().map(|o| o.id.as_str()).collect();
|
||||
tracing::info!(
|
||||
"agent {}: settings discovery got {} config option(s) {:?}, {} model effort list(s)",
|
||||
launch.id,
|
||||
options.len(),
|
||||
option_ids,
|
||||
levels.len()
|
||||
);
|
||||
shared.remember_discovered(&launch.id, &options, &[], &levels);
|
||||
Ok::<(), String>(())
|
||||
};
|
||||
let result = tokio::time::timeout(DISCOVER_TIMEOUT, work).await;
|
||||
peer.kill();
|
||||
match result {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err("the agent did not answer in time".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DiscoveryUpdates {
|
||||
provider: String,
|
||||
commands: Mutex<Option<Vec<crate::AgentCommand>>>,
|
||||
}
|
||||
|
||||
impl ide_jsonrpc::Handler for DiscoveryUpdates {
|
||||
fn on_notification(&self, method: &str, params: Value) {
|
||||
if method == "session/update"
|
||||
&& let Some(update) = params.get("update")
|
||||
&& update["sessionUpdate"] == "available_commands_update"
|
||||
{
|
||||
// Commands are protocol data, not stderr (which may contain credentials).
|
||||
*self.commands.lock() = Some(config::commands_of(update, &self.provider));
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Ok(Err(e)) => Err(match stderr.lock().last() {
|
||||
Some(line) => format!("{e}: {line}"),
|
||||
None => e,
|
||||
}),
|
||||
Err(_) => {
|
||||
let tail = stderr.lock().join("\n");
|
||||
if tail.is_empty() {
|
||||
Err("the agent did not answer in time".into())
|
||||
} else {
|
||||
Err(format!("the agent did not answer in time:\n{tail}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_request(&self, method: &str, _: Value) -> Result<Value, (i64, String)> {
|
||||
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unsupported discovery request {method}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_thought_level(o: &AgentConfigOption) -> bool {
|
||||
|
||||
@@ -108,7 +108,6 @@ fn manager(dir: &Path, agent: Arc<FakeV2Agent>) -> AgentManager {
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
catalogs: Arc::default(),
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
|
||||
@@ -155,7 +155,6 @@ async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent()
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
catalogs: Arc::default(),
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
//! Model availability for new tasks. Sessions own selections; discovery owns catalogs.
|
||||
|
||||
use crate::{
|
||||
AgentCommand, AgentConfigOption, AgentEvent, AgentModelCatalog, Known, Shared, acp, config, native, provider,
|
||||
};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::Instant;
|
||||
|
||||
const FRESH_FOR: Duration = Duration::from_secs(15 * 60);
|
||||
const COOLDOWN: Duration = Duration::from_secs(30);
|
||||
|
||||
type Store = Arc<Mutex<HashMap<String, Known>>>;
|
||||
type Key = (PathBuf, PathBuf, String);
|
||||
|
||||
/// Backend-wide single-flight model lookups, scoped to project and effective launch configuration.
|
||||
#[derive(Default)]
|
||||
pub struct AgentCatalogs {
|
||||
state: Mutex<State>,
|
||||
#[cfg(test)]
|
||||
clock_ms: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
stores: HashMap<PathBuf, Store>,
|
||||
entries: HashMap<Key, Entry>,
|
||||
subscribers: Vec<Weak<Shared>>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
// Launch credentials remain only in memory, never serialized or logged.
|
||||
context: LaunchContext,
|
||||
generation: u64,
|
||||
completed: Option<Instant>,
|
||||
successful: Option<Instant>,
|
||||
retry_at_ms: Option<f64>,
|
||||
error: Option<String>,
|
||||
flight: Option<watch::Receiver<bool>>,
|
||||
abort: Option<tokio::task::AbortHandle>,
|
||||
catalog: Option<CatalogData>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CatalogData {
|
||||
pub config: Vec<AgentConfigOption>,
|
||||
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
|
||||
pub refreshed_at_ms: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SavedCatalog {
|
||||
/// An opaque cryptographic digest, never raw launch arguments or credentials.
|
||||
fingerprint: String,
|
||||
#[serde(flatten)]
|
||||
catalog: CatalogData,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct LaunchContext {
|
||||
spec: provider::ProviderSpec,
|
||||
executable: PathBuf,
|
||||
modified: Option<std::time::SystemTime>,
|
||||
len: Option<u64>,
|
||||
}
|
||||
|
||||
impl LaunchContext {
|
||||
fn new(spec: provider::ProviderSpec, root: &std::path::Path) -> Self {
|
||||
let resolved = spec.clone().resolve_for_launch(root);
|
||||
let executable = dunce::canonicalize(&resolved.command).unwrap_or_else(|_| PathBuf::from(&resolved.command));
|
||||
let metadata = std::fs::metadata(&executable).ok();
|
||||
Self {
|
||||
spec,
|
||||
executable,
|
||||
modified: metadata.as_ref().and_then(|m| m.modified().ok()),
|
||||
len: metadata.as_ref().map(std::fs::Metadata::len),
|
||||
}
|
||||
}
|
||||
|
||||
fn fingerprint(&self) -> String {
|
||||
let mut hash = blake3::Hasher::new();
|
||||
fn field(hash: &mut blake3::Hasher, value: &[u8]) {
|
||||
hash.update(&(value.len() as u64).to_le_bytes());
|
||||
hash.update(value);
|
||||
}
|
||||
field(&mut hash, b"openidea-model-catalog-v1");
|
||||
field(
|
||||
&mut hash,
|
||||
match self.spec.driver {
|
||||
provider::Driver::Acp => b"acp",
|
||||
provider::Driver::Codex => b"codex",
|
||||
provider::Driver::Claude => b"claude",
|
||||
},
|
||||
);
|
||||
field(&mut hash, self.spec.command.as_bytes());
|
||||
hash.update(&(self.spec.args.len() as u64).to_le_bytes());
|
||||
for arg in &self.spec.args {
|
||||
field(&mut hash, arg.as_bytes());
|
||||
}
|
||||
hash.update(&(self.spec.env.len() as u64).to_le_bytes());
|
||||
for (name, value) in &self.spec.env {
|
||||
field(&mut hash, name.as_bytes());
|
||||
field(&mut hash, value.as_bytes());
|
||||
}
|
||||
field(&mut hash, self.executable.to_string_lossy().as_bytes());
|
||||
if let Some(modified) = self.modified.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) {
|
||||
hash.update(&modified.as_nanos().to_le_bytes());
|
||||
}
|
||||
if let Some(len) = self.len {
|
||||
hash.update(&len.to_le_bytes());
|
||||
}
|
||||
hash.finalize().to_hex().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Discovered {
|
||||
pub config: Vec<AgentConfigOption>,
|
||||
pub commands: Option<Vec<AgentCommand>>,
|
||||
pub levels: BTreeMap<String, Vec<AgentConfigOption>>,
|
||||
}
|
||||
|
||||
pub(crate) struct KillPeer(pub ide_jsonrpc::Peer);
|
||||
|
||||
impl Drop for KillPeer {
|
||||
fn drop(&mut self) {
|
||||
self.0.kill();
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentCatalogs {
|
||||
fn now(&self) -> Instant {
|
||||
let now = Instant::now();
|
||||
#[cfg(test)]
|
||||
let now = now + Duration::from_millis(self.clock_ms.load(std::sync::atomic::Ordering::Relaxed));
|
||||
now
|
||||
}
|
||||
|
||||
fn epoch_ms(&self) -> f64 {
|
||||
let now = crate::now_ms();
|
||||
#[cfg(test)]
|
||||
let now = now + self.clock_ms.load(std::sync::atomic::Ordering::Relaxed) as f64;
|
||||
now
|
||||
}
|
||||
|
||||
pub(crate) fn store(&self, path: &std::path::Path) -> Store {
|
||||
self.state
|
||||
.lock()
|
||||
.stores
|
||||
.entry(path.to_path_buf())
|
||||
.or_insert_with(|| {
|
||||
Arc::new(Mutex::new(
|
||||
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(),
|
||||
))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe(&self, shared: &Arc<Shared>) {
|
||||
let mut state = self.state.lock();
|
||||
state.subscribers.retain(|s| s.strong_count() > 0);
|
||||
state.subscribers.push(Arc::downgrade(shared));
|
||||
}
|
||||
|
||||
fn key(shared: &Shared, provider: &str) -> Key {
|
||||
(shared.paths.models_file.clone(), shared.paths.project_root.clone(), provider.to_owned())
|
||||
}
|
||||
|
||||
fn fallback(shared: &Shared, provider: &str) -> CatalogData {
|
||||
let cache = shared.known.lock();
|
||||
let Some(known) = cache.get(provider) else { return CatalogData::default() };
|
||||
known.catalogs.get(&shared.root_key()).map(|saved| saved.catalog.clone()).unwrap_or_else(|| CatalogData {
|
||||
config: known.config.clone(),
|
||||
thought_levels: known.thought_levels.clone(),
|
||||
refreshed_at_ms: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn cached(&self, shared: &Shared, provider: &str) -> CatalogData {
|
||||
self.state
|
||||
.lock()
|
||||
.entries
|
||||
.get(&Self::key(shared, provider))
|
||||
.and_then(|e| e.catalog.clone())
|
||||
.unwrap_or_else(|| Self::fallback(shared, provider))
|
||||
}
|
||||
|
||||
fn snapshot(&self, shared: &Shared, provider: &str, entry: &Entry) -> AgentModelCatalog {
|
||||
let catalog = entry.catalog.clone().unwrap_or_else(|| Self::fallback(shared, provider));
|
||||
AgentModelCatalog {
|
||||
provider: provider.to_owned(),
|
||||
config: shared.config_for_new_task(catalog.config, provider),
|
||||
thought_levels: catalog.thought_levels,
|
||||
refreshing: entry.flight.is_some(),
|
||||
refreshed_at_ms: catalog.refreshed_at_ms,
|
||||
retry_at_ms: entry
|
||||
.retry_at_ms
|
||||
.filter(|_| entry.completed.is_some_and(|at| self.now().duration_since(at) < COOLDOWN)),
|
||||
error: entry.error.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start or join a lookup. No queued refresh can outlive the request that asked for it.
|
||||
pub(crate) fn request(
|
||||
self: &Arc<Self>,
|
||||
shared: &Arc<Shared>,
|
||||
spec: provider::ProviderSpec,
|
||||
force: bool,
|
||||
) -> (AgentModelCatalog, Option<watch::Receiver<bool>>) {
|
||||
let key = Self::key(shared, &spec.id);
|
||||
let context = LaunchContext::new(spec.clone(), &shared.paths.project_root);
|
||||
let now = self.now();
|
||||
let mut state = self.state.lock();
|
||||
let changed = state.entries.get(&key).is_none_or(|e| e.context != context);
|
||||
if changed {
|
||||
if let Some(abort) = state.entries.get_mut(&key).and_then(|entry| entry.abort.take()) {
|
||||
abort.abort();
|
||||
}
|
||||
state.generation += 1;
|
||||
let generation = state.generation;
|
||||
let saved =
|
||||
shared.known.lock().get(&spec.id).and_then(|known| known.catalogs.get(&shared.root_key())).cloned();
|
||||
let matching = saved.as_ref().is_some_and(|saved| saved.fingerprint == context.fingerprint());
|
||||
let age = saved
|
||||
.as_ref()
|
||||
.filter(|_| matching)
|
||||
.and_then(|saved| saved.catalog.refreshed_at_ms)
|
||||
.map(|at| self.epoch_ms() - at)
|
||||
.filter(|age| *age >= 0.0 && *age < FRESH_FOR.as_millis() as f64)
|
||||
.map(|age| Duration::from_secs_f64(age / 1000.0));
|
||||
let successful = age.and_then(|age| now.checked_sub(age));
|
||||
let completed = age.filter(|age| *age < COOLDOWN).and_then(|age| now.checked_sub(age));
|
||||
let retry_at_ms = completed
|
||||
.and_then(|_| saved.as_ref().and_then(|s| s.catalog.refreshed_at_ms))
|
||||
.map(|at| at + COOLDOWN.as_millis() as f64);
|
||||
let catalog = saved.map(|saved| {
|
||||
let mut catalog = saved.catalog;
|
||||
if !matching {
|
||||
catalog.refreshed_at_ms = None;
|
||||
}
|
||||
catalog
|
||||
});
|
||||
state.entries.insert(
|
||||
key.clone(),
|
||||
Entry {
|
||||
context,
|
||||
generation,
|
||||
completed,
|
||||
successful,
|
||||
retry_at_ms,
|
||||
catalog,
|
||||
error: None,
|
||||
flight: None,
|
||||
abort: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let entry = state.entries.get_mut(&key).expect("catalog entry");
|
||||
let cooling = entry.completed.is_some_and(|at| now.duration_since(at) < COOLDOWN);
|
||||
let fresh = entry.successful.is_some_and(|at| now.duration_since(at) < FRESH_FOR);
|
||||
let mut start = None;
|
||||
if entry.flight.is_none() && !cooling && (force || !fresh) {
|
||||
let (done, ready) = watch::channel(false);
|
||||
entry.flight = Some(ready);
|
||||
entry.error = None;
|
||||
entry.retry_at_ms = None;
|
||||
start = Some((entry.generation, done));
|
||||
}
|
||||
let snapshot = self.snapshot(shared, &spec.id, entry);
|
||||
let flight = entry.flight.clone();
|
||||
drop(state);
|
||||
if let Some((generation, done)) = start {
|
||||
self.broadcast(shared, &snapshot);
|
||||
let coordinator = self.clone();
|
||||
let shared = shared.clone();
|
||||
let flight_key = key.clone();
|
||||
let task = shared.runtime.clone().spawn(async move {
|
||||
let result = discover(&shared, spec).await;
|
||||
coordinator.complete(&shared, flight_key, generation, result);
|
||||
let _ = done.send(true);
|
||||
});
|
||||
// A subscriber can re-enter and change configuration during the start event.
|
||||
let mut state = self.state.lock();
|
||||
if let Some(entry) = state.entries.get_mut(&key).filter(|entry| entry.generation == generation) {
|
||||
if entry.flight.is_some() {
|
||||
entry.abort = Some(task.abort_handle());
|
||||
}
|
||||
} else {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
(snapshot, flight)
|
||||
}
|
||||
|
||||
fn complete(&self, shared: &Shared, key: Key, generation: u64, result: Result<Discovered, String>) {
|
||||
let current = provider::load(shared.paths.custom_agents_dir.as_deref(), shared.env_secrets())
|
||||
.into_iter()
|
||||
.find(|spec| spec.id == key.2)
|
||||
.map(|spec| LaunchContext::new(spec, &shared.paths.project_root));
|
||||
let mut state = self.state.lock();
|
||||
let Some(entry) = state.entries.get_mut(&key).filter(|e| e.generation == generation) else { return };
|
||||
// Configuration may have changed without another refresh request arriving.
|
||||
if current.as_ref() != Some(&entry.context) {
|
||||
entry.flight = None;
|
||||
entry.abort = None;
|
||||
entry.completed = None;
|
||||
entry.retry_at_ms = None;
|
||||
entry.error = None;
|
||||
let snapshot = self.snapshot(shared, &key.2, entry);
|
||||
state.entries.remove(&key);
|
||||
drop(state);
|
||||
self.broadcast(shared, &snapshot);
|
||||
return;
|
||||
}
|
||||
let now = self.now();
|
||||
entry.completed = Some(now);
|
||||
entry.retry_at_ms = Some(self.epoch_ms() + COOLDOWN.as_millis() as f64);
|
||||
entry.flight = None;
|
||||
entry.abort = None;
|
||||
let success = match result {
|
||||
Ok(found) => {
|
||||
let mut cache = shared.known.lock();
|
||||
let known = cache.entry(key.2.clone()).or_default();
|
||||
let catalog = CatalogData {
|
||||
config: found.config,
|
||||
thought_levels: found.levels,
|
||||
refreshed_at_ms: Some(self.epoch_ms()),
|
||||
};
|
||||
known.config = catalog.config.clone();
|
||||
known.thought_levels = catalog.thought_levels.clone();
|
||||
if let Some(commands) = found.commands {
|
||||
known.commands.insert(shared.root_key(), commands);
|
||||
}
|
||||
known.authoritative = true;
|
||||
known.refreshed_at_ms = catalog.refreshed_at_ms;
|
||||
known.catalogs.insert(
|
||||
shared.root_key(),
|
||||
SavedCatalog { fingerprint: entry.context.fingerprint(), catalog: catalog.clone() },
|
||||
);
|
||||
entry.catalog = Some(catalog);
|
||||
shared.persist_known(&cache);
|
||||
entry.successful = Some(now);
|
||||
entry.error = None;
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
entry.error = Some(error);
|
||||
false
|
||||
}
|
||||
};
|
||||
let snapshot = self.snapshot(shared, &key.2, entry);
|
||||
drop(state);
|
||||
self.broadcast(shared, &snapshot);
|
||||
if success {
|
||||
for subscriber in self.subscribers(shared) {
|
||||
subscriber.emit(AgentEvent::Providers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribers(&self, shared: &Shared) -> Vec<Arc<Shared>> {
|
||||
self.state
|
||||
.lock()
|
||||
.subscribers
|
||||
.iter()
|
||||
.filter_map(Weak::upgrade)
|
||||
.filter(|s| {
|
||||
s.paths.models_file == shared.paths.models_file && s.paths.project_root == shared.paths.project_root
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn broadcast(&self, shared: &Shared, catalog: &AgentModelCatalog) {
|
||||
for subscriber in self.subscribers(shared) {
|
||||
subscriber.emit(AgentEvent::ModelCatalog { catalog: catalog.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::AgentManager {
|
||||
/// Return cached choices immediately and schedule discovery when eligible.
|
||||
pub fn refresh_models(&self, provider: &str, force: bool) -> IdeResult<AgentModelCatalog> {
|
||||
// Settings and environment secrets can change while this window is open.
|
||||
*self.shared.providers.write() =
|
||||
provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets());
|
||||
let spec = self.shared.spec(provider).ok_or_else(|| IdeError::invalid(format!("unknown agent {provider}")))?;
|
||||
Ok(self.shared.paths.catalogs.request(&self.shared, spec, force).0)
|
||||
}
|
||||
}
|
||||
|
||||
async fn discover(shared: &Arc<Shared>, spec: provider::ProviderSpec) -> Result<Discovered, String> {
|
||||
match spec.driver {
|
||||
provider::Driver::Acp => acp::discover::run(shared, &spec).await,
|
||||
provider::Driver::Codex => native::codex::discover(spec, shared.paths.project_root.clone()).await,
|
||||
provider::Driver::Claude => {
|
||||
let found = native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await?;
|
||||
Ok(Discovered {
|
||||
commands: Some(config::commands_of(
|
||||
&serde_json::json!({ "availableCommands": found.commands }),
|
||||
&spec.id,
|
||||
)),
|
||||
config: config::options_of(&serde_json::json!({ "configOptions": found.options })),
|
||||
levels: found
|
||||
.efforts
|
||||
.into_iter()
|
||||
.map(|(model, option)| {
|
||||
let levels = option
|
||||
.map(|o| config::options_of(&serde_json::json!({ "configOptions": [o] })))
|
||||
.unwrap_or_default();
|
||||
(model, levels)
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,501 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{AgentManager, AgentPaths, Connector};
|
||||
use ide_jsonrpc::{Framing, Peer};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
const PROVIDER: &str = "catalog-test";
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Pending {
|
||||
params: Value,
|
||||
reply: oneshot::Sender<Result<Value, String>>,
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
connector: Connector,
|
||||
pending: mpsc::UnboundedReceiver<Pending>,
|
||||
launches: Arc<AtomicUsize>,
|
||||
live: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
struct Live(Arc<AtomicUsize>);
|
||||
impl Drop for Live {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
let (pending_tx, pending) = mpsc::unbounded_channel();
|
||||
let launches = Arc::new(AtomicUsize::new(0));
|
||||
let live = Arc::new(AtomicUsize::new(0));
|
||||
let connector: Connector = Arc::new({
|
||||
let launches = launches.clone();
|
||||
let live = live.clone();
|
||||
move |_, _, client, _| {
|
||||
launches.fetch_add(1, Ordering::SeqCst);
|
||||
live.fetch_add(1, Ordering::SeqCst);
|
||||
let guard = Live(live.clone());
|
||||
let pending = pending_tx.clone();
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 16);
|
||||
let (read, mut write) = tokio::io::split(server_io);
|
||||
tokio::spawn(async move {
|
||||
let _guard = guard;
|
||||
let mut lines = BufReader::new(read).lines();
|
||||
while let Some(line) = lines.next_line().await.unwrap() {
|
||||
let request: Value = serde_json::from_str(&line).unwrap();
|
||||
let id = &request["id"];
|
||||
if id.is_null() {
|
||||
continue;
|
||||
}
|
||||
let response = match request["method"].as_str().unwrap() {
|
||||
"initialize" => json!({ "id": id, "jsonrpc": "2.0", "result": {
|
||||
"protocolVersion": 1, "agentCapabilities": {}, "authMethods": [] } }),
|
||||
"session/new" => {
|
||||
let (reply, result) = oneshot::channel();
|
||||
pending.send(Pending { params: request["params"].clone(), reply }).unwrap();
|
||||
let result = tokio::select! {
|
||||
result = result => result.unwrap(),
|
||||
_ = lines.next_line() => break,
|
||||
};
|
||||
match result {
|
||||
Ok(result) => json!({ "id": id, "jsonrpc": "2.0", "result": result }),
|
||||
Err(error) => {
|
||||
json!({ "id": id, "jsonrpc": "2.0", "error": { "code": -32000, "message": error } })
|
||||
}
|
||||
}
|
||||
}
|
||||
method => panic!("discovery must not call {method}"),
|
||||
};
|
||||
if write.write_all(format!("{response}\n").as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let (read, write) = tokio::io::split(client_io);
|
||||
Ok(Peer::connect_framed(read, write, client, Framing::Lines))
|
||||
}
|
||||
});
|
||||
Self { connector, pending, launches, live }
|
||||
}
|
||||
|
||||
async fn next(&mut self) -> Pending {
|
||||
tokio::time::timeout(Duration::from_secs(5), self.pending.recv()).await.unwrap().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn configure(root: &Path, version: &str) {
|
||||
std::fs::create_dir_all(root.join("agents")).unwrap();
|
||||
let executable = root.join("catalog-test-bin");
|
||||
if !executable.exists() {
|
||||
std::fs::write(&executable, "first executable").unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
root.join("agents/catalog-test.toml"),
|
||||
format!(
|
||||
"name = \"Catalog Test\"\ncommand = {}\n[env]\nTOKEN = \"secret-{version}\"\n",
|
||||
serde_json::to_string(&executable.to_string_lossy()).unwrap()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn make_manager(root: &Path, catalogs: Arc<AgentCatalogs>, harness: &Harness) -> AgentManager {
|
||||
make_manager_at(root, &root.join("models.json"), catalogs, harness)
|
||||
}
|
||||
|
||||
fn make_manager_at(root: &Path, models_file: &Path, catalogs: Arc<AgentCatalogs>, harness: &Harness) -> AgentManager {
|
||||
AgentManager::with_connector(
|
||||
AgentPaths {
|
||||
project_root: root.to_path_buf(),
|
||||
store_dir: root.join("store"),
|
||||
worktrees_dir: root.join("worktrees"),
|
||||
models_file: models_file.to_path_buf(),
|
||||
catalogs,
|
||||
custom_agents_dir: Some(root.join("agents")),
|
||||
home_dir: Some(root.to_path_buf()),
|
||||
env_secrets: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
harness.connector.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn events(manager: &AgentManager) -> mpsc::UnboundedReceiver<AgentEvent> {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
manager.subscribe(Arc::new(move |event| {
|
||||
let _ = tx.send(event);
|
||||
}));
|
||||
rx
|
||||
}
|
||||
|
||||
async fn completed(events: &mut mpsc::UnboundedReceiver<AgentEvent>) -> AgentModelCatalog {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let AgentEvent::ModelCatalog { catalog } = events.recv().await.unwrap()
|
||||
&& !catalog.refreshing
|
||||
{
|
||||
return catalog;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn response(models: &[&str], effort: &[&str]) -> Value {
|
||||
json!({ "sessionId": "discovery-only", "configOptions": [
|
||||
{ "id": "model", "type": "select", "category": "model", "currentValue": models.first(),
|
||||
"options": models.iter().map(|m| json!({ "value": m, "name": m })).collect::<Vec<_>>() },
|
||||
{ "id": "effort", "type": "select", "category": "thought_level", "currentValue": effort.first(),
|
||||
"options": effort.iter().map(|e| json!({ "value": e })).collect::<Vec<_>>() }
|
||||
] })
|
||||
}
|
||||
|
||||
fn model_values(catalog: &AgentModelCatalog) -> Vec<&str> {
|
||||
catalog
|
||||
.config
|
||||
.iter()
|
||||
.filter(|o| o.category.as_deref() == Some("model"))
|
||||
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn stopped(harness: &Harness) {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while harness.live.load(Ordering::SeqCst) != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn advance(catalogs: &AgentCatalogs, seconds: u64) {
|
||||
catalogs.clock_ms.fetch_add(seconds * 1000, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_refresh_replaces_models_and_efforts_and_old_sessions_cannot_revert_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
configure(tmp.path(), "one");
|
||||
let catalogs = Arc::new(AgentCatalogs::default());
|
||||
let mut harness = Harness::new();
|
||||
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
|
||||
let mut events = events(&manager);
|
||||
let old = config::options_of(&response(&["old", "retained"], &["low", "high"]));
|
||||
manager.shared.remember(PROVIDER, Some(&old), None, Some("old"));
|
||||
let cached = manager.refresh_models(PROVIDER, false).unwrap();
|
||||
assert!(cached.refreshing);
|
||||
assert_eq!(model_values(&cached), ["old", "retained"]);
|
||||
assert!(cached.refreshed_at_ms.is_none(), "session reports are not fresh discovery");
|
||||
let pending = harness.next().await;
|
||||
assert_eq!(pending.params["mcpServers"], json!([]));
|
||||
pending.reply.send(Ok(response(&["retained", "new"], &["medium"]))).unwrap();
|
||||
let fresh = completed(&mut events).await;
|
||||
assert_eq!(model_values(&fresh), ["retained", "new"]);
|
||||
assert_eq!(fresh.thought_levels.keys().map(String::as_str).collect::<Vec<_>>(), ["new", "retained"]);
|
||||
assert_eq!(fresh.thought_levels["retained"][0].values[0].value, "medium");
|
||||
assert!(fresh.refreshed_at_ms.is_some() && fresh.retry_at_ms.is_some());
|
||||
manager.shared.remember(PROVIDER, Some(&old), None, Some("old"));
|
||||
assert_eq!(manager.refresh_models(PROVIDER, false).unwrap().config, fresh.config);
|
||||
let provider = manager.providers().into_iter().find(|p| p.id == PROVIDER).unwrap();
|
||||
assert_eq!(provider.config, fresh.config);
|
||||
let persisted = std::fs::read_to_string(tmp.path().join("models.json")).unwrap();
|
||||
assert!(!persisted.contains("secret-one") && !persisted.contains("TOKEN"));
|
||||
let restored = make_manager(tmp.path(), Arc::default(), &harness);
|
||||
let restored_snapshot = restored.refresh_models(PROVIDER, false).unwrap();
|
||||
assert!(!restored_snapshot.refreshing, "matching persisted launch context remains fresh");
|
||||
assert_eq!(restored_snapshot.refreshed_at_ms, fresh.refreshed_at_ms);
|
||||
let provider = restored.providers().into_iter().find(|p| p.id == PROVIDER).unwrap();
|
||||
assert_eq!(provider.config, fresh.config);
|
||||
restored.shared.remember(PROVIDER, Some(&old), None, Some("old"));
|
||||
assert_eq!(restored.shared.known_config(PROVIDER), fresh.config);
|
||||
assert_eq!(restored.shared.known.lock()[PROVIDER].refreshed_at_ms, fresh.refreshed_at_ms);
|
||||
assert!(!tmp.path().join("store").exists(), "refresh never creates tasks");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_cache_loads_without_claiming_authoritative_freshness() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
configure(tmp.path(), "one");
|
||||
let options = config::options_of(&response(&["legacy"], &["low"]));
|
||||
std::fs::write(
|
||||
tmp.path().join("models.json"),
|
||||
serde_json::to_vec(&json!({
|
||||
(PROVIDER): { "config": options, "thoughtLevels": {}, "projectCommands": {} }
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut harness = Harness::new();
|
||||
let manager = make_manager(tmp.path(), Arc::default(), &harness);
|
||||
let mut events = events(&manager);
|
||||
let cached = manager.refresh_models(PROVIDER, false).unwrap();
|
||||
assert_eq!(model_values(&cached), ["legacy"]);
|
||||
assert!(cached.refreshing && cached.refreshed_at_ms.is_none());
|
||||
harness.next().await.reply.send(Err("Sign in required".into())).unwrap();
|
||||
let failed = completed(&mut events).await;
|
||||
assert_eq!(failed.config, cached.config);
|
||||
assert!(failed.error.as_deref().unwrap().contains("Sign in required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_windows_commands_and_manual_requests_share_one_flight_and_cooldown() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
configure(tmp.path(), "one");
|
||||
let catalogs = Arc::new(AgentCatalogs::default());
|
||||
let mut harness = Harness::new();
|
||||
let first = Arc::new(make_manager(tmp.path(), catalogs.clone(), &harness));
|
||||
let second = make_manager(tmp.path(), catalogs.clone(), &harness);
|
||||
let mut first_events = events(&first);
|
||||
let mut second_events = events(&second);
|
||||
assert!(first.refresh_models(PROVIDER, false).unwrap().refreshing);
|
||||
let pending = harness.next().await;
|
||||
for _ in 0..10 {
|
||||
assert!(second.refresh_models(PROVIDER, true).unwrap().refreshing);
|
||||
assert!(first.refresh_models(PROVIDER, true).unwrap().refreshing);
|
||||
}
|
||||
let commands = tokio::spawn({
|
||||
let first = first.clone();
|
||||
async move { first.commands(PROVIDER, None).await }
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(harness.launches.load(Ordering::SeqCst), 1);
|
||||
pending.reply.send(Ok(response(&["one"], &["low"]))).unwrap();
|
||||
let done = completed(&mut first_events).await;
|
||||
assert_eq!(completed(&mut second_events).await, done);
|
||||
commands.await.unwrap().unwrap();
|
||||
for force in [false, true, false, true] {
|
||||
assert!(!second.refresh_models(PROVIDER, force).unwrap().refreshing);
|
||||
}
|
||||
advance(&catalogs, 29);
|
||||
assert!(!first.refresh_models(PROVIDER, true).unwrap().refreshing);
|
||||
advance(&catalogs, 1);
|
||||
assert!(!first.refresh_models(PROVIDER, false).unwrap().refreshing, "automatic refresh remains fresh");
|
||||
assert!(first.refresh_models(PROVIDER, true).unwrap().refreshing);
|
||||
harness.next().await.reply.send(Ok(response(&["two"], &["high"]))).unwrap();
|
||||
let newer = completed(&mut first_events).await;
|
||||
assert_eq!(model_values(&newer), ["two"]);
|
||||
assert_eq!(completed(&mut second_events).await, newer);
|
||||
advance(&catalogs, 15 * 60 - 1);
|
||||
assert!(!second.refresh_models(PROVIDER, false).unwrap().refreshing);
|
||||
advance(&catalogs, 1);
|
||||
assert!(second.refresh_models(PROVIDER, false).unwrap().refreshing);
|
||||
harness.next().await.reply.send(Ok(response(&["three"], &["low"]))).unwrap();
|
||||
completed(&mut first_events).await;
|
||||
assert_eq!(harness.launches.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failure_keeps_successful_choices_and_throttles_manual_and_auto_retries() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
configure(tmp.path(), "one");
|
||||
let catalogs = Arc::new(AgentCatalogs::default());
|
||||
let mut harness = Harness::new();
|
||||
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
|
||||
let mut events = events(&manager);
|
||||
manager.refresh_models(PROVIDER, true).unwrap();
|
||||
harness.next().await.reply.send(Ok(response(&["cached"], &["low"]))).unwrap();
|
||||
let success = completed(&mut events).await;
|
||||
let disk = std::fs::read(tmp.path().join("models.json")).unwrap();
|
||||
advance(&catalogs, 15 * 60);
|
||||
manager.refresh_models(PROVIDER, false).unwrap();
|
||||
harness.next().await.reply.send(Err("Authentication expired".into())).unwrap();
|
||||
let failed = completed(&mut events).await;
|
||||
assert_eq!(failed.config, success.config);
|
||||
assert_eq!(failed.thought_levels, success.thought_levels);
|
||||
assert_eq!(failed.refreshed_at_ms, success.refreshed_at_ms);
|
||||
assert!(failed.error.as_deref().unwrap().contains("Authentication expired"));
|
||||
assert_eq!(std::fs::read(tmp.path().join("models.json")).unwrap(), disk);
|
||||
assert!(!manager.refresh_models(PROVIDER, false).unwrap().refreshing);
|
||||
assert!(!manager.refresh_models(PROVIDER, true).unwrap().refreshing);
|
||||
advance(&catalogs, 29);
|
||||
assert!(!manager.refresh_models(PROVIDER, true).unwrap().refreshing);
|
||||
advance(&catalogs, 1);
|
||||
assert!(manager.refresh_models(PROVIDER, false).unwrap().refreshing);
|
||||
harness.next().await.reply.send(Ok(response(&["recovered"], &["high"]))).unwrap();
|
||||
let recovered = completed(&mut events).await;
|
||||
assert_eq!(model_values(&recovered), ["recovered"]);
|
||||
assert!(recovered.error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_cleans_up_and_enters_the_same_failure_cooldown() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
configure(tmp.path(), "one");
|
||||
let catalogs = Arc::new(AgentCatalogs::default());
|
||||
let mut harness = Harness::new();
|
||||
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
|
||||
let mut events = events(&manager);
|
||||
manager.refresh_models(PROVIDER, false).unwrap();
|
||||
let pending = harness.next().await;
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(4)).await;
|
||||
let failed = completed(&mut events).await;
|
||||
assert!(failed.error.as_deref().unwrap().contains("time"));
|
||||
assert!(failed.retry_at_ms.is_some());
|
||||
assert!(!manager.refresh_models(PROVIDER, true).unwrap().refreshing);
|
||||
assert!(!manager.refresh_models(PROVIDER, false).unwrap().refreshing);
|
||||
tokio::time::resume();
|
||||
stopped(&harness).await;
|
||||
assert_eq!(harness.live.load(Ordering::SeqCst), 0, "timed-out connection must close");
|
||||
assert!(pending.reply.is_closed());
|
||||
advance(&catalogs, 30);
|
||||
assert!(manager.refresh_models(PROVIDER, false).unwrap().refreshing);
|
||||
harness.next().await.reply.send(Ok(response(&["retry"], &["low"]))).unwrap();
|
||||
completed(&mut events).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changed_effective_config_supersedes_old_completion_without_queueing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
configure(tmp.path(), "one");
|
||||
let catalogs = Arc::new(AgentCatalogs::default());
|
||||
let mut harness = Harness::new();
|
||||
let first = make_manager(tmp.path(), catalogs.clone(), &harness);
|
||||
let second = make_manager(tmp.path(), catalogs.clone(), &harness);
|
||||
let mut events = events(&second);
|
||||
first.refresh_models(PROVIDER, false).unwrap();
|
||||
let obsolete = harness.next().await;
|
||||
configure(tmp.path(), "two");
|
||||
assert!(second.refresh_models(PROVIDER, false).unwrap().refreshing);
|
||||
let latest = harness.next().await;
|
||||
latest.reply.send(Ok(response(&["latest"], &["high"]))).unwrap();
|
||||
let fresh = completed(&mut events).await;
|
||||
let _ = obsolete.reply.send(Ok(response(&["obsolete"], &["low"])));
|
||||
stopped(&harness).await;
|
||||
let shown = first.refresh_models(PROVIDER, false).unwrap();
|
||||
assert_eq!(shown.config, fresh.config);
|
||||
assert_eq!(shown.refreshed_at_ms, fresh.refreshed_at_ms);
|
||||
assert!(!shown.refreshing);
|
||||
assert_eq!(harness.launches.load(Ordering::SeqCst), 2);
|
||||
assert!(!std::fs::read_to_string(tmp.path().join("models.json")).unwrap().contains("obsolete"));
|
||||
// A changed configuration also invalidates successful freshness/cooldown.
|
||||
configure(tmp.path(), "three");
|
||||
assert!(first.refresh_models(PROVIDER, false).unwrap().refreshing);
|
||||
harness.next().await.reply.send(Ok(response(&["new-config"], &["medium"]))).unwrap();
|
||||
completed(&mut events).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_provider_is_a_request_error_and_does_not_launch() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let harness = Harness::new();
|
||||
let manager = make_manager(tmp.path(), Arc::default(), &harness);
|
||||
assert!(manager.refresh_models("missing-provider", true).is_err());
|
||||
assert_eq!(harness.launches.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacing_the_executable_at_the_same_path_invalidates_freshness_and_obsolete_responses() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
configure(tmp.path(), "unchanged-environment");
|
||||
let catalogs = Arc::new(AgentCatalogs::default());
|
||||
let mut harness = Harness::new();
|
||||
let manager = make_manager(tmp.path(), catalogs, &harness);
|
||||
let mut events = events(&manager);
|
||||
manager.refresh_models(PROVIDER, false).unwrap();
|
||||
let obsolete = harness.next().await;
|
||||
std::fs::write(tmp.path().join("catalog-test-bin"), "replacement executable with different size").unwrap();
|
||||
manager.refresh_models(PROVIDER, false).unwrap();
|
||||
let latest = harness.next().await;
|
||||
latest.reply.send(Ok(response(&["updated"], &["high"]))).unwrap();
|
||||
let fresh = completed(&mut events).await;
|
||||
let _ = obsolete.reply.send(Ok(response(&["obsolete"], &["low"])));
|
||||
stopped(&harness).await;
|
||||
assert_eq!(manager.refresh_models(PROVIDER, false).unwrap().config, fresh.config);
|
||||
std::fs::write(tmp.path().join("catalog-test-bin"), "another executable").unwrap();
|
||||
assert!(manager.refresh_models(PROVIDER, false).unwrap().refreshing);
|
||||
harness.next().await.reply.send(Ok(response(&["new-install"], &["medium"]))).unwrap();
|
||||
assert_eq!(model_values(&completed(&mut events).await), ["new-install"]);
|
||||
assert_eq!(harness.launches.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_catalogs_and_persisted_freshness_do_not_leak_between_contexts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let a = tmp.path().join("project-a");
|
||||
let b = tmp.path().join("project-b");
|
||||
configure(&a, "account-a");
|
||||
configure(&b, "account-b");
|
||||
let models_file = tmp.path().join("shared-models.json");
|
||||
let catalogs = Arc::new(AgentCatalogs::default());
|
||||
let mut harness = Harness::new();
|
||||
let first = make_manager_at(&a, &models_file, catalogs.clone(), &harness);
|
||||
let second = make_manager_at(&b, &models_file, catalogs, &harness);
|
||||
let mut first_events = events(&first);
|
||||
let mut second_events = events(&second);
|
||||
first.refresh_models(PROVIDER, false).unwrap();
|
||||
harness.next().await.reply.send(Ok(response(&["account-a-model"], &["low"]))).unwrap();
|
||||
let a_catalog = completed(&mut first_events).await;
|
||||
second.refresh_models(PROVIDER, false).unwrap();
|
||||
harness.next().await.reply.send(Ok(response(&["account-b-model"], &["high"]))).unwrap();
|
||||
let b_catalog = completed(&mut second_events).await;
|
||||
assert_eq!(first.refresh_models(PROVIDER, false).unwrap().config, a_catalog.config);
|
||||
assert_eq!(second.refresh_models(PROVIDER, false).unwrap().config, b_catalog.config);
|
||||
assert_eq!(first.providers().into_iter().find(|p| p.id == PROVIDER).unwrap().config, a_catalog.config);
|
||||
for (root, expected) in [(&a, &a_catalog), (&b, &b_catalog)] {
|
||||
let restored = make_manager_at(root, &models_file, Arc::default(), &harness);
|
||||
let cached = restored.refresh_models(PROVIDER, false).unwrap();
|
||||
assert!(!cached.refreshing, "matching persisted context should retain freshness");
|
||||
assert_eq!(cached.config, expected.config);
|
||||
assert_eq!(cached.refreshed_at_ms, expected.refreshed_at_ms);
|
||||
}
|
||||
assert_eq!(harness.launches.load(Ordering::SeqCst), 2);
|
||||
configure(&a, "new-account");
|
||||
let changed = make_manager_at(&a, &models_file, Arc::default(), &harness);
|
||||
let mut events = events(&changed);
|
||||
let stale = changed.refresh_models(PROVIDER, false).unwrap();
|
||||
assert!(stale.refreshing, "a different launch environment cannot reuse persisted freshness");
|
||||
assert!(stale.refreshed_at_ms.is_none());
|
||||
assert_eq!(stale.config, a_catalog.config, "stale cached choices remain selectable");
|
||||
harness.next().await.reply.send(Ok(response(&["new-account-model"], &["medium"]))).unwrap();
|
||||
completed(&mut events).await;
|
||||
let persisted = std::fs::read_to_string(models_file).unwrap();
|
||||
assert!(!persisted.contains("secret-") && !persisted.contains("TOKEN"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configuration_changed_without_another_request_also_discards_old_discovery() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
configure(tmp.path(), "before");
|
||||
let mut harness = Harness::new();
|
||||
let manager = make_manager(tmp.path(), Arc::default(), &harness);
|
||||
let mut events = events(&manager);
|
||||
manager.refresh_models(PROVIDER, false).unwrap();
|
||||
let obsolete = harness.next().await;
|
||||
configure(tmp.path(), "after");
|
||||
obsolete.reply.send(Ok(response(&["obsolete"], &["low"]))).unwrap();
|
||||
let discarded = completed(&mut events).await;
|
||||
assert!(model_values(&discarded).is_empty());
|
||||
assert!(discarded.refreshed_at_ms.is_none());
|
||||
assert!(!tmp.path().join("models.json").exists());
|
||||
manager.refresh_models(PROVIDER, false).unwrap();
|
||||
harness.next().await.reply.send(Ok(response(&["current"], &["high"]))).unwrap();
|
||||
assert_eq!(model_values(&completed(&mut events).await), ["current"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_empty_catalog_removes_all_previous_models_and_efforts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
configure(tmp.path(), "one");
|
||||
let catalogs = Arc::new(AgentCatalogs::default());
|
||||
let mut harness = Harness::new();
|
||||
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
|
||||
let mut events = events(&manager);
|
||||
manager.refresh_models(PROVIDER, true).unwrap();
|
||||
harness.next().await.reply.send(Ok(response(&["removed"], &["low"]))).unwrap();
|
||||
completed(&mut events).await;
|
||||
advance(&catalogs, 30);
|
||||
manager.refresh_models(PROVIDER, true).unwrap();
|
||||
harness.next().await.reply.send(Ok(response(&[], &[]))).unwrap();
|
||||
let empty = completed(&mut events).await;
|
||||
assert!(model_values(&empty).is_empty() && empty.thought_levels.is_empty());
|
||||
assert!(empty.refreshed_at_ms.is_some() && empty.error.is_none());
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
//! next prompt when the agent supports `session/load`.
|
||||
|
||||
mod acp;
|
||||
mod catalogs;
|
||||
mod config;
|
||||
mod conversation;
|
||||
mod external;
|
||||
@@ -22,7 +21,6 @@ mod skills;
|
||||
mod timeline;
|
||||
mod worktree;
|
||||
|
||||
pub use catalogs::AgentCatalogs;
|
||||
pub use external::ExternalSession;
|
||||
pub use model::*;
|
||||
|
||||
@@ -90,8 +88,6 @@ pub struct AgentPaths {
|
||||
pub worktrees_dir: PathBuf,
|
||||
/// Settings each agent offered last time (shared by all projects), and its commands per project.
|
||||
pub models_file: PathBuf,
|
||||
/// Shared by every project/window on this backend.
|
||||
pub catalogs: Arc<AgentCatalogs>,
|
||||
/// User-defined agents, one `<id>.toml` each (`name`, `command`, `args`, `env`).
|
||||
pub custom_agents_dir: Option<PathBuf>,
|
||||
/// The user's home: its `.claude` holds their own skills and commands.
|
||||
@@ -127,10 +123,12 @@ struct Shared {
|
||||
sink: RwLock<Option<AgentSink>>,
|
||||
security: RwLock<Option<SecurityHook>>,
|
||||
new_task_approvals: RwLock<Option<NewTaskApprovalsHook>>,
|
||||
known: Arc<Mutex<HashMap<String, Known>>>,
|
||||
known: Mutex<HashMap<String, Known>>,
|
||||
providers: RwLock<Vec<provider::ProviderSpec>>,
|
||||
/// Last probe of each provider.
|
||||
probes: Mutex<HashMap<String, AgentProbe>>,
|
||||
/// One pre-session lookup of commands and settings per provider, including failed attempts.
|
||||
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
|
||||
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
|
||||
idle_release_ms: AtomicU64,
|
||||
mcp_servers_hook: RwLock<Option<Arc<McpServersFn>>>,
|
||||
@@ -147,14 +145,6 @@ struct Known {
|
||||
/// By project root: skills and custom commands differ per project.
|
||||
#[serde(default, rename = "projectCommands")]
|
||||
commands: HashMap<String, Vec<AgentCommand>>,
|
||||
/// Successful discovery, unlike a session report, owns availability.
|
||||
#[serde(default)]
|
||||
authoritative: bool,
|
||||
#[serde(default, rename = "refreshedAtMs")]
|
||||
refreshed_at_ms: Option<f64>,
|
||||
/// Authoritative availability and freshness are scoped to project and launch context.
|
||||
#[serde(default, rename = "projectCatalogs")]
|
||||
catalogs: HashMap<String, catalogs::SavedCatalog>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
@@ -181,11 +171,7 @@ impl Shared {
|
||||
}
|
||||
|
||||
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
|
||||
let config = self.paths.catalogs.cached(self, provider).config;
|
||||
self.config_for_new_task(config, provider)
|
||||
}
|
||||
|
||||
fn config_for_new_task(&self, mut config: Vec<AgentConfigOption>, provider: &str) -> Vec<AgentConfigOption> {
|
||||
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
|
||||
// Model-specific options are offered separately, never as provider-wide defaults.
|
||||
config.retain(|o| o.category.as_deref() != Some("thought_level"));
|
||||
config::add_client_options(provider, &mut config);
|
||||
@@ -211,6 +197,80 @@ impl Shared {
|
||||
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
|
||||
let mut ready = {
|
||||
let mut fetches = self.command_fetches.lock();
|
||||
fetches
|
||||
.entry(spec.id.clone())
|
||||
.or_insert_with(|| {
|
||||
let (done, ready) = tokio::sync::watch::channel(false);
|
||||
let shared = self.clone();
|
||||
// Keep the lookup alive if a composer unmounts while it is waiting.
|
||||
self.runtime.spawn(async move {
|
||||
tracing::info!("agent {}: settings discovery started", spec.id);
|
||||
let discovered = if spec.driver == provider::Driver::Acp {
|
||||
acp::discover::run(&shared, &spec).await.map(|_| None)
|
||||
} else if spec.driver == provider::Driver::Claude {
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await.map(Some)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
if let Ok(Some(found)) = discovered {
|
||||
let commands =
|
||||
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
|
||||
let options = config::options_of(&json!({ "configOptions": found.options }));
|
||||
let levels: Vec<_> = found
|
||||
.efforts
|
||||
.into_iter()
|
||||
.map(|(model, o)| {
|
||||
let options = o.map(|o| config::options_of(&json!({ "configOptions": [o] })));
|
||||
(model, options.unwrap_or_default())
|
||||
})
|
||||
.collect();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
// Notifies subscribers itself when the discovery
|
||||
// changed the cached config; unchanged results
|
||||
// stay silent.
|
||||
shared.remember_discovered(&spec.id, &options, &commands, &levels);
|
||||
})
|
||||
.await;
|
||||
} else if let Err(e) = discovered {
|
||||
tracing::info!("agent {}: settings discovery failed: {e}", spec.id);
|
||||
}
|
||||
let _ = done.send(true);
|
||||
});
|
||||
ready
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
while !*ready.borrow_and_update() {
|
||||
if ready.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_config(&self, provider: &str) -> bool {
|
||||
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
|
||||
}
|
||||
|
||||
/// Whether the thinking levels of every model the agent offers are known (#1686).
|
||||
fn knows_all_levels(&self, provider: &str) -> bool {
|
||||
let cache = self.known.lock();
|
||||
let Some(known) = cache.get(provider) else { return false };
|
||||
let models: Vec<&str> = known
|
||||
.config
|
||||
.iter()
|
||||
.filter(|o| o.category.as_deref() == Some("model"))
|
||||
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
|
||||
.collect();
|
||||
// No advertised models yet: nothing to attach effort levels to.
|
||||
if models.is_empty() {
|
||||
return false;
|
||||
}
|
||||
models.iter().all(|id| known.thought_levels.contains_key(*id))
|
||||
}
|
||||
|
||||
fn root_key(&self) -> String {
|
||||
self.paths.project_root.to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -223,48 +283,78 @@ impl Shared {
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
) {
|
||||
self.remember_inner(provider, config, commands, model, &[], false);
|
||||
}
|
||||
|
||||
/// `levels`: each model's thinking-level options, so a new task offers them for any model
|
||||
/// it picks (#1686). A session's report for a model wins over these.
|
||||
fn remember_discovered(
|
||||
&self,
|
||||
provider: &str,
|
||||
config: &[AgentConfigOption],
|
||||
commands: &[AgentCommand],
|
||||
levels: &[(String, Vec<AgentConfigOption>)],
|
||||
) {
|
||||
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), levels, true);
|
||||
}
|
||||
|
||||
fn remember_inner(
|
||||
&self,
|
||||
provider: &str,
|
||||
config: Option<&[AgentConfigOption]>,
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
discovered_levels: &[(String, Vec<AgentConfigOption>)],
|
||||
discovered: bool,
|
||||
) {
|
||||
// Keep the update and its disk write ordered; a session report wins over discovery.
|
||||
let mut cache = self.known.lock();
|
||||
let known = cache.entry(provider.to_owned()).or_default();
|
||||
let mut changed = false;
|
||||
// Running tasks retain their own reported settings. Once discovery has
|
||||
// succeeded, a session's older list/defaults cannot change New Task choices.
|
||||
if !known.authoritative
|
||||
&& let Some(config) = config.filter(|c| !c.is_empty())
|
||||
{
|
||||
for (model, levels) in discovered_levels {
|
||||
if !known.thought_levels.contains_key(model) {
|
||||
known.thought_levels.insert(model.clone(), levels.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
|
||||
if let Some(model) = model {
|
||||
let levels: Vec<_> =
|
||||
config.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
|
||||
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
|
||||
if known.thought_levels.get(model) != Some(&levels) {
|
||||
known.thought_levels.insert(model.to_owned(), levels);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if known.config != config {
|
||||
known.config = config.to_vec();
|
||||
if !c.is_empty() && known.config != c {
|
||||
known.config = c.to_vec();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if let Some(commands) = commands.filter(|c| !c.is_empty())
|
||||
&& known.commands.get(&self.root_key()).map(Vec::as_slice) != Some(commands)
|
||||
{
|
||||
known.commands.insert(self.root_key(), commands.to_vec());
|
||||
let root = self.root_key();
|
||||
if let Some(c) = commands.filter(|c| {
|
||||
!c.is_empty()
|
||||
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
|
||||
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
|
||||
}) {
|
||||
known.commands.insert(root, c.to_vec());
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
self.persist_known(&cache);
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
drop(cache);
|
||||
if changed {
|
||||
self.emit(AgentEvent::Providers);
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_known(&self, cache: &HashMap<String, Known>) {
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(cache)
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
|
||||
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
|
||||
{
|
||||
tracing::warn!("saving agent settings: {e}");
|
||||
}
|
||||
// The cached provider config actually changed (a session report, discovery,
|
||||
// or a turn update): tell subscribers to re-list, so a changed model list
|
||||
// reaches the composer without waiting for the next task (#1490). Unchanged
|
||||
// reports stay silent, so routine session traffic emits nothing. The cache
|
||||
// lock is released first; the sink never runs under it.
|
||||
drop(cache);
|
||||
self.emit(AgentEvent::Providers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +469,8 @@ impl AgentManager {
|
||||
}
|
||||
|
||||
fn with_connector(paths: AgentPaths, runtime: tokio::runtime::Handle, connector: Connector) -> Self {
|
||||
let known = paths.catalogs.store(&paths.models_file);
|
||||
let known =
|
||||
std::fs::read(&paths.models_file).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref(), paths.env_secrets.as_deref()));
|
||||
let shared = Arc::new(Shared {
|
||||
paths,
|
||||
@@ -388,13 +479,13 @@ impl AgentManager {
|
||||
sink: RwLock::new(None),
|
||||
security: RwLock::new(None),
|
||||
new_task_approvals: RwLock::new(None),
|
||||
known,
|
||||
known: Mutex::new(known),
|
||||
providers,
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
command_fetches: Mutex::new(HashMap::new()),
|
||||
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
|
||||
mcp_servers_hook: RwLock::new(None),
|
||||
});
|
||||
shared.paths.catalogs.subscribe(&shared);
|
||||
let mut tasks = HashMap::new();
|
||||
for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() {
|
||||
let path = file.path();
|
||||
@@ -451,9 +542,9 @@ impl AgentManager {
|
||||
.filter(|p| !provider::is_unlisted(p) || in_use.contains(&p.id))
|
||||
.map(|p| {
|
||||
let mut info = provider::describe(p);
|
||||
let catalog = self.shared.paths.catalogs.cached(&self.shared, &p.id);
|
||||
info.config = self.shared.config_for_new_task(catalog.config, &p.id);
|
||||
info.thought_levels = catalog.thought_levels;
|
||||
info.config = self.shared.known_config(&p.id);
|
||||
info.thought_levels =
|
||||
self.shared.known.lock().get(&p.id).map(|k| k.thought_levels.clone()).unwrap_or_default();
|
||||
info.commands = self.shared.known_commands(&p.id);
|
||||
info
|
||||
})
|
||||
@@ -541,20 +632,17 @@ impl AgentManager {
|
||||
// merged without duplicating what `availableCommands` or the disk offer.
|
||||
let mut offered = offered;
|
||||
if task.is_none()
|
||||
&& let Some(spec) =
|
||||
provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets())
|
||||
.into_iter()
|
||||
.find(|spec| spec.id == provider)
|
||||
&& (offered.is_empty() || !self.shared.has_config(&provider) || !self.shared.knows_all_levels(&provider))
|
||||
&& let Some(spec) = self
|
||||
.shared
|
||||
.spec(&provider)
|
||||
.filter(|s| matches!(s.driver, provider::Driver::Claude | provider::Driver::Acp))
|
||||
{
|
||||
let (_, flight) = self.shared.paths.catalogs.request(&self.shared, spec, false);
|
||||
if let Some(mut ready) = flight {
|
||||
while !*ready.borrow_and_update() {
|
||||
if ready.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let claude = spec.driver == provider::Driver::Claude;
|
||||
self.shared.fetch_settings(spec).await;
|
||||
if claude {
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
if !offered.iter().any(|c| c.name == name) {
|
||||
@@ -706,18 +794,16 @@ impl AgentManager {
|
||||
}
|
||||
};
|
||||
// Until the session reports its own, show what the agent offered last time with the choices applied.
|
||||
let catalog = self.shared.paths.catalogs.cached(&self.shared, &spec.id);
|
||||
let mut levels = catalog.thought_levels;
|
||||
let mut config = self.shared.config_for_new_task(catalog.config, &spec.id);
|
||||
let mut config = self.shared.known_config(&spec.id);
|
||||
for choice in &req.config {
|
||||
if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) {
|
||||
o.current = Some(choice.value.clone());
|
||||
}
|
||||
}
|
||||
if let Some(model) = model_of(&config)
|
||||
&& let Some(levels) = levels.remove(&model)
|
||||
&& let Some(levels) = self.shared.known.lock().get(&spec.id).and_then(|k| k.thought_levels.get(&model))
|
||||
{
|
||||
config.extend(levels.into_iter().map(|mut o| {
|
||||
config.extend(levels.iter().cloned().map(|mut o| {
|
||||
if let Some(choice) =
|
||||
req.config.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value))
|
||||
{
|
||||
|
||||
@@ -538,19 +538,6 @@ pub struct AgentChanges {
|
||||
pub commits: Vec<AgentCommit>,
|
||||
}
|
||||
|
||||
/// Authoritative choices for new tasks, independent of any running session's settings.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentModelCatalog {
|
||||
pub provider: String,
|
||||
pub config: Vec<AgentConfigOption>,
|
||||
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
|
||||
pub refreshing: bool,
|
||||
pub refreshed_at_ms: Option<f64>,
|
||||
pub retry_at_ms: Option<f64>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Streamed to the window that subscribed (`AgentManager::subscribe`).
|
||||
#[derive(Clone, Debug, Serialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
@@ -588,10 +575,6 @@ pub enum AgentEvent {
|
||||
provider: String,
|
||||
limits: Vec<AgentLimit>,
|
||||
},
|
||||
/// A model lookup started or completed, including unsuccessful lookups.
|
||||
ModelCatalog {
|
||||
catalog: AgentModelCatalog,
|
||||
},
|
||||
/// The installed or configured agent providers changed.
|
||||
Providers,
|
||||
}
|
||||
|
||||
@@ -85,8 +85,6 @@ pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<Discove
|
||||
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
|
||||
let mut args = spec.args;
|
||||
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
|
||||
// Metadata discovery must not launch project-configured MCP servers.
|
||||
args.extend(["--strict-mcp-config".into(), "--mcp-config".into(), "{\"mcpServers\":{}}".into()]);
|
||||
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
|
||||
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
|
||||
let _stop = io.kill.clone().drop_guard();
|
||||
|
||||
@@ -44,92 +44,6 @@ pub(crate) fn connect(
|
||||
})
|
||||
}
|
||||
|
||||
/// A model lookup uses app-server directly: no thread, task, prompt or project MCP.
|
||||
pub(crate) async fn discover(
|
||||
spec: ProviderSpec,
|
||||
cwd: std::path::PathBuf,
|
||||
) -> Result<crate::catalogs::Discovered, String> {
|
||||
let spec = tokio::task::spawn_blocking({
|
||||
let cwd = cwd.clone();
|
||||
move || spec.resolve_for_launch(&cwd)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let peer = Peer::spawn_framed(
|
||||
&spec.command,
|
||||
&spec.args,
|
||||
&cwd,
|
||||
&spec.env,
|
||||
Arc::new(ide_jsonrpc::NoopHandler),
|
||||
Box::new(|_| {}),
|
||||
Framing::Lines,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let _stop = crate::catalogs::KillPeer(peer.clone());
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
let _: Value = peer.request("initialize", initialize_params(), None).await.map_err(|e| e.to_string())?;
|
||||
peer.notify("initialized", Value::Null).map_err(|e| e.to_string())?;
|
||||
let mut models = list_models(&peer).await.map_err(|e| e.message)?;
|
||||
models.retain(|model| !model.hidden);
|
||||
let mut settings = Settings::of(&json!({}), &models);
|
||||
if settings.model.is_empty()
|
||||
&& let Some(model) = models.first()
|
||||
{
|
||||
settings.model = model.id.clone();
|
||||
settings.fit_effort(&models);
|
||||
}
|
||||
let options = translate::config_options(&models, &settings);
|
||||
let config = crate::config::options_of(&json!({ "configOptions": options }));
|
||||
let levels = models
|
||||
.iter()
|
||||
.map(|model| {
|
||||
let mut settings = settings.clone();
|
||||
settings.model = model.id.clone();
|
||||
settings.fit_effort(&models);
|
||||
let options = translate::config_options(&models, &settings);
|
||||
let levels = crate::config::options_of(&json!({ "configOptions": options }))
|
||||
.into_iter()
|
||||
.filter(|o| o.category.as_deref() == Some("thought_level"))
|
||||
.collect();
|
||||
(model.id.clone(), levels)
|
||||
})
|
||||
.collect();
|
||||
Ok(crate::catalogs::Discovered { config, commands: None, levels })
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Codex model discovery timed out".to_owned())?
|
||||
}
|
||||
|
||||
fn initialize_params() -> Value {
|
||||
json!({
|
||||
"clientInfo": { "name": "openidea", "title": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") },
|
||||
"capabilities": { "experimentalApi": true, "optOutNotificationMethods": OPT_OUT },
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_models(peer: &Peer) -> Result<Vec<ModelInfo>, ResponseError> {
|
||||
let mut all = Vec::new();
|
||||
let mut cursor = Value::Null;
|
||||
let mut seen = HashSet::new();
|
||||
loop {
|
||||
let page: Value = peer.request("model/list", json!({ "cursor": cursor }), None).await.map_err(from_codex)?;
|
||||
let Some(data) = page.get("data").and_then(Value::as_array) else {
|
||||
return Err(error(codes::INTERNAL_ERROR, "Codex returned an invalid model list"));
|
||||
};
|
||||
let models = translate::models_of(&page);
|
||||
if models.len() != data.len() {
|
||||
return Err(error(codes::INTERNAL_ERROR, "Codex returned an invalid model list"));
|
||||
}
|
||||
all.extend(models);
|
||||
match page.get("nextCursor") {
|
||||
None | Some(Value::Null) => return Ok(all),
|
||||
Some(Value::String(next)) if next.is_empty() => return Ok(all),
|
||||
Some(Value::String(next)) if seen.insert(next.clone()) => cursor = json!(next),
|
||||
_ => return Err(error(codes::INTERNAL_ERROR, "Codex returned an invalid model-list cursor")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects `client` to a shim driving the codex connection `connect_codex` opens (it
|
||||
/// gets the handler for what codex sends). Each side's end closes the other.
|
||||
fn attach(
|
||||
@@ -336,7 +250,15 @@ impl CodexShim {
|
||||
}
|
||||
|
||||
async fn initialize(&self) -> Result<Value, ResponseError> {
|
||||
let info = self.call("initialize", initialize_params()).await?;
|
||||
let info = self
|
||||
.call(
|
||||
"initialize",
|
||||
json!({
|
||||
"clientInfo": { "name": "openidea", "title": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") },
|
||||
"capabilities": { "experimentalApi": true, "optOutNotificationMethods": OPT_OUT },
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
self.codex().notify("initialized", Value::Null).map_err(from_codex)?;
|
||||
self.state.lock().initialized = true;
|
||||
// `<client>/<codex version> (<platform>) …`
|
||||
@@ -360,15 +282,32 @@ impl CodexShim {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Cache only complete, successful listings; a failed page must never become availability.
|
||||
async fn models(&self) -> Result<Arc<Vec<ModelInfo>>, ResponseError> {
|
||||
/// The models codex offers; empty when it can't list them.
|
||||
async fn models(&self) -> Arc<Vec<ModelInfo>> {
|
||||
let mut cached = self.models.lock().await;
|
||||
if let Some(models) = cached.as_ref() {
|
||||
return Ok(models.clone());
|
||||
if let Some(m) = cached.as_ref() {
|
||||
return m.clone();
|
||||
}
|
||||
let models = Arc::new(list_models(self.codex()).await?);
|
||||
let mut all = Vec::new();
|
||||
let mut cursor = Value::Null;
|
||||
loop {
|
||||
match self.call("model/list", json!({ "cursor": cursor })).await {
|
||||
Ok(page) => {
|
||||
all.extend(translate::models_of(&page));
|
||||
match page.get("nextCursor").filter(|c| c.is_string()) {
|
||||
Some(next) if *next != cursor => cursor = next.clone(),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("codex: listing models failed: {}", e.message);
|
||||
return Arc::new(all);
|
||||
}
|
||||
}
|
||||
}
|
||||
let models = Arc::new(all);
|
||||
*cached = Some(models.clone());
|
||||
Ok(models)
|
||||
models
|
||||
}
|
||||
|
||||
async fn publish_skills(&self, session: &str) {
|
||||
@@ -402,7 +341,7 @@ impl CodexShim {
|
||||
}
|
||||
|
||||
async fn new_session(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let models = self.models().await?;
|
||||
let models = self.models().await;
|
||||
let mut request = json!({ "cwd": params.get("cwd") });
|
||||
if let Some(config) = translate::mcp_config(params) {
|
||||
request["config"] = config;
|
||||
@@ -419,7 +358,7 @@ impl CodexShim {
|
||||
/// Resumes the thread and replays its history before answering.
|
||||
async fn load_session(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let id = str_param(params, "sessionId")?;
|
||||
let models = self.models().await?;
|
||||
let models = self.models().await;
|
||||
let mut request = json!({ "threadId": id, "cwd": params.get("cwd") });
|
||||
if let Some(config) = translate::mcp_config(params) {
|
||||
request["config"] = config;
|
||||
@@ -437,7 +376,7 @@ impl CodexShim {
|
||||
|
||||
async fn fork_session(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let source = str_param(params, "sessionId")?;
|
||||
let models = self.models().await?;
|
||||
let models = self.models().await;
|
||||
let settings = self.state.lock().threads.get(source).and_then(|t| t.settings.clone());
|
||||
let mut request = json!({ "threadId": source, "cwd": params.get("cwd") });
|
||||
if let Some(config) = translate::mcp_config(params) {
|
||||
@@ -458,7 +397,7 @@ impl CodexShim {
|
||||
let session = str_param(params, "sessionId")?;
|
||||
let option = str_param(params, "configId")?;
|
||||
let value = str_param(params, "value")?;
|
||||
let models = self.models().await?;
|
||||
let models = self.models().await;
|
||||
let mut state = self.state.lock();
|
||||
let settings = state
|
||||
.threads
|
||||
@@ -475,7 +414,7 @@ impl CodexShim {
|
||||
if input.is_empty() {
|
||||
return Err(invalid("empty prompt"));
|
||||
}
|
||||
let models = self.models().await?;
|
||||
let models = self.models().await;
|
||||
let (done, ended) = oneshot::channel();
|
||||
let settings = {
|
||||
let mut state = self.state.lock();
|
||||
|
||||
@@ -11,155 +11,6 @@ use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
struct ModelPages {
|
||||
pages: Mutex<VecDeque<Result<Value, (i64, String)>>>,
|
||||
cursors: Mutex<Vec<Value>>,
|
||||
}
|
||||
|
||||
impl Handler for ModelPages {
|
||||
fn on_notification(&self, _: &str, _: Value) {}
|
||||
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
assert_eq!(method, "model/list", "listing models must never start a thread or turn");
|
||||
self.cursors.lock().push(params["cursor"].clone());
|
||||
self.pages.lock().pop_front().expect("another expected page")
|
||||
}
|
||||
}
|
||||
|
||||
fn model_peer(pages: Arc<ModelPages>) -> (Peer, Peer) {
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 16);
|
||||
let (read, write) = tokio::io::split(theirs);
|
||||
let server = Peer::connect_framed(read, write, pages, Framing::Lines);
|
||||
let (read, write) = tokio::io::split(ours);
|
||||
(Peer::connect_framed(read, write, Arc::new(ide_jsonrpc::NoopHandler), Framing::Lines), server)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_pagination_failure_does_not_cache_an_incomplete_catalog() {
|
||||
let first = json!({ "data": [{ "id": "first" }], "nextCursor": "next" });
|
||||
let pages = Arc::new(ModelPages {
|
||||
pages: Mutex::new(VecDeque::from([
|
||||
Ok(first.clone()),
|
||||
Err((-32000, "authentication expired".into())),
|
||||
Ok(first),
|
||||
Ok(json!({ "data": [{ "id": "second" }], "nextCursor": null })),
|
||||
])),
|
||||
cursors: Mutex::default(),
|
||||
});
|
||||
let (peer, server) = model_peer(pages.clone());
|
||||
let shim = CodexShim {
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
codex: OnceLock::from(peer.clone()),
|
||||
client: OnceLock::new(),
|
||||
state: Mutex::default(),
|
||||
models: tokio::sync::Mutex::new(None),
|
||||
skills: tokio::sync::Mutex::new(()),
|
||||
};
|
||||
assert_eq!(shim.models().await.unwrap_err().message, "authentication expired");
|
||||
let models = shim.models().await.unwrap();
|
||||
assert_eq!(models.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), ["first", "second"]);
|
||||
assert_eq!(shim.models().await.unwrap().len(), 2);
|
||||
assert_eq!(*pages.cursors.lock(), [Value::Null, json!("next"), Value::Null, json!("next")]);
|
||||
peer.kill();
|
||||
server.kill();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_or_invalid_model_cursors_and_malformed_pages_are_failures() {
|
||||
for pages in [
|
||||
vec![json!({ "data": [], "nextCursor": "a" }), json!({ "data": [], "nextCursor": "a" })],
|
||||
vec![
|
||||
json!({ "data": [], "nextCursor": "a" }),
|
||||
json!({ "data": [], "nextCursor": "b" }),
|
||||
json!({ "data": [], "nextCursor": "a" }),
|
||||
],
|
||||
vec![json!({ "data": [], "nextCursor": 7 })],
|
||||
vec![json!({ "nextCursor": null })],
|
||||
vec![json!({ "data": [{}], "nextCursor": null })],
|
||||
] {
|
||||
let pages =
|
||||
Arc::new(ModelPages { pages: Mutex::new(pages.into_iter().map(Ok).collect()), cursors: Mutex::default() });
|
||||
let (peer, server) = model_peer(pages);
|
||||
assert!(list_models(&peer).await.is_err());
|
||||
peer.kill();
|
||||
server.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn native_discovery_only_initializes_and_pages_models_then_stops_the_process() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let script = tmp.path().join("fake-codex");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"#!/usr/bin/env python3
|
||||
import json, os, pathlib, sys
|
||||
pathlib.Path('pid').write_text(str(os.getpid()))
|
||||
for line in sys.stdin:
|
||||
request = json.loads(line)
|
||||
with pathlib.Path('requests').open('a') as f:
|
||||
f.write(json.dumps(request) + '\n')
|
||||
method = request['method']
|
||||
if method == 'initialized':
|
||||
continue
|
||||
if method == 'initialize':
|
||||
result = {}
|
||||
elif method == 'model/list':
|
||||
cursor = request['params']['cursor']
|
||||
model = 'one' if cursor is None else 'two'
|
||||
result = {'data': [{'id': model, 'isDefault': cursor is None,
|
||||
'defaultReasoningEffort': 'low', 'supportedReasoningEfforts': [{'reasoningEffort': 'low'}]}],
|
||||
'nextCursor': 'second-page' if cursor is None else None}
|
||||
else:
|
||||
raise RuntimeError('must not create a thread or prompt: ' + method)
|
||||
print(json.dumps({'jsonrpc': '2.0', 'id': request['id'], 'result': result}), flush=True)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let spec = ProviderSpec {
|
||||
id: "codex".into(),
|
||||
name: "Codex".into(),
|
||||
driver: crate::provider::Driver::Codex,
|
||||
command: script.to_string_lossy().into_owned(),
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
login_hint: String::new(),
|
||||
source: crate::AgentSource::Builtin,
|
||||
};
|
||||
let found = discover(spec, tmp.path().to_path_buf()).await.unwrap();
|
||||
let models: Vec<_> = found
|
||||
.config
|
||||
.iter()
|
||||
.filter(|o| o.category.as_deref() == Some("model"))
|
||||
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
|
||||
.collect();
|
||||
assert_eq!(models, ["one", "two"]);
|
||||
assert_eq!(found.levels.keys().map(String::as_str).collect::<Vec<_>>(), ["one", "two"]);
|
||||
let requests = std::fs::read_to_string(tmp.path().join("requests")).unwrap();
|
||||
let requests: Vec<Value> = requests.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert_eq!(
|
||||
requests.iter().map(|r| r["method"].as_str().unwrap()).collect::<Vec<_>>(),
|
||||
["initialize", "initialized", "model/list", "model/list"]
|
||||
);
|
||||
assert_eq!(requests[2]["params"]["cursor"], Value::Null);
|
||||
assert_eq!(requests[3]["params"]["cursor"], "second-page");
|
||||
let pid = std::fs::read_to_string(tmp.path().join("pid")).unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let alive = std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success();
|
||||
if !alive {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("discovery must kill and reap its child");
|
||||
}
|
||||
|
||||
// ---- Pure translations -------------------------------------------------------------
|
||||
|
||||
fn models() -> Vec<ModelInfo> {
|
||||
|
||||
@@ -25,7 +25,6 @@ fn paths(dir: &Path) -> AgentPaths {
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
catalogs: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -405,7 +405,6 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
|
||||
// user's profile, when temp lives under it) leaks into the test.
|
||||
home_dir: Some(dir.to_path_buf()),
|
||||
env_secrets: None,
|
||||
catalogs: Arc::default(),
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
@@ -443,7 +442,7 @@ async fn saved_conversation_reads_do_not_start_or_load_the_agent() {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn claude_commands_share_catalog_discovery_and_persist_results() {
|
||||
async fn claude_commands_before_session_are_fetched_once_and_saved() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
|
||||
@@ -503,18 +502,14 @@ for line in sys.stdin:
|
||||
std::fs::write(root.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n")
|
||||
.unwrap();
|
||||
let mgr = manager(root, Arc::new(FakeAgent::default()));
|
||||
if outcome != "missing" {
|
||||
provider::set_builtin_command(&root.join("agents"), None, "claude", Some(&script.to_string_lossy()))
|
||||
.unwrap();
|
||||
{
|
||||
let mut providers = mgr.shared.providers.write();
|
||||
let spec = providers.iter_mut().find(|p| p.id == "claude").unwrap();
|
||||
if outcome != "missing" {
|
||||
spec.command = script.to_string_lossy().into_owned();
|
||||
}
|
||||
spec.env = vec![("OUTCOME".into(), outcome.into())];
|
||||
}
|
||||
provider::set_builtin_env(
|
||||
&root.join("agents"),
|
||||
None,
|
||||
"claude",
|
||||
BTreeMap::from([("OUTCOME".into(), outcome.into())]),
|
||||
)
|
||||
.unwrap();
|
||||
mgr.reload_providers();
|
||||
let concurrent = async {
|
||||
if outcome == "session_report" {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
@@ -558,6 +553,7 @@ for line in sys.stdin:
|
||||
effort.unwrap().current.as_deref(),
|
||||
Some(match outcome {
|
||||
"settings_timeout" => "default",
|
||||
"session_report" => "low",
|
||||
_ => "high",
|
||||
})
|
||||
);
|
||||
@@ -575,8 +571,6 @@ for line in sys.stdin:
|
||||
assert_eq!(launches.lines().count(), 1, "{outcome}");
|
||||
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
|
||||
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
|
||||
assert!(args.iter().any(|a| a == "--strict-mcp-config"));
|
||||
assert!(args.windows(2).any(|a| a == ["--mcp-config", "{\"mcpServers\":{}}"]));
|
||||
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
|
||||
let subtypes: Vec<String> = requests
|
||||
.lines()
|
||||
@@ -1437,7 +1431,6 @@ async fn real_claude_task_in_a_worktree() {
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
catalogs: Arc::default(),
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
);
|
||||
@@ -2272,7 +2265,6 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
|
||||
custom_agents_dir: Some(agents_dir.clone()),
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
catalogs: Arc::default(),
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
@@ -2605,7 +2597,6 @@ async fn delete_running_agent(no_load: bool) {
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
catalogs: Arc::default(),
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
@@ -2668,6 +2659,34 @@ fn mcp_servers_follow_server_state_and_agent_capabilities() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
|
||||
for discovery_first in [false, true] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let options = |model: &str, effort: &str| {
|
||||
config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
|
||||
"options": [{ "value": "saved" }, { "value": "discovered" }] },
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
|
||||
"options": [{ "value": "low" }, { "value": "high" }] }
|
||||
] }))
|
||||
};
|
||||
let saved = options("saved", "low");
|
||||
let discovered = options("discovered", "high");
|
||||
if discovery_first {
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
|
||||
}
|
||||
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
|
||||
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
for mgr in [&mgr, &restored] {
|
||||
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
|
||||
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn provider_reload_does_not_start_acp_discovery() {
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
@@ -2754,6 +2773,31 @@ async fn discovery_times_out_when_session_new_hangs() {
|
||||
assert!(!cursor.config.iter().any(|o| o.category.as_deref() == Some("model")));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn discovered_effort_levels_fill_models_no_session_has_reported() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let effort = |values: &[&str], current: &str| {
|
||||
config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": current,
|
||||
"options": values.iter().map(|v| json!({ "value": v })).collect::<Vec<_>>() }
|
||||
] }))
|
||||
};
|
||||
let model = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "sonnet",
|
||||
"options": [{ "value": "sonnet" }, { "value": "opus" }] }
|
||||
] }));
|
||||
// A session already reported sonnet's levels; discovery adds opus's.
|
||||
let reported = [model.clone(), effort(&["low", "high"], "high")].concat();
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("sonnet"));
|
||||
let levels =
|
||||
[("sonnet".to_owned(), effort(&["low"], "low")), ("opus".to_owned(), effort(&["low", "max"], "default"))];
|
||||
mgr.shared.remember_discovered("claude", &model, &[], &levels);
|
||||
let known = mgr.shared.known.lock();
|
||||
assert_eq!(known["claude"].thought_levels["sonnet"], effort(&["low", "high"], "high"), "the session's report wins");
|
||||
assert_eq!(known["claude"].thought_levels["opus"], effort(&["low", "max"], "default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_text_is_sent_as_appends() {
|
||||
let mut streamed = HashMap::new();
|
||||
|
||||
@@ -260,8 +260,6 @@ pub struct DebuggerSettings {
|
||||
pub show_on_breakpoint: bool,
|
||||
pub focus_app_on_breakpoint: bool,
|
||||
pub hide_on_termination: bool,
|
||||
/// A breakpoint or exception hit brings the Debug tool window's Threads & Variables tab to the front.
|
||||
pub variables_on_breakpoint: bool,
|
||||
pub breakpoints_any_file: bool,
|
||||
/// While suspended, hovering the start of a line shows a Run to Cursor button.
|
||||
pub run_to_cursor_inlay: bool,
|
||||
@@ -287,7 +285,6 @@ impl Default for DebuggerSettings {
|
||||
show_on_breakpoint: true,
|
||||
focus_app_on_breakpoint: false,
|
||||
hide_on_termination: false,
|
||||
variables_on_breakpoint: true,
|
||||
breakpoints_any_file: false,
|
||||
run_to_cursor_inlay: true,
|
||||
inline_values: true,
|
||||
|
||||
@@ -474,7 +474,10 @@ impl Session {
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
if let Some(mut program) = self.program.lock().take() {
|
||||
program.kill();
|
||||
let _ = program.child.kill();
|
||||
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
|
||||
let _ = program.child.wait();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -571,7 +574,7 @@ fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
|
||||
for child in s.debuggees.lock().iter() {
|
||||
push(child.id());
|
||||
}
|
||||
push(s.program.lock().as_ref().and_then(ProgramTerminal::process_id));
|
||||
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
|
||||
groups.retain(|g| *g > 0);
|
||||
groups
|
||||
}
|
||||
@@ -871,10 +874,7 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
|
||||
/// A `runInTerminal` program and the pseudo-terminal it runs in.
|
||||
pub(crate) struct ProgramTerminal {
|
||||
writer: ide_core::input::InputWriter,
|
||||
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
|
||||
pid: Option<u32>,
|
||||
/// Set once the program has exited and been reaped.
|
||||
exited: Arc<AtomicBool>,
|
||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
_master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
@@ -882,28 +882,6 @@ impl ProgramTerminal {
|
||||
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
|
||||
self.writer.write(text.as_bytes())
|
||||
}
|
||||
|
||||
/// The program's process id while it runs.
|
||||
pub(crate) fn process_id(&self) -> Option<u32> {
|
||||
if self.exited.load(Ordering::Acquire) { None } else { self.pid }
|
||||
}
|
||||
|
||||
pub(crate) fn kill(&mut self) {
|
||||
// After the reap the pid may belong to another process.
|
||||
if !self.exited.load(Ordering::Acquire) {
|
||||
let _ = self.killer.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reaps the program as soon as it exits. Until then it stays a zombie that `kill(pid, 0)` still
|
||||
/// finds: js-debug polls the `processId` it was given and keeps the session (and its terminal)
|
||||
/// open while that process looks alive, and Stop would wait for it as for a running program.
|
||||
fn reap_on_exit(mut child: Box<dyn portable_pty::Child + Send + Sync>, exited: Arc<AtomicBool>) {
|
||||
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
|
||||
let _ = child.wait();
|
||||
exited.store(true, Ordering::Release);
|
||||
});
|
||||
}
|
||||
|
||||
/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB
|
||||
@@ -990,12 +968,8 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
#[cfg(unix)]
|
||||
wait_readable,
|
||||
);
|
||||
let killer = child.clone_killer();
|
||||
let exited = Arc::new(AtomicBool::new(false));
|
||||
reap_on_exit(child, exited.clone());
|
||||
let program = ProgramTerminal { writer, killer, pid, exited, _master: pair.master };
|
||||
if let Some(mut previous) = s.program.lock().replace(program) {
|
||||
previous.kill();
|
||||
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
|
||||
let _ = previous.child.kill();
|
||||
}
|
||||
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
|
||||
Ok(json!({ "processId": pid }))
|
||||
@@ -1364,29 +1338,4 @@ mod tests {
|
||||
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
|
||||
assert_eq!(parse_jdwp_port("unrelated output"), None);
|
||||
}
|
||||
|
||||
/// js-debug polls the `runInTerminal` pid with `kill(pid, 0)`; an unreaped zombie answers it as
|
||||
/// a live process and keeps the session open after the program ends.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_terminal_program_is_reaped_when_it_exits() {
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
let pair =
|
||||
native_pty_system().openpty(PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0 }).unwrap();
|
||||
let mut cmd = CommandBuilder::new("/bin/sh");
|
||||
cmd.args(["-c", "exit 0"]);
|
||||
let child = pair.slave.spawn_command(cmd).unwrap();
|
||||
let pid = child.process_id().unwrap() as libc::pid_t;
|
||||
let exited = Arc::new(AtomicBool::new(false));
|
||||
super::reap_on_exit(child, exited.clone());
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while !exited.load(Ordering::Acquire) {
|
||||
assert!(std::time::Instant::now() < deadline, "program was not reaped");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
// SAFETY: signal 0 only checks whether the process exists.
|
||||
assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,6 @@ pub struct Host {
|
||||
pub mcp_requests: mcp::Requests,
|
||||
/// Commands agents run in the Run tool window (`run_command`).
|
||||
pub mcp_agent_runs: mcp::AgentRuns,
|
||||
pub(crate) agent_catalogs: Arc<ide_agent::AgentCatalogs>,
|
||||
pub trust: trust::TrustStore,
|
||||
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
|
||||
pub config_dir: PathBuf,
|
||||
@@ -153,7 +152,6 @@ impl Host {
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
mcp_agent_runs: Default::default(),
|
||||
agent_catalogs: Arc::default(),
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
|
||||
@@ -7,8 +7,8 @@ use crate::rpc::Dispatcher;
|
||||
use ide_agent::registry::{AgentRegistry, RegistryPaths};
|
||||
use ide_agent::{
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
|
||||
AgentEvent, AgentModelCatalog, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput,
|
||||
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
|
||||
McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
@@ -28,12 +28,6 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
|
||||
Ok(providers)
|
||||
}
|
||||
|
||||
/// Refreshes the selected agent's model catalog without sending a prompt.
|
||||
pub fn refresh_models(host: &Host, window: &str, provider: String, force: bool) -> IdeResult<AgentModelCatalog> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.refresh_models(&provider, force)
|
||||
}
|
||||
|
||||
/// Overrides built-in agent `id`'s command with `command` (its agent file keeps the
|
||||
/// built-in's driver and arguments), or returns it to auto-detection with `None`.
|
||||
pub fn set_command(host: &Host, window: &str, id: String, command: Option<String>) -> IdeResult<()> {
|
||||
@@ -563,9 +557,6 @@ pub fn mcp_import(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
|
||||
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
|
||||
d.add("agent_refresh_models", |c| async move {
|
||||
refresh_models(&c.host, &c.window, c.arg("provider")?, c.arg("force")?)
|
||||
});
|
||||
d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) });
|
||||
d.add("agent_set_env", |c| async move { set_env(&c.host, &c.window, c.arg("id")?, c.arg("env")?) });
|
||||
d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await });
|
||||
|
||||
@@ -664,7 +664,6 @@ impl Host {
|
||||
.join(project_key)
|
||||
}),
|
||||
models_file: self.data_dir.join("agents").join("models.json"),
|
||||
catalogs: self.agent_catalogs.clone(),
|
||||
custom_agents_dir: Some(self.config_dir.join("agents")),
|
||||
home_dir: dirs::home_dir(),
|
||||
env_secrets: Some(Arc::new(AgentEnvSecrets {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
//! Separate project windows share discovery and its backend-enforced cooldown.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_agent::AgentEvent;
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn windows_share_model_discovery_and_manual_refresh_cooldown() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let config = tmp.path().join("config");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(config.join("agents")).unwrap();
|
||||
let script = tmp.path().join("catalog.sh");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"#!/bin/sh
|
||||
printf 'launch\n' >> "${0%/*}/launches"
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*'"id":'*) rest=${line#*\"id\":}; id=${rest%%,*}; id=${id%%\}*} ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
case "$line" in
|
||||
*'"method":"initialize"'*)
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{}}}\n' "$id" ;;
|
||||
*'"method":"session/new"'*)
|
||||
while [ ! -f "${0%/*}/release" ]; do sleep 0.01; done
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"catalog","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"new-model","options":[{"value":"new-model","name":"New model"}]}]}}\n' "$id" ;;
|
||||
esac
|
||||
done
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
config.join("agents/catalog-test.toml"),
|
||||
format!(
|
||||
"name = \"Catalog test\"\ncommand = \"/bin/sh\"\nargs = [{}]\n",
|
||||
serde_json::to_string(&script).unwrap()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: config,
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("first", &root).unwrap();
|
||||
host.open_project("second", &root).unwrap();
|
||||
let (send, mut events) = tokio::sync::mpsc::unbounded_channel();
|
||||
for window in ["first", "second"] {
|
||||
let send = send.clone();
|
||||
host.project(window).unwrap().agents.subscribe(Arc::new(move |event| {
|
||||
if let AgentEvent::ModelCatalog { catalog } = event {
|
||||
let _ = send.send((window, catalog));
|
||||
}
|
||||
}));
|
||||
}
|
||||
for _ in 0..20 {
|
||||
for window in ["first", "second"] {
|
||||
let state = ops::agent::refresh_models(&host, window, "catalog-test".into(), true).unwrap();
|
||||
assert!(state.refreshing);
|
||||
}
|
||||
}
|
||||
std::fs::write(tmp.path().join("release"), "").unwrap();
|
||||
let completed = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let mut windows = std::collections::HashSet::new();
|
||||
while windows.len() < 2 {
|
||||
let (window, catalog) = events.recv().await.expect("catalog event stream closed");
|
||||
if !catalog.refreshing && catalog.refreshed_at_ms.is_some() {
|
||||
assert!(catalog.error.is_none(), "{:?}", catalog.error);
|
||||
windows.insert(window);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(completed.is_ok(), "both windows must receive the completed catalog");
|
||||
for _ in 0..20 {
|
||||
for window in ["first", "second"] {
|
||||
let catalog = ops::agent::refresh_models(&host, window, "catalog-test".into(), true).unwrap();
|
||||
assert!(!catalog.refreshing);
|
||||
assert!(catalog.retry_at_ms.is_some());
|
||||
let models = catalog.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
|
||||
assert_eq!(models.values[0].value, "new-model");
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_to_string(tmp.path().join("launches")).unwrap(), "launch\n");
|
||||
}
|
||||
@@ -82,9 +82,7 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
invoke(&peer, "lsp_resend_diagnostics", json!({})).await.unwrap();
|
||||
let files = invoke(&peer, "find_files", json!({"query": "main", "limit": 10})).await.unwrap();
|
||||
assert!(files.is_array());
|
||||
for command in
|
||||
["build_sync", "run_start", "sdk_locate_tools", "notebook_execute", "debug_start", "agent_refresh_models"]
|
||||
{
|
||||
for command in ["build_sync", "run_start", "sdk_locate_tools", "notebook_execute", "debug_start"] {
|
||||
let error = invoke(&peer, command, json!({})).await.unwrap_err();
|
||||
assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}");
|
||||
}
|
||||
|
||||
@@ -93,7 +93,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"vcs_git_version",
|
||||
"docker_compose_op",
|
||||
"db_execute",
|
||||
"agent_refresh_models",
|
||||
"remote_delete",
|
||||
"remote_mkdir",
|
||||
"remote_rename",
|
||||
|
||||
@@ -50,12 +50,7 @@ Agents are unavailable in a project you have not marked as trusted.
|
||||
- **Local checkout**: the agent edits your files directly.
|
||||
</Step>
|
||||
<Step title="Pick the agent and its settings">
|
||||
Choose the agent, and its model, mode and other options when it offers them. Cached models appear immediately; the selected agent's catalog refreshes in the background when it is more than 15 minutes old, without sending a prompt or attaching project MCP servers.
|
||||
|
||||
After changing the agent CLI or account, open the model picker and choose **Refresh models**. Refreshes share one lookup and have a 30-second cooldown after completion, including failures. Cached choices remain usable while refreshing or after a failure.
|
||||
|
||||
Available models depend on the installed agent and account. If a selected model is removed, choose another before starting. Refreshing does not change running tasks.
|
||||
|
||||
Choose the agent, and its model, mode and other options when it offers them. Models and modes appear after the agent's first task.
|
||||
</Step>
|
||||
<Step title="Send">
|
||||
The task opens as an editor tab and starts.
|
||||
@@ -72,10 +67,6 @@ The task tab shows the conversation: your messages, the agent's replies, its rea
|
||||
|
||||
On your own messages you can **Edit** (the messages after it are replaced; files already changed stay as they are) or **Retract Message**. On replies you can **Copy Markdown**, **Quote Reply**, **Regenerate Response** and **Branch Conversation from Here**, which starts a new task from that point.
|
||||
|
||||
**Focus Agent Input** defaults to `⌘L` on macOS and `Ctrl+L` on Windows / Linux in every built-in keymap. When focus is inside your writable task conversation, in either an editor tab or the tool window, it returns focus to the message input without changing your draft, caret position or selection—even when pressed repeatedly. It does not send a message or jump through message history.
|
||||
|
||||
The shortcut does not apply to archived tasks, subagent conversations or the New Task page, and does not take focus from open popups or dialogs. Outside the task conversation, the keys keep their usual editor or terminal behavior. To change or remove the binding, open **Settings › Keymap › Agents › Focus Agent Input**.
|
||||
|
||||
Right-click a link and choose **Copy Link Address** to copy its URL; **Copy** copies the selected text.
|
||||
|
||||
### Permissions
|
||||
|
||||
@@ -50,12 +50,7 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
|
||||
- **当前工作区**:agent 直接修改你的文件。
|
||||
</Step>
|
||||
<Step title="选择 agent 及其设置">
|
||||
选择 agent,以及它提供的模型、模式等选项。缓存的模型会立即显示;所选 agent 的模型列表超过 15 分钟未更新时,会在后台刷新,不发送提示词,也不接入项目 MCP 服务器。
|
||||
|
||||
更改 agent CLI 或账号后,打开模型选择器并单击 **刷新模型**。并发刷新会共用一次查询;每次查询完成后(包括失败)都有 30 秒冷却时间。刷新中或刷新失败后,缓存选项仍可使用。
|
||||
|
||||
可用模型取决于已安装的 agent 和账号。如果已选模型被移除,需要先选择其他模型才能开始任务。刷新不会更改正在运行的任务。
|
||||
|
||||
选择 agent,以及它提供的模型、模式等选项。这些选项会在该 agent 跑完第一个任务后出现。
|
||||
</Step>
|
||||
<Step title="发送">
|
||||
任务会在编辑器标签页中打开并开始运行。
|
||||
@@ -72,10 +67,6 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
|
||||
|
||||
在你自己的消息上可以 **编辑**(其后的消息会被替换,已经改动的文件保持不变)或 **撤回消息**。在回复上可以 **复制 Markdown**、**引用回复**、**重新生成回复**,以及 **从此处创建分支对话**,从这一点开始一个新任务。
|
||||
|
||||
所有内置按键映射中,**聚焦 Agent 输入框** 的默认快捷键都是 macOS 上的 `⌘L`、Windows / Linux 上的 `Ctrl+L`。焦点位于你自己的可输入任务对话中时,无论对话显示在编辑器标签页还是工具窗口中,它都会将焦点移回消息输入框,保留草稿、光标位置和选区;重复按下也不会改变这些内容。它不会发送消息,也不会跳转消息历史。
|
||||
|
||||
此快捷键不适用于已归档任务、子 agent 对话或“新建任务”页面,也不会从打开的弹出窗口或对话框中抢走焦点。在任务对话之外,按键保留原有的编辑器或终端行为。要修改或移除绑定,请打开 **设置 › 按键映射 › 智能体 › 聚焦 Agent 输入框**。
|
||||
|
||||
右键点击链接并选择 **复制链接地址** 可复制它的 URL;**复制** 复制选中的文字。
|
||||
|
||||
### 权限
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import type { AgentEntry, AgentTask } from "../src/bindings";
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
type Placement = "editor" | "toolWindow";
|
||||
|
||||
const firstTask: AgentTask = {
|
||||
id: "focus-first",
|
||||
title: "First focus task",
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
isolation: "local",
|
||||
model: null,
|
||||
cwd: "/dev/acme-api",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
const secondTask: AgentTask = { ...firstTask, id: "focus-second", title: "Second focus task" };
|
||||
const draft = "draft keep this unsent";
|
||||
const reboundShortcut = "Control+Meta+Shift+J";
|
||||
const subagent: AgentEntry = {
|
||||
type: "subagent",
|
||||
id: "focus-child",
|
||||
title: "Inspect focus behavior",
|
||||
description: null,
|
||||
status: "done",
|
||||
error: null,
|
||||
prompt: null,
|
||||
reply: null,
|
||||
tools: 0,
|
||||
activity: null,
|
||||
startedMs: null,
|
||||
endedMs: null,
|
||||
cancellable: false,
|
||||
};
|
||||
|
||||
async function seedTasks(page: Page, placement: Placement, status: AgentTask["status"] = "ready") {
|
||||
const entries: AgentEntry[] = Array.from({ length: 24 }, (_, turn): AgentEntry[] => [
|
||||
{ type: "user", text: `Request ${turn}`, attachments: [] },
|
||||
{ type: "agent", text: `Reply ${turn}` },
|
||||
]).flat();
|
||||
await page.evaluate(
|
||||
async ({ firstTask, secondTask, entries, subagent, placement, status }) => {
|
||||
// These modules must load in the browser's Vite realm, not Playwright's Node process.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
const ipcPath = "/src/platform/ipc.ts";
|
||||
const store = await import(storePath);
|
||||
const { commands } = await import(ipcPath);
|
||||
commands.agentChanges = async () => ({ status: "ok", data: [] });
|
||||
document.documentElement.dataset.agentPromptCount = "0";
|
||||
commands.agentPrompt = async () => {
|
||||
const root = document.documentElement;
|
||||
root.dataset.agentPromptCount = String(Number(root.dataset.agentPromptCount) + 1);
|
||||
return { status: "ok", data: null };
|
||||
};
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([
|
||||
[firstTask.id, { ...firstTask, status }],
|
||||
[secondTask.id, secondTask],
|
||||
]),
|
||||
entries: new Map([
|
||||
[firstTask.id, [...entries, subagent]],
|
||||
[secondTask.id, entries],
|
||||
[`${firstTask.id}/${subagent.id}`, [{ type: "agent", text: "Subagent findings" }]],
|
||||
]),
|
||||
composers: new Map(),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
homeOpen: false,
|
||||
sessionLocation: placement,
|
||||
});
|
||||
store.openTask(firstTask.id);
|
||||
},
|
||||
{ firstTask, secondTask, entries, subagent, placement, status },
|
||||
);
|
||||
await expect(messageBox(page)).toBeVisible();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
}
|
||||
|
||||
function messageBox(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message", exact: true }).filter({ visible: true });
|
||||
}
|
||||
|
||||
function conversation(page: Page) {
|
||||
return page.locator("[class*='_stream_']:visible");
|
||||
}
|
||||
|
||||
// Read the browser selection, not the store's focus request or a registry entry.
|
||||
async function selection(box: Locator) {
|
||||
return box.evaluate((element) => {
|
||||
const selected = window.getSelection();
|
||||
if (!selected?.anchorNode || !selected.focusNode) return null;
|
||||
if (!element.contains(selected.anchorNode) || !element.contains(selected.focusNode)) return null;
|
||||
const offset = (node: Node, at: number) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.setEnd(node, at);
|
||||
return range.toString().length;
|
||||
};
|
||||
return {
|
||||
anchor: offset(selected.anchorNode, selected.anchorOffset),
|
||||
head: offset(selected.focusNode, selected.focusOffset),
|
||||
text: selected.toString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function selectDraft(box: Locator, range: boolean) {
|
||||
await box.fill(draft);
|
||||
await box.press("Home");
|
||||
for (let index = 0; index < 6; index++) await box.press("ArrowRight");
|
||||
if (range) {
|
||||
for (let index = 0; index < 4; index++) await box.press("Shift+ArrowRight");
|
||||
}
|
||||
const expected = { anchor: 6, head: range ? 10 : 6, text: range ? "keep" : "" };
|
||||
await expect.poll(() => selection(box)).toEqual(expected);
|
||||
return expected;
|
||||
}
|
||||
|
||||
for (const placement of ["editor", "toolWindow"] as const) {
|
||||
for (const status of ["ready", "running"] as const) {
|
||||
test(`${placement}: default shortcut restores ${status} task input without changing draft, selection or history`, async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await seedTasks(page, placement, status);
|
||||
const box = messageBox(page);
|
||||
const history = conversation(page);
|
||||
for (const range of [false, true]) {
|
||||
const expected = await selectDraft(box, range);
|
||||
await history.click({ position: { x: 12, y: 20 } });
|
||||
await expect(history).toBeFocused();
|
||||
await history.press("Home");
|
||||
await expect.poll(() => history.evaluate((element) => element.scrollTop)).toBe(0);
|
||||
for (let repeat = 0; repeat < 3; repeat++) {
|
||||
await page.keyboard.press("Meta+L");
|
||||
await expect(box).toBeFocused();
|
||||
await expect(box).toHaveText(draft);
|
||||
await expect.poll(() => selection(box)).toEqual(expected);
|
||||
await expect.poll(() => history.evaluate((element) => element.scrollTop)).toBe(0);
|
||||
}
|
||||
}
|
||||
await expect(page.getByRole("dialog", { name: "Go to Line:Column", exact: true })).toHaveCount(0);
|
||||
await expect(page.locator("html")).toHaveAttribute("data-agent-prompt-count", "0");
|
||||
});
|
||||
}
|
||||
|
||||
test(`${placement}: focuses the shown task rather than another kept-alive composer`, async ({ workbench: page }) => {
|
||||
await seedTasks(page, placement);
|
||||
await messageBox(page).fill("first task's unsent draft");
|
||||
await page.evaluate(async (id) => {
|
||||
// Load the workbench store inside the browser, not the test runner.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
(await import(storePath)).openTask(id);
|
||||
}, secondTask.id);
|
||||
await messageBox(page).fill("second task's unsent draft");
|
||||
if (placement === "editor") {
|
||||
await expect(page.getByRole("textbox", { name: "Message", exact: true, includeHidden: true })).toHaveCount(2);
|
||||
await page.getByRole("tab", { name: firstTask.title, exact: true }).click();
|
||||
} else {
|
||||
await page.getByRole("button", { name: "Agent sessions", exact: true }).click();
|
||||
await page.getByRole("tree", { name: "Agent tasks" }).getByText(firstTask.title, { exact: true }).dblclick();
|
||||
}
|
||||
await expect(messageBox(page)).toHaveText("first task's unsent draft");
|
||||
await conversation(page).click({ position: { x: 12, y: 20 } });
|
||||
await page.keyboard.press("Meta+L");
|
||||
await expect(messageBox(page)).toBeFocused();
|
||||
await expect(messageBox(page)).toHaveText("first task's unsent draft");
|
||||
if (placement === "editor") {
|
||||
await page.getByRole("tab", { name: secondTask.title, exact: true }).click();
|
||||
} else {
|
||||
await page.getByRole("button", { name: "Agent sessions", exact: true }).click();
|
||||
await page.getByRole("tree", { name: "Agent tasks" }).getByText(secondTask.title, { exact: true }).dblclick();
|
||||
}
|
||||
await conversation(page).click({ position: { x: 12, y: 20 } });
|
||||
await page.keyboard.press("Meta+L");
|
||||
await expect(messageBox(page)).toBeFocused();
|
||||
await expect(messageBox(page)).toHaveText("second task's unsent draft");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-agent-prompt-count", "0");
|
||||
});
|
||||
}
|
||||
|
||||
test("Settings → Keymap rebinds Focus Agent Input and removes its default shortcut", async ({ workbench: page }) => {
|
||||
await seedTasks(page, "editor");
|
||||
const box = messageBox(page);
|
||||
const expected = await selectDraft(box, true);
|
||||
await page.keyboard.press("Meta+,");
|
||||
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
|
||||
await settings.getByRole("treeitem", { name: "Keymap", exact: true }).click();
|
||||
await settings.getByRole("textbox", { name: "Search actions", exact: true }).fill("Focus Agent Input");
|
||||
const row = settings.getByRole("tree", { name: "Actions" }).getByRole("treeitem", { name: /Focus Agent Input/ });
|
||||
await row.dblclick();
|
||||
await page.getByRole("menuitem", { name: "Add Keyboard Shortcut", exact: true }).click();
|
||||
const recorder = page.getByRole("dialog", { name: "Keyboard Shortcut: Focus Agent Input", exact: true });
|
||||
await recorder.getByRole("textbox", { name: "First stroke" }).press(reboundShortcut);
|
||||
await recorder.getByRole("button", { name: "OK", exact: true }).click();
|
||||
await expect(recorder).toBeHidden();
|
||||
await row.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Remove ⌘L", exact: true }).click();
|
||||
await settings.getByRole("button", { name: "OK", exact: true }).click();
|
||||
await expect(settings).toBeHidden();
|
||||
const history = conversation(page);
|
||||
await history.click({ position: { x: 12, y: 20 } });
|
||||
await expect(history).toBeFocused();
|
||||
await page.keyboard.press("Meta+L");
|
||||
await expect(history).toBeFocused();
|
||||
await expect(box).not.toBeFocused();
|
||||
await page.keyboard.press(reboundShortcut);
|
||||
await expect(box).toBeFocused();
|
||||
await expect(box).toHaveText(draft);
|
||||
await expect.poll(() => selection(box)).toEqual(expected);
|
||||
await expect(page.locator("html")).toHaveAttribute("data-agent-prompt-count", "0");
|
||||
});
|
||||
|
||||
test("modals and conversation popups keep keyboard focus", async ({ workbench: page }) => {
|
||||
await seedTasks(page, "editor");
|
||||
const box = messageBox(page);
|
||||
await box.fill(draft);
|
||||
await page.keyboard.press("Meta+,");
|
||||
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
|
||||
const search = settings.getByRole("textbox", { name: "Search settings", exact: true });
|
||||
await search.fill("keymap");
|
||||
await page.keyboard.press("Meta+L");
|
||||
await expect(search).toBeFocused();
|
||||
await expect(search).toHaveValue("keymap");
|
||||
await expect(box).not.toBeFocused();
|
||||
await settings.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(settings).toBeHidden();
|
||||
const reply = conversation(page).getByText("Reply 0", { exact: true });
|
||||
await reply.dblclick();
|
||||
await reply.click({ button: "right" });
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toBeVisible();
|
||||
const firstItem = menu.getByRole("menuitem").first();
|
||||
await expect(firstItem).toBeFocused();
|
||||
await page.keyboard.press("Meta+L");
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(firstItem).toBeFocused();
|
||||
await expect(box).not.toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(box).toHaveText(draft);
|
||||
});
|
||||
|
||||
test("archived tasks do not focus their disabled composer", async ({ workbench: page }) => {
|
||||
await seedTasks(page, "editor", "archived");
|
||||
await expect(
|
||||
page.getByText("This task is archived. Unarchive it to continue the conversation.", { exact: true }),
|
||||
).toBeVisible();
|
||||
const history = conversation(page);
|
||||
await history.click({ position: { x: 12, y: 20 } });
|
||||
await expect(history).toBeFocused();
|
||||
await page.keyboard.press("Meta+L");
|
||||
await expect(history).toBeFocused();
|
||||
await expect(messageBox(page)).not.toBeFocused();
|
||||
});
|
||||
|
||||
test("subagent conversations do not focus the hidden parent composer", async ({ workbench: page }) => {
|
||||
await seedTasks(page, "editor");
|
||||
await messageBox(page).fill(draft);
|
||||
await page.getByTitle("Open the subagent's conversation", { exact: true }).click();
|
||||
await expect(page.getByRole("navigation", { name: "Subagent", exact: true })).toBeVisible();
|
||||
const history = conversation(page);
|
||||
await history.click({ position: { x: 12, y: 20 } });
|
||||
await expect(history).toBeFocused();
|
||||
await page.keyboard.press("Meta+L");
|
||||
await expect(history).toBeFocused();
|
||||
await expect(messageBox(page)).toHaveCount(0);
|
||||
await page
|
||||
.getByRole("navigation", { name: "Subagent", exact: true })
|
||||
.getByRole("button", { name: firstTask.title, exact: true })
|
||||
.click();
|
||||
await expect(messageBox(page)).toHaveText(draft);
|
||||
await conversation(page).click({ position: { x: 12, y: 20 } });
|
||||
await page.keyboard.press("Meta+L");
|
||||
await expect(messageBox(page)).toBeFocused();
|
||||
});
|
||||
|
||||
test("editor Go to Line stays available while a docked agent conversation is visible", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "README.md");
|
||||
await seedTasks(page, "toolWindow");
|
||||
await messageBox(page).fill(draft);
|
||||
const content = editor.locator(".cm-content");
|
||||
await content.click();
|
||||
await page.keyboard.press("Meta+L");
|
||||
const dialog = page.getByRole("dialog", { name: "Go to Line:Column", exact: true });
|
||||
const line = dialog.getByRole("textbox", { name: "Line and column", exact: true });
|
||||
await expect(line).toBeFocused();
|
||||
await expect(messageBox(page)).not.toBeFocused();
|
||||
await line.fill("2:1");
|
||||
await dialog.getByRole("button", { name: "OK", exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(content).toBeFocused();
|
||||
await expect(messageBox(page)).toHaveText(draft);
|
||||
});
|
||||
|
||||
for (const preset of ["intellij-windows", "vscode-windows", "eclipse-windows"]) {
|
||||
test(`${preset} uses Ctrl+L to focus the task message`, async ({ workbench: page }) => {
|
||||
await seedTasks(page, "editor");
|
||||
const box = messageBox(page);
|
||||
const expected = await selectDraft(box, true);
|
||||
await page.evaluate(async (preset) => {
|
||||
// Load the keymap in the browser where keyboard dispatch runs.
|
||||
const keymapPath = "/src/platform/keymap.ts";
|
||||
(await import(keymapPath)).applyKeymapSettings(preset, {});
|
||||
}, preset);
|
||||
const history = conversation(page);
|
||||
await history.click({ position: { x: 12, y: 20 } });
|
||||
await expect(history).toBeFocused();
|
||||
await page.keyboard.press("Control+L");
|
||||
await expect(box).toBeFocused();
|
||||
await expect(box).toHaveText(draft);
|
||||
await expect.poll(() => selection(box)).toEqual(expected);
|
||||
await expect(page.locator("html")).toHaveAttribute("data-agent-prompt-count", "0");
|
||||
});
|
||||
}
|
||||
|
||||
test("Find Action focuses the current task input after closing its popup", async ({ workbench: page }) => {
|
||||
await seedTasks(page, "editor");
|
||||
const box = messageBox(page);
|
||||
const expected = await selectDraft(box, true);
|
||||
await conversation(page).click({ position: { x: 12, y: 20 } });
|
||||
await page.keyboard.press("Meta+Shift+A");
|
||||
const search = page.getByRole("dialog", { name: "Search Everywhere", exact: true });
|
||||
const query = search.getByRole("combobox");
|
||||
await query.fill("Focus Agent Input");
|
||||
await expect(search.getByRole("option", { name: /Focus Agent Input/ })).toBeVisible();
|
||||
await query.press("Enter");
|
||||
await expect(search).toBeHidden();
|
||||
await expect(box).toBeFocused();
|
||||
await expect(box).toHaveText(draft);
|
||||
await expect.poll(() => selection(box)).toEqual(expected);
|
||||
});
|
||||
|
||||
test("docked Changes view returns to the task input without losing its selection", async ({ workbench: page }) => {
|
||||
await seedTasks(page, "toolWindow");
|
||||
const box = messageBox(page);
|
||||
const expected = await selectDraft(box, true);
|
||||
await page.evaluate(async (id) => {
|
||||
// Seed the store in the browser realm; a static import would run in Node.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
(await import(storePath)).useAgents.setState({
|
||||
changes: new Map([
|
||||
[
|
||||
id,
|
||||
[
|
||||
{
|
||||
path: "/dev/acme-api/file.ts",
|
||||
relPath: "file.ts",
|
||||
kind: "modified",
|
||||
added: 1,
|
||||
removed: 0,
|
||||
oldPath: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
}, firstTask.id);
|
||||
await page.getByRole("button", { name: /1 changed file/ }).click();
|
||||
const changes = page.getByRole("complementary", { name: "Changes", exact: true });
|
||||
await expect(changes).toBeVisible();
|
||||
await expect(box).toHaveCount(0);
|
||||
await changes.getByRole("button", { name: "Back to conversation", exact: true }).focus();
|
||||
await page.keyboard.press("Meta+L");
|
||||
await expect(changes).toBeHidden();
|
||||
await expect(box).toBeFocused();
|
||||
await expect(box).toHaveText(draft);
|
||||
await expect.poll(() => selection(box)).toEqual(expected);
|
||||
});
|
||||
@@ -4,8 +4,8 @@ use crate::state::AppState;
|
||||
use ide_agent::registry::AgentRegistry;
|
||||
use ide_agent::{
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
|
||||
AgentEvent, AgentModelCatalog, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput,
|
||||
ExternalSession, McpServer, McpServerInput, NewAgentTask,
|
||||
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
|
||||
McpServer, McpServerInput, NewAgentTask,
|
||||
};
|
||||
use ide_core::{IdeResult, IpcPath, RequestId};
|
||||
use ide_host::ops::agent;
|
||||
@@ -37,18 +37,6 @@ pub fn agent_providers(window: Window, state: State<'_, AppState>) -> IdeResult<
|
||||
agent::providers(&state.host, window.label())
|
||||
}
|
||||
|
||||
/// Refreshes the model catalog; force bypasses freshness, not the cooldown.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_refresh_models(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
provider: String,
|
||||
force: bool,
|
||||
) -> IdeResult<AgentModelCatalog> {
|
||||
agent::refresh_models(&state.host, window.label(), provider, force)
|
||||
}
|
||||
|
||||
/// Overrides built-in agent `id`'s command with `command` (its agent file keeps the
|
||||
/// built-in's driver and arguments), or returns it to auto-detection with `None`.
|
||||
#[tauri::command]
|
||||
|
||||
@@ -123,7 +123,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
app::log_frontend,
|
||||
agent::agent_subscribe,
|
||||
agent::agent_providers,
|
||||
agent::agent_refresh_models,
|
||||
agent::agent_set_command,
|
||||
agent::agent_set_env,
|
||||
agent::agent_custom_save,
|
||||
|
||||
@@ -136,8 +136,6 @@ export const commands = {
|
||||
/** Streams the project's agent events to this window; returns every task. */
|
||||
agentSubscribe: (onEvent: Channel<AgentEvent>) => typedError<AgentTask[], IdeError>(__TAURI_INVOKE("agent_subscribe", { onEvent })),
|
||||
agentProviders: () => typedError<AgentProvider[], IdeError>(__TAURI_INVOKE("agent_providers")),
|
||||
/** Refreshes the model catalog; force bypasses freshness, not the cooldown. */
|
||||
agentRefreshModels: (provider: string, force: boolean) => typedError<AgentModelCatalog, IdeError>(__TAURI_INVOKE("agent_refresh_models", { provider, force })),
|
||||
/**
|
||||
* Overrides built-in agent `id`'s command with `command` (its agent file keeps the
|
||||
* built-in's driver and arguments), or returns it to auto-detection with `None`.
|
||||
@@ -1972,8 +1970,6 @@ export type AgentEvent =
|
||||
{ type: "append"; task: string; subagent: string | null; index: number; from: number; text: string } |
|
||||
/** An agent reported the usage limits of its account (shared by every task of `provider`). */
|
||||
{ type: "limits"; provider: string; limits: AgentLimit[] } |
|
||||
/** A model lookup started or completed, including unsuccessful lookups. */
|
||||
{ type: "modelCatalog"; catalog: AgentModelCatalog } |
|
||||
/** The installed or configured agent providers changed. */
|
||||
{ type: "providers" };
|
||||
|
||||
@@ -2029,17 +2025,6 @@ export type AgentMcpServer_Serialize = {
|
||||
agents?: string[],
|
||||
};
|
||||
|
||||
/** Authoritative choices for new tasks, independent of any running session's settings. */
|
||||
export type AgentModelCatalog = {
|
||||
provider: string,
|
||||
config: AgentConfigOption[],
|
||||
thoughtLevels: { [key in string]: AgentConfigOption[] },
|
||||
refreshing: boolean,
|
||||
refreshedAtMs: number | null,
|
||||
retryAtMs: number | null,
|
||||
error: string | null,
|
||||
};
|
||||
|
||||
export type AgentPermissionOption = {
|
||||
id: string,
|
||||
name: string,
|
||||
@@ -4059,8 +4044,6 @@ export type DebuggerSettings = {
|
||||
showOnBreakpoint?: boolean,
|
||||
focusAppOnBreakpoint?: boolean,
|
||||
hideOnTermination?: boolean,
|
||||
/** A breakpoint or exception hit brings the Debug tool window's Threads & Variables tab to the front. */
|
||||
variablesOnBreakpoint?: boolean,
|
||||
breakpointsAnyFile?: boolean,
|
||||
/** While suspended, hovering the start of a line shows a Run to Cursor button. */
|
||||
runToCursorInlay?: boolean,
|
||||
|
||||
@@ -220,9 +220,7 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
: undefined,
|
||||
italic: t.preview,
|
||||
modified: dirty[i],
|
||||
// IDEA: hiding the close button is visual only. Keep close gestures
|
||||
// (middle click, Shift+click, Delete) alive when its position is None.
|
||||
showCloseButton,
|
||||
closable: showCloseButton,
|
||||
color: colors[i] || undefined,
|
||||
tint: tints[i] || undefined,
|
||||
tooltip: virtualTooltip(t.input) ?? (showFullPathOnHover ? (t.input.path ?? name) : undefined),
|
||||
|
||||
@@ -80,15 +80,4 @@ describe("pinned tabs in a separate row", () => {
|
||||
fireEvent.mouseDown(screen.getByRole("tab", { name: /f2/ }), { button: 1 });
|
||||
expect(closeTab).toHaveBeenCalledWith("p1", "t2");
|
||||
});
|
||||
|
||||
// Regression: Settings › Editor Tabs › "Close button position: None" hides the ×
|
||||
// but must not disable middle-click. EditorArea used to pass closable:
|
||||
// showCloseButton, so hiding the button made the tab unclosable.
|
||||
it("middle click still closes when the close button is hidden (position None)", () => {
|
||||
seed([false, false, false], { placement: "top", showCloseButton: false });
|
||||
render(<EditorAreaView />);
|
||||
expect(screen.queryByRole("button", { name: "Close Tab" })).toBeNull();
|
||||
fireEvent.mouseDown(screen.getByRole("tab", { name: /f2/ }), { button: 1 });
|
||||
expect(closeTab).toHaveBeenCalledWith("p1", "t2");
|
||||
});
|
||||
});
|
||||
@@ -173,11 +173,6 @@ export interface MarkerDeps {
|
||||
* Absent for languages whose servers do provide one (Java, Go, ...).
|
||||
*/
|
||||
inheritance?: () => Promise<ClassInheritance[]>;
|
||||
/**
|
||||
* The file a Python `import` path names (`from a.b import X`), `.x` resolved
|
||||
* relative to `from`. Disambiguates a base name two files both declare.
|
||||
*/
|
||||
moduleFile?: (module: string, from: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
@@ -271,15 +266,10 @@ export async function fillMarkers(
|
||||
if (graph) for (const cls of graph) byName.set(cls.name, [...(byName.get(cls.name) ?? []), cls]);
|
||||
// The inheritance graph's direct subclasses, keyed by the *declaration* each base
|
||||
// resolves to (path + position), so classes sharing a bare name never mix up.
|
||||
const resolver = baseResolver(byName, deps);
|
||||
const subclassesOf = new Map<string, ClassInheritance[]>();
|
||||
for (const cls of graph ?? []) {
|
||||
for (const base of new Set(cls.bases)) {
|
||||
// Only this file's classes are looked up below: read another file's imports only
|
||||
// when its base may name one of them, not for every shared or library base name.
|
||||
const exact = resolver.exact(base, cls.path);
|
||||
const named = exact.length || !byName.get(base)?.some((c) => c.path === path) ? exact : null;
|
||||
for (const decl of named ?? (await resolver.resolve(base, cls.path))) {
|
||||
for (const decl of resolveBase(byName, base, cls.path)) {
|
||||
const key = declarationKey(decl);
|
||||
subclassesOf.set(key, [...(subclassesOf.get(key) ?? []), cls]);
|
||||
}
|
||||
@@ -327,7 +317,7 @@ export async function fillMarkers(
|
||||
const c = up[i]!;
|
||||
const self = classOf(c);
|
||||
if (!self) continue;
|
||||
const found = await superMethodsFrom(resolver, self.name, c.symbol, deps);
|
||||
const found = await superMethodsFrom(byName, self.name, c.symbol, deps);
|
||||
if (!found.length) continue;
|
||||
const known = supers[i]!;
|
||||
// Same merge rule as ↓: add the graph's super methods to what the server answered.
|
||||
@@ -385,104 +375,12 @@ function declarationKey(cls: ClassInheritance): string {
|
||||
/**
|
||||
* The classes a base name names. A bare name is only trusted when it is unique in the
|
||||
* project, or names a class in the subclass's own file: a `Config` declared in hundreds
|
||||
* of modules must not make every one of them a subclass of every other. When several
|
||||
* files declare it, the subclass's own imports decide (`from x.y import Config`).
|
||||
* of modules must not make every one of them a subclass of every other.
|
||||
*/
|
||||
interface BaseResolver {
|
||||
/** The graph's classes by name. */
|
||||
byName: ReadonlyMap<string, ClassInheritance[]>;
|
||||
/** A base that names one class, or a class of the subclass's own file. */
|
||||
exact(name: string, from: string): ClassInheritance[];
|
||||
/** `exact`, else the declaration the file's imports point at (empty when unknown). */
|
||||
resolve(name: string, from: string): Promise<ClassInheritance[]>;
|
||||
}
|
||||
|
||||
function baseResolver(byName: ReadonlyMap<string, ClassInheritance[]>, deps: MarkerDeps): BaseResolver {
|
||||
const imports = new Map<string, Promise<Map<string, ImportBinding> | null>>();
|
||||
const answers = new Map<string, Promise<ClassInheritance[]>>();
|
||||
const exact = (name: string, from: string): ClassInheritance[] => {
|
||||
const found = byName.get(name) ?? [];
|
||||
return found.length <= 1 ? found : found.filter((c) => c.path === from);
|
||||
};
|
||||
const bindingsOf = (from: string): Promise<Map<string, ImportBinding> | null> => {
|
||||
let pending = imports.get(from);
|
||||
if (!pending) {
|
||||
pending = deps
|
||||
.readFiles([from])
|
||||
.then((files) => {
|
||||
const lines = files.get(from);
|
||||
return lines ? importBindings(lines) : null;
|
||||
})
|
||||
.catch(() => null);
|
||||
imports.set(from, pending);
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
return {
|
||||
byName,
|
||||
exact,
|
||||
resolve: (name, from) => {
|
||||
const found = exact(name, from);
|
||||
if (found.length) return Promise.resolve(found);
|
||||
// No class of that name at all: it may be an import alias (`import X as Y`).
|
||||
const key = `${from}\u0000${name}`;
|
||||
let pending = answers.get(key);
|
||||
if (!pending) {
|
||||
pending = (async () => {
|
||||
const binding = (await bindingsOf(from))?.get(name);
|
||||
if (!binding || !deps.moduleFile) return [];
|
||||
// `from . import X` names the package itself: resolve its `__init__`.
|
||||
const module = binding.module;
|
||||
const named = /^\.+$/.test(module) ? `${module.endsWith(".") ? module : `${module}.`}__init__` : module;
|
||||
const file = await deps.moduleFile(named, from).catch(() => null);
|
||||
// The declaration carries the imported (pre-alias) name.
|
||||
return file ? (byName.get(binding.name) ?? []).filter((c) => c.path === file) : [];
|
||||
})();
|
||||
answers.set(key, pending);
|
||||
}
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a name written in a file comes from: the module of a `from` import and the
|
||||
* name it is declared under there (equal unless the import aliases it).
|
||||
*/
|
||||
export interface ImportBinding {
|
||||
module: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local name -> declaration bindings of a file's `from` imports:
|
||||
* `from a.b import X as Y` binds `Y` to (`a.b`, `X`). Plain `import a.b` statements
|
||||
* are skipped: a base written through them keeps its qualifier, which this graph
|
||||
* does not store.
|
||||
*/
|
||||
export function importBindings(lines: readonly string[]): Map<string, ImportBinding> {
|
||||
const out = new Map<string, ImportBinding>();
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = (lines[i] ?? "").replace(/#.*$/, "");
|
||||
const from = /^\s*from\s+(\.*[\w.]*)\s+import\s+(.*)$/.exec(line);
|
||||
if (!from) continue;
|
||||
let body = from[2]!.trim();
|
||||
if (body.startsWith("(")) {
|
||||
// The names may span lines inside the parentheses.
|
||||
let text = body.slice(1);
|
||||
while (!text.includes(")") && i + 1 < lines.length) text += ` ${(lines[++i] ?? "").replace(/#.*$/, "")}`;
|
||||
body = text.slice(0, text.indexOf(")") < 0 ? undefined : text.indexOf(")"));
|
||||
}
|
||||
for (const part of body.split(",")) {
|
||||
const [name, alias] = part.trim().split(/\s+as\s+/);
|
||||
const declared = (name ?? "").trim();
|
||||
if (!declared || declared === "*" || !/^\w+$/.test(declared)) continue;
|
||||
const local = (alias ?? declared).trim();
|
||||
if (!/^\w+$/.test(local)) continue;
|
||||
out.set(local, { module: from[1]!, name: declared });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
function resolveBase(byName: ReadonlyMap<string, ClassInheritance[]>, name: string, from: string): ClassInheritance[] {
|
||||
const found = byName.get(name) ?? [];
|
||||
if (found.length <= 1) return found;
|
||||
return found.filter((c) => c.path === from);
|
||||
}
|
||||
|
||||
/** The class declaration itself as a navigation target (its name identifier). */
|
||||
@@ -512,7 +410,7 @@ function methodInOutline(outline: readonly OutlineItem[], cls: ClassInheritance,
|
||||
* graph's bases up to a few levels. Empty when no base declares the method.
|
||||
*/
|
||||
async function superMethodsFrom(
|
||||
resolver: BaseResolver,
|
||||
byName: ReadonlyMap<string, ClassInheritance[]>,
|
||||
start: string,
|
||||
method: string,
|
||||
deps: MarkerDeps,
|
||||
@@ -525,11 +423,11 @@ async function superMethodsFrom(
|
||||
const next: string[] = [];
|
||||
const found: { location: Location; owner: string }[] = [];
|
||||
for (const name of level) {
|
||||
for (const cls of resolver.byName.get(name) ?? []) {
|
||||
for (const cls of byName.get(name) ?? []) {
|
||||
for (const base of cls.bases) {
|
||||
if (!seen.add(base)) continue;
|
||||
// Same rule as the graph: a base the imports name, else a unique one.
|
||||
const declarations = await resolver.resolve(base, cls.path);
|
||||
// Same rule as the graph: only a base that names one class is followed.
|
||||
const declarations = resolveBase(byName, base, cls.path);
|
||||
let answered = false;
|
||||
for (const baseCls of declarations) {
|
||||
const outline = await deps.supers.outline(baseCls.path).catch(() => [] as OutlineItem[]);
|
||||
@@ -896,9 +794,6 @@ function markerDeps(ctx: EditorDocContext, items: OutlineItem[], request: Reques
|
||||
// Python servers answer no type hierarchy (basedpyright, pyrefly): the IDE's
|
||||
// own index knows every `class X(Base)` of the project, attached roots included.
|
||||
inheritance: ctx.language === "python" ? inheritanceGraph : undefined,
|
||||
// A base name several modules declare is resolved through the file's imports.
|
||||
moduleFile:
|
||||
ctx.language === "python" ? (name, from) => lspRequest(() => commands.navResolveModule(name, from)) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
emptyCache,
|
||||
enabledGroups,
|
||||
fillMarkers,
|
||||
importBindings,
|
||||
markerBatches,
|
||||
markersOf,
|
||||
noteOtherChanges,
|
||||
@@ -248,13 +247,8 @@ function server(
|
||||
}
|
||||
|
||||
/** A Python project: the server answers neither implementations nor a type hierarchy. */
|
||||
function pythonServer(
|
||||
inheritance: ClassInheritance[],
|
||||
outlines: Record<string, OutlineItem[]>,
|
||||
files: Record<string, string[]> = {},
|
||||
modules: Record<string, string> = {},
|
||||
) {
|
||||
const calls = { implementations: 0, inheritance: 0, modules: [] as string[] };
|
||||
function pythonServer(inheritance: ClassInheritance[], outlines: Record<string, OutlineItem[]>) {
|
||||
const calls = { implementations: 0, inheritance: 0 };
|
||||
const deps = (): MarkerDeps => ({
|
||||
implementations: async () => {
|
||||
calls.implementations++;
|
||||
@@ -266,11 +260,7 @@ function pythonServer(
|
||||
},
|
||||
outline: async (p) => outlines[p] ?? [],
|
||||
},
|
||||
readFiles: async (paths) => new Map(paths.map((p) => [p, files[p] ?? null])),
|
||||
moduleFile: async (module, from) => {
|
||||
calls.modules.push(`${module}@${from}`);
|
||||
return modules[`${module}@${from}`] ?? null;
|
||||
},
|
||||
readFiles: async () => new Map(),
|
||||
inheritance: async () => {
|
||||
calls.inheritance++;
|
||||
return inheritance;
|
||||
@@ -765,119 +755,6 @@ describe("python inheritance fallback", () => {
|
||||
expect(cachedMarkers(candidates, ALL, cache).map((m) => [m.line, m.kind])).toEqual([[0, "overridden"]]);
|
||||
});
|
||||
|
||||
it("resolves an ambiguous base name through the subclass file's imports", async () => {
|
||||
// Two modules declare `Config`; `from pkg.b import Config` picks one of them, so
|
||||
// only b.py's Config is marked as subclassed.
|
||||
const child = [item("Child", "class", 3, 4, [], 6)];
|
||||
const parentOutline = [item("Config", "class", 0, 1, [], 6)];
|
||||
const graph: ClassInheritance[] = [
|
||||
{ path: "/p/a.py", line: 0, col: 6, name: "Config", bases: [] },
|
||||
{ path: "/p/b.py", line: 0, col: 6, name: "Config", bases: [] },
|
||||
{ path: "/p/c.py", line: 3, col: 6, name: "Child", bases: ["Config"] },
|
||||
];
|
||||
const files = { "/p/c.py": ["from pkg.b import Config", "", "", "class Child(Config):"] };
|
||||
const modules = { "pkg.b@/p/c.py": "/p/b.py" };
|
||||
const { deps, calls } = pythonServer(graph, { "/p/c.py": child, "/p/b.py": parentOutline }, files, modules);
|
||||
const candidates = markerCandidates(parentOutline, () => "");
|
||||
const cache = emptyCache();
|
||||
await fillMarkers("/p/b.py", candidates, ALL, deps(), cache);
|
||||
expect(calls.modules).toEqual(["pkg.b@/p/c.py"]);
|
||||
expect(cachedMarkers(candidates, ALL, cache).map((m) => [m.line, m.kind, m.targets.map((t) => t.path)])).toEqual([
|
||||
[0, "overridden", ["/p/c.py"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves a base through an aliased import", async () => {
|
||||
// c.py: `class Child(Cfg)` (line 3) with `m` (line 4); b.py's Config declares `m` too.
|
||||
const outline = [item("Child", "class", 3, 5, [item("m", "method", 4, 4)], 6)];
|
||||
const graph: ClassInheritance[] = [
|
||||
{ path: "/p/a.py", line: 0, col: 6, name: "Config", bases: [] },
|
||||
{ path: "/p/b.py", line: 0, col: 6, name: "Config", bases: [] },
|
||||
{ path: "/p/c.py", line: 3, col: 6, name: "Child", bases: ["Cfg"] },
|
||||
];
|
||||
const files = {
|
||||
"/p/c.py": ["from pkg.b import Config as Cfg", "", "", "class Child(Cfg):", " def m(self): ..."],
|
||||
};
|
||||
const outlines = {
|
||||
"/p/c.py": outline,
|
||||
"/p/a.py": [item("Config", "class", 0, 2, [item("m", "method", 1, 1)], 6)],
|
||||
"/p/b.py": [item("Config", "class", 0, 2, [item("m", "method", 1, 1)], 6)],
|
||||
};
|
||||
const { deps } = pythonServer(graph, outlines, files, { "pkg.b@/p/c.py": "/p/b.py" });
|
||||
const candidates = markerCandidates(outline, () => "");
|
||||
const cache = emptyCache();
|
||||
await fillMarkers("/p/c.py", candidates, ALL, deps(), cache);
|
||||
expect(cachedMarkers(candidates, ALL, cache).map((m) => [m.line, m.kind, m.targets.map((t) => t.path)])).toEqual([
|
||||
[4, "overriding", ["/p/b.py"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads no other file's imports for a shared base this file does not declare", async () => {
|
||||
const outline = [item("Other", "class", 0, 1, [], 6)];
|
||||
const graph: ClassInheritance[] = [
|
||||
{ path: "/p/a.py", line: 0, col: 6, name: "Config", bases: [] },
|
||||
{ path: "/p/b.py", line: 0, col: 6, name: "Config", bases: [] },
|
||||
{ path: "/p/c.py", line: 3, col: 6, name: "Child", bases: ["Config", "BaseModel"] },
|
||||
{ path: "/p/d.py", line: 0, col: 6, name: "Other", bases: [] },
|
||||
];
|
||||
const files = { "/p/c.py": ["from pkg.b import Config", "", "", "class Child(Config, BaseModel):"] };
|
||||
const { deps, calls } = pythonServer(graph, { "/p/d.py": outline }, files, { "pkg.b@/p/c.py": "/p/b.py" });
|
||||
const cache = emptyCache();
|
||||
await fillMarkers(
|
||||
"/p/d.py",
|
||||
markerCandidates(outline, () => ""),
|
||||
ALL,
|
||||
deps(),
|
||||
cache,
|
||||
);
|
||||
expect(calls.modules).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps ignoring a shared base name when no import names it", async () => {
|
||||
const outline = [item("Child", "class", 0, 2, [], 6)];
|
||||
const graph: ClassInheritance[] = [
|
||||
{ path: "/p/a.py", line: 0, col: 6, name: "Config", bases: [] },
|
||||
{ path: "/p/b.py", line: 0, col: 6, name: "Config", bases: [] },
|
||||
{ path: "/p/c.py", line: 0, col: 6, name: "Child", bases: ["Config"] },
|
||||
];
|
||||
// No import of `Config`: the name stays ambiguous and no edge is invented.
|
||||
const files = { "/p/c.py": ["import os", "", "class Child(Config):"] };
|
||||
const { deps } = pythonServer(graph, { "/p/c.py": outline }, files);
|
||||
const cache = emptyCache();
|
||||
await fillMarkers(
|
||||
"/p/c.py",
|
||||
markerCandidates(outline, () => ""),
|
||||
ALL,
|
||||
deps(),
|
||||
cache,
|
||||
);
|
||||
expect(
|
||||
cachedMarkers(
|
||||
markerCandidates(outline, () => ""),
|
||||
ALL,
|
||||
cache,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads plain, aliased and parenthesized imports", () => {
|
||||
const bindings = importBindings([
|
||||
"from pkg.mod import A, B as Bee",
|
||||
"from pkg import (",
|
||||
" C,",
|
||||
" D as Dee,",
|
||||
")",
|
||||
"import x.y as xy",
|
||||
"if TYPE_CHECKING:",
|
||||
]);
|
||||
expect(Object.fromEntries(bindings)).toEqual({
|
||||
A: { module: "pkg.mod", name: "A" },
|
||||
Bee: { module: "pkg.mod", name: "B" },
|
||||
C: { module: "pkg", name: "C" },
|
||||
Dee: { module: "pkg", name: "D" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the existing server path when no inheritance graph is injected", async () => {
|
||||
const { deps } = server({}, {});
|
||||
const candidates = markerCandidates([item("Foo", "class", 0, 2, [item("m", "method", 1, 1)], 6)], () => "");
|
||||
|
||||
@@ -189,40 +189,6 @@
|
||||
.modelEffortPopup .modelList {
|
||||
max-height: 216px;
|
||||
}
|
||||
.catalogFooter {
|
||||
margin-top: 6px;
|
||||
padding: 8px 12px 2px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.catalogNote,
|
||||
.catalogError {
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.catalogError {
|
||||
color: var(--danger);
|
||||
}
|
||||
.catalogFooter .catalogError {
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
.refreshModels {
|
||||
padding: 4px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.refreshModels:hover:not(:disabled) {
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.refreshModels:disabled {
|
||||
color: var(--fg-muted);
|
||||
cursor: default;
|
||||
}
|
||||
.effortHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -133,8 +133,6 @@ export interface ComposerProps {
|
||||
onSubmit: (now: boolean) => void;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
/** A request from this host to focus its input without changing the selection. */
|
||||
focusRequest?: number;
|
||||
/** Left of the bottom row: agent / model / mode pickers. */
|
||||
toolbar?: ReactNode;
|
||||
/** Right of the bottom row: send / stop buttons. */
|
||||
@@ -348,16 +346,6 @@ export function Composer(props: ComposerProps) {
|
||||
});
|
||||
}, [focus]);
|
||||
|
||||
const previousFocusRequest = useRef(props.focusRequest);
|
||||
useEffect(() => {
|
||||
if (previousFocusRequest.current === props.focusRequest) return;
|
||||
previousFocusRequest.current = props.focusRequest;
|
||||
const view = viewRef.current;
|
||||
if (!props.focusRequest || props.disabled || !view) return;
|
||||
view.focus();
|
||||
view.dispatch({ scrollIntoView: true });
|
||||
}, [props.focusRequest, props.disabled]);
|
||||
|
||||
// `@` completion: project file search, mapped into the task's directory.
|
||||
useEffect(() => {
|
||||
if (completion?.kind !== "file") return;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* list and an effort slider; with a mode and an on/off permissions option (Allow All),
|
||||
* the mode menu carries the switch. Names and descriptions are the agent's own.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { useRef, useState, type CSSProperties } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { AgentConfigOption } from "@/bindings";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
@@ -48,45 +48,25 @@ function switchValues(option: AgentConfigOption): { on: string; off: string } |
|
||||
const selected = (option: AgentConfigOption, value: string | null) =>
|
||||
option.values.find((v) => v.value === value) ?? null;
|
||||
|
||||
interface ModelRefresh {
|
||||
refreshing: boolean;
|
||||
retryAtMs: number | null;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function ConfigPickers({
|
||||
config,
|
||||
values,
|
||||
onChange,
|
||||
modelRefresh,
|
||||
}: {
|
||||
config: readonly AgentConfigOption[];
|
||||
/** Chosen values that override `current`, by option id. */
|
||||
values?: Record<string, string>;
|
||||
onChange: (option: string, value: string) => void;
|
||||
/** Only New Task supplies catalog discovery; session pickers retain their own settings. */
|
||||
modelRefresh?: ModelRefresh;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchor = useRef<HTMLButtonElement>(null);
|
||||
const options = mainOptions(config);
|
||||
if (modelRefresh && !options.some((o) => o.category === "model"))
|
||||
options.unshift({
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
description: null,
|
||||
current: null,
|
||||
values: [],
|
||||
via: "option",
|
||||
});
|
||||
const valueOf = (o: AgentConfigOption) => values?.[o.id] ?? o.current ?? null;
|
||||
const model = options.find((o) => o.category === "model");
|
||||
const effort = config.find((o) => o.category === "thought_level" && o.values.length > 0);
|
||||
const modelConfigs = modelConfigOptions(config);
|
||||
const combinedModel = model && (modelRefresh || effort || modelConfigs.length > 0);
|
||||
const combinedModel = model && (effort || modelConfigs.length > 0);
|
||||
const tuckedModelConfig = new Set(modelConfigs.map((o) => o.id));
|
||||
const mode = options.find((o) => o.category === "mode");
|
||||
const allowAll = mode && options.find((o) => switchValues(o));
|
||||
@@ -112,7 +92,6 @@ export function ConfigPickers({
|
||||
modelConfigs={modelConfigs}
|
||||
valueOf={valueOf}
|
||||
onChange={onChange}
|
||||
modelRefresh={modelRefresh}
|
||||
/>
|
||||
) : o === mode && allowAll ? (
|
||||
<ModePicker
|
||||
@@ -227,7 +206,6 @@ function ModelEffortPicker({
|
||||
modelConfigs,
|
||||
valueOf,
|
||||
onChange,
|
||||
modelRefresh,
|
||||
}: {
|
||||
model: AgentConfigOption;
|
||||
modelValue: string | null;
|
||||
@@ -236,15 +214,11 @@ function ModelEffortPicker({
|
||||
modelConfigs: AgentConfigOption[];
|
||||
valueOf: (o: AgentConfigOption) => string | null;
|
||||
onChange: (option: string, value: string) => void;
|
||||
modelRefresh?: ModelRefresh;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchor = useRef<HTMLButtonElement>(null);
|
||||
const unavailable = modelRefresh && modelValue !== null && !selected(model, modelValue);
|
||||
const modelName = unavailable
|
||||
? tx`${modelValue} (unavailable)`
|
||||
: (selected(model, modelValue)?.name ?? tx("Default"));
|
||||
const modelName = selected(model, modelValue)?.name ?? tx("Default");
|
||||
const effortName = effort ? (selected(effort, effortValue)?.name ?? tx("Default")) : null;
|
||||
const modelConfigSummary = modelConfigs
|
||||
.map((o) => {
|
||||
@@ -292,11 +266,6 @@ function ModelEffortPicker({
|
||||
aria-label={tx("Model & effort settings")}
|
||||
className={styles.modelEffortPopup}
|
||||
>
|
||||
{unavailable && (
|
||||
<p className={styles.catalogError} role="alert">
|
||||
<T>Choose another model before starting.</T>
|
||||
</p>
|
||||
)}
|
||||
{effort && (
|
||||
<div className={styles.effortBlock}>
|
||||
<div className={styles.effortHeader}>
|
||||
@@ -312,50 +281,19 @@ function ModelEffortPicker({
|
||||
<ConfigPicker option={o} value={valueOf(o)} onChange={onChange} framed />
|
||||
</div>
|
||||
))}
|
||||
{model.values.length > 0 || !modelRefresh ? (
|
||||
<SelectListbox
|
||||
aria-label={tx(model.name)}
|
||||
value={modelValue}
|
||||
options={configPickerOptions(model)}
|
||||
searchable
|
||||
listClassName={styles.modelList}
|
||||
onChoose={(v) => onChange(model.id, v)}
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.catalogNote} role="status">
|
||||
{modelRefresh?.refreshing ? <T>Loading models…</T> : <T>No models reported by this agent.</T>}
|
||||
</p>
|
||||
)}
|
||||
{modelRefresh && <ModelRefreshFooter key={modelRefresh.retryAtMs} {...modelRefresh} />}
|
||||
<SelectListbox
|
||||
aria-label={tx(model.name)}
|
||||
value={modelValue}
|
||||
options={configPickerOptions(model)}
|
||||
searchable
|
||||
listClassName={styles.modelList}
|
||||
onChoose={(v) => onChange(model.id, v)}
|
||||
/>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRefreshFooter({ refreshing, retryAtMs, error, onRefresh }: ModelRefresh) {
|
||||
const tx = useTx();
|
||||
const [now, setNow] = useState(Date.now);
|
||||
const remaining = retryAtMs == null ? 0 : Math.max(0, Math.ceil((retryAtMs - now) / 1000));
|
||||
const coolingDown = remaining > 0;
|
||||
useEffect(() => {
|
||||
if (!coolingDown) return;
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [retryAtMs, coolingDown]);
|
||||
return (
|
||||
<div className={styles.catalogFooter}>
|
||||
{error && (
|
||||
<p className={styles.catalogError} role="alert">
|
||||
{tx`Could not refresh models: ${error}`}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" className={styles.refreshModels} disabled={refreshing || coolingDown} onClick={onRefresh}>
|
||||
{refreshing ? tx("Refreshing…") : coolingDown ? tx`Refresh in ${remaining}s` : tx("Refresh models")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A stepped slider over the agent's effort levels, in the order the agent lists them. */
|
||||
function EffortSlider({
|
||||
option,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { AgentConfigOption, AgentProvider, NewAgentTask } from "@/bindings";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { HomeEditor } from "./HomeEditor";
|
||||
import { HOME, disconnect, savedDraft, updateComposer, useAgents } from "./store";
|
||||
import { HOME, savedDraft, updateComposer, useAgents } from "./store";
|
||||
|
||||
const backend = vi.hoisted(() => ({ providers: [] as AgentProvider[], create: vi.fn() }));
|
||||
const ok = <T,>(data: T) => Promise.resolve({ status: "ok", data });
|
||||
@@ -11,16 +11,6 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
commands: {
|
||||
agentProviders: () => ok(backend.providers),
|
||||
agentRefreshModels: (provider: string) =>
|
||||
ok({
|
||||
provider,
|
||||
config: backend.providers.find((p) => p.id === provider)?.config ?? [],
|
||||
thoughtLevels: backend.providers.find((p) => p.id === provider)?.thoughtLevels ?? {},
|
||||
refreshing: false,
|
||||
refreshedAtMs: Date.now(),
|
||||
retryAtMs: null,
|
||||
error: null,
|
||||
}),
|
||||
agentProbe: () => ok(null),
|
||||
vcsBranchInfo: () => ok({ head: "main" }),
|
||||
vcsBranches: () => ok([]),
|
||||
@@ -44,7 +34,6 @@ const option = (id: string, category: string, values: string[], current: string)
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
disconnect();
|
||||
backend.create.mockReset().mockImplementation((req: NewAgentTask) => ok({ id: "new", config: req.config }));
|
||||
backend.providers = [
|
||||
{
|
||||
@@ -106,7 +95,7 @@ it("switches model-specific levels and restores each draft effort before startin
|
||||
it.each(["plain", "unknown"])("hides effort for %s models and sends no stale effort", async (model) => {
|
||||
render(<HomeEditor />);
|
||||
await chooseModel(model);
|
||||
expect(screen.getByRole("button", { name: "Model" })).toHaveAttribute("aria-haspopup", "dialog");
|
||||
expect(screen.getByRole("button", { name: "Model" })).toHaveAttribute("aria-haspopup", "listbox");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start" }));
|
||||
await waitFor(() => expect(backend.create).toHaveBeenCalled());
|
||||
expect(backend.create.mock.calls[0]![0].config).toEqual([{ id: "model", value: model }]);
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
NO_COMMANDS,
|
||||
draftConfig,
|
||||
refreshProviders,
|
||||
refreshModels,
|
||||
setDraftOption,
|
||||
startTask,
|
||||
updateDraft,
|
||||
@@ -82,24 +81,6 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
|
||||
const provider = providers.find((p) => p.id === draft.provider) ?? providers[0];
|
||||
const probe = useProbe(provider?.installed ? provider.id : null, provider?.command);
|
||||
const catalog = useAgents((s) => s.modelCatalogs.get(provider?.id ?? ""));
|
||||
const requestingModels = useAgents((s) => s.modelRequests.has(provider?.id ?? ""));
|
||||
const launch = provider && JSON.stringify([provider.program, provider.args, provider.env, provider.command]);
|
||||
useEffect(() => {
|
||||
if (!provider?.installed) return;
|
||||
const refresh = () => void refreshModels(provider.id, false);
|
||||
refresh();
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => window.removeEventListener("focus", refresh);
|
||||
}, [provider?.id, provider?.installed, launch, project?.root]);
|
||||
const defaults = useAgentSettings().defaults;
|
||||
const config = draftConfig(provider, draft, (provider && defaults[provider.id]) || {});
|
||||
const unavailableModel = config.some(
|
||||
(o) =>
|
||||
o.category === "model" &&
|
||||
draft.config[o.id] !== undefined &&
|
||||
!o.values.some((v) => v.value === draft.config[o.id]),
|
||||
);
|
||||
const agentCommands = useAgentCommands(provider?.id, null, provider?.commands ?? NO_COMMANDS);
|
||||
const providerOptions: SelectOption[] = agentOptions(providers, tx, providerIcon);
|
||||
// Without git under the project there are no worktrees: force the local checkout.
|
||||
@@ -107,11 +88,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
const isolation = isGit ? draft.isolation : "local";
|
||||
const base = draft.base ?? current;
|
||||
const canStart =
|
||||
canSend(composer) &&
|
||||
!!provider?.installed &&
|
||||
!unavailableModel &&
|
||||
!starting &&
|
||||
(!project?.workspace || repoState?.root === root);
|
||||
canSend(composer) && !!provider?.installed && !starting && (!project?.workspace || repoState?.root === root);
|
||||
|
||||
const start = () => {
|
||||
if (!canStart) return;
|
||||
@@ -121,6 +98,8 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setStarting(false));
|
||||
};
|
||||
const defaults = useAgentSettings().defaults;
|
||||
const config = draftConfig(provider, draft, (provider && defaults[provider.id]) || {});
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.home, docked && styles.docked)}>
|
||||
@@ -230,21 +209,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
id === ADD_AGENT ? void executeAction("agents.addAgent") : updateDraft({ provider: id, config: {} })
|
||||
}
|
||||
/>
|
||||
<ConfigPickers
|
||||
key={provider?.id}
|
||||
config={config}
|
||||
onChange={setDraftOption}
|
||||
modelRefresh={
|
||||
provider?.installed
|
||||
? {
|
||||
refreshing: requestingModels || (catalog?.refreshing ?? false),
|
||||
retryAtMs: catalog?.retryAtMs ?? null,
|
||||
error: catalog?.error ?? null,
|
||||
onRefresh: () => void executeAction("agents.refreshModels"),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ConfigPickers config={config} onChange={setDraftOption} />
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
@@ -258,11 +223,6 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
/>
|
||||
{/* Notes under the box don't take part in centring, so the box stays put as they come and go. */}
|
||||
<div className={styles.notes}>
|
||||
{unavailableModel && (
|
||||
<p className={styles.warning} role="alert">
|
||||
<T>Choose another model before starting.</T>
|
||||
</p>
|
||||
)}
|
||||
{probe && (!docked || probe.error) && (
|
||||
<p className={probe.error ? styles.warning : styles.note}>
|
||||
{probe.error
|
||||
@@ -270,12 +230,17 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
: [probe.name, probe.version, probe.protocol].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
{(error || (provider && !provider.installed)) && (
|
||||
{(error || (provider && !provider.installed) || (provider && !provider.config.length)) && (
|
||||
<p
|
||||
className={error || !provider?.installed ? styles.warning : styles.note}
|
||||
role={error ? "alert" : undefined}
|
||||
>
|
||||
{error ?? txTemplate(`${provider?.command ?? ""} was not found on PATH`)}
|
||||
{error ??
|
||||
(provider && !provider.installed ? (
|
||||
txTemplate(`${provider.command} was not found on PATH`)
|
||||
) : (
|
||||
<T>Models and modes appear after this agent's first task.</T>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -201,36 +201,23 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
const openSubagent = useCallback((sub: string) => setPath((p) => [...p, sub]), []);
|
||||
const [finding, setFinding] = useState(false);
|
||||
const [findFocus, setFindFocus] = useState(0);
|
||||
const [inputFocus, setInputFocus] = useState(0);
|
||||
const sessionRoot = useRef<HTMLDivElement>(null);
|
||||
const canFocusInput = shown && !!task && task.status !== "archived" && !view;
|
||||
const session = useMemo<FocusedSession>(
|
||||
() => ({
|
||||
openFind: () => {
|
||||
setFinding(true);
|
||||
setFindFocus((n) => n + 1);
|
||||
},
|
||||
focusInput: canFocusInput
|
||||
? () => {
|
||||
if (docked) setChangesOpen(false);
|
||||
setInputFocus((n) => n + 1);
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
[canFocusInput, docked, setChangesOpen],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (shown && sessionRoot.current?.contains(document.activeElement)) setFocusedSession(session);
|
||||
return () => {
|
||||
const [session] = useState<FocusedSession>(() => ({
|
||||
openFind: () => {
|
||||
setFinding(true);
|
||||
setFindFocus((n) => n + 1);
|
||||
},
|
||||
}));
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (focusedSession() === session) setFocusedSession(null);
|
||||
};
|
||||
}, [session, shown]);
|
||||
},
|
||||
[session],
|
||||
);
|
||||
if (!task) return <EmptyState title="This agent task no longer exists" />;
|
||||
return (
|
||||
<div
|
||||
ref={sessionRoot}
|
||||
className={clsx(styles.session, docked && styles.docked)}
|
||||
onFocusCapture={() => shown && setFocusedSession(session)}
|
||||
onFocusCapture={() => setFocusedSession(session)}
|
||||
onBlurCapture={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedSession() === session) {
|
||||
setFocusedSession(null);
|
||||
@@ -261,7 +248,6 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
<QuestionCard task={task} entries={entries ?? null} />
|
||||
<SessionComposer
|
||||
task={task}
|
||||
focusRequest={inputFocus}
|
||||
entries={entries ?? null}
|
||||
head={head}
|
||||
changes={changes ?? null}
|
||||
@@ -2694,7 +2680,6 @@ function PendingPermission({ task, entry }: { task: AgentTask; entry: Permission
|
||||
|
||||
function SessionComposer({
|
||||
task,
|
||||
focusRequest,
|
||||
entries,
|
||||
head,
|
||||
changes,
|
||||
@@ -2704,7 +2689,6 @@ function SessionComposer({
|
||||
onOpenSubagent,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
focusRequest: number;
|
||||
entries: AgentEntry[] | null;
|
||||
head: string | null;
|
||||
changes: AgentChange[] | null;
|
||||
@@ -2782,7 +2766,6 @@ function SessionComposer({
|
||||
</Tray>
|
||||
<Composer
|
||||
id={task.id}
|
||||
focusRequest={focusRequest}
|
||||
placeholder={
|
||||
!busy
|
||||
? "Reply to the agent"
|
||||
@@ -2923,18 +2906,15 @@ function useChangesOpen(
|
||||
open: docked ? false : loadChangesOpen()[task],
|
||||
}));
|
||||
const chosen = state.task === task ? state.open : docked ? false : loadChangesOpen()[task];
|
||||
const set = useCallback(
|
||||
(open: boolean) => {
|
||||
setState({ task, open });
|
||||
if (docked) return;
|
||||
try {
|
||||
globalThis.localStorage?.setItem(CHANGES_OPEN_KEY, JSON.stringify({ ...loadChangesOpen(), [task]: open }));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
},
|
||||
[task, docked],
|
||||
);
|
||||
const set = (open: boolean) => {
|
||||
setState({ task, open });
|
||||
if (docked) return;
|
||||
try {
|
||||
globalThis.localStorage?.setItem(CHANGES_OPEN_KEY, JSON.stringify({ ...loadChangesOpen(), [task]: open }));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
};
|
||||
return [chosen ?? (changes?.length ?? 0) > 0, set];
|
||||
}
|
||||
|
||||
|
||||
@@ -20,18 +20,6 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
...real.commands,
|
||||
agentCommands: (provider: string, task: string | null) => agentCommands(provider, task),
|
||||
agentCreate: (request: unknown) => agentCreate(request),
|
||||
agentRefreshModels: async (provider: string) => ({
|
||||
status: "ok",
|
||||
data: {
|
||||
provider,
|
||||
config: [],
|
||||
thoughtLevels: {},
|
||||
refreshing: false,
|
||||
refreshedAtMs: Date.now(),
|
||||
retryAtMs: null,
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
clipboardImage: () => clipboardImage(),
|
||||
vcsBranchInfo: (root: string | null) => vcsBranchInfo(root),
|
||||
vcsBranches: (root: string | null) => vcsBranches(root),
|
||||
|
||||
@@ -40,18 +40,6 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
agentPrompt: backend.prompt,
|
||||
agentSubscribe: async () => ({ status: "ok", data: [] }),
|
||||
agentProviders: async () => ({ status: "ok", data: [provider] }),
|
||||
agentRefreshModels: async (id: string) => ({
|
||||
status: "ok",
|
||||
data: {
|
||||
provider: id,
|
||||
config: provider.config,
|
||||
thoughtLevels: {},
|
||||
refreshing: false,
|
||||
refreshedAtMs: Date.now(),
|
||||
retryAtMs: null,
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
agentProbe: async () => ({ status: "ok", data: null }),
|
||||
agentEntries: async () => ({ status: "ok", data: [] }),
|
||||
agentChanges: async () => ({ status: "ok", data: [] }),
|
||||
|
||||
@@ -35,16 +35,7 @@ import {
|
||||
} from "./model";
|
||||
import { sendPaths, sendSelection, trackEditorCopies } from "./references";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import {
|
||||
closeActiveTask,
|
||||
closeAndNewTask,
|
||||
connect,
|
||||
disconnect,
|
||||
openHome,
|
||||
refreshModels,
|
||||
refreshProviders,
|
||||
useAgents,
|
||||
} from "./store";
|
||||
import { closeActiveTask, closeAndNewTask, connect, disconnect, openHome, refreshProviders, useAgents } from "./store";
|
||||
import { TaskStatusMark } from "./TaskStatusMark";
|
||||
import { taskActions, useAgentsHeaderMenu } from "./taskActions";
|
||||
import { replyActions } from "./replyActions";
|
||||
@@ -200,15 +191,6 @@ export function activate(): () => void {
|
||||
when: "agentSessionFocus",
|
||||
run: () => focusedSession()?.openFind(),
|
||||
},
|
||||
{
|
||||
id: "agents.focusInput",
|
||||
title: "Focus Agent Input",
|
||||
description: "Focuses the message input of the current agent task without moving the caret.",
|
||||
category: "Agents",
|
||||
when: "agentSessionFocus && agentInputAvailable",
|
||||
keyWhen: "agentSessionFocus && !popupOpen",
|
||||
run: () => focusedSession()?.focusInput?.(),
|
||||
},
|
||||
{
|
||||
id: "agents.newTask",
|
||||
title: "New Agent Task",
|
||||
@@ -219,13 +201,6 @@ export function activate(): () => void {
|
||||
keywords: ["agent", "claude", "codex", "gemini", "ai"],
|
||||
run: ({ arg }) => openHome(typeof arg === "string" ? arg : undefined),
|
||||
},
|
||||
{
|
||||
id: "agents.refreshModels",
|
||||
title: "Refresh models",
|
||||
category: "Agents",
|
||||
when: "projectOpen",
|
||||
run: () => refreshModels(),
|
||||
},
|
||||
{
|
||||
id: "agents.closeTask",
|
||||
title: "Close Agent Task",
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { clearMocks, mockIPC, mockWindows } from "@tauri-apps/api/mocks";
|
||||
import type {
|
||||
AgentConfigOption,
|
||||
AgentEvent,
|
||||
AgentModelCatalog,
|
||||
AgentProvider,
|
||||
NewAgentTask,
|
||||
ProjectInfo,
|
||||
} from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { HomeEditor } from "./HomeEditor";
|
||||
import { activate } from "./index";
|
||||
import { HOME, connect, savedDraft, updateComposer, useAgents } from "./store";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
providers: [] as AgentProvider[],
|
||||
channel: null as { onmessage: (event: AgentEvent) => void } | null,
|
||||
refresh: vi.fn(),
|
||||
create: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
const ok = <T,>(data: T) => ({ status: "ok" as const, data });
|
||||
vi.mock("@tauri-apps/api/core", async (original) => ({
|
||||
...(await original<typeof import("@tauri-apps/api/core")>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
},
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
isTauri: () => true,
|
||||
commands: {
|
||||
agentSubscribe: async (channel: typeof backend.channel) => {
|
||||
backend.channel = channel;
|
||||
return ok([]);
|
||||
},
|
||||
agentProviders: async () => ok(backend.providers),
|
||||
agentRefreshModels: (provider: string, force: boolean) => backend.refresh(provider, force),
|
||||
agentProbe: async () => ok(null),
|
||||
agentCommands: async () => ok([]),
|
||||
vcsBranchInfo: async () => ok({ head: "main" }),
|
||||
vcsBranches: async () => ok([]),
|
||||
agentCreate: (request: NewAgentTask) => backend.create(request),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", async (original) => ({
|
||||
...(await original<typeof import("@/workbench/contributions")>()),
|
||||
maybeEditorService: () => ({ activePath: () => null }),
|
||||
editorService: () => ({
|
||||
activePath: () => null,
|
||||
activeInput: () => null,
|
||||
openTabs: () => [],
|
||||
closeInput: () => false,
|
||||
}),
|
||||
notifications: () => ({ notify: backend.notify }),
|
||||
}));
|
||||
|
||||
const option = (id: string, category: string, values: string[], current = values[0] ?? null): AgentConfigOption => ({
|
||||
id,
|
||||
name: id === "model" ? "Model" : "Reasoning",
|
||||
description: null,
|
||||
category,
|
||||
current,
|
||||
via: "option",
|
||||
values: values.map((value) => ({ value, name: value, description: null })),
|
||||
});
|
||||
const provider = (id: string): AgentProvider => ({
|
||||
id,
|
||||
name: id,
|
||||
command: id,
|
||||
program: id,
|
||||
args: [],
|
||||
defaultProgram: null,
|
||||
defaultArgs: [],
|
||||
loginHint: null,
|
||||
commandOverridden: false,
|
||||
commandEditable: true,
|
||||
commandReadOnlyReason: null,
|
||||
installed: true,
|
||||
env: {},
|
||||
commands: [],
|
||||
source: { kind: "custom" },
|
||||
config: [option("model", "model", ["big", "small"])],
|
||||
thoughtLevels: { big: [option("effort", "thought_level", ["low", "high"], "high")] },
|
||||
});
|
||||
function catalog(id = "first", patch: Partial<AgentModelCatalog> = {}): AgentModelCatalog {
|
||||
const offered = backend.providers.find((p) => p.id === id)!;
|
||||
return {
|
||||
provider: id,
|
||||
config: offered.config,
|
||||
thoughtLevels: offered.thoughtLevels ?? {},
|
||||
refreshing: false,
|
||||
refreshedAtMs: Date.now(),
|
||||
retryAtMs: null,
|
||||
error: null,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
type CatalogReply = { status: "ok"; data: AgentModelCatalog };
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
const emit = (snapshot: AgentModelCatalog) =>
|
||||
act(() => backend.channel!.onmessage({ type: "modelCatalog", catalog: snapshot }));
|
||||
let dispose: () => void;
|
||||
let previousProject: ProjectInfo | null;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockWindows("main");
|
||||
mockIPC(() => undefined, { shouldMockEvents: true });
|
||||
previousProject = useProject.getState().project;
|
||||
useProject.setState({ project: { root: "/models", trusted: true } as ProjectInfo });
|
||||
setContext("projectOpen", true);
|
||||
backend.providers = [provider("first"), provider("second")];
|
||||
backend.refresh.mockReset().mockImplementation(async (id: string) => ok(catalog(id)));
|
||||
backend.create
|
||||
.mockReset()
|
||||
.mockImplementation(async (request: NewAgentTask) => ok({ id: "new", config: request.config }));
|
||||
dispose = activate();
|
||||
await connect();
|
||||
useAgents.setState({ draft: savedDraft({ provider: "first", config: {} }) });
|
||||
updateComposer(HOME, { text: "Check the project" });
|
||||
});
|
||||
afterEach(async () => {
|
||||
await vi.dynamicImportSettled();
|
||||
cleanup();
|
||||
dispose();
|
||||
useProject.setState({ project: previousProject });
|
||||
setContext("projectOpen", false);
|
||||
vi.useRealTimers();
|
||||
await vi.dynamicImportSettled();
|
||||
clearMocks();
|
||||
});
|
||||
|
||||
it("keeps cached choices usable while refreshing and shares the completion/cooldown from another window", async () => {
|
||||
backend.refresh.mockImplementation(async (id: string) => ok(catalog(id, { refreshing: true })));
|
||||
render(<HomeEditor />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
expect(screen.getByRole("button", { name: "Refreshing…" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("option", { name: "small" }));
|
||||
expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("small");
|
||||
expect(screen.getByRole("button", { name: "Start" })).toBeEnabled();
|
||||
await waitFor(() => expect(useAgents.getState().modelRequests.size).toBe(0));
|
||||
emit(catalog("first", { retryAtMs: Date.now() + 30_000 }));
|
||||
expect(screen.getByRole("button", { name: /Refresh in \d+s/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("small");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start" }));
|
||||
await waitFor(() => expect(backend.create).toHaveBeenCalled());
|
||||
expect(backend.create.mock.calls[0]![0].config).toEqual([{ id: "model", value: "small" }]);
|
||||
});
|
||||
|
||||
it("uses the same popup for model-only agents and coalesces repeated manual action invocations", async () => {
|
||||
backend.providers[0]!.thoughtLevels = {};
|
||||
await connect();
|
||||
render(<HomeEditor />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
await screen.findByRole("button", { name: "Refresh models" });
|
||||
expect(backend.refresh).toHaveBeenCalledExactlyOnceWith("first", false);
|
||||
const pending = deferred<CatalogReply>();
|
||||
backend.refresh.mockReturnValueOnce(pending.promise);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh models" }));
|
||||
expect(screen.getByRole("button", { name: "Refreshing…" })).toBeDisabled();
|
||||
await act(async () => {
|
||||
await executeAction("agents.refreshModels");
|
||||
await executeAction("agents.refreshModels");
|
||||
});
|
||||
expect(backend.refresh).toHaveBeenCalledTimes(2);
|
||||
expect(backend.refresh).toHaveBeenLastCalledWith("first", true);
|
||||
await act(async () => pending.resolve(ok(catalog("first", { refreshing: true }))));
|
||||
await act(async () => executeAction("agents.refreshModels"));
|
||||
expect(backend.refresh).toHaveBeenCalledTimes(2);
|
||||
emit(catalog("first", { retryAtMs: Date.now() + 30_000 }));
|
||||
await act(async () => executeAction("agents.refreshModels"));
|
||||
expect(backend.refresh).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByRole("button", { name: /Refresh in \d+s/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows initial loading and inline discovery failure/retry without blocking a model-less agent", async () => {
|
||||
backend.providers[0]!.config = [];
|
||||
backend.providers[0]!.thoughtLevels = {};
|
||||
await connect();
|
||||
backend.refresh.mockImplementation(async (id: string) => ok(catalog(id, { refreshing: true })));
|
||||
render(<HomeEditor />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
expect(screen.getByText("Loading models…")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Start" })).toBeEnabled();
|
||||
await waitFor(() => expect(useAgents.getState().modelRequests.size).toBe(0));
|
||||
emit(catalog("first", { error: "offline" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Could not refresh models: offline");
|
||||
expect(screen.getByText("No models reported by this agent.")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Refresh models" })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh models" }));
|
||||
expect(backend.refresh).toHaveBeenLastCalledWith("first", true);
|
||||
expect(screen.getByRole("button", { name: "Refreshing…" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start" }));
|
||||
await waitFor(() => expect(backend.create).toHaveBeenCalled());
|
||||
expect(backend.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains cached model and effort choices after a command failure and retries inline", async () => {
|
||||
backend.refresh.mockRejectedValueOnce(new Error("connection lost"));
|
||||
useAgents.setState({
|
||||
draft: savedDraft({ provider: "first", config: { model: "big" }, thoughtLevels: { big: { effort: "low" } } }),
|
||||
});
|
||||
render(<HomeEditor />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("connection lost");
|
||||
expect(screen.getByRole("slider", { name: "Reasoning" })).toHaveAttribute("aria-valuetext", "low");
|
||||
fireEvent.click(screen.getByRole("option", { name: "small" }));
|
||||
expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("small");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh models" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("small");
|
||||
expect(backend.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves a valid remembered model and effort when discovery changes the defaults", async () => {
|
||||
useAgents.setState({
|
||||
draft: savedDraft({ provider: "first", config: { model: "big" }, thoughtLevels: { big: { effort: "low" } } }),
|
||||
});
|
||||
backend.refresh.mockResolvedValueOnce(
|
||||
ok(
|
||||
catalog("first", {
|
||||
config: [option("model", "model", ["small", "big", "new"])],
|
||||
thoughtLevels: { big: [option("effort", "thought_level", ["low", "high", "max"], "max")] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(useAgents.getState().modelRequests.size).toBe(0));
|
||||
expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("biglow");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
expect(screen.getByRole("option", { name: "new" })).toBeVisible();
|
||||
expect(screen.getByRole("slider", { name: "Reasoning" })).toHaveAttribute("aria-valuetext", "low");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start" }));
|
||||
await waitFor(() => expect(backend.create).toHaveBeenCalled());
|
||||
expect(backend.create.mock.calls[0]![0].config).toEqual([
|
||||
{ id: "model", value: "big" },
|
||||
{ id: "effort", value: "low" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a removed explicit model visibly unavailable until another model is chosen", async () => {
|
||||
useAgents.setState({ draft: savedDraft({ provider: "first", config: { model: "big" } }) });
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(useAgents.getState().modelRequests.size).toBe(0));
|
||||
emit(catalog("first", { config: [option("model", "model", ["small"])], thoughtLevels: {} }));
|
||||
expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("big (unavailable)");
|
||||
expect(screen.getByRole("button", { name: "Start" })).toBeDisabled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Choose another model before starting.");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "small" }));
|
||||
expect(screen.getByRole("button", { name: "Start" })).toBeEnabled();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start" }));
|
||||
await waitFor(() => expect(backend.create).toHaveBeenCalled());
|
||||
expect(backend.create.mock.calls[0]![0].config).toEqual([{ id: "model", value: "small" }]);
|
||||
});
|
||||
|
||||
it("keeps the unavailable selection when the agent stops advertising models altogether", async () => {
|
||||
useAgents.setState({ draft: savedDraft({ provider: "first", config: { model: "big" } }) });
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(useAgents.getState().modelRequests.size).toBe(0));
|
||||
emit(catalog("first", { config: [], thoughtLevels: {} }));
|
||||
backend.providers[0]!.config = [];
|
||||
await act(async () => backend.channel!.onmessage({ type: "providers" }));
|
||||
expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("big (unavailable)");
|
||||
expect(screen.getByRole("button", { name: "Start" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
expect(screen.getByText("No models reported by this agent.")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Refresh models" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("ignores a delayed refresh after switching agents and preserves the new draft", async () => {
|
||||
const pending = deferred<CatalogReply>();
|
||||
backend.refresh.mockReturnValueOnce(pending.promise);
|
||||
render(<HomeEditor />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Agent" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "second" }));
|
||||
await waitFor(() => expect(backend.refresh).toHaveBeenLastCalledWith("second", false));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "small" }));
|
||||
await act(async () => pending.resolve(ok(catalog("first", { config: [option("model", "model", ["obsolete"])] }))));
|
||||
expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("small");
|
||||
expect(screen.queryByRole("option", { name: "obsolete" })).not.toBeInTheDocument();
|
||||
expect(useAgents.getState().modelCatalogs.has("first")).toBe(false);
|
||||
expect(screen.getByRole("button", { name: "Start" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("ignores old project responses and events when the project connection changes", async () => {
|
||||
const pending = deferred<CatalogReply>();
|
||||
backend.refresh.mockReturnValueOnce(pending.promise);
|
||||
render(<HomeEditor />);
|
||||
const oldChannel = backend.channel!;
|
||||
await act(async () => {
|
||||
useProject.setState({ project: { root: "/another", trusted: true } as ProjectInfo });
|
||||
await connect();
|
||||
pending.resolve(ok(catalog("first", { config: [option("model", "model", ["old-project"])] })));
|
||||
});
|
||||
act(() => oldChannel.onmessage({ type: "modelCatalog", catalog: catalog("first", { error: "old error" }) }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
expect(screen.queryByRole("option", { name: "old-project" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/old error/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "small" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("does not let a delayed command snapshot replace a newer catalog event", async () => {
|
||||
const pending = deferred<CatalogReply>();
|
||||
backend.refresh.mockReturnValueOnce(pending.promise);
|
||||
render(<HomeEditor />);
|
||||
emit(catalog("first", { config: [option("model", "model", ["latest"])] }));
|
||||
await act(async () => pending.resolve(ok(catalog("first", { refreshing: true }))));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
expect(screen.getByRole("option", { name: "latest" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Refresh models" })).toBeEnabled();
|
||||
backend.providers[0]!.config = [option("model", "model", ["older-session"])];
|
||||
await act(async () => backend.channel!.onmessage({ type: "providers" }));
|
||||
expect(screen.getByRole("option", { name: "latest" })).toBeVisible();
|
||||
expect(screen.queryByRole("option", { name: "older-session" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("requests freshness on focus without a retry timer, and counts down only the displayed cooldown", async () => {
|
||||
render(<HomeEditor />);
|
||||
await waitFor(() => expect(useAgents.getState().modelRequests.size).toBe(0));
|
||||
fireEvent.focus(window);
|
||||
await waitFor(() => expect(backend.refresh).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(useAgents.getState().modelRequests.size).toBe(0));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
vi.useFakeTimers();
|
||||
emit(catalog("first", { retryAtMs: Date.now() + 2_000, error: "offline" }));
|
||||
expect(screen.getByRole("button", { name: "Refresh in 2s" })).toBeDisabled();
|
||||
act(() => vi.advanceTimersByTime(1_000));
|
||||
expect(screen.getByRole("button", { name: "Refresh in 1s" })).toBeDisabled();
|
||||
act(() => vi.advanceTimersByTime(1_000));
|
||||
expect(screen.getByRole("button", { name: "Refresh models" })).toBeEnabled();
|
||||
act(() => vi.advanceTimersByTime(60_000));
|
||||
expect(backend.refresh).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
/** The agent session holding focus, for actions scoped to that task view. */
|
||||
/** The agent session tab holding focus, for actions that act on it (Find in Conversation). */
|
||||
import { setContext } from "@/platform/context";
|
||||
|
||||
export interface FocusedSession {
|
||||
openFind(): void;
|
||||
focusInput?: () => void;
|
||||
}
|
||||
|
||||
let focused: FocusedSession | null = null;
|
||||
@@ -11,7 +10,6 @@ let focused: FocusedSession | null = null;
|
||||
export function setFocusedSession(session: FocusedSession | null) {
|
||||
focused = session;
|
||||
setContext("agentSessionFocus", !!session);
|
||||
setContext("agentInputAvailable", !!session?.focusInput);
|
||||
}
|
||||
|
||||
export const focusedSession = () => focused;
|
||||
@@ -24,7 +24,7 @@ import { replyActions } from "./replyActions";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import styles from "./Session.module.css";
|
||||
import chipStyles from "./ReferenceChip.module.css";
|
||||
import { caretOf, textOf, typeIn } from "./composerTesting";
|
||||
import { textOf, typeIn } from "./composerTesting";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
entries: new Map<string, AgentEntry[]>(),
|
||||
@@ -1564,31 +1564,6 @@ describe("conversation loading", () => {
|
||||
});
|
||||
|
||||
describe("session state across tasks and hosts", () => {
|
||||
it("updates input focus availability while the task changes status without losing its draft caret", async () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Answer A");
|
||||
const message = screen.getByRole("textbox", { name: "Message" });
|
||||
typeIn(message, "Draft to keep", 3);
|
||||
const conversation = stream();
|
||||
act(() => conversation.focus());
|
||||
act(() =>
|
||||
useAgents.setState((s) => ({
|
||||
tasks: new Map(s.tasks).set("A", { ...s.tasks.get("A")!, status: "archived" }),
|
||||
})),
|
||||
);
|
||||
act(() => focusedSession()?.focusInput?.());
|
||||
expect(conversation).toHaveFocus();
|
||||
act(() =>
|
||||
useAgents.setState((s) => ({
|
||||
tasks: new Map(s.tasks).set("A", { ...s.tasks.get("A")!, status: "running" }),
|
||||
})),
|
||||
);
|
||||
act(() => focusedSession()?.focusInput?.());
|
||||
await waitFor(() => expect(message).toHaveFocus());
|
||||
expect(textOf(message)).toBe("Draft to keep");
|
||||
expect(caretOf(message)).toBe(3);
|
||||
});
|
||||
|
||||
it("leaves a task unread while its kept-alive tab is hidden, and reads it once shown", () => {
|
||||
vi.spyOn(document, "hasFocus").mockReturnValue(true);
|
||||
useAgents.setState({ unread: new Set(["A"]) });
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
AgentEvent,
|
||||
AgentImage,
|
||||
AgentLimit,
|
||||
AgentModelCatalog,
|
||||
AgentProvider,
|
||||
AgentTask,
|
||||
ExternalSession,
|
||||
@@ -94,8 +93,6 @@ interface AgentsState {
|
||||
/** Scroll position and follow-output preference by conversation, shared by both hosts. */
|
||||
scrollPositions: Map<string, { top: number; stick: boolean }>;
|
||||
providers: AgentProvider[];
|
||||
modelCatalogs: Map<string, AgentModelCatalog>;
|
||||
modelRequests: Set<string>;
|
||||
/** The usage limits each agent last reported for its account, by provider. */
|
||||
limits: Map<string, AgentLimit[]>;
|
||||
draft: Draft;
|
||||
@@ -174,8 +171,6 @@ export const useAgents = create<AgentsState>(() => ({
|
||||
groupedChanges: new Map(),
|
||||
providers: [],
|
||||
limits: new Map(),
|
||||
modelCatalogs: new Map(),
|
||||
modelRequests: new Set(),
|
||||
draft: savedDraft(load<Partial<Draft>>(DRAFT_KEY, {})),
|
||||
composers: new Map(),
|
||||
lastComposer: HOME,
|
||||
@@ -196,7 +191,6 @@ export const useAgents = create<AgentsState>(() => ({
|
||||
|
||||
export function updateDraft(patch: Partial<Draft>) {
|
||||
const current = useAgents.getState().draft;
|
||||
if (patch.provider && patch.provider !== current.provider) draftGeneration++;
|
||||
const draft = {
|
||||
...current,
|
||||
...(patch.provider && patch.provider !== current.provider ? { thoughtLevels: {} } : {}),
|
||||
@@ -211,40 +205,27 @@ export const providerDefaults = (provider: string | undefined): Record<string, s
|
||||
(provider && useProject.getState().settings?.agents?.defaults?.[provider]) || {};
|
||||
|
||||
/**
|
||||
* New Task settings use the authoritative catalog, retaining an explicitly selected
|
||||
* model even if it disappears so the user must choose its replacement.
|
||||
* Config for the selected model, with only supported choices applied: the draft's, else
|
||||
* the agent's defaults from Settings › Agents.
|
||||
*/
|
||||
export function draftConfig(
|
||||
provider: AgentProvider | undefined,
|
||||
draft: Draft,
|
||||
defaults: Record<string, string> = providerDefaults(provider?.id),
|
||||
): AgentConfigOption[] {
|
||||
const catalog = provider && useAgents.getState().modelCatalogs.get(provider.id);
|
||||
const offered = catalog?.config ?? provider?.config ?? [];
|
||||
const priorModel = provider?.config.find((o) => o.category === "model");
|
||||
const source =
|
||||
priorModel && draft.config[priorModel.id] !== undefined && !offered.some((o) => o.category === "model")
|
||||
? [...offered, { ...priorModel, values: [] }]
|
||||
: offered;
|
||||
const config = source
|
||||
const config = (provider?.config ?? [])
|
||||
.filter((o) => o.category !== "thought_level")
|
||||
.map((o) => {
|
||||
const explicit = draft.config[o.id];
|
||||
const choice = explicit ?? defaults[o.id];
|
||||
const choice = draft.config[o.id] ?? defaults[o.id];
|
||||
return {
|
||||
...o,
|
||||
current:
|
||||
o.category === "model" && explicit !== undefined
|
||||
? explicit
|
||||
: choice !== undefined && o.values.some((v) => v.value === choice)
|
||||
? choice
|
||||
: o.current,
|
||||
current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current,
|
||||
};
|
||||
});
|
||||
const model = config.find((o) => o.category === "model")?.current;
|
||||
if (model) {
|
||||
config.push(
|
||||
...((catalog ?? provider)?.thoughtLevels?.[model] ?? []).map((o) => {
|
||||
...(provider?.thoughtLevels?.[model] ?? []).map((o) => {
|
||||
const choice = draft.thoughtLevels?.[model]?.[o.id] ?? defaults[o.id];
|
||||
return {
|
||||
...o,
|
||||
@@ -333,84 +314,6 @@ export function markVisible(task: string): () => void {
|
||||
}
|
||||
|
||||
let generation = 0;
|
||||
let draftGeneration = 0;
|
||||
const modelFlights = new Map<string, { launch: string; generation: number; draftGeneration: number }>();
|
||||
|
||||
const selectedProvider = () => {
|
||||
const { providers, draft } = useAgents.getState();
|
||||
return providers.find((p) => p.id === draft.provider) ?? providers[0];
|
||||
};
|
||||
|
||||
const launchKey = (provider: AgentProvider) =>
|
||||
JSON.stringify([provider.program, provider.args, provider.env, provider.installed]);
|
||||
|
||||
function applyModelCatalog(catalog: AgentModelCatalog) {
|
||||
useAgents.setState((s) => {
|
||||
const previous = s.modelCatalogs.get(catalog.provider);
|
||||
const model = (previous?.config ?? s.providers.find((p) => p.id === catalog.provider)?.config)?.find(
|
||||
(o) => o.category === "model",
|
||||
);
|
||||
const config =
|
||||
model && !catalog.config.some((o) => o.category === "model")
|
||||
? [...catalog.config, { ...model, current: null, values: [] }]
|
||||
: catalog.config;
|
||||
return { modelCatalogs: new Map(s.modelCatalogs).set(catalog.provider, { ...catalog, config }) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Refreshes only the New Task agent; discovery failures stay beside the cached choices. */
|
||||
export async function refreshModels(provider = selectedProvider()?.id, force = true): Promise<void> {
|
||||
const selected = selectedProvider();
|
||||
if (!provider || !selected?.installed || selected.id !== provider || useProject.getState().project?.trusted === false)
|
||||
return;
|
||||
const state = useAgents.getState();
|
||||
const catalog = state.modelCatalogs.get(provider);
|
||||
const launch = launchKey(selected);
|
||||
const pending = modelFlights.get(provider);
|
||||
if (
|
||||
(pending?.launch === launch && pending.generation === generation && pending.draftGeneration === draftGeneration) ||
|
||||
catalog?.refreshing ||
|
||||
(force && catalog?.retryAtMs != null && catalog.retryAtMs > Date.now())
|
||||
)
|
||||
return;
|
||||
const gen = generation;
|
||||
const draftGen = draftGeneration;
|
||||
const root = useProject.getState().project?.root;
|
||||
const flight = { launch, generation: gen, draftGeneration: draftGen };
|
||||
modelFlights.set(provider, flight);
|
||||
useAgents.setState((s) => ({ modelRequests: new Set(s.modelRequests).add(provider) }));
|
||||
const current = () =>
|
||||
gen === generation &&
|
||||
root === useProject.getState().project?.root &&
|
||||
draftGen === draftGeneration &&
|
||||
selectedProvider()?.id === provider &&
|
||||
launchKey(selectedProvider()!) === launch;
|
||||
try {
|
||||
const result = await call(commands.agentRefreshModels(provider, force));
|
||||
// A streamed status/completion is newer than the snapshot returned by the command.
|
||||
if (current() && useAgents.getState().modelCatalogs.get(provider) === catalog) applyModelCatalog(result);
|
||||
} catch (e) {
|
||||
if (current() && useAgents.getState().modelCatalogs.get(provider) === catalog)
|
||||
applyModelCatalog({
|
||||
provider,
|
||||
config: catalog?.config ?? selected.config,
|
||||
thoughtLevels: catalog?.thoughtLevels ?? selected.thoughtLevels ?? {},
|
||||
refreshedAtMs: catalog?.refreshedAtMs ?? null,
|
||||
retryAtMs: catalog?.retryAtMs ?? null,
|
||||
refreshing: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
if (modelFlights.get(provider) === flight) {
|
||||
modelFlights.delete(provider);
|
||||
useAgents.setState((s) => {
|
||||
const modelRequests = new Set(s.modelRequests);
|
||||
modelRequests.delete(provider);
|
||||
return { modelRequests };
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribes this window to the project's agents (again after the project changed). */
|
||||
export async function connect(): Promise<void> {
|
||||
@@ -500,7 +403,6 @@ export function disconnect() {
|
||||
generation++;
|
||||
arrivedWhileLoading.clear();
|
||||
streamingReply.clear();
|
||||
modelFlights.clear();
|
||||
useAgents.setState({
|
||||
loaded: false,
|
||||
error: null,
|
||||
@@ -510,8 +412,6 @@ export function disconnect() {
|
||||
entryLoads: new Map(),
|
||||
changes: new Map(),
|
||||
groupedChanges: new Map(),
|
||||
modelCatalogs: new Map(),
|
||||
modelRequests: new Set(),
|
||||
organize: EMPTY_ORGANIZE,
|
||||
organizeRoot: null,
|
||||
docked: null,
|
||||
@@ -570,10 +470,6 @@ function onEvent(e: AgentEvent) {
|
||||
scheduleChanges(e.task);
|
||||
break;
|
||||
}
|
||||
case "modelCatalog": {
|
||||
if (s.providers.some((p) => p.id === e.catalog.provider)) applyModelCatalog(e.catalog);
|
||||
break;
|
||||
}
|
||||
case "providers": {
|
||||
void refreshProviders();
|
||||
break;
|
||||
@@ -741,19 +637,8 @@ export function scheduleChanges(task: string) {
|
||||
|
||||
/** Reloads the agent list (custom and registry agents are re-read). */
|
||||
export async function refreshProviders() {
|
||||
const gen = generation;
|
||||
const root = useProject.getState().project?.root;
|
||||
const providers = await call(commands.agentProviders()).catch(() => null);
|
||||
if (!providers || gen !== generation || root !== useProject.getState().project?.root) return;
|
||||
useAgents.setState((s) => {
|
||||
const modelCatalogs = new Map(s.modelCatalogs);
|
||||
for (const [id] of modelCatalogs) {
|
||||
const previous = s.providers.find((p) => p.id === id);
|
||||
const provider = providers.find((p) => p.id === id);
|
||||
if (!provider || (previous && launchKey(previous) !== launchKey(provider))) modelCatalogs.delete(id);
|
||||
}
|
||||
return { providers, modelCatalogs };
|
||||
});
|
||||
if (providers) useAgents.setState({ providers });
|
||||
}
|
||||
|
||||
export const NO_COMMANDS: readonly AgentCommand[] = [];
|
||||
@@ -916,15 +801,6 @@ export async function startTask(
|
||||
providers.find((p) => p.id === draft.provider),
|
||||
draft,
|
||||
);
|
||||
if (
|
||||
options.some(
|
||||
(o) =>
|
||||
o.category === "model" &&
|
||||
draft.config[o.id] !== undefined &&
|
||||
!o.values.some((v) => v.value === draft.config[o.id]),
|
||||
)
|
||||
)
|
||||
return;
|
||||
const askForApprovals = useProject.getState().settings?.agents?.newTaskApprovals === "ask";
|
||||
const defaults = {
|
||||
...cursorModelDefaults(draft.provider, options),
|
||||
|
||||
@@ -440,11 +440,7 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
|
||||
|
||||
const treeView = (
|
||||
<div className={styles.body}>
|
||||
<SplitGroup
|
||||
orientation="horizontal"
|
||||
id="maven-dependency-analyzer-split"
|
||||
storageKey="openidea.dependencyAnalyzer.split"
|
||||
>
|
||||
<SplitGroup orientation="horizontal" id="maven-dependency-analyzer-split">
|
||||
<SplitPanel id="dependencies" minSize={200} className={styles.panel}>
|
||||
<Tree<Node>
|
||||
aria-label="Dependencies"
|
||||
|
||||
@@ -339,12 +339,7 @@ export function BuildView() {
|
||||
}
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<SplitGroup
|
||||
orientation="horizontal"
|
||||
className={styles.split}
|
||||
id="build-view-split"
|
||||
storageKey="openidea.build.split"
|
||||
>
|
||||
<SplitGroup orientation="horizontal" className={styles.split} id="build-view-split">
|
||||
<SplitPanel id="build-tree" defaultSize="38%" minSize="120px" className={styles.pane}>
|
||||
<BuildTree />
|
||||
</SplitPanel>
|
||||
|
||||
@@ -410,7 +410,7 @@ export function ProblemsView() {
|
||||
<div className={styles.view}>
|
||||
<div className={styles.body} onKeyDownCapture={onKeyDownCapture}>
|
||||
{options.preview ? (
|
||||
<SplitGroup orientation="horizontal" id="problems-preview-split" storageKey="openidea.problems.previewSplit">
|
||||
<SplitGroup orientation="horizontal" id="problems-preview-split">
|
||||
<SplitPanel id="problems" minSize={120} className={styles.panel}>
|
||||
{list}
|
||||
</SplitPanel>
|
||||
|
||||
@@ -401,7 +401,7 @@ function UsagesSplit({ preview, children }: { preview: ReactNode; children: Reac
|
||||
const show = useUsagesOptions((s) => s.preview);
|
||||
if (!show) return children;
|
||||
return (
|
||||
<SplitGroup orientation="horizontal" id="usages-preview-split" storageKey="openidea.usages.previewSplit">
|
||||
<SplitGroup orientation="horizontal" id="usages-preview-split">
|
||||
<SplitPanel id="usages" minSize={160} className={styles.panel}>
|
||||
{children}
|
||||
</SplitPanel>
|
||||
|
||||
@@ -71,7 +71,6 @@ import { setLocale } from "@/platform/i18n";
|
||||
import { ToolWindowShownProvider } from "@/workbench/contributions";
|
||||
import { handleDebugEvent, setVariableValue, useDebug, type DebugSession } from "../state";
|
||||
import { DebugTabs, DebugToolWindow } from "../ui/DebugToolWindow";
|
||||
import { hideContextMenu, useContextMenuStore } from "@/ui/Menu";
|
||||
import { DebuggerOverlays } from "../ui/Overlays";
|
||||
import { useOverlays } from "../ui/popoverStore";
|
||||
|
||||
@@ -241,32 +240,6 @@ describe("debug tool window", () => {
|
||||
expect(console.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("keeps the chosen tab on a breakpoint hit when Focus On Breakpoint is off", () => {
|
||||
const settings = useProject.getState().settings;
|
||||
useDebug.setState({ sessions: { 1: { ...session, status: "running" } }, active: 1 });
|
||||
render(<DebugToolWindow />);
|
||||
const variables = screen.getByRole("tab", { name: "Threads & Variables" });
|
||||
fireEvent.contextMenu(variables);
|
||||
const item = useContextMenuStore.getState().menu?.items[0];
|
||||
expect(item).toMatchObject({ label: "Focus On Breakpoint", checked: true });
|
||||
act(() => hideContextMenu());
|
||||
try {
|
||||
act(() =>
|
||||
useProject.setState({
|
||||
settings: { debugger: { variablesOnBreakpoint: false } } as ReturnType<
|
||||
typeof useProject.getState
|
||||
>["settings"],
|
||||
}),
|
||||
);
|
||||
const console = screen.getByRole("tab", { name: "Console" });
|
||||
fireEvent.click(console);
|
||||
act(() => useDebug.setState({ sessions: { 1: session } }));
|
||||
expect(console.getAttribute("aria-selected")).toBe("true");
|
||||
} finally {
|
||||
act(() => useProject.setState({ settings }));
|
||||
}
|
||||
});
|
||||
|
||||
it("opens on Console for a running session, and on the view last picked when shown again", () => {
|
||||
useDebug.setState({ sessions: { 1: { ...session, status: "running" } }, active: 1 });
|
||||
const first = render(<DebugToolWindow />);
|
||||
|
||||
@@ -187,7 +187,8 @@ function jsDebugLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
||||
const out = base(rc, "js-debug", cwd);
|
||||
const extra: Record<string, unknown> = {
|
||||
type: "pwa-node",
|
||||
console: "integratedTerminal",
|
||||
console: "internalConsole",
|
||||
outputCapture: "std",
|
||||
skipFiles: workflowSettings().debugger.skipLibraryCode ? ["<node_internals>/**", "**/node_modules/**"] : [],
|
||||
autoAttachChildProcesses: true,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"
|
||||
import clsx from "clsx";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
import { call, commands, isTauri, type Scope, type Variable } from "@/platform/ipc";
|
||||
import { call, commands, type Scope, type Variable } from "@/platform/ipc";
|
||||
import {
|
||||
EmptyState,
|
||||
Icon,
|
||||
@@ -109,14 +109,6 @@ type Tab = "variables" | "console" | "memory" | "network";
|
||||
/** The view the user last picked; the tool window opens on it again (Console until one is picked). */
|
||||
let chosenTab: Tab = "console";
|
||||
|
||||
/** Threads & Variables › Focus On Breakpoint: whether a breakpoint hit selects that tab. */
|
||||
async function setVariablesOnBreakpoint(on: boolean) {
|
||||
if (!isTauri()) return;
|
||||
useProject.setState({
|
||||
settings: await call(commands.settingsSet("global", "debugger.variablesOnBreakpoint", JSON.stringify(on))),
|
||||
});
|
||||
}
|
||||
|
||||
function statusText(s: DebugSession): string {
|
||||
if (s.status === "terminated") return s.exitCode != null ? `Finished · exit code ${s.exitCode}` : "Finished";
|
||||
if (s.status === "starting") return "Starting…";
|
||||
@@ -168,16 +160,12 @@ export function DebugToolWindow() {
|
||||
setView({
|
||||
session: active.startedAt,
|
||||
status: active.status,
|
||||
// A breakpoint or exception brings Threads & Variables to the front unless its Focus On Breakpoint
|
||||
// is off; finishing a step keeps the tab the user is on (e.g. Console, to watch the output while
|
||||
// stepping), and so does Memory.
|
||||
// A breakpoint or exception brings Threads & Variables to the front; finishing a step keeps
|
||||
// the tab the user is on (e.g. Console, to watch the output while stepping), and so does Memory.
|
||||
tab:
|
||||
active.status === "starting"
|
||||
? "console"
|
||||
: active.status === "paused" &&
|
||||
view.tab !== "memory" &&
|
||||
active.stopReason !== "step" &&
|
||||
workflowSettings().debugger.variablesOnBreakpoint
|
||||
: active.status === "paused" && view.tab !== "memory" && active.stopReason !== "step"
|
||||
? "variables"
|
||||
: view.tab,
|
||||
});
|
||||
@@ -259,20 +247,6 @@ export function DebugToolWindow() {
|
||||
chosenTab = id;
|
||||
setView({ session: active.startedAt, status: active.status, tab: id });
|
||||
}}
|
||||
onContextMenu={
|
||||
id === "variables"
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e, [
|
||||
{
|
||||
label: "Focus On Breakpoint",
|
||||
checked: workflowSettings().debugger.variablesOnBreakpoint,
|
||||
run: () => void setVariablesOnBreakpoint(!workflowSettings().debugger.variablesOnBreakpoint),
|
||||
},
|
||||
]);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{tx(label)}
|
||||
</button>
|
||||
|
||||
@@ -261,7 +261,7 @@ export function HttpClientView() {
|
||||
void refreshHistory();
|
||||
}, []);
|
||||
return (
|
||||
<SplitGroup orientation="horizontal" className={styles.root} storageKey="openidea.httpClient.split">
|
||||
<SplitGroup orientation="horizontal" className={styles.root}>
|
||||
<SplitPanel id="http-history" defaultSize="32%" minSize="160px">
|
||||
<div className={styles.list} role="list" aria-label={tx("Response history")}>
|
||||
{running && (
|
||||
|
||||
@@ -56,17 +56,10 @@ it("puts a button on the marker's line that goes to its target", async () => {
|
||||
expect(calls.navigate).toHaveBeenCalledWith(target);
|
||||
});
|
||||
|
||||
it("keeps markers on their lines while typing and hides them when the entry goes away", async () => {
|
||||
it("hides markers on edit and when the entry goes away", async () => {
|
||||
setup();
|
||||
await vi.waitFor(() => expect(button()).not.toBeNull());
|
||||
// Typing must not empty the column: it would collapse and shift the text (#3370).
|
||||
view!.dispatch({ changes: { from: source.indexOf("SELECT"), insert: "x" } });
|
||||
expect(button()).not.toBeNull();
|
||||
view!.dispatch({ changes: { from: 0, insert: "\n" } });
|
||||
expect(button()).not.toBeNull();
|
||||
// Deleting the marker's line drops it.
|
||||
const line = view!.state.doc.line(3);
|
||||
view!.dispatch({ changes: { from: line.from - 1, to: line.to } });
|
||||
expect(button()).toBeNull();
|
||||
usePluginMarkers.setState({ entries: new Map() });
|
||||
await vi.waitFor(() => expect(button()).toBeNull());
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* Gutter markers from plugins' document indexers, resolved by the host from
|
||||
* its cached graph. A click goes to the marker's targets (a chooser when there
|
||||
* are several); an edit keeps them on their lines until the edited text is indexed,
|
||||
* so the column keeps its width while typing.
|
||||
* are several); an edit hides the markers until the edited text is indexed.
|
||||
*/
|
||||
import { RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state";
|
||||
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
|
||||
@@ -53,8 +52,7 @@ const setMarkers = StateEffect.define<RangeSet<GutterMarker>>();
|
||||
const markersField = StateField.define<RangeSet<GutterMarker>>({
|
||||
create: () => RangeSet.empty,
|
||||
update(value, tr) {
|
||||
// Markers move with the text; a deleted line drops its own.
|
||||
if (tr.docChanged) value = value.map(tr.changes);
|
||||
if (tr.docChanged) value = RangeSet.empty;
|
||||
for (const effect of tr.effects) if (effect.is(setMarkers)) value = effect.value;
|
||||
return value;
|
||||
},
|
||||
|
||||
@@ -422,12 +422,7 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SplitGroup
|
||||
orientation="horizontal"
|
||||
className={styles.split}
|
||||
id="find-results-split"
|
||||
storageKey={showPreview ? "openidea.find.previewSplit" : undefined}
|
||||
>
|
||||
<SplitGroup orientation="horizontal" className={styles.split} id="find-results-split">
|
||||
<SplitPanel id="results" minSize={120} className={styles.splitPanel}>
|
||||
<Tree<Node>
|
||||
className={styles.tree}
|
||||
|
||||
@@ -404,15 +404,4 @@ describe("Git colors", () => {
|
||||
act(() => fireEvent.click(within(preview).getByText("feature/login")));
|
||||
expect(screen.getByRole("treeitem", { name: "Local branch decoration", selected: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets a project view branch decoration be set bold and italic (#3385)", () => {
|
||||
act(() => stage("editor.colorScheme", "global", "editor.colorScheme", "dark"));
|
||||
schemePage("editor.colorScheme.git");
|
||||
const preview = screen.getByLabelText("Color scheme preview");
|
||||
act(() => fireEvent.click(within(preview).getByText("feature/login")));
|
||||
const bold = screen.getByRole("checkbox", { name: "Bold" });
|
||||
expect(bold).toBeEnabled();
|
||||
act(() => fireEvent.click(bold));
|
||||
expect(value("colorOverrides")).toEqual({ dark: { gitLocalBranch: { bold: true } } });
|
||||
});
|
||||
});
|
||||
@@ -478,11 +478,7 @@ export function TodoView() {
|
||||
{tab === "scope" && <ScopeBar />}
|
||||
{tab === "changes" && <ChangelistBar />}
|
||||
<div className={styles.body}>
|
||||
<SplitGroup
|
||||
orientation="horizontal"
|
||||
id="todo-preview-split"
|
||||
storageKey={showPreview ? "openidea.todo.previewSplit" : undefined}
|
||||
>
|
||||
<SplitGroup orientation="horizontal" id="todo-preview-split">
|
||||
<SplitPanel id="items" minSize={160} className={styles.panel}>
|
||||
<Tree<Node>
|
||||
handleRef={tree}
|
||||
|
||||
@@ -69,18 +69,14 @@ function CommitDialog({ onClose }: { onClose: () => void }) {
|
||||
]}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<SplitGroup orientation="vertical" className={styles.split} storageKey="openidea.vcs.commitDialogSplit">
|
||||
<SplitPanel id="top" defaultSize="58%" minSize="25%">
|
||||
<SplitGroup
|
||||
orientation="horizontal"
|
||||
className={styles.split}
|
||||
storageKey="openidea.vcs.commitDialogOptionsSplit"
|
||||
>
|
||||
<SplitPanel id="changes" defaultSize="64%" minSize="35%">
|
||||
<SplitGroup orientation="vertical" className={styles.split}>
|
||||
<SplitPanel defaultSize="58%" minSize="25%">
|
||||
<SplitGroup orientation="horizontal" className={styles.split}>
|
||||
<SplitPanel defaultSize="64%" minSize="35%">
|
||||
<ChangesTree mode="dialog" />
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="options" defaultSize="36%" minSize="200px">
|
||||
<SplitPanel defaultSize="36%" minSize="200px">
|
||||
<div className={styles.options} role="group" aria-label={tx("Commit Options")}>
|
||||
<CommitOptionsBody />
|
||||
</div>
|
||||
@@ -88,7 +84,7 @@ function CommitDialog({ onClose }: { onClose: () => void }) {
|
||||
</SplitGroup>
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="message" defaultSize="42%" minSize="120px">
|
||||
<SplitPanel defaultSize="42%" minSize="120px">
|
||||
<div className={viewStyles.commit}>
|
||||
<CommitMessageField included={included} runner={runner} autoFocus />
|
||||
</div>
|
||||
|
||||
@@ -184,12 +184,12 @@ export function StashContent({ tab }: { tab: CommitTab }) {
|
||||
const combine = useWorkflowSettings((s) => s.vcs.git.combineStashAndShelf);
|
||||
if (combine)
|
||||
return (
|
||||
<SplitGroup orientation="vertical" className={styles.split} storageKey="openidea.vcs.stashShelfSplit">
|
||||
<SplitPanel id="stashes" defaultSize="50%" minSize="80px">
|
||||
<SplitGroup orientation="vertical" className={styles.split}>
|
||||
<SplitPanel defaultSize="50%" minSize="80px">
|
||||
<StashesPanel />
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="shelf" defaultSize="50%" minSize="80px">
|
||||
<SplitPanel defaultSize="50%" minSize="80px">
|
||||
<ShelfPanel />
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
@@ -486,18 +486,14 @@ function ChangesPanel() {
|
||||
const path = useCommit((s) => (s.selection.length === 1 ? s.selection[0] : null));
|
||||
const hasChanges = useVcs((s) => s.status.length > 0);
|
||||
return (
|
||||
<SplitGroup
|
||||
orientation="horizontal"
|
||||
className={styles.split}
|
||||
storageKey={preview ? "openidea.vcs.commitPreviewSplit" : undefined}
|
||||
>
|
||||
<SplitPanel id="commit" defaultSize="62%" minSize="20%">
|
||||
<SplitGroup orientation="vertical" className={styles.split} storageKey="openidea.vcs.commitMessageSplit">
|
||||
<SplitPanel id="changes" defaultSize="62%" minSize="20%">
|
||||
<SplitGroup orientation="horizontal" className={styles.split}>
|
||||
<SplitPanel defaultSize="62%" minSize="20%">
|
||||
<SplitGroup orientation="vertical" className={styles.split}>
|
||||
<SplitPanel defaultSize="62%" minSize="20%">
|
||||
<ChangesTree mode="commit" />
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="message" defaultSize="38%" minSize="120px">
|
||||
<SplitPanel defaultSize="38%" minSize="120px">
|
||||
<CommitPanel included={included} />
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
@@ -505,7 +501,7 @@ function ChangesPanel() {
|
||||
{preview && (
|
||||
<>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="preview" defaultSize="38%" minSize="120px">
|
||||
<SplitPanel defaultSize="38%" minSize="120px">
|
||||
{path ? (
|
||||
<DiffEditor input={diffInput(workingDiffData(path))} />
|
||||
) : (
|
||||
@@ -529,20 +525,14 @@ export function LocalChangesView() {
|
||||
const path = useCommit((s) => (s.selection.length === 1 ? s.selection[0] : null));
|
||||
const anchor = useToolWindowLayout((s) => s.placements.git?.anchor ?? "bottom");
|
||||
return (
|
||||
<SplitGroup
|
||||
orientation={anchor === "bottom" ? "horizontal" : "vertical"}
|
||||
className={styles.split}
|
||||
storageKey={
|
||||
preview ? `openidea.vcs.localChangesSplit.${anchor === "bottom" ? "horizontal" : "vertical"}` : undefined
|
||||
}
|
||||
>
|
||||
<SplitPanel id="changes" defaultSize="50%" minSize="20%">
|
||||
<SplitGroup orientation={anchor === "bottom" ? "horizontal" : "vertical"} className={styles.split}>
|
||||
<SplitPanel defaultSize="50%" minSize="20%">
|
||||
<ChangesTree mode="local" />
|
||||
</SplitPanel>
|
||||
{preview && (
|
||||
<>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="preview" defaultSize="50%" minSize="120px">
|
||||
<SplitPanel defaultSize="50%" minSize="120px">
|
||||
{path ? (
|
||||
<DiffEditor input={diffInput(workingDiffData(path))} />
|
||||
) : (
|
||||
|
||||
@@ -327,7 +327,6 @@ export function LogView() {
|
||||
key={showBranches ? "branches" : "no-branches"}
|
||||
orientation="horizontal"
|
||||
id={showBranches ? "git-log" : "git-log-no-branches"}
|
||||
storageKey={`openidea.vcs.logSplit.${showBranches ? "branches" : "noBranches"}.${showDetails ? "details" : "noDetails"}`}
|
||||
className={styles.split}
|
||||
>
|
||||
{showBranches && (
|
||||
@@ -2682,7 +2681,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
};
|
||||
|
||||
const details = (
|
||||
<SplitGroup orientation="vertical" id="git-log-details" storageKey="openidea.vcs.logDetailsSplit">
|
||||
<SplitGroup orientation="vertical" id="git-log-details">
|
||||
<SplitPanel id="files" defaultSize="60%" minSize="60px">
|
||||
<div
|
||||
className={styles.filesPane}
|
||||
@@ -2803,7 +2802,6 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
<SplitGroup
|
||||
orientation={diffLocation === "right" ? "horizontal" : "vertical"}
|
||||
id={`git-log-preview-${diffLocation}`}
|
||||
storageKey={`openidea.vcs.logPreviewSplit.${diffLocation}`}
|
||||
>
|
||||
<SplitPanel id="changes" defaultSize="55%" minSize="20%">
|
||||
{details}
|
||||
|
||||
@@ -146,7 +146,7 @@ function RevisionBrowser({
|
||||
) : !files ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<SplitGroup orientation="horizontal" id="git-revision-browser" storageKey="openidea.vcs.revisionBrowserSplit">
|
||||
<SplitGroup orientation="horizontal" id="git-revision-browser">
|
||||
<SplitPanel id="files" defaultSize="30%" minSize="20%">
|
||||
<Tree
|
||||
roots={tree}
|
||||
|
||||
@@ -75,7 +75,6 @@ export const CLIENT_SETTINGS_KEYS = [
|
||||
"debugger.showOnBreakpoint",
|
||||
"debugger.focusAppOnBreakpoint",
|
||||
"debugger.hideOnTermination",
|
||||
"debugger.variablesOnBreakpoint",
|
||||
"debugger.runToCursorInlay",
|
||||
"debugger.inlineValues",
|
||||
"debugger.sortValues",
|
||||
|
||||
@@ -245,47 +245,26 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
|
||||
bg: "--git-blame-subject-bg",
|
||||
fontStyle: true,
|
||||
},
|
||||
{
|
||||
id: "gitBranchChanged",
|
||||
name: "Project View › Changed decoration",
|
||||
path: ["Git"],
|
||||
fg: "--git-branch-changed",
|
||||
fontStyle: true,
|
||||
},
|
||||
{
|
||||
id: "gitHeadTags",
|
||||
name: "Project View › HEAD tags decoration",
|
||||
path: ["Git"],
|
||||
fg: "--git-head-tags",
|
||||
fontStyle: true,
|
||||
},
|
||||
{
|
||||
id: "gitLocalBranch",
|
||||
name: "Project View › Local branch decoration",
|
||||
path: ["Git"],
|
||||
fg: "--git-local-branch",
|
||||
fontStyle: true,
|
||||
},
|
||||
{ id: "gitBranchChanged", name: "Project View › Changed decoration", path: ["Git"], fg: "--git-branch-changed" },
|
||||
{ id: "gitHeadTags", name: "Project View › HEAD tags decoration", path: ["Git"], fg: "--git-head-tags" },
|
||||
{ id: "gitLocalBranch", name: "Project View › Local branch decoration", path: ["Git"], fg: "--git-local-branch" },
|
||||
{
|
||||
id: "gitMasterWithRemote",
|
||||
name: "Project View › master/main with remote",
|
||||
path: ["Git"],
|
||||
fg: "--git-master-with-remote",
|
||||
fontStyle: true,
|
||||
},
|
||||
{
|
||||
id: "gitMasterWithoutRemote",
|
||||
name: "Project View › master/main without remote",
|
||||
path: ["Git"],
|
||||
fg: "--git-master-without-remote",
|
||||
fontStyle: true,
|
||||
},
|
||||
{
|
||||
id: "gitTrackingBranch",
|
||||
name: "Project View › Tracking branch decoration",
|
||||
path: ["Git"],
|
||||
fg: "--git-tracking-branch",
|
||||
fontStyle: true,
|
||||
},
|
||||
|
||||
syn("keyword", "Keyword", [LD], "keyword"),
|
||||
|
||||
@@ -7,7 +7,6 @@ import { intellijMacos } from "./intellij-macos";
|
||||
|
||||
export const eclipseMacos: Record<string, string[]> = {
|
||||
...intellijMacos,
|
||||
"agents.focusInput": ["Meta+L"],
|
||||
// $Undo / $Redo stay unbound as in the parent keymap: the focused editor or view handles them.
|
||||
"code.autoIndentLines": ["Meta+I"],
|
||||
"navigate.back": ["Meta+[", "Button4", "SwipeRight"],
|
||||
|
||||
@@ -7,7 +7,6 @@ import { intellijWindows } from "./intellij-windows";
|
||||
|
||||
export const eclipseWindows: Record<string, string[]> = {
|
||||
...intellijWindows,
|
||||
"agents.focusInput": ["Ctrl+L"],
|
||||
// $Undo / $Redo stay unbound as in the parent keymap: the focused editor or view handles them.
|
||||
"code.autoIndentLines": ["Ctrl+I"],
|
||||
"navigate.back": ["Alt+Left", "Button4"],
|
||||
|
||||
@@ -32,7 +32,6 @@ const owners = (km: Record<string, string[]>, key: string) =>
|
||||
// parent collisions are not introduced by this preset; new pairs must be reviewed here.
|
||||
const shared = [
|
||||
["navigate.gotoClass", "terminal.new"],
|
||||
["navigate.gotoLine", "agents.focusInput"],
|
||||
["navigate.recentLocations", "project.open", "debug.jumpToSource", "diff.jumpToSource", "find.popup.openResult"],
|
||||
[
|
||||
"navigate.declaration",
|
||||
|
||||
@@ -371,7 +371,6 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"db.revertChanges": ["Meta+Alt+Z"],
|
||||
"db.showToolWindow": [],
|
||||
"agents.newTask": ["Meta+Shift+I"],
|
||||
"agents.focusInput": ["Meta+L"],
|
||||
"agents.showToolWindow": [],
|
||||
"agents.sendToAgent": [],
|
||||
// Database tool window (when the tree has focus; `db.openConsole` anywhere).
|
||||
|
||||
@@ -369,7 +369,6 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"db.revertChanges": ["Ctrl+Alt+Z"],
|
||||
"db.showToolWindow": [],
|
||||
"agents.newTask": ["Ctrl+Alt+Shift+I"],
|
||||
"agents.focusInput": ["Ctrl+L"],
|
||||
"agents.showToolWindow": [],
|
||||
"agents.sendToAgent": [],
|
||||
// Database tool window (when the tree has focus; `db.openConsole` anywhere).
|
||||
|
||||
@@ -402,7 +402,6 @@ export const vscodeMacos: Record<string, string[]> = {
|
||||
"find.popup.recentSearches": ["Alt+Down"],
|
||||
// Agents (VS Code's chat: ⇧⌘I)
|
||||
"agents.newTask": ["Meta+Shift+I"],
|
||||
"agents.focusInput": ["Meta+L"],
|
||||
"agents.showToolWindow": [],
|
||||
"agents.sendToAgent": [],
|
||||
// Search fields (find bar, Find in Files): last, so they win over editor keys while one is focused.
|
||||
|
||||
@@ -63,7 +63,6 @@ export const vscodeWindows: Record<string, string[]> = {
|
||||
"markdown.previewOnly": ["Ctrl+Shift+V"],
|
||||
// Agents (VS Code's chat: Ctrl+Shift+I)
|
||||
"agents.newTask": ["Ctrl+Shift+I"],
|
||||
"agents.focusInput": ["Ctrl+L"],
|
||||
"agents.showToolWindow": [],
|
||||
"agents.sendToAgent": [],
|
||||
};
|
||||
@@ -17,15 +17,6 @@
|
||||
"Pin Agent Tasks": "Pin Agent Tasks",
|
||||
"Unpin Agent Tasks": "Unpin Agent Tasks",
|
||||
"New Agent Task Group": "New Agent Task Group",
|
||||
"Refresh models": "Refresh models",
|
||||
"model": "model",
|
||||
"Refreshing…": "Refreshing…",
|
||||
"Refresh in {seconds}s": "Refresh in {seconds}s",
|
||||
"Loading models…": "Loading models…",
|
||||
"No models reported by this agent.": "No models reported by this agent.",
|
||||
"Could not refresh models: {detail}": "Could not refresh models: {detail}",
|
||||
"{model} (unavailable)": "{model} (unavailable)",
|
||||
"Choose another model before starting.": "Choose another model before starting.",
|
||||
"Rename Agent Task Group": "Rename Agent Task Group",
|
||||
"Remove Agent Task Group": "Remove Agent Task Group",
|
||||
"Import Sessions from Other Apps…": "Import Sessions from Other Apps…",
|
||||
@@ -1457,8 +1448,6 @@
|
||||
"too old, needs JDK {version}+ · {path}": "too old, needs JDK {version}+ · {path}",
|
||||
"Close Agent Task": "Close Agent Task",
|
||||
"Close and New Agent Task": "Close and New Agent Task",
|
||||
"Focus Agent Input": "Focus Agent Input",
|
||||
"Focuses the message input of the current agent task without moving the caret.": "Focuses the message input of the current agent task without moving the caret.",
|
||||
"Closes the agent task tab in the editor; the task keeps running in the Agents tool window.": "Closes the agent task tab in the editor; the task keeps running in the Agents tool window.",
|
||||
"Closes the agent task tab in the editor, then opens the New Task page.": "Closes the agent task tab in the editor, then opens the New Task page.",
|
||||
"May be incomplete: plugin indexes are still updating": "May be incomplete: plugin indexes are still updating",
|
||||
|
||||
@@ -3501,6 +3501,7 @@
|
||||
"edits your files directly": "edita tus archivos directamente",
|
||||
"from": "desde",
|
||||
"on": "en",
|
||||
"Models and modes appear after this agent's first task.": "Los modelos y modos aparecen después de la primera tarea de este agente.",
|
||||
"Queue a message for when the agent is done (⌘↵ sends it now)": "Pon en cola un mensaje para cuando el agente termine (⌘↵ lo envía ahora)",
|
||||
"Couldn't send the message": "No se pudo enviar el mensaje",
|
||||
"Couldn't change the setting": "No se pudo cambiar la configuración",
|
||||
|
||||
@@ -3374,6 +3374,7 @@
|
||||
"edits your files directly": "modifie directement vos fichiers",
|
||||
"from": "depuis",
|
||||
"on": "sur",
|
||||
"Models and modes appear after this agent's first task.": "Les modèles et les modes apparaissent après la première tâche de cet agent.",
|
||||
"Queue a message for when the agent is done (⌘↵ sends it now)": "Mettre un message en file d'attente pour la fin de l'agent (⌘↵ l'envoie maintenant)",
|
||||
"Couldn't send the message": "Impossible d'envoyer le message",
|
||||
"Couldn't change the setting": "Impossible de modifier le paramètre",
|
||||
|
||||
@@ -3551,6 +3551,7 @@
|
||||
"edits your files directly": "파일을 직접 편집함",
|
||||
"from": "기준",
|
||||
"on": "위치",
|
||||
"Models and modes appear after this agent's first task.": "이 에이전트의 첫 작업이 끝나면 모델과 모드가 표시됩니다.",
|
||||
"Queue a message for when the agent is done (⌘↵ sends it now)": "에이전트 작업이 끝나면 보낼 메시지를 대기열에 추가(⌘↵로 즉시 전송)",
|
||||
"Couldn't send the message": "메시지를 보낼 수 없습니다",
|
||||
"Couldn't change the setting": "설정을 변경할 수 없습니다",
|
||||
|
||||
@@ -3472,6 +3472,7 @@
|
||||
"edits your files directly": "edytuje Twoje pliki bezpośrednio",
|
||||
"from": "z",
|
||||
"on": "w",
|
||||
"Models and modes appear after this agent's first task.": "Modele i tryby pojawią się po wykonaniu pierwszego zadania przez tego agenta.",
|
||||
"Queue a message for when the agent is done (⌘↵ sends it now)": "Dodaj wiadomość do kolejki na czas po zakończeniu pracy agenta (⌘↵ wysyła natychmiast)",
|
||||
"Couldn't send the message": "Nie udało się wysłać wiadomości",
|
||||
"Couldn't change the setting": "Nie udało się zmienić ustawienia",
|
||||
|
||||
@@ -3473,6 +3473,7 @@
|
||||
"edits your files directly": "edita seus arquivos diretamente",
|
||||
"from": "de",
|
||||
"on": "em",
|
||||
"Models and modes appear after this agent's first task.": "Modelos e modos aparecem após a primeira tarefa deste agente.",
|
||||
"Queue a message for when the agent is done (⌘↵ sends it now)": "Coloque uma mensagem na fila para quando o agente terminar (⌘↵ envia agora)",
|
||||
"Couldn't send the message": "Não foi possível enviar a mensagem",
|
||||
"Couldn't change the setting": "Não foi possível alterar a configuração",
|
||||
|
||||
@@ -12,15 +12,6 @@
|
||||
"Pin Agent Tasks": "固定 Agent 任务",
|
||||
"Unpin Agent Tasks": "取消固定 Agent 任务",
|
||||
"New Agent Task Group": "新建 Agent 任务组",
|
||||
"Refresh models": "刷新模型",
|
||||
"model": "模型",
|
||||
"Refreshing…": "正在刷新…",
|
||||
"Refresh in {seconds}s": "{seconds} 秒后可刷新",
|
||||
"Loading models…": "正在加载模型…",
|
||||
"No models reported by this agent.": "此 Agent 未提供模型。",
|
||||
"Could not refresh models: {detail}": "无法刷新模型:{detail}",
|
||||
"{model} (unavailable)": "{model}(不可用)",
|
||||
"Choose another model before starting.": "开始前请选择其他模型。",
|
||||
"Rename Agent Task Group": "重命名 Agent 任务组",
|
||||
"Remove Agent Task Group": "移除 Agent 任务组",
|
||||
"Close Others": "关闭其他",
|
||||
@@ -4093,8 +4084,6 @@
|
||||
"New Agent Task": "新建 Agent 任务",
|
||||
"Close Agent Task": "关闭 Agent 任务",
|
||||
"Close and New Agent Task": "关闭并新建 Agent 任务",
|
||||
"Focus Agent Input": "聚焦 Agent 输入框",
|
||||
"Focuses the message input of the current agent task without moving the caret.": "聚焦当前 Agent 任务的消息输入框,不移动光标。",
|
||||
"Closes the agent task tab in the editor; the task keeps running in the Agents tool window.": "关闭编辑器中的 Agent 任务标签页;任务继续在 Agents 工具窗口中运行。",
|
||||
"Closes the agent task tab in the editor, then opens the New Task page.": "关闭编辑器中的 Agent 任务标签页,然后打开“新建任务”页面。",
|
||||
"No agent tasks yet": "还没有 Agent 任务",
|
||||
@@ -4151,6 +4140,7 @@
|
||||
"edits your files directly": "直接修改你的文件",
|
||||
"from": "基于",
|
||||
"on": "位于",
|
||||
"Models and modes appear after this agent's first task.": "这个 agent 跑完第一个任务后会显示模型和模式。",
|
||||
"Queue a message for when the agent is done (⌘↵ sends it now)": "排队一条消息,agent 做完后发送(⌘↵ 立即发送)",
|
||||
"Couldn't send the message": "消息发送失败",
|
||||
"Couldn't change the setting": "设置修改失败",
|
||||
@@ -7529,7 +7519,6 @@
|
||||
"Current file": "当前文件",
|
||||
"Uncommitted files": "未提交的文件",
|
||||
"Threads & Variables": "线程和变量",
|
||||
"Focus On Breakpoint": "命中断点时聚焦",
|
||||
"Usage Limit": "用量上限",
|
||||
"Dev Containers": "开发容器",
|
||||
"Containers": "容器",
|
||||
|
||||
@@ -97,7 +97,6 @@ export interface DebuggerSettingsValid {
|
||||
showOnBreakpoint: boolean;
|
||||
focusAppOnBreakpoint: boolean;
|
||||
hideOnTermination: boolean;
|
||||
variablesOnBreakpoint: boolean;
|
||||
breakpointsAnyFile: boolean;
|
||||
runToCursorInlay: boolean;
|
||||
inlineValues: boolean;
|
||||
@@ -291,7 +290,6 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = {
|
||||
showOnBreakpoint: true,
|
||||
focusAppOnBreakpoint: false,
|
||||
hideOnTermination: false,
|
||||
variablesOnBreakpoint: true,
|
||||
breakpointsAnyFile: false,
|
||||
runToCursorInlay: true,
|
||||
inlineValues: true,
|
||||
@@ -451,7 +449,6 @@ export function validateDebugger(raw: unknown): DebuggerSettingsValid {
|
||||
showOnBreakpoint: bool(v.showOnBreakpoint, d.showOnBreakpoint),
|
||||
focusAppOnBreakpoint: bool(v.focusAppOnBreakpoint, d.focusAppOnBreakpoint),
|
||||
hideOnTermination: bool(v.hideOnTermination, d.hideOnTermination),
|
||||
variablesOnBreakpoint: bool(v.variablesOnBreakpoint, d.variablesOnBreakpoint),
|
||||
breakpointsAnyFile: bool(v.breakpointsAnyFile, d.breakpointsAnyFile),
|
||||
runToCursorInlay: bool(v.runToCursorInlay, d.runToCursorInlay),
|
||||
inlineValues: bool(v.inlineValues, d.inlineValues),
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { createRef } from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
import { SplitGroup, SplitHandle, SplitPanel } from "./Splitter";
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
function Split({ storageKey, preview }: { storageKey: string; preview: boolean }) {
|
||||
return (
|
||||
<SplitGroup orientation="horizontal" storageKey={storageKey} groupRef={ref}>
|
||||
<SplitPanel id="tree" defaultSize="60%" />
|
||||
{preview && (
|
||||
<>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="preview" defaultSize="40%" />
|
||||
</>
|
||||
)}
|
||||
</SplitGroup>
|
||||
);
|
||||
}
|
||||
const ref = createRef<GroupImperativeHandle>();
|
||||
|
||||
it("starts from the sizes saved under its key", () => {
|
||||
localStorage.setItem("split.preview", JSON.stringify({ tree: 30, preview: 70 }));
|
||||
render(<Split storageKey="split.preview" preview />);
|
||||
expect(ref.current!.getLayout()).toEqual({ tree: 30, preview: 70 });
|
||||
});
|
||||
|
||||
it("applies the saved sizes of a panel set that appears later", () => {
|
||||
localStorage.setItem("split.preview", JSON.stringify({ tree: 25, preview: 75 }));
|
||||
const view = render(<Split storageKey="split.plain" preview={false} />);
|
||||
view.rerender(<Split storageKey="split.preview" preview />);
|
||||
expect(ref.current!.getLayout()).toEqual({ tree: 25, preview: 75 });
|
||||
});
|
||||
|
||||
it("ignores saved sizes for other panels", () => {
|
||||
localStorage.setItem("split.preview", JSON.stringify({ other: 25, preview: 75 }));
|
||||
const view = render(<Split storageKey="split.plain" preview={false} />);
|
||||
view.rerender(<Split storageKey="split.preview" preview />);
|
||||
expect(Object.keys(ref.current!.getLayout()).sort()).toEqual(["preview", "tree"]);
|
||||
expect(ref.current!.getLayout().tree).not.toBe(25);
|
||||
});
|
||||
+15
-65
@@ -2,84 +2,36 @@
|
||||
* Thin wrappers over react-resizable-panels with IDE styling: 1px hairline
|
||||
* separators with a wider invisible hit area and an accent hover state.
|
||||
*/
|
||||
import {
|
||||
Group,
|
||||
Panel,
|
||||
Separator,
|
||||
type GroupImperativeHandle,
|
||||
type GroupProps,
|
||||
type PanelProps,
|
||||
type SeparatorProps,
|
||||
} from "react-resizable-panels";
|
||||
import { Group, Panel, Separator, type GroupProps, type PanelProps, type SeparatorProps } from "react-resizable-panels";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import styles from "./Splitter.module.css";
|
||||
|
||||
function savedLayout(storageKey: string | undefined): Record<string, number> | undefined {
|
||||
try {
|
||||
const value: unknown = storageKey ? JSON.parse(localStorage.getItem(storageKey) ?? "null") : null;
|
||||
return value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 100)
|
||||
? (value as Record<string, number>)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `storageKey` remembers the sizes the user drags to. A group whose panels come and go (a
|
||||
* preview pane) passes a key per panel set; when it changes, that set's sizes are applied.
|
||||
*/
|
||||
export function SplitGroup({
|
||||
className,
|
||||
storageKey,
|
||||
defaultLayout,
|
||||
onLayoutChanged,
|
||||
groupRef,
|
||||
...rest
|
||||
}: GroupProps & { storageKey?: string }) {
|
||||
const [saved] = useState(() => savedLayout(storageKey));
|
||||
const group = useRef<GroupImperativeHandle | null>(null);
|
||||
const mountedKey = useRef(storageKey);
|
||||
// Sizes to apply once the group's panels match them (a panel added with the new key registers later).
|
||||
const pending = useRef<Record<string, number> | null>(null);
|
||||
const applyPending = (current: Record<string, number>) => {
|
||||
const layout = pending.current;
|
||||
if (!layout) return;
|
||||
const ids = Object.keys(current);
|
||||
if (ids.length !== Object.keys(layout).length || !ids.every((id) => id in layout)) return;
|
||||
pending.current = null;
|
||||
group.current?.setLayout(layout);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (storageKey === mountedKey.current) return;
|
||||
mountedKey.current = storageKey;
|
||||
pending.current = savedLayout(storageKey) ?? null;
|
||||
const current = group.current?.getLayout();
|
||||
if (current) applyPending(current);
|
||||
}, [storageKey]);
|
||||
useImperativeHandle(
|
||||
groupRef,
|
||||
() => ({
|
||||
getLayout: () => group.current?.getLayout() ?? {},
|
||||
setLayout: (layout) => group.current?.setLayout(layout) ?? layout,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const onLayoutChange: GroupProps["onLayoutChange"] = (layout) => {
|
||||
applyPending(layout);
|
||||
rest.onLayoutChange?.(layout);
|
||||
};
|
||||
const [saved] = useState(() => {
|
||||
try {
|
||||
const value: unknown = storageKey ? JSON.parse(localStorage.getItem(storageKey) ?? "null") : null;
|
||||
return value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 100)
|
||||
? (value as Record<string, number>)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
if (!storageKey)
|
||||
return (
|
||||
<Group
|
||||
className={clsx(styles.group, className)}
|
||||
{...rest}
|
||||
groupRef={group}
|
||||
onLayoutChange={onLayoutChange}
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
/>
|
||||
@@ -88,8 +40,6 @@ export function SplitGroup({
|
||||
<Group
|
||||
className={clsx(styles.group, className)}
|
||||
{...rest}
|
||||
groupRef={group}
|
||||
onLayoutChange={onLayoutChange}
|
||||
defaultLayout={saved ?? defaultLayout}
|
||||
onLayoutChanged={(layout, meta) => {
|
||||
if (storageKey && meta.isUserInteraction) {
|
||||
|
||||
@@ -135,18 +135,6 @@ describe("Tabs interactions", () => {
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Regression: "Close button position: None" hides the × but must not disable
|
||||
// middle click (or the other close gestures). closable used to be bound to
|
||||
// showCloseButton, so hiding the button made the tab unclosable.
|
||||
it("a hidden close button keeps the middle-click gesture", () => {
|
||||
const onClose = vi.fn();
|
||||
const hidden = items.map((it) => ({ ...it, showCloseButton: false }));
|
||||
render(<Tabs items={hidden} activeId="a" onSelect={() => {}} onClose={onClose} />);
|
||||
expect(screen.queryByRole("button", { name: "Close Tab" })).toBeNull();
|
||||
fireEvent.mouseDown(screen.getByRole("tab", { name: "B" }), { button: 1 });
|
||||
expect(onClose).toHaveBeenCalledWith("b");
|
||||
});
|
||||
|
||||
it("keeps Shift+click selection where the strip does not opt in", () => {
|
||||
const onClose = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
+3
-12
@@ -38,12 +38,6 @@ export interface TabItem {
|
||||
* button's place, and the strip's close gestures leave the tab alone.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
/**
|
||||
* Visual only: hide this tab's close button without disabling its close
|
||||
* gestures (middle click, Shift+click, Delete). IDEA's "Close button
|
||||
* position: None" sets this to false; the tab stays closable.
|
||||
*/
|
||||
showCloseButton?: boolean;
|
||||
/** Unsaved changes marker. */
|
||||
modified?: boolean;
|
||||
/** CSS colour for the label (VCS status etc.), e.g. `var(--vcs-modified)`. */
|
||||
@@ -385,17 +379,14 @@ export function Tabs({
|
||||
{items.map((item, index) => {
|
||||
const selected = item.id === activeId;
|
||||
const pinned = !!onTogglePin && !!item.pinned;
|
||||
// Whether a tab can be closed at all (gestures and the button). A hidden
|
||||
// close button is visual only: middle click, Shift+click and Delete still work.
|
||||
const closable = !!onClose && item.closable !== false && !pinned;
|
||||
const showClose = closable && item.showCloseButton !== false;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={clsx(
|
||||
styles.tab,
|
||||
selected && styles.selected,
|
||||
(showClose || pinned) && styles.closable,
|
||||
(closable || pinned) && styles.closable,
|
||||
dragging === item.id && styles.dragging,
|
||||
marker === index && styles.dropBefore,
|
||||
marker === items.length && index === items.length - 1 && styles.dropAfter,
|
||||
@@ -480,7 +471,7 @@ export function Tabs({
|
||||
{typeof item.label === "string" ? <Given>{item.label}</Given> : item.label}
|
||||
</span>
|
||||
{item.trailing}
|
||||
{item.modified && !showClose && <span className={styles.modified} aria-label={tx("Modified")} />}
|
||||
{item.modified && !closable && <span className={styles.modified} aria-label={tx("Modified")} />}
|
||||
</button>
|
||||
{pinned && (
|
||||
<span className={styles.closeSlot} data-tab-close>
|
||||
@@ -498,7 +489,7 @@ export function Tabs({
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{showClose && (
|
||||
{closable && (
|
||||
<span className={styles.closeSlot} data-tab-close>
|
||||
{item.modified && <span className={styles.modified} aria-label={tx("Modified")} />}
|
||||
<IconButton
|
||||
|
||||
Reference in new issue
Заблокувати користувача